feat: improve goal mode and Codex App resilience
This commit is contained in:
@@ -917,7 +917,7 @@ function startTurn(params) {
|
||||
message: 'Reconnecting... 1/5',
|
||||
},
|
||||
});
|
||||
thread.activeTurnId = null;
|
||||
emitCapacityError(thread, turnId);
|
||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,7 +671,7 @@ function assertFrontendSidebarCollapseContract() {
|
||||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||||
);
|
||||
assert(
|
||||
indexSource.includes('style.css?v=20260805-usage-loading-inline')
|
||||
indexSource.includes('style.css?v=20260818-goal-mode-label')
|
||||
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
|
||||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||||
);
|
||||
@@ -1143,7 +1143,7 @@ function assertPlanListProgressContract() {
|
||||
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
|
||||
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('style.css?v=20260818-goal-mode-label'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Plan progress frontend logic should use the dynamic cache-busted URL');
|
||||
}
|
||||
|
||||
@@ -1259,7 +1259,7 @@ function assertFrontendGildedThemeContract() {
|
||||
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
|
||||
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
|
||||
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
|
||||
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('style.css?v=20260818-goal-mode-label'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Theme bundle app script should use the dynamic cache-busted asset URL');
|
||||
}
|
||||
|
||||
@@ -1513,7 +1513,7 @@ function assertFrontendWastelandThemeContract() {
|
||||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||||
});
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('style.css?v=20260818-goal-mode-label'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Wasteland registration should share the dynamic cache-busted theme bundle URL');
|
||||
}
|
||||
|
||||
@@ -2446,6 +2446,71 @@ function assertCodexAppRuntimeSubAgentActivityContract() {
|
||||
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'name'), 'Runtime non-subAgentActivity reasoning tool_end should not gain name');
|
||||
}
|
||||
|
||||
function assertCodexAppTransientReconnectContract() {
|
||||
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
|
||||
const sent = [];
|
||||
const runtime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => sent.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
const entry = { ws: {}, toolCalls: [], fullText: '' };
|
||||
const sessionId = 'codexapp-transient-reconnect-session';
|
||||
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
const result = runtime.processCodexAppNotification(entry, {
|
||||
method: 'error',
|
||||
params: { message: `Reconnecting... ${attempt}/5` },
|
||||
}, sessionId);
|
||||
assert(result?.done === false, 'Codex App reconnect progress must not terminate the active turn');
|
||||
assert(!entry.lastError, 'Codex App reconnect progress must not be persisted as the final error');
|
||||
}
|
||||
|
||||
const finalResult = runtime.processCodexAppNotification(entry, {
|
||||
method: 'error',
|
||||
params: {
|
||||
error: {
|
||||
type: 'service_unavailable_error',
|
||||
code: 'server_is_overloaded',
|
||||
message: 'No available providers',
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
assert(finalResult?.done === true, 'Codex App service-unavailable error must terminate the failed turn');
|
||||
assert(/No available providers/.test(entry.lastError || ''), 'Codex App terminal error should remain available for retry classification');
|
||||
|
||||
const combinedEntry = { ws: {}, toolCalls: [], fullText: '' };
|
||||
const combinedResult = runtime.processCodexAppNotification(combinedEntry, {
|
||||
method: 'error',
|
||||
params: { message: 'Reconnecting... 5/5: unexpected status 503 Service Unavailable' },
|
||||
}, sessionId);
|
||||
assert(combinedResult?.done === true, 'Reconnect text combined with a terminal 503 must still terminate the failed turn');
|
||||
assert(/503 Service Unavailable/.test(combinedEntry.lastError || ''), 'Combined terminal errors must remain available for retry classification');
|
||||
assert(sent.filter((message) => message.type === 'system_message').length === 7, 'Reconnect progress and terminal errors should each be forwarded as system messages');
|
||||
}
|
||||
|
||||
function assertGoalModeTitleContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
|
||||
assert(frontendSource.includes("meta?.ccwebGoalCommand?.action === 'set'"), 'Goal messages should be identified from dedicated metadata');
|
||||
assert(frontendSource.includes("isGoalMessage ? ' goal-message' : ''"), 'Goal messages should receive a dedicated DOM class');
|
||||
assert(frontendSource.includes("goalLabel.textContent = '目标模式'"), 'Goal messages should render the visible mode label');
|
||||
assert(frontendSource.includes("div.dataset.goalMode = 'true'"), 'Goal messages should expose a stable goal-mode data attribute');
|
||||
assert(styleSource.includes('.msg.user.goal-message .msg-bubble'), 'Goal label styling should remain scoped to user Goal bubbles');
|
||||
assert(styleSource.includes('.goal-message-label'), 'Goal mode label should have a dedicated style');
|
||||
assert(styleSource.includes("html[data-theme='wasteland'] .msg.user.goal-message .goal-message-label"), 'Wasteland should provide a dedicated Goal label treatment');
|
||||
assert(indexSource.includes('style.css?v=20260818-goal-mode-label'), 'Goal label CSS should use the current cache-busted URL');
|
||||
assert(serverSource.includes('GOAL_DERIVED_TITLE_MAX_CHARS = 60'), 'Goal-derived titles should keep the 60-character limit');
|
||||
assert(serverSource.includes('function isDefaultConversationTitle(session)'), 'Goal title updates should use a default-title guard');
|
||||
assert(serverSource.includes('function applyDerivedGoalConversationTitle(session, objective)'), 'Goal title updates should stay in the /goal-specific server path');
|
||||
assert(serverSource.includes("type: 'session_renamed'"), 'Goal title updates should notify current viewers');
|
||||
assert(serverSource.includes('broadcastSessionList();'), 'Goal title updates should refresh the session list');
|
||||
assert(serverSource.includes('mcp_servers.${item.server}'), 'Codex App thread config should continue to inject runtime MCP servers');
|
||||
}
|
||||
|
||||
function assertFrontendPrimaryCodexAppUiContract() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||||
@@ -4902,7 +4967,7 @@ function assertAdvancedSessionSearchContract() {
|
||||
'Existing sidebar search clear control contract should remain unchanged');
|
||||
assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'),
|
||||
'Advanced search workspace should expose stable panel and result hooks');
|
||||
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline')
|
||||
assert(indexSource.includes('style.css?v=20260818-goal-mode-label')
|
||||
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
|
||||
'Advanced search CSS and frontend script should share the reviewed cache-bust');
|
||||
|
||||
@@ -5019,7 +5084,7 @@ function assertUsageStatisticsContract() {
|
||||
'Usage dashboard should be a chat-main-local workspace');
|
||||
assert(indexSource.includes('class="usage-dashboard-open"') && !indexSource.includes('class="settings-btn usage-dashboard-open"'),
|
||||
'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak');
|
||||
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline')
|
||||
assert(indexSource.includes('style.css?v=20260818-goal-mode-label')
|
||||
&& indexSource.includes('vendor/echarts.min.js?v=5.6.0')
|
||||
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
|
||||
'Usage dashboard CSS, local ECharts runtime, and frontend script should share the current asset contract');
|
||||
@@ -5878,6 +5943,16 @@ async function main() {
|
||||
console.log('Sub-agent card routing regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'codexapp-retry-runtime') {
|
||||
assertCodexAppTransientReconnectContract();
|
||||
console.log('Codex App retry runtime regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'goal-mode-title') {
|
||||
assertGoalModeTitleContract();
|
||||
console.log('Goal mode/title regression checks passed.');
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
||||
}
|
||||
|
||||
@@ -5897,6 +5972,8 @@ async function main() {
|
||||
assertPlanListProgressContract();
|
||||
assertFrontendSubagentCardMetadataContract();
|
||||
assertCodexAppRuntimeSubAgentActivityContract();
|
||||
assertCodexAppTransientReconnectContract();
|
||||
assertGoalModeTitleContract();
|
||||
assertFrontendPrimaryCodexAppUiContract();
|
||||
assertSetTitleMcpContract();
|
||||
assertSessionItemTooltipContract();
|
||||
@@ -6954,6 +7031,87 @@ async function main() {
|
||||
const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd);
|
||||
assert(codexAppSession.model === 'gpt-5.5(max)', 'Codex App new_session should preserve the max default Codex model');
|
||||
|
||||
const codexAppGoalTitleCwd = path.join(tempRoot, 'codexapp-goal-title');
|
||||
mkdirp(codexAppGoalTitleCwd);
|
||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppGoalTitleCwd, mode: 'yolo' }));
|
||||
const codexAppGoalTitleSession = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppGoalTitleCwd
|
||||
));
|
||||
assert(codexAppGoalTitleSession.title === 'New Chat', 'A new Goal title fixture should start with the default title');
|
||||
const goalTitleObjective = ' 修复 Goal 自动标题\n并保留 ccweb MCP 注册以及窄屏标签 ';
|
||||
const goalTitleExpected = goalTitleObjective.replace(/\s+/g, ' ').trim().slice(0, 60);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: `/goal${goalTitleObjective}`,
|
||||
sessionId: codexAppGoalTitleSession.sessionId,
|
||||
mode: 'yolo',
|
||||
agent: 'codexapp',
|
||||
requestId: 'regression-goal-title-request',
|
||||
clientMessageId: 'regression-goal-title-message',
|
||||
}));
|
||||
const goalTitleBubble = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_message' &&
|
||||
msg.sessionId === codexAppGoalTitleSession.sessionId &&
|
||||
msg.message?.id === 'regression-goal-title-message'
|
||||
));
|
||||
assert(goalTitleBubble.message.ccwebGoalCommand?.action === 'set', 'Goal title fixture should persist dedicated Goal metadata');
|
||||
const goalTitleRenamed = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_renamed' &&
|
||||
msg.sessionId === codexAppGoalTitleSession.sessionId &&
|
||||
msg.title === goalTitleExpected
|
||||
));
|
||||
assert(goalTitleRenamed.titleSource === 'derived', 'Goal default title should be marked as derived');
|
||||
const goalTitleList = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_list' &&
|
||||
msg.sessions.some((item) => (
|
||||
item.id === codexAppGoalTitleSession.sessionId &&
|
||||
item.title === goalTitleExpected &&
|
||||
item.titleSource === 'derived'
|
||||
))
|
||||
));
|
||||
assert(goalTitleList.sessions.some((item) => item.id === codexAppGoalTitleSession.sessionId && item.title === goalTitleExpected), 'Goal derived title should refresh the session list');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppGoalTitleSession.sessionId && /正在同步 Goal/.test(msg.message || ''));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppGoalTitleSession.sessionId && /Goal active/.test(msg.message || ''));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppGoalTitleSession.sessionId && /Goal background output/.test(msg.text || ''));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppGoalTitleSession.sessionId);
|
||||
const storedGoalTitleSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppGoalTitleSession.sessionId}.json`), 'utf8'));
|
||||
assert(storedGoalTitleSession.title === goalTitleExpected, 'Goal derived title should persist to the session file');
|
||||
assert(storedGoalTitleSession.titleSource === 'derived', 'Persisted Goal title should retain derived source');
|
||||
assert(!storedGoalTitleSession.titleHistory?.some((event) => event.source === 'derived'), 'Goal derived title should not fabricate an LLM title history event');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic goal mcp config probe', sessionId: codexAppGoalTitleSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
const goalMcpConfigProbe = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'tool_end' &&
|
||||
msg.sessionId === codexAppGoalTitleSession.sessionId &&
|
||||
msg.toolUseId === 'mcp-ccweb-list'
|
||||
));
|
||||
assert(/"hasCcwebMcpConfig": true/.test(goalMcpConfigProbe.result || ''), 'Goal thread should retain ccweb MCP configuration after thread/goal/set');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppGoalTitleSession.sessionId);
|
||||
|
||||
ws.send(JSON.stringify({ type: 'rename_session', sessionId: codexAppGoalTitleSession.sessionId, title: '手动 Goal 标题' }));
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_renamed' &&
|
||||
msg.sessionId === codexAppGoalTitleSession.sessionId &&
|
||||
msg.title === '手动 Goal 标题'
|
||||
));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: '/goal second objective must not overwrite manual title',
|
||||
sessionId: codexAppGoalTitleSession.sessionId,
|
||||
mode: 'yolo',
|
||||
agent: 'codexapp',
|
||||
requestId: 'regression-goal-manual-request',
|
||||
clientMessageId: 'regression-goal-manual-message',
|
||||
}));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_message' && msg.message?.id === 'regression-goal-manual-message');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppGoalTitleSession.sessionId && /正在同步 Goal/.test(msg.message || ''));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppGoalTitleSession.sessionId && /Goal active/.test(msg.message || ''));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppGoalTitleSession.sessionId && /Goal background output/.test(msg.text || ''));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppGoalTitleSession.sessionId);
|
||||
const storedManualGoalTitleSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppGoalTitleSession.sessionId}.json`), 'utf8'));
|
||||
assert(storedManualGoalTitleSession.title === '手动 Goal 标题', 'Goal must not overwrite a manually renamed title');
|
||||
assert(storedManualGoalTitleSession.titleSource === 'manual', 'Manual Goal title protection should retain manual source');
|
||||
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user