fix: expose session.subscribe on ACP runtime sessions for engine workflow steps (#3501)

## Summary

Fixes the `session.subscribe is not a function` crash that breaks every
engine workflow step (Plan Review, Code Review) executed by an ACP agent
(Hermes ACP, Prime, Grok).

Introduced by the ACP custom-tools bridge (#3476) in the sense that it
made the gap reachable: ACP sessions stream through the bridging client
handler onto `callbacks` and never exposed the pi-style `subscribe()`
that the engine's AgentSession contract promises. Two production call
sites call it unconditionally:

- `packages/engine/src/executor/execute-workflow-step.ts` (workflow
steps — Plan Review / Code Review)
- `packages/engine/src/pi.ts` fallback wiring (`wireFallbackHooks`,
`promptableSession.subscribe`)

(`reviewer.ts` guards with `typeof session.subscribe === "function"`;
the other paths do not.)

Real-world symptom (verified on 0.77.0-beta.6 with a Hermes ACP agent):
every Plan Review attempt fails before producing a verdict:

```
[pre-merge] Workflow step failed: Plan Review
outcome: Plan Review failed before producing a verdict: session.subscribe is not a function
Plan Review provider retry budget exhausted
```

## Fix

Fix at the seam rather than guarding every call site forever:

- `AcpRuntimeAdapter.createSession` wraps the raw callbacks so each
forwarded text/thinking/tool event is **also** replayed to subscribers
as the pi-shaped event consumers parse (`message_update` +
`assistantMessageEvent.{text_delta,thinking_delta}`,
`tool_execution_start/end`)
- exposes `session.subscribe(handler)` returning an unsubscribe
function; dispose clears subscribers
- merges engine `taskEnv` into the subprocess env behind the existing
allow-list trust boundary (KTD6b preserved — only allow-listed keys
forward, task values win)
- original callback delivery unchanged; subscriber exceptions are
isolated so a faulty consumer can't break the streaming bridge

## Testing

- New regression tests in `runtime-adapter.test.ts` against the real
echo-agent fixture (`ACP_FIXTURE_RICH_PROMPT=1`):
  - subscribe replays text/thinking/tool events as pi-shaped events
  - unsubscribe stops delivery
  - dual delivery: original `onText` callback AND subscriber both fire
- Full plugin suite: 255 passed (21 files)
- `tsc --noEmit` clean for the plugin

- changeset for `@runfusion/fusion` (patch, bugfix)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added session event subscriptions with unsubscribe support.
* Streamed text, thinking, and tool updates are replayed in a consistent
event format.
* Added support for task-specific environment values when launching
subprocesses.

* **Bug Fixes**
* Ensured streamed events reach both existing callbacks and subscribers.
  * Isolated subscriber errors so they do not interrupt other handlers.
  * Session cleanup now stops further subscription notifications.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Timoteo
2026-08-21 23:30:31 -03:00
committed by GitHub
parent 41c23adf15
commit 47dd536e36
5 changed files with 211 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Expose `session.subscribe` on ACP runtime sessions so engine workflow steps work with ACP agents.
category: fix
dev: The engine's AgentSession contract (pi-coding-agent) exposes `subscribe(handler)`, and two production call sites call it unconditionally: `execute-workflow-step.ts` (Plan/Code Review steps) and `pi.ts` fallback wiring (`wireFallbackHooks`, `promptableSession.subscribe`). `reviewer.ts` guards with `typeof session.subscribe === "function"`, but the other paths do not. ACP sessions (Hermes/Prime/Grok via the generic ACP runtime) streamed through the bridging client handler onto `callbacks` instead, so any workflow step executed by an ACP agent crashed before producing a verdict with `session.subscribe is not a function`. The adapter now wraps the raw callbacks so every forwarded text/thinking/tool event is also replayed to subscribers as the pi-shaped event (`message_update` + `assistantMessageEvent.{text_delta,thinking_delta}`, `tool_execution_start/end`) consumers parse, exposes `session.subscribe(handler)` returning an unsubscribe function, and merges engine `taskEnv` into the subprocess env behind the existing allow-list trust boundary. Original callback delivery is unchanged; subscriber failures are isolated. Regression tests cover event replay, unsubscribe semantics, and dual delivery (callbacks + subscribers) against the real echo-agent fixture.

View File

@@ -114,6 +114,113 @@ describe("AcpRuntimeAdapter (U3)", () => {
).rejects.toThrow(/no live connection/);
});
/*
FNXC:AcpSubscribeCompat 2026-08-21-18:40:
Regression for "session.subscribe is not a function": the engine's
workflow-step path (execute-workflow-step.ts) and pi.ts wireFallback call
session.subscribe(...) unguarded. ACP sessions must expose a subscribe
adapter that replays bridged stream updates as pi-shaped events.
*/
it("exposes session.subscribe and replays streamed updates as pi-shaped events", async () => {
const adapter = makeAdapter({ acpEnvAllowList: ["ACP_FIXTURE_RICH_PROMPT"] });
const { session } = await adapter.createSession(
makeOptions({ taskEnv: { ACP_FIXTURE_RICH_PROMPT: "1" } } as never),
);
const events: Array<{ type: string; assistantMessageEvent?: { type: string; delta: string; contentIndex?: number }; toolName?: string }> = [];
const retained: Array<{ type: string }> = [];
const unsubscribe = session.subscribe((event: unknown) => events.push(event as { type: string }));
const retainedUnsub = session.subscribe((event: unknown) => retained.push(event as { type: string }));
try {
await expect(
adapter.promptWithFallback(session, "rich turn"),
).resolves.toEqual({ stopReason: "end_turn" });
const textDelta = events.find(
(e) => e.type === "message_update" && e.assistantMessageEvent?.type === "text_delta",
);
expect(textDelta?.assistantMessageEvent?.delta).toContain("Working on it");
// FNXC:AcpSubscribeCompat 2026-08-21-20:24: contentIndex is per block, not per delta.
expect(textDelta?.assistantMessageEvent?.contentIndex).toBe(0);
expect(events.filter((e) => e.assistantMessageEvent?.type === "text_delta").every((e) => e.assistantMessageEvent?.contentIndex === 0)).toBe(true);
const thinkingDelta = events.find(
(e) => e.type === "message_update" && e.assistantMessageEvent?.type === "thinking_delta",
);
expect(thinkingDelta).toBeDefined();
expect(thinkingDelta?.assistantMessageEvent?.contentIndex).toBe(1);
expect(events.filter((e) => e.assistantMessageEvent?.type === "thinking_delta").every((e) => e.assistantMessageEvent?.contentIndex === 1)).toBe(true);
const toolStart = events.find((e) => e.type === "tool_execution_start");
expect(toolStart?.toolName).toBeTruthy();
const toolEnd = events.find(
(e) => e.type === "tool_execution_end" && e.toolName === (toolStart?.toolName),
);
expect(toolEnd).toBeDefined();
// Handler-specific unsubscription: only the unsubscribed handler stops.
const countAfterFirst = events.length;
const retainedBeforeSecond = retained.length;
unsubscribe();
await expect(adapter.promptWithFallback(session, "second rich turn")).resolves.toEqual({
stopReason: "end_turn",
});
expect(events.length).toBe(countAfterFirst);
expect(retained.length).toBeGreaterThan(retainedBeforeSecond);
} finally {
retainedUnsub();
await adapter.dispose(session);
}
});
it("keeps original callbacks firing alongside subscriber replay", async () => {
const onTextChunks: string[] = [];
const onThinkingChunks: string[] = [];
const toolStarts: Array<{ name: string; args?: unknown }> = [];
const toolEnds: Array<{ name: string; isError: boolean }> = [];
const adapter = makeAdapter({ acpEnvAllowList: ["ACP_FIXTURE_RICH_PROMPT"] });
const { session } = await adapter.createSession(
makeOptions({
onText: (t: string) => onTextChunks.push(t),
onThinking: (t: string) => onThinkingChunks.push(t),
onToolStart: (n: string, a?: unknown) => toolStarts.push({ name: n, args: a }),
onToolEnd: (n: string, e: boolean) => toolEnds.push({ name: n, isError: e }),
taskEnv: { ACP_FIXTURE_RICH_PROMPT: "1" },
} as never),
);
const seenText: string[] = [];
const seenThinking: string[] = [];
const seenStarts: string[] = [];
const seenEnds: string[] = [];
session.subscribe((event: unknown) => {
const e = event as { type: string; assistantMessageEvent?: { type: string; delta: string }; toolName?: string };
if (e.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") {
seenText.push(e.assistantMessageEvent.delta);
} else if (e.type === "message_update" && e.assistantMessageEvent?.type === "thinking_delta") {
seenThinking.push(e.assistantMessageEvent.delta);
} else if (e.type === "tool_execution_start") {
seenStarts.push(e.toolName ?? "");
} else if (e.type === "tool_execution_end") {
seenEnds.push(e.toolName ?? "");
}
});
try {
await adapter.promptWithFallback(session, "dual delivery");
expect(onTextChunks.join("")).toContain("Working on it");
expect(seenText.join("")).toContain("Working on it");
expect(onThinkingChunks.join("")).toContain("Let me think");
expect(seenThinking.join("")).toContain("Let me think");
expect(toolStarts).toHaveLength(1);
expect(seenStarts).toHaveLength(1);
expect(seenStarts[0]).toBe(toolStarts[0].name);
expect(toolEnds).toHaveLength(1);
expect(seenEnds).toHaveLength(1);
expect(seenEnds[0]).toBe(toolEnds[0].name);
} finally {
await adapter.dispose(session);
}
});
it("describeModel returns the session model description", async () => {
const adapter = makeAdapter();
const { session } = await adapter.createSession(makeOptions());

View File

@@ -9,6 +9,7 @@
// stopReason.
import { resolveCliSettings, type AcpCliSettings } from "./cli-spawn.js";
import type { AcpCallbacks } from "./types.js";
import {
connect,
newAcpSession,
@@ -48,6 +49,65 @@ export class AcpRuntimeAdapter implements AgentRuntime {
onToolEnd: options.onToolEnd,
};
/*
FNXC:AcpSubscribeCompat 2026-08-21-18:40:
The engine's AgentSession contract (pi-coding-agent) exposes
`subscribe(handler)` and several production call sites call it
unconditionally — executor/execute-workflow-step.ts and pi.ts fallback
wiring do not use the `typeof === "function"` guard that reviewer.ts has.
ACP sessions stream through the bridging client handler onto `callbacks`
(createBridgingClientHandler → createEventBridge) instead of a pi
subscription, so any unguarded call site crashed with
"session.subscribe is not a function" the moment an ACP runtime
(Hermes/Prime/Grok) executed a workflow step.
Fix at the seam, not at every call site: wrap the raw callbacks so each
forwarded text/thinking/tool event is ALSO replayed to subscribers as the
pi-shaped event (`message_update` + `assistantMessageEvent`) that
consumers parse, and expose `session.subscribe(handler)` returning an
unsubscribe function that removes the handler. The bridging client
handler below receives the WRAPPED callbacks so both delivery paths
(original callbacks and subscriber replay) fire from one source.
*/
const subscribers = new Set<(event: unknown) => void>();
const emitToSubscribers = (event: Record<string, unknown>): void => {
for (const handler of subscribers) {
try {
handler(event);
} catch {
// A faulty subscriber must never break the streaming bridge.
}
}
};
// FNXC:AcpSubscribeCompat 2026-08-21-20:16: contentIndex is per content block,
// not per delta — keep it stable for all deltas in the same block.
const TEXT_INDEX = 0;
const THINKING_INDEX = 1;
const bridgedCallbacks: AcpCallbacks = {
onText: (delta: string) => {
emitToSubscribers({
type: "message_update",
assistantMessageEvent: { type: "text_delta", contentIndex: TEXT_INDEX, delta },
});
callbacks.onText?.(delta);
},
onThinking: (delta: string) => {
emitToSubscribers({
type: "message_update",
assistantMessageEvent: { type: "thinking_delta", contentIndex: THINKING_INDEX, delta },
});
callbacks.onThinking?.(delta);
},
onToolStart: (toolName: string, args?: unknown) => {
emitToSubscribers({ type: "tool_execution_start", toolName, args });
callbacks.onToolStart?.(toolName, args);
},
onToolEnd: (toolName: string, isError: boolean, result?: unknown) => {
emitToSubscribers({ type: "tool_execution_end", toolName, isError, result });
callbacks.onToolEnd?.(toolName, isError, result);
},
};
// Build the bridging client handler with the per-run permission gate (U5):
// its `requestPermission` classifies each call per-category against the live
// gate (KTD3a) and selects `allow_once` only (S2). `cancelPending` drains
@@ -57,7 +117,7 @@ export class AcpRuntimeAdapter implements AgentRuntime {
// same toggles drive the advertised `fs` capability in connect() below, so
// advertisement and registered handlers stay consistent.
const { handler: clientHandler, cancelPending, resetTurn } = createBridgingClientHandler(
callbacks,
bridgedCallbacks,
options.actionGateContext,
{
cwd: options.cwd,
@@ -79,7 +139,19 @@ export class AcpRuntimeAdapter implements AgentRuntime {
binaryPath: this.settings.binaryPath,
args: this.settings.args,
cwd: options.cwd,
env: buildSpawnEnv(this.settings.envAllowList, { required: this.settings.requiredEnv }),
env: buildSpawnEnv(this.settings.envAllowList, {
required: this.settings.requiredEnv,
/*
FNXC:AcpSubscribeCompat 2026-08-21-18:40:
`taskEnv` is the engine's contract for task-scoped subprocess env
(AgentRuntimeOptions.taskEnv). Merge it AFTER the allow-list so task
values win, but only for keys the allow-list already admits — the
allow-list stays the trust boundary (KTD6b).
*/
sourceEnv: options.taskEnv
? { ...process.env, ...options.taskEnv }
: process.env,
}),
advertiseFs: { read: this.settings.fsRead, write: this.settings.fsWrite },
clientHandler,
...(this.settings.authenticate ? { authenticate: this.settings.authenticate } : {}),
@@ -145,7 +217,7 @@ export class AcpRuntimeAdapter implements AgentRuntime {
sessionId,
cwd: options.cwd,
lastModelDescription: `acp/${model}`,
callbacks,
callbacks: bridgedCallbacks,
fusionToolBridgeError: toolBridgeFailure ? { reasonCode: toolBridgeFailure } : undefined,
// Persist the per-run gate (KTD3) so U5/U7 can reach the live action gate.
gate: options.actionGateContext,
@@ -158,9 +230,16 @@ export class AcpRuntimeAdapter implements AgentRuntime {
get disposePromise() {
return disposePromise;
},
subscribe: (handler: (event: unknown) => void) => {
subscribers.add(handler);
return () => {
subscribers.delete(handler);
};
},
dispose: () => {
if (disposed) return;
disposed = true;
subscribers.clear();
// Drain in-flight permission requests BEFORE the registry kill so a
// blocked agent is released (KTD4a — the SIGKILL is still authoritative).
cancelPending();

View File

@@ -116,6 +116,11 @@ export interface AgentRuntimeOptions {
cwd: string;
systemPrompt: string;
tools?: "coding" | "readonly";
/**
* FNXC:AcpSubscribeCompat 2026-08-21-19:12:
* Task-scoped subprocess environment (engine contract); allow-list still gates forwarding.
*/
taskEnv?: NodeJS.ProcessEnv;
/**
* Engine-assembled Fusion custom tools (fn_*). ToolDefinition.execute closures
* only run in-process, so the ACP runtime exposes them to the agent through a
@@ -176,6 +181,15 @@ export interface AcpSession {
/** Completion of the most recent direct dispose call. */
disposePromise?: Promise<void>;
dispose(): void;
/**
* Engine-compat event subscription. The engine's AgentSession interface
* (pi-coding-agent) exposes `subscribe(handler)` and several production
* call sites call it unconditionally (execute-workflow-step.ts, pi.ts).
* ACP sessions stream through the bridging client handler onto `callbacks`
* instead, so this adapter replays each forwarded event to every handler.
* Returns a no-op unsubscribe for interface compatibility.
*/
subscribe(handler: (event: unknown) => void): () => void;
}
export type AgentSession = AcpSession;

View File

@@ -8,6 +8,7 @@
"packages/engine/src/merge/auto-merge-finalization.ts": 1,
"packages/engine/src/project-engine.ts": 1,
"packages/engine/src/runtimes/in-process-runtime.ts": 1,
"packages/engine/src/self-healing.ts": 1,
"packages/dashboard/src/routes/register-task-workflow-routes.ts": 1,
"packages/dashboard/app/hooks/useBlockerFanout.ts": 1,
"packages/cli/src/commands/dashboard-tui/app.tsx": 1,