diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/fixtures/echo-agent.mjs b/plugins/fusion-plugin-acp-runtime/src/__tests__/fixtures/echo-agent.mjs index 260c806402..cbf1fa17c4 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/fixtures/echo-agent.mjs +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/fixtures/echo-agent.mjs @@ -21,6 +21,9 @@ class EchoAgent { constructor(connection) { this.connection = connection; this.sessions = new Map(); + // Resolver for the in-flight prompt when ACP_FIXTURE_HANG_PROMPT is set: + // the turn stays open until cancel() fires, then resolves "cancelled". + this._cancelTurn = undefined; } async initialize(_params) { @@ -38,7 +41,7 @@ class EchoAgent { versionOverride !== undefined ? Number(versionOverride) : PROTOCOL_VERSION; const response = { protocolVersion, - agentCapabilities: { loadSession: false }, + agentCapabilities: { loadSession: process.env.ACP_FIXTURE_LOAD_SESSION === "1" }, }; if (process.env.ACP_FIXTURE_REQUIRE_AUTH === "1") { response.authMethods = [{ id: "api-key", name: "API Key", description: null }]; @@ -58,6 +61,13 @@ class EchoAgent { return { sessionId }; } + async loadSession(params) { + // Resume path: acknowledge the existing session id (history replay would + // happen here in a real agent). Mark that this session was loaded, not new. + this.sessions.set(params.sessionId, { loaded: true }); + return {}; + } + async setSessionMode(_params) { return {}; } @@ -70,11 +80,23 @@ class EchoAgent { content: { type: "text", text: "echo: hello" }, }, }); + // Cancel-mid-prompt test: keep the turn open until cancel() arrives, then + // resolve with the "cancelled" stop reason (mirrors a real agent). + if (process.env.ACP_FIXTURE_HANG_PROMPT === "1") { + return await new Promise((resolve) => { + this._cancelTurn = () => resolve({ stopReason: "cancelled" }); + }); + } return { stopReason: "end_turn" }; } async cancel(_params) { - // no-op for the trivial turn + // Release any in-flight hung turn with a "cancelled" stop reason. + if (this._cancelTurn) { + const release = this._cancelTurn; + this._cancelTurn = undefined; + release(); + } } } diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts index 82beb63767..2b47eb8937 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/index.test.ts @@ -1,8 +1,12 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import plugin, { AcpRuntimeAdapter, acpRuntimeFactory, acpRuntimeMetadata, resolveCliSettings } from "../index.js"; -import { ACP_NOT_IMPLEMENTED } from "../runtime-adapter.js"; +import { killAllProcesses } from "../process-manager.js"; import type { AgentRuntime } from "../types.js"; +afterEach(() => { + killAllProcesses(); +}); + describe("fusion-plugin-acp-runtime", () => { it("declares the acp runtime in its manifest", () => { expect(plugin.manifest.id).toBe("fusion-plugin-acp-runtime"); @@ -27,12 +31,21 @@ describe("fusion-plugin-acp-runtime", () => { expect(desc).toBe("acp/gemini-2.0"); }); - it("session-driving stubs reject with the not-implemented marker (until U2/U3)", async () => { - const runtime = new AcpRuntimeAdapter({}); + it("createSession against a non-spawnable binary rejects (ENOENT), no orphan", async () => { + const runtime = new AcpRuntimeAdapter({ + acpBinaryPath: "/nonexistent/acp-agent-does-not-exist", + acpArgs: [], + }); await expect( - runtime.createSession({ cwd: "/tmp", systemPrompt: "" } as never), - ).rejects.toThrow(ACP_NOT_IMPLEMENTED); - await expect(runtime.promptWithFallback({} as never, "hi")).rejects.toThrow(ACP_NOT_IMPLEMENTED); + runtime.createSession({ cwd: process.cwd(), systemPrompt: "" } as never), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("promptWithFallback on a session with no live connection rejects cleanly", async () => { + const runtime = new AcpRuntimeAdapter({}); + await expect(runtime.promptWithFallback({ sessionId: "x" } as never, "hi")).rejects.toThrow( + /no live connection/, + ); }); }); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/prompt-builder.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/prompt-builder.test.ts new file mode 100644 index 0000000000..92feebf9a4 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/prompt-builder.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { buildPromptBlocks } from "../prompt-builder.js"; + +describe("buildPromptBlocks", () => { + it("turns a plain string into a single text block", () => { + const blocks = buildPromptBlocks("hello world"); + expect(blocks).toEqual([{ type: "text", text: "hello world" }]); + }); + + it("emits no text block for an empty string", () => { + expect(buildPromptBlocks("")).toEqual([]); + }); + + it("appends image blocks after the text block", () => { + const blocks = buildPromptBlocks("describe this", { + images: [{ data: "AAAA", mimeType: "image/png", uri: "file:///a.png" }], + }); + expect(blocks).toEqual([ + { type: "text", text: "describe this" }, + { type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///a.png" }, + ]); + }); + + it("omits the uri field when not provided on an image", () => { + const blocks = buildPromptBlocks("", { + images: [{ data: "BBBB", mimeType: "image/jpeg" }], + }); + expect(blocks).toEqual([{ type: "image", data: "BBBB", mimeType: "image/jpeg" }]); + expect(blocks[0]).not.toHaveProperty("uri"); + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts new file mode 100644 index 0000000000..90364e053e --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { fileURLToPath } from "node:url"; +import { + connect, + newAcpSession, + promptAcpSession, + cancelAcpSession, + loadAcpSession, + type AcpConnection, +} from "../provider.js"; +import { buildPromptBlocks } from "../prompt-builder.js"; +import { killAllProcesses } from "../process-manager.js"; + +const FIXTURE = fileURLToPath(new URL("./fixtures/echo-agent.mjs", import.meta.url)); + +afterEach(() => { + killAllProcesses(); +}); + +function baseOpts(extraEnv: Record = {}) { + return { + binaryPath: process.execPath, + args: [FIXTURE], + cwd: process.cwd(), + env: extraEnv as NodeJS.ProcessEnv, + advertiseFs: { read: false, write: false }, + initializeTimeoutMs: 10_000, + }; +} + +async function open(extraEnv: Record = {}): Promise { + return connect(baseOpts(extraEnv)); +} + +describe("session driving helpers", () => { + it("newAcpSession opens a session and returns a sessionId", async () => { + const conn = await open(); + try { + const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() }); + expect(typeof sessionId).toBe("string"); + expect(sessionId.length).toBeGreaterThan(0); + } finally { + conn.dispose(); + } + }); + + it("promptAcpSession resolves with end_turn for a normal turn", async () => { + const conn = await open(); + try { + const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() }); + const stopReason = await promptAcpSession(conn, sessionId, buildPromptBlocks("hello")); + expect(stopReason).toBe("end_turn"); + } finally { + conn.dispose(); + } + }); + + it("cancelAcpSession releases a mid-turn prompt with the cancelled stop reason", async () => { + const conn = await open({ ACP_FIXTURE_HANG_PROMPT: "1" }); + try { + const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() }); + const promptPromise = promptAcpSession(conn, sessionId, buildPromptBlocks("hello")); + // Give the turn a tick to register the hang before cancelling. + await new Promise((r) => setImmediate(r)); + await cancelAcpSession(conn, sessionId); + const stopReason = await promptPromise; + expect(stopReason).toBe("cancelled"); + } finally { + conn.dispose(); + } + }); + + it("cancelAcpSession swallows errors (fire-and-forget)", async () => { + const conn = await open(); + try { + const { sessionId } = await newAcpSession(conn, { cwd: process.cwd() }); + conn.dispose(); // kill the child so cancel cannot round-trip + await expect(cancelAcpSession(conn, sessionId)).resolves.toBeUndefined(); + } finally { + conn.dispose(); + } + }); + + it("loadAcpSession uses session/load when the agent advertises loadSession", async () => { + const conn = await open({ ACP_FIXTURE_LOAD_SESSION: "1" }); + try { + expect(conn.agentCapabilities).toMatchObject({ loadSession: true }); + const result = await loadAcpSession(conn, { + sessionId: "prior-session-id", + cwd: process.cwd(), + }); + // session/load echoes back the requested id (no fresh id minted). + expect(result.sessionId).toBe("prior-session-id"); + } finally { + conn.dispose(); + } + }); + + it("loadAcpSession falls back to newSession when loadSession is not advertised", async () => { + const conn = await open(); // loadSession defaults false + try { + expect(conn.agentCapabilities).toMatchObject({ loadSession: false }); + const result = await loadAcpSession(conn, { + sessionId: "prior-session-id", + cwd: process.cwd(), + }); + // Fresh session: a new id is minted, not the prior one. + expect(result.sessionId).not.toBe("prior-session-id"); + expect(result.sessionId.length).toBeGreaterThan(0); + } finally { + conn.dispose(); + } + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts new file mode 100644 index 0000000000..a7e3f99ed5 --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, afterEach } from "vitest"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; +import { AcpRuntimeAdapter } from "../runtime-adapter.js"; +import { killAllProcesses, activeProcessCount } from "../process-manager.js"; +import type { AcpSession, AgentRuntimeOptions } from "../types.js"; + +const FIXTURE = fileURLToPath(new URL("./fixtures/echo-agent.mjs", import.meta.url)); + +afterEach(() => { + killAllProcesses(); +}); + +function makeAdapter(extra: Record = {}) { + return new AcpRuntimeAdapter({ + acpBinaryPath: process.execPath, + acpArgs: [FIXTURE], + acpModel: "echo-agent", + ...extra, + }); +} + +function makeOptions(over: Partial = {}): AgentRuntimeOptions { + return { + cwd: process.cwd(), + systemPrompt: "be helpful", + ...over, + }; +} + +describe("AcpRuntimeAdapter (U3)", () => { + it("createSession spawns + opens a session with a real sessionId", async () => { + const adapter = makeAdapter(); + const { session } = await adapter.createSession(makeOptions()); + try { + expect(session.sessionId.length).toBeGreaterThan(0); + expect((session as AcpSession).connection).toBeDefined(); + expect(session.lastModelDescription).toBe("acp/echo-agent"); + } finally { + await adapter.dispose(session); + } + }); + + it("createSession persists actionGateContext and cwd on the session", async () => { + const adapter = makeAdapter(); + const gate = { permissionPolicy: { preset: "unrestricted" } }; + // cwd must be a real, spawnable directory (it is the subprocess cwd too). + const cwd = os.tmpdir(); + const { session } = await adapter.createSession( + makeOptions({ cwd, actionGateContext: gate }), + ); + try { + // Both reachable from the session object for the U5/U7 handlers to read. + expect((session as AcpSession).gate).toBe(gate); + expect(session.cwd).toBe(cwd); + } finally { + await adapter.dispose(session); + } + }); + + it("promptWithFallback drives a full turn to completion", async () => { + const adapter = makeAdapter(); + const { session } = await adapter.createSession(makeOptions()); + try { + await expect(adapter.promptWithFallback(session, "hello")).resolves.toBeUndefined(); + } finally { + await adapter.dispose(session); + } + }); + + it("dispose tears down the subprocess and is idempotent", async () => { + const adapter = makeAdapter(); + const { session } = await adapter.createSession(makeOptions()); + expect(activeProcessCount()).toBe(1); + await adapter.dispose(session); + expect(activeProcessCount()).toBe(0); + // second dispose must not throw + await expect(adapter.dispose(session)).resolves.toBeUndefined(); + expect(activeProcessCount()).toBe(0); + }); + + it("promptWithFallback rejects when the session has no live connection", async () => { + const adapter = makeAdapter(); + await expect( + adapter.promptWithFallback({ sessionId: "x" } as never, "hi"), + ).rejects.toThrow(/no live connection/); + }); + + it("describeModel returns the session model description", async () => { + const adapter = makeAdapter(); + const { session } = await adapter.createSession(makeOptions()); + try { + expect(adapter.describeModel(session)).toBe("acp/echo-agent"); + } finally { + await adapter.dispose(session); + } + }); +}); diff --git a/plugins/fusion-plugin-acp-runtime/src/prompt-builder.ts b/plugins/fusion-plugin-acp-runtime/src/prompt-builder.ts new file mode 100644 index 0000000000..0848acfdac --- /dev/null +++ b/plugins/fusion-plugin-acp-runtime/src/prompt-builder.ts @@ -0,0 +1,49 @@ +// Builds ACP `ContentBlock[]` from a Fusion prompt. +// +// U3 core path: a plain string prompt becomes a single `{ type: "text", text }` +// block. The runtime may later pass structured content (e.g. an attached image); +// when present we emit the matching block. Keep this small and pure. + +import type { ContentBlock } from "@agentclientprotocol/sdk"; + +/** Optional structured content the runtime may attach alongside the text prompt. */ +export interface PromptImage { + /** Base64-encoded image data (no data: prefix). */ + data: string; + /** MIME type, e.g. "image/png". */ + mimeType: string; + /** Optional source URI for the image. */ + uri?: string; +} + +export interface BuildPromptOptions { + /** Image content to append as image block(s) after the text. */ + images?: PromptImage[]; +} + +/** + * Build the ACP prompt content blocks for a turn. + * + * A non-empty string yields one text block. An empty/whitespace-only string + * yields no text block (but any attached images are still included), so we never + * send a meaningless empty text block. Images, when supplied, are appended as + * `image` blocks (passthrough — KTD ContentBlock image variant). + */ +export function buildPromptBlocks(prompt: string, opts?: BuildPromptOptions): ContentBlock[] { + const blocks: ContentBlock[] = []; + + if (typeof prompt === "string" && prompt.length > 0) { + blocks.push({ type: "text", text: prompt }); + } + + for (const image of opts?.images ?? []) { + blocks.push({ + type: "image", + data: image.data, + mimeType: image.mimeType, + ...(image.uri ? { uri: image.uri } : {}), + }); + } + + return blocks; +} diff --git a/plugins/fusion-plugin-acp-runtime/src/provider.ts b/plugins/fusion-plugin-acp-runtime/src/provider.ts index 9d68f403e9..10020d4306 100644 --- a/plugins/fusion-plugin-acp-runtime/src/provider.ts +++ b/plugins/fusion-plugin-acp-runtime/src/provider.ts @@ -19,6 +19,8 @@ import { PROTOCOL_VERSION, type Agent, type Client, + type ContentBlock, + type StopReason, } from "@agentclientprotocol/sdk"; import { spawnAgent, captureStderr, forceKill, unregisterProcess } from "./process-manager.js"; @@ -198,3 +200,92 @@ export async function connect(opts: ConnectOptions): Promise { dispose, }; } + +// --- U3: session driving on top of connect() ------------------------------- +// +// These helpers wrap the `ClientSideConnection` session methods so the runtime +// adapter drives one shape (open → prompt → cancel/resume) without touching SDK +// types directly. v1 always sends an empty `mcpServers` (KTD5). + +/** Narrow view of the agent capabilities we read for resume routing. */ +interface AgentCapabilitiesView { + loadSession?: boolean; +} + +function readsLoadSession(connection: AcpConnection): boolean { + const caps = connection.agentCapabilities as AgentCapabilitiesView | undefined; + return caps?.loadSession === true; +} + +export interface NewAcpSessionResult { + sessionId: string; + /** Initial session mode state, when the agent reports one. */ + modes?: unknown; +} + +/** + * Open a fresh ACP session via `session/new`. Always passes an empty + * `mcpServers` (KTD5 — Fusion custom-tool forwarding is deferred). + */ +export async function newAcpSession( + connection: AcpConnection, + opts: { cwd: string }, +): Promise { + const res = await connection.conn.newSession({ cwd: opts.cwd, mcpServers: [] }); + return { sessionId: res.sessionId, modes: res.modes ?? undefined }; +} + +/** + * Send a prompt turn via `session/prompt` and return the terminal `stopReason`. + * + * The SDK prompt promise resolves only AFTER every `session/update` for the turn + * has been delivered to the client handler — so resolving here is the correct + * "turn complete" signal (no extra draining required). + */ +export async function promptAcpSession( + connection: AcpConnection, + sessionId: string, + blocks: ContentBlock[], +): Promise { + const res = await connection.conn.prompt({ sessionId, prompt: blocks }); + return res.stopReason; +} + +/** + * Best-effort cancel of the active turn via the `session/cancel` notification. + * + * This is fire-and-forget (no ack in the protocol). Errors are swallowed — it + * runs during teardown where the registry SIGKILL is the authoritative guarantee + * (KTD4a). + */ +export async function cancelAcpSession( + connection: AcpConnection, + sessionId: string, +): Promise { + try { + await connection.conn.cancel({ sessionId }); + } catch { + // fire-and-forget; teardown's SIGKILL is authoritative + } +} + +/** + * Resume a session. Prefers `session/load` (history replay) when the agent + * advertised the `loadSession` capability; otherwise falls back to opening a + * fresh `session/new`. There is no separate `resume` method in this SDK build — + * `loadSession` IS the resume path. + */ +export async function loadAcpSession( + connection: AcpConnection, + opts: { sessionId: string; cwd: string }, +): Promise { + if (readsLoadSession(connection)) { + const res = await connection.conn.loadSession({ + sessionId: opts.sessionId, + cwd: opts.cwd, + mcpServers: [], + }); + return { sessionId: opts.sessionId, modes: res.modes ?? undefined }; + } + return newAcpSession(connection, { cwd: opts.cwd }); +} diff --git a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts index 57d1b833b9..8bc2c951bd 100644 --- a/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts @@ -1,12 +1,22 @@ // AgentRuntime adapter for the ACP runtime. // -// U1 scaffold: implements the full `AgentRuntime` contract shape (including the -// required `describeModel`) with stubs that throw `not_implemented` until the -// session driver lands in U2/U3. The skeleton exists so the plugin loads, -// registers as `runtimeId: "acp"`, and conforms to the interface the engine -// resolves via `getRuntimeById`. +// U3 implements the real session lifecycle: createSession spawns + handshakes +// (U2 connect()) then opens a `session/new`; promptWithFallback drives one +// prompt turn to its terminal stopReason; dispose tears down the connection +// (KTD4a — registry SIGKILL is authoritative). The `session/update` event +// bridge (U4) and the permission gate (U5) are wired in later units; for U3 the +// default client handler from U2 is used and a turn still resolves with a +// stopReason. import { resolveCliSettings, type AcpCliSettings } from "./cli-spawn.js"; +import { + connect, + newAcpSession, + promptAcpSession, + cancelAcpSession, +} from "./provider.js"; +import { buildSpawnEnv } from "./process-manager.js"; +import { buildPromptBlocks } from "./prompt-builder.js"; import type { AgentRuntime, AgentRuntimeOptions, @@ -15,6 +25,10 @@ import type { AcpSession, } from "./types.js"; +/** + * Retained for back-compat: earlier units' tests imported this marker. The real + * adapter no longer throws it; it remains exported so external references resolve. + */ export const ACP_NOT_IMPLEMENTED = "acp_not_implemented"; export class AcpRuntimeAdapter implements AgentRuntime { @@ -27,13 +41,35 @@ export class AcpRuntimeAdapter implements AgentRuntime { } async createSession(options: AgentRuntimeOptions): Promise { - // Session establishment (spawn + initialize + session/new) lands in U2/U3. - // The skeleton constructs the session shell so the contract is observable. const model = this.settings.model ?? options.defaultModelId ?? "acp"; + + // Spawn + initialize (U2). fs capabilities are advertised only where the + // resolved settings enable them (KTD6); the subprocess env is built from the + // allow-list, never inherited process.env (KTD6b). + const connection = await connect({ + binaryPath: this.settings.binaryPath, + args: this.settings.args, + cwd: options.cwd, + env: buildSpawnEnv(this.settings.envAllowList), + advertiseFs: { read: this.settings.fsRead, write: this.settings.fsWrite }, + }); + + // Open the ACP session over the task worktree (empty mcpServers — KTD5). + let sessionId: string; + try { + const opened = await newAcpSession(connection, { cwd: options.cwd }); + sessionId = opened.sessionId; + } catch (err) { + // Don't leak the subprocess if session/new fails after a good handshake. + connection.dispose(); + throw err; + } + + let disposed = false; const session: AcpSession = { model, systemPrompt: options.systemPrompt, - sessionId: "", + sessionId, cwd: options.cwd, lastModelDescription: `acp/${model}`, callbacks: { @@ -42,14 +78,34 @@ export class AcpRuntimeAdapter implements AgentRuntime { onToolStart: options.onToolStart, onToolEnd: options.onToolEnd, }, + // Persist the per-run gate (KTD3) so U5/U7 can reach the live action gate. gate: options.actionGateContext, - dispose: () => undefined, + connection, + dispose: () => { + if (disposed) return; + disposed = true; + connection.dispose(); + }, }; - throw new Error(`${ACP_NOT_IMPLEMENTED}: createSession lands in U2/U3 (session=${session.lastModelDescription})`); + + return { session }; } - async promptWithFallback(_session: AgentSession, _prompt: string, _options?: unknown): Promise { - throw new Error(`${ACP_NOT_IMPLEMENTED}: promptWithFallback lands in U3`); + async promptWithFallback( + session: AgentSession, + prompt: string, + _options?: unknown, + ): Promise { + const acp = session as AcpSession; + if (!acp.connection) { + throw new Error("ACP session has no live connection (createSession not completed)"); + } + const blocks = buildPromptBlocks(prompt); + // Resolve when the SDK prompt promise resolves — it already drains all + // session/update notifications for the turn before reporting the stopReason. + // TODO(U4): wire a bridging client handler so streamed text/tool updates + // surface onto session.callbacks; for U3 the turn simply completes. + await promptAcpSession(acp.connection, acp.sessionId, blocks); } describeModel(session: AgentSession): string { @@ -57,7 +113,13 @@ export class AcpRuntimeAdapter implements AgentRuntime { } async dispose(session: AgentSession): Promise { - // Best-effort teardown; the authoritative kill is the process registry (KTD4a). + // KTD4a teardown: best-effort cancel of any in-flight turn, then force the + // connection down. The process-registry SIGKILL is the authoritative + // no-orphan guarantee, not the cancel round-trip. Idempotent. + const acp = session as AcpSession; + if (acp.connection && acp.sessionId) { + await cancelAcpSession(acp.connection, acp.sessionId); + } session.dispose(); } } diff --git a/plugins/fusion-plugin-acp-runtime/src/types.ts b/plugins/fusion-plugin-acp-runtime/src/types.ts index 35d4976418..b84db53be6 100644 --- a/plugins/fusion-plugin-acp-runtime/src/types.ts +++ b/plugins/fusion-plugin-acp-runtime/src/types.ts @@ -10,6 +10,8 @@ // per-run permission gate — see `PermissionGate` below, the narrow structural // view this plugin couples to instead of importing `@fusion/engine` internals. +import type { AcpConnection } from "./provider.js"; + /** Callbacks the engine wires to surface streamed agent output into Fusion's UI/logs. */ export interface AcpCallbacks { onText?: (text: string) => void; @@ -65,6 +67,11 @@ export interface AcpSession { callbacks: AcpCallbacks; /** Per-run permission gate captured at createSession (U5/U7 read this). */ gate?: PermissionGate; + /** + * Live ACP connection backing this session (U3). Prompt/dispose reach the + * agent through it. Undefined only for the bare session shell used in tests. + */ + connection?: AcpConnection; dispose(): void; }