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.");
+ });
+});