merge PR #4435: fix(agent): emit session_switch for handoff

This commit is contained in:
can1357
2026-07-08 15:19:38 +02:00
4 changed files with 106 additions and 2 deletions
+3
View File
@@ -205,6 +205,9 @@
- Fixed ACP `terminal/create` sending the bash tool's full shell line in `command` with no `args`, which broke spec-conformant clients that spawn `command`+`args` directly (no implicit shell) — any command containing a space, pipe, `&&`, redirect, or `$(...)` failed with `ENOENT` and the agent silently degraded to read-only tools. The bash tool now wraps the shell line before calling `clientBridge.createTerminal`, reusing the same shell binary + args the local `bash-executor` resolves via `settings.getShellConfig()` (Git Bash / `bash.exe` on Windows, `$SHELL` with `sh` fallback on POSIX) so bash semantics — `$VAR`, `$(...)`, `source`, POSIX quoting, `-l` — are preserved on both platforms. ([#4333](https://github.com/can1357/oh-my-pi/issues/4333))
- Fixed inference worker subprocesses (TTS, STT, tiny-model, mnemopi embeddings) discarding stderr, which left every unexpected exit — most visibly the local Kokoro TTS worker's recurring `exit code 7` crash loop — undiagnosable from the parent's logs. `createWorkerSubprocess` now pipes stderr without starting a live read while the worker is idle, then drains the stream after `onExit`, emits captured lines to `logger.debug` under an `<exitLabel> stderr` message, and keeps the last 16 KiB in a bounded ring that gets appended to the `Error` surfaced through `onError`. The exit surface is synchronized with the post-exit drain via `SpawnedSubprocess.stderrDrained`, so the full native trace shows up on the `tts: worker error` line without reintroducing event-loop liveness from unref'd workers. ([#4324](https://github.com/can1357/oh-my-pi/issues/4324))
- Fixed Windows session tail loss after atomic compaction rewrites by fencing append writers during full-file replacement and gating the atomic publish on a `commitGuard` that the storage backend checks synchronously before rename, so a concurrent `flushSync` (Ctrl+C / session-exit) is not overwritten by the stale body serialized before it ran. Covers post-compaction prompts, tool results, title changes, and exit diagnostics on the current JSONL path ([#4338](https://github.com/can1357/oh-my-pi/issues/4338)).
### 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
@@ -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;
}
@@ -10084,6 +10084,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);
@@ -10140,6 +10150,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) {
@@ -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;