diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1cb7c55d0..f90cfdbad 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 7ee154e59..b434ad016 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -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 { diff --git a/packages/coding-agent/src/session/session-handoff.ts b/packages/coding-agent/src/session/session-handoff.ts index eef73ff39..514c75373 100644 --- a/packages/coding-agent/src/session/session-handoff.ts +++ b/packages/coding-agent/src/session/session-handoff.ts @@ -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 diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 2a6f2f864..06dd92570 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -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(); + }); });