mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
fix(agents): preserve failure reasons and failed-turn forking
Typed failure activity no longer appears in assistant replies. Attach the response footer to the error cursor and read lastError for failed schedules and child notifications so those consumers retain their behavior.
This commit is contained in:
@@ -303,10 +303,12 @@ async function retryRestoredSubmission(page: Page, prompt: string): Promise<void
|
||||
}
|
||||
|
||||
async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) {
|
||||
const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message"));
|
||||
const messages = page
|
||||
.getByTestId("user-message")
|
||||
.or(page.getByText(errorMessage, { exact: true }));
|
||||
await expect(messages).toHaveCount(2);
|
||||
await expect(messages.nth(0)).toContainText(prompt);
|
||||
await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`);
|
||||
await expect(messages.nth(1)).toHaveText(errorMessage);
|
||||
}
|
||||
|
||||
async function configureSteerInSettings(page: Page): Promise<void> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
|
||||
phase: "streaming" | "complete";
|
||||
}
|
||||
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
interface ResponseFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" | "notification" }>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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("<agent-error>\nRequested fake provider failure\n</agent-error>"),
|
||||
);
|
||||
expect(
|
||||
parseAgentMessage(prompts.find((prompt) => prompt.includes("errored.")) ?? "")?.text,
|
||||
).not.toContain("<agent-response>");
|
||||
await manager.closeAgent(child.id);
|
||||
await manager.closeAgent(parent.id);
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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</permission-request>`,
|
||||
);
|
||||
}
|
||||
if (params.reason === "errored" && params.lastError) {
|
||||
sections.push(`<agent-error>\n${params.lastError}\n</agent-error>`);
|
||||
}
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user