diff --git a/frontend/src/lib/components/agentTrace.test.ts b/frontend/src/lib/components/agentTrace.test.ts index d2df80107a..2cca3177ee 100644 --- a/frontend/src/lib/components/agentTrace.test.ts +++ b/frontend/src/lib/components/agentTrace.test.ts @@ -144,3 +144,17 @@ describe('the max-iterations path', () => { ]) }) }) + +// A result is whatever a script returned, so every nested field is arbitrary even +// once the messages have roles. A throw in here takes down the whole result +// viewer, including the ordinary error such a payload usually rides on. +describe('a malformed message that still has a role', () => { + it.each([ + ['tool_calls that are not a list', { role: 'assistant', tool_calls: {} }], + ['annotations that are not a list', { role: 'assistant', content: 'hi', annotations: 'abc' }], + ['a content object', { role: 'assistant', content: { text: 'hi' } }], + ['an agent_action that is not an object', { role: 'tool', agent_action: 'tool_call' }] + ])('survives %s', (_label, message) => { + expect(() => buildAgentTrace([message as never])).not.toThrow() + }) +}) diff --git a/frontend/src/lib/components/agentTrace.ts b/frontend/src/lib/components/agentTrace.ts index 2f7827df61..a30bb1c4cf 100644 --- a/frontend/src/lib/components/agentTrace.ts +++ b/frontend/src/lib/components/agentTrace.ts @@ -46,7 +46,7 @@ function contentText(content: unknown): string { function sourcesOf(message: AgentMessage): WebSearchSource[] | undefined { const annotations = message.annotations - if (!annotations?.length) { + if (!Array.isArray(annotations) || annotations.length === 0) { return undefined } const sources = annotations @@ -61,7 +61,14 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] { // the two are joined by `tool_call_id`. const argsByCallId = new Map() for (const message of messages) { - for (const call of message.tool_calls ?? []) { + // A job result is whatever its script returned, and the shape check that got + // us here only proves each message has a `role`. Anything nested is still + // arbitrary, and a throw here would take the whole result viewer down with + // it — including the plain error a lookalike payload is usually attached to. + if (!Array.isArray(message.tool_calls)) { + continue + } + for (const call of message.tool_calls) { if (call.id && typeof call.function?.arguments === 'string') { argsByCallId.set(call.id, call.function.arguments) }