mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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']);
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user