Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 744878bf62 refactor(agents): name finish notification options 2026-10-13 13:26:03 +02:00
paseo-bot[bot] e1ce819d11 fix(agents): retain complete failure outcomes through delivery
Capture the failure reason when the terminal notification is queued, preserve provider diagnostics at their producer, and apply the existing notification text budget. Failed responses also keep their completion timing when their footer is anchored to error activity.
2026-10-13 13:20:08 +02:00
paseo-bot[bot] 6d7aaf5461 test(app): distinguish timeline errors from submission failures
A rejected prompt displays its reason in both the submission status and the timeline. Scope the timeline assertions to the notification row.
2026-10-13 13:05:09 +02:00
paseo-bot[bot] 9e596e3377 fix(agents): preserve failure reasons and failed-turn forking
Typed failure activity no longer appears in assistant replies. Attach the response footer to the error cursor and read lastError for failed schedules and child notifications so those consumers retain their behavior.
2026-10-13 12:59:06 +02:00
paseo-bot[bot] e5efa7324d test(server): expect failure activity during agent creation
Verify fetched and live creation timelines use the typed error item when an initial provider prompt is rejected.
2026-10-13 05:03:13 +02:00
paseo-bot[bot]andAndonis 303ee418cc test(server): verify failure activity on current timeline paths
Keep daemon failures separate from preceding assistant output and verify live error delivery, including assistant text that resembles the legacy error marker. Update startup failure and successful session-replacement assertions for typed errors.

