f1f6516056
- Removed export leakage by demoting many helper and const symbols to module-local scope. - Renamed underscore-prefixed internals and cache fields, then updated related references and `satisfies never` checks. - Deleted obsolete logic branches and helpers, including harmony-stream interruption flow and unused benchmark runtime helpers. - Updated Biome config and manifests by broadening lint coverage and removing an unused `@napi-rs/cli` dev dependency. - Adjusted tests and utilities to use renamed test helpers and remove redundant private test-only helpers/locals.
696 lines
23 KiB
TypeScript
696 lines
23 KiB
TypeScript
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, 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[] = [
|
|
{
|
|
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,
|
|
},
|
|
{
|
|
id: "gpt-5.4",
|
|
name: "GPT-5.4",
|
|
api: "openai-responses",
|
|
provider: "openai",
|
|
baseUrl: "https://example.invalid",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 200_000,
|
|
maxTokens: 8_192,
|
|
},
|
|
];
|
|
|
|
function makeAssistantMessage(text: string, thinking?: string) {
|
|
const content: Array<{ type: "text"; text: string } | { type: "thinking"; thinking: string }> = [
|
|
{ type: "text", text },
|
|
];
|
|
if (thinking) {
|
|
content.push({ type: "thinking" as const, thinking });
|
|
}
|
|
return {
|
|
role: "assistant" as const,
|
|
content,
|
|
api: "anthropic-messages" as const,
|
|
provider: "anthropic" as const,
|
|
model: TEST_MODELS[0].id,
|
|
usage: {
|
|
input: 10,
|
|
output: 5,
|
|
cacheRead: 2,
|
|
cacheWrite: 1,
|
|
totalTokens: 18,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
},
|
|
stopReason: "stop" as const,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
class FakeAgentSession {
|
|
sessionManager: SessionManager;
|
|
sessionId: string;
|
|
agent: { sessionId: string; waitForIdle: () => Promise<void> };
|
|
model: Model | undefined;
|
|
thinkingLevel: string | undefined;
|
|
customCommands: [] = [];
|
|
extensionRunner = undefined;
|
|
isStreaming = false;
|
|
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(
|
|
cwd: string,
|
|
private readonly models: Model[] = TEST_MODELS,
|
|
) {
|
|
this.sessionManager = SessionManager.create(cwd);
|
|
this.sessionId = this.sessionManager.getSessionId();
|
|
this.agent = {
|
|
sessionId: this.sessionId,
|
|
waitForIdle: async () => {},
|
|
};
|
|
this.model = models[0];
|
|
}
|
|
|
|
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 this.models;
|
|
}
|
|
|
|
getAvailableThinkingLevels(): ReadonlyArray<string> {
|
|
return ["low", "medium", "high"];
|
|
}
|
|
|
|
setThinkingLevel(level: string | undefined): void {
|
|
this.thinkingLevel = level;
|
|
}
|
|
|
|
setSlashCommands(_commands: unknown[]): void {
|
|
// no-op for tests
|
|
}
|
|
|
|
async setModel(model: Model): Promise<void> {
|
|
this.model = model;
|
|
}
|
|
|
|
subscribe(listener: (event: AgentSessionEvent) => void): () => void {
|
|
this.#listeners.add(listener);
|
|
return () => {
|
|
this.#listeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
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");
|
|
for (const listener of this.#listeners) {
|
|
listener({
|
|
type: "message_update",
|
|
message: assistantMessage,
|
|
assistantMessageEvent: { type: "text_delta", delta: "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 abort(): Promise<void> {
|
|
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 {
|
|
return undefined;
|
|
}
|
|
|
|
async switchSession(sessionPath: string): Promise<boolean> {
|
|
await this.sessionManager.setSessionFile(sessionPath);
|
|
this.sessionId = this.sessionManager.getSessionId();
|
|
this.agent.sessionId = this.sessionId;
|
|
return true;
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
this.disposed = true;
|
|
await this.sessionManager.close();
|
|
}
|
|
|
|
async reload(): Promise<void> {}
|
|
|
|
async newSession(): Promise<boolean> {
|
|
await this.sessionManager.newSession();
|
|
this.sessionId = this.sessionManager.getSessionId();
|
|
this.agent.sessionId = this.sessionId;
|
|
return true;
|
|
}
|
|
|
|
async branch(_entryId: string): Promise<{ cancelled: boolean }> {
|
|
return { cancelled: false };
|
|
}
|
|
|
|
async navigateTree(_targetId: string): Promise<{ cancelled: boolean }> {
|
|
return { cancelled: false };
|
|
}
|
|
|
|
getActiveToolNames(): string[] {
|
|
return [];
|
|
}
|
|
|
|
getAllToolNames(): string[] {
|
|
return [];
|
|
}
|
|
|
|
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> {}
|
|
|
|
async compact(_instructions?: string, _options?: unknown): Promise<void> {}
|
|
|
|
async fork(): Promise<boolean> {
|
|
await this.sessionManager.flush();
|
|
const forked = await this.sessionManager.fork();
|
|
if (!forked) {
|
|
return false;
|
|
}
|
|
this.sessionId = this.sessionManager.getSessionId();
|
|
this.agent.sessionId = this.sessionId;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
interface AgentHarness {
|
|
agent: AcpAgent;
|
|
updates: SessionNotification[];
|
|
abortController: AbortController;
|
|
sessions: FakeAgentSession[];
|
|
cwdA: string;
|
|
cwdB: string;
|
|
findSession(sessionId: string): FakeAgentSession | undefined;
|
|
}
|
|
|
|
function getChunkMessageId(notification: SessionNotification): string | undefined {
|
|
const update = notification.update as { messageId?: string | null };
|
|
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");
|
|
|
|
afterEach(async () => {
|
|
if (originalAgentDir) {
|
|
setAgentDir(originalAgentDir);
|
|
} else {
|
|
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 });
|
|
}
|
|
});
|
|
|
|
async function createHarness(): Promise<AgentHarness> {
|
|
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omp-acp-test-"));
|
|
cleanupRoots.push(root);
|
|
const agentDir = path.join(root, "agent");
|
|
const cwdA = path.join(root, "cwd-a");
|
|
const cwdB = path.join(root, "cwd-b");
|
|
await fs.promises.mkdir(agentDir, { recursive: true });
|
|
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();
|
|
const sessions: FakeAgentSession[] = [];
|
|
const connection = {
|
|
sessionUpdate: async (notification: SessionNotification) => {
|
|
updates.push(notification);
|
|
},
|
|
signal: abortController.signal,
|
|
closed: Promise.withResolvers<void>().promise,
|
|
} as unknown as AgentSideConnection;
|
|
|
|
const initialSession = new FakeAgentSession(cwdA);
|
|
sessions.push(initialSession);
|
|
const factory = async (cwd: string): Promise<AgentSession> => {
|
|
const session = new FakeAgentSession(cwd);
|
|
sessions.push(session);
|
|
return session as unknown as AgentSession;
|
|
};
|
|
|
|
return {
|
|
agent: new AcpAgent(connection, initialSession as unknown as AgentSession, factory),
|
|
updates,
|
|
abortController,
|
|
sessions,
|
|
cwdA,
|
|
cwdB,
|
|
findSession: (sessionId: string) => sessions.find(session => session.sessionId === sessionId),
|
|
};
|
|
}
|
|
|
|
describe("ACP agent", () => {
|
|
it("supports multiple live ACP sessions with model and lifecycle handlers", async () => {
|
|
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}`),
|
|
);
|
|
|
|
await harness.agent.unstable_setSessionModel({
|
|
sessionId: first.sessionId,
|
|
modelId: `${TEST_MODELS[1]!.provider}/${TEST_MODELS[1]!.id}`,
|
|
});
|
|
await harness.agent.setSessionConfigOption({
|
|
sessionId: first.sessionId,
|
|
configId: "thinking",
|
|
value: "high",
|
|
});
|
|
// Both model and thinking-level changes must surface as ACP
|
|
// `config_option_update` notifications scoped to the right session;
|
|
// the schema check alone would still pass if either method stopped
|
|
// emitting notifications entirely.
|
|
const configUpdatesForFirst = harness.updates.filter(
|
|
n => n.sessionId === first.sessionId && n.update.sessionUpdate === "config_option_update",
|
|
);
|
|
expect(configUpdatesForFirst.length).toBeGreaterThanOrEqual(2);
|
|
expectAcpNotifications(harness.updates);
|
|
|
|
const firstSession = harness.findSession(first.sessionId);
|
|
const secondSession = harness.findSession(second.sessionId);
|
|
expect(firstSession?.model?.id).toBe(TEST_MODELS[1]!.id);
|
|
expect(firstSession?.thinkingLevel).toBe("high");
|
|
expect(secondSession?.model?.id).toBe(TEST_MODELS[0]!.id);
|
|
expect(secondSession?.thinkingLevel).toBeUndefined();
|
|
|
|
firstSession?.sessionManager.appendMessage({ role: "user", content: "fork me", timestamp: Date.now() });
|
|
await firstSession?.sessionManager.flush();
|
|
|
|
const forked = await harness.agent.unstable_forkSession({
|
|
sessionId: first.sessionId,
|
|
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.closeSession({ sessionId: forked.sessionId });
|
|
await expect(harness.agent.setSessionMode({ sessionId: forked.sessionId, modeId: "default" })).rejects.toThrow(
|
|
"Unsupported ACP session",
|
|
);
|
|
|
|
harness.abortController.abort();
|
|
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);
|
|
harness.sessions.push(stored);
|
|
stored.sessionManager.appendMessage({ role: "user", content: "hello", timestamp: Date.now() });
|
|
stored.sessionManager.appendMessage(makeAssistantMessage("reply", "reasoning"));
|
|
await stored.sessionManager.ensureOnDisk();
|
|
await stored.sessionManager.flush();
|
|
|
|
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 &&
|
|
(update.update.sessionUpdate === "user_message_chunk" ||
|
|
update.update.sessionUpdate === "agent_message_chunk" ||
|
|
update.update.sessionUpdate === "agent_thought_chunk"),
|
|
);
|
|
const replayAssistantChunks = replayChunks.filter(
|
|
update =>
|
|
update.update.sessionUpdate === "agent_message_chunk" ||
|
|
update.update.sessionUpdate === "agent_thought_chunk",
|
|
);
|
|
|
|
expect(
|
|
replayChunks.every(
|
|
update => typeof getChunkMessageId(update) === "string" && getChunkMessageId(update)!.length > 0,
|
|
),
|
|
).toBe(true);
|
|
expect(new Set(replayAssistantChunks.map(update => getChunkMessageId(update))).size).toBe(1);
|
|
|
|
const live = await harness.agent.newSession({ cwd: harness.cwdB, mcpServers: [] });
|
|
const response = await harness.agent.prompt({
|
|
sessionId: live.sessionId,
|
|
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",
|
|
);
|
|
expect(response.userMessageId).toBe("05b17a6f-b310-4be7-b767-6b4f3a84eb63");
|
|
expect(response.usage).toEqual({
|
|
inputTokens: 10,
|
|
outputTokens: 5,
|
|
cachedReadTokens: 2,
|
|
cachedWriteTokens: 1,
|
|
totalTokens: 18,
|
|
});
|
|
expect(
|
|
liveChunks.some(
|
|
update => typeof getChunkMessageId(update) === "string" && getChunkMessageId(update)!.length > 0,
|
|
),
|
|
).toBe(true);
|
|
|
|
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);
|
|
});
|
|
});
|