feat: complete Codex App capabilities and rebuild release

This commit is contained in:
shiyue
2026-08-20 13:37:23 +08:00
parent d7dc32e924
commit 19c1601351
8 changed files with 756 additions and 54 deletions

View File

@@ -102,6 +102,7 @@ function ensureThread(threadId, params = {}) {
capacityRetryAttempts: new Map(),
reconnectRetryAttempts: new Map(),
goal: null,
threadStartWebSearchMode: params.config?.web_search || null,
});
}
const thread = threads.get(id);
@@ -537,9 +538,12 @@ function completeTurnWithoutTerminalNotification(thread, turnId, text) {
thread.steers = [];
}
function completeGoalBackgroundTurn(thread, objective) {
function completeGoalBackgroundTurn(thread, objective, turnNumber = 1, totalTurns = 1) {
const turnId = `goal-turn-${crypto.randomUUID()}`;
const text = `Goal background output: ${objective}`;
const itemId = `goal-agent-msg-${turnNumber}`;
const text = turnNumber === 1
? `Goal background output: ${objective}`
: `Goal continuation output ${turnNumber}: ${objective}`;
thread.activeTurnId = turnId;
send({
method: 'turn/started',
@@ -553,7 +557,7 @@ function completeGoalBackgroundTurn(thread, objective) {
params: {
threadId: thread.id,
turnId,
itemId: 'goal-agent-msg',
itemId,
delta: text,
},
});
@@ -564,13 +568,24 @@ function completeGoalBackgroundTurn(thread, objective) {
turnId,
completedAtMs: Date.now(),
item: {
id: 'goal-agent-msg',
id: itemId,
type: 'agentMessage',
text,
status: 'completed',
},
},
});
if (turnNumber >= totalTurns && thread.goal) {
thread.goal = {
...thread.goal,
status: 'complete',
updatedAt: Date.now(),
};
send({
method: 'thread/goal/updated',
params: { threadId: thread.id, goal: thread.goal },
});
}
send({
method: 'thread/tokenUsage/updated',
params: {
@@ -591,6 +606,9 @@ function completeGoalBackgroundTurn(thread, objective) {
},
});
thread.activeTurnId = null;
if (turnNumber < totalTurns) {
setTimeout(() => completeGoalBackgroundTurn(thread, objective, turnNumber + 1, totalTurns), 120);
}
}
function requestClient(method, params, callback) {
@@ -667,6 +685,9 @@ function completeMcpToolTurn(thread, turnId) {
ok: true,
currentConversationId: env.CC_WEB_SOURCE_SESSION_ID || urlSourceSessionId,
sourceHopCount: env.CC_WEB_CROSS_HOP_COUNT || urlSourceHopCount,
threadStartWebSearchMode: thread.threadStartWebSearchMode || null,
threadConfigMethod: thread.lastThreadConfigMethod || null,
webSearchMode: thread.config?.web_search || null,
hasCcwebMcpConfig: Boolean(ccwebConfig),
hasProjectMcpConfig: Boolean(projectConfig),
ccwebType: ccwebConfig?.type || (ccwebConfig?.url ? 'streamable_http' : (ccwebConfig?.command ? 'stdio' : null)),
@@ -1092,19 +1113,42 @@ function handleRequest(message) {
}
if (method === 'thread/start') {
const thread = ensureThread(null, params);
thread.lastThreadConfigMethod = 'thread/start';
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);
thread.lastThreadConfigMethod = 'thread/start';
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;
}
const thread = ensureThread(params.threadId, params);
thread.lastThreadConfigMethod = 'thread/resume';
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/compact/start') {
const thread = ensureThread(params.threadId, params);
const compactTurnId = `app-compact-${crypto.randomUUID()}`;
thread.lastCompactionTurnId = compactTurnId;
send({
method: 'thread/compacted',
params: {
threadId: thread.id,
turnId: compactTurnId,
},
});
send({
id,
result: {
threadId: thread.id,
turnId: compactTurnId,
},
});
return;
}
if (method === 'thread/goal/get') {
const thread = ensureThread(params.threadId, params);
send({ id, result: { goal: thread.goal } });
@@ -1130,10 +1174,11 @@ function handleRequest(message) {
params: { threadId: thread.id, goal: thread.goal },
});
setTimeout(() => {
send({ id, result: { goal: thread.goal } });
if (params.objective) {
setTimeout(() => completeGoalBackgroundTurn(thread, objective), 50);
}
send({ id, result: { goal: thread.goal } });
if (params.objective) {
const totalTurns = /improve benchmark coverage/i.test(objective) ? 2 : 1;
setTimeout(() => completeGoalBackgroundTurn(thread, objective, 1, totalTurns), 50);
}
}, 250);
return;
}

