diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 7878fe33c..de54e7e0f 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -28,7 +28,7 @@ VM2API_DB_SECRET='再一串' 不是一个父容器里多个子进程。 -槽位默认内存上限为 `512m`,在面板「规格」或 `routing.json` 的 `vm.memory` 配置(`256m` / `512m` / `1g` / `2g` / `4g` / `8g` / `16g`)。创建时可按槽覆盖,结果写在该槽 `vm.machine.memory`。已不再读取 `KIN_VM_MEMORY`。低于 1G 时,常驻 native host 与临时官方 CLI 同时运行可能 OOM;额度 API 探测本身不应额外启动推理 CLI。已有容器不会因控制面升级自动改内存,可在核对宿主余量后使用 `docker update --memory 1g --memory-swap 1g kin-<槽>`,不重建容器。 +槽位默认内存上限为 `512m`,在面板「规格」或 `routing.json` 的 `vm.memory` 配置(`256m` / `512m` / `1g` / `2g` / `4g` / `8g` / `16g`)。创建时可按槽覆盖,结果写在该槽 `vm.machine.memory`。已不再读取 `KIN_VM_MEMORY`。低于 1G 时,常驻 native host 与临时官方 CLI 同时运行可能 OOM;额度 API 探测本身不应额外启动推理 CLI。已有容器不会因控制面升级自动改内存,可在核对宿主余量后使用面板在线扩容,不重建容器。面板成功响应以 Docker 的内存和 swap 上限、容器内 `memory.max` 三者均匹配为准;内核每次准入按当前 `memory.max` 动态计算阈值,并按请求实际序列化大小限制在途负载。单次请求的 CLI 内存突增仍可能超过 512 MiB,准入不保证任意请求都能在此上限内运行。 KVM 槽需要宿主 `/dev/kvm` 可读写(控制面在容器内时,用一次 `--device /dev/kvm` 的探测容器确认)。runner 降权给槽位用户跑 QEMU 时会带上 `/dev/kvm` 的设备组,属组与槽位主 GID 不同也能用。已有 KVM 槽要更新 `kin-kvm-*` 镜像并重建一次。没有 KVM 时不要在生产打开 `vm.allow_tcg`:那是 TCG 软件模拟,仅供测试。 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 5d9254c71..2a4931090 100644 --- a/src/lib/protocol/handle-protocol.mjs +++ b/src/lib/protocol/handle-protocol.mjs @@ -1558,6 +1558,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/src/lib/vm/docker-slot-memory.mjs b/src/lib/vm/docker-slot-memory.mjs index 5d470e758..af293d6b4 100644 --- a/src/lib/vm/docker-slot-memory.mjs +++ b/src/lib/vm/docker-slot-memory.mjs @@ -57,7 +57,12 @@ function dockerExec(vm, argv, execImpl, timeout) { export function inspectDockerContainerMemory(vm, execImpl = defaultExec) { const name = containerName(vm?.id) - const r = dockerExec(vm, ['docker', 'inspect', '-f', '{{.HostConfig.Memory}}', name], execImpl, 10_000) + const r = dockerExec( + vm, + ['docker', 'inspect', '-f', '{{.HostConfig.Memory}} {{.HostConfig.MemorySwap}} {{.State.Running}}', name], + execImpl, + 10_000, + ) if (!r.ok) { return { ok: false, @@ -65,11 +70,21 @@ export function inspectDockerContainerMemory(vm, execImpl = defaultExec) { message: (r.stderr || r.stdout || 'docker inspect failed').trim(), } } - const formatted = formatDockerMemoryBytes(String(r.stdout || '').trim()) - if (!formatted) { + const [memoryValue, swapValue, runningValue] = String(r.stdout || '') + .trim() + .split(/\s+/) + const formatted = formatDockerMemoryBytes(memoryValue) + const swapBytes = Number(swapValue) + if ( + !formatted || + !Number.isSafeInteger(Number(memoryValue)) || + !Number.isSafeInteger(swapBytes) || + swapBytes < -1 || + !['true', 'false'].includes(runningValue) + ) { return { ok: false, code: 'container_memory_unreadable', message: '无法读取容器内存上限' } } - return { ok: true, memory: formatted, bytes: Number(String(r.stdout || '').trim()) } + return { ok: true, memory: formatted, bytes: Number(memoryValue), swapBytes, running: runningValue === 'true' } } export function validateDockerMemoryRaise(vm, requestedMemory, routing) { @@ -106,6 +121,11 @@ export function raiseDockerSlotMemory(projectRoot, vm, requestedMemory, { routin const mem = validated.memory const name = containerName(vm.id) + const before = inspectDockerContainerMemory(vm, execImpl) + if (!before.ok) return before + if (!before.running) { + return { ok: false, code: 'container_not_running', message: '容器未运行,无法验证在线扩容' } + } const update = dockerExec(vm, ['docker', 'update', '--memory', mem, '--memory-swap', mem, name], execImpl, 15_000) if (!update.ok) { const inspected = inspectDockerContainerMemory(vm, execImpl) @@ -127,11 +147,22 @@ export function raiseDockerSlotMemory(projectRoot, vm, requestedMemory, { routin requested: mem, } } - if (memoryMiB(inspected.memory) < memoryMiB(mem)) { + const requestedBytes = memoryMiB(mem) * 1024 ** 2 + if (inspected.bytes !== requestedBytes || inspected.swapBytes !== requestedBytes) { return { ok: false, code: 'memory_diverged', - message: `容器内存为 ${inspected.memory},与请求的 ${mem} 不一致,未写入配置`, + message: `容器内存或 swap 上限与请求的 ${mem} 不一致,未写入配置`, + requested: mem, + container_memory: inspected.memory, + } + } + const cgroup = dockerExec(vm, ['docker', 'exec', name, 'cat', '/sys/fs/cgroup/memory.max'], execImpl, 10_000) + if (!cgroup.ok || Number(String(cgroup.stdout || '').trim()) !== requestedBytes) { + return { + ok: false, + code: 'cgroup_memory_diverged', + message: '容器实际 cgroup 内存上限与请求不一致,未写入配置', requested: mem, container_memory: inspected.memory, } diff --git a/test/unit/docker-slot-memory.test.mjs b/test/unit/docker-slot-memory.test.mjs index 3f4e98147..c66ebad10 100644 --- a/test/unit/docker-slot-memory.test.mjs +++ b/test/unit/docker-slot-memory.test.mjs @@ -43,16 +43,25 @@ test('raiseDockerSlotMemory persists only after docker update and inspect succee tmpVmRecord(root, id) const vm = { id, runtime_type: 'docker', machine: { memory: '512m' }, runtime: { type: 'docker', memory: '512m' } } const calls = [] + let updated = false const execImpl = (argv) => { calls.push(argv.join(' ')) - if (argv.includes('update')) return { ok: true, stdout: '', stderr: '' } - if (argv.includes('inspect')) return { ok: true, stdout: String(1024 ** 3), stderr: '' } + if (argv.includes('update')) { + updated = true + return { ok: true, stdout: '', stderr: '' } + } + if (argv.includes('inspect')) { + const bytes = updated ? 1024 ** 3 : 512 * 1024 ** 2 + return { ok: true, stdout: `${bytes} ${bytes} true`, stderr: '' } + } + if (argv.includes('exec')) return { ok: true, stdout: String(1024 ** 3), stderr: '' } return { ok: false, stderr: 'unexpected' } } const raised = raiseDockerSlotMemory(root, vm, '1g', { execImpl }) assert.equal(raised.ok, true) assert.equal(raised.memory, '1g') assert.match(calls.join('\n'), /--memory-swap 1g/) + assert.match(calls.join('\n'), /memory\.max/) const saved = JSON.parse(fs.readFileSync(path.join(root, 'vms', `${id}.json`), 'utf8')) assert.equal(saved.machine.memory, '1g') assert.equal(saved.runtime.memory, '1g') @@ -69,7 +78,10 @@ test('raiseDockerSlotMemory does not persist when docker update fails', () => { const vm = { id, runtime_type: 'docker', machine: { memory: '512m' } } const execImpl = (argv) => { if (argv.includes('update')) return { ok: false, stderr: 'cannot update' } - if (argv.includes('inspect')) return { ok: true, stdout: String(512 * 1024 ** 2), stderr: '' } + if (argv.includes('inspect')) { + const bytes = 512 * 1024 ** 2 + return { ok: true, stdout: `${bytes} ${bytes} true`, stderr: '' } + } return { ok: false, stderr: 'unexpected' } } const raised = raiseDockerSlotMemory(root, vm, '1g', { execImpl }) @@ -82,12 +94,79 @@ test('raiseDockerSlotMemory does not persist when docker update fails', () => { } }) -test('inspectDockerContainerMemory reads HostConfig.Memory through exec', () => { +test('inspectDockerContainerMemory reads memory, swap and running state', () => { const vm = { id: 'vm-03', runtime_type: 'docker' } const out = inspectDockerContainerMemory(vm, (argv) => { assert.match(argv.join(' '), /inspect.*HostConfig\.Memory/) - return { ok: true, stdout: String(2 * 1024 ** 3), stderr: '' } + return { ok: true, stdout: `${2 * 1024 ** 3} ${2 * 1024 ** 3} true`, stderr: '' } }) assert.equal(out.ok, true) assert.equal(out.memory, '2g') + assert.equal(out.swapBytes, 2 * 1024 ** 3) + assert.equal(out.running, true) +}) + +test('raiseDockerSlotMemory refuses a stopped container before docker update', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-mem-stopped-')) + try { + tmpVmRecord(root, 'vm-06') + const vm = { id: 'vm-06', runtime_type: 'docker', machine: { memory: '512m' } } + const execImpl = (argv) => { + assert.ok(argv.includes('inspect')) + return { ok: true, stdout: `${512 * 1024 ** 2} -1 false` } + } + assert.equal(raiseDockerSlotMemory(root, vm, '1g', { execImpl }).code, 'container_not_running') + assert.equal(JSON.parse(fs.readFileSync(path.join(root, 'vms', 'vm-06.json'), 'utf8')).machine.memory, '512m') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('raiseDockerSlotMemory refuses a mismatched swap limit without persisting', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-mem-swap-')) + try { + tmpVmRecord(root, 'vm-04') + const vm = { id: 'vm-04', runtime_type: 'docker', machine: { memory: '512m' } } + let updated = false + const execImpl = (argv) => { + if (argv.includes('update')) { + updated = true + return { ok: true, stdout: '' } + } + if (argv.includes('inspect')) { + const memory = updated ? 1024 ** 3 : 512 * 1024 ** 2 + return { ok: true, stdout: `${memory} ${512 * 1024 ** 2} true` } + } + return { ok: false, stderr: 'unexpected' } + } + assert.equal(raiseDockerSlotMemory(root, vm, '1g', { execImpl }).code, 'memory_diverged') + assert.equal(JSON.parse(fs.readFileSync(path.join(root, 'vms', 'vm-04.json'), 'utf8')).machine.memory, '512m') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('raiseDockerSlotMemory refuses a mismatched cgroup limit without persisting', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-mem-cgroup-')) + try { + tmpVmRecord(root, 'vm-05') + const vm = { id: 'vm-05', runtime_type: 'docker', machine: { memory: '512m' } } + let updated = false + const execImpl = (argv) => { + if (argv.includes('update')) { + updated = true + return { ok: true, stdout: '' } + } + if (argv.includes('inspect')) { + const bytes = updated ? 1024 ** 3 : 512 * 1024 ** 2 + return { ok: true, stdout: `${bytes} ${bytes} true` } + } + if (argv.includes('exec')) return { ok: true, stdout: String(512 * 1024 ** 2) } + return { ok: false, stderr: 'unexpected' } + } + assert.equal(raiseDockerSlotMemory(root, vm, '1g', { execImpl }).code, 'cgroup_memory_diverged') + assert.equal(JSON.parse(fs.readFileSync(path.join(root, 'vms', 'vm-05.json'), 'utf8')).machine.memory, '512m') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } }) 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/panel-vm-memory-raise.test.mjs b/test/unit/panel-vm-memory-raise.test.mjs index 8aeeea554..efa58354c 100644 --- a/test/unit/panel-vm-memory-raise.test.mjs +++ b/test/unit/panel-vm-memory-raise.test.mjs @@ -53,7 +53,8 @@ const patchVm = (handlePanel, id = 'vm-01') => function mockExec() { return (argv) => { if (argv.includes('update')) return { ok: true, stdout: '', stderr: '' } - if (argv.includes('inspect')) return { ok: true, stdout: String(1024 ** 3), stderr: '' } + if (argv.includes('inspect')) return { ok: true, stdout: `${1024 ** 3} ${1024 ** 3} true`, stderr: '' } + if (argv.includes('exec')) return { ok: true, stdout: String(1024 ** 3), stderr: '' } return { ok: false, stderr: 'unexpected' } } } diff --git a/test/unit/runtime-state.test.mjs b/test/unit/runtime-state.test.mjs index 94286dc34..c3be416d6 100644 --- a/test/unit/runtime-state.test.mjs +++ b/test/unit/runtime-state.test.mjs @@ -122,12 +122,17 @@ test('power-on preserves a verified memory raise made while boot readiness is pe }, }) await ready.promise + let updated = false const raised = raiseDockerSlotMemory(options.projectRoot, getVm(options.projectRoot, options.id), '4g', { - execImpl: (argv) => ({ - ok: true, - stdout: argv[1] === 'inspect' ? String(4 * 1024 ** 3) : '', - stderr: '', - }), + execImpl: (argv) => { + if (argv[1] === 'update') updated = true + const bytes = (updated ? 4 : 2) * 1024 ** 3 + return { + ok: true, + stdout: argv[1] === 'inspect' ? `${bytes} ${bytes} true` : argv[1] === 'exec' ? String(bytes) : '', + stderr: '', + } + }, }) assert.equal(raised.ok, true) resume.resolve() 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') +}) diff --git a/web/src/features/vm/credential-panel.tsx b/web/src/features/vm/credential-panel.tsx index 9afa07933..ac02de87e 100644 --- a/web/src/features/vm/credential-panel.tsx +++ b/web/src/features/vm/credential-panel.tsx @@ -411,6 +411,37 @@ function ClaudeCredentialPanel({ {importCred.isPending ? '换票中…' : '导入'} + ) : activeMethod === 'cc' ? ( +
+ + 打开槽终端并自动执行 claude auth + login。链接在终端里复制,授权码直接粘进终端,不经过面板。需要容器已运行且 + cli-node 已安装。 + + + + + + 官方登录 + + 终端会执行 claude auth + login。复制里面的链接,授权码粘回这个终端。 + + +
+ {loginOpen ? ( + + ) : null} +
+
+
+
) : linkReady ? (
@@ -477,42 +508,9 @@ function ClaudeCredentialPanel({ {kind === 'setup-token' ? `申请完整 OAuth 权限,换票后采集身份。浏览器须走 ${proxyHint || '同一槽出口'},授权码须含 # 后半段。以 Setup Token 模式运行,不跑官方初装。` - : activeMethod === 'cc' - ? `槽内 cli-node 执行官方 claude auth login。链接、PKCE 和换票都由官方 CLI 完成,浏览器须走 ${proxyHint || '同一槽出口'}。需要容器已运行且 cli-node 已安装。` - : `浏览器须走 ${proxyHint || '同一槽出口'},授权码须含 # 后半段。换票后采集身份。`} + : `浏览器须走 ${proxyHint || '同一槽出口'},授权码须含 # 后半段。换票后采集身份。`}
- ) : activeMethod === 'cc' ? ( -
- - 打开槽终端并自动执行 claude auth - login。链接在终端里复制,授权码直接粘进终端,不经过面板。需要容器已运行且 - cli-node 已安装。 - - - - - - 官方登录 - - 终端会执行 claude auth - login。复制里面的链接,授权码粘回这个终端。 - - -
- {loginOpen ? ( - - ) : null} -
-
-
-
) : (