Skip to content

Commit 9b3736e

Browse files
committed
fix(condense): pair stranded tool_use blocks on the send path
1 parent 4170e74 commit 9b3736e

2 files changed

Lines changed: 91 additions & 9 deletions

File tree

src/core/condense/__tests__/index.spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ describe("injectSyntheticToolResults", () => {
124124
]
125125

126126
const result = injectSyntheticToolResults(messages)
127-
expect(result).toEqual(messages)
127+
expect(result).toBe(messages)
128128
})
129129

130130
it("should inject synthetic tool_result for orphan tool_call", () => {
@@ -250,6 +250,75 @@ describe("injectSyntheticToolResults", () => {
250250
// Both tool_uses have matching tool_results, no injection needed
251251
expect(result).toEqual(messages)
252252
})
253+
254+
it("should independently pair two consecutive assistant tool_use turns", () => {
255+
// Regression guard: the per-assistant-message loop must pair EACH turn independently.
256+
// A fix that only patches the first orphan and skips subsequent ones would pass the
257+
// single-orphan cases above but fail here.
258+
const messages: ApiMessage[] = [
259+
{ role: "user", content: "Hello", ts: 1 },
260+
{
261+
role: "assistant",
262+
content: [{ type: "tool_use", id: "tool-A", name: "read_file", input: { path: "a.ts" } }],
263+
ts: 2,
264+
},
265+
{
266+
role: "user",
267+
content: [{ type: "tool_result", tool_use_id: "tool-A", content: "contents a" }],
268+
ts: 3,
269+
},
270+
{
271+
role: "assistant",
272+
content: [{ type: "tool_use", id: "tool-B", name: "read_file", input: { path: "b.ts" } }],
273+
ts: 4,
274+
},
275+
// No tool_result for tool-B
276+
]
277+
278+
const result = injectSyntheticToolResults(messages)
279+
280+
// 4 input messages + 1 synthetic = 5
281+
expect(result.length).toBe(5)
282+
// First pair must be unchanged.
283+
expect((result[2].content as any[])[0]).toMatchObject({ type: "tool_result", tool_use_id: "tool-A" })
284+
// Synthetic for second orphan must be spliced immediately after the second assistant.
285+
expect(result[4].role).toBe("user")
286+
const synthContent = result[4].content as any[]
287+
expect(synthContent).toHaveLength(1)
288+
expect(synthContent[0]).toMatchObject({ type: "tool_result", tool_use_id: "tool-B" })
289+
})
290+
291+
it("should merge synthetic for missing tool_use_id into first surviving user message when result set is partial", () => {
292+
// Two tool_uses on one assistant turn; the first result survives, the second was filtered
293+
// away. The synthetic for the missing id must be merged into the existing user message
294+
// (which carries the first result) rather than appended as a new third message.
295+
const messages: ApiMessage[] = [
296+
{ role: "user", content: "Hello", ts: 1 },
297+
{
298+
role: "assistant",
299+
content: [
300+
{ type: "tool_use", id: "tool-present", name: "read_file", input: { path: "a.ts" } },
301+
{ type: "tool_use", id: "tool-missing", name: "read_file", input: { path: "b.ts" } },
302+
],
303+
ts: 2,
304+
},
305+
{
306+
role: "user",
307+
content: [{ type: "tool_result", tool_use_id: "tool-present", content: "contents a" }],
308+
ts: 3,
309+
},
310+
// tool-missing has no result
311+
]
312+
313+
const result = injectSyntheticToolResults(messages)
314+
315+
// Synthetic merges into existing user message — length stays 3.
316+
expect(result.length).toBe(3)
317+
const merged = result[2].content as any[]
318+
expect(merged).toHaveLength(2)
319+
expect(merged[0]).toMatchObject({ type: "tool_result", tool_use_id: "tool-present" })
320+
expect(merged[1]).toMatchObject({ type: "tool_result", tool_use_id: "tool-missing" })
321+
})
253322
})
254323

