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:
@@ -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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+264
-289
@@ -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",
|
||||
|
||||
+39
-31
@@ -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`);
|
||||
|
||||
+5
-1
@@ -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]);
|
||||
}
|
||||
}
|
||||
+16
-4
@@ -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({
|
||||
|
||||
+134
-142
@@ -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;
|
||||
}
|
||||
|
||||
+58
-54
@@ -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);
|
||||
});
|
||||
|
||||
+57
-49
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+95
-85
@@ -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);
|
||||
});
|
||||
|
||||
+50
-42
@@ -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);
|
||||
});
|
||||
|
||||
+75
-71
@@ -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);
|
||||
});
|
||||
|
||||
+89
-85
@@ -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);
|
||||
});
|
||||
|
||||
+52
-43
@@ -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);
|
||||
});
|
||||
|
||||
+108
-100
@@ -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);
|
||||
});
|
||||
|
||||
+104
-96
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user