feat: enhance codex app and cross-conversation messaging
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
472
lib/codex-app-runtime.js
Normal 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 };
|
||||
220
lib/codex-app-server-client.js
Normal file
220
lib/codex-app-server-client.js
Normal 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 };
|
||||
594
public/app.js
594
public/app.js
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
269
public/style.css
269
public/style.css
@@ -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
497
scripts/mock-codex-app-server.js
Executable 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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user