From 4dbd72d54f94f8c932a41931a42fe7b47a3e0380 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 2 Jun 2026 18:46:33 -0700 Subject: [PATCH] feat(core,engine): add interactive AI session seam for plugin routes (U4) Expose createInteractiveAiSession on route/loader PluginContext alongside the one-shot createAiSession. The prompt->parse->retry->pause->resume loop is reimplemented in an engine seam modeled on planning.ts (no engine await-input primitive exists). Reuses PlanningQuestion/PlanningResponse; stays generic with no plugin-specific concepts. Injected on route contexts only; tool/runtime contexts omit it (parity with createAiSession). --- .../interactive-ai-session-seam.test.ts | 105 ++++++ packages/core/src/ai-engine-loader.ts | 23 +- packages/core/src/index.ts | 7 + packages/core/src/plugin-loader.ts | 4 +- packages/core/src/plugin-types.ts | 102 ++++- .../__tests__/interactive-ai-session.test.ts | 192 ++++++++++ .../src/__tests__/plugin-runner.test.ts | 21 ++ packages/engine/src/index.ts | 36 +- packages/engine/src/interactive-ai-session.ts | 349 ++++++++++++++++++ 9 files changed, 835 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/__tests__/interactive-ai-session-seam.test.ts create mode 100644 packages/engine/src/__tests__/interactive-ai-session.test.ts create mode 100644 packages/engine/src/interactive-ai-session.ts diff --git a/packages/core/src/__tests__/interactive-ai-session-seam.test.ts b/packages/core/src/__tests__/interactive-ai-session-seam.test.ts new file mode 100644 index 0000000000..3fe017a9b5 --- /dev/null +++ b/packages/core/src/__tests__/interactive-ai-session-seam.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getCreateInteractiveAiSessionFactory, + setCreateInteractiveAiSessionFactory, +} from "../ai-engine-loader.js"; +import { PluginLoader } from "../plugin-loader.js"; +import type { + CreateInteractiveAiSessionFactory, + InteractiveAiSession, + InteractiveAiSessionEvent, +} from "../plugin-types.js"; +import type { PlanningQuestion } from "../types.js"; + +/** + * A scripted fake interactive session: drives question → answer → complete + * deterministically so the route-context seam can be integration-tested + * without a live engine/model. + */ +function makeScriptedSession(script: InteractiveAiSessionEvent[]): InteractiveAiSession { + let cursor = -1; + return { + prompt: vi.fn(async () => { + cursor++; + }), + answer: vi.fn(async () => { + cursor++; + }), + nextEvent: vi.fn(async () => script[Math.min(cursor, script.length - 1)]), + dispose: vi.fn(), + } as InteractiveAiSession; +} + +afterEach(() => { + setCreateInteractiveAiSessionFactory(undefined); +}); + +describe("ai-engine-loader: interactive factory DI", () => { + it("returns undefined before registration", async () => { + await expect(getCreateInteractiveAiSessionFactory()).resolves.toBeUndefined(); + }); + + it("stores, returns, and clears the factory", async () => { + const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ + session: makeScriptedSession([{ type: "complete", data: {} }]), + })); + setCreateInteractiveAiSessionFactory(factory); + await expect(getCreateInteractiveAiSessionFactory()).resolves.toBe(factory); + setCreateInteractiveAiSessionFactory(undefined); + await expect(getCreateInteractiveAiSessionFactory()).resolves.toBeUndefined(); + }); +}); + +describe("interactive session injection boundary", () => { + function makeLoader() { + const pluginStore = { + getPlugin: vi.fn().mockResolvedValue({ settings: {} }), + } as never; + const taskStore = { getRootDir: () => "/tmp" } as never; + return new PluginLoader({ pluginStore, taskStore }); + } + + it("route context exposes createInteractiveAiSession when engine registered it; absent otherwise", async () => { + const loader = makeLoader(); + + // Not registered → undefined on route context. + const before = await loader.createRouteContext("fusion-plugin-x"); + expect(before.createInteractiveAiSession).toBeUndefined(); + + const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ + session: makeScriptedSession([{ type: "complete", data: {} }]), + })); + setCreateInteractiveAiSessionFactory(factory); + + const after = await loader.createRouteContext("fusion-plugin-x"); + expect(after.createInteractiveAiSession).toBe(factory); + }); + + it("drives a full question → answer → complete round trip from a route context", async () => { + const question: PlanningQuestion = { id: "q1", type: "single_select", question: "Pick", options: [{ id: "a", label: "A" }] }; + const session = makeScriptedSession([ + { type: "question", data: question }, + { type: "complete", data: { title: "ok" } }, + ]); + const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ session, sessionFile: "/tmp/s.json" })); + setCreateInteractiveAiSessionFactory(factory); + + const loader = makeLoader(); + const ctx = await loader.createRouteContext("fusion-plugin-x"); + expect(ctx.createInteractiveAiSession).toBeDefined(); + + const { session: s } = await ctx.createInteractiveAiSession!({ cwd: "/tmp", systemPrompt: "protocol" }); + + await s.prompt("start"); + const ev1 = await s.nextEvent(); + expect(ev1.type).toBe("question"); + expect(ev1.type === "question" && ev1.data.id).toBe("q1"); + + await s.answer("q1", "a"); + const ev2 = await s.nextEvent(); + expect(ev2.type).toBe("complete"); + + s.dispose(); + expect(s.dispose).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/ai-engine-loader.ts b/packages/core/src/ai-engine-loader.ts index 182947cc13..3ea21c614d 100644 --- a/packages/core/src/ai-engine-loader.ts +++ b/packages/core/src/ai-engine-loader.ts @@ -10,7 +10,7 @@ * returns `undefined` and callers degrade gracefully. */ -import type { CreateAiSessionFactory } from "./plugin-types.js"; +import type { CreateAiSessionFactory, CreateInteractiveAiSessionFactory } from "./plugin-types.js"; // Engine exports a function type we intentionally don't pull in here — importing // the type would reintroduce the cycle this module is designed to avoid. @@ -19,6 +19,7 @@ type CreateFnAgent = any; let createFnAgent: CreateFnAgent | undefined; let createAiSessionFactory: CreateAiSessionFactory | undefined; +let createInteractiveAiSessionFactory: CreateInteractiveAiSessionFactory | undefined; /** Shape of a message in an agent session's state. */ export interface AgentMessage { @@ -57,3 +58,23 @@ export function setCreateAiSessionFactory(fn: CreateAiSessionFactory | undefined export async function getCreateAiSessionFactory(): Promise { return createAiSessionFactory; } + +/** + * Wire engine's plugin-facing interactive AI session factory into core. + * Called by `@fusion/engine` at module load; tests may register stubs. + */ +export function setCreateInteractiveAiSessionFactory( + fn: CreateInteractiveAiSessionFactory | undefined, +): void { + createInteractiveAiSessionFactory = fn; +} + +/** + * Returns engine-registered plugin interactive AI session factory, or + * `undefined` when engine hasn't registered it (common in isolated core tests). + */ +export async function getCreateInteractiveAiSessionFactory(): Promise< + CreateInteractiveAiSessionFactory | undefined +> { + return createInteractiveAiSessionFactory; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a00a01622f..91f6e006b3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,6 +61,8 @@ export { getFnAgent, setCreateAiSessionFactory, getCreateAiSessionFactory, + setCreateInteractiveAiSessionFactory, + getCreateInteractiveAiSessionFactory, type AgentMessage, } from "./ai-engine-loader.js"; export { @@ -524,6 +526,11 @@ export type { CreateAiSessionOptions, AiSessionResult, CreateAiSessionFactory, + CreateInteractiveAiSessionOptions, + InteractiveAiSessionEvent, + InteractiveAiSession, + CreateInteractiveAiSessionResult, + CreateInteractiveAiSessionFactory, PluginLogger, PluginSkillContribution, PluginWorkflowStepContribution, diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index c2c3b1436d..d75bb1e6b3 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -40,7 +40,7 @@ import type { } from "./plugin-types.js"; import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js"; import { createLogger } from "./logger.js"; -import { getCreateAiSessionFactory } from "./ai-engine-loader.js"; +import { getCreateAiSessionFactory, getCreateInteractiveAiSessionFactory } from "./ai-engine-loader.js"; import { scanPluginSecurity } from "./plugin-security-scan.js"; // Minimum Fusion version for plugin compatibility checks (can be expanded later) @@ -123,6 +123,7 @@ export class PluginLoader extends EventEmitter<{ overrides?: Partial>, ): Promise { const createAiSession = await getCreateAiSessionFactory(); + const createInteractiveAiSession = await getCreateInteractiveAiSessionFactory(); if (process.env.DEBUG?.includes("plugins")) { this.log.log( createAiSession @@ -137,6 +138,7 @@ export class PluginLoader extends EventEmitter<{ settings: overrides?.settings ?? await this.getPluginSettings(pluginId), logger: this.createLogger(pluginId), createAiSession, + createInteractiveAiSession, resolveProjectTaskStore: overrides?.resolveProjectTaskStore, emitEvent: (event: string, data: unknown) => { this.emit("plugin:error", { pluginId, error: new Error(`Custom event: ${event}`) }); diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index b9665f27d4..ab0d8648ff 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -13,7 +13,7 @@ import type { Database } from "./db.js"; import type { TaskStore } from "./store.js"; -import type { Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js"; +import type { PlanningQuestion, Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js"; const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; const PROMPT_CONTRIBUTION_SURFACES = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"] as const; @@ -121,6 +121,100 @@ export interface AiSessionResult { */ export type CreateAiSessionFactory = (options: CreateAiSessionOptions) => Promise; +// ── Interactive AI Sessions ─────────────────────────────────────────── +// +// A generic interactive (multi-turn, await-input) AI session capability. +// Unlike the one-shot `createAiSession` above, an interactive session can +// pause mid-agent-turn on a structured question and resume when the caller +// supplies an answer. The host (engine) builds the prompt → parse → retry → +// pause → resume loop; the caller drives it by pulling events. +// +// The protocol is deliberately generic: the caller supplies a `systemPrompt` +// that instructs the agent to emit the JSON question/complete contract +// (the same shape used by `PlanningResponse`). The seam hardcodes no +// application-specific (e.g. compound-engineering) prompts or concepts. + +/** + * Options for creating an interactive AI session. + * Mirrors {@link CreateAiSessionOptions}; the caller-supplied `systemPrompt` + * is responsible for instructing the agent to emit the question/complete + * JSON protocol that the seam parses. + */ +export interface CreateInteractiveAiSessionOptions { + /** Working directory for the agent session */ + cwd: string; + /** System prompt for the agent (must instruct it to emit the JSON protocol) */ + systemPrompt: string; + /** Tool mode: "coding" for full tools, "readonly" for read-only */ + tools?: "coding" | "readonly"; + /** Default model provider (e.g., "anthropic") */ + defaultProvider?: string; + /** Default model ID within the provider */ + defaultModelId?: string; +} + +/** + * A single event pulled from an interactive AI session. + * + * Discriminated union on `type`: + * - `thinking` / `text`: incremental agent output (data is a string). + * - `question`: the agent paused awaiting structured input; the session is + * now in awaiting-input until {@link InteractiveAiSession.answer} is called. + * `data` is a {@link PlanningQuestion} (reused for protocol parity). + * - `complete`: the agent finished; `data` is the final payload (shape is + * defined by the caller's protocol — opaque to the seam). + * - `error`: an agent/session/parse error; `data` carries a human-readable + * message and optional error detail. The caller is never left hanging. + */ +export type InteractiveAiSessionEvent = + | { type: "thinking"; data: string } + | { type: "text"; data: string } + | { type: "question"; data: PlanningQuestion } + | { type: "complete"; data: unknown } + | { type: "error"; data: { message: string; cause?: unknown } }; + +/** + * An interactive, multi-turn AI session. + * + * Event delivery is **pull-based**: the caller awaits {@link nextEvent} to get + * the next event. `nextEvent()` resolves once the session has produced an + * event for the most recent `prompt`/`answer`. A `question` event leaves the + * session in awaiting-input; the caller must call {@link answer} (not + * {@link prompt}) to resume. After a `complete` or `error` event the session + * is terminal and `nextEvent()` will keep returning that terminal event. + * + * (Pull-based `nextEvent()` is chosen over an async iterator because it is the + * simpler shape to drive deterministically from a route/test: each turn is one + * `prompt`/`answer` followed by one awaited `nextEvent`.) + */ +export interface InteractiveAiSession { + /** Send a free-text turn to the agent (the opening turn, or follow-up text). */ + prompt(text: string): Promise; + /** Pull the next event produced by the most recent prompt/answer. */ + nextEvent(): Promise; + /** Answer the currently-awaiting question, resuming the agent. */ + answer(questionId: string, response: unknown): Promise; + /** Release the underlying agent/session handles. Safe to call repeatedly. */ + dispose(): void; +} + +/** + * Result returned from creating an interactive AI session. + */ +export interface CreateInteractiveAiSessionResult { + /** The interactive session handle. */ + session: InteractiveAiSession; + /** Path to persisted session file, if any. */ + sessionFile?: string; +} + +/** + * Engine-injected factory for plugin interactive AI sessions. + */ +export type CreateInteractiveAiSessionFactory = ( + options: CreateInteractiveAiSessionOptions, +) => Promise; + /** * Context object passed to plugins at runtime. * Contains task store access, settings, logging, and event emission. @@ -137,6 +231,12 @@ export interface PluginContext { emitEvent: (event: string, data: unknown) => void; /** Engine-injected AI session factory (undefined when engine is not loaded) */ createAiSession?: CreateAiSessionFactory; + /** + * Engine-injected interactive (multi-turn, await-input) AI session factory. + * Undefined when the engine is not loaded or on non-route contexts (parity + * with `createAiSession`). + */ + createInteractiveAiSession?: CreateInteractiveAiSessionFactory; /** Optional host capability to resolve a project-scoped TaskStore by projectId. */ resolveProjectTaskStore?: (projectId: string) => Promise; } diff --git a/packages/engine/src/__tests__/interactive-ai-session.test.ts b/packages/engine/src/__tests__/interactive-ai-session.test.ts new file mode 100644 index 0000000000..af876cfba7 --- /dev/null +++ b/packages/engine/src/__tests__/interactive-ai-session.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PlanningQuestion, PlanningResponse } from "@fusion/core"; +import { + createInteractiveAiSessionWith, + type InteractiveAgentResult, + type InteractiveAgentSession, +} from "../interactive-ai-session.js"; + +/** + * A scripted fake agent: each `prompt()` advances through a queue of canned + * assistant responses, which are exposed via `state.messages` exactly like the + * real one-shot agent. This deterministically drives the seam's turn loop + * without a live model (the accepted integration approach per the plan). + */ +function makeScriptedAgent(responses: string[]): { + session: InteractiveAgentSession; + disposed: () => boolean; + promptCalls: () => string[]; +} { + let index = 0; + let wasDisposed = false; + const prompts: string[] = []; + const messages: InteractiveAgentSession["state"]["messages"] = []; + + const session: InteractiveAgentSession = { + prompt: vi.fn(async (text: string) => { + prompts.push(text); + const reply = responses[index] ?? responses[responses.length - 1]; + index++; + messages.push({ role: "assistant", content: reply }); + }), + state: { messages }, + dispose: vi.fn(() => { + wasDisposed = true; + }), + }; + + return { session, disposed: () => wasDisposed, promptCalls: () => prompts }; +} + +function factoryFor(agent: InteractiveAgentSession): () => Promise { + return async () => ({ session: agent, sessionFile: "/tmp/fake-session.json" }); +} + +const q = (data: PlanningQuestion): string => JSON.stringify({ type: "question", data } satisfies PlanningResponse); +const complete = (data: unknown): string => JSON.stringify({ type: "complete", data }); + +describe("interactive-ai-session seam", () => { + it("round-trips question → answer → complete (happy path)", async () => { + const question: PlanningQuestion = { + id: "q1", + type: "text", + question: "What is the goal?", + }; + const scripted = makeScriptedAgent([ + q(question), + complete({ title: "Done", summary: "ok" }), + ]); + + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "emit json protocol", + }); + + 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 the thing"); + const ev2 = await session.nextEvent(); + expect(ev2.type).toBe("complete"); + expect(ev2.type === "complete" && ev2.data).toEqual({ title: "Done", summary: "ok" }); + + // nextEvent stays terminal after complete. + expect((await session.nextEvent()).type).toBe("complete"); + + session.dispose(); + expect(scripted.disposed()).toBe(true); + }); + + it.each([ + ["text", { id: "t", type: "text", question: "Free text?" } as PlanningQuestion, "a free answer"], + [ + "single_select", + { + id: "s", + type: "single_select", + question: "Pick one", + options: [{ id: "a", label: "A" }, { id: "b", label: "B" }], + } as PlanningQuestion, + "a", + ], + [ + "multi_select", + { + id: "m", + type: "multi_select", + question: "Pick many", + options: [{ id: "x", label: "X" }, { id: "y", label: "Y" }], + } as PlanningQuestion, + ["x", "y"], + ], + ["confirm", { id: "c", type: "confirm", question: "Sure?" } as PlanningQuestion, true], + ])("round-trips %s question type", async (_name, question, answer) => { + const scripted = makeScriptedAgent([q(question), complete({ ok: true })]); + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await session.prompt("start"); + const ev = await session.nextEvent(); + expect(ev.type).toBe("question"); + expect(ev.type === "question" && ev.data.type).toBe(question.type); + + await session.answer(question.id, answer); + const done = await session.nextEvent(); + expect(done.type).toBe("complete"); + + // The structured answer is forwarded to the agent as JSON. + const lastPrompt = scripted.promptCalls().at(-1)!; + expect(JSON.parse(lastPrompt)).toMatchObject({ type: "answer", questionId: question.id, response: answer }); + }); + + it("retries once on unparseable output then surfaces an error event (no hang)", async () => { + // First turn: garbage. Reformat retry: still garbage. → error. + const scripted = makeScriptedAgent(["not json at all", "still not json"]); + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await session.prompt("start"); + const ev = await session.nextEvent(); + expect(ev.type).toBe("error"); + expect(ev.type === "error" && ev.data.message).toMatch(/parse/i); + + // The reformat-retry prompt was actually sent (2 prompts: initial + retry). + expect(scripted.promptCalls().length).toBe(2); + + // Terminal: nextEvent keeps returning the error, never hangs. + expect((await session.nextEvent()).type).toBe("error"); + }); + + it("recovers when the reformat retry produces valid JSON", async () => { + const question: PlanningQuestion = { id: "q1", type: "text", question: "?" }; + const scripted = makeScriptedAgent(["garbage", q(question)]); + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await session.prompt("start"); + const ev = await session.nextEvent(); + expect(ev.type).toBe("question"); + }); + + it("surfaces agent prompt errors as an error event without throwing", async () => { + const throwing: InteractiveAgentSession = { + prompt: vi.fn(async () => { + throw new Error("transport exploded"); + }), + state: { messages: [] }, + dispose: vi.fn(), + }; + const { session } = await createInteractiveAiSessionWith(factoryFor(throwing), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await expect(session.prompt("start")).resolves.toBeUndefined(); + const ev = await session.nextEvent(); + expect(ev.type).toBe("error"); + expect(ev.type === "error" && ev.data.message).toMatch(/transport exploded/); + }); + + it("ignores answer() when not awaiting input", async () => { + const scripted = makeScriptedAgent([complete({ ok: true })]); + const { session } = await createInteractiveAiSessionWith(factoryFor(scripted.session), { + cwd: "/tmp", + systemPrompt: "protocol", + }); + + await session.prompt("start"); + expect((await session.nextEvent()).type).toBe("complete"); + + // answer() after terminal is a no-op; nextEvent stays complete. + await session.answer("whatever", "x"); + expect((await session.nextEvent()).type).toBe("complete"); + }); +}); diff --git a/packages/engine/src/__tests__/plugin-runner.test.ts b/packages/engine/src/__tests__/plugin-runner.test.ts index e23b0aff3e..9b45922a8c 100644 --- a/packages/engine/src/__tests__/plugin-runner.test.ts +++ b/packages/engine/src/__tests__/plugin-runner.test.ts @@ -1603,4 +1603,25 @@ describe("PluginRunner", () => { expect(true).toBe(true); // Handler exists and doesn't throw }); }); + + describe("interactive AI session injection boundary", () => { + it("does NOT expose createInteractiveAiSession on runtime contexts (parity with createAiSession)", async () => { + // Register a factory the way the engine module-load block would. + const core = await import("@fusion/core"); + core.setCreateInteractiveAiSessionFactory( + async () => ({ session: {} as never }), + ); + try { + mockPluginLoader.getPlugin.mockReturnValue(createMockPlugin({ state: "started" })); + const ctx = await pluginRunner.createRuntimeContext("test-plugin"); + expect(ctx).not.toBeNull(); + // Tool/runtime contexts must not receive the interactive factory, + // exactly as they do not receive createAiSession. + expect(ctx?.createAiSession).toBeUndefined(); + expect(ctx?.createInteractiveAiSession).toBeUndefined(); + } finally { + core.setCreateInteractiveAiSessionFactory(undefined); + } + }); + }); }); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 71bed8fbb2..377b06d9ae 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -112,12 +112,26 @@ export { } from "./merger-squash-audit.js"; export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js"; export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js"; +export { + createInteractiveAiSessionWith, + parseAgentResponse as parseInteractiveAgentResponse, + type InteractiveAgentSession, + type InteractiveAgentResult, + type InteractiveAgentFactory, +} from "./interactive-ai-session.js"; // Register createFnAgent into core's loader so consumers in @fusion/core // (e.g. ai-summarize, memory-compaction) can resolve it without a circular // static import. Runs once at engine module load. -import type { AiSessionResult, CreateAiSessionFactory, CreateAiSessionOptions } from "@fusion/core"; +import type { + AiSessionResult, + CreateAiSessionFactory, + CreateAiSessionOptions, + CreateInteractiveAiSessionFactory, + CreateInteractiveAiSessionOptions, +} from "@fusion/core"; import { createFnAgent as _createFnAgentForCore } from "./pi.js"; +import { createInteractiveAiSessionWith } from "./interactive-ai-session.js"; const _createAiSessionAdapter: CreateAiSessionFactory = async (options: CreateAiSessionOptions): Promise => { return _createFnAgentForCore({ @@ -129,6 +143,23 @@ 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. +const _createInteractiveAiSessionAdapter: CreateInteractiveAiSessionFactory = ( + options: CreateInteractiveAiSessionOptions, +) => + createInteractiveAiSessionWith( + (opts) => + _createFnAgentForCore({ + cwd: opts.cwd, + systemPrompt: opts.systemPrompt, + tools: opts.tools, + defaultProvider: opts.defaultProvider, + defaultModelId: opts.defaultModelId, + }), + options, + ); + void import("@fusion/core") .then((core) => { if ("setCreateFnAgent" in core && typeof core.setCreateFnAgent === "function") { @@ -137,6 +168,9 @@ void import("@fusion/core") if ("setCreateAiSessionFactory" in core && typeof core.setCreateAiSessionFactory === "function") { core.setCreateAiSessionFactory(_createAiSessionAdapter); } + if ("setCreateInteractiveAiSessionFactory" in core && typeof core.setCreateInteractiveAiSessionFactory === "function") { + core.setCreateInteractiveAiSessionFactory(_createInteractiveAiSessionAdapter); + } }) .catch(() => { // Ignore loader registration failures in constrained test/mocked environments. diff --git a/packages/engine/src/interactive-ai-session.ts b/packages/engine/src/interactive-ai-session.ts new file mode 100644 index 0000000000..f175309b76 --- /dev/null +++ b/packages/engine/src/interactive-ai-session.ts @@ -0,0 +1,349 @@ +/** + * 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"; + +/** Minimal shape of an agent session we depend on (subset of pi's AgentSession). */ +export interface InteractiveAgentSession { + prompt(text: string): Promise; + state: { + messages: Array<{ + role: string; + content?: string | Array<{ type: string; text?: string; thinking?: string }>; + }>; + }; + dispose?: () => void | Promise; +} + +/** 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; + +/** 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."); +} + +/** 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 { + const agentResult = await agentFactory(options); + const agent = agentResult.session; + + let state: LoopState = "idle"; + let pendingEvent: Promise | 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 { + 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 { + 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 { + 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 { + if (terminalEvent) return; + if (state !== "awaiting_input") { + pendingEvent = Promise.resolve({ + type: "error", + data: { message: "answer() called while not awaiting input." }, + }); + return; + } + if (currentQuestion && questionId !== currentQuestion.id) { + pendingEvent = Promise.resolve({ + 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 }; +}