Merge pull request #2794 from code-yeongyu/fix/2775-thinking-block-signatures

fix(thinking-block-validator): reuse signed thinking blocks instead of synthetic placeholders
This commit is contained in:
YeonGyu-Kim
2026-03-24 17:54:31 +09:00
committed by GitHub
4 changed files with 533 additions and 94 deletions

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "bun:test"
import { existsSync, readFileSync, rmSync } from "node:fs"
import { join } from "node:path"
import { detectErrorType } from "./index"
import { prependThinkingPart, prependThinkingPartAsync } from "./storage/thinking-prepend"
import { PART_STORAGE } from "../../shared/opencode-storage-paths"
const { describe, expect, it, mock } = require("bun:test")
describe("detectErrorType", () => {
describe("thinking_block_order errors", () => {
@@ -278,3 +283,249 @@ describe("detectErrorType", () => {
})
})
})
type StoredPartRecord = {
id: string
sessionID: string
messageID: string
type: string
signature?: string
thinking?: string
text?: string
}
function cleanupParts(messageID: string): void {
rmSync(join(PART_STORAGE, messageID), { recursive: true, force: true })
}
describe("thinking-prepend", () => {
it("writes the original signed thinking part verbatim for file-backed recovery", () => {
const sessionID = "ses_thinking_prepend_sync"
const targetMessageID = "msg_target_signed"
const originalPart = {
id: "prt_prev_signed",
sessionID,
messageID: "msg_prev_signed",
type: "thinking",
thinking: "prior reasoning",
signature: "sig_prev",
} as const satisfies StoredPartRecord
const result = prependThinkingPart(sessionID, targetMessageID, {
isSqliteBackend: () => false,
patchPart: async () => true,
log: mock(() => {}),
findLastThinkingPart: () => originalPart,
findLastThinkingPartFromSDK: async () => null,
readTargetPartIDs: () => ["prt_target_text"],
readTargetPartIDsFromSDK: async () => [],
})
expect(result).toBe(true)
const writtenPath = join(PART_STORAGE, targetMessageID, `${originalPart.id}.json`)
expect(existsSync(writtenPath)).toBe(true)
expect(JSON.parse(readFileSync(writtenPath, "utf-8"))).toEqual(originalPart)
cleanupParts(targetMessageID)
})
it("returns false without writing when no signed thinking part exists in history", () => {
const sessionID = "ses_thinking_prepend_sync_missing"
const targetMessageID = "msg_target_missing"
const result = prependThinkingPart(sessionID, targetMessageID, {
isSqliteBackend: () => false,
patchPart: async () => true,
log: mock(() => {}),
findLastThinkingPart: () => null,
findLastThinkingPartFromSDK: async () => null,
readTargetPartIDs: () => [],
readTargetPartIDsFromSDK: async () => [],
})
expect(result).toBe(false)
expect(existsSync(join(PART_STORAGE, targetMessageID))).toBe(false)
cleanupParts(targetMessageID)
})
it("returns false immediately when sqlite backend is active", () => {
const result = prependThinkingPart("ses_sqlite", "msg_sqlite", {
isSqliteBackend: () => true,
patchPart: async () => true,
log: mock(() => {}),
findLastThinkingPart: () => null,
findLastThinkingPartFromSDK: async () => null,
readTargetPartIDs: () => [],
readTargetPartIDsFromSDK: async () => [],
})
expect(result).toBe(false)
})
it("returns false when the reused signed thinking part would not sort before target parts", () => {
const sessionID = "ses_thinking_prepend_sync_out_of_order"
const targetMessageID = "msg_target_out_of_order"
const originalPart = {
id: "prt_z_reused",
sessionID,
messageID: "msg_prev_signed",
type: "thinking",
thinking: "prior reasoning",
signature: "sig_prev",
} as const satisfies StoredPartRecord
const result = prependThinkingPart(sessionID, targetMessageID, {
isSqliteBackend: () => false,
patchPart: async () => true,
log: mock(() => {}),
findLastThinkingPart: () => originalPart,
findLastThinkingPartFromSDK: async () => null,
readTargetPartIDs: () => ["prt_a_target"],
readTargetPartIDsFromSDK: async () => [],
})
expect(result).toBe(false)
expect(existsSync(join(PART_STORAGE, targetMessageID))).toBe(false)
})
it("patches the original signed thinking part verbatim for sdk-backed recovery", async () => {
const prependThinkingPartAsyncUntyped = Reflect.get(
{ prependThinkingPartAsync },
"prependThinkingPartAsync"
)
const sessionID = "ses_thinking_prepend_async"
const targetMessageID = "msg_target_async"
const patchPartMock = mock(async () => true)
const originalPart = {
id: "prt_prev_async",
type: "thinking",
thinking: "prior reasoning",
signature: "sig_async",
} as const
const client = {
session: {
messages: async () => ({
data: [
{
info: { id: "msg_prev_async", role: "assistant" },
parts: [originalPart],
},
{
info: { id: targetMessageID, role: "assistant" },
parts: [{ id: "prt_target_text", type: "text", text: "tool result" }],
},
],
}),
},
}
const result = await Reflect.apply(prependThinkingPartAsyncUntyped, undefined, [
client,
sessionID,
targetMessageID,
{
isSqliteBackend: () => false,
patchPart: patchPartMock,
log: mock(() => {}),
findLastThinkingPart: () => null,
findLastThinkingPartFromSDK: async () => originalPart,
readTargetPartIDs: () => [],
readTargetPartIDsFromSDK: async () => ["prt_target_text"],
},
])
expect(result).toBe(true)
expect(patchPartMock).toHaveBeenCalledTimes(1)
expect(patchPartMock.mock.calls[0]).toEqual([
client,
sessionID,
targetMessageID,
"prt_prev_async",
originalPart,
])
})
it("returns false without patching when sdk history has no signed thinking part", async () => {
const prependThinkingPartAsyncUntyped = Reflect.get(
{ prependThinkingPartAsync },
"prependThinkingPartAsync"
)
const sessionID = "ses_thinking_prepend_async_missing"
const targetMessageID = "msg_target_async_missing"
const patchPartMock = mock(async () => true)
const client = {
session: {
messages: async () => ({
data: [
{
info: { id: "msg_prev_async", role: "assistant" },
parts: [{ id: "prt_prev_reasoning", type: "reasoning", text: "unsigned reasoning" }],
},
{
info: { id: targetMessageID, role: "assistant" },
parts: [{ id: "prt_target_text", type: "text", text: "tool result" }],
},
],
}),
},
}
const result = await Reflect.apply(prependThinkingPartAsyncUntyped, undefined, [
client,
sessionID,
targetMessageID,
{
isSqliteBackend: () => false,
patchPart: patchPartMock,
log: mock(() => {}),
findLastThinkingPart: () => null,
findLastThinkingPartFromSDK: async () => null,
readTargetPartIDs: () => [],
readTargetPartIDsFromSDK: async () => ["prt_target_text"],
},
])
expect(result).toBe(false)
expect(patchPartMock).toHaveBeenCalledTimes(0)
})
it("returns false when the sdk reused signed thinking part would not sort before target parts", async () => {
const prependThinkingPartAsyncUntyped = Reflect.get(
{ prependThinkingPartAsync },
"prependThinkingPartAsync"
)
const sessionID = "ses_thinking_prepend_async_out_of_order"
const targetMessageID = "msg_target_async_out_of_order"
const patchPartMock = mock(async () => true)
const originalPart = {
id: "prt_z_reused",
type: "thinking",
thinking: "prior reasoning",
signature: "sig_async",
} as const
const client = {
session: {
messages: async () => ({ data: [] }),
},
}
const result = await Reflect.apply(prependThinkingPartAsyncUntyped, undefined, [
client,
sessionID,
targetMessageID,
{
isSqliteBackend: () => false,
patchPart: patchPartMock,
log: mock(() => {}),
findLastThinkingPart: () => null,
findLastThinkingPartFromSDK: async () => originalPart,
readTargetPartIDs: () => [],
readTargetPartIDsFromSDK: async () => ["prt_a_target"],
},
])
expect(result).toBe(false)
expect(patchPartMock).toHaveBeenCalledTimes(0)
})
})

