From e1ce819d11b5855af8c3a9de4dd2ea2455a38110 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:20:08 +0200 Subject: [PATCH] fix(agents): retain complete failure outcomes through delivery Capture the failure reason when the terminal notification is queued, preserve provider diagnostics at their producer, and apply the existing notification text budget. Failed responses also keep their completion timing when their footer is anchored to error activity. --- .../src/agent-stream/footer-spacing.test.ts | 4 +- packages/app/src/agent-stream/layout.test.ts | 6 +- packages/app/src/agent-stream/layout.ts | 20 +-- packages/app/src/agent-stream/model.test.ts | 12 +- .../src/agent-stream/turn-membership.test.ts | 4 +- packages/app/src/agent-stream/view.tsx | 4 +- packages/app/src/timeline/turn-time.test.ts | 35 ++++- packages/app/src/timeline/turn-time.ts | 23 +-- .../src/server/agent/agent-manager.test.ts | 4 +- .../server/src/server/agent/agent-manager.ts | 9 +- .../src/server/agent/agent-prompt.test.ts | 144 ++++++++++++------ .../server/src/server/agent/agent-prompt.ts | 30 ++-- .../src/server/schedule/service.test.ts | 10 +- .../server/test-utils/fake-agent-client.ts | 10 ++ 14 files changed, 203 insertions(+), 112 deletions(-) diff --git a/packages/app/src/agent-stream/footer-spacing.test.ts b/packages/app/src/agent-stream/footer-spacing.test.ts index a2414fff5c..06f732b1a2 100644 --- a/packages/app/src/agent-stream/footer-spacing.test.ts +++ b/packages/app/src/agent-stream/footer-spacing.test.ts @@ -82,7 +82,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean }) isTurnActive: input.isTurnActive, history: strategy.orderTail([userMessage("user"), assistant]), liveHead: [], - timingByAssistantId: timingFor(assistant.id), + timingByResponseItemId: timingFor(assistant.id), }), }; } @@ -126,7 +126,7 @@ describe("turn footer spacing", () => { isTurnActive, history: strategy.orderTail([userMessage("user"), first]), liveHead: strategy.orderHead([last]), - timingByAssistantId: timingFor(last.id), + timingByResponseItemId: timingFor(last.id), }); const firstLayout = layout.history.find((item) => item.item.id === first.id); const lastLayout = layout.liveHead.find((item) => item.item.id === last.id); diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index f2c3376ef2..a131aa3d38 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -105,7 +105,7 @@ function layoutFor(input: { strategy, streamHead: input.head ?? [], }), - timingByAssistantId: timingFor(...(input.timingIds ?? [])), + timingByResponseItemId: timingFor(...(input.timingIds ?? [])), }); } @@ -201,7 +201,7 @@ describe("layoutStream", () => { isTurnActive: true, history, liveHead, - timingByAssistantId: timingFor(priorAssistant.id), + timingByResponseItemId: timingFor(priorAssistant.id), }); liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" }; @@ -210,7 +210,7 @@ describe("layoutStream", () => { isTurnActive: true, history, liveHead, - timingByAssistantId: timingFor(priorAssistant.id), + timingByResponseItemId: timingFor(priorAssistant.id), }); expect(footerOwners(first)).toEqual([]); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index 18bc019e48..a6dd6433fb 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -39,13 +39,13 @@ export interface StreamLayoutInput { isTurnActive: boolean; history: StreamItem[]; liveHead: StreamItem[]; - timingByAssistantId: Map; + timingByResponseItemId: Map; } interface LayoutSegmentInput { strategy: StreamStrategy; items: StreamItem[]; - timingByAssistantId: Map; + timingByResponseItemId: Map; auxiliaryTurnFooter: TurnFooterHost | null; hasAuxiliaryFooter: boolean; frameOrder: StreamFrameChildOrder; @@ -67,12 +67,12 @@ function createTurnFooterHost(input: { item: StreamItem; items: StreamItem[]; index: number; - timingByAssistantId: Map; + timingByResponseItemId: Map; }): TurnFooterHost { return { itemId: input.item.id, items: input.items, - timing: input.timingByAssistantId.get(input.item.id), + timing: input.timingByResponseItemId.get(input.item.id), startIndex: input.index, }; } @@ -147,7 +147,7 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | item: source.item, items: source.items, index: source.index, - timingByAssistantId: input.timingByAssistantId, + timingByResponseItemId: input.timingByResponseItemId, }); } @@ -157,7 +157,7 @@ function resolveCompletedFooter(input: { index: number; item: StreamItem; belowItem: StreamItem | null; - timingByAssistantId: Map; + timingByResponseItemId: Map; auxiliaryTurnFooter: TurnFooterHost | null; boundaryAboveItems: StreamItem[] | null; boundaryAboveIndex: number | null; @@ -180,7 +180,7 @@ function resolveCompletedFooter(input: { item: source.item, items: source.items, index: source.index, - timingByAssistantId: input.timingByAssistantId, + timingByResponseItemId: input.timingByResponseItemId, }); } @@ -309,7 +309,7 @@ function layoutSegmentItem( index, item, belowItem, - timingByAssistantId: input.timingByAssistantId, + timingByResponseItemId: input.timingByResponseItemId, auxiliaryTurnFooter: input.auxiliaryTurnFooter, boundaryAboveItems: input.boundaryAboveItems, boundaryAboveIndex: input.boundaryAboveIndex, @@ -380,7 +380,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { history = layoutSegment({ strategy: input.strategy, items: input.history, - timingByAssistantId: input.timingByAssistantId, + timingByResponseItemId: input.timingByResponseItemId, auxiliaryTurnFooter, hasAuxiliaryFooter, frameOrder, @@ -400,7 +400,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout { const liveHead = layoutSegment({ strategy: input.strategy, items: input.liveHead, - timingByAssistantId: input.timingByAssistantId, + timingByResponseItemId: input.timingByResponseItemId, auxiliaryTurnFooter, hasAuxiliaryFooter, frameOrder, diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts index e29792ab02..9e9a8fd723 100644 --- a/packages/app/src/agent-stream/model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => { historyStart: 2, }); - expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false); - expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({ + expect(model.turnTiming.byResponseItemId.has("hidden-a")).toBe(false); + expect(model.turnTiming.byResponseItemId.get("visible-a")).toEqual({ completedAt: tail[3]?.timestamp, durationMs: 1000, }); @@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp); - expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false); + expect(model.turnTiming.byResponseItemId.has("live-a")).toBe(false); }); it("maps completed turn timing to assistant ids across committed history and live head", () => { @@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.turnTiming.runningStartedAt).toBe(null); - expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({ + expect(model.turnTiming.byResponseItemId.get("live-a")).toEqual({ completedAt: head[0]?.timestamp, durationMs: 3000, }); @@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => { }); expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]); - expect(model.turnTiming.byAssistantId.get("a1")).toEqual({ + expect(model.turnTiming.byResponseItemId.get("a1")).toEqual({ completedAt: tail[1]?.timestamp, durationMs: 3000, }); @@ -238,6 +238,6 @@ describe("buildAgentStreamRenderModel", () => { isMobileBreakpoint: false, }); - expect(model.turnTiming.byAssistantId.size).toBe(0); + expect(model.turnTiming.byResponseItemId.size).toBe(0); }); }); diff --git a/packages/app/src/agent-stream/turn-membership.test.ts b/packages/app/src/agent-stream/turn-membership.test.ts index 4483e4e1a7..837f013cfc 100644 --- a/packages/app/src/agent-stream/turn-membership.test.ts +++ b/packages/app/src/agent-stream/turn-membership.test.ts @@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) { isTurnActive, history: model.segments.historyMounted, liveHead: model.segments.liveHead, - timingByAssistantId: model.turnTiming.byAssistantId, + timingByResponseItemId: model.turnTiming.byResponseItemId, }), }; } @@ -123,7 +123,7 @@ describe("canonical turn membership", () => { const completedItems = [...build(turnId), assistant("done", 9, turnId)]; const completed = layoutFor(completedItems, false); expect(completedFooterIds(completed.layout)).toEqual(["done"]); - expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({ + expect(completed.model.turnTiming.byResponseItemId.get("done")).toMatchObject({ completedAt: at(9), durationMs: 8000, }); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 1e60240be9..db2027c632 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -593,12 +593,12 @@ const AgentStreamViewComponent = forwardRef { + it("retains completed timing for a failed response with or without assistant text", () => { + const userAt = new Date("2026-05-15T00:00:00.000Z"); + const failedAt = new Date("2026-05-15T00:00:07.000Z"); + const error: StreamItem = { + kind: "notification", + sourceType: "error", + level: "error", + id: "error-1", + message: "Provider failed", + timestamp: failedAt, + }; + for (const replies of [[], [assistant("assistant-1", new Date("2026-05-15T00:00:03.000Z"))]]) { + const timing = deriveStreamTurnTiming({ + isTurnActive: false, + activeTurnStartedAt: null, + tail: [user("user-1", userAt), ...replies, error], + head: [], + }); + assert.deepEqual(timing.byResponseItemId.get(error.id), { + completedAt: failedAt, + durationMs: 7000, + }); + } + }); + it("starts elapsed time from the submitted prompt", () => { const submittedAt = new Date("2026-05-15T00:00:00.000Z"); @@ -51,7 +76,7 @@ describe("deriveStreamTurnTiming", () => { }); assert.equal(timing.runningStartedAt, secondUserAt); - assert.equal(timing.byAssistantId.has("a2"), false); + assert.equal(timing.byResponseItemId.has("a2"), false); }); it("derives completed turn timing from user and assistant item timestamps", () => { @@ -69,7 +94,7 @@ describe("deriveStreamTurnTiming", () => { head: [], }); - assert.deepEqual(timing.byAssistantId.get("a1"), { + assert.deepEqual(timing.byResponseItemId.get("a1"), { completedAt: assistantAt, durationMs: 7000, }); @@ -95,8 +120,8 @@ describe("deriveStreamTurnTiming", () => { completedAt: lastAssistantAt, durationMs: 7000, }; - assert.deepEqual(timing.byAssistantId.get("a1"), expected); - assert.deepEqual(timing.byAssistantId.get("a2"), expected); + assert.deepEqual(timing.byResponseItemId.get("a1"), expected); + assert.deepEqual(timing.byResponseItemId.get("a2"), expected); }); it("preserves the completion timestamp when a canonical turn has no visible prompt", () => { @@ -120,7 +145,7 @@ describe("deriveStreamTurnTiming", () => { head: [], }); - assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), { + assert.deepEqual(timing.byResponseItemId.get("hidden-prompt-a2"), { completedAt: hiddenPromptTurnAt, durationMs: null, }); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index 4542962af4..3e3ae1545d 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -7,7 +7,7 @@ export interface TurnTiming { } export interface StreamTurnTiming { - byAssistantId: Map; + byResponseItemId: Map; runningStartedAt: Date | null; } @@ -17,14 +17,14 @@ export function deriveStreamTurnTiming(params: { tail: StreamItem[]; head: StreamItem[]; }): StreamTurnTiming { - const byAssistantId = new Map(); + const byResponseItemId = new Map(); let currentUserAt: Date | null = null; let currentLastItemAt: Date | null = null; - let currentAssistantIds: string[] = []; + let currentResponseItemIds: string[] = []; let previousItem: StreamItem | null = null; const flushCompletedTurn = () => { - if (!currentLastItemAt || currentAssistantIds.length === 0) { + if (!currentLastItemAt || currentResponseItemIds.length === 0) { return; } const timing: TurnTiming = { @@ -33,8 +33,8 @@ export function deriveStreamTurnTiming(params: { ? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()) : null, }; - for (const id of currentAssistantIds) { - byAssistantId.set(id, timing); + for (const id of currentResponseItemIds) { + byResponseItemId.set(id, timing); } }; @@ -43,11 +43,14 @@ export function deriveStreamTurnTiming(params: { flushCompletedTurn(); currentUserAt = item.kind === "user_message" ? item.timestamp : null; currentLastItemAt = null; - currentAssistantIds = []; + currentResponseItemIds = []; } currentLastItemAt = item.timestamp; - if (item.kind === "assistant_message") { - currentAssistantIds.push(item.id); + if ( + item.kind === "assistant_message" || + (item.kind === "notification" && item.sourceType === "error") + ) { + currentResponseItemIds.push(item.id); } previousItem = item; }; @@ -65,7 +68,7 @@ export function deriveStreamTurnTiming(params: { } return { - byAssistantId, + byResponseItemId, runningStartedAt, }; } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 5cf2e0d244..f971a5a358 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -9348,7 +9348,9 @@ test("turn_failed surfaces provider code and diagnostic in system error message" await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed"); - expect(manager.getAgent(agent.id)?.lastError).toBe("Provider execution failed"); + expect(manager.getAgent(agent.id)?.lastError).toBe( + "Provider execution failed\n\ncode: 126\n\nNo preset version installed for command claude", + ); const systemError = manager .getTimeline(agent.id) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index f0a2208186..66a4669fa3 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -4631,13 +4631,8 @@ export class AgentManager { if (!isForegroundEvent && !agent.activeForegroundTurnId) { agent.lifecycle = "error"; } - agent.lastError = event.error; - await this.appendSystemErrorTimelineMessage( - agent, - event.provider, - this.formatTurnFailedMessage(event), - options, - ); + agent.lastError = this.formatTurnFailedMessage(event); + await this.appendSystemErrorTimelineMessage(agent, event.provider, agent.lastError, options); this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed"); if (!isForegroundEvent && !agent.activeForegroundTurnId) { this.emitState(agent); diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 61b24445c2..b1cd46e0aa 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -863,56 +863,100 @@ test("a human pasting an agent envelope stays a human message through provider r }); }); -test("errored finish notifications carry the failure rather than the earlier assistant reply", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-finish-error-")); - const logger = createTestLogger(); - const storage = new AgentStorage(join(workdir, "agents"), logger); - await storage.initialize(); - const prompts: string[] = []; - const manager = new AgentManager({ - logger, - registry: storage, - clients: createTestAgentClients({ - onStartTurn(prompt) { - prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt)); - }, - }), - }); - try { - const parent = await manager.createAgent( - { provider: "claude", cwd: workdir, modeId: "bypassPermissions" }, - undefined, - { workspaceId: undefined }, - ); - const child = await manager.createAgent( - { provider: "claude", cwd: workdir, modeId: "bypassPermissions" }, - undefined, - { workspaceId: undefined }, - ); - await manager.runAgent(child.id, "Earlier successful request"); - const earlierReply = await manager.getLastAssistantMessage(child.id); - expect(earlierReply).toBeTruthy(); - setupFinishNotification({ - agentManager: manager, - agentStorage: storage, - childAgentId: child.id, - callerAgentId: parent.id, - logger, +test.each([ + { + error: "Requested fake provider failure", + code: "126", + diagnostic: "Provider executable failed", + closeChild: true, + }, + { error: "long failure " + "x".repeat(5000), closeChild: false }, +])( + "errored finish notifications preserve a bounded failure while delivery waits ($closeChild)", + async ({ closeChild, ...failure }) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-finish-error-")); + const logger = createTestLogger(); + let releaseRead!: () => void; + let signalRead!: () => void; + const blocked = new Promise((resolve) => { + releaseRead = resolve; }); - await expect(manager.runAgent(child.id, "Emit a turn failure")).rejects.toThrow( - "Requested fake provider failure", - ); - await vi.waitFor(() => + const readStarted = new Promise((resolve) => { + signalRead = resolve; + }); + class HoldingStorage extends AgentStorage { + blockedAgentId: string | null = null; + override async get(id: string) { + if (id === this.blockedAgentId) { + signalRead(); + await blocked; + } + return super.get(id); + } + } + const storage = new HoldingStorage(join(workdir, "agents"), logger); + await storage.initialize(); + const prompts: string[] = []; + const manager = new AgentManager({ + logger, + registry: storage, + clients: createTestAgentClients({ + turnFailure: failure, + onStartTurn(prompt) { + prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt)); + }, + }), + }); + try { + const parent = await manager.createAgent( + { provider: "claude", cwd: workdir, modeId: "bypassPermissions" }, + undefined, + { workspaceId: undefined }, + ); + const child = await manager.createAgent( + { provider: "claude", cwd: workdir, modeId: "bypassPermissions" }, + undefined, + { workspaceId: undefined }, + ); + await manager.runAgent(child.id, "Earlier successful request"); + const earlierReply = await manager.getLastAssistantMessage(child.id); + expect(earlierReply).toBeTruthy(); + storage.blockedAgentId = parent.id; + setupFinishNotification({ + agentManager: manager, + agentStorage: storage, + childAgentId: child.id, + callerAgentId: parent.id, + logger, + }); + await expect(manager.runAgent(child.id, "Emit a turn failure")).rejects.toThrow( + failure.error, + ); + await readStarted; + if (closeChild) await manager.closeAgent(child.id); + releaseRead(); + const fullReason = [ + failure.error, + ...(failure.code ? [`code: ${failure.code}`] : []), + ...(failure.diagnostic ? [failure.diagnostic] : []), + ].join("\n\n"); + const expectedReason = + fullReason.length > 4000 + ? `${fullReason.slice(0, 4000)}\n[truncated ${fullReason.length - 4000} chars; use get_agent_activity for the full response]` + : fullReason; + await vi.waitFor(() => + expect( + parseAgentMessage(prompts.find((prompt) => prompt.includes("errored.")) ?? "")?.text, + ).toContain(`\n${expectedReason}\n`), + ); expect( parseAgentMessage(prompts.find((prompt) => prompt.includes("errored.")) ?? "")?.text, - ).toContain("\nRequested fake provider failure\n"), - ); - expect( - parseAgentMessage(prompts.find((prompt) => prompt.includes("errored.")) ?? "")?.text, - ).not.toContain(""); - await manager.closeAgent(child.id); - await manager.closeAgent(parent.id); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } -}); + ).not.toContain(""); + if (!closeChild) await manager.closeAgent(child.id); + await manager.closeAgent(parent.id); + } finally { + releaseRead(); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 84fef955d3..f98b0358f5 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -434,6 +434,13 @@ interface FinishNotificationBodyInput { permissionRequest?: AgentPermissionRequest; } +function limitFinishNotificationMessage(message: string): string { + const text = message.trim(); + if (text.length <= FINISH_NOTIFICATION_MESSAGE_LIMIT) return text; + const omitted = text.length - FINISH_NOTIFICATION_MESSAGE_LIMIT; + return `${text.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`; +} + function formatFinishNotificationBody(params: FinishNotificationBodyInput): string { const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`; const sections = [statusLine]; @@ -452,20 +459,20 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri ); } if (params.reason === "errored" && params.lastError) { - sections.push(`\n${params.lastError}\n`); + sections.push( + `\n${limitFinishNotificationMessage(params.lastError)}\n`, + ); } - let lastAssistantMessage = params.lastAssistantMessage?.trim(); - if (lastAssistantMessage) { - if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) { - const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT; - lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`; - } - sections.push(`\n${lastAssistantMessage}\n`); + if (params.lastAssistantMessage?.trim()) { + sections.push( + `\n${limitFinishNotificationMessage(params.lastAssistantMessage)}\n`, + ); } return sections.join("\n\n"); } interface NotifySafelyOptions { + lastError?: string; terminal?: boolean; permissionRequest?: AgentPermissionRequest; } @@ -510,6 +517,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): async function notify( reason: FinishNotificationReason, permissionRequest?: AgentPermissionRequest, + lastError?: string, ): Promise { const callerRecord = await agentStorage.get(callerAgentId); if (callerRecord?.archivedAt) { @@ -523,8 +531,6 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): const title = record?.title ?? childAgentId; const lastAssistantMessage = reason === "errored" ? null : await agentManager.getLastAssistantMessage(childAgentId); - const lastError = - reason === "errored" ? agentManager.getAgent(childAgentId)?.lastError : undefined; const body = formatFinishNotificationBody({ childAgentId, title, @@ -554,7 +560,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): if (stopped) return; if (options.terminal ?? true) stop(); notificationQueue = notificationQueue - .then(() => notify(reason, options.permissionRequest)) + .then(() => notify(reason, options.permissionRequest, options.lastError)) .catch((error) => { logger.error( { err: error, childAgentId, callerAgentId, reason }, @@ -580,7 +586,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): // Prior terminal state is not this task's outcome. A failure before turn start // is reported by the dispatch acknowledgement instead of a finish callback. if (agent.lifecycle === "error" && hasSeenTurn) { - notifySafely("errored"); + notifySafely("errored", { lastError: agent.lastError }); return; } // Idle is published before the terminal stream event for both completion diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index f2f69e268e..b3732e7079 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -1781,7 +1781,13 @@ describe("ScheduleService", () => { async (targetType) => { const manager = new AgentManager({ logger: createTestLogger(), - clients: createTestAgentClients(), + clients: createTestAgentClients({ + turnFailure: { + error: "Requested fake provider failure", + code: "126", + diagnostic: "Provider executable failed", + }, + }), registry: agentStorage, }); const service = createScheduleService({ @@ -1815,7 +1821,7 @@ describe("ScheduleService", () => { await service.runOnce(schedule.id); expect((await service.inspect(schedule.id)).runs[0]).toMatchObject({ status: "failed", - error: "Requested fake provider failure", + error: "Requested fake provider failure\n\ncode: 126\n\nProvider executable failed", }); await manager.closeAgent(agent.id); }, diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 1c7127d144..426c44d6ea 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -52,6 +52,7 @@ interface Deferred { } interface FakeAgentSessionOptions { + turnFailure?: TestAgentClientOptions["turnFailure"]; providerName: string; config: AgentSessionConfig; supportsMcpServers?: boolean; @@ -64,6 +65,10 @@ interface FakeAgentSessionOptions { } export interface TestAgentClientOptions { + turnFailure?: Pick< + Extract, + "error" | "code" | "diagnostic" + >; beforeCreateSession?: (config: AgentSessionConfig) => Promise; closeSession?: () => Promise; onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; @@ -343,6 +348,7 @@ class FakeAgentSession implements AgentSession { private readonly closeSession: (() => Promise) | undefined; private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise) | undefined; + private readonly turnFailure: TestAgentClientOptions["turnFailure"]; private readonly historyEvents: readonly AgentStreamEvent[] | undefined; constructor(options: FakeAgentSessionOptions) { @@ -358,6 +364,7 @@ class FakeAgentSession implements AgentSession { this.onStartTurn = options.onStartTurn; this.beforeTurnComplete = options.beforeTurnComplete; this.historyEvents = options.historyEvents; + this.turnFailure = options.turnFailure; this.historyPath = path.join( tmpdir(), "paseo-fake-provider-history", @@ -761,6 +768,7 @@ class FakeAgentSession implements AgentSession { type: "turn_failed", provider: this.providerName, error: "Requested fake provider failure", + ...this.turnFailure, }; await this.appendHistoryEvent(failed); this.notifySubscribers(failed); @@ -1235,6 +1243,7 @@ class FakeAgentClient implements AgentClient { onStartTurn: this.options.onStartTurn, beforeTurnComplete: this.options.beforeTurnComplete, historyEvents: this.options.historyEvents, + turnFailure: this.options.turnFailure, }); } @@ -1262,6 +1271,7 @@ class FakeAgentClient implements AgentClient { onStartTurn: this.options.onStartTurn, beforeTurnComplete: this.options.beforeTurnComplete, historyEvents: this.options.historyEvents, + turnFailure: this.options.turnFailure, }); }