fix(compaction): skip replay-unsafe session sends (#93293)

* fix(compaction): skip replay-unsafe session sends

* fix(compaction): preserve safe text around session sends

* fix(compaction): narrow replay filtering to sessions_send

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* test(compaction): use review-safe key fixtures

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* test(compaction): use placeholder API key

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* test(compaction): limit fixture cleanup to new cases

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* fix(compaction): preserve completed sessions_send history

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* fix(compaction): preserve unfinished delegated work

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* fix(compaction): detect completion before replay filtering

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* fix(compaction): retain unresolved send status

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* fix(compaction): preserve inert send outcomes

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* fix(compaction): narrow terminal tail type

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* fix(compaction): guard replay input lookup

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
mushuiyu886
2026-07-16 01:46:12 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 120632e6fc
commit f771aa54e8
2 changed files with 515 additions and 2 deletions
@@ -2537,6 +2537,369 @@ describe("compaction-safeguard double-compaction guard", () => {
).toBe(true);
});
it("does not replay inter-session sessions_send branch turns as fallback history", async () => {
mockSummarizeInStages.mockReset();
mockSummarizeInStages.mockResolvedValue("branch summary");
const now = Date.now();
const sessionManager = {
...stubSessionManager(),
getBranch: () => [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: new Date(now).toISOString(),
message: {
role: "user",
content: "say bee",
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:pm",
sourceTool: "sessions_send",
},
timestamp: now,
},
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: new Date(now + 1).toISOString(),
message: {
role: "assistant",
content: [{ type: "text", text: "bee reply" }],
timestamp: now + 1,
},
},
],
} as ExtensionContext["sessionManager"];
const model = createAnthropicModelFixture();
setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 });
const mockEvent = {
preparation: {
messagesToSummarize: [] as AgentMessage[],
turnPrefixMessages: [] as AgentMessage[],
firstKeptEntryId: "entry-7",
tokensBefore: 38085,
fileOps: { read: [], edited: [], written: [] },
settings: { reserveTokens: 4000 },
isSplitTurn: true,
},
customInstructions: "",
signal: new AbortController().signal,
};
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: "dummy",
});
const compaction = expectCompactionResult(result);
expect(compaction.summary).toContain("No prior history.");
expect(mockSummarizeInStages).not.toHaveBeenCalled();
expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled();
});
it.each([
{ toolName: "read", expectedRoles: ["user", "assistant", "toolResult"] },
{ toolName: "functions.sessions_send", expectedRoles: ["user", "assistant"] },
])("preserves unfinished inter-session work after a $toolName result", async (scenario) => {
mockSummarizeInStages.mockReset();
mockSummarizeInStages.mockResolvedValue("unfinished branch summary");
const now = Date.now();
const sessionManager = {
...stubSessionManager(),
getBranch: () => [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: new Date(now).toISOString(),
message: {
role: "user",
content: "continue the delegated task",
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:pm",
sourceTool: "sessions_send",
},
timestamp: now,
},
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: new Date(now + 1).toISOString(),
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "call-tool",
name: scenario.toolName,
arguments: {},
},
],
timestamp: now + 1,
},
},
{
type: "message",
id: "tool-1",
parentId: "assistant-1",
timestamp: new Date(now + 2).toISOString(),
message: {
role: "toolResult",
toolCallId: "call-tool",
toolName: scenario.toolName,
content: [{ type: "text", text: "partial evidence" }],
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-8",
tokensBefore: 38085,
fileOps: { read: [], edited: [], written: [] },
settings: { reserveTokens: 4000 },
isSplitTurn: true,
},
customInstructions: "",
signal: new AbortController().signal,
};
const { result } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: "dummy",
});
const compaction = expectCompactionResult(result);
expect(compaction.summary).toContain("unfinished branch summary");
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages));
const messages = requireArray(summarizeCall.messages);
expect(messages.map((message) => requireRecord(message).role)).toEqual(scenario.expectedRoles);
});
it("keeps source-session sends as inert status history", async () => {
mockSummarizeInStages.mockReset();
mockSummarizeInStages.mockResolvedValue("completed send summary");
const now = Date.now();
const sessionManager = {
...stubSessionManager(),
getBranch: () => [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: new Date(now).toISOString(),
message: {
role: "user",
content: "ask the bee session to respond",
timestamp: now,
},
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: new Date(now + 1).toISOString(),
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "call-1",
name: "functions.sessions_send",
arguments: { sessionKey: "agent:bee", message: "say bee" },
},
{
type: "toolCall",
id: "call-2",
name: "tools/sessions_send",
arguments: { sessionKey: "agent:wasp", message: "say wasp" },
},
{
type: "toolCall",
id: "call-3",
name: "sessions_send",
arguments: { sessionKey: "agent:ant", message: "say ant" },
},
],
timestamp: now + 1,
},
},
{
type: "message",
id: "tool-1",
parentId: "assistant-1",
timestamp: new Date(now + 2).toISOString(),
message: {
role: "toolResult",
toolCallId: "call-1",
toolName: "functions.sessions_send",
content: [{ type: "text", text: '{"status":"ok","reply":"bee replied"}' }],
timestamp: now + 2,
},
},
{
type: "message",
id: "tool-3",
parentId: "tool-1",
timestamp: new Date(now + 3).toISOString(),
message: {
role: "toolResult",
toolCallId: "call-3",
toolName: "sessions_send",
content: [{ type: "text", text: '{"status":"error","error":"ant unavailable"}' }],
timestamp: now + 3,
},
},
],
} as ExtensionContext["sessionManager"];
const model = createAnthropicModelFixture();
setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 });
const mockEvent = {
preparation: {
messagesToSummarize: [] as AgentMessage[],
turnPrefixMessages: [] as AgentMessage[],
firstKeptEntryId: "entry-8",
tokensBefore: 38085,
fileOps: { read: [], edited: [], written: [] },
settings: { reserveTokens: 4000 },
isSplitTurn: true,
},
customInstructions: "",
signal: new AbortController().signal,
};
const { result } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: "dummy",
});
const compaction = expectCompactionResult(result);
expect(compaction.summary).toContain("completed send summary");
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages));
const messages = requireArray(summarizeCall.messages);
expect(messages.map((message) => requireRecord(message).role)).toEqual(["user", "assistant"]);
expect(JSON.stringify(messages)).toContain("sessions_send result received");
expect(JSON.stringify(messages)).toContain("sessions_send result missing");
expect(JSON.stringify(messages)).toContain("bee replied");
expect(JSON.stringify(messages)).toContain("ant unavailable");
expect(JSON.stringify(messages)).toContain("agent:wasp");
expect(JSON.stringify(messages)).toContain("say wasp");
expect(JSON.stringify(messages)).not.toContain("sessions_send completed");
expect(JSON.stringify(messages)).not.toContain("functions.sessions_send");
expect(JSON.stringify(messages)).not.toContain("tools/sessions_send");
});
it("preserves completed historical inter-session turns outside the active tail", async () => {
mockSummarizeInStages.mockReset();
mockSummarizeInStages.mockResolvedValue("historical branch summary");
const now = Date.now();
const sessionManager = {
...stubSessionManager(),
getBranch: () => [
{
type: "message",
id: "user-1",
parentId: null,
timestamp: new Date(now).toISOString(),
message: {
role: "user",
content: "historical inter-session request",
provenance: {
kind: "inter_session",
sourceSessionKey: "agent:pm",
sourceTool: "sessions_send",
},
timestamp: now,
},
},
{
type: "message",
id: "assistant-1",
parentId: "user-1",
timestamp: new Date(now + 1).toISOString(),
message: {
role: "assistant",
content: [{ type: "text", text: "historical reply" }],
timestamp: now + 1,
},
},
{
type: "message",
id: "user-2",
parentId: "assistant-1",
timestamp: new Date(now + 2).toISOString(),
message: { role: "user", content: "later user request", timestamp: now + 2 },
},
{
type: "message",
id: "assistant-2",
parentId: "user-2",
timestamp: new Date(now + 3).toISOString(),
message: {
role: "assistant",
content: [{ type: "text", text: "later reply" }],
timestamp: now + 3,
},
},
],
} as ExtensionContext["sessionManager"];
const model = createAnthropicModelFixture();
setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 });
const mockEvent = {
preparation: {
messagesToSummarize: [] as AgentMessage[],
turnPrefixMessages: [] as AgentMessage[],
firstKeptEntryId: "entry-8",
tokensBefore: 38085,
fileOps: { read: [], edited: [], written: [] },
settings: { reserveTokens: 4000 },
isSplitTurn: true,
},
customInstructions: "",
signal: new AbortController().signal,
};
const { result } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: "dummy",
});
const compaction = expectCompactionResult(result);
expect(compaction.summary).toContain("historical branch summary");
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages));
const messages = requireArray(summarizeCall.messages);
expect(messages.map((message) => requireRecord(message).role)).toEqual([
"user",
"assistant",
"user",
"assistant",
]);
expect(JSON.stringify(messages)).toContain("historical inter-session request");
expect(JSON.stringify(messages)).toContain("historical reply");
});
it("recovers user and assistant branch turns when compaction preparation has only tool output", async () => {
mockSummarizeInStages.mockReset();
mockSummarizeInStages.mockResolvedValue("branch summary with visible turns");
+152 -2
View File
@@ -12,6 +12,7 @@ import {
getCompactionProvider,
type CompactionProvider,
} from "../../plugins/compaction-provider.js";
import { normalizeInputProvenance } from "../../sessions/input-provenance.js";
import { normalizeAcceptedSessionSpawnResult } from "../accepted-session-spawn.js";
import {
buildHistoryPrunePlanWithWorker,
@@ -74,6 +75,7 @@ const DEFAULT_QUALITY_GUARD_MAX_RETRIES = 1;
const MAX_RECENT_TURNS_PRESERVE = 12;
const MAX_QUALITY_GUARD_MAX_RETRIES = 3;
const MAX_RECENT_TURN_TEXT_CHARS = 600;
const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
const PREVIOUS_SUMMARY_REDISTILL_PREFIX =
"Previous compaction summary to re-distill with the current conversation. " +
"Prune stale, duplicate, or superseded details instead of preserving it verbatim.";
@@ -178,6 +180,154 @@ function collectSessionBranchMessages(sessionManager: unknown): AgentMessage[] {
.filter((message): message is AgentMessage => Boolean(message));
}
function isReplayUnsafeInterSessionInput(message: AgentMessage): boolean {
if ((message as { role?: unknown }).role !== "user") {
return false;
}
const provenance = normalizeInputProvenance((message as { provenance?: unknown }).provenance);
return provenance?.kind === "inter_session" && provenance.sourceTool === "sessions_send";
}
function isSessionsSendToolName(value: unknown): boolean {
if (typeof value !== "string") {
return false;
}
return (
value
.trim()
.toLowerCase()
.replace(/^(?:functions?|tools?)[./_-]/, "") === "sessions_send"
);
}
function sanitizeSourceSessionSends(messages: AgentMessage[]): AgentMessage[] {
const sendCallIds = new Set<string>();
const resolvedCallIds = new Set<string>();
const resultTextByCallId = new Map<string, string>();
for (const message of messages) {
if (message.role !== "assistant" || !Array.isArray(message.content)) {
continue;
}
for (const block of message.content) {
if (!block || typeof block !== "object") {
continue;
}
const record = block as { type?: unknown; id?: unknown; name?: unknown };
if (
typeof record.type === "string" &&
TOOL_CALL_BLOCK_TYPES.has(record.type) &&
isSessionsSendToolName(record.name) &&
typeof record.id === "string" &&
record.id.trim()
) {
sendCallIds.add(record.id.trim());
}
}
}
for (const message of messages) {
if (message.role !== "toolResult") {
continue;
}
const callId = extractToolResultId(message);
if (!callId || !sendCallIds.has(callId)) {
continue;
}
resolvedCallIds.add(callId);
const resultText = extractMessageText(message) || formatNonTextPlaceholder(message.content);
if (resultText) {
resultTextByCallId.set(callId, resultText);
}
}
return messages.flatMap((message) => {
if (message.role === "assistant" && Array.isArray(message.content)) {
let replaced = false;
const content = message.content.map((block) => {
if (!block || typeof block !== "object") {
return block;
}
const record = block as {
type?: unknown;
id?: unknown;
name?: unknown;
arguments?: unknown;
};
if (
typeof record.type !== "string" ||
!TOOL_CALL_BLOCK_TYPES.has(record.type) ||
!isSessionsSendToolName(record.name)
) {
return block;
}
replaced = true;
const callId = typeof record.id === "string" ? record.id.trim() : "";
const resultText = callId ? resultTextByCallId.get(callId) : undefined;
const resolved = Boolean(callId && resolvedCallIds.has(callId));
const requestText = JSON.stringify({ callId: callId || undefined, args: record.arguments });
return {
type: "text",
text:
resolved && resultText
? `sessions_send result received; delivery call omitted from replay.\nRequest: ${requestText}\nResult: ${resultText}`
: resolved
? `sessions_send result received; delivery call omitted from replay.\nRequest: ${requestText}\nResult: [empty]`
: `sessions_send result missing; delivery call omitted from replay.\nRequest: ${requestText}`,
};
});
return replaced ? [{ ...message, content } as AgentMessage] : [message];
}
if (message.role === "toolResult") {
const callId = extractToolResultId(message);
if ((callId && sendCallIds.has(callId)) || isSessionsSendToolName(message.toolName)) {
return [];
}
}
return [message];
});
}
function filterReplayUnsafeSessionBranchMessages(messages: AgentMessage[]): AgentMessage[] {
const sanitizedMessages = sanitizeSourceSessionSends(messages);
let turnStart = sanitizedMessages.length;
while (turnStart > 0) {
const role = (sanitizedMessages[turnStart - 1] as { role?: unknown }).role;
if (role !== "assistant" && role !== "toolResult") {
break;
}
turnStart -= 1;
}
const tailMessage = messages.at(-1);
const endsWithTerminalAssistantText =
tailMessage !== undefined &&
tailMessage.role === "assistant" &&
Boolean(extractMessageText(tailMessage).trim()) &&
(!Array.isArray(tailMessage.content) ||
!tailMessage.content.some((block) => {
if (!block || typeof block !== "object") {
return false;
}
const type = (block as { type?: unknown }).type;
return typeof type === "string" && TOOL_CALL_BLOCK_TYPES.has(type);
}));
const activeInput = sanitizedMessages[turnStart - 1];
// A completed sessions_send target run is already delivered to its caller.
// Require terminal text so compaction after tool output can still recover unfinished work.
if (
endsWithTerminalAssistantText &&
turnStart < sanitizedMessages.length &&
turnStart > 0 &&
activeInput !== undefined &&
isReplayUnsafeInterSessionInput(activeInput)
) {
return sanitizedMessages.slice(0, turnStart - 1);
}
return sanitizedMessages;
}
function containsRealConversation(messages: AgentMessage[]): boolean {
return messages.some((message, index, allMessages) =>
isRealConversationMessage(message, allMessages, index),
@@ -899,8 +1049,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
let hasRealSummarizable = containsRealConversation(baseMessagesToSummarize);
let hasRealTurnPrefix = containsRealConversation(baseTurnPrefixMessages);
if (!hasRealSummarizable && !hasRealTurnPrefix) {
const branchMessages = stripRuntimeContextCustomMessages(
collectSessionBranchMessages(ctx.sessionManager),
const branchMessages = filterReplayUnsafeSessionBranchMessages(
stripRuntimeContextCustomMessages(collectSessionBranchMessages(ctx.sessionManager)),
);
if (containsRealConversation(branchMessages)) {
log.info(