From b0a84847b7cd6f81329be83afb1f20b3f5603b77 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 3 Jul 2026 13:46:10 +0000 Subject: [PATCH] fix(agent): emitted handoff session switch hook Fixes #4434 --- packages/coding-agent/CHANGELOG.md | 4 + .../src/extensibility/shared-events.ts | 4 +- .../coding-agent/src/session/agent-session.ts | 17 ++++ .../test/agent-session-handoff.test.ts | 84 +++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 860266c53..18b8c7e23 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `/handoff` and auto-handoff skipping extension lifecycle hooks by emitting `session_switch` with `reason: "handoff"` before replacing the outgoing session ([#4434](https://github.com/can1357/oh-my-pi/issues/4434)). + ## [16.3.4] - 2026-07-03 ### Fixed diff --git a/packages/coding-agent/src/extensibility/shared-events.ts b/packages/coding-agent/src/extensibility/shared-events.ts index 7eb122e9e..8b8a4809e 100644 --- a/packages/coding-agent/src/extensibility/shared-events.ts +++ b/packages/coding-agent/src/extensibility/shared-events.ts @@ -33,7 +33,7 @@ export interface SessionStartEvent { export interface SessionBeforeSwitchEvent { type: "session_before_switch"; /** Reason for the switch */ - reason: "new" | "resume" | "fork"; + reason: "new" | "resume" | "fork" | "handoff"; /** Session file we're switching to (only for "resume") */ targetSessionFile?: string; } @@ -42,7 +42,7 @@ export interface SessionBeforeSwitchEvent { export interface SessionSwitchEvent { type: "session_switch"; /** Reason for the switch */ - reason: "new" | "resume" | "fork"; + reason: "new" | "resume" | "fork" | "handoff"; /** Session file we came from */ previousSessionFile: string | undefined; } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f271f21bf..51608ee6c 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -9901,6 +9901,16 @@ export class AgentSession { // Start a new session const previousSessionFile = this.sessionFile; + if (this.#extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this.#extensionRunner.emit({ + type: "session_before_switch", + reason: "handoff", + })) as SessionBeforeSwitchResult | undefined; + + if (result?.cancel) { + return undefined; + } + } await this.sessionManager.flush(); this.#cancelOwnAsyncJobs(); await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined); @@ -9956,6 +9966,13 @@ export class AgentSession { this.agent.replaceMessages(sessionContext.messages); this.#resetAllAdvisorRuntimes(); this.#syncTodoPhasesFromBranch(); + if (this.#extensionRunner) { + await this.#extensionRunner.emit({ + type: "session_switch", + reason: "handoff", + previousSessionFile, + }); + } return { document: handoffText, savedPath }; } catch (error) { diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 516a4f906..cce5bbd9f 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -158,6 +158,90 @@ describe("AgentSession handoff", () => { expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); }); + it("emits handoff lifecycle hooks on the outgoing and replacement sessions", async () => { + const extensionsResult = await loadExtensions([], tempDir.path()); + const extensionRunner = new ExtensionRunner( + extensionsResult.extensions, + extensionsResult.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + const observedEvents: Array<{ + type: "session_before_switch" | "session_switch"; + reason: string; + previousSessionFile: string | undefined; + activeSessionFile: string | undefined; + messageCount: number; + handoffEntryCount: number; + }> = []; + vi.spyOn(extensionRunner, "hasHandlers").mockImplementation(eventName => eventName === "session_before_switch"); + const emit = extensionRunner.emit.bind(extensionRunner); + vi.spyOn(extensionRunner, "emit").mockImplementation(event => { + if (event.type === "session_before_switch" || event.type === "session_switch") { + observedEvents.push({ + type: event.type, + reason: event.reason, + previousSessionFile: event.type === "session_switch" ? event.previousSessionFile : undefined, + activeSessionFile: session.sessionFile, + messageCount: sessionManager.getBranch().filter(entry => entry.type === "message").length, + handoffEntryCount: sessionManager + .getBranch() + .filter(entry => entry.type === "custom_message" && entry.customType === "handoff").length, + }); + } + return emit(event); + }); + + await session.dispose(); + session = new AgentSession({ + agent: new Agent({ + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }), + sessionManager, + settings: Settings.isolated({ + "compaction.enabled": true, + "compaction.autoContinue": false, + }), + modelRegistry, + extensionRunner, + obfuscator, + }); + const previousSessionFile = session.sessionFile; + const generateHandoffSpy = vi + .spyOn(compactionModule, "generateHandoffFromContext") + .mockResolvedValue("## Goal\nContinue from here"); + + await session.handoff(); + + const nextSessionFile = session.sessionFile; + expect(generateHandoffSpy).toHaveBeenCalledTimes(1); + expect(nextSessionFile).not.toBe(previousSessionFile); + expect(observedEvents).toEqual([ + { + type: "session_before_switch", + reason: "handoff", + previousSessionFile: undefined, + activeSessionFile: previousSessionFile, + messageCount: 2, + handoffEntryCount: 0, + }, + { + type: "session_switch", + reason: "handoff", + previousSessionFile, + activeSessionFile: nextSessionFile, + messageCount: 0, + handoffEntryCount: 1, + }, + ]); + }); + it("runs handoff generation through the configured side stream function", async () => { const handoffText = "## Goal\nContinue via side stream"; let sideStreamCalls = 0;