merge PR #4435: fix(agent): emit session_switch for handoff
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user