Skip to content

Commit a49278b

Browse files
committed
fix: preserve thinking block signatures and fix compaction headroom asymmetry
Two compounding bugs caused sessions to crash with 'thinking blocks cannot be modified' when compaction fired for models with extended thinking: 1. toModelMessages() stripped providerMetadata (including cryptographic signatures) from message parts when the current model differed from the original. Anthropic's API requires signatures to be byte-identical. Fix: always pass providerMetadata through — the API handles filtering. 2. isOverflow() used an asymmetric buffer when limit.input was set (capped at 20K via COMPACTION_BUFFER) vs the full maxOutputTokens on the non-input path. This caused compaction to trigger too late. Fix: use maxOutputTokens (capped at 32K) for both paths. Also fixed the non-input path to respect config.compaction.reserved.
1 parent 59c530c commit a49278b

4 files changed

Lines changed: 201 additions & 28 deletions

File tree

packages/opencode/src/session/compaction.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ export namespace SessionCompaction {
2828
),
2929
}
3030

31-
const COMPACTION_BUFFER = 20_000
32-
3331
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
3432
const config = await Config.get()
3533
if (config.compaction?.auto === false) return false
@@ -40,11 +38,12 @@ export namespace SessionCompaction {
4038
input.tokens.total ||
4139
input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
4240

43-
const reserved =
44-
config.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
45-
const usable = input.model.limit.input
46-
? input.model.limit.input - reserved
47-
: context - ProviderTransform.maxOutputTokens(input.model)
41+
// Reserve headroom so compaction triggers before the next turn overflows.
42+
// maxOutputTokens() is capped at 32K (OUTPUT_TOKEN_MAX) regardless of the
43+
// model's raw output limit, so this is never excessively aggressive.
44+
// Users can override via config.compaction.reserved if needed (#12924).
45+
const reserved = config.compaction?.reserved ?? ProviderTransform.maxOutputTokens(input.model)
46+
const usable = input.model.limit.input ? input.model.limit.input - reserved : context - reserved
4847
return count >= usable
4948
}
5049

packages/opencode/src/session/message-v2.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,6 @@ export namespace MessageV2 {
666666
}
667667

668668
if (msg.info.role === "assistant") {
669-
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
670669
const media: Array<{ mime: string; url: string }> = []
671670

672671
if (
@@ -688,7 +687,7 @@ export namespace MessageV2 {
688687
assistantMessage.parts.push({
689688
type: "text",
690689
text: part.text,
691-
...(differentModel ? {} : { providerMetadata: part.metadata }),
690+
providerMetadata: part.metadata,
692691
})
693692
if (part.type === "step-start")
694693
assistantMessage.parts.push({
@@ -723,7 +722,7 @@ export namespace MessageV2 {
723722
toolCallId: part.callID,
724723
input: part.state.input,
725724
output,
726-
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
725+
callProviderMetadata: part.metadata,
727726
})
728727
}
729728
if (part.state.status === "error")
@@ -733,7 +732,7 @@ export namespace MessageV2 {
733732
toolCallId: part.callID,
734733
input: part.state.input,
735734
errorText: part.state.error,
736-
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
735+
callProviderMetadata: part.metadata,
737736
})
738737
// Handle pending/running tool calls to prevent dangling tool_use blocks
739738
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
@@ -744,14 +743,14 @@ export namespace MessageV2 {
744743
toolCallId: part.callID,
745744
input: part.state.input,
746745
errorText: "[Tool execution was interrupted]",
747-
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
746+
callProviderMetadata: part.metadata,
748747
})
749748
}
750749
if (part.type === "reasoning") {
751750
assistantMessage.parts.push({
752751
type: "reasoning",
753752
text: part.text,
754-
...(differentModel ? {} : { providerMetadata: part.metadata }),
753+
providerMetadata: part.metadata,
755754
})
756755
}
757756
}

packages/opencode/test/session/compaction.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,19 @@ describe("session.compaction.isOverflow", () => {
113113
})
114114
})
115115

