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:
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user