View File

@@ -2444,6 +2444,47 @@ function assertCodexAppRuntimeSubAgentActivityContract() {
assert(reasoningEnd, 'Runtime reasoning item/completed should still emit tool_end');
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'input'), 'Runtime non-subAgentActivity reasoning tool_end should not gain input');
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'name'), 'Runtime non-subAgentActivity reasoning tool_end should not gain name');
const webSearchSent = [];
const webSearchRuntime = createCodexAppRuntime({
wsSend: (_ws, payload) => webSearchSent.push(payload),
loadSession: () => null,
saveSession: () => {},
});
const webSearchEntry = { ws: {}, toolCalls: [], fullText: '' };
webSearchRuntime.processCodexAppNotification(webSearchEntry, {
method: 'item/started',
params: {
item: {
id: 'runtime-web-search',
type: 'webSearch',
query: 'cc-web Web Search regression',
},
},
}, sessionId);
const webSearchStart = webSearchSent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-web-search');
assert(webSearchStart, 'Runtime webSearch item/started should emit tool_start');
assert(webSearchStart.name === 'WebSearch', 'Runtime webSearch tool_start should map to WebSearch');
assert(webSearchStart.kind === 'web_search', 'Runtime webSearch tool_start should map to web_search kind');
assert(webSearchStart.input?.type === 'webSearch', 'Runtime webSearch input should preserve original item type');
assert(webSearchStart.input?.query === 'cc-web Web Search regression', 'Runtime webSearch input should preserve query');
webSearchRuntime.processCodexAppNotification(webSearchEntry, {
method: 'item/completed',
params: {
item: {
id: 'runtime-web-search',
type: 'webSearch',
query: 'cc-web Web Search regression',
results: [{ title: 'Result', url: 'https://example.com/result' }],
},
},
}, sessionId);
const webSearchEnd = webSearchSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-web-search');
assert(webSearchEnd, 'Runtime webSearch item/completed should emit tool_end');
assert(webSearchEnd.kind === 'web_search', 'Runtime webSearch tool_end should keep web_search kind');
assert(webSearchEntry.toolCalls[0]?.name === 'WebSearch', 'Runtime persisted webSearch tool should use WebSearch name');
assert(webSearchEntry.toolCalls[0]?.kind === 'web_search', 'Runtime persisted webSearch tool should use web_search kind');
}
function assertCodexAppTransientReconnectContract() {
@@ -2531,6 +2572,10 @@ function assertFrontendPrimaryCodexAppUiContract() {
assert(source.includes("localStorage.setItem('cc-web-agent', currentAgent);"), 'Frontend should overwrite stale cc-web-agent storage with the primary UI agent');
assert(source.includes('currentAgent = normalizeUiAgent(agent);'), 'setCurrentAgent should coerce ordinary UI agent changes back to Codex App');
assert(source.includes('return sessions.filter((s) => isPrimaryUiAgent(s.agent));'), 'Session list should only expose Codex App sessions in ordinary UI');
assert(source.includes('id="codex-enable-search"'), 'Codex settings should expose the Web Search toggle');
assert(source.includes('仅作用于当前 Codex App 会话的原生联网搜索'), 'Codex Web Search setting copy should scope the toggle to Codex App native search');
assert(source.includes('codexSearchToggle.checked = !!currentCodexConfig.enableSearch;'), 'Codex Web Search toggle should load the persisted setting');
assert(source.includes('enableSearch: !!codexSearchToggle.checked'), 'Codex Web Search save should send the real toggle state');
assert(
/function applySessionSnapshot\(snapshot[\s\S]*?!isPrimaryUiAgent\(snapshotAgent\)[\s\S]*?return false;[\s\S]*?return true;/.test(source),
'Frontend should reject legacy Claude/Codex snapshots from the current view'
@@ -4001,6 +4046,76 @@ function assertCodexAppStaleRunningRecoveryContract() {
);
}
function assertCodexAppGoalLifecycleContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const doneStart = frontendSource.indexOf("case 'done':");
const doneEnd = frontendSource.indexOf("case 'system_message':", doneStart);
const doneBlock = doneStart >= 0 && doneEnd > doneStart
? frontendSource.slice(doneStart, doneEnd)
: '';
const finishBlock = extractFunctionSource(frontendSource, 'finishGenerating');
const controlsBlock = extractFunctionSource(frontendSource, 'updateGenerationControls');
const sendMessageBlock = extractFunctionSource(frontendSource, 'sendMessage');
const runningBlock = extractFunctionSource(serverSource, 'isSessionRunning');
const notificationBlock = extractFunctionSource(serverSource, 'handleCodexAppGoalNotification');
const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete');
const pauseBlock = extractFunctionSource(serverSource, 'pauseCodexAppGoalForAbort');
const abortBlock = extractFunctionSource(serverSource, 'handleAbort');
const messageBlock = extractFunctionSource(serverSource, 'handleMessage');
const deleteBlock = extractFunctionSource(serverSource, 'handleDeleteSession');
assert(
doneBlock.includes('snapshot.isRunning = msg.goalActive === true')
&& doneBlock.includes('finishGenerating(msg.sessionId, { keepRunning: msg.goalActive === true })'),
'Frontend done handling should keep current and background Goal sessions running between turns'
);
assert(
finishBlock.includes('options = {}')
&& finishBlock.includes('const keepRunning = options.keepRunning === true')
&& finishBlock.includes('setCurrentSessionRunningState(keepRunning)'),
'Frontend finishGenerating should close one bubble without ending an active Goal'
);
assert(
controlsBlock.includes('const runtimeBusy = isGenerating || currentSessionRunning')
&& controlsBlock.includes('abortBtn.hidden = !runtimeBusy'),
'Frontend should keep Stop available while an active Goal is between turns'
);
assert(
sendMessageBlock.includes('if (currentSessionRunning && !isGenerating) return;'),
'Frontend should not submit an ordinary message into an active Goal turn gap'
);
assert(
runningBlock.includes('isCodexAppGoalActive(sessionId)')
&& notificationBlock.includes("method !== 'thread/goal/updated'")
&& notificationBlock.includes("method !== 'thread/goal/cleared'")
&& notificationBlock.includes('updateCodexAppGoalState(matched.session, goal)'),
'Server should maintain thread-level Goal state independently from active turns'
);
assert(
completeBlock.includes('const goalActive = isCodexAppGoalActive(sessionId)')
&& completeBlock.includes("{ type: 'done', sessionId, costUsd: null, goalActive }")
&& completeBlock.includes('if (goalActive)')
&& completeBlock.includes('broadcastSessionList()'),
'Server turn completion should report active Goal state without sending final background completion'
);
assert(
pauseBlock.includes("status: 'paused'")
&& pauseBlock.includes("client.request('thread/goal/set'")
&& abortBlock.includes('pauseCodexAppGoalForAbort(sessionId, ws)')
&& abortBlock.includes('handleCodexAppAbortSession(sessionId, ws)'),
'Stop should pause the Goal and interrupt any currently active turn'
);
assert(
messageBlock.includes('!codexAppGoalStates.has(sessionId)')
&& messageBlock.includes('loadSession(sessionId)')
&& messageBlock.includes('isCodexAppGoalActive(sessionId)')
&& messageBlock.includes('Goal 仍在持续运行')
&& deleteBlock.includes('codexAppGoalStates.delete(sessionId)'),
'Server should block ordinary Goal-gap turns and clean Goal state on deletion'
);
}
function assertRuntimeImageSendStaticContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
@@ -5954,6 +6069,11 @@ async function main() {
console.log('Codex App stale running regression checks passed.');
return;
}
if (regressionTarget === 'codexapp-goal-lifecycle') {
assertCodexAppGoalLifecycleContract();
console.log('Codex App Goal lifecycle regression checks passed.');
return;
}
if (regressionTarget === 'session-switch-race') {
assertSessionSwitchRaceContract();
console.log('Session switch race regression checks passed.');
@@ -6064,6 +6184,7 @@ async function main() {
assertCcwebMcpChildUpdateCoalescingContract();
assertTitleHistoryOutlineContract();
assertSessionSwitchResilienceContract();
assertCodexAppGoalLifecycleContract();
assertSessionSwitchRaceContract();
assertAdvancedSessionSearchContract();
await assertAdvancedSearchTimeOrderingContract();
@@ -6260,8 +6381,8 @@ async function main() {
assert(codexConfigMsg.config.mode === 'custom', 'Codex config mode save/load failed');
assert(codexConfigMsg.config.activeProfile === 'Regression Profile', 'Codex active profile save/load failed');
assert(Array.isArray(codexConfigMsg.config.profiles) && codexConfigMsg.config.profiles[0]?.apiKey.includes('****'), 'Codex profile API key should be masked');
assert(codexConfigMsg.config.supportsSearch === false, 'Codex config should expose unsupported search capability');
assert(codexConfigMsg.config.enableSearch === false, 'Codex config should ignore unsupported search toggle');
assert(codexConfigMsg.config.supportsSearch === true, 'Codex config should expose Codex App native search capability');
assert(codexConfigMsg.config.enableSearch === true, 'Codex config should persist enabled Web Search');
assert(codexConfigMsg.config.retry?.mode === 'limited', 'Codex retry mode should round-trip');
assert(codexConfigMsg.config.retry?.intervalSeconds === 1, 'Codex retry interval should round-trip');
assert(codexConfigMsg.config.retry?.maxAttempts === 2, 'Codex retry max attempts should round-trip');
@@ -7215,6 +7336,13 @@ async function main() {
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic web search enabled prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppEnabledSearchDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
assert(/"threadStartWebSearchMode": "live"/.test(codexAppEnabledSearchDynamicTool.result || ''), 'Codex App thread/start should pass web_search=live when Web Search is enabled');
assert(/"webSearchMode": "live"/.test(codexAppEnabledSearchDynamicTool.result || ''), 'Codex App thread config should keep web_search=live while Web Search is enabled');
assert(!/"webSearchToolConfig"/.test(codexAppEnabledSearchDynamicTool.result || ''), 'Codex App should use top-level web_search instead of tools.web_search as the mode switch');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({
type: 'save_codex_config',
config: {
@@ -7229,6 +7357,7 @@ async function main() {
msg.type === 'codex_config' && msg.config?.activeProfile === 'Regression Profile Updated'
);
assert(codexAppChangedConfig.config.mode === 'custom', 'Codex App config-change regression should save custom mode');
assert(codexAppChangedConfig.config.enableSearch === false, 'Codex App config-change regression should persist disabled Web Search');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp after config change prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppAfterConfigChange = await nextMessage(messages, ws, (msg) => (
@@ -7239,6 +7368,13 @@ async function main() {
assert(/codexapp after config change prompt/.test(codexAppAfterConfigChange.text || ''), 'Codex App should not reject a new turn after config signature changes');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic after web search disabled prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppDisabledSearchDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
assert(/"threadConfigMethod": "thread\/resume"/.test(codexAppDisabledSearchDynamicTool.result || ''), 'Codex App existing thread should refresh config through thread/resume');
assert(/"webSearchMode": "disabled"/.test(codexAppDisabledSearchDynamicTool.result || ''), 'Codex App thread/resume should pass web_search=disabled after Web Search is disabled');
assert(!/"webSearchToolConfig"/.test(codexAppDisabledSearchDynamicTool.result || ''), 'Codex App should not use tools.web_search as the Web Search mode switch');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const codexAppRetryText = 'codexapp capacity retry prompt';
ws.send(JSON.stringify({ type: 'message', text: codexAppRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppCapacityRetryNotice = await nextMessage(messages, ws, (msg) => (
@@ -7346,13 +7482,45 @@ async function main() {
/Goal background output: improve benchmark coverage/.test(msg.text || '')
), 5000);
assert(/Goal background output/.test(codexAppGoalBackgroundDelta.text || ''), 'Codex App /goal background turn should stream through the active session');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 5000);
const codexAppGoalFirstTurnDone = await nextMessage(messages, ws, (msg) => (
msg.type === 'done' &&
msg.sessionId === codexAppSession.sessionId &&
msg.goalActive === true
), 5000);
assert(codexAppGoalFirstTurnDone.goalActive === true, 'Goal first turn completion should remain explicitly active');
const codexAppGoalStillRunningList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
Array.isArray(msg.sessions) &&
msg.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning)
), 5000);
assert(codexAppGoalStillRunningList.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Goal should stay running between continuation turns');
const codexAppGoalContinuationDelta = await nextMessage(messages, ws, (msg) => (
msg.type === 'text_delta' &&
msg.sessionId === codexAppSession.sessionId &&
/Goal continuation output 2: improve benchmark coverage/.test(msg.text || '')
), 5000);
assert(/Goal continuation output 2/.test(codexAppGoalContinuationDelta.text || ''), 'Goal continuation turn should stream through the same session');
const codexAppGoalFinalDone = await nextMessage(messages, ws, (msg) => (
msg.type === 'done' &&
msg.sessionId === codexAppSession.sessionId &&
msg.goalActive !== true
), 5000);
assert(codexAppGoalFinalDone.goalActive !== true, 'Goal terminal turn should emit a final done event');
const codexAppGoalIdleList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
Array.isArray(msg.sessions) &&
msg.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning)
), 5000);
assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App /goal RPC should clear running state after app-server responds');
assert(codexAppGoalIdleList.sessions.some((session) => session.id === codexAppSession.sessionId && !session.isRunning), 'Codex App Goal should become idle only after the Goal reaches a terminal status');
const storedCodexAppGoalSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(
storedCodexAppGoalSession.messages.some((message) => message.role === 'assistant' && /Goal background output: improve benchmark coverage/.test(String(message.content || ''))),
'Goal first turn assistant output should be persisted'
);
assert(
storedCodexAppGoalSession.messages.some((message) => message.role === 'assistant' && /Goal continuation output 2: improve benchmark coverage/.test(String(message.content || ''))),
'Goal continuation assistant output should be persisted separately'
);
ws.send(JSON.stringify({
type: 'message',
text: '/goal improve benchmark coverage',
@@ -7372,7 +7540,7 @@ async function main() {
assert(!messages.some((msg) => msg.type === 'session_message' && msg.message?.id === codexAppGoalMessageId), 'Duplicate Goal command ids should not append another user bubble');
assert(!messages.some((msg) => msg.type === 'text_delta' && /Goal background output: improve benchmark coverage/.test(msg.text || '')), 'Duplicate Goal command ids should not trigger another Goal RPC background turn');
ws.send(JSON.stringify({ type: 'message', text: '/goal', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
const codexAppGoalShow = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal complete/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || ''));
assert(/improve benchmark coverage/.test(codexAppGoalShow.message || ''), 'Codex App /goal should show the current goal');
ws.send(JSON.stringify({ type: 'message', text: '/goal pause', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppGoalPause = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal paused/.test(msg.message || ''));
@@ -7432,6 +7600,28 @@ async function main() {
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted');
assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted');
ws.send(JSON.stringify({ type: 'message', text: '/compact', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppCompactStart = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/正在执行 Codex App 原生 \/compact/.test(msg.message || '')
), 5000);
assert(/Codex App 原生 \/compact/.test(codexAppCompactStart.message || ''), 'Codex App /compact should announce native compaction');
const codexAppCompactRunning = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
msg.sessions?.some((session) => session.id === codexAppSession.sessionId && session.isRunning)
), 5000);
assert(codexAppCompactRunning.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Codex App /compact should mark the session running');
const codexAppCompactDone = await nextMessage(messages, ws, (msg) => (
msg.type === 'system_message' &&
msg.sessionId === codexAppSession.sessionId &&
/已执行 Codex App 原生 \/compact/.test(msg.message || '')
), 10000);
assert(/上下文压缩完成/.test(codexAppCompactDone.message || ''), 'Codex App /compact should complete through app-server');
const storedCodexAppAfterCompact = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(storedCodexAppAfterCompact.codexAppThreadId === codexAppThreadId, 'Codex App /compact should keep the same thread');
assert(!storedCodexAppAfterCompact.messages.some((message) => message.role === 'user' && message.content === '/compact'), 'Codex App /compact should not persist the slash command as a user message');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp huge output prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppHugeTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'huge-tool');
assert((codexAppHugeTool.result || '').length <= 33000, 'Codex App huge tool result should be capped before sending to the browser');