116-
// ─── Bug reproduction tests ───────────────────────────────────────────
117-
// These tests demonstrate that when limit.input is set, isOverflow()
118-
// does not subtract any headroom for the next model response. This means
119-
// compaction only triggers AFTER we've already consumed the full input
120-
// budget, leaving zero room for the next API call's output tokens.
116+
// ─── Headroom reservation tests ──────────────────────────────────────
117+
// These tests verify that when limit.input is set, isOverflow()
118+
// correctly reserves headroom (maxOutputTokens, capped at 32K) so
119+
// compaction triggers before the next API call overflows.
121120
//
122-
// Compare: without limit.input, usable = context - output (reserves space).
123-
// With limit.input, usable = limit.input (reserves nothing).
121+
// Previously (bug), the limit.input path only subtracted a 20K buffer
122+
// while the non-input path subtracted the full maxOutputTokens — an
123+
// asymmetry that let sessions grow ~12K tokens too large before compacting.
124124
//
125125
// Related issues: #10634, #8089, #11086, #12621
126126
// Open PRs: #6875, #12924
127127

128-
test("BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", async () => {
128+
test("no headroom when limit.input is set — compaction should trigger near boundary", async () => {
129129
await using tmp = await tmpdir()
130130
await Instance.provide({
131131
directory: tmp.path,
@@ -151,7 +151,7 @@ describe("session.compaction.isOverflow", () => {
151151
})
152152
})
153153

154-
test("BUG: without limit.input, same token count correctly triggers compaction", async () => {
154+
test("without limit.input, same token count correctly triggers compaction", async () => {
155155
await using tmp = await tmpdir()
156156
await Instance.provide({
157157
directory: tmp.path,
@@ -171,7 +171,7 @@ describe("session.compaction.isOverflow", () => {
171171
})
172172
})
173173

174-
test("BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", async () => {
174+
test("asymmetry — limit.input model does not allow more usage than equivalent model without it", async () => {
175175
await using tmp = await tmpdir()
176176
await Instance.provide({
177177
directory: tmp.path,
@@ -180,7 +180,7 @@ describe("session.compaction.isOverflow", () => {
180180
const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 })
181181
const withoutInputLimit = createModel({ context: 200_000, output: 32_000 })
182182

183-
// 170K total tokens — well above context-output (168K) but below input limit (200K)
183+
// 181K total tokens — above usable (context - maxOutput = 168K)
184184
const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } }
185185

186186
const withLimit = await SessionCompaction.isOverflow({ tokens, model: withInputLimit })

packages/opencode/test/session/message-v2.test.ts

Lines changed: 179 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ const model: Provider.Model = {
5656
release_date: "2026-01-01",
5757
}
5858

59+
const model2: Provider.Model = {
60+
...model,
61+
id: "other-model",
62+
providerID: "other",
63+
api: {
64+
...model.api,
65+
id: "other-model",
66+
},
67+
name: "Other Model",
68+
}
69+
5970
function userInfo(id: string): MessageV2.User {
6071
return {
6172
id,
@@ -358,7 +369,90 @@ describe("session.message-v2.toModelMessage", () => {
358369
])
359370
})
360371

361-
test("omits provider metadata when assistant model differs", () => {
372+
test("preserves reasoning providerMetadata when model matches", () => {
373+
const assistantID = "m-assistant"
374+
375+
const input: MessageV2.WithParts[] = [
376+
{
377+
info: assistantInfo(assistantID, "m-parent"),
378+
parts: [
379+
{
380+
...basePart(assistantID, "a1"),
381+
type: "reasoning",
382+
text: "thinking",
383+
metadata: { openai: { signature: "sig-match" } },
384+
time: { start: 0 },
385+
},
386+
] as MessageV2.Part[],
387+
},
388+
]
389+
390+
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
391+
{
392+
role: "assistant",
393+
content: [{ type: "reasoning", text: "thinking", providerOptions: { openai: { signature: "sig-match" } } }],
394+
},
395+
])
396+
})
397+
398+
test("preserves reasoning providerMetadata when model differs", () => {
399+
const assistantID = "m-assistant"
400+
401+
const input: MessageV2.WithParts[] = [
402+
{
403+
info: assistantInfo(assistantID, "m-parent", undefined, {
404+
providerID: model2.providerID,
405+
modelID: model2.api.id,
406+
}),
407+
parts: [
408+
{
409+
...basePart(assistantID, "a1"),
410+
type: "reasoning",
411+
text: "thinking",
412+
metadata: { openai: { signature: "sig-different" } },
413+
time: { start: 0 },
414+
},
415+
] as MessageV2.Part[],
416+
},
417+
]
418+
419+
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
420+
{
421+
role: "assistant",
422+
content: [{ type: "reasoning", text: "thinking", providerOptions: { openai: { signature: "sig-different" } } }],
423+
},
424+
])
425+
})
426+
427+
test("preserves text providerMetadata when model differs", () => {
428+
const assistantID = "m-assistant"
429+
430+
const input: MessageV2.WithParts[] = [
431+
{
432+
info: assistantInfo(assistantID, "m-parent", undefined, {
433+
providerID: model2.providerID,
434+
modelID: model2.api.id,
435+
}),
436+
parts: [
437+
{
438+
...basePart(assistantID, "a1"),
439+
type: "text",
440+
text: "done",
441+
metadata: { openai: { assistant: "meta" } },
442+
},
443+
] as MessageV2.Part[],
444+
},
445+
]
446+
447+
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
448+
{
449+
role: "assistant",
450+
content: [{ type: "text", text: "done", providerOptions: { openai: { assistant: "meta" } } }],
451+
},
452+
])
453+
})
454+
455+
test("preserves tool callProviderMetadata when model differs", () => {
362456
const userID = "m-user"
363457
const assistantID = "m-assistant"
364458

@@ -374,16 +468,97 @@ describe("session.message-v2.toModelMessage", () => {
374468
] as MessageV2.Part[],
375469
},
376470
{
377-
info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }),
471+
info: assistantInfo(assistantID, userID, undefined, {
472+
providerID: model2.providerID,
473+
modelID: model2.api.id,
474+
}),
475+
parts: [
476+
{
477+
...basePart(assistantID, "a1"),
478+
type: "tool",
479+
callID: "call-1",
480+
tool: "bash",
481+
state: {
482+
status: "completed",
483+
input: { cmd: "ls" },
484+
output: "ok",
485+
title: "Bash",
486+
metadata: {},
487+
time: { start: 0, end: 1 },
488+
},
489+
metadata: { openai: { tool: "meta" } },
490+
},
491+
] as MessageV2.Part[],
492+
},
493+
]
494+
495+
expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
496+
{
497+
role: "user",
498+
content: [{ type: "text", text: "run tool" }],
499+
},
500+
{
501+
role: "assistant",
502+
content: [
503+
{
504+
type: "tool-call",
505+
toolCallId: "call-1",
506+
toolName: "bash",
507+
input: { cmd: "ls" },
508+
providerExecuted: undefined,
509+
providerOptions: { openai: { tool: "meta" } },
510+
},
511+
],
512+
},
513+
{
514+
role: "tool",
515+
content: [
516+
{
517+
type: "tool-result",
518+
toolCallId: "call-1",
519+
toolName: "bash",
520+
output: { type: "text", value: "ok" },
521+
providerOptions: { openai: { tool: "meta" } },
522+
},
523+
],
524+
},
525+
])
526+
})
527+
528+
test("handles undefined metadata gracefully", () => {
529+
const userID = "m-user"
530+
const assistantID = "m-assistant"
531+
532+
const input: MessageV2.WithParts[] = [
533+
{
534+
info: userInfo(userID),
535+
parts: [
536+
{
537+
...basePart(userID, "u1"),
538+
type: "text",
539+
text: "run tool",
540+
},
541+
] as MessageV2.Part[],
542+
},
543+
{
544+
info: assistantInfo(assistantID, userID, undefined, {
545+
providerID: model2.providerID,
546+
modelID: model2.api.id,
547+
}),
378548
parts: [
379549
{
380550
...basePart(assistantID, "a1"),
381551
type: "text",
382552
text: "done",
383-
metadata: { openai: { assistant: "meta" } },
384553
},
385554
{
386555
...basePart(assistantID, "a2"),
556+
type: "reasoning",
557+
text: "thinking",
558+
time: { start: 0 },
559+
},
560+
{
561+
...basePart(assistantID, "a3"),
387562
type: "tool",
388563
callID: "call-1",
389564
tool: "bash",
@@ -395,7 +570,6 @@ describe("session.message-v2.toModelMessage", () => {
395570
metadata: {},
396571
time: { start: 0, end: 1 },
397572
},
398-
metadata: { openai: { tool: "meta" } },
399573
},
400574
] as MessageV2.Part[],
401575
},
@@ -410,6 +584,7 @@ describe("session.message-v2.toModelMessage", () => {
410584
role: "assistant",
411585
content: [
412586
{ type: "text", text: "done" },
587+
{ type: "reasoning", text: "thinking", providerOptions: undefined },
413588
{
414589
type: "tool-call",
415590
toolCallId: "call-1",

0 commit comments

Comments
 (0)