Merge origin/main into dev (second round)

Resolve 21 conflicts from latest main (provider profiles, Windows fixes, 0.1.55-rc.1).
Fix all type errors and syntax issues from merge artifacts.
This commit is contained in:
Mohamed Boudra
2026-04-14 04:07:36 +07:00
249 changed files with 9118 additions and 4973 deletions
+4 -2
View File
@@ -136,7 +136,7 @@ export type DaemonEvent =
agentId: string;
payload: Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
}
| {
| {
type: "workspace_update";
workspaceId: string;
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
@@ -1335,7 +1335,9 @@ export class DaemonClient {
workspaceId: string,
scriptName: string,
requestId?: string,
): Promise<Extract<SessionOutboundMessage, { type: "start_workspace_script_response" }>["payload"]> {
): Promise<
Extract<SessionOutboundMessage, { type: "start_workspace_script_response" }>["payload"]
> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
@@ -15,13 +15,12 @@
* This pattern is used in claude-agent.ts listModels().
*/
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailableSync } from "../utils/executable.js";
import { isCommandAvailable } from "../utils/executable.js";
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials;
// Pattern from claude-agent.ts listModels():
// Use an empty async generator when you just need control methods
@@ -30,88 +29,92 @@ function createEmptyPrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
}
describe("Claude Agent SDK Commands POC", () => {
let canRunClaudeIntegration = false;
beforeAll(async () => {
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
});
beforeEach((context) => {
if (!canRunClaudeIntegration) {
context.skip();
}
});
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("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("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);
}
} finally {
await claudeQuery.return?.();
// 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);
}
},
30000,
);
} finally {
await claudeQuery.return?.();
}
}, 30000);
});
describe("Command Execution", () => {
@@ -16,10 +16,7 @@ const pendingAgentBootstrapLoads = new Map<string, Promise<ManagedAgent>>();
export type AgentLoadingServiceOptions = {
agentManager: Pick<
AgentManager,
| "createAgent"
| "getAgent"
| "reloadAgentSession"
| "resumeAgentFromPersistence"
"createAgent" | "getAgent" | "reloadAgentSession" | "resumeAgentFromPersistence"
>;
agentStorage: Pick<AgentSnapshotStore, "get">;
logger: pino.Logger;
@@ -114,7 +111,13 @@ export class AgentLoadingService {
"Agent resumed from persistence",
);
} else {
snapshot = await this.agentManager.createAgent(buildSessionConfig(record), options.agentId, {
const sessionConfig = buildSessionConfig(record);
if (!sessionConfig) {
throw new Error(
`Agent ${options.agentId} has an invalid provider '${record.provider}' and cannot be loaded`,
);
}
snapshot = await this.agentManager.createAgent(sessionConfig, options.agentId, {
labels: record.labels,
});
this.logger.info(
@@ -48,7 +48,7 @@ import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ScheduleService } from "../schedule/service.js";
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
import { AGENT_PROVIDER_DEFINITIONS, type ProviderDefinition } from "./provider-registry.js";
import type { ProviderDefinition } from "./provider-registry.js";
import {
AgentModelSchema,
AgentProviderEnum,
@@ -838,7 +838,7 @@ export async function createAgentManagementMcpServer(
async () => ({
content: [],
structuredContent: ensureValidJson({
providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({
providers: Object.values(providerRegistry ?? {}).map((provider) => ({
id: provider.id,
label: provider.label,
modes: provider.modes.map((mode) => ({
@@ -378,6 +378,156 @@ describe("AgentManager", () => {
expect(snapshot.config.modeId).toBe("auto");
});
test("setAgentMode persists the selected mode across session reload", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class ModeAwareSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
private currentMode: string | null;
constructor(private readonly config: AgentSessionConfig) {
this.currentMode = config.modeId ?? null;
}
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
async startTurn(): Promise<{ turnId: string }> {
return { turnId: "turn-1" };
}
subscribe(): () => void {
return () => {};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: this.config.model ?? null,
modeId: this.currentMode,
};
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return this.currentMode;
}
async setMode(modeId: string): Promise<void> {
this.currentMode = modeId;
}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence() {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
class ModeAwareClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new ModeAwareSession(config);
}
async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
return new ModeAwareSession({
provider: "codex",
cwd: config?.cwd ?? workdir,
modeId: config?.modeId,
model: config?.model,
});
}
async listModels() {
return [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }];
}
}
const manager = new AgentManager({
clients: {
codex: new ModeAwareClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000301",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
modeId: "auto",
});
await manager.setAgentMode(snapshot.id, "full-access");
const beforeReload = manager.getAgent(snapshot.id);
expect(beforeReload?.config.modeId).toBe("full-access");
expect(beforeReload?.currentModeId).toBe("full-access");
const reloaded = await manager.reloadAgentSession(snapshot.id);
expect(reloaded.config.modeId).toBe("full-access");
expect(reloaded.currentModeId).toBe("full-access");
});
test("listProviderAvailability uses registered client keys, including custom providers", async () => {
const customClient: AgentClient = {
provider: "zai",
capabilities: TEST_CAPABILITIES,
async isAvailable() {
return true;
},
async createSession() {
throw new Error("not implemented");
},
async resumeSession() {
throw new Error("not implemented");
},
};
const manager = new AgentManager({
clients: {
zai: customClient,
},
logger,
});
await expect(manager.listProviderAvailability()).resolves.toEqual([
{
provider: "zai",
available: true,
error: null,
},
]);
});
test("createAgent passes daemon launch env through the provider launch context", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -45,7 +45,7 @@ import type {
AgentTimelineRow,
AgentTimelineStore,
} from "./agent-timeline-store-types.js";
import { AGENT_PROVIDER_IDS, getAgentProviderDefinition } from "./provider-manifest.js";
import { getAgentProviderDefinition } from "./provider-manifest.js";
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
export type {
@@ -334,6 +334,10 @@ export class AgentManager {
this.clients.set(provider, client);
}
getRegisteredProviderIds(): AgentProvider[] {
return Array.from(this.clients.keys());
}
setAgentAttentionCallback(callback: AgentAttentionCallback): void {
this.onAgentAttention = callback;
}
@@ -477,8 +481,7 @@ export class AgentManager {
}
async listProviderAvailability(): Promise<ProviderAvailability[]> {
const checks = AGENT_PROVIDER_IDS.map(async (providerId) => {
const provider = providerId as AgentProvider;
const checks = Array.from(this.clients.keys()).map(async (provider) => {
const client = this.clients.get(provider);
if (!client) {
return {
@@ -787,6 +790,7 @@ export class AgentManager {
async setAgentMode(agentId: string, modeId: string): Promise<void> {
const agent = this.requireSessionAgent(agentId);
await agent.session.setMode(modeId);
agent.config.modeId = modeId;
agent.currentModeId = modeId;
// Update runtimeInfo to reflect the new mode
if (agent.runtimeInfo) {
@@ -1847,10 +1851,14 @@ export class AgentManager {
const durableTimelineSeed = shouldSeedFromDurable
? await this.loadCommittedTimelineSeed(resolvedAgentId, now)
: null;
const durableTimelineHasRows = durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1;
const durableTimelineHasRows =
durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1;
const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed;
if (timelineSeed || !this.timelineStore.has(resolvedAgentId)) {
this.timelineStore.initialize(resolvedAgentId, timelineSeed ?? { timestamp: now.toISOString() });
this.timelineStore.initialize(
resolvedAgentId,
timelineSeed ?? { timestamp: now.toISOString() },
);
}
if (options?.timelineRows?.length) {
this.enqueueDurableTimelineBulkInsert(resolvedAgentId, options.timelineRows);
@@ -2157,9 +2165,7 @@ export class AgentManager {
}
}
private async hydrateTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
): Promise<void> {
private async hydrateTimelineFromLegacyProviderHistory(agent: ActiveManagedAgent): Promise<void> {
if (agent.historyPrimed) {
return;
}
@@ -2726,5 +2732,4 @@ export class AgentManager {
}
return agent;
}
}
@@ -44,6 +44,8 @@ export type AgentMode = {
id: string;
label: string;
description?: string;
icon?: string;
colorTier?: string;
};
export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
@@ -74,6 +76,9 @@ export interface ProviderSnapshotEntry {
models?: AgentModelDefinition[];
modes?: AgentMode[];
fetchedAt?: string;
label?: string;
description?: string;
defaultModeId?: string | null;
}
export type AgentFeatureToggle = {
@@ -185,8 +185,7 @@ export class AgentStorage implements AgentSnapshotStore {
workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean },
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
const nextOptions =
typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions;
const nextOptions = typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions;
await this.load();
await this.waitForPendingWrite(agent.id);
const existing = (await this.get(agent.id)) ?? null;
@@ -197,7 +196,9 @@ export class AgentStorage implements AgentSnapshotStore {
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null),
createdAt: existing?.createdAt,
internal: hasInternalOverride ? nextOptions?.internal : (agent.internal ?? existing?.internal),
internal: hasInternalOverride
? nextOptions?.internal
: (agent.internal ?? existing?.internal),
});
// Preserve soft-delete/archive status across snapshot flushes.
@@ -43,8 +43,7 @@ export class InMemoryAgentTimelineStore {
const rows = options?.rows?.length
? options.rows.map(cloneRow)
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
const nextSeq =
options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1);
const nextSeq = options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1);
this.states.set(agentId, {
rows,
nextSeq,
@@ -94,7 +93,9 @@ export class InMemoryAgentTimelineStore {
if (direction === "tail") {
const selected =
selectAll || limit >= state.rows.length ? state.rows : state.rows.slice(state.rows.length - limit);
selectAll || limit >= state.rows.length
? state.rows
: state.rows.slice(state.rows.length - limit);
return {
direction,
window,
@@ -7,6 +7,7 @@ import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import type { ProviderDefinition } from "./provider-registry.js";
type TestDeps = {
agentManager: AgentManager;
@@ -53,6 +54,20 @@ function createTestDeps(): TestDeps {
};
}
function createProviderDefinition(overrides: Partial<ProviderDefinition>): ProviderDefinition {
return {
id: "claude",
label: "Claude",
description: "Test provider",
defaultModeId: "default",
modes: [],
createClient: vi.fn() as ProviderDefinition["createClient"],
fetchModels: vi.fn().mockResolvedValue([]),
fetchModes: vi.fn().mockResolvedValue([]),
...overrides,
};
}
describe("create_agent MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
@@ -213,6 +228,22 @@ describe("create_agent MCP tool", () => {
);
});
it("accepts custom provider IDs in create_agent input validation", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = (server as any)._registeredTools["create_agent"];
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
title: "Custom provider agent",
initialMode: "default",
agentType: "zai",
initialPrompt: "Do work",
});
expect(parsed.success).toBe(true);
});
it("allows caller agents to override cwd and applies caller context labels", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const baseDir = await mkdtemp(join(tmpdir(), "paseo-mcp-test-"));
@@ -302,6 +333,52 @@ describe("create_agent MCP tool", () => {
});
});
describe("provider listing MCP tool", () => {
const logger = createTestLogger();
it("returns providers from the registry, including custom providers", async () => {
const { agentManager, agentStorage } = createTestDeps();
const providerRegistry = {
claude: createProviderDefinition({
id: "claude",
label: "Claude",
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
}),
zai: createProviderDefinition({
id: "zai",
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "default",
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
}),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerRegistry,
logger,
});
const tool = (server as any)._registeredTools["list_providers"];
const response = await tool.callback({});
expect(response.structuredContent).toEqual({
providers: [
{
id: "claude",
label: "Claude",
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
},
{
id: "zai",
label: "ZAI",
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
},
],
});
});
});
describe("speak MCP tool", () => {
const logger = createTestLogger();
@@ -29,7 +29,7 @@ import { captureTerminalLines } from "../../terminal/terminal.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
import type { ScheduleService } from "../schedule/service.js";
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
import { AGENT_PROVIDER_DEFINITIONS, type ProviderDefinition } from "./provider-registry.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { deletePaseoWorktree, listPaseoWorktrees } from "../../utils/worktree.js";
import {
AgentModelSchema,
@@ -1366,7 +1366,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
async () => ({
content: [],
structuredContent: ensureValidJson({
providers: AGENT_PROVIDER_DEFINITIONS.map((provider) => ({
providers: Object.values(providerRegistry ?? {}).map((provider) => ({
id: provider.id,
label: provider.label,
modes: provider.modes.map((mode) => ({
@@ -1473,7 +1473,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
content: [],
structuredContent: ensureValidJson({
branchName,
worktreePath: worktree.worktreePath,
worktreePath: worktree.worktree.worktreePath,
}),
};
},
+7 -11
View File
@@ -1,20 +1,14 @@
import { z } from "zod";
import type { Logger } from "pino";
import type { AgentPromptInput, AgentProvider, AgentPermissionRequest } from "./agent-sdk-types.js";
import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import type { AgentStorage } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import { serializeAgentSnapshot } from "../messages.js";
import { StoredScheduleSchema } from "../schedule/types.js";
export const AgentProviderEnum = z.enum(
AGENT_PROVIDER_DEFINITIONS.map((definition) => definition.id) as [
AgentProvider,
...AgentProvider[],
],
);
export const AgentProviderEnum = z.string();
export const AgentStatusEnum = z.enum(["initializing", "idle", "running", "error", "closed"]);
@@ -22,6 +16,8 @@ export const ProviderModeSchema = z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
colorTier: z.string().optional(),
});
export const ProviderSummarySchema = z.object({
@@ -257,7 +253,7 @@ export function sanitizePermissionRequest(
}
export async function resolveAgentTitle(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
agentId: string,
logger: Logger,
): Promise<string | null> {
@@ -271,7 +267,7 @@ export async function resolveAgentTitle(
}
export async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
snapshot: ManagedAgent,
logger: Logger,
) {
@@ -1,8 +1,10 @@
import { describe, expect, test, vi } from "vitest";
import {
resolveProviderCommandPrefix,
applyProviderEnv,
migrateProviderSettings,
ProviderOverrideSchema,
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "./provider-launch-config.js";
@@ -103,3 +105,175 @@ describe("applyProviderEnv", () => {
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined();
});
});
describe("ProviderOverrideSchema", () => {
test("accepts built-in override fields", () => {
const parsed = ProviderOverrideSchema.parse({
command: ["custom-claude", "--json"],
env: {
FOO: "bar",
},
enabled: false,
order: 2,
});
expect(parsed.command).toEqual(["custom-claude", "--json"]);
expect(parsed.env?.FOO).toBe("bar");
expect(parsed.enabled).toBe(false);
expect(parsed.order).toBe(2);
});
test("accepts models with thinking options", () => {
const parsed = ProviderOverrideSchema.parse({
models: [
{
id: "zai-fast",
label: "ZAI Fast",
isDefault: true,
thinkingOptions: [
{
id: "deep",
label: "Deep",
description: "Higher effort",
},
],
},
],
});
expect(parsed.models).toEqual([
{
id: "zai-fast",
label: "ZAI Fast",
isDefault: true,
thinkingOptions: [
{
id: "deep",
label: "Deep",
description: "Higher effort",
},
],
},
]);
});
});
describe("migrateProviderSettings", () => {
const builtinProviderIds = ["claude", "codex", "copilot", "opencode", "pi"];
test("passes through entries already in the new format", () => {
const migrated = migrateProviderSettings(
{
zai: {
extends: "claude",
label: "ZAI",
command: ["zai"],
env: {
ZAI_KEY: "secret",
},
},
},
builtinProviderIds,
);
expect(migrated).toEqual({
zai: {
extends: "claude",
label: "ZAI",
command: ["zai"],
env: {
ZAI_KEY: "secret",
},
},
});
});
test("migrates mode replace to command argv", () => {
const migrated = migrateProviderSettings(
{
claude: {
command: {
mode: "replace",
argv: ["docker", "run", "--rm", "claude"],
},
},
},
builtinProviderIds,
);
expect(migrated).toEqual({
claude: {
command: ["docker", "run", "--rm", "claude"],
},
});
});
test("migrates mode default by dropping command", () => {
const migrated = migrateProviderSettings(
{
codex: {
command: {
mode: "default",
},
env: {
FOO: "bar",
},
},
},
builtinProviderIds,
);
expect(migrated).toEqual({
codex: {
env: {
FOO: "bar",
},
},
});
});
test("drops append mode entries because they cannot be auto-migrated", () => {
const migrated = migrateProviderSettings(
{
claude: {
command: {
mode: "append",
args: ["--debug"],
},
env: {
FOO: "bar",
},
},
},
builtinProviderIds,
);
expect(migrated).toEqual({});
});
test("preserves legacy env while migrating old entries", () => {
const migrated = migrateProviderSettings(
{
opencode: {
command: {
mode: "replace",
argv: ["opencode"],
},
env: {
PATH: "/custom/bin",
},
},
},
builtinProviderIds,
);
expect(migrated).toEqual({
opencode: {
command: ["opencode"],
env: {
PATH: "/custom/bin",
},
},
});
});
});
@@ -1,6 +1,8 @@
import { z } from "zod";
import { findExecutableSync, isCommandAvailable } from "../../utils/executable.js";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { isCommandAvailable } from "../../utils/executable.js";
import type { AgentProvider } from "./agent-sdk-types.js";
import { AgentProviderSchema } from "./provider-manifest.js";
@@ -37,6 +39,38 @@ export const ProviderRuntimeSettingsSchema = z
})
.strict();
const ProviderProfileThinkingOptionSchema = z
.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
})
.strict();
export const ProviderProfileModelSchema = z
.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
isDefault: z.boolean().optional(),
thinkingOptions: z.array(ProviderProfileThinkingOptionSchema).optional(),
})
.strict();
export const ProviderOverrideSchema = z
.object({
extends: z.string().optional(),
label: z.string().optional(),
description: z.string().optional(),
command: z.array(z.string().min(1)).min(1).optional(),
env: z.record(z.string()).optional(),
models: z.array(ProviderProfileModelSchema).optional(),
enabled: z.boolean().optional(),
order: z.number().optional(),
})
.strict();
export const AgentProviderRuntimeSettingsMapSchema = z.record(
AgentProviderSchema,
ProviderRuntimeSettingsSchema,
@@ -44,6 +78,8 @@ export const AgentProviderRuntimeSettingsMapSchema = z.record(
export type ProviderCommand = z.infer<typeof ProviderCommandSchema>;
export type ProviderRuntimeSettings = z.infer<typeof ProviderRuntimeSettingsSchema>;
export type ProviderProfileModel = z.infer<typeof ProviderProfileModelSchema>;
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
export type AgentProviderRuntimeSettingsMap = Partial<
Record<AgentProvider, ProviderRuntimeSettings>
>;
@@ -87,6 +123,48 @@ export function resolveShellEnv(): Record<string, string> {
return cachedShellEnv;
}
export function migrateProviderSettings(
raw: Record<string, unknown>,
builtinProviderIds: string[],
): Record<string, ProviderOverride> {
const migrated: Record<string, ProviderOverride> = {};
const builtinProviderIdSet = new Set(builtinProviderIds);
for (const [providerId, value] of Object.entries(raw)) {
const parsedNew = ProviderOverrideSchema.safeParse(value);
if (parsedNew.success) {
migrated[providerId] = parsedNew.data;
continue;
}
const parsedOld = ProviderRuntimeSettingsSchema.safeParse(value);
if (!parsedOld.success) {
continue;
}
const nextEntry: ProviderOverride = {};
const command = parsedOld.data.command;
if (command?.mode === "append") {
console.warn(
`[Config] Skipping legacy agents.providers.${providerId}.command append mode during provider override migration because it cannot be auto-migrated.`,
);
continue;
}
if (command?.mode === "replace") {
nextEntry.command = command.argv;
}
if (parsedOld.data.env) {
nextEntry.env = parsedOld.data.env;
}
if (!builtinProviderIdSet.has(providerId) && nextEntry.extends === undefined) {
delete nextEntry.extends;
}
migrated[providerId] = nextEntry;
}
return migrated;
}
// Env vars that indicate a running Claude Code session. If the daemon itself is
// launched from inside Claude Code (e.g. by a Paseo agent), these leak into
// child processes and cause "cannot be launched inside another session" errors.
@@ -113,7 +191,25 @@ export function applyProviderEnv(
}
export function findExecutable(name: string): string | null {
return findExecutableSync(name);
const trimmed = name.trim();
if (!trimmed) return null;
if (trimmed.includes("/") || trimmed.includes("\\")) {
try {
const { existsSync } = require("node:fs");
return existsSync(trimmed) ? trimmed : null;
} catch {
return null;
}
}
try {
const cmd = process.platform === "win32" ? "where.exe" : "which";
const result = execFileSync(cmd, [trimmed], { encoding: "utf8" }).trim();
const lines = result.split(/\r?\n/).filter((l: string) => l.trim());
const candidate = lines.at(-1)?.trim() ?? null;
return candidate && path.isAbsolute(candidate) ? candidate : null;
} catch {
return null;
}
}
export async function isProviderCommandAvailable(
@@ -9,7 +9,7 @@ export interface AgentModeVisuals {
colorTier: AgentModeColorTier;
}
export interface AgentProviderModeDefinition extends AgentMode, AgentModeVisuals {}
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> & AgentModeVisuals;
// TODO: `modes` should not be static. Providers (especially ACP) report their
// own modes at runtime via session/new. We should fetch modes from the provider
@@ -189,24 +189,35 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
},
];
export function getAgentProviderDefinition(provider: string): AgentProviderDefinition {
const definition = AGENT_PROVIDER_DEFINITIONS.find((entry) => entry.id === provider);
export function getAgentProviderDefinition(
provider: string,
definitions: AgentProviderDefinition[] = AGENT_PROVIDER_DEFINITIONS,
): AgentProviderDefinition {
const definition = definitions.find((entry) => entry.id === provider);
if (!definition) {
throw new Error(`Unknown agent provider: ${provider}`);
}
return definition;
}
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
export const BUILTIN_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
export const AGENT_PROVIDER_IDS = BUILTIN_PROVIDER_IDS;
export const AgentProviderSchema = z.string();
export function isValidAgentProvider(value: string): boolean {
return AGENT_PROVIDER_IDS.includes(value);
export function isValidAgentProvider(
value: string,
validIds: Iterable<string> = BUILTIN_PROVIDER_IDS,
): boolean {
return Array.isArray(validIds) ? validIds.includes(value) : new Set(validIds).has(value);
}
export function getModeVisuals(provider: string, modeId: string): AgentModeVisuals | undefined {
const definition = AGENT_PROVIDER_DEFINITIONS.find((entry) => entry.id === provider);
export function getModeVisuals(
provider: string,
modeId: string,
definitions: AgentProviderDefinition[],
): AgentModeVisuals | undefined {
const definition = definitions.find((entry) => entry.id === provider);
const mode = definition?.modes.find((m) => m.id === modeId);
if (!mode) return undefined;
return { icon: mode.icon, colorTier: mode.colorTier };
@@ -0,0 +1,624 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentModelDefinition } from "./agent-sdk-types.js";
const mockState = vi.hoisted(() => {
type ConstructorEntry = {
runtimeSettings?: unknown;
};
return {
constructorArgs: {
claude: [] as ConstructorEntry[],
codex: [] as ConstructorEntry[],
copilot: [] as ConstructorEntry[],
opencode: [] as ConstructorEntry[],
pi: [] as ConstructorEntry[],
genericAcp: [] as Array<{
command: string[];
env?: Record<string, string>;
}>,
},
isCommandAvailable: vi.fn(async (_command: string) => false),
runtimeModels: new Map<string, AgentModelDefinition[]>(),
reset() {
for (const key of Object.keys(this.constructorArgs) as Array<
keyof typeof this.constructorArgs
>) {
this.constructorArgs[key] = [];
}
this.isCommandAvailable.mockReset();
this.isCommandAvailable.mockImplementation(async (_command: string) => false);
this.runtimeModels.clear();
},
};
});
vi.mock("../../utils/executable.js", () => ({
isCommandAvailable: mockState.isCommandAvailable,
}));
vi.mock("./providers/claude-agent.js", () => ({
ClaudeAgentClient: class ClaudeAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
readonly provider = "claude";
readonly runtimeSettings?: unknown;
constructor(options: { runtimeSettings?: unknown }) {
this.runtimeSettings = options.runtimeSettings;
mockState.constructorArgs.claude.push({
runtimeSettings: options.runtimeSettings,
});
}
async createSession(): Promise<never> {
throw new Error("not implemented");
}
async resumeSession(): Promise<never> {
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
}
async isAvailable(): Promise<boolean> {
const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } })
?.command;
if (command?.mode === "replace") {
const { isCommandAvailable } = await import("../../utils/executable.js");
return await isCommandAvailable(command.argv?.[0] ?? "");
}
return true;
}
},
}));
vi.mock("./providers/codex-app-server-agent.js", () => ({
CodexAppServerAgentClient: class CodexAppServerAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
readonly provider = "codex";
readonly runtimeSettings?: unknown;
constructor(_logger: unknown, runtimeSettings?: unknown) {
this.runtimeSettings = runtimeSettings;
mockState.constructorArgs.codex.push({ runtimeSettings });
}
async createSession(): Promise<never> {
throw new Error("not implemented");
}
async resumeSession(): Promise<never> {
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
}
async isAvailable(): Promise<boolean> {
const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } })
?.command;
if (command?.mode === "replace") {
const { isCommandAvailable } = await import("../../utils/executable.js");
return await isCommandAvailable(command.argv?.[0] ?? "");
}
return true;
}
},
}));
vi.mock("./providers/copilot-acp-agent.js", () => ({
CopilotACPAgentClient: class CopilotACPAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
readonly provider = "copilot";
readonly runtimeSettings?: unknown;
constructor(options: { runtimeSettings?: unknown }) {
this.runtimeSettings = options.runtimeSettings;
mockState.constructorArgs.copilot.push({
runtimeSettings: options.runtimeSettings,
});
}
async createSession(): Promise<never> {
throw new Error("not implemented");
}
async resumeSession(): Promise<never> {
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
}
async isAvailable(): Promise<boolean> {
const command = (this.runtimeSettings as { command?: { mode?: string; argv?: string[] } })
?.command;
if (command?.mode === "replace") {
const { isCommandAvailable } = await import("../../utils/executable.js");
return await isCommandAvailable(command.argv?.[0] ?? "");
}
return true;
}
},
}));
vi.mock("./providers/opencode-agent.js", () => ({
OpenCodeAgentClient: class OpenCodeAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
readonly provider = "opencode";
readonly runtimeSettings?: unknown;
constructor(_logger: unknown, runtimeSettings?: unknown) {
this.runtimeSettings = runtimeSettings;
mockState.constructorArgs.opencode.push({ runtimeSettings });
}
async createSession(): Promise<never> {
throw new Error("not implemented");
}
async resumeSession(): Promise<never> {
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
}
async isAvailable(): Promise<boolean> {
return true;
}
},
OpenCodeServerManager: {
getInstance: vi.fn(() => ({
shutdown: vi.fn(),
})),
},
}));
vi.mock("./providers/pi-acp-agent.js", () => ({
PiACPAgentClient: class PiACPAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
readonly provider = "pi";
readonly runtimeSettings?: unknown;
constructor(options: { runtimeSettings?: unknown }) {
this.runtimeSettings = options.runtimeSettings;
mockState.constructorArgs.pi.push({
runtimeSettings: options.runtimeSettings,
});
}
async createSession(): Promise<never> {
throw new Error("not implemented");
}
async resumeSession(): Promise<never> {
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
}
async isAvailable(): Promise<boolean> {
return true;
}
},
}));
vi.mock("./providers/generic-acp-agent.js", () => ({
GenericACPAgentClient: class GenericACPAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
readonly provider = "acp";
readonly runtimeSettings?: unknown;
constructor(options: { command: string[]; env?: Record<string, string> }) {
this.runtimeSettings = {
command: {
mode: "replace",
argv: options.command,
},
env: options.env,
};
mockState.constructorArgs.genericAcp.push({
command: options.command,
env: options.env,
});
}
async createSession(): Promise<never> {
throw new Error("not implemented");
}
async resumeSession(): Promise<never> {
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
}
async isAvailable(): Promise<boolean> {
return true;
}
},
}));
import { AGENT_PROVIDER_DEFINITIONS, buildProviderRegistry } from "./provider-registry.js";
describe("buildProviderRegistry", () => {
const logger = createTestLogger();
beforeEach(() => {
mockState.reset();
});
test("builds registry with no overrides — same as built-in count", () => {
const registry = buildProviderRegistry(logger);
expect(Object.keys(registry)).toHaveLength(AGENT_PROVIDER_DEFINITIONS.length);
});
test("built-in override applies command", () => {
buildProviderRegistry(logger, {
providerOverrides: {
claude: {
command: ["/opt/custom-claude", "--verbose"],
},
},
});
expect(mockState.constructorArgs.claude[0]).toEqual({
runtimeSettings: {
command: {
mode: "replace",
argv: ["/opt/custom-claude", "--verbose"],
},
env: undefined,
},
});
});
test("built-in override applies env", () => {
buildProviderRegistry(logger, {
providerOverrides: {
claude: {
env: {
CLAUDE_CONFIG_DIR: "/tmp/claude",
},
},
},
});
expect(mockState.constructorArgs.claude[0]).toEqual({
runtimeSettings: {
command: undefined,
env: {
CLAUDE_CONFIG_DIR: "/tmp/claude",
},
},
});
});
test("new provider extending claude appears in registry", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
zai: {
extends: "claude",
label: "ZAI",
description: "Claude with ZAI defaults",
},
},
});
expect(registry.zai).toBeDefined();
expect(registry.zai.label).toBe("ZAI");
expect(registry.zai.description).toBe("Claude with ZAI defaults");
expect(registry.zai.createClient(logger).provider).toBe("zai");
});
test("new provider extending acp uses GenericACPAgentClient", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
"my-agent": {
extends: "acp",
label: "My Agent",
command: ["my-agent", "--acp"],
env: {
ACP_TOKEN: "secret",
},
},
},
});
expect(registry["my-agent"].createClient(logger).provider).toBe("my-agent");
expect(mockState.constructorArgs.genericAcp).toEqual([
{
command: ["my-agent", "--acp"],
env: {
ACP_TOKEN: "secret",
},
},
{
command: ["my-agent", "--acp"],
env: {
ACP_TOKEN: "secret",
},
},
]);
});
test('extends: "acp" without command throws', () => {
expect(() =>
buildProviderRegistry(logger, {
providerOverrides: {
"my-agent": {
extends: "acp",
label: "My Agent",
},
},
}),
).toThrowError("ACP provider 'my-agent' requires a command");
});
test("custom provider without label throws", () => {
expect(() =>
buildProviderRegistry(logger, {
providerOverrides: {
zai: {
extends: "claude",
},
},
}),
).toThrowError("Custom provider 'zai' requires a label");
});
test("enabled: false excludes provider from registry", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
enabled: false,
},
},
});
expect(registry.claude).toBeUndefined();
});
test("provider override command can be PATH-resolved and still report available", async () => {
mockState.isCommandAvailable.mockResolvedValue(true);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
command: ["claude", "--flag"],
},
},
});
await expect(registry.claude.createClient(logger).isAvailable()).resolves.toBe(true);
expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude");
});
test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => {
buildProviderRegistry(logger, {
providerOverrides: {
claude: {
command: ["/opt/custom-claude"],
},
zai: {
extends: "claude",
label: "ZAI",
},
},
});
expect(mockState.constructorArgs.claude).toHaveLength(2);
expect(
mockState.constructorArgs.claude.every(
(entry) =>
(entry.runtimeSettings as { command?: { argv?: string[] } }).command?.argv?.[0] ===
"/opt/custom-claude",
),
).toBe(true);
});
describe("model merging", () => {
test("profile models replace runtime models", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "runtime-pro",
label: "Runtime Pro",
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
models: [
{
id: "profile-fast",
label: "Profile Fast",
},
],
},
},
});
const models = await registry.claude.fetchModels();
expect(models.map((model) => model.id)).toEqual(["profile-fast"]);
});
test("profile models exclude runtime models entirely", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "shared-model",
label: "Runtime Label",
},
{
provider: "claude",
id: "runtime-only",
label: "Runtime Only",
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
models: [
{
id: "shared-model",
label: "Profile Label",
},
],
},
},
});
const models = await registry.claude.fetchModels();
expect(models).toEqual([
{
provider: "claude",
id: "shared-model",
label: "Profile Label",
},
]);
});
test("profile isDefault preserved without runtime models", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "runtime-default",
label: "Runtime Default",
isDefault: true,
},
]);
const registry = buildProviderRegistry(logger, {
providerOverrides: {
claude: {
models: [
{
id: "profile-default",
label: "Profile Default",
isDefault: true,
},
],
},
},
});
const models = await registry.claude.fetchModels();
expect(models).toEqual([
{
provider: "claude",
id: "profile-default",
label: "Profile Default",
isDefault: true,
},
]);
});
test("no profile models — runtime models returned as-is", async () => {
mockState.runtimeModels.set("claude", [
{
provider: "claude",
id: "runtime-default",
label: "Runtime Default",
isDefault: true,
},
]);
const registry = buildProviderRegistry(logger);
const models = await registry.claude.fetchModels();
expect(models).toEqual([
{
provider: "claude",
id: "runtime-default",
label: "Runtime Default",
isDefault: true,
},
]);
});
});
});
@@ -1,13 +1,25 @@
import type { Logger } from "pino";
import type {
AgentClient,
AgentMode,
AgentModelDefinition,
AgentPersistenceHandle,
AgentProvider,
AgentRuntimeInfo,
AgentSession,
AgentStreamEvent,
ListModelsOptions,
ListModesOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./provider-launch-config.js";
import type { Logger } from "pino";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
ProviderProfileModel,
ProviderRuntimeSettings,
} from "./provider-launch-config.js";
import { AiderAgentClient } from "./providers/aider-agent.js";
import { AmpAgentClient } from "./providers/amp-agent.js";
@@ -15,11 +27,12 @@ import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js";
import { GeminiAgentClient } from "./providers/gemini-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import { PiACPAgentClient } from "./providers/pi-acp-agent.js";
import {
AGENT_PROVIDER_DEFINITIONS,
BUILTIN_PROVIDER_IDS,
getAgentProviderDefinition,
type AgentProviderDefinition,
} from "./provider-manifest.js";
@@ -34,33 +47,45 @@ export interface ProviderDefinition extends AgentProviderDefinition {
fetchModes: (options?: ListModesOptions) => Promise<AgentMode[]>;
}
type BuildProviderRegistryOptions = {
export type BuildProviderRegistryOptions = {
runtimeSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
};
type ProviderClientFactory = (
logger: Logger,
runtimeSettings?: AgentProviderRuntimeSettingsMap,
runtimeSettings?: ProviderRuntimeSettings,
) => AgentClient;
type ResolvedProvider = {
definition: AgentProviderDefinition;
runtimeSettings?: ProviderRuntimeSettings;
profileModels: ProviderProfileModel[];
enabled: boolean;
createBaseClient: (logger: Logger) => AgentClient;
};
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
claude: (logger, runtimeSettings) =>
new ClaudeAgentClient({
logger,
runtimeSettings: runtimeSettings?.claude,
runtimeSettings,
}),
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
gemini: (_logger, runtimeSettings) => new GeminiAgentClient(runtimeSettings?.gemini),
amp: (_logger, runtimeSettings) => new AmpAgentClient(runtimeSettings?.amp),
aider: (_logger, runtimeSettings) => new AiderAgentClient(runtimeSettings?.aider),
codex: (logger, runtimeSettings) => new CodexAppServerAgentClient(logger, runtimeSettings),
gemini: (_logger, runtimeSettings) => new GeminiAgentClient(runtimeSettings),
amp: (_logger, runtimeSettings) => new AmpAgentClient(runtimeSettings),
aider: (_logger, runtimeSettings) => new AiderAgentClient(runtimeSettings),
copilot: (logger, runtimeSettings) =>
new CopilotACPAgentClient({
logger,
runtimeSettings: runtimeSettings?.copilot,
runtimeSettings,
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
pi: (logger, runtimeSettings) =>
new PiACPAgentClient({ logger, runtimeSettings: runtimeSettings?.pi }),
new PiACPAgentClient({
logger,
runtimeSettings,
}),
};
function getProviderClientFactory(provider: string): ProviderClientFactory {
@@ -71,31 +96,366 @@ function getProviderClientFactory(provider: string): ProviderClientFactory {
return factory;
}
function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings | undefined {
if (!override?.command && !override?.env) {
return undefined;
}
return {
command: override.command
? {
mode: "replace",
argv: override.command,
}
: undefined,
env: override.env,
};
}
function mergeRuntimeSettings(
base: ProviderRuntimeSettings | undefined,
override: ProviderRuntimeSettings | undefined,
): ProviderRuntimeSettings | undefined {
if (!base && !override) {
return undefined;
}
return {
command: override?.command ?? base?.command,
env:
base?.env || override?.env
? {
...(base?.env ?? {}),
...(override?.env ?? {}),
}
: undefined,
};
}
function applyOverrideToDefinition(
definition: AgentProviderDefinition,
override?: ProviderOverride,
): AgentProviderDefinition {
if (!override) {
return definition;
}
return {
...definition,
label: override.label ?? definition.label,
description: override.description ?? definition.description,
};
}
function createDerivedDefinition(
providerId: string,
baseDefinition: AgentProviderDefinition,
override: ProviderOverride,
): AgentProviderDefinition {
if (!override.label) {
throw new Error(`Custom provider '${providerId}' requires a label`);
}
return {
...baseDefinition,
id: providerId,
label: override.label,
description: override.description ?? baseDefinition.description,
};
}
function mapPersistenceHandle(
provider: AgentProvider,
handle: AgentPersistenceHandle | null,
): AgentPersistenceHandle | null {
if (!handle) {
return null;
}
return {
...handle,
provider,
};
}
function mapRuntimeInfo(provider: AgentProvider, runtimeInfo: AgentRuntimeInfo): AgentRuntimeInfo {
return {
...runtimeInfo,
provider,
};
}
function mapStreamEvent(provider: AgentProvider, event: AgentStreamEvent): AgentStreamEvent {
return {
...event,
provider,
};
}
function mapPersistedAgentDescriptor(
provider: AgentProvider,
descriptor: PersistedAgentDescriptor,
): PersistedAgentDescriptor {
return {
...descriptor,
provider,
persistence: {
...descriptor.persistence,
provider,
},
};
}
function mapModel(provider: AgentProvider, model: AgentModelDefinition): AgentModelDefinition {
return {
...model,
provider,
};
}
function mergeModels(
provider: AgentProvider,
profileModels: ProviderProfileModel[],
runtimeModels: AgentModelDefinition[],
): AgentModelDefinition[] {
if (profileModels.length === 0) {
return runtimeModels.map((model) => mapModel(provider, model));
}
return profileModels.map((model) => ({
...model,
provider,
}));
}
function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession {
return {
provider,
id: inner.id,
capabilities: inner.capabilities,
get features() {
return inner.features;
},
run: (prompt, options) => inner.run(prompt, options),
startTurn: (prompt, options) => inner.startTurn(prompt, options),
subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))),
async *streamHistory() {
for await (const event of inner.streamHistory()) {
yield mapStreamEvent(provider, event);
}
},
getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()),
getAvailableModes: () => inner.getAvailableModes(),
getCurrentMode: () => inner.getCurrentMode(),
setMode: (modeId) => inner.setMode(modeId),
getPendingPermissions: () => inner.getPendingPermissions(),
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(),
close: () => inner.close(),
listCommands: inner.listCommands?.bind(inner),
setModel: inner.setModel?.bind(inner),
setThinkingOption: inner.setThinkingOption?.bind(inner),
setFeature: inner.setFeature?.bind(inner),
};
}
function wrapClientProvider(provider: AgentProvider, inner: AgentClient): AgentClient {
const listPersistedAgents = inner.listPersistedAgents?.bind(inner);
return {
provider,
capabilities: inner.capabilities,
createSession: async (config, launchContext) =>
wrapSessionProvider(
provider,
await inner.createSession(
{
...config,
provider: inner.provider,
},
launchContext,
),
),
resumeSession: async (handle, overrides, launchContext) =>
wrapSessionProvider(
provider,
await inner.resumeSession(
{
...handle,
provider: inner.provider,
},
overrides
? {
...overrides,
provider: inner.provider,
}
: undefined,
launchContext,
),
),
listModels: async (options) =>
(await inner.listModels(options)).map((model) => mapModel(provider, model)),
listModes: inner.listModes?.bind(inner),
listPersistedAgents: listPersistedAgents
? async (options?: ListPersistedAgentsOptions) =>
(await listPersistedAgents(options)).map((descriptor) =>
mapPersistedAgentDescriptor(provider, descriptor),
)
: undefined,
isAvailable: () => inner.isAvailable(),
getDiagnostic: inner.getDiagnostic?.bind(inner),
};
}
function createRegistryEntry(
logger: Logger,
provider: AgentProvider,
resolved: ResolvedProvider,
): ProviderDefinition {
const modelClient = resolved.createBaseClient(logger);
return {
...resolved.definition,
createClient: (providerLogger: Logger) => {
const inner = resolved.createBaseClient(providerLogger);
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
},
fetchModels: async (options?: ListModelsOptions) =>
mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)),
fetchModes: async (options?: ListModesOptions) => {
const modes = modelClient.listModes
? await modelClient.listModes(options)
: resolved.definition.modes;
return modes.map((mode) => {
if (mode.icon && mode.colorTier) return mode;
const definitionMode = resolved.definition.modes.find((d) => d.id === mode.id);
if (!definitionMode) return mode;
return {
...mode,
icon: mode.icon ?? definitionMode.icon,
colorTier: mode.colorTier ?? definitionMode.colorTier,
};
});
},
};
}
function buildResolvedBuiltinProviders(
providerOverrides: Record<string, ProviderOverride>,
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
): Map<string, ResolvedProvider> {
const resolvedProviders = new Map<string, ResolvedProvider>();
for (const definition of AGENT_PROVIDER_DEFINITIONS) {
const override = providerOverrides[definition.id];
const factory = getProviderClientFactory(definition.id);
const mergedRuntimeSettings = mergeRuntimeSettings(
runtimeSettings?.[definition.id],
toRuntimeSettings(override),
);
resolvedProviders.set(definition.id, {
definition: applyOverrideToDefinition(definition, override),
runtimeSettings: mergedRuntimeSettings,
profileModels: override?.models ?? [],
enabled: override?.enabled !== false,
createBaseClient: (logger) => factory(logger, mergedRuntimeSettings),
});
}
return resolvedProviders;
}
function addDerivedProviders(
resolvedProviders: Map<string, ResolvedProvider>,
providerOverrides: Record<string, ProviderOverride>,
): void {
for (const [providerId, override] of Object.entries(providerOverrides)) {
if (BUILTIN_PROVIDER_IDS.includes(providerId)) {
continue;
}
if (!override.extends) {
throw new Error(`Custom provider '${providerId}' requires an extends value`);
}
if (override.extends === "acp") {
if (!override.command) {
throw new Error(`ACP provider '${providerId}' requires a command`);
}
resolvedProviders.set(providerId, {
definition: createDerivedDefinition(
providerId,
{
id: providerId,
label: override.label ?? providerId,
description: override.description ?? "Custom ACP provider",
defaultModeId: null,
modes: [],
},
override,
),
runtimeSettings: toRuntimeSettings(override),
profileModels: override.models ?? [],
enabled: override.enabled !== false,
createBaseClient: (logger) =>
new GenericACPAgentClient({
logger,
command: override.command!,
env: override.env,
}),
});
continue;
}
const baseProvider = resolvedProviders.get(override.extends);
if (!baseProvider) {
throw new Error(
`Custom provider '${providerId}' extends unknown provider '${override.extends}'`,
);
}
const mergedRuntimeSettings = mergeRuntimeSettings(
baseProvider.runtimeSettings,
toRuntimeSettings(override),
);
const baseDefinition = baseProvider.definition;
const baseFactory = getProviderClientFactory(override.extends);
resolvedProviders.set(providerId, {
definition: createDerivedDefinition(providerId, baseDefinition, override),
runtimeSettings: mergedRuntimeSettings,
profileModels: override.models ?? [],
enabled: override.enabled !== false,
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
});
}
}
export function buildProviderRegistry(
logger: Logger,
options?: BuildProviderRegistryOptions,
): Record<AgentProvider, ProviderDefinition> {
const runtimeSettings = options?.runtimeSettings;
const providerOverrides = options?.providerOverrides ?? {};
const resolvedProviders = buildResolvedBuiltinProviders(providerOverrides, runtimeSettings);
addDerivedProviders(resolvedProviders, providerOverrides);
return Object.fromEntries(
AGENT_PROVIDER_DEFINITIONS.map((definition) => {
const createClient = getProviderClientFactory(definition.id);
const modelClient = createClient(logger, runtimeSettings);
return [
definition.id,
{
...definition,
createClient: (providerLogger: Logger) => createClient(providerLogger, runtimeSettings),
fetchModels: (listOptions?: ListModelsOptions) => modelClient.listModels(listOptions),
fetchModes: (listOptions?: ListModesOptions) =>
modelClient.listModes
? modelClient.listModes(listOptions)
: Promise.resolve(definition.modes),
} satisfies ProviderDefinition,
];
}),
[...resolvedProviders.entries()]
.filter(([, resolved]) => resolved.enabled)
.map(([provider, resolved]) => [provider, createRegistryEntry(logger, provider, resolved)]),
) as Record<AgentProvider, ProviderDefinition>;
}
export function getProviderIds(
registry: Record<AgentProvider, ProviderDefinition>,
): AgentProvider[] {
return Object.keys(registry);
}
// Deprecated: Use buildProviderRegistry instead
export const PROVIDER_REGISTRY: Record<AgentProvider, ProviderDefinition> = null as any;
@@ -105,7 +465,10 @@ export function createAllClients(
): Record<AgentProvider, AgentClient> {
const registry = buildProviderRegistry(logger, options);
return Object.fromEntries(
Object.entries(registry).map(([provider, definition]) => [provider, definition.createClient(logger)]),
Object.entries(registry).map(([provider, definition]) => [
provider,
definition.createClient(logger),
]),
) as Record<AgentProvider, AgentClient>;
}
@@ -1,3 +1,4 @@
import { resolve } from "node:path";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
@@ -19,6 +20,9 @@ type Deferred<T> = {
type MockProviderOptions = {
provider: AgentProvider;
label?: string;
description?: string;
defaultModeId?: string | null;
isAvailable?: () => Promise<boolean>;
fetchModels?: (cwd?: string) => Promise<AgentModelDefinition[]>;
fetchModes?: (cwd?: string) => Promise<AgentMode[]>;
@@ -41,6 +45,10 @@ const TEST_CAPABILITIES = {
} as const;
describe("ProviderSnapshotManager", () => {
const projectCwd = resolve("/tmp/project");
const projectACwd = resolve("/tmp/project-a");
const projectBCwd = resolve("/tmp/project-b");
test("getSnapshot returns all providers in loading state initially and triggers warmUp", async () => {
const codexModels = deferred<AgentModelDefinition[]>();
const claudeModels = deferred<AgentModelDefinition[]>();
@@ -56,12 +64,23 @@ describe("ProviderSnapshotManager", () => {
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
const snapshot = manager.getSnapshot("/tmp/project");
const snapshot = manager.getSnapshot(projectCwd);
expect(snapshot).toEqual([
{ provider: "claude", status: "loading" },
{ provider: "codex", status: "loading" },
]);
expect(snapshot.map((entry) => entry.provider)).toEqual(["codex", "claude"]);
expect(getProviderEntry(snapshot, "claude")).toMatchObject({
provider: "claude",
status: "loading",
label: "claude",
description: "claude test provider",
defaultModeId: null,
});
expect(getProviderEntry(snapshot, "codex")).toMatchObject({
provider: "codex",
status: "loading",
label: "codex",
description: "codex test provider",
defaultModeId: null,
});
await vi.waitFor(() => {
expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1);
@@ -88,25 +107,31 @@ describe("ProviderSnapshotManager", () => {
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project");
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "claude")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
});
const snapshot = manager.getSnapshot("/tmp/project");
const snapshot = manager.getSnapshot(projectCwd);
expect(getProviderEntry(snapshot, "codex")).toMatchObject({
provider: "codex",
status: "ready",
models: [createModel("codex", "gpt-5.2")],
modes: [createMode("auto")],
label: "codex",
description: "codex test provider",
defaultModeId: null,
});
expect(getProviderEntry(snapshot, "claude")).toMatchObject({
provider: "claude",
status: "ready",
models: [createModel("claude", "sonnet")],
modes: [createMode("default")],
label: "claude",
description: "claude test provider",
defaultModeId: null,
});
expect(getProviderEntry(snapshot, "codex")?.fetchedAt).toEqual(expect.any(String));
@@ -122,11 +147,17 @@ describe("ProviderSnapshotManager", () => {
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project");
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(manager.getSnapshot("/tmp/project")).toEqual([
{ provider: "codex", status: "unavailable" },
expect(manager.getSnapshot(projectCwd)).toEqual([
{
provider: "codex",
status: "unavailable",
label: "codex",
description: "codex test provider",
defaultModeId: null,
},
]);
});
@@ -147,14 +178,17 @@ describe("ProviderSnapshotManager", () => {
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project");
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(manager.getSnapshot("/tmp/project")).toEqual([
expect(manager.getSnapshot(projectCwd)).toEqual([
{
provider: "codex",
status: "error",
error: "model lookup failed",
label: "codex",
description: "codex test provider",
defaultModeId: null,
},
]);
});
@@ -186,7 +220,7 @@ describe("ProviderSnapshotManager", () => {
};
manager.on("change", listener);
manager.getSnapshot("/tmp/project");
manager.getSnapshot(projectCwd);
claudeModels.resolve([createModel("claude", "sonnet")]);
claudeModes.resolve([createMode("default")]);
@@ -195,7 +229,7 @@ describe("ProviderSnapshotManager", () => {
expect(changes).toHaveLength(1);
});
expect(changes[0]?.cwd).toBe("/tmp/project");
expect(changes[0]?.cwd).toBe(projectCwd);
expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready");
expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading");
@@ -227,19 +261,27 @@ describe("ProviderSnapshotManager", () => {
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project");
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe(
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.1",
);
});
manager.refresh("/tmp/project");
expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]);
manager.refresh(projectCwd);
expect(manager.getSnapshot(projectCwd)).toEqual([
{
provider: "codex",
status: "loading",
label: "codex",
description: "codex test provider",
defaultModeId: null,
},
]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe(
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
"gpt-5.2",
);
});
@@ -263,13 +305,21 @@ describe("ProviderSnapshotManager", () => {
const changes: ProviderSnapshotEntry[][] = [];
manager.on("change", (entries) => changes.push(entries));
manager.refresh("/tmp/project");
manager.refresh(projectCwd);
expect(manager.getSnapshot("/tmp/project")).toEqual([{ provider: "codex", status: "loading" }]);
expect(manager.getSnapshot(projectCwd)).toEqual([
{
provider: "codex",
status: "loading",
label: "codex",
description: "codex test provider",
defaultModeId: null,
},
]);
manager.refresh("/tmp/project");
manager.refresh("/tmp/project");
manager.refresh("/tmp/project");
manager.refresh(projectCwd);
manager.refresh(projectCwd);
manager.refresh(projectCwd);
expect(changes).toHaveLength(1);
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
@@ -278,7 +328,7 @@ describe("ProviderSnapshotManager", () => {
fetchModes.resolve([createMode("auto")]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")).toMatchObject({
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
provider: "codex",
status: "ready",
models: [createModel("codex", "gpt-5.2")],
@@ -306,9 +356,9 @@ describe("ProviderSnapshotManager", () => {
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project");
manager.getSnapshot("/tmp/project");
manager.getSnapshot("/tmp/project");
manager.getSnapshot(projectCwd);
manager.getSnapshot(projectCwd);
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
@@ -320,7 +370,7 @@ describe("ProviderSnapshotManager", () => {
codexModels.resolve([createModel("codex", "gpt-5.2")]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
});
manager.destroy();
@@ -339,25 +389,104 @@ describe("ProviderSnapshotManager", () => {
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project-a");
manager.getSnapshot("/tmp/project-b");
manager.getSnapshot(projectACwd);
manager.getSnapshot(projectBCwd);
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(projectACwd), "codex")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.status).toBe("ready");
});
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe(
"model:/tmp/project-a",
expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe(
`model:${projectACwd}`,
);
expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.models?.[0]?.id).toBe(
"model:/tmp/project-b",
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
`model:${projectBCwd}`,
);
expect(seenCwds).toEqual(["/tmp/project-a", "/tmp/project-b"]);
expect(seenCwds).toEqual([projectACwd, projectBCwd]);
manager.destroy();
});
test("snapshot includes user-defined providers from the registry", async () => {
const { registry } = createRegistry([
createMockProvider({ provider: "claude" }),
createMockProvider({
provider: "zai",
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "default",
fetchModes: async () => [createMode("default")],
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot(projectCwd);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")?.status).toBe("ready");
});
expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({
provider: "zai",
status: "ready",
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "default",
});
manager.destroy();
});
test("enabled false providers are omitted when absent from the registry", () => {
const { registry } = createRegistry([createMockProvider({ provider: "claude" })]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
const snapshot = manager.getSnapshot(projectCwd);
expect(snapshot.map((entry) => entry.provider)).toEqual(["claude"]);
expect(getProviderEntry(snapshot, "zai")).toBeUndefined();
manager.destroy();
});
test("snapshot entries include label and description from the registry", async () => {
const models = deferred<AgentModelDefinition[]>();
const modes = deferred<AgentMode[]>();
const { registry } = createRegistry([
createMockProvider({
provider: "zai",
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "plan",
fetchModels: async () => models.promise,
fetchModes: async () => modes.promise,
}),
]);
const manager = new ProviderSnapshotManager(registry, createTestLogger());
expect(manager.getSnapshot(projectCwd)).toEqual([
{
provider: "zai",
status: "loading",
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "plan",
},
]);
models.resolve([createModel("zai", "zai-fast")]);
modes.resolve([createMode("plan")]);
await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({
provider: "zai",
status: "ready",
label: "ZAI",
description: "Custom Claude profile",
defaultModeId: "plan",
});
});
manager.destroy();
});
@@ -403,9 +532,9 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
const definition: ProviderDefinition = {
id: options.provider,
label: options.provider,
description: `${options.provider} test provider`,
defaultModeId: null,
label: options.label ?? options.provider,
description: options.description ?? `${options.provider} test provider`,
defaultModeId: options.defaultModeId ?? null,
modes: [],
createClient: () =>
({
@@ -5,7 +5,6 @@ import type { Logger } from "pino";
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__";
@@ -63,9 +62,13 @@ export class ProviderSnapshotManager {
private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
for (const provider of this.getProviderIds()) {
const definition = this.providerRegistry[provider];
entries.set(provider, {
provider,
status: "loading",
label: definition?.label,
description: definition?.description,
defaultModeId: definition?.defaultModeId ?? null,
});
}
return entries;
@@ -107,6 +110,9 @@ export class ProviderSnapshotManager {
snapshot.set(provider, {
provider,
status: "loading",
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
});
try {
@@ -116,6 +122,9 @@ export class ProviderSnapshotManager {
snapshot.set(provider, {
provider,
status: "unavailable",
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
});
this.emitChange(cwdKey);
return;
@@ -132,6 +141,9 @@ export class ProviderSnapshotManager {
models,
modes,
fetchedAt: new Date().toISOString(),
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
});
this.emitChange(cwdKey);
} catch (error) {
@@ -139,6 +151,9 @@ export class ProviderSnapshotManager {
provider,
status: "error",
error: toErrorMessage(error),
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
});
this.logger.warn(
{ err: error, provider, cwd: cwdKey },
@@ -180,7 +195,7 @@ export class ProviderSnapshotManager {
}
private getProviderIds(): AgentProvider[] {
return AGENT_PROVIDER_IDS.filter((provider) => this.providerRegistry[provider]);
return Object.keys(this.providerRegistry) as AgentProvider[];
}
}
@@ -10,14 +10,14 @@
* CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY in the environment.
* They are skipped automatically when credentials are unavailable.
*/
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../../utils/executable.js";
import { isCommandAvailable } from "../../../../utils/executable.js";
import { ClaudeAgentClient } from "../claude-agent.js";
// ---------------------------------------------------------------------------
@@ -28,7 +28,6 @@ const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const canRun = isCommandAvailableSync("claude") && hasClaudeCredentials;
function tmpCwd(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix));
@@ -194,332 +193,308 @@ 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-" });
let canRun = false;
try {
const { turnId, events } = await startTurnAndCollectEvents(
handle.session,
"respond with just the word hello",
);
beforeAll(async () => {
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
});
const turnStarted = events.find(
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("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",
);
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("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("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,
);
expect(turnStarted).toBeDefined();
).length,
).toBe(0);
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
// 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);
}
},
60_000,
);
test.skipIf(!canRun)(
"Test 2: No duplicate user_messages — THE BUG",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-dedup-" });
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", {
extraMs: 3_000,
});
test("Test 4: Autonomous run", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" });
const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`;
expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1);
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,
},
);
// 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();
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]);
} finally {
await cleanupSession(handle);
return;
}
},
90_000,
);
test.skipIf(!canRun)(
"Test 5: Interruption",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" });
const autoTurnId = autoStarts[0]!.turnId;
expect(fgTurnId).not.toBe(autoTurnId);
try {
let turnId: string | null = null;
const events = await new Promise<AgentStreamEvent[]>((resolve, reject) => {
const collected: AgentStreamEvent[] = [];
let interrupted = false;
// Autonomous turn reaches terminal
expect(
afterForeground.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId),
).toBeDefined();
const timeout = setTimeout(() => {
assertInvariants(events, [fgTurnId]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
test("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("Timed out after 45000ms waiting for terminal event"));
}, 45_000);
resolve(collected);
}
});
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) {
void handle.session
.startTurn("write a very long essay about the history of computing")
.then((result) => {
if (turnId && turnId !== result.turnId) {
clearTimeout(timeout);
unsubscribe();
resolve(collected);
reject(
new Error(
`Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`,
),
);
return;
}
turnId = result.turnId;
})
.catch((error) => {
clearTimeout(timeout);
unsubscribe();
reject(error);
});
});
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);
});
});
expect(turnId).toBeDefined();
expect(turnId).toBeDefined();
// 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();
// 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();
// 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);
// 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);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test("Test 6: Sequential foreground turns", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
test.skipIf(!canRun)(
"Test 6: Sequential foreground turns",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-sequential-" });
try {
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
handle.session,
"say first",
);
try {
const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents(
handle.session,
"say first",
);
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
handle.session,
"say second",
);
const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents(
handle.session,
"say second",
);
const allEvents = [...events1, ...events2];
const allEvents = [...events1, ...events2];
expect(turnId1).not.toBe(turnId2);
expect(turnId1).not.toBe(turnId2);
// 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);
// 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);
assertInvariants(allEvents, [turnId1, turnId2]);
} finally {
await cleanupSession(handle);
}
}, 90_000);
assertInvariants(allEvents, [turnId1, turnId2]);
} finally {
await cleanupSession(handle);
}
},
90_000,
);
test("Test 7: Fast-fail", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
test.skipIf(!canRun)(
"Test 7: Fast-fail",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
extraMs: 3_000,
});
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", {
extraMs: 3_000,
});
// At most one turn_started
expect(
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
.length,
).toBeLessThanOrEqual(1);
// At most one turn_started
expect(
events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId)
.length,
).toBeLessThanOrEqual(1);
// Terminal present
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
// Terminal present
const terminal = events.find(
(e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId,
);
expect(terminal).toBeDefined();
// 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);
// 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,
);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test("Test 8: User message dedup by text", async () => {
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
test.skipIf(!canRun)(
"Test 8: User message dedup by text",
async () => {
const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" });
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
extraMs: 3_000,
});
try {
const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", {
extraMs: 3_000,
});
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
},
60_000,
);
assertInvariants(events, [turnId]);
} finally {
await cleanupSession(handle);
}
}, 60_000);
});
@@ -1,14 +1,26 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import type { AgentSlashCommand } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js";
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
describe("claude agent commands contract (real)", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isCommandAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("lists slash commands with the expected contract", async () => {
expect(isCommandAvailableSync("claude")).toBe(true);
expect(await isCommandAvailable("claude")).toBe(true);
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
@@ -1,4 +1,4 @@
import { describe, expect, test, beforeAll } from "vitest";
import { describe, expect, test, beforeAll, beforeEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -6,7 +6,7 @@ import pino from "pino";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -174,111 +174,106 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P
}
describe("ClaudeAgentSession integration", () => {
const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials;
let canRunClaudeIntegration = false;
beforeAll(() => {
beforeAll(async () => {
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
if (canRunClaudeIntegration) {
expect(isCommandAvailableSync("claude")).toBe(true);
expect(await isCommandAvailable("claude")).toBe(true);
}
});
test.runIf(canRunClaudeIntegration)(
"streams a basic response turn end-to-end",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-basic-response-",
beforeEach((context) => {
if (!canRunClaudeIntegration) {
context.skip();
}
});
test("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",
});
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",
async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-bypass-restart-",
modeId: "bypassPermissions",
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 {
await handle.session.setMode("acceptEdits");
await handle.session.setThinkingOption("high");
await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined();
} finally {
await cleanupSession(handle);
}
},
60_000,
);
test("keeps bypassPermissions available after a thinking-option restart", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-bypass-restart-",
modeId: "bypassPermissions",
});
test.runIf(canRunClaudeIntegration)(
"supportedModels returns the current abstract Claude SDK model shape",
async () => {
const claudeQuery = query({
prompt: createEmptyPrompt(),
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"],
},
});
try {
await handle.session.setMode("acceptEdits");
await handle.session.setThinkingOption("high");
await expect(handle.session.setMode("bypassPermissions")).resolves.toBeUndefined();
} finally {
await cleanupSession(handle);
}
}, 60_000);
try {
const models = await claudeQuery.supportedModels();
test("supportedModels returns the current abstract Claude SDK model shape", async () => {
const claudeQuery = query({
prompt: createEmptyPrompt(),
options: {
cwd: process.cwd(),
permissionMode: "plan",
includePartialMessages: false,
settingSources: ["user", "project"],
},
});
expect(models.length).toBeGreaterThanOrEqual(3);
expect(models).toContainEqual(
expect.objectContaining({
value: "default",
displayName: "Default (recommended)",
supportedEffortLevels: ["low", "medium", "high", "max"],
}),
);
expect(models).toContainEqual(
expect.objectContaining({
value: "haiku",
displayName: "Haiku",
description: expect.stringContaining("Haiku 4.5"),
}),
);
expect(
models.some(
(model) =>
model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"),
),
).toBe(true);
} finally {
await claudeQuery.return?.();
}
},
60_000,
);
try {
const models = await claudeQuery.supportedModels();
expect(models.length).toBeGreaterThanOrEqual(3);
expect(models).toContainEqual(
expect.objectContaining({
value: "default",
displayName: "Default (recommended)",
supportedEffortLevels: ["low", "medium", "high", "max"],
}),
);
expect(models).toContainEqual(
expect.objectContaining({
value: "haiku",
displayName: "Haiku",
description: expect.stringContaining("Haiku 4.5"),
}),
);
expect(
models.some(
(model) =>
model.description.includes("Opus 4.6") || model.description.includes("Sonnet 4.6"),
),
).toBe(true);
} finally {
await claudeQuery.return?.();
}
}, 60_000);
test.runIf(canRunClaudeIntegration)(
"runs a real Bash tool call and completes it",
@@ -1,8 +1,8 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -29,35 +29,43 @@ async function collectUntilTerminal(session: AgentSession): Promise<AgentStreamE
}
describe("Claude max effort availability (real)", () => {
test.runIf(isCommandAvailableSync("claude") && hasClaudeCredentials)(
"surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable",
async () => {
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "bypassPermissions",
model: "claude-opus-4-6",
thinkingOptionId: "max",
});
let canRun = false;
try {
const events = await collectUntilTerminal(session);
const failure = events.find(
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
event.type === "turn_failed",
);
beforeAll(async () => {
canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials;
});
expect(failure).toBeDefined();
expect(failure?.error).toContain("Claude Code process exited with code 1");
expect(failure?.code).toBe("1");
expect(failure?.diagnostic).toContain('Effort level "max" is not available');
} finally {
await session.close().catch(() => undefined);
}
},
30_000,
);
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable", async () => {
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "bypassPermissions",
model: "claude-opus-4-6",
thinkingOptionId: "max",
});
try {
const events = await collectUntilTerminal(session);
const failure = events.find(
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
event.type === "turn_failed",
);
expect(failure).toBeDefined();
expect(failure?.error).toContain("Claude Code process exited with code 1");
expect(failure?.code).toBe("1");
expect(failure?.diagnostic).toContain('Effort level "max" is not available');
} finally {
await session.close().catch(() => undefined);
}
}, 30_000);
});
@@ -1,5 +1,4 @@
import { execFile, type ChildProcessWithoutNullStreams } from "node:child_process";
import { promisify } from "node:util";
import { type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { promises } from "node:fs";
@@ -74,12 +73,11 @@ import type {
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
const fsPromises = promises;
const execFileAsync = promisify(execFile);
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
type TurnState = "idle" | "foreground" | "autonomous";
@@ -244,6 +242,10 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau
return value === "low" || value === "medium" || value === "high" || value === "max";
}
function sanitizeClaudeProjectPath(cwd: string): string {
return cwd.replace(/[\\/._:]/g, "-");
}
type ClaudeOptionsLogSummary = {
cwd: string | null;
permissionMode: string | null;
@@ -1131,7 +1133,7 @@ export class ClaudeAgentClient implements AgentClient {
async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command;
if (command?.mode === "replace") {
return fs.existsSync(command.argv[0]);
return await isCommandAvailable(command.argv[0]);
}
return true;
}
@@ -1187,14 +1189,10 @@ async function resolveClaudeVersion(
try {
if (command?.mode === "replace") {
const { stdout } = await execFileAsync(
const { stdout } = await execCommand(
command.argv[0]!,
[...command.argv.slice(1), "--version"],
{
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
},
{ timeout: 5_000 },
);
return stdout.trim() || null;
}
@@ -1204,11 +1202,7 @@ async function resolveClaudeVersion(
return null;
}
const { stdout } = await execFileAsync(executable, ["--version"], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
const { stdout } = await execCommand(executable, ["--version"], { timeout: 5_000 });
return stdout.trim() || null;
} catch {
return null;
@@ -3273,8 +3267,7 @@ class ClaudeAgentSession implements AgentSession {
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
// Match Claude CLI's path sanitization: replace slashes, dots, and underscores with dashes
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
const sanitized = sanitizeClaudeProjectPath(cwd);
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const dir = path.join(configDir, "projects", sanitized);
return path.join(dir, `${sessionId}.jsonl`);
@@ -21,6 +21,10 @@ const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
function sanitizeClaudeProjectPath(cwd: string): string {
return cwd.replace(/[\\/._:]/g, "-");
}
function buildSdkQueryMock() {
const events = [
{
@@ -106,7 +110,7 @@ describe("ClaudeAgentSession history replay regression", () => {
configDir = path.join(tempRoot, "claude-config");
mkdirSync(cwd, { recursive: true });
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
const sanitized = sanitizeClaudeProjectPath(cwd);
const historyDir = path.join(configDir, "projects", sanitized);
mkdirSync(historyDir, { recursive: true });
const historyPath = path.join(historyDir, "history-session.jsonl");
@@ -4,9 +4,9 @@
import { mkdtempSync, rmSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeAll, describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { findExecutable, isCommandAvailableSync } from "../../../utils/executable.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
@@ -57,100 +57,103 @@ const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
describe("Claude SDK direct behavior", () => {
const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials;
let canRunClaudeIntegration = false;
beforeAll(() => {
beforeAll(async () => {
canRunClaudeIntegration = (await isCommandAvailable("claude")) && hasClaudeCredentials;
if (canRunClaudeIntegration) {
expect(isCommandAvailableSync("claude")).toBe(true);
expect(await isCommandAvailable("claude")).toBe(true);
}
});
test.runIf(canRunClaudeIntegration)(
"shows what happens after interrupt()",
async () => {
const cwd = tmpCwd();
const input = new Pushable<SDKUserMessage>();
const claudeBinary = await findExecutable("claude");
beforeEach((context) => {
if (!canRunClaudeIntegration) {
context.skip();
}
});
// 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"],
test("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",
},
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: "",
});
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);
// 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 === "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;
}
// MSG2 was already pushed before interrupt
const msg2Events: SDKMessage[] = [];
for await (const event of q) {
msg2Events.push(event);
if (event.type === "result") {
break;
}
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;
}
// 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;
}
}
}
}
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);
});
@@ -4,6 +4,7 @@ const CLAUDE_THINKING_OPTIONS = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "max", label: "Max" },
] as const;
const CLAUDE_MODELS: AgentModelDefinition[] = [
@@ -31,7 +31,7 @@ import type { Logger } from "pino";
import { execSync } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, Dirent } from "node:fs";
import { Dirent } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -48,7 +48,7 @@ import {
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
@@ -4213,7 +4213,7 @@ export class CodexAppServerAgentClient implements AgentClient {
async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command;
if (command?.mode === "replace") {
return existsSync(command.argv[0]);
return await isCommandAvailable(command.argv[0]);
}
return true;
}
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -12,53 +12,61 @@ function tmpCwd(): string {
}
describe("Codex app-server provider (real) plan mode", () => {
test.runIf(isProviderAvailable("codex"))(
"maps gpt-5.4 markdown plans to a plan tool call instead of todo items",
async () => {
const cwd = tmpCwd();
const client = new CodexAppServerAgentClient(createTestLogger());
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("codex");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("maps gpt-5.4 markdown plans to a plan tool call instead of todo items", async () => {
const cwd = tmpCwd();
const client = new CodexAppServerAgentClient(createTestLogger());
try {
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "medium",
});
try {
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "medium",
});
await session.setFeature?.("plan_mode", true);
try {
await session.setFeature?.("plan_mode", true);
const result = await session.run(
"You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.",
);
const result = await session.run(
"You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.",
);
expect(result.timeline).not.toContainEqual(
expect.objectContaining({
type: "todo",
}),
);
expect(result.timeline).not.toContainEqual(
expect.objectContaining({
type: "todo",
}),
);
const planCall = result.timeline.find(
(item) => item.type === "tool_call" && item.detail.type === "plan",
);
const planCall = result.timeline.find(
(item) => item.type === "tool_call" && item.detail.type === "plan",
);
expect(planCall).toBeDefined();
if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") {
throw new Error("Expected a plan tool call");
}
expect(planCall.detail.text).toContain("Login");
expect(planCall.detail.text).toContain("- ");
expect(result.finalText).toBe(planCall.detail.text);
} finally {
await session.close();
expect(planCall).toBeDefined();
if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") {
throw new Error("Expected a plan tool call");
}
expect(planCall.detail.text).toContain("Login");
expect(planCall.detail.text).toContain("- ");
expect(result.finalText).toBe(planCall.detail.text);
} finally {
rmSync(cwd, { recursive: true, force: true });
await session.close();
}
},
240_000,
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 240_000);
});
@@ -1,9 +1,5 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
const execFileAsync = promisify(execFile);
import { execCommand } from "../../../utils/spawn.js";
type DiagnosticEntry = {
label: string;
@@ -49,11 +45,7 @@ export function toDiagnosticErrorMessage(error: unknown): string {
export async function resolveBinaryVersion(binaryPath: string): Promise<string> {
try {
const { stdout } = await execFileAsync(binaryPath, ["--version"], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
const { stdout } = await execCommand(binaryPath, ["--version"], { timeout: 5_000 });
return stdout.trim() || "unknown";
} catch {
return "unknown";
@@ -0,0 +1,46 @@
import type { Logger } from "pino";
import { isCommandAvailable } from "../../../utils/executable.js";
import { ACPAgentClient } from "./acp-agent.js";
type GenericACPAgentClientOptions = {
logger: Logger;
command: string[];
env?: Record<string, string>;
};
export class GenericACPAgentClient extends ACPAgentClient {
private readonly command: [string, ...string[]];
constructor(options: GenericACPAgentClientOptions) {
if (options.command.length === 0) {
throw new Error("Generic ACP provider requires a non-empty command");
}
super({
provider: "acp",
logger: options.logger,
runtimeSettings: {
command: {
mode: "replace",
argv: options.command,
},
env: options.env,
},
defaultCommand: options.command as [string, ...string[]],
});
this.command = options.command as [string, ...string[]];
}
protected override async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> {
return {
command: this.command[0],
args: this.command.slice(1),
};
}
override async isAvailable(): Promise<boolean> {
return isCommandAvailable(this.command[0]);
}
}
@@ -1,13 +1,25 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import type { AgentSlashCommand } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
describe("opencode agent commands contract (real)", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isCommandAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("lists slash commands with the expected contract", async () => {
expect(isCommandAvailableSync("opencode")).toBe(true);
expect(await isCommandAvailable("opencode")).toBe(true);
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
@@ -37,7 +49,7 @@ describe("opencode agent commands contract (real)", () => {
}, 60_000);
test("executes a slash command without arguments", async () => {
expect(isCommandAvailableSync("opencode")).toBe(true);
expect(await isCommandAvailable("opencode")).toBe(true);
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
@@ -1,8 +1,8 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -21,166 +21,158 @@ function isTerminalEvent(event: AgentStreamEvent): boolean {
* when models are invalid, auth fails, or provider API calls fail.
*/
describe("opencode agent error handling (real)", () => {
test.runIf(isCommandAvailableSync("opencode"))(
"surfaces error for the exact opencode/<bad-model> path used by paseo run",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
let canRun = false;
try {
await session.setModel("opencode/adklasldkdas");
beforeAll(async () => {
canRun = await isCommandAvailable("opencode");
});
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
} finally {
await session.close().catch(() => undefined);
}
},
45_000,
);
test("surfaces error for the exact opencode/<bad-model> path used by paseo run", async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
test.runIf(isCommandAvailableSync("opencode"))(
"surfaces error for unknown provider model (fast path)",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
try {
await session.setModel("bogus-provider/totally-fake-model-12345");
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
} finally {
await session.close().catch(() => undefined);
}
},
30_000,
);
test.runIf(isCommandAvailableSync("opencode"))(
"sequential sessions: second session works after first errors",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
// Session 1: bogus model, will error quickly
const s1 = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
await s1.setModel("bogus-provider/fake-model-12345");
for await (const event of streamSession(s1, "Say hello")) {
if (isTerminalEvent(event)) break;
}
await s1.close();
// Session 2: different bogus model, should also work
const s2 = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
await s2.setModel("bogus-provider/fake-model-67890");
try {
await session.setModel("opencode/adklasldkdas");
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(s2, "Say hello")) {
for await (const event of streamSession(session, "hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
await s2.close().catch(() => undefined);
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
},
30_000,
);
} finally {
await session.close().catch(() => undefined);
}
}, 45_000);
test.runIf(isCommandAvailableSync("opencode"))(
"surfaces error for known provider with nonexistent model (retry path)",
async () => {
// When the provider is recognized (anthropic) but the model doesn't exist,
// OpenCode retries before surfacing the error. This must not hang.
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
test("surfaces error for unknown provider model (fast path)", async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
try {
await session.setModel("anthropic/claude-nonexistent-99");
try {
await session.setModel("bogus-provider/totally-fake-model-12345");
const events: AgentStreamEvent[] = [];
const start = Date.now();
for await (const event of streamSession(session, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
const elapsed = Date.now() - start;
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(elapsed).toBeLessThan(30_000);
console.log(`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`);
} finally {
await session.close().catch(() => undefined);
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
},
45_000,
);
test.runIf(isCommandAvailableSync("opencode"))(
"surfaces fatal retry status from zai/glm-5.1 instead of hanging forever",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
} finally {
await session.close().catch(() => undefined);
}
}, 30_000);
try {
await session.setModel("zai/glm-5.1");
test("sequential sessions: second session works after first errors", async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
// Session 1: bogus model, will error quickly
const s1 = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
await s1.setModel("bogus-provider/fake-model-12345");
for await (const event of streamSession(s1, "Say hello")) {
if (isTerminalEvent(event)) break;
}
await s1.close();
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/,
);
} finally {
await session.close().catch(() => undefined);
// Session 2: different bogus model, should also work
const s2 = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
await s2.setModel("bogus-provider/fake-model-67890");
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(s2, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
await s2.close().catch(() => undefined);
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
}, 30_000);
test("surfaces error for known provider with nonexistent model (retry path)", async () => {
// When the provider is recognized (anthropic) but the model doesn't exist,
// OpenCode retries before surfacing the error. This must not hang.
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
try {
await session.setModel("anthropic/claude-nonexistent-99");
const events: AgentStreamEvent[] = [];
const start = Date.now();
for await (const event of streamSession(session, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
},
45_000,
);
const elapsed = Date.now() - start;
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(elapsed).toBeLessThan(30_000);
console.log(`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`);
} finally {
await session.close().catch(() => undefined);
}
}, 45_000);
test("surfaces fatal retry status from zai/glm-5.1 instead of hanging forever", async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
try {
await session.setModel("zai/glm-5.1");
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
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/,
);
} finally {
await session.close().catch(() => undefined);
}
}, 45_000);
});
@@ -1,5 +1,4 @@
import type { ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
@@ -44,7 +43,7 @@ import {
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import {
@@ -922,7 +921,7 @@ export class OpenCodeAgentClient implements AgentClient {
async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command;
if (command?.mode === "replace") {
return existsSync(command.argv[0]);
return await isCommandAvailable(command.argv[0]);
}
return true;
}
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest";
import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -9,62 +9,66 @@ import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
describe("OpenCode assistant message", () => {
test.runIf(isProviderAvailable("opencode"))(
"assistant_message appears in live stream with opencode/big-pickle",
async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-"));
const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger);
let canRun = false;
try {
const session = await client.createSession({
provider: "opencode",
cwd,
model: "opencode/big-pickle",
modeId: "build",
});
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
const result = await session.run("Say hello back in one sentence.");
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
const assistantItems = result.timeline.filter((item) => item.type === "assistant_message");
expect(assistantItems.length).toBeGreaterThan(0);
expect(result.finalText.length).toBeGreaterThan(0);
} finally {
rmSync(cwd, { recursive: true, force: true });
test("assistant_message appears in live stream with opencode/big-pickle", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-"));
const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger);
try {
const session = await client.createSession({
provider: "opencode",
cwd,
model: "opencode/big-pickle",
modeId: "build",
});
const result = await session.run("Say hello back in one sentence.");
const assistantItems = result.timeline.filter((item) => item.type === "assistant_message");
expect(assistantItems.length).toBeGreaterThan(0);
expect(result.finalText.length).toBeGreaterThan(0);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 60_000);
test("streamHistory returns assistant_message after a completed turn", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-"));
const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger);
try {
const session = await client.createSession({
provider: "opencode",
cwd,
});
const result = await session.run("Say hello back in one sentence.");
expect(result.timeline.some((item) => item.type === "assistant_message")).toBe(true);
const historyEvents: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
historyEvents.push(event);
}
},
60_000,
);
test.runIf(isProviderAvailable("opencode"))(
"streamHistory returns assistant_message after a completed turn",
async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-"));
const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger);
try {
const session = await client.createSession({
provider: "opencode",
cwd,
});
const result = await session.run("Say hello back in one sentence.");
expect(result.timeline.some((item) => item.type === "assistant_message")).toBe(true);
const historyEvents: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
historyEvents.push(event);
}
const historyAssistant = historyEvents.filter(
(e) => e.type === "timeline" && e.item.type === "assistant_message",
);
expect(historyAssistant.length).toBeGreaterThan(0);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
60_000,
);
const historyAssistant = historyEvents.filter(
(e) => e.type === "timeline" && e.item.type === "assistant_message",
);
expect(historyAssistant.length).toBeGreaterThan(0);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 60_000);
});
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest";
import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -9,60 +9,68 @@ import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
describe("OpenCode reasoning dedup", () => {
test.runIf(isProviderAvailable("opencode"))(
"reasoning content is not duplicated as assistant_message",
async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-"));
const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger);
let canRun = false;
try {
const session = await client.createSession({
provider: "opencode",
cwd,
model: "opencode/gpt-5-nano",
modeId: "build",
});
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
const streamedEvents: AgentStreamEvent[] = [];
session.subscribe((event) => {
streamedEvents.push(event);
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
const result = await session.run("What is 2+2? Think step by step.");
test("reasoning content is not duplicated as assistant_message", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-"));
const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger);
const reasoningTexts: string[] = [];
const assistantTexts: string[] = [];
try {
const session = await client.createSession({
provider: "opencode",
cwd,
model: "opencode/gpt-5-nano",
modeId: "build",
});
for (const event of streamedEvents) {
if (event.type === "timeline") {
if (event.item.type === "reasoning") {
reasoningTexts.push(event.item.text);
} else if (event.item.type === "assistant_message") {
assistantTexts.push(event.item.text);
}
const streamedEvents: AgentStreamEvent[] = [];
session.subscribe((event) => {
streamedEvents.push(event);
});
const result = await session.run("What is 2+2? Think step by step.");
const reasoningTexts: string[] = [];
const assistantTexts: string[] = [];
for (const event of streamedEvents) {
if (event.type === "timeline") {
if (event.item.type === "reasoning") {
reasoningTexts.push(event.item.text);
} else if (event.item.type === "assistant_message") {
assistantTexts.push(event.item.text);
}
}
const fullReasoningText = reasoningTexts.join("");
const fullAssistantText = assistantTexts.join("");
// The model should produce reasoning
expect(reasoningTexts.length).toBeGreaterThan(0);
expect(fullReasoningText.length).toBeGreaterThan(0);
// The assistant text should be the response, not the reasoning
expect(assistantTexts.length).toBeGreaterThan(0);
// Reasoning text must NOT appear in the assistant text
const reasoningPrefix = fullReasoningText.slice(0, 50);
if (reasoningPrefix.length > 10) {
expect(fullAssistantText).not.toContain(reasoningPrefix);
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
120_000,
);
const fullReasoningText = reasoningTexts.join("");
const fullAssistantText = assistantTexts.join("");
// The model should produce reasoning
expect(reasoningTexts.length).toBeGreaterThan(0);
expect(fullReasoningText.length).toBeGreaterThan(0);
// The assistant text should be the response, not the reasoning
expect(assistantTexts.length).toBeGreaterThan(0);
// Reasoning text must NOT appear in the assistant text
const reasoningPrefix = fullReasoningText.slice(0, 50);
if (reasoningPrefix.length > 10) {
expect(fullAssistantText).not.toContain(reasoningPrefix);
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 120_000);
});
@@ -596,11 +596,23 @@ function writeLegacyProjectWorkspaceJson(
): void {
const projectsDir = path.join(paseoHome, "projects");
mkdirSync(projectsDir, { recursive: true });
writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify(input.projects, null, 2), "utf8");
writeFileSync(path.join(projectsDir, "workspaces.json"), JSON.stringify(input.workspaces, null, 2), "utf8");
writeFileSync(
path.join(projectsDir, "projects.json"),
JSON.stringify(input.projects, null, 2),
"utf8",
);
writeFileSync(
path.join(projectsDir, "workspaces.json"),
JSON.stringify(input.workspaces, null, 2),
"utf8",
);
}
function writeLegacyAgentJson(paseoHome: string, relativePath: string, payload: Record<string, unknown>): void {
function writeLegacyAgentJson(
paseoHome: string,
relativePath: string,
payload: Record<string, unknown>,
): void {
const absolutePath = path.join(paseoHome, relativePath);
mkdirSync(path.dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, JSON.stringify(payload, null, 2), "utf8");
@@ -608,7 +620,10 @@ function writeLegacyAgentJson(paseoHome: string, relativePath: string, payload:
function initializeGitRepo(directory: string): void {
execFileSync("git", ["init", "-b", "main"], { cwd: directory, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.dev"], { cwd: directory, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.dev"], {
cwd: directory,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: directory, stdio: "pipe" });
writeFileSync(path.join(directory, "README.md"), "bootstrap fixture\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: directory, stdio: "pipe" });
+10 -76
View File
@@ -120,7 +120,10 @@ import { startRelayTransport, type RelayTransportController } from "./relay-tran
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
import {
ScriptRouteStore,
@@ -130,11 +133,6 @@ import {
import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import {
createVoiceMcpSocketBridgeManager,
type VoiceMcpSocketBridgeManager,
} from "./voice-mcp-bridge.js";
import { resolveVoiceMcpBridgeFromRuntime } from "./voice-mcp-bridge-command.js";
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
@@ -196,6 +194,7 @@ export type PaseoDaemonConfig = {
dictationFinalTimeoutMs?: number;
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
};
@@ -283,9 +282,7 @@ export async function createPaseoDaemon(
// and forwards them to the corresponding local script port. Placed after
// the host allowlist (*.localhost is already allowed) but before CORS and
// the rest of the routes so proxied requests skip unnecessary middleware.
app.use(
createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }),
);
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
// CORS - allow same-origin + configured origins
const allowedOrigins = new Set([
@@ -412,6 +409,7 @@ export async function createPaseoDaemon(
clients: {
...createAllClients(logger, {
runtimeSettings: config.agentProviderSettings,
providerOverrides: config.providerOverrides,
}),
...config.agentClients,
},
@@ -422,6 +420,7 @@ export async function createPaseoDaemon(
});
const providerRegistry = buildProviderRegistry(logger, {
runtimeSettings: config.agentProviderSettings,
providerOverrides: config.providerOverrides,
});
const projectRegistry = new DbProjectRegistry(database.db);
@@ -486,32 +485,6 @@ export async function createPaseoDaemon(
"Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)",
);
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null;
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome,
enableVoiceTools: false,
resolveSpeakHandler: (callerAgentId) =>
wsServer?.resolveVoiceSpeakHandler(callerAgentId) ?? null,
resolveCallerContext: (callerAgentId) =>
wsServer?.resolveVoiceCallerContext(callerAgentId) ?? null,
logger,
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await agentMcpServer.connect(serverTransport);
return clientTransport;
};
const mcpEnabled = config.mcpEnabled ?? true;
let agentMcpBaseUrl: string | null = null;
if (mcpEnabled) {
@@ -637,27 +610,6 @@ export async function createPaseoDaemon(
logger.info("Agent MCP HTTP endpoint disabled");
}
const voiceMcpSocketDir = path.join(config.paseoHome, "runtime", "voice-mcp");
const voiceMcpBridgeCommand = resolveVoiceMcpBridgeCommand(logger);
voiceMcpBridgeManager = createVoiceMcpSocketBridgeManager({
runtimeDir: voiceMcpSocketDir,
logger,
createAgentMcpServerForCaller: async (callerAgentId) => {
return createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome,
callerAgentId,
voiceOnly: true,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
logger,
});
},
});
const speechService = createSpeechService({
logger,
openaiConfig: config.openai,
@@ -718,27 +670,11 @@ export async function createPaseoDaemon(
{ allowedOrigins, allowedHosts: config.allowedHosts },
speechService,
terminalManager,
{
voiceAgentMcpStdio: voiceMcpBridgeCommand
? {
command: voiceMcpBridgeCommand.command,
baseArgs: [...voiceMcpBridgeCommand.baseArgs],
env: {
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: config.paseoHome,
},
}
: null,
ensureVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ??
Promise.reject(new Error("Voice MCP bridge manager is not initialized")),
removeVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.removeBridgeForCaller(agentId) ?? Promise.resolve(),
},
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
},
config.agentProviderSettings,
config.providerOverrides,
daemonVersion,
(intent) => {
try {
@@ -825,6 +761,7 @@ export async function createPaseoDaemon(
await agentManager.flush().catch(() => undefined);
await shutdownProviders(logger, {
runtimeSettings: config.agentProviderSettings,
providerOverrides: config.providerOverrides,
});
terminalManager.killAll();
speechService.stop();
@@ -833,9 +770,6 @@ export async function createPaseoDaemon(
if (wsServer) {
await wsServer.close();
}
if (voiceMcpBridgeManager) {
await voiceMcpBridgeManager.stop().catch(() => undefined);
}
await database?.close().catch(() => undefined);
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
@@ -1,19 +1,10 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } =
vi.hoisted(() => {
const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = 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: "" });
},
),
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
readdirMock: vi.fn(async (directory: string) => {
@@ -40,10 +31,17 @@ const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, w
}),
watchCalls: hoistedWatchCalls,
};
});
},
);
vi.mock("child_process", () => ({
exec: execMock,
vi.mock("../utils/run-git-command.js", () => ({
runGitCommand: vi.fn(async () => ({
stdout: "/tmp/repo\n",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
})),
}));
vi.mock("node:fs/promises", async () => {
@@ -88,7 +86,6 @@ describe("CheckoutDiffManager Linux watchers", () => {
beforeEach(() => {
watchCalls.length = 0;
execMock.mockClear();
getCheckoutDiffMock.mockClear();
resolveCheckoutGitDirMock.mockClear();
readdirMock.mockClear();
@@ -1,16 +1,13 @@
import { watch, type FSWatcher } from "node:fs";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import { exec } from "child_process";
import { promisify } from "util";
import type pino from "pino";
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
import { getCheckoutDiff } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js";
const execAsync = promisify(exec);
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
@@ -177,10 +174,13 @@ export class CheckoutDiffManager {
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const { stdout } = await runGitCommand(
["rev-parse", "--path-format=absolute", "--show-toplevel"],
{
cwd,
env: READ_ONLY_GIT_ENV,
},
);
const root = stdout.trim();
return root.length > 0 ? root : null;
} catch {
@@ -1,12 +1,9 @@
import { exec } from "child_process";
import { promisify } from "util";
import {
MergeConflictError,
MergeFromBaseConflictError,
NotGitRepoError,
} from "../utils/checkout-git.js";
const execAsync = promisify(exec);
import { runGitCommand } from "../utils/run-git-command.js";
export const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env,
@@ -22,7 +19,7 @@ export type CheckoutErrorPayload = {
export async function resolveCheckoutGitDir(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git rev-parse --absolute-git-dir", {
const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
+59 -1
View File
@@ -4,6 +4,11 @@ import { z } from "zod";
import type { PaseoDaemonConfig } from "./bootstrap.js";
import { loadPersistedConfig } from "./persisted-config.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
import { AgentProviderSchema } from "./agent/provider-manifest.js";
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
import {
@@ -52,6 +57,55 @@ function parseOptionalVoiceLlmProvider(value: unknown): AgentProvider | null {
return parsed.success ? parsed.data : null;
}
function extractProviderOverrides(
providers: Record<string, unknown> | undefined,
): Record<string, ProviderOverride> | undefined {
if (!providers) {
return undefined;
}
const providerOverrides = Object.entries(providers).flatMap(([providerId, provider]) => {
const parsed = ProviderOverrideSchema.safeParse(provider);
return parsed.success ? [[providerId, parsed.data] as const] : [];
});
return providerOverrides.length > 0 ? Object.fromEntries(providerOverrides) : undefined;
}
function extractAgentProviderSettings(
providerOverrides: Record<string, ProviderOverride> | undefined,
): AgentProviderRuntimeSettingsMap | undefined {
if (!providerOverrides) {
return undefined;
}
const runtimeSettings = Object.entries(providerOverrides).flatMap(([providerId, provider]) => {
const parsedProviderId = AgentProviderSchema.safeParse(providerId);
if (!parsedProviderId.success || (!provider.command && !provider.env)) {
return [];
}
return [
[
parsedProviderId.data,
{
command: provider.command
? {
mode: "replace" as const,
argv: provider.command,
}
: undefined,
env: provider.env,
},
] as const,
];
});
return runtimeSettings.length > 0
? (Object.fromEntries(runtimeSettings) as AgentProviderRuntimeSettingsMap)
: undefined;
}
export function loadConfig(
paseoHome: string,
options?: {
@@ -117,6 +171,9 @@ export function loadConfig(
const voiceLlmProviderExplicit =
envVoiceLlmProvider !== null || persistedVoiceLlmProvider !== null;
const voiceLlmModel = persisted.features?.voiceMode?.llm?.model ?? null;
const providerOverrides = extractProviderOverrides(
persisted.agents?.providers as Record<string, unknown> | undefined,
);
return {
listen,
@@ -140,6 +197,7 @@ export function loadConfig(
voiceLlmProvider,
voiceLlmProviderExplicit,
voiceLlmModel,
agentProviderSettings: persisted.agents?.providers,
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
providerOverrides,
};
}
@@ -7,9 +7,8 @@ import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import dotenv from "dotenv";
import { isCommandAvailableSync } from "../../utils/executable.js";
import { isCommandAvailable } from "../../utils/executable.js";
// Load .env.test eagerly so isProviderAvailable() has credentials at collection time.
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
dotenv.config({ path: resolve(serverRoot, ".env.test"), override: true });
@@ -64,6 +63,7 @@ export const agentConfigs = {
} as const satisfies Record<string, AgentTestConfig>;
export type AgentProvider = keyof typeof agentConfigs;
const providerAvailabilityCache = new Map<AgentProvider, Promise<boolean>>();
/**
* Get test config for creating an agent with full permissions (no prompts).
@@ -101,31 +101,42 @@ export function getAskModeConfig(provider: AgentProvider) {
* This MUST be a function (not a const) so process.env is read at call time,
* after dotenv has injected the test credentials.
*/
export function isProviderAvailable(provider: AgentProvider): boolean {
switch (provider) {
case "claude":
return (
isCommandAvailableSync("claude") &&
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
);
case "codex":
return (
isCommandAvailableSync("codex") &&
(existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY))
);
case "copilot":
return isCommandAvailableSync("copilot");
case "opencode":
return isCommandAvailableSync("opencode");
case "pi":
return (
isCommandAvailableSync(process.env.PI_ACP_PI_COMMAND ?? "pi") &&
(Boolean(process.env.OPENAI_API_KEY) ||
Boolean(process.env.ANTHROPIC_API_KEY) ||
Boolean(process.env.OPENROUTER_API_KEY) ||
existsSync(join(homedir(), ".pi", "agent", "auth.json")))
);
export function isProviderAvailable(provider: AgentProvider): Promise<boolean> {
const cached = providerAvailabilityCache.get(provider);
if (cached) {
return cached;
}
const availability = (async (): Promise<boolean> => {
switch (provider) {
case "claude":
return (
(await isCommandAvailable("claude")) &&
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
);
case "codex":
return (
(await isCommandAvailable("codex")) &&
(existsSync(join(homedir(), ".codex", "auth.json")) ||
Boolean(process.env.OPENAI_API_KEY))
);
case "copilot":
return await isCommandAvailable("copilot");
case "opencode":
return await isCommandAvailable("opencode");
case "pi":
return (
(await isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) &&
(Boolean(process.env.OPENAI_API_KEY) ||
Boolean(process.env.ANTHROPIC_API_KEY) ||
Boolean(process.env.OPENROUTER_API_KEY) ||
existsSync(join(homedir(), ".pi", "agent", "auth.json")))
);
}
})();
providerAvailabilityCache.set(provider, availability);
return availability;
}
/**
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -18,96 +18,106 @@ function compactText(value: string): string {
}
describe("daemon E2E (real claude) - autonomous wake simple", () => {
test.runIf(isProviderAvailable("claude"))(
"hello + background sleep returns idle, then wakes once on completion",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("hello + background sleep returns idle, then wakes once on completion", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-autonomous-simple-real" },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-autonomous-simple-real" },
});
const agent = await client.createAgent({
cwd,
title: "claude-autonomous-simple-real",
...getFullAccessConfig("claude"),
});
const agent = await client.createAgent({
cwd,
title: "claude-autonomous-simple-real",
...getFullAccessConfig("claude"),
});
const autonomousWakeToken = `AUTONOMOUS_SIMPLE_${Date.now().toString(36)}`;
await client.sendMessage(
agent.id,
[
"Hello.",
"Use the Bash tool with run_in_background.",
"Run exactly: sleep 5",
"Do not wait for the task result.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
);
const autonomousWakeToken = `AUTONOMOUS_SIMPLE_${Date.now().toString(36)}`;
await client.sendMessage(
agent.id,
[
"Hello.",
"Use the Bash tool with run_in_background.",
"Run exactly: sleep 5",
"Do not wait for the task result.",
"Reply immediately with exactly: SPAWNED",
`When the background task completes later, reply with exactly: ${autonomousWakeToken}`,
].join(" "),
);
const firstFinish = await client.waitForFinish(agent.id, 240_000);
expect(firstFinish.status).toBe("idle");
const firstFinish = await client.waitForFinish(agent.id, 240_000);
expect(firstFinish.status).toBe("idle");
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const idleAssistantText = timelineAtIdle.entries
.filter(
(
entry,
): entry is typeof entry & {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
expect(compactText(idleAssistantText)).toContain("spawned");
expect(
timelineAtIdle.entries.some(
(entry) => entry.item.type === "tool_call" && entry.item.name === "Bash",
),
).toBe(true);
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
});
const idleAssistantText = timelineAtIdle.entries
.filter(
(
entry,
): entry is typeof entry & {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
expect(compactText(idleAssistantText)).toContain("spawned");
expect(
timelineAtIdle.entries.some(
(entry) => entry.item.type === "tool_call" && entry.item.name === "Bash",
),
).toBe(true);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000,
);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000,
);
const autonomousFinish = await client.waitForFinish(agent.id, 120_000);
expect(autonomousFinish.status).toBe("idle");
const autonomousFinish = await client.waitForFinish(agent.id, 120_000);
expect(autonomousFinish.status).toBe("idle");
const finalTimeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
});
const finalAssistantText = finalTimeline.entries
.filter(
(
entry,
): entry is typeof entry & {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
expect(compactText(finalAssistantText)).toContain(autonomousWakeToken.toLowerCase());
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
},
420_000,
);
const finalTimeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const finalAssistantText = finalTimeline.entries
.filter(
(
entry,
): entry is typeof entry & {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
expect(compactText(finalAssistantText)).toContain(autonomousWakeToken.toLowerCase());
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
});
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -14,52 +14,60 @@ function tmpCwd(): string {
}
describe("daemon E2E (real claude) - model resolution on init", () => {
test.runIf(isProviderAvailable("claude"))(
"runtimeInfo.model is set as soon as the agent starts running, not after turn completes",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("runtimeInfo.model is set as soon as the agent starts running, not after turn completes", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "model-init-test" },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "model-init-test" },
});
const modelsResult = await client.listProviderModels("claude", { cwd });
const catalogModelIds = new Set(modelsResult.models.map((m) => m.id));
const modelsResult = await client.listProviderModels("claude", { cwd });
const catalogModelIds = new Set(modelsResult.models.map((m) => m.id));
const agent = await client.createAgent({
provider: "claude",
cwd,
model: "haiku",
title: "model-init-test",
});
const agent = await client.createAgent({
provider: "claude",
cwd,
model: "haiku",
title: "model-init-test",
});
await client.sendMessage(agent.id, "Reply with exactly: OK");
await client.sendMessage(agent.id, "Reply with exactly: OK");
const snapshot = await client.waitForAgentUpsert(
agent.id,
(s) => s.runtimeInfo?.model != null,
60_000,
);
const snapshot = await client.waitForAgentUpsert(
agent.id,
(s) => s.runtimeInfo?.model != null,
60_000,
);
expect(snapshot.runtimeInfo?.model).toBeTruthy();
expect(catalogModelIds.has(snapshot.runtimeInfo!.model!)).toBe(true);
expect(snapshot.status).toBe("running");
expect(snapshot.runtimeInfo?.model).toBeTruthy();
expect(catalogModelIds.has(snapshot.runtimeInfo!.model!)).toBe(true);
expect(snapshot.status).toBe("running");
await client.waitForFinish(agent.id, 60_000);
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
120_000,
);
await client.waitForFinish(agent.id, 60_000);
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 120_000);
});
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -14,50 +14,58 @@ function tmpCwd(): string {
}
describe("daemon E2E (real claude) - runtime model reconciliation", () => {
test.runIf(isProviderAvailable("claude"))(
"normalizes runtime model to a model ID exposed by the provider catalog",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("normalizes runtime model to a model ID exposed by the provider catalog", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-runtime-model-reconcile" },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-runtime-model-reconcile" },
});
const modelsResult = await client.listProviderModels("claude", { cwd });
expect(modelsResult.error).toBeNull();
expect(modelsResult.models.length).toBeGreaterThan(0);
const modelIds = new Set(modelsResult.models.map((model) => model.id));
const modelsResult = await client.listProviderModels("claude", { cwd });
expect(modelsResult.error).toBeNull();
expect(modelsResult.models.length).toBeGreaterThan(0);
const modelIds = new Set(modelsResult.models.map((model) => model.id));
const agent = await client.createAgent({
provider: "claude",
cwd,
title: "claude-runtime-model-reconcile",
});
const agent = await client.createAgent({
provider: "claude",
cwd,
title: "claude-runtime-model-reconcile",
});
await client.sendMessage(agent.id, "Reply with exactly: OK");
const finish = await client.waitForFinish(agent.id, 180_000);
expect(finish.status).toBe("idle");
await client.sendMessage(agent.id, "Reply with exactly: OK");
const finish = await client.waitForFinish(agent.id, 180_000);
expect(finish.status).toBe("idle");
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot).not.toBeNull();
const runtimeModelId = snapshot?.agent.runtimeInfo?.model ?? null;
expect(typeof runtimeModelId).toBe("string");
expect(runtimeModelId).not.toBe("");
expect(modelIds.has(runtimeModelId as string)).toBe(true);
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
300_000,
);
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot).not.toBeNull();
const runtimeModelId = snapshot?.agent.runtimeInfo?.model ?? null;
expect(typeof runtimeModelId).toBe("string");
expect(runtimeModelId).not.toBe("");
expect(modelIds.has(runtimeModelId as string)).toBe(true);
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
});
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest";
import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -29,55 +29,63 @@ async function createHarness(): Promise<{
}
describe("daemon E2E (real opencode) - custom agent discovery", () => {
test.runIf(isProviderAvailable("opencode"))(
"custom agents defined in opencode.json appear in availableModes",
async () => {
const cwd = tmpCwd();
writeFileSync(
path.join(cwd, "opencode.json"),
JSON.stringify({
agent: {
"paseo-e2e-custom": {
description: "Custom agent for Paseo daemon E2E test",
mode: "primary",
},
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("custom agents defined in opencode.json appear in availableModes", async () => {
const cwd = tmpCwd();
writeFileSync(
path.join(cwd, "opencode.json"),
JSON.stringify({
agent: {
"paseo-e2e-custom": {
description: "Custom agent for Paseo daemon E2E test",
mode: "primary",
},
}),
},
}),
);
const { client, daemon } = await createHarness();
try {
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode custom agent discovery",
});
// Wait for modes to be populated (they arrive after session init)
const snapshot = await client.waitForAgentUpsert(
agent.id,
(s) => s.availableModes.length > 0,
30_000,
);
const { client, daemon } = await createHarness();
expect(snapshot.availableModes.some((m) => m.id === "build")).toBe(true);
expect(snapshot.availableModes.some((m) => m.id === "plan")).toBe(true);
try {
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode custom agent discovery",
});
const custom = snapshot.availableModes.find((m) => m.id === "paseo-e2e-custom");
expect(custom).toBeDefined();
expect(custom!.description).toBe("Custom agent for Paseo daemon E2E test");
// Wait for modes to be populated (they arrive after session init)
const snapshot = await client.waitForAgentUpsert(
agent.id,
(s) => s.availableModes.length > 0,
30_000,
);
expect(snapshot.availableModes.some((m) => m.id === "build")).toBe(true);
expect(snapshot.availableModes.some((m) => m.id === "plan")).toBe(true);
const custom = snapshot.availableModes.find((m) => m.id === "paseo-e2e-custom");
expect(custom).toBeDefined();
expect(custom!.description).toBe("Custom agent for Paseo daemon E2E test");
// System agents should not leak through
expect(snapshot.availableModes.some((m) => m.id === "compaction")).toBe(false);
expect(snapshot.availableModes.some((m) => m.id === "summary")).toBe(false);
expect(snapshot.availableModes.some((m) => m.id === "title")).toBe(false);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
60_000,
);
// System agents should not leak through
expect(snapshot.availableModes.some((m) => m.id === "compaction")).toBe(false);
expect(snapshot.availableModes.some((m) => m.id === "summary")).toBe(false);
expect(snapshot.availableModes.some((m) => m.id === "title")).toBe(false);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 60_000);
});
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -29,85 +29,89 @@ async function createHarness(): Promise<{
}
describe("daemon E2E (real opencode) - initial prompt wait", () => {
test.runIf(isProviderAvailable("opencode"))(
"waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
let canRun = false;
try {
const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode initial prompt wait regression",
model: "opencode/big-pickle",
initialPrompt: "Reply with exactly: BIG_PICKLE_OK",
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
const finish = await client.waitForFinish(agent.id, 60_000);
expect(finish.status).toBe("idle");
expect(finish.lastMessage).toContain("BIG_PICKLE_OK");
test("waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("idle");
try {
const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
const assistantMessages = timeline.entries.filter(
(entry) => entry.item.type === "assistant_message",
);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode initial prompt wait regression",
model: "opencode/big-pickle",
initialPrompt: "Reply with exactly: BIG_PICKLE_OK",
});
expect(assistantMessages.length).toBeGreaterThan(0);
expect(assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK"))).toBe(
true,
);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
90_000,
);
const finish = await client.waitForFinish(agent.id, 60_000);
expect(finish.status).toBe("idle");
expect(finish.lastMessage).toContain("BIG_PICKLE_OK");
test.runIf(isProviderAvailable("opencode"))(
"waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("idle");
try {
const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
const assistantMessages = timeline.entries.filter(
(entry) => entry.item.type === "assistant_message",
);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode zai fatal retry regression",
model: "zai/glm-5.1",
initialPrompt: "Reply with exactly: GLM_51_OK",
});
expect(assistantMessages.length).toBeGreaterThan(0);
expect(assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK"))).toBe(
true,
);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 90_000);
const finish = await client.waitForFinish(agent.id, 60_000);
expect(finish.status).toBe("error");
expect((finish.error ?? "").toLowerCase()).toMatch(
/insufficient balance|resource package|recharge/,
);
test("waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
90_000,
);
try {
const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode zai fatal retry regression",
model: "zai/glm-5.1",
initialPrompt: "Reply with exactly: GLM_51_OK",
});
const finish = await client.waitForFinish(agent.id, 60_000);
expect(finish.status).toBe("error");
expect((finish.error ?? "").toLowerCase()).toMatch(
/insufficient balance|resource package|recharge/,
);
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 90_000);
});
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -29,33 +29,41 @@ async function createHarness(): Promise<{
}
describe("daemon E2E (real opencode) - invalid model handling", () => {
test.runIf(isProviderAvailable("opencode"))(
"initial prompt with a nonexistent OpenCode model fails instead of hanging forever",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
let canRun = false;
try {
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode invalid model regression",
model: "opencode/adklasldkdas",
initialPrompt: "hello",
});
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
const finish = await client.waitForFinish(agent.id, 30_000);
expect(finish.status).toBe("error");
expect(finish.error).toBeTruthy();
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
60_000,
);
test("initial prompt with a nonexistent OpenCode model fails instead of hanging forever", async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
try {
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode invalid model regression",
model: "opencode/adklasldkdas",
initialPrompt: "hello",
});
const finish = await client.waitForFinish(agent.id, 30_000);
expect(finish.status).toBe("error");
expect(finish.error).toBeTruthy();
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 60_000);
});
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest";
import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -39,102 +39,106 @@ async function createHarness(): Promise<{
}
describe("daemon E2E (real opencode) - plan mode and clarifying questions", () => {
test.runIf(isProviderAvailable("opencode"))(
"surfaces clarifying questions as pending permissions",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
let canRun = false;
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode question regression",
model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]),
modeId: "plan",
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
await client.sendMessage(
agent.id,
[
"Use the question tool/feature to ask me exactly one clarifying question.",
"Ask this exact question: What kind of project should the plan cover?",
"Wait for my answer before doing anything else.",
].join(" "),
);
test("surfaces clarifying questions as pending permissions", async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const snapshotWithQuestion = await client.waitForAgentUpsert(
agent.id,
(snapshot) => (snapshot.pendingPermissions?.[0]?.kind ?? null) === "question",
30_000,
);
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
expect(snapshotWithQuestion.pendingPermissions?.length).toBeGreaterThan(0);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode question regression",
model: pickOpenCodeModel(modelList.models, ["minimax-m2.5-free", "minimax", "free"]),
modeId: "plan",
});
const permission = snapshotWithQuestion.pendingPermissions?.[0];
expect(permission).toBeTruthy();
expect(permission?.kind).toBe("question");
expect(Array.isArray(permission?.input?.questions)).toBe(true);
await client.sendMessage(
agent.id,
[
"Use the question tool/feature to ask me exactly one clarifying question.",
"Ask this exact question: What kind of project should the plan cover?",
"Wait for my answer before doing anything else.",
].join(" "),
);
const firstQuestion = permission?.input?.questions?.[0] as { header?: string } | undefined;
expect(firstQuestion?.header).toBeTruthy();
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
180_000,
);
const snapshotWithQuestion = await client.waitForAgentUpsert(
agent.id,
(snapshot) => (snapshot.pendingPermissions?.[0]?.kind ?? null) === "question",
30_000,
);
test.runIf(isProviderAvailable("opencode"))(
"plan mode stays read-only through the daemon path",
async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "plan-mode-output.txt");
const { client, daemon } = await createHarness();
expect(snapshotWithQuestion.pendingPermissions?.length).toBeGreaterThan(0);
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
const permission = snapshotWithQuestion.pendingPermissions?.[0];
expect(permission).toBeTruthy();
expect(permission?.kind).toBe("question");
expect(Array.isArray(permission?.input?.questions)).toBe(true);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode plan mode regression",
model: pickOpenCodeModel(modelList.models),
modeId: "plan",
});
const firstQuestion = permission?.input?.questions?.[0] as { header?: string } | undefined;
expect(firstQuestion?.header).toBeTruthy();
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 180_000);
await client.sendMessage(
agent.id,
"Create a file named plan-mode-output.txt in the current directory containing exactly hello.",
);
test("plan mode stays read-only through the daemon path", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "plan-mode-output.txt");
const { client, daemon } = await createHarness();
const state = await client.waitForFinish(agent.id, 180_000);
expect(state.status).toBe("idle");
expect(existsSync(filePath)).toBe(false);
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
const assistantText = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => entry.item.text)
.join(" ");
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode plan mode regression",
model: pickOpenCodeModel(modelList.models),
modeId: "plan",
});
expect(assistantText.toLowerCase()).toContain("plan");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
240_000,
);
await client.sendMessage(
agent.id,
"Create a file named plan-mode-output.txt in the current directory containing exactly hello.",
);
const state = await client.waitForFinish(agent.id, 180_000);
expect(state.status).toBe("idle");
expect(existsSync(filePath)).toBe(false);
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
const assistantText = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => entry.item.text)
.join(" ");
expect(assistantText.toLowerCase()).toContain("plan");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 240_000);
});
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -281,142 +281,146 @@ async function createHarness(): Promise<{
}
describe("daemon E2E (real opencode) - send while working and interrupt", () => {
test.runIf(isProviderAvailable("opencode"))(
"send_message while sleep tool call is running starts a clean replacement turn",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const collector = createMessageCollector(client);
const followUpToken = "OPENCODE_SEND_WHILE_WORKING_OK";
let canRun = false;
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
});
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode send while working",
model: pickOpenCodeModel(modelList.models),
modeId: "default",
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
await client.sendMessage(
agent.id,
[
"Use the Bash tool.",
"Run exactly: sleep 60",
"Do not run it in the background.",
"Do not do anything after starting the command.",
].join(" "),
);
test("send_message while sleep tool call is running starts a clean replacement turn", async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const collector = createMessageCollector(client);
const followUpToken = "OPENCODE_SEND_WHILE_WORKING_OK";
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
90_000,
);
await waitForRunningBashToolCall(client, collector, agent.id);
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
collector.clear();
await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode send while working",
model: pickOpenCodeModel(modelList.models),
modeId: "default",
});
const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000);
expect(finish.status).toBe("idle");
await client.sendMessage(
agent.id,
[
"Use the Bash tool.",
"Run exactly: sleep 60",
"Do not run it in the background.",
"Do not do anything after starting the command.",
].join(" "),
);
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,
);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
90_000,
);
await waitForRunningBashToolCall(client, collector, agent.id);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 });
const assistantTexts = getTimelineAssistantTexts(timeline);
expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true);
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false);
} finally {
collector.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
360_000,
);
collector.clear();
await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`);
test.runIf(isProviderAvailable("opencode"))(
"explicit interrupt during sleep tool call still allows the next turn to complete",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const collector = createMessageCollector(client);
const followUpToken = "OPENCODE_INTERRUPT_FOLLOWUP_OK";
const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000);
expect(finish.status).toBe("idle");
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
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,
);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode explicit interrupt",
model: pickOpenCodeModel(modelList.models),
modeId: "default",
});
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 160 });
const assistantTexts = getTimelineAssistantTexts(timeline);
expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true);
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false);
} finally {
collector.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 360_000);
await client.sendMessage(
agent.id,
[
"Use the Bash tool.",
"Run exactly: sleep 60",
"Do not run it in the background.",
"Do not do anything after starting the command.",
].join(" "),
);
test("explicit interrupt during sleep tool call still allows the next turn to complete", async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
const collector = createMessageCollector(client);
const followUpToken = "OPENCODE_INTERRUPT_FOLLOWUP_OK";
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
90_000,
);
await waitForRunningBashToolCall(client, collector, agent.id);
try {
const modelList = await client.listProviderModels("opencode");
expect(modelList.models.length).toBeGreaterThan(0);
await client.cancelAgent(agent.id);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle" || snapshot.status === "error",
90_000,
);
const interruptedToolCall = await waitForSleepToolCallTerminal(client, agent.id, 45_000);
expect(interruptedToolCall.status).toBe("failed");
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode explicit interrupt",
model: pickOpenCodeModel(modelList.models),
modeId: "default",
});
collector.clear();
await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`);
await client.sendMessage(
agent.id,
[
"Use the Bash tool.",
"Run exactly: sleep 60",
"Do not run it in the background.",
"Do not do anything after starting the command.",
].join(" "),
);
const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000);
expect(finish.status).toBe("idle");
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
90_000,
);
await waitForRunningBashToolCall(client, collector, agent.id);
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_SNIPPET)),
).toBe(false);
await client.cancelAgent(agent.id);
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle" || snapshot.status === "error",
90_000,
);
const interruptedToolCall = await waitForSleepToolCallTerminal(client, agent.id, 45_000);
expect(interruptedToolCall.status).toBe("failed");
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 });
const assistantTexts = getTimelineAssistantTexts(timeline);
expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true);
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false);
} finally {
collector.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
360_000,
);
collector.clear();
await client.sendMessage(agent.id, `Reply with exactly: ${followUpToken}`);
const finish = await waitForIdleResolvingPermissions(client, agent.id, 240_000);
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_SNIPPET))).toBe(
false,
);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 200 });
const assistantTexts = getTimelineAssistantTexts(timeline);
expect(assistantTexts.some((text) => text.includes(followUpToken))).toBe(true);
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
expect(assistantTexts.some((text) => text.includes(SYSTEM_ERROR_SNIPPET))).toBe(false);
} finally {
collector.unsubscribe();
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 360_000);
});
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -16,61 +16,69 @@ function tmpCwd(): string {
}
describe("daemon E2E (real pi)", () => {
test.runIf(isProviderAvailable("pi"))(
"smoke test with thinking option configured separately from modes",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { pi: new PiACPAgentClient({ logger }) },
logger,
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("pi");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("smoke test with thinking option configured separately from modes", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { pi: new PiACPAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "pi-real-smoke" },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "pi-real-smoke" },
});
const agent = await client.createAgent({
cwd,
title: "pi-real-smoke",
provider: "pi",
thinkingOptionId: "medium",
});
const agent = await client.createAgent({
cwd,
title: "pi-real-smoke",
provider: "pi",
thinkingOptionId: "medium",
});
await client.sendMessage(agent.id, "Reply with exactly: PINEAPPLE");
await client.sendMessage(agent.id, "Reply with exactly: PINEAPPLE");
const finish = await client.waitForFinish(agent.id, 240_000);
expect(finish.status).toBe("idle");
expect(finish.final?.persistence).toBeTruthy();
expect(finish.final?.persistence?.provider).toBe("pi");
expect(finish.final?.persistence?.sessionId).toBeTruthy();
const finish = await client.waitForFinish(agent.id, 240_000);
expect(finish.status).toBe("idle");
expect(finish.final?.persistence).toBeTruthy();
expect(finish.final?.persistence?.provider).toBe("pi");
expect(finish.final?.persistence?.sessionId).toBeTruthy();
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const assistantText = timeline.entries
.filter(
(
entry,
): entry is typeof entry & {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const assistantText = timeline.entries
.filter(
(
entry,
): entry is typeof entry & {
item: { type: "assistant_message"; text: string };
} => entry.item.type === "assistant_message",
)
.map((entry) => entry.item.text)
.join("\n");
expect(assistantText.replace(/\s+/g, "")).toContain("PINEAPPLE");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
},
420_000,
);
expect(assistantText.replace(/\s+/g, "")).toContain("PINEAPPLE");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
});
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -14,53 +14,62 @@ function tmpCwd(): string {
}
describe("daemon E2E (real claude) - rewind user message dedupe", () => {
test.runIf(isProviderAvailable("claude"))(
"emits /rewind user message once in persisted timeline",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("emits /rewind user message once in persisted timeline", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "rewind-user-message-dedupe" },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "rewind-user-message-dedupe" },
});
const agent = await client.createAgent({
cwd,
title: "rewind-user-message-dedupe-real-claude",
...getFullAccessConfig("claude"),
});
const agent = await client.createAgent({
cwd,
title: "rewind-user-message-dedupe-real-claude",
...getFullAccessConfig("claude"),
});
await client.sendMessage(agent.id, "Reply with exactly: READY");
const initialResult = await client.waitForFinish(agent.id, 180_000);
expect(initialResult.status).toBe("idle");
await client.sendMessage(agent.id, "Reply with exactly: READY");
const initialResult = await client.waitForFinish(agent.id, 180_000);
expect(initialResult.status).toBe("idle");
await client.sendMessage(agent.id, "/rewind");
const rewindResult = await client.waitForFinish(agent.id, 180_000);
expect(rewindResult.status).not.toBe("timeout");
await client.sendMessage(agent.id, "/rewind");
const rewindResult = await client.waitForFinish(agent.id, 180_000);
expect(rewindResult.status).not.toBe("timeout");
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
});
const rewindUserMessages = timeline.entries.filter(
(entry) => entry.item.type === "user_message" && entry.item.text.trim() === "/rewind",
);
const rewindUserMessages = timeline.entries.filter(
(entry) => entry.item.type === "user_message" && entry.item.text.trim() === "/rewind",
);
expect(rewindUserMessages).toHaveLength(1);
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
300_000,
);
expect(rewindUserMessages).toHaveLength(1);
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
});
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest";
import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -168,111 +168,119 @@ async function waitForRunningToolCall(
}
describe("daemon E2E (real claude) - send message during tool call", () => {
test.runIf(isProviderAvailable("claude"))(
"sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "primary" } });
const agent = await client.createAgent({
cwd,
title: "tool-interrupt-repro",
...getFullAccessConfig("claude"),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const collector = createMessageCollector(client);
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "primary" } });
// Step 1: Ask Claude to run sleep 60 in the foreground
await client.sendMessage(
agent.id,
[
"Use the Bash tool.",
"Run exactly: sleep 60",
"Do not use a background task.",
"Do not do anything after starting the command.",
].join(" "),
);
const agent = await client.createAgent({
cwd,
title: "tool-interrupt-repro",
...getFullAccessConfig("claude"),
// Step 2: Wait for the agent to be running
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
60_000,
);
// Step 3: Wait for a tool call to appear as "running" in the stream
await waitForRunningToolCall(client, collector, agent.id);
collector.clear();
// Step 4: Send a second message while the tool call is still running
await client.sendMessage(agent.id, "Reply with exactly: INTERRUPT_RECEIVED");
// Step 5: Wait for the agent to finish — this is the critical assertion.
// If the bug is present, the agent will stop and never start a new turn.
const finish = await client.waitForFinish(agent.id, 120_000);
const postSendMessages = [...collector.messages];
const postSendAssistantTexts = getAssistantTexts(postSendMessages, agent.id);
const postSendStatuses = getAgentStatuses(postSendMessages, agent.id);
const statusesBeforeFirstAssistant = getStatusesBeforeFirstAssistant(
postSendMessages,
agent.id,
);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
if (finish.status !== "idle") {
const snapshot = await client.fetchAgent(agent.id);
throw new Error(
`Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(postSendAssistantTexts)} turnStarted=${countTurnStarted(postSendMessages, agent.id)} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
);
}
// Replacement should create exactly one new turn. A second turn_started here
// means the reply got displaced onto a later autonomous wake.
expect(countTurnStarted(postSendMessages, agent.id)).toBe(1);
// The replacement path should not surface as agent error state.
expect(postSendStatuses).not.toContain("error");
// The agent should not flash idle before the replacement produces visible output.
expect(statusesBeforeFirstAssistant).not.toContain("idle");
expect(statusesBeforeFirstAssistant).not.toContain("error");
// Step 6: Verify the agent actually responded to our second message
const assistantTexts = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => {
const item = entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>;
return item.text;
});
const collector = createMessageCollector(client);
// No system error messages should leak into the timeline
const hasSystemError = assistantTexts.some((text) => text.includes("[System Error]"));
expect(hasSystemError).toBe(false);
expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
// Step 1: Ask Claude to run sleep 60 in the foreground
await client.sendMessage(
agent.id,
[
"Use the Bash tool.",
"Run exactly: sleep 60",
"Do not use a background task.",
"Do not do anything after starting the command.",
].join(" "),
);
const responded = assistantTexts.some((text) =>
text.toUpperCase().includes("INTERRUPT_RECEIVED"),
);
expect(responded).toBe(true);
// Step 2: Wait for the agent to be running
await client.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
60_000,
);
// Step 3: Wait for a tool call to appear as "running" in the stream
await waitForRunningToolCall(client, collector, agent.id);
collector.clear();
// Step 4: Send a second message while the tool call is still running
await client.sendMessage(agent.id, "Reply with exactly: INTERRUPT_RECEIVED");
// Step 5: Wait for the agent to finish — this is the critical assertion.
// If the bug is present, the agent will stop and never start a new turn.
const finish = await client.waitForFinish(agent.id, 120_000);
const postSendMessages = [...collector.messages];
const postSendAssistantTexts = getAssistantTexts(postSendMessages, agent.id);
const postSendStatuses = getAgentStatuses(postSendMessages, agent.id);
const statusesBeforeFirstAssistant = getStatusesBeforeFirstAssistant(
postSendMessages,
agent.id,
);
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
if (finish.status !== "idle") {
const snapshot = await client.fetchAgent(agent.id);
throw new Error(
`Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(postSendAssistantTexts)} turnStarted=${countTurnStarted(postSendMessages, agent.id)} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
);
}
// Replacement should create exactly one new turn. A second turn_started here
// means the reply got displaced onto a later autonomous wake.
expect(countTurnStarted(postSendMessages, agent.id)).toBe(1);
// The replacement path should not surface as agent error state.
expect(postSendStatuses).not.toContain("error");
// The agent should not flash idle before the replacement produces visible output.
expect(statusesBeforeFirstAssistant).not.toContain("idle");
expect(statusesBeforeFirstAssistant).not.toContain("error");
// Step 6: Verify the agent actually responded to our second message
const assistantTexts = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => {
const item = entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>;
return item.text;
});
// No system error messages should leak into the timeline
const hasSystemError = assistantTexts.some((text) => text.includes("[System Error]"));
expect(hasSystemError).toBe(false);
expect(postSendAssistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
const responded = assistantTexts.some((text) =>
text.toUpperCase().includes("INTERRUPT_RECEIVED"),
);
expect(responded).toBe(true);
collector.unsubscribe();
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
300_000,
);
collector.unsubscribe();
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
});
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest";
import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -15,109 +15,117 @@ function tmpCwd(): string {
}
describe("daemon E2E (real claude) - send while running recovery", () => {
test.runIf(isProviderAvailable("claude"))(
"clears input processing when the interrupt transition is missed",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("claude");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("clears input processing when the interrupt transition is missed", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
});
const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await primary.connect();
await secondary.connect();
await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } });
await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } });
const agent = await primary.createAgent({
cwd,
title: "stuck-repro-real-claude",
...getFullAccessConfig("claude"),
});
const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await primary.sendMessage(
agent.id,
"Run bash command sleep 30, wait for completion, then reply done.",
);
await primary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
60_000,
);
let isProcessing = true;
let previousIsRunning = true;
let latestUpdatedAt = Date.now();
await primary.close();
await secondary.sendMessage(agent.id, "Reply with exactly: state saved");
await secondary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
60_000,
);
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await primary.connect();
await secondary.connect();
await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } });
await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } });
await reconnected.connect();
const applySnapshot = (
snapshot: Parameters<typeof applyAgentInputProcessingTransition>[0]["snapshot"],
) => {
const next = applyAgentInputProcessingTransition({
snapshot,
currentIsProcessing: isProcessing,
previousIsRunning,
latestUpdatedAt,
});
isProcessing = next.isProcessing;
previousIsRunning = next.previousIsRunning;
latestUpdatedAt = next.latestUpdatedAt;
};
const agent = await primary.createAgent({
cwd,
title: "stuck-repro-real-claude",
...getFullAccessConfig("claude"),
reconnected.on("agent_update", (message) => {
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
return;
}
if (message.payload.agent.id !== agent.id) {
return;
}
applySnapshot(message.payload.agent);
});
await primary.sendMessage(
agent.id,
"Run bash command sleep 30, wait for completion, then reply done.",
);
await primary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
60_000,
);
let isProcessing = true;
let previousIsRunning = true;
let latestUpdatedAt = Date.now();
await primary.close();
await secondary.sendMessage(agent.id, "Reply with exactly: state saved");
await secondary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
60_000,
);
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await reconnected.connect();
const applySnapshot = (
snapshot: Parameters<typeof applyAgentInputProcessingTransition>[0]["snapshot"],
) => {
const next = applyAgentInputProcessingTransition({
snapshot,
currentIsProcessing: isProcessing,
previousIsRunning,
latestUpdatedAt,
});
isProcessing = next.isProcessing;
previousIsRunning = next.previousIsRunning;
latestUpdatedAt = next.latestUpdatedAt;
};
reconnected.on("agent_update", (message) => {
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
return;
}
if (message.payload.agent.id !== agent.id) {
return;
}
applySnapshot(message.payload.agent);
});
const initial = await reconnected.fetchAgents({
subscribe: { subscriptionId: "reconnected" },
});
const hydratedSnapshot = initial.entries.find(
(candidate) => candidate.agent.id === agent.id,
)?.agent;
if (hydratedSnapshot) {
applySnapshot(hydratedSnapshot);
}
await secondary.waitForFinish(agent.id, 180_000);
const finalResult = await secondary.fetchAgent(agent.id);
if (finalResult) {
applySnapshot(finalResult.agent);
}
// Sending while running should clear processing even if reconnect misses the
// not-running -> running transition.
expect(isProcessing).toBe(false);
} finally {
await reconnected.close();
const initial = await reconnected.fetchAgents({
subscribe: { subscriptionId: "reconnected" },
});
const hydratedSnapshot = initial.entries.find(
(candidate) => candidate.agent.id === agent.id,
)?.agent;
if (hydratedSnapshot) {
applySnapshot(hydratedSnapshot);
}
await secondary.waitForFinish(agent.id, 180_000);
const finalResult = await secondary.fetchAgent(agent.id);
if (finalResult) {
applySnapshot(finalResult.agent);
}
// Sending while running should clear processing even if reconnect misses the
// not-running -> running transition.
expect(isProcessing).toBe(false);
} finally {
await secondary.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
await reconnected.close();
}
},
300_000,
);
} finally {
await secondary.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
});
@@ -1,4 +1,4 @@
import { describe, test, expect } from "vitest";
import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -15,106 +15,114 @@ function tmpCwd(): string {
}
describe("daemon E2E (real codex) - send while running recovery", () => {
test.runIf(isProviderAvailable("codex"))(
"clears input processing when the interrupt transition is missed",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { codex: new CodexAppServerAgentClient(logger) },
logger,
let canRun = false;
beforeAll(async () => {
canRun = await isProviderAvailable("codex");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("clears input processing when the interrupt transition is missed", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: { codex: new CodexAppServerAgentClient(logger) },
logger,
});
const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await primary.connect();
await secondary.connect();
await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } });
await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } });
const agent = await primary.createAgent({
cwd,
title: "stuck-repro-real-codex",
...getFullAccessConfig("codex"),
});
const primary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const secondary = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await primary.sendMessage(agent.id, "Run: sleep 5");
await primary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000,
);
let isProcessing = true;
let previousIsRunning = true;
let latestUpdatedAt = Date.now();
await primary.close();
await secondary.sendMessage(agent.id, "Reply with exactly: state saved");
await secondary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000,
);
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await primary.connect();
await secondary.connect();
await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } });
await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } });
await reconnected.connect();
const applySnapshot = (
snapshot: Parameters<typeof applyAgentInputProcessingTransition>[0]["snapshot"],
) => {
const next = applyAgentInputProcessingTransition({
snapshot,
currentIsProcessing: isProcessing,
previousIsRunning,
latestUpdatedAt,
});
isProcessing = next.isProcessing;
previousIsRunning = next.previousIsRunning;
latestUpdatedAt = next.latestUpdatedAt;
};
const agent = await primary.createAgent({
cwd,
title: "stuck-repro-real-codex",
...getFullAccessConfig("codex"),
reconnected.on("agent_update", (message) => {
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
return;
}
if (message.payload.agent.id !== agent.id) {
return;
}
applySnapshot(message.payload.agent);
});
await primary.sendMessage(agent.id, "Run: sleep 5");
await primary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000,
);
let isProcessing = true;
let previousIsRunning = true;
let latestUpdatedAt = Date.now();
await primary.close();
await secondary.sendMessage(agent.id, "Reply with exactly: state saved");
await secondary.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "running",
30_000,
);
const reconnected = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await reconnected.connect();
const applySnapshot = (
snapshot: Parameters<typeof applyAgentInputProcessingTransition>[0]["snapshot"],
) => {
const next = applyAgentInputProcessingTransition({
snapshot,
currentIsProcessing: isProcessing,
previousIsRunning,
latestUpdatedAt,
});
isProcessing = next.isProcessing;
previousIsRunning = next.previousIsRunning;
latestUpdatedAt = next.latestUpdatedAt;
};
reconnected.on("agent_update", (message) => {
if (message.type !== "agent_update" || message.payload.kind !== "upsert") {
return;
}
if (message.payload.agent.id !== agent.id) {
return;
}
applySnapshot(message.payload.agent);
});
const initial = await reconnected.fetchAgents({
subscribe: { subscriptionId: "reconnected" },
});
const hydratedSnapshot = initial.entries.find(
(candidate) => candidate.agent.id === agent.id,
)?.agent;
if (hydratedSnapshot) {
applySnapshot(hydratedSnapshot);
}
await secondary.waitForFinish(agent.id, 120_000);
const finalResult = await secondary.fetchAgent(agent.id);
if (finalResult) {
applySnapshot(finalResult.agent);
}
// Sending while running should clear processing even if reconnect misses the
// not-running -> running transition.
expect(isProcessing).toBe(false);
} finally {
await reconnected.close();
const initial = await reconnected.fetchAgents({
subscribe: { subscriptionId: "reconnected" },
});
const hydratedSnapshot = initial.entries.find(
(candidate) => candidate.agent.id === agent.id,
)?.agent;
if (hydratedSnapshot) {
applySnapshot(hydratedSnapshot);
}
await secondary.waitForFinish(agent.id, 120_000);
const finalResult = await secondary.fetchAgent(agent.id);
if (finalResult) {
applySnapshot(finalResult.agent);
}
// Sending while running should clear processing even if reconnect misses the
// not-running -> running transition.
expect(isProcessing).toBe(false);
} finally {
await secondary.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
await reconnected.close();
}
},
240_000,
);
} finally {
await secondary.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 240_000);
});
@@ -3,7 +3,11 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createDaemonTestContext, type DaemonTestContext, DaemonClient } from "../test-utils/index.js";
import {
createDaemonTestContext,
type DaemonTestContext,
DaemonClient,
} from "../test-utils/index.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import type { SessionOutboundMessage } from "../messages.js";
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -455,92 +455,94 @@ function createRealAgentClient(provider: AgentProvider, logger: pino.Logger): Ag
}
describe.each(allProviders)("daemon E2E (real %s) - UI action stress", (provider) => {
const shouldRun = isProviderAvailable(provider);
let shouldRun = false;
test.runIf(shouldRun)(
"normal UI submit path (idle sends) stays correct",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: {
[provider]: createRealAgentClient(provider, logger),
} as Partial<Record<AgentProvider, AgentClient>>,
logger,
beforeAll(async () => {
shouldRun = await isProviderAvailable(provider);
});
beforeEach((context) => {
if (!shouldRun) {
context.skip();
}
});
test("normal UI submit path (idle sends) stays correct", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: {
[provider]: createRealAgentClient(provider, logger),
} as Partial<Record<AgentProvider, AgentClient>>,
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `ui-stress-normal-${provider}` },
});
const agent = await client.createAgent({
cwd,
title: `uist-n-${provider}`,
...getFullAccessConfig(provider),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `ui-stress-normal-${provider}` },
});
await runUiScenario({
client,
agentId: agent.id,
scenario: buildNormalUsageScenario(provider, 7),
});
await client.deleteAgent(agent.id);
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
test("queued-send-now path is stable under overlap", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: {
[provider]: createRealAgentClient(provider, logger),
} as Partial<Record<AgentProvider, AgentClient>>,
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const scenarios = [
buildOverlapScenario(provider, 17, "last"),
buildOverlapScenario(provider, 31, "first"),
];
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `ui-stress-overlap-${provider}` },
});
for (const scenario of scenarios) {
const agent = await client.createAgent({
cwd,
title: `uist-n-${provider}`,
title: `uist-o-${provider}`,
...getFullAccessConfig(provider),
});
await runUiScenario({
client,
agentId: agent.id,
scenario: buildNormalUsageScenario(provider, 7),
scenario,
});
await client.deleteAgent(agent.id);
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
420_000,
);
test.runIf(shouldRun)(
"queued-send-now path is stable under overlap",
async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: {
[provider]: createRealAgentClient(provider, logger),
} as Partial<Record<AgentProvider, AgentClient>>,
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const scenarios = [
buildOverlapScenario(provider, 17, "last"),
buildOverlapScenario(provider, 31, "first"),
];
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `ui-stress-overlap-${provider}` },
});
for (const scenario of scenarios) {
const agent = await client.createAgent({
cwd,
title: `uist-o-${provider}`,
...getFullAccessConfig(provider),
});
await runUiScenario({
client,
agentId: agent.id,
scenario,
});
await client.deleteAgent(agent.id);
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
600_000,
);
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 600_000);
});
@@ -227,7 +227,10 @@ describe("DbAgentSnapshotStore", () => {
test("upsert is idempotent for the same agent ID", async () => {
const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" });
await store.upsert(createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" }), workspaceId);
await store.upsert(
createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" }),
workspaceId,
);
await store.upsert(
createStoredAgentRecord({
id: "agent-idempotent",
@@ -254,23 +257,29 @@ async function seedWorkspace(
database: PaseoDatabaseHandle,
options: { directory: string },
): Promise<number> {
const [project] = await database.db.insert(projects).values({
directory: options.directory,
kind: "git",
displayName: "project-1",
gitRemote: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
}).returning();
const [workspace] = await database.db.insert(workspaces).values({
projectId: project.id,
directory: options.directory,
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
}).returning();
const [project] = await database.db
.insert(projects)
.values({
directory: options.directory,
kind: "git",
displayName: "project-1",
gitRemote: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning();
const [workspace] = await database.db
.insert(workspaces)
.values({
projectId: project.id,
directory: options.directory,
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning();
return workspace.id;
}
@@ -116,11 +116,14 @@ export class DbAgentSnapshotStore implements AgentSnapshotStore {
async upsert(record: StoredAgentRecord, workspaceId: number): Promise<void>;
async upsert(record: StoredAgentRecord, workspaceId?: number): Promise<void> {
const nextWorkspaceId =
workspaceId ?? (await this.db
.select({ workspaceId: agentSnapshots.workspaceId })
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, record.id))
.limit(1))[0]?.workspaceId;
workspaceId ??
(
await this.db
.select({ workspaceId: agentSnapshots.workspaceId })
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, record.id))
.limit(1)
)[0]?.workspaceId;
if (nextWorkspaceId === undefined) {
throw new Error(`Workspace ID required for agent ${record.id}`);
}
@@ -159,13 +162,14 @@ export class DbAgentSnapshotStore implements AgentSnapshotStore {
const nextWorkspaceId =
typeof workspaceIdOrOptions === "number"
? workspaceIdOrOptions
: (await this.db
.select({ workspaceId: agentSnapshots.workspaceId })
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, agent.id))
.limit(1))[0]?.workspaceId;
const nextOptions =
typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions;
: (
await this.db
.select({ workspaceId: agentSnapshots.workspaceId })
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, agent.id))
.limit(1)
)[0]?.workspaceId;
const nextOptions = typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions;
const existing = await this.get(agent.id);
const hasTitleOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title");
@@ -104,7 +104,10 @@ describe("DbAgentTimelineStore", () => {
});
test("fetchCommitted tail returns the last N rows", async () => {
await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq)));
await store.bulkInsert(
"agent-1",
[1, 2, 3, 4, 5].map((seq) => createRow(seq)),
);
await expect(
store.fetchCommitted("agent-1", {
@@ -125,7 +128,10 @@ describe("DbAgentTimelineStore", () => {
});
test("fetchCommitted after-cursor returns rows after a given seq", async () => {
await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq)));
await store.bulkInsert(
"agent-1",
[1, 2, 3, 4, 5].map((seq) => createRow(seq)),
);
await expect(
store.fetchCommitted("agent-1", {
@@ -147,7 +153,10 @@ describe("DbAgentTimelineStore", () => {
});
test("fetchCommitted before-cursor returns rows before a given seq", async () => {
await store.bulkInsert("agent-1", [1, 2, 3, 4, 5].map((seq) => createRow(seq)));
await store.bulkInsert(
"agent-1",
[1, 2, 3, 4, 5].map((seq) => createRow(seq)),
);
await expect(
store.fetchCommitted("agent-1", {
@@ -123,12 +123,16 @@ export class DbAgentTimelineStore implements AgentTimelineStore {
? await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)))
.where(
and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)),
)
.orderBy(asc(agentTimelineRows.seq))
: await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)))
.where(
and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)),
)
.orderBy(asc(agentTimelineRows.seq))
.limit(limit)
).map(toTimelineRow);
@@ -159,13 +163,17 @@ export class DbAgentTimelineStore implements AgentTimelineStore {
? await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)))
.where(
and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)),
)
.orderBy(asc(agentTimelineRows.seq))
: (
await this.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)))
.where(
and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)),
)
.orderBy(desc(agentTimelineRows.seq))
.limit(limit)
).reverse()
@@ -5,7 +5,10 @@ import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
import { createPersistedProjectRecord, createPersistedWorkspaceRecord } from "../workspace-registry.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "../workspace-registry.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { DbProjectRegistry } from "./db-project-registry.js";
import { DbWorkspaceRegistry } from "./db-workspace-registry.js";
@@ -24,7 +27,9 @@ function createProjectRecord(input: Partial<PersistedProjectRecord> = {}): Persi
});
}
function createWorkspaceRecord(input: Partial<PersistedWorkspaceRecord> = {}): PersistedWorkspaceRecord {
function createWorkspaceRecord(
input: Partial<PersistedWorkspaceRecord> = {},
): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
@@ -33,11 +33,7 @@ export class DbWorkspaceRegistry implements WorkspaceRegistry {
}
async get(id: number): Promise<PersistedWorkspaceRecord | null> {
const rows = await this.db
.select()
.from(workspaces)
.where(eq(workspaces.id, id))
.limit(1);
const rows = await this.db.select().from(workspaces).where(eq(workspaces.id, id)).limit(1);
const row = rows[0];
return row ? toPersistedWorkspaceRecord(row) : null;
}
@@ -251,7 +251,8 @@ describe("importLegacyAgentSnapshots", () => {
});
const batchLogs = infoSpy.mock.calls.filter(
([context, message]) => message === "Importing agent snapshot batch" && typeof context === "object",
([context, message]) =>
message === "Importing agent snapshot batch" && typeof context === "object",
);
expect(batchLogs.length).toBeGreaterThan(1);
expect(batchLogs[0]?.[0]).toMatchObject({
@@ -82,7 +82,9 @@ export async function importLegacyAgentSnapshots(options: {
.select({ id: projects.id, directory: projects.directory, gitRemote: projects.gitRemote })
.from(projects)
.all();
const projectIdsByDirectory = new Map(projectRows.map((row) => [row.directory, row.id] as const));
const projectIdsByDirectory = new Map(
projectRows.map((row) => [row.directory, row.id] as const),
);
const projectIdsByRemote = new Map(
projectRows
.filter((row): row is typeof row & { gitRemote: string } => row.gitRemote !== null)
@@ -99,18 +101,21 @@ export async function importLegacyAgentSnapshots(options: {
const resolvedDirectory = gitInfo?.toplevel
? normalizeWorkspaceId(gitInfo.toplevel)
: normalizedDirectory;
const projectDisplayName = gitInfo?.metadata.projectDisplayName
?? resolvedDirectory.split(/[\\/]/).filter(Boolean).at(-1)
?? resolvedDirectory;
const projectDisplayName =
gitInfo?.metadata.projectDisplayName ??
resolvedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ??
resolvedDirectory;
const projectKind = gitInfo?.metadata.projectKind ?? "directory";
const gitRemote = gitInfo?.metadata.gitRemote ?? null;
const workspaceKind = gitInfo?.metadata.isWorktree ? "worktree" : "checkout";
const workspaceDisplayName = gitInfo?.metadata.workspaceDisplayName
?? normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1)
?? normalizedDirectory;
const workspaceDisplayName =
gitInfo?.metadata.workspaceDisplayName ??
normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ??
normalizedDirectory;
let projectId = projectIdsByDirectory.get(resolvedDirectory)
?? (gitRemote !== null ? projectIdsByRemote.get(gitRemote) : undefined);
let projectId =
projectIdsByDirectory.get(resolvedDirectory) ??
(gitRemote !== null ? projectIdsByRemote.get(gitRemote) : undefined);
if (projectId === undefined) {
const projectRow = tx
.insert(projects)
@@ -152,13 +157,18 @@ export async function importLegacyAgentSnapshots(options: {
if (workspaceId === undefined) {
return [];
}
const clampedRecord = (record.lastStatus === "running" || record.lastStatus === "initializing")
? { ...record, lastStatus: "closed" as const }
: record;
const clampedRecord =
record.lastStatus === "running" || record.lastStatus === "initializing"
? { ...record, lastStatus: "closed" as const }
: record;
return [toAgentSnapshotRowValues({ record: clampedRecord, workspaceId })];
});
const totalBatches = Math.ceil(rows.length / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT);
for (let startIndex = 0; startIndex < rows.length; startIndex += MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) {
for (
let startIndex = 0;
startIndex < rows.length;
startIndex += MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT
) {
const batch = rows.slice(startIndex, startIndex + MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT);
const batchNum = Math.floor(startIndex / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) + 1;
const rowsProcessed = startIndex + batch.length;
@@ -181,7 +191,10 @@ export async function importLegacyAgentSnapshots(options: {
};
}
async function readLegacyAgentRecords(baseDir: string, logger: Logger): Promise<{
async function readLegacyAgentRecords(
baseDir: string,
logger: Logger,
): Promise<{
records: StoredAgentRecord[];
skippedCount: number;
}> {
@@ -98,9 +98,7 @@ describe("legacy import from real ~/.paseo data", () => {
const paseoProject = paseoProjects[0]!;
// 4. All paseo workspaces (worktrees + main checkout + subdirs) should be under that one project
const paseoWorkspaces = allWorkspaces.filter(
(w) => w.projectId === paseoProject.id,
);
const paseoWorkspaces = allWorkspaces.filter((w) => w.projectId === paseoProject.id);
console.log(`Paseo project id=${paseoProject.id}, directory=${paseoProject.directory}`);
console.log(`Paseo workspaces: ${paseoWorkspaces.length}`);
@@ -108,9 +106,7 @@ describe("legacy import from real ~/.paseo data", () => {
expect(paseoWorkspaces.length).toBeGreaterThanOrEqual(10);
// 5. Subdirectory workspaces (packages/server, packages/app) should be under the same project
const subdirWorkspaces = paseoWorkspaces.filter((w) =>
w.directory.includes("/packages/"),
);
const subdirWorkspaces = paseoWorkspaces.filter((w) => w.directory.includes("/packages/"));
console.log(`Paseo subdirectory workspaces: ${subdirWorkspaces.length}`);
for (const w of subdirWorkspaces) {
console.log(` ${w.directory} (projectId=${w.projectId})`);
@@ -313,7 +313,10 @@ function writeLegacyJson(input: {
const projectsPath = path.join(input.paseoHome, "projects", "projects.json");
const workspacesPath = path.join(input.paseoHome, "projects", "workspaces.json");
mkdirSync(path.dirname(projectsPath), { recursive: true });
writeFileSync(projectsPath, JSON.stringify(input.projectsJson, null, 2), { encoding: "utf8", flag: "w" });
writeFileSync(projectsPath, JSON.stringify(input.projectsJson, null, 2), {
encoding: "utf8",
flag: "w",
});
writeFileSync(workspacesPath, JSON.stringify(input.workspacesJson, null, 2), {
encoding: "utf8",
flag: "w",
@@ -62,7 +62,9 @@ export async function importLegacyProjectWorkspaceJson(options: {
const databaseHasRows = await hasAnyProjectWorkspaceRows(options.db);
if (databaseHasRows) {
options.logger.info("Skipping legacy project/workspace JSON import because the DB is not empty");
options.logger.info(
"Skipping legacy project/workspace JSON import because the DB is not empty",
);
return {
status: "skipped",
reason: "database-not-empty",
@@ -74,7 +76,9 @@ export async function importLegacyProjectWorkspaceJson(options: {
pathExists(workspacesPath),
]);
if (!projectsExists && !workspacesExists) {
options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist");
options.logger.info(
"Skipping legacy project/workspace JSON import because no legacy files exist",
);
return {
status: "skipped",
reason: "no-legacy-files",
@@ -94,7 +98,9 @@ export async function importLegacyProjectWorkspaceJson(options: {
]);
if (projectRows.length === 0 && workspaceRows.length === 0) {
options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist");
options.logger.info(
"Skipping legacy project/workspace JSON import because no legacy files exist",
);
return {
status: "skipped",
reason: "no-legacy-files",
@@ -144,10 +150,11 @@ export async function importLegacyProjectWorkspaceJson(options: {
for (const legacy of workspaceRows) {
const projectId = legacyProjectIdToNewId.get(legacy.projectId);
if (projectId === undefined) {
throw new Error(`Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`);
throw new Error(
`Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`,
);
}
tx
.insert(workspaces)
tx.insert(workspaces)
.values({
projectId,
directory: legacy.cwd,
@@ -252,7 +259,10 @@ async function backupLegacyProjectWorkspaceJson(options: {
await fs.copyFile(options.workspacesPath, path.join(backupDir, "workspaces.json"));
}
options.logger.info({ backupPath: backupDir }, "Backed up legacy project/workspace JSON before migration");
options.logger.info(
{ backupPath: backupDir },
"Backed up legacy project/workspace JSON before migration",
);
}
async function pathExists(targetPath: string): Promise<boolean> {
+6 -4
View File
@@ -1,6 +1,10 @@
import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type { AgentPersistenceHandle, AgentRuntimeInfo, AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type {
AgentPersistenceHandle,
AgentRuntimeInfo,
AgentTimelineItem,
} from "../agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
export const projects = sqliteTable("projects", {
@@ -65,9 +69,7 @@ export const agentTimelineRows = sqliteTable(
item: text("item", { mode: "json" }).$type<AgentTimelineItem>().notNull(),
itemKind: text("item_kind"),
},
(table) => [
primaryKey({ columns: [table.agentId, table.seq], name: "agent_timeline_rows_pk" }),
],
(table) => [primaryKey({ columns: [table.agentId, table.seq], name: "agent_timeline_rows_pk" })],
);
export const paseoDbSchema = {
@@ -8,12 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { openPaseoDatabase } from "./sqlite-database.js";
import { runPaseoDbMigrations } from "./migrations.js";
import {
agentSnapshots,
agentTimelineRows,
projects,
workspaces,
} from "./schema.js";
import { agentSnapshots, agentTimelineRows, projects, workspaces } from "./schema.js";
function createTimestamp(day: number): string {
return `2026-03-${String(day).padStart(2, "0")}T00:00:00.000Z`;
@@ -45,15 +40,18 @@ describe("SQLite database contract", () => {
test("creates, migrates, closes, and reopens a persistent database", async () => {
const database = await openPaseoDatabase(dataDir);
const [project] = await database.db.insert(projects).values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: "git@github.com:acme/project-1.git",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
}).returning();
const [project] = await database.db
.insert(projects)
.values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: "git@github.com:acme/project-1.git",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
})
.returning();
await database.close();
@@ -77,24 +75,30 @@ describe("SQLite database contract", () => {
test("supports project and workspace linkage plus archive field updates", async () => {
const database = await openPaseoDatabase(dataDir);
const [project] = await database.db.insert(projects).values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: null,
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
}).returning();
const [workspace] = await database.db.insert(workspaces).values({
projectId: project.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
}).returning();
const [project] = await database.db
.insert(projects)
.values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: null,
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
})
.returning();
const [workspace] = await database.db
.insert(workspaces)
.values({
projectId: project.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
})
.returning();
await database.db
.update(workspaces)
@@ -123,24 +127,30 @@ describe("SQLite database contract", () => {
test("supports snapshot insert, get, update, and project-delete cascade with integer workspace IDs", async () => {
const database = await openPaseoDatabase(dataDir);
const [project] = await database.db.insert(projects).values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: null,
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
}).returning();
const [workspace] = await database.db.insert(workspaces).values({
projectId: project.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
}).returning();
const [project] = await database.db
.insert(projects)
.values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: null,
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
})
.returning();
const [workspace] = await database.db
.insert(workspaces)
.values({
projectId: project.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
})
.returning();
await database.db.insert(agentSnapshots).values({
agentId: "agent-1",
+7 -3
View File
@@ -30,18 +30,22 @@ export {
} from "./speech/providers/local/sherpa/sherpa-runtime-env.js";
// Provider binary resolution
export { applyProviderEnv } from "./agent/provider-launch-config.js";
export {
applyProviderEnv,
type ProviderOverride,
type ProviderProfileModel,
} from "./agent/provider-launch-config.js";
export {
findExecutable,
findExecutableSync,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../utils/executable.js";
export { spawnProcess } from "../utils/spawn.js";
export { execCommand, spawnProcess } from "../utils/spawn.js";
// Provider manifest (source of truth for provider definitions)
export {
AGENT_PROVIDER_DEFINITIONS,
BUILTIN_PROVIDER_IDS,
type AgentProviderDefinition,
} from "./agent/provider-manifest.js";
+3 -4
View File
@@ -1,8 +1,6 @@
import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
import type { Logger } from "pino";
import { curateAgentActivity } from "./agent/activity-curator.js";
@@ -15,8 +13,8 @@ import type {
AgentTimelineItem,
AgentProvider,
} from "./agent/agent-sdk-types.js";
import { execCommand, platformShell } from "../utils/spawn.js";
const execFileAsync = promisify(execFile);
const LOOP_ID_LENGTH = 8;
const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude";
const MAX_VERIFY_OUTPUT_BYTES = 64 * 1024;
@@ -256,7 +254,8 @@ async function runVerifyCheck(options: {
}): Promise<LoopVerifyCheckResult> {
const startedAt = nowIso();
try {
const result = await execFileAsync("/bin/sh", ["-lc", options.command], {
const shell = platformShell();
const result = await execCommand(shell.command, [...shell.flag, options.command], {
cwd: options.cwd,
maxBuffer: MAX_VERIFY_OUTPUT_BYTES,
});
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import { PersistedConfigSchema } from "./persisted-config.js";
describe("PersistedConfigSchema agent provider runtime settings", () => {
test("accepts provider command append args and env", () => {
test("legacy append entries are skipped during migration", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
@@ -20,8 +20,7 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
},
});
expect(parsed.agents?.providers?.claude?.command?.mode).toBe("append");
expect(parsed.agents?.providers?.claude?.env?.FOO).toBe("bar");
expect(parsed.agents?.providers).toEqual({});
});
test("accepts provider command replace argv", () => {
@@ -38,7 +37,12 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
},
});
expect(parsed.agents?.providers?.codex?.command?.mode).toBe("replace");
expect(parsed.agents?.providers?.codex?.command).toEqual([
"docker",
"run",
"--rm",
"my-codex-wrapper",
]);
});
test("rejects replace command without argv", () => {
@@ -58,6 +62,286 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
});
});
describe("provider overrides (new format)", () => {
test("override built-in provider with command and env", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
claude: {
command: ["/opt/custom/claude"],
env: {
ANTHROPIC_API_KEY: "sk-test",
},
},
},
},
});
expect(parsed.agents?.providers?.claude).toEqual({
command: ["/opt/custom/claude"],
env: {
ANTHROPIC_API_KEY: "sk-test",
},
});
});
test("new provider extending claude with label", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
zai: {
extends: "claude",
label: "ZAI",
},
},
},
});
expect(parsed.agents?.providers?.zai).toEqual({
extends: "claude",
label: "ZAI",
});
});
test("new provider extending acp with command", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
"my-agent": {
extends: "acp",
label: "My Agent",
command: ["my-agent", "--acp"],
},
},
},
});
expect(parsed.agents?.providers?.["my-agent"]).toEqual({
extends: "acp",
label: "My Agent",
command: ["my-agent", "--acp"],
});
});
test("enabled: false accepted", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
claude: {
enabled: false,
},
},
},
});
expect(parsed.agents?.providers?.claude?.enabled).toBe(false);
});
test("models array accepted", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
zai: {
extends: "claude",
label: "ZAI",
models: [
{
id: "zai-fast",
label: "ZAI Fast",
isDefault: true,
},
],
},
},
},
});
expect(parsed.agents?.providers?.zai?.models).toEqual([
{
id: "zai-fast",
label: "ZAI Fast",
isDefault: true,
},
]);
});
test("order field accepted", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
claude: {
order: 1,
},
},
},
});
expect(parsed.agents?.providers?.claude?.order).toBe(1);
});
test("new provider without extends → error", () => {
const result = PersistedConfigSchema.safeParse({
agents: {
providers: {
zai: {
label: "ZAI",
},
},
},
});
expect(result.success).toBe(false);
});
test("new provider without label → error", () => {
const result = PersistedConfigSchema.safeParse({
agents: {
providers: {
zai: {
extends: "claude",
},
},
},
});
expect(result.success).toBe(false);
});
test("extends: acp without command → error", () => {
const result = PersistedConfigSchema.safeParse({
agents: {
providers: {
"my-agent": {
extends: "acp",
label: "My Agent",
},
},
},
});
expect(result.success).toBe(false);
});
test("extends unknown provider → error", () => {
const result = PersistedConfigSchema.safeParse({
agents: {
providers: {
zai: {
extends: "unknown",
label: "ZAI",
},
},
},
});
expect(result.success).toBe(false);
});
test("invalid provider ID format → error", () => {
const result = PersistedConfigSchema.safeParse({
agents: {
providers: {
ZAI: {
extends: "claude",
label: "ZAI",
},
},
},
});
expect(result.success).toBe(false);
});
test("old format with mode: replace auto-migrates", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
claude: {
command: {
mode: "replace",
argv: ["docker", "run", "--rm", "claude"],
},
},
},
},
});
expect(parsed.agents?.providers?.claude).toEqual({
command: ["docker", "run", "--rm", "claude"],
});
});
test("old format with mode: default auto-migrates", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
claude: {
command: {
mode: "default",
},
},
},
},
});
expect(parsed.agents?.providers?.claude).toEqual({});
});
test("old format env preserved during migration", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
claude: {
command: {
mode: "default",
},
env: {
FOO: "bar",
},
},
},
},
});
expect(parsed.agents?.providers?.claude).toEqual({
env: {
FOO: "bar",
},
});
});
test("mixed old and new format entries both work", () => {
const parsed = PersistedConfigSchema.parse({
agents: {
providers: {
claude: {
command: {
mode: "replace",
argv: ["custom-claude"],
},
},
zai: {
extends: "claude",
label: "ZAI",
command: ["zai"],
},
},
},
});
expect(parsed.agents?.providers).toEqual({
claude: {
command: ["custom-claude"],
},
zai: {
extends: "claude",
label: "ZAI",
command: ["zai"],
},
});
});
});
describe("PersistedConfigSchema logging config", () => {
test("accepts destination-specific logging config", () => {
const parsed = PersistedConfigSchema.parse({
+118 -6
View File
@@ -2,7 +2,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js";
import {
AgentProviderRuntimeSettingsMapSchema,
migrateProviderSettings,
ProviderOverrideSchema,
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
const LogFormatSchema = z.enum(["pretty", "json"]);
@@ -113,6 +118,107 @@ const FeatureVoiceModeSchema = z
})
.strict();
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
const ProviderOverridesSchema = z
.record(z.string(), ProviderOverrideSchema)
.superRefine((providers, ctx) => {
const builtinProviderIdSet = new Set<string>(BUILTIN_PROVIDER_IDS);
const validExtendsValues = new Set<string>([...BUILTIN_PROVIDER_IDS, "acp"]);
for (const [providerId, provider] of Object.entries(providers)) {
if (!PROVIDER_ID_PATTERN.test(providerId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId],
message: `Provider ID "${providerId}" must match ${PROVIDER_ID_PATTERN}.`,
});
}
const isBuiltinProvider = builtinProviderIdSet.has(providerId);
if (!isBuiltinProvider && !provider.extends) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId, "extends"],
message: `Custom provider "${providerId}" must declare extends.`,
});
}
if (!isBuiltinProvider && !provider.label) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId, "label"],
message: `Custom provider "${providerId}" must declare label.`,
});
}
if (provider.extends && !validExtendsValues.has(provider.extends)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId, "extends"],
message: `Provider "${providerId}" extends unknown provider "${provider.extends}".`,
});
}
if (provider.extends === "acp" && !provider.command) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [providerId, "command"],
message: `Provider "${providerId}" extending "acp" must declare command.`,
});
}
}
});
function isLegacyProviderEntry(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const command = (value as Record<string, unknown>).command;
if (!command || typeof command !== "object" || Array.isArray(command)) {
return false;
}
return typeof (command as Record<string, unknown>).mode === "string";
}
function normalizeAgentProviders(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const rawProviders = value as Record<string, unknown>;
const hasLegacyEntries = Object.values(rawProviders).some((entry) =>
isLegacyProviderEntry(entry),
);
if (!hasLegacyEntries) {
return value;
}
const legacyEntries: Record<string, unknown> = {};
const normalizedEntries: Record<string, unknown> = {};
for (const [providerId, providerValue] of Object.entries(rawProviders)) {
if (isLegacyProviderEntry(providerValue)) {
legacyEntries[providerId] = providerValue;
continue;
}
normalizedEntries[providerId] = providerValue;
}
const parsedLegacyEntries = AgentProviderRuntimeSettingsMapSchema.safeParse(legacyEntries);
if (!parsedLegacyEntries.success) {
return value;
}
return {
...normalizedEntries,
...migrateProviderSettings(parsedLegacyEntries.data, [...BUILTIN_PROVIDER_IDS]),
};
}
export const PersistedConfigSchema = z
.object({
// v1 schema marker
@@ -158,7 +264,7 @@ export const PersistedConfigSchema = z
providers: ProvidersSchema.optional(),
agents: z
.object({
providers: AgentProviderRuntimeSettingsMapSchema.optional(),
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
})
.strict()
.optional(),
@@ -174,10 +280,16 @@ export const PersistedConfigSchema = z
})
.strict();
export type PersistedConfig = z.infer<typeof PersistedConfigSchema>;
type PersistedConfigSchemaOutput = z.infer<typeof PersistedConfigSchema>;
export type PersistedConfig = Omit<PersistedConfigSchemaOutput, "agents"> & {
agents?: Omit<NonNullable<PersistedConfigSchemaOutput["agents"]>, "providers"> & {
providers?: AgentProviderRuntimeSettingsMap;
};
};
const CONFIG_FILENAME = "config.json";
const DEFAULT_PERSISTED_CONFIG: PersistedConfig = PersistedConfigSchema.parse({
const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({
version: 1,
daemon: {
listen: "127.0.0.1:6767",
@@ -191,7 +303,7 @@ const DEFAULT_PERSISTED_CONFIG: PersistedConfig = PersistedConfigSchema.parse({
app: {
baseUrl: "https://app.paseo.sh",
},
});
}) as PersistedConfig;
type LoggerLike = {
child(bindings: Record<string, unknown>): LoggerLike;
@@ -275,7 +387,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
}
log?.info(`Loaded from ${configPath}`);
return result.data;
return result.data as PersistedConfig;
}
export function savePersistedConfig(
@@ -1,6 +1,82 @@
import { describe, expect, test } from "vitest";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import { buildConfigOverrides, buildSessionConfig } from "./persistence-hooks.js";
import {
attachAgentStoragePersistence,
buildConfigOverrides,
buildSessionConfig,
} from "./persistence-hooks.js";
import type {
AgentPermissionRequest,
AgentSession,
AgentSessionConfig,
} from "./agent/agent-sdk-types.js";
const testLogger = {
child: () => testLogger,
error: vi.fn(),
warn: vi.fn(),
} as any;
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
session?: AgentSession | null;
activeForegroundTurnId?: string | null;
};
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
const provider = overrides.provider ?? "claude";
const cwd = overrides.cwd ?? "/tmp/project";
const lifecycle = overrides.lifecycle ?? "idle";
const configOverrides = overrides.config ?? {};
const config: AgentSessionConfig = {
provider,
cwd,
modeId: configOverrides.modeId ?? "plan",
model: configOverrides.model ?? "claude-3.5-sonnet",
extra: configOverrides.extra ?? { claude: { tone: "focused" } },
};
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const activeForegroundTurnId =
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
const agent: ManagedAgent = {
id: overrides.id ?? "agent-1",
provider,
cwd,
session,
capabilities: overrides.capabilities ?? {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
config,
lifecycle,
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
availableModes: overrides.availableModes ?? [],
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
activeForegroundTurnId,
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
timeline: overrides.timeline ?? [],
persistence: overrides.persistence ?? null,
historyPrimed: overrides.historyPrimed ?? true,
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
lastUsage: overrides.lastUsage,
lastError: overrides.lastError,
};
return agent;
}
function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
const now = new Date().toISOString();
@@ -110,4 +186,22 @@ describe("persistence hooks", () => {
cwd: "/tmp/project",
});
});
test("buildSessionConfig skips records whose provider is missing from the registry", () => {
const record = createRecord({
id: "agent-missing-provider",
provider: "zai",
});
expect(
buildSessionConfig(record, {
validProviders: ["claude", "codex"],
logger: testLogger,
}),
).toBeNull();
expect(testLogger.warn).toHaveBeenCalledWith(
{ agentId: "agent-missing-provider", provider: "zai" },
"Skipping persisted agent with unknown provider 'zai'",
);
});
});
@@ -1,13 +1,14 @@
import type pino from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider } from "./agent/provider-manifest.js";
type LoggerLike = {
child(bindings: Record<string, unknown>): LoggerLike;
error(...args: any[]): void;
warn(...args: any[]): void;
};
function getLogger(logger: LoggerLike): LoggerLike {
@@ -17,6 +18,11 @@ function getLogger(logger: LoggerLike): LoggerLike {
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
type BuildSessionConfigOptions = {
validProviders?: Iterable<AgentProvider>;
logger?: LoggerLike;
};
/**
* Attach AgentStorage persistence to an AgentManager instance so every
* agent_state snapshot is flushed to disk.
@@ -56,9 +62,18 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
};
}
export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfig {
if (!isValidAgentProvider(record.provider)) {
throw new Error(`Unknown provider '${record.provider}'`);
export function buildSessionConfig(
record: StoredAgentRecord,
options?: BuildSessionConfigOptions,
): AgentSessionConfig | null {
const validProviders = options?.validProviders;
const isValidProvider = validProviders ? new Set(validProviders).has(record.provider) : true;
if (!isValidProvider) {
options?.logger?.warn(
{ agentId: record.id, provider: record.provider },
`Skipping persisted agent with unknown provider '${record.provider}'`,
);
return null;
}
const overrides = buildConfigOverrides(record);
return {
@@ -387,8 +387,8 @@ describe("AgentLoadingService", () => {
} as any,
});
await expect(
service.refreshAgent({ agentId: "agent-compat-no-persistence" }),
).rejects.toThrow("Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence");
await expect(service.refreshAgent({ agentId: "agent-compat-no-persistence" })).rejects.toThrow(
"Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence",
);
});
});
@@ -470,7 +470,14 @@ export class ScheduleService {
extractTimestamps(record),
);
} else {
snapshot = await this.agentManager.createAgent(buildSessionConfig(record), agentId, {
const config = buildSessionConfig(record, {
validProviders: this.agentManager.getRegisteredProviderIds(),
logger: this.logger,
});
if (!config) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
snapshot = await this.agentManager.createAgent(config, agentId, {
labels: record.labels,
});
}
@@ -6,10 +6,7 @@ import path from "node:path";
import { scheduler } from "node:timers/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { findFreePort, ScriptRouteStore } from "./script-proxy.js";
import {
ScriptHealthMonitor,
type ScriptHealthEntry,
} from "./script-health-monitor.js";
import { ScriptHealthMonitor, type ScriptHealthEntry } from "./script-health-monitor.js";
import { spawnWorktreeScripts } from "./worktree-bootstrap.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -51,11 +48,7 @@ function createStubTerminalManager(
async getTerminals() {
return [];
},
async createTerminal(options: {
cwd: string;
name?: string;
env?: Record<string, string>;
}) {
async createTerminal(options: { cwd: string; name?: string; env?: Record<string, string> }) {
createTerminalCalls.push(options);
return {
id: `term-${options.name ?? "terminal"}`,
@@ -19,10 +19,7 @@ type RouteHealthState = {
export class ScriptHealthMonitor {
private readonly routeStore: ScriptRouteStore;
private readonly onChange: (
workspaceId: string,
scripts: ScriptHealthEntry[],
) => void;
private readonly onChange: (workspaceId: string, scripts: ScriptHealthEntry[]) => void;
private readonly pollIntervalMs: number;
private readonly probeTimeoutMs: number;
private readonly graceMs: number;
@@ -172,15 +169,13 @@ export class ScriptHealthMonitor {
}
private buildWorkspaceScriptList(workspaceId: string): ScriptHealthEntry[] {
return this.routeStore
.listRoutesForWorkspace(workspaceId)
.flatMap((route) => {
const state = this.routeStates.get(route.hostname);
if (!state) {
return [];
}
return [this.toScriptHealthEntry(route, state.health)];
});
return this.routeStore.listRoutesForWorkspace(workspaceId).flatMap((route) => {
const state = this.routeStates.get(route.hostname);
if (!state) {
return [];
}
return [this.toScriptHealthEntry(route, state.health)];
});
}
getHealthForHostname(hostname: string): ScriptHealthState | null {
+13 -37
View File
@@ -253,9 +253,7 @@ describe("HTTP proxy", () => {
res.end("upstream-ok");
});
await new Promise<void>((resolve) =>
server.listen(port, "127.0.0.1", resolve),
);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
servers.push(server);
return {
@@ -281,9 +279,7 @@ describe("HTTP proxy", () => {
}
const server = http.createServer(app);
await new Promise<void>((resolve) =>
server.listen(port, "127.0.0.1", resolve),
);
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
servers.push(server);
return { port, server };
@@ -296,16 +292,11 @@ describe("HTTP proxy", () => {
path = "/",
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path, headers: { host } },
(res) => {
let body = "";
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
res.on("end", () =>
resolve({ status: res.statusCode ?? 0, body }),
);
},
);
const req = http.get({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => {
let body = "";
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
res.on("end", () => resolve({ status: res.statusCode ?? 0, body }));
});
req.on("error", reject);
});
}
@@ -316,10 +307,7 @@ describe("HTTP proxy", () => {
routeStore.addRoute("test-service.localhost", upstream.port);
const proxy = await startProxy(routeStore);
const res = await httpGet(
proxy.port,
`test-service.localhost:${proxy.port}`,
);
const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`);
expect(res.status).toBe(200);
expect(res.body).toBe("upstream-ok");
@@ -333,10 +321,7 @@ describe("HTTP proxy", () => {
const routeStore = new ScriptRouteStore();
const proxy = await startProxy(routeStore, { fallback: true });
const res = await httpGet(
proxy.port,
`unknown.localhost:${proxy.port}`,
);
const res = await httpGet(proxy.port, `unknown.localhost:${proxy.port}`);
expect(res.status).toBe(404);
expect(res.body).toBe("no route");
@@ -350,10 +335,7 @@ describe("HTTP proxy", () => {
routeStore.addRoute("dead-service.localhost", deadPort);
const proxy = await startProxy(routeStore);
const res = await httpGet(
proxy.port,
`dead-service.localhost:${proxy.port}`,
);
const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`);
expect(res.status).toBe(502);
expect(res.body).toBe("502 Bad Gateway");
@@ -397,9 +379,7 @@ describe("WebSocket proxy", () => {
});
});
await new Promise<void>((resolve) =>
upstreamServer.listen(upstreamPort, "127.0.0.1", resolve),
);
await new Promise<void>((resolve) => upstreamServer.listen(upstreamPort, "127.0.0.1", resolve));
httpServers.push(upstreamServer);
// 2. Create the proxy server with the upgrade handler
@@ -418,9 +398,7 @@ describe("WebSocket proxy", () => {
});
proxyServer.on("upgrade", upgradeHandler);
await new Promise<void>((resolve) =>
proxyServer.listen(proxyPort, "127.0.0.1", resolve),
);
await new Promise<void>((resolve) => proxyServer.listen(proxyPort, "127.0.0.1", resolve));
httpServers.push(proxyServer);
// 3. Connect a WebSocket client through the proxy
@@ -469,9 +447,7 @@ describe("findFreePort", () => {
const addr = server.address();
expect(addr).not.toBeNull();
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(
port,
);
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(port);
await new Promise<void>((resolve) => server.close(() => resolve()));
});
+29 -36
View File
@@ -184,8 +184,7 @@ export function createScriptProxyMiddleware({
}
const forwardedHeaders = stripHopByHopHeaders(req.headers);
forwardedHeaders["x-forwarded-for"] =
req.socket.remoteAddress ?? "127.0.0.1";
forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1";
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
forwardedHeaders["x-forwarded-proto"] = req.protocol;
@@ -241,47 +240,41 @@ export function createScriptProxyUpgradeHandler({
return;
}
const targetSocket = net.connect(
{ host: "127.0.0.1", port: route.port },
() => {
// Reconstruct the raw HTTP upgrade request to send to the target
const forwardedHeaders = stripHopByHopHeaders(req.headers);
forwardedHeaders["x-forwarded-for"] =
req.socket.remoteAddress ?? "127.0.0.1";
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
forwardedHeaders["x-forwarded-proto"] = "http";
const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => {
// Reconstruct the raw HTTP upgrade request to send to the target
const forwardedHeaders = stripHopByHopHeaders(req.headers);
forwardedHeaders["x-forwarded-for"] = req.socket.remoteAddress ?? "127.0.0.1";
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
forwardedHeaders["x-forwarded-proto"] = "http";
// Re-include upgrade and connection headers — they are required for
// WebSocket handshake even though they are hop-by-hop.
forwardedHeaders["connection"] = "Upgrade";
forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket";
// Re-include upgrade and connection headers — they are required for
// WebSocket handshake even though they are hop-by-hop.
forwardedHeaders["connection"] = "Upgrade";
forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket";
const headerLines: string[] = [];
headerLines.push(
`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`,
);
for (const [key, value] of Object.entries(forwardedHeaders)) {
if (Array.isArray(value)) {
for (const v of value) {
headerLines.push(`${key}: ${v}`);
}
} else {
headerLines.push(`${key}: ${value}`);
const headerLines: string[] = [];
headerLines.push(`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`);
for (const [key, value] of Object.entries(forwardedHeaders)) {
if (Array.isArray(value)) {
for (const v of value) {
headerLines.push(`${key}: ${v}`);
}
} else {
headerLines.push(`${key}: ${value}`);
}
headerLines.push("\r\n");
}
headerLines.push("\r\n");
targetSocket.write(headerLines.join("\r\n"));
targetSocket.write(headerLines.join("\r\n"));
if (head.length > 0) {
targetSocket.write(head);
}
if (head.length > 0) {
targetSocket.write(head);
}
// Pipe in both directions
targetSocket.pipe(socket);
socket.pipe(targetSocket);
},
);
// Pipe in both directions
targetSocket.pipe(socket);
socket.pipe(targetSocket);
});
targetSocket.on("error", (err) => {
logger.warn(
@@ -109,7 +109,7 @@ export function buildWorkspaceScriptPayloads(
type,
branchName,
daemonPort: options.daemonPort,
configuredPort: isServiceScript(config) ? config.port ?? null : null,
configuredPort: isServiceScript(config) ? (config.port ?? null) : null,
});
payloads.push({
File diff suppressed because it is too large Load Diff
@@ -180,10 +180,12 @@ function createWorkspaceRuntimeSnapshot(
};
}
function createSessionForWorkspaceTests(options: {
appVersion?: string | null;
workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>;
} = {}): {
function createSessionForWorkspaceTests(
options: {
appVersion?: string | null;
workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>;
} = {},
): {
session: Session;
emitted: Array<{ type: string; payload: unknown }>;
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
@@ -344,10 +346,10 @@ function seedWorkspace(options: {
return record;
}
function createTempGitRepo(options?: {
remoteUrl?: string;
branchName?: string;
}): { tempDir: string; repoDir: string } {
function createTempGitRepo(options?: { remoteUrl?: string; branchName?: string }): {
tempDir: string;
repoDir: string;
} {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-git-")));
const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${repoDir}`);
@@ -1205,9 +1207,7 @@ describe("workspace aggregation", () => {
const response = emitted.find(
(message) => message.type === "create_paseo_worktree_response",
) as
| { type: "create_paseo_worktree_response"; payload: any }
| undefined;
) as { type: "create_paseo_worktree_response"; payload: any } | undefined;
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace).toMatchObject({
@@ -1329,9 +1329,9 @@ describe("workspace aggregation", () => {
requestId: "req-open",
});
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === "/tmp/repo")).toBe(
true,
);
expect(
Array.from(workspaces.values()).some((workspace) => workspace.directory === "/tmp/repo"),
).toBe(true);
const response = emitted.find((message) => message.type === "open_project_response") as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe("/tmp/repo");
@@ -1364,12 +1364,12 @@ describe("workspace aggregation", () => {
requestId: "req-open-subdir",
});
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === repoRoot)).toBe(
true,
);
expect(Array.from(workspaces.values()).some((workspace) => workspace.directory === subdir)).toBe(
false,
);
expect(
Array.from(workspaces.values()).some((workspace) => workspace.directory === repoRoot),
).toBe(true);
expect(
Array.from(workspaces.values()).some((workspace) => workspace.directory === subdir),
).toBe(false);
const response = emitted.find((message) => message.type === "open_project_response") as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(repoRoot);
@@ -85,6 +85,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
undefined,
undefined,
undefined,
undefined,
"1.2.3-test",
undefined,
undefined,
@@ -180,6 +180,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
undefined,
undefined,
undefined,
undefined,
TEST_DAEMON_VERSION,
undefined,
undefined,
+10 -4
View File
@@ -27,7 +27,10 @@ import type { AllowedHostsConfig } from "./allowed-hosts.js";
import { isHostAllowed } from "./allowed-hosts.js";
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { buildProviderRegistry } from "./agent/provider-registry.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
@@ -251,15 +254,14 @@ export class VoiceAssistantWebSocketServer {
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly getDaemonTcpHost: (() => string | null) | null;
private readonly resolveScriptHealth:
| ((hostname: string) => ScriptHealthState | null)
| null;
private readonly resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
private readonly dictation: {
finalTimeoutMs?: number;
} | null;
private readonly voiceSpeakHandlers = new Map<string, VoiceSpeakHandler>();
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly onBranchChanged:
@@ -308,6 +310,7 @@ export class VoiceAssistantWebSocketServer {
finalTimeoutMs?: number;
},
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
providerOverrides?: Record<string, ProviderOverride>,
daemonVersion?: string,
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void,
projectRegistry?: ProjectRegistry,
@@ -365,10 +368,12 @@ export class VoiceAssistantWebSocketServer {
this.terminalManager = terminalManager ?? null;
this.dictation = dictation ?? null;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.providerOverrides = providerOverrides;
const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" });
this.providerSnapshotManager = new ProviderSnapshotManager(
buildProviderRegistry(providerSnapshotLogger, {
runtimeSettings: this.agentProviderRuntimeSettings,
providerOverrides: this.providerOverrides,
}),
providerSnapshotLogger,
);
@@ -720,6 +725,7 @@ export class VoiceAssistantWebSocketServer {
}
: undefined,
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
providerOverrides: this.providerOverrides,
});
connection = {
@@ -1,8 +1,6 @@
import { execFile } from "node:child_process";
import { watch, type FSWatcher } from "node:fs";
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import type pino from "pino";
import type { CheckoutContext } from "../utils/checkout-git.js";
import {
@@ -13,10 +11,9 @@ import {
resolveGhPath,
resolveAbsoluteGitDir,
} from "../utils/checkout-git.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
const execFileAsync = promisify(execFile);
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
@@ -587,11 +584,12 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
}
async function runGitFetch(cwd: string): Promise<void> {
await execFileAsync("git", ["fetch", "origin", "--prune"], {
await runGitCommand(["fetch", "origin", "--prune"], {
cwd,
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
},
timeout: 120_000,
});
}
@@ -209,10 +209,7 @@ export class WorkspaceReconciliationService {
workspace.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.directory;
const wsGit = detectWorkspaceGitMetadata(workspace.directory, wsDirName);
if (
wsGit.projectKind === "git" &&
workspace.displayName !== wsGit.workspaceDisplayName
) {
if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
const timestamp = new Date().toISOString();
await this.workspaceRegistry.upsert({
...workspace,
@@ -67,7 +67,8 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
async insert(record: Omit<TRecord, "id">): Promise<number> {
await this.load();
const nextId = Math.max(0, ...Array.from(this.cache.values(), (value) => this.getId(value))) + 1;
const nextId =
Math.max(0, ...Array.from(this.cache.values(), (value) => this.getId(value))) + 1;
const parsed = this.parseRecord({ ...record, id: nextId });
this.cache.set(String(this.getId(parsed)), parsed);
await this.enqueuePersist();
@@ -515,11 +515,7 @@ describe("runAsyncWorktreeBootstrap", () => {
async getTerminals() {
return [];
},
async createTerminal(options: {
cwd: string;
name?: string;
env?: Record<string, string>;
}) {
async createTerminal(options: { cwd: string; name?: string; env?: Record<string, string> }) {
createTerminalCalls.push(options);
return {
id: "term-service",
@@ -576,7 +572,8 @@ describe("runAsyncWorktreeBootstrap", () => {
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = [];
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> =
[];
const results = await spawnWorktreeScripts({
repoRoot: repoDir,
@@ -622,7 +619,8 @@ describe("runAsyncWorktreeBootstrap", () => {
const routeStore = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = [];
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> =
[];
const results = await spawnWorktreeScripts({
repoRoot: repoDir,
@@ -1,11 +1,10 @@
import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { sep } from "node:path";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import { buildScriptHostname } from "../utils/script-hostname.js";
import { runGitCommand } from "../utils/run-git-command.js";
import {
createWorktree,
getScriptConfigs,
@@ -62,7 +61,7 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env,
GIT_OPTIONAL_LOCKS: "0",
};
const execAsync = promisify(exec);
type MiddleTruncationAccumulator = {
totalBytes: number;
head: string;
@@ -214,7 +213,7 @@ async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptio
}
async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<string> {
const { stdout } = await execAsync("git branch --show-current", {
const { stdout } = await runGitCommand(["branch", "--show-current"], {
cwd: worktreePath,
env: READ_ONLY_GIT_ENV,
});
@@ -877,7 +876,13 @@ export async function spawnWorkspaceScript(
terminal.send({ type: "input", data: `${config.command}\r` });
logger?.info(
{ scriptName, hostname, port, terminalId: terminal.id, type: serviceScript ? "service" : "script" },
{
scriptName,
hostname,
port,
terminalId: terminal.id,
type: serviceScript ? "service" : "script",
},
serviceScript
? `Registered script proxy: ${hostname} -> 127.0.0.1:${port}`
: "Started workspace script",
@@ -1,5 +1,12 @@
import { execSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
@@ -42,38 +49,36 @@ function createTerminalManagerStub(options?: {
terminals,
manager: {
registerCwdEnv: vi.fn(),
createTerminal: vi.fn(async (input: {
cwd: string;
name?: string;
env?: Record<string, string>;
}) => {
if (options?.createTerminal) {
return options.createTerminal(input);
}
const sent: string[] = [];
const terminal = {
id: `terminal-${terminals.length + 1}`,
getState: () => ({
scrollback: [[{ char: "$" }]],
grid: [],
}),
subscribe: () => () => {},
onExit: () => () => {},
send: (message: { type: string; data: string }) => {
if (message.type === "input") {
sent.push(message.data);
}
},
};
terminals.push({
id: terminal.id,
cwd: input.cwd,
name: input.name,
env: input.env,
sent,
});
return terminal;
}),
createTerminal: vi.fn(
async (input: { cwd: string; name?: string; env?: Record<string, string> }) => {
if (options?.createTerminal) {
return options.createTerminal(input);
}
const sent: string[] = [];
const terminal = {
id: `terminal-${terminals.length + 1}`,
getState: () => ({
scrollback: [[{ char: "$" }]],
grid: [],
}),
subscribe: () => () => {},
onExit: () => () => {},
send: (message: { type: string; data: string }) => {
if (message.type === "input") {
sent.push(message.data);
}
},
};
terminals.push({
id: terminal.id,
cwd: input.cwd,
name: input.name,
env: input.env,
sent,
});
return terminal;
},
),
} as any,
};
}
@@ -114,9 +119,12 @@ function createGitHubPrRemoteRepo() {
execSync(`git clone --bare ${JSON.stringify(repoDir)} ${JSON.stringify(remoteDir)}`, {
stdio: "pipe",
});
execSync(`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`, {
stdio: "pipe",
});
execSync(
`git --git-dir=${JSON.stringify(remoteDir)} update-ref refs/pull/123/head ${featureSha}`,
{
stdio: "pipe",
},
);
execSync(`git remote add origin ${JSON.stringify(remoteDir)}`, { cwd: repoDir, stdio: "pipe" });
execSync("git fetch origin", { cwd: repoDir, stdio: "pipe" });
@@ -165,7 +173,8 @@ describe("createPaseoWorktreeInBackground", () => {
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
@@ -261,7 +270,8 @@ describe("createPaseoWorktreeInBackground", () => {
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: null,
@@ -301,7 +311,7 @@ describe("createPaseoWorktreeInBackground", () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
worktree: {
setup: ['sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"'],
setup: ["sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\""],
},
},
});
@@ -327,7 +337,8 @@ describe("createPaseoWorktreeInBackground", () => {
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: null,
@@ -364,11 +375,13 @@ describe("createPaseoWorktreeInBackground", () => {
});
expect(progressMessages.at(-1)?.payload.status).toBe("completed");
const runningMessages = progressMessages.filter((message) => message.payload.status === "running");
expect(runningMessages.length).toBeGreaterThan(0);
expect(progressMessages.findIndex((message) => message.payload.status === "running")).toBeLessThan(
progressMessages.findIndex((message) => message.payload.status === "completed"),
const runningMessages = progressMessages.filter(
(message) => message.payload.status === "running",
);
expect(runningMessages.length).toBeGreaterThan(0);
expect(
progressMessages.findIndex((message) => message.payload.status === "running"),
).toBeLessThan(progressMessages.findIndex((message) => message.payload.status === "completed"));
const setupOutputMessage = runningMessages.find((message) =>
message.payload.detail.commands[0]?.log.includes("phase-one"),
@@ -376,7 +389,7 @@ describe("createPaseoWorktreeInBackground", () => {
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",
log: expect.stringContaining("phase-one"),
status: "running",
});
@@ -394,7 +407,7 @@ describe("createPaseoWorktreeInBackground", () => {
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
command: "sh -c \"printf 'phase-one\\\\n'; sleep 0.1; printf 'phase-two\\\\n'\"",
log: expect.stringContaining("phase-two"),
status: "completed",
exitCode: 0,
@@ -441,7 +454,8 @@ describe("createPaseoWorktreeInBackground", () => {
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
@@ -482,10 +496,12 @@ describe("createPaseoWorktreeInBackground", () => {
},
});
expect(terminalManager.terminals).toHaveLength(0);
expect(
readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"),
).toContain("hello");
expect(() => readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8")).toThrow();
expect(readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8")).toContain(
"hello",
);
expect(() =>
readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8"),
).toThrow();
expect(snapshots.get("44")).toMatchObject({
status: "completed",
error: null,
@@ -531,7 +547,8 @@ describe("createPaseoWorktreeInBackground", () => {
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
@@ -610,7 +627,8 @@ describe("createPaseoWorktreeInBackground", () => {
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
@@ -714,7 +732,6 @@ describe("createPaseoWorktreeInBackground", () => {
},
});
});
});
describe("handleCreatePaseoWorktreeRequest", () => {
@@ -787,7 +804,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
);
const response = emitted.find(
(message): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
(
message,
): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
message.type === "create_paseo_worktree_response",
);
@@ -899,7 +918,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
expect(createAgentWorktreeSpy).toHaveBeenCalledTimes(1);
const response = emitted.find(
(message): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
(
message,
): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
message.type === "create_paseo_worktree_response",
);
expect(response?.payload.error).toBeNull();
@@ -950,7 +971,9 @@ describe("handleCreatePaseoWorktreeRequest", () => {
);
const response = emitted.find(
(message): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
(
message,
): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
message.type === "create_paseo_worktree_response",
);
expect(response?.payload.error).toBeNull();
+28 -17
View File
@@ -1,4 +1,4 @@
import { exec, execFile } from "node:child_process";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { Logger } from "pino";
import { v4 as uuidv4 } from "uuid";
@@ -48,9 +48,9 @@ import {
WorktreeSetupError,
} from "../utils/worktree.js";
import { writePaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
@@ -90,20 +90,14 @@ type RegisterPendingWorktreeWorkspaceDependencies = {
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
findWorkspaceByDirectory: (directory: string) => Promise<PersistedWorkspaceRecord | null>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert" | "insert" | "archive">;
syncWorkspaceGitWatchTarget: (
cwd: string,
options: { isGit: boolean },
) => Promise<void>;
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert" | "insert" | "list">;
archiveProjectRecordIfEmpty: (projectId: number, archivedAt: string) => Promise<void>;
};
type CreatePaseoWorktreeInBackgroundDependencies = {
paseoHome?: string;
emitWorkspaceUpdateForCwd: (
cwd: string,
options?: { dedupeGitState?: boolean },
) => Promise<void>;
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void;
emit: EmitSessionMessage;
sessionLogger: Logger;
@@ -129,12 +123,14 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
branchName: string;
}) => Promise<PersistedWorkspaceRecord>;
sessionLogger: Logger;
createPaseoWorktreeInBackground: (options: {
runWorktreeSetupInBackground: (options: {
requestCwd: string;
repoRoot: string;
workspaceId: number;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
slug: string;
worktreePath: string;
}) => Promise<void>;
};
@@ -175,7 +171,8 @@ export async function buildAgentSessionConfig(
githubPrAttachment.baseRefName?.trim() ||
normalized.baseBranch ||
(await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
const worktreeSlug = normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment));
const worktreeSlug =
normalized.worktreeSlug ?? slugify(resolveGitHubPrBranchName(githubPrAttachment));
const createdWorktree = await createGitHubPrWorktree({
cwd,
branchName: resolveGitHubPrBranchName(githubPrAttachment),
@@ -203,7 +200,7 @@ export async function buildAgentSessionConfig(
if (normalized.createNewBranch) {
targetBranch = normalized.newBranchName!;
} else {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
env: READ_ONLY_GIT_ENV,
});
@@ -690,6 +687,15 @@ export async function handleCreatePaseoWorktreeRequest(
worktreePath: createdWorktree.worktree.worktreePath,
branchName: createdWorktree.worktree.branchName,
});
await createAgentWorktree({
cwd: repoRoot,
branchName: normalizedSlug,
baseBranch,
worktreeSlug: normalizedSlug,
paseoHome: dependencies.paseoHome,
});
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
dependencies.emit({
type: "create_paseo_worktree_response",
@@ -701,12 +707,14 @@ export async function handleCreatePaseoWorktreeRequest(
},
});
void dependencies.createPaseoWorktreeInBackground({
void dependencies.runWorktreeSetupInBackground({
requestCwd: request.cwd,
repoRoot,
workspaceId: workspace.id,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.shouldBootstrap,
slug: normalizedSlug,
worktreePath: createdWorktree.worktree.worktreePath,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create worktree";
@@ -752,7 +760,7 @@ export async function handleWorkspaceSetupStatusRequest(
});
}
export async function createPaseoWorktreeInBackground(
export async function runWorktreeSetupInBackground(
dependencies: CreatePaseoWorktreeInBackgroundDependencies,
options: {
requestCwd: string;
@@ -760,6 +768,8 @@ export async function createPaseoWorktreeInBackground(
workspaceId: number;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
slug: string;
worktreePath: string;
},
): Promise<void> {
let worktree: WorktreeConfig = options.worktree;
@@ -774,7 +784,9 @@ export async function createPaseoWorktreeInBackground(
detail: buildWorktreeSetupDetail({
worktree,
results:
status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults,
status === "running"
? getWorktreeSetupProgressResults(progressAccumulator)
: setupResults,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
}),
error,
@@ -849,7 +861,6 @@ export async function createPaseoWorktreeInBackground(
);
return;
}
} finally {
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
}
+68 -70
View File
@@ -93,6 +93,8 @@ const AgentModeSchema: z.ZodType<AgentMode> = z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
colorTier: z.string().optional(),
});
const ProviderStatusSchema: z.ZodType<ProviderStatus> = z.enum([
@@ -154,6 +156,9 @@ const ProviderSnapshotEntrySchema: z.ZodType<ProviderSnapshotEntry> = z.object({
models: z.array(AgentModelDefinitionSchema).optional(),
modes: z.array(AgentModeSchema).optional(),
fetchedAt: z.string().optional(),
label: z.string().optional(),
description: z.string().optional(),
defaultModeId: z.string().nullable().optional(),
});
const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
@@ -916,27 +921,23 @@ export const ShutdownServerRequestMessageSchema = z.object({
requestId: z.string(),
});
export const AgentTimelineCursorSchema = z
.object({
seq: z.number().int().nonnegative(),
// COMPAT(timeline): retain legacy cursor epoch for older clients.
epoch: z.string().optional(),
})
;
export const AgentTimelineCursorSchema = z.object({
seq: z.number().int().nonnegative(),
// COMPAT(timeline): retain legacy cursor epoch for older clients.
epoch: z.string().optional(),
});
export const FetchAgentTimelineRequestMessageSchema = z
.object({
type: z.literal("fetch_agent_timeline_request"),
agentId: z.string(),
requestId: z.string(),
direction: z.enum(["tail", "before", "after"]).optional(),
cursor: AgentTimelineCursorSchema.optional(),
// 0 means "all matching rows for this query window".
limit: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain removed projection so older clients can still send it.
projection: z.enum(["canonical", "projected"]).optional(),
})
;
export const FetchAgentTimelineRequestMessageSchema = z.object({
type: z.literal("fetch_agent_timeline_request"),
agentId: z.string(),
requestId: z.string(),
direction: z.enum(["tail", "before", "after"]).optional(),
cursor: AgentTimelineCursorSchema.optional(),
// 0 means "all matching rows for this query window".
limit: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain removed projection so older clients can still send it.
projection: z.enum(["canonical", "projected"]).optional(),
});
export const SetAgentModeRequestMessageSchema = z.object({
type: z.literal("set_agent_mode_request"),
@@ -1232,7 +1233,6 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
requestId: z.string(),
});
// TODO(2026-07): Remove once most clients are on >=0.1.50 and support arbitrary editor ids.
export const LEGACY_EDITOR_TARGET_IDS = [
"cursor",
@@ -2030,20 +2030,18 @@ export const AgentUpdateMessageSchema = z.object({
]),
});
export const AgentStreamMessageSchema = z
.object({
type: z.literal("agent_stream"),
payload: z.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present only for committed timeline events.
seq: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain removed epoch for older clients.
epoch: z.string().optional(),
}),
})
;
export const AgentStreamMessageSchema = z.object({
type: z.literal("agent_stream"),
payload: z.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present only for committed timeline events.
seq: z.number().int().nonnegative().optional(),
// COMPAT(timeline): retain removed epoch for older clients.
epoch: z.string().optional(),
}),
});
export const AgentStatusMessageSchema = z.object({
type: z.literal("agent_status"),
@@ -2233,40 +2231,38 @@ export const AgentTimelineEntryPayloadSchema = z
};
});
export const FetchAgentTimelineResponseMessageSchema = z
.object({
type: z.literal("fetch_agent_timeline_response"),
payload: z
.object({
requestId: z.string(),
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
startSeq: z.number().int().nonnegative().nullable().optional(),
endSeq: z.number().int().nonnegative().nullable().optional(),
hasOlder: z.boolean().optional(),
hasNewer: z.boolean().optional(),
entries: z.array(AgentTimelineEntryPayloadSchema),
error: z.string().nullable(),
// COMPAT(timeline): retain legacy response baggage for older clients.
epoch: z.string().optional(),
reset: z.boolean().optional(),
staleCursor: z.boolean().optional(),
gap: z.unknown().optional(),
window: z.unknown().optional(),
startCursor: AgentTimelineCursorSchema.nullable().optional(),
endCursor: AgentTimelineCursorSchema.nullable().optional(),
projection: z.enum(["canonical", "projected"]).optional(),
})
.transform((payload) => ({
...payload,
startSeq: payload.startSeq ?? payload.startCursor?.seq ?? null,
endSeq: payload.endSeq ?? payload.endCursor?.seq ?? null,
hasOlder: payload.hasOlder ?? false,
hasNewer: payload.hasNewer ?? false,
})),
})
;
export const FetchAgentTimelineResponseMessageSchema = z.object({
type: z.literal("fetch_agent_timeline_response"),
payload: z
.object({
requestId: z.string(),
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
startSeq: z.number().int().nonnegative().nullable().optional(),
endSeq: z.number().int().nonnegative().nullable().optional(),
hasOlder: z.boolean().optional(),
hasNewer: z.boolean().optional(),
entries: z.array(AgentTimelineEntryPayloadSchema),
error: z.string().nullable(),
// COMPAT(timeline): retain legacy response baggage for older clients.
epoch: z.string().optional(),
reset: z.boolean().optional(),
staleCursor: z.boolean().optional(),
gap: z.unknown().optional(),
window: z.unknown().optional(),
startCursor: AgentTimelineCursorSchema.nullable().optional(),
endCursor: AgentTimelineCursorSchema.nullable().optional(),
projection: z.enum(["canonical", "projected"]).optional(),
})
.transform((payload) => ({
...payload,
startSeq: payload.startSeq ?? payload.startCursor?.seq ?? null,
endSeq: payload.endSeq ?? payload.endCursor?.seq ?? null,
hasOlder: payload.hasOlder ?? false,
hasNewer: payload.hasNewer ?? false,
})),
});
export const SendAgentMessageResponseMessageSchema = z.object({
type: z.literal("send_agent_message_response"),
@@ -3274,7 +3270,9 @@ export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
export type StartWorkspaceScriptRequest = z.infer<typeof StartWorkspaceScriptRequestSchema>;
export type StartWorkspaceScriptResponse = z.infer<typeof StartWorkspaceScriptResponseMessageSchema>;
export type StartWorkspaceScriptResponse = z.infer<
typeof StartWorkspaceScriptResponseMessageSchema
>;
export type SubscribeTerminalRequest = z.infer<typeof SubscribeTerminalRequestSchema>;
export type SubscribeTerminalResponse = z.infer<typeof SubscribeTerminalResponseSchema>;
export type UnsubscribeTerminalRequest = z.infer<typeof UnsubscribeTerminalRequestSchema>;
+28 -14
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env node
import { Command } from "commander";
import { spawnSync } from "node:child_process";
import { appendFileSync, existsSync, openSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { FileTaskStore } from "./task-store.js";
import { computeExecutionOrder, buildSortedChildrenMap } from "./execution-order.js";
import { resolvePackageVersion } from "../server/package-version.js";
import { spawnProcess } from "../utils/spawn.js";
import type { AgentType, ModelName, Task } from "./types.js";
const TASKS_DIR = resolve(process.cwd(), ".tasks");
@@ -638,11 +638,11 @@ function getAgentConfig(modelStr: string): AgentConfig {
return { cli: "claude", model };
}
function runAgentWithModel(
async function runAgentWithModel(
prompt: string,
modelStr: string,
logFile: string,
): { success: boolean; output: string } {
): Promise<{ success: boolean; output: string }> {
const config = getAgentConfig(modelStr);
let args: string[];
@@ -667,20 +667,34 @@ function runAgentWithModel(
args.push(prompt);
}
const result = spawnSync(config.cli, args, {
stdio: ["inherit", "pipe", "pipe"],
cwd: process.cwd(),
maxBuffer: 50 * 1024 * 1024,
const { stdout, stderr, exitCode } = await new Promise<{
stdout: string;
stderr: string;
exitCode: number | null;
}>((resolve, reject) => {
const child = spawnProcess(config.cli, args, {
stdio: ["inherit", "pipe", "pipe"],
cwd: process.cwd(),
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
child.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
child.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
child.on("error", reject);
child.on("close", (code) => {
resolve({
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
stderr: Buffer.concat(stderrChunks).toString("utf8"),
exitCode: code,
});
});
});
const stdout = result.stdout?.toString() ?? "";
const stderr = result.stderr?.toString() ?? "";
const output = stdout + stderr;
// Append to log file for history
appendFileSync(logFile, output);
return { success: result.status === 0, output };
return { success: exitCode === 0, output };
}
async function buildTaskContext(task: Task, scopeId?: string): Promise<string> {
@@ -1102,7 +1116,7 @@ program
log(logFile, `[PLANNER] Running ${plannerModel} (${reason})...`);
const context = await buildTaskContext(task, scopeId);
const plannerPrompt = makePlannerPrompt(task, context, reason);
runAgentWithModel(plannerPrompt, plannerModel, logFile);
await runAgentWithModel(plannerPrompt, plannerModel, logFile);
// Check if planner created subtasks for this task
const children = await store.getChildren(task.id);
@@ -1221,7 +1235,7 @@ program
const freshContext = await buildTaskContext(freshTask, scopeId);
const workerPrompt = makeWorkerPrompt(freshTask, freshContext, iteration);
runAgentWithModel(workerPrompt, workerModel, logFile);
await runAgentWithModel(workerPrompt, workerModel, logFile);
// Step 3: Judge
log(logFile, `[JUDGE] Verifying with ${judgeModel}...`);
@@ -1229,7 +1243,7 @@ program
if (!judgeTask) break;
const judgePrompt = makeJudgePrompt(judgeTask);
const judgeResult = runAgentWithModel(judgePrompt, judgeModel, logFile);
const judgeResult = await runAgentWithModel(judgePrompt, judgeModel, logFile);
const verdict = parseJudgeVerdict(judgeResult.output);
log(logFile, `[JUDGE] Verdict: ${verdict || "UNKNOWN"}`);
@@ -301,7 +301,8 @@ describe("TerminalManager", () => {
describe("subscribeTerminalsChanged", () => {
it("emits cwd snapshots when terminals are created", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = [];
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> =
[];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push({
cwd: input.cwd,
@@ -327,37 +328,33 @@ describe("TerminalManager", () => {
unsubscribe();
});
it(
"emits updated terminal titles after debounced title changes",
async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
})),
);
});
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
await waitForCondition(
() =>
snapshots.some((snapshot) =>
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"),
),
10000,
it("emits updated terminal titles after debounced title changes", async () => {
await withShell("/bin/sh", async () => {
manager = createTerminalManager();
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
})),
);
unsubscribe();
});
},
10000,
);
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
await waitForCondition(
() =>
snapshots.some((snapshot) =>
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"),
),
10000,
);
unsubscribe();
});
}, 10000);
it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager();

Some files were not shown because too many files have changed in this diff Show More