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:
105
packages/core/src/__tests__/interactive-ai-session-seam.test.ts
Normal file
105
packages/core/src/__tests__/interactive-ai-session-seam.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`) });
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
192
packages/engine/src/__tests__/interactive-ai-session.test.ts
Normal file
192
packages/engine/src/__tests__/interactive-ai-session.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
349
packages/engine/src/interactive-ai-session.ts
Normal file
349
packages/engine/src/interactive-ai-session.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user