fix(session): surface empty handoff generation as failure not cancel

The #7904 fix stopped masking provider errors as "Handoff cancelled", but
an empty or whitespace-only generation still fell through: whitespace-only
text passed the `!handoffText` guard and produced a bogus handoff, while
empty text returned undefined which the interactive /handoff caller mapped
to "Handoff cancelled" with no detail and no log entry.

Treat empty/whitespace-only output as a real failure: a user-initiated
handoff throws "Handoff generation produced no content" (surfaced as
"Handoff failed: ...") and logs it; auto-handoff keeps returning undefined
so maintenance falls back to context-full compaction. Also log genuine
handoff failures in the command controller so they persist for debugging.

Fixes #7993
This commit is contained in:
roboomp
2026-08-08 08:20:37 +00:00
parent 08819b279c
commit 92e574cb02
4 changed files with 45 additions and 3 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `/handoff` reporting "Handoff cancelled" for an empty or whitespace-only generation; a user-initiated handoff now surfaces "Handoff failed: Handoff generation produced no content" and logs the failure, while auto-handoff still falls back to context-full compaction ([#7993](https://github.com/can1357/oh-my-pi/issues/7993)).
## [17.2.11] - 2026-08-07
### Added
@@ -11,7 +11,7 @@ import {
type UsageReport,
} from "@oh-my-pi/pi-ai";
import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
import { formatDuration, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils";
import { formatDuration, logger, Snowflake, sanitizeText } from "@oh-my-pi/pi-utils";
import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
import { type BashResult, isPersistentShellCdCommand } from "../../exec/bash-executor";
import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share";
@@ -1442,6 +1442,9 @@ export class CommandController {
if (message === "Handoff cancelled") {
this.ctx.showError("Handoff cancelled");
} else {
// Persist the real failure so it is debuggable after the transient
// TUI error clears (#7993).
logger.error("Handoff failed", { error: message });
this.ctx.showError(`Handoff failed: ${message}`);
}
} finally {
@@ -211,8 +211,22 @@ export class SessionHandoff {
if (handoffSignal.aborted) {
throw new Error("Handoff cancelled");
}
if (!handoffText) {
return undefined;
if (!handoffText || handoffText.trim().length === 0) {
// Empty/whitespace-only generation is a real failure, not a user
// cancellation. #7904 stopped masking provider errors as "Handoff
// cancelled"; an empty document is the remaining path that produced the
// same misleading, undebuggable message (#7993).
logger.warn("Handoff generation produced no content", {
sessionId: this.#host.sessionId(),
autoTriggered: options?.autoTriggered ?? false,
});
// Auto-handoff is best-effort: returning undefined lets maintenance fall
// back to context-full compaction. A user-initiated handoff must surface
// the failure instead of a silent, misleading "cancelled".
if (options?.autoTriggered) {
return undefined;
}
throw new Error("Handoff generation produced no content");
}
// Start a new session
@@ -2025,4 +2025,25 @@ describe("AgentSession handoff", () => {
expect(generateHandoffSpy).toHaveBeenCalledTimes(1);
expect(session.isGeneratingHandoff).toBe(false);
});
it("surfaces empty handoff generation as a failure, not a false cancel", async () => {
// Regression for #7993: the #7904 fix stopped masking provider errors as
// "Handoff cancelled", but an empty/whitespace-only generation still returned
// undefined, which the /handoff caller reported as "Handoff cancelled" with no
// detail. Empty output is a real failure and must surface as one.
const generateHandoffSpy = vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue(" \n ");
await expect(session.handoff()).rejects.toThrow("Handoff generation produced no content");
expect(generateHandoffSpy).toHaveBeenCalledTimes(1);
expect(session.isGeneratingHandoff).toBe(false);
});
it("auto-triggered handoff returns undefined on empty generation for context-full fallback", async () => {
// Auto-handoff is best-effort: an empty document must NOT throw so maintenance
// can fall back to context-full compaction (see runAutoCompaction).
vi.spyOn(compactionModule, "generateHandoffFromContext").mockResolvedValue("");
const result = await session.handoff(undefined, { autoTriggered: true });
expect(result).toBeUndefined();
});
});