fix(codex): improve web rendering for Codex events (#544)

* test(codex): add web event rendering harness

* fix(codex): surface plan updates in web

* fix(codex): render MCP tool calls in web

* fix(codex): improve terminal and context display

* fix(codex): format token usage events

* fix(codex): show status context in web

* fix(codex): preserve tool result errors
This commit is contained in:
CoColate
2026-04-29 17:22:45 +08:00
committed by GitHub
parent a612be50d6
commit e76738aa5a
27 changed files with 1138 additions and 15 deletions
+56 -1
View File
@@ -69,6 +69,32 @@ vi.mock('./codexAppServerClient', () => {
return { turn: { id: turnId } };
}
if (params?.threadId === 'thread-1') {
const commandStart = {
item: {
id: 'cmd-1',
type: 'commandExecution',
command: 'echo ok',
cwd: '/tmp/hapi-update'
}
};
harness.notifications.push({ method: 'item/started', params: commandStart });
this.notificationHandler?.('item/started', commandStart);
this.notificationHandler?.('item/commandExecution/outputDelta', {
itemId: 'cmd-1',
delta: 'ok\n'
});
const commandEnd = {
item: {
id: 'cmd-1',
type: 'commandExecution',
exitCode: 0
}
};
harness.notifications.push({ method: 'item/completed', params: commandEnd });
this.notificationHandler?.('item/completed', commandEnd);
}
const completed = { status: 'Completed', turn: { id: turnId } };
harness.notifications.push({ method: 'turn/completed', params: completed });
this.notificationHandler?.('turn/completed', completed);
@@ -250,7 +276,12 @@ describe('codexRemoteLauncher', () => {
experimentalApi: true
}
}]);
expect(harness.notifications.map((entry) => entry.method)).toEqual(['turn/started', 'turn/completed']);
expect(harness.notifications.map((entry) => entry.method)).toEqual([
'turn/started',
'item/started',
'item/completed',
'turn/completed'
]);
expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1);
expect(thinkingChanges).toContain(true);
expect(session.thinking).toBe(false);
@@ -286,6 +317,30 @@ describe('codexRemoteLauncher', () => {
expect(session.thinking).toBe(false);
});
it('surfaces Codex bash stdout instead of duplicating raw output json', async () => {
const { session, codexMessages } = createSessionStub();
await codexRemoteLauncher(session as never);
expect(codexMessages).toContainEqual(expect.objectContaining({
type: 'tool-call-result',
callId: 'cmd-1',
output: expect.objectContaining({
command: 'echo ok',
cwd: '/tmp/hapi-update',
stdout: 'ok\n',
exit_code: 0
})
}));
expect(codexMessages).not.toContainEqual(expect.objectContaining({
type: 'tool-call-result',
callId: 'cmd-1',
output: expect.objectContaining({
output: 'ok\n'
})
}));
});
it('clears codex thread state without starting a turn', async () => {
const { session, sessionEvents, resetThreadCalls } = createSessionStub(['/clear', 'next message']);
+24
View File
@@ -410,6 +410,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
delete output.type;
delete output.call_id;
delete output.callId;
output.stdout = output.output;
delete output.output;
session.sendAgentMessage({
type: 'tool-call-result',
@@ -425,6 +427,28 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
id: randomUUID()
});
}
if (msgType === 'plan_update') {
session.sendAgentMessage({
type: 'tool-call',
name: 'update_plan',
callId: 'codex-plan-state',
input: {
plan: Array.isArray(msg.plan) ? msg.plan : [],
source: 'codex'
},
id: randomUUID()
});
session.sendAgentMessage({
type: 'tool-call-result',
callId: 'codex-plan-state',
output: {
plan: Array.isArray(msg.plan) ? msg.plan : [],
source: 'codex',
status: 'updated'
},
id: randomUUID()
});
}
if (msgType === 'patch_apply_begin') {
const callId = asString(msg.call_id ?? msg.callId);
if (callId) {
@@ -115,6 +115,8 @@ describe('appServerConfig', () => {
expect(params.input).toEqual([{ type: 'text', text: 'hello' }]);
expect(params.approvalPolicy).toBe('never');
expect(params.sandboxPolicy).toEqual({ type: 'readOnly' });
expect(params.effort).toBe('high');
expect(params.summary).toBe('detailed');
expect(params.collaborationMode).toEqual({
mode: 'default',
settings: {
+5
View File
@@ -141,6 +141,11 @@ export function buildTurnStartParams(args: {
params.sandboxPolicy = sandboxPolicy;
}
if (args.mode?.modelReasoningEffort) {
params.effort = args.mode.modelReasoningEffort;
params.summary = 'detailed';
}
const collaborationMode = args.mode?.collaborationMode;
const model = args.overrides?.model ?? args.mode?.model;
if (collaborationMode) {
@@ -100,6 +100,75 @@ describe('AppServerEventConverter', () => {
}]);
});
it('maps MCP tool call items', () => {
const converter = new AppServerEventConverter();
const started = converter.handleNotification('item/started', {
item: {
id: 'call-1',
type: 'mcpToolCall',
server: 'hapi',
tool: 'change_title',
arguments: { title: 'MCP Title' }
}
});
expect(started).toEqual([{
type: 'mcp_tool_call_begin',
call_id: 'call-1',
server: 'hapi',
tool: 'change_title',
invocation: {
server: 'hapi',
tool: 'change_title',
arguments: { title: 'MCP Title' }
}
}]);
const completed = converter.handleNotification('item/completed', {
item: {
id: 'call-1',
type: 'mcpToolCall',
server: 'hapi',
tool: 'change_title',
result: {
content: [{ type: 'text', text: 'done' }]
}
}
});
expect(completed).toEqual([{
type: 'mcp_tool_call_end',
call_id: 'call-1',
server: 'hapi',
tool: 'change_title',
result: {
content: [{ type: 'text', text: 'done' }]
}
}]);
});
it('maps MCP tool call item errors', () => {
const converter = new AppServerEventConverter();
const completed = converter.handleNotification('item/completed', {
item: {
id: 'call-1',
type: 'mcpToolCall',
server: 'hapi',
tool: 'change_title',
error: 'boom'
}
});
expect(completed).toEqual([{
type: 'mcp_tool_call_end',
call_id: 'call-1',
server: 'hapi',
tool: 'change_title',
result: { Err: 'boom' }
}]);
});
it('maps reasoning deltas', () => {
const converter = new AppServerEventConverter();
@@ -144,6 +213,51 @@ describe('AppServerEventConverter', () => {
expect(second).toEqual([]);
});
it('maps turn plan updates into update_plan events', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('turn/plan/updated', {
plan: [
{ step: 'Inspect Codex events', status: 'completed' },
{ content: 'Render plan state', status: 'in_progress' },
{ title: 'Verify web DOM', status: 'pending' }
]
});
expect(events).toEqual([{
type: 'plan_update',
plan: [
{ step: 'Inspect Codex events', status: 'completed' },
{ step: 'Render plan state', status: 'in_progress' },
{ step: 'Verify web DOM', status: 'pending' }
]
}]);
});
it('unwraps wrapped codex plan updates', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('codex/event/plan_update', {
msg: {
type: 'plan_update',
update: {
items: [
{ text: 'Plan from wrapped event', status: 'completed' }
]
}
}
});
expect(events).toEqual([{
type: 'plan_update',
plan: [
{ step: 'Plan from wrapped event', status: 'completed' }
]
}]);
});
it('maps diff updates', () => {
const converter = new AppServerEventConverter();
+86 -2
View File
@@ -123,6 +123,50 @@ function extractReasoningText(item: Record<string, unknown>): string | null {
return null;
}
function normalizePlanStatus(value: unknown): 'pending' | 'in_progress' | 'completed' {
const raw = typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s-]/g, '_') : '';
if (raw === 'completed' || raw === 'complete' || raw === 'done') return 'completed';
if (raw === 'in_progress' || raw === 'inprogress' || raw === 'active' || raw === 'running') return 'in_progress';
return 'pending';
}
function extractPlanEntries(value: unknown): Array<{ step: string; status: 'pending' | 'in_progress' | 'completed' }> {
const record = asRecord(value);
const entries = Array.isArray(value)
? value
: Array.isArray(record?.plan)
? record.plan
: Array.isArray(record?.items)
? record.items
: Array.isArray(record?.steps)
? record.steps
: [];
const plan: Array<{ step: string; status: 'pending' | 'in_progress' | 'completed' }> = [];
for (const entry of entries) {
if (typeof entry === 'string') {
plan.push({ step: entry, status: 'pending' });
continue;
}
const item = asRecord(entry);
if (!item) continue;
const step = asString(item.step ?? item.content ?? item.text ?? item.title ?? item.description);
if (!step) continue;
plan.push({
step,
status: normalizePlanStatus(item.status ?? item.state)
});
}
return plan;
}
function extractPlanUpdate(params: Record<string, unknown>): ConvertedEvent[] {
const plan = extractPlanEntries(
params.plan ?? params.update ?? params.items ?? params.steps ?? params
);
return plan.length > 0 ? [{ type: 'plan_update', plan }] : [];
}
export class AppServerEventConverter {
private readonly agentMessageBuffers = new Map<string, string>();
private readonly reasoningBuffers = new Map<string, string>();
@@ -228,10 +272,13 @@ export class AppServerEventConverter {
return error ? [{ type: 'task_failed', error }] : [];
}
if (msgType === 'plan_update') {
return extractPlanUpdate(msg);
}
if (
msgType === 'mcp_startup_update' ||
msgType === 'mcp_startup_complete' ||
msgType === 'plan_update' ||
msgType === 'skills_update_available' ||
msgType === 'stream_error' ||
msgType === 'warning' ||
@@ -253,7 +300,11 @@ export class AppServerEventConverter {
return this.handleWrappedCodexEvent(paramsRecord) ?? events;
}
if (method === 'account/rateLimits/updated' || method === 'turn/plan/updated' || method === 'thread/compacted') {
if (method === 'turn/plan/updated') {
return extractPlanUpdate(paramsRecord);
}
if (method === 'account/rateLimits/updated' || method === 'thread/compacted') {
return events;
}
@@ -484,6 +535,39 @@ export class AppServerEventConverter {
return events;
}
if (itemType === 'mcptoolcall') {
const server = asString(item.server ?? item.serverName ?? item.server_name);
const tool = asString(item.tool ?? item.toolName ?? item.tool_name ?? item.name);
const input = item.arguments ?? item.input ?? {};
if (method === 'item/started') {
events.push({
type: 'mcp_tool_call_begin',
call_id: itemId,
server,
tool,
invocation: {
server,
tool,
arguments: input
}
});
}
if (method === 'item/completed') {
const error = item.error;
events.push({
type: 'mcp_tool_call_end',
call_id: itemId,
server,
tool,
result: error ? { Err: error } : item.result
});
}
return events;
}
if (itemType === 'filechange') {
if (method === 'item/started') {
const changes = extractChanges(item.changes ?? item.change ?? item.diff);