From a1f9919a1ef4e36083266da624e5a5dec34461fe Mon Sep 17 00:00:00 2001 From: longsky li Date: Sat, 7 Nov 2026 03:06:07 +0800 Subject: [PATCH 01/11] kimi-desktop: configure a secondary model for Work subagents Add a secondary-model preference and embedded plugin settings page while keeping the primary model under the existing Magpie agent connection. Reapply the native secondary experiment after runtime regeneration, confirm idle-session reloads, retry busy sessions, and restore owned fields. Validation: 77 isolated desktop tests passed with 704 assertions; the target registry check and offline npm packaging passed. An earlier candidate completed two native Work subagents on official macOS Kimi and Magpie, with Sol/Astra routes HTTP 200. The final enabled-baseline inheritance correction has isolated regression coverage; the compatibility record distinguishes it from the earlier live check. --- packages/kimi-desktop/COMPATIBILITY.md | 33 + packages/kimi-desktop/README.md | 24 +- packages/kimi-desktop/adapter.mjs | 429 +++++++++- packages/kimi-desktop/index.mjs | 23 +- packages/kimi-desktop/kimi-desktop.test.mjs | 61 ++ packages/kimi-desktop/package.json | 13 +- packages/kimi-desktop/page/boot.js | 40 + packages/kimi-desktop/page/index.html | 20 + packages/kimi-desktop/rpc.mjs | 3 +- packages/kimi-desktop/secondary-config.mjs | 152 ++++ .../kimi-desktop/secondary-config.test.mjs | 420 ++++++++++ .../kimi-desktop/secondary-model.test.mjs | 760 ++++++++++++++++++ packages/kimi-desktop/settings.html | 175 ++++ packages/kimi-desktop/settings.mjs | 193 +++++ packages/kimi-desktop/settings.test.mjs | 196 +++++ packages/kimi-work/README.md | 12 + 16 files changed, 2539 insertions(+), 15 deletions(-) create mode 100644 packages/kimi-desktop/page/boot.js create mode 100644 packages/kimi-desktop/page/index.html create mode 100644 packages/kimi-desktop/secondary-config.mjs create mode 100644 packages/kimi-desktop/secondary-config.test.mjs create mode 100644 packages/kimi-desktop/secondary-model.test.mjs create mode 100644 packages/kimi-desktop/settings.html create mode 100644 packages/kimi-desktop/settings.mjs create mode 100644 packages/kimi-desktop/settings.test.mjs diff --git a/packages/kimi-desktop/COMPATIBILITY.md b/packages/kimi-desktop/COMPATIBILITY.md index b7bcc4f38..bc899c7b2 100644 --- a/packages/kimi-desktop/COMPATIBILITY.md +++ b/packages/kimi-desktop/COMPATIBILITY.md @@ -1,5 +1,38 @@ # Compatibility record +## macOS primary and secondary native Agent turn, 2026-11-07 + +The local 0.1.6 candidate ran on macOS 27.2 arm64 with ordinary signed-in Kimi desktop **3.2.17, build 3.2.17.4**, official Magpie **0.1.3077**, and Bun **1.4.4**. No Kimi or Magpie application resources were changed. The embedded Magpie plugin page rendered the current primary model and the gateway catalog; its save and refresh controls confirmed the secondary preference was applied. The same settings form was also exercised in a temporary browser tab. + +With primary `codex/gpt-6.1-sol` and secondary `codex/gpt-6-astra`, a new Work conversation in a task-owned test project requested two new native `Agent` subagents: one with `model="secondary"`, then one with `model="primary"`. Both completed, each used zero tools, and the parent reproduced their distinct requested markers. This was submitted through Kimi's native UI. Magpie's routing metadata recorded six Sol requests and two Astra requests, all HTTP 200 and each with its requested model equal to its served model. These eight requests include the runtime's auxiliary/continuation calls; they are not eight subagent creations. + +Saving **inherit primary** afterward removed the adapter-owned secondary and experimental tables from the runtime TOML, returning to their absent baseline. The main Sol connection remained enabled and connected, with no active turns or tools. All four native model entries and unrelated desktop settings matched their before-test comparisons. The credential aggregate differed during the live-turn phase; its cause was not isolated, so this phase does not claim credential bytes were unchanged. No credential values or full configuration copies were retained. + +That live candidate's **76 tests passed with 682 assertions**. An offline tarball install matched all eleven runtime/UI files to that candidate's source before installation into the real host. Busy-session retry, partial reload failure, regeneration and shutdown restoration were covered by isolated synthetic tests, not additional real disconnect/reconnect cycles in this phase. Official paid search/fetch, allowance accounting, sleep/wake and other operating systems were not newly tested. The original primary-only bridge has the separate record below. + +The final inheritance fix came after this live check: when the original native secondary setting was already enabled for another registered alias, clearing the plugin preference must keep new subagents on the current primary model while connected, and preserve the original baseline for disconnect or shutdown. Isolated regression tests cover the actual mock-bridge route, applied status, following a changed primary without repeated reloads, and disconnect restoration. The final temporary-home suite passed **77 tests with 704 assertions**. The signed-in UI check above used the earlier adapter revision. + +## macOS desktop UI turn, 2026-11-06 + +On 2026-11-06 a real signed-in Kimi desktop **Work** conversation submitted a turn from the native UI while the adapter was connected: macOS 27.2 arm64, ordinary Kimi desktop **3.2.17, build 3.2.17.4**, official Magpie **0.1.3077**, agent package **0.1.5**, Bun **1.4.4**. The model was selected in Magpie and served by an external, non-Kimi provider: provider `codex`, requested and served model `gpt-6.1-sol`. + +The UI prompt named the fixture's relative path and did not include its contents. The task-owned fixture contained a single marker line. + +1. The agent's native `Read` tool call requested that fixture path. In the expanded conversation, the tool response's first line and the turn's final reply contained the same marker. +2. A later UI turn in the same conversation did not call a tool. It recalled the earlier marker from the conversation history, reproduced it accurately, and issued no new tool call. + +Both turns used the ordinary signed-in Work conversation; no UI code, application resource or model-menu patch was installed. Magpie's request log recorded three routed requests for the tool round and one for the recall round, each HTTP 200 with `requestedModel` equal to `servedModel` (`gpt-6.1-sol`). Those are routed requests, not a count of tool calls; the tool-call evidence is the expanded conversation described above. + +In this run, the first turn without the project selected refused the outside-workspace fixture; selecting its project made the retry succeed. + +The generated runtime TOML carried the bridge address during the successful turns. The package's 36 tests passed, and SHA-256 comparisons confirmed that all six installed runtime files matched the tested repository files byte for byte. + +With Work idle, disconnecting through the official Magpie CLI restored `https://agent-gw.kimi.com/coding/v1` in both the desktop JSON configuration and generated runtime TOML. Bridge aliases were removed, and all four native models and the current native selection were preserved. A local in-memory comparison found credentials and unrelated settings unchanged; no credential values or full configuration backup were persisted. + +Reconnecting through the same CLI to `codex/gpt-6.1-sol` succeeded. The adapter was left enabled and connected with 52 catalog models, matching loopback addresses in JSON and TOML, and no active turns or tools. No additional inference was sent during the disconnect/reconnect check. + +Kimi's own model menu still showed its native label (**K3**) while the actual model came from Magpie's selection; custom aliases in that menu remain unverified. Kimi's search/fetch and other paid tools, their billing and allowance accounting, sleep/wake, natural credential rotation and non-`Read` tools were not exercised. + ## Kimi 3.2.18 isolated compatibility check On 2026-10-24, Windows Kimi desktop 3.2.18.13 shipped Daimon 0.6.8. An independent source copy of that installed bundle was used to exercise its unchanged model schema, thinking resolver, request builder and streamed/non-streamed response parser. Test-only exports exposed those internal functions; no installed application file was changed. diff --git a/packages/kimi-desktop/README.md b/packages/kimi-desktop/README.md index 921412860..5ff148fd3 100644 --- a/packages/kimi-desktop/README.md +++ b/packages/kimi-desktop/README.md @@ -10,6 +10,26 @@ Open and sign in to Kimi desktop. On Magpie's Agents page select **Kimi**, pick Disconnect Kimi in Magpie to restore its original model service address. Existing conversations, login credentials, unrelated settings, and native model entries are preserved. +## Primary and secondary models + +Choose the **primary model** on Magpie's **Agents → Kimi** page. Open the plugin's embedded **Kimi Work models / Kimi Work 模型** page to choose a **secondary model**, or leave it set to inherit the primary model. The page uses this Kimi agent's available model catalog and shows the saved preference separately from its application status. Apply changes while Kimi is idle. This setup works with the standard Magpie host. + +Kimi's native `Agent` tool can select either configured role for a new subagent. For example: + +```text +请新建一个子代理处理资料整理,使用副模型(Agent 的 model="secondary"),由主代理综合结果。 +``` + +```text +请让这个新建子代理也使用主模型(Agent 的 model="primary")。 +``` + +The tool's actual `model` argument selects the route. A prompt does not bind an arbitrary model name: first select the models in Magpie, then request `primary` or `secondary`. Already-created subagents retain Kimi's existing model-binding rules when resumed; the selection is for newly created subagents. + +The secondary preference is the optional `secondaryModel` field in `~/.config/magpie-kimi/connection.json`. The primary model and gateway fields remain owned by Magpie's normal agent connection. The settings service listens on `127.0.0.1:3491`; plugin option `settingsPort` can change that port. It uses a separate page-session credential and never sends the gateway or Kimi credential to the page. + +This feature uses Kimi's existing secondary-model experiment and session reload API. It reapplies the preference after Daimon regenerates its runtime TOML, and reloads only cached, idle sessions. The plugin runtime does not patch Kimi or Magpie application resources. Clearing the secondary selection returns new subagents to the current primary model while connected. Disconnecting or shutting down restores the original adapter-owned secondary settings. + ## Library: instructions, MCP and skills Version 0.1.1 registers ordinary **Kimi** as a target on all three Library pages. This is separate from **Kimi Code**. Existing assignments stay unchanged; select Kimi on the resources you want to share. @@ -40,14 +60,14 @@ The adapter does not reload a busy Work runtime. If Kimi independently changes i - `~/.config/magpie-kimi/before-menu-models.json`: one snapshot of the original model list/current choice, removed after a clean disconnect. No full configuration or credential backup is written. - Kimi configuration: `daimon/config.json` in Kimi's `daimon-share` folder: where `kimi-desktop/daimon-storage.json` points (`shareDir`; Kimi 3.2.21 on Windows writes it when installed on another drive, e.g. `D:\KimiData\daimon-share`, and on any system when its storage is moved), else `kimi-desktop/daimon-share`, under the platform's application-support directory (`%APPDATA%`, `~/Library/Application Support`, `~/.config`). -Verified with Windows 11, Kimi 3.2.17.10 and official Magpie 0.1.1275, including an actual Work request through Magpie. The [macOS record](COMPATIBILITY.md) verifies real control methods, connect/disconnect restoration and a real streamed bridge request on macOS 26.6.2 arm64, Kimi 3.2.17 build 3.2.17.4 and official Magpie 0.1.1276. That request was initiated by an HTTP test client; a macOS desktop UI-submitted Work turn remains unverified. The supported runtime has provider `daimon-kimi-code` with original service `https://agent-gw.kimi.com/coding/v1`; other layouts stop with `unsupported_kimi_config`. +Verified with Windows 11, Kimi 3.2.17.10 and official Magpie 0.1.1275, including an actual Work request through Magpie. The [macOS record](COMPATIBILITY.md) verifies real control methods, connect/disconnect restoration and a real streamed bridge request on macOS 26.6.2 arm64, Kimi 3.2.17 build 3.2.17.4 and official Magpie 0.1.1276. That request was initiated by an HTTP test client. A later macOS desktop UI-submitted Work turn was verified on 2026-11-06 with a non-Kimi model selected in Magpie; see the [macOS desktop UI turn record](COMPATIBILITY.md#macos-desktop-ui-turn-2026-11-06). The supported runtime has provider `daimon-kimi-code` with original service `https://agent-gw.kimi.com/coding/v1`; other layouts stop with `unsupported_kimi_config`. The private control API is undocumented. Before connecting, the adapter probes `models.current`; a refused/missing method stops connection without changing the service address. A failed `conversations.setModel` rolls back that address. Failures of `conversations.getKimiModelList` or `conversations.updateKimiModelList` report `catalog_sync_failed` and preserve the existing catalog rather than inventing a different protocol. In that case the selected-model bridge may remain connected; disconnect it to return to Kimi's service. No vendor app files are patched, and no automatic upgrade to an unknown protocol is attempted. No Kimi binaries, personal configuration, prompts, account identifiers or credentials are included. There are no runtime dependencies. Tests use isolated temporary directories and synthetic credentials. ```sh -bun test packages/kimi-desktop/kimi-desktop.test.mjs +bun test packages/kimi-desktop ``` The model catalog is fetched from Magpie's `/v1/models` with this agent's credential and registered User-Agent. Magpie owns its visibility, picked/hidden models, disables and ordering. The adapter never opens Magpie's `providers.json` or `settings.json`. It rechecks the gateway catalog before every inference; removed selections are refused even during a running turn, and an unavailable catalog returns a fixed error instead of falling back to an unrestricted list. This package includes no Electron injection hook or model-menu patch. The icon is Kimi's desktop application icon. diff --git a/packages/kimi-desktop/adapter.mjs b/packages/kimi-desktop/adapter.mjs index cb7781583..e7fa6c0fc 100644 --- a/packages/kimi-desktop/adapter.mjs +++ b/packages/kimi-desktop/adapter.mjs @@ -7,6 +7,7 @@ import { once } from 'node:events'; import { rpc } from './rpc.mjs'; import { normalizeModels, mergeRuntimeModels, isMenuAlias } from './catalog.mjs'; import { prepareLibrary, applyInstructions } from './library.mjs'; +import { inspectSecondary, patchSecondary } from './secondary-config.mjs'; const PROVIDER = 'daimon-kimi-code'; const TOOL_ORIGIN = 'https://agent-gw.kimi.com'; @@ -18,6 +19,57 @@ const baseURL = doc => doc?.model?.providers?.[PROVIDER]?.baseUrl; const activeCount = value => Array.isArray(value) ? value.length : Number(value) || 0; const busy = runner => activeCount(runner?.activeKernelTurns) + activeCount(runner?.activeKernelToolCalls) > 0; +// The only two control calls this feature adds. Kimi's reload walks the +// sessions it currently caches; the targeted call retries one of them. +const RELOAD_SESSIONS = 'kimiPlugin.reloadActiveSessions'; +const RELOAD_SESSION = 'kimiPlugin.ReloadSession'; +const SECONDARY_RETRY_MS = 5000; +const MODEL_ID = /^[^\x00-\x1f/]+\/[^\x00-\x1f]+$/; +const SECONDARY_CODES = new Set(['secondary_config_unsupported', 'secondary_model_unavailable', + 'secondary_model_reload_failed', 'kimi_config_changed']); + +const validModelID = value => typeof value === 'string' && value.length <= 1024 && MODEL_ID.test(value); + +// A snapshot is the helper's four JSON fields and nothing else. Anything else +// is treated as absent instead of being trusted. +function plainSnapshot(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; + const { model, enabled, hasSecondaryTable, hasExperimentalTable } = value; + if (model !== null && typeof model !== 'string') return null; + if (enabled !== null && typeof enabled !== 'boolean') return null; + if (typeof hasSecondaryTable !== 'boolean' || typeof hasExperimentalTable !== 'boolean') return null; + return { model, enabled, hasSecondaryTable, hasExperimentalTable }; +} + +const isRestoredBaseline = (current, owned) => owned?.restored + && current.model === owned.baseline.model && current.enabled === owned.baseline.enabled; + +// The scalars this adapter does not own. A control call can make Daimon rewrite +// the whole file from a fixed key set, so these are read before that call and +// put back afterwards. Only the helper's four JSON fields are remembered. +function externalFields(current, owned) { + if (isRestoredBaseline(current, owned)) return null; + const foreignModel = current.model !== null && current.model !== owned.alias; + const foreignFlag = current.enabled === false; + if (!foreignModel && !foreignFlag) return null; + return { model: foreignModel ? current.model : null, + enabled: foreignFlag ? false : null, + hasSecondaryTable: foreignModel, hasExperimentalTable: foreignFlag }; +} + +// Restore per field: a scalar this adapter wrote (or Daimon dropped) returns to +// its original value; a scalar another writer changed stays untouched. +function restoreTarget(current, baseline, alias) { + const ownsModel = current.model === null || (alias !== '' && current.model === alias); + const ownsFlag = current.enabled === null || current.enabled === true; + return { + model: ownsModel ? baseline.model : current.model, + enabled: ownsFlag ? baseline.enabled : current.enabled, + hasSecondaryTable: ownsModel ? baseline.hasSecondaryTable : current.hasSecondaryTable, + hasExperimentalTable: ownsFlag ? baseline.hasExperimentalTable : current.hasExperimentalTable, + }; +} + function atomicJSON(file, value) { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const temp = file + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp'; @@ -78,6 +130,7 @@ export class KimiAdapter { this.stateDir = stateDir || path.join(home, '.config', 'magpie-kimi'); this.kimiDir = kimiDir || defaultKimiDirectory(home); this.configFile = path.join(this.kimiDir, 'config.json'); + this.nativeConfigFile = path.join(this.kimiDir, 'runtime', 'kimi-code', 'config.toml'); this.runnerFile = path.join(this.kimiDir, 'agents', 'main', 'runner.state.json'); this.connectionFile = path.join(this.stateDir, 'connection.json'); this.stateFile = path.join(this.stateDir, 'adapter-state.json'); @@ -95,6 +148,13 @@ export class KimiAdapter { this.stopping = false; this.state = null; this.inflight = new Set(); + // Memory only: ids whose cached session was mid-turn when the secondary + // settings were written. Never persisted, never resent after a reload. + this.secondaryPending = new Set(); + this.secondaryReloadPending = false; + this.secondaryReloadStale = false; + this.secondaryExternal = null; + this.secondaryAfter = 0; } bootstrap() { @@ -172,6 +232,52 @@ export class KimiAdapter { return normalizeModels(JSON.parse(text), {allowEmpty: true}); } + async readSettings() { + let desired; + try { desired = readJSON(this.connectionFile); } + catch (error) { + if (error.code !== 'ENOENT') throw Error('settings_unavailable'); + return { enabled: false, primaryModel: '', secondaryModel: '', status: 'disconnected', models: [] }; + } + let catalog = [], status = this.status.code || 'disconnected'; + if (desired.enabled && desired.gateway?.key) { + try { catalog = await this.gatewayCatalog(gatewayURL(desired.gateway.url), desired.gateway.key); } + catch { status = 'catalog_unavailable'; } + } + const appliedSecondaryModel = this.state?.secondaryModel + || (!this.state?.secondary ? this.state?.model || this.live?.model : undefined); + return { + enabled: desired.enabled === true, + primaryModel: typeof desired.model === 'string' ? desired.model : '', + secondaryModel: typeof desired.secondaryModel === 'string' ? desired.secondaryModel : '', + ...(this.live && this.state?.active && appliedSecondaryModel ? { appliedSecondaryModel } : {}), + status, + models: catalog.map(row => ({ id: row.id, name: row.menu.displayName })), + }; + } + + async saveSettings({ secondaryModel }) { + const fail = code => { throw Object.assign(Error(code), { code }); }; + let desired; + try { desired = readJSON(this.connectionFile); } catch { fail('connect_kimi_first'); } + if (secondaryModel) { + if (!desired.enabled || !desired.model || !desired.gateway?.key) fail('connect_kimi_first'); + let catalog; + try { catalog = await this.gatewayCatalog(gatewayURL(desired.gateway.url), desired.gateway.key); } + catch { fail('settings_unavailable'); } + if (!catalog.some(row => row.id === secondaryModel)) fail('model_not_enabled_for_kimi'); + } + // Magpie still owns the primary model and gateway fields. Merge only the + // secondary preference into the newest document after the catalog request. + let fresh; + try { fresh = readJSON(this.connectionFile); } catch { fail('settings_unavailable'); } + if (fresh.gateway?.url !== desired.gateway?.url || fresh.gateway?.key !== desired.gateway?.key + || (secondaryModel && !fresh.enabled)) fail('connection_changed'); + fresh.secondaryModel = secondaryModel; + try { atomicJSON(this.connectionFile, fresh); } catch { fail('settings_unavailable'); } + return this.readSettings(); + } + publishCatalog(desired) { atomicJSON(this.catalogFile,{schemaVersion:1,enabled:true,exclusive:true,updatedAt:new Date().toISOString(), selectedAlias:this.catalog.find(x=>x.id===desired.model)?.alias||'',models:this.catalog.map(x=>x.menu)}); @@ -275,6 +381,9 @@ export class KimiAdapter { } async restore({ reload = true } = {}) { + // Read the two scalars before the control calls below can regenerate the + // file, so a value another writer owns survives them. + this.observeNativeSecondary(); if (!this.state?.active) return; let doc = readJSON(this.configFile); let modelsRestored = false; @@ -356,6 +465,280 @@ export class KimiAdapter { } catch { this.catalogError = 'catalog_sync_failed'; } } + // --- Secondary model ----------------------------------------------------- + // Kimi persists the secondary role in two scalars of its runtime TOML: + // [secondary_model] model = "" + // [experimental] "secondary-model" = true + // Only those two scalars are ever written or restored. The file itself is + // never cached, copied or backed up, and no other field is touched. + + readNativeSecondary() { + return inspectSecondary(fs.readFileSync(this.nativeConfigFile, 'utf8')); + } + + // Replace the runtime TOML atomically, but only while it still holds the + // bytes this patch was computed from. Mode 0600, same as Kimi's own writes. + commitNativeSecondary(bytes, text) { + if (!fs.readFileSync(this.nativeConfigFile).equals(bytes)) throw Error('kimi_config_changed'); + const temp = this.nativeConfigFile + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp'; + try { + fs.writeFileSync(temp, text, { mode: 0o600, flag: 'wx' }); + fs.renameSync(temp, this.nativeConfigFile); + } finally { try { fs.unlinkSync(temp); } catch { /* Renamed. */ } } + } + + // Ownership is the four-field baseline plus the alias this adapter wrote. It + // is persisted with the adapter state and survives rebuilding the main state + // object; it is only dropped once the baseline is back on disk. + secondaryOwnership() { + const owned = this.state?.secondary; + const baseline = plainSnapshot(owned?.baseline); + return baseline ? { baseline, alias: typeof owned.alias === 'string' ? owned.alias : '', + restored: owned.restored === true } : null; + } + + captureSecondaryBaseline() { + if (this.secondaryOwnership()) return; + this.state.secondary = { baseline: this.readNativeSecondary(), alias: '' }; + this.saveState(); + } + + setAppliedSecondary(value) { + if (!this.state) return; + let changed = this.state.secondaryModel !== value; + this.state.secondaryModel = value; + if (value && this.state.secondary?.restored) { + delete this.state.secondary.restored; + changed = true; + } + if (changed) this.saveState(); + } + + dropSecondaryOwnership() { + const changed = Boolean(this.state?.secondary) || Boolean(this.state?.secondaryModel); + if (this.state?.secondary) delete this.state.secondary; + if (this.state) this.state.secondaryModel = ''; + this.secondaryPending.clear(); + this.secondaryReloadPending = false; + this.secondaryReloadStale = false; + this.secondaryExternal = null; + if (changed) this.saveState(); + } + + // Read the file before a control call that can make Daimon rewrite it: keep + // the scalars another writer owns, and capture the original values on the + // first application. Nothing is read while the feature is not in use. + protectNativeSecondary(wanted) { + this.observeNativeSecondary(); + if (wanted && !this.secondaryOwnership()) this.captureSecondaryBaseline(); + } + + // Only the helper's four fields of the scalars this adapter does not own. + observeNativeSecondary() { + const owned = this.secondaryOwnership(); + if (!owned) { this.secondaryExternal = null; return; } + let current; + try { current = this.readNativeSecondary(); } catch { return; } + // A prior control call may have removed an external value while a cached + // session was still busy. Keep that observation until it is back on disk. + this.secondaryExternal = externalFields(this.effectiveSecondary(current), owned); + } + + // A rewrite may have dropped a scalar observed just before it; that value + // stands in for the missing one so it can be put back afterwards. + effectiveSecondary(current) { + const external = this.secondaryExternal; + if (!external) return current; + const model = current.model === null && external.model !== null ? external.model : current.model; + const enabled = current.enabled === null && external.enabled !== null ? external.enabled : current.enabled; + return { + model, enabled, + hasSecondaryTable: model === current.model ? current.hasSecondaryTable : external.hasSecondaryTable, + hasExperimentalTable: enabled === current.enabled ? current.hasExperimentalTable : external.hasExperimentalTable, + }; + } + + // Cached sessions only. An empty list means there is nothing left to reload; + // an unconfirmed session is retried by id once the runtime is idle. Every row + // is accounted for by id, so a mixed skipped+failed result can never become a + // false "applied". + async reloadSecondarySessions(runner) { + this.secondaryAfter = Date.now() + SECONDARY_RETRY_MS; + let result; + try { result = await this.rpcCall(runner, RELOAD_SESSIONS, {}, { timeout: 30000 }); } + catch { this.secondaryReloadStale = true; return { failed: true }; } + if (!Array.isArray(result?.sessions)) { this.secondaryReloadStale = true; return { failed: true }; } + let failed = false; + for (const session of result.sessions) { + const status = session?.status; + const id = typeof session?.kernelSessionId === 'string' && session.kernelSessionId + ? session.kernelSessionId : ''; + if (status === 'reloaded') { if (id) this.secondaryPending.delete(id); continue; } + if ((status === 'skipped-active-turn' || status === 'failed') && id) { + this.secondaryPending.add(id); + if (status === 'failed') failed = true; + continue; + } + // A row that cannot be retried by id: only a complete pass resolves it. + this.secondaryReloadStale = true; + failed = true; + } + if (failed) return { failed: true }; + const pending = this.secondaryPending.size > 0; + if (!pending) this.secondaryReloadStale = false; + return { pending }; + } + + // Retry only the ids a previous pass left unconfirmed. Sessions that were + // already reloaded are never reloaded again. + async retrySecondarySessions(runner) { + if (!this.secondaryPending.size) return { pending: false }; + if (busy(runner)) return { pending: true }; + if (Date.now() < (this.secondaryAfter || 0)) return { pending: true }; + this.secondaryAfter = Date.now() + SECONDARY_RETRY_MS; + for (const kernelSessionId of [...this.secondaryPending]) { + let result; + try { result = await this.rpcCall(runner, RELOAD_SESSION, { kernelSessionId }, { timeout: 30000 }); } + catch { return { failed: true }; } + const status = result?.status; + if (status === 'skipped-active-turn') continue; + // Only an explicit acknowledgement counts; a timeout, an unknown shape or + // any other status means the preference is not applied. + if (status !== 'reloaded') return { failed: true }; + this.secondaryPending.delete(kernelSessionId); + } + if (this.secondaryPending.size) return { pending: true }; + // A complete pass the adapter could not attribute still has to be redone. + if (!this.secondaryReloadStale) this.secondaryReloadPending = false; + return { pending: false, confirmed: true }; + } + + // A connected empty preference follows inheritModel; a restore call omits + // it and puts the baseline back. Returns {untouched}, {applied:false} or + // {waiting}; unsupported input and unconfirmed reloads throw a fixed code. + // With no preference and no ownership the runtime TOML is never read. + async reconcileSecondary(runner, wanted, { inheritModel = '' } = {}) { + const owned = this.secondaryOwnership(); + if (!wanted && !owned) { this.secondaryExternal = null; return { untouched: true }; } + const inheriting = !wanted && Boolean(inheritModel); + if (inheriting) wanted = inheritModel; + + const retried = await this.retrySecondarySessions(runner); + if (retried.failed) throw Error('secondary_model_reload_failed'); + if (retried.pending) return { waiting: true }; + + let row = null; + if (wanted) { + row = this.catalog.find(candidate => candidate.id === wanted) || null; + if (!row) throw Error('secondary_model_unavailable'); + } + + let bytes; + try { bytes = fs.readFileSync(this.nativeConfigFile); } + catch (error) { + if (error?.code !== 'ENOENT') throw Error('secondary_config_unsupported'); + if (wanted) throw Error('secondary_model_unavailable'); + this.dropSecondaryOwnership(); + return { applied: false, absent: true }; + } + const text = bytes.toString('utf8'); + let fileState; + try { fileState = inspectSecondary(text); } catch { throw Error('secondary_config_unsupported'); } + const current = this.effectiveSecondary(fileState); + + let target; + if (wanted) { + // A saved preference that differs from the alias this adapter wrote is an + // explicit change and takes effect. Otherwise only an adapter-owned or a + // dropped scalar is replaced; another writer's value stays untouched. + const reassert = owned === null || (!inheriting && owned.alias !== row.alias) || isRestoredBaseline(current, owned); + const ownsModel = current.model === null || current.model === row.alias + || (owned !== null && current.model === owned.alias); + const ownsFlag = current.enabled === null || current.enabled === true; + target = { model: (ownsModel || reassert) ? row.alias : current.model, + enabled: (ownsFlag || reassert) ? true : current.enabled, + hasSecondaryTable: true, hasExperimentalTable: true }; + } else { + target = restoreTarget(current, owned.baseline, owned.alias); + } + + let next; + try { next = patchSecondary(text, target); } catch { throw Error('secondary_config_unsupported'); } + const changed = next !== text; + const appliedNow = wanted ? target.model === row.alias && target.enabled === true : true; + const confirmationNeeded = appliedNow && !retried.confirmed && Boolean(wanted || this.state.secondaryModel) + && this.state.secondaryModel !== wanted; + // Matching file bytes do not prove that existing cached sessions have read + // a newly selected preference. They need one acknowledgement as well. + if ((changed || confirmationNeeded || this.secondaryReloadPending) && busy(runner)) return { waiting: true }; + if (changed) { + if (wanted && !owned) { + // First capture uses the file as it is, never the preserved view. + this.state.secondary = { baseline: fileState, alias: '' }; + } + if (wanted && target.model === row.alias && this.state.secondary.alias !== row.alias) { + // Record intent before the write; a crash in between then leaves the + // file exactly as it was. + this.state.secondary.alias = row.alias; + this.saveState(); + } + this.commitNativeSecondary(bytes, next); + this.secondaryReloadPending = true; + } else if (wanted && !owned) { + // Already in place without a captured baseline: never claim it. + return { applied: false }; + } + if (wanted && appliedNow && this.state.secondary.alias !== row.alias) { + this.state.secondary.alias = row.alias; + this.saveState(); + } + if (confirmationNeeded) this.secondaryReloadPending = true; + this.secondaryExternal = null; + + if (this.secondaryReloadPending) { + if (!runner || runner.lifecycleStatus !== 'running') { + // No live runtime caches the old value; the next start reads the file. + this.secondaryReloadPending = false; + this.secondaryPending.clear(); + this.secondaryReloadStale = false; + } else { + if (Date.now() < (this.secondaryAfter || 0)) return { waiting: true }; + const reload = await this.reloadSecondarySessions(runner); + if (reload.failed) throw Error('secondary_model_reload_failed'); + if (reload.pending) return { waiting: true }; + this.secondaryReloadPending = false; + } + } + + if (!appliedNow) { this.setAppliedSecondary(''); return { applied: false }; } + if (wanted) { + this.setAppliedSecondary(wanted); + return { applied: true }; + } + // The baseline is back on disk and the runtime was reloaded. + this.dropSecondaryOwnership(); + return { applied: true }; + } + + // Synchronous exit path: only the two owned scalars, no RPC. Ownership stays + // on disk so the next start can finish anything left behind. + restoreNativeSecondaryOnExit() { + const owned = this.secondaryOwnership(); + if (!owned) return; + const bytes = fs.readFileSync(this.nativeConfigFile); + const text = bytes.toString('utf8'); + const current = inspectSecondary(text); + const next = patchSecondary(text, restoreTarget(current, owned.baseline, owned.alias)); + if (next !== text) this.commitNativeSecondary(bytes, next); + // A clean exit deliberately restored our own two scalars. Distinguish that + // baseline from a later third-party edit when the saved preference resumes. + if ((current.model === null || current.model === owned.alias) + && (current.enabled === null || current.enabled === true)) { + this.state.secondary.restored = true; + this.saveState(); + } + } + async tick() { if (this.running || this.stopping) return; this.running = true; @@ -368,14 +751,20 @@ export class KimiAdapter { if (!desired.enabled || !desired.model) { if (this.state.active && busy(runner)) { this.report('waiting_kimi_idle'); return; } await this.restore(); + // Restoring the two native scalars runs after every control call above, + // because Daimon rewrites its runtime TOML from its own state. + const secondary = await this.reconcileSecondary(runner, ''); this.pausedSignature = null; this.retryAfter = 0; - this.report('disconnected'); + this.report(secondary.waiting ? 'waiting_kimi_idle' : 'disconnected'); return; } const target = gatewayURL(desired.gateway?.url); + const wantedSecondary = typeof desired.secondaryModel === 'string' ? desired.secondaryModel : ''; if (typeof desired.gateway?.key !== 'string' || !desired.gateway.key - || typeof desired.model !== 'string' || !desired.model.includes('/') || desired.model.length > 1024) { + || typeof desired.model !== 'string' || !desired.model.includes('/') || desired.model.length > 1024 + || (desired.secondaryModel !== undefined && typeof desired.secondaryModel !== 'string') + || (wantedSecondary !== '' && !validModelID(wantedSecondary))) { throw Error('invalid_connection'); } if (!runner || runner.lifecycleStatus !== 'running') { this.report('waiting_kimi'); return; } @@ -387,7 +776,11 @@ export class KimiAdapter { if (fingerprint !== this.fingerprint) { this.fingerprint = fingerprint; this.stableSince = Date.now(); } const unchanged = this.state.active && this.state.generation === generation && this.state.signature === signature; if (unchanged && baseURL(doc) === this.bridgeURL && this.live) { + this.protectNativeSecondary(wantedSecondary); await this.syncCatalog(runner, desired, target); + const secondary = await this.reconcileSecondary(runner, wantedSecondary, { inheritModel: desired.model }); + if (secondary.waiting) { this.report('waiting_kimi_idle'); return; } + if (secondary.applied === false) { this.report('kimi_config_changed'); return; } this.report('connected', { model: desired.model, models: this.catalog.length, ...(this.catalogError ? {catalog: this.catalogError} : {}) }); return; } if (this.pausedSignature === signature) { this.report('kimi_config_changed'); return; } @@ -410,8 +803,13 @@ export class KimiAdapter { await this.rpcCall(runner, 'models.current'); await this.listen(); if (!fs.readFileSync(this.configFile).equals(bytes)) throw Error('kimi_config_changed'); + // Protect the two scalars before the first control call below can make + // Daimon rewrite its runtime TOML from a fixed key set. + this.protectNativeSecondary(wantedSecondary); const previous = this.state; - this.state = { active: true, originalBase, bridgeURL: this.bridgeURL, generation, signature, model: desired.model }; + this.state = { active: true, originalBase, bridgeURL: this.bridgeURL, generation, signature, model: desired.model, + ...(previous?.secondary ? { secondary: previous.secondary } : {}), + ...(typeof previous?.secondaryModel === 'string' ? { secondaryModel: previous.secondaryModel } : {}) }; // Persist recovery ownership before writing the Kimi file. this.saveState(); this.changeBase(oldBase, this.bridgeURL); @@ -425,12 +823,17 @@ export class KimiAdapter { } this.live = { model: desired.model, gatewayURL: target, gatewayKey: desired.gateway.key, originalBase }; await this.syncCatalog(runner, desired, target); + // Reapply after the catalog sync, which may regenerate the same file. + const secondary = await this.reconcileSecondary(runner, wantedSecondary, { inheritModel: desired.model }); this.pausedSignature = null; + if (secondary.waiting) { this.report('waiting_kimi_idle'); return; } + if (secondary.applied === false) { this.report('kimi_config_changed'); return; } this.report('connected', { model: desired.model }); } catch (error) { const codes = new Set(['invalid_gateway', 'invalid_connection', 'unsupported_kimi_config', 'library_path_conflict', 'adapter_lock_invalid', 'adapter_lock_unavailable', - 'kimi_config_changed', 'kimi_control_unavailable', 'kimi_control_timeout', 'kimi_control_closed', 'kimi_control_rejected']); + 'kimi_config_changed', 'kimi_control_unavailable', 'kimi_control_timeout', 'kimi_control_closed', 'kimi_control_rejected', + 'secondary_config_unsupported', 'secondary_model_unavailable', 'secondary_model_reload_failed']); const code = codes.has(error.message) ? error.message : 'adapter_failed'; this.report(code); } finally { this.running = false; } @@ -449,7 +852,20 @@ export class KimiAdapter { while (this.running) await new Promise(resolve => setTimeout(resolve, 20)); if (this.locked) { try { await this.restore(); } catch { /* Never write a changed third-party configuration. */ } - this.report('disconnected'); + // Same order as an idle disconnect: the control calls above may have + // rewritten the runtime TOML, so the two scalars are restored last. + let code = 'disconnected'; + try { + let runner; + try { runner = readJSON(this.runnerFile); } catch { /* Kimi isn't running. */ } + // This is the last pass, so the retry pacing does not apply. + this.secondaryAfter = 0; + const secondary = await this.reconcileSecondary(runner, ''); + if (secondary.waiting) code = 'waiting_kimi_idle'; + } catch (error) { + if (SECONDARY_CODES.has(error.message)) code = error.message; + } + this.report(code); this.releaseLock(); } for (const pending of this.inflight) pending.abort(); @@ -467,6 +883,9 @@ export class KimiAdapter { this.changeBase(this.state.bridgeURL, this.state.originalBase); } } catch { /* Leave a changed configuration alone. */ } + // Synchronous host exit: only the two owned scalars, never a control call. + // Ownership stays on disk so the next start can finish anything left over. + try { this.restoreNativeSecondaryOnExit(); } catch { /* Leave a changed configuration alone. */ } try { atomicJSON(this.catalogFile, {schemaVersion:1, enabled:false, models:[], updatedAt:new Date().toISOString()}); } catch { /* Exiting. */ } this.releaseLock(); } diff --git a/packages/kimi-desktop/index.mjs b/packages/kimi-desktop/index.mjs index 061e3e8f3..87106e4ab 100644 --- a/packages/kimi-desktop/index.mjs +++ b/packages/kimi-desktop/index.mjs @@ -1,19 +1,30 @@ import { KimiAdapter } from './adapter.mjs'; +import { startSettingsServer, SETTINGS_PORT } from './settings.mjs'; const instance = Symbol.for('magpie.kimi-desktop.adapter.v1'); -export default async function KimiDesktop(input) { +export default async function KimiDesktop(input, options = {}) { + const log = code => { + try { + void input?.client?.app?.log?.({ body: { service: 'kimi-desktop', level: 'warn', message: code } })?.catch?.(() => {}); + } catch { /* No configuration, credentials or raw RPC errors in logs. */ } + }; if (!globalThis[instance]) { - const log = code => { - try { - void input?.client?.app?.log?.({ body: { service: 'kimi-desktop', level: 'warn', message: code } })?.catch?.(() => {}); - } catch { /* No configuration, credentials or raw RPC errors in logs. */ } - }; const adapter = await new KimiAdapter({ log }).start(); globalThis[instance] = adapter; // The shared host owns shutdown. Its normal exit path is synchronous; // never install signal handlers or terminate a host shared with plugins. process.once('exit', () => adapter.restoreFileOnExit()); } + const adapter = globalThis[instance]; + if (!adapter.settingsServer) { + try { + adapter.settingsServer = await startSettingsServer({ + port: options?.settingsPort ?? SETTINGS_PORT, + readSettings: () => adapter.readSettings(), + saveSettings: patch => adapter.saveSettings(patch), + }); + } catch { log('settings_server_unavailable'); } + } return {}; } diff --git a/packages/kimi-desktop/kimi-desktop.test.mjs b/packages/kimi-desktop/kimi-desktop.test.mjs index d71a3b25c..6637e3d6f 100644 --- a/packages/kimi-desktop/kimi-desktop.test.mjs +++ b/packages/kimi-desktop/kimi-desktop.test.mjs @@ -116,6 +116,67 @@ async function fixture() { cleanup: async () => { await controller.stop(); gateway.closeAllConnections(); await new Promise(resolve => gateway.close(resolve)); fs.rmSync(directory, { recursive: true, force: true }); } }; } +test('secondary settings expose only agent-visible models and preserve Magpie primary ownership', async () => { + const f = await fixture(); + try { + const fetchBefore = f.controller.fetchImpl; + let changed = false; + f.controller.fetchImpl = async (url, options) => { + const result = await fetchBefore(url, options); + if (!changed && String(url).endsWith('/models')) { + changed = true; + write(f.controller.connectionFile, { ...read(f.controller.connectionFile), + model: 'other-provider/other-model', newestHostField: 'preserve-this' }); + } + return result; + }; + const settings = await f.controller.saveSettings({ secondaryModel: 'original-provider/test-model' }); + const saved = read(f.controller.connectionFile); + expect(saved.model).toBe('other-provider/other-model'); + expect(saved.gateway).toEqual(f.connection.gateway); + expect(saved.newestHostField).toBe('preserve-this'); + expect(saved.secondaryModel).toBe('original-provider/test-model'); + expect(settings.primaryModel).toBe(saved.model); + expect(settings.models.map(row => row.id)).toEqual(f.models.map(row => row.id)); + expect(settings.appliedSecondaryModel).toBeUndefined(); + expect(JSON.stringify(settings)).not.toContain(f.connection.gateway.key); + expect(JSON.stringify(settings)).not.toContain(f.connection.gateway.url); + expect(Object.keys(settings).sort()).toEqual(['enabled', 'models', 'primaryModel', 'secondaryModel', 'status']); + } finally { await f.cleanup(); } +}); + +test('secondary settings reject hidden models and refuse writes after gateway identity changes', async () => { + const f = await fixture(); + try { + await expect(f.controller.saveSettings({ secondaryModel: 'hidden-provider/hidden-model' })) + .rejects.toHaveProperty('code', 'model_not_enabled_for_kimi'); + expect(read(f.controller.connectionFile)).toEqual(f.connection); + const fetchBefore = f.controller.fetchImpl; + f.controller.fetchImpl = async (url, options) => { + const result = await fetchBefore(url, options); + if (String(url).endsWith('/models')) write(f.controller.connectionFile, { + ...read(f.controller.connectionFile), gateway: { ...f.connection.gateway, key: 'replacement-test-key' }, + }); + return result; + }; + await expect(f.controller.saveSettings({ secondaryModel: f.models[1].id })) + .rejects.toHaveProperty('code', 'connection_changed'); + expect(read(f.controller.connectionFile).secondaryModel).toBeUndefined(); + expect(read(f.controller.connectionFile).gateway.key).toBe('replacement-test-key'); + } finally { await f.cleanup(); } +}); + +test('a saved secondary model can be cleared while disconnected without changing the primary', async () => { + const f = await fixture(); + try { + write(f.controller.connectionFile, { ...f.connection, enabled: false, secondaryModel: f.models[1].id }); + const settings = await f.controller.saveSettings({ secondaryModel: '' }); + expect(read(f.controller.connectionFile)).toEqual({ ...f.connection, enabled: false, secondaryModel: '' }); + expect(settings.enabled).toBe(false); + expect(settings.models).toEqual([]); + } finally { await f.cleanup(); } +}); + test('Work inference uses selected model and gateway auth; SSE tools survive; official fetch/search keep Kimi auth', async () => { const f = await fixture(); try { diff --git a/packages/kimi-desktop/package.json b/packages/kimi-desktop/package.json index ceb3c14cc..0579f4c27 100644 --- a/packages/kimi-desktop/package.json +++ b/packages/kimi-desktop/package.json @@ -1,6 +1,6 @@ { "name": "@magpie-community/agent-kimi-desktop", - "version": "0.1.5", + "version": "0.1.6", "description": "Kimi desktop Work as a Magpie agent, using the models configured in Magpie", "type": "module", "main": "./index.mjs", @@ -11,6 +11,10 @@ "rpc.mjs", "catalog.mjs", "library.mjs", + "secondary-config.mjs", + "settings.mjs", + "settings.html", + "page", "README.md", "COMPATIBILITY.md" ], @@ -28,6 +32,13 @@ }, "magpie": { "agent": "./kimi-desktop.agent.js", + "page": { + "title": "Kimi Work models", + "titles": { "zh": "Kimi Work 模型" }, + "file": "page/index.html", + "service": { "port": 3491, "option": "settingsPort" } + }, + "options": { "settingsPort": 3491 }, "icon": "data:image/png;base64,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" } } diff --git a/packages/kimi-desktop/page/boot.js b/packages/kimi-desktop/page/boot.js new file mode 100644 index 000000000..d2e5ff832 --- /dev/null +++ b/packages/kimi-desktop/page/boot.js @@ -0,0 +1,40 @@ +// The host page cannot fetch. Navigate its existing sandboxed iframe directly +// to the plugin service; magpie.service supplies a proxy URL for Magpie Web. +(async () => { + const DEFAULT_PORT = 3491; + let theme = ''; + let port = DEFAULT_PORT; + let service = null; + try { + const ready = await window.magpie?.ready; + if (typeof ready?.dark === 'boolean') theme = ready.dark ? 'dark' : 'light'; + } catch { /* The service can use the system theme without the bridge. */ } + try { + const options = await window.magpie?.options?.get?.(); + const value = Number(options?.settingsPort); + if (Number.isInteger(value) && value > 0 && value < 65536) port = value; + } catch { /* The server and launcher share the same default. */ } + try { + const value = window.magpie?.service; + if (typeof value === 'string') { + const url = new URL(value, location.href); + if ((url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password) service = url; + } + } catch { /* An old host may not provide a service proxy. */ } + if (!service) { + const host = String(location.hostname || '').toLowerCase().replace(/^\[|\]$/g, ''); + const local = host === '' || host === 'localhost' || host.endsWith('.localhost') || host === '127.0.0.1' || host === '::1'; + if (!local) { + const message = document.getElementById('message'); + if (message) message.textContent = '未获得设置服务地址。请在运行 Magpie 的桌面端打开,或使用支持插件服务代理的 Magpie Web。'; + return; + } + service = new URL(`http://127.0.0.1:${port}/`); + } + if (!service.pathname.endsWith('/')) service.pathname += '/'; + if (theme) service.searchParams.set('theme', theme); + // An opaque frame cannot use connect-src 'self'; the service validates this + // concrete origin before including it in its response's connect-src policy. + service.searchParams.set('uiOrigin', service.origin); + location.replace(service.href); +})(); diff --git a/packages/kimi-desktop/page/index.html b/packages/kimi-desktop/page/index.html new file mode 100644 index 000000000..f32b3ba20 --- /dev/null +++ b/packages/kimi-desktop/page/index.html @@ -0,0 +1,20 @@ + + + + + +Kimi 子任务模型 + + + +

