Route ACP-backed planning and validation through a read-only ask-once runner with a pinned Claude bridge foundation. - Add askAcpOnce for single-turn ACP sessions with timeout handling, JSON recovery, clean stop validation, and disposal. - Refactor validation seams to use ACP runtime prompts and require structured pass verdicts. - Resolve the Claude ACP bridge from the plugin bundle and add setup checks for identity, environment, probing, and auth readiness. - Document the ACP Route B plan and update tests for validator, session, runtime, and plugin setup behavior. Files changed: CONCEPTS.md | 6 + docs/acp-contract.md | 36 ++ .../2026-06-14-001-feat-claude-acp-runtime-plan.md | 465 +++++++++++++++++++++ .../engine/src/__tests__/cli-agent-ask.test.ts | 104 +++++ .../src/__tests__/cli-agent-validator.test.ts | 137 +++--- .../src/__tests__/interactive-ai-session.test.ts | 96 +++-- packages/engine/src/agent-runtime.ts | 6 +- packages/engine/src/cli-agent-ask.ts | 120 ++++++ packages/engine/src/cli-agent-validator.ts | 65 ++- .../cli-agent/__tests__/one-shot-session.test.ts | 16 +- packages/engine/src/cli-agent/one-shot-session.ts | 17 +- packages/engine/src/index.ts | 8 +- packages/engine/src/interactive-ai-session.ts | 33 +- plugins/fusion-plugin-acp-runtime/AGENTS.md | 14 + plugins/fusion-plugin-acp-runtime/CHANGELOG.md | 6 + plugins/fusion-plugin-acp-runtime/README.md | 13 +- plugins/fusion-plugin-acp-runtime/package.json | 3 +- .../src/__tests__/index.test.ts | 51 ++- .../src/__tests__/process-manager.test.ts | 32 +- .../src/__tests__/runtime-adapter.test.ts | 4 +- .../src/__tests__/setup.test.ts | 71 ++++ plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts | 95 ++++- plugins/fusion-plugin-acp-runtime/src/index.ts | 16 +- .../src/process-manager.ts | 26 +- .../src/runtime-adapter.ts | 11 +- plugins/fusion-plugin-acp-runtime/src/setup.ts | 104 +++++ plugins/fusion-plugin-acp-runtime/src/types.ts | 6 +- pnpm-lock.yaml | 139 ++++-- 28 files changed, 1502 insertions(+), 198 deletions(-) Fusion-Task-Id: FN-6457 Fusion-Task-Lineage: a3364ed7-cb28-4a2b-b898-6ccd0d95fb92
105 lines
4.4 KiB
TypeScript
105 lines
4.4 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
import type { AgentRuntime, AgentRuntimeOptions, AgentSessionResult } from "../agent-runtime.js";
|
|
import { askAcpOnce } from "../cli-agent-ask.js";
|
|
|
|
interface FakeRuntimeOptions {
|
|
createError?: Error;
|
|
promptError?: Error;
|
|
deltas?: string[];
|
|
stopReason?: string;
|
|
neverResolve?: boolean;
|
|
}
|
|
|
|
function makeRuntime(options: FakeRuntimeOptions = {}) {
|
|
const session = { dispose: vi.fn() } as unknown as AgentSession;
|
|
const createdOptions: AgentRuntimeOptions[] = [];
|
|
const runtime: AgentRuntime = {
|
|
id: "acp",
|
|
name: "ACP Runtime",
|
|
async createSession(opts: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
|
createdOptions.push(opts);
|
|
if (options.createError) throw options.createError;
|
|
return { session };
|
|
},
|
|
async promptWithFallback(): Promise<{ stopReason?: string } | void> {
|
|
if (options.promptError) throw options.promptError;
|
|
for (const delta of options.deltas ?? []) {
|
|
createdOptions[0]?.onText?.(delta);
|
|
}
|
|
if (options.neverResolve) {
|
|
await new Promise(() => undefined);
|
|
}
|
|
return options.stopReason ? { stopReason: options.stopReason } : undefined;
|
|
},
|
|
describeModel() {
|
|
return "acp/test";
|
|
},
|
|
};
|
|
return { runtime, session, createdOptions };
|
|
}
|
|
|
|
describe("askAcpOnce", () => {
|
|
it("streams a happy path response through readonly ACP options", async () => {
|
|
const { runtime, session, createdOptions } = makeRuntime({ deltas: ["hello"] });
|
|
const result = await askAcpOnce(runtime, {
|
|
prompt: "say hi",
|
|
cwd: "/repo",
|
|
model: "claude-sonnet-4",
|
|
systemPrompt: "system",
|
|
});
|
|
expect(result).toEqual({ ok: true, text: "hello" });
|
|
expect(createdOptions[0]).toMatchObject({
|
|
cwd: "/repo",
|
|
systemPrompt: "system",
|
|
tools: "readonly",
|
|
defaultModelId: "claude-sonnet-4",
|
|
});
|
|
expect(session.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("accumulates multiple deltas in order", async () => {
|
|
const { runtime } = makeRuntime({ deltas: ["hel", "lo", "!"] });
|
|
await expect(askAcpOnce(runtime, { prompt: "p", cwd: "/repo" })).resolves.toEqual({ ok: true, text: "hello!" });
|
|
});
|
|
|
|
it("recovers the trailing JSON object when requested", async () => {
|
|
const { runtime } = makeRuntime({ deltas: ["prose\n", "{\"verdict\":\"pass\"}"] });
|
|
const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo", recoverJson: true });
|
|
expect(result).toMatchObject({ ok: true, parsed: { verdict: "pass" } });
|
|
});
|
|
|
|
it("leaves parsed undefined when JSON recovery finds no object", async () => {
|
|
const { runtime } = makeRuntime({ deltas: ["plain prose"] });
|
|
const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo", recoverJson: true });
|
|
expect(result).toEqual({ ok: true, text: "plain prose" });
|
|
});
|
|
|
|
it("maps createSession errors to typed failures without leaking a session", async () => {
|
|
const { runtime, session } = makeRuntime({ createError: new Error("spawn failed") });
|
|
const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo" });
|
|
expect(result).toMatchObject({ ok: false, reason: "create_session_failed", message: "spawn failed" });
|
|
expect(session.dispose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("maps prompt errors to typed failures and disposes", async () => {
|
|
const { runtime, session } = makeRuntime({ promptError: new Error("turn failed") });
|
|
const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo" });
|
|
expect(result).toMatchObject({ ok: false, reason: "turn_failed", message: "turn failed" });
|
|
expect(session.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("times out a never-resolving prompt and disposes", async () => {
|
|
const { runtime, session } = makeRuntime({ neverResolve: true });
|
|
const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo", timeoutMs: 5 });
|
|
expect(result).toMatchObject({ ok: false, reason: "timeout" });
|
|
expect(session.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("reflects an abnormal stopReason as a typed failure", async () => {
|
|
const { runtime } = makeRuntime({ deltas: ["{\"verdict\":\"pass\"}"], stopReason: "max_tokens" });
|
|
const result = await askAcpOnce(runtime, { prompt: "p", cwd: "/repo", recoverJson: true });
|
|
expect(result).toMatchObject({ ok: false, reason: "abnormal_stop", stopReason: "max_tokens" });
|
|
});
|
|
});
|