diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca99d695..f16de4cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
## 未发布
+- 保留路径不再给没有可用签名的 thinking 补 `signature: ""`:缺失、空、短于 24、dummy 的删掉;签名可用的留,空正文也留。这一支里的 `redacted_thinking` 不看 data 和签名,原样留;不走保留路径时仍按原规则删。5.5 的非保留路径仍接受短的非 dummy 签名。没有按账号归属过滤签名。
+- 协议页新增「thinking 显示」,`routing.compatibility.thinking_display` 默认 `summarized`。调用方打开 thinking 却要求隐藏(`omitted`、`updates`,或默认隐藏的模型上没写 display)时,出站改成 `summarized`;显式 `passthrough` 保留调用方的值。已保存的 `routing.json` 不写这个键也按 summarized 生效。
+
## 1.3.199 — 2026-10-19
- 修复集群槽位凭据 pull 时 bytes 相同仍回填 VM 元数据;已运行容器不重建;节点凭证不被旧副本覆盖;原手动禁用 / 撤销 / 冷却保留。(#589)
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 83a1b37d..34591333 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -121,18 +121,20 @@ Rikka / 客户端 `search_web`、`scrape_web` **不是** Anthropic 自带搜索
1. `thinking` 即使文本为空,只要有完整签名也保留;这是 `display: omitted` 的正常响应形态。
2. Opus / Sonnet / Haiku 5.5 按 sub2api:非空且非 dummy 的签名都算有效,不看长度;`redacted_thinking` 有 `data` 就留,没有 `data` 时再看签名。历史里只要有一块无效,整段 thinking / `redacted_thinking` 都拆掉,只留正文;拆完没有内容的轮删掉。全部有效时不拆。
3. 更早的型号仍逐块过滤:签名短于 24、空签名、dummy 签名删掉,合法签名块留下。`redacted_thinking` 不看 `data`,只在签名达到 24 字符时留。
-4. 保留路径(cli-hop 首跳,以及默认 HTTP hop)不走上面的拆链。缺少 `signature` 字段的 `thinking` 补空字段,已有字段不改写。
+4. 保留路径(cli-hop 首跳,以及默认 HTTP hop)不走上面的拆链,只在 `stripInvalidThinkingBlocks` 的这一支里处理:`redacted_thinking` 不看 `data` 和签名,原样留。`thinking` 只有签名可用(非空、不少于 24、非 dummy)才留,空正文也留;缺失、空、短于 24、dummy 的删掉,不再补 `signature: ""`。不走保留路径时,`redacted_thinking` 仍按上面第 2、3 条删。当前没有按账号归属过滤签名的步骤。
-HTTP hop 与 cli-hop(`prepareCliHopBody`)共用内容预过滤,但缓存职责不同。cli-hop 首跳保留 thinking 指纹,缺失签名补 `signature: ""`;签名修复重试才清除。官方家族开启 `official_cc_passthrough` 时,Node 保留 system/tools/messages 的 caller marker、显式 TTL、缺失 TTL 和位置,不添点、不裁剪;超过四点、thinking marker、非法 TTL 或 5m(含缺失)后出现 1h,在调度前返回 `400 invalid_cache_control`。
+HTTP hop 与 cli-hop(`prepareCliHopBody`)共用内容预过滤,但缓存职责不同。cli-hop 首跳保留签名可用的 thinking 指纹,没有可用签名的删掉;签名修复重试才清除其余块。官方家族开启 `official_cc_passthrough` 时,Node 保留 system/tools/messages 的 caller marker、显式 TTL、缺失 TTL 和位置,不添点、不裁剪;超过四点、thinking marker、非法 TTL 或 5m(含缺失)后出现 1h,在调度前返回 `400 invalid_cache_control`。
关闭透传或非官方请求仍走 Node rewrite:清除 caller marker,生成最后消息及 `messages.length>=4` 时倒数第二 user 的点,TTL 用入站会话 pin 值。应用 persona 后,模板显式 TTL 保留,只补自产缺失 TTL;超额时优先删 tools、system 尾部的自产点。非法模板返回 request-scoped 400,零上游执行,不换号、不冷却。
native CLI `2.1.296` 保留带 billing 的 wire system(含官方 CLI / 子代理布局)、wire tools 和 wire messages,不自动给最后一个 tool 加点;caller tool 的缺失 TTL 也保持缺失。仅对 CLI 自有 marker 补 TTL,优先请求显式 TTL(混合时 5m)→ slot `kernel.json.default_cache_ttl` → 1h,并按保留内容占用后的剩余预算裁剪。Rust kernel 不增删 marker、不重打 last、不自动降级 TTL。Node native envelope 不用菜单 TTL 重分类上游 usage,日志/计费保留真实 5m/1h 分桶。Node 对象不等于最终 wire;HTTP hop 原有独立缓存整流不变。
-长度够的签名原样转发,由 Anthropic 验。5.5 的非 dummy 签名也原样转发,不按 24 字符预过滤。签名是 opaque 加密内容,当前模型 / 账户 / 会话前缀不匹配时可能被丢弃或返回 400 `Invalid \`signature\` in \`thinking\` block`。网关不伪造真实签名,只在面板 / cli-hop 保留路径补齐缺失字段。切换账号时不改写已有签名;`cli-node` 仍按现有登录 / fallback 逻辑清理签名块。
+长度够的签名原样转发,由 Anthropic 验。5.5 的非 dummy 签名在非保留路径原样转发,不按 24 字符预过滤;保留路径仍要求不少于 24。签名是 opaque 加密内容,当前模型 / 账户 / 会话前缀不匹配时可能被丢弃或返回 400 `Invalid \`signature\` in \`thinking\` block`。网关不伪造真实签名,也不给缺签名的块补空字段。切换账号时不改写已有签名;`cli-node` 仍按现有登录 / fallback 逻辑清理签名块。
`routing.failover.signature_repair=true`(设置页「探测 → 签名修复」)才走 `signature_repairable` 的 `repair-and-retry`(剥 thinking 历史 + 删 `body.thinking` 后重试一次),客户端只看到 200。关闭时包括 cli-hop 在内都原样透传 400;`cli-node` 自身行为不受该面板开关改变。
+`routing.compatibility.thinking_display` 默认 `summarized`(设置 → 协议 → thinking 显示;缺省即开,显式 `passthrough` 关闭)。只改已经打开的 thinking(`adaptive` / `enabled`):`omitted`、`updates`,以及 Opus 4.7+、5 系列、Haiku 5.5、Fable、Mythos 上没写 display 的请求,出站改成 `summarized`。其它值、`disabled`、没开 thinking 不动。改写在 beta 计算之前,所以被改掉的 `updates` 不再带 `thinking-display-updates-2026-08-18`。`passthrough` 时 `updates` 仍只在出站 beta 声明该 token 时保留,否则降成 `omitted`。分类器请求不经过这条出站改写。
+
## 健康探测缓存
`routing.health_probe`:
diff --git a/src/lib/admin/panel-api.mjs b/src/lib/admin/panel-api.mjs
index 76c8cea1..1351ee25 100644
--- a/src/lib/admin/panel-api.mjs
+++ b/src/lib/admin/panel-api.mjs
@@ -6,6 +6,7 @@
* { ok: false, error: { type, code, message, ... } }
*/
+import { THINKING_DISPLAY_MODES } from '../protocol/thinking.mjs'
import { readSlotProcessStatus } from '../vm/slot-process-status.mjs'
import os from 'node:os'
import path from 'node:path'
@@ -474,6 +475,13 @@ export function validatePersonaRoutingPatch(body = {}) {
if (Object.prototype.hasOwnProperty.call(compat, 'cch_enabled') && typeof compat.cch_enabled !== 'boolean') {
problems.push('cch_enabled 必须是布尔')
}
+ if (
+ compat.thinking_display != null &&
+ compat.thinking_display !== '' &&
+ !THINKING_DISPLAY_MODES.includes(String(compat.thinking_display).trim().toLowerCase())
+ ) {
+ problems.push(`thinking_display 必须是 ${THINKING_DISPLAY_MODES.join(' / ')},收到 ${compat.thinking_display}`)
+ }
if (compat.cache_breakpoints != null) {
const bp = compat.cache_breakpoints
if (typeof bp !== 'object' || Array.isArray(bp)) {
@@ -1394,6 +1402,10 @@ export function buildUsage({ accountQuota, cfg, requestLog = null }) {
function presentCompatibility(compat) {
const out = compat && typeof compat === 'object' && !Array.isArray(compat) ? { ...compat } : {}
out.cch_enabled = out.cch_enabled !== false
+ const display = String(out.thinking_display ?? '')
+ .trim()
+ .toLowerCase()
+ out.thinking_display = display === 'passthrough' ? 'passthrough' : 'summarized'
return out
}
diff --git a/src/lib/protocol/anthropic-policy.mjs b/src/lib/protocol/anthropic-policy.mjs
index 717ef4d5..f335dfb7 100644
--- a/src/lib/protocol/anthropic-policy.mjs
+++ b/src/lib/protocol/anthropic-policy.mjs
@@ -175,8 +175,11 @@ export function alignSamplingWithThinking(body = {}) {
* Opus / Sonnet / Haiku 5.5 follow sub2api: one invalid block drops the whole
* signed chain, including later valid thinking and redacted_thinking. Turns
* left with no content are dropped. redacted_thinking stays when it carries
- * data on 5.5, otherwise when its signature is usable. The preserve path is
- * unchanged.
+ * data on 5.5, otherwise when its signature is usable. The preserve path
+ * (cli-hop first hop, default HTTP hop) skips the chain rule and keeps a
+ * signed block as-is, empty text and redacted_thinking included. A thinking
+ * block without a usable signature (missing, empty, shorter than 24, dummy)
+ * is dropped instead of forwarded with an empty signature.
*/
export function stripInvalidThinkingBlocks(body = {}, { preserveThinkingBlocks = false } = {}) {
if (!Array.isArray(body.messages)) return body
@@ -194,12 +197,11 @@ export function stripInvalidThinkingBlocks(body = {}, { preserveThinkingBlocks =
if ((keepSigned || preserveThinkingBlocks) && message.role === 'assistant') {
// Omitted thinking keeps empty text; redacted thinking keeps encrypted data.
if (preserveThinkingBlocks) {
- if (type === 'thinking' && !Object.hasOwn(block, 'signature')) {
+ if (type === 'redacted_thinking' || hasUsableThinkingSignature(block.signature)) {
+ content.push(block)
+ } else {
changed = true
filteredThis = true
- content.push({ ...block, signature: '' })
- } else {
- content.push(block)
}
continue
}
diff --git a/src/lib/protocol/handle-protocol.mjs b/src/lib/protocol/handle-protocol.mjs
index 907831f7..68e69808 100644
--- a/src/lib/protocol/handle-protocol.mjs
+++ b/src/lib/protocol/handle-protocol.mjs
@@ -1374,6 +1374,7 @@ export function createHandleProtocol(deps) {
vmId: selected.vmId,
epoch: attemptStartedAt,
mode: sessionMode,
+ routing: routingNow,
clientDiscriminator,
firstUserText,
firstUserIdentity,
diff --git a/src/lib/protocol/outbound-attempt.mjs b/src/lib/protocol/outbound-attempt.mjs
index 249b16c9..95326c71 100644
--- a/src/lib/protocol/outbound-attempt.mjs
+++ b/src/lib/protocol/outbound-attempt.mjs
@@ -14,7 +14,13 @@ import {
modelSupportsMidConversationSystem,
} from './anthropic-policy.mjs'
import { liftMidConversationSystemMessages } from './sanitize.mjs'
-import { ensureUnofficialAdaptiveThinking, ensureUnofficialEffortHigh, normalizeThinkingForModel } from './thinking.mjs'
+import {
+ applyThinkingDisplay,
+ ensureUnofficialAdaptiveThinking,
+ ensureUnofficialEffortHigh,
+ normalizeThinkingForModel,
+ thinkingDisplayMode,
+} from './thinking.mjs'
import { ensureOutputConfigSchema } from './request-rectifier.mjs'
import {
applyCrsIdentityReplace,
@@ -334,6 +340,7 @@ export function prepareOutboundHeaders(reqHeaders, homeDir, identity, model, { c
/** Body + headers after the context_management ↔ context-management beta gate. */
export function prepareOutboundEnvelope({
+ requestContext = null,
canonicalBody,
inbound = {},
identity,
@@ -368,6 +375,7 @@ export function prepareOutboundEnvelope({
want1m,
} = {}) {
const prepared = prepareOutboundAttempt({
+ requestContext,
canonicalBody,
inbound,
identity,
@@ -421,11 +429,15 @@ export function prepareOutboundEnvelope({
const beta = ensureFastModeBeta(headers['anthropic-beta'] || '', prepared.body)
if (beta) headers['anthropic-beta'] = beta
}
+ const displayed =
+ requestContext?.purpose === 'auto_mode_classifier'
+ ? prepared.body
+ : applyThinkingDisplay(prepared.body, thinkingDisplayMode(routing))
headers['anthropic-beta'] = withRequestProtocolBetas(
String(headers['anthropic-beta'] || '').split(','),
- prepared.body,
+ displayed,
).join(',')
- const body = sealClaudeCodeCch(sanitizeAnthropicBodyForBetaTokens(prepared.body, headers?.['anthropic-beta'] || ''), {
+ const body = sealClaudeCodeCch(sanitizeAnthropicBodyForBetaTokens(displayed, headers?.['anthropic-beta'] || ''), {
enabled: routing?.compatibility?.cch_enabled !== false,
})
return { body, headers, toolNames: prepared.toolNames }
diff --git a/src/lib/protocol/thinking.mjs b/src/lib/protocol/thinking.mjs
index 6ae89707..558ada7c 100644
--- a/src/lib/protocol/thinking.mjs
+++ b/src/lib/protocol/thinking.mjs
@@ -11,6 +11,16 @@ import { anthropicContentIsEmpty, mergeConsecutiveAnthropicMessages, messageHasT
export { clampEnabledThinkingBudget, MIN_THINKING_BUDGET }
+/** `routing.compatibility.thinking_display`:缺省 summarized,显式 passthrough 才保留调用方的值。 */
+export const THINKING_DISPLAY_MODES = ['summarized', 'passthrough']
+
+export function thinkingDisplayMode(routing) {
+ const raw = String(routing?.compatibility?.thinking_display ?? '')
+ .trim()
+ .toLowerCase()
+ return raw === 'passthrough' ? 'passthrough' : 'summarized'
+}
+
/**
* OpenAI reasoning <-> Claude thinking mapping + model-aware normalize.
* Official (Aug 2026):
@@ -154,6 +164,53 @@ export function downgradeUngatedThinkingDisplay(body = {}, anthropicBetaHeader =
return { ...body, thinking: { ...thinking, display: OFFICIAL_THINKING_DISPLAY } }
}
+const HIDDEN_THINKING_DISPLAYS = new Set(['omitted', 'updates'])
+
+function bareModel(model) {
+ return (
+ String(model || '')
+ .toLowerCase()
+ .split('[')[0]
+ .split('/')
+ .filter(Boolean)
+ .pop() || ''
+ )
+}
+
+/** Opus 4.7+, the 5 series, Haiku 5.5, Fable and Mythos return empty thinking unless asked. */
+export function modelHidesThinkingByDefault(model) {
+ const m = bareModel(model)
+ if (!m) return false
+ if (/^claude-haiku-5(?:[-.]|$)/.test(m)) return true
+ if (/^claude-opus-4[-.][7-9](?:[-.]|$)/.test(m)) return true
+ if (/^claude-(?:opus|sonnet|fable|mythos)-5(?:[-.]|$)/.test(m)) return true
+ return /(?:^|-)(?:fable|mythos)(?:-|$)/.test(m)
+}
+
+/**
+ * `summarized` (the default) asks for a readable summary whenever the caller
+ * turned thinking on but hid it: explicit `omitted` or `updates`, or no display
+ * on a model whose default is `omitted`. Other values, `disabled`, and requests
+ * without thinking stay untouched, so a later fill can still write `omitted`.
+ * `passthrough` sends the caller's choice. Never mutates the input.
+ */
+export function applyThinkingDisplay(body, mode) {
+ if (mode !== 'summarized' || !body || typeof body !== 'object') return body
+ const thinking = body.thinking
+ if (!thinking || typeof thinking !== 'object') return body
+ const type = String(thinking.type || '')
+ .trim()
+ .toLowerCase()
+ if (type !== 'adaptive' && type !== 'enabled') return body
+ const display = String(thinking.display ?? '')
+ .trim()
+ .toLowerCase()
+ const hidden = HIDDEN_THINKING_DISPLAYS.has(display)
+ const unset = !display && modelHidesThinkingByDefault(body.model)
+ if (!hidden && !unset) return body
+ return { ...body, thinking: { ...thinking, display: 'summarized' } }
+}
+
function thinkingNeedsOfficialDisplay(thinking) {
if (!thinking || typeof thinking !== 'object') return false
const type = String(thinking.type || '').toLowerCase()
diff --git a/src/lib/transport/go-worker-client.mjs b/src/lib/transport/go-worker-client.mjs
index f32c0f0a..5e90a7ea 100644
--- a/src/lib/transport/go-worker-client.mjs
+++ b/src/lib/transport/go-worker-client.mjs
@@ -5,6 +5,7 @@ import path from 'node:path'
import { prepareOutboundHeaders } from '../protocol/outbound-attempt.mjs'
import { sanitizeAnthropicBodyForBetaTokens } from '../protocol/anthropic-policy.mjs'
import { withRequestProtocolBetas } from '../protocol/claude-code-betas.mjs'
+import { applyThinkingDisplay, thinkingDisplayMode } from '../protocol/thinking.mjs'
import { sealClaudeCodeCch } from '../identity/cch.mjs'
import { credentialModeFromOauth } from '../oauth/credential-mode.mjs'
import { isCrsMock, writeCrsTrace, mockCrsPayload, emitMockSse } from './crs-mock.mjs'
@@ -429,18 +430,31 @@ function dumpSessionEnvelope(envelope) {
* body on the VM's HTTP betas would lift every role=system turn into system[],
* so system grows each turn and no cached prefix is ever read.
*/
-export function finalizeWorkerPayload({ body, reqHeaders, exec, identity, routing, want1m = false, cliHop = false }) {
+export function finalizeWorkerPayload({
+ body,
+ reqHeaders,
+ exec,
+ identity,
+ routing,
+ want1m = false,
+ cliHop = false,
+ requestContext = null,
+} = {}) {
const model = body?.model || ''
const credMode = credentialModeFromOauth(exec?.vm?.claude || {})
const headers = prepareOutboundHeaders(reqHeaders, exec?.homeDir, identity, model, {
credentialMode: credMode,
want1m: want1m === true,
})
- const betas = withRequestProtocolBetas(String(headers?.['anthropic-beta'] || '').split(','), body)
+ const displayed =
+ requestContext?.purpose === 'auto_mode_classifier' ? body : applyThinkingDisplay(body, thinkingDisplayMode(routing))
+ const betas = withRequestProtocolBetas(String(headers?.['anthropic-beta'] || '').split(','), displayed)
headers['anthropic-beta'] = betas.join(',')
// Envelope headers are discarded; the existing MessageRequest.betas channel
// carries request gates to the native CLI, which builds the actual HTTP wire.
- const gated = cliHop ? { ...body, betas } : sanitizeAnthropicBodyForBetaTokens(body, headers['anthropic-beta'])
+ const gated = cliHop
+ ? { ...displayed, betas }
+ : sanitizeAnthropicBodyForBetaTokens(displayed, headers['anthropic-beta'])
return {
headers,
body: sealClaudeCodeCch(gated, { enabled: routing?.compatibility?.cch_enabled !== false, seal: !cliHop }),
@@ -461,7 +475,16 @@ function workerEnvelope({
preserveCacheBreakpoints = null,
cliHop = false,
} = {}) {
- const finalized = finalizeWorkerPayload({ body, reqHeaders, exec, identity, routing, want1m, cliHop })
+ const finalized = finalizeWorkerPayload({
+ body,
+ reqHeaders,
+ exec,
+ identity,
+ routing,
+ want1m,
+ cliHop,
+ requestContext,
+ })
const envelope = {
...(requestContext ? { request_context: requestContext } : {}),
body: finalized.body,
@@ -509,6 +532,7 @@ export async function callGoWorker({
routing,
want1m,
cliHop,
+ requestContext,
})
writeCrsTrace({ body: outboundBody, headers, stream: false })
const mock = mockCrsPayload({ scenario: mockScenario(exec) })
@@ -613,6 +637,7 @@ export async function streamGoWorker({
routing,
want1m,
cliHop,
+ requestContext,
})
writeCrsTrace({ body: outboundBody, headers, stream: true })
const scenario = mockScenario(exec)
diff --git a/test/e2e/auto-mode.e2e.test.mjs b/test/e2e/auto-mode.e2e.test.mjs
index b8d78b27..1b2a0726 100644
--- a/test/e2e/auto-mode.e2e.test.mjs
+++ b/test/e2e/auto-mode.e2e.test.mjs
@@ -694,19 +694,19 @@ test('actual kernel+CLI ELF keeps per-message effort and top-level high', { time
)
}
})
-test('actual kernel+CLI ELF preserves mixed thinking signature blocks on final wire', { timeout: 30000 }, async (t) => {
+test('actual kernel+CLI ELF drops thinking without a usable signature on final wire', { timeout: 30000 }, async (t) => {
const h = await harness(t)
const result = await h.send({
model: 'claude-opus-5-5',
max_tokens: 256,
thinking: { type: 'adaptive' },
- system: [{ type: 'text', text: 'Preserve the prior thinking blocks exactly.' }],
+ system: [{ type: 'text', text: 'Drop thinking blocks that have no usable signature.' }],
messages: [
{ role: 'user', content: 'Continue the prior turn.' },
{
role: 'assistant',
content: [
- { type: 'thinking', thinking: 'existing thinking', signature: 'existing-signature' },
+ { type: 'thinking', thinking: 'existing thinking', signature: 'existing-signature-0123456789' },
{ type: 'thinking', thinking: 'missing signature' },
{ type: 'thinking', thinking: '', signature: 'empty-thinking-signature' },
{ type: 'redacted_thinking', data: 'redacted-thinking' },
@@ -720,8 +720,7 @@ test('actual kernel+CLI ELF preserves mixed thinking signature blocks on final w
const wireMessage = h.captured.at(-1).messages.find((message) => message.role === 'assistant')
assert.ok(wireMessage)
assert.deepEqual(wireMessage.content, [
- { type: 'thinking', thinking: 'existing thinking', signature: 'existing-signature' },
- { type: 'thinking', thinking: 'missing signature', signature: '' },
+ { type: 'thinking', thinking: 'existing thinking', signature: 'existing-signature-0123456789' },
{ type: 'thinking', thinking: '', signature: 'empty-thinking-signature' },
{ type: 'redacted_thinking', data: 'redacted-thinking' },
{ type: 'text', text: 'previous answer' },
diff --git a/test/unit/anthropic-policy.test.mjs b/test/unit/anthropic-policy.test.mjs
index 1b8058db..312b751f 100644
--- a/test/unit/anthropic-policy.test.mjs
+++ b/test/unit/anthropic-policy.test.mjs
@@ -258,7 +258,7 @@ test('5.5 keeps a lone redacted block and a short non-dummy signature', () => {
}
})
-test('5.5 preserve path still keeps an invalid signed chain', () => {
+test('preserve path drops thinking without a usable signature and keeps the rest', () => {
const body = stripInvalidThinkingBlocks(
{
model: 'claude-opus-5-5',
@@ -277,10 +277,11 @@ test('5.5 preserve path still keeps an invalid signed chain', () => {
},
{ preserveThinkingBlocks: true },
)
- assert.deepEqual(
- body.messages[0].content.map((block) => block.type),
- ['thinking', 'thinking', 'redacted_thinking', 'text'],
- )
+ assert.deepEqual(body.messages[0].content, [
+ { type: 'thinking', thinking: 'later', signature: 'sig_real_1234567890abcdef' },
+ { type: 'redacted_thinking', data: 'encrypted' },
+ { type: 'text', text: 'answer' },
+ ])
})
test('an untyped thinking block breaks a valid 5.5 chain and is dropped on older models', () => {
const messages = [
@@ -310,7 +311,7 @@ test('an untyped thinking block breaks a valid 5.5 chain and is dropped on older
)
})
-test('panel preservation creates a missing thinking signature field', () => {
+test('panel preservation drops a thinking block that has no signature field', () => {
const body = prepareAnthropicRequest(
{
model: 'claude-opus-5',
@@ -327,10 +328,7 @@ test('panel preservation creates a missing thinking signature field', () => {
},
{ preserveThinkingBlocks: true },
)
- assert.deepEqual(body.messages[0].content, [
- { type: 'thinking', thinking: 'opaque', signature: '' },
- { type: 'text', text: 'answer' },
- ])
+ assert.deepEqual(body.messages[0].content, [{ type: 'text', text: 'answer' }])
})
test('existing context_management is not overwritten', () => {
diff --git a/test/unit/auto-mode.test.mjs b/test/unit/auto-mode.test.mjs
index 8a9431ae..abb551a2 100644
--- a/test/unit/auto-mode.test.mjs
+++ b/test/unit/auto-mode.test.mjs
@@ -1,7 +1,12 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { classifierFixture } from '../fixtures/auto-mode.mjs'
-import { prepareCliHopBody, prepareOutboundAttempt } from '../../src/lib/protocol/outbound-attempt.mjs'
+import {
+ prepareCliHopBody,
+ prepareOutboundAttempt,
+ prepareOutboundEnvelope,
+} from '../../src/lib/protocol/outbound-attempt.mjs'
+import { finalizeWorkerPayload } from '../../src/lib/transport/go-worker-client.mjs'
import {
classifyClaudeRequestPurpose,
prepareClassifierBody,
@@ -30,6 +35,33 @@ test('classifier assembly retains caller fields across both outbound paths', ()
}
})
+test('classifier outbound skips thinking_display rewrite on envelope and worker finalize', () => {
+ const input = {
+ ...classifierFixture({ model: 'claude-sonnet-5-5' }),
+ thinking: { type: 'adaptive', display: 'omitted' },
+ }
+ const prepared = prepareClassifierBody(input)
+ const envelope = prepareOutboundEnvelope({
+ requestContext: context,
+ canonicalBody: input,
+ inbound: input,
+ identity: {},
+ unofficial: true,
+ stream: true,
+ })
+ assert.equal(envelope.body.thinking.display, 'omitted')
+ const exec = { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } }
+ const worker = finalizeWorkerPayload({
+ body: prepared,
+ reqHeaders: {},
+ exec,
+ identity: null,
+ requestContext: context,
+ cliHop: true,
+ })
+ assert.equal(worker.body.thinking.display, 'omitted')
+})
+
for (const verdict of ['block', 'severity'])
for (const stage of ['xml_s1', 'fast', 'xml_s2'])
test(`recognizes ${verdict} XML ${stage} from the wire contract`, () => {
diff --git a/test/unit/cli-hop-body.test.mjs b/test/unit/cli-hop-body.test.mjs
index 27caeb72..fc8a38c8 100644
--- a/test/unit/cli-hop-body.test.mjs
+++ b/test/unit/cli-hop-body.test.mjs
@@ -210,7 +210,7 @@ test('prepareCliHopBody strips unsigned dummy and short thinking history', () =>
{ type: 'text', text: 'hello' },
])
})
-test('prepareCliHopBody keeps existing signatures and fills missing ones', () => {
+test('prepareCliHopBody drops thinking without a usable signature on the preserve path', () => {
const body = prepareCliHopBody(
{
model: 'claude-sonnet-5',
@@ -220,8 +220,10 @@ test('prepareCliHopBody keeps existing signatures and fills missing ones', () =>
{
role: 'assistant',
content: [
- { type: 'thinking', thinking: 'existing', signature: 'abc' },
+ { type: 'thinking', thinking: 'existing', signature: 'sig_real_1234567890abcdef' },
+ { type: 'thinking', thinking: 'short', signature: 'abc' },
{ type: 'thinking', thinking: 'missing' },
+ { type: 'redacted_thinking', data: '' },
{ type: 'text', text: 'hello' },
],
},
@@ -230,8 +232,8 @@ test('prepareCliHopBody keeps existing signatures and fills missing ones', () =>
{ preserveThinkingBlocks: true },
)
assert.deepEqual(body.messages[1].content, [
- { type: 'thinking', thinking: 'existing', signature: 'abc' },
- { type: 'thinking', thinking: 'missing', signature: '' },
+ { type: 'thinking', thinking: 'existing', signature: 'sig_real_1234567890abcdef' },
+ { type: 'redacted_thinking', data: '' },
{ type: 'text', text: 'hello', cache_control: { type: 'ephemeral', ttl: '1h' } },
])
})
diff --git a/test/unit/fast-mode.test.mjs b/test/unit/fast-mode.test.mjs
index 4aedbb77..c8ff91d5 100644
--- a/test/unit/fast-mode.test.mjs
+++ b/test/unit/fast-mode.test.mjs
@@ -77,3 +77,29 @@ test('outbound envelope drops speed for a model without fast mode', () => {
assert.equal('speed' in r.body, false)
assert.equal(r.headers['anthropic-beta'].includes(FAST_MODE_BETA), false)
})
+
+test('outbound envelope summarizes a hidden thinking display unless passthrough', () => {
+ const body = {
+ model: 'claude-opus-5',
+ max_tokens: 100,
+ thinking: { type: 'adaptive', display: 'omitted' },
+ messages: [{ role: 'user', content: 'hi' }],
+ }
+ const summarized = prepareOutboundEnvelope({
+ identity: IDENTITY,
+ canonicalBody: body,
+ inbound: body,
+ unofficial: true,
+ stream: true,
+ })
+ assert.equal(summarized.body.thinking.display, 'summarized')
+ const passed = prepareOutboundEnvelope({
+ identity: IDENTITY,
+ canonicalBody: body,
+ inbound: body,
+ unofficial: true,
+ stream: true,
+ routing: { compatibility: { thinking_display: 'passthrough' } },
+ })
+ assert.equal(passed.body.thinking.display, 'omitted')
+})
diff --git a/test/unit/go-worker-client.test.mjs b/test/unit/go-worker-client.test.mjs
index 7a600862..43a1a32e 100644
--- a/test/unit/go-worker-client.test.mjs
+++ b/test/unit/go-worker-client.test.mjs
@@ -164,7 +164,7 @@ test('cli-hop envelope keeps role=system turns that the VM betas do not declare'
assert.deepEqual(out.body.system, body.system)
})
-test('setup-token cli-hop preserves updates with its beta and isolates later requests', () => {
+test('setup-token cli-hop summarizes hidden thinking by default and keeps updates when passthrough', () => {
const exec = { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } }
const updates = finalizeWorkerPayload({
body: {
@@ -178,22 +178,23 @@ test('setup-token cli-hop preserves updates with its beta and isolates later req
cliHop: true,
})
assert.equal(updates.body.thinking.type, 'adaptive')
- assert.equal(updates.body.thinking.display, 'updates')
- assert.ok(updates.body.betas.includes('thinking-display-updates-2026-08-18'))
+ assert.equal(updates.body.thinking.display, 'summarized')
+ assert.ok(!updates.body.betas.includes('thinking-display-updates-2026-08-18'))
assert.ok(!updates.body.betas.includes('claude-code-20250219'))
- const summarized = finalizeWorkerPayload({
+ const passed = finalizeWorkerPayload({
body: {
model: 'claude-opus-5-5',
- thinking: { type: 'adaptive', display: 'summarized' },
+ thinking: { type: 'adaptive', display: 'updates' },
messages: [{ role: 'user', content: 'hi' }],
},
reqHeaders: {},
exec,
identity: null,
+ routing: { compatibility: { thinking_display: 'passthrough' } },
cliHop: true,
})
- assert.equal(summarized.body.thinking.display, 'summarized')
- assert.ok(!summarized.body.betas.includes('thinking-display-updates-2026-08-18'))
+ assert.equal(passed.body.thinking.display, 'updates')
+ assert.ok(passed.body.betas.includes('thinking-display-updates-2026-08-18'))
})
const unix = process.platform !== 'win32'
diff --git a/test/unit/outbound-official-capture.test.mjs b/test/unit/outbound-official-capture.test.mjs
index ba0b5a25..67f7d8ac 100644
--- a/test/unit/outbound-official-capture.test.mjs
+++ b/test/unit/outbound-official-capture.test.mjs
@@ -62,6 +62,8 @@ test('unofficial outbound envelope matches official 2.1.281 capture keys', () =>
stream: true,
reqHeaders: headersIn,
homeDir: dir,
+ // 2.1.281 wire used omitted; passthrough keeps this snapshot off the gateway summarized default.
+ routing: { compatibility: { thinking_display: 'passthrough' } },
})
const { headers, body } = envelope
assert.equal(headers['user-agent'], 'claude-cli/2.1.281 (external, sdk-cli)')
diff --git a/test/unit/persona-template.test.mjs b/test/unit/persona-template.test.mjs
index e400de60..05a9a04d 100644
--- a/test/unit/persona-template.test.mjs
+++ b/test/unit/persona-template.test.mjs
@@ -441,6 +441,15 @@ test('PUT routing accepts only 5m and 1h cache TTL values', () => {
assert.match(validatePersonaRoutingPatch({ compatibility: { cache_ttl: '60m' } })[0], /5m \/ 1h/)
})
+test('PUT routing accepts summarized and passthrough thinking display', () => {
+ assert.deepEqual(validatePersonaRoutingPatch({ compatibility: { thinking_display: 'summarized' } }), [])
+ assert.deepEqual(validatePersonaRoutingPatch({ compatibility: { thinking_display: 'passthrough' } }), [])
+ assert.match(
+ validatePersonaRoutingPatch({ compatibility: { thinking_display: 'omitted' } })[0],
+ /summarized \/ passthrough/,
+ )
+})
+
test('legacy routing without persona_preset keeps the old rewrite path', () => {
withRoutingFile({ persona_inject: 'rewrite', persona_park: false }, (file) => {
const out = applyCrsUnofficialPersona(
diff --git a/test/unit/quota-tiers.test.mjs b/test/unit/quota-tiers.test.mjs
index 1351a71c..bf4a3314 100644
--- a/test/unit/quota-tiers.test.mjs
+++ b/test/unit/quota-tiers.test.mjs
@@ -86,6 +86,19 @@ test('buildRouting returns normalized vm defaults', () => {
assert.equal(data.vm.allow_tcg, false)
})
+test('buildRouting defaults a missing thinking display to summarized', () => {
+ const data = buildRouting({
+ routingConfig: { sticky: {}, quota: {}, concurrency: {} },
+ stickyRouter: { stats: () => ({ active_sessions: 0 }) },
+ }).data
+ assert.equal(data.compatibility.thinking_display, 'summarized')
+ const pinned = buildRouting({
+ routingConfig: { compatibility: { thinking_display: 'passthrough' } },
+ stickyRouter: { stats: () => ({ active_sessions: 0 }) },
+ }).data
+ assert.equal(pinned.compatibility.thinking_display, 'passthrough')
+})
+
test('buildRouting exposes effective Codex routing to the panel', () => {
const data = buildRouting({
routingConfig: { codex: { clients: { official_codex: 'allow' } } },
diff --git a/web/src/features/settings/index.tsx b/web/src/features/settings/index.tsx
index b9788fd3..5cc27aa0 100644
--- a/web/src/features/settings/index.tsx
+++ b/web/src/features/settings/index.tsx
@@ -40,6 +40,7 @@ import {
cchEnabledFromCompat,
inheritProtocolOnSlots,
sessionIdleTtlFromInference,
+ thinkingDisplayFromCompat,
} from '@/features/settings/protocol-authority'
import {
notifyQueryOptions,
@@ -112,6 +113,7 @@ export function SettingsPage() {
claude_code_only: compat.claude_code_only === true,
official_cc_passthrough: compat.official_cc_passthrough === true,
cch_enabled: cchEnabledFromCompat(compat),
+ thinking_display: thinkingDisplayFromCompat(compat),
persona_rules: rules,
}
}
diff --git a/web/src/features/settings/protocol-authority.ts b/web/src/features/settings/protocol-authority.ts
index 2fcf0001..45b1d94f 100644
--- a/web/src/features/settings/protocol-authority.ts
+++ b/web/src/features/settings/protocol-authority.ts
@@ -24,6 +24,15 @@ export function cchEnabledFromCompat(
return compat?.cch_enabled !== false
}
+/** `routing.compatibility.thinking_display`:缺省 summarized,显式 passthrough 才关。 */
+export function thinkingDisplayFromCompat(
+ compat: Record