From e5d97a177aad8ef23ce017c5672da86aa0d18226 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:35:14 +0000 Subject: [PATCH 1/6] fix(dashboard): Terminalize tools when turns fail Co-Authored-By: David Cramer --- .../client/conversations/eventTranscript.ts | 43 +++++++++++++++---- .../tests/telemetry-components.test.tsx | 31 +++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 231a2e6d7..32e59f180 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -57,9 +57,23 @@ export function conversationTranscriptMessages( >(); const messages: TranscriptViewMessage[] = []; const latestUserMessageByTurn = new Map(); + const runningToolIdsByTurn = new Map>(); + let activeTurnId: string | undefined; let latestUserMessage: TranscriptViewMessage | undefined; + const trackTool = (toolCallId: string, status: ToolCall["status"]): void => { + if (status === "running") { + if (!activeTurnId) return; + const ids = runningToolIdsByTurn.get(activeTurnId) ?? new Set(); + ids.add(toolCallId); + runningToolIdsByTurn.set(activeTurnId, ids); + return; + } + for (const ids of runningToolIdsByTurn.values()) ids.delete(toolCallId); + }; + const ensureTool = (event: ConversationReportEvent, call: ToolCall): void => { + trackTool(call.toolCallId, call.status); if (replacedToolIds.has(call.toolCallId)) return; const existing = tools.get(call.toolCallId); if (existing) { @@ -114,6 +128,7 @@ export function conversationTranscriptMessages( } if (data.type === "turn_lifecycle" && data.state === "started") { + activeTurnId = data.turnId; if (latestUserMessage) { latestUserMessageByTurn.set(data.turnId, latestUserMessage); } @@ -197,14 +212,26 @@ export function conversationTranscriptMessages( continue; } - if (data.type === "turn_lifecycle" && data.state === "failed") { - messages.push({ - role: data.failureKind === "delivery" ? "system" : "assistant", - outcome: data.failureKind === "delivery" ? "delivery_failed" : "error", - parts: [], - sourceSeq: event.seq, - timestamp: eventTimestamp(event), - }); + if (data.type === "turn_lifecycle") { + if (data.state === "failed") { + for (const toolCallId of runningToolIdsByTurn.get(data.turnId) ?? []) { + const tool = tools.get(toolCallId); + if (tool?.status === "running") { + tool.status = "error"; + tool.resultTimestamp = eventTimestamp(event); + } + } + messages.push({ + role: data.failureKind === "delivery" ? "system" : "assistant", + outcome: + data.failureKind === "delivery" ? "delivery_failed" : "error", + parts: [], + sourceSeq: event.seq, + timestamp: eventTimestamp(event), + }); + } + runningToolIdsByTurn.delete(data.turnId); + if (activeTurnId === data.turnId) activeTurnId = undefined; continue; } } diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index 8826ec3ef..46683a6cf 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -499,6 +499,37 @@ describe("dashboard canonical-event components", () => { expect(html).not.toContain("missing result"); }); + it("renders a running tool as failed when its turn fails", () => { + const html = renderTranscript( + conversation([ + event(0, { + type: "turn_lifecycle", + turnId: "turn-1", + state: "started", + }), + event(1, { + type: "tool_calls", + calls: [ + { + toolCallId: "search-1", + name: "search", + status: "running", + }, + ], + }), + event(2, { + type: "turn_lifecycle", + turnId: "turn-1", + state: "failed", + failureKind: "agent", + }), + ]), + ); + + expect(html).toContain('aria-label="Tool failed"'); + expect(html).not.toContain('aria-label="search (running)"'); + }); + it("replaces the running treatment with details on the same completed row", () => { const html = renderTranscript( conversation([ From 205a7f5726ce863a6271118562c2cfc204aa04ee Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:09:29 +0000 Subject: [PATCH 2/6] fix(api): Terminalize tools for failed turns Co-Authored-By: David Cramer --- .../client/conversations/eventTranscript.ts | 46 ++++---------- .../tests/telemetry-components.test.tsx | 1 + .../src/api/conversations/event-page.ts | 60 ++++++++++++++++--- .../junior/src/api/conversations/events.ts | 44 +++++++++++--- .../junior/src/api/schema/conversation.ts | 2 + packages/junior/src/chat/agent/index.ts | 1 + .../junior/src/chat/conversations/history.ts | 3 + .../src/chat/conversations/projection.ts | 2 + .../api/conversations/event-list.test.ts | 54 +++++++++++++++++ .../unit/api/conversation-events.test.ts | 41 +++++++++++++ 10 files changed, 205 insertions(+), 49 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 32e59f180..12cee39a2 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -57,23 +57,9 @@ export function conversationTranscriptMessages( >(); const messages: TranscriptViewMessage[] = []; const latestUserMessageByTurn = new Map(); - const runningToolIdsByTurn = new Map>(); - let activeTurnId: string | undefined; let latestUserMessage: TranscriptViewMessage | undefined; - const trackTool = (toolCallId: string, status: ToolCall["status"]): void => { - if (status === "running") { - if (!activeTurnId) return; - const ids = runningToolIdsByTurn.get(activeTurnId) ?? new Set(); - ids.add(toolCallId); - runningToolIdsByTurn.set(activeTurnId, ids); - return; - } - for (const ids of runningToolIdsByTurn.values()) ids.delete(toolCallId); - }; - const ensureTool = (event: ConversationReportEvent, call: ToolCall): void => { - trackTool(call.toolCallId, call.status); if (replacedToolIds.has(call.toolCallId)) return; const existing = tools.get(call.toolCallId); if (existing) { @@ -128,7 +114,6 @@ export function conversationTranscriptMessages( } if (data.type === "turn_lifecycle" && data.state === "started") { - activeTurnId = data.turnId; if (latestUserMessage) { latestUserMessageByTurn.set(data.turnId, latestUserMessage); } @@ -212,26 +197,21 @@ export function conversationTranscriptMessages( continue; } - if (data.type === "turn_lifecycle") { - if (data.state === "failed") { - for (const toolCallId of runningToolIdsByTurn.get(data.turnId) ?? []) { - const tool = tools.get(toolCallId); - if (tool?.status === "running") { - tool.status = "error"; - tool.resultTimestamp = eventTimestamp(event); - } + if (data.type === "turn_lifecycle" && data.state === "failed") { + for (const toolCallId of data.toolCallIds ?? []) { + const tool = tools.get(toolCallId); + if (tool?.status === "running") { + tool.status = "error"; + tool.resultTimestamp = eventTimestamp(event); } - messages.push({ - role: data.failureKind === "delivery" ? "system" : "assistant", - outcome: - data.failureKind === "delivery" ? "delivery_failed" : "error", - parts: [], - sourceSeq: event.seq, - timestamp: eventTimestamp(event), - }); } - runningToolIdsByTurn.delete(data.turnId); - if (activeTurnId === data.turnId) activeTurnId = undefined; + messages.push({ + role: data.failureKind === "delivery" ? "system" : "assistant", + outcome: data.failureKind === "delivery" ? "delivery_failed" : "error", + parts: [], + sourceSeq: event.seq, + timestamp: eventTimestamp(event), + }); continue; } } diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index 46683a6cf..516db1506 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -522,6 +522,7 @@ describe("dashboard canonical-event components", () => { turnId: "turn-1", state: "failed", failureKind: "agent", + toolCallIds: ["search-1"], }), ]), ); diff --git a/packages/junior/src/api/conversations/event-page.ts b/packages/junior/src/api/conversations/event-page.ts index bd4aa93e6..3b6fbd01b 100644 --- a/packages/junior/src/api/conversations/event-page.ts +++ b/packages/junior/src/api/conversations/event-page.ts @@ -35,13 +35,14 @@ async function readConversationEventRows( beforeSeq?: number; conversationId: string; direction: "backward" | "forward"; - limit: number; + limit?: number; subagentInvocationIds?: string[]; toolCallIds?: string[]; + turnIds?: string[]; types?: ConversationEvent["data"]["type"][]; }, ) { - return executor + const query = executor .db() .select({ ...conversationEventColumns, @@ -76,17 +77,26 @@ async function readConversationEventRows( args.toolCallIds === undefined ? undefined : inArray( - sql`${juniorConversationEvents.payload}->>'toolCallId'`, + sql`coalesce( + ${juniorConversationEvents.payload}->>'toolCallId', + ${juniorConversationEvents.payload}->'message'->>'toolCallId' + )`, args.toolCallIds, ), + args.turnIds === undefined + ? undefined + : inArray( + sql`${juniorConversationEvents.payload}->>'turnId'`, + args.turnIds, + ), ), ) .orderBy( args.direction === "forward" ? asc(juniorConversationEvents.seq) : desc(juniorConversationEvents.seq), - ) - .limit(args.limit); + ); + return args.limit === undefined ? query : query.limit(args.limit); } function decodeConversationEventRow( @@ -134,22 +144,56 @@ async function projectConversationEventRows( subagentInvocationIds: endedInvocationIds, types: ["subagent_started"], }); + const failedTurnIds = [ + ...new Set( + events.flatMap((event) => + event.data.type === "turn_failed" ? [event.data.turnId] : [], + ), + ), + ]; + const failedTurnToolStartRows = + failedTurnIds.length === 0 + ? [] + : await readConversationEventRows(executor, { + conversationId: args.conversationId, + direction: "forward", + turnIds: failedTurnIds, + types: ["tool_execution_started"], + }); const toolResultIds = conversationReportToolResultIds(events); + const toolStartIds = [ + ...new Set([ + ...toolResultIds, + ...failedTurnToolStartRows.map((row) => row.payload.toolCallId as string), + ]), + ]; const toolStartRows = - toolResultIds.length === 0 + toolStartIds.length === 0 ? [] : await readConversationEventRows(executor, { conversationId: args.conversationId, direction: "forward", - limit: toolResultIds.length, - toolCallIds: toolResultIds, + limit: toolStartIds.length, + toolCallIds: toolStartIds, types: ["tool_execution_started"], }); + const toolResultRows = + failedTurnToolStartRows.length === 0 + ? [] + : await readConversationEventRows(executor, { + conversationId: args.conversationId, + direction: "forward", + toolCallIds: failedTurnToolStartRows.map( + (row) => row.payload.toolCallId as string, + ), + types: ["agent_step"], + }); return projectConversationReportEventPage({ canExposePayload: args.canExposePayload, events, subagentStartEvents: subagentStartRows.map(decodeConversationEventRow), + toolResultEvents: toolResultRows.map(decodeConversationEventRow), toolStartEvents: toolStartRows.map(decodeConversationEventRow), }); } diff --git a/packages/junior/src/api/conversations/events.ts b/packages/junior/src/api/conversations/events.ts index dfb55b653..4f53c3a59 100644 --- a/packages/junior/src/api/conversations/events.ts +++ b/packages/junior/src/api/conversations/events.ts @@ -76,6 +76,7 @@ type ToolStart = { createdAtMs: number; name: string; seq: number; + turnId?: string; }; /** Retain model-visible text and media descriptors, dropping all opaque fields. */ @@ -166,6 +167,7 @@ function reportToolResult(args: { ? { startedAt: new Date(args.start.createdAtMs).toISOString(), startedSeq: args.start.seq, + ...(args.start.turnId ? { turnId: args.start.turnId } : {}), } : {}), ...(args.canExposePayload && output !== undefined ? { output } : {}), @@ -238,14 +240,6 @@ function reportEventData(args: { turnId: data.turnId, state: data.outcome === "success" ? "succeeded" : "no_reply", }; - case "turn_failed": - return { - type: "turn_lifecycle", - turnId: data.turnId, - state: "failed", - failureKind: - data.failureCode === "delivery_failed" ? "delivery" : "agent", - }; case "compaction": return { type: "compaction", @@ -272,6 +266,7 @@ export function projectConversationReportEventPage(args: { canExposePayload: boolean; events: ConversationEvent[]; subagentStartEvents?: ConversationEvent[]; + toolResultEvents?: ConversationEvent[]; toolStartEvents?: ConversationEvent[]; }): ConversationReportEvent[] { const subagentStarts = new Map< @@ -300,10 +295,21 @@ export function projectConversationReportEventPage(args: { createdAtMs: event.createdAtMs, name: event.data.toolName, seq: event.seq, + ...(event.data.turnId ? { turnId: event.data.turnId } : {}), }); } } } + const toolResultSeqs = new Map(); + for (const event of [...(args.toolResultEvents ?? []), ...args.events]) { + if (event.data.type !== "agent_step") continue; + const result = reportingToolResultMessageSchema.safeParse(event.data.message); + if (!result.success) continue; + const current = toolResultSeqs.get(result.data.toolCallId); + if (current === undefined || event.seq < current) { + toolResultSeqs.set(result.data.toolCallId, event.seq); + } + } const projected: ConversationReportEvent[] = []; for (const event of args.events) { @@ -333,6 +339,7 @@ export function projectConversationReportEventPage(args: { createdAtMs: event.createdAtMs, name: event.data.toolName, seq: event.seq, + ...(event.data.turnId ? { turnId: event.data.turnId } : {}), }); data = { type: "tool_calls", @@ -343,6 +350,7 @@ export function projectConversationReportEventPage(args: { status: "running", startedAt: new Date(event.createdAtMs).toISOString(), startedSeq: event.seq, + ...(event.data.turnId ? { turnId: event.data.turnId } : {}), }, ], }; @@ -395,6 +403,26 @@ export function projectConversationReportEventPage(args: { ? { summary: event.data.summary } : {}), }; + } else if (event.data.type === "turn_failed") { + const failedTurn = event.data; + const toolCallIds = [...toolStarts.entries()] + .filter(([toolCallId, start]) => { + const resultSeq = toolResultSeqs.get(toolCallId); + return ( + start.turnId === failedTurn.turnId && + start.seq < event.seq && + (resultSeq === undefined || resultSeq > event.seq) + ); + }) + .map(([toolCallId]) => toolCallId); + data = { + type: "turn_lifecycle", + turnId: failedTurn.turnId, + state: "failed", + failureKind: + failedTurn.failureCode === "delivery_failed" ? "delivery" : "agent", + ...(toolCallIds.length > 0 ? { toolCallIds } : {}), + }; } else { data = reportEventData({ canExposePayload: args.canExposePayload, diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 3186580da..43d277d21 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -117,6 +117,7 @@ const conversationReportToolCallSchema = z toolCallId: z.string().min(1), name: z.string().min(1), status: z.enum(["running", "completed", "error"]), + turnId: z.string().min(1).optional(), startedAt: z.string().datetime().optional(), startedSeq: z.number().int().nonnegative().optional(), input: z.unknown().optional(), @@ -155,6 +156,7 @@ const conversationReportTurnLifecycleEventDataSchema = z.discriminatedUnion( turnId: z.string().min(1), state: z.literal("failed"), failureKind: z.enum(["agent", "delivery"]), + toolCallIds: z.array(z.string().min(1)).optional(), }) .strict(), ], diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 1d5885999..d9dd3acbc 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -420,6 +420,7 @@ async function executeAgentRunInPrivacyContext( conversationId, toolCallId: event.toolCallId, toolName: event.toolName, + turnId, }); } catch (error) { // Host-only activity events are best-effort reporting writes; a diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index a931afa09..3505f571f 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -156,6 +156,9 @@ const toolExecutionStartedEventDataSchema = z type: z.literal("tool_execution_started"), toolCallId: z.string().min(1), toolName: z.string().min(1), + // Optional only for decoding events written before tool starts were owned + // by a turn. New writes always include this field. + turnId: z.string().min(1).optional(), }) .strict(); diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index 8847e0382..1a8f044ae 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -446,6 +446,7 @@ export async function recordToolExecutionStarted(args: { createdAtMs?: number; toolCallId: string; toolName: string; + turnId: string; }): Promise { await getConversationEventStore().append(args.conversationId, [ { @@ -453,6 +454,7 @@ export async function recordToolExecutionStarted(args: { type: "tool_execution_started", toolCallId: args.toolCallId, toolName: args.toolName, + turnId: args.turnId, }, createdAtMs: args.createdAtMs ?? Date.now(), }, diff --git a/packages/junior/tests/integration/api/conversations/event-list.test.ts b/packages/junior/tests/integration/api/conversations/event-list.test.ts index e459cb7d1..80c371212 100644 --- a/packages/junior/tests/integration/api/conversations/event-list.test.ts +++ b/packages/junior/tests/integration/api/conversations/event-list.test.ts @@ -263,6 +263,60 @@ describe("conversation event list API", () => { ]); }); + it("terminalizes a failed turn tool when its start is on an older page", async () => { + const conversationId = "internal:paged-failed-tool"; + await recordConversation(conversationId); + await getConversationEventStore().append(conversationId, [ + { + data: { + type: "tool_execution_started", + toolCallId: "search-before-failure-page", + toolName: "search", + turnId: "failed-turn", + }, + createdAtMs: 2, + }, + message("page-boundary", 3), + { + data: { + type: "turn_failed", + turnId: "failed-turn", + failureCode: "model_execution_failed", + }, + createdAtMs: 4, + }, + message("latest-message", 5), + ]); + + const app = createJuniorApi(); + const detailResponse = await app.request( + `http://localhost/api/conversations/${conversationId}?limit=1`, + ); + const detail = conversationDetailReportSchema.parse( + await detailResponse.json(), + ); + if (!detail.previousCursor) throw new Error("Expected a previous cursor"); + + const historyResponse = await app.request( + `http://localhost/api/conversations/${conversationId}/events?before=${encodeURIComponent(detail.previousCursor)}&limit=1`, + ); + expect(historyResponse.status).toBe(200); + const history = conversationEventPageSchema.parse( + await historyResponse.json(), + ); + expect(history.events).toEqual([ + expect.objectContaining({ + data: { + type: "turn_lifecycle", + turnId: "failed-turn", + state: "failed", + failureKind: "agent", + toolCallIds: ["search-before-failure-page"], + }, + }), + ]); + }); + it("rejects tampered, cross-conversation, and invalid pagination input", async () => { const firstConversationId = "internal:first-cursor-owner"; const secondConversationId = "internal:second-cursor-owner"; diff --git a/packages/junior/tests/unit/api/conversation-events.test.ts b/packages/junior/tests/unit/api/conversation-events.test.ts index 23992b47c..c50822714 100644 --- a/packages/junior/tests/unit/api/conversation-events.test.ts +++ b/packages/junior/tests/unit/api/conversation-events.test.ts @@ -317,6 +317,47 @@ describe("conversation report event projection", () => { expect(JSON.stringify(prefix)).toBe(JSON.stringify(complete.slice(0, 1))); }); + it("projects failed turns with explicit outstanding tool ids", () => { + const projected = projectConversationReportEventPage({ + canExposePayload: true, + events: [ + event(1, { + type: "tool_execution_started", + toolCallId: "call-running", + toolName: "search", + turnId: "turn-1", + }), + event(2, { + type: "tool_execution_started", + toolCallId: "call-completed", + toolName: "fetch", + turnId: "turn-1", + }), + event(3, { + type: "agent_step", + message: { + role: "toolResult", + toolCallId: "call-completed", + content: [{ type: "text", text: "done" }], + } as ConversationAgentStepPayload, + }), + event(4, { + type: "turn_failed", + turnId: "turn-1", + failureCode: "model_execution_failed", + }), + ], + }); + + expect(projected.at(-1)?.data).toEqual({ + type: "turn_lifecycle", + turnId: "turn-1", + state: "failed", + failureKind: "agent", + toolCallIds: ["call-running"], + }); + }); + it("does not use future start context to rewrite an earlier tool result", () => { const result = event(1, { type: "agent_step", From b1852f23e209b4a9fd92013720e8db1e49f61005 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:58:10 +0000 Subject: [PATCH 3/6] Revert "fix(api): Terminalize tools for failed turns" This reverts commit 205a7f5726ce863a6271118562c2cfc204aa04ee. Co-Authored-By: David Cramer --- .../client/conversations/eventTranscript.ts | 46 ++++++++++---- .../tests/telemetry-components.test.tsx | 1 - .../src/api/conversations/event-page.ts | 60 +++---------------- .../junior/src/api/conversations/events.ts | 44 +++----------- .../junior/src/api/schema/conversation.ts | 2 - packages/junior/src/chat/agent/index.ts | 1 - .../junior/src/chat/conversations/history.ts | 3 - .../src/chat/conversations/projection.ts | 2 - .../api/conversations/event-list.test.ts | 54 ----------------- .../unit/api/conversation-events.test.ts | 41 ------------- 10 files changed, 49 insertions(+), 205 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 12cee39a2..32e59f180 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -57,9 +57,23 @@ export function conversationTranscriptMessages( >(); const messages: TranscriptViewMessage[] = []; const latestUserMessageByTurn = new Map(); + const runningToolIdsByTurn = new Map>(); + let activeTurnId: string | undefined; let latestUserMessage: TranscriptViewMessage | undefined; + const trackTool = (toolCallId: string, status: ToolCall["status"]): void => { + if (status === "running") { + if (!activeTurnId) return; + const ids = runningToolIdsByTurn.get(activeTurnId) ?? new Set(); + ids.add(toolCallId); + runningToolIdsByTurn.set(activeTurnId, ids); + return; + } + for (const ids of runningToolIdsByTurn.values()) ids.delete(toolCallId); + }; + const ensureTool = (event: ConversationReportEvent, call: ToolCall): void => { + trackTool(call.toolCallId, call.status); if (replacedToolIds.has(call.toolCallId)) return; const existing = tools.get(call.toolCallId); if (existing) { @@ -114,6 +128,7 @@ export function conversationTranscriptMessages( } if (data.type === "turn_lifecycle" && data.state === "started") { + activeTurnId = data.turnId; if (latestUserMessage) { latestUserMessageByTurn.set(data.turnId, latestUserMessage); } @@ -197,21 +212,26 @@ export function conversationTranscriptMessages( continue; } - if (data.type === "turn_lifecycle" && data.state === "failed") { - for (const toolCallId of data.toolCallIds ?? []) { - const tool = tools.get(toolCallId); - if (tool?.status === "running") { - tool.status = "error"; - tool.resultTimestamp = eventTimestamp(event); + if (data.type === "turn_lifecycle") { + if (data.state === "failed") { + for (const toolCallId of runningToolIdsByTurn.get(data.turnId) ?? []) { + const tool = tools.get(toolCallId); + if (tool?.status === "running") { + tool.status = "error"; + tool.resultTimestamp = eventTimestamp(event); + } } + messages.push({ + role: data.failureKind === "delivery" ? "system" : "assistant", + outcome: + data.failureKind === "delivery" ? "delivery_failed" : "error", + parts: [], + sourceSeq: event.seq, + timestamp: eventTimestamp(event), + }); } - messages.push({ - role: data.failureKind === "delivery" ? "system" : "assistant", - outcome: data.failureKind === "delivery" ? "delivery_failed" : "error", - parts: [], - sourceSeq: event.seq, - timestamp: eventTimestamp(event), - }); + runningToolIdsByTurn.delete(data.turnId); + if (activeTurnId === data.turnId) activeTurnId = undefined; continue; } } diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index 516db1506..46683a6cf 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -522,7 +522,6 @@ describe("dashboard canonical-event components", () => { turnId: "turn-1", state: "failed", failureKind: "agent", - toolCallIds: ["search-1"], }), ]), ); diff --git a/packages/junior/src/api/conversations/event-page.ts b/packages/junior/src/api/conversations/event-page.ts index 3b6fbd01b..bd4aa93e6 100644 --- a/packages/junior/src/api/conversations/event-page.ts +++ b/packages/junior/src/api/conversations/event-page.ts @@ -35,14 +35,13 @@ async function readConversationEventRows( beforeSeq?: number; conversationId: string; direction: "backward" | "forward"; - limit?: number; + limit: number; subagentInvocationIds?: string[]; toolCallIds?: string[]; - turnIds?: string[]; types?: ConversationEvent["data"]["type"][]; }, ) { - const query = executor + return executor .db() .select({ ...conversationEventColumns, @@ -77,26 +76,17 @@ async function readConversationEventRows( args.toolCallIds === undefined ? undefined : inArray( - sql`coalesce( - ${juniorConversationEvents.payload}->>'toolCallId', - ${juniorConversationEvents.payload}->'message'->>'toolCallId' - )`, + sql`${juniorConversationEvents.payload}->>'toolCallId'`, args.toolCallIds, ), - args.turnIds === undefined - ? undefined - : inArray( - sql`${juniorConversationEvents.payload}->>'turnId'`, - args.turnIds, - ), ), ) .orderBy( args.direction === "forward" ? asc(juniorConversationEvents.seq) : desc(juniorConversationEvents.seq), - ); - return args.limit === undefined ? query : query.limit(args.limit); + ) + .limit(args.limit); } function decodeConversationEventRow( @@ -144,56 +134,22 @@ async function projectConversationEventRows( subagentInvocationIds: endedInvocationIds, types: ["subagent_started"], }); - const failedTurnIds = [ - ...new Set( - events.flatMap((event) => - event.data.type === "turn_failed" ? [event.data.turnId] : [], - ), - ), - ]; - const failedTurnToolStartRows = - failedTurnIds.length === 0 - ? [] - : await readConversationEventRows(executor, { - conversationId: args.conversationId, - direction: "forward", - turnIds: failedTurnIds, - types: ["tool_execution_started"], - }); const toolResultIds = conversationReportToolResultIds(events); - const toolStartIds = [ - ...new Set([ - ...toolResultIds, - ...failedTurnToolStartRows.map((row) => row.payload.toolCallId as string), - ]), - ]; const toolStartRows = - toolStartIds.length === 0 + toolResultIds.length === 0 ? [] : await readConversationEventRows(executor, { conversationId: args.conversationId, direction: "forward", - limit: toolStartIds.length, - toolCallIds: toolStartIds, + limit: toolResultIds.length, + toolCallIds: toolResultIds, types: ["tool_execution_started"], }); - const toolResultRows = - failedTurnToolStartRows.length === 0 - ? [] - : await readConversationEventRows(executor, { - conversationId: args.conversationId, - direction: "forward", - toolCallIds: failedTurnToolStartRows.map( - (row) => row.payload.toolCallId as string, - ), - types: ["agent_step"], - }); return projectConversationReportEventPage({ canExposePayload: args.canExposePayload, events, subagentStartEvents: subagentStartRows.map(decodeConversationEventRow), - toolResultEvents: toolResultRows.map(decodeConversationEventRow), toolStartEvents: toolStartRows.map(decodeConversationEventRow), }); } diff --git a/packages/junior/src/api/conversations/events.ts b/packages/junior/src/api/conversations/events.ts index 4f53c3a59..dfb55b653 100644 --- a/packages/junior/src/api/conversations/events.ts +++ b/packages/junior/src/api/conversations/events.ts @@ -76,7 +76,6 @@ type ToolStart = { createdAtMs: number; name: string; seq: number; - turnId?: string; }; /** Retain model-visible text and media descriptors, dropping all opaque fields. */ @@ -167,7 +166,6 @@ function reportToolResult(args: { ? { startedAt: new Date(args.start.createdAtMs).toISOString(), startedSeq: args.start.seq, - ...(args.start.turnId ? { turnId: args.start.turnId } : {}), } : {}), ...(args.canExposePayload && output !== undefined ? { output } : {}), @@ -240,6 +238,14 @@ function reportEventData(args: { turnId: data.turnId, state: data.outcome === "success" ? "succeeded" : "no_reply", }; + case "turn_failed": + return { + type: "turn_lifecycle", + turnId: data.turnId, + state: "failed", + failureKind: + data.failureCode === "delivery_failed" ? "delivery" : "agent", + }; case "compaction": return { type: "compaction", @@ -266,7 +272,6 @@ export function projectConversationReportEventPage(args: { canExposePayload: boolean; events: ConversationEvent[]; subagentStartEvents?: ConversationEvent[]; - toolResultEvents?: ConversationEvent[]; toolStartEvents?: ConversationEvent[]; }): ConversationReportEvent[] { const subagentStarts = new Map< @@ -295,21 +300,10 @@ export function projectConversationReportEventPage(args: { createdAtMs: event.createdAtMs, name: event.data.toolName, seq: event.seq, - ...(event.data.turnId ? { turnId: event.data.turnId } : {}), }); } } } - const toolResultSeqs = new Map(); - for (const event of [...(args.toolResultEvents ?? []), ...args.events]) { - if (event.data.type !== "agent_step") continue; - const result = reportingToolResultMessageSchema.safeParse(event.data.message); - if (!result.success) continue; - const current = toolResultSeqs.get(result.data.toolCallId); - if (current === undefined || event.seq < current) { - toolResultSeqs.set(result.data.toolCallId, event.seq); - } - } const projected: ConversationReportEvent[] = []; for (const event of args.events) { @@ -339,7 +333,6 @@ export function projectConversationReportEventPage(args: { createdAtMs: event.createdAtMs, name: event.data.toolName, seq: event.seq, - ...(event.data.turnId ? { turnId: event.data.turnId } : {}), }); data = { type: "tool_calls", @@ -350,7 +343,6 @@ export function projectConversationReportEventPage(args: { status: "running", startedAt: new Date(event.createdAtMs).toISOString(), startedSeq: event.seq, - ...(event.data.turnId ? { turnId: event.data.turnId } : {}), }, ], }; @@ -403,26 +395,6 @@ export function projectConversationReportEventPage(args: { ? { summary: event.data.summary } : {}), }; - } else if (event.data.type === "turn_failed") { - const failedTurn = event.data; - const toolCallIds = [...toolStarts.entries()] - .filter(([toolCallId, start]) => { - const resultSeq = toolResultSeqs.get(toolCallId); - return ( - start.turnId === failedTurn.turnId && - start.seq < event.seq && - (resultSeq === undefined || resultSeq > event.seq) - ); - }) - .map(([toolCallId]) => toolCallId); - data = { - type: "turn_lifecycle", - turnId: failedTurn.turnId, - state: "failed", - failureKind: - failedTurn.failureCode === "delivery_failed" ? "delivery" : "agent", - ...(toolCallIds.length > 0 ? { toolCallIds } : {}), - }; } else { data = reportEventData({ canExposePayload: args.canExposePayload, diff --git a/packages/junior/src/api/schema/conversation.ts b/packages/junior/src/api/schema/conversation.ts index 43d277d21..3186580da 100644 --- a/packages/junior/src/api/schema/conversation.ts +++ b/packages/junior/src/api/schema/conversation.ts @@ -117,7 +117,6 @@ const conversationReportToolCallSchema = z toolCallId: z.string().min(1), name: z.string().min(1), status: z.enum(["running", "completed", "error"]), - turnId: z.string().min(1).optional(), startedAt: z.string().datetime().optional(), startedSeq: z.number().int().nonnegative().optional(), input: z.unknown().optional(), @@ -156,7 +155,6 @@ const conversationReportTurnLifecycleEventDataSchema = z.discriminatedUnion( turnId: z.string().min(1), state: z.literal("failed"), failureKind: z.enum(["agent", "delivery"]), - toolCallIds: z.array(z.string().min(1)).optional(), }) .strict(), ], diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index d9dd3acbc..1d5885999 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -420,7 +420,6 @@ async function executeAgentRunInPrivacyContext( conversationId, toolCallId: event.toolCallId, toolName: event.toolName, - turnId, }); } catch (error) { // Host-only activity events are best-effort reporting writes; a diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index 3505f571f..a931afa09 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -156,9 +156,6 @@ const toolExecutionStartedEventDataSchema = z type: z.literal("tool_execution_started"), toolCallId: z.string().min(1), toolName: z.string().min(1), - // Optional only for decoding events written before tool starts were owned - // by a turn. New writes always include this field. - turnId: z.string().min(1).optional(), }) .strict(); diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index 1a8f044ae..8847e0382 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -446,7 +446,6 @@ export async function recordToolExecutionStarted(args: { createdAtMs?: number; toolCallId: string; toolName: string; - turnId: string; }): Promise { await getConversationEventStore().append(args.conversationId, [ { @@ -454,7 +453,6 @@ export async function recordToolExecutionStarted(args: { type: "tool_execution_started", toolCallId: args.toolCallId, toolName: args.toolName, - turnId: args.turnId, }, createdAtMs: args.createdAtMs ?? Date.now(), }, diff --git a/packages/junior/tests/integration/api/conversations/event-list.test.ts b/packages/junior/tests/integration/api/conversations/event-list.test.ts index 80c371212..e459cb7d1 100644 --- a/packages/junior/tests/integration/api/conversations/event-list.test.ts +++ b/packages/junior/tests/integration/api/conversations/event-list.test.ts @@ -263,60 +263,6 @@ describe("conversation event list API", () => { ]); }); - it("terminalizes a failed turn tool when its start is on an older page", async () => { - const conversationId = "internal:paged-failed-tool"; - await recordConversation(conversationId); - await getConversationEventStore().append(conversationId, [ - { - data: { - type: "tool_execution_started", - toolCallId: "search-before-failure-page", - toolName: "search", - turnId: "failed-turn", - }, - createdAtMs: 2, - }, - message("page-boundary", 3), - { - data: { - type: "turn_failed", - turnId: "failed-turn", - failureCode: "model_execution_failed", - }, - createdAtMs: 4, - }, - message("latest-message", 5), - ]); - - const app = createJuniorApi(); - const detailResponse = await app.request( - `http://localhost/api/conversations/${conversationId}?limit=1`, - ); - const detail = conversationDetailReportSchema.parse( - await detailResponse.json(), - ); - if (!detail.previousCursor) throw new Error("Expected a previous cursor"); - - const historyResponse = await app.request( - `http://localhost/api/conversations/${conversationId}/events?before=${encodeURIComponent(detail.previousCursor)}&limit=1`, - ); - expect(historyResponse.status).toBe(200); - const history = conversationEventPageSchema.parse( - await historyResponse.json(), - ); - expect(history.events).toEqual([ - expect.objectContaining({ - data: { - type: "turn_lifecycle", - turnId: "failed-turn", - state: "failed", - failureKind: "agent", - toolCallIds: ["search-before-failure-page"], - }, - }), - ]); - }); - it("rejects tampered, cross-conversation, and invalid pagination input", async () => { const firstConversationId = "internal:first-cursor-owner"; const secondConversationId = "internal:second-cursor-owner"; diff --git a/packages/junior/tests/unit/api/conversation-events.test.ts b/packages/junior/tests/unit/api/conversation-events.test.ts index c50822714..23992b47c 100644 --- a/packages/junior/tests/unit/api/conversation-events.test.ts +++ b/packages/junior/tests/unit/api/conversation-events.test.ts @@ -317,47 +317,6 @@ describe("conversation report event projection", () => { expect(JSON.stringify(prefix)).toBe(JSON.stringify(complete.slice(0, 1))); }); - it("projects failed turns with explicit outstanding tool ids", () => { - const projected = projectConversationReportEventPage({ - canExposePayload: true, - events: [ - event(1, { - type: "tool_execution_started", - toolCallId: "call-running", - toolName: "search", - turnId: "turn-1", - }), - event(2, { - type: "tool_execution_started", - toolCallId: "call-completed", - toolName: "fetch", - turnId: "turn-1", - }), - event(3, { - type: "agent_step", - message: { - role: "toolResult", - toolCallId: "call-completed", - content: [{ type: "text", text: "done" }], - } as ConversationAgentStepPayload, - }), - event(4, { - type: "turn_failed", - turnId: "turn-1", - failureCode: "model_execution_failed", - }), - ], - }); - - expect(projected.at(-1)?.data).toEqual({ - type: "turn_lifecycle", - turnId: "turn-1", - state: "failed", - failureKind: "agent", - toolCallIds: ["call-running"], - }); - }); - it("does not use future start context to rewrite an earlier tool result", () => { const result = event(1, { type: "agent_step", From b6c7fa50ac05413a95e9048a9beeee4fa1661229 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:58:10 +0000 Subject: [PATCH 4/6] Revert "fix(dashboard): Terminalize tools when turns fail" This reverts commit e5d97a177aad8ef23ce017c5672da86aa0d18226. Co-Authored-By: David Cramer --- .../client/conversations/eventTranscript.ts | 43 ++++--------------- .../tests/telemetry-components.test.tsx | 31 ------------- 2 files changed, 8 insertions(+), 66 deletions(-) diff --git a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts index 32e59f180..231a2e6d7 100644 --- a/packages/junior-dashboard/src/client/conversations/eventTranscript.ts +++ b/packages/junior-dashboard/src/client/conversations/eventTranscript.ts @@ -57,23 +57,9 @@ export function conversationTranscriptMessages( >(); const messages: TranscriptViewMessage[] = []; const latestUserMessageByTurn = new Map(); - const runningToolIdsByTurn = new Map>(); - let activeTurnId: string | undefined; let latestUserMessage: TranscriptViewMessage | undefined; - const trackTool = (toolCallId: string, status: ToolCall["status"]): void => { - if (status === "running") { - if (!activeTurnId) return; - const ids = runningToolIdsByTurn.get(activeTurnId) ?? new Set(); - ids.add(toolCallId); - runningToolIdsByTurn.set(activeTurnId, ids); - return; - } - for (const ids of runningToolIdsByTurn.values()) ids.delete(toolCallId); - }; - const ensureTool = (event: ConversationReportEvent, call: ToolCall): void => { - trackTool(call.toolCallId, call.status); if (replacedToolIds.has(call.toolCallId)) return; const existing = tools.get(call.toolCallId); if (existing) { @@ -128,7 +114,6 @@ export function conversationTranscriptMessages( } if (data.type === "turn_lifecycle" && data.state === "started") { - activeTurnId = data.turnId; if (latestUserMessage) { latestUserMessageByTurn.set(data.turnId, latestUserMessage); } @@ -212,26 +197,14 @@ export function conversationTranscriptMessages( continue; } - if (data.type === "turn_lifecycle") { - if (data.state === "failed") { - for (const toolCallId of runningToolIdsByTurn.get(data.turnId) ?? []) { - const tool = tools.get(toolCallId); - if (tool?.status === "running") { - tool.status = "error"; - tool.resultTimestamp = eventTimestamp(event); - } - } - messages.push({ - role: data.failureKind === "delivery" ? "system" : "assistant", - outcome: - data.failureKind === "delivery" ? "delivery_failed" : "error", - parts: [], - sourceSeq: event.seq, - timestamp: eventTimestamp(event), - }); - } - runningToolIdsByTurn.delete(data.turnId); - if (activeTurnId === data.turnId) activeTurnId = undefined; + if (data.type === "turn_lifecycle" && data.state === "failed") { + messages.push({ + role: data.failureKind === "delivery" ? "system" : "assistant", + outcome: data.failureKind === "delivery" ? "delivery_failed" : "error", + parts: [], + sourceSeq: event.seq, + timestamp: eventTimestamp(event), + }); continue; } } diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index 46683a6cf..8826ec3ef 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -499,37 +499,6 @@ describe("dashboard canonical-event components", () => { expect(html).not.toContain("missing result"); }); - it("renders a running tool as failed when its turn fails", () => { - const html = renderTranscript( - conversation([ - event(0, { - type: "turn_lifecycle", - turnId: "turn-1", - state: "started", - }), - event(1, { - type: "tool_calls", - calls: [ - { - toolCallId: "search-1", - name: "search", - status: "running", - }, - ], - }), - event(2, { - type: "turn_lifecycle", - turnId: "turn-1", - state: "failed", - failureKind: "agent", - }), - ]), - ); - - expect(html).toContain('aria-label="Tool failed"'); - expect(html).not.toContain('aria-label="search (running)"'); - }); - it("replaces the running treatment with details on the same completed row", () => { const html = renderTranscript( conversation([ From f7b762a91b5bc4e952770782f0c7060b67d196e3 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:17:28 +0000 Subject: [PATCH 5/6] fix(dashboard): Persist terminal tool activity Co-Authored-By: David Cramer --- .../junior/src/api/conversations/events.ts | 22 +++++++++ packages/junior/src/chat/agent/index.ts | 49 ++++++++++++++----- .../junior/src/chat/conversations/history.ts | 11 +++++ .../src/chat/conversations/projection.ts | 21 ++++++++ .../api/conversations/event-list.test.ts | 11 ++--- .../runtime/agent-run-provider-retry.test.ts | 27 +++++++++- 6 files changed, 120 insertions(+), 21 deletions(-) diff --git a/packages/junior/src/api/conversations/events.ts b/packages/junior/src/api/conversations/events.ts index dfb55b653..3495f3654 100644 --- a/packages/junior/src/api/conversations/events.ts +++ b/packages/junior/src/api/conversations/events.ts @@ -12,6 +12,7 @@ export const conversationReportSourceEventTypes = [ "message_handled", "agent_step", "tool_execution_started", + "tool_execution_completed", "turn_started", "turn_routed", "turn_completed", @@ -181,6 +182,9 @@ export function conversationReportToolResultIds( return [ ...new Set( events.flatMap((event) => { + if (event.data.type === "tool_execution_completed") { + return [event.data.toolCallId]; + } if (event.data.type !== "agent_step") return []; const result = reportingToolResultMessageSchema.safeParse( event.data.message, @@ -346,6 +350,24 @@ export function projectConversationReportEventPage(args: { }, ], }; + } else if (event.data.type === "tool_execution_completed") { + const start = toolStarts.get(event.data.toolCallId); + data = { + type: "tool_calls", + calls: [ + { + toolCallId: event.data.toolCallId, + name: event.data.toolName, + status: event.data.outcome, + ...(start && start.seq < event.seq + ? { + startedAt: new Date(start.createdAtMs).toISOString(), + startedSeq: start.seq, + } + : {}), + }, + ], + }; } else if (event.data.type === "subagent_started") { subagentStarts.set(event.data.subagentInvocationId, { createdAtMs: event.createdAtMs, diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 1d5885999..ae8c95881 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -41,6 +41,7 @@ import { loadConnectedMcpProviders, loadTurnRoute, openConversationProjection, + recordToolExecutionCompleted, recordToolExecutionStarted, recordMcpProviderConnected, recordTurnRoute, @@ -410,17 +411,36 @@ async function executeAgentRunInPrivacyContext( surface, }); const runResume = resume; - const recordParentToolExecutionStart = async (event: { - args: unknown; - toolCallId: string; - toolName: string; - }) => { + const recordParentToolExecution = async (event: + | { + args: unknown; + toolCallId: string; + toolName: string; + type: "tool_execution_start"; + } + | { + isError: boolean; + result: unknown; + toolCallId: string; + toolName: string; + type: "tool_execution_end"; + }, + ) => { try { - await recordToolExecutionStarted({ - conversationId, - toolCallId: event.toolCallId, - toolName: event.toolName, - }); + if (event.type === "tool_execution_start") { + await recordToolExecutionStarted({ + conversationId, + toolCallId: event.toolCallId, + toolName: event.toolName, + }); + } else { + await recordToolExecutionCompleted({ + conversationId, + isError: event.isError, + toolCallId: event.toolCallId, + toolName: event.toolName, + }); + } } catch (error) { // Host-only activity events are best-effort reporting writes; a // failed append must not abort the in-flight model turn. @@ -431,7 +451,7 @@ async function executeAgentRunInPrivacyContext( { "gen_ai.tool.name": event.toolName, }, - "Failed to record host-only tool execution start", + "Failed to record host-only tool execution", ); } }; @@ -998,8 +1018,11 @@ async function executeAgentRunInPrivacyContext( }); const unsubscribe = agent.subscribe((event) => { - if (event.type === "tool_execution_start") { - return recordParentToolExecutionStart(event); + if ( + event.type === "tool_execution_start" || + event.type === "tool_execution_end" + ) { + return recordParentToolExecution(event); } if (event.type === "turn_end" && event.toolResults.length > 0) { if (pendingHandoff) { diff --git a/packages/junior/src/chat/conversations/history.ts b/packages/junior/src/chat/conversations/history.ts index a931afa09..00386e630 100644 --- a/packages/junior/src/chat/conversations/history.ts +++ b/packages/junior/src/chat/conversations/history.ts @@ -159,6 +159,15 @@ const toolExecutionStartedEventDataSchema = z }) .strict(); +const toolExecutionCompletedEventDataSchema = z + .object({ + type: z.literal("tool_execution_completed"), + toolCallId: z.string().min(1), + toolName: z.string().min(1), + outcome: z.enum(["completed", "error"]), + }) + .strict(); + const conversationMessageRoleSchema = z.union([ z.literal("user"), z.literal("assistant"), @@ -307,6 +316,7 @@ const appendableConversationEventDataSchema = z.union([ authorizationRequestedEventDataSchema, authorizationCompletedEventDataSchema, toolExecutionStartedEventDataSchema, + toolExecutionCompletedEventDataSchema, messageHandledEventDataSchema, messagesSummarizedEventDataSchema, turnStartedEventDataSchema, @@ -339,6 +349,7 @@ const knownConversationEventTypeSchema = z.enum([ "authorization_requested", "authorization_completed", "tool_execution_started", + "tool_execution_completed", "message_handled", "messages_summarized", "turn_started", diff --git a/packages/junior/src/chat/conversations/projection.ts b/packages/junior/src/chat/conversations/projection.ts index 8847e0382..4908a035b 100644 --- a/packages/junior/src/chat/conversations/projection.ts +++ b/packages/junior/src/chat/conversations/projection.ts @@ -458,3 +458,24 @@ export async function recordToolExecutionStarted(args: { }, ]); } + +/** Record a host-observed parent tool completion without persisting its output. */ +export async function recordToolExecutionCompleted(args: { + conversationId: string; + createdAtMs?: number; + isError: boolean; + toolCallId: string; + toolName: string; +}): Promise { + await getConversationEventStore().append(args.conversationId, [ + { + data: { + type: "tool_execution_completed", + toolCallId: args.toolCallId, + toolName: args.toolName, + outcome: args.isError ? "error" : "completed", + }, + createdAtMs: args.createdAtMs ?? Date.now(), + }, + ]); +} diff --git a/packages/junior/tests/integration/api/conversations/event-list.test.ts b/packages/junior/tests/integration/api/conversations/event-list.test.ts index e459cb7d1..50ebac55a 100644 --- a/packages/junior/tests/integration/api/conversations/event-list.test.ts +++ b/packages/junior/tests/integration/api/conversations/event-list.test.ts @@ -216,13 +216,10 @@ describe("conversation event list API", () => { }, { data: { - type: "agent_step", - message: { - role: "toolResult", - toolCallId: "search-before-page", - content: [{ type: "text", text: "two matches" }], - isError: false, - } as PiMessage, + type: "tool_execution_completed", + toolCallId: "search-before-page", + toolName: "search", + outcome: "completed", }, createdAtMs: 3, }, diff --git a/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts b/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts index f5513ead9..5c1813f54 100644 --- a/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts +++ b/packages/junior/tests/integration/runtime/agent-run-provider-retry.test.ts @@ -80,6 +80,15 @@ vi.mock("@/chat/conversations/projection", async (importOriginal) => { } return actual.recordToolExecutionStarted(...args); }, + recordToolExecutionCompleted: async ( + ...args: Parameters + ) => { + sessionLogState.toolExecutionAppendCalls += 1; + if (sessionLogState.failToolExecutionAppend) { + throw new Error("store blip during host-only append"); + } + return actual.recordToolExecutionCompleted(...args); + }, }; }); @@ -223,6 +232,22 @@ vi.mock("@earendil-works/pi-agent-core", () => { this.recordRunFailure(error); return {}; } + try { + await Promise.all( + this.subscribers.map((subscriber) => + subscriber({ + type: "tool_execution_end", + toolCallId: "call_1", + toolName: "bash", + result: { content: [{ type: "text", text: "ok" }] }, + isError: false, + }), + ), + ); + } catch (error) { + this.recordRunFailure(error); + return {}; + } this.state.messages.push({ role: "toolResult", toolName: "bash", @@ -1094,7 +1119,7 @@ describe("executeAgentRun provider retry", () => { }), ); - expect(sessionLogState.toolExecutionAppendCalls).toBe(1); + expect(sessionLogState.toolExecutionAppendCalls).toBe(2); expect(reply.diagnostics.outcome).toBe("success"); expect(reply.text).toBe("Tool done."); }); From b2b82ba5054f2cb4d5723fd7e010a49d1adc4ba1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:51:32 +0000 Subject: [PATCH 6/6] fix(agent): Preserve timeout tool results Co-Authored-By: David Cramer --- packages/junior/src/chat/agent/index.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index ae8c95881..05dbb9866 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -411,6 +411,7 @@ async function executeAgentRunInPrivacyContext( surface, }); const runResume = resume; + let pendingToolActivityWrites = Promise.resolve(); const recordParentToolExecution = async (event: | { args: unknown; @@ -455,6 +456,13 @@ async function executeAgentRunInPrivacyContext( ); } }; + const enqueueParentToolExecution = ( + event: Parameters[0], + ): void => { + pendingToolActivityWrites = pendingToolActivityWrites.then(() => + recordParentToolExecution(event), + ); + }; const persistedConfigurationValues = policy.channelConfiguration ? await policy.channelConfiguration.resolveValues() : {}; @@ -1022,15 +1030,21 @@ async function executeAgentRunInPrivacyContext( event.type === "tool_execution_start" || event.type === "tool_execution_end" ) { - return recordParentToolExecution(event); + // Pi emits tool_execution_end before appending the corresponding + // toolResult message. Queue reporting writes without blocking that + // lifecycle so timeout recovery can snapshot the continuable result. + enqueueParentToolExecution(event); + return; } if (event.type === "turn_end" && event.toolResults.length > 0) { if (pendingHandoff) { - return; + return pendingToolActivityWrites; } - return runResume - .persistSafeBoundary([...agent!.state.messages]) - .then(() => undefined); + return pendingToolActivityWrites.then(() => + runResume + .persistSafeBoundary([...agent!.state.messages]) + .then(() => undefined), + ); } if (event.type === "message_end" && isAssistantMessage(event.message)) { if ( @@ -1359,6 +1373,7 @@ async function executeAgentRunInPrivacyContext( return authPauseOutcome; } } finally { + await pendingToolActivityWrites; unsubscribe(); }