修复 Codex App MCP 跨线程假就绪

This commit is contained in:
shiyue
2026-09-14 17:42:05 +08:00
parent e785e3c5dd
commit b1cd820819
4 changed files with 385 additions and 55 deletions

View File

@@ -1155,6 +1155,15 @@ function handleRequest(message) {
threadId: 'mock-reload-unrelated-thread',
},
});
// 全局通知可能不带线程;服务端只有在单个重载窗口内才允许安全归属。
send({
method: 'mcpServer/startupStatus/updated',
params: {
name: 'ccweb',
status: 'ready',
message: 'ccweb MCP ready without thread id CC_WEB_MCP_TOKEN=mock-secret-token',
},
});
}
send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } });
return;
@@ -1165,6 +1174,20 @@ function handleRequest(message) {
id,
result: {
data: [
{
name: 'ccweb',
authStatus: 'unsupported',
resources: [],
resourceTemplates: [],
serverInfo: { name: 'ccweb', version: '1.0.0' },
tools: {
ccweb_list_conversations: {
name: 'ccweb_list_conversations',
description: 'Regression ccweb MCP tool.',
inputSchema: { type: 'object' },
},
},
},
{
name: 'reg-app-project',
authStatus: 'unsupported',
@@ -1206,6 +1229,16 @@ function handleRequest(message) {
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/fork') {
const source = ensureThread(params.threadId, params);
const thread = ensureThread(null, params);
thread.forkedFromId = source.id;
thread.lastForkTurnId = params.lastTurnId || params.beforeTurnId || null;
thread.lastThreadConfigMethod = 'thread/fork';
thread.lastForkConfig = params.config || {};
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') {
if (params.threadId && resumeMismatchThreads.delete(params.threadId)) {
const thread = ensureThread(null, params);

View File

@@ -296,6 +296,19 @@ async function postAuthedJson(port, token, pathname, body = {}) {
return payload;
}
async function postAuthedJsonAllowFailure(port, token, pathname, body = {}) {
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const payload = await response.json();
return { status: response.status, payload };
}
async function callInternalMcp(port, token, payload) {
const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
method: 'POST',
@@ -1617,16 +1630,21 @@ function assertCcwebMcpRecoveryContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8');
const mockSource = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8');
const startTurnSource = extractFunctionSource(serverSource, 'startCodexAppTurn');
assert(
serverSource.includes('CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC')
&& serverSource.includes('CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,')
&& serverSource.includes('CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS')
&& serverSource.includes('CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS,')
&& serverSource.includes('allowThreadMismatch: true'),
'ccweb MCP recovery should expose configurable startup/reload windows and tolerate global reload thread IDs'
&& serverSource.includes('queryCodexAppMcpInventory')
&& serverSource.includes('inventory_thread_mismatch')
&& startTurnSource.includes('ensureCodexAppMcpReadyForThread')
&& !serverSource.includes('allowThreadMismatch: true'),
'ccweb MCP recovery should expose configurable windows, strict thread routing, and inventory verification'
);
assert(
serverSource.includes('codex_app_mcp_reload_timeout')
&& serverSource.includes('codex_app_mcp_reload_inventory_failed')
&& serverSource.includes("creationStatus: 'created_but_initial_message_failed'")
&& serverSource.includes('mcpStatus: buildCodexAppMcpStatusSummary'),
'ccweb MCP recovery should persist timeout failure and expose creation/MCP status'
@@ -1636,7 +1654,13 @@ function assertCcwebMcpRecoveryContract() {
&& runtimeSource.includes('mcp_servers.ccweb.startup_timeout_sec=${CCWEB_MCP_STARTUP_TIMEOUT_SEC}'),
'Legacy Codex runtime should use the same configurable ccweb MCP startup timeout'
);
assert(mockSource.includes('mock-reload-unrelated-thread'), 'MCP reload regression fixture should cover unrelated notification thread ids');
assert(
mockSource.includes('mock-reload-unrelated-thread')
&& mockSource.includes('without thread id')
&& mockSource.includes("name: 'ccweb'")
&& mockSource.includes('ccweb_list_conversations'),
'MCP reload regression fixture should cover unrelated notifications and current-thread tool inventory'
);
}
function assertFrontendSubagentCardMetadataContract() {
@@ -2603,6 +2627,23 @@ function assertCodexAppTransientReconnectContract() {
assert(sent.filter((message) => message.type === 'system_message').length === 7, 'Reconnect progress and terminal errors should each be forwarded as system messages');
}
function assertCodexAppBranchForkContract() {
const source = fs.readFileSync(SERVER_PATH, 'utf8');
const branchSource = extractFunctionSource(source, 'resolveBranchSource');
const turnSource = extractFunctionSource(source, 'startCodexAppTurn');
assert(branchSource.includes('sourceThreadId'), 'Codex App branch should persist the source thread id');
assert(branchSource.includes('sourceTurnId'), 'Codex App branch should persist the selected source turn id');
assert(branchSource.includes("selectedMessage?.role !== 'assistant'"), 'Codex App branch should require the selected assistant message');
assert(branchSource.includes("selectedMessage?.codexAppTurnId"), 'Codex App branch should use the selected assistant turn id only');
assert(!branchSource.includes('slice(0, sourceMessageIndex + 1)].reverse'), 'Codex App branch should not guess an earlier turn id');
assert(turnSource.includes("client.request('thread/fork'"), 'Codex App branch should call native thread/fork');
assert(turnSource.includes('lastTurnId: sourceTurnId'), 'Codex App fork should stop at the selected source turn');
assert(turnSource.includes('缺少来源线程或回合信息'), 'Codex App branch should refuse to start an empty thread when fork metadata is missing');
const goalSource = extractFunctionSource(source, 'ensureCodexAppGoalThread');
assert(goalSource.includes("client.request('thread/fork'"), 'Codex App goal should call native thread/fork for branches');
assert(goalSource.includes('lastTurnId: sourceTurnId'), 'Codex App goal fork should stop at the selected source turn');
}
function assertGoalModeTitleContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
@@ -7000,6 +7041,11 @@ async function main() {
console.log('Codex App retry runtime regression checks passed.');
return;
}
if (regressionTarget === 'codexapp-branch-fork') {
assertCodexAppBranchForkContract();
console.log('Codex App branch fork regression checks passed.');
return;
}
if (regressionTarget === 'goal-mode-title') {
assertGoalModeTitleContract();
console.log('Goal mode/title regression checks passed.');
@@ -7026,6 +7072,7 @@ async function main() {
assertFrontendSubagentCardMetadataContract();
assertCodexAppRuntimeSubAgentActivityContract();
assertCodexAppTransientReconnectContract();
assertCodexAppBranchForkContract();
assertGoalModeTitleContract();
assertFrontendPrimaryCodexAppUiContract();
assertSetTitleMcpContract();
@@ -8594,7 +8641,10 @@ async function main() {
ws.send(JSON.stringify({ type: 'message', text: 'codexapp task schema refresh', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const reloadAfterTracking = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
const reloadAfterTrackingResponse = await postAuthedJsonAllowFailure(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
const reloadAfterTracking = reloadAfterTrackingResponse.payload;
assert(reloadAfterTrackingResponse.status === 503, 'MCP reload without a final startup notification should return HTTP 503');
assert(reloadAfterTracking.ok === false, 'MCP reload without a final startup notification should report ok=false');
assert(
Number(reloadAfterTracking.result?.reloadCount || 0) === baselineMcpReloadCount + 1,
'Task tracking changes must not trigger an implicit MCP reload; only the explicit reload request should count'