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 { 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
108
src/hooks/thinking-block-validator/hook.test.ts
Normal file
108
src/hooks/thinking-block-validator/hook.test.ts
Normal 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 {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user