style: autoformat with biome to fix 195 formatting errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2026-04-10 19:49:33 +00:00
co-authored by Claude Opus 4.6
parent be88ddbce1
commit 13de7d197b
195 changed files with 4419 additions and 3943 deletions
+11 -1
View File
@@ -10,7 +10,17 @@ dotenv.config({
const daemonRunnerEntry = fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url));
const result = spawnSync(
process.execPath,
["--inspect", "--heapsnapshot-near-heap-limit=3", "--max-old-space-size=3072", "--report-on-fatalerror", "--report-directory=/tmp/paseo-reports", ...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
[
"--inspect",
"--heapsnapshot-near-heap-limit=3",
"--max-old-space-size=3072",
"--report-on-fatalerror",
"--report-directory=/tmp/paseo-reports",
...process.execArgv,
daemonRunnerEntry,
"--dev",
...process.argv.slice(2),
],
{
stdio: "inherit",
env: process.env,
+10 -39
View File
@@ -273,10 +273,7 @@ type ChatCreatePayload = Extract<
SessionOutboundMessage,
{ type: "chat/create/response" }
>["payload"];
type ChatListPayload = Extract<
SessionOutboundMessage,
{ type: "chat/list/response" }
>["payload"];
type ChatListPayload = Extract<SessionOutboundMessage, { type: "chat/list/response" }>["payload"];
type ChatInspectPayload = Extract<
SessionOutboundMessage,
{ type: "chat/inspect/response" }
@@ -285,38 +282,17 @@ type ChatDeletePayload = Extract<
SessionOutboundMessage,
{ type: "chat/delete/response" }
>["payload"];
type ChatPostPayload = Extract<
SessionOutboundMessage,
{ type: "chat/post/response" }
>["payload"];
type ChatReadPayload = Extract<
SessionOutboundMessage,
{ type: "chat/read/response" }
>["payload"];
type ChatWaitPayload = Extract<
SessionOutboundMessage,
{ type: "chat/wait/response" }
>["payload"];
type LoopRunPayload = Extract<
SessionOutboundMessage,
{ type: "loop/run/response" }
>["payload"];
type LoopListPayload = Extract<
SessionOutboundMessage,
{ type: "loop/list/response" }
>["payload"];
type ChatPostPayload = Extract<SessionOutboundMessage, { type: "chat/post/response" }>["payload"];
type ChatReadPayload = Extract<SessionOutboundMessage, { type: "chat/read/response" }>["payload"];
type ChatWaitPayload = Extract<SessionOutboundMessage, { type: "chat/wait/response" }>["payload"];
type LoopRunPayload = Extract<SessionOutboundMessage, { type: "loop/run/response" }>["payload"];
type LoopListPayload = Extract<SessionOutboundMessage, { type: "loop/list/response" }>["payload"];
type LoopInspectPayload = Extract<
SessionOutboundMessage,
{ type: "loop/inspect/response" }
>["payload"];
type LoopLogsPayload = Extract<
SessionOutboundMessage,
{ type: "loop/logs/response" }
>["payload"];
type LoopStopPayload = Extract<
SessionOutboundMessage,
{ type: "loop/stop/response" }
>["payload"];
type LoopLogsPayload = Extract<SessionOutboundMessage, { type: "loop/logs/response" }>["payload"];
type LoopStopPayload = Extract<SessionOutboundMessage, { type: "loop/stop/response" }>["payload"];
type ScheduleCreatePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/create/response" }
@@ -2430,11 +2406,7 @@ export class DaemonClient {
});
}
async stashPop(
cwd: string,
stashIndex: number,
requestId?: string,
): Promise<StashPopPayload> {
async stashPop(cwd: string, stashIndex: number, requestId?: string): Promise<StashPopPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
@@ -3351,8 +3323,7 @@ export class DaemonClient {
}
async loopLogs(options: string | LoopLogsOptions, afterSeq?: number): Promise<LoopLogsPayload> {
const normalized =
typeof options === "string" ? { id: options, afterSeq } : options;
const normalized = typeof options === "string" ? { id: options, afterSeq } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
@@ -16,10 +16,7 @@
*/
import { describe, expect, test } from "vitest";
import {
query,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailableSync } from "../utils/executable.js";
const hasClaudeCredentials =
@@ -34,79 +31,87 @@ function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
describe("Claude Agent SDK Commands POC", () => {
describe("supportedCommands() API", () => {
test.runIf(canRunClaudeIntegration)("should return an array of SlashCommand objects", async () => {
// Use the pattern from claude-agent.ts:
// Create a query with empty prompt generator for control methods
const emptyPrompt = createEmptyPrompt();
test.runIf(canRunClaudeIntegration)(
"should return an array of SlashCommand objects",
async () => {
// Use the pattern from claude-agent.ts:
// Create a query with empty prompt generator for control methods
const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"], // Required to load skills
},
});
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"], // Required to load skills
},
});
try {
// supportedCommands() is a control method - works without iterating
const commands = await claudeQuery.supportedCommands();
try {
// supportedCommands() is a control method - works without iterating
const commands = await claudeQuery.supportedCommands();
// Should be an array
expect(Array.isArray(commands)).toBe(true);
// Should be an array
expect(Array.isArray(commands)).toBe(true);
// Verify structure
if (commands.length > 0) {
const firstCommand = commands[0];
expect(typeof firstCommand.name).toBe("string");
expect(typeof firstCommand.description).toBe("string");
expect(typeof firstCommand.argumentHint).toBe("string");
expect(firstCommand.name.startsWith("/")).toBe(false);
}
} finally {
if (typeof claudeQuery.return === "function") {
try {
await claudeQuery.return();
} catch {
// ignore shutdown errors
// Verify structure
if (commands.length > 0) {
const firstCommand = commands[0];
expect(typeof firstCommand.name).toBe("string");
expect(typeof firstCommand.description).toBe("string");
expect(typeof firstCommand.argumentHint).toBe("string");
expect(firstCommand.name.startsWith("/")).toBe(false);
}
} finally {
if (typeof claudeQuery.return === "function") {
try {
await claudeQuery.return();
} catch {
// ignore shutdown errors
}
}
}
}
}, 30000);
},
30000,
);
test.runIf(canRunClaudeIntegration)("should have valid SlashCommand structure for all commands", async () => {
const emptyPrompt = createEmptyPrompt();
test.runIf(canRunClaudeIntegration)(
"should have valid SlashCommand structure for all commands",
async () => {
const emptyPrompt = createEmptyPrompt();
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
settingSources: ["user", "project"],
},
});
const claudeQuery = query({
prompt: emptyPrompt,
options: {
cwd: process.cwd(),
permissionMode: "plan",
settingSources: ["user", "project"],
},
});
try {
const commands = await claudeQuery.supportedCommands();
try {
const commands = await claudeQuery.supportedCommands();
expect(commands.length).toBeGreaterThan(0);
expect(commands.length).toBeGreaterThan(0);
// Verify all commands have valid structure
for (const cmd of commands) {
expect(cmd).toHaveProperty("name");
expect(cmd).toHaveProperty("description");
expect(cmd).toHaveProperty("argumentHint");
expect(typeof cmd.name).toBe("string");
expect(typeof cmd.description).toBe("string");
expect(typeof cmd.argumentHint).toBe("string");
expect(cmd.name.length).toBeGreaterThan(0);
expect(cmd.name.startsWith("/")).toBe(false);
// Verify all commands have valid structure
for (const cmd of commands) {
expect(cmd).toHaveProperty("name");
expect(cmd).toHaveProperty("description");
expect(cmd).toHaveProperty("argumentHint");
expect(typeof cmd.name).toBe("string");
expect(typeof cmd.description).toBe("string");
expect(typeof cmd.argumentHint).toBe("string");
expect(cmd.name.length).toBeGreaterThan(0);
expect(cmd.name.startsWith("/")).toBe(false);
}
} finally {
await claudeQuery.return?.();
}
} finally {
await claudeQuery.return?.();
}
}, 30000);
},
30000,
);
});
describe("Command Execution", () => {
@@ -887,7 +887,12 @@ describe("AgentManager", () => {
});
await this.gate;
if (this.delayedInterrupted) {
this.pushEvent({ type: "turn_canceled", provider: this.provider, reason: "Interrupted", turnId });
this.pushEvent({
type: "turn_canceled",
provider: this.provider,
reason: "Interrupted",
turnId,
});
} else {
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}
@@ -1456,7 +1461,12 @@ describe("AgentManager", () => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
if (turnNum === 1) {
await allowFirstRunToEnd.promise;
this.pushEvent({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId });
this.pushEvent({
type: "turn_canceled",
provider: this.provider,
reason: "interrupted",
turnId,
});
} else {
await allowSecondRunToEnd.promise;
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
@@ -1640,7 +1650,7 @@ describe("AgentManager", () => {
await secondStartEntered.promise;
const replaceGapSnapshot = manager.getAgent(snapshot.id) as
| ({ pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string })
| { pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string }
| undefined;
expect(replaceGapSnapshot?.pendingReplacement).toBe(false);
expect(replaceGapSnapshot?.activeForegroundTurnId).toBeNull();
@@ -1712,14 +1722,22 @@ describe("AgentManager", () => {
// Push autonomous events through the session's subscribe() callbacks
const autonomousTurnId = "autonomous-turn-1";
capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE" },
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_completed",
provider: "codex",
turnId: autonomousTurnId,
});
await settled;
const updated = manager.getAgent(snapshot.id);
@@ -1789,7 +1807,11 @@ describe("AgentManager", () => {
},
{ agentId: snapshot.id, replayState: false },
);
capturedSession.pushEvent({ type: "turn_started", provider: "codex", turnId: "autonomous-cancel-1" });
capturedSession.pushEvent({
type: "turn_started",
provider: "codex",
turnId: "autonomous-cancel-1",
});
});
const beforeCancel = manager.getAgent(snapshot.id);
@@ -1832,8 +1854,16 @@ describe("AgentManager", () => {
const autonomousTurnId = "autonomous-wait-1";
const waitPromise = manager.waitForAgentEvent(snapshot.id, { waitForActive: true });
capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({
type: "turn_completed",
provider: "codex",
turnId: autonomousTurnId,
});
const result = await waitPromise;
expect(result.status).toBe("idle");
@@ -1894,7 +1924,11 @@ describe("AgentManager", () => {
await new Promise<void>((resolve) => {
const unsub = manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === snapshot.id && event.agent.lifecycle === "running") {
if (
event.type === "agent_state" &&
event.agent.id === snapshot.id &&
event.agent.lifecycle === "running"
) {
unsub();
resolve();
}
@@ -1905,14 +1939,22 @@ describe("AgentManager", () => {
// Push autonomous events while foreground is active
const autonomousTurnId = "autonomous-during-fg-1";
capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "AUTONOMOUS_DURING_FOREGROUND" },
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_completed",
provider: "codex",
turnId: autonomousTurnId,
});
releaseForeground.resolve();
const foregroundEvents = await foregroundResults;
@@ -1968,7 +2010,11 @@ describe("AgentManager", () => {
const settled = new Promise<void>((resolve) => {
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === snapshot.id && event.agent.lifecycle === "idle") {
if (
event.type === "agent_state" &&
event.agent.id === snapshot.id &&
event.agent.lifecycle === "idle"
) {
resolve();
}
if (event.type === "agent_stream" && event.agentId === snapshot.id) {
@@ -1980,14 +2026,22 @@ describe("AgentManager", () => {
});
const autonomousTurnId = "autonomous-isolation-1";
capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_started",
provider: "codex",
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "EVENT_AFTER_ERROR" },
turnId: autonomousTurnId,
});
capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: autonomousTurnId });
capturedSession!.pushEvent({
type: "turn_completed",
provider: "codex",
turnId: autonomousTurnId,
});
await settled;
@@ -2127,7 +2181,9 @@ describe("AgentManager", () => {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subs.add(callback);
return () => { this.subs.delete(callback); };
return () => {
this.subs.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
@@ -2410,7 +2466,12 @@ describe("AgentManager", () => {
const turnId = `fail-turn-${attempt}`;
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({ type: "turn_failed", provider: this.provider, error: `boom-${attempt}`, turnId });
this.pushEvent({
type: "turn_failed",
provider: this.provider,
error: `boom-${attempt}`,
turnId,
});
}, 0);
return { turnId };
}
@@ -2537,7 +2598,12 @@ describe("AgentManager", () => {
const turnId = "turn-failed-1";
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({ type: "turn_failed", provider: this.provider, error: "invalid model id", turnId });
this.pushEvent({
type: "turn_failed",
provider: this.provider,
error: "invalid model id",
turnId,
});
}, 0);
return { turnId };
}
@@ -2789,7 +2855,9 @@ describe("AgentManager", () => {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subs.add(callback);
return () => { this.subs.delete(callback); };
return () => {
this.subs.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
@@ -3079,7 +3147,9 @@ describe("AgentManager", () => {
}
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
seen.push(`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`);
seen.push(
`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
);
return;
}
if (event.type === "agent_stream" && event.event.type === "permission_resolved") {
@@ -3127,12 +3197,18 @@ describe("AgentManager", () => {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => { this.subscribers.delete(callback); };
return () => {
this.subscribers.delete(callback);
};
}
private pushEvent(event: AgentStreamEvent): void {
for (const cb of this.subscribers) {
try { cb(event); } catch { /* isolation */ }
try {
cb(event);
} catch {
/* isolation */
}
}
}
@@ -1330,10 +1330,7 @@ export class AgentManager {
}
const pendingRun = this.getPendingForegroundRun(agentId);
if (
(snapshot.lifecycle === "running" || pendingRun?.started) &&
!snapshot.pendingReplacement
) {
if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) {
return;
}
@@ -1410,11 +1407,7 @@ export class AgentManager {
return true;
}
if (
!currentPendingRun &&
!current.activeForegroundTurnId &&
!current.pendingReplacement
) {
if (!currentPendingRun && !current.activeForegroundTurnId && !current.pendingReplacement) {
finishErr(new Error(`Agent ${agentId} run finished before starting`));
return true;
}
@@ -1476,8 +1469,7 @@ export class AgentManager {
const pendingRun = this.getPendingForegroundRun(agentId);
const foregroundTurnId = agent.activeForegroundTurnId;
const hasForegroundTurn = Boolean(foregroundTurnId);
const isAutonomousRunning =
agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun;
const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun;
if (!hasForegroundTurn && !isAutonomousRunning && !pendingRun) {
return false;
@@ -2104,9 +2096,7 @@ export class AgentManager {
},
): void {
const eventTurnId = (event as { turnId?: string }).turnId;
const isForegroundEvent = Boolean(
eventTurnId && agent.activeForegroundTurnId === eventTurnId,
);
const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId);
// Only update timestamp for live events, not history replay
if (!options?.fromHistory) {
@@ -2147,11 +2137,7 @@ export class AgentManager {
}
// Suppress user_message echoes for the active foreground turn —
// these are already recorded by recordUserMessage().
if (
!options?.fromHistory &&
event.item.type === "user_message" &&
isForegroundEvent
) {
if (!options?.fromHistory && event.item.type === "user_message" && isForegroundEvent) {
const eventMessageId = normalizeMessageId(event.item.messageId);
const eventText = event.item.text;
if (eventMessageId) {
@@ -2535,9 +2521,7 @@ export class AgentManager {
}
}
private async normalizeConfig(
config: AgentSessionConfig,
): Promise<AgentSessionConfig> {
private async normalizeConfig(config: AgentSessionConfig): Promise<AgentSessionConfig> {
const normalized: AgentSessionConfig = { ...config };
// Always resolve cwd to absolute path for consistent history file lookup
@@ -2595,5 +2579,4 @@ export class AgentManager {
}
return agent;
}
}
@@ -312,7 +312,12 @@ export type AgentStreamEvent =
}
| { type: "turn_canceled"; provider: AgentProvider; reason: string; turnId?: string }
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider; turnId?: string }
| { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest; turnId?: string }
| {
type: "permission_requested";
provider: AgentProvider;
request: AgentPermissionRequest;
turnId?: string;
}
| {
type: "permission_resolved";
provider: AgentProvider;
@@ -16,10 +16,7 @@ function isBinaryInstalled(binary: string): boolean {
const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
function modelMatchesFamily(
model: AgentModelDefinition,
family: "sonnet" | "haiku",
): boolean {
function modelMatchesFamily(model: AgentModelDefinition, family: "sonnet" | "haiku"): boolean {
const haystacks = [model.id, model.label, model.description ?? ""].map((value) =>
value.toLowerCase(),
);
@@ -52,9 +52,9 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
logger,
runtimeSettings: runtimeSettings?.copilot,
}),
opencode: (logger, runtimeSettings) =>
new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
pi: (logger, runtimeSettings) => new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
pi: (logger, runtimeSettings) =>
new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
};
function getProviderClientFactory(provider: string): ProviderClientFactory {
@@ -236,9 +236,7 @@ describe("ProviderSnapshotManager", () => {
});
manager.refresh("/tmp/project");
expect(manager.getSnapshot("/tmp/project")).toEqual([
{ provider: "codex", status: "loading" },
]);
expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe(
@@ -267,9 +265,7 @@ describe("ProviderSnapshotManager", () => {
manager.refresh("/tmp/project");
expect(manager.getSnapshot("/tmp/project")).toEqual([
{ provider: "codex", status: "loading" },
]);
expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]);
manager.refresh("/tmp/project");
manager.refresh("/tmp/project");
@@ -347,8 +343,12 @@ describe("ProviderSnapshotManager", () => {
manager.getSnapshot("/tmp/project-b");
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe(
"ready",
);
expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe(
"ready",
);
});
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe(
@@ -381,19 +381,24 @@ function createRegistry(handles: MockProviderHandle[]): {
registry: Object.fromEntries(
handles.map((handle) => [handle.definition.id, handle.definition]),
) as Record<AgentProvider, ProviderDefinition>,
handles: Object.fromEntries(
handles.map((handle) => [handle.definition.id, handle]),
) as Record<AgentProvider, MockProviderHandle>,
handles: Object.fromEntries(handles.map((handle) => [handle.definition.id, handle])) as Record<
AgentProvider,
MockProviderHandle
>,
};
}
function createMockProvider(options: MockProviderOptions): MockProviderHandle {
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
const fetchModels = vi.fn(async (listOptions?: { cwd?: string }) =>
options.fetchModels?.(listOptions?.cwd) ?? [createModel(options.provider, `${options.provider}-default`)],
const fetchModels = vi.fn(
async (listOptions?: { cwd?: string }) =>
options.fetchModels?.(listOptions?.cwd) ?? [
createModel(options.provider, `${options.provider}-default`),
],
);
const fetchModes = vi.fn(async (listOptions?: { cwd?: string }) =>
options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)],
const fetchModes = vi.fn(
async (listOptions?: { cwd?: string }) =>
options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)],
);
const definition: ProviderDefinition = {
@@ -3,19 +3,13 @@ import { resolve } from "node:path";
import type { Logger } from "pino";
import type {
AgentProvider,
ProviderSnapshotEntry,
} from "./agent-sdk-types.js";
import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { AGENT_PROVIDER_IDS } from "./provider-manifest.js";
const DEFAULT_CWD_KEY = "__default__";
type ProviderSnapshotChangeListener = (
entries: ProviderSnapshotEntry[],
cwd?: string,
) => void;
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
export class ProviderSnapshotManager {
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
@@ -146,7 +140,10 @@ export class ProviderSnapshotManager {
status: "error",
error: toErrorMessage(error),
});
this.logger.warn({ err: error, provider, cwd: cwdKey }, "Failed to refresh provider snapshot");
this.logger.warn(
{ err: error, provider, cwd: cwdKey },
"Failed to refresh provider snapshot",
);
this.emitChange(cwdKey);
}
}
@@ -55,10 +55,7 @@ function eventsForTurn(events: AgentStreamEvent[], turnId: string): AgentStreamE
function userMessagesWithText(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
return events.filter(
(e) =>
e.type === "timeline" &&
e.item.type === "user_message" &&
e.item.text === text,
(e) => e.type === "timeline" && e.item.type === "user_message" && e.item.text === text,
);
}
@@ -185,12 +182,8 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
}
// Invariant 4: Autonomous turns have distinct turnIds from foreground turns
const allTurnIds = new Set(
events.filter(hasTurnId).map((e) => e.turnId),
);
const autonomousTurnIds = [...allTurnIds].filter(
(id) => !foregroundTurnIds.includes(id),
);
const allTurnIds = new Set(events.filter(hasTurnId).map((e) => e.turnId));
const autonomousTurnIds = [...allTurnIds].filter((id) => !foregroundTurnIds.includes(id));
for (const autoId of autonomousTurnIds) {
expect(foregroundTurnIds).not.toContain(autoId);
}
@@ -201,300 +194,332 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[
// ---------------------------------------------------------------------------
describe("Agent event stream redesign — integration", () => {
test.skipIf(!canRun)("Test 1: Basic foreground turn", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
test.skipIf(!canRun)(
"Test 1: Basic foreground turn",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-basic-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(
handle.session,
"respond with just the word hello",
);
try {
const { turnId, events } = await startTurnAndCollectEvents(
handle.session,
"respond with just the word hello",
);
const turnStarted = events.find(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
);
expect(turnStarted).toBeDefined();
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)("Test 2: No duplicate user_messages — THE BUG", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
// No turn_started after terminal for the same turnId
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const staleTurnStarted = events.slice(terminalIdx + 1).filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
);
expect(staleTurnStarted.length).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)("Test 3: Lifecycle doesn't get stuck in running", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const afterTerminal = events.slice(terminalIdx + 1);
// No subsequent turn_started for same turnId
expect(
afterTerminal.filter(
const turnStarted = events.find(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
).length,
).toBe(0);
);
expect(turnStarted).toBeDefined();
// Any turn_started after terminal must have a different turnId
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
test.skipIf(!canRun)(
"Test 2: No duplicate user_messages — THE BUG",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
test.skipIf(!canRun)("Test 4: Autonomous run", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
{
extraMs: 10_000,
timeoutMs: 60_000,
},
);
const fgTerminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
);
const afterForeground = events.slice(fgTerminalIdx + 1);
// Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
) as EventWithTurnId[];
if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]);
return;
}
const autoTurnId = autoStarts[0]!.turnId;
expect(fgTurnId).not.toBe(autoTurnId);
// Autonomous turn reaches terminal
expect(
afterForeground.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId,
),
).toBeDefined();
assertInvariants(events, [fgTurnId]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
test.skipIf(!canRun)("Test 5: Interruption", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try {
let turnId: string | null = null;
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out after 45000ms waiting for terminal event"));
}, 45_000);
const unsubscribe = handle.session.subscribe((event) => {
collected.push(event);
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
turnId = event.turnId;
}
// Once we see turn_started, fire the interrupt
if (
!interrupted &&
turnId &&
event.type === "turn_started" &&
hasTurnId(event) &&
event.turnId === turnId
) {
interrupted = true;
handle.session.interrupt().catch(() => undefined);
}
// Resolve when we get a terminal event for this turn
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
resolve(collected);
}
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
// No turn_started after terminal for the same turnId
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const staleTurnStarted = events
.slice(terminalIdx + 1)
.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId);
expect(staleTurnStarted.length).toBe(0);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.skipIf(!canRun)(
"Test 3: Lifecycle doesn't get stuck in running",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
const terminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
const afterTerminal = events.slice(terminalIdx + 1);
// No subsequent turn_started for same turnId
expect(
afterTerminal.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId,
).length,
).toBe(0);
// Any turn_started after terminal must have a different turnId
for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) {
expect((ts as EventWithTurnId).turnId).not.toBe(turnId);
}
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.skipIf(!canRun)(
"Test 4: Autonomous run",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const { turnId: fgTurnId, events } = await startTurnAndCollectEvents(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
{
extraMs: 10_000,
timeoutMs: 60_000,
},
);
const fgTerminalIdx = events.findIndex(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId,
);
const afterForeground = events.slice(fgTerminalIdx + 1);
// Autonomous turn_started with a different turnId
const autoStarts = afterForeground.filter(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId,
) as EventWithTurnId[];
if (autoStarts.length === 0) {
assertInvariants(events, [fgTurnId]);
return;
}
const autoTurnId = autoStarts[0]!.turnId;
expect(fgTurnId).not.toBe(autoTurnId);
// Autonomous turn reaches terminal
expect(
afterForeground.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId,
),
).toBeDefined();
assertInvariants(events, [fgTurnId]);
} finally {
await cleanupSession(handle);
}
},
90_000,
);
test.skipIf(!canRun)(
"Test 5: Interruption",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
try {
let turnId: string | null = null;
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out after 45000ms waiting for terminal event"));
}, 45_000);
const unsubscribe = handle.session.subscribe((event) => {
collected.push(event);
if (!turnId && event.type === "turn_started" && hasTurnId(event)) {
turnId = event.turnId;
}
// Once we see turn_started, fire the interrupt
if (
!interrupted &&
turnId &&
event.type === "turn_started" &&
hasTurnId(event) &&
event.turnId === turnId
) {
interrupted = true;
handle.session.interrupt().catch(() => undefined);
}
// Resolve when we get a terminal event for this turn
if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) {
clearTimeout(timeout);
unsubscribe();
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
resolve(collected);
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
expect(turnId).toBeDefined();
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
clearTimeout(timeout);
unsubscribe();
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
// turn_canceled or turn_failed arrives for that turnId
const terminal = events.find(
(e) =>
(e.type === "turn_canceled" || e.type === "turn_failed") &&
hasTurnId(e) &&
e.turnId === turnId,
);
expect(terminal).toBeDefined();
expect(turnId).toBeDefined();
// No further events for that turnId after terminal
const terminalIdx = events.indexOf(terminal!);
expect(
events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
).toBe(0);
// turn_canceled or turn_failed arrives for that turnId
const terminal = events.find(
(e) =>
(e.type === "turn_canceled" || e.type === "turn_failed") &&
hasTurnId(e) &&
e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
// No further events for that turnId after terminal
const terminalIdx = events.indexOf(terminal!);
expect(
events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length,
).toBe(0);
test.skipIf(!canRun)("Test 6: Sequential foreground turns", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
try {
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
handle.session,
"say first",
);
test.skipIf(!canRun)(
"Test 6: Sequential foreground turns",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
handle.session,
"say second",
);
try {
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
handle.session,
"say first",
);
const allEvents = [...events1, ...events2];
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
handle.session,
"say second",
);
expect(turnId1).not.toBe(turnId2);
const allEvents = [...events1, ...events2];
// No events from turn 1 after turn 2 starts
const turn2StartIdx = allEvents.findIndex(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
);
expect(
allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1)
.length,
).toBe(0);
expect(turnId1).not.toBe(turnId2);
assertInvariants(allEvents, [turnId1, turnId2]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
// No events from turn 1 after turn 2 starts
const turn2StartIdx = allEvents.findIndex(
(e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2,
);
expect(
allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1)
.length,
).toBe(0);
test.skipIf(!canRun)("Test 7: Fast-fail", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
assertInvariants(allEvents, [turnId1, turnId2]);
} finally {
await cleanupSession(handle);
}
},
90_000,
);
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
extraMs: 3_000,
});
test.skipIf(!canRun)(
"Test 7: Fast-fail",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
// At most one turn_started
expect(
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
.length,
).toBeLessThanOrEqual(1);
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
extraMs: 3_000,
});
// Terminal present
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
// At most one turn_started
expect(
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
.length,
).toBeLessThanOrEqual(1);
// No stale turn_started after terminal
const terminalIdx = events.indexOf(terminal!);
expect(
events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length,
).toBe(0);
// Terminal present
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
// No stale turn_started after terminal
const terminalIdx = events.indexOf(terminal!);
expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(
0,
);
test.skipIf(!canRun)("Test 8: User message dedup by text", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
extraMs: 3_000,
});
test.skipIf(!canRun)(
"Test 8: User message dedup by text",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
extraMs: 3_000,
});
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
});
@@ -75,23 +75,19 @@ describe("deriveModesFromACP", () => {
});
test("falls back to config options when explicit mode state is absent", () => {
const result = deriveModesFromACP(
[{ id: "fallback", label: "Fallback" }],
null,
[
{
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue: "acceptEdits",
options: [
{ value: "default", name: "Always Ask" },
{ value: "acceptEdits", name: "Accept File Edits" },
],
},
],
);
const result = deriveModesFromACP([{ id: "fallback", label: "Fallback" }], null, [
{
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue: "acceptEdits",
options: [
{ value: "default", name: "Always Ask" },
{ value: "acceptEdits", name: "Accept File Edits" },
],
},
]);
expect(result).toEqual({
modes: [
@@ -103,13 +99,43 @@ describe("deriveModesFromACP", () => {
});
test("returns an empty mode list when fallback modes are empty and config only exposes thought levels", () => {
const result = deriveModesFromACP(
[],
null,
const result = deriveModesFromACP([], null, [
{
id: "thought_level",
name: "Thinking",
category: "thought_level",
type: "select",
currentValue: "medium",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
],
},
]);
expect(result).toEqual({
modes: [],
currentModeId: null,
});
});
});
describe("deriveModelDefinitionsFromACP", () => {
test("attaches shared thinking options to ACP model state", () => {
const result = deriveModelDefinitionsFromACP(
"claude-acp",
{
availableModels: [
{ modelId: "haiku", name: "Haiku", description: "Fast" },
{ modelId: "sonnet", name: "Sonnet", description: "Balanced" },
],
currentModelId: "haiku",
},
[
{
id: "thought_level",
name: "Thinking",
id: "reasoning",
name: "Reasoning",
category: "thought_level",
type: "select",
currentValue: "medium",
@@ -122,36 +148,6 @@ describe("deriveModesFromACP", () => {
],
);
expect(result).toEqual({
modes: [],
currentModeId: null,
});
});
});
describe("deriveModelDefinitionsFromACP", () => {
test("attaches shared thinking options to ACP model state", () => {
const result = deriveModelDefinitionsFromACP("claude-acp", {
availableModels: [
{ modelId: "haiku", name: "Haiku", description: "Fast" },
{ modelId: "sonnet", name: "Sonnet", description: "Balanced" },
],
currentModelId: "haiku",
}, [
{
id: "reasoning",
name: "Reasoning",
category: "thought_level",
type: "select",
currentValue: "medium",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
],
},
]);
expect(result).toEqual([
{
provider: "claude-acp",
@@ -160,9 +156,27 @@ describe("deriveModelDefinitionsFromACP", () => {
description: "Fast",
isDefault: true,
thinkingOptions: [
{ id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
{ id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
{ id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
{
id: "low",
label: "Low",
description: undefined,
isDefault: false,
metadata: undefined,
},
{
id: "medium",
label: "Medium",
description: undefined,
isDefault: true,
metadata: undefined,
},
{
id: "high",
label: "High",
description: undefined,
isDefault: false,
metadata: undefined,
},
],
defaultThinkingOptionId: "medium",
},
@@ -173,9 +187,27 @@ describe("deriveModelDefinitionsFromACP", () => {
description: "Balanced",
isDefault: false,
thinkingOptions: [
{ id: "low", label: "Low", description: undefined, isDefault: false, metadata: undefined },
{ id: "medium", label: "Medium", description: undefined, isDefault: true, metadata: undefined },
{ id: "high", label: "High", description: undefined, isDefault: false, metadata: undefined },
{
id: "low",
label: "Low",
description: undefined,
isDefault: false,
metadata: undefined,
},
{
id: "medium",
label: "Medium",
description: undefined,
isDefault: true,
metadata: undefined,
},
{
id: "high",
label: "High",
description: undefined,
isDefault: false,
metadata: undefined,
},
],
defaultThinkingOptionId: "medium",
},
@@ -604,9 +636,7 @@ describe("ACPAgentSession", () => {
thinkingOptionId: null,
modeId: "xhigh",
})),
getAvailableModes: vi.fn(async () => [
{ id: "xhigh", label: "xhigh" },
]),
getAvailableModes: vi.fn(async () => [{ id: "xhigh", label: "xhigh" }]),
getCurrentMode: vi.fn(async () => "xhigh"),
setMode: vi.fn(),
getPendingPermissions: vi.fn(() => []),
@@ -1,7 +1,4 @@
import {
type ChildProcess,
type ChildProcessWithoutNullStreams,
} from "node:child_process";
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
@@ -107,8 +104,7 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
terminal: true,
};
const COPILOT_AUTOPILOT_MODE =
"https://agentclientprotocol.com/protocol/session-modes#autopilot";
const COPILOT_AUTOPILOT_MODE = "https://agentclientprotocol.com/protocol/session-modes#autopilot";
type ACPAgentClientOptions = {
provider: string;
@@ -485,14 +481,15 @@ export class ACPAgentClient implements AgentClient {
}
}
protected async spawnProcess(
launchEnv?: Record<string, string>,
): Promise<SpawnedACPProcess> {
protected async spawnProcess(launchEnv?: Record<string, string>): Promise<SpawnedACPProcess> {
const { command, args } = await this.resolveLaunchCommand();
const child = spawnProcess(command, args, {
cwd: process.cwd(),
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...applyProviderEnv(
process.env as Record<string, string | undefined>,
this.runtimeSettings,
),
...(launchEnv ?? {}),
},
stdio: ["pipe", "pipe", "pipe"],
@@ -804,7 +801,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
};
}
async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<{ turnId: string }> {
async startTurn(
prompt: AgentPromptInput,
_options?: AgentRunOptions,
): Promise<{ turnId: string }> {
if (this.closed) {
throw new Error(`${this.provider} session is closed`);
}
@@ -1155,9 +1155,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.activeForegroundTurnId = null;
}
async requestPermission(
params: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
if (shouldAutoApprovePermissionRequest(this.provider, this.currentMode)) {
const selectedOption = selectPermissionOption(params.options, { behavior: "allow" });
return selectedOption
@@ -1177,12 +1175,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (this.toolSnapshotTransformer) {
toolSnapshot = this.toolSnapshotTransformer(toolSnapshot);
}
const request = mapPermissionRequest(
this.provider,
requestId,
params,
toolSnapshot,
);
const request = mapPermissionRequest(this.provider, requestId, params, toolSnapshot);
const promise = new Promise<RequestPermissionResponse>((resolve, reject) => {
this.pendingPermissions.set(requestId, {
@@ -1242,11 +1235,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> {
const terminalId = randomUUID();
const env = Object.fromEntries((params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]));
const env = Object.fromEntries(
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
);
const child = spawnProcess(params.command, params.args ?? [], {
cwd: params.cwd ?? this.config.cwd,
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...applyProviderEnv(
process.env as Record<string, string | undefined>,
this.runtimeSettings,
),
...env,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -1271,9 +1269,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
rejectExit,
};
child.stdout!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.stderr!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.once("error", (error) => rejectExit(error instanceof Error ? error : new Error(String(error))));
child.stdout!.on("data", (chunk: Buffer | string) =>
appendTerminalOutput(entry, chunk.toString()),
);
child.stderr!.on("data", (chunk: Buffer | string) =>
appendTerminalOutput(entry, chunk.toString()),
);
child.once("error", (error) =>
rejectExit(error instanceof Error ? error : new Error(String(error))),
);
child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal };
entry.exit = exit;
@@ -1328,7 +1332,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const child = spawnProcess(command, args, {
cwd: this.config.cwd,
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...applyProviderEnv(
process.env as Record<string, string | undefined>,
this.runtimeSettings,
),
...(this.launchEnv ?? {}),
},
stdio: ["pipe", "pipe", "pipe"],
@@ -1518,7 +1525,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private deriveAvailableModels(
models: SessionModelState | null | undefined,
): AgentModelDefinition[] {
const availableModels = deriveModelDefinitionsFromACP(this.provider, models, this.configOptions);
const availableModels = deriveModelDefinitionsFromACP(
this.provider,
models,
this.configOptions,
);
return this.modelTransformer ? this.modelTransformer(availableModels) : availableModels;
}
@@ -1578,7 +1589,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
private finishTurn(event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>): void {
private finishTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.activeForegroundTurnId = null;
this.suppressUserEchoMessageId = null;
this.suppressUserEchoText = null;
@@ -1623,7 +1636,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return parts.length > 0 ? parts.join(" | ") : undefined;
}
private getSelectConfigOption(category: string): Extract<SessionConfigOption, { type: "select" }> | null {
private getSelectConfigOption(
category: string,
): Extract<SessionConfigOption, { type: "select" }> | null {
const option = this.configOptions.find(
(entry): entry is Extract<SessionConfigOption, { type: "select" }> =>
entry.type === "select" && entry.category === category,
@@ -1643,7 +1658,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
function flattenSelectOptions(
options: Extract<SessionConfigOption, { type: "select" }>["options"],
): Array<{ value: string; name: string; description?: string | null; group?: string }> {
const flattened: Array<{ value: string; name: string; description?: string | null; group?: string }> = [];
const flattened: Array<{
value: string;
name: string;
description?: string | null;
group?: string;
}> = [];
for (const option of options) {
if ("value" in option) {
flattened.push(option);
@@ -1752,7 +1772,9 @@ function extractPromptText(prompt: AgentPromptInput): string {
return prompt;
}
return prompt
.filter((block): block is Extract<AgentPromptContentBlock, { type: "text" }> => block.type === "text")
.filter(
(block): block is Extract<AgentPromptContentBlock, { type: "text" }> => block.type === "text",
)
.map((block) => block.text)
.join("");
}
@@ -1764,7 +1786,9 @@ function contentBlockToText(content: ContentBlock): string {
case "resource_link":
return content.title ?? content.uri;
case "resource":
return "text" in content.resource ? content.resource.text : `[resource:${content.resource.mimeType ?? "binary"}]`;
return "text" in content.resource
? content.resource.text
: `[resource:${content.resource.mimeType ?? "binary"}]`;
case "image":
return "[image]";
case "audio":
@@ -1785,8 +1809,8 @@ function mergeToolSnapshot(
title: (update.title ?? previous?.title ?? toolCallId) as string,
kind: update.kind ?? previous?.kind ?? null,
status: update.status ?? previous?.status ?? null,
content: update.content !== undefined ? update.content : previous?.content ?? null,
locations: update.locations !== undefined ? update.locations : previous?.locations ?? null,
content: update.content !== undefined ? update.content : (previous?.content ?? null),
locations: update.locations !== undefined ? update.locations : (previous?.locations ?? null),
rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput,
rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput,
...(isFull ? {} : {}),
@@ -1853,7 +1877,10 @@ function mapToolStatus(status: ToolCallStatus | null | undefined): ToolCallTimel
}
}
function mapToolDetail(snapshot: ACPToolSnapshot, terminals: Map<string, TerminalEntry>): ToolCallDetail {
function mapToolDetail(
snapshot: ACPToolSnapshot,
terminals: Map<string, TerminalEntry>,
): ToolCallDetail {
const firstLocation = snapshot.locations?.[0]?.path;
const textContent = extractToolText(snapshot.content);
const diffContent = extractDiffContent(snapshot.content);
@@ -1881,7 +1908,7 @@ function mapToolDetail(snapshot: ACPToolSnapshot, terminals: Map<string, Termina
newString:
snapshot.kind === "delete"
? ""
: diffContent?.newText ?? readString(rawInput, ["newText", "newString"]),
: (diffContent?.newText ?? readString(rawInput, ["newText", "newString"])),
unifiedDiff: textContent ?? undefined,
};
case "search":
@@ -1971,7 +1998,9 @@ function extractToolText(content: ToolCallContent[] | null | undefined): string
function extractDiffContent(
content: ToolCallContent[] | null | undefined,
): { oldText?: string | null; newText: string } | null {
const diff = content?.find((item): item is Extract<ToolCallContent, { type: "diff" }> => item.type === "diff");
const diff = content?.find(
(item): item is Extract<ToolCallContent, { type: "diff" }> => item.type === "diff",
);
return diff ? { oldText: diff.oldText ?? undefined, newText: diff.newText } : null;
}
@@ -2063,10 +2092,7 @@ function readRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function readString(
record: Record<string, unknown> | null,
keys: string[],
): string | undefined {
function readString(record: Record<string, unknown> | null, keys: string[]): string | undefined {
if (!record) {
return undefined;
}
@@ -2079,10 +2105,7 @@ function readString(
return undefined;
}
function readNumber(
record: Record<string, unknown> | null,
keys: string[],
): number | undefined {
function readNumber(record: Record<string, unknown> | null, keys: string[]): number | undefined {
if (!record) {
return undefined;
}
@@ -2131,7 +2154,9 @@ function stringifyUnknown(value: unknown): string | undefined {
}
}
function coerceSessionConfigMetadata(metadata: AgentMetadata | undefined): Partial<AgentSessionConfig> {
function coerceSessionConfigMetadata(
metadata: AgentMetadata | undefined,
): Partial<AgentSessionConfig> {
if (!metadata || typeof metadata !== "object") {
return {};
}
@@ -182,36 +182,40 @@ describe("ClaudeAgentSession integration", () => {
}
});
test.runIf(canRunClaudeIntegration)("streams a basic response turn end-to-end", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-",
});
try {
const events = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
);
expect(events[0]).toMatchObject({
type: "turn_started",
provider: "claude",
test.runIf(canRunClaudeIntegration)(
"streams a basic response turn end-to-end",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-",
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
compactText(event.item.text).includes("hello_world"),
),
).toBe(true);
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
try {
const events = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: HELLO_WORLD"),
);
expect(events[0]).toMatchObject({
type: "turn_started",
provider: "claude",
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
compactText(event.item.text).includes("hello_world"),
),
).toBe(true);
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)(
"keeps bypassPermissions available after a thinking-option restart",
@@ -276,100 +280,104 @@ describe("ClaudeAgentSession integration", () => {
60_000,
);
test.runIf(canRunClaudeIntegration)("runs a real Bash tool call and completes it", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-tool-",
});
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: echo TOOL_TEST_OUTPUT",
"After the command completes, reply with exactly: TOOL_DONE",
].join(" "),
),
);
const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
const completedBashCall = getLatestCompletedBashCall(events);
expect(bashCalls.length).toBeGreaterThan(0);
expect(completedBashCall).toBeDefined();
expect(completedBashCall?.detail.type).toBe("shell");
expect(
completedBashCall?.detail.type === "shell" &&
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
).toBe(true);
expect(compactText(getAssistantText(events))).toContain("tool_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
test.runIf(canRunClaudeIntegration)(
"runs a real Bash tool call and completes it",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-tool-",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: echo TOOL_TEST_OUTPUT",
"After the command completes, reply with exactly: TOOL_DONE",
].join(" "),
),
);
const bashCalls = getToolCalls(events).filter((item) => item.name.toLowerCase() === "bash");
const completedBashCall = getLatestCompletedBashCall(events);
expect(bashCalls.length).toBeGreaterThan(0);
expect(completedBashCall).toBeDefined();
expect(completedBashCall?.detail.type).toBe("shell");
expect(
completedBashCall?.detail.type === "shell" &&
completedBashCall.detail.output?.includes("TOOL_TEST_OUTPUT"),
).toBe(true);
expect(compactText(getAssistantText(events))).toContain("tool_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)(
"interrupts a running Bash turn and continues on the same query",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-interrupt-continue-",
});
try {
const firstStream = streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: sleep 10",
"Do not use a background task.",
"Do not do anything after starting the command.",
].join(" "),
);
const initialEvents = await collectUntil(
firstStream,
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name.toLowerCase() === "bash",
45_000,
);
const firstQuery = getInternalQuery(handle.session);
expect(firstQuery).toBeTruthy();
await handle.session.interrupt();
const canceledEvents = await collectUntilTerminal(firstStream, {
timeoutMs: 20_000,
const handle = await createSession({
cwdPrefix: "claude-agent-interrupt-continue-",
});
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
expect(
allFirstTurnEvents.some(
(event) => event.type === "turn_canceled" && event.provider === "claude",
),
).toBe(true);
try {
const firstStream = streamSession(
handle.session,
[
"Use the Bash tool.",
"Run exactly: sleep 10",
"Do not use a background task.",
"Do not do anything after starting the command.",
].join(" "),
);
const followUpEvents = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
);
const secondQuery = getInternalQuery(handle.session);
const initialEvents = await collectUntil(
firstStream,
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name.toLowerCase() === "bash",
45_000,
);
const firstQuery = getInternalQuery(handle.session);
expect(secondQuery).toBe(firstQuery);
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
expect(followUpEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
expect(firstQuery).toBeTruthy();
await handle.session.interrupt();
const canceledEvents = await collectUntilTerminal(firstStream, {
timeoutMs: 20_000,
});
const allFirstTurnEvents = [...initialEvents, ...canceledEvents];
expect(
allFirstTurnEvents.some(
(event) => event.type === "turn_canceled" && event.provider === "claude",
),
).toBe(true);
const followUpEvents = await collectUntilTerminal(
streamSession(handle.session, "Respond with exactly: AFTER_INTERRUPT_OK"),
);
const secondQuery = getInternalQuery(handle.session);
expect(secondQuery).toBe(firstQuery);
expect(compactText(getAssistantText(followUpEvents))).toContain("after_interrupt_ok");
expect(followUpEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
@@ -377,106 +385,110 @@ describe("ClaudeAgentSession integration", () => {
test.runIf(canRunClaudeIntegration)(
"creates an autonomous live turn when a background task completes",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-autonomous-",
});
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const foregroundEvents = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
),
{ timeoutMs: 45_000 },
);
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await collectSubscribedUntil(
handle.session,
(event) => isTerminalEvent(event),
45_000,
);
expect(
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
).toBe(true);
expect(compactText(getAssistantText(liveEvents))).toContain(
autonomousWakeToken.toLowerCase(),
);
expect(liveEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
const handle = await createSession({
cwdPrefix: "claude-agent-autonomous-",
});
} finally {
await cleanupSession(handle);
}
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
try {
const foregroundEvents = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Task tool to start a background sub-agent.",
"In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE",
"Do not wait for task completion.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
),
{ timeoutMs: 45_000 },
);
expect(compactText(getAssistantText(foregroundEvents))).toContain("spawned");
const liveEvents = await collectSubscribedUntil(
handle.session,
(event) => isTerminalEvent(event),
45_000,
);
expect(
liveEvents.some((event) => event.type === "turn_started" && event.provider === "claude"),
).toBe(true);
expect(compactText(getAssistantText(liveEvents))).toContain(
autonomousWakeToken.toLowerCase(),
);
expect(liveEvents.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test.runIf(canRunClaudeIntegration)("surfaces permission requests and resumes after approval", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
modeId: "default",
});
const permissionFile = path.join(handle.cwd, "permission.txt");
test.runIf(canRunClaudeIntegration)(
"surfaces permission requests and resumes after approval",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
modeId: "default",
});
const permissionFile = path.join(handle.cwd, "permission.txt");
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
"If approval is required, wait for approval.",
"After the command succeeds, reply with exactly: PERM_DONE",
].join(" "),
),
{
timeoutMs: 45_000,
onEvent: async (event) => {
if (event.type !== "permission_requested") {
return;
}
await handle.session.respondToPermission(event.request.id, {
behavior: "allow",
});
try {
const events = await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool to run exactly: printf 'PERM_TEST' > permission.txt",
"If approval is required, wait for approval.",
"After the command succeeds, reply with exactly: PERM_DONE",
].join(" "),
),
{
timeoutMs: 45_000,
onEvent: async (event) => {
if (event.type !== "permission_requested") {
return;
}
await handle.session.respondToPermission(event.request.id, {
behavior: "allow",
});
},
},
},
);
);
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
const permissionResolved = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
event.type === "permission_resolved",
);
const completedBashCall = getLatestCompletedBashCall(events);
const permissionRequest = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested",
);
const permissionResolved = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> =>
event.type === "permission_resolved",
);
const completedBashCall = getLatestCompletedBashCall(events);
expect(permissionRequest?.request.kind).toBe("tool");
expect(permissionResolved).toMatchObject({
type: "permission_resolved",
provider: "claude",
resolution: { behavior: "allow" },
});
expect(completedBashCall).toBeDefined();
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
expect(compactText(getAssistantText(events))).toContain("perm_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
}, 60_000);
expect(permissionRequest?.request.kind).toBe("tool");
expect(permissionResolved).toMatchObject({
type: "permission_resolved",
provider: "claude",
resolution: { behavior: "allow" },
});
expect(completedBashCall).toBeDefined();
expect(readFileSync(permissionFile, "utf8")).toBe("PERM_TEST");
expect(compactText(getAssistantText(events))).toContain("perm_done");
expect(events.at(-1)).toMatchObject({
type: "turn_completed",
provider: "claude",
});
} finally {
await cleanupSession(handle);
}
},
60_000,
);
});
@@ -212,7 +212,9 @@ function collectAssistantText(events: AgentStreamEvent[]): string {
.join("");
}
function subscribeToEvents(session: { subscribe: (callback: (event: AgentStreamEvent) => void) => () => void }) {
function subscribeToEvents(session: {
subscribe: (callback: (event: AgentStreamEvent) => void) => () => void;
}) {
const queue = createAsyncQueue<AgentStreamEvent>();
const unsubscribe = session.subscribe((event) => {
queue.push(event);
@@ -484,9 +486,9 @@ describe("ClaudeAgentSession interrupt regression", () => {
expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false);
expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true);
expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE");
expect(
observedSecondTurnEvents.filter((event) => event.type === "turn_started").length,
).toBe(1);
expect(observedSecondTurnEvents.filter((event) => event.type === "turn_started").length).toBe(
1,
);
expect(
observedSecondTurnEvents.some(
(event) => event.type === "turn_failed" || event.type === "turn_canceled",
@@ -806,9 +806,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
effort: options.effort,
});
return createBaseQueryMock(
vi.fn(async () => ({ done: true, value: undefined })),
);
return createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
},
);
@@ -165,8 +165,7 @@ describe("convertClaudeHistoryEntry", () => {
type: "user",
message: {
role: "user",
content:
"<local-command-stdout>Set model to claude-opus-4-6</local-command-stdout>",
content: "<local-command-stdout>Set model to claude-opus-4-6</local-command-stdout>",
},
userType: "external",
};
@@ -176,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
message: {
role: "user",
content:
'<local-command-stdout>Set model to \u001b[1mopus (claude-opus-4-6)\u001b[22m</local-command-stdout>',
"<local-command-stdout>Set model to \u001b[1mopus (claude-opus-4-6)\u001b[22m</local-command-stdout>",
},
};
@@ -10,7 +10,6 @@ import {
type AgentDefinition,
type CanUseTool,
type McpServerConfig as ClaudeSdkMcpServerConfig,
type Options,
type PermissionMode,
type PermissionResult,
@@ -35,10 +34,7 @@ import {
mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall,
} from "./claude/task-notification-tool-call.js";
import {
getClaudeModels,
normalizeClaudeRuntimeModelId,
} from "./claude/claude-models.js";
import { getClaudeModels, normalizeClaudeRuntimeModelId } from "./claude/claude-models.js";
import { parsePartialJsonObject } from "./claude/partial-json.js";
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
import {
@@ -76,10 +72,7 @@ import type {
McpServerConfig,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
@@ -221,8 +214,7 @@ function applyRuntimeSettingsToClaudeOptions(
// PATH lookup failures in the managed runtime bundle.
// When the SDK passes a native binary path (from pathToClaudeCodeExecutable)
// or the user overrides the command via runtime settings, use that directly.
const isDefaultRuntime =
resolved.command === "node" || resolved.command === "bun";
const isDefaultRuntime = resolved.command === "node" || resolved.command === "bun";
const command = isDefaultRuntime ? process.execPath : resolved.command;
const child = spawnProcess(command, resolved.args, {
cwd: spawnOptions.cwd,
@@ -412,7 +404,8 @@ function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER;
}
const LOCAL_COMMAND_STDOUT_PATTERN = /^\s*<local-command-stdout>[\s\S]*<\/local-command-stdout>\s*$/;
const LOCAL_COMMAND_STDOUT_PATTERN =
/^\s*<local-command-stdout>[\s\S]*<\/local-command-stdout>\s*$/;
function isClaudeLocalCommandStdout(value: unknown): boolean {
const normalized = normalizeClaudeTranscriptText(value);
@@ -1103,7 +1096,6 @@ export class ClaudeAgentClient implements AgentClient {
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
return getClaudeModels();
}
async listPersistedAgents(
@@ -1141,7 +1133,7 @@ export class ClaudeAgentClient implements AgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const resolvedBinary = await findExecutable("claude") ?? "not found";
const resolvedBinary = (await findExecutable("claude")) ?? "not found";
const available = await this.isAvailable();
const version = await resolveClaudeVersion(this.runtimeSettings);
let modelsValue = "Not checked";
@@ -1183,16 +1175,22 @@ export class ClaudeAgentClient implements AgentClient {
}
}
async function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): Promise<string | null> {
async function resolveClaudeVersion(
runtimeSettings?: ProviderRuntimeSettings,
): Promise<string | null> {
const command = runtimeSettings?.command;
try {
if (command?.mode === "replace") {
const { stdout } = await execFileAsync(command.argv[0]!, [...command.argv.slice(1), "--version"], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
const { stdout } = await execFileAsync(
command.argv[0]!,
[...command.argv.slice(1), "--version"],
{
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
},
);
return stdout.trim() || null;
}
@@ -1236,9 +1234,7 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow;
}
function readUsageTotalTokens(
usage: unknown,
): number | undefined {
function readUsageTotalTokens(usage: unknown): number | undefined {
if (!usage || typeof usage !== "object") {
return undefined;
}
@@ -2094,10 +2090,9 @@ class ClaudeAgentSession implements AgentSession {
: process.env["PATH"] !== undefined
? "PATH"
: null,
pathIncludesClaudeLocalBin:
(process.env["Path"] ?? process.env["PATH"] ?? "")
.toLowerCase()
.includes("\\.local\\bin"),
pathIncludesClaudeLocalBin: (process.env["Path"] ?? process.env["PATH"] ?? "")
.toLowerCase()
.includes("\\.local\\bin"),
},
"Resolved Claude executable",
);
@@ -2229,8 +2224,7 @@ class ClaudeAgentSession implements AgentSession {
}
private isAbortError(message: SDKMessage): boolean {
const errors =
"errors" in message && Array.isArray(message.errors) ? message.errors : [];
const errors = "errors" in message && Array.isArray(message.errors) ? message.errors : [];
return errors.some((e: string) => /\baborted\b/i.test(e));
}
@@ -2271,9 +2265,11 @@ class ClaudeAgentSession implements AgentSession {
if (this.getRecentStderrDiagnostic()) {
return;
}
const message =
typeof error === "string" ? error : error instanceof Error ? error.message : "";
if (!/\bprocess exited with code\b/i.test(message) && !/\bterminated by signal\b/i.test(message)) {
const message = typeof error === "string" ? error : error instanceof Error ? error.message : "";
if (
!/\bprocess exited with code\b/i.test(message) &&
!/\bterminated by signal\b/i.test(message)
) {
return;
}
@@ -2516,11 +2512,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
}
if (
message.type === "result" &&
message.subtype !== "success" &&
this.isAbortError(message)
) {
if (message.type === "result" && message.subtype !== "success" && this.isAbortError(message)) {
this.logger.debug("Suppressing abort result by content");
return;
}
@@ -2988,9 +2980,7 @@ class ClaudeAgentSession implements AgentSession {
outputTokens: message.usage.output_tokens,
totalCostUsd: message.total_cost_usd,
};
const contextWindowMaxTokens = extractContextWindowSize(
modelUsage ?? message.modelUsage,
);
const contextWindowMaxTokens = extractContextWindowSize(modelUsage ?? message.modelUsage);
if (contextWindowMaxTokens !== undefined) {
this.lastContextWindowMaxTokens = contextWindowMaxTokens;
usage.contextWindowMaxTokens = contextWindowMaxTokens;
@@ -3107,8 +3097,7 @@ class ClaudeAgentSession implements AgentSession {
input,
detail: toolDetail,
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
actions:
kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
actions: kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
metadata: Object.keys(metadata).length ? metadata : undefined,
};
@@ -65,88 +65,92 @@ describe("Claude SDK direct behavior", () => {
}
});
test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => {
const cwd = tmpCwd();
const input = new Pushable<SDKUserMessage>();
const claudeBinary = await findExecutable("claude");
test.runIf(canRunClaudeIntegration)(
"shows what happens after interrupt()",
async () => {
const cwd = tmpCwd();
const input = new Pushable<SDKUserMessage>();
const claudeBinary = await findExecutable("claude");
// Use same options as claude-agent.ts
const q = query({
prompt: input,
options: {
cwd,
includePartialMessages: true,
permissionMode: "bypassPermissions",
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
systemPrompt: {
type: "preset",
preset: "claude_code",
// Use same options as claude-agent.ts
const q = query({
prompt: input,
options: {
cwd,
includePartialMessages: true,
permissionMode: "bypassPermissions",
...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}),
systemPrompt: {
type: "preset",
preset: "claude_code",
},
settingSources: ["user", "project"],
},
settingSources: ["user", "project"],
},
});
try {
// Send first message
input.push({
type: "user",
message: { role: "user", content: "Say exactly: MESSAGE_ONE" },
parent_tool_use_id: null,
session_id: "",
});
// Collect events until we see assistant, then interrupt
const msg1Events: SDKMessage[] = [];
for await (const event of q) {
msg1Events.push(event);
try {
// Send first message
input.push({
type: "user",
message: { role: "user", content: "Say exactly: MESSAGE_ONE" },
parent_tool_use_id: null,
session_id: "",
});
if (event.type === "assistant") {
// Push MSG2 BEFORE interrupt (like our wrapper does when a new message comes in)
input.push({
type: "user",
message: { role: "user", content: "Say exactly: MESSAGE_TWO" },
parent_tool_use_id: null,
session_id: "",
});
await q.interrupt();
break;
// Collect events until we see assistant, then interrupt
const msg1Events: SDKMessage[] = [];
for await (const event of q) {
msg1Events.push(event);
if (event.type === "assistant") {
// Push MSG2 BEFORE interrupt (like our wrapper does when a new message comes in)
input.push({
type: "user",
message: { role: "user", content: "Say exactly: MESSAGE_TWO" },
parent_tool_use_id: null,
session_id: "",
});
await q.interrupt();
break;
}
if (event.type === "result") {
break;
}
}
if (event.type === "result") {
break;
// MSG2 was already pushed before interrupt
const msg2Events: SDKMessage[] = [];
for await (const event of q) {
msg2Events.push(event);
if (event.type === "result") {
break;
}
}
}
// MSG2 was already pushed before interrupt
const msg2Events: SDKMessage[] = [];
for await (const event of q) {
msg2Events.push(event);
if (event.type === "result") {
break;
}
}
// Analyze response
let responseText = "";
for (const event of msg2Events) {
if (event.type === "assistant" && "message" in event && event.message?.content) {
const content = event.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
responseText += block.text;
// Analyze response
let responseText = "";
for (const event of msg2Events) {
if (event.type === "assistant" && "message" in event && event.message?.content) {
const content = event.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
responseText += block.text;
}
}
}
}
}
}
const sawResult = msg2Events.some((event) => event.type === "result");
// The SDK may short-circuit after interrupt without a result event.
expect(sawResult || responseText.length === 0).toBe(true);
} finally {
input.end();
rmSync(cwd, { recursive: true, force: true });
}
}, 120000);
const sawResult = msg2Events.some((event) => event.type === "result");
// The SDK may short-circuit after interrupt without a result event.
expect(sawResult || responseText.length === 0).toBe(true);
} finally {
input.end();
rmSync(cwd, { recursive: true, force: true });
}
},
120000,
);
});
@@ -45,9 +45,7 @@ export function getClaudeModels(): AgentModelDefinition[] {
* Normalize a runtime model string (from SDK init message) to a known model ID.
* Handles the `[1m]` suffix that the SDK appends for 1M context sessions.
*/
export function normalizeClaudeRuntimeModelId(
value: string | null | undefined,
): string | null {
export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return null;
@@ -61,7 +61,11 @@ function responseCompleted(id: string): Record<string, unknown> {
};
}
function functionCallEvent(callId: string, name: string, argumentsJson: string): Record<string, unknown> {
function functionCallEvent(
callId: string,
name: string,
argumentsJson: string,
): Record<string, unknown> {
return {
type: "response.output_item.done",
item: {
@@ -116,7 +120,11 @@ function requestUserInputSse(callId: string): string {
}
function assistantMessageSse(text: string): string {
return sse([responseCreated("resp-2"), assistantMessageEvent("msg-1", text), responseCompleted("resp-2")]);
return sse([
responseCreated("resp-2"),
assistantMessageEvent("msg-1", text),
responseCompleted("resp-2"),
]);
}
async function startMockResponsesServer(sequence: string[]): Promise<{
@@ -276,10 +284,7 @@ describe("Codex app-server provider (e2e)", () => {
label: "question permission request",
predicate: (
event,
): event is Extract<
AgentStreamEvent,
{ type: "permission_requested" }
> =>
): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" &&
event.request.provider === "codex" &&
event.request.kind === "question" &&
@@ -1,7 +1,12 @@
import { describe, expect, test, vi } from "vitest";
import { existsSync, rmSync } from "node:fs";
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
import type {
AgentLaunchContext,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
} from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
codexAppServerTurnInputFromPrompt,
@@ -356,10 +361,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
});
@@ -386,10 +388,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
@@ -416,10 +415,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
@@ -432,10 +428,7 @@ describe("Codex app-server provider", () => {
id: "favorite_drink",
header: "Drink",
question: "Which drink do you want?",
options: [
{ label: "Coffee", description: "Default" },
{ label: "Tea" },
],
options: [{ label: "Coffee", description: "Default" }, { label: "Tea" }],
},
],
},
@@ -157,10 +157,7 @@ function isObjectSchemaNode(schema: Record<string, unknown>): boolean {
);
}
function normalizeCodexOutputSchemaNode(
schema: unknown,
path: string,
): unknown {
function normalizeCodexOutputSchemaNode(schema: unknown, path: string): unknown {
if (Array.isArray(schema)) {
return schema.map((entry, index) => normalizeCodexOutputSchemaNode(entry, `${path}[${index}]`));
}
@@ -819,7 +816,10 @@ function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): st
return normalizePlanMarkdown(lines.join("\n"));
}
function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolCallTimelineItem | null {
function mapCodexPlanToToolCall(params: {
callId: string;
text: string;
}): ToolCallTimelineItem | null {
const text = normalizePlanMarkdown(params.text);
if (!text) {
return null;
@@ -837,9 +837,10 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC
};
}
function buildPlanPermissionActions(
options?: { includeResumeAction?: boolean; resumeLabel?: string },
): AgentPermissionAction[] {
function buildPlanPermissionActions(options?: {
includeResumeAction?: boolean;
resumeLabel?: string;
}): AgentPermissionAction[] {
const actions: AgentPermissionAction[] = [
{
id: "reject",
@@ -2559,9 +2560,7 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
private findCollaborationMode(
target: "code" | "plan",
): {
private findCollaborationMode(target: "code" | "plan"): {
name: string;
mode?: string | null;
model?: string | null;
@@ -3189,7 +3188,9 @@ class CodexAppServerAgentSession implements AgentSession {
const questions = pending.questions ?? [];
const itemId =
typeof pendingRequest?.metadata?.itemId === "string" ? pendingRequest.metadata.itemId : requestId;
typeof pendingRequest?.metadata?.itemId === "string"
? pendingRequest.metadata.itemId
: requestId;
if (response.behavior === "allow") {
const mappedAnswers = mapCodexQuestionResponseByHeader({
questions,
@@ -3201,9 +3202,7 @@ class CodexAppServerAgentSession implements AgentSession {
questions
.map((question) => {
const fallback = question.options[0]?.label?.trim();
return fallback
? [question.id, { answers: [fallback] }]
: null;
return fallback ? [question.id, { answers: [fallback] }] : null;
})
.filter((entry): entry is [string, { answers: string[] }] => entry !== null),
);
@@ -3999,7 +3998,9 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly runtimeSettings?: ProviderRuntimeSettings,
) {}
private async spawnAppServer(launchEnv?: Record<string, string>): Promise<ChildProcessWithoutNullStreams> {
private async spawnAppServer(
launchEnv?: Record<string, string>,
): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
this.logger.trace(
{
@@ -4216,7 +4217,10 @@ export class CodexAppServerAgentClient implements AgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
{ label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{
label: "Version",
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
},
];
let status = formatDiagnosticStatus(available);
@@ -1,11 +1,6 @@
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = [
"gpt-5",
"gpt-4.1",
"o3",
"o4-mini",
] as const;
const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = ["gpt-5", "gpt-4.1", "o3", "o4-mini"] as const;
export const CODEX_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
@@ -68,7 +68,10 @@ const CodexEditToolNameSchema = z.union([
z.literal("apply_diff"),
]);
const CodexSearchToolNameSchema = z.union([z.literal("search"), z.literal("web_search")]);
const CodexSpeakToolNameSchema = z.string().min(1).refine((name) => isSpeakToolName(name.trim()));
const CodexSpeakToolNameSchema = z
.string()
.min(1)
.refine((name) => isSpeakToolName(name.trim()));
const CodexToolKindSchema = z.enum([
"shell",
@@ -734,9 +737,10 @@ function parseFileChangeEntries(
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
}
function resolveFileChangeTextFields(
file: CodexFileChangeEntry | undefined,
): { unifiedDiff?: string; newString?: string } {
function resolveFileChangeTextFields(file: CodexFileChangeEntry | undefined): {
unifiedDiff?: string;
newString?: string;
} {
if (!file) {
return {};
}
@@ -97,7 +97,10 @@ export class CopilotACPAgentClient extends ACPAgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
{ label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{
label: "Version",
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
},
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
]),
@@ -10,17 +10,11 @@ type DiagnosticEntry = {
value: string;
};
export function formatProviderDiagnostic(
providerName: string,
entries: DiagnosticEntry[],
): string {
export function formatProviderDiagnostic(providerName: string, entries: DiagnosticEntry[]): string {
return [providerName, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
}
export function formatProviderDiagnosticError(
providerName: string,
error: unknown,
): string {
export function formatProviderDiagnosticError(providerName: string, error: unknown): string {
return formatProviderDiagnostic(providerName, [
{
label: "Error",
@@ -79,10 +79,7 @@ describe("opencode agent commands E2E", () => {
});
const token = `RAW_PROMPT_TOKEN_${Date.now()}`;
await ctx.client.sendMessage(
agent.id,
`/not-a-real-command respond with exactly: ${token}`,
);
await ctx.client.sendMessage(agent.id, `/not-a-real-command respond with exactly: ${token}`);
const state = await ctx.client.waitForFinish(agent.id, 30_000);
expect(state.status).toBe("idle");
@@ -144,9 +144,7 @@ describe("opencode agent error handling (real)", () => {
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(elapsed).toBeLessThan(30_000);
console.log(
`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`,
);
console.log(`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`);
} finally {
await session.close().catch(() => undefined);
}
@@ -176,9 +174,9 @@ describe("opencode agent error handling (real)", () => {
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
expect(
(terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase(),
).toMatch(/insufficient balance|resource package|recharge/);
expect((terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase()).toMatch(
/insufficient balance|resource package|recharge/,
);
} finally {
await session.close().catch(() => undefined);
}
@@ -378,13 +378,16 @@ function buildOpenCodeModelDefinition(
}
function resolveOpenCodeSelectedModelContextWindow(
providers: {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
} | null | undefined,
providers:
| {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
}
| null
| undefined,
modelId: string | null | undefined,
): number | undefined {
if (!providers) {
@@ -398,13 +401,18 @@ function resolveOpenCodeSelectedModelContextWindow(
return lookup.get(modelLookupKey);
}
function buildOpenCodeModelContextWindowLookup(providers: {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
} | null | undefined): Map<string, number> {
function buildOpenCodeModelContextWindowLookup(
providers:
| {
connected?: string[];
all?: Array<{
id: string;
models?: Record<string, unknown>;
}>;
}
| null
| undefined,
): Map<string, number> {
const lookup = new Map<string, number>();
if (!providers) {
return lookup;
@@ -876,16 +884,10 @@ export class OpenCodeAgentClient implements AgentClient {
const client = createOpencodeClient({ baseUrl: url, directory });
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("OpenCode app.agents timed out after 10s")),
10_000,
);
setTimeout(() => reject(new Error("OpenCode app.agents timed out after 10s")), 10_000);
});
const response = await Promise.race([
client.app.agents({ directory }),
timeoutPromise,
]);
const response = await Promise.race([client.app.agents({ directory }), timeoutPromise]);
if (response.error || !response.data) {
return DEFAULT_MODES;
@@ -965,7 +967,10 @@ export class OpenCodeAgentClient implements AgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
{ label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{
label: "Version",
value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown",
},
{ label: "Server", value: serverStatus },
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
@@ -1038,8 +1043,6 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export function translateOpenCodeEvent(
event: OpenCodeEvent,
state: OpenCodeEventTranslationState,
@@ -1075,10 +1078,7 @@ export function translateOpenCodeEvent(
}
}
if (
!state.emittedStructuredMessageIds.has(info.id) &&
info.time?.completed !== undefined
) {
if (!state.emittedStructuredMessageIds.has(info.id) && info.time?.completed !== undefined) {
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
state.emittedStructuredMessageIds.add(info.id);
@@ -1224,16 +1224,19 @@ export function translateOpenCodeEvent(
if (!q.question || !q.header) {
return [];
}
const options = q.options?.map((o) => ({
label: o.label,
...(o.description ? { description: o.description } : {}),
})) ?? [];
return [{
question: q.question,
header: q.header,
options,
...(q.multiple === true ? { multiSelect: true } : {}),
}];
const options =
q.options?.map((o) => ({
label: o.label,
...(o.description ? { description: o.description } : {}),
})) ?? [];
return [
{
question: q.question,
header: q.header,
options,
...(q.multiple === true ? { multiSelect: true } : {}),
},
];
});
if (questions.length === 0) {
@@ -1357,8 +1360,9 @@ class OpenCodeAgentSession implements AgentSession {
this.logger = logger;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(config.model);
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
config.model,
);
}
get id(): string | null {
@@ -1378,8 +1382,9 @@ class OpenCodeAgentSession implements AgentSession {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
this.config.model = normalizedModelId ?? undefined;
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(this.config.model);
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
this.config.model,
);
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -1494,8 +1499,7 @@ class OpenCodeAgentSession implements AgentSession {
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
this.accumulatedUsage =
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
this.accumulatedUsage = contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
const parts = buildOpenCodePromptParts(prompt);
const model = this.parseModel(this.config.model);
@@ -1515,71 +1519,77 @@ class OpenCodeAgentSession implements AgentSession {
// Handle both success and error in the response handler as a fallback —
// finishForegroundTurn's guard prevents duplicate terminal events if the
// SSE stream already delivered the event.
void this.client.session.command({
sessionID: this.sessionId,
directory: this.config.cwd,
command: slashCommand.commandName,
arguments: slashCommand.args ?? "",
...(this.config.model ? { model: this.config.model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
}).then((response) => {
if (response.error) {
const errorMsg = normalizeTurnFailureError(response.error);
void this.client.session
.command({
sessionID: this.sessionId,
directory: this.config.cwd,
command: slashCommand.commandName,
arguments: slashCommand.args ?? "",
...(this.config.model ? { model: this.config.model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
})
.then((response) => {
if (response.error) {
const errorMsg = normalizeTurnFailureError(response.error);
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: errorMsg },
turnId,
);
} else {
this.finishForegroundTurn(
{ type: "turn_completed", provider: "opencode", usage: undefined },
turnId,
);
}
})
.catch((err) => {
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: errorMsg },
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
turnId,
);
} else {
this.finishForegroundTurn(
{ type: "turn_completed", provider: "opencode", usage: undefined },
turnId,
);
}
}).catch((err) => {
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
turnId,
);
});
});
} else {
void this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
...(options?.outputSchema
? {
format: {
type: "json_schema" as const,
schema: options.outputSchema as Record<string, unknown>,
void this.client.session
.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
...(options?.outputSchema
? {
format: {
type: "json_schema" as const,
schema: options.outputSchema as Record<string, unknown>,
},
}
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
})
.then((promptResponse) => {
if (promptResponse.error) {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(promptResponse.error),
},
}
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
}).then((promptResponse) => {
if (promptResponse.error) {
turnId,
);
}
})
.catch((error) => {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(promptResponse.error),
error: normalizeTurnFailureError(error),
},
turnId,
);
}
}).catch((error) => {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(error),
},
turnId,
);
});
});
}
return { turnId };
@@ -1615,7 +1625,11 @@ class OpenCodeAgentSession implements AgentSession {
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
if (e.type === "turn_completed" || e.type === "turn_failed" || e.type === "turn_canceled") {
if (
e.type === "turn_completed" ||
e.type === "turn_failed" ||
e.type === "turn_canceled"
) {
if (e.type === "turn_failed") {
this.finishForegroundTurn(
{
@@ -2065,12 +2079,9 @@ class OpenCodeAgentSession implements AgentSession {
if (hasNormalizedOpenCodeUsage(this.accumulatedUsage)) {
translatedEvent.usage = this.accumulatedUsage;
}
const contextWindowMaxTokens =
this.resolveSelectedModelContextWindowMaxTokens();
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
this.accumulatedUsage =
contextWindowMaxTokens !== undefined
? { contextWindowMaxTokens }
: {};
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
}
}
@@ -26,9 +26,7 @@ describe("OpenCode assistant message", () => {
const result = await session.run("Say hello back in one sentence.");
const assistantItems = result.timeline.filter(
(item) => item.type === "assistant_message",
);
const assistantItems = result.timeline.filter((item) => item.type === "assistant_message");
expect(assistantItems.length).toBeGreaterThan(0);
expect(result.finalText.length).toBeGreaterThan(0);
} finally {
@@ -69,5 +67,4 @@ describe("OpenCode assistant message", () => {
},
60_000,
);
});
@@ -3,11 +3,7 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { Logger } from "pino";
import type {
ClientSideConnection,
SessionConfigOption,
ToolKind,
} from "@agentclientprotocol/sdk";
import type { ClientSideConnection, SessionConfigOption, ToolKind } from "@agentclientprotocol/sdk";
import type {
AgentLaunchContext,
@@ -30,11 +26,7 @@ import type {
} from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import {
ACPAgentClient,
type ACPToolSnapshot,
type SessionStateResponse,
} from "./acp-agent.js";
import { ACPAgentClient, type ACPToolSnapshot, type SessionStateResponse } from "./acp-agent.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
@@ -102,9 +94,7 @@ function transformPiToolSnapshot(snapshot: ACPToolSnapshot): ACPToolSnapshot {
* This transformer remaps them so the base ACP class treats them as thinking
* options instead of permission modes.
*/
export function transformPiSessionResponse(
response: SessionStateResponse,
): SessionStateResponse {
export function transformPiSessionResponse(response: SessionStateResponse): SessionStateResponse {
const modes = response.modes;
if (!modes?.availableModes?.length) {
return response;
@@ -126,10 +116,7 @@ export function transformPiSessionResponse(
return {
...response,
modes: undefined,
configOptions: [
thinkingOption,
...(response.configOptions ?? []),
],
configOptions: [thinkingOption, ...(response.configOptions ?? [])],
};
}
@@ -228,10 +215,7 @@ class PiACPAgentSession implements AgentSession {
return this.inner.getPendingPermissions();
}
async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
await this.inner.respondToPermission(requestId, response);
}
@@ -260,7 +260,9 @@ export class FileBackedChatService {
if (existing.length > 0) {
return existing;
}
const knownMessage = this.getRoomMessages(room.id).some((message) => message.id === afterMessageId);
const knownMessage = this.getRoomMessages(room.id).some(
(message) => message.id === afterMessageId,
);
if (!knownMessage) {
throw new ChatServiceError(
"chat_message_not_found",
@@ -341,7 +343,9 @@ export class FileBackedChatService {
private async persist(): Promise<void> {
const payload: ChatStorePayload = {
rooms: Array.from(this.rooms.values()).sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
rooms: Array.from(this.rooms.values()).sort((left, right) =>
left.createdAt.localeCompare(right.createdAt),
),
messages: Array.from(this.messagesByRoomId.values())
.flat()
.sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
@@ -5,9 +5,15 @@ const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, w
vi.hoisted(() => {
const hoistedWatchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
return {
execMock: vi.fn((_command: string, _options: unknown, callback: (error: null, result: { stdout: string; stderr: string }) => void) => {
callback(null, { stdout: "/tmp/repo\n", stderr: "" });
}),
execMock: vi.fn(
(
_command: string,
_options: unknown,
callback: (error: null, result: { stdout: string; stderr: string }) => void,
) => {
callback(null, { stdout: "/tmp/repo\n", stderr: "" });
},
),
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
readdirMock: vi.fn(async (directory: string) => {
+4 -1
View File
@@ -51,7 +51,10 @@ export function loadConfig(
// - unix:///path/to/socket (Unix socket)
// Default is TCP at 127.0.0.1:6767
const listen =
options?.cli?.listen ?? env.PASEO_LISTEN ?? persisted.daemon?.listen ?? `127.0.0.1:${env.PORT ?? DEFAULT_PORT}`;
options?.cli?.listen ??
env.PASEO_LISTEN ??
persisted.daemon?.listen ??
`127.0.0.1:${env.PORT ?? DEFAULT_PORT}`;
const envCorsOrigins = env.PASEO_CORS_ORIGINS
? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
@@ -131,10 +131,4 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
/**
* Helper to run a test for each provider.
*/
export const allProviders: AgentProvider[] = [
"claude",
"codex",
"copilot",
"opencode",
"pi",
];
export const allProviders: AgentProvider[] = ["claude", "codex", "copilot", "opencode", "pi"];
@@ -470,11 +470,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
// wake; if none arrives within the expected sleep window, verify the
// agent settled to idle (notification was already processed).
const autonomousWake = await client
.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
15_000,
)
.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000)
.catch(() => null);
if (autonomousWake) {
@@ -539,11 +535,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
// HELLO. When it races with HELLO, the notification is handled during
// the foreground turn and there is no separate autonomous running edge.
const autonomousWake = await client
.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
15_000,
)
.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 15_000)
.catch(() => null);
if (autonomousWake) {
@@ -24,7 +24,10 @@ function collectAgentUpdates(client: DaemonClient): {
return { updates, unsub };
}
function lastUpsertFor(updates: AgentUpdatePayload[], agentId: string): AgentUpsertPayload | undefined {
function lastUpsertFor(
updates: AgentUpdatePayload[],
agentId: string,
): AgentUpsertPayload | undefined {
return updates
.filter((u): u is AgentUpsertPayload => u.kind === "upsert" && u.agent.id === agentId)
.at(-1);
@@ -125,9 +128,7 @@ describe("mode-switch update propagation", () => {
const modeUpdate = client2Updates.find(
(u): u is AgentUpsertPayload =>
u.kind === "upsert" &&
u.agent.id === agent.id &&
u.agent.currentModeId === "acceptEdits",
u.kind === "upsert" && u.agent.id === agent.id && u.agent.currentModeId === "acceptEdits",
);
expect(modeUpdate).toBeDefined();
@@ -64,9 +64,9 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
);
expect(assistantMessages.length).toBeGreaterThan(0);
expect(
assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK")),
).toBe(true);
expect(assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK"))).toBe(
true,
);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
@@ -17,7 +17,9 @@ function pickOpenCodeModel(
models: Array<{ id: string }>,
preferences: string[] = ["gpt-5-nano", "gpt-4.1-nano", "mini", "free"],
): string {
const preferred = models.find((model) => preferences.some((fragment) => model.id.includes(fragment)));
const preferred = models.find((model) =>
preferences.some((fragment) => model.id.includes(fragment)),
);
return preferred?.id ?? models[0]!.id;
}
@@ -325,12 +325,10 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
expect(finish.status).toBe("idle");
const postSendAssistantTexts = getAssistantTexts(collector.messages, agent.id);
expect(
postSendAssistantTexts.some((text) => text.includes("[System Error]")),
).toBe(false);
expect(
postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)),
).toBe(false);
expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(postSendAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(
false,
);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 });
const assistantTexts = getTimelineAssistantTexts(timeline);
@@ -400,9 +398,9 @@ describe("daemon E2E (real opencode) - send while working and interrupt", () =>
expect(finish.status).toBe("idle");
const postInterruptAssistantTexts = getAssistantTexts(collector.messages, agent.id);
expect(
postInterruptAssistantTexts.some((text) => text.includes("[System Error]")),
).toBe(false);
expect(postInterruptAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(
false,
);
expect(
postInterruptAssistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET)),
).toBe(false);
@@ -71,7 +71,10 @@ function getAgentStatuses(messages: SessionOutboundMessage[], agentId: string):
.map((message) => message.payload.agent.status);
}
function getStatusesBeforeFirstAssistant(messages: SessionOutboundMessage[], agentId: string): string[] {
function getStatusesBeforeFirstAssistant(
messages: SessionOutboundMessage[],
agentId: string,
): string[] {
const firstAssistantIndex = messages.findIndex(
(message) =>
message.type === "agent_stream" &&
@@ -126,7 +129,9 @@ async function waitForRunningToolCall(
.map((entry) => entry.item.text) ?? [];
const limitText = assistantTexts.find((text) => hasProviderLimitText(text));
if (limitText) {
throw new Error(`Claude could not reach the tool call because the provider rejected the run: ${limitText}`);
throw new Error(
`Claude could not reach the tool call because the provider rejected the run: ${limitText}`,
);
}
if (
timeline?.entries.some(
@@ -252,9 +257,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
});
// No system error messages should leak into the timeline
const hasSystemError = assistantTexts.some((text) =>
text.includes("[System Error]"),
);
const hasSystemError = assistantTexts.some((text) => text.includes("[System Error]"));
expect(hasSystemError).toBe(false);
expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
@@ -1073,7 +1073,12 @@ describe("daemon E2E terminal", () => {
type: "input",
data: "echo hello world\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("hello world"), 15000);
await waitForTerminalOutput(
ctx.client,
terminalId,
(text) => text.includes("hello world"),
15000,
);
const capture = await ctx.client.captureTerminal(terminalId);
@@ -1155,7 +1160,12 @@ describe("daemon E2E terminal", () => {
type: "input",
data: "printf '\\033[31mred text\\033[0m\\n'\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("red text"), 15000);
await waitForTerminalOutput(
ctx.client,
terminalId,
(text) => text.includes("red text"),
15000,
);
const capture = await ctx.client.captureTerminal(terminalId);
const capturedText = capture.lines.join("\n");
+4 -6
View File
@@ -6,11 +6,7 @@ import type {
EditorTargetId,
KnownEditorTargetId,
} from "../shared/messages.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../utils/executable.js";
import { findExecutable, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
type EditorTargetDefinition = {
id: KnownEditorTargetId;
@@ -148,7 +144,9 @@ export async function openInEditorTarget(
const command = platform === "win32" ? quoteWindowsCommand(launch.command) : launch.command;
const args =
platform === "win32" ? launch.args.map((argument) => quoteWindowsArgument(argument)) : launch.args;
platform === "win32"
? launch.args.map((argument) => quoteWindowsArgument(argument))
: launch.args;
await new Promise<void>((resolve, reject) => {
let child: ChildProcess;
+6 -3
View File
@@ -30,10 +30,13 @@ export {
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
// Provider binary resolution
export { applyProviderEnv } from "./agent/provider-launch-config.js";
export {
applyProviderEnv,
} from "./agent/provider-launch-config.js";
export { findExecutable, findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
findExecutable,
findExecutableSync,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../utils/executable.js";
export { spawnProcess } from "../utils/spawn.js";
// Provider manifest (source of truth for provider definitions)
@@ -102,7 +102,10 @@ class ScriptedAgentSession implements AgentSession {
};
}
async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<{ turnId: string }> {
async startTurn(
prompt: AgentPromptInput,
_options?: AgentRunOptions,
): Promise<{ turnId: string }> {
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
const turnId = `turn-${++this.turnCount}`;
this.interrupted = false;
@@ -183,7 +186,12 @@ class ScriptedAgentSession implements AgentSession {
turnId,
});
if (this.interrupted) {
this.emit({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId });
this.emit({
type: "turn_canceled",
provider: this.provider,
reason: "interrupted",
turnId,
});
return;
}
this.emit({
@@ -236,7 +244,7 @@ describe("LoopService", () => {
if (config.title?.includes("worker")) {
return `worker run ${state.workerRuns}`;
}
return "{\"passed\":true,\"reason\":\"not used\"}";
return '{"passed":true,"reason":"not used"}';
},
}),
},
@@ -282,7 +290,7 @@ describe("LoopService", () => {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
verifierConfigs.push(config);
return "{\"passed\":true,\"reason\":\"verified\"}";
return '{"passed":true,"reason":"verified"}';
},
}),
},
@@ -338,7 +346,7 @@ describe("LoopService", () => {
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
return "created done.txt";
}
return "{\"passed\":true,\"reason\":\"done.txt exists\"}";
return '{"passed":true,"reason":"done.txt exists"}';
},
}),
},
@@ -394,8 +402,8 @@ describe("LoopService", () => {
}
const exists = pathExists(path.join(workspaceDir, "done.txt"));
return exists
? "{\"passed\":true,\"reason\":\"done.txt exists\"}"
: "{\"passed\":false,\"reason\":\"done.txt missing\"}";
? '{"passed":true,"reason":"done.txt exists"}'
: '{"passed":false,"reason":"done.txt missing"}';
},
}),
},
@@ -437,7 +445,7 @@ describe("LoopService", () => {
await blocker;
return "finished";
}
return "{\"passed\":true,\"reason\":\"ok\"}";
return '{"passed":true,"reason":"ok"}';
},
}),
},
+21 -12
View File
@@ -598,7 +598,9 @@ export class LoopService {
iteration: LoopIterationRecord,
signal: AbortSignal,
): Promise<boolean> {
const agent = await this.options.agentManager.createAgent(this.buildWorkerConfig(loop, iteration));
const agent = await this.options.agentManager.createAgent(
this.buildWorkerConfig(loop, iteration),
);
iteration.workerAgentId = agent.id;
loop.activeWorkerAgentId = agent.id;
loop.updatedAt = nowIso();
@@ -688,9 +690,7 @@ export class LoopService {
iteration: iteration.index,
source: "verify-check",
level: result.passed ? "info" : "error",
text: output
? `exit ${result.exitCode}\n${output}`
: `exit ${result.exitCode}`,
text: output ? `exit ${result.exitCode}\n${output}` : `exit ${result.exitCode}`,
});
loop.updatedAt = nowIso();
await this.persist();
@@ -736,7 +736,10 @@ export class LoopService {
try {
const result = await getStructuredAgentResponse({
caller: async (nextPrompt) => {
const run = await this.options.agentManager.runAgent(verifierAgent.id, this.toPrompt(nextPrompt));
const run = await this.options.agentManager.runAgent(
verifierAgent.id,
this.toPrompt(nextPrompt),
);
return this.resolveFinalText(run.timeline, run.finalText);
},
prompt: loop.verifyPrompt,
@@ -788,7 +791,10 @@ export class LoopService {
};
}
private buildVerifierConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig {
private buildVerifierConfig(
loop: LoopRecord,
iteration: LoopIterationRecord,
): AgentSessionConfig {
return {
provider: loop.verifierProvider ?? loop.provider,
cwd: loop.cwd,
@@ -815,7 +821,11 @@ export class LoopService {
return text;
}
private finishLoop(loop: LoopRecord, status: Exclude<LoopStatus, "running">, message: string): void {
private finishLoop(
loop: LoopRecord,
status: Exclude<LoopStatus, "running">,
message: string,
): void {
loop.status = status;
loop.completedAt = nowIso();
loop.updatedAt = loop.completedAt;
@@ -830,10 +840,7 @@ export class LoopService {
});
}
private appendLog(
loop: LoopRecord,
entry: Omit<LoopLogEntry, "seq" | "timestamp">,
): void {
private appendLog(loop: LoopRecord, entry: Omit<LoopLogEntry, "seq" | "timestamp">): void {
loop.logs.push({
seq: loop.nextLogSeq,
timestamp: nowIso(),
@@ -852,7 +859,9 @@ export class LoopService {
if (exact) {
return exact;
}
const matches = Array.from(this.loops.values()).filter((record) => record.id.startsWith(trimmed));
const matches = Array.from(this.loops.values()).filter((record) =>
record.id.startsWith(trimmed),
);
if (matches.length === 1) {
return matches[0]!;
}
+1 -4
View File
@@ -127,10 +127,7 @@ export async function updatePidLock(
const existingLock = JSON.parse(content) as PidLockInfo;
if (existingLock.pid !== lockOwnerPid) {
throw new PidLockError(
`Cannot update PID lock owned by PID ${existingLock.pid}`,
existingLock,
);
throw new PidLockError(`Cannot update PID lock owned by PID ${existingLock.pid}`, existingLock);
}
const updatedLock: PidLockInfo = {
@@ -21,8 +21,8 @@ describe("schedule cron cadence", () => {
});
test("rejects invalid cron expressions", () => {
expect(() =>
validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" }),
).toThrow("Cron expressions must have 5 fields");
expect(() => validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" })).toThrow(
"Cron expressions must have 5 fields",
);
});
});
+1 -2
View File
@@ -33,8 +33,7 @@ function parseField(
}
const [base, stepSource] = part.split("/");
const step =
stepSource === undefined ? 1 : Number.parseInt(stepSource, 10);
const step = stepSource === undefined ? 1 : Number.parseInt(stepSource, 10);
if (!Number.isInteger(step) || step <= 0) {
throw new Error(`Invalid cron ${bounds.name} step`);
}
@@ -4,12 +4,13 @@ import type { Logger } from "pino";
import { AgentManager } from "../agent/agent-manager.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
import { AgentStorage } from "../agent/agent-storage.js";
import type {
AgentPromptInput,
AgentSessionConfig,
} from "../agent/agent-sdk-types.js";
import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js";
import { curateAgentActivity } from "../agent/activity-curator.js";
import { buildConfigOverrides, buildSessionConfig, extractTimestamps } from "../persistence-hooks.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
} from "../persistence-hooks.js";
import { ScheduleStore } from "./store.js";
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
import type {
+436 -444
View File
@@ -145,9 +145,7 @@ import {
} from "./file-explorer/service.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import {
type WorktreeConfig,
} from "../utils/worktree.js";
import { type WorktreeConfig } from "../utils/worktree.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import {
getCheckoutDiff,
@@ -165,21 +163,14 @@ import {
import { getProjectIcon } from "../utils/project-icon.js";
import { expandTilde } from "../utils/path.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
import {
READ_ONLY_GIT_ENV,
resolveCheckoutGitDir,
toCheckoutError,
} from "./checkout-git-utils.js";
import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
import type pino from "pino";
import { resolveClientMessageId } from "./client-message-id.js";
import {
ChatServiceError,
FileBackedChatService,
} from "./chat/chat-service.js";
import { ChatServiceError, FileBackedChatService } from "./chat/chat-service.js";
import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
@@ -1072,7 +1063,10 @@ export class Session {
if (storedUpdatedAt) {
const liveUpdatedAt = Date.parse(payload.updatedAt);
const persistedUpdatedAt = Date.parse(storedUpdatedAt);
if (!Number.isNaN(persistedUpdatedAt) && (Number.isNaN(liveUpdatedAt) || persistedUpdatedAt > liveUpdatedAt)) {
if (
!Number.isNaN(persistedUpdatedAt) &&
(Number.isNaN(liveUpdatedAt) || persistedUpdatedAt > liveUpdatedAt)
) {
payload.updatedAt = storedUpdatedAt;
}
}
@@ -1409,7 +1403,9 @@ export class Session {
const placement = await this.buildProjectPlacement(normalizedCwd);
const resolvedWorkspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const staleWorkspace =
resolvedWorkspaceId === normalizedCwd ? null : await this.workspaceRegistry.get(normalizedCwd);
resolvedWorkspaceId === normalizedCwd
? null
: await this.workspaceRegistry.get(normalizedCwd);
const existing = (await this.workspaceRegistry.get(resolvedWorkspaceId)) ?? staleWorkspace;
await this.syncWorkspaceGitWatchTarget(resolvedWorkspaceId, {
isGit: placement.checkout.isGit,
@@ -1564,467 +1560,467 @@ export class Session {
this.peakInflightRequests = this.inflightRequests;
}
try {
this.sessionLogger.trace(
{ messageType: msg.type, payloadBytes: JSON.stringify(msg).length },
"inbound message",
);
try {
switch (msg.type) {
case "voice_audio_chunk":
await this.handleAudioChunk(msg);
break;
this.sessionLogger.trace(
{ messageType: msg.type, payloadBytes: JSON.stringify(msg).length },
"inbound message",
);
try {
switch (msg.type) {
case "voice_audio_chunk":
await this.handleAudioChunk(msg);
break;
case "abort_request":
await this.handleAbort();
break;
case "abort_request":
await this.handleAbort();
break;
case "audio_played":
this.handleAudioPlayed(msg.id);
break;
case "audio_played":
this.handleAudioPlayed(msg.id);
break;
case "fetch_agents_request":
await this.handleFetchAgents(msg);
break;
case "fetch_agents_request":
await this.handleFetchAgents(msg);
break;
case "fetch_workspaces_request":
await this.handleFetchWorkspacesRequest(msg);
break;
case "fetch_workspaces_request":
await this.handleFetchWorkspacesRequest(msg);
break;
case "fetch_agent_request":
await this.handleFetchAgent(msg.agentId, msg.requestId);
break;
case "fetch_agent_request":
await this.handleFetchAgent(msg.agentId, msg.requestId);
break;
case "delete_agent_request":
await this.handleDeleteAgentRequest(msg.agentId, msg.requestId);
break;
case "delete_agent_request":
await this.handleDeleteAgentRequest(msg.agentId, msg.requestId);
break;
case "archive_agent_request":
await this.handleArchiveAgentRequest(msg.agentId, msg.requestId);
break;
case "archive_agent_request":
await this.handleArchiveAgentRequest(msg.agentId, msg.requestId);
break;
case "close_items_request":
await this.handleCloseItemsRequest(msg);
break;
case "close_items_request":
await this.handleCloseItemsRequest(msg);
break;
case "update_agent_request":
await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId);
break;
case "update_agent_request":
await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId);
break;
case "set_voice_mode":
await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId);
break;
case "set_voice_mode":
await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId);
break;
case "send_agent_message_request":
await this.handleSendAgentMessageRequest(msg);
break;
case "send_agent_message_request":
await this.handleSendAgentMessageRequest(msg);
break;
case "wait_for_finish_request":
await this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs);
break;
case "wait_for_finish_request":
await this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs);
break;
case "dictation_stream_start":
{
const unavailable = this.resolveVoiceFeatureUnavailableContext("dictation");
if (unavailable) {
this.emit({
type: "dictation_stream_error",
payload: {
dictationId: msg.dictationId,
error: unavailable.message,
retryable: unavailable.retryable,
reasonCode: unavailable.reasonCode,
missingModelIds: unavailable.missingModelIds,
},
});
break;
case "dictation_stream_start":
{
const unavailable = this.resolveVoiceFeatureUnavailableContext("dictation");
if (unavailable) {
this.emit({
type: "dictation_stream_error",
payload: {
dictationId: msg.dictationId,
error: unavailable.message,
retryable: unavailable.retryable,
reasonCode: unavailable.reasonCode,
missingModelIds: unavailable.missingModelIds,
},
});
break;
}
}
await this.dictationStreamManager.handleStart(msg.dictationId, msg.format);
break;
case "dictation_stream_chunk":
await this.dictationStreamManager.handleChunk({
dictationId: msg.dictationId,
seq: msg.seq,
audioBase64: msg.audio,
format: msg.format,
});
break;
case "dictation_stream_finish":
await this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq);
break;
case "dictation_stream_cancel":
this.dictationStreamManager.handleCancel(msg.dictationId);
break;
case "create_agent_request":
await this.handleCreateAgentRequest(msg);
break;
case "resume_agent_request":
await this.handleResumeAgentRequest(msg);
break;
case "refresh_agent_request":
await this.handleRefreshAgentRequest(msg);
break;
case "cancel_agent_request":
await this.handleCancelAgentRequest(msg.agentId);
break;
case "restart_server_request":
await this.handleRestartServerRequest(msg.requestId, msg.reason);
break;
case "shutdown_server_request":
await this.handleShutdownServerRequest(msg.requestId);
break;
case "fetch_agent_timeline_request":
await this.handleFetchAgentTimelineRequest(msg);
break;
case "set_agent_mode_request":
await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId);
break;
case "set_agent_model_request":
await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId);
break;
case "set_agent_feature_request":
await this.handleSetAgentFeatureRequest(
msg.agentId,
msg.featureId,
msg.value,
msg.requestId,
);
break;
case "set_agent_thinking_request":
await this.handleSetAgentThinkingRequest(
msg.agentId,
msg.thinkingOptionId,
msg.requestId,
);
break;
case "agent_permission_response":
await this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response);
break;
case "checkout_status_request":
await this.handleCheckoutStatusRequest(msg);
break;
case "validate_branch_request":
await this.handleValidateBranchRequest(msg);
break;
case "branch_suggestions_request":
await this.handleBranchSuggestionsRequest(msg);
break;
case "directory_suggestions_request":
await this.handleDirectorySuggestionsRequest(msg);
break;
case "subscribe_checkout_diff_request":
await this.handleSubscribeCheckoutDiffRequest(msg);
break;
case "unsubscribe_checkout_diff_request":
this.handleUnsubscribeCheckoutDiffRequest(msg);
break;
case "checkout_switch_branch_request":
await this.handleCheckoutSwitchBranchRequest(msg);
break;
case "stash_save_request":
await this.handleStashSaveRequest(msg);
break;
case "stash_pop_request":
await this.handleStashPopRequest(msg);
break;
case "stash_list_request":
await this.handleStashListRequest(msg);
break;
case "checkout_commit_request":
await this.handleCheckoutCommitRequest(msg);
break;
case "checkout_merge_request":
await this.handleCheckoutMergeRequest(msg);
break;
case "checkout_merge_from_base_request":
await this.handleCheckoutMergeFromBaseRequest(msg);
break;
case "checkout_pull_request":
await this.handleCheckoutPullRequest(msg);
break;
case "checkout_push_request":
await this.handleCheckoutPushRequest(msg);
break;
case "checkout_pr_create_request":
await this.handleCheckoutPrCreateRequest(msg);
break;
case "checkout_pr_status_request":
await this.handleCheckoutPrStatusRequest(msg);
break;
case "paseo_worktree_list_request":
await this.handlePaseoWorktreeListRequest(msg);
break;
case "paseo_worktree_archive_request":
await this.handlePaseoWorktreeArchiveRequest(msg);
break;
case "create_paseo_worktree_request":
await this.handleCreatePaseoWorktreeRequest(msg);
break;
case "list_available_editors_request":
await this.handleListAvailableEditorsRequest(msg);
break;
case "open_in_editor_request":
await this.handleOpenInEditorRequest(msg);
break;
case "open_project_request":
await this.handleOpenProjectRequest(msg);
break;
case "archive_workspace_request":
await this.handleArchiveWorkspaceRequest(msg);
break;
case "file_explorer_request":
await this.handleFileExplorerRequest(msg);
break;
case "project_icon_request":
await this.handleProjectIconRequest(msg);
break;
case "file_download_token_request":
await this.handleFileDownloadTokenRequest(msg);
break;
case "list_provider_models_request":
await this.handleListProviderModelsRequest(msg);
break;
case "list_provider_modes_request":
await this.handleListProviderModesRequest(msg);
break;
case "list_provider_features_request":
await this.handleListProviderFeaturesRequest(msg);
break;
case "list_available_providers_request":
await this.handleListAvailableProvidersRequest(msg);
break;
case "get_providers_snapshot_request":
await this.handleGetProvidersSnapshotRequest(msg);
break;
case "refresh_providers_snapshot_request":
await this.handleRefreshProvidersSnapshotRequest(msg);
break;
case "provider_diagnostic_request":
await this.handleProviderDiagnosticRequest(msg);
break;
case "clear_agent_attention":
await this.handleClearAgentAttention(msg.agentId);
break;
case "client_heartbeat":
this.handleClientHeartbeat(msg);
break;
case "ping": {
const now = Date.now();
this.emit({
type: "pong",
payload: {
requestId: msg.requestId,
clientSentAt: msg.clientSentAt,
serverReceivedAt: now,
serverSentAt: now,
},
});
break;
}
await this.dictationStreamManager.handleStart(msg.dictationId, msg.format);
break;
case "dictation_stream_chunk":
await this.dictationStreamManager.handleChunk({
dictationId: msg.dictationId,
seq: msg.seq,
audioBase64: msg.audio,
format: msg.format,
});
break;
case "list_commands_request":
await this.handleListCommandsRequest(msg);
break;
case "dictation_stream_finish":
await this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq);
break;
case "register_push_token":
this.handleRegisterPushToken(msg.token);
break;
case "dictation_stream_cancel":
this.dictationStreamManager.handleCancel(msg.dictationId);
break;
case "subscribe_terminals_request":
this.handleSubscribeTerminalsRequest(msg);
break;
case "create_agent_request":
await this.handleCreateAgentRequest(msg);
break;
case "unsubscribe_terminals_request":
this.handleUnsubscribeTerminalsRequest(msg);
break;
case "resume_agent_request":
await this.handleResumeAgentRequest(msg);
break;
case "list_terminals_request":
await this.handleListTerminalsRequest(msg);
break;
case "refresh_agent_request":
await this.handleRefreshAgentRequest(msg);
break;
case "create_terminal_request":
await this.handleCreateTerminalRequest(msg);
break;
case "cancel_agent_request":
await this.handleCancelAgentRequest(msg.agentId);
break;
case "subscribe_terminal_request":
await this.handleSubscribeTerminalRequest(msg);
break;
case "restart_server_request":
await this.handleRestartServerRequest(msg.requestId, msg.reason);
break;
case "unsubscribe_terminal_request":
this.handleUnsubscribeTerminalRequest(msg);
break;
case "shutdown_server_request":
await this.handleShutdownServerRequest(msg.requestId);
break;
case "terminal_input":
this.handleTerminalInput(msg);
break;
case "fetch_agent_timeline_request":
await this.handleFetchAgentTimelineRequest(msg);
break;
case "kill_terminal_request":
await this.handleKillTerminalRequest(msg);
break;
case "set_agent_mode_request":
await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId);
break;
case "capture_terminal_request":
await this.handleCaptureTerminalRequest(msg);
break;
case "set_agent_model_request":
await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId);
break;
case "chat/create":
await this.handleChatCreateRequest(msg);
break;
case "set_agent_feature_request":
await this.handleSetAgentFeatureRequest(
msg.agentId,
msg.featureId,
msg.value,
msg.requestId,
);
break;
case "chat/list":
await this.handleChatListRequest(msg);
break;
case "set_agent_thinking_request":
await this.handleSetAgentThinkingRequest(
msg.agentId,
msg.thinkingOptionId,
msg.requestId,
);
break;
case "chat/inspect":
await this.handleChatInspectRequest(msg);
break;
case "agent_permission_response":
await this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response);
break;
case "chat/delete":
await this.handleChatDeleteRequest(msg);
break;
case "checkout_status_request":
await this.handleCheckoutStatusRequest(msg);
break;
case "chat/post":
await this.handleChatPostRequest(msg);
break;
case "validate_branch_request":
await this.handleValidateBranchRequest(msg);
break;
case "chat/read":
await this.handleChatReadRequest(msg);
break;
case "branch_suggestions_request":
await this.handleBranchSuggestionsRequest(msg);
break;
case "chat/wait":
await this.handleChatWaitRequest(msg);
break;
case "directory_suggestions_request":
await this.handleDirectorySuggestionsRequest(msg);
break;
case "schedule/create":
await this.handleScheduleCreateRequest(msg);
break;
case "subscribe_checkout_diff_request":
await this.handleSubscribeCheckoutDiffRequest(msg);
break;
case "schedule/list":
await this.handleScheduleListRequest(msg);
break;
case "unsubscribe_checkout_diff_request":
this.handleUnsubscribeCheckoutDiffRequest(msg);
break;
case "schedule/inspect":
await this.handleScheduleInspectRequest(msg);
break;
case "checkout_switch_branch_request":
await this.handleCheckoutSwitchBranchRequest(msg);
break;
case "schedule/logs":
await this.handleScheduleLogsRequest(msg);
break;
case "stash_save_request":
await this.handleStashSaveRequest(msg);
break;
case "schedule/pause":
await this.handleSchedulePauseRequest(msg);
break;
case "stash_pop_request":
await this.handleStashPopRequest(msg);
break;
case "schedule/resume":
await this.handleScheduleResumeRequest(msg);
break;
case "stash_list_request":
await this.handleStashListRequest(msg);
break;
case "schedule/delete":
await this.handleScheduleDeleteRequest(msg);
break;
case "checkout_commit_request":
await this.handleCheckoutCommitRequest(msg);
break;
case "loop/run":
await this.handleLoopRunRequest(msg);
break;
case "checkout_merge_request":
await this.handleCheckoutMergeRequest(msg);
break;
case "loop/list":
await this.handleLoopListRequest(msg);
break;
case "checkout_merge_from_base_request":
await this.handleCheckoutMergeFromBaseRequest(msg);
break;
case "loop/inspect":
await this.handleLoopInspectRequest(msg);
break;
case "checkout_pull_request":
await this.handleCheckoutPullRequest(msg);
break;
case "loop/logs":
await this.handleLoopLogsRequest(msg);
break;
case "checkout_push_request":
await this.handleCheckoutPushRequest(msg);
break;
case "loop/stop":
await this.handleLoopStopRequest(msg);
break;
}
} catch (error: any) {
const err = error instanceof Error ? error : new Error(String(error));
this.sessionLogger.error({ err }, "Error handling message");
case "checkout_pr_create_request":
await this.handleCheckoutPrCreateRequest(msg);
break;
case "checkout_pr_status_request":
await this.handleCheckoutPrStatusRequest(msg);
break;
case "paseo_worktree_list_request":
await this.handlePaseoWorktreeListRequest(msg);
break;
case "paseo_worktree_archive_request":
await this.handlePaseoWorktreeArchiveRequest(msg);
break;
case "create_paseo_worktree_request":
await this.handleCreatePaseoWorktreeRequest(msg);
break;
case "list_available_editors_request":
await this.handleListAvailableEditorsRequest(msg);
break;
case "open_in_editor_request":
await this.handleOpenInEditorRequest(msg);
break;
case "open_project_request":
await this.handleOpenProjectRequest(msg);
break;
case "archive_workspace_request":
await this.handleArchiveWorkspaceRequest(msg);
break;
case "file_explorer_request":
await this.handleFileExplorerRequest(msg);
break;
case "project_icon_request":
await this.handleProjectIconRequest(msg);
break;
case "file_download_token_request":
await this.handleFileDownloadTokenRequest(msg);
break;
case "list_provider_models_request":
await this.handleListProviderModelsRequest(msg);
break;
case "list_provider_modes_request":
await this.handleListProviderModesRequest(msg);
break;
case "list_provider_features_request":
await this.handleListProviderFeaturesRequest(msg);
break;
case "list_available_providers_request":
await this.handleListAvailableProvidersRequest(msg);
break;
case "get_providers_snapshot_request":
await this.handleGetProvidersSnapshotRequest(msg);
break;
case "refresh_providers_snapshot_request":
await this.handleRefreshProvidersSnapshotRequest(msg);
break;
case "provider_diagnostic_request":
await this.handleProviderDiagnosticRequest(msg);
break;
case "clear_agent_attention":
await this.handleClearAgentAttention(msg.agentId);
break;
case "client_heartbeat":
this.handleClientHeartbeat(msg);
break;
case "ping": {
const now = Date.now();
this.emit({
type: "pong",
payload: {
requestId: msg.requestId,
clientSentAt: msg.clientSentAt,
serverReceivedAt: now,
serverSentAt: now,
},
});
break;
const requestId = (msg as { requestId?: unknown }).requestId;
if (typeof requestId === "string") {
try {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: `Request failed: ${err.message}`,
code: "handler_error",
},
});
} catch (emitError) {
this.sessionLogger.error({ err: emitError }, "Failed to emit rpc_error");
}
}
case "list_commands_request":
await this.handleListCommandsRequest(msg);
break;
case "register_push_token":
this.handleRegisterPushToken(msg.token);
break;
case "subscribe_terminals_request":
this.handleSubscribeTerminalsRequest(msg);
break;
case "unsubscribe_terminals_request":
this.handleUnsubscribeTerminalsRequest(msg);
break;
case "list_terminals_request":
await this.handleListTerminalsRequest(msg);
break;
case "create_terminal_request":
await this.handleCreateTerminalRequest(msg);
break;
case "subscribe_terminal_request":
await this.handleSubscribeTerminalRequest(msg);
break;
case "unsubscribe_terminal_request":
this.handleUnsubscribeTerminalRequest(msg);
break;
case "terminal_input":
this.handleTerminalInput(msg);
break;
case "kill_terminal_request":
await this.handleKillTerminalRequest(msg);
break;
case "capture_terminal_request":
await this.handleCaptureTerminalRequest(msg);
break;
case "chat/create":
await this.handleChatCreateRequest(msg);
break;
case "chat/list":
await this.handleChatListRequest(msg);
break;
case "chat/inspect":
await this.handleChatInspectRequest(msg);
break;
case "chat/delete":
await this.handleChatDeleteRequest(msg);
break;
case "chat/post":
await this.handleChatPostRequest(msg);
break;
case "chat/read":
await this.handleChatReadRequest(msg);
break;
case "chat/wait":
await this.handleChatWaitRequest(msg);
break;
case "schedule/create":
await this.handleScheduleCreateRequest(msg);
break;
case "schedule/list":
await this.handleScheduleListRequest(msg);
break;
case "schedule/inspect":
await this.handleScheduleInspectRequest(msg);
break;
case "schedule/logs":
await this.handleScheduleLogsRequest(msg);
break;
case "schedule/pause":
await this.handleSchedulePauseRequest(msg);
break;
case "schedule/resume":
await this.handleScheduleResumeRequest(msg);
break;
case "schedule/delete":
await this.handleScheduleDeleteRequest(msg);
break;
case "loop/run":
await this.handleLoopRunRequest(msg);
break;
case "loop/list":
await this.handleLoopListRequest(msg);
break;
case "loop/inspect":
await this.handleLoopInspectRequest(msg);
break;
case "loop/logs":
await this.handleLoopLogsRequest(msg);
break;
case "loop/stop":
await this.handleLoopStopRequest(msg);
break;
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Error: ${err.message}`,
},
});
}
} catch (error: any) {
const err = error instanceof Error ? error : new Error(String(error));
this.sessionLogger.error({ err }, "Error handling message");
const requestId = (msg as { requestId?: unknown }).requestId;
if (typeof requestId === "string") {
try {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: `Request failed: ${err.message}`,
code: "handler_error",
},
});
} catch (emitError) {
this.sessionLogger.error({ err: emitError }, "Failed to emit rpc_error");
}
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Error: ${err.message}`,
},
});
}
} finally {
this.inflightRequests--;
}
@@ -3267,9 +3263,7 @@ export class Session {
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId
? { thinkingOptionId: draftConfig.thinkingOptionId }
: {}),
...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}),
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}),
};
}
@@ -5649,9 +5643,11 @@ export class Session {
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
return Array.from(
(await this.buildWorkspaceDescriptorMap({
includeGitData: false,
})).values(),
(
await this.buildWorkspaceDescriptorMap({
includeGitData: false,
})
).values(),
);
}
@@ -7217,14 +7213,9 @@ export class Session {
return;
}
await this.handleSendAgentMessage(
agentId,
result.text,
undefined,
undefined,
undefined,
{ spokenInput: true },
);
await this.handleSendAgentMessage(agentId, result.text, undefined, undefined, undefined, {
spokenInput: true,
});
await this.flushPendingAudioSegments("transcription complete");
}
@@ -7595,8 +7586,7 @@ export class Session {
}
private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void {
const message =
error instanceof Error ? error.message : "Chat request failed";
const message = error instanceof Error ? error.message : "Chat request failed";
const code = error instanceof ChatServiceError ? error.code : "chat_request_failed";
this.sessionLogger.error({ err: error, requestType: request.type }, "Chat request failed");
this.emit({
@@ -7774,7 +7764,10 @@ export class Session {
private toScheduleSummary(
schedule: Awaited<ReturnType<ScheduleService["inspect"]>>,
): Extract<SessionOutboundMessage, { type: "schedule/list/response" }>["payload"]["schedules"][number] {
): Extract<
SessionOutboundMessage,
{ type: "schedule/list/response" }
>["payload"]["schedules"][number] {
const { runs: _runs, ...summary } = schedule;
return summary;
}
@@ -8549,5 +8542,4 @@ export class Session {
this.detachTerminalStream(terminalId, { emitExit: false });
}
}
}
@@ -92,17 +92,15 @@ function createSessionForWorkspaceGitWatchTests(): {
error: vi.fn(),
};
const backgroundGitFetchManager = {
subscribe: vi.fn(
async (params: { repoGitRoot: string; cwd: string }, listener: () => void) => {
const unsubscribe = vi.fn();
backgroundGitFetchSubscriptions.push({
params,
listener,
unsubscribe,
});
return { unsubscribe };
},
),
subscribe: vi.fn(async (params: { repoGitRoot: string; cwd: string }, listener: () => void) => {
const unsubscribe = vi.fn();
backgroundGitFetchSubscriptions.push({
params,
listener,
unsubscribe,
});
return { unsubscribe };
}),
};
const session = new Session({
@@ -255,8 +253,7 @@ describe("workspace git watch targets", () => {
diffStat: { additions: 1, deletions: 0 },
};
sessionAny.buildWorkspaceDescriptorMap = async () =>
new Map([[descriptor.id, descriptor]]);
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
@@ -440,7 +437,8 @@ describe("workspace git watch targets", () => {
});
test("refreshes the workspace when the background fetch manager callback fires and unsubscribes on cleanup", async () => {
const { session, emitted, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests();
const { session, emitted, backgroundGitFetchManager } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
sessionAny.buildProjectPlacement = async (cwd: string) => ({
@@ -478,8 +476,7 @@ describe("workspace git watch targets", () => {
diffStat: { additions: 1, deletions: 0 },
};
sessionAny.buildWorkspaceDescriptorMap = async () =>
new Map([[descriptor.id, descriptor]]);
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
@@ -268,9 +268,7 @@ describe("workspace aggregation", () => {
archivedAt: expect.any(String),
},
});
expect(
emitted.find((message) => message.type === "agent_archived")?.payload,
).toMatchObject({
expect(emitted.find((message) => message.type === "agent_archived")?.payload).toMatchObject({
agentId: "agent-1",
archivedAt: expect.any(String),
requestId: "req-archive",
@@ -418,9 +416,7 @@ describe("workspace aggregation", () => {
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1");
expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1");
expect(
emitted.find((message) => message.type === "close_items_response")?.payload,
).toEqual({
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
agents: [{ agentId: "agent-1", archivedAt }],
terminals: [{ terminalId: "term-1", success: true }],
requestId: "req-close-items",
@@ -720,9 +716,7 @@ describe("workspace aggregation", () => {
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-bad");
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-good");
expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1");
expect(
emitted.find((message) => message.type === "close_items_response")?.payload,
).toEqual({
expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({
agents: [{ agentId: "agent-good", archivedAt }],
terminals: [{ terminalId: "term-1", success: true }],
requestId: "req-close-best-effort",
@@ -1321,9 +1315,7 @@ describe("workspace aggregation", () => {
});
expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]);
const response = emitted.find(
(message) => message.type === "open_in_editor_response",
) as any;
const response = emitted.find((message) => message.type === "open_in_editor_response") as any;
expect(response?.payload.error).toBeNull();
});
@@ -1614,7 +1606,8 @@ describe("workspace aggregation", () => {
}),
);
session.projectRegistry.get = async (nextProjectId: string) => projects.get(nextProjectId) ?? null;
session.projectRegistry.get = async (nextProjectId: string) =>
projects.get(nextProjectId) ?? null;
session.projectRegistry.list = async () => Array.from(projects.values());
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
@@ -1802,14 +1795,16 @@ describe("workspace aggregation", () => {
});
await new Promise((resolve) => setTimeout(resolve, 0));
const response = emitted.find(
(message) => message.type === "fetch_workspaces_response",
) as { type: "fetch_workspaces_response"; payload: any } | undefined;
const response = emitted.find((message) => message.type === "fetch_workspaces_response") as
| { type: "fetch_workspaces_response"; payload: any }
| undefined;
expect(
response?.payload.entries.map((entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [
entry.id,
entry.diffStat,
]),
response?.payload.entries.map(
(entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [
entry.id,
entry.diffStat,
],
),
).toEqual([
[directoryDescriptor.id, directoryDescriptor.diffStat],
[baselineGitDescriptor.id, baselineGitDescriptor.diffStat],
@@ -22,7 +22,10 @@ import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-para
import { SherpaRealtimeTranscriptionSession } from "./sherpa/sherpa-realtime-session.js";
import { SherpaOnnxSTT } from "./sherpa/sherpa-stt.js";
import { SherpaOnnxTTS } from "./sherpa/sherpa-tts.js";
import { ensureSileroVadModel, SherpaSileroTurnDetectionProvider } from "./sherpa/silero-vad-provider.js";
import {
ensureSileroVadModel,
SherpaSileroTurnDetectionProvider,
} from "./sherpa/silero-vad-provider.js";
type LocalSttEngine =
| { kind: "offline"; engine: SherpaOfflineRecognizerEngine }
@@ -244,7 +247,10 @@ export async function initializeLocalSpeechServices(params: {
logger.warn({ err }, "Failed to provision Silero VAD model, falling back to bundled");
}
}
turnDetectionService = new SherpaSileroTurnDetectionProvider({ modelPath: vadModelPath }, logger);
turnDetectionService = new SherpaSileroTurnDetectionProvider(
{ modelPath: vadModelPath },
logger,
);
}
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") {
@@ -22,7 +22,10 @@ class TestSherpaOnnxParakeetStt extends SherpaOnnxParakeetSTT {
super({ engine: { sampleRate: 16000 } as any }, pino({ level: "silent" }));
}
override async transcribeAudio(audioBuffer: Buffer, format: string): Promise<TranscriptionResult> {
override async transcribeAudio(
audioBuffer: Buffer,
format: string,
): Promise<TranscriptionResult> {
this.calls.push({ audio: Buffer.from(audioBuffer), format });
const deferred = createDeferred<TranscriptionResult>();
this.pending.push(deferred);
@@ -162,10 +162,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
}
try {
this.logger.debug(
{ phase: this.phase.state },
"[VAD] Flushing remaining audio",
);
this.logger.debug({ phase: this.phase.state }, "[VAD] Flushing remaining audio");
this.vad.flush();
this.stepStateMachine();
if (this.phase.state === "speaking" || this.phase.state === "ending") {
@@ -205,10 +202,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
switch (this.phase.state) {
case "idle": {
if (detected) {
this.logger.debug(
{ now },
"[VAD] idle → confirming (detection started)",
);
this.logger.debug({ now }, "[VAD] idle → confirming (detection started)");
this.phase = { state: "confirming", startedAt: now };
}
break;
@@ -238,10 +232,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
case "speaking": {
if (!detected) {
this.logger.debug(
{ now },
"[VAD] speaking → ending (silence started)",
);
this.logger.debug({ now }, "[VAD] speaking → ending (silence started)");
this.phase = { state: "ending", startedAt: now };
}
break;
@@ -243,7 +243,10 @@ class FakeAgentSession implements AgentSession {
private async resolveSlashCommandInput(
prompt: AgentPromptInput,
): Promise<{ commandName: string; args?: string } | null> {
if ((this.providerName !== "codex" && this.providerName !== "opencode") || typeof prompt !== "string") {
if (
(this.providerName !== "codex" && this.providerName !== "opencode") ||
typeof prompt !== "string"
) {
return null;
}
const parsed = this.parseSlashCommandInput(prompt);
+7 -15
View File
@@ -23,10 +23,7 @@ import {
type WSOutboundMessage,
wrapSessionMessage,
} from "./messages.js";
import {
asUint8Array,
decodeTerminalStreamFrame,
} from "../shared/terminal-stream-protocol.js";
import { asUint8Array, decodeTerminalStreamFrame } from "../shared/terminal-stream-protocol.js";
import type { AllowedHostsConfig } from "./allowed-hosts.js";
import { isHostAllowed } from "./allowed-hosts.js";
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
@@ -362,9 +359,10 @@ export class VoiceAssistantWebSocketServer {
this.serverCapabilities = buildServerCapabilities({
readiness: this.speech?.getReadiness() ?? null,
});
this.unsubscribeSpeechReadiness = this.speech?.onReadinessChange((snapshot) => {
this.publishSpeechReadiness(snapshot);
}) ?? null;
this.unsubscribeSpeechReadiness =
this.speech?.onReadinessChange((snapshot) => {
this.publishSpeechReadiness(snapshot);
}) ?? null;
const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
@@ -527,10 +525,7 @@ export class VoiceAssistantWebSocketServer {
}
}
private sendBinaryToClient(
ws: WebSocketLike,
frame: Uint8Array,
): void {
private sendBinaryToClient(ws: WebSocketLike, frame: Uint8Array): void {
if (ws.readyState !== 1) {
return;
}
@@ -543,10 +538,7 @@ export class VoiceAssistantWebSocketServer {
}
}
private sendBinaryToConnection(
connection: SessionConnection,
frame: Uint8Array,
): void {
private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void {
for (const ws of connection.sockets) {
this.sendBinaryToClient(ws, frame);
}
@@ -70,7 +70,10 @@ export async function bootstrapWorkspaceRegistries(options: {
const activeRecords = records.filter((record) => !record.archivedAt);
const recordsByWorkspaceId = new Map<
string,
{ placement: Awaited<ReturnType<typeof buildProjectPlacementForCwd>>; records: StoredAgentRecord[] }
{
placement: Awaited<ReturnType<typeof buildProjectPlacementForCwd>>;
records: StoredAgentRecord[];
}
>();
for (const record of activeRecords) {
const normalizedCwd = normalizeWorkspaceId(record.cwd);
+9 -17
View File
@@ -22,10 +22,7 @@ import type {
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import { createAgentWorktree } from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import {
getCheckoutStatusLite,
resolveRepositoryDefaultBranch,
} from "../utils/checkout-git.js";
import { getCheckoutStatusLite, resolveRepositoryDefaultBranch } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
computeWorktreePath,
@@ -91,20 +88,14 @@ type RegisterPendingWorktreeWorkspaceDependencies = {
}) => PersistedWorkspaceRecord;
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
syncWorkspaceGitWatchTarget: (
cwd: string,
options: { isGit: boolean },
) => Promise<void>;
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
};
type CreatePaseoWorktreeInBackgroundDependencies = {
paseoHome?: string;
emitWorkspaceUpdateForCwd: (
cwd: string,
options?: { dedupeGitState?: boolean },
) => Promise<void>;
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
};
@@ -277,10 +268,7 @@ export function assertSafeGitRef(ref: string, label: string): void {
}
}
export async function resolveGitCreateBaseBranch(
cwd: string,
paseoHome?: string,
): Promise<string> {
export async function resolveGitCreateBaseBranch(cwd: string, paseoHome?: string): Promise<string> {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome });
if (!checkout.isGit) {
throw new Error("Cannot create a worktree outside a git repository");
@@ -580,7 +568,11 @@ export async function handleCreatePaseoWorktreeRequest(
throw new Error(`Invalid worktree name: ${validation.error}`);
}
const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome);
const worktreePath = await computeWorktreePath(
repoRoot,
normalizedSlug,
dependencies.paseoHome,
);
const workspace = await dependencies.registerPendingWorktreeWorkspace({
repoRoot,
worktreePath,
+1 -3
View File
@@ -2835,9 +2835,7 @@ export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessage
export type ListProviderModelsRequestMessage = z.infer<
typeof ListProviderModelsRequestMessageSchema
>;
export type ListProviderModesRequestMessage = z.infer<
typeof ListProviderModesRequestMessageSchema
>;
export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRequestMessageSchema>;
export type ListProviderFeaturesRequestMessage = z.infer<
typeof ListProviderFeaturesRequestMessageSchema
>;
+6 -13
View File
@@ -120,10 +120,7 @@ export function ensureNodePtySpawnHelperExecutableForCurrentPlatform(
}
export function resolveDefaultTerminalShell(
options: {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
} = {},
options: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv } = {},
): string {
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
@@ -259,7 +256,10 @@ function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
}
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells.map((cell) => cell.char).join("").trimEnd();
const text = cells
.map((cell) => cell.char)
.join("")
.trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
}
@@ -313,14 +313,7 @@ export function captureTerminalLines(
}
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const {
cwd,
shell,
env = {},
rows = 24,
cols = 80,
name = "Terminal",
} = options;
const { cwd, shell, env = {}, rows = 24, cols = 80, name = "Terminal" } = options;
const resolvedShell = shell ?? resolveDefaultTerminalShell();
const id = randomUUID();
@@ -593,7 +593,9 @@ const x = 1;
writeFileSync(join(repoDir, "conflict.txt"), "local\n");
execSync("git add conflict.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'local rebase conflict commit'", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'local rebase conflict commit'", {
cwd: repoDir,
});
const otherClone = join(tempDir, "other-clone");
execSync(`git clone ${remoteDir} ${otherClone}`);
+4 -19
View File
@@ -157,7 +157,6 @@ type CheckoutFileChange = {
isUntracked?: boolean;
};
function normalizeBranchSuggestionName(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
@@ -683,9 +682,7 @@ async function getMainRepoRoot(cwd: string): Promise<string> {
env: READ_ONLY_GIT_ENV,
});
const worktrees = parseWorktreeList(worktreeOut);
const nonBareNonPaseo = worktrees.filter(
(wt) => !wt.isBare && !isPaseoWorktreePath(wt.path),
);
const nonBareNonPaseo = worktrees.filter((wt) => !wt.isBare && !isPaseoWorktreePath(wt.path));
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => isDescendantPath(wt.path, normalized));
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
@@ -1533,11 +1530,7 @@ export async function getCheckoutDiff(
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break;
}
const { text, truncated, stat } = await getUntrackedDiffText(
cwd,
change,
ignoreWhitespace,
);
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change, ignoreWhitespace);
if (!compare.includeStructured) {
if (stat?.isBinary) {
@@ -1927,7 +1920,6 @@ async function resolveGitHubRepo(cwd: string): Promise<string | null> {
return null;
}
export async function createPullRequest(
cwd: string,
options: CreatePullRequestOptions,
@@ -2016,12 +2008,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
try {
const { stdout } = await execFileAsync(
ghPath,
[
"pr",
"view",
"--json",
"url,title,state,baseRefName,headRefName,mergedAt",
],
["pr", "view", "--json", "url,title,state,baseRefName,headRefName,mergedAt"],
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
);
const pr = JSON.parse(stdout.trim());
@@ -2029,9 +2016,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
return { status: null, githubFeaturesEnabled: true };
}
const mergedAt =
typeof pr.mergedAt === "string" && pr.mergedAt.trim().length > 0
? pr.mergedAt
: null;
typeof pr.mergedAt === "string" && pr.mergedAt.trim().length > 0 ? pr.mergedAt : null;
const state =
mergedAt !== null
? "merged"
+8 -14
View File
@@ -1,10 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
findExecutableSync,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./executable.js";
import { findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutableSync>[1]>;
@@ -46,20 +42,18 @@ describe("findExecutableSync", () => {
"C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n",
);
expect(findExecutableSync("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
expect(findExecutableSync("codex", findExecutableDependencies)).toBe(
"C:\\nvm4w\\nodejs\\codex",
);
});
test("on Unix, uses the last line from which output", () => {
findExecutableDependencies.execFileSync.mockReturnValue(
"/usr/local/bin/codex\n",
);
findExecutableDependencies.execFileSync.mockReturnValue("/usr/local/bin/codex\n");
expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith(
"which",
["codex"],
{ encoding: "utf8" },
);
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith("which", ["codex"], {
encoding: "utf8",
});
});
test("warns and returns null when the final which line is not an absolute path", () => {
+6 -5
View File
@@ -66,10 +66,12 @@ export function findExecutableSync(
if (deps.platform() === "win32") {
try {
const out = deps.execFileSync("where.exe", [trimmed], {
encoding: "utf8",
windowsHide: true,
}).trim();
const out = deps
.execFileSync("where.exe", [trimmed], {
encoding: "utf8",
windowsHide: true,
})
.trim();
return (
out
.split(/\r?\n/)
@@ -160,4 +162,3 @@ export function quoteWindowsArgument(argument: string): string {
if (argument.startsWith('"') && argument.endsWith('"')) return argument;
return `"${argument}"`;
}
+1 -5
View File
@@ -1,8 +1,4 @@
import {
spawn,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
/**
* Platform-aware spawn that centralizes Windows shell and quoting concerns.
+1 -3
View File
@@ -11,9 +11,7 @@ import {
runWorktreeSetupCommands,
slugify,
} from "./worktree";
import {
getPaseoWorktreeMetadataPath,
} from "./worktree-metadata.js";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
import { execSync } from "child_process";
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
import { dirname, join } from "path";