Kimi 子任务模型

正在连接设置服务…

+ + + + diff --git a/packages/kimi-desktop/rpc.mjs b/packages/kimi-desktop/rpc.mjs index 04174bdef..539db2713 100644 --- a/packages/kimi-desktop/rpc.mjs +++ b/packages/kimi-desktop/rpc.mjs @@ -12,7 +12,8 @@ export function controlEndpoint(state) { // Bun supplies the authenticated WebSocket client; no browser or Kimi Code CLI. export async function rpc(state, method, params = {}, { timeout = 6000, Socket = globalThis.WebSocket } = {}) { if (!['models.current', 'conversations.setModel', 'conversations.getKimiModelList', - 'conversations.updateKimiModelList'].includes(method)) throw Error('unsupported_control_method'); + 'conversations.updateKimiModelList', 'kimiPlugin.reloadActiveSessions', + 'kimiPlugin.ReloadSession'].includes(method)) throw Error('unsupported_control_method'); const endpoint = controlEndpoint(state); return new Promise((resolve, reject) => { let socket, settled = false; diff --git a/packages/kimi-desktop/secondary-config.mjs b/packages/kimi-desktop/secondary-config.mjs new file mode 100644 index 000000000..6a91745c7 --- /dev/null +++ b/packages/kimi-desktop/secondary-config.mjs @@ -0,0 +1,152 @@ +// Read and patch only the two secondary-model scalars. Snapshots contain +// their values and original table presence, never the rest of the config. +// Bun reserializes changed documents; unchanged text is returned verbatim. + +const SECONDARY_TABLE = 'secondary_model'; +const EXPERIMENTAL_TABLE = 'experimental'; +const MODEL_KEY = 'model'; +const FLAG_KEY = 'secondary-model'; + +const UNSUPPORTED_MESSAGE = 'secondary_config_unsupported'; + +function unsupported() { + return new Error(UNSUPPORTED_MESSAGE); +} + +function isTable(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasOwn(object, key) { + return Object.prototype.hasOwnProperty.call(object, key); +} + +function parseDocument(text) { + if (typeof text !== 'string') { + throw unsupported(); + } + return Bun.TOML.parse(text); +} + +function readState(document) { + const hasSecondaryTable = hasOwn(document, SECONDARY_TABLE); + let model = null; + if (hasSecondaryTable) { + const table = document[SECONDARY_TABLE]; + if (!isTable(table)) { + throw unsupported(); + } + if (hasOwn(table, MODEL_KEY)) { + if (typeof table[MODEL_KEY] !== 'string') { + throw unsupported(); + } + model = table[MODEL_KEY]; + } + } + + const hasExperimentalTable = hasOwn(document, EXPERIMENTAL_TABLE); + let enabled = null; + if (hasExperimentalTable) { + const table = document[EXPERIMENTAL_TABLE]; + if (!isTable(table)) { + throw unsupported(); + } + if (hasOwn(table, FLAG_KEY)) { + if (typeof table[FLAG_KEY] !== 'boolean') { + throw unsupported(); + } + enabled = table[FLAG_KEY]; + } + } + + return { model, enabled, hasSecondaryTable, hasExperimentalTable }; +} + +function normalizeSnapshot(snapshot) { + if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) { + throw unsupported(); + } + + const { model, enabled, hasSecondaryTable, hasExperimentalTable } = snapshot; + if (model !== null && typeof model !== 'string') { + throw unsupported(); + } + if (enabled !== null && typeof enabled !== 'boolean') { + throw unsupported(); + } + if (typeof hasSecondaryTable !== 'boolean' || typeof hasExperimentalTable !== 'boolean') { + throw unsupported(); + } + + return { model, enabled, hasSecondaryTable, hasExperimentalTable }; +} + +function applyState(document, target) { + if (target.model === null) { + const table = hasOwn(document, SECONDARY_TABLE) ? document[SECONDARY_TABLE] : null; + if (table !== null) { + delete table[MODEL_KEY]; + if (!target.hasSecondaryTable && Object.keys(table).length === 0) { + delete document[SECONDARY_TABLE]; + } + } else if (target.hasSecondaryTable) { + document[SECONDARY_TABLE] = {}; + } + } else { + if (!hasOwn(document, SECONDARY_TABLE)) { + document[SECONDARY_TABLE] = {}; + } + document[SECONDARY_TABLE][MODEL_KEY] = target.model; + } + + if (target.enabled === null) { + const table = hasOwn(document, EXPERIMENTAL_TABLE) ? document[EXPERIMENTAL_TABLE] : null; + if (table !== null) { + delete table[FLAG_KEY]; + if (!target.hasExperimentalTable && Object.keys(table).length === 0) { + delete document[EXPERIMENTAL_TABLE]; + } + } else if (target.hasExperimentalTable) { + document[EXPERIMENTAL_TABLE] = {}; + } + } else { + if (!hasOwn(document, EXPERIMENTAL_TABLE)) { + document[EXPERIMENTAL_TABLE] = {}; + } + document[EXPERIMENTAL_TABLE][FLAG_KEY] = target.enabled; + } +} + +export function inspectSecondary(text) { + try { + return readState(parseDocument(text)); + } catch { + throw unsupported(); + } +} + +export function patchSecondary(text, snapshot) { + try { + const document = parseDocument(text); + const target = normalizeSnapshot(snapshot); + const current = readState(document); + + if ( + current.model === target.model && + current.enabled === target.enabled && + current.hasSecondaryTable === target.hasSecondaryTable && + current.hasExperimentalTable === target.hasExperimentalTable + ) { + return text; + } + + applyState(document, target); + return Bun.TOML.stringify(document); + } catch { + throw unsupported(); + } +} diff --git a/packages/kimi-desktop/secondary-config.test.mjs b/packages/kimi-desktop/secondary-config.test.mjs new file mode 100644 index 000000000..64e2b3642 --- /dev/null +++ b/packages/kimi-desktop/secondary-config.test.mjs @@ -0,0 +1,420 @@ +import { describe, expect, test } from 'bun:test'; + +import { inspectSecondary, patchSecondary } from './secondary-config.mjs'; + +// All fixtures below are synthetic. The canary is used only to prove that +// unsupported input does not leak document contents through the fixed error. +const CANARY = 'sk-synthetic-0f4a9c-canary'; +const UNSUPPORTED = 'secondary_config_unsupported'; + +const NO_SETTINGS = { + model: null, + enabled: null, + hasSecondaryTable: false, + hasExperimentalTable: false, +}; + +function parse(text) { + return Bun.TOML.parse(text); +} + +/** Snapshot exactly as the caller would persist it (JSON, non-credential state). */ +function snapshotOf(text) { + return JSON.parse(JSON.stringify(inspectSecondary(text))); +} + +/** + * Deep, order-independent view of parsed TOML. Temporal values (dates/times) + * become their TOML text so two documents can be compared as plain data. + */ +function canonical(value) { + if (Array.isArray(value)) { + return value.map(canonical); + } + if (value !== null && typeof value === 'object') { + if (typeof value.toJSON === 'function') { + return value.toJSON(); + } + const sorted = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = canonical(value[key]); + } + return sorted; + } + return value; +} + +/** + * Canonical parsed document with the two managed scalars removed, plus any + * section left empty by that removal. Everything else must survive a rewrite. + */ +function unrelatedConfig(text) { + const document = canonical(parse(text)); + for (const [tableName, scalarName] of [ + ['secondary_model', 'model'], + ['experimental', 'secondary-model'], + ]) { + const table = document[tableName]; + if (table === undefined) { + continue; + } + delete table[scalarName]; + if (Object.keys(table).length === 0) { + delete document[tableName]; + } + } + return document; +} + +/** + * Assert the operation fails with the single fixed error and return it so the + * caller can inspect what it does (or does not) expose. + */ +function expectUnsupported(operation) { + let error; + try { + operation(); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect(error.constructor).toBe(Error); + expect(error.message).toBe(UNSUPPORTED); + expect(error.cause).toBeUndefined(); + return error; +} + +function expectNoCanary(error) { + const exposed = JSON.stringify({ + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause ?? null, + }); + expect(exposed).not.toContain(CANARY); +} + +describe('inspectSecondary', () => { + test('reports missing settings as nulls with table presence flags', () => { + expect(inspectSecondary('default_model = "primary"\n')).toEqual(NO_SETTINGS); + expect(inspectSecondary('')).toEqual(NO_SETTINGS); + }); + + test('distinguishes an absent scalar from an explicit false flag', () => { + expect(inspectSecondary('[experimental]\nsecondary-model = false\n').enabled).toBe(false); + expect(inspectSecondary('[experimental]\nsecondary-model = true\n').enabled).toBe(true); + + const emptyFlagTable = inspectSecondary('[experimental]\n'); + expect(emptyFlagTable.enabled).toBeNull(); + expect(emptyFlagTable.hasExperimentalTable).toBe(true); + + const emptyModelTable = inspectSecondary('[secondary_model]\n'); + expect(emptyModelTable.model).toBeNull(); + expect(emptyModelTable.hasSecondaryTable).toBe(true); + }); + + test('reads the hyphenated experimental key and ignores the underscored spelling', () => { + expect(inspectSecondary('[experimental]\n"secondary-model" = false\n').enabled).toBe(false); + expect(inspectSecondary('[experimental]\nsecondary_model = true\n').enabled).toBeNull(); + }); +}); + +describe('patchSecondary', () => { + test('adds both settings to a config that has neither table', () => { + const original = [ + '# synthetic fixture', + 'default_model = "primary-alias"', + '', + '[providers.acme]', + 'api_key = "sk-synthetic-provider-key"', + 'base_url = "https://acme.invalid/v1"', + '', + '[models.primary-alias]', + 'model = "wire-primary"', + 'provider = "acme"', + '', + ].join('\n'); + + expect(inspectSecondary(original)).toEqual(NO_SETTINGS); + + const patched = patchSecondary(original, { + model: 'primary-alias', + enabled: true, + hasSecondaryTable: false, + hasExperimentalTable: false, + }); + + expect(inspectSecondary(patched)).toEqual({ + model: 'primary-alias', + enabled: true, + hasSecondaryTable: true, + hasExperimentalTable: true, + }); + + const parsed = parse(patched); + expect(parsed.providers.acme.api_key).toBe('sk-synthetic-provider-key'); + expect(parsed.models['primary-alias'].model).toBe('wire-primary'); + expect(parsed.default_model).toBe('primary-alias'); + + // An idle tick that re-applies the inspected snapshot must not rewrite. + expect(patchSecondary(patched, snapshotOf(patched))).toBe(patched); + }); + + test('round-trips a non-default model and disabled flag through a JSON snapshot', () => { + const original = [ + 'default_model = "primary-alias"', + '', + '[secondary_model]', + 'model = "secondary-slow"', + 'default_effort = "max"', + 'capabilities = ["thinking", "tool_use"]', + '', + '[experimental]', + 'secondary-model = false', + 'keep-me = 1', + '', + '[providers.acme]', + 'api_key = "sk-synthetic-provider-key"', + 'base_url = "https://acme.invalid/v1"', + '', + '[models.primary-alias]', + 'model = "wire-primary"', + '', + '[models.secondary-slow]', + 'model = "wire-secondary"', + 'max_context_size = 131072', + '', + ].join('\n'); + + const snapshot = snapshotOf(original); + expect(snapshot).toEqual({ + model: 'secondary-slow', + enabled: false, + hasSecondaryTable: true, + hasExperimentalTable: true, + }); + const snapshotBefore = JSON.stringify(snapshot); + + const modified = patchSecondary(original, { + model: 'primary-alias', + enabled: true, + hasSecondaryTable: true, + hasExperimentalTable: true, + }); + + expect(inspectSecondary(modified)).toEqual({ + model: 'primary-alias', + enabled: true, + hasSecondaryTable: true, + hasExperimentalTable: true, + }); + // Sibling keys in the same two tables must survive the rewrite. + expect(parse(modified).secondary_model.default_effort).toBe('max'); + expect(parse(modified).secondary_model.capabilities).toEqual(['thinking', 'tool_use']); + expect(parse(modified).experimental['keep-me']).toBe(1); + + const restored = patchSecondary(modified, snapshot); + expect(canonical(parse(restored))).toEqual(canonical(parse(original))); + expect(inspectSecondary(restored)).toEqual(snapshot); + expect(JSON.stringify(snapshot)).toBe(snapshotBefore); + }); + + test('removes only sections it created when the snapshot says they were absent', () => { + const original = 'default_model = "primary"\n\n[models.primary]\nmodel = "wire"\n'; + const withSecondary = patchSecondary(original, { + model: 'primary', + enabled: true, + hasSecondaryTable: false, + hasExperimentalTable: false, + }); + + expect(parse(withSecondary).secondary_model).toEqual({ model: 'primary' }); + expect(parse(withSecondary).experimental).toEqual({ 'secondary-model': true }); + + const restored = patchSecondary(withSecondary, snapshotOf(original)); + const parsed = parse(restored); + + expect('secondary_model' in parsed).toBe(false); + expect('experimental' in parsed).toBe(false); + expect(parsed).toEqual(parse(original)); + expect(restored).not.toContain('[secondary_model]'); + expect(restored).not.toContain('[experimental]'); + }); + + test('keeps target sections that still hold unrelated keys', () => { + const original = [ + '[secondary_model]', + 'model = "slow"', + 'default_effort = "high"', + '', + '[experimental]', + 'secondary-model = true', + 'nested = { retries = 2 }', + '', + ].join('\n'); + + const cleared = patchSecondary(original, NO_SETTINGS); + const parsed = parse(cleared); + + expect('model' in parsed.secondary_model).toBe(false); + expect(parsed.secondary_model.default_effort).toBe('high'); + expect('secondary-model' in parsed.experimental).toBe(false); + expect(parsed.experimental.nested).toEqual({ retries: 2 }); + + expect(inspectSecondary(cleared)).toEqual({ + model: null, + enabled: null, + hasSecondaryTable: true, + hasExperimentalTable: true, + }); + }); + + test('preserves empty tables recorded by the snapshot and recreates them when missing', () => { + const emptyTables = '[secondary_model]\n\n[experimental]\n'; + const emptySnapshot = { + model: null, + enabled: null, + hasSecondaryTable: true, + hasExperimentalTable: true, + }; + + // Both sections already exist and are empty: return the exact bytes. + expect(patchSecondary(emptyTables, emptySnapshot)).toBe(emptyTables); + + // The snapshot remembers empty sections that the current text lacks. + const noTables = 'default_model = "primary"\n'; + const created = patchSecondary(noTables, emptySnapshot); + expect(inspectSecondary(created)).toEqual(emptySnapshot); + expect(patchSecondary(created, emptySnapshot)).toBe(created); + }); + + test('returns the original text untouched when the snapshot already matches', () => { + const text = [ + '# keep my formatting', + 'default_model = "primary"', + '', + '[ experimental ] # inline comment', + 'secondary-model = false', + '', + '[secondary_model]', + 'model = "slow" # trailing comment', + '', + ].join('\n'); + + const snapshot = snapshotOf(text); + expect(snapshot).toEqual({ + model: 'slow', + enabled: false, + hasSecondaryTable: true, + hasExperimentalTable: true, + }); + expect(patchSecondary(text, snapshot)).toBe(text); + expect(patchSecondary(text, JSON.parse(JSON.stringify(snapshot)))).toBe(text); + + const absent = 'default_model = "primary"\n'; + expect(patchSecondary(absent, NO_SETTINGS)).toBe(absent); + }); + + test('preserves every unrelated TOML value across a rewrite', () => { + const original = [ + 'default_model = "primary-alias"', + 'created = 2026-11-07T00:00:00Z', + 'ratio = 0.125', + '"quoted key" = "value"', + 'tags = ["alpha", "beta"]', + 'inline = { retries = 2, mode = "safe" }', + '', + '[experimental]', + 'secondary-model = false', + 'other_flag = true', + '', + '[secondary_model]', + 'model = "secondary-slow"', + 'default_effort = "max"', + '', + '[providers.acme]', + 'api_key = "sk-synthetic-provider-key"', + 'base_url = "https://acme.invalid/v1"', + '', + '[models.primary-alias]', + 'model = "wire-primary"', + 'provider = "acme"', + 'max_context_size = 262144', + '', + '[[models.primary-alias.capabilities]]', + 'name = "thinking"', + '', + '[[models.primary-alias.capabilities]]', + 'name = "tool_use"', + '', + ].join('\n'); + + const patched = patchSecondary(original, { + model: 'primary-alias', + enabled: true, + hasSecondaryTable: true, + hasExperimentalTable: true, + }); + + expect(inspectSecondary(patched)).toEqual({ + model: 'primary-alias', + enabled: true, + hasSecondaryTable: true, + hasExperimentalTable: true, + }); + expect(unrelatedConfig(patched)).toEqual(unrelatedConfig(original)); + expect(canonical(parse(patched).created)).toBe('2026-11-07T00:00:00Z'); + expect(parse(patched).inline).toEqual({ retries: 2, mode: 'safe' }); + }); + + test('normalizes every unsupported shape to one fixed, content-free error', () => { + const validText = 'default_model = "primary"\n'; + + const invalidDocuments = [ + // section name already used by a scalar + `secondary_model = "primary"\n\n[providers.${CANARY}]\napi_key = "${CANARY}"\n`, + // array of tables instead of a table + `[[secondary_model]]\nmodel = "primary"\n\n[providers.${CANARY}]\napi_key = "${CANARY}"\n`, + // model present but not a string + `[secondary_model]\nmodel = 7\n\n[providers.${CANARY}]\napi_key = "${CANARY}"\n`, + // model used as a nested table + `[secondary_model.model]\nname = "primary"\n\n[providers.${CANARY}]\napi_key = "${CANARY}"\n`, + // experimental section is not a table + `experimental = true\n\n[providers.${CANARY}]\napi_key = "${CANARY}"\n`, + `[[experimental]]\nsecondary-model = true\n\n[providers.${CANARY}]\napi_key = "${CANARY}"\n`, + // flag present but not a boolean + `[experimental]\nsecondary-model = "true"\n\n[providers.${CANARY}]\napi_key = "${CANARY}"\n`, + `[experimental]\nsecondary-model = 0\n\n[providers.${CANARY}]\napi_key = "${CANARY}"\n`, + // parse error whose native message would name the synthetic provider key + `[providers.${CANARY}]\napi_key = "${CANARY}"\n[providers.${CANARY}]\n`, + // parse error with an unterminated array after the canary + `api_key = "${CANARY}"\nbroken = [1,\n`, + ]; + + for (const document of invalidDocuments) { + for (const operation of [ + () => inspectSecondary(document), + () => patchSecondary(document, NO_SETTINGS), + ]) { + expectNoCanary(expectUnsupported(operation)); + } + } + + const invalidSnapshots = [ + null, + [], + { model: 7, enabled: true, hasSecondaryTable: false, hasExperimentalTable: false }, + { model: null, enabled: 'true', hasSecondaryTable: false, hasExperimentalTable: false }, + { model: null, enabled: null, hasSecondaryTable: false }, + { model: undefined, enabled: null, hasSecondaryTable: false, hasExperimentalTable: false }, + ]; + for (const snapshot of invalidSnapshots) { + expectNoCanary(expectUnsupported(() => patchSecondary(validText, snapshot))); + } + + expectUnsupported(() => inspectSecondary(42)); + expectUnsupported(() => inspectSecondary(null)); + expectUnsupported(() => patchSecondary(null, NO_SETTINGS)); + }); +}); diff --git a/packages/kimi-desktop/secondary-model.test.mjs b/packages/kimi-desktop/secondary-model.test.mjs new file mode 100644 index 000000000..afe7d4f7e --- /dev/null +++ b/packages/kimi-desktop/secondary-model.test.mjs @@ -0,0 +1,760 @@ +// Synthetic behaviour tests for the primary/secondary model integration. +// Every file here is temporary: no real Kimi profile, no real RPC, no gateway. +import { test, expect } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { KimiAdapter } from './adapter.mjs'; +import { normalizeModels } from './catalog.mjs'; + +const official = 'https://agent-gw.kimi.com/coding/v1'; +const primaryID = 'original-provider/test-model'; +const secondaryID = 'other-provider/other-model'; +const catalogPayload = { data: [{ id: primaryID, display_name: 'First' }, { id: secondaryID, display_name: 'Second' }] }; +const alias = new Map(normalizeModels(catalogPayload).map(row => [row.id, row.alias])); + +const read = file => JSON.parse(fs.readFileSync(file, 'utf8')); +const write = (file, value) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value)); +}; +const reloadCalls = calls => calls.filter(call => call.method === 'kimiPlugin.reloadActiveSessions'); +const sessionCalls = calls => calls.filter(call => call.method === 'kimiPlugin.ReloadSession'); + +// The fixed key set Daimon's own generator writes. It never contains +// secondary_model or experimental, so a regeneration drops both sections. +function runtimeToml({ secondaryModel, secondaryExtra = '', flag, experimentalExtra = '' } = {}) { + const lines = [ + 'default_model = "k2d8-preview"', + '', + '[models]', + '"k2d8-preview" = { model = "k2d8-preview", provider = "daimon-kimi-code" }', + '', + '[providers.daimon-kimi-code]', + 'type = "kimi"', + '', + '[thinking]', + 'enabled = true', + '', + '[loop_control]', + 'max_steps_per_turn = 64', + ]; + if (secondaryModel !== undefined) { + lines.push('', '[secondary_model]', 'model = ' + JSON.stringify(secondaryModel)); + if (secondaryExtra) lines.push(secondaryExtra); + } + if (flag !== undefined) { + lines.push('', '[experimental]', '"secondary-model" = ' + String(flag)); + if (experimentalExtra) lines.push(experimentalExtra); + } + return lines.join('\n') + '\n'; +} + +async function fixture({ secondaryModel = '', native, regenerate = true, script = {} } = {}) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'magpie-kimi-secondary-')); + const kimiDir = path.join(directory, 'kimi'); + const nativeFile = path.join(kimiDir, 'runtime', 'kimi-code', 'config.toml'); + const calls = [], upstream = []; + const catalog = { data: [...catalogPayload.data] }; + let runtimeModels = [{ model: 'k2d8-preview' }]; + const writeNative = text => { + fs.mkdirSync(path.dirname(nativeFile), { recursive: true }); + fs.writeFileSync(nativeFile, text); + }; + const behavior = { reload: () => ({ sessions: [] }), reloadSession: () => ({ status: 'reloaded' }), ...script }; + writeNative(native ?? runtimeToml()); + const controller = new KimiAdapter({ + stateDir: path.join(directory, 'state'), kimiDir, stabilityMs: 0, intervalMs: 100000, + rpcCall: async (_runner, method, params) => { + calls.push({ method, params }); + switch (method) { + case 'models.current': return { current: 'k2d8-preview' }; + case 'conversations.getKimiModelList': return { models: runtimeModels }; + case 'conversations.updateKimiModelList': + runtimeModels = params.models; + if (regenerate) writeNative(runtimeToml()); + return { ok: true }; + case 'conversations.setModel': + if (regenerate) writeNative(runtimeToml()); + return { current: params.modelAlias }; + case 'kimiPlugin.reloadActiveSessions': return behavior.reload(); + case 'kimiPlugin.ReloadSession': return behavior.reloadSession(params); + default: throw Error('kimi_control_rejected'); + } + }, + fetchImpl: async (url, options) => { + const target = String(url); + if (target.endsWith('/models')) { + if (options.headers.Authorization !== 'Bearer synthetic-magpie-key') { + return new Response('{}', { status: 403 }); + } + return new Response(JSON.stringify(catalog), { headers: { 'Content-Type': 'application/json' } }); + } + if (target.startsWith('https://agent-gw.kimi.com/')) { + return new Response('{"result":"synthetic"}', { headers: { 'Content-Type': 'application/json' } }); + } + upstream.push({ url: target, body: JSON.parse(options.body) }); + return new Response('data: [DONE]\n\n', { headers: { 'Content-Type': 'text/event-stream' } }); + }, + }); + const doc = { + credentials: { kimiCode: { apiKey: 'synthetic-kimi-key', baseUrl: official, refreshToken: 'synthetic-refresh-before' } }, + model: { current: 'k2d8-preview', providers: { 'daimon-kimi-code': { type: 'kimi', baseUrl: official } }, + models: { 'k2d8-preview': { model: 'k2d8-preview', provider: 'daimon-kimi-code' } } }, + unrelated: { preserve: ['all', 'original'] }, + }; + write(controller.configFile, doc); + const runner = { lifecycleStatus: 'running', daemonGeneration: 'synthetic-generation-1', + activeKernelTurns: 0, activeKernelToolCalls: 0, + control: { endpoint: { url: 'ws://127.0.0.1:3072/control', pid: 10, startedAt: 1 } } }; + write(controller.runnerFile, runner); + controller.bootstrap(); + const connection = { schemaVersion: 1, enabled: true, model: primaryID, + gateway: { url: 'http://127.0.0.1:3425/v1', key: 'synthetic-magpie-key' }, + ...(secondaryModel ? { secondaryModel } : {}) }; + write(controller.connectionFile, connection); + const nativeText = () => fs.readFileSync(nativeFile, 'utf8'); + return { + controller, connection, runner, calls, upstream, nativeFile, catalog, doc, + nativeText, writeNative, + // Daimon rewriting the file from its own fixed key set, outside any adapter call. + regenerate: (options = {}) => writeNative(runtimeToml(options)), + complete: () => { controller.retryAfter = 0; controller.catalogAfter = 0; controller.secondaryAfter = 0; }, + cleanup: async () => { + await controller.stop(); + fs.rmSync(directory, { recursive: true, force: true }); + }, + }; +} + +test('the chosen secondary model is written as its catalog alias and both roles keep their alias route', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const toml = Bun.TOML.parse(f.nativeText()); + expect(toml.secondary_model.model).toBe(alias.get(secondaryID)); + expect(toml.experimental['secondary-model']).toBe(true); + expect(f.nativeText()).not.toContain('__secondary__'); + expect(fs.statSync(f.nativeFile).mode & 0o777).toBe(0o600); + expect(read(f.controller.stateFile).secondaryModel).toBe(secondaryID); + expect(read(f.controller.stateFile).secondary.baseline).toEqual({ + model: null, enabled: null, hasSecondaryTable: false, hasExperimentalTable: false, + }); + // The state file keeps model names, never the runtime TOML or credentials. + const state = fs.readFileSync(f.controller.stateFile, 'utf8'); + expect(state).not.toContain('default_model'); + expect(state).not.toContain('synthetic-kimi-key'); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(secondaryID); + // Both roles route through the aliases already registered in Kimi. + for (const [modelAlias, id] of [[alias.get(primaryID), primaryID], [alias.get(secondaryID), secondaryID]]) { + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { + method: 'POST', headers: { Authorization: 'Bearer kbemfhpmb-kimi-key' }, + body: JSON.stringify({ model: modelAlias, messages: [] }), + }); + // A wrong Kimi credential is refused before any upstream call. + expect(response.status).toBe(401); + await response.text(); + } + for (const [modelAlias, id] of [[alias.get(primaryID), primaryID], [alias.get(secondaryID), secondaryID]]) { + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { + method: 'POST', headers: { Authorization: 'Bearer synthetic-kimi-key' }, + body: JSON.stringify({ model: modelAlias, messages: [] }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(f.upstream.at(-1).body.model).toBe(id); + } + } finally { await f.cleanup(); } +}); + +test('a regenerated runtime TOML is repaired after the connection pass and only while idle', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(reloadCalls(f.calls)).toHaveLength(1); + + f.regenerate(); + expect(f.nativeText()).not.toContain('[secondary_model]'); + + // Mid-turn: neither the TOML nor the runtime may change, and the adapter + // must not report the preference as applied. + write(f.controller.runnerFile, { ...f.runner, activeKernelTurns: 1 }); + f.complete(); + const before = f.calls.length; + await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + expect(f.nativeText()).not.toContain('[secondary_model]'); + expect(f.calls.length).toBe(before); + + // Idle: the lost scalars are reapplied from the original baseline record. + write(f.controller.runnerFile, f.runner); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const toml = Bun.TOML.parse(f.nativeText()); + expect(toml.secondary_model.model).toBe(alias.get(secondaryID)); + expect(toml.experimental['secondary-model']).toBe(true); + expect(read(f.controller.stateFile).secondary.baseline.model).toBeNull(); + expect(reloadCalls(f.calls)).toHaveLength(2); + } finally { await f.cleanup(); } +}); + +test('a session skipped mid-turn is retried by id without reloading the others again', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + script: { reload: () => ({ sessions: [ + { kernelSessionId: 'synthetic-session-a', status: 'reloaded' }, + { kernelSessionId: 'synthetic-session-b', status: 'skipped-active-turn' }, + ] }) }, + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + expect(read(f.controller.stateFile).secondaryModel).toBeUndefined(); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + + // Busy runtime: the targeted retry waits. + write(f.controller.runnerFile, { ...f.runner, activeKernelTurns: ['synthetic-turn'] }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + expect(sessionCalls(f.calls)).toHaveLength(0); + + write(f.controller.runnerFile, f.runner); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(sessionCalls(f.calls).map(call => call.params)).toEqual([{ kernelSessionId: 'synthetic-session-b' }]); + // The sessions that were already reloaded are not touched again. + expect(reloadCalls(f.calls)).toHaveLength(1); + expect(read(f.controller.stateFile).secondaryModel).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('clearing the preference follows primary and keeps the original scalars for disconnect', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + regenerate: false, + native: runtimeToml({ secondaryModel: 'native/first', secondaryExtra: 'default_effort = "max"', + flag: false, experimentalExtra: 'other-flag = true' }), + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + let toml = Bun.TOML.parse(f.nativeText()); + expect(toml.secondary_model.model).toBe(alias.get(secondaryID)); + expect(toml.experimental['secondary-model']).toBe(true); + expect(toml.secondary_model.default_effort).toBe('max'); + expect(toml.experimental['other-flag']).toBe(true); + + // A new selection changes the secondary alias without rebuilding the bridge. + write(f.controller.connectionFile, { ...f.connection, secondaryModel: primaryID }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + toml = Bun.TOML.parse(f.nativeText()); + expect(toml.secondary_model.model).toBe(alias.get(primaryID)); + expect(read(f.controller.stateFile).secondaryModel).toBe(primaryID); + + // Clearing follows the current primary; Disconnect still owns the baseline. + write(f.controller.connectionFile, { ...f.connection, secondaryModel: '' }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + toml = Bun.TOML.parse(f.nativeText()); + expect(toml.secondary_model.model).toBe(alias.get(primaryID)); + expect(toml.experimental['secondary-model']).toBe(true); + expect(toml.secondary_model.default_effort).toBe('max'); + expect(toml.experimental['other-flag']).toBe(true); + expect(read(f.controller.stateFile).secondary.baseline).toEqual({ + model: 'native/first', enabled: false, + hasSecondaryTable: true, hasExperimentalTable: true, + }); + expect(read(f.controller.stateFile).secondaryModel).toBe(primaryID); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(primaryID); + } finally { await f.cleanup(); } +}); + +test('inherit follows the primary route while an enabled secondary baseline remains recoverable', async () => { + const baseline = runtimeToml({ secondaryModel: alias.get(secondaryID), flag: true }); + const f = await fixture({ secondaryModel: primaryID, native: baseline }); + const assertInherited = async model => { + const current = Bun.TOML.parse(f.nativeText()); + const settings = await f.controller.readSettings(); + expect(settings.secondaryModel).toBe(''); + expect(settings.appliedSecondaryModel).toBe(model); + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { + method: 'POST', headers: { Authorization: 'Bearer ' + f.doc.credentials.kimiCode.apiKey }, + body: JSON.stringify({ model: current.secondary_model.model, messages: [] }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(f.upstream.at(-1).body.model).toBe(model); + expect(current.secondary_model.model).toBe(alias.get(model)); + expect(current.experimental['secondary-model']).toBe(true); + }; + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + write(f.controller.connectionFile, { ...f.connection, secondaryModel: '' }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + await assertInherited(primaryID); + expect(read(f.controller.stateFile).secondary.baseline).toEqual({ + model: alias.get(secondaryID), enabled: true, + hasSecondaryTable: true, hasExperimentalTable: true, + }); + + const reloaded = reloadCalls(f.calls).length; + f.complete(); + await f.controller.tick(); + expect(reloadCalls(f.calls)).toHaveLength(reloaded); + + write(f.controller.connectionFile, { ...f.connection, model: secondaryID, secondaryModel: '' }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + await assertInherited(secondaryID); + + write(f.controller.connectionFile, { ...f.connection, model: secondaryID, secondaryModel: '', enabled: false }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + const restored = Bun.TOML.parse(f.nativeText()); + expect(restored.secondary_model).toEqual(Bun.TOML.parse(baseline).secondary_model); + expect(restored.experimental).toEqual(Bun.TOML.parse(baseline).experimental); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +test('disconnect restores the baseline after the last control call and keeps credentials and models', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + native: runtimeToml({ secondaryModel: 'native/first', flag: false }), + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Bun.TOML.parse(f.nativeText()).secondary_model.model).toBe(alias.get(secondaryID)); + write(f.controller.connectionFile, { ...f.connection, enabled: false }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + const toml = Bun.TOML.parse(f.nativeText()); + // Daimon regenerated the file during that restore pass; both scalars are + // written back afterwards, and nothing else is invented. + expect(toml.secondary_model.model).toBe('native/first'); + expect(toml.experimental['secondary-model']).toBe(false); + expect(toml.models['k2d8-preview']).toBeDefined(); + expect(read(f.controller.configFile).credentials).toEqual(f.doc.credentials); + expect(read(f.controller.configFile).unrelated).toEqual(f.doc.unrelated); + expect(read(f.controller.configFile).model.providers['daimon-kimi-code'].baseUrl).toBe(official); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +test('a value another writer installed is never restored over', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + regenerate: false, + native: runtimeToml({ secondaryModel: 'native/first', flag: false }), + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + // Hand-edited while connected: no longer ours. + f.writeNative(runtimeToml({ secondaryModel: 'third-party/手动', flag: false })); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('kimi_config_changed'); + expect(Bun.TOML.parse(f.nativeText()).secondary_model.model).toBe('third-party/手动'); + // Not applied, so nothing is claimed for the saved preference. + expect(read(f.controller.stateFile).secondaryModel).toBe(''); + + write(f.controller.connectionFile, { ...f.connection, enabled: false }); + f.complete(); + await f.controller.tick(); + const toml = Bun.TOML.parse(f.nativeText()); + expect(toml.secondary_model.model).toBe('third-party/手动'); + expect(toml.experimental['secondary-model']).toBe(false); + } finally { await f.cleanup(); } +}); + +test('a pending reload waits for an idle runtime instead of reloading it', async () => { + let reloads = 0; + const f = await fixture({ + secondaryModel: secondaryID, + script: { + reload: () => { + reloads += 1; + return reloads === 2 ? { sessions: [{ kernelSessionId: 'synthetic-session-d', status: 'failed' }] } : { sessions: [] }; + }, + }, + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + // A regeneration is repaired on disk, but its reload fails. + f.regenerate(); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(reloadCalls(f.calls)).toHaveLength(2); + expect(f.controller.secondaryReloadPending).toBe(true); + + // The written file is left alone and no new reload is attempted mid-turn. + write(f.controller.runnerFile, { ...f.runner, activeKernelTurns: 1 }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + expect(reloadCalls(f.calls)).toHaveLength(2); + + // Idle again: only the unconfirmed session is retried, and only then is + // the preference reported as applied. + write(f.controller.runnerFile, f.runner); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(reloadCalls(f.calls)).toHaveLength(2); + expect(sessionCalls(f.calls).map(call => call.params)).toEqual([{ kernelSessionId: 'synthetic-session-d' }]); + expect(read(f.controller.stateFile).secondaryModel).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('a targeted reload without an explicit acknowledgement is a failure, not an application', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + script: { + reload: () => ({ sessions: [{ kernelSessionId: 'synthetic-session-e', status: 'skipped-active-turn' }] }), + reloadSession: () => ({}), + }, + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(read(f.controller.stateFile).secondaryModel).toBeUndefined(); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +test('failures are reported as not applied instead of connected', async () => { + // A reload that reports a failed session. + const failed = await fixture({ + secondaryModel: secondaryID, + script: { reload: () => ({ sessions: [{ kernelSessionId: 'synthetic-session-c', status: 'failed' }] }) }, + }); + try { + await failed.controller.tick(); + expect(failed.controller.status.code).toBe('secondary_model_reload_failed'); + expect((await failed.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + expect(read(failed.controller.stateFile).secondaryModel).toBeUndefined(); + } finally { await failed.cleanup(); } + + // A saved model that the gateway catalog no longer exposes. + const unavailable = await fixture({ secondaryModel: 'hidden-provider/hidden-model', regenerate: false }); + try { + const before = unavailable.nativeText(); + await unavailable.controller.tick(); + expect(unavailable.controller.status.code).toBe('secondary_model_unavailable'); + expect(unavailable.nativeText()).toBe(before); + expect(unavailable.controller.state.secondaryModel).toBeUndefined(); + } finally { await unavailable.cleanup(); } + + // A malformed preference fails the connection validation, before any write. + const malformed = await fixture({ regenerate: false }); + try { + const before = malformed.nativeText(); + write(malformed.controller.connectionFile, { ...malformed.connection, secondaryModel: 'no-slash' }); + await malformed.controller.tick(); + expect(malformed.controller.status.code).toBe('invalid_connection'); + expect(malformed.nativeText()).toBe(before); + expect(malformed.controller.state.secondary).toBeUndefined(); + } finally { await malformed.cleanup(); } +}); + +test('without a secondary preference the original path is untouched', async () => { + const f = await fixture({ regenerate: false }); + try { + const before = f.nativeText(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(f.nativeText()).toBe(before); + expect(f.calls.map(call => call.method)).toEqual(['models.current', 'conversations.setModel', + 'conversations.getKimiModelList', 'conversations.updateKimiModelList']); + expect(reloadCalls(f.calls)).toHaveLength(0); + expect(sessionCalls(f.calls)).toHaveLength(0); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(read(f.controller.stateFile).secondaryModel).toBeUndefined(); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(primaryID); + } finally { await f.cleanup(); } +}); + +test('a mixed skipped and failed reload never becomes an application', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + script: { + reload: () => ({ sessions: [ + { kernelSessionId: 'synthetic-mixed-a', status: 'skipped-active-turn' }, + { kernelSessionId: 'synthetic-mixed-b', status: 'failed' }, + ] }), + }, + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(read(f.controller.stateFile).secondaryModel).toBeUndefined(); + expect(f.controller.secondaryPending).toEqual(new Set(['synthetic-mixed-a', 'synthetic-mixed-b'])); + + // Each id is retried once, in order: the skipped one and the failed one. + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(sessionCalls(f.calls).map(call => call.params)).toEqual([ + { kernelSessionId: 'synthetic-mixed-a' }, { kernelSessionId: 'synthetic-mixed-b' }, + ]); + // No cached session is reloaded twice, and nothing was applied early. + expect(reloadCalls(f.calls)).toHaveLength(1); + expect(read(f.controller.stateFile).secondaryModel).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('a reload row that cannot be retried by id keeps the preference unapplied until a full pass', async () => { + let reloads = 0; + const f = await fixture({ + secondaryModel: secondaryID, + script: { + reload: () => { + reloads += 1; + return reloads === 1 + ? { sessions: [{ kernelSessionId: 'synthetic-unattributed-a', status: 'skipped-active-turn' }, + { status: 'skipped-active-turn' }] } + : { sessions: [] }; + }, + }, + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(read(f.controller.stateFile).secondaryModel).toBeUndefined(); + + // The tracked id is retried, but the unattributed row still needs a pass. + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + expect(read(f.controller.stateFile).secondaryModel).toBeUndefined(); + + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(reloadCalls(f.calls)).toHaveLength(2); + expect(read(f.controller.stateFile).secondaryModel).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('stop restores the baseline after the last control call, like an idle disconnect', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + native: runtimeToml({ secondaryModel: 'native/first', flag: false }), + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Bun.TOML.parse(f.nativeText()).secondary_model.model).toBe(alias.get(secondaryID)); + await f.controller.stop(); + // Still checked before the temporary directory is removed. + const toml = Bun.TOML.parse(f.nativeText()); + expect(toml.secondary_model.model).toBe('native/first'); + expect(toml.experimental['secondary-model']).toBe(false); + expect(toml.models['k2d8-preview']).toBeDefined(); + expect(fs.readFileSync(f.controller.configFile, 'utf8')).toContain(official); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(read(f.controller.stateFile).secondaryModel).toBe(''); + expect(f.controller.status.code).toBe('disconnected'); + } finally { await f.cleanup(); } +}); + +test('the synchronous exit path restores only the two scalars and keeps ownership', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + native: runtimeToml({ secondaryModel: 'native/first', flag: false }), + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const calls = f.calls.length; + f.controller.restoreFileOnExit(); + const toml = Bun.TOML.parse(f.nativeText()); + expect(toml.secondary_model.model).toBe('native/first'); + expect(toml.experimental['secondary-model']).toBe(false); + // No reload and no other control call happens on this path. + expect(f.calls.length).toBe(calls); + // Ownership stays for the next start to finish the job. + expect(read(f.controller.stateFile).secondary).toBeDefined(); + } finally { await f.cleanup(); } +}); + +test('clearing the preference keeps a third-party value that a catalog regeneration dropped', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + native: runtimeToml({ secondaryModel: 'native/first', flag: false }), + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(read(f.controller.stateFile).secondary.baseline.model).toBe('native/first'); + // Hand-edited while connected, then Daimon rewrites the whole file. + f.writeNative(runtimeToml({ secondaryModel: 'third-party/手动', flag: false })); + f.catalog.data.push({ id: 'third-provider/third-model', display_name: 'Third' }); + write(f.controller.connectionFile, { ...f.connection, secondaryModel: '' }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('kimi_config_changed'); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + const toml = Bun.TOML.parse(f.nativeText()); + expect(toml.secondary_model.model).toBe('third-party/手动'); + expect(toml.experimental['secondary-model']).toBe(false); + expect(f.nativeText()).not.toContain(alias.get(secondaryID)); + expect(read(f.controller.stateFile).secondary.baseline.model).toBe('native/first'); + } finally { await f.cleanup(); } +}); + +test('disconnecting keeps a third-party scalar that Daimon rewrote away', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + native: runtimeToml({ secondaryModel: 'native/first', flag: false }), + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + f.writeNative(runtimeToml({ secondaryModel: 'third-party/手动', flag: false })); + write(f.controller.connectionFile, { ...f.connection, enabled: false }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + const toml = Bun.TOML.parse(f.nativeText()); + expect(toml.secondary_model.model).toBe('third-party/手动'); + expect(toml.experimental['secondary-model']).toBe(false); + expect(read(f.controller.configFile).credentials).toEqual(f.doc.credentials); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +test('switching the secondary model keeps the last applied marker until the new one is confirmed', async () => { + let reloads = 0; + const f = await fixture({ + secondaryModel: secondaryID, + script: { + reload: () => { + reloads += 1; + return reloads === 2 + ? { sessions: [{ kernelSessionId: 'synthetic-session-switch', status: 'failed' }] } + : { sessions: [] }; + }, + }, + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(secondaryID); + const setModels = f.calls.filter(call => call.method === 'conversations.setModel').length; + + // Only the preference changes: no new bridge connection is made. + write(f.controller.connectionFile, { ...f.connection, secondaryModel: primaryID }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(f.calls.filter(call => call.method === 'conversations.setModel').length).toBe(setModels); + // The marker still names what was really applied, not the new preference. + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(secondaryID); + + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(primaryID); + expect(Bun.TOML.parse(f.nativeText()).secondary_model.model).toBe(alias.get(primaryID)); + } finally { await f.cleanup(); } +}); + + +test('a fresh secondary preference confirms cached sessions even when the native bytes already match', async () => { + const f = await fixture(); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + f.writeNative(runtimeToml({ secondaryModel: alias.get(secondaryID), flag: true })); + write(f.controller.connectionFile, { ...f.connection, secondaryModel: secondaryID }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(reloadCalls(f.calls)).toHaveLength(1); + expect(read(f.controller.stateFile).secondary.alias).toBe(alias.get(secondaryID)); + expect(read(f.controller.stateFile).secondaryModel).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('a clean synchronous exit can reapply the saved secondary preference after restart', async () => { + const f = await fixture({ + secondaryModel: secondaryID, + native: runtimeToml({ secondaryModel: 'native/first', flag: false }), + }); + let restarted; + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + f.controller.restoreFileOnExit(); + expect(Bun.TOML.parse(f.nativeText()).secondary_model.model).toBe('native/first'); + expect(Bun.TOML.parse(f.nativeText()).experimental['secondary-model']).toBe(false); + await f.controller.stop(); + restarted = new KimiAdapter({ + stateDir: f.controller.stateDir, kimiDir: f.controller.kimiDir, + stabilityMs: 0, rpcCall: f.controller.rpcCall, fetchImpl: f.controller.fetchImpl, + }); + await restarted.tick(); + expect(restarted.status.code).toBe('connected'); + expect(Bun.TOML.parse(f.nativeText()).secondary_model.model).toBe(alias.get(secondaryID)); + expect(Bun.TOML.parse(f.nativeText()).experimental['secondary-model']).toBe(true); + expect(read(restarted.stateFile).secondary.baseline.model).toBe('native/first'); + expect(read(restarted.stateFile).secondaryModel).toBe(secondaryID); + } finally { + if (restarted) await restarted.stop(); + await f.cleanup(); + } +}); + +test('a waiting restore keeps external secondary values until the native rewrite is repaired', async () => { + let batches = 0, stillBusy = true; + const f = await fixture({ + secondaryModel: secondaryID, + native: runtimeToml({ secondaryModel: 'native/first', flag: false }), + script: { + reload: () => ++batches === 1 + ? { sessions: [{ kernelSessionId: 'waiting-session', status: 'skipped-active-turn' }] } + : { sessions: [] }, + reloadSession: () => ({ status: stillBusy ? 'skipped-active-turn' : 'reloaded' }), + }, + }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + f.writeNative(runtimeToml({ secondaryModel: 'third-party/手动', flag: false })); + write(f.controller.connectionFile, { ...f.connection, enabled: false }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + stillBusy = false; + f.complete(); + await f.controller.tick(); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + const current = Bun.TOML.parse(f.nativeText()); + expect(current.secondary_model.model).toBe('third-party/手动'); + expect(current.experimental['secondary-model']).toBe(false); + } finally { await f.cleanup(); } +}); diff --git a/packages/kimi-desktop/settings.html b/packages/kimi-desktop/settings.html new file mode 100644 index 000000000..81a7844e9 --- /dev/null +++ b/packages/kimi-desktop/settings.html @@ -0,0 +1,175 @@ + + + + + + +Kimi 子任务模型 + + + +
+

Kimi 子任务模型

+

为新建的 Agent 子任务配置可选的辅助模型。

+
+ 主模型 + 正在读取… +

在 Magpie Agents → Kimi 中更改主模型。

+
+
+
+ + +

模型列表来自当前 Kimi 连接可用的模型。

+
+
+ + +
+
+
+

正在读取设置…

+

+
+

+

新建的 Agent 子任务可在已配置的主模型与辅助模型中选择。仅在提示词中写入其他模型名称,不会改变模型配置。

+
+ + + diff --git a/packages/kimi-desktop/settings.mjs b/packages/kimi-desktop/settings.mjs new file mode 100644 index 000000000..9700121aa --- /dev/null +++ b/packages/kimi-desktop/settings.mjs @@ -0,0 +1,193 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; + +export const SETTINGS_PORT = 3491; +export const TOKEN_HEADER = 'x-magpie-kimi-settings-token'; + +const MAX_BODY_BYTES = 8 * 1024; +const SAVE_ERRORS = new Map([ + ['connect_kimi_first', 409], + ['model_not_enabled_for_kimi', 400], + ['connection_changed', 409], + ['settings_unavailable', 503], +]); + +class RequestError extends Error { + constructor(code, status = 400) { super(code); this.code = code; this.status = status; } +} + +function settingsView(state) { + if (!state || typeof state.enabled !== 'boolean' || typeof state.primaryModel !== 'string' + || typeof state.secondaryModel !== 'string' || typeof state.status !== 'string' + || !Array.isArray(state.models)) throw new Error('invalid_settings_state'); + const models = state.models.map(model => { + if (!model || typeof model.id !== 'string' || typeof model.name !== 'string') { + throw new Error('invalid_settings_state'); + } + return { id: model.id, name: model.name }; + }); + const view = { + enabled: state.enabled, + primaryModel: state.primaryModel, + secondaryModel: state.secondaryModel, + status: /^[a-z][a-z0-9_]{0,63}$/.test(state.status) ? state.status : 'settings_unavailable', + models, + }; + if (typeof state.appliedSecondaryModel === 'string') view.appliedSecondaryModel = state.appliedSecondaryModel; + return view; +} + +function readBody(req) { + if (Number(req.headers['content-length']) > MAX_BODY_BYTES) { + req.resume(); + return Promise.reject(new RequestError('body_too_large', 413)); + } + return new Promise((resolve, reject) => { + let size = 0; + let chunks = []; + let settled = false; + const fail = error => { + if (settled) return; + settled = true; + chunks = []; + reject(error); + }; + req.on('data', chunk => { + if (settled) return; + size += chunk.length; + if (size > MAX_BODY_BYTES) { fail(new RequestError('body_too_large', 413)); return; } + chunks.push(chunk); + }); + req.on('error', () => fail(new RequestError('invalid_request'))); + req.on('aborted', () => fail(new RequestError('invalid_request'))); + req.on('end', () => { + if (settled) return; + settled = true; + try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } + catch { reject(new RequestError('invalid_json')); } + }); + }); +} + +function uiOrigin(value) { + if (!value) return ''; + try { + const url = new URL(value); + if ((url.protocol === 'http:' || url.protocol === 'https:') && url.origin === value + && !url.username && !url.password) return url.origin; + } catch { /* An origin is an HTTP(S) origin only, never a CSP fragment. */ } + throw new RequestError('invalid_ui_origin'); +} + +// This server holds no gateway or Kimi credentials. Its callbacks own the +// connected-agent model whitelist and persistence; only their view DTO leaves it. +// The iframe keeps an opaque origin after navigation, so API reads need both +// Origin:null CORS and a separate per-server token from the non-CORS HTML page. +export async function startSettingsServer({ port = SETTINGS_PORT, readSettings, saveSettings } = {}) { + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('settings_invalid_port'); + if (typeof readSettings !== 'function' || typeof saveSettings !== 'function') { + throw new Error('settings_callbacks_required'); + } + let template; + try { template = await readFile(new URL('./settings.html', import.meta.url), 'utf8'); } + catch { throw new Error('settings_page_unavailable'); } + const token = randomBytes(32).toString('hex'); + const expectedToken = Buffer.from(token); + const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]); + const origins = new Set([...hosts].map(host => `http://${host}`)); + + const server = createServer(async (req, res) => { + const origin = req.headers.origin; + const originOK = origin === undefined || origin === 'null' || origins.has(origin); + const send = (status, body, { html = false, cors = false, extra = {} } = {}) => { + const headers = { + 'content-type': html ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + ...extra, + }; + if (cors && originOK) { + headers.vary = 'Origin'; + if (origin !== undefined) headers['access-control-allow-origin'] = origin; + headers['access-control-allow-headers'] = `content-type, ${TOKEN_HEADER}`; + headers['access-control-allow-methods'] = 'GET, POST, OPTIONS'; + } + res.writeHead(status, headers); + res.end(html ? body : JSON.stringify(body)); + }; + if (!hosts.has(String(req.headers.host || '').toLowerCase()) || !originOK) { + send(403, { error: 'forbidden' }); return; + } + let url; + try { url = new URL(req.url || '/', `http://127.0.0.1:${port}`); } + catch { send(400, { error: 'invalid_request' }); return; } + + if (url.pathname === '/' && req.method === 'GET') { + try { + const connectOrigin = uiOrigin(url.searchParams.get('uiOrigin')); + const nonce = randomBytes(18).toString('hex'); + const theme = ['dark', 'light'].includes(url.searchParams.get('theme')) ? url.searchParams.get('theme') : 'auto'; + const html = template.replaceAll('__KIMI_SETTINGS_TOKEN__', token) + .replaceAll('__KIMI_SETTINGS_NONCE__', nonce).replaceAll('__KIMI_SETTINGS_THEME__', theme) + .replaceAll('__KIMI_SETTINGS_HEADER__', TOKEN_HEADER); + const connect = [...new Set([...origins, ...(connectOrigin ? [connectOrigin] : [])])].join(' '); + send(200, html, { html: true, extra: { + 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src ${connect}; base-uri 'none'; form-action 'none'`, + } }); + } catch { send(400, { error: 'invalid_ui_origin' }); } + return; + } + if (url.pathname !== '/api/settings') { send(404, { error: 'not_found' }); return; } + if (req.method === 'OPTIONS') { send(204, undefined, { cors: true }); return; } + const got = Buffer.from(String(req.headers[TOKEN_HEADER] || '')); + if (got.length !== expectedToken.length || !timingSafeEqual(got, expectedToken)) { + send(403, { error: 'invalid_session' }, { cors: true }); return; + } + if (req.method === 'GET') { + try { send(200, settingsView(await readSettings()), { cors: true }); } + catch { send(500, { error: 'settings_read_failed' }, { cors: true }); } + return; + } + if (req.method !== 'POST') { + send(405, { error: 'method_not_allowed' }, { cors: true, extra: { allow: 'GET, POST, OPTIONS' } }); return; + } + let body; + try { + if (!/^application\/json(?:\s*;|\s*$)/i.test(req.headers['content-type'] || '')) { + throw new RequestError('unsupported_media_type', 415); + } + body = await readBody(req); + if (!body || typeof body !== 'object' || Array.isArray(body) + || Object.keys(body).length !== 1 || typeof body.secondaryModel !== 'string') { + throw new RequestError('invalid_request'); + } + } catch (error) { + const code = error instanceof RequestError ? error.code : 'invalid_request'; + send(error instanceof RequestError ? error.status : 400, { error: code }, { cors: true }); return; + } + try { send(200, settingsView(await saveSettings({ secondaryModel: body.secondaryModel })), { cors: true }); } + catch (error) { + const code = SAVE_ERRORS.has(error?.code) ? error.code : 'settings_save_failed'; + send(SAVE_ERRORS.get(code) || 500, { error: code }, { cors: true }); + } + }); + await new Promise((resolve, reject) => { + const onError = error => { + server.removeListener('listening', onListening); + const code = error?.code === 'EADDRINUSE' ? 'settings_port_in_use' : 'settings_bind_failed'; + reject(Object.assign(new Error(code), { code })); + }; + const onListening = () => { server.removeListener('error', onError); resolve(); }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, '127.0.0.1'); + }); + server.unref(); + let stopping; + return { + port, + stop: () => stopping ||= new Promise(resolve => { server.close(() => resolve()); server.closeIdleConnections?.(); }), + }; +} diff --git a/packages/kimi-desktop/settings.test.mjs b/packages/kimi-desktop/settings.test.mjs new file mode 100644 index 000000000..cb1f1c719 --- /dev/null +++ b/packages/kimi-desktop/settings.test.mjs @@ -0,0 +1,196 @@ +import { expect, test } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { createServer, request } from 'node:http'; +import { runInNewContext } from 'node:vm'; +import { startSettingsServer, TOKEN_HEADER } from './settings.mjs'; + +const initial = () => ({ + enabled: true, primaryModel: 'example/main', secondaryModel: '', status: 'connected', + models: [{ id: 'example/main', name: 'Main' }, { id: 'example/secondary', name: 'Secondary' }], +}); +const close = server => new Promise(resolve => server.close(resolve)); +const listen = server => new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); +}); + +async function fixture(callbacks = {}) { + // Production rejects port 0: reserve and release one only to choose this + // fixture's explicit configured port, then verify the service keeps it. + const reservation = createServer(); + const port = await listen(reservation); + await close(reservation); + let state = initial(); + const saved = []; + const service = await startSettingsServer({ + port, + readSettings: callbacks.readSettings || (() => state), + saveSettings: callbacks.saveSettings || (body => { + saved.push(body); + state = { ...state, ...body, status: 'waiting_kimi_idle' }; + return state; + }), + }); + const raw = (method, path, headers = {}, body) => new Promise((resolve, reject) => { + const req = request({ hostname: '127.0.0.1', port, method, path, headers: { + host: `127.0.0.1:${port}`, connection: 'close', ...headers, + } }, res => { + let text = ''; + res.setEncoding('utf8'); + res.on('data', chunk => { text += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text, json: () => JSON.parse(text) })); + }); + req.on('error', reject); + req.end(body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body)); + }); + const page = await raw('GET', '/'); + const token = /name="magpie-kimi-settings-token" content="([0-9a-f]{64})"/.exec(page.text)?.[1]; + const auth = { origin: 'null', [TOKEN_HEADER]: token }; + return { service, raw, page, token, auth, saved, port }; +} + +test('the opaque page receives its own token and concrete proxy CSP, without HTML CORS', async () => { + const f = await fixture(); + try { + expect(f.service.port).toBe(f.port); + expect(f.token).toMatch(/^[a-f0-9]{64}$/); + expect(f.page.headers['access-control-allow-origin']).toBeUndefined(); + const page = await f.raw('GET', '/?theme=dark&uiOrigin=' + encodeURIComponent('https://magpie.example:444'), { origin: 'null' }); + expect(page.status).toBe(200); + expect(page.headers['access-control-allow-origin']).toBeUndefined(); + expect(page.headers['content-security-policy']).toContain(`connect-src http://127.0.0.1:${f.port} http://localhost:${f.port} https://magpie.example:444;`); + expect(page.text).toContain('data-theme="dark"'); + expect(page.text).toContain(`'${TOKEN_HEADER}': token`); + expect(page.text).not.toContain('__KIMI_SETTINGS_'); + expect((await f.raw('GET', '/?uiOrigin=' + encodeURIComponent("https://magpie.example; script-src *"))).status).toBe(400); + expect((await f.raw('GET', '/?uiOrigin=' + encodeURIComponent('https://magpie.example/path'))).status).toBe(400); + const script = / diff --git a/packages/kimi-desktop/primary-model.mjs b/packages/kimi-desktop/primary-model.mjs new file mode 100644 index 000000000..eb4208866 --- /dev/null +++ b/packages/kimi-desktop/primary-model.mjs @@ -0,0 +1,53 @@ +import { execFile } from 'node:child_process'; +import { constants } from 'node:fs'; +import { access, readlink, realpath, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const runFile = promisify(execFile); +const failure = code => Object.assign(new Error(code), { code }); + +async function executablePath(value) { + if (typeof value !== 'string' || !path.isAbsolute(value.trim())) return ''; + try { + const resolved = await realpath(value.trim()); + if (!(await stat(resolved)).isFile()) return ''; + await access(resolved, constants.X_OK); + return resolved; + } catch { return ''; } +} + +// Use the running host's executable and environment so its normal agent setter +// owns the primary model, applied record and stash, including portable profiles. +export async function setPrimaryModel(model, { magpiePath, directory, env = process.env, + platform = process.platform, parentPID = process.ppid, run = runFile, + which = name => globalThis.Bun?.which?.(name) } = {}) { + let executable = ''; + if (magpiePath !== undefined && magpiePath !== '') { + executable = await executablePath(magpiePath); + } else { + let parent = ''; + try { + if (platform === 'darwin') { + const result = await run('/bin/ps', ['-ww', '-p', String(parentPID), '-o', 'comm='], + { timeout: 6000, encoding: 'utf8', windowsHide: true }); + parent = String(result.stdout || '').trim(); + } else if (platform === 'linux') { + parent = await readlink(`/proc/${parentPID}/exe`); + } + } catch { /* An explicit path or PATH may still name the host. */ } + executable = await executablePath(parent); + if (!executable) { + try { executable = await executablePath(which('magpie')); } catch { /* No PATH entry. */ } + } + } + if (!executable) throw failure('primary_model_unavailable'); + try { + await run(executable, ['kimi-desktop', 'model', 'magpie/' + model], { + cwd: directory || undefined, + env: { ...env, MAGPIE_KIMI_SKIP_ADAPTER: '1' }, + timeout: 60000, + windowsHide: true, + }); + } catch { throw failure('primary_model_update_failed'); } +} diff --git a/packages/kimi-desktop/primary-model.test.mjs b/packages/kimi-desktop/primary-model.test.mjs new file mode 100644 index 000000000..40028ddfb --- /dev/null +++ b/packages/kimi-desktop/primary-model.test.mjs @@ -0,0 +1,84 @@ +import { expect, test } from 'bun:test'; +import { execFile } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { setPrimaryModel } from './primary-model.mjs'; + +const runFile = promisify(execFile); +const temporary = () => fs.mkdtempSync(path.join(os.tmpdir(), 'kimi primary cli ')); + +test('the official primary argv inherits the host environment and its nested plugin skips adapter startup', async () => { + const directory = temporary(); + const observation = path.join(directory, 'observed.json'); + const calls = []; + const env = { ...process.env, HOME: directory, USERPROFILE: directory, CFFIXED_USER_HOME: directory, + XDG_CONFIG_HOME: path.join(directory, '.config'), KIMI_PRIMARY_TEST_MARKER: 'same-profile', + KIMI_PRIMARY_TEST_OBSERVATION: observation }; + delete env.MAGPIE_KIMI_SKIP_ADAPTER; + const before = process.env.MAGPIE_KIMI_SKIP_ADAPTER; + // Bun acts as the fake CLI executable and runs its fixed first argument as + // this temporary script. No real Magpie executable or profile is invoked. + fs.writeFileSync(path.join(directory, 'kimi-desktop'), ` + import fs from 'node:fs'; + const { default: plugin } = await import(${JSON.stringify(new URL('./index.mjs', import.meta.url).href)}); + await plugin({ directory: process.cwd() }); + fs.writeFileSync(process.env.KIMI_PRIMARY_TEST_OBSERVATION, JSON.stringify({ + args: process.argv.slice(2), cwd: process.cwd(), home: process.env.HOME, + marker: process.env.KIMI_PRIMARY_TEST_MARKER, skip: process.env.MAGPIE_KIMI_SKIP_ADAPTER, + adapterStarted: Boolean(globalThis[Symbol.for('magpie.kimi-desktop.adapter.v1')]), + })); + `); + try { + await setPrimaryModel('example/main', { magpiePath: process.execPath, directory, env, + run: (file, args, options) => { calls.push({ file, args, timeout: options.timeout }); return runFile(file, args, options); }, + }); + expect(calls).toHaveLength(1); + expect(calls[0].args).toEqual(['kimi-desktop', 'model', 'magpie/example/main']); + expect(calls[0].timeout).toBe(60000); + expect(JSON.parse(fs.readFileSync(observation, 'utf8'))).toEqual({ + args: ['model', 'magpie/example/main'], cwd: fs.realpathSync(directory), home: directory, + marker: 'same-profile', skip: '1', adapterStarted: false, + }); + expect(env.MAGPIE_KIMI_SKIP_ADAPTER).toBeUndefined(); + expect(process.env.MAGPIE_KIMI_SKIP_ADAPTER).toBe(before); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); + +test('mac parent discovery preserves executable path spaces before using PATH', async () => { + const directory = temporary(); + const executable = path.join(directory, 'Magpie with spaces'); + fs.writeFileSync(executable, '', { mode: 0o700 }); + const calls = []; + try { + await setPrimaryModel('example/main', { directory, platform: 'darwin', parentPID: 123, + which: () => { throw Error('PATH should not be consulted'); }, + run: async (file, args, options) => { + calls.push({ file, args, options }); + return file === '/bin/ps' ? { stdout: ' ' + executable + '\n' } : { stdout: 'ignored' }; + }, + }); + expect(calls[0].args).toEqual(['-ww', '-p', '123', '-o', 'comm=']); + expect(calls[1].file).toBe(fs.realpathSync(executable)); + expect(calls[1].options.cwd).toBe(directory); + expect(calls[1].options.env.MAGPIE_KIMI_SKIP_ADAPTER).toBe('1'); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); + +test('missing executables and failed CLI updates return only fixed error codes', async () => { + await expect(setPrimaryModel('example/main', { magpiePath: './relative-magpie' })) + .rejects.toHaveProperty('code', 'primary_model_unavailable'); + await expect(setPrimaryModel('example/main', { platform: 'unsupported', which: () => null })) + .rejects.toHaveProperty('code', 'primary_model_unavailable'); + let error; + try { + await setPrimaryModel('example/main', { magpiePath: process.execPath, + run: async () => { throw Object.assign(Error('synthetic private stderr'), { stderr: 'synthetic private stderr' }); }, + }); + } catch (caught) { error = caught; } + expect(error.code).toBe('primary_model_update_failed'); + expect(error.message).toBe('primary_model_update_failed'); + expect(error.cause).toBeUndefined(); + expect(error.stderr).toBeUndefined(); +}); diff --git a/packages/kimi-desktop/secondary-model.test.mjs b/packages/kimi-desktop/secondary-model.test.mjs index afe7d4f7e..b2c0c7efb 100644 --- a/packages/kimi-desktop/secondary-model.test.mjs +++ b/packages/kimi-desktop/secondary-model.test.mjs @@ -127,6 +127,140 @@ async function fixture({ secondaryModel = '', native, regenerate = true, script }; } +test('a primary-only save calls the host setter, preserves secondary and applies the route only when idle', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const selected = []; + f.controller.setPrimaryModel = async model => { + selected.push(model); + write(f.controller.connectionFile, { ...read(f.controller.connectionFile), model, hostOwned: 'keep' }); + }; + write(f.controller.runnerFile, { ...f.runner, activeKernelTurns: 1 }); + const saved = await f.controller.saveSettings({ primaryModel: secondaryID }); + expect(selected).toEqual([secondaryID]); + expect(read(f.controller.connectionFile).secondaryModel).toBe(secondaryID); + expect(read(f.controller.connectionFile).hostOwned).toBe('keep'); + expect(saved.primaryModel).toBe(secondaryID); + expect(saved.appliedPrimaryModel).toBe(primaryID); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + expect(f.controller.live.model).toBe(primaryID); + + write(f.controller.runnerFile, f.runner); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect((await f.controller.readSettings()).appliedPrimaryModel).toBe(secondaryID); + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { + method: 'POST', headers: { Authorization: 'Bearer ' + f.doc.credentials.kimiCode.apiKey }, + body: JSON.stringify({ model: 'k2d8-preview', messages: [] }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(f.upstream.at(-1).body.model).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('two-model saves validate before the host call and report partial persistence without rollback', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const selected = []; + try { + f.controller.setPrimaryModel = async model => { + selected.push(model); + write(f.controller.connectionFile, { ...read(f.controller.connectionFile), model }); + }; + await expect(f.controller.saveSettings({ primaryModel: secondaryID, secondaryModel: 'hidden/model' })) + .rejects.toHaveProperty('code', 'model_not_enabled_for_kimi'); + expect(selected).toEqual([]); + expect(read(f.controller.connectionFile)).toEqual(f.connection); + + f.controller.setPrimaryModel = async () => { throw Error('private CLI stderr'); }; + await expect(f.controller.saveSettings({ primaryModel: secondaryID, secondaryModel: '' })) + .rejects.toHaveProperty('code', 'primary_model_update_failed'); + expect(read(f.controller.connectionFile)).toEqual(f.connection); + + f.controller.setPrimaryModel = async model => { + selected.push(model); + write(f.controller.connectionFile, { ...read(f.controller.connectionFile), model, + gateway: { ...f.connection.gateway, key: 'synthetic-new-gateway' } }); + }; + await expect(f.controller.saveSettings({ primaryModel: secondaryID, secondaryModel: '' })) + .rejects.toHaveProperty('code', 'settings_partially_saved'); + expect(selected).toEqual([secondaryID]); + expect(read(f.controller.connectionFile).model).toBe(secondaryID); + expect(read(f.controller.connectionFile).secondaryModel).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('disconnect restores the native baseline despite a failing pending reload from the old preference', async () => { + const f = await fixture({ + secondaryModel: secondaryID, regenerate: false, + native: runtimeToml({ secondaryModel: 'native/first', flag: false }), + script: { + reload: () => ({ sessions: [{ kernelSessionId: 'old-preference-session', status: 'skipped-active-turn' }] }), + reloadSession: () => { throw Error('synthetic reload failure'); }, + }, + }); + try { + await f.controller.tick(); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + const attempted = sessionCalls(f.calls).length; + write(f.controller.connectionFile, { ...f.connection, enabled: false }); + f.complete(); + await f.controller.tick(); + const native = Bun.TOML.parse(f.nativeText()); + expect(native.secondary_model.model).toBe('native/first'); + expect(native.experimental['secondary-model']).toBe(false); + expect(read(f.controller.configFile).model.providers['daimon-kimi-code'].baseUrl).toBe(official); + expect(sessionCalls(f.calls)).toHaveLength(attempted); + expect(f.controller.status.code).not.toBe('connected'); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +test('a missing optional secondary TOML does not block the primary bridge route', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false }); + try { + fs.unlinkSync(f.nativeFile); + await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_unavailable'); + expect(read(f.controller.configFile).model.providers['daimon-kimi-code'].baseUrl).toBe(f.controller.bridgeURL); + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { + method: 'POST', headers: { Authorization: 'Bearer ' + f.doc.credentials.kimiCode.apiKey }, + body: JSON.stringify({ model: 'k2d8-preview', messages: [] }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(f.upstream.at(-1).body.model).toBe(primaryID); + expect(f.controller.state.secondary).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +test('an unsupported optional secondary TOML does not block the primary bridge route', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false, + native: 'secondary_model = "native/first"\n' + runtimeToml() }); + try { + const before = f.nativeText(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_config_unsupported'); + expect(f.nativeText()).toBe(before); + expect(f.controller.state.secondary).toBeUndefined(); + expect(f.controller.state.secondaryModel).toBeUndefined(); + expect(read(f.controller.configFile).model.providers['daimon-kimi-code'].baseUrl).toBe(f.controller.bridgeURL); + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { + method: 'POST', headers: { Authorization: 'Bearer ' + f.doc.credentials.kimiCode.apiKey }, + body: JSON.stringify({ model: 'k2d8-preview', messages: [] }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(f.upstream.at(-1).body.model).toBe(primaryID); + } finally { await f.cleanup(); } +}); + test('the chosen secondary model is written as its catalog alias and both roles keep their alias route', async () => { const f = await fixture({ secondaryModel: secondaryID }); try { @@ -557,7 +691,7 @@ test('a reload row that cannot be retried by id keeps the preference unapplied u } finally { await f.cleanup(); } }); -test('stop restores the baseline after the last control call, like an idle disconnect', async () => { +test('stop restores the disk baseline without new RPCs and keeps recovery ownership', async () => { const f = await fixture({ secondaryModel: secondaryID, native: runtimeToml({ secondaryModel: 'native/first', flag: false }), @@ -566,6 +700,7 @@ test('stop restores the baseline after the last control call, like an idle disco await f.controller.tick(); expect(f.controller.status.code).toBe('connected'); expect(Bun.TOML.parse(f.nativeText()).secondary_model.model).toBe(alias.get(secondaryID)); + const calls = f.calls.length; await f.controller.stop(); // Still checked before the temporary directory is removed. const toml = Bun.TOML.parse(f.nativeText()); @@ -573,8 +708,8 @@ test('stop restores the baseline after the last control call, like an idle disco expect(toml.experimental['secondary-model']).toBe(false); expect(toml.models['k2d8-preview']).toBeDefined(); expect(fs.readFileSync(f.controller.configFile, 'utf8')).toContain(official); - expect(read(f.controller.stateFile).secondary).toBeUndefined(); - expect(read(f.controller.stateFile).secondaryModel).toBe(''); + expect(f.calls).toHaveLength(calls); + expect(read(f.controller.stateFile).secondary.restored).toBe(true); expect(f.controller.status.code).toBe('disconnected'); } finally { await f.cleanup(); } }); @@ -733,7 +868,9 @@ test('a waiting restore keeps external secondary values until the native rewrite secondaryModel: secondaryID, native: runtimeToml({ secondaryModel: 'native/first', flag: false }), script: { - reload: () => ++batches === 1 + // The original preference and then the restored baseline each have a + // busy cached session; Disconnect discards the original pending batch. + reload: () => ++batches <= 2 ? { sessions: [{ kernelSessionId: 'waiting-session', status: 'skipped-active-turn' }] } : { sessions: [] }, reloadSession: () => ({ status: stillBusy ? 'skipped-active-turn' : 'reloaded' }), diff --git a/packages/kimi-desktop/settings-page.test.mjs b/packages/kimi-desktop/settings-page.test.mjs new file mode 100644 index 000000000..c1bff3937 --- /dev/null +++ b/packages/kimi-desktop/settings-page.test.mjs @@ -0,0 +1,268 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import { runInNewContext } from 'node:vm'; + +const html = await readFile(new URL('./settings.html', import.meta.url), 'utf8'); +const script = / diff --git a/packages/kimi-desktop/settings-page.test.mjs b/packages/kimi-desktop/settings-page.test.mjs index 7ffeb227f..6df602f49 100644 --- a/packages/kimi-desktop/settings-page.test.mjs +++ b/packages/kimi-desktop/settings-page.test.mjs @@ -7,6 +7,7 @@ const html = await readFile(new URL('./settings.html', import.meta.url), 'utf8') const script = /