Files
fusion/packages/engine/src/interactive-ai-session.ts
gsxdsm a9815fb1ff FN-6457: add ACP ask runner and bundled Claude bridge setup
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
2026-06-15 02:30:41 -07:00

395 lines
13 KiB
TypeScript

/**
* Interactive AI session adapter (the U4 host seam).
*
* Builds a generic prompt → parse → retry → pause → resume loop on top of the
* one-shot `createFnAgent`, modeled on `packages/dashboard/src/planning.ts`.
* There is NO engine await-input primitive to call — this module IS that loop.
*
* Kept deliberately generic: it knows nothing about compound-engineering (or
* any other application). The caller supplies a system prompt instructing the
* agent to emit the JSON question/complete protocol; this module parses it and
* surfaces structured events. To avoid leaking dashboard types into the seam,
* the JSON parse/extract/repair helpers are reimplemented locally here rather
* than imported from `@fusion/dashboard`.
*/
import type {
CreateInteractiveAiSessionOptions,
CreateInteractiveAiSessionResult,
InteractiveAiSession,
InteractiveAiSessionEvent,
PlanningQuestion,
PlanningResponse,
} from "@fusion/core";
import type { AgentRuntime } from "./agent-runtime.js";
import { askAcpOnce } from "./cli-agent-ask.js";
/** Minimal shape of an agent session we depend on (subset of pi's AgentSession). */
export interface InteractiveAgentSession {
prompt(text: string): Promise<void>;
state: {
messages: Array<{
role: string;
content?: string | Array<{ type: string; text?: string; thinking?: string }>;
}>;
};
dispose?: () => void | Promise<void>;
}
/** Minimal shape of an agent factory result. */
export interface InteractiveAgentResult {
session: InteractiveAgentSession;
sessionFile?: string;
}
/** Factory that creates the underlying one-shot agent (injectable for tests). */
export type InteractiveAgentFactory = (
options: CreateInteractiveAiSessionOptions,
) => Promise<InteractiveAgentResult>;
/** One bounded reformat retry, matching planning.ts's MAX_PARSE_RETRIES. */
const MAX_PARSE_RETRIES = 1;
const REFORMAT_PROMPT =
"Your previous response could not be parsed as JSON. " +
'Please respond with ONLY a valid JSON object: {"type":"question","data":{...}} ' +
'or {"type":"complete","data":{...}}. No markdown, no explanation, just the JSON.';
// ── Local JSON extraction/repair (reimplemented to keep core generic) ──────
function extractJsonCandidate(text: string): string | null {
if (!text || !text.trim()) return null;
// 1. Markdown code blocks first (most reliable).
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (codeBlockMatch?.[1]) {
const candidate = codeBlockMatch[1].trim();
if (candidate.startsWith("{")) return candidate;
}
// 2. Balanced top-level brace objects.
const candidates: Array<{ text: string }> = [];
for (let i = 0; i < text.length; i++) {
if (text[i] !== "{") continue;
let depth = 0;
let inString = false;
let escape = false;
for (let j = i; j < text.length; j++) {
const ch = text[j];
if (escape) {
escape = false;
continue;
}
if (ch === "\\") {
escape = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{") depth++;
if (ch === "}") depth--;
if (depth === 0) {
const candidate = text.slice(i, j + 1).trim();
try {
JSON.parse(candidate);
candidates.push({ text: candidate });
} catch {
// not valid JSON, skip
}
break;
}
}
}
if (candidates.length > 0) {
candidates.sort((a, b) => b.text.length - a.text.length);
return candidates[0].text;
}
// 3. Last resort: full trimmed text.
const trimmed = text.trim();
if (trimmed.startsWith("{")) return trimmed;
return null;
}
function repairJson(text: string): string {
let repaired = text.replace(/,\s*([}\]])/g, "$1");
const count = (s: string): { braces: number; brackets: number; inString: boolean } => {
let braces = 0;
let brackets = 0;
let inString = false;
let escape = false;
for (const ch of s) {
if (escape) {
escape = false;
continue;
}
if (ch === "\\") {
escape = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "{") braces++;
if (ch === "}") braces--;
if (ch === "[") brackets++;
if (ch === "]") brackets--;
}
return { braces, brackets, inString };
};
if (count(repaired).inString) repaired += '"';
const { braces, brackets } = count(repaired);
repaired += "]".repeat(Math.max(0, brackets));
repaired += "}".repeat(Math.max(0, braces));
return repaired;
}
/** Parse agent output into a PlanningResponse; throws on unparseable/invalid. */
export function parseAgentResponse(text: string): PlanningResponse {
const candidate = extractJsonCandidate(text);
if (!candidate) {
throw new Error("AI returned no valid JSON.");
}
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
try {
parsed = JSON.parse(repairJson(candidate));
} catch (repairErr) {
throw new Error(
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}.`,
);
}
}
if (
typeof parsed === "object" &&
parsed !== null &&
"type" in parsed &&
"data" in parsed
) {
const typed = parsed as { type: string; data: unknown };
if (
(typed.type === "question" || typed.type === "complete") &&
typed.data !== null &&
typed.data !== undefined
) {
return parsed as PlanningResponse;
}
}
throw new Error("AI returned an invalid response structure.");
}
/**
* CLI-agent planning one-shot seam (U9).
*
* Runs a CLI-agent one-shot in `planning` purpose and maps its output into the
* SAME `PlanningResponse` shape a model-backed planning run produces — so the
* downstream planning flow cannot tell a CLI-backed run from a model run. The
* one-shot runner is injected (`run`) so this is testable without a live PTY.
*
* NOTE (deviation, see report): the full planning *loop* (multi-turn
* question/answer over a resumable interactive session) is interactive, not
* one-shot — a one-shot planning run produces a single terminal response. This
* seam covers the single-shot "produce a plan" case and proves output-shape
* compatibility (`parseAgentResponse`). Wiring it into the resumable planning
* loop's executor resolution remains TODO when planning gains a CLI executor
* selector.
*/
export interface CliAgentPlanningOptions {
prompt: string;
cwd: string;
settings?: { model?: string };
systemPrompt?: string;
timeoutMs?: number;
}
export async function runCliAgentPlanning(
runtime: AgentRuntime,
opts: CliAgentPlanningOptions,
): Promise<PlanningResponse> {
const result = await askAcpOnce(runtime, {
prompt: opts.prompt,
cwd: opts.cwd,
model: opts.settings?.model,
systemPrompt: opts.systemPrompt,
timeoutMs: opts.timeoutMs,
});
if (!result.ok) {
throw new Error(`CLI-agent planning ACP ask failed (${result.reason}): ${result.message}`);
}
// Map to the planning flow's shape exactly as a model run would: parse the
// ACP prose through the canonical planning parser.
return parseAgentResponse(result.text);
}
/** Extract text from the last assistant message (string | text blocks | thinking fallback). */
function extractLastAssistantText(session: InteractiveAgentSession): string {
const lastMessage = session.state.messages.filter((m) => m.role === "assistant").pop();
if (!lastMessage?.content) return "";
if (typeof lastMessage.content === "string") return lastMessage.content;
if (Array.isArray(lastMessage.content)) {
const textContent = lastMessage.content
.filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
.map((c) => c.text)
.join("");
if (textContent) return textContent;
// Fallback: thinking blocks when no text blocks present.
return lastMessage.content
.filter((c): c is { type: "thinking"; thinking: string } => c.type === "thinking" && typeof c.thinking === "string")
.map((c) => c.thinking)
.join("");
}
return "";
}
type LoopState = "idle" | "awaiting_input" | "complete" | "error";
/**
* Build the interactive session over an injected agent factory.
* Exported for direct (deterministic, fake-agent) testing.
*/
export async function createInteractiveAiSessionWith(
agentFactory: InteractiveAgentFactory,
options: CreateInteractiveAiSessionOptions,
): Promise<CreateInteractiveAiSessionResult> {
const agentResult = await agentFactory(options);
const agent = agentResult.session;
let state: LoopState = "idle";
let pendingEvent: Promise<InteractiveAiSessionEvent> | undefined;
let terminalEvent: InteractiveAiSessionEvent | undefined;
let currentQuestion: PlanningQuestion | undefined;
let disposed = false;
/**
* Prompt the agent, read the last assistant message, parse it, and run one
* bounded reformat retry. Returns the structured event for this turn.
*/
async function runTurn(text: string): Promise<InteractiveAiSessionEvent> {
if (disposed) {
return { type: "error", data: { message: "Session disposed." } };
}
try {
await agent.prompt(text);
} catch (err) {
state = "error";
const ev: InteractiveAiSessionEvent = {
type: "error",
data: { message: err instanceof Error ? err.message : String(err), cause: err },
};
terminalEvent = ev;
return ev;
}
let responseText = extractLastAssistantText(agent);
let parsed: PlanningResponse | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
parsed = parseAgentResponse(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_PARSE_RETRIES) {
try {
await agent.prompt(REFORMAT_PROMPT);
responseText = extractLastAssistantText(agent);
} catch (promptErr) {
lastError = promptErr instanceof Error ? promptErr : new Error(String(promptErr));
break;
}
}
}
}
if (!parsed) {
state = "error";
const ev: InteractiveAiSessionEvent = {
type: "error",
data: { message: `Failed to parse agent response: ${lastError?.message ?? "Unknown error"}`, cause: lastError },
};
terminalEvent = ev;
return ev;
}
if (parsed.type === "question") {
currentQuestion = parsed.data;
state = "awaiting_input";
return { type: "question", data: parsed.data };
}
// complete
state = "complete";
const ev: InteractiveAiSessionEvent = { type: "complete", data: parsed.data };
terminalEvent = ev;
return ev;
}
const session: InteractiveAiSession = {
async prompt(text: string): Promise<void> {
if (terminalEvent) return; // terminal: ignore further input
pendingEvent = runTurn(text);
// Surface prompt-time errors only via nextEvent(); never throw to caller.
await pendingEvent.catch(() => undefined);
},
async nextEvent(): Promise<InteractiveAiSessionEvent> {
if (terminalEvent) return terminalEvent;
if (!pendingEvent) {
return { type: "error", data: { message: "No turn in progress. Call prompt() or answer() first." } };
}
return pendingEvent;
},
async answer(questionId: string, response: unknown): Promise<void> {
if (terminalEvent) return;
if (state !== "awaiting_input") {
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
type: "error",
data: { message: "answer() called while not awaiting input." },
});
return;
}
if (currentQuestion && questionId !== currentQuestion.id) {
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
type: "error",
data: { message: `answer() questionId "${questionId}" does not match current question "${currentQuestion.id}".` },
});
return;
}
const answerMessage = JSON.stringify({
type: "answer",
questionId,
response,
});
currentQuestion = undefined;
state = "idle";
pendingEvent = runTurn(answerMessage);
await pendingEvent.catch(() => undefined);
},
dispose(): void {
if (disposed) return;
disposed = true;
try {
void agent.dispose?.();
} catch {
// Best-effort cleanup; never throw from dispose.
}
},
};
return { session, sessionFile: agentResult.sessionFile };
}