Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] f02537727a test(claude): split the thinking-restart regression into two tests
Each case now states its own setup instead of branching inside one test body.
2026-10-13 16:42:51 +02:00
870c8dc9c5 fix(claude): keep the first turn working after a settings change before it
Changing thinking (or another query setting) on a Claude agent that has no
live query left the restart flag set after a fresh query was created, so
the next prompt failed with "Cannot read properties of null (reading
'push')". A fresh query now consumes the pending restart, and the query
pump drains the query the turn created instead of re-running ensureQuery.

Keeps the submitted-message emission as it was on main; the immediate-
failure rewind change from #5950 is not part of this fix.

Adds a real Claude daemon test for the reported path: create an agent,
set thinking, then send the first message.

Co-authored-by: louis1618 <louis.devop@gmail.com>
Co-authored-by: oyzh888 <oyzh888@users.noreply.github.com>
2026-10-13 16:42:51 +02:00
louis1618 d3c1cc54bb fix(claude): preserve prompt delivery and rewind identity across query restarts 2026-10-13 16:42:51 +02:00
Elowen 5805b776c2 fix(git): open commit diffs from subdirectory workspaces (#6801)
* fix(git): resolve commit file diffs from the worktree root

* test(git): cover commit diffs from nested workspaces through daemon RPC
2026-10-13 15:52:54 +02:00
paseo-bot[bot] 4400445edb Allow worktree creation after Git initialization (#6785)
* fix(server): allow worktree creation after Git initialization

Fresh project checkout reads could publish Git capability while worktree creation retained a cached non-Git snapshot. Refresh an existing snapshot when the fresh read observes a classification change, before returning the new capability.

* test(server): cover Git initialization in the standard integration suite

* test(server): separate stress-test writes from deletion delivery

Wait for active classifications and queued deliveries between the distinct write and deletion phases. Otherwise a late create and delete coalesce as update, leaving the strict deletion assertion waiting indefinitely. Preserve every event assertion without waiting for scheduled native safety audits.

* test(server): compare canonical repository paths on Windows

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 13:55:25 +02:00
Elowen 337e81a9c6 fix(files): preserve relative symlinks when duplicating entries (#6771)
* fix(files): preserve relative symlinks when duplicating entries

* test(files): verify duplicated symlink targets through daemon RPC
2026-10-13 10:56:43 +02:00
paseo-bot[bot] b923cde6cf fix(claude): show custom model context limits during the first turn (#6767)
Seed active usage from the explicit 1M modifier and the model resolved by Claude Code initialization. Preserve SDK-reported limits on repeated initialization and reset them when the selected model changes.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 10:30:03 +02:00
Galaxy Roaming 058375dac8 fix(server): release ACP provider process after history resume (#6766)
* fix(server): release ACP provider process after history resume

Resuming a persisted agent with purpose "history" only replays the
session into the canonical timeline, but ACP sessions kept the spawned
CLI process resident afterwards. Every client that fetches an archived
agent's timeline (the app reconnects fetching every visible agent)
therefore re-materialized a provider process per archived agent and
nothing ever released it — measured ~280 MB resident per dsh agent.

After the replay and configured overrides finish, a history-purpose
resume now settles the commands gate, drops the connection, and
terminates the child. The session object stays registered with the
replayed history so read paths keep working; the existing
`this.child !== child` exit guard already ignores the released
process, and interactive use goes through reloadAgentSession which
spawns a fresh process. This matches the runtime-only contract the
Codex provider already implements.

* fix(server): retain child refs when history-runtime release times out

Addresses Greptile review: if terminateProcess returns kill-timeout the
child stays registered so close() can retry instead of leaking an
untracked process. The test now asserts the process is already dead when
resumeSession resolves, which pins the documented release-before-return
contract instead of a post-hoc poll.

Greptile iteration 1.
2026-10-13 09:05:02 +02:00
paseo-bot[bot] eb051ea81e Render Astro diffs containing empty expressions (#6763)
* fix(highlight): render Astro diffs containing empty expressions

Empty Astro expressions produced zero-length nested JavaScript ranges, which rejected the whole diff. Leave those expressions in the markup tree and preserve highlighting around them.

* test(highlight): make empty Astro expression assertions explicit

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 08:00:47 +02:00
paseo-bot[bot]andGeorgeR 1839de5b1d Restore provider readiness for mobile plugins (#6760)
* fix(client): restore provider readiness on mobile

React Native uses an AbortSignal without throwIfAborted. Check aborted with the existing API disposal error so readiness works while preserving cleanup and cancellation.

Co-authored-by: GeorgeR <897457+GeorgeR@users.noreply.github.com>

* test(plugins): run mobile readiness regression in CI

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: GeorgeR <897457+GeorgeR@users.noreply.github.com>
2026-10-13 06:58:33 +02:00
paseo-bot[bot]andfdkevin ec6393f31f Keep pairing QR codes fully visible on mobile (#6758)
* Keep pairing QR code within compact tile

* Move pairing screenshot after viewport assertion

* test(pairing): verify the complete QR code on Android

The native regression failed with the fixed-size SVG because the code was clipped at both screen edges. Verify the complete square and visible margins through the Android app, and keep browser geometry assertions in the pairing helper.

Co-authored-by: fdkevin <25277753+fdkevin0@users.noreply.github.com>

---------

Co-authored-by: fdkevin <25277753+fdkevin0@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 05:57:41 +02:00
paseo-bot[bot] b5e51cf839 Run letter shortcuts with non-Latin keyboard layouts (#6755)
* fix(app): run recorded letter shortcuts with non-Latin layouts

Recording unsupported letters saves the physical A-Z key, but dispatch rejected that same event. Accept its letter code while keeping ASCII character matching for layouts such as Dvorak.

* fix(app): run recorded dotted-letter shortcuts

Check the original single-letter key before case folding expands uppercase dotted I into two code points.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-13 05:00:54 +02:00
51 changed files with 1084 additions and 490 deletions
+6 -5
View File
@@ -151,11 +151,12 @@ 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. 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
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
outside Paseo while its Paseo agent is active.
Provider session connection owns every process it spawns until the session is registered with
@@ -303,12 +303,10 @@ 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("timeline-notification").filter({ hasText: errorMessage }));
const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message"));
await expect(messages).toHaveCount(2);
await expect(messages.nth(0)).toContainText(prompt);
await expect(messages.nth(1)).toHaveText(errorMessage);
await expect(messages.nth(1)).toContainText(`[System Error] ${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("Requested mock provider failure")).toBeVisible({
await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({
timeout: 30_000,
});
@@ -1,6 +1,8 @@
import { test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
expectPairingOffer,
expectCompletePairingQr,
expectRelayConsent,
openPairDeviceModal,
preparePairingHost,
@@ -18,3 +20,20 @@ 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();
}
});
@@ -0,0 +1,43 @@
// 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");
+8 -10
View File
@@ -186,9 +186,8 @@ export async function createCreationScenario(page: Page) {
await requests.settled();
await expect(
page
.getByTestId("timeline-notification")
.filter({ hasText: "Requested mock prompt rejection" })
.filter({ visible: true }),
.getByTestId("assistant-message")
.filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }),
).toBeVisible();
await expect(
page
@@ -204,15 +203,11 @@ export async function createCreationScenario(page: Page) {
async expectPromptBeforeError(prompt: string) {
const messages = page
.getByTestId("user-message")
.or(
page
.getByTestId("timeline-notification")
.filter({ hasText: "Requested mock prompt rejection" }),
)
.or(page.getByTestId("assistant-message"))
.filter({ visible: true });
await expect(messages).toHaveCount(2);
await expect(messages.nth(0)).toContainText(prompt);
await expect(messages.nth(1)).toHaveText("Requested mock prompt rejection");
await expect(messages.nth(1)).toContainText("[System Error]");
await expect(messages.nth(1)).toBeInViewport();
},
async reloadAgent() {
@@ -222,7 +217,10 @@ export async function createCreationScenario(page: Page) {
},
async expectAssistantReply() {
await expect(
page.getByTestId("assistant-message").filter({ visible: true }).first(),
page
.getByTestId("assistant-message")
.filter({ visible: true, hasNotText: "[System Error]" })
.first(),
).toBeVisible();
},
async expectStartupFailure() {
@@ -220,3 +220,24 @@ 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: [],
timingByResponseItemId: timingFor(assistant.id),
timingByAssistantId: timingFor(assistant.id),
}),
};
}
@@ -126,7 +126,7 @@ describe("turn footer spacing", () => {
isTurnActive,
history: strategy.orderTail([userMessage("user"), first]),
liveHead: strategy.orderHead([last]),
timingByResponseItemId: timingFor(last.id),
timingByAssistantId: 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);
+3 -32
View File
@@ -105,7 +105,7 @@ function layoutFor(input: {
strategy,
streamHead: input.head ?? [],
}),
timingByResponseItemId: timingFor(...(input.timingIds ?? [])),
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
});
}
@@ -149,35 +149,6 @@ 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");
@@ -201,7 +172,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByResponseItemId: timingFor(priorAssistant.id),
timingByAssistantId: timingFor(priorAssistant.id),
});
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
@@ -210,7 +181,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByResponseItemId: timingFor(priorAssistant.id),
timingByAssistantId: timingFor(priorAssistant.id),
});
expect(footerOwners(first)).toEqual([]);
+25 -28
View File
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
isTurnActive: boolean;
history: StreamItem[];
liveHead: StreamItem[];
timingByResponseItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
}
interface LayoutSegmentInput {
strategy: StreamStrategy;
items: StreamItem[];
timingByResponseItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder;
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
phase: "streaming" | "complete";
}
interface ResponseFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" | "notification" }>;
interface AssistantFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>;
items: StreamItem[];
index: number;
}
@@ -67,23 +67,23 @@ function createTurnFooterHost(input: {
item: StreamItem;
items: StreamItem[];
index: number;
timingByResponseItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByResponseItemId.get(input.item.id),
timing: input.timingByAssistantId.get(input.item.id),
startIndex: input.index,
};
}
function findLatestFooterSourceInResponse(input: {
function findLatestAssistantInResponse(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | null;
}): ResponseFooterSource | null {
}): AssistantFooterSource | null {
let items = input.items;
let index = input.startIndex;
let canCrossBoundary = true;
@@ -99,10 +99,7 @@ function findLatestFooterSourceInResponse(input: {
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
return null;
}
if (
item.kind === "assistant_message" ||
(item.kind === "notification" && item.sourceType === "error")
) {
if (item.kind === "assistant_message") {
return { item, items, index };
}
laterItem = item;
@@ -134,20 +131,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null;
}
const source = findLatestFooterSourceInResponse({
const assistant = findLatestAssistantInResponse({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (!source) {
if (!assistant) {
return null;
}
return createTurnFooterHost({
item: source.item,
items: source.items,
index: source.index,
timingByResponseItemId: input.timingByResponseItemId,
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
@@ -157,7 +154,7 @@ function resolveCompletedFooter(input: {
index: number;
item: StreamItem;
belowItem: StreamItem | null;
timingByResponseItemId: Map<string, TurnTiming>;
timingByAssistantId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
@@ -166,21 +163,21 @@ function resolveCompletedFooter(input: {
return null;
}
const source = findLatestFooterSourceInResponse({
const assistant = findLatestAssistantInResponse({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
return null;
}
return createTurnFooterHost({
item: source.item,
items: source.items,
index: source.index,
timingByResponseItemId: input.timingByResponseItemId,
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
});
}
@@ -309,7 +306,7 @@ function layoutSegmentItem(
index,
item,
belowItem,
timingByResponseItemId: input.timingByResponseItemId,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
@@ -380,7 +377,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
history = layoutSegment({
strategy: input.strategy,
items: input.history,
timingByResponseItemId: input.timingByResponseItemId,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
@@ -400,7 +397,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({
strategy: input.strategy,
items: input.liveHead,
timingByResponseItemId: input.timingByResponseItemId,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
+6 -6
View File
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
historyStart: 2,
});
expect(model.turnTiming.byResponseItemId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byResponseItemId.get("visible-a")).toEqual({
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.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.byResponseItemId.has("live-a")).toBe(false);
expect(model.turnTiming.byAssistantId.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.byResponseItemId.get("live-a")).toEqual({
expect(model.turnTiming.byAssistantId.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.byResponseItemId.get("a1")).toEqual({
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
completedAt: tail[1]?.timestamp,
durationMs: 3000,
});
@@ -238,6 +238,6 @@ describe("buildAgentStreamRenderModel", () => {
isMobileBreakpoint: false,
});
expect(model.turnTiming.byResponseItemId.size).toBe(0);
expect(model.turnTiming.byAssistantId.size).toBe(0);
});
});
@@ -32,12 +32,7 @@ function assistantMessage(
describe("resolveAssistantTurnForkBoundary", () => {
it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => {
const failedTurn = {
kind: "notification" as const,
sourceType: "error" as const,
level: "error" as const,
message: "Provider failed",
id: "error-1",
timestamp: timestamp(2),
...assistantMessage("assistant-error", 2),
timelineCursor: { epoch: "timeline-1", seq: 42 },
};
@@ -10,11 +10,6 @@ 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,
timingByResponseItemId: model.turnTiming.byResponseItemId,
timingByAssistantId: model.turnTiming.byAssistantId,
}),
};
}
@@ -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.byResponseItemId.get("done")).toMatchObject({
expect(completed.model.turnTiming.byAssistantId.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,
timingByResponseItemId: baseRenderModel.turnTiming.byResponseItemId,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}),
[
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byResponseItemId,
baseRenderModel.turnTiming.byAssistantId,
isTurnActive,
streamRenderStrategy,
],
+1 -1
View File
@@ -2161,7 +2161,7 @@ export const Notification = memo(function Notification({
[resolvedDisableOuterSpacing, config.bg],
);
return (
<View style={containerStyle} testID="timeline-notification">
<View style={containerStyle}>
<View style={notificationStylesheet.content}>
<View style={notificationStylesheet.row}>
<View style={notificationStylesheet.iconContainer}>
@@ -80,7 +80,6 @@ export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps)
type: "svg",
errorCorrectionLevel: "M",
margin: 1,
width: 480,
}),
enabled: Boolean(pairingQuery.data?.url),
dataShape: "value",
@@ -275,7 +274,8 @@ function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) {
return (
<SvgXml
xml={svg}
style={styles.qrImage}
width="100%"
height="100%"
accessibilityRole="image"
accessibilityLabel={t("pairing.device.qrAccessibility")}
/>
@@ -365,10 +365,6 @@ 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,6 +1027,71 @@ 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,6 +1285,12 @@ 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
+5 -30
View File
@@ -22,31 +22,6 @@ 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");
@@ -76,7 +51,7 @@ describe("deriveStreamTurnTiming", () => {
});
assert.equal(timing.runningStartedAt, secondUserAt);
assert.equal(timing.byResponseItemId.has("a2"), false);
assert.equal(timing.byAssistantId.has("a2"), false);
});
it("derives completed turn timing from user and assistant item timestamps", () => {
@@ -94,7 +69,7 @@ describe("deriveStreamTurnTiming", () => {
head: [],
});
assert.deepEqual(timing.byResponseItemId.get("a1"), {
assert.deepEqual(timing.byAssistantId.get("a1"), {
completedAt: assistantAt,
durationMs: 7000,
});
@@ -120,8 +95,8 @@ describe("deriveStreamTurnTiming", () => {
completedAt: lastAssistantAt,
durationMs: 7000,
};
assert.deepEqual(timing.byResponseItemId.get("a1"), expected);
assert.deepEqual(timing.byResponseItemId.get("a2"), expected);
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
});
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
@@ -145,7 +120,7 @@ describe("deriveStreamTurnTiming", () => {
head: [],
});
assert.deepEqual(timing.byResponseItemId.get("hidden-prompt-a2"), {
assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), {
completedAt: hiddenPromptTurnAt,
durationMs: null,
});
+10 -13
View File
@@ -7,7 +7,7 @@ export interface TurnTiming {
}
export interface StreamTurnTiming {
byResponseItemId: Map<string, TurnTiming>;
byAssistantId: Map<string, TurnTiming>;
runningStartedAt: Date | null;
}
@@ -17,14 +17,14 @@ export function deriveStreamTurnTiming(params: {
tail: StreamItem[];
head: StreamItem[];
}): StreamTurnTiming {
const byResponseItemId = new Map<string, TurnTiming>();
const byAssistantId = new Map<string, TurnTiming>();
let currentUserAt: Date | null = null;
let currentLastItemAt: Date | null = null;
let currentResponseItemIds: string[] = [];
let currentAssistantIds: string[] = [];
let previousItem: StreamItem | null = null;
const flushCompletedTurn = () => {
if (!currentLastItemAt || currentResponseItemIds.length === 0) {
if (!currentLastItemAt || currentAssistantIds.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 currentResponseItemIds) {
byResponseItemId.set(id, timing);
for (const id of currentAssistantIds) {
byAssistantId.set(id, timing);
}
};
@@ -43,14 +43,11 @@ export function deriveStreamTurnTiming(params: {
flushCompletedTurn();
currentUserAt = item.kind === "user_message" ? item.timestamp : null;
currentLastItemAt = null;
currentResponseItemIds = [];
currentAssistantIds = [];
}
currentLastItemAt = item.timestamp;
if (
item.kind === "assistant_message" ||
(item.kind === "notification" && item.sourceType === "error")
) {
currentResponseItemIds.push(item.id);
if (item.kind === "assistant_message") {
currentAssistantIds.push(item.id);
}
previousItem = item;
};
@@ -68,7 +65,7 @@ export function deriveStreamTurnTiming(params: {
}
return {
byResponseItemId,
byAssistantId,
runningStartedAt,
};
}
+1 -1
View File
@@ -1051,7 +1051,7 @@ async function waitForProvidersReady(
try {
await observation.ready;
signal.throwIfAborted();
if (signal.aborted) throw new Error("Paseo API is disposed");
return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => {
let settled = false;
let requestId: string | null = null;
@@ -144,6 +144,34 @@ 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>";
+6 -4
View File
@@ -45,10 +45,12 @@ function maskExpressions(text: string): string {
}
function expressionOverlays(node: SyntaxNodeRef, input: Input): Range[] | null {
const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({
from: node.from + from + 1,
to: node.from + to,
}));
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,
}));
return overlays.length > 0 ? overlays : null;
}
@@ -3966,7 +3966,11 @@ 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 === "error")).toBe(false);
expect(
timeline.some(
(item) => item.type === "assistant_message" && item.text.includes("[System Error]"),
),
).toBe(false);
} finally {
await manager.closeAgent(created.id).catch(() => undefined);
await manager.flush().catch(() => undefined);
@@ -7028,78 +7032,47 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy
});
});
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;
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;
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,
});
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), { agentId: snapshot.id, replayState: false });
await manager.appendTimelineItem(snapshot.id, {
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({
type: "assistant_message",
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" },
}),
}),
);
},
);
text: expect.stringContaining("untracked failure"),
}),
);
});
test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-"));
@@ -8384,7 +8357,7 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist
clientMessageId: "rejected-prompt",
messageId: "rejected-prompt",
},
{ type: "error", message: "Invalid request: missing field `text`" },
{ type: "assistant_message", text: "[System Error] Invalid request: missing field `text`" },
]);
await expect(manager.runAgent(agent.id, "second turn")).resolves.toEqual(
@@ -9211,7 +9184,7 @@ test("archiveAgent cascade surfaces partial child archive failures", async () =>
);
});
test("turn_failed emits error activity and keeps error lifecycle", async () => {
test("turn_failed emits a system error assistant timeline message 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);
@@ -9279,9 +9252,12 @@ test("turn_failed emits error activity and keeps error lifecycle", async () => {
const systemErrors = manager
.getTimeline(agent.id)
.filter((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
.filter(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message" && item.text.includes("[System Error]"),
);
expect(systemErrors).toHaveLength(1);
expect(systemErrors[0]?.message).toContain("invalid model id");
expect(systemErrors[0]?.text).toContain("invalid model id");
});
test("turn_failed surfaces provider code and diagnostic in system error message", async () => {
@@ -9348,16 +9324,17 @@ 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\n\ncode: 126\n\nNo preset version installed for command claude",
);
expect(manager.getAgent(agent.id)?.lastError).toBe("Provider execution failed");
const systemError = manager
.getTimeline(agent.id)
.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");
.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");
});
test("permission request notifies once without forcing unread attention state", async () => {
@@ -551,6 +551,8 @@ interface AgentMetadataPatch {
labels?: AgentLabelPatch;
}
const SYSTEM_ERROR_PREFIX = "[System Error]";
function attachPersistenceCwd(
handle: AgentPersistenceHandle | null,
cwd: string,
@@ -4631,8 +4633,13 @@ export class AgentManager {
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
agent.lifecycle = "error";
}
agent.lastError = this.formatTurnFailedMessage(event);
await this.appendSystemErrorTimelineMessage(agent, event.provider, agent.lastError, options);
agent.lastError = event.error;
await this.appendSystemErrorTimelineMessage(
agent,
event.provider,
this.formatTurnFailedMessage(event),
options,
);
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed");
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
this.emitState(agent);
@@ -4876,12 +4883,13 @@ export class AgentManager {
return;
}
const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`;
const lastItem = await this.getLastItemFromStores(agent.id);
if (lastItem?.type === "error" && lastItem.message === normalized) {
if (lastItem?.type === "assistant_message" && lastItem.text === text) {
return;
}
const item: AgentTimelineItem = { type: "error", message: normalized };
const item: AgentTimelineItem = { type: "assistant_message", text };
const row = this.recordTimeline(agent.id, item);
this.dispatchStream(
agent.id,
@@ -15,7 +15,6 @@ 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";
@@ -862,101 +861,3 @@ 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,17 +430,9 @@ 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];
@@ -458,21 +450,18 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
)}\n</permission-request>`,
);
}
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>`,
);
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>`);
}
return sections.join("\n\n");
}
interface NotifySafelyOptions {
lastError?: string;
terminal?: boolean;
permissionRequest?: AgentPermissionRequest;
}
@@ -516,7 +505,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
async function notify(
reason: FinishNotificationReason,
{ permissionRequest, lastError }: Pick<NotifySafelyOptions, "permissionRequest" | "lastError">,
permissionRequest?: AgentPermissionRequest,
): Promise<void> {
const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) {
@@ -528,14 +517,12 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return;
}
const title = record?.title ?? childAgentId;
const lastAssistantMessage =
reason === "errored" ? null : await agentManager.getLastAssistantMessage(childAgentId);
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
const body = formatFinishNotificationBody({
childAgentId,
title,
reason,
lastAssistantMessage,
lastError,
permissionRequest,
});
@@ -559,7 +546,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
if (stopped) return;
if (options.terminal ?? true) stop();
notificationQueue = notificationQueue
.then(() => notify(reason, options))
.then(() => notify(reason, options.permissionRequest))
.catch((error) => {
logger.error(
{ err: error, childAgentId, callerAgentId, reason },
@@ -585,7 +572,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", { lastError: agent.lastError });
notifySafely("errored");
return;
}
// Idle is published before the terminal stream event for both completion
@@ -1750,6 +1750,7 @@ 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;
@@ -1867,11 +1868,38 @@ 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();
@@ -2841,7 +2869,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
});
const spawnError = rejectOnSpawnError(child, stderrChunks);
child.once("exit", (code, signal) => {
if (this.closed) {
if (this.closed || this.historyRuntimeReleased) {
return;
}
if (this.activeForegroundTurnId) {
@@ -1,5 +1,5 @@
import { createServer } from "node:http";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
@@ -13,12 +13,18 @@ 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",
async ({ placement, cacheTokens }) => {
"counts $placement input and $cacheTokens cached tokens through Claude Code ($model)",
async ({ placement, cacheTokens, model = "claude-sonnet-4-6", resolvedModel = model }) => {
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) {
@@ -45,7 +51,7 @@ test.each([
id: `msg_context_usage_${requestCount}`,
type: "message",
role: "assistant",
model: "claude-sonnet-4-6",
model: resolvedModel,
content: [],
stop_reason: null,
stop_sequence: null,
@@ -86,11 +92,9 @@ test.each([
},
},
});
const session = await client.createSession(
{ provider: "claude", cwd, model: "claude-sonnet-4-6" },
undefined,
{ persistSession: false },
);
const session = await client.createSession({ provider: "claude", cwd, model }, undefined, {
persistSession: false,
});
try {
for (let turn = 0; turn < 2; turn++) {
const events = await collectSessionTurnEvents(session, "say hello", {
@@ -111,7 +115,10 @@ test.each([
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
usage: expect.objectContaining({ contextWindowUsedTokens: expectedTokens }),
usage: expect.objectContaining({
contextWindowUsedTokens: expectedTokens,
contextWindowMaxTokens: resolvedModel.endsWith("[1m]") ? 1_000_000 : 200_000,
}),
}),
);
}
@@ -0,0 +1,112 @@
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();
}
},
);
@@ -12,6 +12,7 @@ interface QueryMock {
close: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
applyFlagSettings: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
rewindFiles: ReturnType<typeof vi.fn>;
@@ -148,6 +149,7 @@ function createScriptedQuery(params: {
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
applyFlagSettings: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
@@ -276,6 +278,64 @@ afterEach(() => {
queryFactory.mockReset();
});
async function createThinkingRestartSession() {
const queries: ScriptedQuery[] = [];
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({
prompt,
sessionId: "thinking-restart-session",
handlePrompt: ({ query }) => query.emit(buildSuccessResult("thinking-restart-session")),
});
queries.push(scriptedQuery);
return scriptedQuery;
});
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
return { session, queries };
}
test("submits after a thinking change before the first turn", async () => {
const { session, queries } = await createThinkingRestartSession();
try {
await session.setThinkingOption!("medium");
const events = await collectUntilTerminal(
streamSession(session, "continue", { clientMessageId: "client-continue" }),
);
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
expect(events.at(-1)?.type).toBe("turn_completed");
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queries[0].prompts.map((prompt) => prompt.text)).toEqual(["continue"]);
} finally {
await session.close();
}
});
test("submits after a thinking change once the previous query ended", async () => {
const { session, queries } = await createThinkingRestartSession();
try {
await collectUntilTerminal(streamSession(session, "first prompt"));
queries[0].end();
// Let the pump retire the old query before changing thinking.
await new Promise((resolve) => setTimeout(resolve, 0));
await session.setThinkingOption!("medium");
const events = await collectUntilTerminal(
streamSession(session, "continue", { clientMessageId: "client-continue" }),
);
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
expect(events.at(-1)?.type).toBe("turn_completed");
expect(queryFactory).toHaveBeenCalledTimes(2);
expect(queries[1].prompts.map((prompt) => prompt.text)).toEqual(["continue"]);
} finally {
await session.close();
}
});
test("interrupt only calls query.interrupt and leaves the query open", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
@@ -2805,6 +2805,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 150,
},
}),
@@ -2840,7 +2841,13 @@ describe("ClaudeAgentSession context window usage", () => {
test("selected 1M Claude models seed active context window usage from the catalog", async () => {
const session = await createSessionForTurns(
[[createInitMessage(), createMessageStartEvent(), createSuccessResult()]],
[
[
{ ...createInitMessage(), model: "claude-sonnet-5[1m]" },
createMessageStartEvent(),
createSuccessResult(),
],
],
{ model: "claude-sonnet-5[1m]" },
);
@@ -2880,6 +2887,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 175,
},
}),
@@ -2922,6 +2930,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 20_300,
},
}),
@@ -2963,6 +2972,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 1_069,
},
}),
@@ -3026,6 +3036,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 55,
},
}),
@@ -3035,6 +3046,7 @@ describe("ClaudeAgentSession context window usage", () => {
type: "usage_updated",
provider: "claude",
usage: {
contextWindowMaxTokens: 200_000,
contextWindowUsedTokens: 62,
},
}),
@@ -3072,6 +3084,7 @@ 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 {
findClaudeModel,
getClaudeContextWindowMaxTokens,
getClaudeModelsWithSettings,
normalizeClaudeRuntimeModelId,
resolveConfiguredClaudeModel,
@@ -2240,7 +2240,7 @@ class ClaudeAgentSession implements AgentSession {
this.resolveBinary = options.resolveBinary;
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
this.contextUsage = new ClaudeContextUsageState(
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
getClaudeContextWindowMaxTokens(this.config.model),
);
const handle = options.handle;
@@ -2572,7 +2572,7 @@ class ClaudeAgentSession implements AgentSession {
await this.applyFastModeFeature(false, activeQuery);
}
this.contextUsage.setInitialContextWindowMaxTokens(
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
getClaudeContextWindowMaxTokens(this.config.model),
);
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
this.lastRuntimeModel = null;
@@ -3275,6 +3275,9 @@ class ClaudeAgentSession implements AgentSession {
}
}
// A fresh query already uses the current settings, even when there was no old query to retire.
this.queryRestartNeeded = false;
// Preserve claudeSessionId across query recreation so buildOptions() passes
// resume: sessionId and the new query continues the existing conversation.
this.persistence = null;
@@ -3877,11 +3880,12 @@ class ClaudeAgentSession implements AgentSession {
}
private startQueryPump(): void {
if (this.closed || this.queryPumpPromise) {
if (this.closed || this.queryPumpPromise || !this.query) {
return;
}
const pump = this.runQueryPump().catch((error) => {
// Drain the initialized query; a pending settings change belongs to the next turn.
const pump = this.runQueryPump(this.query).catch((error) => {
this.logger.trace(
{
agentId: this.agentId,
@@ -3902,25 +3906,7 @@ class ClaudeAgentSession implements AgentSession {
});
}
private async runQueryPump(): Promise<void> {
let activeQuery: Query;
try {
activeQuery = await this.ensureQuery();
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.init_failed",
);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return;
}
private async runQueryPump(activeQuery: Query): Promise<void> {
let consecutiveInterruptAbortRecoveries = 0;
const logRawMessage = (message: SDKMessage): void => {
this.logger.trace(
@@ -4821,6 +4807,12 @@ 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,7 +15,27 @@ import {
} from "./model-manifest.js";
import { discoverClaudeModels } from "./model-discovery.js";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
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);
});
});
const createdClaudeConfigDirs: string[] = [];
@@ -48,6 +48,15 @@ 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 { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -68,8 +68,60 @@ 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) {
@@ -88,17 +140,20 @@ 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],
command: [process.execPath, scriptPath, loadRequestsPath, pidPath],
removedCwd: path.join(testDir, "removed-worktree"),
loadRequestsPath,
pidPath,
});
} finally {
await rm(testDir, { recursive: true, force: true });
@@ -110,6 +165,10 @@ 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: "error", message: rejection },
{ type: "assistant_message", text: `[System Error] ${rejection}` },
];
expect(
(await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item),
@@ -115,4 +115,43 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
test("a thinking change before the first turn does not fail that turn", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: createRealProviderClients(["claude"], logger),
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const modelList = await client.listProviderModels("claude", { cwd });
const model = modelList.models.find(modelHasLowThinkingOption);
if (!model) {
throw new Error("No Claude Sonnet model with low thinking effort returned");
}
const agent = await client.createAgent({
cwd,
title: "claude-thinking-before-first-turn-real",
...getRealProviderConfig("claude"),
model: model.id,
});
await client.setAgentThinkingOption(agent.id, "low");
await client.sendMessage(agent.id, "Reply exactly: ACK_FIRST");
const finish = await client.waitForFinish(agent.id, 180_000);
expect(finish.final?.lastError).toBeUndefined();
expect(finish.status).toBe("idle");
expect(compactText(await getAssistantText(client, agent.id))).toContain("ack_first");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
});
@@ -1,7 +1,16 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
import { describe, test, expect, beforeEach, afterEach, onTestFinished } from "vitest";
import {
mkdtempSync,
writeFileSync,
rmSync,
mkdirSync,
symlinkSync,
readdirSync,
readlinkSync,
} 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 {
@@ -387,6 +396,84 @@ 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();
@@ -489,3 +576,63 @@ 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 } from "node:fs/promises";
import { readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
@@ -63,10 +63,12 @@ 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).toEqual({
expect(added).toMatchObject({
requestId: expect.any(String),
project: {
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
@@ -77,7 +79,11 @@ test("an empty project becomes Git without changing its identity or creating a w
},
error: null,
});
const project = added.project!;
const {
projectIconRevision: _icon,
projectCustomIconRevision: _customIcon,
...project
} = added.project!;
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
@@ -96,15 +102,15 @@ test("an empty project becomes Git without changing its identity or creating a w
label: "project.update after git init",
});
expect(update).toEqual({
expect(update).toMatchObject({
kind: "upsert",
project: { ...project, projectKind: "git" },
project: { ...project, projectKind: "git", projectKey: expect.any(String) },
});
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(afterGitInit).toMatchObject({
entries: [],
emptyProjects: [{ ...project, projectKind: "git" }],
emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }],
});
const persistedProjects = JSON.parse(
@@ -119,5 +125,72 @@ 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,6 +669,7 @@ export async function duplicateExplorerEntry({
errorOnExist: true,
force: false,
preserveTimestamps: true,
verbatimSymlinks: true,
});
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
} catch (error) {
@@ -236,7 +236,8 @@ 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 subscription = await subscribeToFileChanges(root, (error, events) => {
const observer = createObserver();
const subscription = await observer.subscribe(root, (error, events) => {
expect(error).toBeNull();
for (const event of events) {
population.record(event);
@@ -254,9 +255,18 @@ 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;
// 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.
// 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.
await Promise.all(removedPaths.map((path) => rm(path)));
await deletions.complete;
@@ -1,3 +1,7 @@
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";
@@ -15,6 +19,59 @@ 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,57 +1776,6 @@ 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,12 +912,6 @@ 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,
@@ -951,7 +945,7 @@ export class ScheduleService {
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
}
if (waitResult.status === "error") {
throw this.scheduledAgentFailure(agent.id);
throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`);
}
return {
agentId: agent.id,
@@ -1019,7 +1013,7 @@ export class ScheduleService {
throw new Error(`Scheduled agent ${agent.id} is waiting for permission`);
}
if (waitResult.status === "error") {
throw this.scheduledAgentFailure(agent.id);
throw new Error(waitResult.lastMessage ?? `Scheduled agent ${agent.id} failed`);
}
const timelineText = curateAgentActivity(result.timeline);
return {
@@ -52,7 +52,6 @@ interface Deferred<T> {
}
interface FakeAgentSessionOptions {
turnFailure?: TestAgentClientOptions["turnFailure"];
providerName: string;
config: AgentSessionConfig;
supportsMcpServers?: boolean;
@@ -65,10 +64,6 @@ 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;
@@ -348,7 +343,6 @@ 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) {
@@ -364,7 +358,6 @@ 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",
@@ -768,7 +761,6 @@ class FakeAgentSession implements AgentSession {
type: "turn_failed",
provider: this.providerName,
error: "Requested fake provider failure",
...this.turnFailure,
};
await this.appendHistoryEvent(failed);
this.notifySubscribers(failed);
@@ -1243,7 +1235,6 @@ class FakeAgentClient implements AgentClient {
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
historyEvents: this.options.historyEvents,
turnFailure: this.options.turnFailure,
});
}
@@ -1271,7 +1262,6 @@ class FakeAgentClient implements AgentClient {
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
historyEvents: this.options.historyEvents,
turnFailure: this.options.turnFailure,
});
}
@@ -625,6 +625,28 @@ 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,6 +63,27 @@ 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,6 +765,16 @@ 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,
+5 -4
View File
@@ -2665,10 +2665,11 @@ 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,
cwd: worktreeRoot,
envOverlay: READ_ONLY_GIT_ENV,
},
);
@@ -2677,9 +2678,9 @@ export async function getCommitFileDiff({
return null;
}
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, {
getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path),
});
// `--` scopes the diff to a single pathspec, so there is at most one real