mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 07:54:38 +00:00
style: autoformat with biome to fix 195 formatting errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
be88ddbce1
commit
13de7d197b
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+297
-272
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
+6
-4
@@ -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");
|
||||
|
||||
+4
-6
@@ -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 } : {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}';
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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]!;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
+4
-1
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user