Update ccweb codex app integration

This commit is contained in:
shiyue
2026-06-16 14:36:06 +08:00
parent 2e119fd7e3
commit 51838a2ce1
7 changed files with 1254 additions and 164 deletions

View File

@@ -9,6 +9,7 @@ const WebSocket = require('ws');
const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.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');
@@ -66,6 +67,23 @@ async function waitForFile(filePath, timeoutMs = 10000) {
throw new Error(`Timed out waiting for file: ${filePath}`);
}
async function waitForJsonCondition(filePath, predicate, timeoutMs = 5000) {
const started = Date.now();
let lastError = null;
while (Date.now() - started < timeoutMs) {
try {
if (fs.existsSync(filePath)) {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (predicate(parsed)) return parsed;
}
} catch (err) {
lastError = err;
}
await sleep(50);
}
throw new Error(`Timed out waiting for JSON condition: ${filePath}${lastError ? ` (${lastError.message})` : ''}`);
}
async function withServer(env, fn) {
const child = spawn('/usr/bin/node', [SERVER_PATH], {
cwd: REPO_DIR,
@@ -417,7 +435,64 @@ function createFakeCodexConfig(homeDir, { model = 'gpt-5.5', reasoningEffort = '
].join('\n'));
}
function assertFrontendGenerationControlsContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const controlsStart = source.indexOf('function updateGenerationControls()');
const controlsEnd = source.indexOf('\n function updateNoteModeUI()', controlsStart);
assert(controlsStart >= 0 && controlsEnd > controlsStart, 'Frontend should define updateGenerationControls before updateNoteModeUI');
for (const target of ['sendBtn.hidden', 'abortBtn.hidden']) {
const regex = new RegExp(`${target.replace('.', '\\.')}\\s*=`, 'g');
let match;
while ((match = regex.exec(source))) {
assert(
match.index > controlsStart && match.index < controlsEnd,
`${target} should only be assigned in updateGenerationControls`
);
}
}
const resumeStart = source.indexOf("case 'resume_generating':");
const resumeEnd = source.indexOf("case 'error':", resumeStart);
assert(resumeStart >= 0 && resumeEnd > resumeStart, 'Frontend should keep an explicit resume_generating handler');
const resumeBlock = source.slice(resumeStart, resumeEnd);
assert(
resumeBlock.includes('updateGenerationControls();'),
'resume_generating should refresh send/abort controls when reusing an existing streaming bubble'
);
const controlsBlock = source.slice(controlsStart, controlsEnd);
assert(
/allowRuntimeInsert\s*=\s*isGenerating\s*&&\s*isCodexAppAgent\(currentAgent\)/.test(controlsBlock),
'Codex App should keep the runtime insert send button visible while generating'
);
}
function assertFrontendComposerMcpContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const requestStart = source.indexOf('function requestComposerSuggestions()');
const requestEnd = source.indexOf('\n function handleComposerSuggestions', requestStart);
assert(requestStart >= 0 && requestEnd > requestStart, 'Frontend should define requestComposerSuggestions before handleComposerSuggestions');
const requestBlock = source.slice(requestStart, requestEnd);
const slashStart = requestBlock.indexOf("if (token.trigger === '/')");
const slashEnd = requestBlock.indexOf('clearTimeout(composerSuggestionTimer);', slashStart);
assert(slashStart >= 0 && slashEnd > slashStart, 'Slash composer branch should precede backend debounce');
const slashBlock = requestBlock.slice(slashStart, slashEnd);
assert(slashBlock.includes('getLocalSlashSuggestions(token.query)'), 'Slash composer should keep local fallback suggestions');
assert(!/\breturn\s*;/.test(slashBlock), 'Slash composer should continue to backend suggestions so MCP items can be merged');
const menuStart = source.indexOf('function showCmdMenu(token, items)');
const menuEnd = source.indexOf('\n function requestComposerSuggestions()', menuStart);
assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions');
const menuBlock = source.slice(menuStart, menuEnd);
assert(/item\.kind\s*===\s*'mcp'/.test(menuBlock) && menuBlock.includes("'MCP'"), 'Composer menu should render MCP item labels');
}
async function main() {
assertFrontendGenerationControlsContract();
assertFrontendComposerMcpContract();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
@@ -555,14 +630,26 @@ async function main() {
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-slash-mcp', trigger: '/', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp');
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools');
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-skill-mcp', trigger: '$', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const skillMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-mcp');
assert(skillMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer skill trigger suggestions should include ccweb MCP tools');
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-prompt-mcp', trigger: '@', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
const promptMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-mcp');
assert(promptMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer prompt trigger suggestions should include ccweb MCP tools');
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');
@@ -597,6 +684,69 @@ async function main() {
assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id');
assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary');
const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_create_conversation',
sourceSessionId: codexSession.sessionId,
args: { cwd: 'relative-project', title: 'Relative path should fail' },
});
assert(mcpRelativeCreate.status === 400 && mcpRelativeCreate.body?.code === 'create_conversation_cwd_relative', 'MCP create conversation should reject relative cwd');
const mcpCreate = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_create_conversation',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
agent: 'codex',
title: 'MCP Created Conversation',
initialMessage: 'mcp created initial prompt',
},
});
assert(mcpCreate.status === 200 && mcpCreate.body?.ok, `MCP create conversation should succeed: ${JSON.stringify(mcpCreate.body)}`);
assert(mcpCreate.body.cwd === codexInitCwd, 'MCP create conversation should inherit source cwd by default');
assert(mcpCreate.body.mode === 'plan', 'MCP create conversation should inherit source mode by default');
assert(mcpCreate.body.status === 'running', 'MCP create with initialMessage should start the new conversation');
assert(mcpCreate.body.messageId, 'MCP create with initialMessage should return the delivered message id');
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId);
const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8'));
assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title');
assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata');
assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message');
assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt');
const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply');
mkdirp(mcpReplyCreateCwd);
const mcpCreateReply = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_create_conversation',
sourceSessionId: codexSession.sessionId,
sourceHopCount: 0,
args: {
agent: 'codex',
cwd: mcpReplyCreateCwd,
title: 'MCP Reply Conversation',
initialMessage: 'mcp create request reply',
requestReply: true,
},
});
assert(mcpCreateReply.status === 200 && mcpCreateReply.body?.ok, `MCP create conversation with requestReply should succeed: ${JSON.stringify(mcpCreateReply.body)}`);
assert(mcpCreateReply.body.cwd === mcpReplyCreateCwd, 'MCP create conversation should use an explicit absolute cwd');
assert(mcpCreateReply.body.requestId && mcpCreateReply.body.replyStatus === 'waiting', 'MCP create requestReply should return a waiting request id');
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreateReply.body.conversationId);
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId &&
message.crossConversation?.processed === true
))
));
const storedMcpCreateReply = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreateReply.body.conversationId}.json`), 'utf8'));
const storedMcpCreateSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
assert(storedMcpCreateReply.messages.some((message) => message.crossConversation?.replyRequestId === mcpCreateReply.body.requestId), 'MCP create requestReply should persist waiting metadata on the new conversation');
assert(storedMcpCreateSource.messages.some((message) => (
message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId &&
message.crossConversation?.processed === true &&
message.crossConversation?.autoRun === false
)), 'MCP create requestReply should send a processed display-only reply back to source');
const crossTargetCwd = path.join(tempRoot, 'codex-mcp-cross-target');
mkdirp(crossTargetCwd);
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: crossTargetCwd, mode: 'yolo' }));
@@ -660,6 +810,7 @@ async function main() {
});
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');
assert(requestReply.body.replyDelivery === 'display_only' && requestReply.body.sourceAutoRun === false, 'MCP request reply should declare display-only delivery without source auto-run');
const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_message' &&
msg.sessionId === crossReplyTargetSession.sessionId &&
@@ -669,9 +820,12 @@ async function main() {
));
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
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
Array.isArray(session.messages) &&
session.messages.some((message) => (
message.crossConversation?.replyToRequestId === requestReply.body.requestId &&
message.crossConversation?.processed === true
))
));
const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8'));
@@ -682,14 +836,18 @@ async function main() {
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.role === 'assistant', 'Returned cross message should be persisted as display-only assistant content');
assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply');
assert(storedReplyMessage.crossConversation.processed === true, 'Returned cross message should persist a processed marker');
assert(storedReplyMessage.crossConversation.autoRun === false, 'Returned cross message should not auto-run the source session again');
assert(storedReplyMessage.ccwebDisplayOnly === true, 'Returned cross message should be marked display-only');
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) => (
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');
)), 'Returned cross message should not trigger the source session to run again');
const processLogAfterMcp = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
const mcpSpawnLine = processLogAfterMcp
@@ -884,15 +1042,43 @@ async function main() {
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const ccwebMcpChildRunning = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-a' &&
msg.child?.status === 'running'
);
assert(ccwebMcpChildRunning.toolUseId === 'tool-collab', 'ccweb MCP child update should reference the parent collab tool');
const codexAppCollabTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-collab');
assert(codexAppCollabTool.kind === 'collab_agent_tool_call', 'Codex App should surface collab agent tool calls');
assert(/child-thread-a/.test(codexAppCollabTool.result || ''), 'Codex App collab tool should include child thread ids');
assert(/child-thread-a/.test(codexAppCollabTool.result || ''), 'ccweb MCP collab tool should include child thread ids');
const ccwebMcpChildReturned = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-a' &&
msg.child?.status === 'returned' &&
/子代理最终消息/.test(msg.child?.candidateResult || '')
);
assert(/finalMessage/.test(ccwebMcpChildReturned.tool?.result || ''), 'ccweb MCP child final message should be merged into the parent tool result');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'ccweb_mcp_child_agent_close', sessionId: codexAppSession.sessionId, threadId: 'child-thread-a' }));
const ccwebMcpChildClosed = await nextMessage(messages, ws, (msg) =>
msg.type === 'ccweb_mcp_child_agent_update' &&
msg.sessionId === codexAppSession.sessionId &&
msg.child?.threadId === 'child-thread-a' &&
msg.child?.status === 'closed'
);
assert(/"status": "closed"/.test(ccwebMcpChildClosed.tool?.result || ''), 'ccweb MCP child close should update the parent collab tool state');
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const hasCollabTool = storedCodexApp.messages
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
.some((tool) => tool.kind === 'collab_agent_tool_call');
assert(hasCollabTool, 'Codex App collab tool should be persisted into session history');
assert(hasCollabTool, 'ccweb MCP collab tool should be persisted into session history');
const persistedClosedCollabTool = storedCodexApp.messages
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
.reverse()
.find((tool) => tool.id === 'tool-collab');
assert(/"status": "closed"/.test(persistedClosedCollabTool?.result || ''), 'ccweb MCP manual child close should persist closed state');
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 || ''));