mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-20 16:22:43 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744878bf62 | ||
|
|
e1ce819d11 | ||
|
|
6d7aaf5461 | ||
|
|
9e596e3377 | ||
|
|
e5efa7324d | ||
|
|
303ee418cc | ||
|
|
b282bbb944 | ||
|
|
3e977b5e7b |
@@ -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.getByTestId("timeline-notification").filter({ hasText: errorMessage }));
|
||||
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,9 @@ 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 }),
|
||||
.getByTestId("timeline-notification")
|
||||
.filter({ hasText: "Requested mock prompt rejection" })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
@@ -203,11 +204,15 @@ export async function createCreationScenario(page: Page) {
|
||||
async expectPromptBeforeError(prompt: string) {
|
||||
const messages = page
|
||||
.getByTestId("user-message")
|
||||
.or(page.getByTestId("assistant-message"))
|
||||
.or(
|
||||
page
|
||||
.getByTestId("timeline-notification")
|
||||
.filter({ hasText: "Requested mock prompt rejection" }),
|
||||
)
|
||||
.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 +222,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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -105,7 +105,7 @@ function layoutFor(input: {
|
||||
strategy,
|
||||
streamHead: input.head ?? [],
|
||||
}),
|
||||
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
|
||||
timingByResponseItemId: timingFor(...(input.timingIds ?? [])),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -172,7 +201,7 @@ describe("layoutStream", () => {
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
timingByResponseItemId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
||||
@@ -181,7 +210,7 @@ describe("layoutStream", () => {
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
timingByResponseItemId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
expect(footerOwners(first)).toEqual([]);
|
||||
|
||||
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
|
||||
isTurnActive: boolean;
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByResponseItemId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByResponseItemId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
hasAuxiliaryFooter: boolean;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -67,23 +67,23 @@ function createTurnFooterHost(input: {
|
||||
item: StreamItem;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByResponseItemId: Map<string, TurnTiming>;
|
||||
}): 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,
|
||||
};
|
||||
}
|
||||
|
||||
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,20 +134,20 @@ 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,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
item: source.item,
|
||||
items: source.items,
|
||||
index: source.index,
|
||||
timingByResponseItemId: input.timingByResponseItemId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,7 +157,7 @@ function resolveCompletedFooter(input: {
|
||||
index: number;
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByResponseItemId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
@@ -163,21 +166,21 @@ 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,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
item: source.item,
|
||||
items: source.items,
|
||||
index: source.index,
|
||||
timingByResponseItemId: input.timingByResponseItemId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,7 +309,7 @@ function layoutSegmentItem(
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
timingByResponseItemId: input.timingByResponseItemId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
@@ -377,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,
|
||||
@@ -397,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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -593,12 +593,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
isTurnActive,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
timingByResponseItemId: baseRenderModel.turnTiming.byResponseItemId,
|
||||
}),
|
||||
[
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
baseRenderModel.turnTiming.byResponseItemId,
|
||||
isTurnActive,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
|
||||
@@ -2161,7 +2161,7 @@ export const Notification = memo(function Notification({
|
||||
[resolvedDisableOuterSpacing, config.bg],
|
||||
);
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={containerStyle} testID="timeline-notification">
|
||||
<View style={notificationStylesheet.content}>
|
||||
<View style={notificationStylesheet.row}>
|
||||
<View style={notificationStylesheet.iconContainer}>
|
||||
|
||||
@@ -22,6 +22,31 @@ function assistant(id: string, timestamp: Date): StreamItem {
|
||||
}
|
||||
|
||||
describe("deriveStreamTurnTiming", () => {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface TurnTiming {
|
||||
}
|
||||
|
||||
export interface StreamTurnTiming {
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
byResponseItemId: Map<string, TurnTiming>;
|
||||
runningStartedAt: Date | null;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,14 @@ export function deriveStreamTurnTiming(params: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}): StreamTurnTiming {
|
||||
const byAssistantId = new Map<string, TurnTiming>();
|
||||
const byResponseItemId = new Map<string, TurnTiming>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3966,11 +3966,7 @@ test("a prompt after provider replacement reopens the stale session", async () =
|
||||
expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([
|
||||
{ text: "continue after reload", clientMessageId: "stale-session-retry" },
|
||||
]);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) => item.type === "assistant_message" && item.text.includes("[System Error]"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(timeline.some((item) => item.type === "error")).toBe(false);
|
||||
} finally {
|
||||
await manager.closeAgent(created.id).catch(() => undefined);
|
||||
await manager.flush().catch(() => undefined);
|
||||
@@ -7032,47 +7028,78 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves terminal fallback when no active turn identity was observed", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let capturedSession: TestAgentSession | null = null;
|
||||
test.each(["Task completed.", "[System Error] untracked failure"])(
|
||||
"preserves terminal fallback and separates untracked failure activity after %s",
|
||||
async (assistantText) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let capturedSession: TestAgentSession | null = null;
|
||||
|
||||
class LiveEventClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
capturedSession = new TestAgentSession(config);
|
||||
return capturedSession;
|
||||
class LiveEventClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
capturedSession = new TestAgentSession(config);
|
||||
return capturedSession;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new LiveEventClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000136",
|
||||
});
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
capturedSession!.pushEvent({
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "untracked-turn",
|
||||
error: "untracked failure",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const failed = manager.getAgent(snapshot.id);
|
||||
expect(failed?.lifecycle).toBe("error");
|
||||
expect(failed?.lastError).toBe("untracked failure");
|
||||
});
|
||||
expect(manager.getTimeline(snapshot.id)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new LiveEventClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000136",
|
||||
});
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const events: AgentManagerEvent[] = [];
|
||||
manager.subscribe((event) => events.push(event), { agentId: snapshot.id, replayState: false });
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
text: expect.stringContaining("untracked failure"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
text: assistantText,
|
||||
messageId: "assistant-1",
|
||||
});
|
||||
|
||||
capturedSession!.pushEvent({
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "untracked-turn",
|
||||
error: "untracked failure",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const failed = manager.getAgent(snapshot.id);
|
||||
expect(failed?.lifecycle).toBe("error");
|
||||
expect(failed?.lastError).toBe("untracked failure");
|
||||
});
|
||||
expect(manager.getTimeline(snapshot.id)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
message: expect.stringContaining("untracked failure"),
|
||||
}),
|
||||
);
|
||||
const projected = projectTimelineRows({
|
||||
rows: await manager.getTimelineRows(snapshot.id),
|
||||
mode: "projected",
|
||||
});
|
||||
expect(projected.map((entry) => entry.item)).toEqual([
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: assistantText,
|
||||
messageId: "assistant-1",
|
||||
},
|
||||
{ type: "error", message: "untracked failure" },
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "agent_stream",
|
||||
event: expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: { type: "error", message: "untracked failure" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-"));
|
||||
@@ -8357,7 +8384,7 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist
|
||||
clientMessageId: "rejected-prompt",
|
||||
messageId: "rejected-prompt",
|
||||
},
|
||||
{ type: "assistant_message", text: "[System Error] Invalid request: missing field `text`" },
|
||||
{ type: "error", message: "Invalid request: missing field `text`" },
|
||||
]);
|
||||
|
||||
await expect(manager.runAgent(agent.id, "second turn")).resolves.toEqual(
|
||||
@@ -9184,7 +9211,7 @@ test("archiveAgent cascade surfaces partial child archive failures", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => {
|
||||
test("turn_failed emits error activity and keeps error lifecycle", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -9252,12 +9279,9 @@ test("turn_failed emits a system error assistant timeline message and keeps erro
|
||||
|
||||
const systemErrors = manager
|
||||
.getTimeline(agent.id)
|
||||
.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message" && item.text.includes("[System Error]"),
|
||||
);
|
||||
.filter((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
|
||||
expect(systemErrors).toHaveLength(1);
|
||||
expect(systemErrors[0]?.text).toContain("invalid model id");
|
||||
expect(systemErrors[0]?.message).toContain("invalid model id");
|
||||
});
|
||||
|
||||
test("turn_failed surfaces provider code and diagnostic in system error message", async () => {
|
||||
@@ -9324,17 +9348,16 @@ 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)
|
||||
.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message" && item.text.includes("[System Error]"),
|
||||
);
|
||||
expect(systemError?.text).toContain("Provider execution failed");
|
||||
expect(systemError?.text).toContain("code: 126");
|
||||
expect(systemError?.text).toContain("No preset version installed for command claude");
|
||||
.find((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
|
||||
expect(systemError?.message).toContain("Provider execution failed");
|
||||
expect(systemError?.message).toContain("code: 126");
|
||||
expect(systemError?.message).toContain("No preset version installed for command claude");
|
||||
});
|
||||
|
||||
test("permission request notifies once without forcing unread attention state", async () => {
|
||||
|
||||
@@ -551,8 +551,6 @@ interface AgentMetadataPatch {
|
||||
labels?: AgentLabelPatch;
|
||||
}
|
||||
|
||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
|
||||
function attachPersistenceCwd(
|
||||
handle: AgentPersistenceHandle | null,
|
||||
cwd: string,
|
||||
@@ -4633,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);
|
||||
@@ -4883,13 +4876,12 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`;
|
||||
const lastItem = await this.getLastItemFromStores(agent.id);
|
||||
if (lastItem?.type === "assistant_message" && lastItem.text === text) {
|
||||
if (lastItem?.type === "error" && lastItem.message === normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: AgentTimelineItem = { type: "assistant_message", text };
|
||||
const item: AgentTimelineItem = { type: "error", message: normalized };
|
||||
const row = this.recordTimeline(agent.id, item);
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
|
||||
@@ -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,101 @@ test("a human pasting an agent envelope stays a human message through provider r
|
||||
clientMessageId: "human-submission",
|
||||
});
|
||||
});
|
||||
|
||||
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<void>((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
const readStarted = new Promise<void>((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(`<agent-error>\n${expectedReason}\n</agent-error>`),
|
||||
);
|
||||
expect(
|
||||
parseAgentMessage(prompts.find((prompt) => prompt.includes("errored.")) ?? "")?.text,
|
||||
).not.toContain("<agent-response>");
|
||||
if (!closeChild) await manager.closeAgent(child.id);
|
||||
await manager.closeAgent(parent.id);
|
||||
} finally {
|
||||
releaseRead();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -430,9 +430,17 @@ interface FinishNotificationBodyInput {
|
||||
title: string;
|
||||
reason: FinishNotificationReason;
|
||||
lastAssistantMessage: string | null;
|
||||
lastError?: string;
|
||||
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];
|
||||
@@ -450,18 +458,21 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
|
||||
)}\n</permission-request>`,
|
||||
);
|
||||
}
|
||||
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(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`);
|
||||
if (params.reason === "errored" && params.lastError) {
|
||||
sections.push(
|
||||
`<agent-error>\n${limitFinishNotificationMessage(params.lastError)}\n</agent-error>`,
|
||||
);
|
||||
}
|
||||
if (params.lastAssistantMessage?.trim()) {
|
||||
sections.push(
|
||||
`<agent-response>\n${limitFinishNotificationMessage(params.lastAssistantMessage)}\n</agent-response>`,
|
||||
);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
interface NotifySafelyOptions {
|
||||
lastError?: string;
|
||||
terminal?: boolean;
|
||||
permissionRequest?: AgentPermissionRequest;
|
||||
}
|
||||
@@ -505,7 +516,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
|
||||
async function notify(
|
||||
reason: FinishNotificationReason,
|
||||
permissionRequest?: AgentPermissionRequest,
|
||||
{ permissionRequest, lastError }: Pick<NotifySafelyOptions, "permissionRequest" | "lastError">,
|
||||
): Promise<void> {
|
||||
const callerRecord = await agentStorage.get(callerAgentId);
|
||||
if (callerRecord?.archivedAt) {
|
||||
@@ -517,12 +528,14 @@ 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 body = formatFinishNotificationBody({
|
||||
childAgentId,
|
||||
title,
|
||||
reason,
|
||||
lastAssistantMessage,
|
||||
lastError,
|
||||
permissionRequest,
|
||||
});
|
||||
|
||||
@@ -546,7 +559,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))
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
{ err: error, childAgentId, callerAgentId, reason },
|
||||
@@ -572,7 +585,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
|
||||
|
||||
@@ -374,7 +374,7 @@ test.each(["agent", "workspace"] as const)(
|
||||
clientMessageId: agent.clientMessageId,
|
||||
messageId: agent.clientMessageId,
|
||||
},
|
||||
{ type: "assistant_message", text: `[System Error] ${rejection}` },
|
||||
{ type: "error", message: rejection },
|
||||
];
|
||||
expect(
|
||||
(await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item),
|
||||
|
||||
@@ -1776,6 +1776,57 @@ 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({
|
||||
turnFailure: {
|
||||
error: "Requested fake provider failure",
|
||||
code: "126",
|
||||
diagnostic: "Provider executable failed",
|
||||
},
|
||||
}),
|
||||
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\n\ncode: 126\n\nProvider executable failed",
|
||||
});
|
||||
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 {
|
||||
|
||||
@@ -52,6 +52,7 @@ interface Deferred<T> {
|
||||
}
|
||||
|
||||
interface FakeAgentSessionOptions {
|
||||
turnFailure?: TestAgentClientOptions["turnFailure"];
|
||||
providerName: string;
|
||||
config: AgentSessionConfig;
|
||||
supportsMcpServers?: boolean;
|
||||
@@ -64,6 +65,10 @@ interface FakeAgentSessionOptions {
|
||||
}
|
||||
|
||||
export interface TestAgentClientOptions {
|
||||
turnFailure?: Pick<
|
||||
Extract<AgentStreamEvent, { type: "turn_failed" }>,
|
||||
"error" | "code" | "diagnostic"
|
||||
>;
|
||||
beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>;
|
||||
closeSession?: () => Promise<void>;
|
||||
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
|
||||
@@ -343,6 +348,7 @@ class FakeAgentSession implements AgentSession {
|
||||
private readonly closeSession: (() => Promise<void>) | undefined;
|
||||
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
|
||||
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user