feat: enhance codex app and cross-conversation messaging

This commit is contained in:
shiyue
2026-06-13 22:13:30 +08:00
parent 04e15c9c89
commit 4a1c988990
10 changed files with 3740 additions and 179 deletions

View File

@@ -266,9 +266,9 @@ function createAgentRuntime(deps) {
const currentText = entry.fullText || '';
const hasExistingText = /\S/.test(currentText);
const hasParagraphBoundary = /\n\s*\n\s*$/.test(currentText) || /^\s*\n\s*\n/.test(nextText);
const separator = hasExistingText && !hasParagraphBoundary
? (/\n\s*$/.test(currentText) ? '\n' : '\n\n')
const hasVisualBoundary = /\n\s*(?:---|\*\*\*|___)\s*$/.test(currentText) || /^\s*(?:---|\*\*\*|___)\s*\n/.test(nextText);
const separator = hasExistingText && !hasVisualBoundary
? (/\n\s*$/.test(currentText) ? '\n---\n\n' : '\n\n---\n\n')
: '';
const chunk = separator + nextText;
entry.fullText += chunk;

View File

@@ -18,7 +18,7 @@ const TOOLS = [
properties: {
agent: {
type: 'string',
enum: ['claude', 'codex'],
enum: ['claude', 'codex', 'codexapp'],
description: '可选。只返回指定 Agent 的对话。',
},
status: {
@@ -55,6 +55,25 @@ const TOOLS = [
additionalProperties: false,
},
},
{
name: 'ccweb_request_reply',
description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后自动把回复发回当前对话。',
inputSchema: {
type: 'object',
properties: {
targetConversationId: {
type: 'string',
description: '目标对话 ID。',
},
content: {
type: 'string',
description: '要发送到目标对话的纯文本消息。',
},
},
required: ['targetConversationId', 'content'],
additionalProperties: false,
},
},
];
function writeMessage(message) {

472
lib/codex-app-runtime.js Normal file
View File

@@ -0,0 +1,472 @@
'use strict';
function createCodexAppRuntime(deps = {}) {
const {
wsSend,
loadSession,
saveSession,
truncateObj,
} = deps;
function truncate(value, maxLen) {
if (typeof truncateObj === 'function') return truncateObj(value, maxLen);
const text = typeof value === 'string' ? value : JSON.stringify(value);
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : value;
}
function sendRuntime(entry, sessionId, payload) {
wsSend(entry.ws, { ...payload, sessionId });
}
function itemKind(item) {
switch (item?.type) {
case 'commandExecution':
return 'command_execution';
case 'mcpToolCall':
return 'mcp_tool_call';
case 'fileChange':
return 'file_change';
case 'reasoning':
return 'reasoning';
case 'dynamicToolCall':
return 'dynamic_tool_call';
case 'collabAgentToolCall':
return 'collab_agent_tool_call';
case 'webSearch':
return 'web_search';
case 'imageView':
return 'image_view';
case 'imageGeneration':
return 'image_generation';
default:
return item?.type || 'codex_app_item';
}
}
function itemName(item) {
switch (item?.type) {
case 'commandExecution':
return 'CommandExecution';
case 'mcpToolCall':
return 'McpToolCall';
case 'fileChange':
return 'FileChange';
case 'reasoning':
return 'Reasoning';
case 'dynamicToolCall':
return item.tool || 'DynamicToolCall';
case 'collabAgentToolCall':
return item.tool || 'CollabAgentToolCall';
case 'webSearch':
return 'WebSearch';
case 'imageGeneration':
return 'ImageGeneration';
default:
return item?.type || 'CodexAppItem';
}
}
function itemInput(item) {
if (!item) return null;
switch (item.type) {
case 'commandExecution':
return { command: item.command || '' };
case 'mcpToolCall':
return {
server: item.server || '',
tool: item.tool || '',
arguments: item.arguments ?? null,
};
case 'fileChange':
return { changes: item.changes || [] };
case 'reasoning':
return { content: item.content || [], summary: item.summary || [] };
case 'dynamicToolCall':
return { tool: item.tool || '', namespace: item.namespace || null, arguments: item.arguments ?? null };
case 'collabAgentToolCall':
return {
tool: item.tool || '',
prompt: item.prompt || null,
receiverThreadIds: item.receiverThreadIds || [],
agentsStates: item.agentsStates || {},
};
default:
return truncate(item, 500);
}
}
function reasoningTextFromItem(item) {
const parts = [...(item?.summary || []), ...(item?.content || [])];
return parts.map((part) => {
if (typeof part === 'string') return part;
if (typeof part?.text === 'string') return part.text;
return '';
}).filter(Boolean).join('\n\n');
}
function hasReasoningContent(item) {
return Boolean(reasoningTextFromItem(item).trim());
}
function itemMeta(item) {
if (!item) return null;
switch (item.type) {
case 'commandExecution':
return {
kind: 'command_execution',
title: 'Shell Command',
subtitle: item.command || '',
exitCode: typeof item.exitCode === 'number' ? item.exitCode : null,
status: item.status || null,
};
case 'mcpToolCall':
return {
kind: 'mcp_tool_call',
title: 'MCP Tool',
subtitle: [item.server, item.tool].filter(Boolean).join('.'),
status: item.status || null,
};
case 'fileChange':
return {
kind: 'file_change',
title: 'File Change',
subtitle: (item.changes || []).map((change) => change.path).filter(Boolean).join(', '),
status: item.status || null,
};
case 'reasoning':
return {
kind: 'reasoning',
title: 'Reasoning',
subtitle: reasoningTextFromItem(item).replace(/\s+/g, ' ').slice(0, 120),
status: item.status || null,
};
default:
return {
kind: itemKind(item),
title: itemName(item),
subtitle: item.tool || item.query || '',
status: item.status || null,
};
}
}
function stringifyMcpResult(result) {
if (!result) return '';
if (Array.isArray(result.content)) {
const text = result.content.map((part) => {
if (typeof part?.text === 'string') return part.text;
try {
return JSON.stringify(part);
} catch {
return String(part);
}
}).filter(Boolean).join('\n');
if (text) return text;
}
try {
return JSON.stringify(result, null, 2);
} catch {
return String(result);
}
}
function itemResult(item) {
if (!item) return '';
switch (item.type) {
case 'commandExecution':
return item.aggregatedOutput || '';
case 'mcpToolCall':
return item.error?.message || stringifyMcpResult(item.result);
case 'fileChange':
return JSON.stringify(item.changes || [], null, 2);
case 'reasoning':
return reasoningTextFromItem(item);
case 'dynamicToolCall':
return JSON.stringify({
success: item.success ?? null,
contentItems: item.contentItems || null,
}, null, 2);
case 'collabAgentToolCall':
return JSON.stringify({
status: item.status || null,
receiverThreadIds: item.receiverThreadIds || [],
agentsStates: item.agentsStates || {},
}, null, 2);
default:
if (typeof item.text === 'string') return item.text;
return JSON.stringify(truncate(item, 1200));
}
}
function ensureToolCall(entry, item, sessionId) {
if (!item?.id) return null;
const kind = itemKind(item);
let toolCall = entry.toolCalls.find((tool) => tool.id === item.id);
if (toolCall) {
toolCall.name = itemName(item);
toolCall.kind = kind;
toolCall.meta = itemMeta(item) || toolCall.meta || null;
if (toolCall.input == null) toolCall.input = itemInput(item);
return toolCall;
}
toolCall = {
name: itemName(item),
id: item.id,
kind,
meta: itemMeta(item),
input: itemInput(item),
done: false,
};
entry.toolCalls.push(toolCall);
sendRuntime(entry, sessionId, {
type: 'tool_start',
name: toolCall.name,
toolUseId: toolCall.id,
input: toolCall.input,
kind: toolCall.kind,
meta: toolCall.meta,
});
return toolCall;
}
function appendAgentText(entry, itemId, text) {
const nextText = String(text || '');
if (!nextText) return '';
if (!entry.agentMessageItems) entry.agentMessageItems = new Map();
const currentItemText = entry.agentMessageItems.get(itemId) || '';
entry.agentMessageItems.set(itemId, currentItemText + nextText);
entry.fullText = (entry.fullText || '') + nextText;
return nextText;
}
function appendAgentCompletedText(entry, item) {
const text = String(item?.text || '');
if (!text) return '';
if (!entry.agentMessageItems) entry.agentMessageItems = new Map();
const currentItemText = entry.agentMessageItems.get(item.id) || '';
if (currentItemText && text.startsWith(currentItemText)) {
const remainder = text.slice(currentItemText.length);
entry.agentMessageItems.set(item.id, text);
entry.fullText = (entry.fullText || '') + remainder;
return remainder;
}
if (currentItemText === text) return '';
entry.agentMessageItems.set(item.id, text);
entry.fullText = (entry.fullText || '') + text;
return text;
}
function updateToolResult(entry, sessionId, itemId, result, done = false, patch = {}) {
if (!itemId) return;
let toolCall = entry.toolCalls.find((tool) => tool.id === itemId);
if (!toolCall) {
toolCall = {
name: patch.name || 'CodexAppItem',
id: itemId,
kind: patch.kind || 'codex_app_item',
meta: patch.meta || null,
input: patch.input || null,
done: false,
};
entry.toolCalls.push(toolCall);
sendRuntime(entry, sessionId, {
type: 'tool_start',
name: toolCall.name,
toolUseId: toolCall.id,
input: toolCall.input,
kind: toolCall.kind,
meta: toolCall.meta,
});
}
if (patch.name) toolCall.name = patch.name;
if (patch.kind) toolCall.kind = patch.kind;
if (patch.meta) toolCall.meta = patch.meta;
if (patch.input !== undefined) toolCall.input = patch.input;
toolCall.done = done;
toolCall.result = result;
sendRuntime(entry, sessionId, {
type: done ? 'tool_end' : 'tool_update',
toolUseId: itemId,
name: toolCall.name,
input: toolCall.input,
result,
kind: toolCall.kind,
meta: toolCall.meta,
});
}
function updateUsage(sessionId, entry, usage) {
const total = usage?.total || usage || null;
if (!total) return;
const session = loadSession(sessionId);
if (!session) return;
session.totalUsage = {
inputTokens: total.inputTokens || 0,
cachedInputTokens: total.cachedInputTokens || 0,
outputTokens: total.outputTokens || 0,
};
entry.lastUsage = session.totalUsage;
saveSession(session);
sendRuntime(entry, sessionId, { type: 'usage', totalUsage: session.totalUsage });
}
function processCodexAppNotification(entry, notification, sessionId) {
if (!entry || !notification?.method) return { done: false };
const method = notification.method;
const params = notification.params || {};
if (params.threadId && !entry.threadId) entry.threadId = params.threadId;
if (params.turnId && !entry.turnId) entry.turnId = params.turnId;
switch (method) {
case 'turn/started':
if (params.turn?.id) entry.turnId = params.turn.id;
return { done: false };
case 'item/started': {
const item = params.item;
if (!item || !item.id || item.type === 'agentMessage' || item.type === 'userMessage') return { done: false };
if (item.type === 'reasoning' && !hasReasoningContent(item)) return { done: false };
ensureToolCall(entry, item, sessionId);
return { done: false };
}
case 'item/agentMessage/delta': {
const chunk = appendAgentText(entry, params.itemId || 'agent-message', params.delta || '');
if (chunk) sendRuntime(entry, sessionId, { type: 'text_delta', text: chunk });
return { done: false };
}
case 'item/commandExecution/outputDelta': {
const itemId = params.itemId;
const current = entry.toolOutputDeltas?.get(itemId) || '';
const next = current + String(params.delta || '');
if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map();
entry.toolOutputDeltas.set(itemId, next);
updateToolResult(entry, sessionId, itemId, next, false, {
name: 'CommandExecution',
kind: 'command_execution',
});
return { done: false };
}
case 'item/fileChange/patchUpdated': {
updateToolResult(entry, sessionId, params.itemId, JSON.stringify(params.changes || [], null, 2), false, {
name: 'FileChange',
kind: 'file_change',
input: { changes: params.changes || [] },
meta: {
kind: 'file_change',
title: 'File Change',
subtitle: (params.changes || []).map((change) => change.path).filter(Boolean).join(', '),
status: 'inProgress',
},
});
return { done: false };
}
case 'item/mcpToolCall/progress': {
updateToolResult(entry, sessionId, params.itemId, params.message || '', false, {
name: 'McpToolCall',
kind: 'mcp_tool_call',
});
return { done: false };
}
case 'item/reasoning/summaryTextDelta':
case 'item/reasoning/textDelta': {
const itemId = params.itemId;
const delta = String(params.delta || '');
if (!itemId || !delta) return { done: false };
const current = entry.toolOutputDeltas?.get(itemId) || '';
const next = current + delta;
if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map();
entry.toolOutputDeltas.set(itemId, next);
updateToolResult(entry, sessionId, itemId, next, false, {
name: 'Reasoning',
kind: 'reasoning',
});
return { done: false };
}
case 'item/completed': {
const item = params.item;
if (!item || !item.id) return { done: false };
if (item.type === 'agentMessage') {
const chunk = appendAgentCompletedText(entry, item);
if (chunk) sendRuntime(entry, sessionId, { type: 'text_delta', text: chunk });
return { done: false };
}
if (item.type === 'userMessage') return { done: false };
if (item.type === 'reasoning') {
const result = (itemResult(item) || entry.toolOutputDeltas?.get(item.id) || '').slice(0, 4000);
if (!result.trim()) return { done: false };
const toolCall = ensureToolCall(entry, item, sessionId);
if (!toolCall) return { done: false };
toolCall.done = true;
toolCall.result = result;
toolCall.meta = itemMeta(item) || toolCall.meta;
sendRuntime(entry, sessionId, {
type: 'tool_end',
toolUseId: toolCall.id,
result,
kind: toolCall.kind,
meta: toolCall.meta,
});
return { done: false };
}
const toolCall = ensureToolCall(entry, item, sessionId);
if (!toolCall) return { done: false };
const result = itemResult(item).slice(0, 4000);
toolCall.done = true;
toolCall.result = result;
toolCall.meta = itemMeta(item) || toolCall.meta;
sendRuntime(entry, sessionId, {
type: 'tool_end',
toolUseId: toolCall.id,
result,
kind: toolCall.kind,
meta: toolCall.meta,
});
return { done: false };
}
case 'thread/tokenUsage/updated':
updateUsage(sessionId, entry, params.tokenUsage);
return { done: false };
case 'turn/completed': {
if (params.turn?.id) entry.turnId = params.turn.id;
entry.turnStatus = params.turn?.status || 'completed';
if (params.turn?.status === 'failed') {
entry.lastError = params.turn?.error?.message || 'Codex App 任务失败';
}
return { done: true };
}
case 'error':
case 'warning':
case 'guardianWarning':
case 'configWarning':
case 'deprecationNotice': {
const message = params.message || params.title || '';
if (message) {
if (method === 'error') entry.lastError = message;
sendRuntime(entry, sessionId, { type: 'system_message', message });
}
return { done: false };
}
default:
return { done: false };
}
}
return {
processCodexAppNotification,
updateUsage,
};
}
module.exports = { createCodexAppRuntime };

View File

@@ -0,0 +1,220 @@
'use strict';
const readline = require('readline');
const { spawn } = require('child_process');
function createCodexAppServerClient(options = {}) {
const command = options.command || 'codex';
const args = Array.isArray(options.args) && options.args.length > 0
? options.args.slice()
: ['app-server', '--stdio'];
const env = options.env || process.env;
const cwd = options.cwd || process.cwd();
const clientInfo = options.clientInfo || {
name: 'ccweb_codexapp',
title: 'CC-Web Codex App',
version: '0.1.0',
};
const onNotification = typeof options.onNotification === 'function' ? options.onNotification : () => {};
const onServerRequest = typeof options.onServerRequest === 'function' ? options.onServerRequest : null;
const onExit = typeof options.onExit === 'function' ? options.onExit : () => {};
const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
const postInitialize = typeof options.postInitialize === 'function' ? options.postInitialize : null;
let proc = null;
let rl = null;
let nextId = 1;
let initPromise = null;
let exited = false;
const pending = new Map();
function rejectAllPending(err) {
for (const [, pendingRequest] of pending) {
clearTimeout(pendingRequest.timer);
pendingRequest.reject(err);
}
pending.clear();
}
function sendRaw(message) {
if (!proc || !proc.stdin || proc.stdin.destroyed) {
throw new Error('Codex app-server 未启动。');
}
proc.stdin.write(`${JSON.stringify(message)}\n`);
}
function respondToServerRequest(id, result, error) {
try {
if (error) {
sendRaw({ id, error });
} else {
sendRaw({ id, result: result || {} });
}
} catch (err) {
onLog('WARN', 'codex_app_server_response_failed', { error: err.message });
}
}
function handleServerRequest(message) {
const id = message.id;
const method = message.method;
const params = message.params || {};
if (onServerRequest) {
Promise.resolve()
.then(() => onServerRequest({ method, params, id }))
.then((result) => respondToServerRequest(id, result || {}))
.catch((err) => respondToServerRequest(id, null, {
code: -32603,
message: err?.message || 'cc-web 无法处理 Codex app-server 请求。',
}));
return;
}
respondToServerRequest(id, null, {
code: -32601,
message: `cc-web 暂不支持 Codex app-server 请求: ${method}`,
});
}
function handleMessage(line) {
let message;
try {
message = JSON.parse(line);
} catch {
onLog('WARN', 'codex_app_server_invalid_json', { line: String(line || '').slice(0, 200) });
return;
}
if (Object.prototype.hasOwnProperty.call(message, 'id')) {
const pendingRequest = pending.get(message.id);
if (pendingRequest) {
pending.delete(message.id);
clearTimeout(pendingRequest.timer);
if (message.error) {
const err = new Error(message.error.message || 'Codex app-server 请求失败。');
err.code = message.error.code;
err.data = message.error.data;
pendingRequest.reject(err);
} else {
pendingRequest.resolve(message.result || {});
}
return;
}
if (message.method) {
handleServerRequest(message);
return;
}
}
if (message.method) {
onNotification(message);
}
}
function request(method, params = {}, timeoutMs = 300000) {
const id = nextId++;
const message = { id, method, params };
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`Codex app-server 请求超时: ${method}`));
}, timeoutMs);
pending.set(id, { resolve, reject, timer, method });
try {
sendRaw(message);
} catch (err) {
clearTimeout(timer);
pending.delete(id);
reject(err);
}
});
}
function notification(method, params = {}) {
sendRaw({ method, params });
}
function start() {
if (initPromise) return initPromise;
exited = false;
proc = spawn(command, args, {
env,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
let stderr = '';
proc.stderr.on('data', (chunk) => {
stderr += chunk.toString();
if (stderr.length > 4000) stderr = stderr.slice(-4000);
});
rl = readline.createInterface({ input: proc.stdout });
rl.on('line', handleMessage);
proc.on('exit', (code, signal) => {
exited = true;
if (rl) rl.close();
const err = new Error(`Codex app-server 已退出: code=${code ?? 'null'} signal=${signal || 'null'}`);
err.exitCode = code;
err.signal = signal;
err.stderr = stderr;
rejectAllPending(err);
onExit({ code, signal, stderr });
});
proc.on('error', (err) => {
rejectAllPending(err);
onExit({ code: null, signal: null, stderr: err.message });
});
initPromise = request('initialize', {
clientInfo,
capabilities: { experimentalApi: true },
}, 30000)
.then(async (result) => {
notification('initialized', {});
if (postInitialize) await postInitialize({ request, notification, onLog });
return result;
})
.catch((err) => {
stop();
throw err;
});
return initPromise;
}
function stop() {
initPromise = null;
if (rl) {
try { rl.close(); } catch {}
rl = null;
}
if (proc && !exited) {
try { proc.kill('SIGTERM'); } catch {}
setTimeout(() => {
try {
if (proc && !proc.killed) proc.kill('SIGKILL');
} catch {}
}, 3000);
}
proc = null;
rejectAllPending(new Error('Codex app-server 已停止。'));
}
function isRunning() {
return !!proc && !exited;
}
return {
start,
stop,
request,
notification,
isRunning,
pid: () => proc?.pid || null,
};
}
module.exports = { createCodexAppServerClient };

View File

@@ -2,8 +2,10 @@
(function () {
'use strict';
const ASSET_VERSION = '20260613-codexapp-tools2';
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
const RENDER_DEBOUNCE = 100;
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
const SLASH_COMMANDS = [
{ cmd: '/clear', desc: '清除当前会话' },
@@ -24,6 +26,7 @@
const AGENT_LABELS = {
claude: 'Claude',
codex: 'Codex',
codexapp: 'Codex App',
};
const DEFAULT_AGENT = 'claude';
@@ -99,6 +102,10 @@
let loadedHistorySessionId = null;
let activeSessionLoad = null;
let sidebarSwipe = null;
let activeComposerToken = null;
let composerSuggestionTimer = null;
let composerRequestSeq = 0;
let latestComposerRequestId = '';
let pendingAttachments = [];
let uploadingAttachments = [];
let attachmentPreviewModal = null;
@@ -108,6 +115,7 @@
let currentSessionRunning = false;
let fileBrowserState = null;
let directoryPickerState = null;
let codexAppUserInputModal = null;
let pendingNewSessionRequest = null;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false;
@@ -143,6 +151,7 @@
const chatCwd = $('#chat-cwd');
const costDisplay = $('#cost-display');
const attachmentTray = $('#attachment-tray');
const pendingNotesTray = $('#pending-notes-tray');
const imageUploadInput = $('#image-upload-input');
const attachBtn = $('#attach-btn');
const messagesDiv = $('#messages');
@@ -172,6 +181,15 @@
return AGENT_LABELS[agent] ? agent : DEFAULT_AGENT;
}
function isCodexLikeAgent(agent) {
const normalized = normalizeAgent(agent);
return normalized === 'codex' || normalized === 'codexapp';
}
function isCodexAppAgent(agent) {
return normalizeAgent(agent) === 'codexapp';
}
function getDraftNoteKey(agent = currentAgent) {
return `draft:${normalizeAgent(agent)}`;
}
@@ -218,9 +236,10 @@
}
if (inputWrapper) inputWrapper.classList.toggle('note-mode-active', active);
if (sendBtn) {
const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !active;
sendBtn.classList.toggle('note-send', active);
sendBtn.title = active ? '记录笔记' : '发送';
sendBtn.hidden = isGenerating ? !active : false;
sendBtn.title = active ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送');
sendBtn.hidden = isGenerating ? (!active && !allowRuntimeInsert) : false;
}
if (msgInput) {
msgInput.placeholder = active ? '记录笔记,稍后从气泡发送…' : defaultMsgInputPlaceholder;
@@ -240,15 +259,15 @@
function createPendingNoteElement(note) {
const div = document.createElement('div');
div.className = 'msg note';
div.className = 'pending-note';
div.dataset.noteId = note.id;
const avatar = document.createElement('div');
avatar.className = 'msg-avatar note-avatar';
avatar.className = 'note-avatar';
avatar.textContent = 'N';
const bubble = document.createElement('div');
bubble.className = 'msg-bubble note-bubble';
bubble.className = 'note-bubble';
const meta = document.createElement('div');
meta.className = 'note-meta';
@@ -274,24 +293,23 @@
}
function renderPendingNotes(options = {}) {
messagesDiv.querySelectorAll('.msg.note').forEach((node) => node.remove());
if (!pendingNotesTray) return;
pendingNotesTray.innerHTML = '';
const notes = getCurrentNotes(false);
if (!notes || notes.length === 0) {
updateScrollbar();
pendingNotesTray.hidden = true;
if (options.updateScrollbar !== false) updateScrollbar();
return;
}
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
const frag = document.createDocumentFragment();
notes.forEach((note) => frag.appendChild(createPendingNoteElement(note)));
messagesDiv.appendChild(frag);
if (options.scroll !== false) {
scrollToBottom();
} else {
updateScrollbar();
pendingNotesTray.appendChild(frag);
pendingNotesTray.hidden = false;
if (options.scrollIntoView !== false && options.scroll !== false) {
pendingNotesTray.scrollTop = pendingNotesTray.scrollHeight;
}
if (options.updateScrollbar !== false) updateScrollbar();
}
function findPendingNote(noteId) {
@@ -336,7 +354,7 @@
function beginEditPendingNote(noteId) {
const found = findPendingNote(noteId);
if (!found) return;
const noteEl = messagesDiv.querySelector(`.msg.note[data-note-id="${noteId}"]`);
const noteEl = pendingNotesTray?.querySelector(`.pending-note[data-note-id="${noteId}"]`);
const bubble = noteEl?.querySelector('.note-bubble');
if (!bubble) return;
@@ -650,6 +668,15 @@
return sessions.find((s) => s.id === sessionId) || null;
}
function compareSessionUpdatedDesc(a, b) {
return new Date(b?.updated || 0) - new Date(a?.updated || 0);
}
function compareSessionPinnedDesc(a, b) {
const pinnedDiff = new Date(b?.pinnedAt || 0) - new Date(a?.pinnedAt || 0);
return pinnedDiff || compareSessionUpdatedDesc(a, b);
}
function shortSessionId(sessionId) {
const value = String(sessionId || '');
return value ? value.slice(0, 8) : '';
@@ -730,6 +757,7 @@
mode: payload.mode || 'yolo',
model: payload.model || '',
agent: normalizeAgent(payload.agent),
pinnedAt: payload.pinnedAt || null,
hasUnread: !!payload.hasUnread,
cwd: payload.cwd || null,
totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0,
@@ -832,6 +860,7 @@
snapshot.agent = normalizeAgent(meta.agent || snapshot.agent);
snapshot.hasUnread = !!meta.hasUnread;
snapshot.updated = meta.updated || snapshot.updated;
snapshot.pinnedAt = meta.pinnedAt || null;
snapshot.isRunning = !!meta.isRunning;
}
return snapshot;
@@ -898,6 +927,154 @@
return data || {};
}
function closeCodexAppUserInputModal(sendCancel = false) {
if (!codexAppUserInputModal) return;
const { overlay, escapeHandler, requestId, sessionId } = codexAppUserInputModal;
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
codexAppUserInputModal = null;
if (sendCancel && requestId) {
send({
type: 'codex_app_user_input_response',
sessionId,
requestId,
answers: {},
});
}
}
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
return String(value || '').replace(/["\\]/g, '\\$&');
}
function collectCodexAppUserInputAnswers(panel, questions) {
const answers = {};
for (const question of questions) {
const id = String(question?.id || '').trim();
if (!id) continue;
const escapedId = cssEscape(id);
const checked = panel.querySelector(`input[name="codex-ui-${escapedId}"]:checked`);
const values = [];
if (checked) {
if (checked.value === '__other__') {
const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`);
const text = String(input?.value || '').trim();
if (text) values.push(text);
} else {
values.push(checked.value);
}
} else {
const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`);
const text = String(input?.value || '').trim();
if (text) values.push(text);
}
answers[id] = { answers: values };
}
return answers;
}
function renderCodexAppQuestion(question, index) {
const id = String(question?.id || `q${index}`);
const options = Array.isArray(question?.options) ? question.options : [];
const hasOther = !!question?.isOther || options.length === 0;
const inputType = question?.isSecret ? 'password' : 'text';
const optionHtml = options.map((option, optionIndex) => {
const value = String(option?.label || `选项 ${optionIndex + 1}`);
return `
<label class="codex-user-input-option">
<input type="radio" name="codex-ui-${escapeHtml(id)}" value="${escapeHtml(value)}"${optionIndex === 0 && !hasOther ? ' checked' : ''}>
<span class="codex-user-input-option-copy">
<span class="codex-user-input-option-label">${escapeHtml(value)}</span>
${option?.description ? `<span class="codex-user-input-option-desc">${escapeHtml(option.description)}</span>` : ''}
</span>
</label>
`;
}).join('');
const otherHtml = hasOther ? `
<label class="codex-user-input-option codex-user-input-other-option">
${options.length > 0 ? `<input type="radio" name="codex-ui-${escapeHtml(id)}" value="__other__">` : ''}
<span class="codex-user-input-option-copy">
<span class="codex-user-input-option-label">${options.length > 0 ? '其他' : '回答'}</span>
<input class="codex-user-input-text" type="${inputType}" data-codex-ui-other="${escapeHtml(id)}" autocomplete="off">
</span>
</label>
` : '';
return `
<section class="codex-user-input-question">
<div class="codex-user-input-kicker">${escapeHtml(question?.header || `问题 ${index + 1}`)}</div>
<div class="codex-user-input-prompt">${escapeHtml(question?.question || '请选择一个答案。')}</div>
<div class="codex-user-input-options">
${optionHtml}
${otherHtml}
</div>
</section>
`;
}
function showCodexAppUserInputModal(msg) {
closeCodexAppUserInputModal(true);
const questions = Array.isArray(msg.questions) ? msg.questions : [];
const overlay = document.createElement('div');
overlay.className = 'modal-overlay codex-user-input-overlay';
overlay.innerHTML = `
<div class="modal-panel codex-user-input-panel">
<div class="modal-header">
<span class="modal-title">Codex App 需要输入</span>
<button class="modal-close-btn" type="button" data-codex-ui-cancel>✕</button>
</div>
<div class="modal-body codex-user-input-body">
${questions.length > 0
? questions.map((question, index) => renderCodexAppQuestion(question, index)).join('')
: '<div class="modal-empty">Codex App 没有提供可回答的问题。</div>'}
</div>
<div class="modal-footer">
<button class="modal-btn-secondary" type="button" data-codex-ui-cancel>取消</button>
<button class="modal-btn-primary" type="button" data-codex-ui-submit>提交</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const panel = overlay.querySelector('.codex-user-input-panel');
const escapeHandler = (e) => {
if (e.key === 'Escape') closeCodexAppUserInputModal(true);
};
document.addEventListener('keydown', escapeHandler);
codexAppUserInputModal = {
overlay,
requestId: msg.requestId || '',
sessionId: msg.sessionId || '',
escapeHandler,
};
overlay.querySelectorAll('[data-codex-ui-cancel]').forEach((button) => {
button.addEventListener('click', () => closeCodexAppUserInputModal(true));
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeCodexAppUserInputModal(true);
});
overlay.querySelector('[data-codex-ui-submit]')?.addEventListener('click', () => {
send({
type: 'codex_app_user_input_response',
sessionId: msg.sessionId,
requestId: msg.requestId,
answers: collectCodexAppUserInputAnswers(panel, questions),
});
closeCodexAppUserInputModal(false);
});
panel.querySelectorAll('.codex-user-input-text').forEach((input) => {
input.addEventListener('focus', () => {
const radio = input.closest('.codex-user-input-option')?.querySelector('input[type="radio"]');
if (radio) radio.checked = true;
});
});
panel.querySelector('input, button')?.focus();
}
function closeDirectoryPicker() {
if (!directoryPickerState) return;
const { overlay, escapeHandler } = directoryPickerState;
@@ -1824,26 +2001,66 @@
group.cwd = getSessionEffectiveCwd(session) || group.cwd;
}
}
for (const group of groups) {
group.sessions.sort(compareSessionUpdatedDesc);
}
ungroupedSessions.sort(compareSessionUpdatedDesc);
return {
groups: groups.sort((a, b) => new Date(b.latestUpdated || 0) - new Date(a.latestUpdated || 0)),
ungroupedSessions,
};
}
function splitPinnedSessions(sessionItems) {
const pinnedSessions = [];
const regularSessions = [];
for (const session of sessionItems) {
if (session.pinnedAt) {
pinnedSessions.push(session);
} else {
regularSessions.push(session);
}
}
pinnedSessions.sort(compareSessionPinnedDesc);
regularSessions.sort(compareSessionUpdatedDesc);
return { pinnedSessions, regularSessions };
}
function applySessionPinnedState(sessionId, pinnedAt) {
sessions = sessions.map((session) => (
session.id === sessionId ? { ...session, pinnedAt: pinnedAt || null } : session
));
updateCachedSession(sessionId, (snapshot) => {
snapshot.pinnedAt = pinnedAt || null;
});
renderSessionList();
}
function toggleSessionPinned(session) {
if (!session?.id) return;
const nextPinned = !session.pinnedAt;
const pinnedAt = nextPinned ? new Date().toISOString() : null;
applySessionPinnedState(session.id, pinnedAt);
send({ type: 'set_session_pinned', sessionId: session.id, pinned: nextPinned });
}
function createSessionListItem(session) {
const item = document.createElement('div');
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}`;
const isPinned = !!session.pinnedAt;
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}`;
item.dataset.id = session.id;
const sessionCwd = getSessionEffectiveCwd(session);
if (sessionCwd) item.title = sessionCwd;
item.innerHTML = `
<div class="session-item-main">
<span class="session-item-title">${escapeHtml(session.title || 'Untitled')}</span>
${isPinned ? '<span class="session-item-pin-badge" title="已置顶">顶</span>' : ''}
${session.isRunning ? '<span class="session-item-status">运行中</span>' : ''}
</div>
${session.hasUnread ? '<span class="session-unread-dot"></span>' : ''}
<span class="session-item-time">${timeAgo(session.updated)}</span>
<div class="session-item-actions">
<button class="session-item-btn pin${isPinned ? ' active' : ''}" title="${isPinned ? '取消置顶' : '置顶'}" aria-label="${isPinned ? '取消置顶' : '置顶'}">${isPinned ? '✓' : '⇧'}</button>
<button class="session-item-btn copy-id" title="复制 ID">ID</button>
<button class="session-item-btn edit" title="重命名">✎</button>
<button class="session-item-btn delete" title="删除">×</button>
@@ -1857,6 +2074,11 @@
copyTextToClipboard(session.id, '会话 ID 已复制');
return;
}
if (target.classList.contains('pin')) {
e.stopPropagation();
toggleSessionPinned(session);
return;
}
if (target.classList.contains('delete')) {
e.stopPropagation();
const doDelete = () => {
@@ -1928,7 +2150,13 @@
});
}
if (importSessionBtn) {
importSessionBtn.textContent = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 Claude 会话';
if (isCodexAppAgent(currentAgent)) {
importSessionBtn.textContent = 'Codex App 暂不支持导入';
importSessionBtn.disabled = true;
} else {
importSessionBtn.textContent = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 Claude 会话';
importSessionBtn.disabled = false;
}
}
}
@@ -1961,7 +2189,7 @@
clearSessionLoading();
setCurrentSessionRunningState(false);
currentCwd = null;
currentModel = currentAgent === 'claude' ? 'opus' : '';
currentModel = isCodexLikeAgent(currentAgent) ? '' : 'opus';
isGenerating = false;
generatingSessionId = null;
pendingText = '';
@@ -2152,7 +2380,7 @@
}
function setStatsDisplay(msg) {
if (currentAgent === 'codex' && msg && msg.totalUsage) {
if (isCodexLikeAgent(currentAgent) && msg && msg.totalUsage) {
const usage = msg.totalUsage;
if ((usage.inputTokens || 0) > 0 || (usage.outputTokens || 0) > 0) {
const cacheText = usage.cachedInputTokens ? ` · cache ${usage.cachedInputTokens}` : '';
@@ -2204,7 +2432,7 @@
DEFAULT_CODEX_MODEL_OPTIONS.forEach((opt) => addBaseOption(opt.value, opt.label, opt.desc));
addBaseOption(currentModel, currentModel, '当前会话模型');
sessions
.filter((s) => normalizeAgent(s.agent) === 'codex' && s.id === currentSessionId)
.filter((s) => isCodexLikeAgent(s.agent) && s.id === currentSessionId)
.forEach((s) => addBaseOption(s.model, s.model, '当前会话已保存模型'));
return options;
@@ -2409,6 +2637,7 @@
cwd: snapshot.cwd || session.cwd || '',
projectName: snapshot.cwd ? getPathLeaf(snapshot.cwd) : session.projectName || '',
title: snapshot.title || session.title,
pinnedAt: snapshot.pinnedAt || session.pinnedAt || null,
}
: session
));
@@ -2471,6 +2700,10 @@
renderSessionList();
break;
case 'session_pinned':
applySessionPinnedState(msg.sessionId, msg.pinnedAt || null);
break;
case 'text_delta':
if (!ensureGeneratingForEvent(msg)) break;
pendingText += msg.text;
@@ -2488,6 +2721,7 @@
case 'tool_start':
if (!ensureGeneratingForEvent(msg)) break;
if (isEmptyReasoningTool({ name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false })) break;
activeToolCalls.set(msg.toolUseId, { name: msg.name, input: msg.input, kind: msg.kind || null, meta: msg.meta || null, done: false });
appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null);
break;
@@ -2500,9 +2734,10 @@
input: msg.input,
kind: msg.kind || null,
meta: msg.meta || null,
result: msg.result,
done: false,
});
appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null);
appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null, msg.result);
}
activeToolCalls.get(msg.toolUseId).done = false;
if (msg.name) activeToolCalls.get(msg.toolUseId).name = msg.name;
@@ -2515,6 +2750,17 @@
case 'tool_end':
if (!isCurrentSessionEvent(msg)) break;
if (!activeToolCalls.has(msg.toolUseId) && !isEmptyReasoningTool({ name: msg.name, input: msg.input, result: msg.result, kind: msg.kind || null, meta: msg.meta || null, done: true })) {
activeToolCalls.set(msg.toolUseId, {
name: msg.name,
input: msg.input,
kind: msg.kind || null,
meta: msg.meta || null,
result: msg.result,
done: true,
});
appendToolCall(msg.toolUseId, msg.name, msg.input, true, msg.kind || null, msg.meta || null, msg.result);
}
if (activeToolCalls.has(msg.toolUseId)) {
activeToolCalls.get(msg.toolUseId).done = true;
if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind;
@@ -2558,6 +2804,13 @@
appendSystemMessage(msg.message);
break;
case 'codex_app_user_input_request':
if (msg.sessionId && msg.sessionId !== currentSessionId) {
showToast('Codex App 需要输入', msg.sessionId);
}
showCodexAppUserInputModal(msg);
break;
case 'mode_changed':
if (msg.mode && MODE_LABELS[msg.mode]) {
currentMode = msg.mode;
@@ -2714,6 +2967,10 @@
if (typeof _onCwdSuggestions === 'function') _onCwdSuggestions(msg);
break;
case 'composer_suggestions':
handleComposerSuggestions(msg);
break;
case 'update_info':
if (typeof window._ccOnUpdateInfo === 'function') window._ccOnUpdateInfo(msg);
break;
@@ -2853,7 +3110,8 @@
function createMsgElement(role, content, attachments = [], meta = {}) {
const div = document.createElement('div');
const isCrossConversation = role === 'user' && !!meta.crossConversation;
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}`;
const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId);
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`;
if (role === 'system') {
const bubble = document.createElement('div');
@@ -2866,11 +3124,11 @@
const avatar = document.createElement('div');
avatar.className = 'msg-avatar';
if (isCrossConversation) {
avatar.textContent = '↗';
avatar.textContent = isCrossConversationReply ? '↩' : '↗';
} else if (role === 'user') {
avatar.textContent = 'U';
} else if (currentAgent === 'codex') {
avatar.innerHTML = `<img src="/codex.png" width="24" height="24" style="display:block;" alt="Codex">`;
} else if (isCodexLikeAgent(currentAgent)) {
avatar.innerHTML = `<img src="/codex.png" width="24" height="24" style="display:block;" alt="${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'}">`;
} else {
avatar.innerHTML = `<img src="/claude.png" width="24" height="24" style="display:block;" alt="Claude">`;
}
@@ -2888,7 +3146,9 @@
const label = document.createElement('span');
label.className = 'cross-conversation-label';
label.textContent = `来自「${sourceTitle}」的对话`;
label.textContent = isCrossConversationReply
? `来自「${sourceTitle}」的回复`
: `来自「${sourceTitle}」的对话`;
sourceMeta.appendChild(label);
if (sourceId) {
@@ -3090,6 +3350,26 @@
}
}
function reasoningPartText(parts) {
if (!Array.isArray(parts)) return '';
return parts.map((part) => {
if (typeof part === 'string') return part;
if (typeof part?.text === 'string') return part.text;
return '';
}).filter(Boolean).join('\n');
}
function isEmptyReasoningTool(tool) {
if (toolKind(tool) !== 'reasoning') return false;
const resultText = stringifyToolValue(tool?.result).trim();
const input = tool?.input || {};
const inputText = [
reasoningPartText(input.content),
reasoningPartText(input.summary),
].filter(Boolean).join('\n').trim();
return !resultText && !inputText;
}
function toolStateLabel(tool, done) {
if (!done) return 'Running';
if (toolKind(tool) === 'command_execution' && typeof tool?.meta?.exitCode === 'number') {
@@ -3183,6 +3463,7 @@
const FOLD_AT = 3;
let grouped = false;
for (const tc of m.toolCalls) {
if (isEmptyReasoningTool(tc)) continue;
const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true);
// 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group
@@ -3548,7 +3829,7 @@
const kind = toolKind(tool);
if (tool.name === 'AskUserQuestion') {
details.open = true;
} else if (agent !== 'codex' && !done && kind === 'command_execution') {
} else if (!isCodexLikeAgent(agent) && !done && kind === 'command_execution') {
details.open = true;
}
@@ -3559,7 +3840,7 @@
return details;
}
function appendToolCall(toolUseId, name, input, done, kind = null, meta = null) {
function appendToolCall(toolUseId, name, input, done, kind = null, meta = null, result = undefined) {
const streamEl = document.getElementById('streaming-msg');
if (!streamEl) return;
const bubble = streamEl.querySelector('.msg-bubble');
@@ -3568,6 +3849,8 @@
if (!toolsDiv) { toolsDiv = bubble; }
const tool = { id: toolUseId, name, input, kind, meta, done };
if (result !== undefined) tool.result = result;
if (isEmptyReasoningTool(tool)) return;
// 如果是 todo_list检查是否已存在相同 id 的 todo_list
if (kind === 'todo_list' && input?.id) {
@@ -3672,6 +3955,11 @@
};
nextTool.done = done;
if (result !== undefined) nextTool.result = result;
if (isEmptyReasoningTool(nextTool)) {
activeToolCalls.delete(toolUseId);
el.remove();
return;
}
rememberToolCallTarget(toolUseId, nextTool, el);
const summary = el.querySelector('summary');
if (summary) applyToolSummary(summary, nextTool, done);
@@ -3690,6 +3978,9 @@
if (normalized === 'codex') {
return '删除本会话将同步删去本地 Codex rollout 历史与线程记录,不可恢复。确认删除?';
}
if (normalized === 'codexapp') {
return '删除本会话只会删除 cc-web 中的 Codex App 会话记录,不会清理本地 Codex App 线程历史。确认删除?';
}
return '删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?';
}
@@ -3868,7 +4159,26 @@
return;
}
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(visibleSessions);
const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
if (pinnedSessions.length > 0) {
const pinnedGroupEl = document.createElement('section');
pinnedGroupEl.className = 'session-project-group session-pinned-group';
const pinnedHeader = document.createElement('div');
pinnedHeader.className = 'session-project-header session-pinned-header';
pinnedHeader.innerHTML = `
<span class="session-project-name">置顶</span>
<span class="session-project-header-actions">
<span class="session-project-count">${pinnedSessions.length}</span>
</span>
`;
pinnedGroupEl.appendChild(pinnedHeader);
for (const session of pinnedSessions) {
pinnedGroupEl.appendChild(createSessionListItem(session));
}
sessionList.appendChild(pinnedGroupEl);
}
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions);
for (const group of projectGroups) {
const groupEl = document.createElement('section');
groupEl.className = 'session-project-group';
@@ -4093,52 +4403,137 @@
}
}
// --- Slash Command Menu ---
function showCmdMenu(filter) {
const filtered = SLASH_COMMANDS.filter(c =>
c.cmd.startsWith(filter) || c.desc.includes(filter.slice(1))
);
// Exact match first (fixes /mode vs /model ambiguity)
filtered.sort((a, b) => (b.cmd === filter ? 1 : 0) - (a.cmd === filter ? 1 : 0));
if (filtered.length === 0) {
// --- Composer Modifier Menu ---
function getLocalSlashSuggestions(query) {
const normalized = String(query || '').replace(/^\//, '').toLowerCase();
const filtered = SLASH_COMMANDS
.filter((item) => {
const cmd = item.cmd.toLowerCase();
const desc = item.desc.toLowerCase();
return !normalized || cmd.includes(normalized) || desc.includes(normalized);
})
.sort((a, b) => (b.cmd === `/${normalized}` ? 1 : 0) - (a.cmd === `/${normalized}` ? 1 : 0));
return filtered.map((item) => ({
kind: 'command',
name: item.cmd,
label: item.cmd,
description: item.desc,
insertion: `${item.cmd} `,
appendSpace: false,
}));
}
function findActiveComposerToken() {
if (!msgInput) return null;
const value = msgInput.value || '';
const cursor = typeof msgInput.selectionStart === 'number' ? msgInput.selectionStart : value.length;
const before = value.slice(0, cursor);
if (!before.includes('\n') && value.startsWith('/') && cursor > 0) {
const nextWhitespace = value.search(/\s/);
const end = nextWhitespace >= 0 ? Math.min(cursor, nextWhitespace) : cursor;
if (cursor <= end || nextWhitespace < 0) {
return { trigger: '/', query: value.slice(1, cursor), start: 0, end: cursor };
}
}
const lineStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1;
const line = before.slice(lineStart);
const match = line.match(/(^|\s)([@$])([^\s]*)$/);
if (!match) return null;
const prefixLength = match[1] ? match[1].length : 0;
const start = lineStart + match.index + prefixLength;
return {
trigger: match[2],
query: match[3] || '',
start,
end: cursor,
};
}
function showCmdMenu(token, items) {
const safeItems = Array.isArray(items) ? items : [];
if (!token || safeItems.length === 0) {
hideCmdMenu();
return;
}
activeComposerToken = token;
cmdMenuIndex = 0;
cmdMenu.innerHTML = filtered.map((c, i) =>
`<div class="cmd-item${i === 0 ? ' active' : ''}" data-cmd="${c.cmd}">
<span class="cmd-item-cmd">${c.cmd}</span>
<span class="cmd-item-desc">${c.desc}</span>
</div>`
).join('');
cmdMenu.innerHTML = safeItems.map((item, i) => {
const kindLabel = item.kind === 'skill'
? 'Skill'
: item.kind === 'prompt'
? 'Prompt'
: item.kind === 'file'
? (item.itemType === 'directory' ? 'Dir' : 'File')
: 'Cmd';
return `<div class="cmd-item${i === 0 ? ' active' : ''}" data-index="${i}">
<span class="cmd-item-kind">${kindLabel}</span>
<span class="cmd-item-main">
<span class="cmd-item-cmd">${escapeHtml(item.label || item.name || item.insertion || '')}</span>
<span class="cmd-item-desc">${escapeHtml(item.description || item.title || '')}</span>
</span>
</div>`;
}).join('');
cmdMenu._items = safeItems;
cmdMenu.hidden = false;
// Click handlers
cmdMenu.querySelectorAll('.cmd-item').forEach(el => {
cmdMenu.querySelectorAll('.cmd-item').forEach((el) => {
el.addEventListener('click', () => {
const cmd = el.dataset.cmd;
if (cmd === '/model') {
hideCmdMenu();
msgInput.value = '';
showModelPicker();
return;
}
if (cmd === '/mode') {
hideCmdMenu();
msgInput.value = '';
showModePicker();
return;
}
msgInput.value = cmd + ' ';
hideCmdMenu();
msgInput.focus();
const index = Number.parseInt(el.dataset.index || '-1', 10);
selectComposerItemByIndex(index);
});
});
}
function requestComposerSuggestions() {
const token = findActiveComposerToken();
if (!token || noteMode) {
hideCmdMenu();
return;
}
if (token.trigger === '/') {
showCmdMenu(token, getLocalSlashSuggestions(token.query));
return;
}
clearTimeout(composerSuggestionTimer);
composerSuggestionTimer = setTimeout(() => {
const liveToken = findActiveComposerToken();
if (!liveToken || liveToken.trigger !== token.trigger || liveToken.start !== token.start) {
hideCmdMenu();
return;
}
const requestId = `composer-${Date.now()}-${++composerRequestSeq}`;
latestComposerRequestId = requestId;
activeComposerToken = liveToken;
send({
type: 'composer_suggestions',
requestId,
trigger: liveToken.trigger,
query: liveToken.query,
sessionId: currentSessionId,
agent: currentAgent,
});
}, COMPOSER_SUGGESTION_DEBOUNCE);
}
function handleComposerSuggestions(msg) {
if (!msg || msg.requestId !== latestComposerRequestId) return;
const token = findActiveComposerToken();
if (!token || token.trigger !== msg.trigger) {
hideCmdMenu();
return;
}
showCmdMenu(token, msg.items || []);
}
function hideCmdMenu() {
cmdMenu.hidden = true;
cmdMenuIndex = -1;
cmdMenu._items = [];
activeComposerToken = null;
}
function navigateCmdMenu(direction) {
@@ -4147,12 +4542,16 @@
items[cmdMenuIndex]?.classList.remove('active');
cmdMenuIndex = (cmdMenuIndex + direction + items.length) % items.length;
items[cmdMenuIndex]?.classList.add('active');
items[cmdMenuIndex]?.scrollIntoView({ block: 'nearest' });
}
function selectCmdMenuItem() {
const items = cmdMenu.querySelectorAll('.cmd-item');
if (cmdMenuIndex >= 0 && items[cmdMenuIndex]) {
const cmd = items[cmdMenuIndex].dataset.cmd;
function selectComposerItemByIndex(index) {
const items = Array.isArray(cmdMenu._items) ? cmdMenu._items : [];
const item = items[index];
if (!item) return;
if (item.kind === 'command') {
const cmd = item.name || item.label || '';
if (cmd === '/model') {
hideCmdMenu();
msgInput.value = '';
@@ -4165,10 +4564,30 @@
showModePicker();
return;
}
msgInput.value = cmd + ' ';
msgInput.value = item.insertion || `${cmd} `;
hideCmdMenu();
msgInput.focus();
autoResize();
return;
}
const token = activeComposerToken || findActiveComposerToken();
if (!token) return;
const value = msgInput.value || '';
const insertion = String(item.insertion || item.label || item.name || '');
const appendSpace = item.appendSpace !== false;
const suffix = appendSpace ? ' ' : '';
msgInput.value = value.slice(0, token.start) + insertion + suffix + value.slice(token.end);
const nextCursor = token.start + insertion.length + suffix.length;
msgInput.setSelectionRange(nextCursor, nextCursor);
hideCmdMenu();
msgInput.focus();
autoResize();
if (!appendSpace) requestComposerSuggestions();
}
function selectCmdMenuItem() {
if (cmdMenuIndex >= 0) selectComposerItemByIndex(cmdMenuIndex);
}
// --- Option Picker (generic) ---
@@ -4232,10 +4651,10 @@
}
function showModelPicker() {
if (currentAgent === 'codex') {
if (isCodexLikeAgent(currentAgent)) {
const current = _splitCodexThinkingModel(currentModel || '');
const baseOptions = getCodexBaseModelOptions();
showOptionPicker('选择 Codex 模型', baseOptions, current.base || '', (baseValue) => {
showOptionPicker(`选择 ${isCodexAppAgent(currentAgent) ? 'Codex App' : 'Codex'} 模型`, baseOptions, current.base || '', (baseValue) => {
const base = String(baseValue || '').trim();
const thinkingOptions = [
{ value: '', label: '无 (默认)', desc: '不附加 (low/medium/high/xhigh) 后缀' },
@@ -4296,10 +4715,29 @@
return;
}
if ((!text && pendingAttachments.length === 0) || isGenerating || isBlockingSessionLoad()) return;
const runtimeInsert = isGenerating && isCodexAppAgent(currentAgent);
if ((!text && pendingAttachments.length === 0) || isBlockingSessionLoad()) return;
if (isGenerating && !runtimeInsert) return;
hideCmdMenu();
hideOptionPicker();
if (runtimeInsert) {
if (pendingAttachments.length > 0) {
appendError('Codex App 运行中插入暂不支持图片附件,请先移除图片。');
return;
}
if (text.startsWith('/')) {
appendError('Codex App 运行中暂不支持 slash 指令插入。');
return;
}
messagesDiv.appendChild(createMsgElement('user', text, []));
scrollToBottom();
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
msgInput.value = '';
autoResize();
return;
}
// Slash commands: don't show as user bubble
if (text.startsWith('/')) {
if (pendingAttachments.length > 0) {
@@ -4397,7 +4835,9 @@
});
importSessionBtn.addEventListener('click', () => {
newChatDropdown.hidden = true;
if (currentAgent === 'codex') {
if (isCodexAppAgent(currentAgent)) {
appendError('Codex App 模式暂不支持导入本地会话。');
} else if (currentAgent === 'codex') {
showImportCodexSessionModal();
} else {
showImportSessionModal();
@@ -4461,13 +4901,7 @@
msgInput.addEventListener('input', () => {
autoResize();
const val = msgInput.value;
// Show slash command menu
if (!noteMode && val.startsWith('/') && !val.includes('\n')) {
showCmdMenu(val);
} else {
hideCmdMenu();
}
requestComposerSuggestions();
});
msgInput.addEventListener('keydown', (e) => {
@@ -5116,7 +5550,7 @@
}
function showSettingsPanel() {
if (currentAgent === 'codex') {
if (isCodexLikeAgent(currentAgent)) {
showCodexSettingsPanel();
return;
}
@@ -6085,7 +6519,7 @@
// Register Service Worker for mobile push notifications
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
navigator.serviceWorker.register(`/sw.js?v=${ASSET_VERSION}`).catch(() => {});
}
// Restore remembered password

View File

@@ -12,7 +12,7 @@
document.documentElement.dataset.theme = theme;
})();
</script>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="style.css?v=20260613-codexapp-tools2">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
</head>
<body>
@@ -65,6 +65,7 @@
<div id="chat-agent-menu" class="chat-agent-menu" hidden>
<button type="button" class="chat-agent-option active" data-agent="claude">Claude</button>
<button type="button" class="chat-agent-option" data-agent="codex">Codex</button>
<button type="button" class="chat-agent-option" data-agent="codexapp">Codex App</button>
</div>
<span id="chat-runtime-state" class="chat-runtime-state" hidden>运行中</span>
<button id="chat-cwd" class="chat-cwd" type="button" hidden></button>
@@ -94,6 +95,7 @@
<div class="input-area">
<div id="attachment-tray" class="attachment-tray" hidden></div>
<div id="pending-notes-tray" class="pending-notes-tray" hidden></div>
<div class="input-wrapper">
<input id="image-upload-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden>
<button id="attach-btn" class="attach-btn" title="上传图片" type="button">
@@ -137,6 +139,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="app.js"></script>
<script src="app.js?v=20260613-codexapp-tools2"></script>
</body>
</html>

View File

@@ -661,6 +661,10 @@ body.session-loading-active {
.session-project-group {
margin: 0 0 12px;
}
.session-pinned-group {
padding-bottom: 8px;
border-bottom: 1px solid rgba(221, 208, 192, 0.72);
}
.session-project-header {
position: sticky;
top: 0;
@@ -729,6 +733,9 @@ body.session-loading-active {
outline: 2px solid rgba(192, 85, 58, 0.22);
outline-offset: 2px;
}
.session-pinned-header {
color: var(--accent);
}
.session-item {
display: flex;
align-items: center;
@@ -741,6 +748,16 @@ body.session-loading-active {
}
.session-item:hover { background: var(--bg-tertiary); }
.session-item.active { background: var(--accent-light); }
.session-item.pinned::before {
content: '';
position: absolute;
left: 4px;
top: 9px;
bottom: 9px;
width: 3px;
border-radius: 999px;
background: var(--accent);
}
.session-item-main {
flex: 1;
min-width: 0;
@@ -757,6 +774,16 @@ body.session-loading-active {
color: var(--text-primary);
}
.session-item.active .session-item-title { color: var(--accent); font-weight: 500; }
.session-item-pin-badge {
flex-shrink: 0;
padding: 1px 5px;
border-radius: 999px;
background: rgba(192, 85, 58, 0.11);
color: var(--accent);
font-size: 10px;
font-weight: 800;
line-height: 1.4;
}
.session-item-status {
display: inline-flex;
align-items: center;
@@ -810,6 +837,18 @@ body.session-loading-active {
font-weight: 800;
letter-spacing: 0.02em;
}
.session-item-btn.pin {
min-width: 24px;
font-weight: 800;
}
.session-item-btn.pin.active {
color: var(--accent);
background: rgba(192, 85, 58, 0.1);
}
.session-item-btn.pin:hover {
color: var(--accent);
background: rgba(192, 85, 58, 0.12);
}
.session-item-btn.delete:hover { color: var(--danger); background: var(--accent-light); }
/* Inline edit in sidebar */
.session-item-edit-input {
@@ -1028,6 +1067,8 @@ body.session-loading-active {
overflow-x: hidden;
padding: 16px;
padding-right: 20px;
padding-bottom: 24px;
scroll-padding-bottom: 24px;
display: flex;
flex-direction: column;
gap: 12px;
@@ -1298,6 +1339,9 @@ body.session-loading-active {
background: var(--info);
color: #fff;
}
.msg.user.cross-conversation-reply .msg-avatar {
background: var(--success);
}
.msg.user.cross-conversation .msg-bubble {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.76), transparent),
@@ -1306,6 +1350,22 @@ body.session-loading-active {
color: var(--text-primary);
box-shadow: 0 10px 22px rgba(45, 31, 20, 0.05);
}
.msg.user.cross-conversation-reply .msg-bubble {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.78), transparent),
rgba(93, 138, 84, 0.12);
border-color: rgba(93, 138, 84, 0.28);
}
.msg.user.cross-conversation-reply .cross-conversation-meta,
.msg.user.cross-conversation-reply .cross-conversation-id-btn {
color: var(--success);
}
.msg.user.cross-conversation-reply .cross-conversation-id-btn {
border-color: rgba(93, 138, 84, 0.28);
}
.msg.user.cross-conversation-reply .cross-conversation-id-btn:hover {
background: rgba(93, 138, 84, 0.14);
}
.cross-conversation-meta {
display: flex;
align-items: center;
@@ -1340,24 +1400,6 @@ body.session-loading-active {
border-bottom-left-radius: 4px;
color: var(--text-primary);
}
.msg.note {
align-self: flex-end;
flex-direction: row-reverse;
max-width: min(680px, 85%);
}
.msg.note .note-avatar {
background: var(--note-accent);
color: #fff;
}
.msg.note .note-bubble {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), transparent),
var(--note-bg);
border: 1px solid var(--note-border);
border-bottom-right-radius: 4px;
color: var(--text-primary);
box-shadow: 0 10px 22px rgba(45, 31, 20, 0.05);
}
.note-meta {
margin-bottom: 6px;
color: var(--note-accent);
@@ -1452,6 +1494,11 @@ body.session-loading-active {
/* Markdown content */
.msg-bubble p { margin: 0 0 8px 0; }
.msg-bubble p:last-child { margin-bottom: 0; }
.msg-bubble hr {
border: 0;
border-top: 1px solid rgba(122, 104, 82, 0.22);
margin: 10px 0 11px;
}
.msg-bubble ul, .msg-bubble ol { margin: 4px 0 8px 20px; }
.msg-bubble li { margin-bottom: 2px; }
.msg-bubble h1, .msg-bubble h2, .msg-bubble h3, .msg-bubble h4 {
@@ -1585,7 +1632,7 @@ body.session-loading-active {
color: var(--text-secondary);
background: var(--bg-secondary);
display: flex;
align-items: anchor-center;
align-items: center;
gap: 8px;
user-select: none;
list-style: none;
@@ -1891,7 +1938,7 @@ body.session-loading-active {
30% { transform: translateY(-7px); }
}
/* === Slash Command Menu === */
/* === Composer Modifier Menu === */
.cmd-menu {
position: absolute;
bottom: 80px;
@@ -1904,27 +1951,64 @@ body.session-loading-active {
padding: 6px;
min-width: 240px;
max-width: 320px;
max-height: min(52vh, 360px);
overflow-y: auto;
overscroll-behavior: contain;
z-index: 50;
}
.cmd-menu::-webkit-scrollbar { width: 8px; }
.cmd-menu::-webkit-scrollbar-track { background: transparent; }
.cmd-menu::-webkit-scrollbar-thumb {
background: rgba(95, 74, 58, 0.22);
border-radius: 999px;
}
.cmd-menu::-webkit-scrollbar-thumb:hover {
background: rgba(95, 74, 58, 0.34);
}
.cmd-item {
display: flex;
align-items: center;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s;
min-width: 0;
}
.cmd-item:hover, .cmd-item.active { background: var(--accent-light); }
.cmd-item-kind {
flex: 0 0 auto;
min-width: 42px;
padding: 2px 6px;
border-radius: 6px;
background: rgba(192, 85, 58, 0.1);
color: var(--accent);
font-size: 10px;
font-weight: 800;
line-height: 1.4;
text-align: center;
text-transform: uppercase;
}
.cmd-item-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.cmd-item-cmd {
font-weight: 600;
font-size: 14px;
color: var(--accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmd-item-desc {
font-size: 13px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* === Input Area === */
@@ -2001,9 +2085,63 @@ body.session-loading-active {
font-size: 12px;
line-height: 1.55;
}
.pending-notes-tray {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 800px;
max-height: min(32vh, 220px);
margin: 0 auto 10px;
padding: 2px 3px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--note-border) transparent;
}
.pending-notes-tray[hidden] {
display: none;
}
.pending-notes-tray::-webkit-scrollbar {
width: 6px;
}
.pending-notes-tray::-webkit-scrollbar-thumb {
background: var(--note-border);
border-radius: 999px;
}
.pending-note {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: start;
gap: 8px;
animation: fadeIn 0.2s ease;
}
.pending-note .note-avatar {
width: 28px;
height: 28px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
background: var(--note-accent);
color: #fff;
font-size: 12px;
font-weight: 800;
flex-shrink: 0;
}
.pending-note .note-bubble {
min-width: 0;
padding: 10px 12px;
border-radius: 12px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), transparent),
var(--note-bg);
border: 1px solid var(--note-border);
color: var(--text-primary);
box-shadow: 0 8px 18px rgba(45, 31, 20, 0.05);
}
.input-wrapper {
display: flex;
align-items: anchor-center;
align-items: center;
gap: 8px;
background: #fff;
border: 1px solid var(--border-color);
@@ -2011,6 +2149,7 @@ body.session-loading-active {
padding: 8px 12px;
max-width: 800px;
margin: 0 auto;
width: 100%;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input-wrapper.drag-active {
@@ -2087,6 +2226,7 @@ body.session-loading-active {
}
#msg-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: var(--text-primary);
@@ -2204,7 +2344,11 @@ body.session-loading-active {
.menu-btn { display: block; }
.msg { max-width: 95%; }
.input-area { padding: 8px 10px; padding-bottom: max(10px, var(--safe-bottom)); }
.messages { padding: 12px 8px; gap: 10px; }
.messages {
padding: 12px 8px 24px;
scroll-padding-bottom: 24px;
gap: 10px;
}
.session-item-actions { display: flex; }
.cmd-menu { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; }
.option-picker { left: 10px; right: 10px; transform: none; min-width: auto; bottom: 72px; }
@@ -2252,7 +2396,9 @@ body.session-loading-active {
.attach-btn { width: 34px; height: 34px; border-radius: 10px; }
.note-mode-btn { width: 34px; height: 34px; border-radius: 10px; }
.send-btn, .abort-btn { width: 34px; height: 34px; }
.msg.note { max-width: 92%; }
.pending-notes-tray { max-height: 34vh; margin-bottom: 8px; }
.pending-note { grid-template-columns: 26px minmax(0, 1fr); gap: 7px; }
.pending-note .note-avatar { width: 26px; height: 26px; border-radius: 8px; font-size: 11px; }
.note-actions { gap: 5px; }
.note-action { min-height: 28px; padding: 0 8px; }
.note-edit-input { min-width: 0; }
@@ -3037,6 +3183,81 @@ html[data-theme='coolvibe'] .settings-back:hover {
line-height: 1.5;
word-break: break-all;
}
.codex-user-input-panel {
max-width: 520px;
}
.codex-user-input-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.codex-user-input-question {
display: flex;
flex-direction: column;
gap: 10px;
}
.codex-user-input-kicker {
font-size: 12px;
font-weight: 700;
color: var(--accent);
}
.codex-user-input-prompt {
color: var(--text-primary);
font-size: 15px;
line-height: 1.5;
}
.codex-user-input-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.codex-user-input-option {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
cursor: pointer;
}
.codex-user-input-option:hover {
border-color: rgba(192, 85, 58, 0.36);
}
.codex-user-input-option input[type='radio'] {
margin-top: 3px;
}
.codex-user-input-option-copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.codex-user-input-option-label {
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
}
.codex-user-input-option-desc {
color: var(--text-muted);
font-size: 12px;
line-height: 1.4;
}
.codex-user-input-text {
width: 100%;
margin-top: 4px;
padding: 8px 10px;
background: #fff;
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font: inherit;
outline: none;
}
.codex-user-input-text:focus {
border-color: var(--accent);
}
.modal-quick-picks {
display: flex;
flex-wrap: wrap;

497
scripts/mock-codex-app-server.js Executable file
View File

@@ -0,0 +1,497 @@
#!/usr/bin/env node
const crypto = require('crypto');
const path = require('path');
const readline = require('readline');
const { spawn } = require('child_process');
const args = process.argv.slice(2);
if (args[0] !== 'app-server') {
const child = spawn(process.execPath, [path.join(__dirname, 'mock-codex.js'), ...args], {
stdio: 'inherit',
});
child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exit(code || 0);
});
child.on('error', (err) => {
console.error(err.stack || err.message);
process.exit(1);
});
return;
}
const threads = new Map();
const pendingServerRequests = new Map();
let nextServerRequestId = 1;
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function textFromInput(input) {
return (Array.isArray(input) ? input : [])
.map((item) => {
if (item?.type === 'text') return item.text || '';
if (item?.type === 'localImage') return `[image:${path.basename(item.path || 'image')}]`;
return '';
})
.filter(Boolean)
.join('\n')
.trim();
}
function tokenUsage(text) {
const inputTokens = Math.max(1, Math.ceil(String(text || '').length / 4));
const outputTokens = 7;
return {
last: {
inputTokens,
cachedInputTokens: 1,
outputTokens,
reasoningOutputTokens: 0,
totalTokens: inputTokens + outputTokens + 1,
},
total: {
inputTokens,
cachedInputTokens: 1,
outputTokens,
reasoningOutputTokens: 0,
totalTokens: inputTokens + outputTokens + 1,
},
};
}
function collaborationSummary(params = {}) {
const collaborationMode = params.collaborationMode;
const settings = collaborationMode?.settings || {};
return JSON.stringify({
mode: collaborationMode?.mode || null,
hasModel: Boolean(settings.model),
hasDeveloperInstructions: /Codex sub-agent spawning rules/.test(String(settings.developer_instructions || '')),
hasReasoningEffort: Object.prototype.hasOwnProperty.call(settings, 'reasoning_effort'),
hasTopLevelModel: Object.prototype.hasOwnProperty.call(params, 'model'),
hasTopLevelEffort: Object.prototype.hasOwnProperty.call(params, 'effort'),
});
}
function ensureThread(threadId, params = {}) {
const id = threadId || `app-thread-${crypto.randomUUID()}`;
if (!threads.has(id)) {
threads.set(id, {
id,
cwd: params.cwd || process.cwd(),
dynamicTools: Array.isArray(params.dynamicTools) ? params.dynamicTools : [],
config: params.config && typeof params.config === 'object' ? params.config : {},
activeTurnId: null,
timer: null,
steers: [],
});
}
const thread = threads.get(id);
if (Array.isArray(params.dynamicTools)) thread.dynamicTools = params.dynamicTools;
if (params.config && typeof params.config === 'object') thread.config = params.config;
return thread;
}
function threadPayload(thread) {
return {
id: thread.id,
cwd: thread.cwd,
status: thread.activeTurnId ? 'running' : 'idle',
turns: [],
};
}
function completeTurn(thread, turnId, text, status = 'completed') {
if (thread.activeTurnId !== turnId) return;
const suffix = thread.steers.length > 0 ? ` | steer: ${thread.steers.join(' | ')}` : '';
const responseText = `Codex App mock handled: ${text}${suffix}`;
send({
method: 'item/agentMessage/delta',
params: {
threadId: thread.id,
turnId,
itemId: 'agent-msg',
delta: responseText,
},
});
if (/tool/i.test(text)) {
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: 'tool-cmd',
type: 'commandExecution',
command: '/bin/bash -lc echo codexapp',
status: 'inProgress',
},
},
});
send({
method: 'item/commandExecution/outputDelta',
params: {
threadId: thread.id,
turnId,
itemId: 'tool-cmd',
delta: 'codexapp\n',
},
});
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
id: 'tool-cmd',
type: 'commandExecution',
command: '/bin/bash -lc echo codexapp',
aggregatedOutput: 'codexapp\n',
exitCode: 0,
status: 'completed',
},
},
});
}
send({
method: 'thread/tokenUsage/updated',
params: {
threadId: thread.id,
turnId,
tokenUsage: tokenUsage(`${text}${suffix}`),
},
});
send({
method: 'turn/completed',
params: {
threadId: thread.id,
turn: {
id: turnId,
status,
items: [],
},
},
});
thread.activeTurnId = null;
thread.timer = null;
thread.steers = [];
}
function requestClient(method, params, callback) {
const id = `mock-server-request-${nextServerRequestId++}`;
pendingServerRequests.set(id, callback);
send({ id, method, params });
}
function completeDynamicToolTurn(thread, turnId, text) {
const callId = 'dynamic-ccweb-list';
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: callId,
type: 'dynamicToolCall',
namespace: 'ccweb',
tool: 'ccweb_list_conversations',
arguments: { limit: 5 },
status: 'inProgress',
},
},
});
requestClient('item/tool/call', {
threadId: thread.id,
turnId,
callId,
namespace: 'ccweb',
tool: 'ccweb_list_conversations',
arguments: { limit: 5 },
}, (message) => {
const result = message.result || {};
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
id: callId,
type: 'dynamicToolCall',
namespace: 'ccweb',
tool: 'ccweb_list_conversations',
arguments: { limit: 5 },
status: result.success === false ? 'failed' : 'completed',
contentItems: result.contentItems || [],
success: result.success !== false,
durationMs: 1,
},
},
});
completeTurn(thread, turnId, `dynamic result: ${JSON.stringify(result)}`);
});
}
function completeMcpToolTurn(thread, turnId) {
const itemId = 'mcp-ccweb-list';
const ccwebConfig = thread.config?.['mcp_servers.ccweb'] || null;
const env = ccwebConfig?.env || {};
const payload = {
ok: true,
currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || null,
sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || null,
hasCcwebMcpConfig: Boolean(ccwebConfig),
};
const itemBase = {
id: itemId,
type: 'mcpToolCall',
server: 'ccweb',
tool: 'ccweb_list_conversations',
arguments: { limit: 5 },
};
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
...itemBase,
status: 'inProgress',
},
},
});
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
...itemBase,
status: 'completed',
result: {
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
structuredContent: payload,
isError: false,
},
},
},
});
completeTurn(thread, turnId, `mcp result: ${JSON.stringify(payload)}`);
}
function completeGuidedInputTurn(thread, turnId) {
requestClient('item/tool/requestUserInput', {
threadId: thread.id,
turnId,
itemId: 'guided-input-call',
questions: [{
id: 'choice',
header: '选择',
question: '选择一个测试答案',
isOther: true,
isSecret: false,
options: [
{ label: 'A', description: '测试选项 A' },
{ label: 'B', description: '测试选项 B' },
],
}],
}, (message) => {
const answer = message.result?.answers?.choice?.answers?.[0] || 'empty';
completeTurn(thread, turnId, `guided answer: ${answer}`);
});
}
function completeEmptyReasoningTurn(thread, turnId, text) {
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: 'reasoning-empty',
type: 'reasoning',
content: [],
summary: [],
status: 'inProgress',
},
},
});
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
id: 'reasoning-empty',
type: 'reasoning',
content: [],
summary: [],
status: 'completed',
},
},
});
completeTurn(thread, turnId, text);
}
function startTurn(params) {
const thread = ensureThread(params.threadId, params);
const turnId = `app-turn-${crypto.randomUUID()}`;
const text = textFromInput(params.input);
thread.activeTurnId = turnId;
thread.steers = [];
send({
method: 'turn/started',
params: {
threadId: thread.id,
turn: {
id: turnId,
status: 'running',
items: [],
},
},
});
if (/collaboration/i.test(text)) {
completeTurn(thread, turnId, `collaboration mode: ${collaborationSummary(params)}`);
return { turn: { id: turnId, status: 'running', items: [] } };
}
if (/empty reasoning/i.test(text)) {
completeEmptyReasoningTurn(thread, turnId, text);
return { turn: { id: turnId, status: 'running', items: [] } };
}
if (/dynamic/i.test(text)) {
completeMcpToolTurn(thread, turnId);
return { turn: { id: turnId, status: 'running', items: [] } };
}
if (/guided/i.test(text)) {
if (!params.collaborationMode || params.collaborationMode.mode !== 'plan') {
completeTurn(thread, turnId, 'guided input unavailable outside plan');
return { turn: { id: turnId, status: 'running', items: [] } };
}
completeGuidedInputTurn(thread, turnId);
return { turn: { id: turnId, status: 'running', items: [] } };
}
const delay = /slow/i.test(text) ? 900 : 80;
thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);
return { turn: { id: turnId, status: 'running', items: [] } };
}
function interruptTurn(params) {
const thread = ensureThread(params.threadId, params);
if (thread.timer) clearTimeout(thread.timer);
if (thread.activeTurnId === params.turnId) {
completeTurn(thread, params.turnId, 'interrupted', 'interrupted');
}
return {};
}
function steerTurn(params) {
const thread = ensureThread(params.threadId, params);
if (!thread.activeTurnId || thread.activeTurnId !== params.expectedTurnId) {
return {
error: {
code: -32001,
message: 'expectedTurnId does not match active turn',
},
};
}
const text = textFromInput(params.input);
if (text) thread.steers.push(text);
send({
method: 'item/agentMessage/delta',
params: {
threadId: thread.id,
turnId: thread.activeTurnId,
itemId: 'agent-msg',
delta: `\n[steer accepted: ${text}]`,
},
});
return { result: {} };
}
function handleRequest(message) {
const id = message.id;
const method = message.method;
const params = message.params || {};
if (id && !method && pendingServerRequests.has(id)) {
const callback = pendingServerRequests.get(id);
pendingServerRequests.delete(id);
callback(message);
return;
}
try {
if (method === 'initialize') {
send({ id, result: { serverInfo: { name: 'mock-codex-app-server', version: '0.0.0' } } });
return;
}
if (method === 'experimentalFeature/enablement/set') {
send({ id, result: { enablement: params.enablement || {} } });
return;
}
if (method === 'collaborationMode/list') {
send({ id, result: { data: [{ mode: 'default' }, { mode: 'plan' }] } });
return;
}
if (method === 'thread/start') {
const thread = ensureThread(null, params);
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
return;
}
if (method === 'thread/resume') {
const thread = ensureThread(params.threadId, params);
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
return;
}
if (method === 'turn/start') {
send({ id, result: startTurn(params) });
return;
}
if (method === 'turn/steer') {
const result = steerTurn(params);
send({ id, ...result });
return;
}
if (method === 'turn/interrupt') {
send({ id, result: interruptTurn(params) });
return;
}
if (method === 'initialized') return;
send({ id, error: { code: -32601, message: `Unknown mock method: ${method}` } });
} catch (err) {
send({ id, error: { code: -32603, message: err.message || String(err) } });
}
}
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => {
if (!line.trim()) return;
let message;
try {
message = JSON.parse(line);
} catch {
return;
}
if (Object.prototype.hasOwnProperty.call(message, 'id')) handleRequest(message);
});

