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).
This commit is contained in:
gsxdsm
2026-06-02 18:46:33 -07:00
parent 3cf13dd25b
commit 4dbd72d54f
9 changed files with 835 additions and 4 deletions

View File

@@ -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();
});
});

View File

@@ -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<CreateAiSessionFactory | undefined> {
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;
}

View File

@@ -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,

View File

@@ -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<Pick<PluginContext, "taskStore" | "settings" | "resolveProjectTaskStore">>,
): Promise<PluginContext> {
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}`) });

View File

@@ -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<AiSessionResult>;
// ── 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<void>;
/** Pull the next event produced by the most recent prompt/answer. */
nextEvent(): Promise<InteractiveAiSessionEvent>;
/** Answer the currently-awaiting question, resuming the agent. */
answer(questionId: string, response: unknown): Promise<void>;
/** 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<CreateInteractiveAiSessionResult>;
/**
* 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<TaskStore>;
}

View File

@@ -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<InteractiveAgentResult> {
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");
});
});

View File

@@ -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);
}
});
});
});

View File

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

View File

@@ -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<void>;
state: {
messages: Array<{
role: string;
content?: string | Array<{ type: string; text?: string; thinking?: string }>;
}>;
};
dispose?: () => void | Promise<void>;
}
/** 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<InteractiveAgentResult>;
/** 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<CreateInteractiveAiSessionResult> {
const agentResult = await agentFactory(options);
const agent = agentResult.session;
let state: LoopState = "idle";
let pendingEvent: Promise<InteractiveAiSessionEvent> | 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<InteractiveAiSessionEvent> {
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<void> {
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<InteractiveAiSessionEvent> {
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<void> {
if (terminalEvent) return;
if (state !== "awaiting_input") {
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
type: "error",
data: { message: "answer() called while not awaiting input." },
});
return;
}
if (currentQuestion && questionId !== currentQuestion.id) {
pendingEvent = Promise.resolve<InteractiveAiSessionEvent>({
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 };
}