Merge pull request #1062 from enieuwy/fix/copy-handoff-context

Fix /copy fallback for handoff context
This commit is contained in:
Can Bölük
2026-05-14 04:42:53 +02:00
committed by GitHub
4 changed files with 121 additions and 16 deletions
@@ -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() {
@@ -7860,21 +7860,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;
}
@@ -7883,6 +7873,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.
@@ -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(
`<handoff-context>\n${handoffText}\n</handoff-context>\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(
@@ -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: "<handoff-context>\n## Goal\nContinue\n</handoff-context>",
});
controller.handleCopyCommand();
expect(copySpy).toHaveBeenCalledWith("<handoff-context>\n## Goal\nContinue\n</handoff-context>");
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: "<handoff-context>\n## Goal\nContinue\n</handoff-context>",
});
controller.handleCopyCommand();
expect(copySpy).not.toHaveBeenCalled();
expect(showStatus).not.toHaveBeenCalled();
expect(showError).toHaveBeenCalledWith("No agent messages to copy yet.");
});
});