View File

@@ -11,6 +11,7 @@ const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js');
const HAS_SQLITE3 = spawnSync('sqlite3', ['-version'], { stdio: 'ignore' }).status === 0;
function mkdirp(dir) {
@@ -397,6 +398,29 @@ async function main() {
qqbot: { qmsgKey: '' },
}, null, 2));
const skillDir = path.join(homeDir, '.codex', 'skills', 'regression-skill');
mkdirp(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), [
'---',
'name: regression-skill',
'description: Regression skill for composer suggestions.',
'---',
'',
'# Regression Skill',
'',
'Use this only in regression tests.',
].join('\n'));
fs.writeFileSync(path.join(configDir, 'prompts.json'), JSON.stringify({
prompts: [
{
name: 'shipit',
title: 'Ship It',
description: 'Regression prompt template.',
content: 'Regression prompt body from @shipit.',
},
],
}, null, 2));
createFakeClaudeHistory(homeDir);
createFakeCodexConfig(homeDir);
const codexFixture = createFakeCodexHistory(homeDir);
@@ -414,7 +438,7 @@ async function main() {
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
await assertWsUpgradeRejected(port, '/not-ws');
@@ -467,11 +491,64 @@ async function main() {
const codexInitCwd = path.join(tempRoot, 'codex-space');
mkdirp(codexInitCwd);
fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' }));
const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd);
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
assert(codexSession.model === 'gpt-5.5(xhigh)', 'Codex new_session should read default model from ~/.codex/config.toml');
ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: true }));
const pinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId);
assert(pinnedAck.pinnedAt, 'Pinning a session should return pinnedAt');
const pinnedList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexSession.sessionId && s.pinnedAt));
assert(pinnedList.sessions[0].id === codexSession.sessionId, 'Pinned session should sort before regular sessions');
let storedPinnedSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
assert(storedPinnedSession.pinnedAt === pinnedAck.pinnedAt, 'Pinned state should persist to session JSON');
ws.send(JSON.stringify({ type: 'set_session_pinned', sessionId: codexSession.sessionId, pinned: false }));
const unpinnedAck = await nextMessage(messages, ws, (msg) => msg.type === 'session_pinned' && msg.sessionId === codexSession.sessionId && !msg.pinnedAt);
assert(unpinnedAck.pinnedAt === null, 'Unpinning a session should clear pinnedAt');
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexSession.sessionId && !s.pinnedAt));
storedPinnedSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
assert(storedPinnedSession.pinnedAt === null, 'Unpinned state should persist to session JSON');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash', trigger: '/', query: 'mo', sessionId: codexSession.sessionId, agent: 'codex' }));
const slashComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash');
assert(slashComposer.items.some((item) => item.kind === 'command' && item.name === '/model'), 'Composer slash suggestions should include /model');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill');
assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt', trigger: '@', query: 'ship', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt');
assert(promptComposer.items.some((item) => item.kind === 'prompt' && item.name === 'shipit'), 'Composer prompt suggestions should include configured prompt');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-file', trigger: '@', query: 'context', sessionId: codexSession.sessionId, agent: 'codex' }));
const fileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-file');
assert(fileComposer.items.some((item) => item.kind === 'file' && item.name === 'context.txt'), 'Composer file suggestions should include cwd file');
ws.send(JSON.stringify({
type: 'message',
text: '@shipit @context.txt $regression-skill run composer regression',
sessionId: codexSession.sessionId,
mode: 'plan',
agent: 'codex',
}));
const composerExpanded = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
/BEGIN CC-WEB PROMPT: shipit/.test(msg.text || '') &&
/Composer file context body/.test(msg.text || '')
));
assert(/Regression prompt body from @shipit/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand @prompt content');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
const storedComposerSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedComposerMessage = storedComposerSession.messages.find((message) => message.content === '@shipit @context.txt $regression-skill run composer regression');
assert(storedComposerMessage, 'Composer message should persist original user text');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'shipit'), 'Composer message should persist prompt mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'file' && mention.name === 'context.txt'), 'Composer message should persist file mention metadata');
assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'regression-skill'), 'Composer message should persist skill mention metadata');
const mcpList = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_list_conversations',
sourceSessionId: codexSession.sessionId,
@@ -504,21 +581,76 @@ async function main() {
assert(crossUserBubble.message.crossConversation.hopCount === 1, 'Cross message should persist hop count');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
const storedCrossTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossTargetSession.sessionId}.json`), 'utf8'));
const storedCrossSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedCrossMessage = storedCrossTarget.messages.find((message) => message.crossConversation?.messageId === crossSend.body.messageId);
assert(storedCrossMessage?.content === 'cross hello from mcp', 'Cross message should be persisted in target session');
assert(storedCrossMessage.crossConversation.sourceTitle === codexSession.title, 'Cross message should persist source title');
assert(storedCrossMessage.crossConversation.sourceTitle === storedCrossSource.title, 'Cross message should persist source title');
assert(storedCrossTarget.messages.some((message) => message.role === 'assistant' && /来自/.test(String(message.content || ''))), 'Cross message runtime prompt should include source context for the target agent');
const hopLimit = await callInternalMcp(port, internalMcpToken, {
const hopAllowed = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: crossTargetSession.sessionId,
sourceSessionId: codexSession.sessionId,
sourceHopCount: 1,
args: {
targetConversationId: codexSession.sessionId,
content: 'this should be blocked by hop limit',
targetConversationId: crossTargetSession.sessionId,
content: 'cross hop still allowed',
},
});
assert(hopLimit.status === 400 && hopLimit.body?.code === 'hop_limit_exceeded', 'MCP cross send should enforce hop limit');
assert(hopAllowed.status === 200 && hopAllowed.body?.ok, `MCP cross send should not enforce hop limit: ${JSON.stringify(hopAllowed.body)}`);
const hopAllowedBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossTargetSession.sessionId &&
msg.message?.crossConversation?.messageId === hopAllowed.body.messageId &&
msg.message?.content === 'cross hop still allowed'
));
assert(hopAllowedBubble.message.crossConversation.hopCount === 2, 'Cross message should keep incrementing hop count without blocking');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossTargetSession.sessionId);
const crossReplyTargetCwd = path.join(tempRoot, 'codex-mcp-cross-reply-target');
mkdirp(crossReplyTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossReplyTargetCwd, mode: 'yolo' }));
const crossReplyTargetSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === crossReplyTargetCwd);
const requestReply = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_request_reply',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: crossReplyTargetSession.sessionId,
content: 'cross reply requested',
},
});
assert(requestReply.status === 200 && requestReply.body?.ok, `MCP request reply should succeed: ${JSON.stringify(requestReply.body)}`);
assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP request reply should return a waiting request id');
const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossReplyTargetSession.sessionId &&
msg.message?.crossConversation?.replyRequestId === requestReply.body.requestId &&
msg.message?.crossConversation?.expectsReply === true &&
msg.message?.content === 'cross reply requested'
));
assert(requestReplyTargetBubble.message.crossConversation.hopCount === 1, 'Request reply target message should persist hop count');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === crossReplyTargetSession.sessionId);
await nextMessage(messages, ws, (msg) => (
(msg.type === 'done' || msg.type === 'background_done') &&
msg.sessionId === codexSession.sessionId
));
const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8'));
const storedReplySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
const storedReplyRequestMessage = storedReplyTarget.messages.find((message) => message.crossConversation?.replyRequestId === requestReply.body.requestId);
assert(storedReplyRequestMessage?.crossConversation?.expectsReply === true, 'Request reply target message should persist waiting metadata');
assert(storedReplyTarget.messages.some((message) => message.role === 'assistant' && /cross reply requested/.test(String(message.content || ''))), 'Request reply target should produce an assistant reply');
const storedReplyMessageIndex = storedReplySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === requestReply.body.requestId);
assert(storedReplyMessageIndex >= 0, 'Request reply should send the target reply back to source session');
const storedReplyMessage = storedReplySource.messages[storedReplyMessageIndex];
assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply');
assert(/线程「/.test(storedReplyMessage.content || '') && /已返回消息/.test(storedReplyMessage.content || ''), 'Returned cross message should include reply heading');
assert(/Codex mock handled/.test(storedReplyMessage.content || ''), 'Returned cross message should include target assistant output');
assert(storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => (
message.role === 'assistant' &&
/Codex mock handled/.test(String(message.content || '')) &&
/已返回消息/.test(String(message.content || ''))
)), 'Returned cross message should trigger the source session to run again');
const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
const mcpSpawnLine = processLogAfterMcp
@@ -634,6 +766,100 @@ async function main() {
assert(/trigger codex context limit/.test(autoCompactRetry.text || ''), 'Codex auto /compact should replay the failed prompt after compact');
}
const codexAppCwd = path.join(tempRoot, 'codexapp-space');
mkdirp(codexAppCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppCwd, mode: 'yolo' }));
const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd);
assert(codexAppSession.model === 'gpt-5.5(xhigh)', 'Codex App new_session should read default Codex model');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-skill', trigger: '$', query: 'reg', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-skill');
assert(codexAppSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Codex App composer skill suggestions should include local Codex skill');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration default probe', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppDefaultCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
assert(/"mode":"default"/.test(codexAppDefaultCollab.text || ''), 'Codex App YOLO mode should pass default collaboration mode');
assert(/"hasModel":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include model');
assert(/"hasDeveloperInstructions":true/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration settings should include sub-agent developer instructions');
assert(/"hasTopLevelModel":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate model at top level');
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp empty reasoning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const storedCodexAppAfterReasoning = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const hasEmptyReasoningTool = storedCodexAppAfterReasoning.messages
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
.some((tool) => (tool.kind === 'reasoning' || tool.meta?.kind === 'reasoning') && !String(tool.result || '').trim());
assert(!hasEmptyReasoningTool, 'Codex App should not persist empty reasoning tool calls');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp tool prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
const codexAppTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-cmd');
assert(/codexapp/.test(codexAppTool.result || ''), 'Codex App should stream app-server tool results');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
let storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const codexAppThreadId = storedCodexApp.codexAppThreadId;
assert(codexAppThreadId, 'Codex App thread id should be persisted');
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted');
assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls');
assert(/currentConversationId/.test(codexAppDynamicTool.result || ''), 'Codex App MCP tool should return ccweb conversation data');
assert(/"hasCcwebMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass ccweb MCP config');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration plan probe', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
const codexAppPlanCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
assert(/"mode":"plan"/.test(codexAppPlanCollab.text || ''), 'Codex App Plan mode should pass plan collaboration mode');
assert(/"hasDeveloperInstructions":true/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration settings should keep sub-agent developer instructions');
assert(/"hasTopLevelModel":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate model at top level');
assert(/"hasTopLevelEffort":false/.test(codexAppPlanCollab.text || ''), 'Codex App Plan collaboration turn should not duplicate effort at top level');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp guided prompt', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
const guidedRequest = await nextMessage(messages, ws, (msg) => msg.type === 'codex_app_user_input_request' && msg.sessionId === codexAppSession.sessionId);
assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions');
ws.send(JSON.stringify({
type: 'codex_app_user_input_response',
sessionId: codexAppSession.sessionId,
requestId: guidedRequest.requestId,
answers: { choice: { answers: ['A'] } },
}));
const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || ''));
assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
await sleep(150);
ws.send(JSON.stringify({ type: 'message', text: 'runtime steer insert', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const steerDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /steer accepted: runtime steer insert/.test(msg.text || ''));
assert(/runtime steer insert/.test(steerDelta.text || ''), 'Codex App running message should use turn/steer');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(storedCodexApp.codexAppThreadId === codexAppThreadId, 'Codex App follow-up should resume the same app-server thread');
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === 'runtime steer insert'), 'Codex App steer message should be persisted as user history');
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /runtime steer insert/.test(String(message.content || ''))), 'Codex App steered assistant output should be persisted');
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp abort prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
const codexAppRunningMcp = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_send_message',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
targetConversationId: codexAppSession.sessionId,
content: 'running codexapp target should reject this',
},
});
assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets');
await sleep(150);
ws.send(JSON.stringify({ type: 'abort' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const claudeAttachment = await uploadAttachment(port, token, {
filename: 'claude-test.png',
mime: 'image/png',

1594
server.js

File diff suppressed because it is too large Load Diff