function Lamp({ tone }: { tone: 'on' | 'off' | 'unknown' }) {
@@ -235,7 +232,7 @@ export function SeedPolicyCard({
onChange={(event) => patch({ claude_setting: event.target.value })}
className='min-h-28 font-mono text-xs'
spellCheck={false}
- placeholder={DEFAULT_CLAUDE_SETTING}
+ placeholder='每行 KEY=value'
/>
{syncTelemetry
diff --git a/web/src/features/vm/seed-settings.test.ts b/web/src/features/vm/seed-settings.test.ts
index 4897c174e..535f7674f 100644
--- a/web/src/features/vm/seed-settings.test.ts
+++ b/web/src/features/vm/seed-settings.test.ts
@@ -82,12 +82,17 @@ describe('seed draft', () => {
expect(draft.reject_client_settings).toBe(true)
})
- it('keeps an empty claude.setting file empty', () => {
- const draft = seedDraftOf({
- seed_policy: { claude_setting: 'FROM_POLICY=1' },
- claude_setting: '',
- })
- expect(draft.claude_setting).toBe('')
+ it('keeps a missing or empty claude.setting empty', () => {
+ expect(
+ seedDraftOf({
+ seed_policy: { claude_setting: 'FROM_POLICY=1' },
+ claude_setting: '',
+ }).claude_setting
+ ).toBe('')
+ expect(
+ seedDraftOf({ seed_policy: { claude_setting: 'FROM_POLICY=1' } })
+ .claude_setting
+ ).toBe('')
})
})
diff --git a/web/src/features/vm/seed-settings.ts b/web/src/features/vm/seed-settings.ts
index 434934fe6..e16b290ef 100644
--- a/web/src/features/vm/seed-settings.ts
+++ b/web/src/features/vm/seed-settings.ts
@@ -7,9 +7,6 @@ import {
} from '@/features/vm/telemetry-policy'
import { telemetryStatusLabel } from '@/features/vm/telemetry-status'
-export const DEFAULT_CLAUDE_SETTING =
- 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1'
-
export const REJECT_FLAGS = [
['reject_client_settings', '拒客户端 settings'],
['reject_client_metadata_identity', '拒客户端身份'],
@@ -88,16 +85,9 @@ export function seedDraftOf(
): SeedDraft {
const policy = asRecord(seed?.seed_policy) || {}
const enabled = telemetryEnabled(policy)
- // null/undefined: the file was not read, so the policy text is the only copy.
- // An empty string is a real file and stays empty.
- const fileSetting = settingText(seed?.claude_setting)
- const policySetting = settingText(policy.claude_setting, '')
return {
...telemetrySeedFlags(enabled),
- claude_setting:
- seed?.claude_setting == null
- ? policySetting || DEFAULT_CLAUDE_SETTING
- : fileSetting,
+ claude_setting: settingText(seed?.claude_setting),
reject_client_settings:
'reject_client_settings' in policy
? policy.reject_client_settings !== false
From 2c62e8e0534e88042238a0f38ccd2845690baac9 Mon Sep 17 00:00:00 2001
From: dofastted
Date: Mon, 19 Oct 2026 20:43:19 +0800
Subject: [PATCH 12/19] =?UTF-8?q?fix(panel):=20=E7=BC=BA=E5=A4=B1=E7=9A=84?=
=?UTF-8?q?=20claude.setting=20=E8=BF=94=E5=9B=9E=20null?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GET /seed-settings 不再用策略默认串填补不存在的文件。空文件仍返回空串,策略字段保持独立。
---
src/lib/admin/panel-routes.mjs | 2 +-
test/unit/panel-vm-seed-settings.test.mjs | 75 +++++++++++++++++++++++
2 files changed, 76 insertions(+), 1 deletion(-)
create mode 100644 test/unit/panel-vm-seed-settings.test.mjs
diff --git a/src/lib/admin/panel-routes.mjs b/src/lib/admin/panel-routes.mjs
index 80b67bd18..e2fb1027f 100644
--- a/src/lib/admin/panel-routes.mjs
+++ b/src/lib/admin/panel-routes.mjs
@@ -3535,7 +3535,7 @@ export function createPanelHandler(ctx) {
.replace(/\r\n?/g, '\n')
.replace(/\n+$/g, '')
} catch {
- claude_setting = defaultSeedPolicy(vm.seed_policy || {}).claude_setting
+ // Missing file stays null. The policy default is not this slot's file.
}
try {
kin_seed = JSON.parse(fs.readFileSync(path.join(homeDir, '.claude', 'kin-seed.json'), 'utf8'))
diff --git a/test/unit/panel-vm-seed-settings.test.mjs b/test/unit/panel-vm-seed-settings.test.mjs
new file mode 100644
index 000000000..5926df0bb
--- /dev/null
+++ b/test/unit/panel-vm-seed-settings.test.mjs
@@ -0,0 +1,75 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { createPanelHandler } from '../../src/lib/admin/panel-routes.mjs'
+import { DEFAULT_CLAUDE_SETTING } from '../../src/lib/protocol/seed-policy.mjs'
+
+function seed(root, id) {
+ const dir = path.join(root, 'vms')
+ fs.mkdirSync(dir, { recursive: true })
+ fs.writeFileSync(
+ path.join(dir, `${id}.json`),
+ JSON.stringify({
+ id,
+ name: id,
+ status: 'stopped',
+ seed_policy: { claude_setting: 'FROM_POLICY=1' },
+ }),
+ )
+}
+
+function handler(project) {
+ const response = {}
+ const handlePanel = createPanelHandler({
+ cfg: { paths: { project }, vm: { id: null } },
+ requireAuth(req) {
+ req.apiKeyKind = 'master'
+ req.panelRole = 'admin'
+ return true
+ },
+ json(_res, status, payload) {
+ response.status = status
+ response.body = payload
+ return true
+ },
+ readBody: async () => ({}),
+ proxyPool: { unbindVm() {} },
+ })
+ return { handlePanel, response }
+}
+
+test('GET seed-settings keeps a missing claude.setting null', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-seed-get-'))
+ try {
+ seed(root, 'vm-01')
+ const { handlePanel, response } = handler(root)
+ await handlePanel({ method: 'GET' }, {}, new URL('http://localhost/api/panel/vms/vm-01/seed-settings'))
+ assert.equal(response.status, 200, JSON.stringify(response.body).slice(0, 400))
+ assert.equal(response.body.data.claude_setting, null)
+ assert.equal(response.body.data.settings_json, null)
+ assert.equal(response.body.data.seed_policy.claude_setting, 'FROM_POLICY=1')
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+})
+
+test('GET seed-settings returns an empty claude.setting file as empty', async () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-seed-empty-'))
+ try {
+ seed(root, 'vm-01')
+ const dir = path.join(root, 'vms', 'vm-01', 'cli-home', '.claude')
+ fs.mkdirSync(dir, { recursive: true })
+ fs.writeFileSync(path.join(dir, 'claude.setting'), '\n')
+ fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ env: { TZ: 'UTC' } }))
+ const { handlePanel, response } = handler(root)
+ await handlePanel({ method: 'GET' }, {}, new URL('http://localhost/api/panel/vms/vm-01/seed-settings'))
+ assert.equal(response.status, 200, JSON.stringify(response.body).slice(0, 400))
+ assert.equal(response.body.data.claude_setting, '')
+ assert.equal(response.body.data.seed_policy.claude_setting, '')
+ assert.equal(response.body.data.settings_json.env.TZ, 'UTC')
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+})
From 7776c557b5317a7fbe729fd6a9d81d8de9018ed0 Mon Sep 17 00:00:00 2001
From: dofastted
Date: Mon, 19 Oct 2026 21:06:33 +0800
Subject: [PATCH 13/19] fix(protocol): pin message thread betas across turns
---
src/lib/protocol/claude-code-betas.mjs | 9 ++
src/lib/protocol/handle-protocol.mjs | 1 +
src/lib/protocol/thread-betas.mjs | 38 +++++++
src/lib/transport/go-worker-client.mjs | 86 ++++++++-------
test/unit/go-worker-client.test.mjs | 141 +++++++++++++++++++++++++
test/unit/mid-convo-effort.test.mjs | 31 ++++++
test/unit/thread-betas.test.mjs | 63 +++++++++++
7 files changed, 331 insertions(+), 38 deletions(-)
create mode 100644 src/lib/protocol/thread-betas.mjs
create mode 100644 test/unit/thread-betas.test.mjs
diff --git a/src/lib/protocol/claude-code-betas.mjs b/src/lib/protocol/claude-code-betas.mjs
index aec4b1e39..477f90237 100644
--- a/src/lib/protocol/claude-code-betas.mjs
+++ b/src/lib/protocol/claude-code-betas.mjs
@@ -49,6 +49,14 @@ const FAST_MODE_MODELS = {
'claude-opus-5': true,
'claude-opus-5-5': true,
}
+const MID_OUTPUT_CONFIG_MODELS = {
+ 'claude-haiku-5-5': true,
+ 'claude-sonnet-5-5': true,
+ 'claude-opus-5': true,
+ 'claude-opus-5-5': true,
+ 'claude-fable-5-1': true,
+ 'claude-mythos-5-1': true,
+}
function hasHourCache(node) {
if (Array.isArray(node)) return node.some(hasHourCache)
@@ -69,6 +77,7 @@ export function withRequestProtocolBetas(tokens = [], body = {}) {
.replace(/-\d{8}$/, '')
if (Object.hasOwn(PER_TURN_MODELS, model)) add('per-turn-control-2026-07-01')
if (Object.hasOwn(MID_TOOL_CHANGE_MODELS, model)) add(BETA_MID_CONVERSATION_TOOL_CHANGES)
+ if (body.thread != null && Object.hasOwn(MID_OUTPUT_CONFIG_MODELS, model)) add(BETA_MID_CONVERSATION_OUTPUT_CONFIG)
if (body.output_config?.effort != null) add(BETA_EFFORT)
if (body.thinking?.display === 'updates') add(BETA_THINKING_DISPLAY_UPDATES)
if (Array.isArray(body.safeguards) && body.safeguards.some((item) => item?.type === 'dangerous_tool_use'))
diff --git a/src/lib/protocol/handle-protocol.mjs b/src/lib/protocol/handle-protocol.mjs
index 8a3689db7..8222a9bb8 100644
--- a/src/lib/protocol/handle-protocol.mjs
+++ b/src/lib/protocol/handle-protocol.mjs
@@ -1537,6 +1537,7 @@ export function createHandleProtocol(deps) {
logBag.vm_id = result?.vmId || null
logBag.account_id = result?.accountId || null
logBag.final_account_id = result?.accountId || null
+ if (result?.threadBetas) logBag.hop_meta = { ...logBag.hop_meta, thread_betas: result.threadBetas }
// Zero executions is a real 0, not unknown; the VM is the last one that ran.
logBag.attempt_count = result?.attemptCount ?? 0
logBag.final_state = result?.finalState || result?.terminalState || null
diff --git a/src/lib/protocol/thread-betas.mjs b/src/lib/protocol/thread-betas.mjs
new file mode 100644
index 000000000..d3f8b4112
--- /dev/null
+++ b/src/lib/protocol/thread-betas.mjs
@@ -0,0 +1,38 @@
+const TTL_MS = 24 * 60 * 60 * 1000
+const MAX_ENTRIES = 10_000
+
+const pins = new Map()
+
+function sweep(now) {
+ for (const [id, pin] of pins) {
+ if (pins.size <= MAX_ENTRIES && pin.expiresAt > now) break
+ pins.delete(id)
+ }
+}
+
+export function pinThreadBetas(body, betas, { now = Date.now() } = {}) {
+ const thread = body?.thread
+ if (!thread || typeof thread !== 'object') return { betas, turn: null }
+ const turn = { type: String(thread.type || ''), model: String(body.model || ''), betas }
+ if (turn.type !== 'continue') return { betas, turn }
+ const from = String(thread.previous_message_id || '')
+ const pin = from ? pins.get(from) : null
+ if (!pin || pin.expiresAt <= now) return { betas, turn: { ...turn, pin: 'miss', from } }
+ if (pin.model !== turn.model) return { betas, turn: { ...turn, pin: 'model_changed', from } }
+ const added = pin.betas.filter((token) => !betas.includes(token))
+ const extra = betas.filter((token) => !pin.betas.includes(token))
+ const pinned = [...pin.betas, ...extra]
+ return { betas: pinned, turn: { ...turn, betas: pinned, pin: 'pinned', from, added, extra } }
+}
+
+export function rememberThreadTurn(messageId, turn, { now = Date.now() } = {}) {
+ const id = String(messageId || '')
+ if (!id || !turn) return
+ pins.delete(id)
+ pins.set(id, { betas: [...turn.betas], model: turn.model, expiresAt: now + TTL_MS })
+ sweep(now)
+}
+
+export function resetThreadBetasForTest() {
+ pins.clear()
+}
diff --git a/src/lib/transport/go-worker-client.mjs b/src/lib/transport/go-worker-client.mjs
index 5e90a7ea0..e80937ab6 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 { pinThreadBetas, rememberThreadTurn } from '../protocol/thread-betas.mjs'
import { applyThinkingDisplay, thinkingDisplayMode } from '../protocol/thinking.mjs'
import { sealClaudeCodeCch } from '../identity/cch.mjs'
import { credentialModeFromOauth } from '../oauth/credential-mode.mjs'
@@ -448,7 +449,11 @@ export function finalizeWorkerPayload({
})
const displayed =
requestContext?.purpose === 'auto_mode_classifier' ? body : applyThinkingDisplay(body, thinkingDisplayMode(routing))
- const betas = withRequestProtocolBetas(String(headers?.['anthropic-beta'] || '').split(','), displayed)
+ const thread = pinThreadBetas(
+ displayed,
+ withRequestProtocolBetas(String(headers?.['anthropic-beta'] || '').split(','), displayed),
+ )
+ const betas = thread.betas
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.
@@ -458,6 +463,7 @@ export function finalizeWorkerPayload({
return {
headers,
body: sealClaudeCodeCch(gated, { enabled: routing?.compatibility?.cch_enabled !== false, seal: !cliHop }),
+ thread: thread.turn,
}
}
@@ -495,7 +501,7 @@ function workerEnvelope({
cache_ttl: cacheTtl == null ? null : String(cacheTtl),
}
dumpSessionEnvelope(envelope)
- return envelope
+ return { envelope, thread: finalized.thread }
}
function mockScenario(exec) {
@@ -548,34 +554,35 @@ export async function callGoWorker({
const requestId = randomUUID()
const stopCancel = cancelOnAbort(exec, signal, requestId)
try {
+ const prepared = envelope
+ ? { envelope, thread: null }
+ : workerEnvelope({
+ body,
+ reqHeaders,
+ exec,
+ identity,
+ routing,
+ stream: false,
+ want1m,
+ cacheTtl,
+ preserveCacheBreakpoints,
+ cliHop,
+ requestContext,
+ })
const response = await workerRequest(exec, {
method: 'POST',
requestPath,
- body: {
- ...(envelope ||
- workerEnvelope({
- body,
- reqHeaders,
- exec,
- identity,
- routing,
- stream: false,
- want1m,
- cacheTtl,
- preserveCacheBreakpoints,
- cliHop,
- requestContext,
- })),
- request_id: requestId,
- },
+ body: { ...prepared.envelope, request_id: requestId },
signal,
timeoutMs,
})
const data = await readAll(response)
const parsed = parseJson(data)
const headers = mergeRateLimitHeaders(publicHeaders(response.headers))
+ const ok = response.statusCode >= 200 && response.statusCode < 300 && parsed?.type !== 'error'
+ if (ok) rememberThreadTurn(parsed?.id, prepared.thread)
return restoreKernelErrorStatus({
- ok: response.statusCode >= 200 && response.statusCode < 300 && parsed?.type !== 'error',
+ ok,
status: response.statusCode || 0,
via: 'go-worker',
body: parsed,
@@ -585,6 +592,7 @@ export async function callGoWorker({
stopReason: parsed?.stop_reason || null,
terminalState: headers['x-terminal-state'] || null,
transportError: false,
+ threadBetas: prepared.thread,
})
} catch (error) {
return {
@@ -715,27 +723,26 @@ export async function streamGoWorker({
const requestId = randomUUID()
const stopCancel = cancelOnAbort(exec, signal, requestId)
try {
+ const prepared = envelope
+ ? { envelope, thread: null }
+ : workerEnvelope({
+ body,
+ reqHeaders,
+ exec,
+ identity,
+ routing,
+ stream: true,
+ deliveryMode,
+ want1m,
+ cacheTtl,
+ preserveCacheBreakpoints,
+ cliHop,
+ requestContext,
+ })
const response = await workerRequest(exec, {
method: 'POST',
requestPath,
- body: {
- ...(envelope ||
- workerEnvelope({
- body,
- reqHeaders,
- exec,
- identity,
- routing,
- stream: true,
- deliveryMode,
- want1m,
- cacheTtl,
- preserveCacheBreakpoints,
- cliHop,
- requestContext,
- })),
- request_id: requestId,
- },
+ body: { ...prepared.envelope, request_id: requestId },
signal,
timeoutMs,
timeoutMode: 'first-byte',
@@ -753,6 +760,7 @@ export async function streamGoWorker({
committed: false,
terminalState: headers['x-terminal-state'] || 'error',
transportError: false,
+ threadBetas: prepared.thread,
})
}
let buffer = ''
@@ -797,6 +805,7 @@ export async function streamGoWorker({
}
if (event.type === 'error') lastError = event
if (event.type === 'message_stop') sawMessageStop = true
+ if (event.type === 'message_start') rememberThreadTurn(event.message?.id, prepared.thread)
const evUsage = usageFromSseEvent(event)
if (evUsage) sseUsage = mergeUsage(sseUsage, evUsage)
if (event.message?.model) sseModel = event.message.model
@@ -891,6 +900,7 @@ export async function streamGoWorker({
committed,
terminalState,
transportError: false,
+ threadBetas: prepared.thread,
})
} finally {
if (idleTimer) clearInterval(idleTimer)
diff --git a/test/unit/go-worker-client.test.mjs b/test/unit/go-worker-client.test.mjs
index 388d601a8..7a040a84b 100644
--- a/test/unit/go-worker-client.test.mjs
+++ b/test/unit/go-worker-client.test.mjs
@@ -17,6 +17,7 @@ import {
import { prepareCliHopBody } from '../../src/lib/protocol/outbound-attempt.mjs'
import { callerAutoCompactWindow, defaultSeedPolicy, sanitizeInboundBody } from '../../src/lib/protocol/seed-policy.mjs'
import { extractOpenaiUsage } from '../../src/lib/protocol/openai-usage.mjs'
+import { rememberThreadTurn, resetThreadBetasForTest } from '../../src/lib/protocol/thread-betas.mjs'
import { callRustKernel, rustKernelHealth } from '../../src/lib/transport/rust-kernel-client.mjs'
import { forwardApi, readApiJson } from '../../src/lib/transport/api-kernel-client.mjs'
@@ -166,6 +167,33 @@ test('cli-hop envelope keeps role=system turns that the VM betas do not declare'
assert.deepEqual(out.body.system, body.system)
})
+test('cli-hop continue reuses the beta list sent by its create turn', () => {
+ resetThreadBetasForTest()
+ const exec = { homeDir: '', vm: { claude: { mode: 'setup-token', scope: 'user:inference' } } }
+ const send = (body) => finalizeWorkerPayload({ body, reqHeaders: {}, exec, identity: null, cliHop: true })
+ const created = send({
+ model: 'claude-opus-5-5',
+ thread: { type: 'create' },
+ messages: [
+ { role: 'user', content: 'go' },
+ { role: 'system', content: 'reminder' },
+ ],
+ })
+ assert.ok(created.body.betas.includes('mid-conversation-system-2026-04-07'))
+ assert.equal(created.thread.type, 'create')
+ rememberThreadTurn('msg_create', created.thread)
+ const continued = send({
+ model: 'claude-opus-5-5',
+ thread: { type: 'continue', previous_message_id: 'msg_create' },
+ messages: [{ role: 'user', content: 'next' }],
+ })
+ assert.deepEqual(continued.body.betas, created.body.betas)
+ assert.equal(continued.headers['anthropic-beta'], created.headers['anthropic-beta'])
+ assert.equal(continued.thread.pin, 'pinned')
+ assert.deepEqual(continued.thread.added, ['mid-conversation-system-2026-04-07'])
+ assert.equal(send({ model: 'claude-opus-5-5', messages: [{ role: 'user', content: 'hi' }] }).thread, null)
+})
+
test('cli-hop keeps the caller compact window outside settings', () => {
const inbound = {
model: 'claude-opus-5-5',
@@ -392,6 +420,70 @@ unixTest('callGoWorker marks null TTL as client-owned cache breakpoints', async
}
})
+unixTest('callGoWorker records thread betas only after a successful JSON response', async () => {
+ resetThreadBetasForTest()
+ const sent = []
+ const fx = await fixture(async (req, res) => {
+ const chunks = []
+ for await (const chunk of req) chunks.push(chunk)
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
+ sent.push(payload.body.betas)
+ res.setHeader('content-type', 'application/json')
+ if (sent.length === 1) {
+ res.statusCode = 400
+ res.end(JSON.stringify({ type: 'error', id: 'msg_failed', error: { message: 'rejected' } }))
+ return
+ }
+ res.end(
+ JSON.stringify({
+ type: 'message',
+ id: `msg_turn${sent.length}`,
+ role: 'assistant',
+ content: [{ type: 'text', text: 'ok' }],
+ }),
+ )
+ })
+ try {
+ const exec = { ...fx.exec, vm: { ...fx.exec.vm, claude: { mode: 'setup-token', scope: 'user:inference' } } }
+ const create = {
+ model: 'claude-opus-5-5',
+ thread: { type: 'create' },
+ messages: [
+ { role: 'user', content: 'go' },
+ { role: 'system', content: 'reminder' },
+ ],
+ }
+ const failed = await callGoWorker({ exec, cliHop: true, body: create })
+ assert.equal(failed.ok, false)
+ const afterFailure = finalizeWorkerPayload({
+ body: {
+ model: 'claude-opus-5-5',
+ thread: { type: 'continue', previous_message_id: 'msg_failed' },
+ messages: [{ role: 'user', content: 'next' }],
+ },
+ exec,
+ cliHop: true,
+ })
+ assert.equal(afterFailure.thread.pin, 'miss')
+ const created = await callGoWorker({ exec, cliHop: true, body: create })
+ assert.equal(created.ok, true)
+ const resumed = await callGoWorker({
+ exec,
+ cliHop: true,
+ body: {
+ model: 'claude-opus-5-5',
+ thread: { type: 'continue', previous_message_id: 'msg_turn2' },
+ messages: [{ role: 'user', content: 'next' }],
+ },
+ })
+ assert.equal(resumed.ok, true)
+ assert.equal(resumed.threadBetas.pin, 'pinned')
+ assert.deepEqual(sent[2], sent[1])
+ } finally {
+ await fx.close()
+ }
+})
+
unixTest('streamGoWorker accepts message_stop before delayed EOF', async () => {
const fx = await fixture((req, res) => {
res.setHeader('content-type', 'text/event-stream')
@@ -417,6 +509,55 @@ unixTest('streamGoWorker accepts message_stop before delayed EOF', async () => {
}
})
+unixTest('streamGoWorker pins continue betas using the streamed response message id', async () => {
+ resetThreadBetasForTest()
+ const sent = []
+ const fx = await fixture(async (req, res) => {
+ const chunks = []
+ for await (const chunk of req) chunks.push(chunk)
+ sent.push(JSON.parse(Buffer.concat(chunks).toString('utf8')).body.betas)
+ res.setHeader('content-type', 'text/event-stream')
+ writeData(res, {
+ type: 'message_start',
+ message: {
+ type: 'message',
+ id: `msg_turn${sent.length}`,
+ role: 'assistant',
+ model: 'claude-opus-5-5',
+ content: [],
+ },
+ })
+ writeData(res, { type: 'content_block_start', index: 0, content_block: { type: 'text', text: 'ok' } })
+ writeData(res, { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } })
+ writeData(res, { type: 'message_stop' })
+ res.end()
+ })
+ try {
+ const exec = { ...fx.exec, vm: { ...fx.exec.vm, claude: { mode: 'setup-token', scope: 'user:inference' } } }
+ const hop = (thread, messages) =>
+ streamGoWorker({
+ exec,
+ cliHop: true,
+ body: { model: 'claude-opus-5-5', stream: true, thread, messages },
+ })
+ const created = await hop({ type: 'create' }, [
+ { role: 'user', content: 'go' },
+ { role: 'system', content: 'reminder' },
+ ])
+ assert.equal(created.ok, true)
+ assert.equal(created.threadBetas.type, 'create')
+ const resumed = await hop({ type: 'continue', previous_message_id: 'msg_turn1' }, [
+ { role: 'user', content: 'next' },
+ ])
+ assert.equal(resumed.ok, true)
+ assert.equal(resumed.threadBetas.pin, 'pinned')
+ assert.deepEqual(resumed.threadBetas.added, ['mid-conversation-system-2026-04-07'])
+ assert.deepEqual(sent[1], sent[0])
+ } finally {
+ await fx.close()
+ }
+})
+
unixTest('streamGoWorker names the aborted hop to the kernel cancel route', async () => {
let hopId = null
let cancelled = null
diff --git a/test/unit/mid-convo-effort.test.mjs b/test/unit/mid-convo-effort.test.mjs
index 0f7d508d2..9920a7d49 100644
--- a/test/unit/mid-convo-effort.test.mjs
+++ b/test/unit/mid-convo-effort.test.mjs
@@ -271,6 +271,37 @@ test('withRequestProtocolBetas derives mid-conversation-output-config from messa
assert.deepEqual(withRequestProtocolBetas(['caller-beta'], { model: 'claude-sonnet-4-6' }), ['caller-beta'])
})
+test('thread create and continue derive the same output-config beta without replayed effort controls', () => {
+ const create = {
+ model: 'claude-opus-5-5',
+ output_config: { effort: 'high' },
+ thread: { type: 'create' },
+ messages: [
+ { role: 'user', content: 'go' },
+ { role: 'system', content: [], output_config: { effort: 'max' } },
+ ],
+ }
+ const resume = {
+ model: 'claude-opus-5-5',
+ output_config: { effort: 'high' },
+ thread: { type: 'continue', previous_message_id: 'msg_create' },
+ messages: [{ role: 'user', content: 'next' }],
+ }
+ const created = withRequestProtocolBetas(['caller-beta'], create)
+ assert.ok(created.includes(BETA_MID_CONVERSATION_OUTPUT_CONFIG))
+ const continued = withRequestProtocolBetas(['caller-beta'], resume)
+ assert.ok(continued.includes(BETA_MID_CONVERSATION_OUTPUT_CONFIG))
+ assert.ok(!continued.includes(BETA_MID_CONVERSATION_SYSTEM))
+ assert.ok(created.includes(BETA_MID_CONVERSATION_SYSTEM))
+ const { thread: _thread, ...plain } = resume
+ assert.ok(!withRequestProtocolBetas(['caller-beta'], plain).includes(BETA_MID_CONVERSATION_OUTPUT_CONFIG))
+ assert.ok(
+ !withRequestProtocolBetas([], { ...resume, model: 'claude-sonnet-5' }).includes(
+ BETA_MID_CONVERSATION_OUTPUT_CONFIG,
+ ),
+ )
+})
+
test('thinking strip keeps empty system effort controls and a valid short signature on Opus 5.5', () => {
const body = stripInvalidThinkingBlocks({
model: 'claude-opus-5-5',
diff --git a/test/unit/thread-betas.test.mjs b/test/unit/thread-betas.test.mjs
new file mode 100644
index 000000000..908d4b164
--- /dev/null
+++ b/test/unit/thread-betas.test.mjs
@@ -0,0 +1,63 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { pinThreadBetas, rememberThreadTurn, resetThreadBetasForTest } from '../../src/lib/protocol/thread-betas.mjs'
+
+const MODEL = 'claude-opus-5-5'
+const OUTPUT_CONFIG = 'mid-conversation-output-config-2026-07-01'
+const CREATED = ['oauth-2025-04-20', 'message-threads-2026-08-12', OUTPUT_CONFIG]
+const create = { model: MODEL, thread: { type: 'create' }, messages: [] }
+
+function resume(from, model = MODEL) {
+ return { model, thread: { type: 'continue', previous_message_id: from }, messages: [] }
+}
+
+test('requests outside a thread keep their betas', () => {
+ resetThreadBetasForTest()
+ const betas = ['oauth-2025-04-20']
+ assert.deepEqual(pinThreadBetas({ model: MODEL, messages: [] }, betas), { betas, turn: null })
+})
+
+test('a continue turn carries forward the create turn betas', () => {
+ resetThreadBetasForTest()
+ const created = pinThreadBetas(create, CREATED)
+ rememberThreadTurn('msg_create', created.turn)
+ const computed = CREATED.filter((token) => token !== OUTPUT_CONFIG)
+ const resumed = pinThreadBetas(resume('msg_create'), computed)
+ assert.deepEqual(resumed.betas, CREATED)
+ assert.equal(resumed.turn.pin, 'pinned')
+ assert.deepEqual(resumed.turn.added, [OUTPUT_CONFIG])
+ assert.deepEqual(resumed.turn.extra, [])
+ rememberThreadTurn('msg_turn2', resumed.turn)
+ assert.deepEqual(pinThreadBetas(resume('msg_turn2'), computed).betas, CREATED)
+})
+
+test('newly derived betas remain present on continue turns', () => {
+ resetThreadBetasForTest()
+ rememberThreadTurn('msg_create', pinThreadBetas(create, CREATED).turn)
+ const clearAt = 'mid-conversation-system-clear-at-2026-08-21'
+ const resumed = pinThreadBetas(resume('msg_create'), [...CREATED, clearAt])
+ assert.deepEqual(resumed.betas, [...CREATED, clearAt])
+ assert.deepEqual(resumed.turn.extra, [clearAt])
+})
+
+test('unknown, expired and changed-model turns use their computed betas', () => {
+ resetThreadBetasForTest()
+ const computed = ['oauth-2025-04-20']
+ assert.equal(pinThreadBetas(resume('msg_unknown'), computed).turn.pin, 'miss')
+ rememberThreadTurn('msg_create', pinThreadBetas(create, CREATED).turn, { now: 0 })
+ const expired = pinThreadBetas(resume('msg_create'), computed, { now: 24 * 60 * 60 * 1000 })
+ assert.equal(expired.turn.pin, 'miss')
+ assert.deepEqual(expired.betas, computed)
+ rememberThreadTurn('msg_create', pinThreadBetas(create, CREATED).turn)
+ const switched = pinThreadBetas(resume('msg_create', 'claude-sonnet-5-5'), computed)
+ assert.equal(switched.turn.pin, 'model_changed')
+ assert.deepEqual(switched.betas, computed)
+})
+
+test('pins stay bounded to the newest 10000 message ids', () => {
+ resetThreadBetasForTest()
+ const { turn } = pinThreadBetas(create, CREATED)
+ for (let index = 0; index <= 10_000; index++) rememberThreadTurn(`msg_${index}`, turn)
+ assert.equal(pinThreadBetas(resume('msg_0'), CREATED).turn.pin, 'miss')
+ assert.equal(pinThreadBetas(resume('msg_10000'), CREATED).turn.pin, 'pinned')
+})
From c399c2daf98429e1cf2dcd2fd995625b252e2db1 Mon Sep 17 00:00:00 2001
From: dofastted
Date: Mon, 19 Oct 2026 21:12:04 +0800
Subject: [PATCH 14/19] =?UTF-8?q?feat(web):=20=E7=A7=8D=E5=AD=90=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=E9=A1=B9=E5=8F=AF=E7=9B=B4=E6=8E=A5=E7=BC=96=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
settings.json 的 env 改成输入框,播种时写回。缺少 CLAUDE_CODE_ATTRIBUTION_HEADER 时默认 0。
---
src/lib/protocol/seed-policy.mjs | 1 +
test/unit/seed-policy.test.mjs | 1 +
web/src/features/vm/seed-policy-card.test.ts | 1 +
web/src/features/vm/seed-policy-card.tsx | 96 ++++++++++++++------
web/src/features/vm/seed-settings.test.ts | 14 +++
web/src/features/vm/seed-settings.ts | 32 +++++--
6 files changed, 108 insertions(+), 37 deletions(-)
diff --git a/src/lib/protocol/seed-policy.mjs b/src/lib/protocol/seed-policy.mjs
index d3b82dde5..6b51a34c5 100644
--- a/src/lib/protocol/seed-policy.mjs
+++ b/src/lib/protocol/seed-policy.mjs
@@ -114,6 +114,7 @@ export function buildSlotSettingsEnv(pol = {}, { timezone, locale, extra } = {})
const out = applyRequiredSeedEnv(env, pol)
// 2.1.283+ sends x-claude-code-prompt-id only when this is set. Default on.
out.CLAUDE_CODE_GATEWAY_HINT_HEADERS = '1'
+ if (out.CLAUDE_CODE_ATTRIBUTION_HEADER == null) out.CLAUDE_CODE_ATTRIBUTION_HEADER = '0'
return out
}
diff --git a/test/unit/seed-policy.test.mjs b/test/unit/seed-policy.test.mjs
index 390110c1d..b7f5ec166 100644
--- a/test/unit/seed-policy.test.mjs
+++ b/test/unit/seed-policy.test.mjs
@@ -69,6 +69,7 @@ test('buildSlotSettingsEnv is TZ/LANG plus contract, leftover extra cannot win',
assert.equal(env.LC_ALL, 'en_US.UTF-8')
assert.equal(env.CUSTOM, 'ok')
assert.equal(env.CLAUDE_CODE_GATEWAY_HINT_HEADERS, '1')
+ assert.equal(env.CLAUDE_CODE_ATTRIBUTION_HEADER, '0')
})
test('buildSeedSettingsEnv delegates to slot env', () => {
diff --git a/web/src/features/vm/seed-policy-card.test.ts b/web/src/features/vm/seed-policy-card.test.ts
index 69a3333c1..445e96f99 100644
--- a/web/src/features/vm/seed-policy-card.test.ts
+++ b/web/src/features/vm/seed-policy-card.test.ts
@@ -11,6 +11,7 @@ describe('seed save follows the telemetry bit', () => {
reject_client_settings: true,
reject_client_metadata_identity: true,
claude_setting: 'A=1',
+ env: {},
})
expect(telemetryEnabled(body)).toBe(true)
expect(body.disable_nonessential_traffic).toBe(true)
diff --git a/web/src/features/vm/seed-policy-card.tsx b/web/src/features/vm/seed-policy-card.tsx
index 561e01544..c3c294791 100644
--- a/web/src/features/vm/seed-policy-card.tsx
+++ b/web/src/features/vm/seed-policy-card.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import type { VmKernelSnapshot, VmSeedSettings } from '@/types/panel-vm'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
@@ -40,6 +41,20 @@ function Lamp({ tone }: { tone: 'on' | 'off' | 'unknown' }) {
)
}
+function TelemetryUploadLog({ lines }: { lines: unknown }) {
+ const rows = Array.isArray(lines)
+ ? lines.filter(
+ (line): line is string => typeof line === 'string' && line !== ''
+ )
+ : []
+ return (
+
+ {rows.length
+ ? rows.join('\n')
+ : '还没有上传记录。遥测开着并完成一次上传后会出现。'}
+
+ )
+}
function ToggleRow({
id,
@@ -90,13 +105,16 @@ export function SeedPolicyCard({
onSave,
saving,
syncTelemetry = false,
+ debug,
+ onDebug,
}: {
seed: VmSeedSettings
telemetry?: ObservedTelemetry | null
- onSave: (next: Record) => void
+ onSave: (next: Record) => void
saving: boolean
- /** External official_cc.sync_telemetry. True means the next init turns telemetry back on. */
syncTelemetry?: boolean
+ debug: boolean
+ onDebug: (next: boolean) => void
}) {
const saved = seedDraftOf(seed)
const [draft, setDraft] = useState(saved)
@@ -133,34 +151,39 @@ export function SeedPolicyCard({
文件在,但没有 env 项。
) : (
-
-
-
- {rows.map((row) => (
-
- |
-
- {row.inClaudeSetting ? (
-
- ) : (
-
- )}
- {row.key}
-
- |
-
- {row.value === '' ? '空' : row.value}
- |
-
- ))}
-
-
+
+ {rows
+ .filter((row) => row.key in draft.env)
+ .map((row) => (
+
+ ))}
)}
@@ -259,6 +282,19 @@ export function SeedPolicyCard({
重置
+
+
+ {debug ? : null}
+