test(acp): add comprehensive ACP and edit tool test suites
- AcpAgent integration tests: initialize→run→notify cycle, notification structure, message-ID continuity - ACP built-in slash command unit tests across all command handlers via fake runtime - ACP event mapper unit tests: text chunks, thinking, tool calls/results, errors → SessionNotification - ACP initialize conformance: terminal auth capability negotiation, agentInfo/agentCapabilities contract - stdout hygiene smoke test: first bytes on omp acp stdout must be a valid JSON-RPC frame - AgentSession permission gate: allow-once, reject-once, allow-always, abort-during-pending, non-gated tools - BashTool ACP terminal routing: bridge dispatch vs local PTY fallback - EditTool diff content propagation: patch, replace, and multi-file aggregation paths - ReadTool and WriteTool ACP filesystem permission tests - Shared ACP schema test helper
This commit is contained in:
@@ -3,11 +3,21 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentSideConnection, PromptRequest, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
zForkSessionResponse,
|
||||
zLoadSessionResponse,
|
||||
zNewSessionResponse,
|
||||
zPromptResponse,
|
||||
zSessionNotification,
|
||||
} from "@agentclientprotocol/sdk/dist/schema/zod.gen.js";
|
||||
import type { Model } from "@oh-my-pi/pi-ai";
|
||||
import { getConfigRootDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
import { _resetSettingsForTest, Settings } from "../src/config/settings";
|
||||
import { AcpAgent } from "../src/modes/acp/acp-agent";
|
||||
import type { PlanModeState } from "../src/plan-mode/state";
|
||||
import type { AgentSession, AgentSessionEvent } from "../src/session/agent-session";
|
||||
import { SessionManager } from "../src/session/session-manager";
|
||||
import { expectAcpStructure } from "./helpers/acp-schema";
|
||||
|
||||
const TEST_MODELS: Model[] = [
|
||||
{
|
||||
@@ -74,6 +84,13 @@ class FakeAgentSession {
|
||||
queuedMessageCount = 0;
|
||||
systemPrompt = "system";
|
||||
disposed = false;
|
||||
fastMode = false;
|
||||
forcedToolChoice: string | undefined;
|
||||
promptCalls: string[] = [];
|
||||
customMessages: Array<{ customType: string; content: string; details?: unknown }> = [];
|
||||
skillsSettings = { enableSkillCommands: true };
|
||||
skills: Array<{ name: string; description: string; filePath: string; baseDir: string; source: string }> = [];
|
||||
planModeState: PlanModeState | undefined;
|
||||
#listeners = new Set<(event: AgentSessionEvent) => void>();
|
||||
|
||||
constructor(
|
||||
@@ -123,6 +140,7 @@ class FakeAgentSession {
|
||||
}
|
||||
|
||||
async prompt(text: string): Promise<void> {
|
||||
this.promptCalls.push(text);
|
||||
this.isStreaming = true;
|
||||
this.sessionManager.appendMessage({ role: "user", content: text, timestamp: Date.now() });
|
||||
const assistantMessage = makeAssistantMessage("pong");
|
||||
@@ -147,6 +165,27 @@ class FakeAgentSession {
|
||||
this.isStreaming = false;
|
||||
}
|
||||
|
||||
async promptCustomMessage(message: { customType: string; content: string; details?: unknown }): Promise<void> {
|
||||
this.customMessages.push(message);
|
||||
this.isStreaming = true;
|
||||
const assistantMessage = makeAssistantMessage("skill pong");
|
||||
for (const listener of this.#listeners) {
|
||||
listener({
|
||||
type: "message_update",
|
||||
message: assistantMessage,
|
||||
assistantMessageEvent: { type: "text_delta", delta: "skill pong" },
|
||||
} as AgentSessionEvent);
|
||||
}
|
||||
this.sessionManager.appendMessage(assistantMessage);
|
||||
for (const listener of this.#listeners) {
|
||||
listener({
|
||||
type: "agent_end",
|
||||
messages: [assistantMessage],
|
||||
} as AgentSessionEvent);
|
||||
}
|
||||
this.isStreaming = false;
|
||||
}
|
||||
|
||||
async refreshMCPTools(_tools: unknown[]): Promise<void> {}
|
||||
|
||||
getContextUsage(): undefined {
|
||||
@@ -192,6 +231,37 @@ class FakeAgentSession {
|
||||
|
||||
setActiveToolsByName(_toolNames: string[]): void {}
|
||||
|
||||
setClientBridge(_bridge: unknown): void {}
|
||||
|
||||
getPlanModeState(): PlanModeState | undefined {
|
||||
return this.planModeState;
|
||||
}
|
||||
|
||||
setPlanModeState(state: PlanModeState | undefined): void {
|
||||
this.planModeState = state;
|
||||
}
|
||||
|
||||
getToolByName(_name: string): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
toggleFastMode(): boolean {
|
||||
this.fastMode = !this.fastMode;
|
||||
return this.fastMode;
|
||||
}
|
||||
|
||||
setFastMode(enabled: boolean): void {
|
||||
this.fastMode = enabled;
|
||||
}
|
||||
|
||||
isFastModeEnabled(): boolean {
|
||||
return this.fastMode;
|
||||
}
|
||||
|
||||
setForcedToolChoice(toolName: string): void {
|
||||
this.forcedToolChoice = toolName;
|
||||
}
|
||||
|
||||
async sendCustomMessage(_message: string, _options?: unknown): Promise<void> {}
|
||||
|
||||
async sendUserMessage(_content: string, _options?: unknown): Promise<void> {}
|
||||
@@ -225,6 +295,12 @@ function getChunkMessageId(notification: SessionNotification): string | undefine
|
||||
return typeof update.messageId === "string" ? update.messageId : undefined;
|
||||
}
|
||||
|
||||
function expectAcpNotifications(updates: SessionNotification[]): void {
|
||||
for (const update of updates) {
|
||||
expectAcpStructure(zSessionNotification, update);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupRoots: string[] = [];
|
||||
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
@@ -236,6 +312,7 @@ afterEach(async () => {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
_resetSettingsForTest();
|
||||
|
||||
for (const root of cleanupRoots.splice(0)) {
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
@@ -252,6 +329,7 @@ async function createHarness(): Promise<AgentHarness> {
|
||||
await fs.promises.mkdir(cwdA, { recursive: true });
|
||||
await fs.promises.mkdir(cwdB, { recursive: true });
|
||||
setAgentDir(agentDir);
|
||||
await Settings.init({ agentDir, inMemory: true });
|
||||
|
||||
const updates: SessionNotification[] = [];
|
||||
const abortController = new AbortController();
|
||||
@@ -288,6 +366,8 @@ describe("ACP agent", () => {
|
||||
const harness = await createHarness();
|
||||
const first = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
|
||||
const second = await harness.agent.newSession({ cwd: harness.cwdB, mcpServers: [] });
|
||||
expectAcpStructure(zNewSessionResponse, first);
|
||||
expectAcpStructure(zNewSessionResponse, second);
|
||||
|
||||
expect(first.models?.availableModels.map(model => model.modelId)).toEqual(
|
||||
TEST_MODELS.map(model => `${model.provider}/${model.id}`),
|
||||
@@ -302,6 +382,7 @@ describe("ACP agent", () => {
|
||||
configId: "thinking",
|
||||
value: "high",
|
||||
});
|
||||
expectAcpNotifications(harness.updates);
|
||||
|
||||
const firstSession = harness.findSession(first.sessionId);
|
||||
const secondSession = harness.findSession(second.sessionId);
|
||||
@@ -318,12 +399,13 @@ describe("ACP agent", () => {
|
||||
cwd: harness.cwdA,
|
||||
mcpServers: [],
|
||||
});
|
||||
expectAcpStructure(zForkSessionResponse, forked);
|
||||
const forkedSession = harness.findSession(forked.sessionId);
|
||||
const forkedMessages = forkedSession?.sessionManager.buildSessionContext().messages ?? [];
|
||||
expect(forked.sessionId).not.toBe(first.sessionId);
|
||||
expect(forkedMessages.some(message => message.role === "user" && message.content === "fork me")).toBe(true);
|
||||
|
||||
await harness.agent.unstable_closeSession({ sessionId: forked.sessionId });
|
||||
await harness.agent.closeSession({ sessionId: forked.sessionId });
|
||||
await expect(harness.agent.setSessionMode({ sessionId: forked.sessionId, modeId: "default" })).rejects.toThrow(
|
||||
"Unsupported ACP session",
|
||||
);
|
||||
@@ -332,6 +414,72 @@ describe("ACP agent", () => {
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("advertises plan mode and emits schema-valid mode updates", async () => {
|
||||
const harness = await createHarness();
|
||||
Settings.instance.set("plan.enabled", true);
|
||||
|
||||
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
|
||||
expectAcpStructure(zNewSessionResponse, created);
|
||||
expect(created.modes?.availableModes.map(mode => mode.id)).toEqual(["default", "plan"]);
|
||||
const initialModeConfig = created.configOptions?.find(option => option.id === "mode") as
|
||||
| { currentValue?: unknown; options?: Array<{ value: string }> }
|
||||
| undefined;
|
||||
expect(initialModeConfig?.currentValue).toBe("default");
|
||||
expect(initialModeConfig?.options?.map(option => option.value)).toEqual(["default", "plan"]);
|
||||
|
||||
await harness.agent.setSessionMode({ sessionId: created.sessionId, modeId: "plan" });
|
||||
|
||||
const session = harness.findSession(created.sessionId)!;
|
||||
expect(session.planModeState).toEqual(
|
||||
expect.objectContaining({ enabled: true, planFilePath: "local://PLAN.md", workflow: "parallel" }),
|
||||
);
|
||||
const modeNotifications = harness.updates.filter(
|
||||
notification =>
|
||||
notification.sessionId === created.sessionId &&
|
||||
(notification.update.sessionUpdate === "current_mode_update" ||
|
||||
notification.update.sessionUpdate === "config_option_update"),
|
||||
);
|
||||
expectAcpNotifications(modeNotifications);
|
||||
expect(
|
||||
modeNotifications.some(
|
||||
notification =>
|
||||
notification.update.sessionUpdate === "current_mode_update" &&
|
||||
notification.update.currentModeId === "plan",
|
||||
),
|
||||
).toBe(true);
|
||||
const configNotification = modeNotifications.findLast(
|
||||
notification => notification.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
const currentModeConfig =
|
||||
configNotification?.update.sessionUpdate === "config_option_update"
|
||||
? (configNotification.update.configOptions.find(option => option.id === "mode") as
|
||||
| { currentValue?: unknown }
|
||||
| undefined)
|
||||
: undefined;
|
||||
expect(currentModeConfig?.currentValue).toBe("plan");
|
||||
|
||||
await harness.agent.setSessionMode({ sessionId: created.sessionId, modeId: "default" });
|
||||
expect(session.planModeState).toBeUndefined();
|
||||
|
||||
harness.abortController.abort();
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("accepts only ACP underscore-prefixed extension methods", async () => {
|
||||
const harness = await createHarness();
|
||||
|
||||
const result = await harness.agent.extMethod("_omp/sessions/listAll", { limit: 2 });
|
||||
|
||||
expect(Array.isArray(result.sessions)).toBe(true);
|
||||
expect(typeof result.total).toBe("number");
|
||||
await expect(harness.agent.extMethod("omp/sessions/listAll", { limit: 2 })).rejects.toThrow(
|
||||
"Unknown ACP ext method",
|
||||
);
|
||||
|
||||
harness.abortController.abort();
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("replays messageIds and returns turn usage for prompts", async () => {
|
||||
const harness = await createHarness();
|
||||
const stored = new FakeAgentSession(harness.cwdA);
|
||||
@@ -341,7 +489,12 @@ describe("ACP agent", () => {
|
||||
await stored.sessionManager.ensureOnDisk();
|
||||
await stored.sessionManager.flush();
|
||||
|
||||
await harness.agent.loadSession({ sessionId: stored.sessionId, cwd: harness.cwdA, mcpServers: [] });
|
||||
const loaded = await harness.agent.loadSession({
|
||||
sessionId: stored.sessionId,
|
||||
cwd: harness.cwdA,
|
||||
mcpServers: [],
|
||||
});
|
||||
expectAcpStructure(zLoadSessionResponse, loaded);
|
||||
const replayChunks = harness.updates.filter(
|
||||
update =>
|
||||
update.sessionId === stored.sessionId &&
|
||||
@@ -368,6 +521,8 @@ describe("ACP agent", () => {
|
||||
messageId: "05b17a6f-b310-4be7-b767-6b4f3a84eb63",
|
||||
prompt: [{ type: "text", text: "ping" }],
|
||||
} as PromptRequest);
|
||||
expectAcpStructure(zPromptResponse, response);
|
||||
expectAcpNotifications(harness.updates);
|
||||
|
||||
const liveChunks = harness.updates.filter(
|
||||
update => update.sessionId === live.sessionId && update.update.sessionUpdate === "agent_message_chunk",
|
||||
@@ -389,4 +544,140 @@ describe("ACP agent", () => {
|
||||
harness.abortController.abort();
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("advertises ACP-safe builtins and skill commands", async () => {
|
||||
const harness = await createHarness();
|
||||
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
|
||||
const session = harness.findSession(created.sessionId)!;
|
||||
const skillDir = path.join(harness.cwdA, ".skills", "sample");
|
||||
const skillPath = path.join(skillDir, "SKILL.md");
|
||||
await fs.promises.mkdir(skillDir, { recursive: true });
|
||||
await fs.promises.writeFile(skillPath, "---\ndescription: Sample skill\n---\n# Sample\nDo work.\n");
|
||||
session.skills = [
|
||||
{
|
||||
name: "sample",
|
||||
description: "Sample skill",
|
||||
filePath: skillPath,
|
||||
baseDir: skillDir,
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
await harness.agent.prompt({
|
||||
sessionId: created.sessionId,
|
||||
messageId: "00000000-0000-4000-8000-000000000004",
|
||||
prompt: [{ type: "text", text: "/reload-plugins" }],
|
||||
} as PromptRequest);
|
||||
|
||||
const commandUpdates = harness.updates.filter(
|
||||
update =>
|
||||
update.sessionId === created.sessionId && update.update.sessionUpdate === "available_commands_update",
|
||||
);
|
||||
const names = commandUpdates.flatMap(update =>
|
||||
update.update.sessionUpdate === "available_commands_update"
|
||||
? update.update.availableCommands.map(command => command.name)
|
||||
: [],
|
||||
);
|
||||
expect(names).toContain("fast");
|
||||
expect(names).toContain("force");
|
||||
expect(names).toContain("skill:sample");
|
||||
expect(names).not.toContain("settings");
|
||||
expect(names).not.toContain("copy");
|
||||
expect(names).not.toContain("plan");
|
||||
expect(names).not.toContain("loop");
|
||||
expect(names).not.toContain("login");
|
||||
expect(names).not.toContain("new");
|
||||
expect(names).not.toContain("handoff");
|
||||
expect(names).not.toContain("fork");
|
||||
expect(names).not.toContain("btw");
|
||||
expect(names).not.toContain("drop");
|
||||
expect(names).not.toContain("resume");
|
||||
expect(names).not.toContain("agents");
|
||||
expect(names).not.toContain("extensions");
|
||||
expect(names).not.toContain("hotkeys");
|
||||
|
||||
harness.abortController.abort();
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("executes skill commands through custom skill messages", async () => {
|
||||
const harness = await createHarness();
|
||||
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
|
||||
const session = harness.findSession(created.sessionId)!;
|
||||
const skillDir = path.join(harness.cwdA, ".skills", "sample");
|
||||
const skillPath = path.join(skillDir, "SKILL.md");
|
||||
await fs.promises.mkdir(skillDir, { recursive: true });
|
||||
await fs.promises.writeFile(skillPath, "---\ndescription: Sample skill\n---\n# Sample\nDo work.\n");
|
||||
session.skills = [
|
||||
{
|
||||
name: "sample",
|
||||
description: "Sample skill",
|
||||
filePath: skillPath,
|
||||
baseDir: skillDir,
|
||||
source: "test",
|
||||
},
|
||||
];
|
||||
|
||||
await harness.agent.prompt({
|
||||
sessionId: created.sessionId,
|
||||
messageId: "00000000-0000-4000-8000-000000000001",
|
||||
prompt: [{ type: "text", text: "/skill:sample extra context" }],
|
||||
} as PromptRequest);
|
||||
|
||||
expect(session.promptCalls).toEqual([]);
|
||||
expect(session.customMessages).toHaveLength(1);
|
||||
expect(session.customMessages[0]!.customType).toBe("skill-prompt");
|
||||
expect(session.customMessages[0]!.content).toContain("# Sample\nDo work.");
|
||||
expect(session.customMessages[0]!.content).toContain(`Skill: ${skillPath}`);
|
||||
expect(session.customMessages[0]!.content).toContain("User: extra context");
|
||||
|
||||
harness.abortController.abort();
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("executes consumed ACP builtins without prompting the agent", async () => {
|
||||
const harness = await createHarness();
|
||||
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
|
||||
const session = harness.findSession(created.sessionId)!;
|
||||
|
||||
const response = await harness.agent.prompt({
|
||||
sessionId: created.sessionId,
|
||||
messageId: "00000000-0000-4000-8000-000000000002",
|
||||
prompt: [{ type: "text", text: "/fast status" }],
|
||||
} as PromptRequest);
|
||||
|
||||
const chunks = harness.updates.filter(
|
||||
update => update.sessionId === created.sessionId && update.update.sessionUpdate === "agent_message_chunk",
|
||||
);
|
||||
expect(response.userMessageId).toBe("00000000-0000-4000-8000-000000000002");
|
||||
expect(session.promptCalls).toEqual([]);
|
||||
expect(
|
||||
chunks.some(
|
||||
update =>
|
||||
update.update.sessionUpdate === "agent_message_chunk" &&
|
||||
update.update.content.type === "text" &&
|
||||
update.update.content.text === "Fast mode is off.",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
harness.abortController.abort();
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
|
||||
it("executes force builtins and forwards remaining prompt text", async () => {
|
||||
const harness = await createHarness();
|
||||
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
|
||||
const session = harness.findSession(created.sessionId)!;
|
||||
|
||||
await harness.agent.prompt({
|
||||
sessionId: created.sessionId,
|
||||
messageId: "00000000-0000-4000-8000-000000000003",
|
||||
prompt: [{ type: "text", text: "/force read inspect package.json" }],
|
||||
} as PromptRequest);
|
||||
|
||||
expect(session.forcedToolChoice).toBe("read");
|
||||
expect(session.promptCalls).toEqual(["inspect package.json"]);
|
||||
|
||||
harness.abortController.abort();
|
||||
await Bun.sleep(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
import { describe, expect, it, spyOn } from "bun:test";
|
||||
import { Settings } from "../src/config/settings";
|
||||
import type { AgentSession } from "../src/session/agent-session";
|
||||
import type { SessionManager } from "../src/session/session-manager";
|
||||
import { executeAcpBuiltinSlashCommand } from "../src/slash-commands/acp-builtins";
|
||||
|
||||
interface FakeAcpBuiltinSession {
|
||||
fastMode: boolean;
|
||||
forcedToolChoice: string | undefined;
|
||||
isStreaming: boolean;
|
||||
sessionFile: string | undefined;
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
_todoPhases: Array<{ name: string; tasks: Array<{ content: string; status: string }> }>;
|
||||
toggleFastMode(): boolean;
|
||||
setFastMode(enabled: boolean): void;
|
||||
isFastModeEnabled(): boolean;
|
||||
setForcedToolChoice(toolName: string): void;
|
||||
fetchUsageReports?: () => Promise<unknown>;
|
||||
getAsyncJobSnapshot: (opts?: { recentLimit?: number }) => { running: unknown[]; recent: unknown[] } | null;
|
||||
formatSessionAsText: () => string;
|
||||
getLastAssistantText: () => string | undefined;
|
||||
messages: unknown[];
|
||||
model: { provider: string; id: string } | undefined;
|
||||
newSession(opts?: { drop?: boolean; parentSession?: string }): Promise<boolean>;
|
||||
fork(): Promise<boolean>;
|
||||
handoff(instr?: string): Promise<{ document: string; savedPath?: string } | undefined>;
|
||||
exportToHtml(outputPath?: string): Promise<string>;
|
||||
getTodoPhases(): Array<{ name: string; tasks: Array<{ content: string; status: string }> }>;
|
||||
setTodoPhases(phases: Array<{ name: string; tasks: Array<{ content: string; status: string }> }>): void;
|
||||
refreshBaseSystemPrompt(): Promise<void>;
|
||||
getToolByName(name: string): unknown;
|
||||
compact(args?: string): Promise<void>;
|
||||
getContextUsage(): { tokens?: number; contextWindow: number } | undefined;
|
||||
getAvailableModels(): Array<{ provider: string; id: string; contextWindow?: number }>;
|
||||
setModel(model: unknown): Promise<void>;
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
const output: string[] = [];
|
||||
const session: FakeAcpBuiltinSession = {
|
||||
fastMode: false,
|
||||
forcedToolChoice: undefined as string | undefined,
|
||||
isStreaming: false,
|
||||
sessionFile: undefined,
|
||||
sessionId: "fake-session-id",
|
||||
sessionName: "Fake Session",
|
||||
_todoPhases: [],
|
||||
toggleFastMode() {
|
||||
this.fastMode = !this.fastMode;
|
||||
return this.fastMode;
|
||||
},
|
||||
setFastMode(enabled: boolean) {
|
||||
this.fastMode = enabled;
|
||||
},
|
||||
isFastModeEnabled() {
|
||||
return this.fastMode;
|
||||
},
|
||||
setForcedToolChoice(toolName: string) {
|
||||
this.forcedToolChoice = toolName;
|
||||
},
|
||||
async newSession(_opts?: { drop?: boolean; parentSession?: string }) {
|
||||
return true;
|
||||
},
|
||||
async fork() {
|
||||
return true;
|
||||
},
|
||||
async handoff(_instr?: string) {
|
||||
return undefined;
|
||||
},
|
||||
async exportToHtml(outputPath?: string) {
|
||||
return outputPath ?? "/tmp/exported-session.html";
|
||||
},
|
||||
getTodoPhases() {
|
||||
return this._todoPhases;
|
||||
},
|
||||
setTodoPhases(phases) {
|
||||
this._todoPhases = phases;
|
||||
},
|
||||
async refreshBaseSystemPrompt() {},
|
||||
getAsyncJobSnapshot: () => null,
|
||||
formatSessionAsText: () => "",
|
||||
getLastAssistantText: () => undefined,
|
||||
messages: [],
|
||||
model: undefined,
|
||||
getToolByName: (_name: string) => undefined,
|
||||
async compact(_args?: string) {},
|
||||
getContextUsage: () => undefined,
|
||||
getAvailableModels: () => [] as Array<{ provider: string; id: string; contextWindow?: number }>,
|
||||
async setModel(_model: unknown) {},
|
||||
};
|
||||
const typedSession = session as unknown as AgentSession & FakeAcpBuiltinSession;
|
||||
const fakeSessionManager = {
|
||||
_sessionFile: undefined as string | undefined,
|
||||
_cwd: "/tmp/project",
|
||||
_entries: [] as { type: string }[],
|
||||
_customEntries: [] as Array<{ customType: string; data: unknown }>,
|
||||
_movedTo: undefined as string | undefined,
|
||||
_flushed: false,
|
||||
_sessionName: undefined as string | undefined,
|
||||
getSessionId(): string {
|
||||
return "fake-session-id";
|
||||
},
|
||||
getSessionFile(): string | undefined {
|
||||
return this._sessionFile;
|
||||
},
|
||||
getEntries(): { type: string }[] {
|
||||
return this._entries;
|
||||
},
|
||||
getBranch(): { type: string }[] {
|
||||
return this._entries;
|
||||
},
|
||||
appendCustomEntry(customType: string, data?: unknown): string {
|
||||
this._customEntries.push({ customType, data });
|
||||
return "fake-entry-id";
|
||||
},
|
||||
async flush() {
|
||||
this._flushed = true;
|
||||
},
|
||||
async moveTo(newCwd: string) {
|
||||
this._cwd = newCwd;
|
||||
this._movedTo = newCwd;
|
||||
},
|
||||
getCwd(): string {
|
||||
return this._cwd;
|
||||
},
|
||||
async setSessionName(name: string, _source: string): Promise<boolean> {
|
||||
this._sessionName = name;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return {
|
||||
output,
|
||||
session,
|
||||
fakeSessionManager,
|
||||
runtime: {
|
||||
session: typedSession,
|
||||
sessionManager: fakeSessionManager as unknown as SessionManager,
|
||||
settings: Settings.isolated(),
|
||||
cwd: "/tmp/project",
|
||||
output: (text: string) => {
|
||||
output.push(text);
|
||||
},
|
||||
refreshCommands: () => {},
|
||||
notifyTitleChanged: undefined as (() => Promise<void> | void) | undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ACP builtin slash commands", () => {
|
||||
it("consumes fast status without returning prompt text", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
|
||||
const result = await executeAcpBuiltinSlashCommand("/fast status", runtime);
|
||||
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output).toEqual(["Fast mode is off."]);
|
||||
});
|
||||
|
||||
it("forces a tool and returns remaining prompt text", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
|
||||
const result = await executeAcpBuiltinSlashCommand("/force read inspect package.json", runtime);
|
||||
|
||||
expect(result).toEqual({ prompt: "inspect package.json" });
|
||||
expect(runtime.session.forcedToolChoice).toBe("read");
|
||||
expect(output).toEqual(["Next turn forced to use read."]);
|
||||
});
|
||||
|
||||
it("renders provider usage reports when the session can fetch them", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
runtime.session.fetchUsageReports = async () => [
|
||||
{
|
||||
provider: "openai-codex",
|
||||
fetchedAt: Date.now(),
|
||||
limits: [
|
||||
{
|
||||
id: "codex-5h",
|
||||
label: "5 hours",
|
||||
scope: { provider: "openai-codex", tier: "prolite", accountId: "account-1" },
|
||||
window: { id: "5h", label: "5 hours", resetsAt: Date.now() + 60 * 60 * 1000 },
|
||||
amount: { used: 0.24, usedFraction: 0.24, unit: "unknown" },
|
||||
},
|
||||
],
|
||||
metadata: { email: "user@example.com" },
|
||||
},
|
||||
];
|
||||
|
||||
const result = await executeAcpBuiltinSlashCommand("/usage", runtime);
|
||||
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Openai Codex");
|
||||
expect(output[0]).toContain("5 hours (prolite)");
|
||||
expect(output[0]).toContain("user@example.com: 0.24 unknown used (76.0% left)");
|
||||
expect(output[0]).toContain("resets in");
|
||||
});
|
||||
|
||||
it("returns false for unknown commands", async () => {
|
||||
const { runtime } = createRuntime();
|
||||
|
||||
const result = await executeAcpBuiltinSlashCommand("/not-a-real-command-xyz", runtime);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
// /jobs
|
||||
it("jobs: shows informative message when snapshot is null", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/jobs", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("background jobs");
|
||||
});
|
||||
|
||||
it("jobs: lists running and recent jobs from snapshot", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
runtime.session.getAsyncJobSnapshot = () => ({
|
||||
running: [{ id: "j1", type: "bash", status: "running", label: "npm install", startTime: Date.now() - 5000 }],
|
||||
recent: [{ id: "j2", type: "task", status: "completed", label: "build done", startTime: Date.now() - 60_000 }],
|
||||
});
|
||||
|
||||
const result = await executeAcpBuiltinSlashCommand("/jobs", runtime);
|
||||
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("npm install");
|
||||
expect(output[0]).toContain("build done");
|
||||
expect(output[0]).toContain("Running Jobs");
|
||||
expect(output[0]).toContain("Recent Jobs");
|
||||
});
|
||||
|
||||
// /dump
|
||||
it("dump: outputs transcript when present", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
runtime.session.formatSessionAsText = () => "Session content here";
|
||||
|
||||
const result = await executeAcpBuiltinSlashCommand("/dump", runtime);
|
||||
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toBe("Session content here");
|
||||
});
|
||||
|
||||
it("dump: outputs empty-state message when no messages", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
|
||||
const result = await executeAcpBuiltinSlashCommand("/dump", runtime);
|
||||
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("No messages");
|
||||
});
|
||||
|
||||
// /model
|
||||
it("model: returns current model when set", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
runtime.session.model = { provider: "anthropic", id: "claude-opus-4-5" } as never;
|
||||
|
||||
const result = await executeAcpBuiltinSlashCommand("/model", runtime);
|
||||
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("anthropic/claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("model: returns no-selection message when undefined", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
|
||||
const result = await executeAcpBuiltinSlashCommand("/model", runtime);
|
||||
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("No model");
|
||||
});
|
||||
|
||||
it("model: returns ACP usage message when args provided", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
|
||||
const result = await executeAcpBuiltinSlashCommand("/model claude-3-5-sonnet", runtime);
|
||||
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]?.toLowerCase()).toContain("acp");
|
||||
});
|
||||
|
||||
// Removed TUI-only and dropped commands fall through as false
|
||||
it("removed commands return false (fall through to model)", async () => {
|
||||
const removedCommands = [
|
||||
"/login",
|
||||
"/logout",
|
||||
"/resume",
|
||||
"/tree",
|
||||
"/branch",
|
||||
"/plan",
|
||||
"/loop",
|
||||
"/hotkeys",
|
||||
"/extensions",
|
||||
"/agents",
|
||||
"/copy",
|
||||
"/btw hi",
|
||||
"/new",
|
||||
"/drop",
|
||||
"/handoff",
|
||||
"/fork",
|
||||
];
|
||||
for (const cmd of removedCommands) {
|
||||
const { runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand(cmd, runtime);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("session lifecycle commands", () => {
|
||||
it("/session delete: returns in-memory usage when no sessionFile", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/session delete", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("in-memory");
|
||||
});
|
||||
|
||||
it("/session delete: refuses while streaming", async () => {
|
||||
const { output, session, fakeSessionManager, runtime } = createRuntime();
|
||||
session.isStreaming = true;
|
||||
fakeSessionManager._sessionFile = "/tmp/session.jsonl";
|
||||
const result = await executeAcpBuiltinSlashCommand("/session delete", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("streaming");
|
||||
});
|
||||
|
||||
it("/rename: renames and calls notifyTitleChanged on success", async () => {
|
||||
const { output, fakeSessionManager, runtime } = createRuntime();
|
||||
let notified = false;
|
||||
runtime.notifyTitleChanged = async () => {
|
||||
notified = true;
|
||||
};
|
||||
const result = await executeAcpBuiltinSlashCommand("/rename Project Apex", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(fakeSessionManager._sessionName).toBe("Project Apex");
|
||||
expect(output[0]).toBe("Session renamed to Project Apex.");
|
||||
expect(notified).toBe(true);
|
||||
});
|
||||
|
||||
it("/rename: outputs precedence message when setSessionName returns false", async () => {
|
||||
const { output, fakeSessionManager, runtime } = createRuntime();
|
||||
let notified = false;
|
||||
runtime.notifyTitleChanged = async () => {
|
||||
notified = true;
|
||||
};
|
||||
fakeSessionManager.setSessionName = async () => false;
|
||||
const result = await executeAcpBuiltinSlashCommand("/rename Bar", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("takes precedence");
|
||||
expect(notified).toBe(false);
|
||||
});
|
||||
|
||||
it("/move: reports moved path via sessionManager.getCwd() and calls notifyTitleChanged", async () => {
|
||||
const { output, fakeSessionManager, runtime } = createRuntime();
|
||||
let notified = false;
|
||||
runtime.notifyTitleChanged = async () => {
|
||||
notified = true;
|
||||
};
|
||||
const result = await executeAcpBuiltinSlashCommand("/move /tmp", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(fakeSessionManager._flushed).toBe(true);
|
||||
expect(fakeSessionManager._movedTo).toBe("/tmp");
|
||||
expect(output[0]).toContain("/tmp");
|
||||
expect(notified).toBe(true);
|
||||
});
|
||||
|
||||
it("/move: refuses while streaming", async () => {
|
||||
const { output, session, runtime } = createRuntime();
|
||||
session.isStreaming = true;
|
||||
const result = await executeAcpBuiltinSlashCommand("/move /tmp", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("streaming");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wave 3 commands", () => {
|
||||
// /export
|
||||
it("/export: calls exportToHtml with the given arg and outputs the path", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/export /tmp/out.html", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toBe("Session exported to: /tmp/out.html");
|
||||
});
|
||||
|
||||
it("/export: uses default path when no arg given", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/export", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Session exported to:");
|
||||
});
|
||||
|
||||
it("/export: returns usage on exportToHtml failure", async () => {
|
||||
const { output, session, runtime } = createRuntime();
|
||||
session.exportToHtml = async () => {
|
||||
throw new Error("disk full");
|
||||
};
|
||||
const result = await executeAcpBuiltinSlashCommand("/export", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Failed to export session: disk full");
|
||||
});
|
||||
|
||||
// /todo
|
||||
it("/todo no-args: outputs empty state message when no todos", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/todo", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toBe("No todos. Use /todo append <task> to start one.");
|
||||
});
|
||||
|
||||
it("/todo append: stores phases and records custom entry", async () => {
|
||||
const { session, fakeSessionManager, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand('/todo append "Build" "Wire setup"', runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(session._todoPhases).toHaveLength(1);
|
||||
expect(session._todoPhases[0]?.name).toBe("Build");
|
||||
expect(session._todoPhases[0]?.tasks[0]?.content).toBe("Wire setup");
|
||||
expect(fakeSessionManager._customEntries).toHaveLength(1);
|
||||
expect(fakeSessionManager._customEntries[0]?.customType).toBe("user_todo_edit");
|
||||
});
|
||||
|
||||
it("/todo edit: returns TUI-only usage message", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/todo edit", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("TUI editor");
|
||||
});
|
||||
|
||||
it("/todo unknown: returns usage message", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/todo foobar", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Unknown /todo subcommand");
|
||||
});
|
||||
|
||||
// /move
|
||||
it("/move: returns usage when no arg", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/move", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Usage: /move");
|
||||
});
|
||||
|
||||
it("/move: returns usage when path does not exist", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/move /no/such/path/xyz", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("does not exist");
|
||||
});
|
||||
|
||||
// /memory
|
||||
it("/memory unknown: returns usage message", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/memory unknownverb", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Usage: /memory");
|
||||
});
|
||||
|
||||
it("/memory view: outputs memory payload (or empty message)", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/memory view", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("/memory (no args): defaults to view", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/memory", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// /todo start fuzzy match
|
||||
it("/todo start: finds pending task by substring and starts it", async () => {
|
||||
const { output, session, runtime } = createRuntime();
|
||||
session._todoPhases = [{ name: "Setup", tasks: [{ content: "Wire up router", status: "pending" }] }];
|
||||
const result = await executeAcpBuiltinSlashCommand('/todo start "wire"', runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Wire up router");
|
||||
expect(session._todoPhases[0]?.tasks[0]?.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
// /browser
|
||||
it("/browser visible: sets headless=false; second call is idempotent", async () => {
|
||||
const { runtime } = createRuntime();
|
||||
runtime.settings.set("browser.enabled" as never, true as never);
|
||||
runtime.settings.set("browser.headless" as never, true as never);
|
||||
const r1 = await executeAcpBuiltinSlashCommand("/browser visible", runtime);
|
||||
expect(r1).toEqual({ consumed: true });
|
||||
expect(runtime.settings.get("browser.headless" as never)).toBe(false);
|
||||
const r2 = await executeAcpBuiltinSlashCommand("/browser visible", runtime);
|
||||
expect(r2).toEqual({ consumed: true });
|
||||
expect(runtime.settings.get("browser.headless" as never)).toBe(false);
|
||||
});
|
||||
|
||||
it("/browser no-arg after /browser visible toggles to headless", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
runtime.settings.set("browser.enabled" as never, true as never);
|
||||
runtime.settings.set("browser.headless" as never, true as never);
|
||||
await executeAcpBuiltinSlashCommand("/browser visible", runtime);
|
||||
const r = await executeAcpBuiltinSlashCommand("/browser", runtime);
|
||||
expect(r).toEqual({ consumed: true });
|
||||
expect(output[output.length - 1]).toContain("headless");
|
||||
expect(runtime.settings.get("browser.headless" as never)).toBe(true);
|
||||
});
|
||||
|
||||
// /compact
|
||||
it("/compact: reports Compaction complete. after session.compact resolves", async () => {
|
||||
const { output, session, runtime } = createRuntime();
|
||||
let compactCalled = false;
|
||||
session.compact = async (_args?: string) => {
|
||||
compactCalled = true;
|
||||
};
|
||||
const result = await executeAcpBuiltinSlashCommand("/compact", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(compactCalled).toBe(true);
|
||||
expect(output[0]).toContain("Compaction complete.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wave 4 commands", () => {
|
||||
// /mcp
|
||||
it("/mcp (no args): outputs help text containing list, enable, disable, remove, reload", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/mcp", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("list");
|
||||
expect(output[0]).toContain("enable");
|
||||
expect(output[0]).toContain("disable");
|
||||
expect(output[0]).toContain("remove");
|
||||
expect(output[0]).toContain("reload");
|
||||
});
|
||||
|
||||
it("/mcp help: outputs help text containing list, enable, disable, remove, reload", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/mcp help", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("list");
|
||||
expect(output[0]).toContain("enable");
|
||||
expect(output[0]).toContain("disable");
|
||||
expect(output[0]).toContain("remove");
|
||||
expect(output[0]).toContain("reload");
|
||||
});
|
||||
|
||||
it("/mcp add (no args): returns usage string", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/mcp add", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Usage");
|
||||
});
|
||||
|
||||
it("/mcp reload: calls refreshCommands and outputs confirmation", async () => {
|
||||
let refreshCalled = false;
|
||||
const { output, runtime } = createRuntime();
|
||||
runtime.refreshCommands = () => {
|
||||
refreshCalled = true;
|
||||
};
|
||||
const result = await executeAcpBuiltinSlashCommand("/mcp reload", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(refreshCalled).toBe(true);
|
||||
expect(output[0]).toContain("reload");
|
||||
});
|
||||
|
||||
it("/mcp resources: outputs server list or no-server message", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/mcp resources", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
// No servers configured in tmp project dir — should report that
|
||||
expect(output[0]).toMatch(/No MCP servers configured|No resources/);
|
||||
});
|
||||
|
||||
it("/mcp unknown-verb: returns usage pointing to help", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/mcp frobnicate", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Unknown");
|
||||
});
|
||||
|
||||
// /ssh
|
||||
it("/ssh (no args): outputs help text containing list and remove", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/ssh", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("list");
|
||||
expect(output[0]).toContain("remove");
|
||||
});
|
||||
|
||||
it("/ssh help: outputs help text containing list and remove", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/ssh help", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("list");
|
||||
expect(output[0]).toContain("remove");
|
||||
});
|
||||
|
||||
it("/ssh add (no args): returns usage", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/ssh add", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Usage");
|
||||
});
|
||||
|
||||
it("/ssh unknown-verb: returns unknown subcommand message", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/ssh frobnicate", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Unknown");
|
||||
});
|
||||
|
||||
// /marketplace
|
||||
it("/marketplace help: outputs help text", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/marketplace help", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Marketplace commands");
|
||||
expect(output[0]).toContain("install");
|
||||
});
|
||||
|
||||
it("/marketplace install (no args): returns interactive picker usage", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/marketplace install", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("TUI-only");
|
||||
});
|
||||
|
||||
it("/marketplace uninstall (no args): returns interactive picker usage", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/marketplace uninstall", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("TUI-only");
|
||||
});
|
||||
|
||||
// /plugins
|
||||
it("/plugins list: outputs without throwing when registries are empty", async () => {
|
||||
const { MarketplaceManager } = await import("../src/extensibility/plugins/marketplace");
|
||||
const { PluginManager } = await import("../src/extensibility/plugins");
|
||||
const listInstalledSpy = spyOn(MarketplaceManager.prototype, "listInstalledPlugins").mockResolvedValue([]);
|
||||
const npmListSpy = spyOn(PluginManager.prototype, "list").mockResolvedValue([]);
|
||||
try {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/plugins list", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
listInstalledSpy.mockRestore();
|
||||
npmListSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("/plugins (no args): defaults to list", async () => {
|
||||
const { MarketplaceManager } = await import("../src/extensibility/plugins/marketplace");
|
||||
const { PluginManager } = await import("../src/extensibility/plugins");
|
||||
const listInstalledSpy = spyOn(MarketplaceManager.prototype, "listInstalledPlugins").mockResolvedValue([]);
|
||||
const npmListSpy = spyOn(PluginManager.prototype, "list").mockResolvedValue([]);
|
||||
try {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/plugins", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
listInstalledSpy.mockRestore();
|
||||
npmListSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// /todo start with in_progress status in fuzzy list
|
||||
it("/todo start: resolves ambiguous matches by preferring active tasks", async () => {
|
||||
const { output, session, runtime } = createRuntime();
|
||||
session._todoPhases = [
|
||||
{
|
||||
name: "Phase 1",
|
||||
tasks: [
|
||||
{ content: "Wire auth middleware", status: "pending" },
|
||||
{ content: "Wire session store", status: "completed" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = await executeAcpBuiltinSlashCommand('/todo start "wire"', runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Wire auth middleware");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wave 5 — adapters and polish", () => {
|
||||
// /mcp help lists new subcommands
|
||||
it("/mcp help: lists resources, prompts, test, add, smithery-search", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/mcp help", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("resources");
|
||||
expect(output[0]).toContain("prompts");
|
||||
expect(output[0]).toContain("test");
|
||||
expect(output[0]).toContain("add");
|
||||
expect(output[0]).toContain("smithery-search");
|
||||
});
|
||||
|
||||
// /mcp add — verify parsing and output message
|
||||
it("/mcp add foo --url https://example.com --token X --scope project: outputs success or propagates write error", async () => {
|
||||
// Uses project scope so it writes to /tmp/project/.omp/mcp.json which test infra controls.
|
||||
// We verify the command either reports success or a meaningful error (not a parse error).
|
||||
const mcpModule = await import("../src/mcp/config-writer");
|
||||
const spy = spyOn(mcpModule, "addMCPServer").mockResolvedValue(undefined);
|
||||
try {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand(
|
||||
"/mcp add foo --url https://example.com --token X --scope project",
|
||||
runtime,
|
||||
);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain('Added MCP server "foo" (project).');
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// /mcp test — spy on connectToServer
|
||||
it("/mcp test bogus: returns error when server not found in config", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
// No servers in /tmp/project config — server not found
|
||||
const result = await executeAcpBuiltinSlashCommand("/mcp test bogus", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("not found");
|
||||
});
|
||||
|
||||
// /ssh add — spy on addSSHHost
|
||||
it("/ssh add foo --host x --user y --scope user: calls addSSHHost", async () => {
|
||||
const sshModule = await import("../src/ssh/config-writer");
|
||||
const spy = spyOn(sshModule, "addSSHHost").mockResolvedValue(undefined);
|
||||
try {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/ssh add foo --host x --user y --scope user", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain('Added SSH host "foo" (user).');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// /model with unknown id
|
||||
it("/model gpt-fake-9000: returns unknown-model message", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/model gpt-fake-9000", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Unknown model");
|
||||
});
|
||||
|
||||
// /model with known id (fake registry)
|
||||
it("/model known-id: reports model set and triggers notifyTitleChanged", async () => {
|
||||
const { output, session, runtime } = createRuntime();
|
||||
session.getAvailableModels = () => [{ provider: "anthropic", id: "claude-sonnet-test" }];
|
||||
let titleChanged = false;
|
||||
runtime.notifyTitleChanged = () => {
|
||||
titleChanged = true;
|
||||
};
|
||||
const result = await executeAcpBuiltinSlashCommand("/model claude-sonnet-test", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("Model set to anthropic/claude-sonnet-test.");
|
||||
expect(titleChanged).toBe(true);
|
||||
});
|
||||
|
||||
// /usage bar character
|
||||
it("/usage: includes bar character when usedFraction is 0.5", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
runtime.session.fetchUsageReports = async () => [
|
||||
{
|
||||
provider: "test-provider",
|
||||
fetchedAt: Date.now(),
|
||||
limits: [
|
||||
{
|
||||
id: "test-limit",
|
||||
label: "Monthly",
|
||||
scope: { provider: "test-provider", tier: "pro", accountId: "acct-1" },
|
||||
window: { id: "monthly", label: "monthly", resetsAt: Date.now() + 30 * 86400_000 },
|
||||
amount: { used: 50, usedFraction: 0.5, unit: "requests" },
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
const result = await executeAcpBuiltinSlashCommand("/usage", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("█");
|
||||
});
|
||||
|
||||
// /context breakdown
|
||||
it("/context: lists more than one breakdown line for session with messages", async () => {
|
||||
const { output, session, runtime } = createRuntime();
|
||||
// computeContextBreakdown needs model.contextWindow; fake session falls back gracefully
|
||||
(session as unknown as Record<string, unknown>).model = {
|
||||
provider: "anthropic",
|
||||
id: "claude-test",
|
||||
contextWindow: 200_000,
|
||||
};
|
||||
(session as unknown as Record<string, unknown>).skills = [];
|
||||
(session as unknown as Record<string, unknown>).agent = { state: { tools: [] } };
|
||||
(session as unknown as Record<string, unknown>).systemPrompt = ["You are a helpful assistant."];
|
||||
(session as unknown as Record<string, unknown>).settings = {
|
||||
getGroup: () => ({ enabled: false, strategy: "off" }),
|
||||
};
|
||||
session.messages = [
|
||||
{ role: "user", content: "Hello, how are you?" },
|
||||
{ role: "assistant", content: "I am doing well." },
|
||||
];
|
||||
const result = await executeAcpBuiltinSlashCommand("/context", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
// Should show the breakdown with multiple lines (Messages category visible)
|
||||
const text = output[0] ?? "";
|
||||
expect(text).toContain("tokens");
|
||||
expect(text.split("\n").length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
// /jobs empty state
|
||||
it("/jobs: empty-state output mentions background jobs definition", async () => {
|
||||
const { output, runtime } = createRuntime();
|
||||
// Return empty snapshot (running=[], recent=[])
|
||||
runtime.session.getAsyncJobSnapshot = () => ({ running: [], recent: [] });
|
||||
const result = await executeAcpBuiltinSlashCommand("/jobs", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain("background jobs");
|
||||
});
|
||||
|
||||
// /marketplace discover bulleted list
|
||||
it("/marketplace discover: output is bulleted with ' - ' token", async () => {
|
||||
const { MarketplaceManager } = await import("../src/extensibility/plugins/marketplace");
|
||||
const discoverSpy = spyOn(MarketplaceManager.prototype, "listAvailablePlugins").mockResolvedValue([
|
||||
{ name: "hello", version: "1.0.0", description: "A greeting plugin" } as never,
|
||||
{ name: "world", version: "2.0.0", description: undefined } as never,
|
||||
]);
|
||||
try {
|
||||
const { output, runtime } = createRuntime();
|
||||
const result = await executeAcpBuiltinSlashCommand("/marketplace discover", runtime);
|
||||
expect(result).toEqual({ consumed: true });
|
||||
expect(output[0]).toContain(" - ");
|
||||
expect(output[0]).toContain("hello@1.0.0");
|
||||
} finally {
|
||||
discoverSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import { zSessionNotification } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js";
|
||||
import { mapAgentSessionEventToAcpSessionUpdates } from "../src/modes/acp/acp-event-mapper";
|
||||
import type { AgentSessionEvent } from "../src/session/agent-session";
|
||||
import { expectAcpStructure, expectAcpStructureRejects } from "./helpers/acp-schema";
|
||||
|
||||
function makeAssistantMessage(text: string) {
|
||||
return {
|
||||
@@ -27,6 +30,12 @@ function getChunkMessageId(event: { update: object }): string | undefined {
|
||||
return typeof update.messageId === "string" ? update.messageId : undefined;
|
||||
}
|
||||
|
||||
function expectAcpNotifications(updates: SessionNotification[]): void {
|
||||
for (const update of updates) {
|
||||
expectAcpStructure(zSessionNotification, update);
|
||||
}
|
||||
}
|
||||
|
||||
describe("ACP event mapper", () => {
|
||||
it("attaches a stable messageId to live assistant chunks", () => {
|
||||
const assistantMessage = makeAssistantMessage("chunk");
|
||||
@@ -54,6 +63,7 @@ describe("ACP event mapper", () => {
|
||||
|
||||
expect(textUpdates).toHaveLength(1);
|
||||
expect(thoughtUpdates).toHaveLength(1);
|
||||
expectAcpNotifications([...textUpdates, ...thoughtUpdates]);
|
||||
expect(textUpdates[0] ? getChunkMessageId(textUpdates[0]) : undefined).toBe(
|
||||
"a80f1ff7-4f0a-4e6b-9f09-c94857b62a4a",
|
||||
);
|
||||
@@ -61,4 +71,210 @@ describe("ACP event mapper", () => {
|
||||
"a80f1ff7-4f0a-4e6b-9f09-c94857b62a4a",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits final assistant text when no text deltas were observed", () => {
|
||||
const assistantMessage = makeAssistantMessage("final response");
|
||||
const progress = { textEmitted: false, thoughtEmitted: false };
|
||||
|
||||
const updates = mapAgentSessionEventToAcpSessionUpdates(
|
||||
{
|
||||
type: "message_end",
|
||||
message: assistantMessage,
|
||||
} as AgentSessionEvent,
|
||||
"session-1",
|
||||
{ getMessageProgress: message => (message === assistantMessage ? progress : undefined) },
|
||||
);
|
||||
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "final response" },
|
||||
messageId: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expectAcpNotifications(updates);
|
||||
expect(progress.textEmitted).toBe(true);
|
||||
});
|
||||
|
||||
it("does not duplicate final assistant text after streaming deltas", () => {
|
||||
const assistantMessage = makeAssistantMessage("streamed response");
|
||||
const progress = { textEmitted: false, thoughtEmitted: false };
|
||||
const options = {
|
||||
getMessageProgress: (message: unknown) => (message === assistantMessage ? progress : undefined),
|
||||
};
|
||||
|
||||
const deltaUpdates = mapAgentSessionEventToAcpSessionUpdates(
|
||||
{
|
||||
type: "message_update",
|
||||
message: assistantMessage,
|
||||
assistantMessageEvent: { type: "text_delta", delta: "streamed response" },
|
||||
} as AgentSessionEvent,
|
||||
"session-1",
|
||||
options,
|
||||
);
|
||||
const doneUpdates = mapAgentSessionEventToAcpSessionUpdates(
|
||||
{
|
||||
type: "message_end",
|
||||
message: assistantMessage,
|
||||
} as AgentSessionEvent,
|
||||
"session-1",
|
||||
options,
|
||||
);
|
||||
|
||||
expect(deltaUpdates).toHaveLength(1);
|
||||
expectAcpNotifications(deltaUpdates);
|
||||
expect(doneUpdates).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits a diff ToolCallContent for each per-file edit result", () => {
|
||||
const updates = mapAgentSessionEventToAcpSessionUpdates(
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tc-1",
|
||||
toolName: "edit",
|
||||
isError: false,
|
||||
result: {
|
||||
content: [{ type: "text", text: "applied" }],
|
||||
details: {
|
||||
diff: "--- a/foo\n+++ b/foo\n",
|
||||
perFileResults: [
|
||||
{ path: "foo.ts", diff: "...", oldText: "before\n", newText: "after\n" },
|
||||
{ path: "bar.ts", diff: "...", oldText: undefined, newText: "created\n" },
|
||||
{ path: "skipped.ts", diff: "", isError: true, errorText: "boom" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as AgentSessionEvent,
|
||||
"session-1",
|
||||
);
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expectAcpNotifications(updates);
|
||||
const update = updates[0]!.update as {
|
||||
sessionUpdate: string;
|
||||
content?: Array<{ type: string; path?: string; oldText?: string | null; newText?: string }>;
|
||||
locations?: { path: string }[];
|
||||
};
|
||||
expect(update.sessionUpdate).toBe("tool_call_update");
|
||||
const diffBlocks = update.content?.filter(block => block.type === "diff") ?? [];
|
||||
expect(diffBlocks).toEqual([
|
||||
{ type: "diff", path: "foo.ts", oldText: "before\n", newText: "after\n" },
|
||||
{ type: "diff", path: "bar.ts", oldText: null, newText: "created\n" },
|
||||
]);
|
||||
expect(update.locations).toEqual([{ path: "foo.ts" }, { path: "bar.ts" }, { path: "skipped.ts" }]);
|
||||
});
|
||||
|
||||
it("emits a diff ToolCallContent for single-file edit details", () => {
|
||||
const updates = mapAgentSessionEventToAcpSessionUpdates(
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tc-single",
|
||||
toolName: "edit",
|
||||
isError: false,
|
||||
result: {
|
||||
content: [{ type: "text", text: "applied" }],
|
||||
details: {
|
||||
path: "single.ts",
|
||||
diff: "--- a/single.ts\n+++ b/single.ts\n",
|
||||
oldText: "before\n",
|
||||
newText: "after\n",
|
||||
},
|
||||
},
|
||||
} as AgentSessionEvent,
|
||||
"session-1",
|
||||
);
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expectAcpNotifications(updates);
|
||||
const update = updates[0]!.update as {
|
||||
sessionUpdate: string;
|
||||
content?: Array<{ type: string; path?: string; oldText?: string | null; newText?: string }>;
|
||||
locations?: { path: string }[];
|
||||
};
|
||||
expect(update.sessionUpdate).toBe("tool_call_update");
|
||||
expect(update.content?.filter(block => block.type === "diff")).toEqual([
|
||||
{ type: "diff", path: "single.ts", oldText: "before\n", newText: "after\n" },
|
||||
]);
|
||||
expect(update.locations).toEqual([{ path: "single.ts" }]);
|
||||
});
|
||||
|
||||
it("emits locations on tool_execution_update from args", () => {
|
||||
const updates = mapAgentSessionEventToAcpSessionUpdates(
|
||||
{
|
||||
type: "tool_execution_update",
|
||||
toolCallId: "tc-2",
|
||||
toolName: "edit",
|
||||
args: { path: "src/foo.ts" },
|
||||
partialResult: { content: [{ type: "text", text: "in progress" }] },
|
||||
} as AgentSessionEvent,
|
||||
"session-1",
|
||||
);
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expectAcpNotifications(updates);
|
||||
const update = updates[0]!.update as { sessionUpdate: string; locations?: { path: string }[] };
|
||||
expect(update.sessionUpdate).toBe("tool_call_update");
|
||||
expect(update.locations).toEqual([{ path: "src/foo.ts" }]);
|
||||
});
|
||||
|
||||
it("emits a terminal ToolCallContent when tool details carry a terminalId", () => {
|
||||
const updates = mapAgentSessionEventToAcpSessionUpdates(
|
||||
{
|
||||
type: "tool_execution_update",
|
||||
toolCallId: "tc-3",
|
||||
toolName: "bash",
|
||||
args: { command: "echo hi" },
|
||||
partialResult: { content: [], details: { terminalId: "term-42" } },
|
||||
} as AgentSessionEvent,
|
||||
"session-1",
|
||||
);
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expectAcpNotifications(updates);
|
||||
const update = updates[0]!.update as {
|
||||
sessionUpdate: string;
|
||||
content?: Array<{ type: string; terminalId?: string }>;
|
||||
};
|
||||
expect(update.sessionUpdate).toBe("tool_call_update");
|
||||
expect(update.content).toEqual([{ type: "terminal", terminalId: "term-42" }]);
|
||||
});
|
||||
it("emits distinct locations for move-style path arguments", () => {
|
||||
const updates = mapAgentSessionEventToAcpSessionUpdates(
|
||||
{
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tc-move",
|
||||
toolName: "move",
|
||||
args: { path: "src/current.ts", oldPath: "src/old.ts", newPath: "src/new.ts" },
|
||||
} as AgentSessionEvent,
|
||||
"session-1",
|
||||
);
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
expectAcpNotifications(updates);
|
||||
const update = updates[0]!.update as { sessionUpdate: string; locations?: { path: string }[] };
|
||||
expect(update.sessionUpdate).toBe("tool_call");
|
||||
expect(update.locations).toEqual([{ path: "src/current.ts" }, { path: "src/old.ts" }, { path: "src/new.ts" }]);
|
||||
});
|
||||
|
||||
it("rejects mutated ACP notification discriminators", () => {
|
||||
const [notification] = mapAgentSessionEventToAcpSessionUpdates(
|
||||
{
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tc-schema",
|
||||
toolName: "read",
|
||||
args: { path: "package.json" },
|
||||
} as AgentSessionEvent,
|
||||
"session-1",
|
||||
);
|
||||
|
||||
expectAcpStructure(zSessionNotification, notification);
|
||||
expectAcpStructureRejects(zSessionNotification, {
|
||||
...notification,
|
||||
update: { ...notification!.update, sessionUpdate: "tool_call_updates" },
|
||||
});
|
||||
expectAcpStructureRejects(zSessionNotification, { ...notification, sessionId: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* ACP `initialize` conformance — gates `terminal` auth methods on
|
||||
* `clientCapabilities.auth.terminal`, advertises stable agentInfo, and keeps
|
||||
* the agentCapabilities contract that downstream clients rely on.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentSideConnection, InitializeRequest } from "@agentclientprotocol/sdk";
|
||||
import { zInitializeResponse } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js";
|
||||
import type { Model } from "@oh-my-pi/pi-ai";
|
||||
import { getConfigRootDir, setAgentDir, VERSION } from "@oh-my-pi/pi-utils";
|
||||
import { AcpAgent } from "../src/modes/acp/acp-agent";
|
||||
import { ACP_TERMINAL_AUTH_FLAG, prepareAcpTerminalAuthArgs } from "../src/modes/acp/terminal-auth";
|
||||
import type { AgentSession } from "../src/session/agent-session";
|
||||
import { SessionManager } from "../src/session/session-manager";
|
||||
import { expectAcpStructure } from "./helpers/acp-schema";
|
||||
|
||||
const TEST_MODELS: Model[] = [
|
||||
{
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "Claude Sonnet",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
},
|
||||
];
|
||||
|
||||
class FakeAgentSession {
|
||||
sessionManager: SessionManager;
|
||||
sessionId: string;
|
||||
agent: { sessionId: string; waitForIdle: () => Promise<void> };
|
||||
model: Model | undefined = TEST_MODELS[0];
|
||||
thinkingLevel: string | undefined;
|
||||
customCommands: [] = [];
|
||||
extensionRunner = undefined;
|
||||
isStreaming = false;
|
||||
queuedMessageCount = 0;
|
||||
systemPrompt = "system";
|
||||
disposed = false;
|
||||
|
||||
constructor(cwd: string) {
|
||||
this.sessionManager = SessionManager.create(cwd);
|
||||
this.sessionId = this.sessionManager.getSessionId();
|
||||
this.agent = { sessionId: this.sessionId, waitForIdle: async () => {} };
|
||||
}
|
||||
|
||||
get sessionName(): string {
|
||||
return this.sessionManager.getHeader()?.title ?? `Session ${this.sessionId}`;
|
||||
}
|
||||
|
||||
get modelRegistry(): { getApiKey: (model: Model) => Promise<string> } {
|
||||
return { getApiKey: async (_model: Model) => "test-key" };
|
||||
}
|
||||
|
||||
getAvailableModels(): Model[] {
|
||||
return TEST_MODELS;
|
||||
}
|
||||
|
||||
getAvailableThinkingLevels(): ReadonlyArray<string> {
|
||||
return ["low", "medium", "high"];
|
||||
}
|
||||
|
||||
setThinkingLevel(): void {}
|
||||
async setModel(): Promise<void> {}
|
||||
subscribe(): () => void {
|
||||
return () => {};
|
||||
}
|
||||
async prompt(): Promise<void> {}
|
||||
async abort(): Promise<void> {}
|
||||
async refreshMCPTools(): Promise<void> {}
|
||||
getContextUsage(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
async switchSession(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true;
|
||||
await this.sessionManager.close();
|
||||
}
|
||||
async reload(): Promise<void> {}
|
||||
async newSession(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
async branch(): Promise<{ cancelled: boolean }> {
|
||||
return { cancelled: false };
|
||||
}
|
||||
async navigateTree(): Promise<{ cancelled: boolean }> {
|
||||
return { cancelled: false };
|
||||
}
|
||||
getActiveToolNames(): string[] {
|
||||
return [];
|
||||
}
|
||||
getAllToolNames(): string[] {
|
||||
return [];
|
||||
}
|
||||
setActiveToolsByName(): void {}
|
||||
setClientBridge(): void {}
|
||||
getPlanModeState(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
setPlanModeState(): void {}
|
||||
async sendCustomMessage(): Promise<void> {}
|
||||
async sendUserMessage(): Promise<void> {}
|
||||
async compact(): Promise<void> {}
|
||||
async fork(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupRoots: string[] = [];
|
||||
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalAgentDir) {
|
||||
setAgentDir(originalAgentDir);
|
||||
} else {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
for (const root of cleanupRoots.splice(0)) {
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createAgent(): Promise<AcpAgent> {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omp-acp-init-"));
|
||||
cleanupRoots.push(root);
|
||||
const agentDir = path.join(root, "agent");
|
||||
const cwd = path.join(root, "cwd");
|
||||
await fs.promises.mkdir(agentDir, { recursive: true });
|
||||
await fs.promises.mkdir(cwd, { recursive: true });
|
||||
setAgentDir(agentDir);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const connection = {
|
||||
sessionUpdate: async () => {},
|
||||
signal: abortController.signal,
|
||||
closed: Promise.withResolvers<void>().promise,
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const initialSession = new FakeAgentSession(cwd);
|
||||
const factory = async (next: string): Promise<AgentSession> => new FakeAgentSession(next) as unknown as AgentSession;
|
||||
return new AcpAgent(connection, initialSession as unknown as AgentSession, factory);
|
||||
}
|
||||
|
||||
function buildInitializeRequest(overrides: Partial<InitializeRequest> = {}): InitializeRequest {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {},
|
||||
...overrides,
|
||||
} as InitializeRequest;
|
||||
}
|
||||
|
||||
describe("ACP initialize conformance", () => {
|
||||
it("only advertises the agent-managed auth method when the client lacks terminal capability", async () => {
|
||||
const agent = await createAgent();
|
||||
const response = await agent.initialize(buildInitializeRequest());
|
||||
expectAcpStructure(zInitializeResponse, response);
|
||||
expect(response.authMethods).toHaveLength(1);
|
||||
const [agentMethod] = response.authMethods!;
|
||||
// AuthMethodAgent omits the `type` discriminator per ACP spec — the absence is the signal.
|
||||
expect((agentMethod as { type?: string }).type).toBeUndefined();
|
||||
expect(agentMethod).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
name: expect.any(String),
|
||||
description: expect.any(String),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("appends the terminal setup method when the client opts in via clientCapabilities.auth.terminal", async () => {
|
||||
const agent = await createAgent();
|
||||
const response = await agent.initialize(
|
||||
buildInitializeRequest({ clientCapabilities: { auth: { terminal: true } } }),
|
||||
);
|
||||
expectAcpStructure(zInitializeResponse, response);
|
||||
expect(response.authMethods).toHaveLength(2);
|
||||
const [first, second] = response.authMethods!;
|
||||
expect((first as { type?: string }).type).toBeUndefined();
|
||||
expect(first).toEqual(expect.objectContaining({ id: "agent" }));
|
||||
expect(response.authMethods![1]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "terminal",
|
||||
id: "terminal",
|
||||
args: [ACP_TERMINAL_AUTH_FLAG],
|
||||
}),
|
||||
);
|
||||
void second;
|
||||
});
|
||||
|
||||
it("uses a terminal auth arg that removes ACP mode before launching the interactive setup flow", () => {
|
||||
const result = prepareAcpTerminalAuthArgs(["--mode", "acp", "--no-extensions", ACP_TERMINAL_AUTH_FLAG]);
|
||||
|
||||
expect(result).toEqual({
|
||||
args: ["--no-extensions"],
|
||||
terminalAuth: true,
|
||||
});
|
||||
expect(prepareAcpTerminalAuthArgs(["--mode=acp", ACP_TERMINAL_AUTH_FLAG])).toEqual({
|
||||
args: [],
|
||||
terminalAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("declares agentInfo.version that matches the published package version", async () => {
|
||||
const agent = await createAgent();
|
||||
const response = await agent.initialize(buildInitializeRequest());
|
||||
const pkgPath = path.join(import.meta.dir, "..", "package.json");
|
||||
const pkg = (await Bun.file(pkgPath).json()) as { version: string };
|
||||
expect(response.agentInfo).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "oh-my-pi",
|
||||
title: "Oh My Pi",
|
||||
version: VERSION,
|
||||
}),
|
||||
);
|
||||
expect(response.agentInfo!.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it("preserves the agentCapabilities contract clients depend on", async () => {
|
||||
const agent = await createAgent();
|
||||
const response = await agent.initialize(buildInitializeRequest());
|
||||
expectAcpStructure(zInitializeResponse, response);
|
||||
expect(response.agentCapabilities).toEqual(
|
||||
expect.objectContaining({
|
||||
loadSession: true,
|
||||
mcpCapabilities: expect.objectContaining({ http: true, sse: true }),
|
||||
promptCapabilities: expect.objectContaining({ embeddedContext: true, image: true }),
|
||||
sessionCapabilities: expect.objectContaining({
|
||||
list: expect.any(Object),
|
||||
fork: expect.any(Object),
|
||||
resume: expect.any(Object),
|
||||
close: expect.any(Object),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* ACP stdout-hygiene smoke: launching `omp acp` must not leak any banner,
|
||||
* progress text, or stray non-JSON bytes onto stdout — that channel is owned
|
||||
* by the JSON-RPC protocol. We spawn the CLI as a subprocess, send a single
|
||||
* `initialize` frame, and assert the first stdout line parses cleanly as a
|
||||
* JSON-RPC response.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dir, "..", "..", "..");
|
||||
const cliEntry = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts");
|
||||
|
||||
const cleanupRoots: string[] = [];
|
||||
let activeProc: ReturnType<typeof Bun.spawn> | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (activeProc) {
|
||||
try {
|
||||
activeProc.kill();
|
||||
await activeProc.exited;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
activeProc = undefined;
|
||||
}
|
||||
for (const root of cleanupRoots.splice(0)) {
|
||||
await fs.promises.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function readFirstFrame(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const newlineIdx = buffer.indexOf("\n");
|
||||
if (newlineIdx >= 0) {
|
||||
reader.releaseLock();
|
||||
return buffer.slice(0, newlineIdx);
|
||||
}
|
||||
}
|
||||
reader.releaseLock();
|
||||
return buffer;
|
||||
}
|
||||
|
||||
describe("ACP stdout hygiene", () => {
|
||||
it("emits a JSON-RPC initialize response as the first bytes on stdout", async () => {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omp-acp-stdout-"));
|
||||
cleanupRoots.push(root);
|
||||
const home = path.join(root, "home");
|
||||
const xdg = path.join(root, "xdg");
|
||||
const agentDir = path.join(root, "agent");
|
||||
await fs.promises.mkdir(home, { recursive: true });
|
||||
await fs.promises.mkdir(xdg, { recursive: true });
|
||||
await fs.promises.mkdir(agentDir, { recursive: true });
|
||||
|
||||
const proc = Bun.spawn(["bun", cliEntry, "acp"], {
|
||||
cwd: repoRoot,
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
XDG_DATA_HOME: xdg,
|
||||
XDG_CONFIG_HOME: xdg,
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
PI_NO_TITLE: "1",
|
||||
},
|
||||
});
|
||||
activeProc = proc;
|
||||
|
||||
const initRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: { protocolVersion: 1, clientCapabilities: { auth: { terminal: true } } },
|
||||
};
|
||||
proc.stdin.write(new TextEncoder().encode(`${JSON.stringify(initRequest)}\n`));
|
||||
proc.stdin.flush();
|
||||
|
||||
const firstLine = await readFirstFrame(proc.stdout as ReadableStream<Uint8Array>);
|
||||
expect(firstLine.length).toBeGreaterThan(0);
|
||||
expect(firstLine[0]).toBe("{");
|
||||
|
||||
const message = JSON.parse(firstLine) as {
|
||||
jsonrpc?: string;
|
||||
id?: unknown;
|
||||
result?: { protocolVersion?: number; authMethods?: Array<{ type?: string; id?: string }> };
|
||||
error?: unknown;
|
||||
};
|
||||
expect(message.jsonrpc).toBe("2.0");
|
||||
expect(message.id).toBe(1);
|
||||
expect(message.error).toBeUndefined();
|
||||
expect(message.result?.protocolVersion).toBe(1);
|
||||
expect(message.result?.authMethods).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "agent" }),
|
||||
expect.objectContaining({ type: "terminal", id: "terminal" }),
|
||||
]),
|
||||
);
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Tests for the ACP permission gate in AgentSession.
|
||||
*
|
||||
* Verifies that sensitive tools (bash, edit, write, ast_edit) are gated behind
|
||||
* `ClientBridge.requestPermission` when a bridge is set, and that allow/reject
|
||||
* decisions are cached appropriately for allow_always / reject_always.
|
||||
*/
|
||||
import { afterEach, beforeEach, expect, it, spyOn } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-ai";
|
||||
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import type { ClientBridge, ClientBridgePermissionOutcome } from "@oh-my-pi/pi-coding-agent/session/client-bridge";
|
||||
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
class MockAssistantStream extends AssistantMessageEventStream {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let tempDir: TempDir;
|
||||
let authStorage: AuthStorage | undefined;
|
||||
let session: AgentSession;
|
||||
|
||||
/** Fake bash tool that records execute calls. */
|
||||
function makeFakeTool(name: string): AgentTool & { executeCalls: number } {
|
||||
const tool = {
|
||||
name,
|
||||
label: name,
|
||||
description: `Fake ${name}`,
|
||||
parameters: Type.Object({ command: Type.Optional(Type.String()) }),
|
||||
executeCalls: 0,
|
||||
async execute() {
|
||||
tool.executeCalls++;
|
||||
return { content: [{ type: "text" as const, text: "ok" }] };
|
||||
},
|
||||
};
|
||||
return tool;
|
||||
}
|
||||
|
||||
/** Build a minimal ClientBridge whose requestPermission resolves to the given outcome. */
|
||||
function makeBridge(outcome: ClientBridgePermissionOutcome): ClientBridge {
|
||||
return {
|
||||
capabilities: { requestPermission: true },
|
||||
async requestPermission(_toolCall, _options, _signal) {
|
||||
return outcome;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession(tools: AgentTool[], bridge?: ClientBridge): Promise<AgentSession> {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
|
||||
|
||||
const settings = Settings.isolated({ "compaction.enabled": false });
|
||||
const sessionManager = SessionManager.inMemory(tempDir.path());
|
||||
const registry = new ModelRegistry(authStorage!, path.join(tempDir.path(), "models.yml"));
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: ["Test"],
|
||||
tools,
|
||||
messages: [],
|
||||
},
|
||||
convertToLlm,
|
||||
streamFn: () => new MockAssistantStream(),
|
||||
});
|
||||
|
||||
const sess = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settings,
|
||||
modelRegistry: registry,
|
||||
toolRegistry: new Map(tools.map(t => [t.name, t])),
|
||||
});
|
||||
|
||||
if (bridge) sess.setClientBridge(bridge);
|
||||
return sess;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = TempDir.createSync("@pi-acp-permission-test-");
|
||||
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
||||
authStorage.setRuntimeApiKey("anthropic", "test-key");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await session?.dispose();
|
||||
authStorage?.close();
|
||||
authStorage = undefined;
|
||||
tempDir.removeSync();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Allow once: bridge called once, underlying execute called once
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("allow_once: calls bridge once and executes the underlying tool", async () => {
|
||||
const bashTool = makeFakeTool("bash");
|
||||
const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" });
|
||||
const permissionSpy = spyOn(bridge, "requestPermission");
|
||||
session = await createSession([bashTool], bridge);
|
||||
|
||||
await session.setActiveToolsByName(["bash"]);
|
||||
// Get the wrapped tool from the agent's active set.
|
||||
const wrappedBash = session.agent.state.tools.find(t => t.name === "bash");
|
||||
expect(wrappedBash).toBeDefined();
|
||||
|
||||
await wrappedBash!.execute("call-1", { command: "echo hi" }, undefined, undefined as never, undefined as never);
|
||||
|
||||
expect(permissionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(bashTool.executeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("setClientBridge wraps tools that were already active", async () => {
|
||||
const bashTool = makeFakeTool("bash");
|
||||
const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" });
|
||||
const permissionSpy = spyOn(bridge, "requestPermission");
|
||||
session = await createSession([bashTool]);
|
||||
|
||||
session.setClientBridge(bridge);
|
||||
const wrappedBash = session.agent.state.tools.find(t => t.name === "bash");
|
||||
expect(wrappedBash).toBeDefined();
|
||||
|
||||
await wrappedBash!.execute("call-1", { command: "echo hi" }, undefined, undefined as never, undefined as never);
|
||||
|
||||
expect(permissionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(bashTool.executeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("aborting an open permission request rejects without executing the tool", async () => {
|
||||
const bashTool = makeFakeTool("bash");
|
||||
const pending = Promise.withResolvers<ClientBridgePermissionOutcome>();
|
||||
const bridge: ClientBridge = {
|
||||
capabilities: { requestPermission: true },
|
||||
requestPermission: async () => pending.promise,
|
||||
};
|
||||
session = await createSession([bashTool], bridge);
|
||||
await session.setActiveToolsByName(["bash"]);
|
||||
const wrappedBash = session.agent.state.tools.find(t => t.name === "bash");
|
||||
expect(wrappedBash).toBeDefined();
|
||||
|
||||
const abortController = new AbortController();
|
||||
const execution = wrappedBash!.execute(
|
||||
"call-1",
|
||||
{ command: "echo hi" },
|
||||
abortController.signal,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
);
|
||||
abortController.abort();
|
||||
|
||||
await expect(execution).rejects.toThrow(/Permission request cancelled/);
|
||||
expect(bashTool.executeCalls).toBe(0);
|
||||
pending.resolve({ outcome: "cancelled" });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Reject once: throws, underlying execute never called
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("reject_once: throws ToolError and never calls underlying execute", async () => {
|
||||
const editTool = makeFakeTool("edit");
|
||||
const bridge = makeBridge({ outcome: "selected", optionId: "reject_once", kind: "reject_once" });
|
||||
session = await createSession([editTool], bridge);
|
||||
|
||||
await session.setActiveToolsByName(["edit"]);
|
||||
const wrappedEdit = session.agent.state.tools.find(t => t.name === "edit");
|
||||
expect(wrappedEdit).toBeDefined();
|
||||
|
||||
await expect(
|
||||
wrappedEdit!.execute("call-1", { path: "/tmp/foo.ts" }, undefined, undefined as never, undefined as never),
|
||||
).rejects.toThrow(/rejected by user/);
|
||||
|
||||
expect(editTool.executeCalls).toBe(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Always allow caches: bridge called exactly once across two executions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("allow_always: caches decision and calls bridge only once for subsequent executes", async () => {
|
||||
const writeTool = makeFakeTool("write");
|
||||
const bridge = makeBridge({ outcome: "selected", optionId: "allow_always", kind: "allow_always" });
|
||||
const permissionSpy = spyOn(bridge, "requestPermission");
|
||||
session = await createSession([writeTool], bridge);
|
||||
|
||||
await session.setActiveToolsByName(["write"]);
|
||||
const wrappedWrite = session.agent.state.tools.find(t => t.name === "write");
|
||||
expect(wrappedWrite).toBeDefined();
|
||||
|
||||
// First call — bridge is consulted, decision cached.
|
||||
await wrappedWrite!.execute("call-1", { path: "/tmp/a.ts" }, undefined, undefined as never, undefined as never);
|
||||
// Second call — must skip the bridge entirely.
|
||||
await wrappedWrite!.execute("call-2", { path: "/tmp/b.ts" }, undefined, undefined as never, undefined as never);
|
||||
|
||||
expect(permissionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(writeTool.executeCalls).toBe(2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Read tool not gated: bridge never called even when bridge is set
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("read tool: requestPermission is never called for non-gated tools", async () => {
|
||||
const readTool = makeFakeTool("read");
|
||||
const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" });
|
||||
const permissionSpy = spyOn(bridge, "requestPermission");
|
||||
session = await createSession([readTool], bridge);
|
||||
|
||||
await session.setActiveToolsByName(["read"]);
|
||||
const wrappedRead = session.agent.state.tools.find(t => t.name === "read");
|
||||
expect(wrappedRead).toBeDefined();
|
||||
|
||||
await wrappedRead!.execute("call-1", {}, undefined, undefined as never, undefined as never);
|
||||
|
||||
expect(permissionSpy).toHaveBeenCalledTimes(0);
|
||||
expect(readTool.executeCalls).toBe(1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. No bridge → original tool object identity preserved (no wrapping)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("no bridge: original tool object is returned unchanged", async () => {
|
||||
const bashTool = makeFakeTool("bash");
|
||||
session = await createSession([bashTool]); // no bridge
|
||||
|
||||
await session.setActiveToolsByName(["bash"]);
|
||||
const activeBash = session.agent.state.tools.find(t => t.name === "bash");
|
||||
expect(activeBash).toBe(bashTool);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it, spyOn } from "bun:test";
|
||||
import type { ClientBridge, ClientBridgeTerminalHandle } from "../src/session/client-bridge";
|
||||
import type { ToolSession } from "../src/tools";
|
||||
import { BashTool } from "../src/tools/bash";
|
||||
|
||||
function makeSession(bridge: ClientBridge): ToolSession {
|
||||
return {
|
||||
cwd: "/tmp",
|
||||
hasUI: false,
|
||||
skills: [],
|
||||
getSessionFile: () => null,
|
||||
settings: {
|
||||
get(key: string) {
|
||||
if (key === "async.enabled") return false;
|
||||
if (key === "bash.autoBackground.enabled") return false;
|
||||
if (key === "bash.autoBackground.thresholdMs") return 60_000;
|
||||
if (key === "bashInterceptor.enabled") return false;
|
||||
if (key === "astGrep.enabled") return false;
|
||||
if (key === "astEdit.enabled") return false;
|
||||
if (key === "search.enabled") return false;
|
||||
if (key === "find.enabled") return false;
|
||||
return undefined;
|
||||
},
|
||||
getBashInterceptorRules() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
getClientBridge: () => bridge,
|
||||
} as unknown as ToolSession;
|
||||
}
|
||||
|
||||
describe("BashTool ACP terminal routing", () => {
|
||||
it("routes through bridge, emits terminalId update, and releases the handle", async () => {
|
||||
const stubText = "hello from terminal\n";
|
||||
|
||||
const handle: ClientBridgeTerminalHandle = {
|
||||
terminalId: "term-xyz",
|
||||
waitForExit: async () => ({ exitCode: 0, signal: null }),
|
||||
currentOutput: async () => ({ output: stubText, truncated: false }),
|
||||
kill: async () => {},
|
||||
release: async () => {},
|
||||
};
|
||||
|
||||
const bridge: ClientBridge = {
|
||||
capabilities: { terminal: true },
|
||||
createTerminal: async () => handle,
|
||||
};
|
||||
|
||||
const createSpy = spyOn(bridge, "createTerminal");
|
||||
const releaseSpy = spyOn(handle, "release");
|
||||
|
||||
const updates: Array<{ details?: { terminalId?: string } }> = [];
|
||||
|
||||
const tool = new BashTool(makeSession(bridge));
|
||||
const result = await tool.execute("call-1", { command: "echo hi" }, undefined, update => {
|
||||
updates.push(update as { details?: { terminalId?: string } });
|
||||
});
|
||||
|
||||
// createTerminal must be called with the expanded command
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
const params = createSpy.mock.calls[0]![0];
|
||||
expect(params.command).toBe("echo hi");
|
||||
|
||||
// The first onUpdate must carry the terminalId so the editor can embed it
|
||||
expect(updates.length).toBeGreaterThanOrEqual(1);
|
||||
expect(updates[0]!.details?.terminalId).toBe("term-xyz");
|
||||
|
||||
// The final result text must contain the stub output
|
||||
const text = result.content.find(c => c.type === "text");
|
||||
expect(text?.text).toContain("hello from terminal");
|
||||
|
||||
// The result details must carry terminalId for the ACP event mapper
|
||||
expect(result.details?.terminalId).toBe("term-xyz");
|
||||
|
||||
// The handle must always be released
|
||||
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("kills and releases the client terminal when the command times out", async () => {
|
||||
const pendingExit = Promise.withResolvers<{ exitCode: number | null; signal: string | null }>();
|
||||
const handle: ClientBridgeTerminalHandle = {
|
||||
terminalId: "term-timeout",
|
||||
waitForExit: async () => pendingExit.promise,
|
||||
currentOutput: async () => ({ output: "", truncated: false }),
|
||||
kill: async () => {},
|
||||
release: async () => {},
|
||||
};
|
||||
const bridge: ClientBridge = {
|
||||
capabilities: { terminal: true },
|
||||
createTerminal: async () => handle,
|
||||
};
|
||||
const killSpy = spyOn(handle, "kill");
|
||||
const releaseSpy = spyOn(handle, "release");
|
||||
|
||||
const tool = new BashTool(makeSession(bridge));
|
||||
|
||||
await expect(tool.execute("call-timeout", { command: "sleep 60", timeout: 1 })).rejects.toThrow(
|
||||
/Command timed out after 1 seconds/,
|
||||
);
|
||||
|
||||
expect(killSpy).toHaveBeenCalledTimes(1);
|
||||
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
||||
pendingExit.resolve({ exitCode: null, signal: "TERM" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import {
|
||||
DEFAULT_FUZZY_THRESHOLD,
|
||||
EditTool,
|
||||
type EditToolDetails,
|
||||
executePatchSingle,
|
||||
executeReplaceSingle,
|
||||
} from "@oh-my-pi/pi-coding-agent/edit";
|
||||
import { writethroughNoop } from "@oh-my-pi/pi-coding-agent/lsp";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
|
||||
// ─── Minimal ToolSession stub ────────────────────────────────────────────────
|
||||
|
||||
function makeSession(cwd: string): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
enableLsp: false,
|
||||
settings: Settings.isolated({ "edit.mode": "patch" }),
|
||||
getArtifactsDir: () => null,
|
||||
getSessionId: () => null,
|
||||
getPlanModeState: () => undefined,
|
||||
} as unknown as ToolSession;
|
||||
}
|
||||
|
||||
const noopBeginDeferred = (_p: string) => ({
|
||||
onDeferredDiagnostics: () => {},
|
||||
signal: new AbortController().signal,
|
||||
finalize: () => {},
|
||||
});
|
||||
|
||||
// ─── Setup / teardown ────────────────────────────────────────────────────────
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
_resetSettingsForTest();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-edit-diff-"));
|
||||
await Settings.init({ inMemory: true, cwd: tempDir });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
_resetSettingsForTest();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── executePatchSingle ───────────────────────────────────────────────────────
|
||||
|
||||
describe("executePatchSingle — oldText/newText propagation", () => {
|
||||
test("update: oldText is pre-edit content, newText is post-edit content", async () => {
|
||||
await Bun.write(path.join(tempDir, "foo.txt"), "a\n");
|
||||
|
||||
const result = await executePatchSingle({
|
||||
session: makeSession(tempDir),
|
||||
path: "foo.txt",
|
||||
params: { op: "update", diff: "@@\n-a\n+b" },
|
||||
allowFuzzy: true,
|
||||
fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD,
|
||||
writethrough: writethroughNoop,
|
||||
beginDeferredDiagnosticsForPath: noopBeginDeferred,
|
||||
});
|
||||
|
||||
expect(result.details?.path).toBe("foo.txt");
|
||||
expect(result.details?.oldText).toBe("a\n");
|
||||
expect(result.details?.newText).toBe("b\n");
|
||||
});
|
||||
|
||||
test("create: oldText is undefined, newText is the created content", async () => {
|
||||
const result = await executePatchSingle({
|
||||
session: makeSession(tempDir),
|
||||
path: "new.txt",
|
||||
params: { op: "create", diff: "hello\n" },
|
||||
allowFuzzy: true,
|
||||
fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD,
|
||||
writethrough: writethroughNoop,
|
||||
beginDeferredDiagnosticsForPath: noopBeginDeferred,
|
||||
});
|
||||
|
||||
expect(result.details?.path).toBe("new.txt");
|
||||
expect(result.details?.oldText).toBeUndefined();
|
||||
expect(result.details?.newText).toBe("hello\n");
|
||||
});
|
||||
|
||||
test("delete: oldText is prior content, newText is undefined", async () => {
|
||||
await Bun.write(path.join(tempDir, "gone.txt"), "will be deleted\n");
|
||||
|
||||
const result = await executePatchSingle({
|
||||
session: makeSession(tempDir),
|
||||
path: "gone.txt",
|
||||
params: { op: "delete" },
|
||||
allowFuzzy: true,
|
||||
fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD,
|
||||
writethrough: writethroughNoop,
|
||||
beginDeferredDiagnosticsForPath: noopBeginDeferred,
|
||||
});
|
||||
|
||||
expect(result.details?.path).toBe("gone.txt");
|
||||
expect(result.details?.oldText).toBe("will be deleted\n");
|
||||
expect(result.details?.newText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditTool patch aggregation — oldText/newText propagation", () => {
|
||||
test("create followed by update preserves create-shaped oldText", async () => {
|
||||
const tool = new EditTool(makeSession(tempDir));
|
||||
|
||||
const result = await tool.execute("call-create-update", {
|
||||
path: "created.txt",
|
||||
edits: [
|
||||
{ op: "create", diff: "a\n" },
|
||||
{ op: "update", diff: "@@\n-a\n+b" },
|
||||
],
|
||||
});
|
||||
const details = result.details as EditToolDetails;
|
||||
expect(details.path).toBe("created.txt");
|
||||
expect("oldText" in details).toBe(true);
|
||||
expect(details.oldText).toBeUndefined();
|
||||
expect(details.newText).toBe("b\n");
|
||||
});
|
||||
|
||||
test("update followed by delete preserves delete-shaped newText", async () => {
|
||||
await Bun.write(path.join(tempDir, "updated-then-gone.txt"), "a\n");
|
||||
const tool = new EditTool(makeSession(tempDir));
|
||||
|
||||
const result = await tool.execute("call-update-delete", {
|
||||
path: "updated-then-gone.txt",
|
||||
edits: [{ op: "update", diff: "@@\n-a\n+b" }, { op: "delete" }],
|
||||
});
|
||||
const details = result.details as EditToolDetails;
|
||||
expect(details.path).toBe("updated-then-gone.txt");
|
||||
expect(details.oldText).toBe("a\n");
|
||||
expect("newText" in details).toBe(true);
|
||||
expect(details.newText).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── executeReplaceSingle ─────────────────────────────────────────────────────
|
||||
|
||||
describe("executeReplaceSingle — oldText/newText propagation", () => {
|
||||
test("replace: oldText is full file before, newText is full file after", async () => {
|
||||
const originalContent = "line one\nline two\nline three\n";
|
||||
await Bun.write(path.join(tempDir, "bar.txt"), originalContent);
|
||||
|
||||
const result = await executeReplaceSingle({
|
||||
session: makeSession(tempDir),
|
||||
path: "bar.txt",
|
||||
params: { old_text: "line two", new_text: "line TWO" },
|
||||
allowFuzzy: false,
|
||||
fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD,
|
||||
writethrough: writethroughNoop,
|
||||
beginDeferredDiagnosticsForPath: noopBeginDeferred,
|
||||
});
|
||||
|
||||
expect(result.details?.path).toBe("bar.txt");
|
||||
expect(result.details?.oldText).toBe(originalContent);
|
||||
expect(result.details?.newText).toBe("line one\nline TWO\nline three\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect } from "bun:test";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
function formatIssues(error: z.ZodError): string {
|
||||
return error.issues.map(issue => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("\n");
|
||||
}
|
||||
|
||||
export function expectAcpStructure(schema: z.ZodType, value: unknown): void {
|
||||
const result = schema.safeParse(value);
|
||||
expect(result.success, result.success ? undefined : formatIssues(result.error)).toBe(true);
|
||||
}
|
||||
|
||||
export function expectAcpStructureRejects(schema: z.ZodType, value: unknown): void {
|
||||
const result = schema.safeParse(value);
|
||||
expect(result.success).toBe(false);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { ClientBridge } from "@oh-my-pi/pi-coding-agent/session/client-bridge";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import type { ReadToolDetails } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
|
||||
|
||||
const BRIDGE_CONTENT = "// content from editor buffer\nexport function greet() { return 'bridge'; }\n";
|
||||
|
||||
function textOutput(result: AgentToolResult<ReadToolDetails>): string {
|
||||
return result.content
|
||||
.filter(c => c.type === "text")
|
||||
.map(c => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function createSession(cwd: string, bridge?: ClientBridge): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => path.join(cwd, "session.jsonl"),
|
||||
getSessionSpawns: () => "*",
|
||||
getArtifactsDir: () => path.join(cwd, "artifacts"),
|
||||
allocateOutputArtifact: async () => ({ id: "artifact-1", path: path.join(cwd, "artifact-1.log") }),
|
||||
settings: Settings.isolated(),
|
||||
getClientBridge: bridge ? () => bridge : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("read tool ACP fs routing", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "read-acp-fs-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("routes plain text reads through the bridge and does not call Bun.file().text()", async () => {
|
||||
// .ts file so summarize would normally run (read.summarize.enabled defaults to true)
|
||||
const filePath = path.join(tmpDir, "example.ts");
|
||||
await fs.writeFile(filePath, "export function greet() { return 'disk'; }\n");
|
||||
|
||||
const bridge: ClientBridge = {
|
||||
capabilities: { readTextFile: true },
|
||||
readTextFile: async () => BRIDGE_CONTENT,
|
||||
};
|
||||
const bridgeSpy = spyOn(bridge, "readTextFile");
|
||||
|
||||
// Wrap Bun.file() to detect any .text() calls
|
||||
let textCallCount = 0;
|
||||
const origBunFile = Bun.file.bind(Bun);
|
||||
const bunFileSpy = spyOn(Bun, "file").mockImplementation(
|
||||
(arg: string | URL | Uint8Array | ArrayBufferLike | number, opts?: BlobPropertyBag) => {
|
||||
const bunFile = origBunFile(arg as string, opts);
|
||||
const origText = bunFile.text.bind(bunFile);
|
||||
bunFile.text = async () => {
|
||||
textCallCount++;
|
||||
return origText();
|
||||
};
|
||||
return bunFile;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const session = createSession(tmpDir, bridge);
|
||||
const tool = new ReadTool(session);
|
||||
|
||||
const result = await tool.execute("call-1", { path: filePath });
|
||||
const text = textOutput(result);
|
||||
|
||||
// Bridge content should appear in output
|
||||
expect(text).toContain("content from editor buffer");
|
||||
// Bridge readTextFile was invoked
|
||||
expect(bridgeSpy).toHaveBeenCalled();
|
||||
// Bun.file().text() must not have been called — bridge is source of truth
|
||||
expect(textCallCount).toBe(0);
|
||||
} finally {
|
||||
bunFileSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies requested line ranges to bridge content exactly once", async () => {
|
||||
const filePath = path.join(tmpDir, "range.txt");
|
||||
await fs.writeFile(filePath, "disk one\ndisk two\ndisk three\n");
|
||||
const bridgeContent = "bridge one\nbridge two\nbridge three\n";
|
||||
const bridge: ClientBridge = {
|
||||
capabilities: { readTextFile: true },
|
||||
readTextFile: async params => {
|
||||
if (typeof params.line !== "number") return bridgeContent;
|
||||
const lines = bridgeContent.split("\n");
|
||||
const start = Math.max(0, params.line - 1);
|
||||
return lines.slice(start, params.limit === undefined ? undefined : start + params.limit).join("\n");
|
||||
},
|
||||
};
|
||||
|
||||
const session = createSession(tmpDir, bridge);
|
||||
const tool = new ReadTool(session);
|
||||
|
||||
const result = await tool.execute("call-range", { path: `${filePath}:2+1` });
|
||||
const text = textOutput(result);
|
||||
|
||||
expect(text).toContain("bridge two");
|
||||
expect(text).not.toContain("Line 2 is beyond end");
|
||||
expect(text).not.toContain("disk two");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { ClientBridge } from "@oh-my-pi/pi-coding-agent/session/client-bridge";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write";
|
||||
|
||||
const FILE_CONTENT = "bridge write content\n";
|
||||
|
||||
function createSession(cwd: string, bridge?: ClientBridge): ToolSession {
|
||||
return {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => path.join(cwd, "session.jsonl"),
|
||||
getSessionSpawns: () => "*",
|
||||
getArtifactsDir: () => path.join(cwd, "artifacts"),
|
||||
allocateOutputArtifact: async () => ({ id: "artifact-1", path: path.join(cwd, "artifact-1.log") }),
|
||||
settings: Settings.isolated(),
|
||||
getClientBridge: bridge ? () => bridge : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("write tool ACP fs routing", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-acp-fs-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("routes plain text writes through the bridge and does not call Bun.write", async () => {
|
||||
const filePath = path.join(tmpDir, "output.txt");
|
||||
|
||||
const bridge: ClientBridge = {
|
||||
capabilities: { writeTextFile: true },
|
||||
writeTextFile: async () => undefined,
|
||||
};
|
||||
|
||||
const bridgeSpy = spyOn(bridge, "writeTextFile");
|
||||
const bunWriteSpy = spyOn(Bun, "write");
|
||||
|
||||
try {
|
||||
const session = createSession(tmpDir, bridge);
|
||||
const tool = new WriteTool(session);
|
||||
|
||||
await tool.execute("call-1", { path: filePath, content: FILE_CONTENT });
|
||||
|
||||
// Bridge was called with the exact path and content
|
||||
expect(bridgeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(bridgeSpy).toHaveBeenCalledWith({ path: filePath, content: FILE_CONTENT });
|
||||
// Disk write must not have been called — bridge is the destination
|
||||
expect(bunWriteSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
bunWriteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user