Fix copy fallback for handoff context
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user