diff --git a/packages/engine/src/__tests__/interactive-ai-session.test.ts b/packages/engine/src/__tests__/interactive-ai-session.test.ts index 207c89042e..bc94a206bb 100644 --- a/packages/engine/src/__tests__/interactive-ai-session.test.ts +++ b/packages/engine/src/__tests__/interactive-ai-session.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from "vitest"; import type { PlanningQuestion, PlanningResponse } from "@fusion/core"; import { createInteractiveAiSessionWith, + resolvePlanningExecutorSession, runCliAgentPlanning, + type InteractiveAgentFactory, type InteractiveAgentResult, type InteractiveAgentSession, } from "../interactive-ai-session.js"; @@ -109,6 +111,95 @@ function factoryFor(agent: InteractiveAgentSession): () => Promise JSON.stringify({ type: "question", data } satisfies PlanningResponse); const complete = (data: unknown): string => JSON.stringify({ type: "complete", data }); +describe("resolvePlanningExecutorSession", () => { + it("keeps the default model-backed path unchanged", async () => { + const question: PlanningQuestion = { + id: "q1", + type: "text", + question: "What is the goal?", + }; + const scripted = makeScriptedAgent([ + q(question), + complete({ title: "Done", summary: "ok" }), + ]); + const factory = vi.fn(factoryFor(scripted.session)); + + const { session, sessionFile } = await resolvePlanningExecutorSession({ kind: "model" }, factory, { + cwd: "/tmp", + systemPrompt: "emit json protocol", + }); + + expect(sessionFile).toBe("/tmp/fake-session.json"); + expect(factory).toHaveBeenCalledTimes(1); + await session.prompt("start"); + const ev1 = await session.nextEvent(); + expect(ev1.type).toBe("question"); + expect(ev1.type === "question" && ev1.data.id).toBe("q1"); + + await session.answer("q1", "ship it"); + const ev2 = await session.nextEvent(); + expect(ev2.type).toBe("complete"); + expect(ev2.type === "complete" && ev2.data).toEqual({ title: "Done", summary: "ok" }); + }); + + it.each([ + ["complete", { type: "complete", data: { title: "Do X", summary: "ok" } } satisfies PlanningResponse], + ["question", { type: "question", data: { id: "q1", type: "text", question: "What is the goal?" } } satisfies PlanningResponse], + ])("selects the CLI-agent path for a terminal %s event without using the model factory", async (_name, response) => { + const { runtime, createOptions } = planningRuntime(`ACP says: ${JSON.stringify(response)}`); + const modelFactory = vi.fn(async () => { + throw new Error("model factory should not be used"); + }); + + const { session } = await resolvePlanningExecutorSession({ kind: "cli-agent", runtime }, modelFactory, { + cwd: "/tmp/project", + systemPrompt: "emit json protocol", + defaultModelId: "claude-sonnet-4", + }); + + await session.prompt("plan it"); + const ev = await session.nextEvent(); + expect(modelFactory).not.toHaveBeenCalled(); + expect(createOptions[0]).toMatchObject({ + cwd: "/tmp/project", + systemPrompt: "emit json protocol", + tools: "readonly", + defaultModelId: "claude-sonnet-4", + }); + expect(ev.type).toBe(response.type); + expect(ev.type === "question" || ev.type === "complete" ? ev.data : undefined).toEqual(response.data); + expect(await session.nextEvent()).toBe(ev); + + await session.prompt("ignored after terminal"); + await session.answer("q1", "ignored after terminal"); + expect(await session.nextEvent()).toBe(ev); + }); + + it.each([ + ["unparseable output", () => planningRuntime("no structured answer"), /no valid JSON/i], + ["failed ACP prompt", () => planningRuntime("", { throwPrompt: new Error("transport failed") }), /planning ACP ask failed/i], + ])("surfaces malformed/failed CLI-agent output as a terminal error: %s", async (_name, makeRuntime, message) => { + const { runtime } = makeRuntime(); + const modelFactory = vi.fn(async () => { + throw new Error("model factory should not be used"); + }); + + const { session } = await resolvePlanningExecutorSession({ kind: "cli-agent", runtime }, modelFactory, { + cwd: "/tmp", + systemPrompt: "emit json protocol", + }); + + await session.prompt("plan it"); + const ev = await session.nextEvent(); + expect(modelFactory).not.toHaveBeenCalled(); + expect(ev.type).toBe("error"); + expect(ev.type === "error" && ev.data.message).toMatch(message); + expect(ev.type).not.toBe("complete"); + expect(ev.type).not.toBe("question"); + expect(await session.nextEvent()).toBe(ev); + }); +}); + describe("interactive-ai-session seam", () => { it("round-trips question → answer → complete (happy path)", async () => { const question: PlanningQuestion = { diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index cb580413ab..485e79ffb8 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -268,10 +268,13 @@ export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, typ export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js"; export { createInteractiveAiSessionWith, + createCliAgentPlanningSessionWith, + resolvePlanningExecutorSession, parseAgentResponse as parseInteractiveAgentResponse, type InteractiveAgentSession, type InteractiveAgentResult, type InteractiveAgentFactory, + type PlanningExecutorSelection, } from "./interactive-ai-session.js"; export { selectPermanentAgentForTask, listEligibleExecutorAgents } from "./agent-assignment.js"; @@ -286,7 +289,7 @@ import type { CreateInteractiveAiSessionOptions, } from "@fusion/core"; import { createFnAgent as _createFnAgentForCore } from "./pi.js"; -import { createInteractiveAiSessionWith } from "./interactive-ai-session.js"; +import { resolvePlanningExecutorSession } from "./interactive-ai-session.js"; const _createAiSessionAdapter: CreateAiSessionFactory = async (options: CreateAiSessionOptions): Promise => { return _createFnAgentForCore({ @@ -298,12 +301,14 @@ const _createAiSessionAdapter: CreateAiSessionFactory = async (options: CreateAi }); }; -// Interactive (multi-turn, await-input) adapter: builds the prompt→parse→ -// retry→pause→resume loop on top of the one-shot createFnAgent. +// Interactive (multi-turn, await-input) adapter: resolves the default +// model-backed planning executor, then builds the prompt→parse→retry→pause→ +// resume loop on top of the one-shot createFnAgent. const _createInteractiveAiSessionAdapter: CreateInteractiveAiSessionFactory = ( options: CreateInteractiveAiSessionOptions, ) => - createInteractiveAiSessionWith( + resolvePlanningExecutorSession( + { kind: "model" }, (opts) => _createFnAgentForCore({ cwd: opts.cwd, diff --git a/packages/engine/src/interactive-ai-session.ts b/packages/engine/src/interactive-ai-session.ts index b0aefb8376..cc34dcf808 100644 --- a/packages/engine/src/interactive-ai-session.ts +++ b/packages/engine/src/interactive-ai-session.ts @@ -47,6 +47,14 @@ export type InteractiveAgentFactory = ( options: CreateInteractiveAiSessionOptions, ) => Promise; +/** + * FNXC:PlanningExecutor 2026-06-19-01:45: + * Planning now has one engine-local executor selector so callers can opt into the read-only CLI-agent/ACP path without changing core interactive-session options or the downstream PlanningResponse contract. The model executor remains the default selection. + */ +export type PlanningExecutorSelection = + | { kind: "model" } + | { kind: "cli-agent"; runtime: AgentRuntime }; + /** One bounded reformat retry, matching planning.ts's MAX_PARSE_RETRIES. */ const MAX_PARSE_RETRIES = 1; @@ -197,13 +205,12 @@ export function parseAgentResponse(text: string): PlanningResponse { * 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. + * The planning executor selector below is the production resolution point for + * this seam: selecting `{ kind: "cli-agent" }` wraps this one-shot in the + * `InteractiveAiSession` contract, while `{ kind: "model" }` keeps the existing + * resumable model-backed loop. Threading a live CE `AgentRuntime` into the CE + * orchestrator remains a gated Route-A follow-up and is intentionally out of + * scope for this read-only planning path. */ export interface CliAgentPlanningOptions { prompt: string; @@ -232,6 +239,82 @@ export async function runCliAgentPlanning( return parseAgentResponse(result.text); } +/** + * FNXC:PlanningExecutor 2026-06-19-01:45: + * The CLI-agent planning executor is a read-only one-shot ACP ask adapted to the InteractiveAiSession interface. It emits exactly one terminal question/complete/error event; malformed or failed CLI-agent output must surface as error instead of fabricating a plan. + * + * Create an interactive-session facade over `runCliAgentPlanning`. + * + * The one-shot CLI-agent path produces a single terminal turn: even a + * `question` event is terminal here and does not support multi-turn + * question/answer. `runCliAgentPlanning` owns ACP session disposal via + * `askAcpOnce`; this facade's dispose is therefore best-effort no-op. + */ +export async function createCliAgentPlanningSessionWith( + runtime: AgentRuntime, + options: CreateInteractiveAiSessionOptions, +): Promise { + let started = false; + let terminalEvent: InteractiveAiSessionEvent | undefined; + + async function runOnce(text: string): Promise { + try { + const response = await runCliAgentPlanning(runtime, { + prompt: text, + cwd: options.cwd, + systemPrompt: options.systemPrompt, + settings: options.defaultModelId ? { model: options.defaultModelId } : undefined, + }); + terminalEvent = response.type === "question" + ? { type: "question", data: response.data } + : { type: "complete", data: response.data }; + } catch (err) { + terminalEvent = { + type: "error", + data: { message: err instanceof Error ? err.message : String(err), cause: err }, + }; + } + return terminalEvent; + } + + const session: InteractiveAiSession = { + async prompt(text: string): Promise { + if (terminalEvent || started) return; + started = true; + await runOnce(text); + }, + + async nextEvent(): Promise { + return terminalEvent ?? { type: "error", data: { message: "No turn in progress. Call prompt() first." } }; + }, + + async answer(): Promise { + // Terminal one-shot facade: question events are not resumable here. + }, + + dispose(): void { + // Best-effort no-op; askAcpOnce disposes the underlying ACP session. + }, + }; + + return { session }; +} + +/** + * Resolve planning execution to either the default model-backed loop or the + * selected CLI-agent/ACP one-shot adapter. + */ +export async function resolvePlanningExecutorSession( + selection: PlanningExecutorSelection, + modelFactory: InteractiveAgentFactory, + options: CreateInteractiveAiSessionOptions, +): Promise { + if (selection.kind === "cli-agent") { + return createCliAgentPlanningSessionWith(selection.runtime, options); + } + return createInteractiveAiSessionWith(modelFactory, options); +} + /** 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();