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:
5
.changeset/fn-3889-session-shutdown.md
Normal file
5
.changeset/fn-3889-session-shutdown.md
Normal 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).
|
||||
@@ -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(
|
||||
|
||||
62
packages/engine/src/__tests__/pi-session-shutdown.test.ts
Normal file
62
packages/engine/src/__tests__/pi-session-shutdown.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user