diff --git a/src/features/background-agent/session-validator.ts b/src/features/background-agent/session-validator.ts index 6181dec9be..639e9316bb 100644 --- a/src/features/background-agent/session-validator.ts +++ b/src/features/background-agent/session-validator.ts @@ -70,6 +70,7 @@ export async function validateSessionHasOutput( if (type === "tool") return true if (type === "text" && hasNonEmptyText(part.text)) return true if (type === "reasoning" && hasNonEmptyText(part.text)) return true + if (type === "reasoning.encrypted" && hasNonEmptyText(part.text)) return true if (type === "tool_result" && isToolResultContentNonEmpty(part.content)) return true return false }) diff --git a/src/hooks/session-recovery/constants.ts b/src/hooks/session-recovery/constants.ts index a45b8026fa..b2230580d9 100644 --- a/src/hooks/session-recovery/constants.ts +++ b/src/hooks/session-recovery/constants.ts @@ -5,6 +5,6 @@ export const OPENCODE_STORAGE = getOpenCodeStorageDir() export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message") export const PART_STORAGE = join(OPENCODE_STORAGE, "part") -export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"]) +export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning", "reasoning.encrypted"]) export const META_TYPES = new Set(["step-start", "step-finish"]) export const CONTENT_TYPES = new Set(["text", "tool", "tool_use", "tool_result"]) diff --git a/src/hooks/session-recovery/reasoning-encrypted.test.ts b/src/hooks/session-recovery/reasoning-encrypted.test.ts new file mode 100644 index 0000000000..837b2cc684 --- /dev/null +++ b/src/hooks/session-recovery/reasoning-encrypted.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "bun:test" +import { THINKING_TYPES } from "./constants" + +describe("reasoning.encrypted support", () => { + //#given reasoning.encrypted is a valid thinking-family type from Grok models + + it("should include reasoning.encrypted in THINKING_TYPES Set", () => { + //#when checking if reasoning.encrypted is in THINKING_TYPES + const result = THINKING_TYPES.has("reasoning.encrypted") + + //#then it should return true + expect(result).toBe(true) + }) + + it("should recognize reasoning.encrypted as a ThinkingPartType", () => { + //#given a type assertion for reasoning.encrypted + //#when assigning to ThinkingPartType + const thinkingType: import("./types").ThinkingPartType = "reasoning.encrypted" + + //#then it should compile without error + expect(thinkingType).toBe("reasoning.encrypted") + }) +}) diff --git a/src/hooks/session-recovery/types.ts b/src/hooks/session-recovery/types.ts index 23d19fd935..b38a4d8164 100644 --- a/src/hooks/session-recovery/types.ts +++ b/src/hooks/session-recovery/types.ts @@ -1,4 +1,4 @@ -export type ThinkingPartType = "thinking" | "redacted_thinking" | "reasoning" +export type ThinkingPartType = "thinking" | "redacted_thinking" | "reasoning" | "reasoning.encrypted" export type MetaPartType = "step-start" | "step-finish" export type ContentPartType = "text" | "tool" | "tool_use" | "tool_result" diff --git a/src/hooks/thinking-block-validator/hook.ts b/src/hooks/thinking-block-validator/hook.ts index a4217b3e8d..99748f313f 100644 --- a/src/hooks/thinking-block-validator/hook.ts +++ b/src/hooks/thinking-block-validator/hook.ts @@ -71,7 +71,7 @@ function startsWithThinkingBlock(parts: Part[]): boolean { const firstPart = parts[0] const type = firstPart.type as string - return type === "thinking" || type === "reasoning" + return type === "thinking" || type === "reasoning" || type === "reasoning.encrypted" } /** @@ -90,7 +90,7 @@ function findPreviousThinkingContent( if (!msg.parts) continue for (const part of msg.parts) { const type = part.type as string - if (type === "thinking" || type === "reasoning") { + if (type === "thinking" || type === "reasoning" || type === "reasoning.encrypted") { const thinking = (part as any).thinking || (part as any).text if (thinking && typeof thinking === "string" && thinking.trim().length > 0) { return thinking diff --git a/src/hooks/thinking-block-validator/reasoning-encrypted.test.ts b/src/hooks/thinking-block-validator/reasoning-encrypted.test.ts new file mode 100644 index 0000000000..9c950c08f5 --- /dev/null +++ b/src/hooks/thinking-block-validator/reasoning-encrypted.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test" +import { createThinkingBlockValidatorHook } from "./hook" + +describe("reasoning.encrypted in thinking-block-validator", () => { + const hook = createThinkingBlockValidatorHook() + const transform = hook["experimental.chat.messages.transform"]! + + function makeMessages(msgs: Array<{ role: string; parts: Array>; modelID?: string }>) { + return msgs.map((m, i) => ({ + info: { id: `msg_${i}`, role: m.role, modelID: m.modelID } as Record, + parts: m.parts.map((p, j) => ({ id: `prt_${i}_${j}`, sessionID: "ses_test", messageID: `msg_${i}`, ...p })), + })) + } + + it("should not prepend thinking when message starts with reasoning.encrypted", async () => { + //#given an assistant message that starts with a reasoning.encrypted block + const messages = makeMessages([ + { role: "user", parts: [{ type: "text", text: "test" }], modelID: "claude-opus-4" }, + { role: "assistant", parts: [ + { type: "reasoning.encrypted", text: "encrypted reasoning" }, + { type: "text", text: "response" }, + ]}, + ]) + + //#when the hook processes the messages + const output = { messages } + await transform({} as Record, output) + + //#then no synthetic thinking block should be prepended + const assistantParts = output.messages[1].parts + expect(assistantParts[0].type).toBe("reasoning.encrypted") + expect(assistantParts).toHaveLength(2) + }) + + it("should find previous reasoning.encrypted content for messages missing thinking", async () => { + //#given a previous message with reasoning.encrypted and a current message without thinking + const messages = makeMessages([ + { role: "user", parts: [{ type: "text", text: "test" }], modelID: "claude-opus-4" }, + { role: "assistant", parts: [ + { type: "reasoning.encrypted", text: "previous encrypted reasoning" }, + { type: "text", text: "first response" }, + ]}, + { role: "user", parts: [{ type: "text", text: "follow up" }], modelID: "claude-opus-4" }, + { role: "assistant", parts: [ + { type: "tool_use", name: "bash" }, + ]}, + ]) + + //#when the hook processes the messages + const output = { messages } + await transform({} as Record, output) + + //#then the second assistant message should have a prepended thinking block with previous reasoning content + const secondAssistantParts = output.messages[3].parts + expect(secondAssistantParts[0].type).toBe("thinking") + expect((secondAssistantParts[0] as Record).thinking).toBe("previous encrypted reasoning") + }) +}) diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index 52a6ac86d8..72cfe05ea1 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -97,6 +97,9 @@ async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Pr if (part.type === "reasoning" && part.text) { chunks.push(part.text) } + if (part.type === "reasoning.encrypted" && part.text) { + chunks.push(part.text) + } } } diff --git a/src/tools/background-task/full-session-format.ts b/src/tools/background-task/full-session-format.ts index 9b50a09fb6..707bbd8ec4 100644 --- a/src/tools/background-task/full-session-format.ts +++ b/src/tools/background-task/full-session-format.ts @@ -81,7 +81,7 @@ export async function formatFullSession( const normalizedMessages: BackgroundOutputMessage[] = [] for (const message of filteredMessages) { const parts = (message.parts ?? []).filter((part) => { - if (part.type === "thinking" || part.type === "reasoning") { + if (part.type === "thinking" || part.type === "reasoning" || part.type === "reasoning.encrypted") { return includeThinking } if (part.type === "tool_result") { @@ -135,6 +135,8 @@ export async function formatFullSession( lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`) } else if (part.type === "reasoning" && part.text) { lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) + } else if (part.type === "reasoning.encrypted" && part.text) { + lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) } else if (part.type === "tool_result") { const toolTexts = extractToolResultText(part) for (const toolText of toolTexts) { diff --git a/src/tools/background-task/modules/formatters.ts b/src/tools/background-task/modules/formatters.ts index aa17e7d9e6..1645bf35dc 100644 --- a/src/tools/background-task/modules/formatters.ts +++ b/src/tools/background-task/modules/formatters.ts @@ -244,7 +244,7 @@ export async function formatFullSession( const normalizedMessages: FullSessionMessage[] = [] for (const message of filteredMessages) { const parts = (message.parts ?? []).filter((part) => { - if (part.type === "thinking" || part.type === "reasoning") { + if (part.type === "thinking" || part.type === "reasoning" || part.type === "reasoning.encrypted") { return includeThinking } if (part.type === "tool_result") { @@ -302,6 +302,8 @@ export async function formatFullSession( lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`) } else if (part.type === "reasoning" && part.text) { lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) + } else if (part.type === "reasoning.encrypted" && part.text) { + lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) } else if (part.type === "tool_result") { const toolTexts = extractToolResultText(part) for (const toolText of toolTexts) { diff --git a/src/tools/background-task/modules/message-processing.ts b/src/tools/background-task/modules/message-processing.ts index 18ac17316f..73d56d2f47 100644 --- a/src/tools/background-task/modules/message-processing.ts +++ b/src/tools/background-task/modules/message-processing.ts @@ -62,7 +62,7 @@ export function extractToolResultText(part: FullSessionMessagePart): string[] { if (Array.isArray(part.content)) { const blocks = part.content - .filter((block) => (block.type === "text" || block.type === "reasoning") && block.text) + .filter((block) => (block.type === "text" || block.type === "reasoning" || block.type === "reasoning.encrypted") && block.text) .map((block) => block.text as string) if (blocks.length > 0) return blocks } diff --git a/src/tools/background-task/reasoning-encrypted-extraction.test.ts b/src/tools/background-task/reasoning-encrypted-extraction.test.ts new file mode 100644 index 0000000000..009219a6cf --- /dev/null +++ b/src/tools/background-task/reasoning-encrypted-extraction.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test" + +describe("reasoning.encrypted content extraction", () => { + //#given content parts with reasoning.encrypted type + + it("should extract text from reasoning.encrypted part when text is present", () => { + //#given a part with reasoning.encrypted type and text + const part = { type: "reasoning.encrypted" as const, text: "decrypted reasoning content" } + + //#when filtering for text or reasoning.encrypted with text guard + const shouldInclude = !!((part.type === "text" || part.type === "reasoning" || part.type === "reasoning.encrypted") && part.text) + + //#then it should be included + expect(shouldInclude).toBe(true) + }) + + it("should skip reasoning.encrypted part when text is missing", () => { + //#given a part with reasoning.encrypted type but no text + const part = { type: "reasoning.encrypted" as const } + + //#when filtering for text or reasoning.encrypted with text guard + const shouldInclude = !!((part.type === "text" || part.type === "reasoning" || part.type === "reasoning.encrypted") && (part as any).text) + + //#then it should be excluded + expect(shouldInclude).toBe(false) + }) + + it("should extract text from reasoning part when text is present", () => { + //#given a part with reasoning type and text + const part = { type: "reasoning" as const, text: "reasoning content" } + + //#when filtering for text or reasoning with text guard + const shouldInclude = !!((part.type === "text" || part.type === "reasoning" || part.type === "reasoning.encrypted") && part.text) + + //#then it should be included + expect(shouldInclude).toBe(true) + }) + + it("should extract text from text part", () => { + //#given a part with text type + const part = { type: "text" as const, text: "text content" } + + //#when filtering for text or reasoning with text guard + const shouldInclude = !!((part.type === "text" || part.type === "reasoning" || part.type === "reasoning.encrypted") && part.text) + + //#then it should be included + expect(shouldInclude).toBe(true) + }) +}) diff --git a/src/tools/delegate-task/sync-result-fetcher.ts b/src/tools/delegate-task/sync-result-fetcher.ts index 64d1a27876..67c5c5a6b5 100644 --- a/src/tools/delegate-task/sync-result-fetcher.ts +++ b/src/tools/delegate-task/sync-result-fetcher.ts @@ -41,7 +41,7 @@ export async function fetchSyncResult( return { ok: false, error: `No assistant response found.\n\nSession ID: ${sessionID}` } } - const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] + const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning" || p.type === "reasoning.encrypted") ?? [] const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n") return { ok: true, textContent } diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 3f7b2fd991..01827a14fe 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -98,7 +98,7 @@ export async function pollSyncSession( if (m.info?.role !== "assistant") return false const parts = m.parts ?? [] return parts.some((p) => { - if (p.type !== "text" && p.type !== "reasoning") return false + if (p.type !== "text" && p.type !== "reasoning" && p.type !== "reasoning.encrypted") return false const text = (p.text ?? "").trim() return text.length > 0 }) diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts index ae97153bd2..caa84d463d 100644 --- a/src/tools/delegate-task/unstable-agent-task.ts +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -120,7 +120,7 @@ export async function executeUnstableAgentTask( return `No assistant response found (task ran in background mode).\n\nSession ID: ${sessionID}` } - const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? [] + const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning" || p.type === "reasoning.encrypted") ?? [] const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n") const duration = formatDuration(startTime)