View File

@@ -2,19 +2,115 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { PART_STORAGE, THINKING_TYPES } from "../constants"
import type { MessageData } from "../types"
import type { MessageData, StoredPart } from "../types"
import { readMessages } from "./messages-reader"
import { readParts } from "./parts-reader"
import { log, isSqliteBackend, patchPart } from "../../../shared"
import { normalizeSDKResponse } from "../../../shared"
type OpencodeClient = PluginInput["client"]
type StoredSignedThinkingPart = StoredPart & {
type: "thinking" | "redacted_thinking"
signature: string
}
type SDKMessagePart = NonNullable<MessageData["parts"]>[number]
type SDKSignedThinkingPart = SDKMessagePart & {
id: string
type: "thinking" | "redacted_thinking"
signature: string
}
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
type ThinkingPrependDeps = {
isSqliteBackend: typeof isSqliteBackend
patchPart: typeof patchPart
log: typeof log
findLastThinkingPart: typeof findLastThinkingPart
findLastThinkingPartFromSDK: typeof findLastThinkingPartFromSDK
readTargetPartIDs: typeof readTargetPartIDs
readTargetPartIDsFromSDK: typeof readTargetPartIDsFromSDK
}
const thinkingPrependDeps: ThinkingPrependDeps = {
isSqliteBackend,
patchPart,
log,
findLastThinkingPart,
findLastThinkingPartFromSDK,
readTargetPartIDs,
readTargetPartIDsFromSDK,
}
function readTargetPartIDs(messageID: string): string[] {
return readParts(messageID)
.map((part) => part.id)
.filter((id): id is string => typeof id === "string")
}
async function readTargetPartIDsFromSDK(
client: OpencodeClient,
sessionID: string,
messageID: string
): Promise<string[]> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
const targetMessage = messages.find((message) => message.info?.id === messageID)
if (!targetMessage?.parts) {
return []
}
return targetMessage.parts
.map((part) => part.id)
.filter((id): id is string => typeof id === "string")
} catch {
return []
}
}
function canPrependBeforeTargetParts(partID: string, targetPartIDs: string[]): boolean {
const firstTargetPartID = [...targetPartIDs].sort((left, right) => left.localeCompare(right))[0]
return !firstTargetPartID || partID.localeCompare(firstTargetPartID) < 0
}
function isStoredSignedThinkingPart(part: StoredPart): part is StoredSignedThinkingPart {
if (!THINKING_TYPES.has(part.type)) {
return false
}
if (part.type === "reasoning") {
return false
}
const signature = Reflect.get(part, "signature")
return typeof signature === "string" && signature.length > 0
}
function isSDKSignedThinkingPart(part: SDKMessagePart): part is SDKSignedThinkingPart {
if (!part.type || !THINKING_TYPES.has(part.type)) {
return false
}
if (part.type === "reasoning") {
return false
}
return typeof part.id === "string"
&& typeof (part as { signature?: unknown }).signature === "string"
&& ((part as { signature?: string }).signature?.length ?? 0) > 0
}
function toPatchBody(part: SDKSignedThinkingPart): Record<string, unknown> {
return { ...part }
}
function findLastThinkingPart(
sessionID: string,
beforeMessageID: string
): StoredSignedThinkingPart | null {
const messages = readMessages(sessionID)
const currentIndex = messages.findIndex((message) => message.id === beforeMessageID)
if (currentIndex === -1) return ""
if (currentIndex === -1) return null
for (let i = currentIndex - 1; i >= 0; i--) {
const message = messages[i]
@@ -22,63 +118,62 @@ function findLastThinkingContent(sessionID: string, beforeMessageID: string): st
const parts = readParts(message.id)
for (const part of parts) {
if (THINKING_TYPES.has(part.type)) {
const thinking = (part as { thinking?: string; text?: string }).thinking
const reasoning = (part as { thinking?: string; text?: string }).text
const content = thinking || reasoning
if (content && content.trim().length > 0) {
return content
}
if (isStoredSignedThinkingPart(part)) {
return part
}
}
}
return ""
return null
}
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
if (isSqliteBackend()) {
export function prependThinkingPart(
sessionID: string,
messageID: string,
deps: ThinkingPrependDeps = thinkingPrependDeps
): boolean {
if (deps.isSqliteBackend()) {
log("[session-recovery] Disabled on SQLite backend: prependThinkingPart (use async variant)")
return false
}
const previousThinkingPart = deps.findLastThinkingPart(sessionID, messageID)
if (!previousThinkingPart) {
return false
}
if (!canPrependBeforeTargetParts(previousThinkingPart.id, deps.readTargetPartIDs(messageID))) {
return false
}
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
const previousThinking = findLastThinkingContent(sessionID, messageID)
const partId = `prt_0000000000_${messageID}_thinking`
const part = {
id: partId,
sessionID,
messageID,
type: "thinking",
thinking: previousThinking || "[Continuing from previous reasoning]",
synthetic: true,
}
try {
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
writeFileSync(
join(partDir, `${previousThinkingPart.id}.json`),
JSON.stringify(previousThinkingPart, null, 2)
)
return true
} catch {
return false
}
}
async function findLastThinkingContentFromSDK(
async function findLastThinkingPartFromSDK(
client: OpencodeClient,
sessionID: string,
beforeMessageID: string
): Promise<string> {
): Promise<SDKSignedThinkingPart | null> {
try {
const response = await client.session.messages({ path: { id: sessionID } })
const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
const currentIndex = messages.findIndex((m) => m.info?.id === beforeMessageID)
if (currentIndex === -1) return ""
if (currentIndex === -1) return null
for (let i = currentIndex - 1; i >= 0; i--) {
const msg = messages[i]
@@ -86,39 +181,43 @@ async function findLastThinkingContentFromSDK(
if (!msg.parts) continue
for (const part of msg.parts) {
if (part.type && THINKING_TYPES.has(part.type)) {
const content = part.thinking || part.text
if (content && content.trim().length > 0) return content
if (isSDKSignedThinkingPart(part)) {
return part
}
}
}
} catch {
return ""
return null
}
return ""
return null
}
export async function prependThinkingPartAsync(
client: OpencodeClient,
sessionID: string,
messageID: string
messageID: string,
deps: ThinkingPrependDeps = thinkingPrependDeps
): Promise<boolean> {
const previousThinking = await findLastThinkingContentFromSDK(client, sessionID, messageID)
const previousThinkingPart = await deps.findLastThinkingPartFromSDK(client, sessionID, messageID)
if (!previousThinkingPart) {
return false
}
const partId = `prt_0000000000_${messageID}_thinking`
const part: Record<string, unknown> = {
id: partId,
sessionID,
messageID,
type: "thinking",
thinking: previousThinking || "[Continuing from previous reasoning]",
synthetic: true,
const targetPartIDs = await deps.readTargetPartIDsFromSDK(client, sessionID, messageID)
if (!canPrependBeforeTargetParts(previousThinkingPart.id, targetPartIDs)) {
return false
}
try {
return await patchPart(client, sessionID, messageID, partId, part)
return await deps.patchPart(
client,
sessionID,
messageID,
previousThinkingPart.id,
toPatchBody(previousThinkingPart)
)
} catch (error) {
log("[session-recovery] prependThinkingPartAsync failed", { error: String(error) })
deps.log("[session-recovery] prependThinkingPartAsync failed", { error: String(error) })
return false
}
}

View File

@@ -0,0 +1,108 @@
const { describe, expect, test } = require("bun:test")
const { createThinkingBlockValidatorHook } = require("./hook")
type TestPart = {
type: string
id: string
text?: string
thinking?: string
data?: string
signature?: string
}
type TestMessage = {
info: {
role: string
id?: string
modelID?: string
}
parts: TestPart[]
}
function createMessage(info: TestMessage["info"], parts: TestPart[]): TestMessage {
return { info, parts }
}
function createTextPart(id: string, text: string): TestPart {
return { type: "text", id, text }
}
function createSignedThinkingPart(id: string, thinking: string, signature: string): TestPart {
return { type: "thinking", id, thinking, signature }
}
function createRedactedThinkingPart(id: string, signature: string): TestPart {
return { type: "redacted_thinking", id, data: "encrypted", signature }
}
describe("createThinkingBlockValidatorHook", () => {
test("reuses the previous signed thinking part verbatim when assistant content lacks a leading thinking block", async () => {
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
expect(typeof transform).toBe("function")
const previousThinkingPart = createSignedThinkingPart("prt_prev_signed", "prior reasoning", "sig_prev")
const targetTextPart = createTextPart("prt_target_text", "tool result")
const messages: TestMessage[] = [
createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]),
createMessage({ role: "assistant", id: "msg_prev" }, [previousThinkingPart, createTextPart("prt_prev_text", "done")]),
createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]),
]
await Reflect.apply(transform, undefined, [{}, { messages }])
expect(messages[2]?.parts[0]).toBe(previousThinkingPart)
expect(messages[2]?.parts).toEqual([previousThinkingPart, targetTextPart])
})
test("skips injection when no signed Anthropic thinking part exists in history", async () => {
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
expect(typeof transform).toBe("function")
const targetTextPart = createTextPart("prt_target_text", "tool result")
const messages: TestMessage[] = [
createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]),
createMessage({ role: "assistant", id: "msg_prev" }, [{ type: "reasoning", id: "prt_reason", text: "gpt reasoning" }]),
createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]),
]
await Reflect.apply(transform, undefined, [{}, { messages }])
expect(messages[2]?.parts).toEqual([targetTextPart])
})
test("does not inject when the assistant message already starts with redacted thinking", async () => {
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
expect(typeof transform).toBe("function")
const existingThinkingPart = createRedactedThinkingPart("prt_redacted", "sig_redacted")
const targetTextPart = createTextPart("prt_target_text", "tool result")
const messages: TestMessage[] = [
createMessage({ role: "user", modelID: "claude-opus-4-6-thinking" }, [createTextPart("prt_user_text", "continue")]),
createMessage({ role: "assistant", id: "msg_target" }, [existingThinkingPart, targetTextPart]),
]
await Reflect.apply(transform, undefined, [{}, { messages }])
expect(messages[1]?.parts).toEqual([existingThinkingPart, targetTextPart])
})
test("skips processing for models without extended thinking", async () => {
const transform = Reflect.get(createThinkingBlockValidatorHook(), "experimental.chat.messages.transform")
expect(typeof transform).toBe("function")
const previousThinkingPart = createSignedThinkingPart("prt_prev_signed", "prior reasoning", "sig_prev")
const targetTextPart = createTextPart("prt_target_text", "tool result")
const messages: TestMessage[] = [
createMessage({ role: "user", modelID: "gpt-5.4" }, [createTextPart("prt_user_text", "continue")]),
createMessage({ role: "assistant", id: "msg_prev" }, [previousThinkingPart]),
createMessage({ role: "assistant", id: "msg_target" }, [targetTextPart]),
]
await Reflect.apply(transform, undefined, [{}, { messages }])
expect(messages[2]?.parts).toEqual([targetTextPart])
})
})
export {}

