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
121 lines
4.0 KiB
TypeScript
121 lines
4.0 KiB
TypeScript
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
import type { AgentRuntime } from "./agent-runtime.js";
|
|
import { extractJsonObjects } from "./cli-agent/one-shot-session.js";
|
|
|
|
export type AskAcpOnceFailureReason =
|
|
| "create_session_failed"
|
|
| "turn_failed"
|
|
| "timeout"
|
|
| "abnormal_stop"
|
|
| "dispose_failed";
|
|
|
|
export type AskAcpOnceResult =
|
|
| { ok: true; text: string; parsed?: Record<string, unknown>; stopReason?: string }
|
|
| { ok: false; reason: AskAcpOnceFailureReason; message: string; text?: string; stopReason?: string };
|
|
|
|
export interface AskAcpOnceOptions {
|
|
prompt: string;
|
|
cwd: string;
|
|
model?: string;
|
|
systemPrompt?: string;
|
|
timeoutMs?: number;
|
|
recoverJson?: boolean;
|
|
}
|
|
|
|
function messageFromError(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|
|
|
|
function recoverTrailingJson(text: string): Record<string, unknown> | undefined {
|
|
const objects = extractJsonObjects(text);
|
|
return objects.length > 0 ? objects[objects.length - 1] : undefined;
|
|
}
|
|
|
|
function isCleanStop(stopReason: string | undefined): boolean {
|
|
return stopReason === undefined || stopReason === "end_turn";
|
|
}
|
|
|
|
async function disposeSession(
|
|
runtime: AgentRuntime,
|
|
session: AgentSession | undefined,
|
|
): Promise<void> {
|
|
if (!session) return;
|
|
const runtimeWithDispose = runtime as AgentRuntime & { dispose?: (session: AgentSession) => Promise<void> | void };
|
|
if (typeof runtimeWithDispose.dispose === "function") {
|
|
await runtimeWithDispose.dispose(session);
|
|
return;
|
|
}
|
|
session.dispose();
|
|
}
|
|
|
|
export async function askAcpOnce(runtime: AgentRuntime, opts: AskAcpOnceOptions): Promise<AskAcpOnceResult> {
|
|
/*
|
|
FNXC:ACP-RouteB 2026-06-14-20:11:
|
|
Planning and validator Route-B seams need a one-turn ACP runner that preserves the previous one-shot shape while using readonly tools only. Accumulate streamed prose, optionally recover a trailing JSON object, and always dispose the ACP session.
|
|
*/
|
|
let text = "";
|
|
let session: AgentSession | undefined;
|
|
try {
|
|
const created = await runtime.createSession({
|
|
cwd: opts.cwd,
|
|
systemPrompt: opts.systemPrompt ?? "",
|
|
tools: "readonly",
|
|
defaultModelId: opts.model,
|
|
runtimeContext: { sessionPurpose: "cli-agent-ask", toolMode: "readonly" },
|
|
onText: (delta) => {
|
|
text += delta;
|
|
},
|
|
});
|
|
session = created.session;
|
|
} catch (err) {
|
|
return { ok: false, reason: "create_session_failed", message: messageFromError(err), text };
|
|
}
|
|
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
let timedOut = false;
|
|
try {
|
|
const promptPromise = runtime.promptWithFallback(session, opts.prompt).catch((err: unknown) => {
|
|
if (timedOut) return undefined;
|
|
throw err;
|
|
});
|
|
const result = opts.timeoutMs && opts.timeoutMs > 0
|
|
? await Promise.race([
|
|
promptPromise,
|
|
new Promise<"timeout">((resolve) => {
|
|
timeout = setTimeout(() => resolve("timeout"), opts.timeoutMs);
|
|
}),
|
|
])
|
|
: await promptPromise;
|
|
|
|
if (result === "timeout") {
|
|
timedOut = true;
|
|
return { ok: false, reason: "timeout", message: `ACP prompt timed out after ${opts.timeoutMs}ms`, text };
|
|
}
|
|
|
|
const stopReason = typeof result === "object" && result && "stopReason" in result
|
|
? String((result as { stopReason?: unknown }).stopReason ?? "") || undefined
|
|
: undefined;
|
|
if (!isCleanStop(stopReason)) {
|
|
return {
|
|
ok: false,
|
|
reason: "abnormal_stop",
|
|
message: `ACP prompt ended with stopReason=${stopReason}`,
|
|
text,
|
|
stopReason,
|
|
};
|
|
}
|
|
|
|
const parsed = opts.recoverJson ? recoverTrailingJson(text) : undefined;
|
|
return { ok: true, text, ...(parsed ? { parsed } : {}), ...(stopReason ? { stopReason } : {}) };
|
|
} catch (err) {
|
|
return { ok: false, reason: "turn_failed", message: messageFromError(err), text };
|
|
} finally {
|
|
if (timeout) clearTimeout(timeout);
|
|
try {
|
|
await disposeSession(runtime, session);
|
|
} catch {
|
|
// The turn result is more useful than a best-effort disposal error. Runtimes also own process registries.
|
|
}
|
|
}
|
|
}
|