diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 919f3a427..c578452d0 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -256,12 +256,21 @@ export class CommandController { } #copyLastMessage() { - const text = this.ctx.session.getLastAssistantText(); - if (!text) { - this.ctx.showError("No agent messages to copy yet."); + const assistantText = this.ctx.session.getLastAssistantText(); + if (assistantText) { + this.#doCopy(assistantText, "Copied last agent message to clipboard"); return; } - this.#doCopy(text, "Copied last agent message to clipboard"); + + if (!this.ctx.session.hasCopyCandidateAssistantMessage()) { + const handoffText = this.ctx.session.getLastVisibleHandoffText(); + if (handoffText) { + this.#doCopy(handoffText, "Copied handoff context to clipboard"); + return; + } + } + + this.ctx.showError("No agent messages to copy yet."); } #copyCode() { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f61f35a13..61e5b738f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -7840,21 +7840,11 @@ export class AgentSession { * @returns Text content, or undefined if no assistant message exists */ getLastAssistantText(): string | undefined { - const lastAssistant = this.messages - .slice() - .reverse() - .find(m => { - if (m.role !== "assistant") return false; - const msg = m as AssistantMessage; - // Skip aborted messages with no content - if (msg.stopReason === "aborted" && msg.content.length === 0) return false; - return true; - }); - + const lastAssistant = this.#getLastCopyCandidateAssistantMessage(); if (!lastAssistant) return undefined; let text = ""; - for (const content of (lastAssistant as AssistantMessage).content) { + for (const content of lastAssistant.content) { if (content.type === "text") { text += content.text; } @@ -7863,6 +7853,54 @@ export class AgentSession { return text.trim() || undefined; } + hasCopyCandidateAssistantMessage(): boolean { + return this.#getLastCopyCandidateAssistantMessage() !== undefined; + } + + #getLastCopyCandidateAssistantMessage(): AssistantMessage | undefined { + for (let i = this.messages.length - 1; i >= 0; i--) { + const message = this.messages[i]; + if (message.role !== "assistant") continue; + + const assistantMessage = message as AssistantMessage; + // Skip aborted messages with no content + if (assistantMessage.stopReason === "aborted" && assistantMessage.content.length === 0) continue; + + return assistantMessage; + } + + return undefined; + } + /** + * Get text content of the most recent visible handoff message. + * Fresh handoff sessions store the handoff context as a custom message, not + * an assistant message, so callers that copy the "last" message can use this + * as a fallback before the new session has an assistant response. + */ + getLastVisibleHandoffText(): string | undefined { + for (let i = this.messages.length - 1; i >= 0; i--) { + const message = this.messages[i]; + if (message.role !== "custom") continue; + + const customMessage = message as CustomMessage; + if (customMessage.customType !== "handoff" || !customMessage.display) continue; + + if (typeof customMessage.content === "string") { + return customMessage.content.trim() || undefined; + } + + let text = ""; + for (const content of customMessage.content) { + if (content.type === "text") { + text += content.text; + } + } + return text.trim() || undefined; + } + + return undefined; + } + /** * Format the entire session as plain text for clipboard export. * Includes user messages, assistant text, thinking blocks, tool calls, and tool results. diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index dfaf95f98..53bcbdbce 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -242,6 +242,11 @@ describe("AgentSession handoff", () => { .map(line => JSON.parse(line) as PersistedEntry); expect(result?.document).toBe(handoffText); + expect(session.getLastAssistantText()).toBeUndefined(); + expect(session.hasCopyCandidateAssistantMessage()).toBe(false); + expect(session.getLastVisibleHandoffText()).toBe( + `\n${handoffText}\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`, + ); expect(handoffSessionFile).not.toBe(previousSessionFile); expect(handoffEntries[0]).toMatchObject({ type: "session", parentSession: previousSessionFile }); expect( diff --git a/packages/coding-agent/test/modes/controllers/copy-command.test.ts b/packages/coding-agent/test/modes/controllers/copy-command.test.ts new file mode 100644 index 000000000..b87f4d178 --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/copy-command.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { CommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import * as native from "@oh-my-pi/pi-natives"; + +function createController(options: { assistantText?: string; hasAssistantMessage?: boolean; handoffText?: string }) { + const showStatus = vi.fn(); + const showError = vi.fn(); + const ctx = { + session: { + getLastAssistantText: () => options.assistantText, + hasCopyCandidateAssistantMessage: () => options.hasAssistantMessage ?? options.assistantText !== undefined, + getLastVisibleHandoffText: () => options.handoffText, + }, + showStatus, + showError, + } as unknown as InteractiveModeContext; + + return { controller: new CommandController(ctx), showStatus, showError }; +} + +describe("/copy command", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("falls back to the fresh handoff context when no assistant message exists", () => { + const copySpy = vi.spyOn(native, "copyToClipboard").mockImplementation(() => undefined); + const { controller, showStatus, showError } = createController({ + handoffText: "\n## Goal\nContinue\n", + }); + + controller.handleCopyCommand(); + + expect(copySpy).toHaveBeenCalledWith("\n## Goal\nContinue\n"); + expect(showStatus).toHaveBeenCalledWith("Copied handoff context to clipboard"); + expect(showError).not.toHaveBeenCalled(); + }); + + it("does not fall back to stale handoff context after a textless assistant response", () => { + const copySpy = vi.spyOn(native, "copyToClipboard").mockImplementation(() => undefined); + const { controller, showStatus, showError } = createController({ + hasAssistantMessage: true, + handoffText: "\n## Goal\nContinue\n", + }); + + controller.handleCopyCommand(); + + expect(copySpy).not.toHaveBeenCalled(); + expect(showStatus).not.toHaveBeenCalled(); + expect(showError).toHaveBeenCalledWith("No agent messages to copy yet."); + }); +});