2454 lines
77 KiB
TypeScript
2454 lines
77 KiB
TypeScript
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
import {
|
|
AgentSideConnection,
|
|
ClientSideConnection,
|
|
PROTOCOL_VERSION,
|
|
RequestError,
|
|
ndJsonStream,
|
|
type Agent,
|
|
PermissionOption,
|
|
PromptResponse,
|
|
RequestPermissionRequest,
|
|
SessionConfigOption,
|
|
SessionUpdate,
|
|
} from "@agentclientprotocol/sdk";
|
|
|
|
import {
|
|
ACPAgentClient,
|
|
ACPAgentSession,
|
|
type SpawnedACPProcess,
|
|
type SessionStateResponse,
|
|
createLoggedNdJsonStream,
|
|
deriveModelDefinitionsFromACP,
|
|
deriveModesFromACP,
|
|
mapACPUsage,
|
|
resolveACPModeSelection,
|
|
resolveACPModelSelection,
|
|
summarizeACPRequestError,
|
|
} from "./acp-agent.js";
|
|
import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js";
|
|
import {
|
|
COPILOT_AGENT_FEATURE_OPTION,
|
|
COPILOT_ALLOW_ALL_MODE_ID,
|
|
COPILOT_MODES,
|
|
CopilotACPAgentClient,
|
|
beforeCopilotModeWriter,
|
|
transformCopilotConfigOptions,
|
|
transformCopilotModeId,
|
|
transformCopilotSessionResponse,
|
|
writeCopilotProviderMode,
|
|
} from "./copilot-acp-agent.js";
|
|
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
|
import { transformPiModels } from "./pi/agent.js";
|
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
|
import { asInternals } from "../../test-utils/class-mocks.js";
|
|
import * as spawnUtils from "../../../utils/spawn.js";
|
|
|
|
interface ACPSessionInternals {
|
|
sessionId: string | null;
|
|
connection: { prompt: (...args: unknown[]) => Promise<PromptResponse> };
|
|
activeForegroundTurnId: string | null;
|
|
configOptions: SessionConfigOption[];
|
|
translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[];
|
|
acpMcpServers(): unknown[];
|
|
}
|
|
|
|
interface ACPModelSelectionInternals {
|
|
sessionId: string | null;
|
|
connection: {
|
|
setSessionConfigOption: (input: {
|
|
sessionId: string;
|
|
configId: string;
|
|
value: string;
|
|
}) => Promise<unknown>;
|
|
};
|
|
configOptions: SessionConfigOption[];
|
|
}
|
|
|
|
interface ACPConfiguredOverrideInternals {
|
|
sessionId: string | null;
|
|
connection: {
|
|
setSessionMode: (input: { sessionId: string; modeId: string }) => Promise<void>;
|
|
setSessionConfigOption: (input: {
|
|
sessionId: string;
|
|
configId: string;
|
|
value: string;
|
|
}) => Promise<unknown>;
|
|
unstable_setSessionModel?: (input: { sessionId: string; modelId: string }) => Promise<void>;
|
|
};
|
|
configOptions: SessionConfigOption[];
|
|
availableModes: Array<{ id: string; label: string; description?: string }>;
|
|
availableModels: Array<{ modelId: string; name: string; description?: string | null }> | null;
|
|
currentMode: string | null;
|
|
currentModel: string | null;
|
|
applyConfiguredOverrides(): Promise<void>;
|
|
}
|
|
|
|
function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession {
|
|
return new ACPAgentSession(
|
|
{
|
|
provider: "claude-acp",
|
|
cwd: "/tmp/paseo-acp-test",
|
|
},
|
|
{
|
|
provider: "claude-acp",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["claude", "--acp"],
|
|
defaultModes: [],
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
...(terminateProcess ? { terminateProcess } : {}),
|
|
},
|
|
);
|
|
}
|
|
|
|
// Typed substitute for the real tree-kill terminator. Records which child
|
|
// processes it was asked to terminate, so tests assert on observable state
|
|
// instead of spying on the production function. In "deferred" mode the
|
|
// terminations hang until releaseAll(), letting tests observe parallelism.
|
|
class FakeTerminator {
|
|
readonly terminated: TreeKillTarget[] = [];
|
|
private readonly pending: Array<() => void> = [];
|
|
|
|
constructor(private readonly mode: "immediate" | "deferred" = "immediate") {}
|
|
|
|
readonly terminate: ProcessTerminator = async (child) => {
|
|
this.terminated.push(child);
|
|
if (this.mode === "deferred") {
|
|
await new Promise<void>((resolve) => this.pending.push(resolve));
|
|
}
|
|
return "terminated";
|
|
};
|
|
|
|
releaseAll(): void {
|
|
for (const resolve of this.pending.splice(0)) {
|
|
resolve();
|
|
}
|
|
}
|
|
}
|
|
|
|
function createSessionWithConfig(
|
|
config: { provider?: string; modeId?: string | null; model?: string | null } = {},
|
|
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
|
|
): ACPAgentSession {
|
|
return new ACPAgentSession(
|
|
{
|
|
provider: config.provider ?? "claude-acp",
|
|
cwd: "/tmp/paseo-acp-test",
|
|
modeId: config.modeId ?? undefined,
|
|
model: config.model ?? undefined,
|
|
},
|
|
{
|
|
provider: config.provider ?? "claude-acp",
|
|
logger,
|
|
defaultCommand: ["claude", "--acp"],
|
|
defaultModes: [],
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function createTerminalChildStub(): ChildProcess {
|
|
const child = new EventEmitter() as ChildProcess;
|
|
child.stdout = new EventEmitter() as ChildProcess["stdout"];
|
|
child.stderr = new EventEmitter() as ChildProcess["stderr"];
|
|
child.kill = vi.fn(() => true) as ChildProcess["kill"];
|
|
return child;
|
|
}
|
|
|
|
function createDestroyableStream(): { destroyed: boolean; destroy: () => void } {
|
|
const stream = {
|
|
destroyed: false,
|
|
destroy() {
|
|
stream.destroyed = true;
|
|
},
|
|
};
|
|
return stream;
|
|
}
|
|
|
|
function createProbeChildStub(): ChildProcessWithoutNullStreams {
|
|
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
|
child.stdin = createDestroyableStream() as unknown as ChildProcessWithoutNullStreams["stdin"];
|
|
child.stdout = createDestroyableStream() as unknown as ChildProcessWithoutNullStreams["stdout"];
|
|
child.stderr = createDestroyableStream() as unknown as ChildProcessWithoutNullStreams["stderr"];
|
|
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
|
|
return child;
|
|
}
|
|
|
|
function selectConfigOption(
|
|
category: "mode" | "model" | "thought_level",
|
|
values: string[],
|
|
currentValue = values[0] ?? "",
|
|
): SessionConfigOption {
|
|
return {
|
|
id: `${category}-option`,
|
|
name: selectConfigOptionName(category),
|
|
category,
|
|
type: "select",
|
|
currentValue,
|
|
options: values.map((value) => ({ value, name: value })),
|
|
};
|
|
}
|
|
|
|
function createCopilotSessionWithConfig(
|
|
modeId?: string | null,
|
|
featureValues?: Record<string, unknown>,
|
|
): ACPAgentSession {
|
|
return new ACPAgentSession(
|
|
{
|
|
provider: "copilot",
|
|
cwd: "/tmp/paseo-acp-test",
|
|
modeId: modeId ?? undefined,
|
|
...(featureValues ? { featureValues } : {}),
|
|
},
|
|
{
|
|
provider: "copilot",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["copilot", "--acp"],
|
|
defaultModes: COPILOT_MODES,
|
|
sessionResponseTransformer: transformCopilotSessionResponse,
|
|
configOptionsTransformer: transformCopilotConfigOptions,
|
|
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
|
|
modeIdTransformer: transformCopilotModeId,
|
|
providerModeWriter: writeCopilotProviderMode,
|
|
beforeModeWriter: beforeCopilotModeWriter,
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
function copilotModeConfigOption(currentValue: string): SessionConfigOption {
|
|
return {
|
|
id: "mode",
|
|
name: "Mode",
|
|
category: "mode",
|
|
type: "select",
|
|
currentValue,
|
|
options: [
|
|
{
|
|
value: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
|
name: "Agent",
|
|
},
|
|
{
|
|
value: "https://agentclientprotocol.com/protocol/session-modes#plan",
|
|
name: "Plan",
|
|
},
|
|
{
|
|
value: "https://agentclientprotocol.com/protocol/session-modes#autopilot",
|
|
name: "Autopilot",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function copilotAllowAllConfigOption(currentValue: "on" | "off"): SessionConfigOption {
|
|
return {
|
|
id: "allow_all",
|
|
name: "Allow All",
|
|
category: "permissions",
|
|
type: "select",
|
|
currentValue,
|
|
options: [
|
|
{ value: "on", name: "On" },
|
|
{ value: "off", name: "Off" },
|
|
],
|
|
};
|
|
}
|
|
|
|
function copilotAgentConfigOption(currentValue: string): SessionConfigOption {
|
|
return {
|
|
id: "agent",
|
|
name: "Agent",
|
|
category: "_agent",
|
|
type: "select",
|
|
currentValue,
|
|
options: [
|
|
{
|
|
value: "",
|
|
name: "",
|
|
},
|
|
{
|
|
value: "Probe Agent",
|
|
name: "Probe Agent",
|
|
description: "Temporary probe agent",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function selectConfigOptionName(category: "mode" | "model" | "thought_level"): string {
|
|
if (category === "mode") {
|
|
return "Mode";
|
|
}
|
|
if (category === "model") {
|
|
return "Model";
|
|
}
|
|
return "Thinking";
|
|
}
|
|
|
|
function prepareConfiguredOverrideSession(
|
|
session: ACPAgentSession,
|
|
options: {
|
|
currentMode?: string | null;
|
|
availableModes?: Array<{ id: string; label: string; description?: string }>;
|
|
currentModel?: string | null;
|
|
availableModels?: Array<{ modelId: string; name: string; description?: string | null }> | null;
|
|
configOptions?: SessionConfigOption[];
|
|
connection?: Partial<ACPConfiguredOverrideInternals["connection"]>;
|
|
} = {},
|
|
): {
|
|
internals: ACPConfiguredOverrideInternals;
|
|
setSessionMode: ReturnType<typeof vi.fn>;
|
|
unstableSetSessionModel: ReturnType<typeof vi.fn>;
|
|
setSessionConfigOption: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const setSessionMode = vi.fn(async () => undefined);
|
|
const unstableSetSessionModel = vi.fn(async () => undefined);
|
|
const setSessionConfigOption = vi.fn(async () => ({
|
|
configOptions: options.configOptions ?? [],
|
|
}));
|
|
const internals = asInternals<ACPConfiguredOverrideInternals>(session);
|
|
internals.sessionId = "session-1";
|
|
internals.connection = {
|
|
setSessionMode,
|
|
setSessionConfigOption,
|
|
unstable_setSessionModel: unstableSetSessionModel,
|
|
...options.connection,
|
|
};
|
|
internals.availableModes = options.availableModes ?? [];
|
|
internals.availableModels = options.availableModels ?? null;
|
|
internals.configOptions = options.configOptions ?? [];
|
|
internals.currentMode = options.currentMode ?? null;
|
|
internals.currentModel = options.currentModel ?? null;
|
|
|
|
return { internals, setSessionMode, unstableSetSessionModel, setSessionConfigOption };
|
|
}
|
|
|
|
test("ACP setModel only uses config-option fallback when the matching select choice contains the model", async () => {
|
|
const logger = createTestLogger();
|
|
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
|
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
|
const session = createSessionWithConfig({}, logger);
|
|
const setSessionConfigOption = vi.fn(async () => ({
|
|
configOptions: [
|
|
{
|
|
id: "model-option",
|
|
name: "Model",
|
|
category: "model",
|
|
type: "select",
|
|
currentValue: "sonnet",
|
|
options: [{ value: "sonnet", name: "Sonnet" }],
|
|
},
|
|
],
|
|
}));
|
|
const internals = asInternals<ACPModelSelectionInternals>(session);
|
|
internals.sessionId = "session-1";
|
|
internals.connection = { setSessionConfigOption };
|
|
internals.configOptions = [
|
|
{
|
|
id: "model-option",
|
|
name: "Model",
|
|
category: "model",
|
|
type: "select",
|
|
currentValue: "sonnet",
|
|
options: [{ value: "sonnet", name: "Sonnet" }],
|
|
},
|
|
];
|
|
|
|
await session.setModel("sonnet");
|
|
|
|
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
|
sessionId: "session-1",
|
|
configId: "model-option",
|
|
value: "sonnet",
|
|
});
|
|
|
|
setSessionConfigOption.mockClear();
|
|
|
|
await expect(session.setModel("new-provider-model")).resolves.toBeUndefined();
|
|
expect(childLogger.warn).toHaveBeenCalledWith(
|
|
{ value: "new-provider-model" },
|
|
expect.stringContaining("is not a valid claude-acp model config option"),
|
|
);
|
|
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe("createLoggedNdJsonStream", () => {
|
|
test("routes malformed ACP stdout through the provider logger instead of console.error", async () => {
|
|
const input = new TransformStream<Uint8Array, Uint8Array>();
|
|
const output = new TransformStream<Uint8Array, Uint8Array>();
|
|
const logger = {
|
|
warn: vi.fn(),
|
|
};
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const stream = createLoggedNdJsonStream(output.writable, input.readable, {
|
|
logger: asInternals<ReturnType<typeof createTestLogger>>(logger),
|
|
provider: "gemini",
|
|
});
|
|
const reader = stream.readable.getReader();
|
|
const writer = input.writable.getWriter();
|
|
|
|
await writer.write(
|
|
new TextEncoder().encode(
|
|
'Please visit the following URL to authorize the application:\n{"jsonrpc":"2.0","method":"ok","params":{}}\n',
|
|
),
|
|
);
|
|
|
|
const parsed = await reader.read();
|
|
|
|
expect(parsed.value).toEqual({ jsonrpc: "2.0", method: "ok", params: {} });
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
err: {
|
|
type: "SyntaxError",
|
|
message: "ACP stdout line was not valid JSON",
|
|
},
|
|
provider: "gemini",
|
|
}),
|
|
"ACP agent emitted non-JSON stdout; ignoring line",
|
|
);
|
|
expect(logger.warn.mock.calls[0]?.[0]).not.toHaveProperty("linePreview");
|
|
expect(consoleError).not.toHaveBeenCalled();
|
|
|
|
await writer.close();
|
|
reader.releaseLock();
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
test("normalizes stringified numeric ACP response ids", async () => {
|
|
const input = new TransformStream<Uint8Array, Uint8Array>();
|
|
const output = new TransformStream<Uint8Array, Uint8Array>();
|
|
const logger = {
|
|
warn: vi.fn(),
|
|
};
|
|
|
|
const stream = createLoggedNdJsonStream(output.writable, input.readable, {
|
|
logger: asInternals<ReturnType<typeof createTestLogger>>(logger),
|
|
provider: "deepseek-tui",
|
|
});
|
|
const reader = stream.readable.getReader();
|
|
const writer = input.writable.getWriter();
|
|
|
|
await writer.write(
|
|
new TextEncoder().encode('{"jsonrpc":"2.0","id":"0","result":{"ok":true}}\n'),
|
|
);
|
|
|
|
const parsed = await reader.read();
|
|
|
|
expect(parsed.value).toEqual({ jsonrpc: "2.0", id: 0, result: { ok: true } });
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
|
|
await writer.close();
|
|
reader.releaseLock();
|
|
});
|
|
|
|
test("does not log terminal control sequences from malformed ACP stdout", async () => {
|
|
const input = new TransformStream<Uint8Array, Uint8Array>();
|
|
const output = new TransformStream<Uint8Array, Uint8Array>();
|
|
const logger = {
|
|
warn: vi.fn(),
|
|
};
|
|
|
|
const stream = createLoggedNdJsonStream(output.writable, input.readable, {
|
|
logger: asInternals<ReturnType<typeof createTestLogger>>(logger),
|
|
provider: "gemini",
|
|
});
|
|
const reader = stream.readable.getReader();
|
|
const writer = input.writable.getWriter();
|
|
|
|
await writer.write(new TextEncoder().encode('\u001b[1G\u001b[0JEn\n{"ok":true}\n'));
|
|
|
|
const parsed = await reader.read();
|
|
|
|
expect(parsed.value).toEqual({ ok: true });
|
|
expect(JSON.stringify(logger.warn.mock.calls)).not.toContain("\u001b");
|
|
expect(JSON.stringify(logger.warn.mock.calls)).not.toContain("[1G");
|
|
expect(logger.warn.mock.calls[0]?.[0]).toEqual({
|
|
err: {
|
|
type: "SyntaxError",
|
|
message: "ACP stdout line was not valid JSON",
|
|
},
|
|
provider: "gemini",
|
|
});
|
|
|
|
await writer.close();
|
|
reader.releaseLock();
|
|
});
|
|
});
|
|
|
|
describe("ACPAgentSession terminal tools", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
test("runs single-string terminal commands through the platform shell", async () => {
|
|
const child = createTerminalChildStub();
|
|
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
|
const session = createSession();
|
|
const shell = spawnUtils.platformShell();
|
|
|
|
await session.createTerminal({
|
|
sessionId: "session-1",
|
|
command: "git -C /repo status --short",
|
|
cwd: "/repo",
|
|
});
|
|
|
|
expect(spawn).toHaveBeenCalledWith(
|
|
shell.command,
|
|
[...shell.flag, "git -C /repo status --short"],
|
|
expect.objectContaining({ cwd: "/repo" }),
|
|
);
|
|
});
|
|
|
|
test("preserves explicit terminal argv", async () => {
|
|
const child = createTerminalChildStub();
|
|
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
|
const session = createSession();
|
|
|
|
await session.createTerminal({
|
|
sessionId: "session-1",
|
|
command: "git",
|
|
args: ["status", "--short"],
|
|
cwd: "/repo",
|
|
});
|
|
|
|
expect(spawn).toHaveBeenCalledWith(
|
|
"git",
|
|
["status", "--short"],
|
|
expect.objectContaining({ cwd: "/repo" }),
|
|
);
|
|
});
|
|
|
|
test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => {
|
|
const child = createTerminalChildStub();
|
|
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
|
const session = createSession();
|
|
|
|
const terminal = await session.createTerminal({
|
|
sessionId: "session-1",
|
|
command: "missing-command",
|
|
});
|
|
child.emit("error", new Error("spawn missing-command ENOENT"));
|
|
|
|
await expect(
|
|
session.waitForTerminalExit({
|
|
sessionId: "session-1",
|
|
terminalId: terminal.terminalId,
|
|
}),
|
|
).rejects.toThrow("spawn missing-command ENOENT");
|
|
await expect(
|
|
session.terminalOutput({
|
|
sessionId: "session-1",
|
|
terminalId: terminal.terminalId,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
output: "spawn missing-command ENOENT\n",
|
|
truncated: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("mapACPUsage", () => {
|
|
test("maps ACP usage fields into Paseo usage", () => {
|
|
expect(
|
|
mapACPUsage({
|
|
inputTokens: 11,
|
|
outputTokens: 7,
|
|
totalTokens: 18,
|
|
cachedReadTokens: 5,
|
|
}),
|
|
).toEqual({
|
|
inputTokens: 11,
|
|
outputTokens: 7,
|
|
cachedInputTokens: 5,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("deriveModesFromACP", () => {
|
|
test("prefers explicit ACP mode state", () => {
|
|
const result = deriveModesFromACP(
|
|
[{ id: "fallback", label: "Fallback" }],
|
|
{
|
|
availableModes: [
|
|
{ id: "default", name: "Always Ask", description: "Prompt before tools" },
|
|
{ id: "plan", name: "Plan", description: "Read only" },
|
|
],
|
|
currentModeId: "plan",
|
|
},
|
|
[],
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
modes: [
|
|
{ id: "default", label: "Always Ask", description: "Prompt before tools" },
|
|
{ id: "plan", label: "Plan", description: "Read only" },
|
|
],
|
|
currentModeId: "plan",
|
|
});
|
|
});
|
|
|
|
test("falls back to config options when explicit mode state is absent", () => {
|
|
const result = deriveModesFromACP([{ id: "fallback", label: "Fallback" }], null, [
|
|
{
|
|
id: "mode",
|
|
name: "Mode",
|
|
category: "mode",
|
|
type: "select",
|
|
currentValue: "acceptEdits",
|
|
options: [
|
|
{ value: "default", name: "Always Ask" },
|
|
{ value: "acceptEdits", name: "Accept File Edits" },
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
modes: [
|
|
{ id: "default", label: "Always Ask", description: undefined },
|
|
{ id: "acceptEdits", label: "Accept File Edits", description: undefined },
|
|
],
|
|
currentModeId: "acceptEdits",
|
|
});
|
|
});
|
|
|
|
test("returns an empty mode list when fallback modes are empty and config only exposes thought levels", () => {
|
|
const result = deriveModesFromACP([], null, [
|
|
{
|
|
id: "thought_level",
|
|
name: "Thinking",
|
|
category: "thought_level",
|
|
type: "select",
|
|
currentValue: "medium",
|
|
options: [
|
|
{ value: "low", name: "Low" },
|
|
{ value: "medium", name: "Medium" },
|
|
{ value: "high", name: "High" },
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
modes: [],
|
|
currentModeId: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ACP selection validity helpers", () => {
|
|
test("classifies advertised ACP modes and select config option choices", () => {
|
|
const result = resolveACPModeSelection({
|
|
modeId: "plan",
|
|
availableModes: [
|
|
{ id: "default", label: "Always Ask" },
|
|
{ id: "plan", label: "Plan" },
|
|
],
|
|
configOptions: [
|
|
{
|
|
id: "mode",
|
|
name: "Mode",
|
|
category: "mode",
|
|
type: "select",
|
|
currentValue: "default",
|
|
options: [{ value: "default", name: "Always Ask" }],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
availableMode: { id: "plan", label: "Plan" },
|
|
configChoice: null,
|
|
hasAvailableModes: true,
|
|
});
|
|
expect(result.configOption?.id).toBe("mode");
|
|
});
|
|
|
|
test("classifies model select config option choices separately from advertised models", () => {
|
|
const result = resolveACPModelSelection({
|
|
modelId: "opus",
|
|
availableModels: [{ modelId: "sonnet", name: "Sonnet", description: null }],
|
|
configOptions: [
|
|
{
|
|
id: "model",
|
|
name: "Model",
|
|
category: "model",
|
|
type: "select",
|
|
currentValue: "sonnet",
|
|
options: [
|
|
{
|
|
group: "Anthropic",
|
|
options: [{ value: "opus", name: "Opus", description: "Deep" }],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
availableModel: null,
|
|
configChoice: {
|
|
value: "opus",
|
|
name: "Opus",
|
|
description: "Deep",
|
|
group: "Anthropic",
|
|
},
|
|
hasAvailableModels: true,
|
|
});
|
|
expect(result.configOption?.id).toBe("model");
|
|
});
|
|
});
|
|
|
|
describe("ACPAgentSession Zed parity", () => {
|
|
test("applies valid stored mode/model values, routes current_mode_update, and skips invalid Cursor-style stored values with warnings", async () => {
|
|
const validSession = createSessionWithConfig({ modeId: "plan", model: "sonnet" });
|
|
const valid = prepareConfiguredOverrideSession(validSession, {
|
|
currentMode: "default",
|
|
availableModes: [
|
|
{ id: "default", label: "Always Ask" },
|
|
{ id: "plan", label: "Plan" },
|
|
],
|
|
currentModel: "haiku",
|
|
availableModels: [
|
|
{ modelId: "haiku", name: "Haiku", description: null },
|
|
{ modelId: "sonnet", name: "Sonnet", description: null },
|
|
],
|
|
});
|
|
|
|
await valid.internals.applyConfiguredOverrides();
|
|
expect(valid.setSessionMode).toHaveBeenCalledWith({ sessionId: "session-1", modeId: "plan" });
|
|
expect(valid.unstableSetSessionModel).toHaveBeenCalledWith({
|
|
sessionId: "session-1",
|
|
modelId: "sonnet",
|
|
});
|
|
|
|
const modeEvents = asInternals<ACPSessionInternals>(validSession).translateSessionUpdate({
|
|
sessionUpdate: "current_mode_update",
|
|
currentModeId: "default",
|
|
});
|
|
expect(modeEvents).toEqual([
|
|
{
|
|
type: "mode_changed",
|
|
provider: "claude-acp",
|
|
currentModeId: "default",
|
|
availableModes: [
|
|
{ id: "default", label: "Always Ask" },
|
|
{ id: "plan", label: "Plan" },
|
|
],
|
|
},
|
|
]);
|
|
expect(await validSession.getCurrentMode()).toBe("default");
|
|
|
|
const logger = createTestLogger();
|
|
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
|
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
|
const invalidSession = createSessionWithConfig(
|
|
{ modeId: "acceptEdits", model: "opus" },
|
|
logger,
|
|
);
|
|
const invalid = prepareConfiguredOverrideSession(invalidSession, {
|
|
currentMode: "default",
|
|
availableModes: [
|
|
{ id: "default", label: "Always Ask" },
|
|
{ id: "plan", label: "Plan" },
|
|
],
|
|
currentModel: "sonnet",
|
|
availableModels: [{ modelId: "sonnet", name: "Sonnet", description: null }],
|
|
});
|
|
|
|
await expect(invalid.internals.applyConfiguredOverrides()).resolves.toBeUndefined();
|
|
expect(invalid.setSessionMode).not.toHaveBeenCalled();
|
|
expect(invalid.unstableSetSessionModel).not.toHaveBeenCalled();
|
|
expect(childLogger.warn).toHaveBeenCalledWith(
|
|
{ value: expect.stringContaining("acceptEdits") },
|
|
expect.stringContaining("not valid"),
|
|
);
|
|
expect(childLogger.warn).toHaveBeenCalledWith(
|
|
{ value: expect.stringContaining("opus") },
|
|
expect.stringContaining("not a valid"),
|
|
);
|
|
});
|
|
|
|
test("does not use config-option fallback when Cursor-style availableModes omit the stored mode", async () => {
|
|
const session = createSessionWithConfig({ modeId: "acceptEdits" });
|
|
const { internals, setSessionConfigOption } = prepareConfiguredOverrideSession(session, {
|
|
currentMode: "default",
|
|
availableModes: [
|
|
{ id: "default", label: "Always Ask" },
|
|
{ id: "plan", label: "Plan" },
|
|
],
|
|
configOptions: [selectConfigOption("mode", ["default", "acceptEdits"], "default")],
|
|
connection: { unstable_setSessionModel: undefined },
|
|
});
|
|
|
|
await expect(internals.applyConfiguredOverrides()).resolves.toBeUndefined();
|
|
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("does not fail session start when configured model cannot be applied by ACP", async () => {
|
|
const logger = createTestLogger();
|
|
const childLogger = { trace: vi.fn(), warn: vi.fn() };
|
|
vi.spyOn(logger, "child").mockReturnValue(asInternals<typeof logger>(childLogger));
|
|
const session = createSessionWithConfig(
|
|
{ provider: "deepseek-tui", model: "deepseek/v4" },
|
|
logger,
|
|
);
|
|
const { internals, setSessionConfigOption, unstableSetSessionModel } =
|
|
prepareConfiguredOverrideSession(session, {
|
|
currentModel: null,
|
|
availableModels: null,
|
|
configOptions: [],
|
|
connection: { unstable_setSessionModel: undefined },
|
|
});
|
|
|
|
await expect(internals.applyConfiguredOverrides()).resolves.toBeUndefined();
|
|
expect(unstableSetSessionModel).not.toHaveBeenCalled();
|
|
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
|
expect(childLogger.warn).toHaveBeenCalledWith(
|
|
{ value: "deepseek/v4" },
|
|
"deepseek-tui does not expose ACP model selection; using provider default model",
|
|
);
|
|
});
|
|
|
|
test("routes config_option_update and refreshes derived mode, model, and thinking state", async () => {
|
|
const session = createSession();
|
|
const internals = asInternals<ACPSessionInternals>(session);
|
|
|
|
const events = internals.translateSessionUpdate({
|
|
sessionUpdate: "config_option_update",
|
|
configOptions: [
|
|
selectConfigOption("mode", ["default", "plan"], "plan"),
|
|
selectConfigOption("model", ["sonnet", "opus"], "opus"),
|
|
selectConfigOption("thought_level", ["low", "high"], "high"),
|
|
],
|
|
});
|
|
|
|
expect(events).toMatchObject([
|
|
{
|
|
type: "mode_changed",
|
|
provider: "claude-acp",
|
|
currentModeId: "plan",
|
|
availableModes: [
|
|
{ id: "default", label: "default" },
|
|
{ id: "plan", label: "plan" },
|
|
],
|
|
},
|
|
{
|
|
type: "model_changed",
|
|
provider: "claude-acp",
|
|
runtimeInfo: expect.objectContaining({
|
|
model: "opus",
|
|
thinkingOptionId: "high",
|
|
modeId: "plan",
|
|
}),
|
|
},
|
|
{
|
|
type: "thinking_option_changed",
|
|
provider: "claude-acp",
|
|
thinkingOptionId: "high",
|
|
},
|
|
]);
|
|
expect(internals.configOptions).toEqual([
|
|
selectConfigOption("mode", ["default", "plan"], "plan"),
|
|
selectConfigOption("model", ["sonnet", "opus"], "opus"),
|
|
selectConfigOption("thought_level", ["low", "high"], "high"),
|
|
]);
|
|
expect(await session.getAvailableModes()).toEqual([
|
|
{ id: "default", label: "default" },
|
|
{ id: "plan", label: "plan" },
|
|
]);
|
|
expect(await session.getCurrentMode()).toBe("plan");
|
|
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
|
model: "opus",
|
|
thinkingOptionId: "high",
|
|
modeId: "plan",
|
|
});
|
|
});
|
|
|
|
test("keeps pushed mode when a later config_option_update has no mode payload", async () => {
|
|
const session = createSession();
|
|
const internals = asInternals<ACPSessionInternals>(session);
|
|
|
|
internals.translateSessionUpdate({
|
|
sessionUpdate: "current_mode_update",
|
|
currentModeId: "plan",
|
|
});
|
|
const events = internals.translateSessionUpdate({
|
|
sessionUpdate: "config_option_update",
|
|
configOptions: [selectConfigOption("model", ["sonnet"], "sonnet")],
|
|
});
|
|
|
|
expect(events.map((event) => event.type)).toEqual(["model_changed"]);
|
|
expect(await session.getCurrentMode()).toBe("plan");
|
|
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
|
model: "sonnet",
|
|
modeId: "plan",
|
|
});
|
|
});
|
|
|
|
test("uses last writer when current_mode_update and config_option_update both include a mode", async () => {
|
|
const session = createSession();
|
|
const internals = asInternals<ACPSessionInternals>(session);
|
|
|
|
const configEvents = internals.translateSessionUpdate({
|
|
sessionUpdate: "config_option_update",
|
|
configOptions: [selectConfigOption("mode", ["default", "plan"], "plan")],
|
|
});
|
|
const modeEvents = internals.translateSessionUpdate({
|
|
sessionUpdate: "current_mode_update",
|
|
currentModeId: "default",
|
|
});
|
|
|
|
expect(configEvents).toMatchObject([{ type: "mode_changed", currentModeId: "plan" }]);
|
|
expect(modeEvents).toMatchObject([{ type: "mode_changed", currentModeId: "default" }]);
|
|
expect(await session.getCurrentMode()).toBe("default");
|
|
});
|
|
|
|
test("uses canonical mode returned by setSessionConfigOption response", async () => {
|
|
const session = createSession();
|
|
const internals = asInternals<ACPModelSelectionInternals>(session);
|
|
const events: AgentStreamEvent[] = [];
|
|
const unsubscribe = session.subscribe((event) => events.push(event));
|
|
internals.sessionId = "session-1";
|
|
internals.configOptions = [selectConfigOption("mode", ["ask", "default"], "ask")];
|
|
internals.connection = {
|
|
setSessionConfigOption: vi.fn(async () => ({
|
|
configOptions: [selectConfigOption("mode", ["ask", "default"], "default")],
|
|
})),
|
|
};
|
|
|
|
await session.setMode("ask");
|
|
unsubscribe();
|
|
|
|
expect(await session.getCurrentMode()).toBe("default");
|
|
expect(events).toMatchObject([
|
|
{
|
|
type: "mode_changed",
|
|
provider: "claude-acp",
|
|
currentModeId: "default",
|
|
availableModes: [
|
|
{ id: "ask", label: "ask" },
|
|
{ id: "default", label: "default" },
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("uses canonical model returned by setSessionConfigOption response", async () => {
|
|
const session = createSession();
|
|
const internals = asInternals<ACPModelSelectionInternals>(session);
|
|
const events: AgentStreamEvent[] = [];
|
|
const unsubscribe = session.subscribe((event) => events.push(event));
|
|
internals.sessionId = "session-1";
|
|
internals.configOptions = [selectConfigOption("model", ["claude-sonnet", "sonnet"], "sonnet")];
|
|
internals.connection = {
|
|
setSessionConfigOption: vi.fn(async () => ({
|
|
configOptions: [selectConfigOption("model", ["claude-sonnet", "sonnet"], "sonnet")],
|
|
})),
|
|
};
|
|
|
|
await session.setModel("claude-sonnet");
|
|
unsubscribe();
|
|
|
|
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "sonnet" });
|
|
expect(events).toContainEqual({
|
|
type: "model_changed",
|
|
provider: "claude-acp",
|
|
runtimeInfo: expect.objectContaining({ model: "sonnet" }),
|
|
});
|
|
});
|
|
|
|
test("uses canonical thinking option returned by setSessionConfigOption response", async () => {
|
|
const session = createSession();
|
|
const internals = asInternals<ACPModelSelectionInternals>(session);
|
|
const events: AgentStreamEvent[] = [];
|
|
const unsubscribe = session.subscribe((event) => events.push(event));
|
|
internals.sessionId = "session-1";
|
|
internals.configOptions = [
|
|
selectConfigOption("thought_level", ["think-hard", "high"], "think-hard"),
|
|
];
|
|
internals.connection = {
|
|
setSessionConfigOption: vi.fn(async () => ({
|
|
configOptions: [selectConfigOption("thought_level", ["think-hard", "high"], "high")],
|
|
})),
|
|
};
|
|
|
|
await session.setThinkingOption("think-hard");
|
|
unsubscribe();
|
|
|
|
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "high" });
|
|
expect(events).toContainEqual({
|
|
type: "thinking_option_changed",
|
|
provider: "claude-acp",
|
|
thinkingOptionId: "high",
|
|
});
|
|
});
|
|
|
|
test("passes generic ACP permission requests through to the user", async () => {
|
|
const session = createSessionWithConfig({
|
|
provider: "cursor-acp",
|
|
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
|
});
|
|
const events: Array<{ type: string; request?: { id: string } }> = [];
|
|
const permissionOptions: PermissionOption[] = [
|
|
{ optionId: "allow-once", name: "Allow", kind: "allow_once" },
|
|
{ optionId: "reject-once", name: "Reject", kind: "reject_once" },
|
|
];
|
|
|
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
|
session.subscribe((event) => {
|
|
events.push(event as { type: string; request?: { id: string } });
|
|
});
|
|
|
|
const permission = session.requestPermission({
|
|
sessionId: "session-1",
|
|
toolCall: {
|
|
toolCallId: "tool-1",
|
|
title: "Edit file",
|
|
kind: "edit",
|
|
status: "pending",
|
|
},
|
|
options: permissionOptions,
|
|
} satisfies RequestPermissionRequest);
|
|
|
|
await Promise.resolve();
|
|
|
|
const requested = events.find((event) => event.type === "permission_requested");
|
|
expect(requested?.request?.id).toEqual(expect.any(String));
|
|
|
|
await session.respondToPermission(requested!.request!.id, { behavior: "allow" });
|
|
await expect(permission).resolves.toEqual({
|
|
outcome: { outcome: "selected", optionId: "allow-once" },
|
|
});
|
|
});
|
|
|
|
test("maps Copilot Allow All mode to allow_all ACP config on session start", async () => {
|
|
const setSessionConfigOption = vi.fn(async () => ({
|
|
configOptions: [
|
|
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
copilotAllowAllConfigOption("on"),
|
|
],
|
|
}));
|
|
const setSessionMode = vi.fn(async () => undefined);
|
|
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
|
|
const { internals } = prepareConfiguredOverrideSession(session, {
|
|
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
|
availableModes: COPILOT_MODES,
|
|
configOptions: [
|
|
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
copilotAllowAllConfigOption("off"),
|
|
],
|
|
connection: { setSessionConfigOption, setSessionMode },
|
|
});
|
|
const events: AgentStreamEvent[] = [];
|
|
const unsubscribe = session.subscribe((event) => events.push(event));
|
|
await internals.applyConfiguredOverrides();
|
|
unsubscribe();
|
|
|
|
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
|
sessionId: "session-1",
|
|
configId: "allow_all",
|
|
value: "on",
|
|
});
|
|
expect(setSessionMode).not.toHaveBeenCalled();
|
|
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
|
expect(events.some((event) => event.type === "permission_requested")).toBe(false);
|
|
});
|
|
|
|
test("accepts Copilot's legacy autopilot mode ID as Allow All", async () => {
|
|
const setSessionConfigOption = vi.fn(async () => ({
|
|
configOptions: [
|
|
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
copilotAllowAllConfigOption("on"),
|
|
],
|
|
}));
|
|
const setSessionMode = vi.fn(async () => undefined);
|
|
const session = createCopilotSessionWithConfig();
|
|
prepareConfiguredOverrideSession(session, {
|
|
currentMode: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
|
availableModes: COPILOT_MODES,
|
|
configOptions: [
|
|
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
copilotAllowAllConfigOption("off"),
|
|
],
|
|
connection: { setSessionConfigOption, setSessionMode },
|
|
});
|
|
|
|
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#autopilot");
|
|
|
|
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
|
sessionId: "session-1",
|
|
configId: "allow_all",
|
|
value: "on",
|
|
});
|
|
expect(setSessionMode).not.toHaveBeenCalled();
|
|
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
|
});
|
|
|
|
test("switching Copilot away from Allow All turns allow_all off before setting the ACP mode", async () => {
|
|
const setSessionConfigOption = vi.fn(async (input: { value: string }) => ({
|
|
configOptions: [
|
|
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
copilotAllowAllConfigOption(input.value === "on" ? "on" : "off"),
|
|
],
|
|
}));
|
|
const setSessionMode = vi.fn(async () => undefined);
|
|
const session = createCopilotSessionWithConfig(COPILOT_ALLOW_ALL_MODE_ID);
|
|
prepareConfiguredOverrideSession(session, {
|
|
currentMode: COPILOT_ALLOW_ALL_MODE_ID,
|
|
availableModes: COPILOT_MODES,
|
|
configOptions: [
|
|
copilotModeConfigOption(COPILOT_ALLOW_ALL_MODE_ID),
|
|
copilotAllowAllConfigOption("on"),
|
|
],
|
|
connection: { setSessionConfigOption, setSessionMode },
|
|
});
|
|
|
|
await session.setMode("https://agentclientprotocol.com/protocol/session-modes#agent");
|
|
|
|
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
|
sessionId: "session-1",
|
|
configId: "allow_all",
|
|
value: "off",
|
|
});
|
|
expect(setSessionMode).toHaveBeenCalledWith({
|
|
sessionId: "session-1",
|
|
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
|
});
|
|
});
|
|
|
|
test("trusts Copilot allow_all config updates as the current mode source", async () => {
|
|
const session = createCopilotSessionWithConfig();
|
|
const internals = asInternals<ACPSessionInternals>(session);
|
|
|
|
const events = internals.translateSessionUpdate({
|
|
sessionUpdate: "config_option_update",
|
|
configOptions: [
|
|
copilotModeConfigOption("https://agentclientprotocol.com/protocol/session-modes#agent"),
|
|
copilotAllowAllConfigOption("on"),
|
|
],
|
|
});
|
|
|
|
expect(events).toMatchObject([
|
|
{
|
|
type: "mode_changed",
|
|
provider: "copilot",
|
|
currentModeId: COPILOT_ALLOW_ALL_MODE_ID,
|
|
availableModes: expect.arrayContaining([
|
|
expect.objectContaining({ id: COPILOT_ALLOW_ALL_MODE_ID, label: "Allow All" }),
|
|
]),
|
|
},
|
|
]);
|
|
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
|
});
|
|
|
|
test("exposes Copilot custom agents as a select feature", () => {
|
|
const session = createCopilotSessionWithConfig();
|
|
const internals = asInternals<ACPSessionInternals>(session);
|
|
internals.configOptions = [copilotAgentConfigOption("")];
|
|
|
|
expect(session.features).toEqual([
|
|
{
|
|
type: "select",
|
|
id: "agent",
|
|
label: "Agent",
|
|
description: "Use a Copilot custom agent profile",
|
|
tooltip: "Select Copilot agent",
|
|
icon: undefined,
|
|
value: "",
|
|
options: [
|
|
{
|
|
id: "",
|
|
label: "Default",
|
|
description: undefined,
|
|
isDefault: true,
|
|
metadata: undefined,
|
|
},
|
|
{
|
|
id: "Probe Agent",
|
|
label: "Probe Agent",
|
|
description: "Temporary probe agent",
|
|
isDefault: false,
|
|
metadata: undefined,
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("applies configured Copilot custom agent before the first turn", async () => {
|
|
const setSessionConfigOption = vi.fn(async () => ({
|
|
configOptions: [copilotAgentConfigOption("Probe Agent")],
|
|
}));
|
|
const session = createCopilotSessionWithConfig(null, { agent: "Probe Agent" });
|
|
const { internals } = prepareConfiguredOverrideSession(session, {
|
|
configOptions: [copilotAgentConfigOption("")],
|
|
connection: { setSessionConfigOption },
|
|
});
|
|
|
|
await internals.applyConfiguredOverrides();
|
|
|
|
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
|
sessionId: "session-1",
|
|
configId: "agent",
|
|
value: "Probe Agent",
|
|
});
|
|
expect(session.features).toEqual([
|
|
expect.objectContaining({
|
|
id: "agent",
|
|
value: "Probe Agent",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
test("sets Copilot custom agent through ACP config options", async () => {
|
|
const setSessionConfigOption = vi.fn(async () => ({
|
|
configOptions: [copilotAgentConfigOption("Probe Agent")],
|
|
}));
|
|
const session = createCopilotSessionWithConfig();
|
|
prepareConfiguredOverrideSession(session, {
|
|
configOptions: [copilotAgentConfigOption("")],
|
|
connection: { setSessionConfigOption },
|
|
});
|
|
|
|
await session.setFeature("agent", "Probe Agent");
|
|
|
|
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
|
sessionId: "session-1",
|
|
configId: "agent",
|
|
value: "Probe Agent",
|
|
});
|
|
expect(session.features).toEqual([
|
|
expect.objectContaining({
|
|
id: "agent",
|
|
value: "Probe Agent",
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("deriveModelDefinitionsFromACP", () => {
|
|
test("attaches shared thinking options to ACP model state", () => {
|
|
const result = deriveModelDefinitionsFromACP(
|
|
"claude-acp",
|
|
{
|
|
availableModels: [
|
|
{ modelId: "haiku", name: "Haiku", description: "Fast" },
|
|
{ modelId: "sonnet", name: "Sonnet", description: "Balanced" },
|
|
],
|
|
currentModelId: "haiku",
|
|
},
|
|
[
|
|
{
|
|
id: "reasoning",
|
|
name: "Reasoning",
|
|
category: "thought_level",
|
|
type: "select",
|
|
currentValue: "medium",
|
|
options: [
|
|
{ value: "low", name: "Low" },
|
|
{ value: "medium", name: "Medium" },
|
|
{ value: "high", name: "High" },
|
|
],
|
|
},
|
|
],
|
|
);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
provider: "claude-acp",
|
|
id: "haiku",
|
|
label: "Haiku",
|
|
description: "Fast",
|
|
isDefault: true,
|
|
thinkingOptions: [
|
|
{
|
|
id: "low",
|
|
label: "Low",
|
|
description: undefined,
|
|
isDefault: false,
|
|
metadata: undefined,
|
|
},
|
|
{
|
|
id: "medium",
|
|
label: "Medium",
|
|
description: undefined,
|
|
isDefault: true,
|
|
metadata: undefined,
|
|
},
|
|
{
|
|
id: "high",
|
|
label: "High",
|
|
description: undefined,
|
|
isDefault: false,
|
|
metadata: undefined,
|
|
},
|
|
],
|
|
defaultThinkingOptionId: "medium",
|
|
},
|
|
{
|
|
provider: "claude-acp",
|
|
id: "sonnet",
|
|
label: "Sonnet",
|
|
description: "Balanced",
|
|
isDefault: false,
|
|
thinkingOptions: [
|
|
{
|
|
id: "low",
|
|
label: "Low",
|
|
description: undefined,
|
|
isDefault: false,
|
|
metadata: undefined,
|
|
},
|
|
{
|
|
id: "medium",
|
|
label: "Medium",
|
|
description: undefined,
|
|
isDefault: true,
|
|
metadata: undefined,
|
|
},
|
|
{
|
|
id: "high",
|
|
label: "High",
|
|
description: undefined,
|
|
isDefault: false,
|
|
metadata: undefined,
|
|
},
|
|
],
|
|
defaultThinkingOptionId: "medium",
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("ACPAgentClient modelTransformer", () => {
|
|
test("applies modelTransformer after deriving ACP models", async () => {
|
|
class TestACPAgentClient extends ACPAgentClient {
|
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
|
return {
|
|
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
|
connection: {
|
|
newSession: vi.fn().mockResolvedValue({
|
|
models: {
|
|
availableModels: [
|
|
{
|
|
modelId: "openrouter/openai/gpt-4.1-mini",
|
|
name: "openrouter/openai/gpt-4.1-mini",
|
|
description: null,
|
|
},
|
|
],
|
|
currentModelId: "openrouter/openai/gpt-4.1-mini",
|
|
},
|
|
configOptions: [],
|
|
}),
|
|
},
|
|
initialize: { agentCapabilities: {} },
|
|
} as SpawnedACPProcess;
|
|
}
|
|
|
|
protected override async closeProbe(): Promise<void> {}
|
|
}
|
|
|
|
const client = new TestACPAgentClient({
|
|
provider: "pi",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["test-acp"],
|
|
modelTransformer: transformPiModels,
|
|
});
|
|
|
|
await expect(client.fetchCatalog({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual({
|
|
models: [
|
|
{
|
|
provider: "pi",
|
|
id: "openrouter/openai/gpt-4.1-mini",
|
|
label: "gpt-4.1-mini",
|
|
description: "openrouter/openai/gpt-4.1-mini",
|
|
isDefault: true,
|
|
thinkingOptions: undefined,
|
|
defaultThinkingOptionId: undefined,
|
|
},
|
|
],
|
|
modes: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ACPAgentClient config features", () => {
|
|
test("derives features from configured ACP select options", async () => {
|
|
class TestACPAgentClient extends ACPAgentClient {
|
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
|
return {
|
|
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
|
connection: {
|
|
newSession: vi.fn().mockResolvedValue({
|
|
sessionId: "session-1",
|
|
configOptions: [copilotAgentConfigOption("Probe Agent")],
|
|
}),
|
|
},
|
|
initialize: { agentCapabilities: {} },
|
|
} as SpawnedACPProcess;
|
|
}
|
|
|
|
protected override async closeProbe(): Promise<void> {}
|
|
}
|
|
|
|
const client = new TestACPAgentClient({
|
|
provider: "copilot",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["copilot", "--acp"],
|
|
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
|
|
});
|
|
|
|
await expect(
|
|
client.listFeatures({
|
|
provider: "copilot",
|
|
cwd: "/tmp/acp-features",
|
|
}),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
type: "select",
|
|
id: "agent",
|
|
value: "Probe Agent",
|
|
options: [
|
|
expect.objectContaining({ id: "", label: "Default", isDefault: false }),
|
|
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
|
|
],
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("ACPAgentClient sessionResponseTransformer", () => {
|
|
class TestACPAgentClient extends ACPAgentClient {
|
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
|
const response: SessionStateResponse = {
|
|
sessionId: "session-1",
|
|
modes: {
|
|
availableModes: [{ id: "raw", name: "Raw", description: "Before transform" }],
|
|
currentModeId: "raw",
|
|
},
|
|
models: null,
|
|
configOptions: [],
|
|
};
|
|
|
|
return {
|
|
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
|
connection: {
|
|
newSession: vi.fn().mockResolvedValue(response),
|
|
},
|
|
initialize: { agentCapabilities: {} },
|
|
} as SpawnedACPProcess;
|
|
}
|
|
|
|
protected override async closeProbe(): Promise<void> {}
|
|
}
|
|
|
|
test("applies sessionResponseTransformer before deriving catalog modes", async () => {
|
|
const client = new TestACPAgentClient({
|
|
provider: "claude-acp",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["claude", "--acp"],
|
|
defaultModes: [],
|
|
sessionResponseTransformer: (response) => ({
|
|
...response,
|
|
modes: {
|
|
availableModes: [{ id: "review", name: "Review", description: "After transform" }],
|
|
currentModeId: "review",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({
|
|
models: [],
|
|
modes: [
|
|
{
|
|
id: "review",
|
|
label: "Review",
|
|
description: "After transform",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ACPAgentClient fetchCatalog", () => {
|
|
test("passes the requested cwd to the catalog probe", async () => {
|
|
const newSession = vi.fn().mockResolvedValue({ modes: null, models: null, configOptions: [] });
|
|
|
|
class TestACPAgentClient extends ACPAgentClient {
|
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
|
return {
|
|
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
|
connection: { newSession },
|
|
initialize: { agentCapabilities: {} },
|
|
} as SpawnedACPProcess;
|
|
}
|
|
|
|
protected override async closeProbe(): Promise<void> {}
|
|
}
|
|
|
|
const client = new TestACPAgentClient({
|
|
provider: "pi",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["test-acp"],
|
|
defaultModes: [],
|
|
});
|
|
|
|
await client.fetchCatalog({ cwd: "/tmp/acp-catalog-cwd", force: false });
|
|
|
|
expect(newSession).toHaveBeenCalledWith({
|
|
cwd: "/tmp/acp-catalog-cwd",
|
|
mcpServers: [],
|
|
});
|
|
});
|
|
|
|
test("returns an empty modes array when no ACP modes are reported and fallback modes are empty", async () => {
|
|
class TestACPAgentClient extends ACPAgentClient {
|
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
|
return {
|
|
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
|
connection: {
|
|
newSession: vi.fn().mockResolvedValue({
|
|
modes: null,
|
|
configOptions: [
|
|
{
|
|
id: "thought_level",
|
|
name: "Thinking",
|
|
category: "thought_level",
|
|
type: "select",
|
|
currentValue: "medium",
|
|
options: [
|
|
{ value: "low", name: "Low" },
|
|
{ value: "medium", name: "Medium" },
|
|
{ value: "high", name: "High" },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
initialize: { agentCapabilities: {} },
|
|
} as SpawnedACPProcess;
|
|
}
|
|
|
|
protected override async closeProbe(): Promise<void> {}
|
|
}
|
|
|
|
const client = new TestACPAgentClient({
|
|
provider: "pi",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["test-acp"],
|
|
defaultModes: [],
|
|
});
|
|
|
|
await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({
|
|
models: [],
|
|
modes: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ACPAgentClient listImportableSessions", () => {
|
|
function makeClient(args: { listSessions: ReturnType<typeof vi.fn>; supportsList?: boolean }) {
|
|
class TestACPAgentClient extends ACPAgentClient {
|
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
|
return {
|
|
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
|
connection: { listSessions: args.listSessions },
|
|
initialize: {
|
|
agentCapabilities:
|
|
args.supportsList === false ? {} : { sessionCapabilities: { list: {} } },
|
|
},
|
|
} as unknown as SpawnedACPProcess;
|
|
}
|
|
|
|
protected override async closeProbe(): Promise<void> {}
|
|
}
|
|
|
|
return new TestACPAgentClient({
|
|
provider: "kimi",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["kimi", "acp"],
|
|
defaultModes: [],
|
|
});
|
|
}
|
|
|
|
test("forwards the requested cwd to session/list so the agent filters by directory", async () => {
|
|
const listSessions = vi.fn().mockResolvedValue({
|
|
sessions: [
|
|
{
|
|
sessionId: "session-1",
|
|
cwd: "/Users/moonshot",
|
|
title: "细致查看一下本仓库内容",
|
|
updatedAt: "2026-06-13T00:00:00.000Z",
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
|
|
const client = makeClient({ listSessions });
|
|
const result = await client.listImportableSessions({ cwd: "/Users/moonshot", limit: 20 });
|
|
|
|
expect(listSessions).toHaveBeenCalledWith({ cwd: "/Users/moonshot" });
|
|
expect(result).toEqual([
|
|
{
|
|
providerHandleId: "session-1",
|
|
cwd: "/Users/moonshot",
|
|
title: "细致查看一下本仓库内容",
|
|
firstPromptPreview: null,
|
|
lastPromptPreview: null,
|
|
lastActivityAt: new Date("2026-06-13T00:00:00.000Z"),
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("omits cwd from session/list when none is requested", async () => {
|
|
const listSessions = vi.fn().mockResolvedValue({ sessions: [], nextCursor: null });
|
|
const client = makeClient({ listSessions });
|
|
|
|
await client.listImportableSessions({ limit: 20 });
|
|
|
|
expect(listSessions).toHaveBeenCalledWith({});
|
|
});
|
|
|
|
test("forwards cwd alongside the pagination cursor across pages", async () => {
|
|
const listSessions = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
sessions: [{ sessionId: "s1", cwd: "/Users/moonshot", title: null, updatedAt: null }],
|
|
nextCursor: "cursor-2",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
sessions: [{ sessionId: "s2", cwd: "/Users/moonshot", title: null, updatedAt: null }],
|
|
nextCursor: null,
|
|
});
|
|
|
|
const client = makeClient({ listSessions });
|
|
await client.listImportableSessions({ cwd: "/Users/moonshot" });
|
|
|
|
expect(listSessions).toHaveBeenNthCalledWith(1, { cwd: "/Users/moonshot" });
|
|
expect(listSessions).toHaveBeenNthCalledWith(2, {
|
|
cursor: "cursor-2",
|
|
cwd: "/Users/moonshot",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ACP providers advertise session listing", () => {
|
|
// The daemon's agent-manager only queries providers whose
|
|
// capabilities.supportsSessionListing is true. Without it, ACP providers
|
|
// (Kimi and other custom ACP agents, Copilot) are skipped and import shows
|
|
// nothing even though listImportableSessions is implemented.
|
|
test("generic ACP clients (e.g. Kimi) report supportsSessionListing", () => {
|
|
const client = new GenericACPAgentClient({
|
|
logger: createTestLogger(),
|
|
command: ["kimi", "acp"],
|
|
});
|
|
expect(client.capabilities.supportsSessionListing).toBe(true);
|
|
});
|
|
|
|
test("Copilot ACP client reports supportsSessionListing", () => {
|
|
const client = new CopilotACPAgentClient({ logger: createTestLogger() });
|
|
expect(client.capabilities.supportsSessionListing).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("transformPiModels", () => {
|
|
test("keeps slash-free labels unchanged", () => {
|
|
expect(
|
|
transformPiModels([
|
|
{
|
|
provider: "pi",
|
|
id: "gpt-4.1-mini",
|
|
label: "GPT 4.1 Mini",
|
|
description: "Fast",
|
|
},
|
|
]),
|
|
).toEqual([
|
|
{
|
|
provider: "pi",
|
|
id: "gpt-4.1-mini",
|
|
label: "GPT 4.1 Mini",
|
|
description: "Fast",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("uses the last path segment as label and preserves existing descriptions", () => {
|
|
expect(
|
|
transformPiModels([
|
|
{
|
|
provider: "pi",
|
|
id: "openrouter/openai/gpt-4.1-mini",
|
|
label: "openrouter/openai/gpt-4.1-mini",
|
|
description: undefined,
|
|
},
|
|
{
|
|
provider: "pi",
|
|
id: "anthropic/claude-sonnet-4",
|
|
label: "anthropic/claude-sonnet-4",
|
|
description: "Balanced",
|
|
},
|
|
]),
|
|
).toEqual([
|
|
{
|
|
provider: "pi",
|
|
id: "openrouter/openai/gpt-4.1-mini",
|
|
label: "gpt-4.1-mini",
|
|
description: "openrouter/openai/gpt-4.1-mini",
|
|
},
|
|
{
|
|
provider: "pi",
|
|
id: "anthropic/claude-sonnet-4",
|
|
label: "claude-sonnet-4",
|
|
description: "Balanced",
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("ACPAgentSession slash commands", () => {
|
|
test("returns immediately for ACP sessions that do not wait for async command discovery", async () => {
|
|
const session = new ACPAgentSession(
|
|
{
|
|
provider: "claude-acp",
|
|
cwd: "/tmp/paseo-acp-test",
|
|
},
|
|
{
|
|
provider: "claude-acp",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["claude", "--acp"],
|
|
defaultModes: [],
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
waitForInitialCommands: false,
|
|
},
|
|
);
|
|
|
|
await expect(session.listCommands()).resolves.toEqual([]);
|
|
});
|
|
|
|
test("waits for async available_commands_update when enabled", async () => {
|
|
const session = new ACPAgentSession(
|
|
{
|
|
provider: "claude-acp",
|
|
cwd: "/tmp/paseo-acp-test",
|
|
},
|
|
{
|
|
provider: "claude-acp",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["claude", "--acp"],
|
|
defaultModes: [],
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
waitForInitialCommands: true,
|
|
initialCommandsWaitTimeoutMs: 1500,
|
|
},
|
|
);
|
|
|
|
const listCommandsPromise = session.listCommands();
|
|
|
|
asInternals<ACPSessionInternals>(session).translateSessionUpdate({
|
|
sessionUpdate: "available_commands_update",
|
|
availableCommands: [
|
|
{
|
|
name: "research_codebase",
|
|
description: "Search the workspace for relevant files",
|
|
},
|
|
{
|
|
name: "create_plan",
|
|
description: "Draft a plan for the requested work",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(await listCommandsPromise).toEqual([
|
|
{
|
|
name: "research_codebase",
|
|
description: "Search the workspace for relevant files",
|
|
argumentHint: "",
|
|
kind: "command",
|
|
},
|
|
{
|
|
name: "create_plan",
|
|
description: "Draft a plan for the requested work",
|
|
argumentHint: "",
|
|
kind: "command",
|
|
},
|
|
]);
|
|
|
|
expect(await session.listCommands()).toEqual([
|
|
{
|
|
name: "research_codebase",
|
|
description: "Search the workspace for relevant files",
|
|
argumentHint: "",
|
|
kind: "command",
|
|
},
|
|
{
|
|
name: "create_plan",
|
|
description: "Draft a plan for the requested work",
|
|
argumentHint: "",
|
|
kind: "command",
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("ACPAgentSession", () => {
|
|
test("drops MCP servers from ACP requests when the provider does not support MCP", () => {
|
|
const session = new ACPAgentSession(
|
|
{
|
|
provider: "no-mcp-acp",
|
|
cwd: "/tmp/paseo-acp-test",
|
|
mcpServers: {
|
|
paseo: {
|
|
type: "http",
|
|
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
provider: "no-mcp-acp",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["no-mcp-acp", "serve"],
|
|
defaultModes: [],
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: false,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(asInternals<ACPSessionInternals>(session).acpMcpServers()).toEqual([]);
|
|
});
|
|
|
|
test("summarizes JSON-RPC error details without stringifying objects", () => {
|
|
const summary = summarizeACPRequestError(
|
|
new RequestError(-32603, "Internal error", {
|
|
details: "Droid process exited unexpectedly (exit code 1)",
|
|
}),
|
|
);
|
|
|
|
expect(summary).toMatchObject({
|
|
message: "Internal error: Droid process exited unexpectedly (exit code 1)",
|
|
code: "-32603",
|
|
});
|
|
expect(summary.message).not.toContain("[object Object]");
|
|
expect(summary.diagnostic).toContain("Droid process exited unexpectedly");
|
|
});
|
|
|
|
test("accepts ACP extension notifications without failing the JSON-RPC connection", async () => {
|
|
const logger = createTestLogger();
|
|
const trace = vi.spyOn(logger, "trace");
|
|
const session = createSessionWithConfig({ provider: "kiro" }, logger);
|
|
|
|
await expect(
|
|
session.extNotification("_kiro.dev/session/initialized", {
|
|
sessionId: "session-1",
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
expect(trace).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
provider: "kiro",
|
|
method: "_kiro.dev/session/initialized",
|
|
sessionId: "session-1",
|
|
}),
|
|
"provider.acp.extension_notification",
|
|
);
|
|
});
|
|
|
|
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
|
|
const session = createSession();
|
|
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
|
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
|
|
|
session.subscribe((event) => {
|
|
events.push(event as { type: string; item?: { type: string; text?: string } });
|
|
});
|
|
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
messageId: "assistant-1",
|
|
content: { type: "text", text: "Hey!" },
|
|
} as SessionUpdate,
|
|
});
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
messageId: "assistant-1",
|
|
content: { type: "text", text: " How are you?" },
|
|
} as SessionUpdate,
|
|
});
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "agent_thought_chunk",
|
|
messageId: "thought-1",
|
|
content: { type: "text", text: "Thinking" },
|
|
} as SessionUpdate,
|
|
});
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "agent_thought_chunk",
|
|
messageId: "thought-1",
|
|
content: { type: "text", text: " more" },
|
|
} as SessionUpdate,
|
|
});
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "user_message_chunk",
|
|
messageId: "user-1",
|
|
content: { type: "text", text: "hel" },
|
|
} as SessionUpdate,
|
|
});
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "user_message_chunk",
|
|
messageId: "user-1",
|
|
content: { type: "text", text: "lo" },
|
|
} as SessionUpdate,
|
|
});
|
|
|
|
const timeline = events
|
|
.filter((event) => event.type === "timeline")
|
|
.map((event) => event.item)
|
|
.filter(Boolean);
|
|
|
|
expect(timeline).toEqual([
|
|
{ type: "assistant_message", text: "Hey!" },
|
|
{ type: "assistant_message", text: " How are you?" },
|
|
{ type: "reasoning", text: "Thinking" },
|
|
{ type: "reasoning", text: " more" },
|
|
{ type: "user_message", text: "hel", messageId: "user-1" },
|
|
{ type: "user_message", text: "hello", messageId: "user-1" },
|
|
]);
|
|
});
|
|
|
|
test("startTurn returns before the ACP prompt settles and completes later via subscribers", async () => {
|
|
const session = createSession();
|
|
const events: Array<{ type: string; turnId?: string }> = [];
|
|
let resolvePrompt!: (value: PromptResponse) => void;
|
|
const prompt = vi.fn(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolvePrompt = resolve;
|
|
}),
|
|
);
|
|
|
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
|
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
|
|
|
session.subscribe((event) => {
|
|
events.push(event as { type: string; turnId?: string });
|
|
});
|
|
|
|
const { turnId } = await session.startTurn("hello");
|
|
|
|
expect(prompt).toHaveBeenCalledOnce();
|
|
expect(events.find((event) => event.type === "turn_started")).toMatchObject({
|
|
type: "turn_started",
|
|
turnId,
|
|
});
|
|
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBe(turnId);
|
|
|
|
resolvePrompt({ stopReason: "end_turn", usage: { outputTokens: 3 } });
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(events.find((event) => event.type === "turn_completed")).toMatchObject({
|
|
type: "turn_completed",
|
|
turnId,
|
|
});
|
|
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
|
|
});
|
|
|
|
test("startTurn emits the submitted user message even when ACP does not echo it", async () => {
|
|
const session = createSession();
|
|
const events: AgentStreamEvent[] = [];
|
|
let resolvePrompt!: (value: PromptResponse) => void;
|
|
const prompt = vi.fn(
|
|
() =>
|
|
new Promise<PromptResponse>((resolve) => {
|
|
resolvePrompt = resolve;
|
|
}),
|
|
);
|
|
|
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
|
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
|
|
|
session.subscribe((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
const { turnId } = await session.startTurn("hello", { messageId: "msg-client-1" });
|
|
|
|
expect(prompt).toHaveBeenCalledWith({
|
|
sessionId: "session-1",
|
|
messageId: "msg-client-1",
|
|
prompt: [{ type: "text", text: "hello" }],
|
|
});
|
|
expect(
|
|
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
|
|
).toEqual([
|
|
{
|
|
type: "timeline",
|
|
provider: "claude-acp",
|
|
turnId,
|
|
item: { type: "user_message", text: "hello", messageId: "msg-client-1" },
|
|
},
|
|
]);
|
|
|
|
resolvePrompt({ stopReason: "end_turn" });
|
|
});
|
|
|
|
test("startTurn dedupes ACP user echo chunks for the submitted message", async () => {
|
|
const session = createSession();
|
|
const events: AgentStreamEvent[] = [];
|
|
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
|
|
|
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
|
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
|
|
|
session.subscribe((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
await session.startTurn("hello", { messageId: "msg-client-1" });
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "user_message_chunk",
|
|
messageId: "msg-client-1",
|
|
content: { type: "text", text: "hello" },
|
|
} as SessionUpdate,
|
|
});
|
|
|
|
expect(
|
|
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
test("startTurn dedupes ACP user echo chunks without message ids for the submitted message", async () => {
|
|
const session = createSession();
|
|
const events: AgentStreamEvent[] = [];
|
|
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
|
|
|
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
|
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
|
|
|
session.subscribe((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
await session.startTurn("hello", { messageId: "msg-client-1" });
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "user_message_chunk",
|
|
content: { type: "text", text: "hello" },
|
|
} as SessionUpdate,
|
|
});
|
|
|
|
expect(
|
|
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
|
|
).toEqual([
|
|
{
|
|
type: "timeline",
|
|
provider: "claude-acp",
|
|
item: { type: "user_message", text: "hello", messageId: "msg-client-1" },
|
|
turnId: expect.any(String),
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("startTurn dedupes ACP user echo chunks without message ids across turns", async () => {
|
|
const session = createSession();
|
|
const events: AgentStreamEvent[] = [];
|
|
let resolvePrompt!: (value: PromptResponse) => void;
|
|
const prompt = vi.fn(
|
|
() =>
|
|
new Promise<PromptResponse>((resolve) => {
|
|
resolvePrompt = resolve;
|
|
}),
|
|
);
|
|
|
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
|
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
|
|
|
session.subscribe((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
await session.startTurn("first", { messageId: "msg-client-1" });
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "user_message_chunk",
|
|
content: { type: "text", text: "first" },
|
|
} as SessionUpdate,
|
|
});
|
|
resolvePrompt({ stopReason: "end_turn" });
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
await session.startTurn("second", { messageId: "msg-client-2" });
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "user_message_chunk",
|
|
content: { type: "text", text: "second" },
|
|
} as SessionUpdate,
|
|
});
|
|
|
|
expect(
|
|
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
|
|
).toEqual([
|
|
{
|
|
type: "timeline",
|
|
provider: "claude-acp",
|
|
item: { type: "user_message", text: "first", messageId: "msg-client-1" },
|
|
turnId: expect.any(String),
|
|
},
|
|
{
|
|
type: "timeline",
|
|
provider: "claude-acp",
|
|
item: { type: "user_message", text: "second", messageId: "msg-client-2" },
|
|
turnId: expect.any(String),
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("startTurn dedupes ACP user echo chunks with provider-owned ids for the submitted message", async () => {
|
|
const session = createSession();
|
|
const events: AgentStreamEvent[] = [];
|
|
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
|
|
|
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
|
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
|
|
|
session.subscribe((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
await session.startTurn("hello", { messageId: "msg-client-1" });
|
|
await session.sessionUpdate({
|
|
sessionId: "session-1",
|
|
update: {
|
|
sessionUpdate: "user_message_chunk",
|
|
messageId: "msg-provider-1",
|
|
content: { type: "text", text: "hello" },
|
|
} as SessionUpdate,
|
|
});
|
|
|
|
expect(
|
|
events.filter((event) => event.type === "timeline" && event.item.type === "user_message"),
|
|
).toEqual([
|
|
{
|
|
type: "timeline",
|
|
provider: "claude-acp",
|
|
item: { type: "user_message", text: "hello", messageId: "msg-client-1" },
|
|
turnId: expect.any(String),
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("startTurn converts background prompt rejections into turn_failed events", async () => {
|
|
const session = createSession();
|
|
const events: Array<{ type: string; turnId?: string; error?: string }> = [];
|
|
let rejectPrompt!: (error: Error) => void;
|
|
const prompt = vi.fn(
|
|
() =>
|
|
new Promise((_, reject) => {
|
|
rejectPrompt = reject;
|
|
}),
|
|
);
|
|
|
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
|
asInternals<ACPSessionInternals>(session).connection = { prompt };
|
|
|
|
session.subscribe((event) => {
|
|
events.push(event as { type: string; turnId?: string; error?: string });
|
|
});
|
|
|
|
const { turnId } = await session.startTurn("hello");
|
|
|
|
rejectPrompt(new Error("prompt failed"));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
const turnFailedEvent = events.find((event) => event.type === "turn_failed");
|
|
expect(turnFailedEvent).toMatchObject({
|
|
type: "turn_failed",
|
|
turnId,
|
|
error: "prompt failed",
|
|
});
|
|
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
|
|
});
|
|
|
|
test("startTurn preserves JSON-RPC error details from a real ACP prompt response", async () => {
|
|
const session = createSession();
|
|
const clientToAgent = new TransformStream();
|
|
const agentToClient = new TransformStream();
|
|
const upstreamMessage =
|
|
"Authentication failed: Please authenticate to continue. Run `/login` to log in.";
|
|
const upstreamData = {
|
|
cause: "auth_required",
|
|
errorMessage: "Please authenticate to continue. Run `/login` to log in.",
|
|
};
|
|
const agent: Agent = {
|
|
async initialize() {
|
|
return {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
agentCapabilities: {},
|
|
authMethods: [{ id: "windsurf-api-key", name: "API Key" }],
|
|
};
|
|
},
|
|
async newSession() {
|
|
return { sessionId: "session-1" };
|
|
},
|
|
async prompt() {
|
|
throw new RequestError(-32000, upstreamMessage, upstreamData);
|
|
},
|
|
async authenticate() {},
|
|
async cancel() {},
|
|
};
|
|
const agentConnection = new AgentSideConnection(
|
|
() => agent,
|
|
ndJsonStream(agentToClient.writable, clientToAgent.readable),
|
|
);
|
|
const connection = new ClientSideConnection(
|
|
() => ({
|
|
async requestPermission() {
|
|
return { outcome: { outcome: "cancelled" } };
|
|
},
|
|
async sessionUpdate() {},
|
|
}),
|
|
ndJsonStream(clientToAgent.writable, agentToClient.readable),
|
|
);
|
|
await connection.initialize({
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
clientCapabilities: {},
|
|
clientInfo: { name: "Paseo test", version: "dev" },
|
|
});
|
|
expect(agentConnection.signal.aborted).toBe(false);
|
|
const sessionResponse = await connection.newSession({
|
|
cwd: "/tmp/paseo-acp-test",
|
|
mcpServers: [],
|
|
});
|
|
const turnFailed = new Promise<Extract<AgentStreamEvent, { type: "turn_failed" }>>(
|
|
(resolve) => {
|
|
session.subscribe((event) => {
|
|
if (event.type === "turn_failed") {
|
|
resolve(event);
|
|
}
|
|
});
|
|
},
|
|
);
|
|
|
|
asInternals<ACPSessionInternals>(session).sessionId = sessionResponse.sessionId;
|
|
asInternals<ACPSessionInternals>(session).connection = connection;
|
|
|
|
await session.startTurn("hello");
|
|
|
|
await expect(turnFailed).resolves.toMatchObject({
|
|
error: expect.stringContaining(upstreamMessage),
|
|
code: "-32000",
|
|
diagnostic: expect.stringContaining("auth_required"),
|
|
});
|
|
await expect(turnFailed).resolves.toMatchObject({
|
|
error: expect.not.stringContaining("[object Object]"),
|
|
});
|
|
});
|
|
});
|
|
|
|
interface ACPCloseInternals {
|
|
child: ChildProcess | null;
|
|
connection: unknown;
|
|
sessionId: string | null;
|
|
}
|
|
|
|
async function startTerminal(
|
|
session: ACPAgentSession,
|
|
child: ChildProcess,
|
|
command = "sleep",
|
|
): Promise<string> {
|
|
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child as ChildProcessWithoutNullStreams);
|
|
const terminal = await session.createTerminal({
|
|
sessionId: "session-1",
|
|
command,
|
|
args: ["60"],
|
|
});
|
|
vi.restoreAllMocks();
|
|
return terminal.terminalId;
|
|
}
|
|
|
|
describe("ACPAgentSession close() tree-kill", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
test("close() terminates the main child process via the process tree", async () => {
|
|
const terminator = new FakeTerminator();
|
|
const session = createSession(terminator.terminate);
|
|
const internals = asInternals<ACPCloseInternals>(session);
|
|
|
|
const child = createTerminalChildStub();
|
|
// The ACP host process is set by the live connect handshake, which has no
|
|
// in-test seam; everything else is driven through the public API.
|
|
internals.child = child;
|
|
internals.connection = null;
|
|
internals.sessionId = null;
|
|
|
|
await session.close();
|
|
|
|
expect(terminator.terminated).toContain(child);
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("close() terminates running terminal child processes", async () => {
|
|
const terminator = new FakeTerminator();
|
|
const session = createSession(terminator.terminate);
|
|
|
|
const terminalChild = createTerminalChildStub();
|
|
await startTerminal(session, terminalChild);
|
|
|
|
await session.close();
|
|
|
|
expect(terminator.terminated).toContain(terminalChild);
|
|
expect(terminalChild.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("close() terminates terminal child processes in parallel", async () => {
|
|
const terminator = new FakeTerminator("deferred");
|
|
const session = createSession(terminator.terminate);
|
|
|
|
const firstChild = createTerminalChildStub();
|
|
const secondChild = createTerminalChildStub();
|
|
await startTerminal(session, firstChild);
|
|
await startTerminal(session, secondChild);
|
|
|
|
const close = session.close();
|
|
await Promise.resolve();
|
|
|
|
expect(terminator.terminated).toEqual([firstChild, secondChild]);
|
|
|
|
terminator.releaseAll();
|
|
await close;
|
|
});
|
|
|
|
test("killTerminal terminates the terminal process tree without a direct SIGTERM", async () => {
|
|
const terminator = new FakeTerminator();
|
|
const session = createSession(terminator.terminate);
|
|
|
|
const child = createTerminalChildStub();
|
|
const terminalId = await startTerminal(session, child);
|
|
|
|
await session.killTerminal({ sessionId: "session-1", terminalId });
|
|
|
|
expect(terminator.terminated).toContain(child);
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("releaseTerminal terminates and removes a running terminal", async () => {
|
|
const terminator = new FakeTerminator();
|
|
const session = createSession(terminator.terminate);
|
|
|
|
const child = createTerminalChildStub();
|
|
const terminalId = await startTerminal(session, child);
|
|
|
|
await session.releaseTerminal({ sessionId: "session-1", terminalId });
|
|
|
|
expect(terminator.terminated).toContain(child);
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
await expect(session.terminalOutput({ sessionId: "session-1", terminalId })).rejects.toThrow(
|
|
`Unknown terminal '${terminalId}'`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("ACPAgentClient probe cleanup", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
test("terminates the probe process tree and closes its stdio", async () => {
|
|
const terminator = new FakeTerminator();
|
|
const child = createProbeChildStub();
|
|
|
|
class TestACPAgentClient extends ACPAgentClient {
|
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
|
return {
|
|
child,
|
|
connection: {
|
|
newSession: vi.fn().mockResolvedValue({
|
|
modes: null,
|
|
models: null,
|
|
configOptions: [],
|
|
}),
|
|
},
|
|
initialize: { agentCapabilities: {} },
|
|
} as SpawnedACPProcess;
|
|
}
|
|
}
|
|
|
|
const client = new TestACPAgentClient({
|
|
provider: "claude-acp",
|
|
logger: createTestLogger(),
|
|
defaultCommand: ["claude", "--acp"],
|
|
defaultModes: [],
|
|
terminateProcess: terminator.terminate,
|
|
});
|
|
|
|
await client.fetchCatalog({ cwd: "/tmp/acp-models", force: false });
|
|
|
|
expect(terminator.terminated).toContain(child);
|
|
expect(child.stdin.destroyed).toBe(true);
|
|
expect(child.stdout.destroyed).toBe(true);
|
|
expect(child.stderr.destroyed).toBe(true);
|
|
});
|
|
});
|