Co-authored-by: Andonis <qq3290949433@gmail.com>
2026-10-13 04:57:22 +02:00
paseo-bot[bot] b282bbb944 Merge remote-tracking branch 'origin/main' into bot/fix-4262-131bae8c 2026-10-13 04:53:51 +02:00
Andonis 3e977b5e7b fix(server): keep system errors separate from replies 2026-09-03 15:56:42 +08:00
51 changed files with 470 additions and 1084 deletions
+5 -6
View File
@@ -151,12 +151,11 @@ lifecycle queue. Resume chooses its history or interactive purpose from the dura
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
close retains the runtime for cleanup and blocks replacement through that close operation.
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained
history session contains only the read results and must release its provider process once replay
finishes. Codex reads the persisted thread and child histories on a temporary app-server and never
loads, resumes, or unarchives a native thread, including legacy records whose native archive failed.
ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process
before returning. Interactive resume remains responsible for repairing a provider session archived
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
releases the process before returning. It never loads, resumes, or unarchives a native thread,
including legacy records whose native archive failed. The retained history session contains only
the read results. Interactive resume remains responsible for repairing a provider session archived
outside Paseo while its Paseo agent is active.
Provider session connection owns every process it spawns until the session is registered with
@@ -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,
});
@@ -1,8 +1,6 @@
import { test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
expectPairingOffer,
expectCompletePairingQr,
expectRelayConsent,
openPairDeviceModal,
preparePairingHost,
@@ -20,20 +18,3 @@ test("opens relay consent in browser web", async ({ page }) => {
await daemon.close();
}
});
test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => {
const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", {
mutableRelay: { enabled: true },
});
try {
await preparePairingHost(page, daemon);
await page.setViewportSize({ width: 320, height: 568 });
await openPairDeviceModal(page);
await expectPairingOffer(page);
await expectCompletePairingQr(page);
await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") });
} finally {
await daemon.close();
}
});
@@ -1,101 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const DRAG_DISTANCE = -40;
function splitDividers(page: Page): Locator {
return page.getByTestId("workspace-split-resize-handle").filter({ visible: true });
}
async function dividerX(divider: Locator): Promise<number> {
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
return box.x;
}
async function dragDivider(divider: Locator, deltaX: number): Promise<void> {
const page = divider.page();
const box = await divider.boundingBox();
if (!box) throw new Error("Divider has no bounding box");
const x = box.x + box.width / 2;
const y = box.y + box.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x + deltaX / 2, y, { steps: 4 });
await page.mouse.move(x + deltaX, y, { steps: 4 });
await page.mouse.up();
}
async function expectDividerFollowsDrag({
page,
index,
}: {
page: Page;
index: number;
}): Promise<void> {
const divider = splitDividers(page).nth(index);
const before = await dividerX(divider);
await dragDivider(divider, DRAG_DISTANCE);
await expect
.poll(async () => (await dividerX(divider)) - before, {
message: `divider ${index + 1} should follow the drag`,
})
.toBeLessThan(DRAG_DISTANCE * 0.75);
}
async function splitRight({
page,
expectedDividers,
}: {
page: Page;
expectedDividers: number;
}): Promise<void> {
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await expect(splitDividers(page)).toHaveCount(expectedDividers);
}
test.describe("Split pane resize", () => {
test("every divider drags in a four-pane row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-resize-fresh-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitRight({ page, expectedDividers: 1 });
await splitRight({ page, expectedDividers: 2 });
await splitRight({ page, expectedDividers: 3 });
for (const index of [0, 1, 2]) {
await expectDividerFollowsDrag({ page, index });
}
} finally {
await workspace.cleanup();
}
});
test("every divider drags after panes are added to a resized row", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "split-resize-grown-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
await clickNewChat(page);
await splitRight({ page, expectedDividers: 1 });
await expectDividerFollowsDrag({ page, index: 0 });
await splitRight({ page, expectedDividers: 2 });
await splitRight({ page, expectedDividers: 3 });
for (const index of [0, 1, 2]) {
await expectDividerFollowsDrag({ page, index });
}
} finally {
await workspace.cleanup();
}
});
});
@@ -1,43 +0,0 @@
// Start on Settings > Host > Pair device with relay enabled in the Android app.
// node packages/app/e2e/mobile/pair-device/android.mjs <agent-device-session>
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
const [, , session] = process.argv;
assert(session, "Pass the agent-device session for the connected Android app");
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 60_000,
});
}
ad(
"press",
'label="Pair a device, Scan a QR code or copy a link to connect your phone to this host"',
"--settle",
);
ad("wait", "text", "Pairing QR code", "10000");
const { data } = JSON.parse(ad("snapshot", "-i", "--json"));
const sheet = data.nodes.find((node) => node.label === "Bottom Sheet");
const qr = data.nodes.find((node) => node.label === "Pairing QR code");
assert(sheet?.rect, "Pairing sheet must be visible");
assert(qr?.rect, "Pairing QR code must be visible");
assert(qr.rect.x > sheet.rect.x, "QR must have an unclipped left margin inside the sheet");
assert(
qr.rect.x + qr.rect.width < sheet.rect.x + sheet.rect.width,
"QR must have an unclipped right margin inside the sheet",
);
assert(Math.abs(qr.rect.width - qr.rect.height) <= 1, "Complete QR must remain square");
assert(qr.rect.y >= sheet.rect.y, "QR top must be visible inside the sheet");
assert(
qr.rect.y + qr.rect.height <= sheet.rect.y + sheet.rect.height,
"QR bottom must be visible inside the sheet",
);
assert(
data.nodes.some((node) => node.label === "Copy" && node.enabled),
"Copy must remain available",
);
console.log("PASS: complete square QR fits inside the pairing sheet", qr.rect);
ad("press", 'label="Close"', "--settle");
+10 -8
View File
@@ -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() {
@@ -220,24 +220,3 @@ export function expectDaemonPidUnchanged(
expect(before).toBeDefined();
expect(after).toBe(before);
}
export async function expectCompletePairingQr(page: Page): Promise<void> {
const qr = page.getByRole("img", { name: "Pairing QR code" });
await qr.scrollIntoViewIfNeeded();
// IntersectionObserver rounds fractional sheet/scroll coordinates.
await expect(qr).toBeInViewport({ ratio: 0.999 });
const bounds = await qr.evaluate((element) => {
const tile = element.parentElement;
if (!tile) throw new Error("Pairing QR tile is missing");
const qrRect = element.getBoundingClientRect();
const tileRect = tile.getBoundingClientRect();
return {
qr: { x: qrRect.x, y: qrRect.y, right: qrRect.right, bottom: qrRect.bottom },
tile: { x: tileRect.x, y: tileRect.y, right: tileRect.right, bottom: tileRect.bottom },
};
});
expect(bounds.qr.x).toBeGreaterThanOrEqual(bounds.tile.x);
expect(bounds.qr.y).toBeGreaterThanOrEqual(bounds.tile.y);
expect(bounds.qr.right).toBeLessThanOrEqual(bounds.tile.right);
expect(bounds.qr.bottom).toBeLessThanOrEqual(bounds.tile.bottom);
}
@@ -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);
+32 -3
View File
@@ -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([]);
+28 -25
View File
@@ -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,
+6 -6
View File
@@ -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,
});
+2 -2
View File
@@ -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,
],
+1 -1
View File
@@ -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}>
@@ -968,12 +968,8 @@ function SplitNodeView({
groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined,
);
const groupChildren = node.kind === "group" ? node.group.children : EMPTY_SPLIT_NODES;
// Dragged sizes live per group id, apart from the layout tree, and a group keeps its id when
// panes are added or removed. They only apply while they still have one entry per child;
// otherwise the dividers past the end of the list could not move.
const layoutGroupSizes = node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES;
const groupSizes =
storedGroupSizes?.length === groupChildren.length ? storedGroupSizes : layoutGroupSizes;
storedGroupSizes ?? (node.kind === "group" ? node.group.sizes : EMPTY_SPLIT_SIZES);
const visibleFlex = useMemo(
() => resolveVisibleGroupFlex(groupChildren, groupSizes, maximizedPaneId),
[groupChildren, groupSizes, maximizedPaneId],
@@ -80,6 +80,7 @@ export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps)
type: "svg",
errorCorrectionLevel: "M",
margin: 1,
width: 480,
}),
enabled: Boolean(pairingQuery.data?.url),
dataShape: "value",
@@ -274,8 +275,7 @@ function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) {
return (
<SvgXml
xml={svg}
width="100%"
height="100%"
style={styles.qrImage}
accessibilityRole="image"
accessibilityLabel={t("pairing.device.qrAccessibility")}
/>
@@ -365,6 +365,10 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
backgroundColor: theme.colors.palette.white,
},
qrImage: {
width: "100%",
height: "100%",
},
linkRow: {
flexDirection: "row",
alignItems: "center",
@@ -1027,71 +1027,6 @@ describe("unassigned shortcuts", () => {
});
describe("matching", () => {
it("dispatches a captured Russian letter shortcut on the same key", () => {
const event = keyboardInput({ key: "и", code: "KeyB", ctrlKey: true });
const combo = keyboardEventToComboString(event as KeyboardEvent);
expect(combo).toBe("Ctrl+B");
const bindings = buildEffectiveBindings({
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
});
expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe(
"sidebar.toggle.left",
);
});
it("dispatches a recorded dotted-uppercase letter with Shift held", () => {
const event = keyboardInput({ key: "İ", code: "KeyS", ctrlKey: true, shiftKey: true });
const combo = keyboardEventToComboString(event as KeyboardEvent);
expect(combo).toBe("Ctrl+Shift+S");
const bindings = buildEffectiveBindings({
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
});
expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe(
"sidebar.toggle.left",
);
});
it.each(["и", "И", "β", "ش", "ב", "é"])(
"matches default and recorded letter bindings for unsupported letter %s",
(key) => {
const event = keyboardInput({ key, code: "KeyB", ctrlKey: true });
expectShortcutResolution({
event,
context: desktopNonMac,
action: "sidebar.toggle.left",
});
const combo = keyboardEventToComboString(event as KeyboardEvent);
expect(combo).toBe("Ctrl+B");
expect(
resolveShortcut({
event,
context: desktopNonMac,
bindings: buildEffectiveBindings({
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
}),
}).match?.action,
).toBe("sidebar.toggle.left");
},
);
it.each(["v", "†", "Dead", "Unidentified", ""])(
"does not match an unrelated character or named key %s by physical letter",
(key) => {
expectNoShortcutResolution({
event: { key, code: "KeyB", ctrlKey: true },
context: desktopNonMac,
});
},
);
it("does not match non-letter bindings by physical key for unsupported letters", () => {
expectNoShortcutResolution({
event: { key: "ю", code: "Period", ctrlKey: true },
context: desktopNonMac,
});
});
it("dispatches a captured Dvorak binding on the same logical key", () => {
const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true });
const combo = keyboardEventToComboString(event as KeyboardEvent);
@@ -1285,12 +1285,6 @@ function matchesKeyOrCode(combo: KeyCombo, event: KeyboardShortcutInput): boolea
if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) {
return true;
}
// Recording an unsupported letter (for example Cyrillic и on KeyB)
// stores its physical A-Z key. Dispatch must accept that same key without
// giving Latin layouts such as Dvorak a second physical-key binding.
if (/^[a-z]$/.test(combo.key) && /^\p{Letter}$/u.test(event.key) && !/^[a-z]$/.test(eventKey)) {
return event.code === combo.code;
}
// macOS rewrites event.key when Option is held (Option+T -> "†",
// Option+[ -> "“"), so Alt-bound letter / bracket bindings can only
// match by event.code. Stay key-first for non-Alt bindings so Dvorak
+30 -5
View File
@@ -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,
});
+13 -10
View File
@@ -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,
};
}
+1 -1
View File
@@ -1051,7 +1051,7 @@ async function waitForProvidersReady(
try {
await observation.ready;
if (signal.aborted) throw new Error("Paseo API is disposed");
signal.throwIfAborted();
return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => {
let settled = false;
let requestId: string | null = null;
@@ -144,34 +144,6 @@ const title = "Hello";
);
});
it("highlights Astro markup containing only an empty expression", () => {
const code = "<p>{}</p>";
const tokens = highlightCode(code, "Empty.astro").flat();
expect(tokens.map((token) => token.text).join("")).toBe(code);
expect(tokens).toContainEqual({ text: "p", style: "tag" });
});
it.each([
"<p>{title} {} {title}</p>",
'<p class="{} {title}">{title}</p>',
"<p class={} title={title}>{title}</p>",
])("highlights Astro containing an empty expression: %s", (markup) => {
const code = `---\nconst title = "Hello";\n---\n${markup}\n<style>.page { color: red; }</style>`;
const lines = highlightCode(code, "Empty.astro");
expect(lines).toHaveLength(code.split("\n").length);
for (const [index, tokens] of lines.entries()) {
expect(tokens.map((token) => token.text).join("")).toBe(code.split("\n")[index]);
}
expect(lines.flat()).toContainEqual({ text: "const", style: "keyword" });
expect(lines.flat()).toContainEqual({ text: "p", style: "tag" });
expect(lines.flat()).toContainEqual({ text: "color", style: "property" });
expect(lines.flat()).toContainEqual({ text: "title", style: "variable" });
});
it("keeps an Astro expression open through nested template strings", () => {
const code = "<p>{`a ${`}`} b`}</p>";
+4 -6
View File
@@ -45,12 +45,10 @@ function maskExpressions(text: string): string {
}
function expressionOverlays(node: SyntaxNodeRef, input: Input): Range[] | null {
const overlays = findExpressions(input.read(node.from, node.to))
.filter(({ from, to }) => to > from + 1)
.map(({ from, to }) => ({
from: node.from + from + 1,
to: node.from + to,
}));
const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({
from: node.from + from + 1,
to: node.from + to,
}));
return overlays.length > 0 ? overlays : null;
}
@@ -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
@@ -1750,7 +1750,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private fallbackAssistantMessageId: string | null = null;
private closed = false;
private historyPending = false;
private historyRuntimeReleased = false;
private replayingHistory = false;
private bootstrapThreadEventPending = false;
private readonly terminateProcess: ProcessTerminator;
@@ -1868,38 +1867,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
await this.applyConfiguredOverrides();
if (this.resumePurpose === "history") {
await this.releaseHistoryRuntime();
}
} catch (error) {
await this.closeAfterInitializationFailure(error);
}
}
// History resumes exist to replay persisted events into the canonical
// timeline; the provider process is released once replay finishes so
// archived agents hold no resident runtime. Interactive work goes through
// reloadAgentSession, which spawns a fresh process.
private async releaseHistoryRuntime(): Promise<void> {
const child = this.child;
if (child) {
const result = await this.terminateProcess(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
});
if (result === "kill-timeout") {
// Keep the child registered so close() can retry the shutdown.
this.logger.warn("ACP history resume could not release the provider process");
return;
}
}
this.historyRuntimeReleased = true;
this.connection = null;
this.child = null;
this.settleCommandsReady();
}
private async closeAfterInitializationFailure(error: unknown): Promise<never> {
try {
await this.close();
@@ -2869,7 +2841,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
});
const spawnError = rejectOnSpawnError(child, stderrChunks);
child.once("exit", (code, signal) => {
if (this.closed || this.historyRuntimeReleased) {
if (this.closed) {
return;
}
if (this.activeForegroundTurnId) {
@@ -1,5 +1,5 @@
import { createServer } from "node:http";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
@@ -13,18 +13,12 @@ test.each([
{ placement: "delta", cacheTokens: 0 },
{ placement: "start", cacheTokens: 1_000 },
{ placement: "delta", cacheTokens: 1_000 },
{ placement: "start", cacheTokens: 0, model: "glm-5.2[1m]" },
{ placement: "start", cacheTokens: 0, model: "sonnet", resolvedModel: "glm-5.2[1m]" },
])(
"counts $placement input and $cacheTokens cached tokens through Claude Code ($model)",
async ({ placement, cacheTokens, model = "claude-sonnet-4-6", resolvedModel = model }) => {
"counts $placement input and $cacheTokens cached tokens through Claude Code",
async ({ placement, cacheTokens }) => {
const cwd = await mkdtemp(path.resolve(".claude-context-usage-"));
const configDir = path.join(cwd, "config");
await mkdir(configDir);
await writeFile(
path.join(configDir, "settings.json"),
JSON.stringify({ env: { ANTHROPIC_DEFAULT_SONNET_MODEL: resolvedModel } }),
);
let requestCount = 0;
const server = createServer(async (request, response) => {
for await (const chunk of request) {
@@ -51,7 +45,7 @@ test.each([
id: `msg_context_usage_${requestCount}`,
type: "message",
role: "assistant",
model: resolvedModel,
model: "claude-sonnet-4-6",
content: [],
stop_reason: null,
stop_sequence: null,
@@ -92,9 +86,11 @@ test.each([
},
},
});
const session = await client.createSession({ provider: "claude", cwd, model }, undefined, {
persistSession: false,
});
const session = await client.createSession(
{ provider: "claude", cwd, model: "claude-sonnet-4-6" },
undefined,
{ persistSession: false },
);
try {
for (let turn = 0; turn < 2; turn++) {
const events = await collectSessionTurnEvents(session, "say hello", {
@@ -115,10 +111,7 @@ test.each([
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
usage: expect.objectContaining({
contextWindowUsedTokens: expectedTokens,
contextWindowMaxTokens: resolvedModel.endsWith("[1m]") ? 1_000_000 : 200_000,
}),
usage: expect.objectContaining({ contextWindowUsedTokens: expectedTokens }),
}),
);
}
@@ -1,112 +0,0 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
// A query dependency that speaks the same stream interface as Claude Code.
// Each request repeats init so a second turn exercises runtime-limit retention.
function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryFactory {
return ({ prompt }) => {
let model = runtimeModel;
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
yield {
type: "system",
subtype: "init",
session_id: "context-session",
permissionMode: "default",
model,
};
yield {
type: "stream_event",
session_id: "context-session",
event: { type: "message_start", message: { usage: { input_tokens: 249_950 } } },
};
yield {
type: "stream_event",
session_id: "context-session",
event: { type: "message_delta", usage: { output_tokens: 50 } },
};
yield {
type: "result",
subtype: "success",
session_id: "context-session",
duration_ms: 1,
duration_api_ms: 1,
is_error: false,
num_turns: 1,
result: "done",
stop_reason: null,
total_cost_usd: 0,
usage: { input_tokens: 249_950, output_tokens: 50, cache_read_input_tokens: 0 },
modelUsage: { [model]: { contextWindow: reportedLimit } },
permission_denials: [],
uuid: "context-result",
};
}
})();
return Object.assign(messages, {
close: () => {
void messages.return();
},
interrupt: async () => undefined,
setModel: async (value: string) => {
model = value;
},
setPermissionMode: async () => undefined,
supportedModels: async () => [],
supportedCommands: async () => [],
}) as ReturnType<ClaudeQueryFactory>;
};
}
test.each([
{ selected: "glm-5.2[1m]", runtime: "glm-5.2[1m]", expected: 1_000_000 },
{ selected: "sonnet", runtime: "glm-5.2[1m]", expected: 1_000_000 },
{ selected: "glm-5.2", runtime: "glm-5.2", expected: undefined },
])(
"streams the context limit for $selected resolved as $runtime",
async ({ selected, runtime, expected }) => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: contextQuery(runtime, 800_000),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: selected,
});
try {
const first = await collectSessionTurnEvents(session, "first turn");
const live = first.filter((event) => event.type === "usage_updated");
expect(live.map((event) => event.usage.contextWindowMaxTokens)).toEqual([expected, expected]);
expect(live.map((event) => event.usage.contextWindowUsedTokens)).toEqual([249_950, 250_000]);
expect(first).toContainEqual(
expect.objectContaining({
type: "turn_completed",
usage: expect.objectContaining({ contextWindowMaxTokens: 800_000 }),
}),
);
const second = await collectSessionTurnEvents(session, "second turn");
expect(
second
.filter((event) => event.type === "usage_updated")
.map((event) => event.usage.contextWindowMaxTokens),
).toEqual([800_000, 800_000]);
await session.setModel?.("another-custom-model");
const switched = await collectSessionTurnEvents(session, "after model switch");
expect(
switched
.filter((event) => event.type === "usage_updated")
.map((event) => event.usage.contextWindowMaxTokens),
).toEqual([undefined, undefined]);
} finally {
await session.close();
}
},
);
@@ -2805,7 +2805,6 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 150,
},
}),
@@ -2841,13 +2840,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("selected 1M Claude models seed active context window usage from the catalog", async () => {
const session = await createSessionForTurns(
[
[
{ ...createInitMessage(), model: "claude-sonnet-5[1m]" },
createMessageStartEvent(),
createSuccessResult(),
],
],
[[createInitMessage(), createMessageStartEvent(), createSuccessResult()]],
{ model: "claude-sonnet-5[1m]" },
);
@@ -2887,7 +2880,6 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 175,
},
}),
@@ -2930,7 +2922,6 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 20_300,
},
}),
@@ -2972,7 +2963,6 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 1_069,
},
}),
@@ -3036,7 +3026,6 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 55,
},
}),
@@ -3046,7 +3035,6 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 62,
},
}),
@@ -3084,7 +3072,6 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 704,
},
}),
@@ -31,7 +31,7 @@ import {
readTaskNotificationToolUseIdFromHistoryRecord,
} from "./task-notification-tool-call.js";
import {
getClaudeContextWindowMaxTokens,
findClaudeModel,
getClaudeModelsWithSettings,
normalizeClaudeRuntimeModelId,
resolveConfiguredClaudeModel,
@@ -2240,7 +2240,7 @@ class ClaudeAgentSession implements AgentSession {
this.resolveBinary = options.resolveBinary;
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
this.contextUsage = new ClaudeContextUsageState(
getClaudeContextWindowMaxTokens(this.config.model),
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
);
const handle = options.handle;
@@ -2572,7 +2572,7 @@ class ClaudeAgentSession implements AgentSession {
await this.applyFastModeFeature(false, activeQuery);
}
this.contextUsage.setInitialContextWindowMaxTokens(
getClaudeContextWindowMaxTokens(this.config.model),
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
);
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
this.lastRuntimeModel = null;
@@ -4821,12 +4821,6 @@ class ClaudeAgentSession implements AgentSession {
this.observePermissionMode(message.permissionMode);
this.persistence = null;
if (message.model) {
const runtimeContextWindowMaxTokens = getClaudeContextWindowMaxTokens(message.model);
// Seed usage before the first result, including settings-resolved aliases.
// A repeated init for the same model must retain the SDK's reported limit.
if (message.model !== this.lastRuntimeModel && runtimeContextWindowMaxTokens !== undefined) {
this.contextUsage.setInitialContextWindowMaxTokens(runtimeContextWindowMaxTokens);
}
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
this.logger.debug(
{ runtimeModel: message.model, normalizedRuntimeModel },
@@ -15,27 +15,7 @@ import {
} from "./model-manifest.js";
import { discoverClaudeModels } from "./model-discovery.js";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import {
findClaudeModel,
getClaudeContextWindowMaxTokens,
getClaudeModels,
normalizeClaudeRuntimeModelId,
} from "./models.js";
describe("getClaudeContextWindowMaxTokens", () => {
it.each([
["glm-5.2[1m]", 1_000_000],
[" glm-5.2[1M] ", 1_000_000],
["claude-sonnet-4-6", 200_000],
["claude-sonnet-4-6[1m]", 1_000_000],
["glm-5.2", undefined],
["glm-5.2[1m]-other", undefined],
[undefined, undefined],
[null, undefined],
])("resolves %s to %s without guessing unknown models", (model, expected) => {
expect(getClaudeContextWindowMaxTokens(model)).toBe(expected);
});
});
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
const createdClaudeConfigDirs: string[] = [];
@@ -48,15 +48,6 @@ export function findClaudeModel(
return getClaudeModels().find((model) => model.id === normalizedModelId);
}
export function getClaudeContextWindowMaxTokens(
modelId: string | null | undefined,
): number | undefined {
if (modelId?.trim().toLowerCase().endsWith("[1m]")) {
return 1_000_000;
}
return findClaudeModel(modelId)?.contextWindowMaxTokens;
}
export async function getClaudeModelsWithSettings(
logger: Logger,
configDir: string,
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -68,60 +68,8 @@ describe("GenericACPAgentClient archived history", () => {
).rejects.toThrow("ENOENT");
});
});
test("releases the provider process once an archived session's history is replayed", async () => {
await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.resumeSession(
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
undefined,
undefined,
{ purpose: "history" },
);
try {
const pid = Number(await readFile(pidPath, "utf8"));
expect(isProcessAlive(pid)).toBe(false);
expect(await collectTimelineText(session.streamHistory())).toEqual([
"user_message:What is in the README?",
"assistant_message:It describes the project.",
]);
} finally {
await session.close();
}
});
});
test("keeps the provider process for an interactive resume", async () => {
await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => {
await mkdir(removedCwd, { recursive: true });
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.resumeSession(
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
undefined,
undefined,
{ purpose: "interactive" },
);
try {
const pid = Number(await readFile(pidPath, "utf8"));
expect(isProcessAlive(pid)).toBe(true);
} finally {
await session.close();
}
});
});
});
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function collectTimelineText(events: AsyncGenerator<AgentStreamEvent>): Promise<string[]> {
const text: string[] = [];
for await (const event of events) {
@@ -140,20 +88,17 @@ async function withFakeACPAgent(
command: [string, ...string[]];
removedCwd: string;
loadRequestsPath: string;
pidPath: string;
}) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-"));
try {
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
const loadRequestsPath = path.join(testDir, "load-requests.json");
const pidPath = path.join(testDir, "agent.pid");
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
await run({
command: [process.execPath, scriptPath, loadRequestsPath, pidPath],
command: [process.execPath, scriptPath, loadRequestsPath],
removedCwd: path.join(testDir, "removed-worktree"),
loadRequestsPath,
pidPath,
});
} finally {
await rm(testDir, { recursive: true, force: true });
@@ -165,10 +110,6 @@ const fs = require("node:fs");
const readline = require("node:readline");
const loadRequestsPath = process.argv[2];
const pidPath = process.argv[3];
if (pidPath) {
fs.writeFileSync(pidPath, String(process.pid));
}
const loadRequests = [];
const rl = readline.createInterface({ input: process.stdin });
@@ -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),
@@ -1,16 +1,7 @@
import { describe, test, expect, beforeEach, afterEach, onTestFinished } from "vitest";
import {
mkdtempSync,
writeFileSync,
rmSync,
mkdirSync,
symlinkSync,
readdirSync,
readlinkSync,
} from "fs";
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { execFileSync } from "node:child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
function tmpCwd(): string {
@@ -396,84 +387,6 @@ describe("file explorer", () => {
}
});
test.skipIf(process.platform === "win32")(
"duplicating a folder keeps relative symlinks inside the copy",
async () => {
const cwd = tmpCwd();
try {
mkdirSync(path.join(cwd, "template"));
writeFileSync(path.join(cwd, "template", "data.txt"), "original payload\n");
symlinkSync("data.txt", path.join(cwd, "template", "alias.txt"));
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "template" });
expect(duplicated).toMatchObject({
duplicatedPath: "template copy",
success: true,
error: null,
});
expect(readlinkSync(path.join(cwd, "template copy", "alias.txt"))).toBe("data.txt");
const copiedTarget = await ctx.client.readFile(cwd, "template copy/data.txt");
const targetWrite = await ctx.client.writeFile({
cwd,
path: "template copy/data.txt",
content: "copy payload\n",
expectedModifiedAt: copiedTarget.modifiedAt,
expectedRevision: copiedTarget.revision,
});
expect(targetWrite.status).toBe("written");
const copiedAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
expect(new TextDecoder().decode(copiedAlias.bytes)).toBe("copy payload\n");
const aliasWrite = await ctx.client.writeFile({
cwd,
path: "template copy/alias.txt",
content: "edited through copied alias\n",
expectedModifiedAt: copiedAlias.modifiedAt,
expectedRevision: copiedAlias.revision,
});
expect(aliasWrite.status).toBe("written");
const original = await ctx.client.readFile(cwd, "template/data.txt");
expect(new TextDecoder().decode(original.bytes)).toBe("original payload\n");
const editedCopy = await ctx.client.readFile(cwd, "template copy/data.txt");
expect(new TextDecoder().decode(editedCopy.bytes)).toBe("edited through copied alias\n");
const deleted = await ctx.client.deleteFileEntry({ cwd, path: "template" });
expect(deleted).toMatchObject({ success: true, error: null });
const survivingAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
expect(new TextDecoder().decode(survivingAlias.bytes)).toBe(
"edited through copied alias\n",
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"duplicating a file symlink preserves an explicit absolute target",
async () => {
const cwd = tmpCwd();
const targetPath = path.join(cwd, "data.txt");
try {
writeFileSync(targetPath, "absolute target payload\n");
symlinkSync(targetPath, path.join(cwd, "alias.txt"));
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "alias.txt" });
expect(duplicated).toMatchObject({
duplicatedPath: "alias copy.txt",
success: true,
error: null,
});
expect(readlinkSync(path.join(cwd, "alias copy.txt"))).toBe(targetPath);
const copy = await ctx.client.readFile(cwd, "alias copy.txt");
expect(new TextDecoder().decode(copy.bytes)).toBe("absolute target payload\n");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test("lists directory contents", async () => {
const cwd = tmpCwd();
@@ -576,63 +489,3 @@ describe("file explorer", () => {
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
});
describe("commit file diffs", () => {
test.each([".", "apps/web"])("opens committed files from %s workspaces", async (relativePath) => {
const cwd = tmpCwd();
onTestFinished(() =>
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
);
const workspace = path.join(cwd, relativePath);
const filePath = "apps/web/notes.txt";
function git(args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
git(["init", "-b", "main"]);
git(["config", "user.name", "Test"]);
git(["config", "user.email", "test@example.com"]);
git(["config", "core.autocrlf", "false"]);
mkdirSync(path.join(cwd, "apps", "web"), { recursive: true });
writeFileSync(path.join(cwd, filePath), "before\n");
writeFileSync(path.join(cwd, "apps/web/untouched.txt"), "unchanged\n");
git(["add", "."]);
git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
git(["checkout", "-b", "feature"]);
writeFileSync(path.join(cwd, filePath), "after\n");
git(["add", "."]);
git(["-c", "commit.gpgsign=false", "commit", "-m", "edit"]);
const sha = git(["rev-parse", "HEAD"]).trim();
const history = await ctx.client.listCheckoutCommits(workspace);
const commit = history.commits.find((entry) => entry.sha === sha);
expect(commit?.files).toEqual([
{ path: filePath, additions: 1, deletions: 1, status: "modified" },
]);
const result = await ctx.client.getCommitFileDiff(workspace, sha, filePath);
expect(result).toEqual({
file: {
path: filePath,
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
},
});
expect(await ctx.client.getCommitFileDiff(workspace, sha, "apps/web/untouched.txt")).toEqual({
file: null,
});
});
});
@@ -1,6 +1,6 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdtempSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import { readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
@@ -63,12 +63,10 @@ test("an empty project becomes Git without changing its identity or creating a w
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: {} });
const projectEvents = client.observeEvents(["project.update"]);
await projectEvents.ready;
const added = await client.addProject(projectRoot);
expect(added).toMatchObject({
expect(added).toEqual({
requestId: expect.any(String),
project: {
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
@@ -79,11 +77,7 @@ test("an empty project becomes Git without changing its identity or creating a w
},
error: null,
});
const {
projectIconRevision: _icon,
projectCustomIconRevision: _customIcon,
...project
} = added.project!;
const project = added.project!;
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
@@ -102,15 +96,15 @@ test("an empty project becomes Git without changing its identity or creating a w
label: "project.update after git init",
});
expect(update).toMatchObject({
expect(update).toEqual({
kind: "upsert",
project: { ...project, projectKind: "git", projectKey: expect.any(String) },
project: { ...project, projectKind: "git" },
});
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(afterGitInit).toMatchObject({
entries: [],
emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }],
emptyProjects: [{ ...project, projectKind: "git" }],
});
const persistedProjects = JSON.parse(
@@ -125,72 +119,5 @@ test("an empty project becomes Git without changing its identity or creating a w
createdAt: expect.any(String),
updatedAt: expect.any(String),
archivedAt: null,
projectKey: expect.any(String),
customIconRevision: null,
});
}, 30_000);
test("a failed worktree attempt before git init does not prevent creation afterward", async () => {
const projectRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-worktree-")));
const paseoHomeRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-home-")));
cleanupPaths.add(projectRoot);
cleanupPaths.add(paseoHomeRoot);
await writeFile(path.join(projectRoot, "README.md"), "hi\n");
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
cleanupDaemons.add(daemon);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: {} });
const projectEvents = client.observeEvents(["project.update"]);
await projectEvents.ready;
const added = await client.addProject(projectRoot);
expect(added.project?.projectKind).toBe("non_git");
const source = {
kind: "worktree" as const,
cwd: projectRoot,
projectId: added.project!.projectId,
action: "branch-off" as const,
refName: "main",
branchName: "after-init",
worktreeSlug: "after-init",
};
const before = await client.createWorkspace({ source });
expect(before.error).toBe("Create worktree requires a git repository");
const gitProjectUpdate = waitForProjectUpdate(
client,
(payload) =>
payload.kind === "upsert" &&
payload.project.projectId === added.project!.projectId &&
payload.project.projectKind === "git",
);
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
await execFile("git", ["add", "README.md"], { cwd: projectRoot });
await execFile(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@getpaseo.dev",
"-c",
"commit.gpgSign=false",
"commit",
"-m",
"initial",
],
{ cwd: projectRoot },
);
await withTimeout({ promise: gitProjectUpdate, timeoutMs: 10_000, label: "project becomes Git" });
const projects = await client.fetchWorkspaces({
filter: { projectId: added.project!.projectId },
});
expect(projects.emptyProjects[0]?.projectKind).toBe("git");
const created = await client.createWorkspace({ source });
expect(created.error).toBeNull();
expect(created.workspace?.workspaceKind).toBe("worktree");
expect(created.workspace?.projectId).toBe(added.project!.projectId);
}, 30_000);
@@ -669,7 +669,6 @@ export async function duplicateExplorerEntry({
errorOnExist: true,
force: false,
preserveTimestamps: true,
verbatimSymlinks: true,
});
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
} catch (error) {
@@ -236,8 +236,7 @@ test("observes a thousand concurrent writes and remains healthy after delete and
const deletions = expectFileChanges(removedPaths, "delete");
const movedFiles = expectFileChanges(movedPaths);
const recovery = expectFileChanges([sentinel]);
const observer = createObserver();
const subscription = await observer.subscribe(root, (error, events) => {
const subscription = await subscribeToFileChanges(root, (error, events) => {
expect(error).toBeNull();
for (const event of events) {
population.record(event);
@@ -255,18 +254,9 @@ test("observes a thousand concurrent writes and remains healthy after delete and
await Promise.all(paths.map((path, index) => writeFile(path, `${index}`)));
await writes.complete;
// The first notice for each write can arrive before its remaining
// classifications. Drain those deliveries before starting a separate deletion
// phase, so a late create cannot coalesce with the expected delete as update.
await expect
.poll(() => observer.getDiagnostics(), { timeout: 10_000 })
.toMatchObject({
pendingEventCount: 0,
pendingClassificationCount: 0,
reconciliationInFlightCount: 0,
});
// Completion still requires the delivered deletion notices.
// A native watcher may recover coalesced paths through its safety audit.
// Completion comes from the delivered deletions, not an idle diagnostic or
// a deadline shorter than that recovery cycle.
await Promise.all(removedPaths.map((path) => rm(path)));
await deletions.complete;
@@ -1,7 +1,3 @@
import { createRequire } from "node:module";
import { runInNewContext } from "node:vm";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { EventEmitter, once } from "node:events";
import { resolveDaemonVersion } from "../daemon-version.js";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
@@ -19,59 +15,6 @@ afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test.each(["React Native", "native"])(
"provider readiness works with %s AbortController",
async (runtime) => {
const require = createRequire(import.meta.url);
const reactNativeRequire = createRequire(require.resolve("react-native/package.json"));
const { AbortController: ReactNativeAbortController } = reactNativeRequire(
"abort-controller/dist/abort-controller",
);
const bundle = await build({
entryPoints: [fileURLToPath(new URL("../../../../client/src/index.ts", import.meta.url))],
bundle: true,
write: false,
format: "iife",
globalName: "paseoSdk",
platform: "browser",
});
// A separate runtime starts with React Native's real abort implementation.
// Node's globals and the daemon's runtime remain unchanged.
const sdk: typeof import("@getpaseo/client") = runInNewContext(
`${bundle.outputFiles[0]!.text}\npaseoSdk`,
{
AbortController: runtime === "React Native" ? ReactNativeAbortController : AbortController,
setTimeout,
clearTimeout,
queueMicrotask,
TextEncoder,
TextDecoder,
},
);
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const api = sdk.createPaseoApi(client);
try {
await client.connect();
const snapshot = await api.providers.waitForReady({ cwd: daemon.staticDir });
expect(snapshot.entries.length).toBeGreaterThan(0);
expect(snapshot.entries.every((entry) => entry.status !== "loading")).toBe(true);
const waiting = api.providers.waitForReady({ cwd: daemon.staticDir });
const canceled = expect(waiting).rejects.toThrow(/disposed|released/);
await api.dispose();
await canceled;
const { diagnostic } = await client.collectDiagnostics();
expect(diagnostic).toContain("Source registrations: 0");
expect(diagnostic).toContain("Producer listeners: 0");
expect(() => api.providers.waitForReady()).toThrow("disposed");
} finally {
await api.dispose();
await client.close();
await daemon.close();
}
},
);
test("plugin handlers create workspaces and agents through their Paseo API", async () => {
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-plugin-"));
const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-api-workspace-"));
@@ -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,
});
}
@@ -625,28 +625,6 @@ describe("highlightDiffFromHunks", () => {
});
describe("highlightDiffWithFileContent", () => {
it("renders an Astro diff with an empty expression and keeps surrounding highlighting", async () => {
const diff = ASTRO_DIFF.replace("{title}", "{title} {}");
const file = parseDiff(diff)[0];
const highlighted = await highlightDiffWithFileContent(file, ".", {
oldFileContent:
'---\nconst title = "Hello";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>',
newFileContent:
'---\nconst title = "Hello, Astro";\n---\n<main class="page">\n <h1>{title} {}</h1>\n</main>',
});
expect(highlighted.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({
text: '"Hello, Astro"',
style: "string",
});
expect(highlighted.hunks[0].lines.map(({ content }) => content)).toEqual(
file.hunks[0].lines.map(({ content }) => content),
);
expect(
highlightDiffFromHunks(file).hunks[0].lines.find((line) => line.type === "add")?.tokens,
).toContainEqual({ text: '"Hello, Astro"', style: "string" });
});
it("leaves files with an oversized line unhighlighted", async () => {
const oversizedLine = `const payload = "${"x".repeat(10_001)}";`;
const [file] = parseDiff(
@@ -63,27 +63,6 @@ afterEach(async () => {
}
});
test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => {
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-")));
const repo = path.join(root, "repo");
mkdirSync(repo);
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(root, "home"),
});
cleanup.push(async () => {
await service.dispose();
rmSync(root, { recursive: true, force: true });
});
expect((await service.getSnapshot(repo, { includeForge: false })).git.isGit).toBe(false);
execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" });
expect((await service.getCheckout(repo)).isGit).toBe(true);
const repoRoot = await service.resolveRepoRoot(repo);
expect(realpathSync.native(repoRoot)).toBe(realpathSync.native(repo));
}, 30_000);
test("recursive observation updates tracked state and prunes ignored storms", async () => {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-git-observation-")));
const repoDir = path.join(tempDir, "repo");
@@ -765,16 +765,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
worktreesRoot: this.worktreesRoot,
logger: this.logger,
});
const cached = this.peekSnapshot(normalizedCwd);
if (cached && cached.git.isGit !== status.isGit) {
// A fresh checkout read discovered a repository transition. Keep the
// shared snapshot current before exposing the new project capability.
await this.getSnapshot(normalizedCwd, {
force: true,
includeForge: false,
reason: "checkout-classification-changed",
});
}
if (!status.isGit) {
return checkoutLiteFromGitSnapshot(normalizedCwd, {
isGit: false,
+4 -5
View File
@@ -2665,11 +2665,10 @@ export async function getCommitFileDiff({
sha: string;
path: string;
}): Promise<ParsedDiffFile | null> {
const worktreeRoot = await requireGitWorktreeRoot(cwd);
const { stdout } = await runGitCommand(
["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path],
{
cwd: worktreeRoot,
cwd,
envOverlay: READ_ONLY_GIT_ENV,
},
);
@@ -2678,9 +2677,9 @@ export async function getCommitFileDiff({
return null;
}
const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, {
getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path),
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
});
// `--` scopes the diff to a single pathspec, so there is at most one real