View File

@@ -21,16 +21,9 @@ interface MessageWithParts {
parts: Part[]
}
interface ThinkingPart {
thinking?: string
text?: string
}
interface MessageInfoExtended {
id: string
role: string
sessionID?: string
modelID?: string
type SignedThinkingPart = Part & {
type: "thinking" | "redacted_thinking"
signature: string
}
type MessagesTransformHook = {
@@ -83,57 +76,45 @@ function startsWithThinkingBlock(parts: Part[]): boolean {
const firstPart = parts[0]
const type = firstPart.type as string
return type === "thinking" || type === "reasoning"
return type === "thinking" || type === "redacted_thinking" || type === "reasoning"
}
/**
* Find the most recent thinking content from previous assistant messages
*/
function findPreviousThinkingContent(
function isSignedThinkingPart(part: Part): part is SignedThinkingPart {
const type = part.type as string
if (type !== "thinking" && type !== "redacted_thinking") {
return false
}
const signature = (part as { signature?: unknown }).signature
return typeof signature === "string" && signature.length > 0
}
function findPreviousThinkingPart(
messages: MessageWithParts[],
currentIndex: number
): string {
): SignedThinkingPart | null {
// Search backwards from current message
for (let i = currentIndex - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.info.role !== "assistant") continue
// Look for thinking parts
if (!msg.parts) continue
for (const part of msg.parts) {
const type = part.type as string
if (type === "thinking" || type === "reasoning") {
const thinking = (part as unknown as ThinkingPart).thinking || (part as unknown as ThinkingPart).text
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
return thinking
}
if (isSignedThinkingPart(part)) {
return part
}
}
}
return ""
return null
}
/**
* Prepend a thinking block to a message's parts array
*/
function prependThinkingBlock(message: MessageWithParts, thinkingContent: string): void {
function prependThinkingBlock(message: MessageWithParts, thinkingPart: SignedThinkingPart): void {
if (!message.parts) {
message.parts = []
}
// Create synthetic thinking part
const thinkingPart = {
type: "thinking" as const,
id: `prt_0000000000_synthetic_thinking`,
sessionID: (message.info as unknown as MessageInfoExtended).sessionID || "",
messageID: message.info.id,
thinking: thinkingContent,
synthetic: true,
}
// Prepend to parts array
message.parts.unshift(thinkingPart as unknown as Part)
message.parts.unshift(thinkingPart)
}
/**
@@ -150,7 +131,8 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
// Get the model info from the last user message
const lastUserMessage = messages.findLast(m => m.info.role === "user")
const modelID = (lastUserMessage?.info as unknown as MessageInfoExtended)?.modelID || ""
const modelIDValue = (lastUserMessage?.info as { modelID?: unknown } | undefined)?.modelID
const modelID = typeof modelIDValue === "string" ? modelIDValue : ""
// Only process if extended thinking might be enabled
if (!isExtendedThinkingModel(modelID)) {
@@ -166,13 +148,12 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
// Check if message has content parts but doesn't start with thinking
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
// Find thinking content from previous turns
const previousThinking = findPreviousThinkingContent(messages, i)
const previousThinkingPart = findPreviousThinkingPart(messages, i)
if (!previousThinkingPart) {
continue
}
// Prepend thinking block with content from previous turn or placeholder
const thinkingContent = previousThinking || "[Continuing from previous reasoning]"
prependThinkingBlock(msg, thinkingContent)
prependThinkingBlock(msg, previousThinkingPart)
}
}
},