diff --git a/src/agents/compaction-real-conversation.test.ts b/src/agents/compaction-real-conversation.test.ts new file mode 100644 index 000000000000..16ade06ec31f --- /dev/null +++ b/src/agents/compaction-real-conversation.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + hasMeaningfulConversationContent, + isRealConversationMessage, +} from "./compaction-real-conversation.js"; +import type { AgentMessage } from "./runtime/index.js"; + +type SummaryRole = "branchSummary" | "compactionSummary"; + +function summaryMessage(role: SummaryRole, summary: string): AgentMessage { + return { role, summary, timestamp: 1 } as AgentMessage; +} + +describe("compaction real conversation classification", () => { + it.each(["branchSummary", "compactionSummary"])( + "treats non-empty %s messages as conversation anchors", + (role) => { + const summary = summaryMessage(role, "The user asked for a repository audit."); + const toolResult = { + role: "toolResult", + toolCallId: "call-1", + toolName: "exec", + content: [{ type: "text", text: "audit output" }], + } as AgentMessage; + const messages = [summary, toolResult]; + + expect(hasMeaningfulConversationContent(summary)).toBe(true); + expect(isRealConversationMessage(summary, messages, 0)).toBe(true); + expect(isRealConversationMessage(toolResult, messages, 1)).toBe(true); + }, + ); + + it.each(["branchSummary", "compactionSummary"])( + "rejects blank %s messages", + (role) => { + const summary = summaryMessage(role, " "); + + expect(hasMeaningfulConversationContent(summary)).toBe(false); + expect(isRealConversationMessage(summary, [summary], 0)).toBe(false); + }, + ); + + it("rejects tool-call-only messages and orphan tool results", () => { + const toolCall = { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "exec", arguments: {} }], + } as AgentMessage; + const orphanToolResult = { + role: "toolResult", + toolCallId: "call-1", + toolName: "exec", + content: [{ type: "text", text: "audit output" }], + } as AgentMessage; + + expect(isRealConversationMessage(toolCall, [toolCall], 0)).toBe(false); + expect(isRealConversationMessage(orphanToolResult, [orphanToolResult], 0)).toBe(false); + }); +}); diff --git a/src/agents/compaction-real-conversation.ts b/src/agents/compaction-real-conversation.ts index fe12fa36cbe9..edadf01cb876 100644 --- a/src/agents/compaction-real-conversation.ts +++ b/src/agents/compaction-real-conversation.ts @@ -30,6 +30,10 @@ function hasMeaningfulText(text: string): boolean { return true; } +function isSummaryRole(role: unknown): boolean { + return role === "branchSummary" || role === "compactionSummary"; +} + /** Returns whether a message has content worth preserving as conversation. */ export function hasMeaningfulConversationContent(message: AgentMessage): boolean { if ((message as { role?: unknown }).role === "custom") { @@ -49,7 +53,7 @@ export function hasMeaningfulConversationContent(message: AgentMessage): boolean const output = typeof bash.output === "string" ? bash.output : ""; return hasMeaningfulText(`${command}\n${output}`); } - if ((message as { role?: unknown }).role === "branchSummary") { + if (isSummaryRole((message as { role?: unknown }).role)) { const summary = (message as { summary?: unknown }).summary; return typeof summary === "string" && hasMeaningfulText(summary); } @@ -93,10 +97,7 @@ function hasMeaningfulMessageContent(content: unknown): boolean { function isToolResultConversationAnchor(message: AgentMessage): boolean { const role = (message as { role?: unknown }).role; return ( - (role === "user" || - role === "custom" || - role === "bashExecution" || - role === "branchSummary") && + (role === "user" || role === "custom" || role === "bashExecution" || isSummaryRole(role)) && hasMeaningfulConversationContent(message) ); } @@ -112,7 +113,7 @@ export function isRealConversationMessage( message.role === "assistant" || message.role === "custom" || message.role === "bashExecution" || - message.role === "branchSummary" + isSummaryRole(message.role) ) { return hasMeaningfulConversationContent(message); } diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index ce4111c31a4c..136bd253261d 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -2170,6 +2170,38 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); }); + it("compacts an overflow transcript anchored by a compaction summary", async () => { + sessionMessages.splice( + 0, + sessionMessages.length, + { + role: "compactionSummary", + summary: "The user asked for a long-running repository audit.", + timestamp: 1, + }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "exec", arguments: {} }], + timestamp: 2, + }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "exec", + content: [{ type: "text", text: "audit output" }], + isError: false, + timestamp: 3, + }, + ); + + const result = await compactEmbeddedAgentSessionDirect( + wrappedCompactionArgs({ trigger: "overflow" }), + ); + + expect(result).toMatchObject({ ok: true, compacted: true }); + expect(sessionCompactImpl).toHaveBeenCalledOnce(); + }); + it("skips compaction when the transcript only contains boilerplate replies and tool output", () => { const messages = [ { role: "user", content: "HEARTBEAT_OK", timestamp: 1 },