mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02537727a | ||
|
|
870c8dc9c5 | ||
|
|
d3c1cc54bb | ||
|
|
5805b776c2 | ||
|
|
4400445edb | ||
|
|
337e81a9c6 | ||
|
|
b923cde6cf | ||
|
|
058375dac8 | ||
|
|
eb051ea81e | ||
|
|
1839de5b1d | ||
|
|
ec6393f31f | ||
|
|
b5e51cf839 |
@@ -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");
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>";
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+17
-10
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
+60
@@ -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,
|
||||
|
||||
+61
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user