修复 Codex App MCP 跨线程假就绪
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user