FN-6689: route planning executor selection

Route planning executor selection through a shared engine seam for model and CLI-agent sessions.

- Add a planning executor selection type with model and CLI-agent variants.
- Wrap CLI-agent planning as a one-shot interactive session that returns terminal complete, question, or error events.
- Export the resolver and wire the core interactive adapter through the model-backed default path.
- Cover resolver behavior for default model sessions, CLI-agent terminal events, and CLI-agent failures.

Files changed:
 .../src/__tests__/interactive-ai-session.test.ts   | 91 ++++++++++++++++++++
 packages/engine/src/index.ts                       | 13 ++-
 packages/engine/src/interactive-ai-session.ts      | 97 ++++++++++++++++++++--
 3 files changed, 190 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-6689

Fusion-Task-Lineage: b2fc0589-621e-44dd-a470-49107b0ad69f
This commit is contained in:
gsxdsm
2026-06-19 01:56:52 -07:00
parent 282b06949e
commit 6210031602
3 changed files with 190 additions and 11 deletions

View File

@@ -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<InteractiveAg
const q = (data: PlanningQuestion): string => 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<InteractiveAgentFactory>(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<InteractiveAgentFactory>(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 = {

View File

@@ -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<AiSessionResult> => {
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,

View File

@@ -47,6 +47,14 @@ export type InteractiveAgentFactory = (
options: CreateInteractiveAiSessionOptions,
) => Promise<InteractiveAgentResult>;
/**
* 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<CreateInteractiveAiSessionResult> {
let started = false;
let terminalEvent: InteractiveAiSessionEvent | undefined;
async function runOnce(text: string): Promise<InteractiveAiSessionEvent> {
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<void> {
if (terminalEvent || started) return;
started = true;
await runOnce(text);
},
async nextEvent(): Promise<InteractiveAiSessionEvent> {
return terminalEvent ?? { type: "error", data: { message: "No turn in progress. Call prompt() first." } };
},
async answer(): Promise<void> {
// 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<CreateInteractiveAiSessionResult> {
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();