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:
@@ -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 { 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("detectErrorType", () => {
|
||||||
describe("thinking_block_order errors", () => {
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,19 +2,115 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { PART_STORAGE, THINKING_TYPES } from "../constants"
|
import { PART_STORAGE, THINKING_TYPES } from "../constants"
|
||||||
import type { MessageData } from "../types"
|
import type { MessageData, StoredPart } from "../types"
|
||||||
import { readMessages } from "./messages-reader"
|
import { readMessages } from "./messages-reader"
|
||||||
import { readParts } from "./parts-reader"
|
import { readParts } from "./parts-reader"
|
||||||
import { log, isSqliteBackend, patchPart } from "../../../shared"
|
import { log, isSqliteBackend, patchPart } from "../../../shared"
|
||||||
import { normalizeSDKResponse } from "../../../shared"
|
import { normalizeSDKResponse } from "../../../shared"
|
||||||
|
|
||||||
type OpencodeClient = PluginInput["client"]
|
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 messages = readMessages(sessionID)
|
||||||
|
|
||||||
const currentIndex = messages.findIndex((message) => message.id === beforeMessageID)
|
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--) {
|
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||||
const message = messages[i]
|
const message = messages[i]
|
||||||
@@ -22,63 +118,62 @@ function findLastThinkingContent(sessionID: string, beforeMessageID: string): st
|
|||||||
|
|
||||||
const parts = readParts(message.id)
|
const parts = readParts(message.id)
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (THINKING_TYPES.has(part.type)) {
|
if (isStoredSignedThinkingPart(part)) {
|
||||||
const thinking = (part as { thinking?: string; text?: string }).thinking
|
return part
|
||||||
const reasoning = (part as { thinking?: string; text?: string }).text
|
|
||||||
const content = thinking || reasoning
|
|
||||||
if (content && content.trim().length > 0) {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
export function prependThinkingPart(
|
||||||
if (isSqliteBackend()) {
|
sessionID: string,
|
||||||
|
messageID: string,
|
||||||
|
deps: ThinkingPrependDeps = thinkingPrependDeps
|
||||||
|
): boolean {
|
||||||
|
if (deps.isSqliteBackend()) {
|
||||||
log("[session-recovery] Disabled on SQLite backend: prependThinkingPart (use async variant)")
|
log("[session-recovery] Disabled on SQLite backend: prependThinkingPart (use async variant)")
|
||||||
return false
|
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)
|
const partDir = join(PART_STORAGE, messageID)
|
||||||
|
|
||||||
if (!existsSync(partDir)) {
|
if (!existsSync(partDir)) {
|
||||||
mkdirSync(partDir, { recursive: true })
|
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 {
|
try {
|
||||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
writeFileSync(
|
||||||
|
join(partDir, `${previousThinkingPart.id}.json`),
|
||||||
|
JSON.stringify(previousThinkingPart, null, 2)
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findLastThinkingContentFromSDK(
|
async function findLastThinkingPartFromSDK(
|
||||||
client: OpencodeClient,
|
client: OpencodeClient,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
beforeMessageID: string
|
beforeMessageID: string
|
||||||
): Promise<string> {
|
): Promise<SDKSignedThinkingPart | null> {
|
||||||
try {
|
try {
|
||||||
const response = await client.session.messages({ path: { id: sessionID } })
|
const response = await client.session.messages({ path: { id: sessionID } })
|
||||||
const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
|
const messages = normalizeSDKResponse(response, [] as MessageData[], { preferResponseOnMissingData: true })
|
||||||
|
|
||||||
const currentIndex = messages.findIndex((m) => m.info?.id === beforeMessageID)
|
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--) {
|
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||||
const msg = messages[i]
|
const msg = messages[i]
|
||||||
@@ -86,39 +181,43 @@ async function findLastThinkingContentFromSDK(
|
|||||||
if (!msg.parts) continue
|
if (!msg.parts) continue
|
||||||
|
|
||||||
for (const part of msg.parts) {
|
for (const part of msg.parts) {
|
||||||
if (part.type && THINKING_TYPES.has(part.type)) {
|
if (isSDKSignedThinkingPart(part)) {
|
||||||
const content = part.thinking || part.text
|
return part
|
||||||
if (content && content.trim().length > 0) return content
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return ""
|
return null
|
||||||
}
|
}
|
||||||
return ""
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prependThinkingPartAsync(
|
export async function prependThinkingPartAsync(
|
||||||
client: OpencodeClient,
|
client: OpencodeClient,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
messageID: string
|
messageID: string,
|
||||||
|
deps: ThinkingPrependDeps = thinkingPrependDeps
|
||||||
): Promise<boolean> {
|
): 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 targetPartIDs = await deps.readTargetPartIDsFromSDK(client, sessionID, messageID)
|
||||||
const part: Record<string, unknown> = {
|
if (!canPrependBeforeTargetParts(previousThinkingPart.id, targetPartIDs)) {
|
||||||
id: partId,
|
return false
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
type: "thinking",
|
|
||||||
thinking: previousThinking || "[Continuing from previous reasoning]",
|
|
||||||
synthetic: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await patchPart(client, sessionID, messageID, partId, part)
|
return await deps.patchPart(
|
||||||
|
client,
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
previousThinkingPart.id,
|
||||||
|
toPatchBody(previousThinkingPart)
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("[session-recovery] prependThinkingPartAsync failed", { error: String(error) })
|
deps.log("[session-recovery] prependThinkingPartAsync failed", { error: String(error) })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -21,16 +21,9 @@ interface MessageWithParts {
|
|||||||
parts: Part[]
|
parts: Part[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThinkingPart {
|
type SignedThinkingPart = Part & {
|
||||||
thinking?: string
|
type: "thinking" | "redacted_thinking"
|
||||||
text?: string
|
signature: string
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageInfoExtended {
|
|
||||||
id: string
|
|
||||||
role: string
|
|
||||||
sessionID?: string
|
|
||||||
modelID?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessagesTransformHook = {
|
type MessagesTransformHook = {
|
||||||
@@ -83,57 +76,45 @@ function startsWithThinkingBlock(parts: Part[]): boolean {
|
|||||||
|
|
||||||
const firstPart = parts[0]
|
const firstPart = parts[0]
|
||||||
const type = firstPart.type as string
|
const type = firstPart.type as string
|
||||||
return type === "thinking" || type === "reasoning"
|
return type === "thinking" || type === "redacted_thinking" || type === "reasoning"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function isSignedThinkingPart(part: Part): part is SignedThinkingPart {
|
||||||
* Find the most recent thinking content from previous assistant messages
|
const type = part.type as string
|
||||||
*/
|
if (type !== "thinking" && type !== "redacted_thinking") {
|
||||||
function findPreviousThinkingContent(
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = (part as { signature?: unknown }).signature
|
||||||
|
return typeof signature === "string" && signature.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPreviousThinkingPart(
|
||||||
messages: MessageWithParts[],
|
messages: MessageWithParts[],
|
||||||
currentIndex: number
|
currentIndex: number
|
||||||
): string {
|
): SignedThinkingPart | null {
|
||||||
// Search backwards from current message
|
// Search backwards from current message
|
||||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||||
const msg = messages[i]
|
const msg = messages[i]
|
||||||
if (msg.info.role !== "assistant") continue
|
if (msg.info.role !== "assistant") continue
|
||||||
|
|
||||||
// Look for thinking parts
|
|
||||||
if (!msg.parts) continue
|
if (!msg.parts) continue
|
||||||
for (const part of msg.parts) {
|
for (const part of msg.parts) {
|
||||||
const type = part.type as string
|
if (isSignedThinkingPart(part)) {
|
||||||
if (type === "thinking" || type === "reasoning") {
|
return part
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function prependThinkingBlock(message: MessageWithParts, thinkingPart: SignedThinkingPart): void {
|
||||||
* Prepend a thinking block to a message's parts array
|
|
||||||
*/
|
|
||||||
function prependThinkingBlock(message: MessageWithParts, thinkingContent: string): void {
|
|
||||||
if (!message.parts) {
|
if (!message.parts) {
|
||||||
message.parts = []
|
message.parts = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create synthetic thinking part
|
message.parts.unshift(thinkingPart)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +131,8 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
|||||||
|
|
||||||
// Get the model info from the last user message
|
// Get the model info from the last user message
|
||||||
const lastUserMessage = messages.findLast(m => m.info.role === "user")
|
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
|
// Only process if extended thinking might be enabled
|
||||||
if (!isExtendedThinkingModel(modelID)) {
|
if (!isExtendedThinkingModel(modelID)) {
|
||||||
@@ -166,13 +148,12 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
|||||||
|
|
||||||
// Check if message has content parts but doesn't start with thinking
|
// Check if message has content parts but doesn't start with thinking
|
||||||
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||||
// Find thinking content from previous turns
|
const previousThinkingPart = findPreviousThinkingPart(messages, i)
|
||||||
const previousThinking = findPreviousThinkingContent(messages, i)
|
if (!previousThinkingPart) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Prepend thinking block with content from previous turn or placeholder
|
prependThinkingBlock(msg, previousThinkingPart)
|
||||||
const thinkingContent = previousThinking || "[Continuing from previous reasoning]"
|
|
||||||
|
|
||||||
prependThinkingBlock(msg, thinkingContent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user