mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +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) {
|
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).toHaveCount(2);
|
||||||
await expect(messages.nth(0)).toContainText(prompt);
|
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> {
|
async function configureSteerInSettings(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ test.describe("Assistant fork menu", () => {
|
|||||||
await openAgentRoute(page, session);
|
await openAgentRoute(page, session);
|
||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
await submitMessage(page, "Emit a synthetic turn failure.");
|
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,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -186,8 +186,9 @@ export async function createCreationScenario(page: Page) {
|
|||||||
await requests.settled();
|
await requests.settled();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
.getByTestId("assistant-message")
|
.getByTestId("timeline-notification")
|
||||||
.filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }),
|
.filter({ hasText: "Requested mock prompt rejection" })
|
||||||
|
.filter({ visible: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page
|
||||||
@@ -203,11 +204,15 @@ export async function createCreationScenario(page: Page) {
|
|||||||
async expectPromptBeforeError(prompt: string) {
|
async expectPromptBeforeError(prompt: string) {
|
||||||
const messages = page
|
const messages = page
|
||||||
.getByTestId("user-message")
|
.getByTestId("user-message")
|
||||||
.or(page.getByTestId("assistant-message"))
|
.or(
|
||||||
|
page
|
||||||
|
.getByTestId("timeline-notification")
|
||||||
|
.filter({ hasText: "Requested mock prompt rejection" }),
|
||||||
|
)
|
||||||
.filter({ visible: true });
|
.filter({ visible: true });
|
||||||
await expect(messages).toHaveCount(2);
|
await expect(messages).toHaveCount(2);
|
||||||
await expect(messages.nth(0)).toContainText(prompt);
|
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();
|
await expect(messages.nth(1)).toBeInViewport();
|
||||||
},
|
},
|
||||||
async reloadAgent() {
|
async reloadAgent() {
|
||||||
@@ -217,10 +222,7 @@ export async function createCreationScenario(page: Page) {
|
|||||||
},
|
},
|
||||||
async expectAssistantReply() {
|
async expectAssistantReply() {
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.getByTestId("assistant-message").filter({ visible: true }).first(),
|
||||||
.getByTestId("assistant-message")
|
|
||||||
.filter({ visible: true, hasNotText: "[System Error]" })
|
|
||||||
.first(),
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
},
|
},
|
||||||
async expectStartupFailure() {
|
async expectStartupFailure() {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
|
|||||||
isTurnActive: input.isTurnActive,
|
isTurnActive: input.isTurnActive,
|
||||||
history: strategy.orderTail([userMessage("user"), assistant]),
|
history: strategy.orderTail([userMessage("user"), assistant]),
|
||||||
liveHead: [],
|
liveHead: [],
|
||||||
timingByAssistantId: timingFor(assistant.id),
|
timingByResponseItemId: timingFor(assistant.id),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ describe("turn footer spacing", () => {
|
|||||||
isTurnActive,
|
isTurnActive,
|
||||||
history: strategy.orderTail([userMessage("user"), first]),
|
history: strategy.orderTail([userMessage("user"), first]),
|
||||||
liveHead: strategy.orderHead([last]),
|
liveHead: strategy.orderHead([last]),
|
||||||
timingByAssistantId: timingFor(last.id),
|
timingByResponseItemId: timingFor(last.id),
|
||||||
});
|
});
|
||||||
const firstLayout = layout.history.find((item) => item.item.id === first.id);
|
const firstLayout = layout.history.find((item) => item.item.id === first.id);
|
||||||
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
|
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function layoutFor(input: {
|
|||||||
strategy,
|
strategy,
|
||||||
streamHead: input.head ?? [],
|
streamHead: input.head ?? [],
|
||||||
}),
|
}),
|
||||||
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
|
timingByResponseItemId: timingFor(...(input.timingIds ?? [])),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +149,35 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("layoutStream", () => {
|
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", () => {
|
it("places one response footer after an adjacent tagged tool-only turn", () => {
|
||||||
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||||
const nextTool = toolCall("next-tool", 2, "turn-2");
|
const nextTool = toolCall("next-tool", 2, "turn-2");
|
||||||
@@ -172,7 +201,7 @@ describe("layoutStream", () => {
|
|||||||
isTurnActive: true,
|
isTurnActive: true,
|
||||||
history,
|
history,
|
||||||
liveHead,
|
liveHead,
|
||||||
timingByAssistantId: timingFor(priorAssistant.id),
|
timingByResponseItemId: timingFor(priorAssistant.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
||||||
@@ -181,7 +210,7 @@ describe("layoutStream", () => {
|
|||||||
isTurnActive: true,
|
isTurnActive: true,
|
||||||
history,
|
history,
|
||||||
liveHead,
|
liveHead,
|
||||||
timingByAssistantId: timingFor(priorAssistant.id),
|
timingByResponseItemId: timingFor(priorAssistant.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(footerOwners(first)).toEqual([]);
|
expect(footerOwners(first)).toEqual([]);
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
|
|||||||
isTurnActive: boolean;
|
isTurnActive: boolean;
|
||||||
history: StreamItem[];
|
history: StreamItem[];
|
||||||
liveHead: StreamItem[];
|
liveHead: StreamItem[];
|
||||||
timingByAssistantId: Map<string, TurnTiming>;
|
timingByResponseItemId: Map<string, TurnTiming>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutSegmentInput {
|
interface LayoutSegmentInput {
|
||||||
strategy: StreamStrategy;
|
strategy: StreamStrategy;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
timingByAssistantId: Map<string, TurnTiming>;
|
timingByResponseItemId: Map<string, TurnTiming>;
|
||||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||||
hasAuxiliaryFooter: boolean;
|
hasAuxiliaryFooter: boolean;
|
||||||
frameOrder: StreamFrameChildOrder;
|
frameOrder: StreamFrameChildOrder;
|
||||||
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
|
|||||||
phase: "streaming" | "complete";
|
phase: "streaming" | "complete";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssistantFooterSource {
|
interface ResponseFooterSource {
|
||||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
item: Extract<StreamItem, { kind: "assistant_message" | "notification" }>;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
@@ -67,23 +67,23 @@ function createTurnFooterHost(input: {
|
|||||||
item: StreamItem;
|
item: StreamItem;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
index: number;
|
index: number;
|
||||||
timingByAssistantId: Map<string, TurnTiming>;
|
timingByResponseItemId: Map<string, TurnTiming>;
|
||||||
}): TurnFooterHost {
|
}): TurnFooterHost {
|
||||||
return {
|
return {
|
||||||
itemId: input.item.id,
|
itemId: input.item.id,
|
||||||
items: input.items,
|
items: input.items,
|
||||||
timing: input.timingByAssistantId.get(input.item.id),
|
timing: input.timingByResponseItemId.get(input.item.id),
|
||||||
startIndex: input.index,
|
startIndex: input.index,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLatestAssistantInResponse(input: {
|
function findLatestFooterSourceInResponse(input: {
|
||||||
strategy: StreamStrategy;
|
strategy: StreamStrategy;
|
||||||
items: StreamItem[];
|
items: StreamItem[];
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
boundaryAboveItems?: StreamItem[] | null;
|
boundaryAboveItems?: StreamItem[] | null;
|
||||||
boundaryAboveIndex?: number | null;
|
boundaryAboveIndex?: number | null;
|
||||||
}): AssistantFooterSource | null {
|
}): ResponseFooterSource | null {
|
||||||
let items = input.items;
|
let items = input.items;
|
||||||
let index = input.startIndex;
|
let index = input.startIndex;
|
||||||
let canCrossBoundary = true;
|
let canCrossBoundary = true;
|
||||||
@@ -99,7 +99,10 @@ function findLatestAssistantInResponse(input: {
|
|||||||
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
|
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (item.kind === "assistant_message") {
|
if (
|
||||||
|
item.kind === "assistant_message" ||
|
||||||
|
(item.kind === "notification" && item.sourceType === "error")
|
||||||
|
) {
|
||||||
return { item, items, index };
|
return { item, items, index };
|
||||||
}
|
}
|
||||||
laterItem = item;
|
laterItem = item;
|
||||||
@@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistant = findLatestAssistantInResponse({
|
const source = findLatestFooterSourceInResponse({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: footerItems,
|
items: footerItems,
|
||||||
startIndex: latestIndex,
|
startIndex: latestIndex,
|
||||||
});
|
});
|
||||||
if (!assistant) {
|
if (!source) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return createTurnFooterHost({
|
return createTurnFooterHost({
|
||||||
item: assistant.item,
|
item: source.item,
|
||||||
items: assistant.items,
|
items: source.items,
|
||||||
index: assistant.index,
|
index: source.index,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByResponseItemId: input.timingByResponseItemId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +157,7 @@ function resolveCompletedFooter(input: {
|
|||||||
index: number;
|
index: number;
|
||||||
item: StreamItem;
|
item: StreamItem;
|
||||||
belowItem: StreamItem | null;
|
belowItem: StreamItem | null;
|
||||||
timingByAssistantId: Map<string, TurnTiming>;
|
timingByResponseItemId: Map<string, TurnTiming>;
|
||||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||||
boundaryAboveItems: StreamItem[] | null;
|
boundaryAboveItems: StreamItem[] | null;
|
||||||
boundaryAboveIndex: number | null;
|
boundaryAboveIndex: number | null;
|
||||||
@@ -163,21 +166,21 @@ function resolveCompletedFooter(input: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistant = findLatestAssistantInResponse({
|
const source = findLatestFooterSourceInResponse({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: input.items,
|
items: input.items,
|
||||||
startIndex: input.index,
|
startIndex: input.index,
|
||||||
boundaryAboveItems: input.boundaryAboveItems,
|
boundaryAboveItems: input.boundaryAboveItems,
|
||||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||||
});
|
});
|
||||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return createTurnFooterHost({
|
return createTurnFooterHost({
|
||||||
item: assistant.item,
|
item: source.item,
|
||||||
items: assistant.items,
|
items: source.items,
|
||||||
index: assistant.index,
|
index: source.index,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByResponseItemId: input.timingByResponseItemId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +309,7 @@ function layoutSegmentItem(
|
|||||||
index,
|
index,
|
||||||
item,
|
item,
|
||||||
belowItem,
|
belowItem,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByResponseItemId: input.timingByResponseItemId,
|
||||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||||
boundaryAboveItems: input.boundaryAboveItems,
|
boundaryAboveItems: input.boundaryAboveItems,
|
||||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||||
@@ -377,7 +380,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
|||||||
history = layoutSegment({
|
history = layoutSegment({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: input.history,
|
items: input.history,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByResponseItemId: input.timingByResponseItemId,
|
||||||
auxiliaryTurnFooter,
|
auxiliaryTurnFooter,
|
||||||
hasAuxiliaryFooter,
|
hasAuxiliaryFooter,
|
||||||
frameOrder,
|
frameOrder,
|
||||||
@@ -397,7 +400,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
|||||||
const liveHead = layoutSegment({
|
const liveHead = layoutSegment({
|
||||||
strategy: input.strategy,
|
strategy: input.strategy,
|
||||||
items: input.liveHead,
|
items: input.liveHead,
|
||||||
timingByAssistantId: input.timingByAssistantId,
|
timingByResponseItemId: input.timingByResponseItemId,
|
||||||
auxiliaryTurnFooter,
|
auxiliaryTurnFooter,
|
||||||
hasAuxiliaryFooter,
|
hasAuxiliaryFooter,
|
||||||
frameOrder,
|
frameOrder,
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
historyStart: 2,
|
historyStart: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
expect(model.turnTiming.byResponseItemId.has("hidden-a")).toBe(false);
|
||||||
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
expect(model.turnTiming.byResponseItemId.get("visible-a")).toEqual({
|
||||||
completedAt: tail[3]?.timestamp,
|
completedAt: tail[3]?.timestamp,
|
||||||
durationMs: 1000,
|
durationMs: 1000,
|
||||||
});
|
});
|
||||||
@@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
|
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", () => {
|
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.runningStartedAt).toBe(null);
|
||||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
expect(model.turnTiming.byResponseItemId.get("live-a")).toEqual({
|
||||||
completedAt: head[0]?.timestamp,
|
completedAt: head[0]?.timestamp,
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
});
|
});
|
||||||
@@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
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,
|
completedAt: tail[1]?.timestamp,
|
||||||
durationMs: 3000,
|
durationMs: 3000,
|
||||||
});
|
});
|
||||||
@@ -238,6 +238,6 @@ describe("buildAgentStreamRenderModel", () => {
|
|||||||
isMobileBreakpoint: false,
|
isMobileBreakpoint: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(model.turnTiming.byAssistantId.size).toBe(0);
|
expect(model.turnTiming.byResponseItemId.size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ function assistantMessage(
|
|||||||
describe("resolveAssistantTurnForkBoundary", () => {
|
describe("resolveAssistantTurnForkBoundary", () => {
|
||||||
it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => {
|
it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => {
|
||||||
const failedTurn = {
|
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 },
|
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export function resolveAssistantTurnForkBoundary(input: {
|
|||||||
supportsTimelineCursor: boolean;
|
supportsTimelineCursor: boolean;
|
||||||
}): AssistantTurnForkBoundary | undefined {
|
}): AssistantTurnForkBoundary | undefined {
|
||||||
const item = input.items[input.startIndex];
|
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") {
|
if (item?.kind !== "assistant_message") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
|
|||||||
isTurnActive,
|
isTurnActive,
|
||||||
history: model.segments.historyMounted,
|
history: model.segments.historyMounted,
|
||||||
liveHead: model.segments.liveHead,
|
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 completedItems = [...build(turnId), assistant("done", 9, turnId)];
|
||||||
const completed = layoutFor(completedItems, false);
|
const completed = layoutFor(completedItems, false);
|
||||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
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),
|
completedAt: at(9),
|
||||||
durationMs: 8000,
|
durationMs: 8000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -593,12 +593,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
isTurnActive,
|
isTurnActive,
|
||||||
history: baseRenderModel.history,
|
history: baseRenderModel.history,
|
||||||
liveHead: baseRenderModel.segments.liveHead,
|
liveHead: baseRenderModel.segments.liveHead,
|
||||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
timingByResponseItemId: baseRenderModel.turnTiming.byResponseItemId,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
baseRenderModel.history,
|
baseRenderModel.history,
|
||||||
baseRenderModel.segments.liveHead,
|
baseRenderModel.segments.liveHead,
|
||||||
baseRenderModel.turnTiming.byAssistantId,
|
baseRenderModel.turnTiming.byResponseItemId,
|
||||||
isTurnActive,
|
isTurnActive,
|
||||||
streamRenderStrategy,
|
streamRenderStrategy,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2161,7 +2161,7 @@ export const Notification = memo(function Notification({
|
|||||||
[resolvedDisableOuterSpacing, config.bg],
|
[resolvedDisableOuterSpacing, config.bg],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle} testID="timeline-notification">
|
||||||
<View style={notificationStylesheet.content}>
|
<View style={notificationStylesheet.content}>
|
||||||
<View style={notificationStylesheet.row}>
|
<View style={notificationStylesheet.row}>
|
||||||
<View style={notificationStylesheet.iconContainer}>
|
<View style={notificationStylesheet.iconContainer}>
|
||||||
|
|||||||
@@ -22,6 +22,31 @@ function assistant(id: string, timestamp: Date): StreamItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("deriveStreamTurnTiming", () => {
|
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", () => {
|
it("starts elapsed time from the submitted prompt", () => {
|
||||||
const submittedAt = new Date("2026-05-15T00:00:00.000Z");
|
const submittedAt = new Date("2026-05-15T00:00:00.000Z");
|
||||||
|
|
||||||
@@ -51,7 +76,7 @@ describe("deriveStreamTurnTiming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(timing.runningStartedAt, secondUserAt);
|
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", () => {
|
it("derives completed turn timing from user and assistant item timestamps", () => {
|
||||||
@@ -69,7 +94,7 @@ describe("deriveStreamTurnTiming", () => {
|
|||||||
head: [],
|
head: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(timing.byAssistantId.get("a1"), {
|
assert.deepEqual(timing.byResponseItemId.get("a1"), {
|
||||||
completedAt: assistantAt,
|
completedAt: assistantAt,
|
||||||
durationMs: 7000,
|
durationMs: 7000,
|
||||||
});
|
});
|
||||||
@@ -95,8 +120,8 @@ describe("deriveStreamTurnTiming", () => {
|
|||||||
completedAt: lastAssistantAt,
|
completedAt: lastAssistantAt,
|
||||||
durationMs: 7000,
|
durationMs: 7000,
|
||||||
};
|
};
|
||||||
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
|
assert.deepEqual(timing.byResponseItemId.get("a1"), expected);
|
||||||
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
|
assert.deepEqual(timing.byResponseItemId.get("a2"), expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
|
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
|
||||||
@@ -120,7 +145,7 @@ describe("deriveStreamTurnTiming", () => {
|
|||||||
head: [],
|
head: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), {
|
assert.deepEqual(timing.byResponseItemId.get("hidden-prompt-a2"), {
|
||||||
completedAt: hiddenPromptTurnAt,
|
completedAt: hiddenPromptTurnAt,
|
||||||
durationMs: null,
|
durationMs: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface TurnTiming {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamTurnTiming {
|
export interface StreamTurnTiming {
|
||||||
byAssistantId: Map<string, TurnTiming>;
|
byResponseItemId: Map<string, TurnTiming>;
|
||||||
runningStartedAt: Date | null;
|
runningStartedAt: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,14 +17,14 @@ export function deriveStreamTurnTiming(params: {
|
|||||||
tail: StreamItem[];
|
tail: StreamItem[];
|
||||||
head: StreamItem[];
|
head: StreamItem[];
|
||||||
}): StreamTurnTiming {
|
}): StreamTurnTiming {
|
||||||
const byAssistantId = new Map<string, TurnTiming>();
|
const byResponseItemId = new Map<string, TurnTiming>();
|
||||||
let currentUserAt: Date | null = null;
|
let currentUserAt: Date | null = null;
|
||||||
let currentLastItemAt: Date | null = null;
|
let currentLastItemAt: Date | null = null;
|
||||||
let currentAssistantIds: string[] = [];
|
let currentResponseItemIds: string[] = [];
|
||||||
let previousItem: StreamItem | null = null;
|
let previousItem: StreamItem | null = null;
|
||||||
|
|
||||||
const flushCompletedTurn = () => {
|
const flushCompletedTurn = () => {
|
||||||
if (!currentLastItemAt || currentAssistantIds.length === 0) {
|
if (!currentLastItemAt || currentResponseItemIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timing: TurnTiming = {
|
const timing: TurnTiming = {
|
||||||
@@ -33,8 +33,8 @@ export function deriveStreamTurnTiming(params: {
|
|||||||
? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime())
|
? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime())
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
for (const id of currentAssistantIds) {
|
for (const id of currentResponseItemIds) {
|
||||||
byAssistantId.set(id, timing);
|
byResponseItemId.set(id, timing);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,11 +43,14 @@ export function deriveStreamTurnTiming(params: {
|
|||||||
flushCompletedTurn();
|
flushCompletedTurn();
|
||||||
currentUserAt = item.kind === "user_message" ? item.timestamp : null;
|
currentUserAt = item.kind === "user_message" ? item.timestamp : null;
|
||||||
currentLastItemAt = null;
|
currentLastItemAt = null;
|
||||||
currentAssistantIds = [];
|
currentResponseItemIds = [];
|
||||||
}
|
}
|
||||||
currentLastItemAt = item.timestamp;
|
currentLastItemAt = item.timestamp;
|
||||||
if (item.kind === "assistant_message") {
|
if (
|
||||||
currentAssistantIds.push(item.id);
|
item.kind === "assistant_message" ||
|
||||||
|
(item.kind === "notification" && item.sourceType === "error")
|
||||||
|
) {
|
||||||
|
currentResponseItemIds.push(item.id);
|
||||||
}
|
}
|
||||||
previousItem = item;
|
previousItem = item;
|
||||||
};
|
};
|
||||||
@@ -65,7 +68,7 @@ export function deriveStreamTurnTiming(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
byAssistantId,
|
byResponseItemId,
|
||||||
runningStartedAt,
|
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([
|
expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([
|
||||||
{ text: "continue after reload", clientMessageId: "stale-session-retry" },
|
{ text: "continue after reload", clientMessageId: "stale-session-retry" },
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(timeline.some((item) => item.type === "error")).toBe(false);
|
||||||
timeline.some(
|
|
||||||
(item) => item.type === "assistant_message" && item.text.includes("[System Error]"),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
} finally {
|
} finally {
|
||||||
await manager.closeAgent(created.id).catch(() => undefined);
|
await manager.closeAgent(created.id).catch(() => undefined);
|
||||||
await manager.flush().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 () => {
|
test.each(["Task completed.", "[System Error] untracked failure"])(
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-"));
|
"preserves terminal fallback and separates untracked failure activity after %s",
|
||||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
async (assistantText) => {
|
||||||
let capturedSession: TestAgentSession | null = null;
|
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 {
|
class LiveEventClient extends TestAgentClient {
|
||||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
capturedSession = new TestAgentSession(config);
|
capturedSession = new TestAgentSession(config);
|
||||||
return capturedSession;
|
return capturedSession;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const manager = new AgentManager({
|
const manager = new AgentManager({
|
||||||
clients: { codex: new LiveEventClient() },
|
clients: { codex: new LiveEventClient() },
|
||||||
registry: storage,
|
registry: storage,
|
||||||
logger,
|
logger,
|
||||||
idFactory: () => "00000000-0000-4000-8000-000000000136",
|
idFactory: () => "00000000-0000-4000-8000-000000000136",
|
||||||
});
|
});
|
||||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||||
workspaceId: undefined,
|
workspaceId: undefined,
|
||||||
});
|
});
|
||||||
|
const events: AgentManagerEvent[] = [];
|
||||||
capturedSession!.pushEvent({
|
manager.subscribe((event) => events.push(event), { agentId: snapshot.id, replayState: false });
|
||||||
type: "turn_failed",
|
await manager.appendTimelineItem(snapshot.id, {
|
||||||
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: "assistant_message",
|
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 () => {
|
test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-"));
|
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",
|
clientMessageId: "rejected-prompt",
|
||||||
messageId: "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(
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
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
|
const systemErrors = manager
|
||||||
.getTimeline(agent.id)
|
.getTimeline(agent.id)
|
||||||
.filter(
|
.filter((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
|
||||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
|
||||||
item.type === "assistant_message" && item.text.includes("[System Error]"),
|
|
||||||
);
|
|
||||||
expect(systemErrors).toHaveLength(1);
|
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 () => {
|
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");
|
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
|
const systemError = manager
|
||||||
.getTimeline(agent.id)
|
.getTimeline(agent.id)
|
||||||
.find(
|
.find((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
|
||||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
expect(systemError?.message).toContain("Provider execution failed");
|
||||||
item.type === "assistant_message" && item.text.includes("[System Error]"),
|
expect(systemError?.message).toContain("code: 126");
|
||||||
);
|
expect(systemError?.message).toContain("No preset version installed for command claude");
|
||||||
expect(systemError?.text).toContain("Provider execution failed");
|
|
||||||
expect(systemError?.text).toContain("code: 126");
|
|
||||||
expect(systemError?.text).toContain("No preset version installed for command claude");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("permission request notifies once without forcing unread attention state", async () => {
|
test("permission request notifies once without forcing unread attention state", async () => {
|
||||||
|
|||||||
@@ -551,8 +551,6 @@ interface AgentMetadataPatch {
|
|||||||
labels?: AgentLabelPatch;
|
labels?: AgentLabelPatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
|
||||||
|
|
||||||
function attachPersistenceCwd(
|
function attachPersistenceCwd(
|
||||||
handle: AgentPersistenceHandle | null,
|
handle: AgentPersistenceHandle | null,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
@@ -4633,13 +4631,8 @@ export class AgentManager {
|
|||||||
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
||||||
agent.lifecycle = "error";
|
agent.lifecycle = "error";
|
||||||
}
|
}
|
||||||
agent.lastError = event.error;
|
agent.lastError = this.formatTurnFailedMessage(event);
|
||||||
await this.appendSystemErrorTimelineMessage(
|
await this.appendSystemErrorTimelineMessage(agent, event.provider, agent.lastError, options);
|
||||||
agent,
|
|
||||||
event.provider,
|
|
||||||
this.formatTurnFailedMessage(event),
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed");
|
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed");
|
||||||
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
||||||
this.emitState(agent);
|
this.emitState(agent);
|
||||||
@@ -4883,13 +4876,12 @@ export class AgentManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`;
|
|
||||||
const lastItem = await this.getLastItemFromStores(agent.id);
|
const lastItem = await this.getLastItemFromStores(agent.id);
|
||||||
if (lastItem?.type === "assistant_message" && lastItem.text === text) {
|
if (lastItem?.type === "error" && lastItem.message === normalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const item: AgentTimelineItem = { type: "assistant_message", text };
|
const item: AgentTimelineItem = { type: "error", message: normalized };
|
||||||
const row = this.recordTimeline(agent.id, item);
|
const row = this.recordTimeline(agent.id, item);
|
||||||
this.dispatchStream(
|
this.dispatchStream(
|
||||||
agent.id,
|
agent.id,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import { AgentManager } from "./agent-manager.js";
|
import { AgentManager } from "./agent-manager.js";
|
||||||
|
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||||
import { AgentStorage } from "./agent-storage.js";
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js";
|
import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js";
|
||||||
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.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",
|
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;
|
title: string;
|
||||||
reason: FinishNotificationReason;
|
reason: FinishNotificationReason;
|
||||||
lastAssistantMessage: string | null;
|
lastAssistantMessage: string | null;
|
||||||
|
lastError?: string;
|
||||||
permissionRequest?: AgentPermissionRequest;
|
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 {
|
function formatFinishNotificationBody(params: FinishNotificationBodyInput): string {
|
||||||
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
|
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
|
||||||
const sections = [statusLine];
|
const sections = [statusLine];
|
||||||
@@ -450,18 +458,21 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
|
|||||||
)}\n</permission-request>`,
|
)}\n</permission-request>`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let lastAssistantMessage = params.lastAssistantMessage?.trim();
|
if (params.reason === "errored" && params.lastError) {
|
||||||
if (lastAssistantMessage) {
|
sections.push(
|
||||||
if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) {
|
`<agent-error>\n${limitFinishNotificationMessage(params.lastError)}\n</agent-error>`,
|
||||||
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]`;
|
}
|
||||||
}
|
if (params.lastAssistantMessage?.trim()) {
|
||||||
sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`);
|
sections.push(
|
||||||
|
`<agent-response>\n${limitFinishNotificationMessage(params.lastAssistantMessage)}\n</agent-response>`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return sections.join("\n\n");
|
return sections.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotifySafelyOptions {
|
interface NotifySafelyOptions {
|
||||||
|
lastError?: string;
|
||||||
terminal?: boolean;
|
terminal?: boolean;
|
||||||
permissionRequest?: AgentPermissionRequest;
|
permissionRequest?: AgentPermissionRequest;
|
||||||
}
|
}
|
||||||
@@ -505,7 +516,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
|||||||
|
|
||||||
async function notify(
|
async function notify(
|
||||||
reason: FinishNotificationReason,
|
reason: FinishNotificationReason,
|
||||||
permissionRequest?: AgentPermissionRequest,
|
{ permissionRequest, lastError }: Pick<NotifySafelyOptions, "permissionRequest" | "lastError">,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const callerRecord = await agentStorage.get(callerAgentId);
|
const callerRecord = await agentStorage.get(callerAgentId);
|
||||||
if (callerRecord?.archivedAt) {
|
if (callerRecord?.archivedAt) {
|
||||||
@@ -517,12 +528,14 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const title = record?.title ?? childAgentId;
|
const title = record?.title ?? childAgentId;
|
||||||
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
|
const lastAssistantMessage =
|
||||||
|
reason === "errored" ? null : await agentManager.getLastAssistantMessage(childAgentId);
|
||||||
const body = formatFinishNotificationBody({
|
const body = formatFinishNotificationBody({
|
||||||
childAgentId,
|
childAgentId,
|
||||||
title,
|
title,
|
||||||
reason,
|
reason,
|
||||||
lastAssistantMessage,
|
lastAssistantMessage,
|
||||||
|
lastError,
|
||||||
permissionRequest,
|
permissionRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -546,7 +559,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
|||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
if (options.terminal ?? true) stop();
|
if (options.terminal ?? true) stop();
|
||||||
notificationQueue = notificationQueue
|
notificationQueue = notificationQueue
|
||||||
.then(() => notify(reason, options.permissionRequest))
|
.then(() => notify(reason, options))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ err: error, childAgentId, callerAgentId, reason },
|
{ 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
|
// 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.
|
// is reported by the dispatch acknowledgement instead of a finish callback.
|
||||||
if (agent.lifecycle === "error" && hasSeenTurn) {
|
if (agent.lifecycle === "error" && hasSeenTurn) {
|
||||||
notifySafely("errored");
|
notifySafely("errored", { lastError: agent.lastError });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Idle is published before the terminal stream event for both completion
|
// Idle is published before the terminal stream event for both completion
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ test.each(["agent", "workspace"] as const)(
|
|||||||
clientMessageId: agent.clientMessageId,
|
clientMessageId: agent.clientMessageId,
|
||||||
messageId: agent.clientMessageId,
|
messageId: agent.clientMessageId,
|
||||||
},
|
},
|
||||||
{ type: "assistant_message", text: `[System Error] ${rejection}` },
|
{ type: "error", message: rejection },
|
||||||
];
|
];
|
||||||
expect(
|
expect(
|
||||||
(await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item),
|
(await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item),
|
||||||
|
|||||||
@@ -1776,6 +1776,57 @@ describe("ScheduleService", () => {
|
|||||||
expect(storedAgent?.archivedAt).toBeTruthy();
|
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 () => {
|
test("records prompt-start failures as failed and archives the scheduled agent", async () => {
|
||||||
class StartFailureScheduleSession implements AgentSession {
|
class StartFailureScheduleSession implements AgentSession {
|
||||||
readonly provider = "claude";
|
readonly provider = "claude";
|
||||||
|
|||||||
@@ -912,6 +912,12 @@ export class ScheduleService {
|
|||||||
requireSchedule(updatedSchedule, params.scheduleId);
|
requireSchedule(updatedSchedule, params.scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduledAgentFailure(agentId: string): Error {
|
||||||
|
return new Error(
|
||||||
|
this.agentManager.getAgent(agentId)?.lastError ?? `Scheduled agent ${agentId} failed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async executeSchedule(
|
private async executeSchedule(
|
||||||
schedule: StoredSchedule,
|
schedule: StoredSchedule,
|
||||||
runId: string,
|
runId: string,
|
||||||
@@ -945,7 +951,7 @@ export class ScheduleService {
|
|||||||
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
|
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
|
||||||
}
|
}
|
||||||
if (waitResult.status === "error") {
|
if (waitResult.status === "error") {
|
||||||
throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`);
|
throw this.scheduledAgentFailure(agent.id);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
@@ -1013,7 +1019,7 @@ export class ScheduleService {
|
|||||||
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
|
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
|
||||||
}
|
}
|
||||||
if (waitResult.status === "error") {
|
if (waitResult.status === "error") {
|
||||||
throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`);
|
throw this.scheduledAgentFailure(agent.id);
|
||||||
}
|
}
|
||||||
const timelineText = curateAgentActivity(result.timeline);
|
const timelineText = curateAgentActivity(result.timeline);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ interface Deferred<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FakeAgentSessionOptions {
|
interface FakeAgentSessionOptions {
|
||||||
|
turnFailure?: TestAgentClientOptions["turnFailure"];
|
||||||
providerName: string;
|
providerName: string;
|
||||||
config: AgentSessionConfig;
|
config: AgentSessionConfig;
|
||||||
supportsMcpServers?: boolean;
|
supportsMcpServers?: boolean;
|
||||||
@@ -64,6 +65,10 @@ interface FakeAgentSessionOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TestAgentClientOptions {
|
export interface TestAgentClientOptions {
|
||||||
|
turnFailure?: Pick<
|
||||||
|
Extract<AgentStreamEvent, { type: "turn_failed" }>,
|
||||||
|
"error" | "code" | "diagnostic"
|
||||||
|
>;
|
||||||
beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>;
|
beforeCreateSession?: (config: AgentSessionConfig) => Promise<void>;
|
||||||
closeSession?: () => Promise<void>;
|
closeSession?: () => Promise<void>;
|
||||||
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
|
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
|
||||||
@@ -343,6 +348,7 @@ class FakeAgentSession implements AgentSession {
|
|||||||
private readonly closeSession: (() => Promise<void>) | undefined;
|
private readonly closeSession: (() => Promise<void>) | undefined;
|
||||||
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
|
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
|
||||||
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
|
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
|
||||||
|
private readonly turnFailure: TestAgentClientOptions["turnFailure"];
|
||||||
private readonly historyEvents: readonly AgentStreamEvent[] | undefined;
|
private readonly historyEvents: readonly AgentStreamEvent[] | undefined;
|
||||||
|
|
||||||
constructor(options: FakeAgentSessionOptions) {
|
constructor(options: FakeAgentSessionOptions) {
|
||||||
@@ -358,6 +364,7 @@ class FakeAgentSession implements AgentSession {
|
|||||||
this.onStartTurn = options.onStartTurn;
|
this.onStartTurn = options.onStartTurn;
|
||||||
this.beforeTurnComplete = options.beforeTurnComplete;
|
this.beforeTurnComplete = options.beforeTurnComplete;
|
||||||
this.historyEvents = options.historyEvents;
|
this.historyEvents = options.historyEvents;
|
||||||
|
this.turnFailure = options.turnFailure;
|
||||||
this.historyPath = path.join(
|
this.historyPath = path.join(
|
||||||
tmpdir(),
|
tmpdir(),
|
||||||
"paseo-fake-provider-history",
|
"paseo-fake-provider-history",
|
||||||
@@ -761,6 +768,7 @@ class FakeAgentSession implements AgentSession {
|
|||||||
type: "turn_failed",
|
type: "turn_failed",
|
||||||
provider: this.providerName,
|
provider: this.providerName,
|
||||||
error: "Requested fake provider failure",
|
error: "Requested fake provider failure",
|
||||||
|
...this.turnFailure,
|
||||||
};
|
};
|
||||||
await this.appendHistoryEvent(failed);
|
await this.appendHistoryEvent(failed);
|
||||||
this.notifySubscribers(failed);
|
this.notifySubscribers(failed);
|
||||||
@@ -1235,6 +1243,7 @@ class FakeAgentClient implements AgentClient {
|
|||||||
onStartTurn: this.options.onStartTurn,
|
onStartTurn: this.options.onStartTurn,
|
||||||
beforeTurnComplete: this.options.beforeTurnComplete,
|
beforeTurnComplete: this.options.beforeTurnComplete,
|
||||||
historyEvents: this.options.historyEvents,
|
historyEvents: this.options.historyEvents,
|
||||||
|
turnFailure: this.options.turnFailure,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1262,6 +1271,7 @@ class FakeAgentClient implements AgentClient {
|
|||||||
onStartTurn: this.options.onStartTurn,
|
onStartTurn: this.options.onStartTurn,
|
||||||
beforeTurnComplete: this.options.beforeTurnComplete,
|
beforeTurnComplete: this.options.beforeTurnComplete,
|
||||||
historyEvents: this.options.historyEvents,
|
historyEvents: this.options.historyEvents,
|
||||||
|
turnFailure: this.options.turnFailure,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user