fix(codex,web): preserve late command result identity and timing (#2239)

This commit is contained in:
Ananovo
2026-10-29 22:12:27 +08:00
committed by GitHub
parent 9384ce031b
commit f29be07bb9
18 changed files with 405 additions and 8 deletions
+18
View File
@@ -5,6 +5,24 @@ import { codexPlanProposalId } from './plan';
import { extractNotifySummary } from '@hapi/protocol/messages';
describe('shared history projection', () => {
it.each([undefined, 'root'])('projects command outcomes for live output and replay (parent: %s)', async parentThreadId => {
const send = vi.fn();
const session = { getMetadata: () => ({}), sendAgentMessage: send } as unknown as ApiSessionClient;
const projection = new SharedCodexProjection(session, 'thread', async () => {}, parentThreadId);
const items = [
{ id: 'failed', type: 'commandExecution', command: 'sleep 100', status: 'failed', exitCode: -1, durationMs: 2000 },
{ id: 'cancelled', type: 'commandExecution', command: 'sleep 100', status: 'cancelled' },
{ id: 'exit', type: 'commandExecution', command: 'exit 2', status: 'completed', exitCode: 2 },
{ id: 'ok', type: 'commandExecution', command: 'echo ok', status: 'completed', exitCode: 0 }
];
for (const item of items) await projection.notification('item/completed', { threadId: 'thread', turnId: 'turn', item });
const results = () => send.mock.calls.map(([body]) => parentThreadId ? body.message : body).filter(body => body.type === 'tool-call-result');
expect(results().map(body => body.is_error)).toEqual([true, true, true, false]);
expect(results()[0].output).toMatchObject({ command: 'sleep 100', exit_code: -1, duration_ms: 2000 });
projection.reset(); send.mockClear();
await projection.history({ turns: [{ id: 'turn', status: 'completed', items }] });
expect(results().map(body => body.is_error)).toEqual([true, true, true, false]);
});
it.each([undefined, 'root'])('publishes only final summaries in live output and history replay (parent: %s)', async parentThreadId => {
const send = vi.fn();
const session = { getMetadata: () => ({}), sendAgentMessage: send } as unknown as ApiSessionClient;
+4 -1
View File
@@ -145,7 +145,10 @@ export class SharedCodexProjection {
else if (event.type === 'exec_command_begin' && callId) {
send({ type: 'tool-call', name: 'CodexBash', callId, input: event }, key);
} else if (event.type === 'exec_command_end' && callId) {
send({ type: 'tool-call-result', callId, output: { ...event, stdout: event.output } }, key);
const failed = ['failed', 'cancelled', 'canceled', 'declined', 'interrupted'].includes(String(event.status))
|| (typeof event.exit_code === 'number' && event.exit_code !== 0)
|| Boolean(event.error);
send({ type: 'tool-call-result', callId, output: { ...event, stdout: event.output }, is_error: failed }, key);
} else if (event.type === 'patch_apply_begin' && callId) {
send({ type: 'tool-call', name: 'CodexPatch', callId, input: { changes: event.changes, auto_approved: event.auto_approved } }, key);
} else if (event.type === 'patch_apply_end' && callId) {
@@ -215,6 +215,16 @@ describe('AppServerEventConverter', () => {
}]);
});
it('preserves command identity when completion arrives without a start', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('item/completed', {
item: { id: 'late-command', type: 'commandExecution', command: 'node server.js', cwd: '/repo',
status: 'failed', exitCode: -1, durationMs: 16_636_720 }
});
expect(events).toEqual([expect.objectContaining({ type: 'exec_command_end', call_id: 'late-command',
command: 'node server.js', cwd: '/repo', status: 'failed', exit_code: -1, duration_ms: 16_636_720 })]);
});
it('maps MCP tool call items', () => {
const converter = new AppServerEventConverter();
@@ -1067,6 +1067,9 @@ export class AppServerEventConverter {
if (method === 'item/completed') {
const meta = this.commandMeta.get(itemId) ?? {};
// Completion snapshots can arrive without a start (reconnect or late shutdown).
const command = extractCommand(item.command ?? item.cmd ?? item.args);
const cwd = asString(item.cwd ?? item.workingDirectory ?? item.working_directory);
const output = asString(item.aggregatedOutput ?? item.aggregated_output ?? item.output ?? item.result ?? item.stdout)
?? this.commandOutputBuffers.get(itemId);
const stderr = asString(item.stderr);
@@ -1079,6 +1082,8 @@ export class AppServerEventConverter {
type: 'exec_command_end',
call_id: itemId,
...meta,
...(command ? { command } : {}),
...(cwd ? { cwd } : {}),
...(output ? { output } : {}),
...(stderr ? { stderr } : {}),
...(error ? { error } : {}),