Skip to content

Commit 2cda289

Browse files
fix(opencode): strip thinking blocks from compaction input, guard orphaned boundaries (#268)
Fixes #267 — compaction fails with API error on Bedrock Claude models using extended thinking because reasoning blocks in the latest assistant message cannot be modified. Two independent fixes: 1. Add forCompaction option to toModelMessagesEffect that unconditionally downgrades reasoning blocks to plain text (regardless of model match), enforcing the spec invariant that reasoning does not survive compaction. Wire this option in the compaction caller. 2. Guard filterCompacted's second-phase reorder to require !msg.info.error on the summary-assistant, preventing an errored compaction from truncating pre-tail history (the 'context cleared' symptom).
1 parent 7256129 commit 2cda289

3 files changed

Lines changed: 135 additions & 3 deletions

File tree

‎packages/opencode/src/session/compaction.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ const layer = Layer.effect(
351351
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
352352
stripMedia: true,
353353
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
354+
forCompaction: true,
354355
})
355356
const ctx = yield* InstanceState.context
356357
const msg: SessionV1.Assistant = {

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ function providerMeta(metadata: Record<string, any> | undefined) {
131131
export const toModelMessagesEffect = Effect.fnUntraced(function* (
132132
input: WithParts[],
133133
model: Provider.Model,
134-
options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
134+
options?: { stripMedia?: boolean; toolOutputMaxChars?: number; forCompaction?: boolean },
135135
) {
136136
const result: UIMessage[] = []
137137
const toolNames = new Set<string>()
@@ -368,7 +368,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
368368
})
369369
}
370370
if (part.type === "reasoning") {
371-
if (differentModel) {
371+
if (differentModel || options?.forCompaction) {
372372
if (part.text.trim().length > 0)
373373
assistantMessage.parts.push({
374374
type: "text",
@@ -425,7 +425,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
425425
export function toModelMessages(
426426
input: WithParts[],
427427
model: Provider.Model,
428-
options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
428+
options?: { stripMedia?: boolean; toolOutputMaxChars?: number; forCompaction?: boolean },
429429
): Promise<ModelMessage[]> {
430430
return Effect.runPromise(toModelMessagesEffect(input, model, options))
431431
}
@@ -565,6 +565,7 @@ export function filterCompacted(msgs: Iterable<WithParts>) {
565565
index > compactionIndex &&
566566
msg.info.role === "assistant" &&
567567
msg.info.summary &&
568+
!msg.info.error &&
568569
msg.info.parentID === compaction.info.id,
569570
)
570571
: -1

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

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,6 +1361,40 @@ describe("session.message-v2.toModelMessage", () => {
13611361
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
13621362
expect(texts.map((t) => t.text)).toStrictEqual(["", "hello"])
13631363
})
1364+
1365+
test("strips reasoning blocks when forCompaction is true, even for same model", async () => {
1366+
// When building messages for the compaction agent, reasoning blocks must be
1367+
// unconditionally stripped (downgraded to text) regardless of model match.
1368+
// This prevents the Anthropic API error: "thinking blocks in the latest
1369+
// assistant message cannot be modified."
1370+
const assistantID = "m-assistant-compaction"
1371+
const input: SessionV1.WithParts[] = [
1372+
{
1373+
info: assistantInfo(assistantID, "m-parent"),
1374+
parts: [
1375+
{
1376+
...basePart(assistantID, "p1"),
1377+
type: "reasoning",
1378+
text: "deep thinking about the problem",
1379+
metadata: { anthropic: { signature: "sig-compaction" } },
1380+
},
1381+
{ ...basePart(assistantID, "p2"), type: "text", text: "the answer" },
1382+
] as SessionV1.Part[],
1383+
},
1384+
]
1385+
1386+
// Without forCompaction, same-model reasoning is preserved
1387+
const preserved = await MessageV2.toModelMessages(input, model)
1388+
const preservedParts = preserved[0].content as any[]
1389+
expect(preservedParts.some((p) => p.type === "reasoning")).toBe(true)
1390+
1391+
// With forCompaction, reasoning is stripped to text
1392+
const stripped = await MessageV2.toModelMessages(input, model, { forCompaction: true })
1393+
const strippedParts = stripped[0].content as any[]
1394+
expect(strippedParts.some((p) => p.type === "reasoning")).toBe(false)
1395+
expect(strippedParts.some((p) => p.type === "text" && p.text === "deep thinking about the problem")).toBe(true)
1396+
expect(strippedParts.some((p) => p.type === "text" && p.text === "the answer")).toBe(true)
1397+
})
13641398
})
13651399

13661400
describe("session.message-v2.fromError", () => {
@@ -1661,6 +1695,102 @@ describe("session.message-v2.latest", () => {
16611695
})
16621696
})
16631697

1698+
describe("session.message-v2.filterCompacted — orphaned compaction guard", () => {
1699+
const ANCIENT_USER = MessageID.make("msg_009")
1700+
const ANCIENT_ASSISTANT = MessageID.make("msg_010")
1701+
const TAIL_USER = MessageID.make("msg_011")
1702+
const TAIL_ASSISTANT = MessageID.make("msg_012")
1703+
const COMPACTION_USER = MessageID.make("msg_013")
1704+
const ERRORED_SUMMARY = MessageID.make("msg_014")
1705+
1706+
const ancientUser: SessionV1.WithParts = {
1707+
info: userInfo(ANCIENT_USER),
1708+
parts: [{ ...basePart(ANCIENT_USER, "p1"), type: "text", text: "ancient question" }] as SessionV1.Part[],
1709+
}
1710+
1711+
const ancientAssistant: SessionV1.WithParts = {
1712+
info: {
1713+
...assistantInfo(ANCIENT_ASSISTANT, ANCIENT_USER),
1714+
finish: "stop",
1715+
tokens: { input: 100, output: 100, reasoning: 0, cache: { read: 0, write: 0 }, total: 200 },
1716+
} as SessionV1.Assistant,
1717+
parts: [{ ...basePart(ANCIENT_ASSISTANT, "p1"), type: "text", text: "ancient response" }] as SessionV1.Part[],
1718+
}
1719+
1720+
const tailUser: SessionV1.WithParts = {
1721+
info: userInfo(TAIL_USER),
1722+
parts: [{ ...basePart(TAIL_USER, "p1"), type: "text", text: "recent question" }] as SessionV1.Part[],
1723+
}
1724+
1725+
const tailAssistant: SessionV1.WithParts = {
1726+
info: {
1727+
...assistantInfo(TAIL_ASSISTANT, TAIL_USER),
1728+
finish: "stop",
1729+
tokens: { input: 100, output: 100, reasoning: 0, cache: { read: 0, write: 0 }, total: 200 },
1730+
} as SessionV1.Assistant,
1731+
parts: [{ ...basePart(TAIL_ASSISTANT, "p1"), type: "text", text: "recent response" }] as SessionV1.Part[],
1732+
}
1733+
1734+
// tail_start_id points at TAIL_USER, meaning ancientUser + ancientAssistant
1735+
// would normally be "summarized away" — but only if the summary succeeds.
1736+
const compactionUser: SessionV1.WithParts = {
1737+
info: userInfo(COMPACTION_USER),
1738+
parts: [
1739+
{
1740+
...basePart(COMPACTION_USER, "p1"),
1741+
type: "compaction",
1742+
auto: true,
1743+
tail_start_id: TAIL_USER,
1744+
},
1745+
] as SessionV1.Part[],
1746+
}
1747+
1748+
test("errored summary does not truncate pre-tail history", () => {
1749+
// When the compaction LLM call fails (e.g. thinking block API error), the
1750+
// summary-assistant gets summary:true but also error set. filterCompacted
1751+
// must NOT use this as a valid boundary — the ancient messages must survive.
1752+
const erroredSummary: SessionV1.WithParts = {
1753+
info: {
1754+
...assistantInfo(ERRORED_SUMMARY, COMPACTION_USER),
1755+
summary: true,
1756+
finish: "error",
1757+
error: { name: "APIError", message: "thinking blocks cannot be modified" } as any,
1758+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 }, total: 0 },
1759+
} as SessionV1.Assistant,
1760+
parts: [],
1761+
}
1762+
1763+
// Newest-first (DB order)
1764+
const input: SessionV1.WithParts[] = [
1765+
erroredSummary,
1766+
compactionUser,
1767+
tailAssistant,
1768+
tailUser,
1769+
ancientAssistant,
1770+
ancientUser,
1771+
]
1772+
1773+
const result = MessageV2.filterCompacted(input)
1774+
1775+
// ALL messages must be preserved — errored compaction is not a valid boundary
1776+
expect(result).toHaveLength(6)
1777+
// Critically: ancient messages (before tail_start_id) must still be present
1778+
expect(result.some((m) => m.info.id === ANCIENT_USER)).toBe(true)
1779+
expect(result.some((m) => m.info.id === ANCIENT_ASSISTANT)).toBe(true)
1780+
})
1781+
1782+
test("missing summary does not truncate pre-tail history", () => {
1783+
// Compaction marker exists with tail_start_id but no summary-assistant
1784+
const input: SessionV1.WithParts[] = [compactionUser, tailAssistant, tailUser, ancientAssistant, ancientUser]
1785+
1786+
const result = MessageV2.filterCompacted(input)
1787+
1788+
// All messages preserved
1789+
expect(result).toHaveLength(5)
1790+
expect(result.some((m) => m.info.id === ANCIENT_USER)).toBe(true)
1791+
})
1792+
})
1793+
16641794
describe("session.message-v2.toModelMessage — skill parts", () => {
16651795
test("expands skill part content to text for the model", async () => {
16661796
const input: SessionV1.WithParts[] = [

0 commit comments

Comments
 (0)