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, }); }