255324
describe("getMessagesSinceLastSummary", () => {

src/core/condense/index.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ The goal is for work to continue seamlessly after condensation - as if it never
140140
* following user message, adding synthetic placeholders for any that are missing.
141141
* Existing tool_result blocks are left untouched. Emits MissingToolResultError telemetry
142142
* when it fires.
143+
*
144+
* @param reason - Content for injected synthetic blocks. Defaults to
145+
* `SYNTHETIC_TOOL_RESULT_REASONS.condense` (condensation caller); use
146+
* `SYNTHETIC_TOOL_RESULT_REASONS.historyShaping` at the send path.
143147
*/
144148
export function injectSyntheticToolResults(
145149
messages: ApiMessage[],
@@ -151,14 +155,15 @@ export function injectSyntheticToolResults(
151155
// after the orphan-bearing assistant — what the provider sees as "the next user
152156
// message" once `mergeConsecutiveApiMessages` fuses them. A truncation marker stops
153157
// the span (merge refuses to cross one).
154-
const out: ApiMessage[] = messages.map((msg) => msg)
158+
let out: ApiMessage[] | undefined
155159
const allOrphanIds: string[] = []
156160
const allExistingResultIds = new Set<string>()
157161
let toolUseCount = 0
158162
let toolResultCount = 0
159163

160-
for (let i = 0; i < out.length; i++) {
161-
const msg = out[i]
164+
for (let i = 0; i < (out ?? messages).length; i++) {
165+
const work = out ?? messages
166+
const msg = work[i]
162167
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
163168
continue
164169
}
@@ -177,8 +182,8 @@ export function injectSyntheticToolResults(
177182

178183
let spanEnd = i + 1
179184
const spanResultIds = new Set<string>()
180-
while (spanEnd < out.length) {
181-
const candidate = out[spanEnd]
185+
while (spanEnd < work.length) {
186+
const candidate = work[spanEnd]
182187
if (candidate.role !== "user") break
183188
if (Array.isArray(candidate.content)) {
184189
for (const block of candidate.content) {
@@ -204,6 +209,11 @@ export function injectSyntheticToolResults(
204209

205210
allOrphanIds.push(...missing)
206211

212+
// Clone lazily — only on first mutation.
213+
if (!out) {
214+
out = [...messages]
215+
}
216+
207217
const syntheticResults: Anthropic.Messages.ToolResultBlockParam[] = missing.map((id) => ({
208218
type: "tool_result" as const,
209219
tool_use_id: id,
@@ -241,25 +251,28 @@ export function injectSyntheticToolResults(
241251
// Mirror the validateToolResultIds.ts telemetry shape so PostHog dashboards keyed off
242252
// MissingToolResultError already aggregate this. The `reason` tag lets us split sources
243253
// once data is in.
254+
const existingResultIds = [...allExistingResultIds]
244255
if (TelemetryService.hasInstance()) {
245256
TelemetryService.instance.captureException(
246257
new MissingToolResultError(
247258
`injectSyntheticToolResults paired ${allOrphanIds.length} orphan tool_use block(s). reason=${reason}`,
248259
allOrphanIds,
249-
[...allExistingResultIds],
260+
existingResultIds,
250261
),
251262
{
252263
reason,
253264
missingToolUseIds: allOrphanIds,
254-
existingToolResultIds: [...allExistingResultIds],
265+
existingToolResultIds: existingResultIds,
255266
toolUseCount,
256267
toolResultCount,
257268
source: "injectSyntheticToolResults",
258269
},
259270
)
260271
}
261272

262-
return out
273+
// out is guaranteed non-undefined here: allOrphanIds.length > 0 means at least one
274+
// mutation occurred, which always initialises out before pushing to allOrphanIds.
275+
return out!
263276
}
264277

265278
/**

0 commit comments

Comments
 (0)