diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 3bfda6b4b0..8828e35b9d 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -303,10 +303,12 @@ async function retryRestoredSubmission(page: Page, prompt: string): Promise { diff --git a/packages/app/e2e/browser/assistant-fork-menu.spec.ts b/packages/app/e2e/browser/assistant-fork-menu.spec.ts index b5a0b0c27a..e22c4f5fb1 100644 --- a/packages/app/e2e/browser/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/browser/assistant-fork-menu.spec.ts @@ -51,7 +51,7 @@ test.describe("Assistant fork menu", () => { await openAgentRoute(page, session); await expectComposerVisible(page); await submitMessage(page, "Emit a synthetic turn failure."); - await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({ + await expect(page.getByText("Requested mock provider failure")).toBeVisible({ timeout: 30_000, }); diff --git a/packages/app/e2e/support/helpers/creation.ts b/packages/app/e2e/support/helpers/creation.ts index 9098068698..e701c00928 100644 --- a/packages/app/e2e/support/helpers/creation.ts +++ b/packages/app/e2e/support/helpers/creation.ts @@ -186,8 +186,8 @@ export async function createCreationScenario(page: Page) { await requests.settled(); await expect( page - .getByTestId("assistant-message") - .filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }), + .getByText("Requested mock prompt rejection", { exact: true }) + .filter({ visible: true }), ).toBeVisible(); await expect( page @@ -203,11 +203,11 @@ export async function createCreationScenario(page: Page) { async expectPromptBeforeError(prompt: string) { const messages = page .getByTestId("user-message") - .or(page.getByTestId("assistant-message")) + .or(page.getByText("Requested mock prompt rejection", { exact: true })) .filter({ visible: true }); await expect(messages).toHaveCount(2); await expect(messages.nth(0)).toContainText(prompt); - await expect(messages.nth(1)).toContainText("[System Error]"); + await expect(messages.nth(1)).toHaveText("Requested mock prompt rejection"); await expect(messages.nth(1)).toBeInViewport(); }, async reloadAgent() { @@ -217,10 +217,7 @@ export async function createCreationScenario(page: Page) { }, async expectAssistantReply() { await expect( - page - .getByTestId("assistant-message") - .filter({ visible: true, hasNotText: "[System Error]" }) - .first(), + page.getByTestId("assistant-message").filter({ visible: true }).first(), ).toBeVisible(); }, async expectStartupFailure() { diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index 3ab2a9f8f0..f2c3376ef2 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -149,6 +149,35 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem { } describe("layoutStream", () => { + it.each(["web", "android"] as const)( + "hosts failed response footers on the error row on %s", + (platform) => { + const error: StreamItem = { + kind: "notification", + sourceType: "error", + level: "error", + id: "error-1", + message: "Provider failed", + timestamp: timestamp(2), + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }; + const user = userMessage("user-1", 1); + const latest = layoutFor({ platform, tail: [user, error] }); + expect(latest.auxiliaryTurnFooter?.itemId).toBe(error.id); + const completed = layoutFor({ + platform, + tail: [user, error, userMessage("user-2", 3)], + isTurnActive: true, + }); + expect(findLayoutItem(completed, error.id).completedFooter?.itemId).toBe(error.id); + const afterReply = layoutFor({ + platform, + tail: [user, assistantMessage("assistant-1", 2), error], + }); + expect(afterReply.auxiliaryTurnFooter?.itemId).toBe(error.id); + }, + ); + it("places one response footer after an adjacent tagged tool-only turn", () => { const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1"); const nextTool = toolCall("next-tool", 2, "turn-2"); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index d460c1fa2e..18bc019e48 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -57,8 +57,8 @@ interface LayoutSegmentInput { phase: "streaming" | "complete"; } -interface AssistantFooterSource { - item: Extract; +interface ResponseFooterSource { + item: Extract; items: StreamItem[]; index: number; } @@ -77,13 +77,13 @@ function createTurnFooterHost(input: { }; } -function findLatestAssistantInResponse(input: { +function findLatestFooterSourceInResponse(input: { strategy: StreamStrategy; items: StreamItem[]; startIndex: number; boundaryAboveItems?: StreamItem[] | null; boundaryAboveIndex?: number | null; -}): AssistantFooterSource | null { +}): ResponseFooterSource | null { let items = input.items; let index = input.startIndex; let canCrossBoundary = true; @@ -99,7 +99,10 @@ function findLatestAssistantInResponse(input: { if (!item || (laterItem && !continuesResponse(item, laterItem))) { return null; } - if (item.kind === "assistant_message") { + if ( + item.kind === "assistant_message" || + (item.kind === "notification" && item.sourceType === "error") + ) { return { item, items, index }; } laterItem = item; @@ -131,19 +134,19 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | return null; } - const assistant = findLatestAssistantInResponse({ + const source = findLatestFooterSourceInResponse({ strategy: input.strategy, items: footerItems, startIndex: latestIndex, }); - if (!assistant) { + if (!source) { return null; } return createTurnFooterHost({ - item: assistant.item, - items: assistant.items, - index: assistant.index, + item: source.item, + items: source.items, + index: source.index, timingByAssistantId: input.timingByAssistantId, }); } @@ -163,20 +166,20 @@ function resolveCompletedFooter(input: { return null; } - const assistant = findLatestAssistantInResponse({ + const source = findLatestFooterSourceInResponse({ strategy: input.strategy, items: input.items, startIndex: input.index, boundaryAboveItems: input.boundaryAboveItems, boundaryAboveIndex: input.boundaryAboveIndex, }); - if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) { + if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) { return null; } return createTurnFooterHost({ - item: assistant.item, - items: assistant.items, - index: assistant.index, + item: source.item, + items: source.items, + index: source.index, timingByAssistantId: input.timingByAssistantId, }); } diff --git a/packages/app/src/agent-stream/turn-boundary.test.ts b/packages/app/src/agent-stream/turn-boundary.test.ts index 14e5ea776d..270da0e89b 100644 --- a/packages/app/src/agent-stream/turn-boundary.test.ts +++ b/packages/app/src/agent-stream/turn-boundary.test.ts @@ -32,7 +32,12 @@ function assistantMessage( describe("resolveAssistantTurnForkBoundary", () => { it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => { const failedTurn = { - ...assistantMessage("assistant-error", 2), + kind: "notification" as const, + sourceType: "error" as const, + level: "error" as const, + message: "Provider failed", + id: "error-1", + timestamp: timestamp(2), timelineCursor: { epoch: "timeline-1", seq: 42 }, }; diff --git a/packages/app/src/agent-stream/turn-boundary.ts b/packages/app/src/agent-stream/turn-boundary.ts index f7cbe8c00f..428be080b1 100644 --- a/packages/app/src/agent-stream/turn-boundary.ts +++ b/packages/app/src/agent-stream/turn-boundary.ts @@ -10,6 +10,11 @@ export function resolveAssistantTurnForkBoundary(input: { supportsTimelineCursor: boolean; }): AssistantTurnForkBoundary | undefined { const item = input.items[input.startIndex]; + if (item?.kind === "notification" && item.sourceType === "error") { + return input.supportsTimelineCursor && item.timelineCursor + ? { boundaryCursor: item.timelineCursor } + : undefined; + } if (item?.kind !== "assistant_message") { return undefined; } diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index dcdfdb1cbf..61b24445c2 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -15,6 +15,7 @@ import { join } from "node:path"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { AgentStorage } from "./agent-storage.js"; import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js"; import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; @@ -861,3 +862,57 @@ test("a human pasting an agent envelope stays a human message through provider r clientMessageId: "human-submission", }); }); + +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, + }); + await expect(manager.runAgent(child.id, "Emit a turn failure")).rejects.toThrow( + "Requested fake provider failure", + ); + await vi.waitFor(() => + 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 }); + } +}); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 4044fd84bc..84fef955d3 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -430,6 +430,7 @@ interface FinishNotificationBodyInput { title: string; reason: FinishNotificationReason; lastAssistantMessage: string | null; + lastError?: string; permissionRequest?: AgentPermissionRequest; } @@ -450,6 +451,9 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri )}\n`, ); } + if (params.reason === "errored" && params.lastError) { + sections.push(`\n${params.lastError}\n`); + } let lastAssistantMessage = params.lastAssistantMessage?.trim(); if (lastAssistantMessage) { if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) { @@ -517,12 +521,16 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } const title = record?.title ?? childAgentId; - const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId); + const lastAssistantMessage = + reason === "errored" ? null : await agentManager.getLastAssistantMessage(childAgentId); + const lastError = + reason === "errored" ? agentManager.getAgent(childAgentId)?.lastError : undefined; const body = formatFinishNotificationBody({ childAgentId, title, reason, lastAssistantMessage, + lastError, permissionRequest, }); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 1a9f0f5fd4..f2f69e268e 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -1776,6 +1776,51 @@ describe("ScheduleService", () => { expect(storedAgent?.archivedAt).toBeTruthy(); }); + test.each(["new-agent", "agent"] as const)( + "scheduled %s failures preserve the provider reason", + async (targetType) => { + const manager = new AgentManager({ + logger: createTestLogger(), + clients: createTestAgentClients(), + registry: agentStorage, + }); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + }); + const agent = await manager.createAgent( + { provider: "claude", cwd: tempDir, modeId: "bypassPermissions" }, + undefined, + { workspaceId: undefined }, + ); + await manager.runAgent(agent.id, "Earlier successful request"); + const earlierReply = await manager.getLastAssistantMessage(agent.id); + expect(earlierReply).toBeTruthy(); + const schedule = await service.create({ + prompt: "Emit a turn failure", + cadence: { type: "every", everyMs: 60_000 }, + target: + targetType === "agent" + ? { type: "agent", agentId: agent.id } + : { + type: "new-agent", + config: { provider: "claude", cwd: tempDir, archiveOnFinish: false }, + }, + maxRuns: 1, + }); + await service.runOnce(schedule.id); + expect((await service.inspect(schedule.id)).runs[0]).toMatchObject({ + status: "failed", + error: "Requested fake provider failure", + }); + await manager.closeAgent(agent.id); + }, + ); + test("records prompt-start failures as failed and archives the scheduled agent", async () => { class StartFailureScheduleSession implements AgentSession { readonly provider = "claude"; diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 7b5487c3f8..31c0f7965d 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -912,6 +912,12 @@ export class ScheduleService { requireSchedule(updatedSchedule, params.scheduleId); } + private scheduledAgentFailure(agentId: string): Error { + return new Error( + this.agentManager.getAgent(agentId)?.lastError ?? `Scheduled agent ${agentId} failed`, + ); + } + private async executeSchedule( schedule: StoredSchedule, runId: string, @@ -945,7 +951,7 @@ export class ScheduleService { throw new Error(`Scheduled agent ${agent.id} is waiting for permission`); } if (waitResult.status === "error") { - throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`); + throw this.scheduledAgentFailure(agent.id); } return { agentId: agent.id, @@ -1013,7 +1019,7 @@ export class ScheduleService { throw new Error(`Scheduled agent ${agent.id} is waiting for permission`); } if (waitResult.status === "error") { - throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`); + throw this.scheduledAgentFailure(agent.id); } const timelineText = curateAgentActivity(result.timeline); return {