From 0732cb85f9145dc4fe4412be61f7b7cd558bb887 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Mar 2026 17:22:07 +0900 Subject: [PATCH 1/2] fix(thinking-block-validator): reuse signed thinking parts Preserve prior signed Anthropic thinking blocks instead of creating unsigned synthetic placeholders, and skip injection when no signed block exists. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../thinking-block-validator/hook.test.ts | 108 ++++++++++++++++++ src/hooks/thinking-block-validator/hook.ts | 75 +++++------- 2 files changed, 136 insertions(+), 47 deletions(-) create mode 100644 src/hooks/thinking-block-validator/hook.test.ts diff --git a/src/hooks/thinking-block-validator/hook.test.ts b/src/hooks/thinking-block-validator/hook.test.ts new file mode 100644 index 000000000..9eab6dff6 --- /dev/null +++ b/src/hooks/thinking-block-validator/hook.test.ts @@ -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 {} diff --git a/src/hooks/thinking-block-validator/hook.ts b/src/hooks/thinking-block-validator/hook.ts index 18a4d3e0b..67b6c12c0 100644 --- a/src/hooks/thinking-block-validator/hook.ts +++ b/src/hooks/thinking-block-validator/hook.ts @@ -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) } } }, From 0e7bd595f8751a1cdb4208de99c917340ebaca3f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 24 Mar 2026 17:22:07 +0900 Subject: [PATCH 2/2] fix(session-recovery): reuse signed thinking blocks safely Reuse signed Anthropic thinking blocks only when they can still sort before the target message's parts, otherwise skip recovery instead of reintroducing invalid loops. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/session-recovery/index.test.ts | 253 +++++++++++++++++- .../storage/thinking-prepend.ts | 191 +++++++++---- 2 files changed, 397 insertions(+), 47 deletions(-) diff --git a/src/hooks/session-recovery/index.test.ts b/src/hooks/session-recovery/index.test.ts index 257fe05a5..1cb3768d9 100644 --- a/src/hooks/session-recovery/index.test.ts +++ b/src/hooks/session-recovery/index.test.ts @@ -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) + }) +}) diff --git a/src/hooks/session-recovery/storage/thinking-prepend.ts b/src/hooks/session-recovery/storage/thinking-prepend.ts index 464898c9f..9ccb7131c 100644 --- a/src/hooks/session-recovery/storage/thinking-prepend.ts +++ b/src/hooks/session-recovery/storage/thinking-prepend.ts @@ -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[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 { + 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 { + 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 { +): Promise { 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 { - 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 = { - 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 } }