feat: improve goal mode and Codex App resilience

This commit is contained in:
shiyue
2026-08-18 21:27:58 +08:00
parent 389690af64
commit 8fe31e0a5e
27 changed files with 631 additions and 24 deletions

View File

@@ -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');