fix: recognize custom compaction conversation (#78390)

* fix: recognize custom compaction conversation

* fix: use branch fallback for compaction safeguard

---------

Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
This commit is contained in:
Alex Knight
2026-05-06 20:13:31 +10:00
committed by GitHub
parent 9e7fd27577
commit 1c2915677b
5 changed files with 299 additions and 11 deletions

View File

@@ -27,7 +27,32 @@ function hasMeaningfulText(text: string): boolean {
}
export function hasMeaningfulConversationContent(message: AgentMessage): boolean {
if ((message as { role?: unknown }).role === "custom") {
const custom = message as { content?: unknown; display?: unknown };
return custom.display !== false && hasMeaningfulMessageContent(custom.content);
}
if ((message as { role?: unknown }).role === "bashExecution") {
const bash = message as {
command?: unknown;
output?: unknown;
excludeFromContext?: unknown;
};
if (bash.excludeFromContext === true) {
return false;
}
const command = typeof bash.command === "string" ? bash.command : "";
const output = typeof bash.output === "string" ? bash.output : "";
return hasMeaningfulText(`${command}\n${output}`);
}
if ((message as { role?: unknown }).role === "branchSummary") {
const summary = (message as { summary?: unknown }).summary;
return typeof summary === "string" && hasMeaningfulText(summary);
}
const content = (message as { content?: unknown }).content;
return hasMeaningfulMessageContent(content);
}
function hasMeaningfulMessageContent(content: unknown): boolean {
if (typeof content === "string") {
return hasMeaningfulText(content);
}
@@ -60,12 +85,29 @@ export function hasMeaningfulConversationContent(message: AgentMessage): boolean
return sawMeaningfulNonTextBlock;
}
function isToolResultConversationAnchor(message: AgentMessage): boolean {
const role = (message as { role?: unknown }).role;
return (
(role === "user" ||
role === "custom" ||
role === "bashExecution" ||
role === "branchSummary") &&
hasMeaningfulConversationContent(message)
);
}
export function isRealConversationMessage(
message: AgentMessage,
messages: AgentMessage[],
index: number,
): boolean {
if (message.role === "user" || message.role === "assistant") {
if (
message.role === "user" ||
message.role === "assistant" ||
message.role === "custom" ||
message.role === "bashExecution" ||
message.role === "branchSummary"
) {
return hasMeaningfulConversationContent(message);
}
if (message.role !== "toolResult") {
@@ -74,10 +116,10 @@ export function isRealConversationMessage(
const start = Math.max(0, index - TOOL_RESULT_REAL_CONVERSATION_LOOKBACK);
for (let i = index - 1; i >= start; i -= 1) {
const candidate = messages[i];
if (!candidate || candidate.role !== "user") {
if (!candidate) {
continue;
}
if (hasMeaningfulConversationContent(candidate)) {
if (isToolResultConversationAnchor(candidate)) {
return true;
}
}

View File

@@ -989,6 +989,30 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
).toBe(true);
});
it("counts visible custom prompts as real conversation anchors for tool output", () => {
const messages = [
{
role: "custom",
customType: "cron-request",
content: "prepare the daily report",
display: true,
},
{
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "report source data" }],
},
] as AgentMessage[];
expect(compactTesting.hasRealConversationContent(messages[0], messages, 0)).toBe(true);
expect(compactTesting.hasRealConversationContent(messages[2], messages, 2)).toBe(true);
});
it("registers the Ollama api provider before compaction", async () => {
const streamFn = vi.fn();
registerProviderStreamForModelMock.mockReturnValue(streamFn);

View File

@@ -2037,6 +2037,138 @@ describe("compaction-safeguard double-compaction guard", () => {
expect(result).toEqual({ cancel: true });
});
it("does not write boundary when visible custom turn-prefix content is real conversation", async () => {
const sessionManager = stubSessionManager();
const model = createAnthropicModelFixture();
setCompactionSafeguardRuntime(sessionManager, { model });
const mockEvent = {
preparation: {
messagesToSummarize: [] as AgentMessage[],
turnPrefixMessages: [
{
role: "custom" as const,
customType: "cron-request",
content: "prepare the daily report",
display: true,
timestamp: 1,
},
{
role: "assistant" as const,
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }],
timestamp: 2,
},
{
role: "toolResult" as const,
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "report source data" }],
timestamp: 3,
},
] as AgentMessage[],
firstKeptEntryId: "entry-5",
tokensBefore: 38085,
fileOps: { read: [], edited: [], written: [] },
isSplitTurn: true,
},
customInstructions: "",
signal: new AbortController().signal,
};
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: null,
});
expect(result).toEqual({ cancel: true });
expect(getApiKeyAndHeadersMock).toHaveBeenCalled();
});
it("falls back to visible custom session branch entries before writing an empty boundary", async () => {
mockSummarizeInStages.mockReset();
mockSummarizeInStages.mockResolvedValue("branch summary");
const now = Date.now();
const sessionManager = {
...stubSessionManager(),
getBranch: () => [
{
type: "custom_message",
id: "custom-1",
parentId: null,
timestamp: new Date(now).toISOString(),
customType: "cron-request",
content: "prepare the daily report",
display: true,
},
{
type: "message",
id: "assistant-1",
parentId: "custom-1",
timestamp: new Date(now + 1).toISOString(),
message: {
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }],
timestamp: now + 1,
},
},
{
type: "message",
id: "tool-1",
parentId: "assistant-1",
timestamp: new Date(now + 2).toISOString(),
message: {
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "text", text: "report source data" }],
timestamp: now + 2,
},
},
],
} as ExtensionContext["sessionManager"];
const model = createAnthropicModelFixture();
setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 });
const mockEvent = {
preparation: {
messagesToSummarize: [] as AgentMessage[],
turnPrefixMessages: [] as AgentMessage[],
firstKeptEntryId: "entry-5",
tokensBefore: 38085,
fileOps: { read: [], edited: [], written: [] },
settings: { reserveTokens: 4000 },
isSplitTurn: true,
},
customInstructions: "",
signal: new AbortController().signal,
};
const { result } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: "test-key",
});
const compaction = expectCompactionResult(result);
expect(compaction.summary).toContain("branch summary");
expect(compaction.summary).not.toContain("No prior history.");
expect(mockSummarizeInStages).toHaveBeenCalled();
const summaryCall = mockSummarizeInStages.mock.calls[0]?.[0];
expect(summaryCall?.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "custom",
customType: "cron-request",
content: "prepare the daily report",
}),
expect.objectContaining({
role: "toolResult",
toolName: "read",
}),
]),
);
});
it("continues when messages include real conversation content", async () => {
const sessionManager = stubSessionManager();
const model = createAnthropicModelFixture();

View File

@@ -99,6 +99,85 @@ function prependPreviousSummaryForRedistill(params: {
return [buildPreviousSummaryMessage(previousSummary), ...params.messages];
}
type SessionBranchEntry = {
type?: unknown;
message?: unknown;
customType?: unknown;
content?: unknown;
display?: unknown;
details?: unknown;
timestamp?: unknown;
summary?: unknown;
fromId?: unknown;
};
function coerceTimestamp(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return 0;
}
function sessionBranchEntryToMessage(entry: SessionBranchEntry): AgentMessage | undefined {
if (entry.type === "message" && entry.message && typeof entry.message === "object") {
return entry.message as AgentMessage;
}
if (entry.type === "custom_message") {
return {
role: "custom",
customType: typeof entry.customType === "string" ? entry.customType : "custom",
content: entry.content,
display: entry.display !== false,
details: entry.details,
timestamp: coerceTimestamp(entry.timestamp),
} as AgentMessage;
}
if (entry.type === "branch_summary") {
return {
role: "branchSummary",
summary: typeof entry.summary === "string" ? entry.summary : "",
fromId: typeof entry.fromId === "string" ? entry.fromId : "root",
timestamp: coerceTimestamp(entry.timestamp),
} as AgentMessage;
}
return undefined;
}
function collectSessionBranchMessages(sessionManager: unknown): AgentMessage[] {
const getBranch = (sessionManager as { getBranch?: unknown })?.getBranch;
if (typeof getBranch !== "function") {
return [];
}
let entries: unknown;
try {
entries = getBranch.call(sessionManager);
} catch {
return [];
}
if (!Array.isArray(entries)) {
return [];
}
return entries
.map((entry) =>
entry && typeof entry === "object"
? sessionBranchEntryToMessage(entry as SessionBranchEntry)
: undefined,
)
.filter((message): message is AgentMessage => Boolean(message));
}
function containsRealConversation(messages: AgentMessage[]): boolean {
return messages.some((message, index, allMessages) =>
isRealConversationMessage(message, allMessages, index),
);
}
/**
* Attempt provider-based summarization. Returns the summary string on success,
* or `undefined` when the caller should fall back to built-in LLM summarization.
@@ -778,16 +857,26 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions: eventInstructions, signal } = event;
const rawTurnPrefixMessages = preparation.turnPrefixMessages ?? [];
const baseMessagesToSummarize = stripRuntimeContextCustomMessages(
let baseMessagesToSummarize = stripRuntimeContextCustomMessages(
preparation.messagesToSummarize,
);
const baseTurnPrefixMessages = stripRuntimeContextCustomMessages(rawTurnPrefixMessages);
const hasRealSummarizable = baseMessagesToSummarize.some((message, index, messages) =>
isRealConversationMessage(message, messages, index),
);
const hasRealTurnPrefix = baseTurnPrefixMessages.some((message, index, messages) =>
isRealConversationMessage(message, messages, index),
);
let baseTurnPrefixMessages = stripRuntimeContextCustomMessages(rawTurnPrefixMessages);
let hasRealSummarizable = containsRealConversation(baseMessagesToSummarize);
let hasRealTurnPrefix = containsRealConversation(baseTurnPrefixMessages);
if (!hasRealSummarizable && !hasRealTurnPrefix) {
const branchMessages = stripRuntimeContextCustomMessages(
collectSessionBranchMessages(ctx.sessionManager),
);
if (containsRealConversation(branchMessages)) {
log.info(
"Compaction safeguard: using session branch messages after compaction preparation omitted real conversation content.",
);
baseMessagesToSummarize = branchMessages;
baseTurnPrefixMessages = [];
hasRealSummarizable = true;
hasRealTurnPrefix = false;
}
}
setCompactionSafeguardCancelReason(ctx.sessionManager, undefined);
if (!hasRealSummarizable && !hasRealTurnPrefix) {
// When there are no summarizable messages AND no real turn-prefix content,