feat(FN-3889): emit shutdown event when Fusion session is disposed

Emit a session shutdown event when a Fusion session is disposed, with a matching test update in the engine package.

Fusion-Task-Id: FN-3889
This commit is contained in:
Fusion
2026-05-09 13:51:31 -07:00
committed by gsxdsm
parent 1e800594d5
commit f4967162c9
5 changed files with 153 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fire pi `session_shutdown` extension events when Fusion-spawned `AgentSession` instances are disposed, so extensions registered with `pi.on("session_shutdown", …)` run cleanup handlers (including Fusion's dashboard child-process cleanup).

View File

@@ -158,6 +158,12 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
}
});
it("exposes a callable session_shutdown handler", async () => {
const shutdown = api.events.get("session_shutdown");
expect(typeof shutdown).toBe("function");
await expect(shutdown?.()).resolves.toBeUndefined();
});
it("creates and lists tasks through the built extension", async () => {
const createTool = api.tools.get("fn_task_create")!;
const created = await createTool.execute(

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { _wrapSessionDisposeForTest } from "../pi.js";
describe("session shutdown dispose wrapper", () => {
const createSession = (options?: { hasHandlers?: boolean; emitReject?: boolean }) => {
const dispose = vi.fn();
const hasHandlers = vi.fn(() => options?.hasHandlers ?? true);
const emit = options?.emitReject
? vi.fn().mockRejectedValue(new Error("emit failed"))
: vi.fn().mockResolvedValue(undefined);
const extensionRunner = { hasHandlers, emit };
const session = {
extensionRunner,
dispose,
};
return { session, dispose, extensionRunner, hasHandlers, emit };
};
it("emits session_shutdown once on dispose", async () => {
const { session, dispose, extensionRunner, emit } = createSession();
_wrapSessionDisposeForTest(session as any);
await (session as any).dispose();
expect(extensionRunner.hasHandlers).toHaveBeenCalledWith("session_shutdown");
expect(emit).toHaveBeenCalledTimes(1);
expect(emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" });
expect(dispose).toHaveBeenCalledTimes(1);
});
it("is idempotent across multiple dispose calls", async () => {
const { session, dispose, emit } = createSession();
_wrapSessionDisposeForTest(session as any);
await (session as any).dispose();
await (session as any).dispose();
expect(emit).toHaveBeenCalledTimes(1);
expect(dispose).toHaveBeenCalledTimes(1);
});
it("does not throw when shutdown emit fails", async () => {
const { session, dispose } = createSession({ emitReject: true });
_wrapSessionDisposeForTest(session as any);
await expect((session as any).dispose()).resolves.toBeUndefined();
expect(dispose).toHaveBeenCalledTimes(1);
});
it("runs dispose on no-handler fast path", async () => {
const { session, dispose, emit, hasHandlers } = createSession({ hasHandlers: false });
_wrapSessionDisposeForTest(session as any);
await (session as any).dispose();
expect(hasHandlers).toHaveBeenCalledWith("session_shutdown");
expect(emit).not.toHaveBeenCalled();
expect(dispose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -662,7 +662,7 @@ describe("session failure diagnostics", () => {
await expect((session as any).promptWithFallback("Run task")).resolves.toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to dispose session during swap: dispose failed"),
expect.stringContaining("Session dispose failed after session_shutdown emit: dispose failed"),
);
warnSpy.mockRestore();

View File

@@ -115,6 +115,79 @@ type AgentToolHookSession = AgentSession & {
__fusionMessageContentGuardInstalled?: boolean;
};
const FN_MEMORY_APPEND_TOOL_NAME = "fn_memory_append";
const FUSION_SHUTDOWN_WRAP_FLAG = "__fusionSessionShutdownDisposeWrapped";
type SessionShutdownEventShape = { type: "session_shutdown"; reason: "quit" | "reload" };
type ExtensionRunnerShutdownEmitter = {
hasHandlers: (event: "session_shutdown") => boolean;
emit: (event: SessionShutdownEventShape) => Promise<unknown>;
};
async function emitSessionShutdownEvent(
extensionRunner: ExtensionRunnerShutdownEmitter,
event: SessionShutdownEventShape,
): Promise<boolean> {
if (!extensionRunner.hasHandlers("session_shutdown")) {
return false;
}
await extensionRunner.emit(event);
return true;
}
/**
* Fusion creates raw pi `AgentSession` objects and many engine call sites
* invoke `session.dispose()` directly. Wrap dispose so we mirror
* `AgentSessionRuntime.dispose()` behavior and emit `session_shutdown` first.
*/
function wrapSessionDisposeWithShutdown(session: AgentSession): void {
const mutableSession = session as AgentSession & Record<string, unknown>;
if (mutableSession[FUSION_SHUTDOWN_WRAP_FLAG]) {
return;
}
mutableSession[FUSION_SHUTDOWN_WRAP_FLAG] = true;
const originalDispose =
typeof session.dispose === "function"
? session.dispose.bind(session)
: () => undefined;
let disposeStarted = false;
const wrappedDispose = async (): Promise<void> => {
if (disposeStarted) {
return;
}
disposeStarted = true;
const extensionRunner = (session as { extensionRunner?: unknown }).extensionRunner;
if (
extensionRunner &&
typeof (extensionRunner as ExtensionRunnerShutdownEmitter).hasHandlers === "function" &&
typeof (extensionRunner as ExtensionRunnerShutdownEmitter).emit === "function"
) {
try {
await emitSessionShutdownEvent(extensionRunner as ExtensionRunnerShutdownEmitter, {
type: "session_shutdown",
reason: "quit",
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
piLog.warn(`Failed to emit session_shutdown during dispose: ${message}`);
}
}
try {
await Promise.resolve(originalDispose());
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
piLog.warn(`Session dispose failed after session_shutdown emit: ${message}`);
}
};
(mutableSession as unknown as { dispose: () => void }).dispose = wrappedDispose as unknown as () => void;
}
export function _wrapSessionDisposeForTest(session: AgentSession): void {
wrapSessionDisposeWithShutdown(session);
}
function getSessionStateError(session: AgentSession): string {
const state = (session as any).state;
@@ -1469,6 +1542,9 @@ export function wrapToolsWithActionGate(
/**
* Create a pi agent session configured for fn.
* Reuses the user's existing pi auth and model configuration.
*
* Returned sessions are wrapped so `session.dispose()` emits pi's
* `session_shutdown` extension event before teardown.
*/
export async function createFnAgent(options: AgentOptions): Promise<AgentResult> {
piLog.log(`createFnAgent called (tools=${options.tools}, provider=${options.defaultProvider}, model=${options.defaultModelId})`);
@@ -1716,6 +1792,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
}
let activeSession = sessionResult.session;
wrapSessionDisposeWithShutdown(activeSession);
installToolResultContentGuard(activeSession as AgentToolHookSession);
installMessageContentGuard(activeSession as AgentToolHookSession, sessionManager as unknown as SessionManagerLike);
(activeSession as any).__fusionMemoryAppendAvailable = options.customTools?.some((tool) => tool.name === FN_MEMORY_APPEND_TOOL_NAME) === true;
@@ -1767,6 +1844,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
if (!modelToUse) {
throw new Error("Cannot swap session without a resolved model");
}
wrapSessionDisposeWithShutdown(activeSession);
try {
activeSession.dispose();
} catch (err: unknown) {
@@ -1775,6 +1853,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
}
const next = (await createSessionWithModel(modelToUse)).session as PromptableSession;
wireFallbackHooks(next);
wrapSessionDisposeWithShutdown(next);
applyThinkingLevelIfSupported(next, `${modelToUse.provider}/${modelToUse.id}`);
Object.setPrototypeOf(promptableSession, Object.getPrototypeOf(next));
Object.assign(promptableSession, next);