Skip to content

Commit 69fa892

Browse files
committed
fix(compaction): preserve safe text around session sends
1 parent dc35675 commit 69fa892

2 files changed

Lines changed: 116 additions & 22 deletions

File tree

src/agents/agent-hooks/compaction-safeguard.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2611,6 +2611,95 @@ describe("compaction-safeguard double-compaction guard", () => {
26112611
expect(JSON.stringify(messages)).not.toContain("sessions_send");
26122612
});
26132613

2614+
it("preserves assistant text while filtering sessions_send tool calls as fallback history", async () => {
2615+
mockSummarizeInStages.mockReset();
2616+
mockSummarizeInStages.mockResolvedValue("mixed branch summary");
2617+
2618+
const now = Date.now();
2619+
const sessionManager = {
2620+
...stubSessionManager(),
2621+
getBranch: () => [
2622+
{
2623+
type: "message",
2624+
id: "user-1",
2625+
parentId: null,
2626+
timestamp: new Date(now).toISOString(),
2627+
message: {
2628+
role: "user",
2629+
content: "ask the bee session to respond",
2630+
timestamp: now,
2631+
},
2632+
},
2633+
{
2634+
type: "message",
2635+
id: "assistant-1",
2636+
parentId: "user-1",
2637+
timestamp: new Date(now + 1).toISOString(),
2638+
message: {
2639+
role: "assistant",
2640+
content: [
2641+
{ type: "text", text: "I will ask the bee session and keep context." },
2642+
{
2643+
type: "toolCall",
2644+
id: "call-1",
2645+
name: "sessions_send",
2646+
arguments: { sessionKey: "agent:bee", message: "say bee" },
2647+
},
2648+
],
2649+
timestamp: now + 1,
2650+
},
2651+
},
2652+
{
2653+
type: "message",
2654+
id: "tool-1",
2655+
parentId: "assistant-1",
2656+
timestamp: new Date(now + 2).toISOString(),
2657+
message: {
2658+
role: "toolResult",
2659+
toolCallId: "call-1",
2660+
toolName: "sessions_send",
2661+
content: [{ type: "text", text: "delivered" }],
2662+
timestamp: now + 2,
2663+
},
2664+
},
2665+
],
2666+
} as ExtensionContext["sessionManager"];
2667+
const model = createAnthropicModelFixture();
2668+
setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 });
2669+
2670+
const mockEvent = {
2671+
preparation: {
2672+
messagesToSummarize: [] as AgentMessage[],
2673+
turnPrefixMessages: [] as AgentMessage[],
2674+
firstKeptEntryId: "entry-9",
2675+
tokensBefore: 38085,
2676+
fileOps: { read: [], edited: [], written: [] },
2677+
settings: { reserveTokens: 4000 },
2678+
isSplitTurn: true,
2679+
},
2680+
customInstructions: "",
2681+
signal: new AbortController().signal,
2682+
};
2683+
const { result } = await runCompactionScenario({
2684+
sessionManager,
2685+
event: mockEvent,
2686+
apiKey: "test-key",
2687+
});
2688+
2689+
const compaction = expectCompactionResult(result);
2690+
expect(compaction.summary).toContain("mixed branch summary");
2691+
expect(mockSummarizeInStages).toHaveBeenCalledTimes(1);
2692+
const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages));
2693+
const messages = requireArray(summarizeCall.messages);
2694+
expect(messages.map((message) => requireRecord(message).role)).toEqual(["user", "assistant"]);
2695+
const assistantMessage = requireRecord(messages[1]);
2696+
expect(assistantMessage.content).toEqual([
2697+
{ type: "text", text: "I will ask the bee session and keep context." },
2698+
]);
2699+
expect(JSON.stringify(messages)).not.toContain("sessions_send");
2700+
expect(JSON.stringify(messages)).not.toContain("delivered");
2701+
});
2702+
26142703
it("recovers user and assistant branch turns when compaction preparation has only tool output", async () => {
26152704
mockSummarizeInStages.mockReset();
26162705
mockSummarizeInStages.mockResolvedValue("branch summary with visible turns");

src/agents/agent-hooks/compaction-safeguard.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -193,21 +193,18 @@ function recordOrEmpty(value: unknown): Record<string, unknown> {
193193
: {};
194194
}
195195

196-
type ReplayUnsafeToolCalls = {
197-
found: boolean;
198-
ids: Set<string>;
199-
};
200-
201-
function collectReplayUnsafeMessagingToolCalls(message: AgentMessage): ReplayUnsafeToolCalls {
196+
function filterReplayUnsafeMessagingToolCalls(
197+
message: AgentMessage,
198+
replayUnsafeToolCallIds: Set<string>,
199+
): AgentMessage | undefined {
202200
const content = (message as { content?: unknown }).content;
203-
const ids = new Set<string>();
204-
let found = false;
205201
if (!Array.isArray(content)) {
206-
return { found, ids };
202+
return message;
207203
}
208-
for (const block of content) {
204+
let removed = false;
205+
const filteredContent = content.filter((block) => {
209206
if (!block || typeof block !== "object") {
210-
continue;
207+
return true;
211208
}
212209
const record = block as {
213210
type?: unknown;
@@ -217,21 +214,27 @@ function collectReplayUnsafeMessagingToolCalls(message: AgentMessage): ReplayUns
217214
input?: unknown;
218215
};
219216
if (typeof record.type !== "string" || !TOOL_CALL_BLOCK_TYPES.has(record.type)) {
220-
continue;
217+
return true;
221218
}
222219
if (typeof record.name !== "string" || !record.name.trim()) {
223-
continue;
220+
return true;
224221
}
225222
const args = recordOrEmpty(record.arguments ?? record.input);
226223
if (!isMessagingToolSendAction(record.name, args)) {
227-
continue;
224+
return true;
228225
}
229-
found = true;
226+
removed = true;
230227
if (typeof record.id === "string" && record.id.trim()) {
231-
ids.add(record.id.trim());
228+
replayUnsafeToolCallIds.add(record.id.trim());
232229
}
230+
return false;
231+
});
232+
if (!removed) {
233+
return message;
233234
}
234-
return { found, ids };
235+
return filteredContent.length > 0
236+
? ({ ...message, content: filteredContent } as AgentMessage)
237+
: undefined;
235238
}
236239

237240
function isReplayUnsafeMessagingToolResult(
@@ -267,13 +270,15 @@ function filterReplayUnsafeSessionBranchMessages(messages: AgentMessage[]): Agen
267270
skippingInterSessionReply = false;
268271
}
269272
if (role === "assistant") {
270-
const replayUnsafeToolCalls = collectReplayUnsafeMessagingToolCalls(message);
271-
if (replayUnsafeToolCalls.found) {
272-
for (const id of replayUnsafeToolCalls.ids) {
273-
replayUnsafeToolCallIds.add(id);
274-
}
273+
const filteredMessage = filterReplayUnsafeMessagingToolCalls(
274+
message,
275+
replayUnsafeToolCallIds,
276+
);
277+
if (!filteredMessage) {
275278
continue;
276279
}
280+
filtered.push(filteredMessage);
281+
continue;
277282
}
278283
if (isReplayUnsafeMessagingToolResult(message, replayUnsafeToolCallIds)) {
279284
continue;

0 commit comments

Comments
 (0)