fix: centralize Windows exec handling and async migrations (#318)
* Add Windows exec foundation helpers * server: centralize windows exec handling phase 2 * migrate cli daemon status to execCommand * refactor: complete phase 4 async exec migrations * test: cover windows exec helpers * fix: use beforeEach for context.skip in test setup
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { execCommand } from "@getpaseo/server";
|
||||
|
||||
export interface NodePathFromPidResult {
|
||||
nodePath: string | null;
|
||||
@@ -13,63 +13,37 @@ function normalizeError(error: unknown): string {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function resolveNodePathFromPidUnix(pid: number): NodePathFromPidResult {
|
||||
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return { nodePath: null, error: `ps failed: ${normalizeError(result.error)}` };
|
||||
async function resolveNodePathFromPidUnix(pid: number): Promise<NodePathFromPidResult> {
|
||||
try {
|
||||
const { stdout } = await execCommand("ps", ["-o", "comm=", "-p", String(pid)]);
|
||||
const resolved = stdout.trim();
|
||||
return resolved
|
||||
? { nodePath: resolved }
|
||||
: { nodePath: null, error: "ps returned an empty command path" };
|
||||
} catch (error) {
|
||||
return { nodePath: null, error: `ps failed: ${normalizeError(error)}` };
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
const details = result.stderr?.trim();
|
||||
return {
|
||||
nodePath: null,
|
||||
error: details ? `ps failed: ${details}` : `ps exited with code ${result.status ?? 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = result.stdout.trim();
|
||||
return resolved
|
||||
? { nodePath: resolved }
|
||||
: { nodePath: null, error: "ps returned an empty command path" };
|
||||
}
|
||||
|
||||
function runProcessProbe(
|
||||
async function runProcessProbe(
|
||||
command: string,
|
||||
args: string[],
|
||||
): {
|
||||
): Promise<{
|
||||
resolved: string | null;
|
||||
error?: string;
|
||||
} {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return { resolved: null, error: `${command} failed: ${normalizeError(result.error)}` };
|
||||
}> {
|
||||
try {
|
||||
const { stdout } = await execCommand(command, args);
|
||||
const resolved = stdout.trim();
|
||||
return resolved
|
||||
? { resolved }
|
||||
: { resolved: null, error: `${command} returned no executable path` };
|
||||
} catch (error) {
|
||||
return { resolved: null, error: `${command} failed: ${normalizeError(error)}` };
|
||||
}
|
||||
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
const details = result.stderr?.trim();
|
||||
return {
|
||||
resolved: null,
|
||||
error: details
|
||||
? `${command} failed: ${details}`
|
||||
: `${command} exited with code ${result.status ?? 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = result.stdout.trim();
|
||||
return resolved
|
||||
? { resolved }
|
||||
: { resolved: null, error: `${command} returned no executable path` };
|
||||
}
|
||||
|
||||
function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
|
||||
async function resolveNodePathFromPidWindows(pid: number): Promise<NodePathFromPidResult> {
|
||||
const probes: Array<{
|
||||
label: string;
|
||||
command: string;
|
||||
@@ -103,7 +77,7 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const probe of probes) {
|
||||
const result = runProcessProbe(probe.command, probe.args);
|
||||
const result = await runProcessProbe(probe.command, probe.args);
|
||||
if (result.resolved) {
|
||||
const resolved = probe.parseValue ? probe.parseValue(result.resolved) : result.resolved;
|
||||
if (resolved) {
|
||||
@@ -123,8 +97,8 @@ function resolveNodePathFromPidWindows(pid: number): NodePathFromPidResult {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
|
||||
export async function resolveNodePathFromPid(pid: number): Promise<NodePathFromPidResult> {
|
||||
return platform() === "win32"
|
||||
? resolveNodePathFromPidWindows(pid)
|
||||
: resolveNodePathFromPidUnix(pid);
|
||||
? await resolveNodePathFromPidWindows(pid)
|
||||
: await resolveNodePathFromPidUnix(pid);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Command } from "commander";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { promisify } from "node:util";
|
||||
import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
import {
|
||||
getOrCreateServerId,
|
||||
findExecutable,
|
||||
applyProviderEnv,
|
||||
execCommand,
|
||||
} from "@getpaseo/server";
|
||||
import { tryConnectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
|
||||
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
|
||||
@@ -178,11 +179,9 @@ async function checkProviderBinary(
|
||||
}
|
||||
const env = applyProviderEnv(process.env);
|
||||
try {
|
||||
const { stdout } = await execFileAsync(binaryPath, ["--version"], {
|
||||
encoding: "utf8",
|
||||
const { stdout } = await execCommand(binaryPath, ["--version"], {
|
||||
timeout: 5000,
|
||||
env,
|
||||
windowsHide: true,
|
||||
});
|
||||
return { path: binaryPath, version: stdout.trim() || null };
|
||||
} catch {
|
||||
@@ -224,7 +223,7 @@ export async function runStatusCommand(
|
||||
if (!state.running) {
|
||||
daemonNode = "-";
|
||||
} else if (state.pidInfo?.pid) {
|
||||
const fromPid = resolveNodePathFromPid(state.pidInfo.pid);
|
||||
const fromPid = await resolveNodePathFromPid(state.pidInfo.pid);
|
||||
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
|
||||
} else {
|
||||
daemonNode = "unknown (no PID available)";
|
||||
|
||||
@@ -123,11 +123,13 @@ async function verifyInjectedMcpForProvider(
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!isProviderAvailable("claude")) {
|
||||
const claudeAvailable = await isProviderAvailable("claude");
|
||||
if (!claudeAvailable) {
|
||||
throw new Error(
|
||||
"Claude is not available in this environment. Ensure the `claude` binary and credentials are configured.",
|
||||
);
|
||||
}
|
||||
const codexAvailable = await isProviderAvailable("codex");
|
||||
|
||||
const logger = pino({ level: "silent" });
|
||||
const rootCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-mcp-inject-real-"));
|
||||
@@ -136,7 +138,7 @@ async function main(): Promise<void> {
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: {
|
||||
claude: new ClaudeAgentClient({ logger }),
|
||||
...(isProviderAvailable("codex") ? { codex: new CodexAppServerAgentClient(logger) } : {}),
|
||||
...(codexAvailable ? { codex: new CodexAppServerAgentClient(logger) } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
@@ -159,7 +161,7 @@ async function main(): Promise<void> {
|
||||
results.push(claudeResult);
|
||||
console.log(`[PASS] Claude MCP injection verified for agent ${claudeResult.agentId}`);
|
||||
|
||||
if (isProviderAvailable("codex")) {
|
||||
if (codexAvailable) {
|
||||
const codexResult = await verifyInjectedMcpForProvider(client, "codex", codexCwd);
|
||||
createdAgentIds.push(codexResult.agentId);
|
||||
results.push(codexResult);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+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";
|
||||
@@ -73,12 +72,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 { executableExists, findExecutable } 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";
|
||||
@@ -1130,7 +1128,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 executableExists(command.argv[0]) !== null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1186,14 +1184,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;
|
||||
}
|
||||
@@ -1203,11 +1197,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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { executableExists, findExecutable } 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";
|
||||
@@ -4203,7 +4203,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 executableExists(command.argv[0]) !== null;
|
||||
}
|
||||
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";
|
||||
|
||||
+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 { executableExists, findExecutable } from "../../../utils/executable.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
import {
|
||||
@@ -917,7 +916,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 executableExists(command.argv[0]) !== null;
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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
-87
@@ -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,98 +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,
|
||||
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);
|
||||
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,
|
||||
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,
|
||||
);
|
||||
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
-44
@@ -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,54 +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,
|
||||
projection: "canonical",
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -456,92 +456,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);
|
||||
});
|
||||
|
||||
@@ -33,11 +33,11 @@ export {
|
||||
export { applyProviderEnv } from "./agent/provider-launch-config.js";
|
||||
export {
|
||||
findExecutable,
|
||||
findExecutableSync,
|
||||
executableExists,
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { stat } from "fs/promises";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "util";
|
||||
import { resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -170,6 +170,7 @@ import { ChatServiceError, FileBackedChatService } from "./chat/chat-service.js"
|
||||
import { notifyChatMentions } from "./chat/chat-mentions.js";
|
||||
import { LoopService } from "./loop-service.js";
|
||||
import { ScheduleService } from "./schedule/service.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
import {
|
||||
assertSafeGitRef as assertWorktreeSafeGitRef,
|
||||
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
||||
@@ -182,7 +183,6 @@ import {
|
||||
} from "./worktree-session.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
||||
@@ -3531,7 +3531,7 @@ export class Session {
|
||||
private async checkoutExistingBranch(cwd: string, branch: string): Promise<void> {
|
||||
this.assertSafeGitRef(branch, "branch");
|
||||
try {
|
||||
await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd });
|
||||
await execCommand("git", ["rev-parse", "--verify", branch], { cwd });
|
||||
} catch (error) {
|
||||
throw new Error(`Branch not found: ${branch}`);
|
||||
}
|
||||
@@ -3545,7 +3545,7 @@ export class Session {
|
||||
}
|
||||
|
||||
await this.ensureCleanWorkingTree(cwd);
|
||||
await execFileAsync("git", ["checkout", branch], { cwd });
|
||||
await execCommand("git", ["checkout", branch], { cwd });
|
||||
}
|
||||
|
||||
private async createBranchFromBase(params: {
|
||||
@@ -3558,7 +3558,7 @@ export class Session {
|
||||
this.assertSafeGitRef(newBranchName, "new branch");
|
||||
|
||||
try {
|
||||
await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd });
|
||||
await execCommand("git", ["rev-parse", "--verify", baseBranch], { cwd });
|
||||
} catch (error) {
|
||||
throw new Error(`Base branch not found: ${baseBranch}`);
|
||||
}
|
||||
@@ -3569,7 +3569,7 @@ export class Session {
|
||||
}
|
||||
|
||||
await this.ensureCleanWorkingTree(cwd);
|
||||
await execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], {
|
||||
await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], {
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
@@ -3577,7 +3577,7 @@ export class Session {
|
||||
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
|
||||
this.assertSafeGitRef(branch, "branch");
|
||||
try {
|
||||
await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
||||
await execCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
||||
cwd,
|
||||
});
|
||||
return true;
|
||||
@@ -4030,7 +4030,7 @@ export class Session {
|
||||
|
||||
// Try local branch first
|
||||
try {
|
||||
await execFileAsync("git", ["rev-parse", "--verify", branchName], {
|
||||
await execCommand("git", ["rev-parse", "--verify", branchName], {
|
||||
cwd: resolvedCwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -4051,7 +4051,7 @@ export class Session {
|
||||
|
||||
// Try remote branch (origin/{branchName})
|
||||
try {
|
||||
await execFileAsync("git", ["rev-parse", "--verify", `origin/${branchName}`], {
|
||||
await execCommand("git", ["rev-parse", "--verify", `origin/${branchName}`], {
|
||||
cwd: resolvedCwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -4281,7 +4281,7 @@ export class Session {
|
||||
const message = branchLabel
|
||||
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
|
||||
: `${Session.PASEO_STASH_PREFIX} unnamed`;
|
||||
await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
|
||||
await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
this.emit({
|
||||
type: "stash_save_response",
|
||||
@@ -4300,7 +4300,7 @@ export class Session {
|
||||
): Promise<void> {
|
||||
const { cwd, stashIndex, requestId } = msg;
|
||||
try {
|
||||
await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
|
||||
await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
|
||||
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
|
||||
this.emit({
|
||||
type: "stash_pop_response",
|
||||
|
||||
@@ -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"}`);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { resolve, dirname, basename } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, stat as statFile } from "fs/promises";
|
||||
@@ -8,10 +6,9 @@ import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
import { findExecutable } from "./executable.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
import { execCommand } from "./spawn.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
@@ -1899,7 +1896,7 @@ export async function createPullRequest(
|
||||
if (options.body) {
|
||||
args.push("-f", `body=${options.body}`);
|
||||
}
|
||||
const { stdout } = await execFileAsync(ghPath, args, { cwd, env: ghEnv });
|
||||
const { stdout } = await execCommand(ghPath, args, { cwd, env: ghEnv });
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
if (!parsed?.url || !parsed?.number) {
|
||||
throw new Error("GitHub CLI did not return PR url/number");
|
||||
@@ -1951,7 +1948,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
const { stdout } = await execCommand(
|
||||
ghPath,
|
||||
["pr", "view", "--json", "url,title,state,baseRefName,headRefName,mergedAt"],
|
||||
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
|
||||
|
||||
@@ -1,78 +1,209 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
|
||||
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
|
||||
|
||||
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutableSync>[1]>;
|
||||
async function loadExecutableModule(params?: {
|
||||
execFileImpl?: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: unknown,
|
||||
callback: ExecFileCallback,
|
||||
) => void;
|
||||
}) {
|
||||
vi.resetModules();
|
||||
|
||||
function createFindExecutableDependencies(): FindExecutableDependencies {
|
||||
const execFileMock = vi.fn(
|
||||
params?.execFileImpl ??
|
||||
((_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(new Error("execFile not mocked"), "", "");
|
||||
}),
|
||||
);
|
||||
Object.assign(execFileMock, {
|
||||
[promisify.custom]: (command: string, args: string[], options: unknown) =>
|
||||
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
execFileMock(
|
||||
command,
|
||||
args,
|
||||
options,
|
||||
(error: Error | null, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
vi.doMock("node:child_process", () => ({
|
||||
execFile: execFileMock,
|
||||
}));
|
||||
const module = await import("./executable.js");
|
||||
return {
|
||||
execFileSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
platform: vi.fn(() => "darwin"),
|
||||
...module,
|
||||
execFileMock,
|
||||
};
|
||||
}
|
||||
|
||||
let findExecutableDependencies: FindExecutableDependencies;
|
||||
describe("findExecutable", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
findExecutableDependencies = createFindExecutableDependencies();
|
||||
});
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
describe("findExecutableSync", () => {
|
||||
test("on Windows, resolves executables using where.exe with inherited PATH", () => {
|
||||
findExecutableDependencies.platform = vi.fn(() => "win32");
|
||||
findExecutableDependencies.execFileSync.mockReturnValue(
|
||||
"C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n",
|
||||
);
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
expect(findExecutableSync("claude", findExecutableDependencies)).toBe(
|
||||
test("on Windows, resolves executables using where.exe with inherited PATH", async () => {
|
||||
setPlatform("win32");
|
||||
const { execFileMock, findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("claude")).resolves.toBe(
|
||||
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
|
||||
);
|
||||
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledOnce();
|
||||
const call = findExecutableDependencies.execFileSync.mock.calls[0];
|
||||
expect(execFileMock).toHaveBeenCalledOnce();
|
||||
const call = execFileMock.mock.calls[0];
|
||||
expect(call?.[0]).toBe("where.exe");
|
||||
expect(call?.[1]).toEqual(["claude"]);
|
||||
expect(call?.[2]?.encoding).toBe("utf8");
|
||||
expect(call?.[2]?.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
test("on Windows, preserves the first where.exe match", () => {
|
||||
findExecutableDependencies.platform = vi.fn(() => "win32");
|
||||
findExecutableDependencies.execFileSync.mockReturnValue(
|
||||
"C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n",
|
||||
);
|
||||
|
||||
expect(findExecutableSync("codex", findExecutableDependencies)).toBe(
|
||||
"C:\\nvm4w\\nodejs\\codex",
|
||||
);
|
||||
});
|
||||
|
||||
test("on Unix, uses the last line from which output", () => {
|
||||
findExecutableDependencies.execFileSync.mockReturnValue("/usr/local/bin/codex\n");
|
||||
|
||||
expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
|
||||
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith("which", ["codex"], {
|
||||
expect(call?.[2]).toMatchObject({
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("warns and returns null when the final which line is not an absolute path", () => {
|
||||
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
test("on Windows, prefers an executable match from where.exe output", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
expect(findExecutableSync("codex", findExecutableDependencies)).toBeNull();
|
||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.cmd");
|
||||
});
|
||||
|
||||
test("on Windows, prefers .exe over .cmd, .ps1, and extensionless candidates", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(
|
||||
null,
|
||||
[
|
||||
"C:\\nvm4w\\nodejs\\codex",
|
||||
"C:\\nvm4w\\nodejs\\codex.ps1",
|
||||
"C:\\nvm4w\\nodejs\\codex.cmd",
|
||||
"C:\\nvm4w\\nodejs\\codex.exe",
|
||||
].join("\r\n"),
|
||||
"",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.exe");
|
||||
});
|
||||
|
||||
test("on Windows, returns null when where.exe output is empty", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("on Windows, falls back to the first extensionless candidate when needed", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "C:\\nvm4w\\nodejs\\codex\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("on Unix, uses the last line from which output", async () => {
|
||||
const { execFileMock, findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "/usr/local/bin/codex\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBe("/usr/local/bin/codex");
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
"which",
|
||||
["codex"],
|
||||
{ encoding: "utf8" },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
test("warns and returns null when the final which line is not an absolute path", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "codex\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("returns direct paths when they exist", () => {
|
||||
findExecutableDependencies.existsSync.mockReturnValue(true);
|
||||
test("returns null when which lookup fails", async () => {
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(new Error("which failed"), "", "");
|
||||
},
|
||||
});
|
||||
|
||||
expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe(
|
||||
"/usr/local/bin/codex",
|
||||
);
|
||||
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
|
||||
await expect(findExecutable("codex")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("executableExists", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("returns the path when it already exists", async () => {
|
||||
const { executableExists } = await loadExecutableModule();
|
||||
const exists = vi.fn((candidate: string) => candidate === "/usr/local/bin/codex");
|
||||
|
||||
expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex");
|
||||
});
|
||||
|
||||
test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", async () => {
|
||||
setPlatform("win32");
|
||||
const { executableExists } = await loadExecutableModule();
|
||||
const exists = vi.fn((candidate: string) => candidate === "C:\\tools\\codex.cmd");
|
||||
|
||||
expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd");
|
||||
});
|
||||
|
||||
test("returns null when no matching path exists", async () => {
|
||||
const { executableExists } = await loadExecutableModule();
|
||||
const exists = vi.fn(() => false);
|
||||
|
||||
expect(executableExists("/missing/codex", exists)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,27 +218,31 @@ describe("quoteWindowsCommand", () => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows path with spaces", () => {
|
||||
test("quotes a Windows path with spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted path", () => {
|
||||
test("does not double-quote an already-quoted path", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged when there are no spaces", () => {
|
||||
test("returns the command unchanged when there are no spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("returns the command unchanged on non-Windows platforms", () => {
|
||||
test("returns the command unchanged on non-Windows platforms", async () => {
|
||||
setPlatform("darwin");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
@@ -123,27 +258,31 @@ describe("quoteWindowsArgument", () => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows argument with spaces", () => {
|
||||
test("quotes a Windows argument with spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted argument", () => {
|
||||
test("does not double-quote an already-quoted argument", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the argument unchanged when there are no spaces", () => {
|
||||
test("returns the argument unchanged when there are no spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument("--version")).toBe("--version");
|
||||
});
|
||||
|
||||
test("returns the argument unchanged on non-Windows platforms", () => {
|
||||
test("returns the argument unchanged on non-Windows platforms", async () => {
|
||||
setPlatform("darwin");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import path from "node:path";
|
||||
import path, { extname } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface FindExecutableDependencies {
|
||||
execFileSync: typeof execFileSync;
|
||||
existsSync: typeof existsSync;
|
||||
platform: typeof platform;
|
||||
function pickBestWindowsCandidate(lines: string[]): string | null {
|
||||
const candidates = lines.filter((line) => line.length > 0);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const extPriority = [".exe", ".cmd", ".ps1"];
|
||||
for (const ext of extPriority) {
|
||||
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext));
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveExecutableFromWhichOutput(
|
||||
@@ -44,58 +50,18 @@ function resolveExecutableFromWhichOutput(
|
||||
* enriches it at startup via inheritLoginShellEnv(); on Windows, Electron
|
||||
* inherits the full user environment from Explorer.
|
||||
*/
|
||||
export function findExecutableSync(
|
||||
name: string,
|
||||
dependencies?: FindExecutableDependencies,
|
||||
export function executableExists(
|
||||
executablePath: string,
|
||||
exists: typeof existsSync = existsSync,
|
||||
): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deps: FindExecutableDependencies = {
|
||||
execFileSync,
|
||||
existsSync,
|
||||
platform,
|
||||
...dependencies,
|
||||
};
|
||||
|
||||
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
||||
return deps.existsSync(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
if (deps.platform() === "win32") {
|
||||
try {
|
||||
const out = deps
|
||||
.execFileSync("where.exe", [trimmed], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
})
|
||||
.trim();
|
||||
return (
|
||||
out
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
if (exists(executablePath)) return executablePath;
|
||||
if (process.platform === "win32" && !extname(executablePath)) {
|
||||
for (const ext of [".exe", ".cmd", ".ps1"]) {
|
||||
const candidate = executablePath + ext;
|
||||
if (exists(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return resolveExecutableFromWhichOutput(
|
||||
trimmed,
|
||||
deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(),
|
||||
"which",
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCommandAvailableSync(command: string): boolean {
|
||||
return findExecutableSync(command) !== null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExecutable(name: string): Promise<string | null> {
|
||||
@@ -105,21 +71,22 @@ export async function findExecutable(name: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
||||
return existsSync(trimmed) ? trimmed : null;
|
||||
return executableExists(trimmed);
|
||||
}
|
||||
|
||||
if (platform() === "win32") {
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("where.exe", [trimmed], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
return (
|
||||
stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? null
|
||||
pickBestWindowsCandidate(
|
||||
stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim()),
|
||||
) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { execCommand } from "./spawn.js";
|
||||
|
||||
describe("execCommand", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
test("returns stdout and stderr for a successful command", async () => {
|
||||
await expect(execCommand("echo", ["hello"])).resolves.toEqual({
|
||||
stdout: "hello\n",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects when the command times out", async () => {
|
||||
const command =
|
||||
process.platform === "win32"
|
||||
? {
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => {}, 10_000)"],
|
||||
}
|
||||
: { command: "sleep", args: ["10"] };
|
||||
|
||||
await expect(execCommand(command.command, command.args, { timeout: 100 })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("runs the command in the provided cwd", async () => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "spawn-test-")));
|
||||
tempDirs.push(cwd);
|
||||
|
||||
const command =
|
||||
process.platform === "win32"
|
||||
? {
|
||||
command: process.execPath,
|
||||
args: ["-e", "console.log(process.cwd())"],
|
||||
}
|
||||
: { command: "pwd", args: [] };
|
||||
|
||||
await expect(execCommand(command.command, command.args, { cwd })).resolves.toEqual({
|
||||
stdout: `${cwd}\n`,
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,27 @@
|
||||
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface ExecCommandOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
encoding?: BufferEncoding;
|
||||
timeout?: number;
|
||||
maxBuffer?: number;
|
||||
}
|
||||
|
||||
interface ExecCommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function quoteForCmd(value: string): string {
|
||||
if (!value.includes(" ")) return value;
|
||||
if (value.startsWith('"') && value.endsWith('"')) return value;
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-aware spawn that centralizes Windows shell and quoting concerns.
|
||||
*
|
||||
* On Windows:
|
||||
* - Enables `shell: true` (routes through cmd.exe) unless the caller explicitly sets `shell`
|
||||
* - Quotes the command and arguments so paths with spaces survive cmd.exe parsing
|
||||
* - Always sets `windowsHide: true` to prevent console window flashes
|
||||
*
|
||||
* On other platforms the call is passed through to `spawn` unchanged (with `windowsHide: true`).
|
||||
*/
|
||||
export function spawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -27,12 +39,38 @@ export function spawnProcess(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a string for cmd.exe if it contains spaces and isn't already quoted.
|
||||
* No-op for strings without spaces or strings that are already double-quoted.
|
||||
*/
|
||||
function quoteForCmd(value: string): string {
|
||||
if (!value.includes(" ")) return value;
|
||||
if (value.startsWith('"') && value.endsWith('"')) return value;
|
||||
return `"${value}"`;
|
||||
export async function execCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: ExecCommandOptions,
|
||||
): Promise<ExecCommandResult> {
|
||||
const isWindows = process.platform === "win32";
|
||||
const resolvedCommand = isWindows ? quoteForCmd(command) : command;
|
||||
const resolvedArgs = isWindows ? args.map(quoteForCmd) : args;
|
||||
|
||||
return execFileAsync(resolvedCommand, resolvedArgs, {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
encoding: options?.encoding ?? "utf8",
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer,
|
||||
shell: isWindows,
|
||||
windowsHide: true,
|
||||
}) as Promise<ExecCommandResult>;
|
||||
}
|
||||
|
||||
export function platformShell(): { command: string; flag: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
return { command: "cmd.exe", flag: ["/c"] };
|
||||
}
|
||||
|
||||
return { command: "/bin/sh", flag: ["-lc"] };
|
||||
}
|
||||
|
||||
export function platformBash(): { command: string; flag: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
return { command: "cmd.exe", flag: ["/c"] };
|
||||
}
|
||||
|
||||
return { command: "/bin/bash", flag: ["-lc"] };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { exec, spawn } from "child_process";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
|
||||
import { join, basename, dirname, resolve, sep } from "path";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
writePaseoWorktreeRuntimeMetadata,
|
||||
} from "./worktree-metadata.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
import { platformBash, spawnProcess } from "./spawn.js";
|
||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
|
||||
interface PaseoConfig {
|
||||
@@ -204,7 +205,7 @@ async function execSetupCommand(
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
shell: "/bin/bash",
|
||||
...(process.platform === "win32" ? {} : { shell: "/bin/bash" }),
|
||||
});
|
||||
return {
|
||||
command,
|
||||
@@ -275,7 +276,8 @@ async function execSetupCommandStreamed(options: {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
|
||||
const child = spawn("/bin/bash", ["-lc", options.command], {
|
||||
const shell = platformBash();
|
||||
const child = spawnProcess(shell.command, [...shell.flag, options.command], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
Reference in New Issue
Block a user