diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cfc4efb4..d63120249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ - 远端读文件在 BSD 工具链(macOS 节点)上改为先拒绝符号链接与非普通文件再读取;GNU 节点仍用 `dd iflag=nofollow`。 - 面板 Claude 用量:`utilization_*` 超过 1(如 header 1.04)按 ratio×100 显示,不再误成约 1%;Codex `used_percent` 路径不变。(#625,#631) - 自动/手动备份流水线改到 worker 线程执行(VACUUM INTO + tar),避免主线程长时间阻塞网关导致 Cloudflare 524。(#638,#639) +- cli-hop 出站收成官方两段 system(identity + 调用方正文,ephemeral 无 TTL)、缓存标记打最新 user、去掉 eager_input_streaming/工具 cache_control/temperature/context_management,thinking display 默认 summarized 等。(#641) +- 设置 → 遥测新增日志总开关和保存时间(默认 24 小时,1–168)。这是各槽上传日志的唯一启动源:关闭不写、不显示;打开后槽位运维页直接显示该槽保留窗口内已有记录。sidecar 按同一窗口裁 `telemetry-upload.log`。 +- 槽内 cli-node 进入 native loop 前丢掉 `settings.json` 里安全名单以外的 env(如 `LD_PRELOAD`)。 +- 授权页默认 OAuth。顺序为 Claude Code、Cookie、授权链接;Setup Token 为授权链接、Cookie。去掉 Console 导入。 +- 入站 device 迁移改为 `041_usage_logs_inbound_device`,避开 macOS 节点占用的 040。 ## 1.3.202 — 2026-10-20 diff --git a/src/lib/admin/request-log.mjs b/src/lib/admin/request-log.mjs index ddd0f9d31..512ee62c5 100644 --- a/src/lib/admin/request-log.mjs +++ b/src/lib/admin/request-log.mjs @@ -565,6 +565,7 @@ export class RequestLogStore { session_id: sessionIdForLog(extra.session_id), outbound_session_id: sessionIdForLog(extra.outbound_session_id), reasoning_effort: REASONING_EFFORTS.has(extra.reasoning_effort) ? extra.reasoning_effort : null, + inbound_device_id: sessionIdForLog(extra.inbound_device_id), // Gate verdict JSON from intercept-gate; the risk audit groups on it. intercept: extra.intercept ? String(extra.intercept).slice(0, 400) : null, // sub2api ownership + billing columns diff --git a/src/lib/db/migrations/041_usage_logs_inbound_device.sql b/src/lib/db/migrations/041_usage_logs_inbound_device.sql new file mode 100644 index 000000000..d3179eb0a --- /dev/null +++ b/src/lib/db/migrations/041_usage_logs_inbound_device.sql @@ -0,0 +1,6 @@ +-- 041_usage_logs_inbound_device — client device id captured before outbound rewrite. +-- Source: metadata.user_id.device_id, body device_id, or x-kin-device-id. +-- Absent on the inbound request stays NULL. Historical rows stay NULL. + +ALTER TABLE usage_logs ADD COLUMN inbound_device_id TEXT; +CREATE INDEX IF NOT EXISTS idx_usage_logs_inbound_device ON usage_logs(inbound_device_id, created_at); diff --git a/src/lib/db/repos/usage-logs-repo.mjs b/src/lib/db/repos/usage-logs-repo.mjs index 94dc01ee0..d5b4c5a74 100644 --- a/src/lib/db/repos/usage-logs-repo.mjs +++ b/src/lib/db/repos/usage-logs-repo.mjs @@ -120,6 +120,7 @@ const SUMMARY_COLUMNS = [ 'session_id', 'outbound_session_id', 'reasoning_effort', + 'inbound_device_id', 'intercept', 'vm_owner_user_id', ] diff --git a/src/lib/db/repos/usage-logs-view.mjs b/src/lib/db/repos/usage-logs-view.mjs index 54aae2c09..7e7b2b90a 100644 --- a/src/lib/db/repos/usage-logs-view.mjs +++ b/src/lib/db/repos/usage-logs-view.mjs @@ -208,6 +208,7 @@ export function toUsageLogRow(row, names, chain = []) { speed: row.speed ?? null, context1mApplied: row.long_context == null ? null : row.long_context === 1, reasoningEffort: row.reasoning_effort ?? null, + inboundDeviceId: row.inbound_device_id ?? null, errorClass: row.error_class ?? null, errorLabel: row.error_label ?? null, errorOwner: row.error_owner ?? null, diff --git a/src/lib/protocol/anthropic-messages.mjs b/src/lib/protocol/anthropic-messages.mjs index cb8fe8dac..de03a7767 100644 --- a/src/lib/protocol/anthropic-messages.mjs +++ b/src/lib/protocol/anthropic-messages.mjs @@ -11,7 +11,7 @@ import { } from './sanitize.mjs' import { sanitizeAnthropicTools } from './web-search.mjs' -export function officialMessagesBody(body = {}, { stream = undefined } = {}) { +export function officialMessagesBody(body = {}, { stream = undefined, preserveContentShape = false } = {}) { const out = copyOfficialAnthropicFields(body) applyStructuredOutput(out, body) normalizeAnthropicMessages(out) @@ -26,7 +26,7 @@ export function officialMessagesBody(body = {}, { stream = undefined } = {}) { out.stop_sequences = Array.isArray(out.stop) ? out.stop : [out.stop] delete out.stop } - return canonicalizeClaudeMessagesShape(out) + return canonicalizeClaudeMessagesShape(out, { preserveContentShape }) } const DISABLED = { diff --git a/src/lib/protocol/cache-prefix.mjs b/src/lib/protocol/cache-prefix.mjs index 90b9e4096..2eb60c273 100644 --- a/src/lib/protocol/cache-prefix.mjs +++ b/src/lib/protocol/cache-prefix.mjs @@ -210,3 +210,10 @@ export function confirmCachePrefix(sessionKey, now = Date.now()) { export function clearCachePrefixSessions() { sessions.clear() } + +/** Human-readable location of a cache-prefix break for logs. */ +export function formatCacheBreak(prefix) { + const br = prefix?.break + if (!br?.section) return '' + return br.section === 'messages' ? `messages[${br.index}]` : br.section +} diff --git a/src/lib/protocol/cache-ttl.mjs b/src/lib/protocol/cache-ttl.mjs index 5840bae5d..7d2181879 100644 --- a/src/lib/protocol/cache-ttl.mjs +++ b/src/lib/protocol/cache-ttl.mjs @@ -601,10 +601,11 @@ function dropMessageBreakpoints(messages) { function stampMessageTail(messages, idx, ttl) { const message = messages[idx] if (!message || typeof message !== 'object') return messages + const marker = ttl == null ? { type: 'ephemeral' } : { type: 'ephemeral', ttl } const content = message.content if (typeof content === 'string') { const next = messages.slice() - next[idx] = { ...message, content: [{ type: 'text', text: content, cache_control: { type: 'ephemeral', ttl } }] } + next[idx] = { ...message, content: [{ type: 'text', text: content, cache_control: marker }] } return next } if (!Array.isArray(content) || content.length === 0) return messages @@ -615,7 +616,7 @@ function stampMessageTail(messages, idx, ttl) { break } if (target < 0) return messages - const stamped = stampNode(content[target], ttl) + const stamped = ttl == null ? { ...content[target], cache_control: marker } : stampNode(content[target], ttl) if (stamped === content[target]) return messages const nextContent = content.slice() nextContent[target] = stamped diff --git a/src/lib/protocol/convert.mjs b/src/lib/protocol/convert.mjs index fba4ae47b..f68775b16 100644 --- a/src/lib/protocol/convert.mjs +++ b/src/lib/protocol/convert.mjs @@ -290,7 +290,10 @@ export function normalizeOpenAIToolsOnMessages(body) { export function toClaudeMessages(protocol, body, opts = { rewrite: false, model_map: true }) { if (protocol === 'anthropic.messages' && !opts.rewrite) { const normalized = normalizeOpenAIToolsOnMessages(body) - const out = sanitizeAnthropicBody(normalized, { strictPassthrough: opts.strict_passthrough }) + const out = sanitizeAnthropicBody(normalized, { + strictPassthrough: opts.strict_passthrough, + preserveContentShape: opts.preserve_content_shape === true, + }) if (!out.max_tokens) out.max_tokens = defaultMaxTokensForModel(out.model) return { claude: withUpstreamStream(out), mode: inboundHasOpenAIToolShape(body) ? 'convert' : 'passthrough' } } diff --git a/src/lib/protocol/eager-tool-streaming.mjs b/src/lib/protocol/eager-tool-streaming.mjs index e2713d819..3d62bcec9 100644 --- a/src/lib/protocol/eager-tool-streaming.mjs +++ b/src/lib/protocol/eager-tool-streaming.mjs @@ -1,16 +1,13 @@ /** - * routing failover.eager_tool_streaming (default on): mark every custom tool - * eager_input_streaming, the shape official Claude Code sends when its - * fine-grained tool streaming is on. Without it upstream buffers a whole Write - * content and stays silent for minutes, which trips the stream idle limit. - * Official Claude Code only enables it on a first-party base URL, so relayed - * clients never send it. Explicit false disables; a caller's own boolean wins; + * routing failover.eager_tool_streaming (default off): mark custom tools + * eager_input_streaming only when the panel turns it on. Official Claude Code + * omits the field. Explicit false stays off; a caller's own boolean wins; * server tools are left alone. */ import { isAnthropicServerTool, isWebSearchTool } from './web-search.mjs' export function eagerToolStreamingEnabled(routing) { - return routing?.failover?.eager_tool_streaming !== false + return routing?.failover?.eager_tool_streaming === true } export function callerEagerInputStreaming(tool) { diff --git a/src/lib/protocol/handle-protocol.mjs b/src/lib/protocol/handle-protocol.mjs index f505c1a4c..e7cc05d39 100644 --- a/src/lib/protocol/handle-protocol.mjs +++ b/src/lib/protocol/handle-protocol.mjs @@ -141,7 +141,7 @@ import { fillMissingCacheTtl, validateCacheBreakpoints, } from './cache-ttl.mjs' -import { confirmCachePrefix, describeCacheContinuity, trackCachePrefix } from './cache-prefix.mjs' +import { confirmCachePrefix, describeCacheContinuity, formatCacheBreak, trackCachePrefix } from './cache-prefix.mjs' import { ensureClaudeWebSearch, shouldInjectClaudeWebSearch } from './web-search.mjs' import { dispatchStreamInference } from '../transport/kernel-router.mjs' import { syncClaudeKernelConfigsFromFile } from '../transport/rust-kernel-supervisor.mjs' @@ -829,10 +829,14 @@ export function createHandleProtocol(deps) { const hdrRewrite = String(req.headers['x-kin-rewrite'] || '') === '1' const rewriteEnabled = cfg.rewrite.enabled || hdrRewrite + const officialInbound = + isOfficialClaudeCodeTraffic(req.headers, inbound) || + (detectProxiedOfficialCcFromRoutingFile(routingConfigPath) && isProxiedOfficialClaudeCode(inbound, req.headers)) const converted = toClaudeMessages(protocol, ctx.body, { rewrite: rewriteEnabled, model_map: false, strict_passthrough: String(req.headers['x-kin-strict-passthrough'] || '') === '1', + preserve_content_shape: officialInbound, }) stats.requests++ stats.by_route[protocol] = (stats.by_route[protocol] || 0) + 1 @@ -841,9 +845,7 @@ export function createHandleProtocol(deps) { else stats.convert++ ctx = applyIntercept(cfg.intercept.rules, 'before_upstream', { ...ctx, body: converted.claude }) - const officialTraffic = - isOfficialClaudeCodeTraffic(req.headers, inbound) || - (detectProxiedOfficialCcFromRoutingFile(routingConfigPath) && isProxiedOfficialClaudeCode(inbound, req.headers)) + const officialTraffic = officialInbound const requestContext = classifyClaudeRequestPurpose(inbound, { officialTraffic }) if (requestContext) { logBag.classifier = classifierRequestSummary(inbound, ctx.body, requestContext) @@ -896,6 +898,7 @@ export function createHandleProtocol(deps) { // read before outbound cleaning. API key never scopes it. const inboundIdentity = resolveInboundIdentity({ inbound, body: ctx.body, headers: req.headers }) const stickyDeviceId = inboundIdentity.deviceId + logBag.inbound_device_id = sessionIdForLog(stickyDeviceId) if (childDeclaredWithoutParent(inbound, req.headers)) { stats.errors++ logBag.error_code = 'family_relation_required' @@ -1201,7 +1204,7 @@ export function createHandleProtocol(deps) { logBag.cache_prefix = prefix logBag.cache_prefix_key = key if (!prefix?.break) return - const where = prefix.break.section === 'messages' ? `messages[${prefix.break.index}]` : prefix.break.section + const where = formatCacheBreak(prefix) console.warn(`[cache-prefix] request ${logCtx.request_id} turn ${prefix.turn} broke at ${where}`) } let result @@ -1275,9 +1278,7 @@ export function createHandleProtocol(deps) { boundAccountId: keptAccount, boundVmId: keptVm, vmId: selected.vmId, - epoch: extra.freshSlot - ? `slot:${selected.slotIndex ?? 'x'}:${attemptStartedAt}` - : `${attemptStartedAt}:${selected.vmId || extra.attemptNo || ''}`, + epoch: extra.freshSlot ? `slot:${selected.slotIndex ?? 'x'}:${attemptStartedAt}` : 'pending', }) if (identity && attemptSessionId) identity.callerSessionId = attemptSessionId const credMode = credentialModeFromOauth(selected.vm?.claude || {}) @@ -1301,18 +1302,17 @@ export function createHandleProtocol(deps) { ) let hopBody = body if (cliHop) { - // Classifier keeps its own system. Official family keeps its inbound - // system only when the protocol tab turns passthrough on. - const panelRewrite = !requestContext && !officialPassthrough + // The front rewrite already produces the official two-block shape. + const panelRewrite = false hopBody = prepareCliHopBody(repaired ? body : hopBody, { stream: upstreamStream, repaired, cacheTtl: requestedCacheTtl, requestContext, - preserveSystem: officialPassthrough, preserveThinkingBlocks: preserveCliHopThinkingBlocks, preserveCallerEnvironment, autoCompactWindow: ctx.autoCompactWindow, + routing: routingNow, }) hopBody = await materializeRemoteImageSources(hopBody) if (panelRewrite) { diff --git a/src/lib/protocol/outbound-attempt.mjs b/src/lib/protocol/outbound-attempt.mjs index 7e9be7668..f451234b3 100644 --- a/src/lib/protocol/outbound-attempt.mjs +++ b/src/lib/protocol/outbound-attempt.mjs @@ -167,18 +167,107 @@ function stabilizeMessageBudgets(body) { }) return changed ? { ...body, messages } : body } +const OFFICIAL_CACHE = Object.freeze({ type: 'ephemeral' }) + +function textOf(block) { + if (typeof block === 'string') return block + return typeof block?.text === 'string' ? block.text : '' +} + +/** + * Every cli-hop request leaves as the captured Claude Code shape: the identity + * sentence, then the caller's own prompt. Both markers omit ttl. A request that + * already has that identity keeps its second block; anything else is joined + * behind a fresh identity. + */ +export function officialSystemTemplate(system) { + const blocks = Array.isArray(system) ? system : system == null || system === '' ? [] : [system] + const texts = blocks.map(textOf).filter((text) => text.trim()) + const identityAt = texts.findIndex((text) => text.trim() === CRS_OFFICIAL_CLI_SYSTEM) + const body = identityAt >= 0 ? texts.slice(identityAt + 1).join('\n\n') : texts.join('\n\n') + const out = [{ type: 'text', text: CRS_OFFICIAL_CLI_SYSTEM, cache_control: { ...OFFICIAL_CACHE } }] + if (body.trim()) out.push({ type: 'text', text: body, cache_control: { ...OFFICIAL_CACHE } }) + return out +} + +/** One marker, on the last text of the current tail, ttl omitted. */ +function officialMessageMarker(messages) { + if (!Array.isArray(messages) || messages.length === 0) return messages + const next = messages.map((message) => { + if (!message || typeof message !== 'object') return message + if (typeof message.content === 'string') { + if (!message.cache_control) return message + const copy = { ...message } + delete copy.cache_control + return copy + } + if (!Array.isArray(message.content)) return message + let changed = false + const content = message.content.map((block) => { + if (!block?.cache_control) return block + changed = true + const copy = { ...block } + delete copy.cache_control + return copy + }) + return changed ? { ...message, content } : message + }) + let tail = -1 + for (let i = next.length - 1; i >= 0; i--) { + if (next[i]?.role !== 'user') continue + const content = next[i].content + if (typeof content === 'string' ? content : Array.isArray(content) && content.length) { + tail = i + break + } + } + if (tail < 0) return next + const message = next[tail] + if (typeof message.content === 'string') { + next[tail] = { + ...message, + content: [{ type: 'text', text: message.content, cache_control: { ...OFFICIAL_CACHE } }], + } + return next + } + const content = message.content.slice() + for (let i = content.length - 1; i >= 0; i--) { + if (content[i]?.type === 'thinking' || content[i]?.type === 'redacted_thinking') continue + content[i] = { ...content[i], cache_control: { ...OFFICIAL_CACHE } } + next[tail] = { ...message, content } + return next + } + return next +} +/** Official Claude Code omits eager streaming and tool cache markers. A caller TTL here makes CLI fill the omitted system and message markers. */ +function stripEagerInputStreaming(tools) { + if (!Array.isArray(tools)) return tools + let changed = false + const next = tools.map((tool) => { + if (!tool || typeof tool !== 'object') return tool + const hasOwn = Object.prototype.hasOwnProperty.call(tool, 'eager_input_streaming') + const hasCache = Object.prototype.hasOwnProperty.call(tool, 'cache_control') + const fn = tool.function + const fnHas = fn && typeof fn === 'object' && Object.prototype.hasOwnProperty.call(fn, 'eager_input_streaming') + if (!hasOwn && !hasCache && !fnHas) return tool + changed = true + const copy = { ...tool } + delete copy.eager_input_streaming + delete copy.cache_control + if (fnHas) { + copy.function = { ...fn } + delete copy.function.eager_input_streaming + } + return copy + }) + return changed ? next : tools +} /** - * Caller fields plus Node-owned message breakpoints. - * CLI owns persona layout and system/tools markers; kernel only forwards. - * - * role=system turns stay where the caller put them, including a trailing one: - * Claude Code ends most turns with a reminder (SessionStart context, then - * ``). Moving it into system[] puts a different text in the - * cached system block whenever the reminder changes, so the whole prefix - * misses. In place, it is history on the next turn and the prefix only grows. - * This must not depend on client classification: relays strip the billing - * block and rewrite the UA, so relayed Claude Code looks third-party. + * Every non-classifier body leaves as the official two-block template. + * Node owns that shape: identity, caller body, omitted TTL, one marker on the + * latest user turn. A trailing role=system stays a string so the cached prefix + * does not move when the reminder changes. */ export function prepareCliHopBody( canonicalBody, @@ -187,48 +276,46 @@ export function prepareCliHopBody( repaired = false, cacheTtl = DEFAULT_CACHE_TTL, requestContext = null, - preserveSystem = false, preserveThinkingBlocks = false, preserveCallerEnvironment = false, autoCompactWindow = null, + routing = null, } = {}, ) { if (requestContext?.purpose === 'auto_mode_classifier') return prepareClassifierBody(canonicalBody, { stream }) - if (preserveSystem) validateCacheBreakpoints(canonicalBody) - let body = officialMessagesBody(canonicalBody, { stream }) + let body = officialMessagesBody(canonicalBody, { stream, preserveContentShape: true }) const windowTokens = Number(autoCompactWindow) if (Number.isInteger(windowTokens) && windowTokens >= 100_000 && windowTokens <= 1_000_000) { body.auto_compact_window = windowTokens } delete body.metadata - if (!preserveSystem) { - const leftover = stripCliOwnedSystem(body.system, { preserveCallerEnvironment }) - if (leftover == null) delete body.system - else body.system = leftover - } - if (!preserveSystem) { - body = stabilizeSystemBudget(body) - body = stabilizeMessageBudgets(body) - } + const leftover = stripCliOwnedSystem(body.system, { preserveCallerEnvironment }) + if (leftover == null) delete body.system + else body.system = leftover + body = stabilizeSystemBudget(body) + body = stabilizeMessageBudgets(body) // cli-node sends mid-conversation-system. Only models that reject the role need the lift. if (!modelSupportsMidConversationSystem(body.model)) body = liftMidConversationSystemMessages(body) - if (!preserveSystem) body = collapseSystemBudgetReminders(body) + body = collapseSystemBudgetReminders(body) if (!repaired) { - body = ensureUnofficialAdaptiveThinking(body) normalizeThinkingForModel(body) body = pinHaikuCliThinking(body) body = ensureUnofficialEffortHigh(body) - body = ensureClearThinkingContextManagement(body) } + body = applyThinkingDisplay(body, thinkingDisplayMode(routing)) body = stripInvalidThinkingBlocks(body, { preserveThinkingBlocks }) body = applyModelRequestRules(body) body = ensureOutputConfigSchema(body) - body = alignSamplingWithThinking(body) - if (!preserveSystem) { - body = stripIllegalCacheControlFields(body) - body = removeCacheControlFields(body) - body = applyMessageBreakpoints(body, normalizeCacheTtl(cacheTtl), 'rewrite') + if (Object.prototype.hasOwnProperty.call(body, 'temperature')) delete body.temperature + if (Object.prototype.hasOwnProperty.call(body, 'top_p')) delete body.top_p + if (Object.prototype.hasOwnProperty.call(body, 'top_k')) delete body.top_k + if (Object.prototype.hasOwnProperty.call(body, 'context_management')) delete body.context_management + if (Object.prototype.hasOwnProperty.call(body, 'cache_control')) delete body.cache_control + if (!requestContext) { + body = { ...body, system: officialSystemTemplate(body.system) } + body.messages = officialMessageMarker(body.messages) + if (Array.isArray(body.tools)) body = { ...body, tools: stripEagerInputStreaming(body.tools) } } return body } diff --git a/src/lib/protocol/sanitize.mjs b/src/lib/protocol/sanitize.mjs index 6f22664ec..6758b7a3d 100644 --- a/src/lib/protocol/sanitize.mjs +++ b/src/lib/protocol/sanitize.mjs @@ -169,9 +169,18 @@ export function promoteSystemToBlocks(system) { * blocks. Kernel restamp and applyCacheBreakpoints only hang markers on blocks, * so every inbound protocol must leave this shape before persona / hop. */ -export function canonicalizeClaudeMessagesShape(body) { +export function canonicalizeClaudeMessagesShape(body, { preserveContentShape = false } = {}) { if (!body || typeof body !== 'object') return body const out = { ...body } + if (preserveContentShape) { + if (Array.isArray(out.messages)) { + out.messages = out.messages.map((message) => { + if (!message || message.role === 'system' || typeof message.content !== 'string') return message + return { ...message, content: [{ type: 'text', text: message.content }] } + }) + } + return out + } if (out.system != null) out.system = promoteSystemToBlocks(out.system) if (Array.isArray(out.messages)) { out.messages = out.messages.map((message) => { @@ -182,7 +191,7 @@ export function canonicalizeClaudeMessagesShape(body) { return out } -export function sanitizeAnthropicBody(body, { strictPassthrough = false } = {}) { +export function sanitizeAnthropicBody(body, { strictPassthrough = false, preserveContentShape = false } = {}) { if (!body || typeof body !== 'object') return body if (strictPassthrough) { const out = { ...body } @@ -193,7 +202,7 @@ export function sanitizeAnthropicBody(body, { strictPassthrough = false } = {}) const out = copyOfficialAnthropicFields(body) applyStructuredOutput(out, body) - if (Array.isArray(out.system)) { + if (!preserveContentShape && Array.isArray(out.system)) { const blocks = out.system .map((b) => { if (typeof b === 'string') return { type: 'text', text: b } @@ -216,7 +225,7 @@ export function sanitizeAnthropicBody(body, { strictPassthrough = false } = {}) if (!out.max_tokens) out.max_tokens = defaultMaxTokensForModel(out.model) if (out.tool_choice && !out.tools) delete out.tool_choice normalizeStop(out) - return canonicalizeClaudeMessagesShape(out) + return canonicalizeClaudeMessagesShape(out, { preserveContentShape }) } /** diff --git a/src/lib/protocol/thinking.mjs b/src/lib/protocol/thinking.mjs index 558ada7ce..fe0da661b 100644 --- a/src/lib/protocol/thinking.mjs +++ b/src/lib/protocol/thinking.mjs @@ -189,10 +189,10 @@ export function modelHidesThinkingByDefault(model) { /** * `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. + * turned thinking on but hid it: explicit `omitted` or `updates`, no display + * at all, or no display on a model whose default is `omitted`. Other values + * and `disabled` stay untouched. `passthrough` sends the caller's choice. + * Never mutates the input. */ export function applyThinkingDisplay(body, mode) { if (mode !== 'summarized' || !body || typeof body !== 'object') return body @@ -205,8 +205,9 @@ export function applyThinkingDisplay(body, mode) { const display = String(thinking.display ?? '') .trim() .toLowerCase() + if (display === 'summarized') return body const hidden = HIDDEN_THINKING_DISPLAYS.has(display) - const unset = !display && modelHidesThinkingByDefault(body.model) + const unset = !display if (!hidden && !unset) return body return { ...body, thinking: { ...thinking, display: 'summarized' } } } diff --git a/test/unit/api-backend.test.mjs b/test/unit/api-backend.test.mjs index 612c7fc22..587be2c9d 100644 --- a/test/unit/api-backend.test.mjs +++ b/test/unit/api-backend.test.mjs @@ -134,7 +134,9 @@ test('API backend applies the global official_full persona setting', async () => assert.ok(received) const outbound = JSON.parse(received.body) assert.equal(outbound.system.length, 5) - assert.equal(outbound.system[3].text, `${DEFAULT_AGENT_STANDING}\n${CRS_OFFICIAL_AGENT_PROMPT}`) + assert.ok( + outbound.system.some((block) => String(block.text || '').includes(CRS_OFFICIAL_AGENT_PROMPT.slice(0, 80))), + ) } finally { await new Promise((resolve) => kernel.close(resolve)) fs.rmSync(root, { recursive: true, force: true }) @@ -220,8 +222,8 @@ test('OAuth cli-hop applies the resolved Protocol custom persona template', asyn await handler.handleProtocol(req, response, 'anthropic.messages', '/v1/messages') assert.equal(response.status, 503) assert.ok(prepared?.body) - assert.equal(prepared.body.system.length, 1) - assert.equal(prepared.body.system[0].text, 'CUSTOM_PERSONA UTC') + assert.equal(prepared.body.system.length, 2) + assert.ok(prepared.body.system.some((block) => String(block.text || '').includes('CUSTOM_PERSONA UTC'))) } finally { fs.rmSync(root, { recursive: true, force: true }) } diff --git a/test/unit/cli-hop-body.test.mjs b/test/unit/cli-hop-body.test.mjs index fc8a38c89..5b7bef87a 100644 --- a/test/unit/cli-hop-body.test.mjs +++ b/test/unit/cli-hop-body.test.mjs @@ -30,16 +30,15 @@ test('prepareCliHopBody drops metadata and CLI-owned system but keeps official a messages: [{ role: 'user', content: 'hi' }], }) assert.equal(body.metadata, undefined) - assert.equal(body.system.length, 1) - assert.equal(body.system[0].text, CRS_OFFICIAL_AGENT_PROMPT) + assert.equal(body.system[0].text, CRS_OFFICIAL_CLI_SYSTEM) + assert.equal(body.system[0].cache_control.ttl, undefined) + assert.match(body.system[1].text, /You are an interactive agent/) assert.equal(body.model, 'claude-sonnet-5') - assert.deepEqual(body.messages[0].content, [ - { type: 'text', text: 'hi', cache_control: { type: 'ephemeral', ttl: '1h' } }, - ]) + assert.deepEqual(body.messages[0].content, [{ type: 'text', text: 'hi', cache_control: { type: 'ephemeral' } }]) assert.equal(body.stream, true) }) -test('prepareCliHopBody preserveSystem keeps official billing and system cache_control', () => { +test('every cli-hop system becomes the identity plus the caller body', () => { const billing = 'x-anthropic-billing-header: cc_version=2.1.280.096; cc_entrypoint=cli; cch=bdff0;' const body = prepareCliHopBody( { @@ -48,18 +47,20 @@ test('prepareCliHopBody preserveSystem keeps official billing and system cache_c system: [ { type: 'text', text: billing }, { type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." }, - { type: 'text', text: 'RFC 2119 keywords', cache_control: { type: 'ephemeral' } }, + { type: 'text', text: 'RFC 2119 keywords', cache_control: { type: 'ephemeral', ttl: '1h' } }, ], messages: [{ role: 'user', content: 'ping' }], }, { preserveSystem: true }, ) - assert.equal(body.system[0].text, billing) - assert.match(body.system[1].text, /You are Claude Code/) - assert.deepEqual(body.system[2].cache_control, { type: 'ephemeral' }) + assert.equal(body.system.length, 2) + assert.equal(body.system[0].text, "You are Claude Code, Anthropic's official CLI for Claude.") + assert.equal(body.system[1].text, 'RFC 2119 keywords') + assert.deepEqual(body.system[0].cache_control, { type: 'ephemeral' }) + assert.deepEqual(body.system[1].cache_control, { type: 'ephemeral' }) }) -test('cli-hop passthrough preserves mixed TTLs, missing TTL and caller marker positions', () => { +test('cli-hop normalizes mixed caller markers to the official template', () => { const input = { model: 'claude-opus-5-5', max_tokens: 128, @@ -74,21 +75,23 @@ test('cli-hop passthrough preserves mixed TTLs, missing TTL and caller marker po { role: 'user', content: [{ type: 'text', text: 'last' }] }, ], } - const before = structuredClone(input) const body = prepareCliHopBody(input, { preserveSystem: true, cacheTtl: '1h' }) - assert.deepEqual(body.tools[0].cache_control, before.tools[0].cache_control) - assert.deepEqual(body.system, before.system) - assert.deepEqual(body.messages, before.messages) - assert.deepEqual(input, before) + assert.equal(body.system[0].text, CRS_OFFICIAL_CLI_SYSTEM) + assert.equal(body.system[1].text, 'caller') + assert.deepEqual(body.system[1].cache_control, { type: 'ephemeral' }) + assert.equal(body.messages[0].content[0].cache_control, undefined) + assert.equal(body.messages[1].content[0].cache_control, undefined) + assert.deepEqual(body.messages[2].content[0].cache_control, { type: 'ephemeral' }) }) -test('disabled persona environment preserves caller environment through CLI preparation', () => { +test('caller environment stays behind the official identity', () => { const caller = ' # Environment\n - Timezone: Pacific/Auckland\n - Caller-only fact: KEEP_ME\n ' const body = prepareCliHopBody( { model: 'claude-opus-5-5', system: [{ type: 'text', text: caller }], messages: [{ role: 'user', content: 'hi' }] }, { preserveCallerEnvironment: true }, ) - assert.deepEqual(body.system, [{ type: 'text', text: caller }]) + assert.equal(body.system[0].text, CRS_OFFICIAL_CLI_SYSTEM) + assert.equal(body.system[1].text, caller) }) test('environment opt-out prevents HTTP slot selection from overwriting caller timezone', () => { @@ -107,30 +110,38 @@ test('environment opt-out prevents HTTP slot selection from overwriting caller t assert.deepEqual(body.system, [{ type: 'text', text: caller }]) }) -test('cli-hop passthrough rejects invalid caller markers instead of repairing them', () => { +test('cli-hop strips caller message markers then restamps only a text tail', () => { const request = (content) => ({ model: 'claude-opus-5-5', messages: [{ role: 'user', content }], }) const marker = (ttl) => ({ type: 'text', text: 'anchor', cache_control: { type: 'ephemeral', ...ttl } }) + const messageMarkers = (body) => + body.messages + .flatMap((message) => (Array.isArray(message.content) ? message.content : [])) + .filter((block) => block.cache_control) + for (const content of [ Array.from({ length: 5 }, () => marker({ ttl: '5m' })), [marker({}), marker({ ttl: '1h' })], [marker({ ttl: 'bad' })], - [{ type: 'thinking', thinking: 'private', cache_control: { type: 'ephemeral' } }], - [{ type: 'redacted_thinking', data: 'private', cache_control: { type: 'ephemeral' } }], ]) { const input = request(content) const before = structuredClone(input) - assert.throws( - () => prepareCliHopBody(input, { preserveSystem: true }), - (error) => { - assert.equal(error.status, 400) - assert.equal(error.body.error.code, 'invalid_cache_control') - return true - }, - ) + const body = prepareCliHopBody(input) assert.deepEqual(input, before) + const marked = messageMarkers(body) + assert.equal(marked.length, 1) + assert.equal(marked[0].type, 'text') + assert.deepEqual(marked[0].cache_control, { type: 'ephemeral' }) + } + + for (const content of [ + [{ type: 'thinking', thinking: 'private', cache_control: { type: 'ephemeral' } }], + [{ type: 'redacted_thinking', data: 'private', cache_control: { type: 'ephemeral' } }], + ]) { + const body = prepareCliHopBody(request(content)) + assert.equal(messageMarkers(body).length, 0) } }) @@ -147,8 +158,8 @@ test('prepareCliHopBody keeps caller leftover system and tools', () => { messages: [{ role: 'user', content: '你好呀。' }], }) assert.equal(body.metadata, undefined) - assert.equal(body.system.length, 1) - assert.equal(body.system[0].text, '你是一个高速收费员。') + assert.equal(body.system[0].text, CRS_OFFICIAL_CLI_SYSTEM) + assert.equal(body.system[1].text, '你是一个高速收费员。') assert.equal(body.tools[0].name, 'get_weather') assert.equal(body.thinking.type, 'disabled') }) @@ -159,16 +170,19 @@ test('stripCliOwnedSystem leaves empty inbound system absent', () => { assert.equal(stripCliOwnedSystem([{ type: 'text', text: '' }]), undefined) }) -test('prepareCliHopBody fills 2.1.263 thinking effort and context_management', () => { +test('prepareCliHopBody follows summarized display and omits sampling and context_management', () => { const body = prepareCliHopBody({ model: 'claude-sonnet-5', + thinking: { type: 'adaptive' }, messages: [{ role: 'user', content: 'hello' }], }) - assert.deepEqual(body.thinking, { type: 'adaptive', display: 'omitted' }) + assert.deepEqual(body.thinking, { type: 'adaptive', display: 'summarized' }) assert.equal(body.output_config.effort, 'high') - assert.equal(body.context_management.edits[0].type, 'clear_thinking_20251015') + assert.equal(body.context_management, undefined) + assert.equal(body.temperature, undefined) assert.equal(body.metadata, undefined) - assert.equal(body.system, undefined) + assert.equal(body.system.length, 1) + assert.equal(body.system[0].text, CRS_OFFICIAL_CLI_SYSTEM) }) test('prepareCliHopBody does not overwrite caller thinking disabled', () => { @@ -186,6 +200,7 @@ test('prepareCliHopBody strips unsigned dummy and short thinking history', () => const body = prepareCliHopBody({ model: 'claude-sonnet-5', max_tokens: 256, + thinking: { type: 'adaptive' }, messages: [ { role: 'user', content: 'hi' }, { @@ -202,8 +217,8 @@ test('prepareCliHopBody strips unsigned dummy and short thinking history', () => { role: 'user', content: 'again' }, ], }) - assert.deepEqual(body.thinking, { type: 'adaptive', display: 'omitted' }) - assert.equal(body.temperature, 1) + assert.deepEqual(body.thinking, { type: 'adaptive', display: 'summarized' }) + assert.equal(body.temperature, undefined) assert.deepEqual(body.messages[1].content, [ { type: 'thinking', thinking: 'keep-me', signature: 'sig_real_1234567890abcdef' }, { type: 'thinking', thinking: '', signature: 'sig_empty_text_still_long_enough' }, @@ -231,11 +246,8 @@ test('prepareCliHopBody drops thinking without a usable signature on the preserv }, { preserveThinkingBlocks: true }, ) - assert.deepEqual(body.messages[1].content, [ - { type: 'thinking', thinking: 'existing', signature: 'sig_real_1234567890abcdef' }, - { type: 'redacted_thinking', data: '' }, - { type: 'text', text: 'hello', cache_control: { type: 'ephemeral', ttl: '1h' } }, - ]) + assert.equal(body.messages[1].content.at(-1).text, 'hello') + assert.equal(body.messages[1].content.at(-1).cache_control, undefined) }) test('prepareCliHopBody keeps a short non-dummy signature on Opus 5.5', () => { @@ -369,11 +381,9 @@ test('cli-hop drops caller system/tools markers and restamps last plus penultima ) assert.equal(body.cache_control, undefined) assert.equal(body.tools[0].cache_control, undefined) - assert.equal(body.system[0].cache_control, undefined) - assert.deepEqual(messageMarkers(body), [ - { index: 0, blockIndex: 0, type: 'text', ttl: '5m' }, - { index: 3, blockIndex: 0, type: 'text', ttl: '5m' }, - ]) + assert.equal(body.system[0].text, CRS_OFFICIAL_CLI_SYSTEM) + assert.deepEqual(body.system[0].cache_control, { type: 'ephemeral' }) + assert.deepEqual(messageMarkers(body), [{ index: 2, blockIndex: 0, type: 'text', ttl: undefined }]) }) test('cli-hop short history only marks the last message', () => { @@ -390,7 +400,7 @@ test('cli-hop short history only marks the last message', () => { body.messages.map((message) => message.role), ['user', 'assistant', 'user'], ) - assert.deepEqual(messageMarkers(body), [{ index: 2, blockIndex: 0, type: 'text', ttl: '1h' }]) + assert.deepEqual(messageMarkers(body), [{ index: 2, blockIndex: 0, type: 'text', ttl: undefined }]) }) test('cli-hop does not mark thinking blocks', () => { @@ -398,19 +408,21 @@ test('cli-hop does not mark thinking blocks', () => { { model: 'claude-sonnet-5', max_tokens: 256, + thinking: { type: 'adaptive' }, messages: [ { role: 'assistant', content: [ { type: 'text', text: 'answer' }, - { type: 'thinking', thinking: 'hidden', signature: 'sig'.repeat(8) }, + { type: 'thinking', thinking: 'hidden', signature: 'sig_real_1234567890abcdef' }, ], }, ], }, { cacheTtl: '1h' }, ) - assert.deepEqual(body.messages.at(-1).content[0].cache_control, { type: 'ephemeral', ttl: '1h' }) + assert.equal(body.messages.at(-1).content[0].cache_control, undefined) + assert.equal(body.messages.at(-1).content[1].type, 'thinking') assert.equal(body.messages.at(-1).content[1].cache_control, undefined) }) @@ -430,7 +442,7 @@ test('cli-hop uses the inbound session TTL on both message anchors', () => { ) assert.deepEqual( messageMarkers(body).map((hit) => hit.ttl), - ['1h', '1h'], + [undefined], ) }) diff --git a/test/unit/convert-cache-shape.test.mjs b/test/unit/convert-cache-shape.test.mjs index 18e499087..56583ccb9 100644 --- a/test/unit/convert-cache-shape.test.mjs +++ b/test/unit/convert-cache-shape.test.mjs @@ -130,7 +130,7 @@ test('unofficial cli-hop converts openai.chat and messages to the same marked sh toClaudeMessages('anthropic.messages', { model: MODEL, max_tokens: 256, messages: turns }).claude, { unofficial: true, cacheTtl: '1h' }, ) - assert.deepEqual(stampMap(fromChat), ['messages[2].content[0]', 'messages[4].content[0]']) + assert.deepEqual(stampMap(fromChat), ['system[0]', 'messages[4].content[0]']) assert.deepEqual(stampMap(fromMessages), stampMap(fromChat)) assert.deepEqual( fromChat.messages.map((message) => message.role), @@ -151,7 +151,7 @@ test('openai.chat multi-turn cli-hop marks last and penultimate user', () => { ], }).claude const body = prepareCliHopBody(chat, { unofficial: true, cacheTtl: '1h' }) - assert.deepEqual(body.messages[2].content[0].cache_control, { type: 'ephemeral', ttl: '1h' }) - assert.deepEqual(body.messages[4].content[0].cache_control, { type: 'ephemeral', ttl: '1h' }) + assert.equal(body.messages[2].content[0].cache_control, undefined) + assert.deepEqual(body.messages[4].content[0].cache_control, { type: 'ephemeral' }) assert.equal(body.messages[0].content[0].cache_control, undefined) }) diff --git a/test/unit/crs-persona.test.mjs b/test/unit/crs-persona.test.mjs index aa6d9fd2c..5819ecb88 100644 --- a/test/unit/crs-persona.test.mjs +++ b/test/unit/crs-persona.test.mjs @@ -21,6 +21,7 @@ import { CRS_AGENT_PROMPT_REQUIRED, CRS_OFFICIAL_CLI_SYSTEM, CRS_OFFICIAL_SYSTEM, + CRS_REPORTING_OUTCOMES, CRS_PROMPT_LEAK_APPEND, DEFAULT_CACHE_CONTROL_TTL, DEFAULT_CLI_VERSION, @@ -113,6 +114,7 @@ test('forceConfigured rewrites official and proxied-official system onto the pre const out = applyCrsUnofficialPersona(body, { headers, mode: 'official_full', forceConfigured: true }) assert.match(out.system[0].text, /^x-anthropic-billing-header:/) assert.equal(out.system[1].text, CRS_OFFICIAL_CLI_SYSTEM) + assert.equal(out.system[2].text, CRS_REPORTING_OUTCOMES) assert.equal(out.system[3].text, CRS_OFFICIAL_AGENT_PROMPT) assert.match(out.system.at(-1).text, /RFC 2119/) assert.match(out.system.at(-1).text, //) @@ -144,7 +146,7 @@ test('empty unofficial system still writes the official full prompt', () => { assert.match(out.system[0].text, /^x-anthropic-billing-header:/) assert.equal(out.system[0].cache_control, undefined) assert.equal(out.system[1].text, CRS_OFFICIAL_CLI_SYSTEM) - assert.match(out.system[2].text, /^# Reporting outcomes/) + assert.equal(out.system[2].text, CRS_REPORTING_OUTCOMES) assert.equal(out.system[3].text, CRS_OFFICIAL_AGENT_PROMPT) assert.deepEqual(out.system[3].cache_control, { type: 'ephemeral', ttl: '1h' }) assert.match(out.system[4].text, /# Text output/) @@ -726,10 +728,14 @@ test('pi and omp harness prompts replace the official full system instead of sta texts.some((text) => text.startsWith('x-anthropic-billing-header:')), mode, ) - assert.ok( - mode === 'official_full' ? texts.includes(CRS_OFFICIAL_CLI_SYSTEM) : texts.includes(CRS_OFFICIAL_SYSTEM), - mode, - ) + if (mode === 'official_full') { + assert.ok(texts.includes(CRS_OFFICIAL_CLI_SYSTEM), mode) + assert.ok(texts.includes(CRS_REPORTING_OUTCOMES), mode) + } else { + assert.ok(texts.includes(CRS_OFFICIAL_SYSTEM), mode) + assert.equal(texts.includes(CRS_OFFICIAL_CLI_SYSTEM), false, mode) + assert.equal(texts.includes(CRS_REPORTING_OUTCOMES), false, mode) + } const harness = texts.find((text) => text.includes('Oh My Pi coding harness')) assert.match(harness, //) assert.match(harness, /\/tmp\/demo/) diff --git a/test/unit/eager-tool-streaming.test.mjs b/test/unit/eager-tool-streaming.test.mjs index 0e9ed22c6..60d30c879 100644 --- a/test/unit/eager-tool-streaming.test.mjs +++ b/test/unit/eager-tool-streaming.test.mjs @@ -22,11 +22,11 @@ function byName(tools) { return Object.fromEntries(tools.map((tool) => [tool.name, tool])) } -test('applyEagerToolStreaming defaults on when unset', () => { +test('applyEagerToolStreaming stays off unless the panel turns it on', () => { const unset = applyEagerToolStreaming(body(), {}) const emptyFailover = applyEagerToolStreaming(body(), { failover: {} }) - assert.equal(byName(unset.tools).Write.eager_input_streaming, true) - assert.equal(byName(emptyFailover.tools).Write.eager_input_streaming, true) + assert.equal(byName(unset.tools).Write.eager_input_streaming, undefined) + assert.equal(byName(emptyFailover.tools).Write.eager_input_streaming, undefined) assert.equal(byName(applyEagerToolStreaming(body(), on).tools).Write.eager_input_streaming, true) }) @@ -45,8 +45,8 @@ test('applyEagerToolStreaming marks every custom tool and keeps caller booleans' assert.equal(tools.web_search.eager_input_streaming, undefined) }) -test('the eager flag survives outbound tool sanitizing', () => { - const out = officialMessagesBody(applyEagerToolStreaming(body(), {})) +test('the eager flag survives outbound tool sanitizing when the panel enables it', () => { + const out = officialMessagesBody(applyEagerToolStreaming(body(), on)) assert.equal(out.tools.find((tool) => tool.name === 'Write').eager_input_streaming, true) }) @@ -66,7 +66,7 @@ test('OpenAI chat and responses tools get eager on the outbound Anthropic body', ], } const converted = toClaudeMessages(protocol, inbound) - const out = officialMessagesBody(applyEagerToolStreaming(converted.claude, {})) + const out = officialMessagesBody(applyEagerToolStreaming(converted.claude, on)) const tools = byName(out.tools) assert.equal(tools.Write.eager_input_streaming, true) assert.equal(tools.Edit.eager_input_streaming, false) @@ -80,7 +80,7 @@ test('applyEagerToolStreaming marks leftover OpenAI function tools before saniti messages: [{ role: 'user', content: 'hi' }], tools: [{ type: 'function', function: { name: 'Write', parameters: schema } }], } - const marked = applyEagerToolStreaming(openaiBody, {}) + const marked = applyEagerToolStreaming(openaiBody, on) assert.equal(marked.tools[0].eager_input_streaming, true) assert.equal(marked.tools[0].function.eager_input_streaming, true) const out = officialMessagesBody(marked) diff --git a/test/unit/pool-scheduler.test.mjs b/test/unit/pool-scheduler.test.mjs index c78ded534..2cfc63ab8 100644 --- a/test/unit/pool-scheduler.test.mjs +++ b/test/unit/pool-scheduler.test.mjs @@ -2415,10 +2415,13 @@ function applyRebuildAttempt(callerSession, extra = {}) { const sessionId = resolveOutboundSessionId(callerSession, { mode: 'rebuild', officialClient: true, + accountId: extra.accountId || selected.accountId, + firstUserIdentity: extra.firstUserIdentity || '', + clientDiscriminator: extra.clientDiscriminator || '', boundSessionId: stickyBound?.sessionId || '', boundVmId: stickyBound?.vmId || '', vmId: selected.vmId, - epoch: extra.epoch ?? attemptExtra.attemptStartedAt ?? Date.now(), + epoch: extra.epoch ?? (attemptExtra.freshSlot ? attemptExtra.attemptStartedAt : 'pending'), }) extra.seen?.push({ vmId: selected.vmId, sessionId }) const identity = { deviceId: 'dev', accountUuid: 'acc', vmId: selected.vmId } @@ -2483,6 +2486,39 @@ test('rebuild outbound session stays on one VM and changes after failover', asyn assert.equal(sticky.resolve(stickyKey).sessionId, seen[0].sessionId) }) +test('consecutive turns without an inbound session keep one outbound session', async (t) => { + const root = project() + const sticky = new StickyRouter({ dataDir: path.join(root, 'data'), config: { sticky: { enabled: true } } }) + t.after(() => { + try { + sticky.db?.close?.() + } catch {} + fs.rmSync(root, { recursive: true, force: true }) + }) + const pool = scheduler(root, { stickyRouter: sticky, accountQuota: { canAccept: () => ({ ok: true }) } }) + const runner = new FailoverRunner({ + scheduler: pool, + stickyRouter: sticky, + config: { same_account_retry_delay_ms: 0 }, + }) + const stickyKey = sticky.isolateKey('ch:same-opener', { apiKeyRecord: { id: 'key-1' } }) + const seen = [] + for (const requestId of ['turn-1', 'turn-2', 'turn-3']) { + const result = await runner.run({ + requestId, + canonicalBody: { model: 'claude-opus-test' }, + model: 'claude-opus-test', + stickyKey, + applyAttempt: applyRebuildAttempt('', { seen, accountId: 'acct-1', firstUserIdentity: 'same opener' }), + callAttempt: () => verifiedHop(requestId), + }) + assert.equal(result.ok, true) + } + assert.equal(seen.length, 3) + assert.equal(seen[1].sessionId, seen[0].sessionId) + assert.equal(seen[2].sessionId, seen[0].sessionId) +}) + test('family migration remints parent and child outbound sessions', async (t) => { const root = project() const sticky = new StickyRouter({ dataDir: path.join(root, 'data'), config: { sticky: { enabled: true } } }) diff --git a/web/src/features/logs/log-detail/summary-tab.tsx b/web/src/features/logs/log-detail/summary-tab.tsx index 535fa23de..9628b8229 100644 --- a/web/src/features/logs/log-detail/summary-tab.tsx +++ b/web/src/features/logs/log-detail/summary-tab.tsx @@ -292,6 +292,7 @@ export function SummaryTab({ {row.sessionId || row.clientSessionId || + row.inboundDeviceId || row.reasoningEffort || row.requestId ? (
@@ -325,6 +326,16 @@ export function SummaryTab({
) : null} + {row.inboundDeviceId ? ( +
+ + Device ID + + + {row.inboundDeviceId} + +
+ ) : null} {row.requestId ? (
diff --git a/web/src/lib/persona-template.test.ts b/web/src/lib/persona-template.test.ts index 88ed6714a..acf30487e 100644 --- a/web/src/lib/persona-template.test.ts +++ b/web/src/lib/persona-template.test.ts @@ -54,6 +54,9 @@ describe('persona template contract', () => { describe('agent standing + system preview', () => { const vars = { identity: 'ID', + identity_cli: 'ID', + reporting: 'REPORTING', + dynamic_boundary: 'BOUNDARY', agent_official: 'AGENT', env_official: 'ENV', caller_agent: '', diff --git a/web/src/types/panel-usage-logs.ts b/web/src/types/panel-usage-logs.ts index 0d3186a20..d57ebc0d9 100644 --- a/web/src/types/panel-usage-logs.ts +++ b/web/src/types/panel-usage-logs.ts @@ -97,6 +97,8 @@ export type UsageLogRow = { sessionId: string | null /** 客户端请求带来的会话:Claude Code `metadata.user_id` / `x-claude-code-session-id` 等。 */ clientSessionId: string | null + /** 入站剥离前的客户端 device id。 */ + inboundDeviceId: string | null /** 最终服务的账号展示名(`final_account_id` 优先)。 */ providerName: string | null accountId: string | null