diff --git a/packages/kimi-desktop/COMPATIBILITY.md b/packages/kimi-desktop/COMPATIBILITY.md index b7bcc4f38..0b7a4e594 100644 --- a/packages/kimi-desktop/COMPATIBILITY.md +++ b/packages/kimi-desktop/COMPATIBILITY.md @@ -1,5 +1,348 @@ # Compatibility record +## Independent inference settings, 2026-11-09 (SGT) + +The package remains **0.1.6**. The existing embedded page and settings API now save separate reasoning efforts and optional context input budgets for the primary and secondary roles. Only explicitly published catalog capabilities are offered; unknown context limits do not inherit the native runtime's 131072 fallback. Kimi's native model aliases and top-level `thinking`, `reasoning` and `reasoning_effort` no longer select external inference settings, including connections that have not saved new preferences. Native menus remain visible. No Magpie core or Kimi application resources were changed. + +A custom budget checks a supported, count-only copy through the existing Magpie `POST /v1/messages/count_tokens` route, using the current gateway identity. The count may be an estimate. Over-budget input returns `context_length_exceeded`; unavailable counting or an unsupported input representation returns fixed `context_count_unavailable`, without sending generation. With no budget, no count request is added. The adapter does not set Kimi's native compaction window, limit output through this setting, alter usage or directly trim history. Native compaction may follow an overflow and can lose earlier detail; its own summary requests face the same budget, so recovery is not guaranteed. + +Authenticated role identities distinguish primary and secondary even when they target the same model. Older authenticated profiles without a role keep their model binding and use provider defaults without an extra budget. During a pending model, gateway or key switch, active requests keep their previous role settings in memory. Partial saves merge with the latest host document and detect changed role selections before or after the official primary selector. Saved values that become unsupported remain visible for explicit replacement or reset. + +Other native plugins, Kimi login, paid-data authorizations, billing and service routes remain unchanged. The bridge preserves financial-tool schemas, calls and results. Counting uses already-present tool content; it does not call a financial database's paid query API. Synthetic regressions also check the original official search/fetch URL, credential and body, plus preservation of an unrelated paid-plugin record through companion enable/clear. + +### Automated validation + +The frozen runtime/test bytes passed the requested sequence on Bun **1.4.4**, in copied trees with temporary HOME/XDG directories, synthetic credentials and the existing OS real-HOME/application deny policy: + +| Command | Result | +| --- | --- | +| `HOME= bun test packages/kimi-desktop` | **626 pass, 0 fail, 3990 assertions; 29 files** | +| `bun scripts/check.mjs kimi-desktop` | **Exit 0** | +| `bun scripts/check.mjs` | **Exit 0** | +| `HOME= bun test` | **7447 pass, 20 skip, 0 fail, 239426 assertions; 523 files** | + +This uses the same real-ps bootstrap and passive synthetic observer documented below, with no additional command-line exclusions. It is an unfiltered repository-default run with those explicit harness accommodations, not a pristine bare-process run or real-browser acceptance. The original tracked files and workspace AtomCode-state digest were unchanged by validation. Subsequent compatibility-note edits change documentation only. + +Component guard checks used isolated copies and required genuine matcher failures: + +| Component | Final assertion-red variants | +| --- | --- | +| Catalog capability and option policy | 24 | +| Authenticated role/profile identity | 21 | +| Active/pending connections and legacy isolation | 32 | +| Existing settings API and forwarding integration | 34 | +| Embedded controls, drafts and recovery | 50 | +| Count conversion, transport and response checks | 36 | +| Empty count-copy text normalization | 1 | + +These are **198 final single-point variants**, checked during their component revisions. Three affected count variants were rerun after empty-text normalization. An additional attempt to delete the legacy-default branch was equivalent under the current snapshot invariants; it is recorded separately and is not counted as a red deletion. Changing that branch to apply an actual effort/budget produced a matcher failure. Syntax, import, permission and listener failures were not counted. + +An extracted Kimi 3.2.25 SDK error-conversion probe passed **9 scenarios / 119 assertions** in a VM: the bridge's overflow response becomes the native context-overflow error. It executes extracted callables only, without importing the full application bundle, network access, real credentials or application-state writes. + +The maintainer's subsequent non-blocking formatting request was also handled: twenty blank lines left by removed comments in `role-plugin-state.mjs` were deleted, with all nonblank lines byte-identical. Bun 1.4.4 `Transpiler({loader:'js', target:'bun'})` produced identical 5197-byte output before and after, SHA-256 `697b94e706162d80fdd77a0265993ffc55b3f56f79e558d0a9698cf5fceddb32`. The focused role-state suite passed **58 tests / 230 assertions** with a temporary HOME. The final four-command sequence above includes this cleanup. + +### Current native acceptance + +The checked **22-file** archive was installed through the official plugin CLI on Magpie **0.1.3336** with Kimi **3.2.25 / build 3.2.25.22**. Its SHA-256 is `62ef44e76c2e0934f62b1e5203b81c0b30d0003327f1968a5a1f6165feb0c042`. Installation reloaded the shared Bun plugin worker. The Magpie main PID and application snapshot stayed unchanged through installation, UI acceptance and restoration; no Magpie quit, start or restart command was issued. View → Reload refreshed only the WebView to load the updated page. + +| Check | Observed result | +| --- | --- | +| Capability-aware page | Both role sections appeared; the connected catalog had 54 models. Current primary and secondary each published a 1048576-token cap and their own effort lists. | +| Independent parameter persistence | Saved primary `low` / `196608` and secondary `high` / `131072` through native controls. Authenticated API readback returned 200 with both values; a page refresh confirmed both applied. Model selections were retained. | +| Real Kimi tool chain | In an existing owned acceptance task, the primary invoked one secondary Agent, which used one local Read tool on an owned synthetic file. Child and parent returned the expected synthetic phrases. The native model display in this task remained K3; external primary selection remained the plugin's configured choice. | +| Restoration | Removed only the exact test preferences using original-byte comparison plus temp/rename. Original primary/secondary choices, all other connection fields, native model definitions/current choice, other 23 native-plugin records, other Magpie plugins and AtomCode-state digest were preserved. The original new-task project and unsent draft were restored. | + +The UI/tool run proves saved settings and successful native execution; it does not independently measure provider reasoning behavior or exact tokenization. No real paid-data query was submitted. Native over-budget compaction, same-model/different-option execution in the native UI, and the five quit/disconnect lifecycle cases were **not repeated for this revision**; synthetic cases cover the new role/budget behavior, and earlier dated native lifecycle records below retain their original scope. No Chrome or Playwright was started. The installed archive precedes the blank-line cleanup and later compatibility note; the cleanup preserves nonblank helper code and transpiler output as verified above. + +## Settings-page recovery follow-up, 2026-11-08 (SGT) + +The package remains **0.1.6**. This follow-up changes only settings-service lifecycle and launcher recovery; it does not change model routing, profile generation, RPC permissions or Magpie application resources. + +The original launcher was reproduced in native Magpie **0.1.3335** with Kimi **3.2.25** present: it remained on the Chinese connecting message while port 3491 refused connections. During read-only inspection the shared Bun worker was replaced and the service became readable on that port; the main application PID stayed unchanged. The trigger and original bind error were not established. This is an observation of the previous runtime, not evidence that this patch repaired that running instance. The host's current WebKit implementation also documents that a failed service navigation can leave the old connecting document displayed. + +A synthetic occupied-port reproduction first failed: releasing the configured port and calling the real adapter tick did not start the settings service. The fix retries through the existing tick, shares pending initialization, follows the latest configured port, preserves the page session across stable ticks, and waits for pending initialization before shutdown closes the listener. Startup warnings use fixed allowed codes, with repeated identical failures suppressed until recovery. The launcher makes three navigation attempts, three seconds apart, and then offers a localized in-page Retry button. It continues to navigate the existing frame, with no fetch API, new network endpoint, background process or credential storage. + +Fourteen regression tests were added. **15/15** isolated single-point mutations produced genuine assertion failures: thirteen backend mutations (19 failing-test observations) and two launcher mutations (four observations). Tests use temporary homes, synthetic HTTP services and controlled VM timers. The missing-template case imports module copies from a temporary directory; it never renames a product file in the checkout. + +The frozen executable/test source passed the same isolated sequence described below on Bun **1.4.4**: + +| Command | Result | +| --- | --- | +| `HOME= bun test packages/kimi-desktop` | **464 pass, 0 fail, 3106 assertions** | +| `bun scripts/check.mjs kimi-desktop` | **Exit 0** | +| `bun scripts/check.mjs` | **Exit 0** | +| `HOME= bun test` | **7285 pass, 20 skip, 0 fail, 227322 assertions; 517 files** | + +These runs used fresh source copies and HOME directories, the existing OS real-HOME/application deny policy, real-ps bootstrap and passive synthetic observer. There were no additional command-line test exclusions. The source checkout's AtomCode-state digest was unchanged. Documentation updates after that sequence do not change the checked executable/test bytes. + +The checked 20-file archive was subsequently installed through the official plugin CLI. Installation reloaded the shared Bun plugin worker; the main Magpie process remained the same before installation, after installation and after UI acceptance. At acceptance the applications were Magpie **0.1.3336** and Kimi **3.2.25 / build 3.2.25.22**. One post-install launcher returned 404; **View → Reload** refreshed only the WebView and resolved it. The native window then showed both model lists, the existing selected primary and secondary, and the connected/applied status. An independent read of the authenticated settings API returned **200** with **54 models** and matching saved/applied selections. No selection was edited and no new Kimi inference was submitted. + +All 20 installed files matched archive SHA-256 `e7d1111753d9b5848f882ee4d7d3deda8648357306e33d7aa612edf203aee2ae`. Other Magpie plugin entries, the other 23 native plugin records, native model definitions/current choice, and the workspace AtomCode-state digest matched the immediate pre-install snapshot. The later compatibility-note update was not reinstalled; executable/test bytes remain those checked above. The initial inspection and installation were separated by an application-process/version change whose trigger was not established; the unchanged-main-process claim applies specifically to the installation/UI-acceptance window. + +Port-release recovery, retry exhaustion and manual-retry behavior were exercised in synthetic HTTP/VM tests, not forced on the running native host. The five native Kimi lifecycle/routing cases below apply to the earlier `e0ac41f` runtime and were not repeated for this settings-only follow-up. This revision was not tested by submitting a new turn in the native Kimi UI. No Chrome or Playwright was started. + +## Review follow-up after `6e3257e`, 2026-11-08 (SGT) + +The package remains **0.1.6**. This section describes the quoted-marker, catalog-absence, dotfile and equivalent-directory fixes. The dated records below describe earlier revisions and do not extend these results. No Magpie core or Kimi application resources were patched. + +### Changes and assertion-red guard checks + +| Issue | Change | Representative regression | Guards / assertion-red test observations | +| --- | --- | --- | --- | +| 1: quoted route markers | Without a routing key, retain 0.1.5 request-body behavior and existing alias cleanup. With a key, authenticate/strip system and developer identity only; invalid identity still returns 409. Neutralize reserved opening/closing tag prefixes in other fields and shared instructions without binding. Keep the final serialized residue check. | `R2-1 no routing key preserves route quotations in user`; `a copied secondary section in user and tool content never selects a route and neutralizes` | 7 / 20 | +| 2: temporary catalog absence | Persist the first-missing time and count. Retire only after three consecutive successful missing catalog synchronizations and ten minutes, with idle, verifiable profile synchronization. Failed fetches and cached ticks do not count; returning targets retain the old token before retirement. | `R2-2 successful absence count and start time survive failure, cached ticks, and restart`; `R2-2 a selected missing secondary recovers before grace and is pruned after sustained absence` | 31 / 74 | +| 3: dotfiles | Skip dot-prefixed entries before stat/read/hash/pruning in both agents directories. Preserve them; ordinary unknown files still conflict. | `dot-prefixed entries are skipped before stat/read/hash/delete and never conflict`; `R2-3 source and managed dot files stay unchanged through stable readiness and retirement` | 2 / 2 | +| 4: equivalent directories | Compare canonical real paths for existing source/managed directories, including parent symlinks or junctions. Retain provenance, exact manifest, whole-root, direct-root/managed-parent/registry symlink and byte-comparison guards, plus deleted-source cleanup. | `a sibling directory under the same managed parent is never owned even with a matching manifest`; parent-directory alias lifecycle regressions | 7 / 20 | + +Each issue first had a failing synthetic reproduction. **47/47** single-point guard mutations then produced genuine assertion failures in isolated copies; non-assertion/environment errors were not counted. The **116** test-failure observations are summed per mutation, so the same test can count for multiple mutations. The eight helper mutations were rerun after removing twenty full-line `// MUTATION` comments; all remained assertion-red. That cleanup retained every newline and all executable text. Previous B1–B3 and S1–S3 regressions remain in the passing package suite. + +Five additional catalog-retirement regressions cover temporary unknown source/managed files, edits to owned profiles and registry-provenance conflicts. A detected conflict defers binding deletion until owned profiles can safely synchronize. The preflight is not a cross-process filesystem transaction against a later I/O failure or another writer. + +### Automated validation + +The final executable/test source, including the comment cleanup, was copied unchanged into fresh temporary trees. Each step used a temporary HOME below TMPDIR, synthetic credentials, and an OS policy denying the real HOME and real application/browser paths. Registry-generated AtomCode state stayed in the temporary checkout. No unrelated package source was changed. + +| Command | Actual final result | +| --- | --- | +| `HOME= bun test packages/kimi-desktop` | **450 pass, 0 fail, 3069 assertions** | +| `bun scripts/check.mjs kimi-desktop` | **Exit 0** | +| `bun scripts/check.mjs` | **Exit 0** | +| `HOME= bun test` | **7271 pass, 20 skip, 0 fail, 238450 assertions; 516 files** | + +The commands ran in the listed order on Bun 1.4.4 through `sandbox-exec -f ...`, with `AGNES_BROWSER_TEST=0` and an explicit `BUN_OPTIONS` preload. No command-line test exclusions were added; the repository's two default agent-console exclusions remain. Real browser/native-app availability follows the OS deny policy. Synthetic VM, fake-browser and fake-CDP cases remain included. This is an **unfiltered command with repository defaults, real-ps bootstrap and passive observer**, not a pristine bare-process run or native browser acceptance. + +The bootstrap redirects only calls actually resolving to system `/bin/ps` to a private, ad-hoc-signed non-setuid copy of Apple's binary, with real argv and output. All three architectures and 25 file-backed instruction/constant sections were compared unchanged; the original `/bin/ps` bytes and mode were untouched. Fixture-provided fake ps commands remain unchanged. The corrected bootstrap preserves execFile/promisify metadata. A passive observer records only owned fake-Antigravity smoke argv/runtime/exit and synthetic output; it changes no timeout, fixture or result. The bootstrap SHA-256 is `040b912e38b49104b71b8cdc89a79f31b801d3301a65c8e1453f505f774cc37d`. + +Earlier unsuccessful runs are retained separately, not counted as passes: the first OS-policy/HOME setup yielded 7016 pass / 43 fail / 9 skip; the corrected HOME/availability setup without the ps bootstrap yielded 7255 / 11 / 20; an initial bootstrap lacking promisify metadata yielded 7198 / 63 / 20. A later run yielded **7270 / 1 / 20**, with one synthetic Antigravity usage smoke failure. Its cause remains unknown: focused replay and two unchanged observed whole-repository reruns passed. These passes do not claim to have fixed that one-off failure. DSH's nested OS-sandbox launch was rejected before project tests; the supervised Sol executor ran the same isolation policy without broadening DSH permissions. + +### Native Kimi 3.2.25 acceptance + +The fixed runtime candidate was installed with the official Magpie CLI and its **20 installed package files** matched the checked archive (package.json compared semantically). Kimi was **3.2.25 / build 3.2.25.22**, Daimon **1.0.6**, on macOS arm64. Magpie was **0.1.3240** for cases 1–4 and **0.1.3285** after an earlier exit/restart attempt. The later user-operated case 5 started with **0.1.3329** and observed **0.1.3335** after the user reopened Magpie. No Chrome or Playwright was started for these checks. + +Kimi's application-support directory is a parent symlink to relocated storage. Its registered managed root uses the physical path while the adapter also encounters the logical path. Installation, clear and disconnect now succeed across those equivalent directory forms without a drive-specific exception. An earlier native attempt stopped at that conflict and was not accepted. + +| Case | What was actually observed | +| --- | --- | +| 1. No secondary selected | After retiring a pre-existing owned registration, native `getPluginInfo` returned fixed not-found. A new UI-submitted Work turn performed one real Read and returned the fixture text. The native Agent schema had no `model` field or generated role, and still required only prompt/description. | +| 2. Three child routes | A UI-submitted parent created three children and each performed a real Read. The requested default-secondary child used DeepSeek V4.1 Flash through the configured group; `model="primary"` used Sol; `model="native"` retained native `explore` and used the primary Sol bridge. Ten gateway requests were 200: eight primary and two group requests. | +| 3. Clear secondary | Observed the registration enabled=false before its removal; final phase was disabled and native `getPluginInfo` returned fixed not-found. The installed await chain requires checked reload acknowledgement between disable and remove. This is state/order evidence, not an RPC packet trace. | +| 4. Disconnect and reconnect | Disconnect restored the original official provider URL in native JSON and both runtime TOMLs and removed the companion registration. Reconnection restored the original Sol primary selection with secondary opt-in ready. Native current choice and all four native model definitions were preserved. | +| 5. Quit Magpie and restart Kimi | **Passed in the later user-operated run.** At 22:47:38 SGT on 2026-11-08, the old plugin host had exited, no current owner was alive, the official service URL was restored and the owned disk registration was disabled. At 22:47:55, after Kimi's runtime PID and startup generation changed, native `getPluginInfo` returned the expected ID with `enabled=false` while Magpie was still offline. Native owner/control identity stayed stable across that RPC. At 22:50:10, the user-reopened host was ready and the temporarily selected companion was enabled again. | + +For case 2, the native history contains arguments **after bridge translation**. The recorded role intent, authenticated child bindings, completed Read calls and gateway model attribution support the observed routes; they are not a capture of raw supplier arguments. Literal simultaneous omission of `model` and `subagent_type` is covered by isolated protocol tests; the native prompt requested it, but its raw pre-translation arguments were not captured. + +Two short retirement observers expired before final state save or before the native disconnect confirmation was clicked. Their failed observation windows remain recorded; subsequent snapshots and native fixed-not-found results establish the final clear/disconnect states. Earlier case-5 attempts stopped at the quit dialog, missed intentional saved recovery debt with an active=false predicate, or lacked a controllable window; none is counted as a pass. The later manual run used an independently running, read-only observer with 11 synthetic checks and 42 assertions. Its restart check required a new live native PID/generation, the old PID gone, stable owner/control identity across `getPluginInfo`, and a second host-offline snapshot. A changed control token alone could not satisfy it. The observer exited 0 after offline native disablement and the user-operated host return were both confirmed. No new Kimi Work inference was submitted for this manual run. + +After the manual run, the saved selections were restored to **Sol / no secondary**, with the companion phase disabled, registration removed and native fixed-not-found confirmed. The default-port settings API refused the DSH restoration attempt before any save; the temporary secondary value alone was then restored by original-byte comparison and private temp/rename, preserving all other connection bytes. The connected primary bridge matched native JSON and both TOMLs. Other 23 native plugin records, other Magpie plugin entries, native choice/definitions and the workspace AtomCode-state digest matched the before-manual-run snapshot. No full real configuration or credential backup was retained. The tested runtime remains `e0ac41f`; all 20 installed files matched its archive before and after the run. This documentation-only follow-up was not reinstalled. Magpie exit/start/restart operations in the accepted run were performed by the user; the observer changed no application resources or native settings. + +### Remaining native boundaries + +Windows/Linux native behavior, native child compaction/fork, resume after disable or removal, paid tools/billing, sleep/wake and natural credential rotation remain untested in this follow-up. An optional native read of a fixture containing a valid signed route section was stopped by automatic approval review before sending, because the fixture carried a valid token/MAC. The unused fixture was byte-compared and removed; valid-token quotation behavior is isolated-test evidence only. + + +## Historical `6e3257e` isolated checkpoint, 2026-11-07–08 (SGT) + +At this historical isolated checkpoint, `6e3257e` had not been accepted in the native Kimi UI. The later live results and limitations for the current follow-up are recorded above. The changes after `cc9a023` were developed with temporary HOME directories, synthetic credentials, synthetic control peers and protocol/VM tests, plus read-only native-source inspection. Kimi-specific validation did not read a real Kimi HOME, install the candidate, launch or control Kimi, send a real model request, or use a browser or Playwright. The native turns and desktop operations recorded for `cc9a023` below belong to the previous revision. + +### Baseline opt-in routing contract + +An empty or absent `secondaryModel` keeps native Agent declarations, audit history and response calls unchanged, apart from the existing neutral alias cleanup. It does not install or enable a role plugin or reload sessions for role routing. Explicitly selecting secondary enables optional `model="primary|secondary|native"` roles only after the profiles are ready. A new call defaults to secondary only when both `model` and `subagent_type` are absent; a native type without `model` retains its original restrictions. Clearing secondary during a busy turn preserves the current schema until idle retirement completes, then turns new role translation off. + +Generated ordinary workers use general native tools (`tools: ["*"]`), disable `Agent` / `AgentSwarm` and declare no subagents. They inherit the parent's permission mode and available user tools; they do not reproduce the specialized restrictions of native `coder`, `explore` or `plan` profiles. Resume or fork keeps an existing authenticated binding while that target remains allowed and the runtime already has the profile. Clearing secondary schedules registration removal; neither disable nor removal is evidence that a native profile remains resumable. + +Profiles carry a random opaque route token and MAC, with token-to-model mapping only in `adapter-state.json`. Their text contains no target ID or reversible target encoding. Binding state must persist before profile publication or plugin-control calls; a failed save must not let the next retry publish unbound token text. Profile names have a key-specific namespace. All reserved route sections are consumed locally: unknown or invalid identity, or a section in another message/tool context, fails with HTTP 409 `agent_profile_unbound` rather than selecting primary or reaching upstream. Authenticated opaque call IDs preserve explicit role choices for audit without establishing the route. Every inference, including an existing worker, checks the bound target against the current gateway catalog; a removed target is refused without falling back to primary. + +Only Agent tool fragments require translation buffering; non-Agent tools are released as their identity becomes known. Invalid Agent JSON or role values pass through for native validation. A missing `finish_reason` flushes pending calls at `[DONE]`; missing indexes use the native pass-through path rather than destroying the stream. These are isolated protocol results, not a native UI acceptance claim. + +### Baseline companion lifecycle and bounded files + +The companion is a declarative `kimi.plugin.json` plus generated Markdown agents, with neutral `local-work-model-roles-` ID. The manifest is exactly `{name:,version:"1.0.0",description:"Local Work model roles",agents:["./agents"]}`; it grants no other plugin capability. Previously bound profiles are retained only while their targets remain in the current allowed gateway catalog, bounding retained generated profiles by that catalog. Pruning requires matching ownership; edited or unknown files are preserved and report `agent_profile_conflict`. + +Local registry discovery uses `/plugins/installed.json`. An ID prefix alone is not ownership: the record must have local-path provenance, the expected original source and managed root, and the exact declarative managed manifest. Before enabling roles, another key's role-plugin ID or a foreign prefix-matching registration reports `agent_profile_conflict` with no plugin RPC or registry change, preventing silent coexistence. Symlinks, malformed registry state and changed source content are not claimed or replaced. Existing native profiles and application resources remain outside ownership. + +Normal clearing, disconnect and `stop()` verify the saved plugin's ownership, call `kimiPlugin.setPluginEnabled(false)`, obtain explicit session-reload acknowledgement, recheck provenance, then use `kimiPlugin.removePlugin` to remove its installed registration. Busy sessions, transport failures, incomplete reload results and incomplete removal retain pending recovery for the next idle tick or start. A removal reply lost before its effect is retried; after-effect absence requires a fixed native-not-found reply and checked reload before pending work clears. Reconnecting with secondary selected installs the source when its registration is missing, even if the managed cache still contains matching files. Stable `getPluginInfo` probes are at least 30 seconds apart and skipped while busy; a transient or ambiguous failure does not change the last confirmed tool schema. Readiness changes require an idle confirmation of disabled or missing state. + +Disk absence is not native acknowledgement. If the registry record is missing but `getPluginInfo` still finds the plugin, recovery verifies nonsymlink source/cache directories and saved file hashes before disabling it. For plugin RPCs only, the public envelope's fixed `code:-32003` together with `data.reasonCode:"KIMI_PLUGIN_NOT_FOUND"` is mapped locally to `kimi_plugin_not_found`; other remote message/data content is discarded. Generic rejection, null, wrong IDs or missing `enabled` keep the obligation and report reload failure. Confirmed absence still needs checked reload, so a removed plugin can finish recovery without treating every rejection as success. + +The inspected native `removePlugin` path removes the installed record, reloads plugin contributions and refreshes active sessions. Its core removal chain does not delete the managed cache or the adapter's generated source and binding state. This is registration removal; it is not a claim that every generated file or the Magpie package was uninstalled. Ownership-checked catalog pruning remains separate. + +Synchronous exit performs no RPC and does not remove the registration. It changes only the saved, verified companion registration's `enabled` field to `false`, with original-byte comparison and a private mode-0600 exclusive temporary file followed by rename. The saved plugin ID and pending phase retain the runtime/removal obligation even when the helper fails or disk is already false. Missing registries are not created; foreign entries and unknown fields survive. Observed concurrent byte changes abort publication and staging files are cleaned. This is a compare/rename pattern, not a cross-process lock or an uninstall. + +Read-only inspection of the Kimi 3.2.25.22 / Daimon 1.0.6 bundle found no installed-registry watcher: the manager caches records and the profile loader listens to explicit plugin reload events. Disk `false` therefore does not immediately remove profiles in Kimi's memory or remove the installed record. If native presence remains, the next idle recovery must call `setPluginEnabled(false)` even if disk already says false, obtain checked `reloadActiveSessions` / targeted retry acknowledgement, then finish registration removal. Fixed native absence plus checked reload can finish an already-removed obligation. A setter reply alone can omit failed reload rows. These static findings and synthetic recovery tests do not establish live native memory refresh, removal or resume after either operation. + +The earlier review revisions were unreleased but had been run locally. Existing `state.secondary` and `state.secondaryPool` records therefore carry restore obligations that must finish rather than leave old aliases or owned fields behind. Only that recorded-ownership path is retained. The adapter does not acquire ownership of an untouched native `[secondary_model]` or model pool and does not reload it on connection. Foreign snapshots exist only around the adapter's own primary/menu RPCs; a restoration CAS conflict preserves concurrent bytes, reports fixed `kimi_config_changed` and allows other primary recovery to finish. Later ticks retain that warning instead of reporting connected. Earlier owned TOML restoration still serializes parsed TOML: comments and original numeric representations are not preserved. No model/provider definitions or credential-bearing configuration backup is added. + +### Baseline evidence and untested boundaries + +Repository validation deviation: an earlier repository-wide attempt accidentally included `packages/gcp-ai/test/browser.test.mjs`, which launched real Chrome with temporary test profiles. That attempt failed and is not acceptance evidence. A subsequent process check found no remaining Chrome process using those test profiles. The final repository command explicitly excludes real-browser and native-application-probe tests; it retains synthetic VM and fake-CDP tests. This deviation is separate from the untested native Kimi UI boundary. + +| Boundary | Evidence for this review revision | +| --- | --- | +| Package, repository, registry checks and mutation counts | Package: 384 pass, 0 fail, 2749 assertions. Repository with the documented exclusions: 7185 pass, 6 skip, 0 fail, 226769 assertions. Both registry commands exit 0. 129/129 mutants trigger assertion failures (217 test observations). | +| Registry ownership, atomic disable, registration removal and recovery state | Synthetic temporary-file/controller tests; no real native profile | +| Routing, fail-closed markers and SSE continuity | Synthetic JSON/SSE protocol tests; no real model request | +| English/Chinese settings and opt-in state | Pure VM interaction tests and static fallback assertions; no browser | +| Native plugin registry and reload behavior | Read-only Kimi 3.2.25.22 / Daimon 1.0.6 source inspection | +| macOS native UI, install, reconnect and current child turns | Not tested in this review revision | +| Windows and Linux native behavior | Not tested in this review revision | +| Full native child compaction and fork | Not tested in native Kimi; source/synthetic evidence only | +| Synchronous-exit in-memory residue and subsequent native refresh | Not tested in native Kimi; disk mutation synthetic, no-watcher boundary static | +| Resume after native plugin disable or registration removal | Not tested in native Kimi | +| Paid tools, billing, sleep/wake and natural credential rotation | Not tested in this review revision | + +### Baseline final commands and guard results + +```text +AGNES_BROWSER_TEST=0 HOME= bun test packages/kimi-desktop +bun scripts/check.mjs kimi-desktop +bun scripts/check.mjs +AGNES_BROWSER_TEST=0 HOME= bun test \ + --path-ignore-patterns=packages/agent-console/test/** \ + --path-ignore-patterns=packages/agent-console/web/** \ + --path-ignore-patterns=packages/window-warmup/tests/browser.test.ts \ + --path-ignore-patterns=packages/gcp-ai/test/browser.test.mjs \ + --path-ignore-patterns=packages/chat/epub-reader.test.mjs \ + --path-ignore-patterns=packages/nowledge-mem/page-layout.test.mjs \ + --path-ignore-patterns=packages/agent-office/cli.test.mjs \ + --path-ignore-patterns=packages/agent-office/jump.test.mjs +``` + +The final run used Bun 1.4.4 and a separate temporary checkout with the exact candidate executable/test bytes. The two default agent-console exclusions remain. Four real-browser files and two conditional native-terminal-probe files are also excluded; `AGNES_BROWSER_TEST=0` keeps the opt-in Agnes browser case off. Synthetic VM/fake-CDP tests remain included. The earlier DSH repository attempt failed; the final successful repository run used the Codex executor. No unrelated package source was changed for those environment failures. + +| Item | Status | Changes | Representative test | Mutants killed / assertion-red test observations | +| --- | --- | --- | --- | --- | +| B1 | Fixed | `adapter.mjs`, `agent-routing.mjs` | `B1 no secondary: supplier responses pass through unchanged` | 8/8 / 12 | +| B2 | Fixed | `adapter.mjs`, `agent-profiles.mjs`, `agent-routing.mjs`, `role-plugin-state.mjs`, `rpc.mjs` | `B2 an idle stop disables the owned role plugin` | 91/91 / 151 | +| B3 | Fixed | `adapter.mjs` | `B3 a busy runtime performs no getPluginInfo polling` | 7/7 / 13 | +| S1 | Fixed | `agent-routing.mjs` | `S1: an Agent fragment without index does not destroy the stream` | 9/9 / 15 | +| S2 | Fixed | `adapter.mjs` | `S2 a stable-tick external deletion is not repaired by the foreign snapshot` | 12/12 / 22 | +| S3 | Fixed | `settings.html` | `en: primary-only confirmation survives saves and refreshes without confirming a secondary` | 2/2 / 4 | + +Mutation test observations are summed per mutation, so a test repeated across mutants is counted repeatedly. Non-assertion errors are excluded. Two equivalent next-text checks were removed instead of counting a compound mutation. + +## Historical signed-history role-profile candidate `cc9a023`, 2026-11-07 + +Everything in this section describes the previous `cc9a023` revision, before the current review fixes. Its native results and test totals do not accept the current implementation. + +That signed-history candidate was installed into official Magpie **0.1.3208** and exercised in ordinary signed-in Kimi **3.2.25, build 3.2.25.22 / Daimon 1.0.6**, on macOS with Bun **1.4.4**. All **19 installed files** matched its tarball: 16 runtime/UI files plus documentation and package metadata. The companion loaded ordinary worker profiles and the embedded page confirmed application. That candidate still used inherit-primary behavior when secondary was empty, so those observations do not prove the new opt-out contract. + +In the first native Work turn, an ordinary Agent omitted `model` and used the Astra-bound profile; another explicitly selected primary and used the Sol-bound profile. Both executed native Read with the fixture marker in their tool results. Eight requests completed with HTTP 200: six Sol and two Astra, with requested and served targets matching after normalizing the provider prefix. Native Agent arguments contained no shim `model`; authenticated call IDs preserved omitted-default and explicit-primary intentions and the corresponding native result links. + +After that page cleared secondary to inherit-primary, resuming the existing Astra Agent with an explicit primary selector retained its prior profile and target and executed Read again, producing two Astra requests. A new ordinary Agent omitted the selector, used the Sol profile and completed Read with two Sol requests. The second turn had seven requests: five Sol and two Astra, all HTTP 200. Across both historical turns there were **15 requests, three new subagents, one resume and four successful Reads**; request totals include parent and continuation calls. + +The same historical page saved primary Astra, confirmed application, then restored Sol and confirmed again. Host applied-model state, stash reference and connection agreed; the credential comparison was performed only in memory. With Work idle, the native Magpie Agent card disconnected Kimi: JSON and converted runtime TOML returned to the official service, recovery ownership cleared, and four native model entries and the `k3-agent` selection remained. Reconnecting Sol returned both addresses to the bridge with secondary empty and no pool. These operations sent no additional inference and required no application restart. Native model entries and unrelated settings matched before-run comparisons. The credential aggregate changed during that acceptance interval; its cause was not isolated, so unchanged credential bytes were not claimed. No credential values or full configuration copies were retained. + +That source passed **234 package tests, 0 failures and 1,975 assertions across 18 files**. Its repository run passed **6,714 tests with 8 skips, 0 failures and 224,592 assertions across 485 files**, excluding the two default-excluded agent-console directories and the window-warmup browser test. Its registry check exited 0 with a cached GoAnyAPI record when remote resolution was unavailable. Four temporary-source mutations failed assertions: skipping primary restoration RPCs on stop, removing typed Agent-alias history migration, disabling staged JSON/TOML byte comparisons, and removing authenticated selection-history restoration. These are historical counts, not current review results. + +The previous pool-based candidate's native runtime events at **2026-11-07 09:58:39Z** showed desktop model definitions falling from **58 to 4** and a converted home pool falling from **2 entries to 0** before queue dispatch. That observation motivated replacing the pool, but does not validate the current review changes. Full native child compaction was not forced; the public `conversations.compact` targets the main agent. Fork had isolated coverage only, and Windows/Linux and paid tools had no new native acceptance in that revision. + +## Historical legacy primary and secondary native Agent turn, 2026-11-07 + +The following dated records describe earlier revisions. Their implementation details and native results are retained as evidence; they are not the current role-profile support contract. + +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 inheritance revision 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. That revision passed **77 tests with 704 assertions**. The signed-in UI check above used the earlier adapter revision. + +### Revised model page and recovery tests + +The first review revision, `b5f46210`, passed **113 tests with 795 assertions across 10 files** under a temporary HOME on Bun 1.4.4. The primary picker uses Magpie's official CLI, with a private child-process flag preventing another Kimi adapter from starting. Its command arguments, inherited environment and profile directory are covered with a fake executable. Primary-only saves retain the secondary preference; partial saves are reported without rolling the primary back. + +Isolated regressions reproduce and fix a failed targeted session reload blocking disconnect restoration, and missing or unsupported secondary TOML blocking the primary connection. The latter tests send an actual HTTP request through the mock bridge and confirm the primary route remains usable. Shutdown closes the settings service without another secondary RPC; a changed service port is rebound when options reload. + +The page has 22 pure VM interaction tests covering host-language forwarding, English and Chinese copy, neutral radio lists, primary and secondary saves, partial failure, applied-state confirmation, and clearing a secondary preference while disconnected. These tests do not use a browser engine. Real loopback WebSocket tests exercise both reload names through the production RPC allow-list. Removing either allow-list entry, or disabling the native byte-comparison guard in temporary source copies, makes the added guard tests fail. + +### Native primary-model page save, 2026-11-07 + +The first review revision was installed into official Magpie 0.1.3077; all 15 installed package files matched its checked tarball. In Magpie's native window, the Chinese model page selected and saved primary `codex/gpt-6-astra`, then restored `codex/gpt-6.1-sol`. At both stages the host's applied-model record, stash reference and connection model agreed, and the stash credential matched the current connection in memory. The page distinguished saving from confirmed application; refreshing confirmed the applied state. The normal Agents → Kimi card also showed the restored Sol selection. The secondary preference remained inherit-primary. + +Kimi remained **3.2.17 / build 3.2.17.4**, with Daimon **0.6.7**, running and idle throughout; it was not exited or restarted. No Work conversation or inference was submitted for this check, and no Chrome or Playwright was started. All four native model entries and unrelated Kimi settings matched their before-install comparisons. The credential aggregate changed during the installation/UI-save interval; its cause was not isolated, and no credential values or full configuration copies were retained. This check verifies the real page, official CLI and host synchronization; the failed-reload and unsupported-TOML cases remain isolated-test coverage. + +### Subsequent review guards + +The subsequent revision passed **128 tests with 886 assertions across 10 files** under a temporary HOME on Bun 1.4.4, including 24 pure VM page tests. The helper now validates the basename of both automatically discovered paths and resolved targets. A shell parent, a non-Magpie PATH result and `magpie-bun` are rejected without executing a model command. Explicit `magpiePath` remains available for custom executable names. Windows parent discovery uses PowerShell CIM with a numeric process ID, argument-array invocation and a bounded timeout; failures fall back to PATH or the configured option. Fake-exec tests cover this lookup and fallback, not a real Windows installation. + +HTTP regressions reject a valid-looking primary ID absent from the gateway catalog without calling the host setter, and detect a gateway-key change during primary saving in both primary-only and combined saves. A disconnect test first lets an external writer restore the baseline and a normal tick clear the application marker; disconnect must still request the final baseline batch reload. Removing each corresponding guard in a temporary module copy makes its behavioral test fail. The discovered-executable basename guard was also checked this way. The V2-layout checks are described below. + +These changes to the helper, adapter and page follow the native check above. This revision's isolated tests and package checks do not extend that earlier live result to a different Kimi runtime. No real Windows/Linux test, application restart, browser or model request was used for this review round. + +### Historical runtime TOML representation + +The patch targets the two secondary-model scalars, but writes serialize the whole parsed document with `Bun.TOML.stringify`. Comments are dropped and numeric representations can change: `1.0` is emitted as `1`, changing a TOML float to an integer. Integers above 2^53 are rejected by `Bun.TOML.parse` and report `secondary_config_unsupported`. These cases were confirmed with synthetic input on Bun 1.4.4. Daimon regenerates this runtime file; the adapter does not preserve its comments or original numeric representations. + +### Historical Kimi 3.2.25 / Daimon 1.0.6 static boundary + +A separate read-only inspection of a staged Kimi **3.2.25.22 / Daimon 1.0.6** bundle found both reload RPC names and the runner/control layout still present. Its V2 core, however, uses a model pool for the standard `Agent` tool rather than the old built-in `primary|secondary` roles. It retains legacy `secondary_model.model` parsing but no longer uses the `secondary-model` experimental key. Daimon also converts the legacy TOML into `runtime/kimi-code/home/config.toml`, which V2 actually reads. The staged application was not started during that inspection. + +Revision `536617a` detected the converted file and reported `secondary_model_unsupported_runtime` rather than applying an ineffective legacy patch. Its isolated tests covered initial V2 detection and conversion after a legacy secondary was applied. That guard revision did not claim V2 support. + +### Superseded V2 model-pool implementation + +This section describes the removed pool-based candidate. Its writes, alias registration, retry/reassertion and empty-session confirmation policy are historical; the current role-profile path above does not use them. + +The V2 path uses the selected models' existing catalog aliases as keys in `secondary_model.models`, with descriptions identifying the primary and secondary roles. New aliases are neutral `local-model-` names. The old branded prefix remains recognized for routing, migration and cleanup. `secondary_model.default_model` selects the secondary alias for new Agent subtasks. Global model/provider definitions are not copied into ownership state. If both selections name the same model, the pool has one entry and Kimi hides the redundant model-choice parameter. Clearing the secondary preference after use fixes new subtasks to the current primary; this explicit pool follows Kimi's pool thinking defaults. Resume and fork retain their native model-binding rules. + +The ordinary Agent tool does not translate literal `primary` or `secondary` strings into these pool entries. Its generated tool description lists the actual aliases and their role descriptions. A prompt can request a role, and the agent chooses its corresponding entry. Unsupported converted layouts remain guarded instead of being written as though they had this contract. + +A pure-function probe extracted the installed 3.2.25.22 core's real TOML parser/stringifier, secondary schema, pool resolver/validator, Agent schema/getters, tool registry and request projection. All **24 checks passed**, including the omitted-model secondary default, explicit primary selection, one-model pool behavior and the existing hash alias reaching the wire model. External catalog dependencies used synthetic stubs. The installed bundle SHA-256 was `ea1c5125c7af0825c3e5105f2753f8d1f2bccfa1bf1360e1f72c525d3a3f4f09`. The probe did not import the whole bundle, launch an application or issue an inference. + +The superseded pool candidate passed **211 tests with 1,636 assertions across 16 files** under a temporary HOME on Bun 1.4.4, including 28 pure VM page tests. V2 cases covered two-model HTTP routing, the secondary default, same-model selections, clearing and following the primary, home-only regeneration, busy sessions, empty batches, targeted retries, external edits, disconnect/stop restoration, restart and legacy-to-V2 transitions. Settings reads invalidated confirmation when definitions, effective capabilities/effort or daemon generation changed. Pool ownership snapshots contained only aliases, descriptions, defaults and narrow ownership metadata, including the previous written binding while a new atomic write was pending. They did not copy model/provider definitions or their authentication fields. + +Primary/menu restore retains recovery ownership and the model backup until both the menu and final runtime reload are acknowledged. This includes failed calls, lost replies after side effects, an address already restored by synchronous exit, and a first apply whose reply was lost. A subsequent disabled start can finish recovery. Explicit stop skips only extra secondary-session reloads. The real loopback WebSocket tests cover these failures; an independent frozen-source matrix passed **52 checks** across primary failures (8), stop/restart failures (16), apply reply loss (2), the production entry's registered exit callback (8), and external-pool resolution through the extracted real core (18). These are isolated checks, not a claim that the actual desktop process was quit and reopened. + +Real permission-denied writes reproduce secondary-selection and baseline-restoration failures. The previous written binding remains owned until a new write is confirmed, and baseline reload intent is set before restoring files. Retries, stop, synchronous exit and crash recovery can therefore finish the operation. Independent pool edits retain valid native entries; bindings that depend on plugin aliases being removed are cleaned before restoration, avoiding unresolved model references. An explicit new selection supersedes an intermediate external edit and later restores the original baseline. Unowned incompatible bindings are reported instead of silently changed. + +Runtime dispatch uses `kernel.agentKernel`, not application version strings or optional LoopControl fields. Known legacy kernels ignore residual converted files. Known V2 kernels wait for their home file; unknown or ambiguous families cannot report a secondary as applied. Equivalent native `provider`/`provider_id` and `model`/`name` forms and effective model overrides are supported. The model context and provider/wire binding must resolve, and effective effort must be compatible. Missing definitions or another provider are rejected before any pool write or reload. Deleting the alias or effort guards makes the corresponding regressions fail. + +The checked-in synthetic Agent schema/description and history fixture was generated through the installed core's actual Agent getter, message lowering and request projection. Real bridge tests send it with both old and neutral aliases and assert no added branding reaches upstream. Normalization is confined to Agent model identities: user messages, literal filenames, other tools, results, arbitrary keys and the rest of an Agent prompt remain unchanged. Migration retains the selected old definition, acknowledges the neutral selection, then prunes old entries. Its independent **12-case** failure matrix covers each step before/after side effects and across plugin restart. Removing historical-alias normalization, synchronous pool restoration, restored-baseline reapplication or post-staging byte comparisons makes targeted tests fail in temporary source copies. + +Daimon can regenerate the converted file even when the legacy TOML is unchanged. Reconciliation therefore checks the converted file after the primary/catalog control calls. Writes wait for idle and use the native byte-comparison guard. V2 confirmation requires an actual cached-session reload acknowledgement; an empty batch remains `secondary_model_waiting_session`. New sessions reuse the core's initialization promise, so neither creating a session nor waiting for the 150 ms file watcher proves the configuration was read. After a confirmed reload, Kimi's dynamic Agent getters and per-request tool reconciliation expose the changed pool. + +The installed Daimon generator uses direct `fs/promises.writeFile` for both TOML files; its JSON writer uses temp+rename. The adapter compares expected bytes after staging, immediately before publishing, and rejects a detected competing edit. This is best-effort comparison, not an atomic cross-process compare-and-swap: an uncoordinated Daimon write can still race the final rename, and a well-formed partial read can be indistinguishable from an intentional document. No full credential-bearing file is backed up. Serial idle-time updates remain the tested operating boundary. + +### Historical macOS V2 native UI acceptance, 2026-11-07 + +The following Work turns preceded the neutral-alias, recovery/contract and role-profile changes above. They establish the earlier pool-based V2 route behavior; they are not acceptance of the current candidate. + +The candidate was installed into official Magpie **0.1.3208**, with ordinary signed-in Kimi **3.2.25 / build 3.2.25.22**, Daimon **1.0.6** and Bun **1.4.4**. All **16 installed package files** matched its checked tarball. No Kimi or Magpie application resources were patched, and neither application was restarted for this acceptance. + +The native embedded Chinese page saved secondary `codex/gpt-6-astra`, changed primary **Sol → Astra → Sol**, and retained the secondary preference. The host's applied model, stash reference and connection agreed at each primary selection. The converted pool correctly had one entry when both choices were Astra, then two entries after the primary returned to `codex/gpt-6.1-sol`. With no loaded kernel session, the page remained waiting instead of reporting application. Merely opening a conversation did not load that session; the waiting hint was subsequently clarified to request a short Work message and an idle session. That was a wording-only change verified by the page's VM tests; adapter logic stayed unchanged. + +A native UI-submitted Work turn created two new ordinary Agent subtasks in the task-owned test project. The prompt asked the first to omit `model` to exercise the configured secondary default, and the second to select the primary role from the tool's model descriptions. Both completed with zero tools and their distinct expected markers, which the parent reproduced. The expanded native Agent panels showed each result. Routing metadata recorded **six requests: five Sol and one Astra**, all HTTP 200. Magpie 0.1.3208 records provider-qualified requested IDs; the served model matched each exact requested provider/model target after accounting for that prefix. The six requests include parent and auxiliary/continuation calls, not six subagent creations. After the runtime became idle, the adapter obtained confirmation and the refreshed page showed both selections applied. + +Clearing the secondary preference on the same page produced a single pool entry for the current Sol primary. A second native UI turn created one new default Agent, which returned the expected inherit-primary marker with zero tools. All **three routed requests** in that turn used Sol and returned HTTP 200 with matching served targets. + +With Work idle, an authorized temporary disconnect through the official CLI restored the original service in both desktop JSON and converted TOML, removed the owned pool/default, and cleared ownership. All four native model entries and the original `k3-agent` selection remained. Reconnecting through the same CLI to Sol returned both service addresses to the active bridge; the secondary preference stayed empty and no pool ownership was recreated. No additional inference was sent for disconnect/reconnect. Native model entries and unrelated desktop settings matched their pre-install comparisons throughout. Credential aggregates changed during the primary-selection interval; the cause was not isolated, and no credential values or full configuration copies were retained. The adapter's explicit field writes and narrow snapshots were checked; native token refresh was not instrumented or ruled out, so the aggregate comparison cannot attribute the change. + +This round did not launch a test browser, Chrome or Playwright. It did not verify Windows/Linux V2 operation, paid search/fetch, allowance accounting, sleep/wake or natural credential renewal. + +## 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..0181308ca 100644 --- a/packages/kimi-desktop/README.md +++ b/packages/kimi-desktop/README.md @@ -6,10 +6,84 @@ Use the ordinary **Kimi desktop application's local Work agent** with a model se magpie plugin add @magpie-community/agent-kimi-desktop ``` -Open and sign in to Kimi desktop. On Magpie's Agents page select **Kimi**, pick a model, and connect. The adapter waits for the local Work runtime to be ready and idle, then applies the connection without restarting either application. Use Kimi's **Work** page. Chat and cloud Claw are outside its scope. The standard Kimi model menu continues to use the model selected in Magpie; this package does not modify Kimi's application resources or install a custom model-menu patch. +Open and sign in to Kimi desktop. On Magpie's Agents page select **Kimi**, pick a model, and connect. The adapter waits for the local Work runtime to be ready and idle, then applies the connection without restarting either application. Use Kimi's **Work** page. Chat and cloud Claw are outside its scope. Kimi's native model menu remains visible; its selections do not choose the external inference model or reasoning effort. Configure those in Magpie. This package does not modify Kimi's application resources or install a custom model-menu patch. 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 + +Open the plugin's embedded **Kimi Work models / Kimi Work 模型** page to choose the **primary model**. Selecting a **secondary model** opts in to role routing for new Agent tasks. An empty or absent secondary preference keeps native subagent behavior: the adapter does not install or enable the companion plugin or reload sessions for role routing, and native Agent schemas, history auditing and response calls pass through unchanged. Normal neutral alias cleanup still applies. The page shows saved preferences separately from their application status. Apply changes while Kimi is idle. Magpie's **Agents → Kimi** picker uses the same primary selection. + +Only while a secondary model is selected and its profiles are ready, the bridge adds an optional `model` selector to the `Agent` tool declaration sent to the model: + +- `primary`: create an ordinary Work worker profile bound to the configured primary model. +- `secondary`: create an ordinary Work worker profile bound to the configured secondary model. This is the default only when both `model` and `subagent_type` are omitted. +- `native`: keep the requested native `subagent_type` and its specialized behavior. + +For example: + +```text +请新建一个子代理处理资料整理,使用已配置的副模型,由主代理综合结果。 +``` + +```text +请让这个新建子代理使用已配置的主模型。 +``` + +The actual tool argument selects the role; a prompt alone does not bind an arbitrary model ID. For new `primary` or `secondary` tasks, the bridge removes its `model` argument and replaces `subagent_type` with the selected ordinary worker profile, preserving the prompt, paths and other arguments. An explicit native `subagent_type` without `model` retains that profile and its restrictions. `native` removes only the selector and preserves the original type. Resume or fork retains an existing authenticated profile and model binding rather than selecting a new target. + +Authenticated opaque call IDs retain the original selector for history auditing: the bridge restores explicit role choices only in its own verified assistant `Agent` history and leaves an omitted default omitted. The ID preserves native tool-result linkage and never selects the route; the role profile remains the model binding. + +These ordinary workers declare general native tools (`tools: ["*"]`), disable `Agent` and `AgentSwarm`, and declare no subagents. They inherit the parent's native permission mode, and user-registered tools remain gated by their availability to the parent. Kimi's native permission guards still apply. The generated workers do not copy the specialized restrictions of `coder`, `explore` or `plan`; request a native type without `model`, or use `model="native"`, to keep its native profile. + +The role prompt contains an opaque random token authenticated by a local MAC. The token-to-model mapping and signing secret stay in `adapter-state.json`; generated profile text and role instructions contain neither the application brand, target model ID nor a reversible encoding of it. Binding state must be successfully persisted before profiles are published or plugin-control calls are sent. Profile names use a key-specific namespace, so rebuilding a key does not reuse another key's agent names. Only an authenticated section in system/developer text selects a binding and is removed before forwarding. Invalid, malformed or unknown identity there still fails with HTTP 409 `agent_profile_unbound`. With a saved routing key, quoted markers in user/assistant text, tool arguments/results and other fields are neutralized by removing the opening bracket from the reserved opening/closing tag prefixes; they never select a model or poison later requests. Shared instructions are refreshed and neutralized before profile authentication, so an `AGENTS.md` quotation cannot become a binding. A connection without a routing key performs no marker checks or neutralization; quoted content and the existing neutral alias cleanup retain their earlier behavior. Model routing and top-level thinking controls follow the plugin settings described below. The protocol's `model` field still names the selected model; user-authored brand or model text is not otherwise redacted. Every inference checks a bound target against the current gateway catalog; a temporarily missing or disabled target is refused without falling back to the primary. + +Previously authenticated workers can retain their original allowed binding across model-preference changes while the native runtime has their profile. Clearing secondary schedules registration removal; a busy turn keeps its current confirmed schema until idle cleanup completes, then new role-tool translation is off. This does not promise that Kimi can resume a profile after its plugin has been disabled or its registration removed. Full native child compaction, fork and those resume boundaries have not been tested in this review revision. + +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. Saving a changed primary runs the current host's official `magpie kimi-desktop model magpie/` command, preserving the host's selected model and disconnect bookkeeping. The command inherits the host environment; a plugin-private flag prevents that command's extra plugin host from starting another Kimi adapter. Automatic discovery checks the parent executable (`ps` on macOS, `/proc` on Linux, PowerShell CIM on Windows), then PATH. Discovered paths and their resolved targets must have the case-insensitive basename `magpie` (`magpie.exe` is also accepted on Windows); a shell or `magpie-bun` is not used. If discovery fails, including on Windows, set plugin option `magpiePath` to the current Magpie executable. An explicitly configured path permits a custom executable name. Connect Kimi in Magpie before using the model page. + +When both selections change, the primary is saved first, then the secondary preference is merged into the fresh connection. A secondary save failure after a successful primary change is reported as a partial save; refresh the page to check the applied state and retry. 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. + +If the configured settings port is temporarily unavailable, the existing adapter tick retries it without restarting the primary bridge or changing the selected models. Stable ticks keep the same settings service and page session. The launcher tries at 0, 3 and 6 seconds; if navigation still has not opened the service after 9 seconds, it shows a localized error and an in-page Retry button. Stopping the adapter waits for pending settings initialization and closes its listener. + +When opted in, the package generates a companion plugin containing only `kimi.plugin.json` and agent Markdown files under `~/.config/magpie-kimi/native-model-roles`. Its neutral ID is `local-work-model-roles-`. Kimi's existing local `installPlugin` / `updatePlugin` API receives `{source}`, copies it to `/plugins/managed/` and registers it. The adapter enables it through `setPluginEnabled` and checks the cached, idle-session reload acknowledgements. Stable `getPluginInfo` probes are at least 30 seconds apart and skipped while busy. A transient or ambiguous failure keeps the last confirmed role schema; an idle confirmation of a missing or disabled plugin changes readiness. The manifest stays at `1.0.0`, including local same-version updates. + +Previously bound models have a retirement grace period: their bindings are deleted only after **three consecutive successful catalog synchronizations** report them absent **and at least ten minutes** have elapsed since the first absence. Failed fetches and cached ticks do not count. Absence counts and their start times are persisted with bindings in `adapter-state.json`, including across restarts. Actual retirement waits for an idle role-profile synchronization with verifiable registry provenance and unchanged owned source/managed snapshots, so token revocation and owned-profile cleanup stay consistent. A detected conflict defers deletion until it is resolved. A cleared secondary keeps its inactive cached bindings until the next valid opt-in; successful observations still update the absence record, without reinstalling the companion. A returning model clears its absence record and can use its original token again before retirement. During absence, requests for that binding still receive 409. Generated profile pruning uses the resulting retained binding set and requires matching ownership evidence; it does not grow indefinitely with every past model selection. Dot-prefixed entries in either source or managed `agents` directory are ignored, are not hashed or pruned, and do not cause conflicts. Other unknown files and edited generated files are preserved and report `agent_profile_conflict`. Registry ownership requires local-source, managed-root and exact declarative-manifest checks. Equivalent directory paths, including a parent symlink or junction, are compared by canonical real paths for existing directories rather than by an installation-drive assumption; the existing checks against a symlinked registry file or replaced managed root remain. Before enabling roles, another key's role-plugin ID or a foreign prefix-matching registration reports `agent_profile_conflict` without plugin RPCs or registry changes; it cannot silently coexist. The adapter leaves native profiles, application resources, Magpie core and SQLite untouched. + +Normal cleanup for clearing secondary, disconnecting, or calling `stop()` verifies the saved companion's ownership, disables it with `setPluginEnabled(false)`, waits for explicit session-reload acknowledgement, rechecks provenance, then calls `removePlugin` to remove its installed registration. Busy sessions, missing or failed acknowledgements and incomplete removal leave a persisted recovery obligation for the next idle tick or start. A lost removal reply is retried; a fixed native-not-found reply still requires reload confirmation before recovery completes. Reconnecting with secondary selected installs the source again when the registration is missing, even if a byte-identical managed cache remains. + +Registration removal retains the managed cache, generated source profiles and adapter binding state. The inspected native removal chain removes the installed record and reloads contributions; it does not delete all generated files or uninstall the Magpie package. Catalog pruning remains the separate ownership-checked policy above. A missing disk record alone does not prove native removal: recovery checks native presence and saved source/cache ownership. Ambiguous or malformed replies keep recovery pending. + +The synchronous exit handler cannot send RPC or remove the registration. It writes only the saved, verified companion registration's `enabled` field to `false` in `/plugins/installed.json`, using original-byte comparison and a private temporary file followed by rename. Unrelated entries and fields are preserved; conflicts are left untouched. Native-source inspection found no registry watcher, so disk `false` neither removes the registration nor immediately removes profiles already loaded in Kimi's memory. The plugin identity and pending phase remain in adapter state even when the helper fails or disk is already false. At the next idle recovery, a present plugin still receives `setPluginEnabled(false)`, followed by checked session reload and registration removal. Only a fixed native-not-found reply plus reload confirmation can finish an already-removed obligation. This follow-up has native evidence for disable and registration removal; resume after either operation remains untested. Synchronous-exit and restart observations are separately listed in the compatibility record. + +The adapter does not add native model definitions or `secondary_model` pools, and does not rewrite them in the background. An earlier Kimi 3.2.25 observation found queue dispatch rebuilding those definitions and dropping an added pool. The earlier review revisions were unreleased but had been run locally: existing `state.secondary` and `state.secondaryPool` snapshots therefore carry real restore obligations. Their ownership-recorded restoration path remains so removing the code does not strand old aliases or owned fields. A native TOML file this adapter never wrote does not acquire ownership or trigger a secondary reload. In-memory foreign snapshots protect values only around the adapter's own primary/menu RPCs. A byte-comparison conflict during restoration preserves the external edit and reports fixed `kimi_config_changed`, while allowing other primary-restoration work to finish. Later ticks retain that warning instead of replacing it with `connected`. Owned restoration does not save model/provider definitions or credentials. Legacy alias recognition and scoped historical Agent metadata cleanup support these recorded local review states. + +Secondary profiles require a recognized native V2 kernel family; support is not pinned to a Kimi application version. A legacy or unknown family keeps the primary bridge available but reports `secondary_model_unsupported_runtime` for an explicit secondary preference. This follow-up was exercised in native Kimi 3.2.25 / build 3.2.25.22 with real Read calls, three child-role routes, clearing and disconnect/reconnect. The [compatibility record](COMPATIBILITY.md) distinguishes those results from the incomplete host-offline exit/restart confirmation, records the Magpie version change during that attempt, and lists source/protocol evidence and remaining native boundaries. Supplier arguments in native history are already translated, so they are not a raw pre-translation capture. Settings failure cases still use a VM. The `cc9a023` turns and older pool/runtime results are historical records. + +## Reasoning effort and context input budgets + +On the embedded **Kimi Work models** page, choose reasoning effort and an optional **context input budget (counted/estimated)** separately for the primary and secondary model, then save. Selecting the same model for both roles still permits different parameters. A blank reasoning choice uses Magpie/provider defaults; only efforts explicitly published by the model catalog are selectable. Fixed reasoning policies in Magpie's routing configuration take precedence. Unknown capabilities are not inferred from model names, and the historical 131072-token fallback is not presented as a known limit. + +Kimi's top-level `thinking`, `reasoning` and `reasoning_effort` controls are removed from inference requests, including existing connections that have not saved these new preferences. The plugin sends the selected effort, or no effort override for Magpie/provider defaults. It preserves message content, reasoning history and tool calls/results. Native menu aliases cannot override the connected primary; only authenticated worker profiles select a child target. New role-scoped profiles distinguish primary from secondary even when their target IDs are identical. Older authenticated profiles without a role keep their model binding and use provider defaults with no additional input budget; create a new child to adopt independent role parameters. Native and resumed/forked profiles retain their existing behavior and binding. + +No additional input budget is the default. A custom budget must be a positive integer no greater than the model catalog's published context limit; an unknown limit disables custom budgeting. Before generation, the bridge converts the final routed request into a count-only copy containing its system/developer text, supported text/images, reasoning text, tool schemas, calls and results. It calls Magpie's existing `POST /v1/messages/count_tokens` with the same gateway credential and permitted caller-identity headers. Magpie may return a provider count or an estimate. If that number exceeds the budget, generation is refused with `context_length_exceeded`; if counting fails or the message format cannot be represented, generation is refused with fixed `context_count_unavailable`. No generation request is sent in either case. Only a custom budget adds this counting request; it can add latency and consume the model service's request allowance. No new local port, network endpoint or native RPC method is added. + +This is an input-budget check, **not a setting for Kimi's native compaction window** or an output-token limit. It does not change `max_tokens`, invent token usage or directly trim history. Kimi may attempt native compaction after a context error, preserving the worker's authenticated system prompt; its summary requests are checked against the same role budget. Recovery is not guaranteed: fixed system/tool overhead or a large remaining message can still exceed the budget. Raise/remove the budget, shorten the input or start a new conversation if recovery fails. Native compaction can reduce earlier details available to subsequent inference, and raising the budget does not expand Kimi's native window. + +Preferences are bound to their selected model. While a model or gateway change waits for application, active requests retain the previous role rules in memory. After a confirmed switch, an older resumed model binding does not inherit another model's parameters. Saving a partial patch merges it into the latest connection document; unavailable values remain visible so they can be explicitly replaced or reset. Kimi login, other native plugins, paid-data authorizations, billing and service paths are unchanged. Financial database results remain ordinary tool input to the selected model; the plugin does not call their paid query APIs for counting. + +### 中文说明 + +在内嵌的 **Kimi Work 模型**页面中,分别选择主、副模型的思考强度与可选的**上下文输入预算(计数/估算)**,然后保存。主、副使用同一模型也能设置不同参数。默认思考选项交给 Magpie/供应方决定;只有模型目录明确公布的强度可以选择,Magpie 路由中固定的思考强度优先。插件不按模型名称猜测能力,也不会把旧的 131072-token 回退值当作已知上限显示。 + +推理请求中的 Kimi 顶层 `thinking`、`reasoning` 和 `reasoning_effort` 会被移除,尚未保存新偏好的已有连接也适用。插件发送选定强度,默认时不指定强度,让 Magpie/供应方决定。消息正文、推理历史、工具调用与结果保持完整。Kimi 菜单别名不能覆盖已接入的主模型,子任务只能由认证的 worker profile 选择目标。新的角色 profile 能区分指向同一模型的主、副角色。旧的无角色认证 profile 保留原模型绑定,使用供应方默认值且不增加输入预算;新建子代理才能采用独立角色参数。原生类型及恢复/分叉任务保留原行为和绑定。 + +默认不额外限制输入。自定义预算必须是正整数,且不能超过模型目录公布的上下文上限;上限未知时不能自定义。生成回答前,桥接将完成路由后的请求转换为仅供计数的副本,包含 system/developer 文本、支持的文本与图片、推理文本、工具 schema、调用及结果,并以相同网关凭据和允许的调用身份头请求 Magpie 已有的 `POST /v1/messages/count_tokens`。Magpie 可能返回供应方计数,也可能返回估算。结果超过预算时返回 `context_length_exceeded`;计数失败或无法表示消息格式时返回固定的 `context_count_unavailable`,两种情况下都不会发送生成请求。只有自定义预算会增加这次计数请求,可能增加延迟并占用模型服务的请求额度。插件不新增本地端口、网络端点或原生 RPC 方法。 + +这是输入预算检查,**不设置 Kimi 原生压缩窗口**,也不设置输出长度。插件不改 `max_tokens`、不伪造 usage,也不直接裁剪历史。Kimi 遇到上下文错误后可能尝试原生压缩,并保留 worker 的认证 system prompt;摘要请求仍按同一角色预算检查。恢复不保证成功:固定 system/工具开销或剩余的大消息仍可能超预算。失败时可调高或取消预算、缩短输入或新建会话。原生压缩可能减少后续推理能看到的早期细节,提高预算也不会扩大 Kimi 原生窗口。 + +偏好与所选模型绑定。模型或网关切换等待应用时,当前请求继续使用内存中的旧角色规则;确认切换后,恢复的旧模型绑定不会套用另一模型的参数。部分字段保存会合并最新连接文档;不可用值仍保留显示,允许明确替换或恢复默认。Kimi 登录、其他原生插件、付费数据授权、计费及服务通路保持不变。金融数据库结果仍作为普通工具输入交给选定模型,计数不会调用金融数据库的付费查询 API。 + ## 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. @@ -36,29 +110,32 @@ The adapter does not reload a busy Work runtime. If Kimi independently changes i - `~/.config/magpie-kimi/connection.json`: Magpie's selected model and connection. - `~/.config/magpie-kimi/status.json`: fixed status categories, without credentials. -- `~/.config/magpie-kimi/adapter-state.json`: original service address and connection ownership; no Kimi login credentials. -- `~/.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. +- `~/.config/magpie-kimi/adapter-state.json`: original service address, recovery ownership, local role-signing secret, opaque token-to-model bindings, companion-plugin state and pending disable/reload IDs; no Kimi login credentials. +- `~/.config/magpie-kimi/native-model-roles`: generated companion-plugin source; Kimi owns its installed managed copy. +- `~/.config/magpie-kimi/before-menu-models.json`: an earlier adapter's original model-list/current-choice snapshot, retained until acknowledged menu/runtime restoration. 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`. +Earlier primary-bridge revisions were verified with Windows 11, Kimi 3.2.17.10 and official Magpie 0.1.1275, including an actual Work request through Magpie. The historical [macOS record](COMPATIBILITY.md) covers 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). These older results do not validate the current role-profile candidate. The required 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. -Thinking models with a complete, recognized effort list that omits `none` also declare `always_thinking` in both the runtime and menu model configurations. Kimi can then resolve an old Off selection using that model's thinking defaults. A list containing `none` remains switchable; missing or unrecognized effort metadata does not imply mandatory thinking. The adapter does not rewrite the inference request's thinking fields. See the [3.2.18 compatibility check](COMPATIBILITY.md#kimi-3218-isolated-compatibility-check). - -The 3.2.18 check includes real model requests, four background-control turns, and three signed-in UI submissions in a separate official desktop profile. Both real models completed native read-tool loops, and the final UI turn recalled the earlier history without a tool call. The signed-in UI retained native model labels; this does not verify custom Magpie aliases in the online menu. The full record distinguishes these checks from the synthetic core tests. +Inference thinking fields now follow the plugin-page policy above. The historical [3.2.18 compatibility check](COMPATIBILITY.md#kimi-3218-isolated-compatibility-check) covers the earlier runtime/menu registration path's `always_thinking` handling, real model requests, background-control turns and signed-in UI submissions in a separate official desktop profile. It does not establish thinking behavior or native acceptance for the current role-profile implementation. ## Shutdown and manual recovery -The shared plugin host owns process shutdown. This plugin installs no SIGTERM/SIGINT handler and never calls `process.exit`. On the host's normal exit event it synchronously restores the original address if the current address is still its own; restart Kimi to reload its runtime. An explicit disconnect while both apps are running additionally restores model selection/list through the control API. +The shared plugin host owns process shutdown. This plugin installs no SIGTERM/SIGINT handler and never calls `process.exit`. On the host's normal exit event it synchronously restores the original address if still owned and attempts the owned plugin-registry disable described above. This leaves a pending native memory/session refresh; a disk `false` alone is not that acknowledgement. An explicit disconnect while both apps are running also restores model selection/list through the control API. + +The adapter's explicit `stop()` closes its settings service and uses normal restoration for owned primary/menu state, earlier owned secondary fields and companion-plugin disable/reload acknowledgement followed by registration removal. Busy or unacknowledged recovery remains pending. Changing `settingsPort` recreates the page service when plugin options are loaded again. + +If a primary/menu control call fails or loses its reply, recovery ownership and the model backup remain until the menu and final runtime reload are acknowledged. A subsequent start can finish restoration even when the address is already official and the connection is disabled. A failed secondary write retains its previous owned binding so retries, stopping and crash recovery do not mistake it for another writer's configuration. Owner records are fully written to a temporary file and published with an exclusive hard link, so interruption during the write cannot publish an empty/partial `adapter-owner.json`. An existing live owner is left alone. A malformed legacy owner record reports `adapter_lock_invalid` in `status.json` and the plugin log; a file access/publication failure reports `adapter_lock_unavailable`. The plugin does not guess who owns a malformed record or automatically remove it. To recover from `adapter_lock_invalid`, close all Magpie instances first, remove only `~/.config/magpie-kimi/adapter-owner.json`, then reopen Magpie. Leave `connection.json`, the other state files and Kimi's credentials intact. @@ -66,6 +143,7 @@ A forced kill, power loss or a broken private API can leave Kimi pointing at an 1. Close Kimi normally. In `~/.config/magpie-kimi/connection.json`, set `enabled` to `false` so the adapter does not reconnect when Magpie next starts. 2. Open Kimi's `config.json` at the application-support path above. Only if `model.providers["daimon-kimi-code"].baseUrl` equals `bridgeURL` in `adapter-state.json`, change that one field to the same state's `originalBase` (normally `https://agent-gw.kimi.com/coding/v1`). Leave `credentials` and all unrelated fields untouched; do not replace the whole document from an old backup. -3. If `model.current` starts with `magpie-local-`, set it to `current` from `before-menu-models.json` if that alias still exists in `model.models`; otherwise choose an existing native `daimon-kimi-code` alias, such as `k3-agent` or `k2d8-preview`. Remove only `model.models` keys beginning with `magpie-local-` if present. Reopen Kimi; it refreshes its native model catalog. +3. If an earlier adapter left `model.current` starting with `magpie-local-` or `local-model-`, set it to `current` from `before-menu-models.json` if that alias still exists in `model.models`; otherwise choose an existing native `daimon-kimi-code` alias, such as `k3-agent` or `k2d8-preview`. Remove only those adapter alias keys from `model.models` if present. Reopen Kimi; it refreshes its native model catalog. +4. If a forced termination left a `local-work-model-roles-*` companion enabled, use Kimi's native plugin management to disable or remove that entry before continuing without the bridge. Registration removal can leave generated files on disk; resume after disable or removal is not established by this review. Older experimental packages created full `backups/before-connect-*.json` copies, which can contain Kimi credentials. This package creates no such files and does not silently delete existing user backups. After recovery, users can remove those older copies locally. Do not upload them in an issue or PR. diff --git a/packages/kimi-desktop/adapter.mjs b/packages/kimi-desktop/adapter.mjs index cb7781583..574ca5233 100644 --- a/packages/kimi-desktop/adapter.mjs +++ b/packages/kimi-desktop/adapter.mjs @@ -5,8 +5,16 @@ import http from 'node:http'; import crypto from 'node:crypto'; import { once } from 'node:events'; import { rpc } from './rpc.mjs'; -import { normalizeModels, mergeRuntimeModels, isMenuAlias } from './catalog.mjs'; +import { normalizeModels, isMenuAlias, neutralPromptAliases } from './catalog.mjs'; import { prepareLibrary, applyInstructions } from './library.mjs'; +import { inspectSecondary, patchSecondary } from './secondary-config.mjs'; +import { inspectModelPool, patchModelPool } from './model-pool-config.mjs'; +import { AgentModelRouter, neutralizeRouteMarkers } from './agent-routing.mjs'; +import { ensureAgentProfiles } from './agent-profiles.mjs'; +import { kernelFamily, runnerGeneration } from './runtime-family.mjs'; +import { inspectRolePlugins, disableRolePluginsSync } from './role-plugin-state.mjs'; +import { validateModelOptions, applyReasoningOptions } from './model-options.mjs'; +import { countContextTokens } from './context-budget.mjs'; const PROVIDER = 'daimon-kimi-code'; const TOOL_ORIGIN = 'https://agent-gw.kimi.com'; @@ -18,11 +26,122 @@ 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; -function atomicJSON(file, value) { +// 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; +// A stable opt-in connection rereads the public plugin state at most this +// often; a busy runtime defers the check entirely. +const AGENT_PLUGIN_CONFIRM_MS = 30000; +const MODEL_ID = /^[^\x00-\x1f/]+\/[^\x00-\x1f]+$/; +const RESERVED_ROUTE = /\[\/?agent-profile-route\b/i; +// Primary/menu control calls are the only boundary that can regenerate the +// native TOML from a fixed key set. +const PRIMARY_REWRITE_METHODS = new Set(['models.current', 'conversations.setModel', + 'conversations.updateKimiModelList']); +const SECONDARY_CODES = new Set(['secondary_config_unsupported', 'secondary_model_unavailable', + 'secondary_model_reload_failed', 'secondary_model_unsupported_runtime', 'kimi_config_changed']); + +const validModelID = value => typeof value === 'string' && value.length <= 1024 && MODEL_ID.test(value); +const record = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const OPTION_ROLES = ['primary', 'secondary']; +const optionModel = (desired, role) => role === 'primary' ? desired.model : desired.secondaryModel; +const boundOptions = (desired, role, model = optionModel(desired, role)) => { + const saved = desired.modelOptions?.[role]; + return model && record(saved) && saved.model === model ? { reasoningEffort: saved.reasoningEffort, + contextTokens: saved.contextTokens } : null; +}; +const optionSnapshot = (desired, role) => { + const model = optionModel(desired, role); + return model ? { model, ...(boundOptions(desired, role) || { reasoningEffort: '', contextTokens: null }) } : null; +}; +const modelOptionsSnapshot = desired => Object.fromEntries(OPTION_ROLES.flatMap(role => { + const options = optionSnapshot(desired, role); + return options ? [[role, options]] : []; +})); +const sameOptionConnection = (active, desired) => { + if (active.model !== desired.model || active.gatewayKey !== desired.gateway?.key) return false; + try { return active.gatewayURL === gatewayURL(desired.gateway?.url); } + catch { return false; } +}; +const sameModels = (left, right) => left === null || right === null ? left === right + : Object.keys(left).length === Object.keys(right).length + && Object.entries(left).every(([key, value]) => Object.hasOwn(right, key) && right[key] === value); +const samePool = (left, right) => Boolean(left && right) && sameModels(left.models, right.models) + && left.defaultModel === right.defaultModel + && left.hasSecondaryTable === right.hasSecondaryTable && left.hasModelsTable === right.hasModelsTable; + +function rolePluginFiles(directory) { + const names = ['kimi.plugin.json', ...fs.readdirSync(path.join(directory, 'agents')).filter(name => !name.startsWith('.')).sort().map(name => { + if (!/^local-agent-(?:[a-f0-9]{12}-)?[a-f0-9]{24}\.md$/.test(name)) throw Error('agent_profile_conflict'); + return 'agents/' + name; + })]; + return Object.fromEntries(names.map(name => { + const file = path.join(directory, name); + if (!fs.lstatSync(file).isFile()) throw Error('agent_profile_conflict'); + return [name, sha(fs.readFileSync(file))]; + })); +} +const sameFiles = (left, right) => record(left) && record(right) + && Object.keys(left).length === Object.keys(right).length + && Object.entries(left).every(([key, value]) => right[key] === value); + +function plainPoolSnapshot(value) { + if (!record(value) || (value.models !== null && (!record(value.models) + || Object.values(value.models).some(description => typeof description !== 'string'))) + || (value.defaultModel !== null && typeof value.defaultModel !== 'string') + || typeof value.hasSecondaryTable !== 'boolean' || typeof value.hasModelsTable !== 'boolean') return null; + return { models: value.models === null ? null : { ...value.models }, defaultModel: value.defaultModel, + hasSecondaryTable: value.hasSecondaryTable, hasModelsTable: value.hasModelsTable }; +} + +// 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, expectedBytes) { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); const temp = file + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp'; try { fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); + if (expectedBytes && !fs.readFileSync(file).equals(expectedBytes)) throw Error('kimi_config_changed'); fs.renameSync(temp, file); } finally { try { fs.unlinkSync(temp); } catch { /* Renamed. */ } } } @@ -74,10 +193,12 @@ export function defaultKimiDirectory(home = os.homedir(), { platform = process.p export class KimiAdapter { constructor({ home = os.homedir(), stateDir, kimiDir, intervalMs = 1000, stabilityMs = 2000, - rpcCall = rpc, fetchImpl = globalThis.fetch, log = () => {} } = {}) { + rpcCall = rpc, fetchImpl = globalThis.fetch, setPrimaryModel = null, log = () => {} } = {}) { 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.convertedNativeConfigFile = path.join(this.kimiDir, 'runtime', 'kimi-code', 'home', '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'); @@ -87,14 +208,40 @@ export class KimiAdapter { this.modelBackupFile = path.join(this.stateDir, 'before-menu-models.json'); this.instructionsFile = path.join(this.stateDir, 'AGENTS.md'); this.catalog = []; - Object.assign(this, { intervalMs, stabilityMs, rpcCall, fetchImpl, log }); + Object.assign(this, { intervalMs, stabilityMs, rpcCall, fetchImpl, setPrimaryModel, log }); + // Foreign native values are snapshotted around our own primary/menu RPC + // only. Stable ticks never touch a value another writer changed. + const controlCall = this.rpcCall; + this.rpcCall = async (runner, method, params, options) => { + if (!PRIMARY_REWRITE_METHODS.has(method)) return controlCall(runner, method, params, options); + this.captureForeignNative(); + try { return await controlCall(runner, method, params, options); } + finally { + try { this.restoreForeignNative(); } + catch (error) { + if (error?.message === 'kimi_config_changed') this.nativeRewriteConflict = true; + } + } + }; this.ownerID = crypto.randomUUID(); this.status = {}; this.running = false; this.live = null; + this.rolesReady = false; + this.roleOptionsEnabled = false; 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.modelPoolExternal = null; + this.secondaryReloadRuntime = null; + this.secondaryReloadGeneration = null; + this.secondaryAfter = 0; } bootstrap() { @@ -151,6 +298,7 @@ export class KimiAdapter { } report(code, details = {}) { + if (this.nativeRewriteConflict) code = 'kimi_config_changed'; const next = { code, ...details }; if (JSON.stringify(next) === JSON.stringify(this.status)) return; this.status = next; @@ -172,6 +320,138 @@ 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', settingsGateway = ''; + if (desired.enabled && desired.gateway?.key) { + try { + settingsGateway = gatewayURL(desired.gateway.url); + catalog = await this.gatewayCatalog(settingsGateway, desired.gateway.key); + } + catch { status = 'catalog_unavailable'; } + } + // Confirmed role translation only. A pending install, conflict or opt-out + // reports unconfirmed instead of a stale bridge selection. + const appliedSecondaryModel = this.rolesReady ? this.bridgeSecondaryModel : ''; + return { + enabled: desired.enabled === true, + primaryModel: typeof desired.model === 'string' ? desired.model : '', + secondaryModel: typeof desired.secondaryModel === 'string' ? desired.secondaryModel : '', + ...(this.live && this.state?.active ? { appliedPrimaryModel: this.live.model } : {}), + ...(this.live && this.state?.active && appliedSecondaryModel ? { appliedSecondaryModel } : {}), + status, + ...Object.fromEntries(OPTION_ROLES.flatMap(role => { + const saved = boundOptions(desired, role); + const row = catalog.find(row => row.id === optionModel(desired, role)); + let options = { reasoningEffort: '', contextTokens: null }, optionStatus = 'not_configured'; + if (saved) { + options = { + reasoningEffort: ['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(saved.reasoningEffort) + ? saved.reasoningEffort : '', + contextTokens: Number.isSafeInteger(saved.contextTokens) && saved.contextTokens > 0 ? saved.contextTokens : null, + }; + try { + if (row) validateModelOptions(saved, row.options); + optionStatus = !desired.enabled || !row ? 'unavailable' + : this.live && this.state?.active && this.live.model === desired.model + && this.live.gatewayURL === settingsGateway && this.live.gatewayKey === desired.gateway?.key + && (role === 'primary' || (this.rolesReady && this.roleOptionsEnabled + && this.bridgeSecondaryModel === desired.secondaryModel)) ? 'applied' : 'pending'; + } catch { optionStatus = 'unavailable'; } + } + return [[role + 'ReasoningEffort', options.reasoningEffort], [role + 'ContextTokens', options.contextTokens], + [role + 'OptionsStatus', optionStatus]]; + })), + models: catalog.map(row => ({ id: row.id, name: row.menu.displayName, + reasoningEfforts: row.options.reasoningEfforts, contextWindow: row.options.contextWindow })), + }; + } + + async saveSettings(patch) { + const fail = code => { throw Object.assign(Error(code), { code }); }; + const hasPrimary = Object.hasOwn(patch, 'primaryModel'); + const hasSecondary = Object.hasOwn(patch, 'secondaryModel'); + const optionRoles = OPTION_ROLES.filter(role => Object.hasOwn(patch, role + 'ReasoningEffort') + || Object.hasOwn(patch, role + 'ContextTokens')); + const { primaryModel, secondaryModel } = patch; + let desired; + try { desired = readJSON(this.connectionFile); } catch { fail('connect_kimi_first'); } + const needsConnection = hasPrimary || secondaryModel || optionRoles.length > 0; + const selections = { primary: hasPrimary ? primaryModel : desired.model, + secondary: hasSecondary ? secondaryModel : desired.secondaryModel }; + const requestedOptions = {}; + let catalog = []; + const optionsFor = (document, role) => { + const model = selections[role], row = catalog.find(row => row.id === model); + if (!model || !row) fail('model_not_enabled_for_kimi'); + const previous = boundOptions(document, role, model) || { reasoningEffort: '', contextTokens: null }; + const options = validateModelOptions({ + reasoningEffort: Object.hasOwn(patch, role + 'ReasoningEffort') ? patch[role + 'ReasoningEffort'] : previous.reasoningEffort, + contextTokens: Object.hasOwn(patch, role + 'ContextTokens') ? patch[role + 'ContextTokens'] : previous.contextTokens, + }, row.options); + return { model, ...options }; + }; + if (needsConnection) { + if (!desired.enabled || !desired.model || !desired.gateway?.key) fail('connect_kimi_first'); + if ((hasPrimary && !validModelID(primaryModel)) || (secondaryModel && !validModelID(secondaryModel))) { + fail('model_not_enabled_for_kimi'); + } + try { catalog = await this.gatewayCatalog(gatewayURL(desired.gateway.url), desired.gateway.key); } + catch { fail('settings_unavailable'); } + if ((hasPrimary && !catalog.some(row => row.id === primaryModel)) + || (secondaryModel && !catalog.some(row => row.id === secondaryModel))) fail('model_not_enabled_for_kimi'); + for (const role of optionRoles) { + optionsFor(desired, role); + } + } + const sameGateway = value => value.gateway?.url === desired.gateway?.url && value.gateway?.key === desired.gateway?.key; + let fresh; + try { fresh = readJSON(this.connectionFile); } catch { fail('settings_unavailable'); } + const optionsMatch = value => optionRoles.every(role => optionModel(value, role) === optionModel(desired, role)); + if (!sameGateway(fresh) || (needsConnection && !fresh.enabled) || !optionsMatch(fresh)) fail('connection_changed'); + let primarySaved = false; + if (hasPrimary && primaryModel !== fresh.model) { + if (typeof this.setPrimaryModel !== 'function') fail('primary_model_unavailable'); + try { await this.setPrimaryModel(primaryModel); } + catch (error) { fail(error?.code === 'primary_model_unavailable' ? error.code : 'primary_model_update_failed'); } + try { fresh = readJSON(this.connectionFile); } catch { fail('primary_model_update_failed'); } + if (!fresh.enabled || fresh.model !== primaryModel) fail('connection_changed'); + primarySaved = true; + if (!sameGateway(fresh)) fail(hasSecondary || optionRoles.length ? 'settings_partially_saved' : 'connection_changed'); + } + if (hasSecondary || optionRoles.length) { + // The host owns model/gateway. Merge the requested secondary preference + // only after its primary selection, into the newest connection document. + const secondaryFailure = code => fail(primarySaved ? 'settings_partially_saved' : code); + let freshBytes; + try { + freshBytes = fs.readFileSync(this.connectionFile); + fresh = JSON.parse(freshBytes.toString('utf8').replace(/^\uFEFF/, '')); + } catch { secondaryFailure('settings_unavailable'); } + if (!sameGateway(fresh) || (needsConnection && !fresh.enabled) + || (hasPrimary && fresh.model !== primaryModel) + || optionRoles.some(role => optionModel(fresh, role) !== (role === 'primary' && hasPrimary + ? primaryModel : optionModel(desired, role)))) secondaryFailure('connection_changed'); + // Only fields present in this patch override the freshest preference. + // A concurrent editor may have changed the other option during the CLI. + try { for (const role of optionRoles) requestedOptions[role] = optionsFor(fresh, role); } + catch (error) { secondaryFailure(error.code || 'settings_unavailable'); } + if (hasSecondary) fresh.secondaryModel = secondaryModel; + if (optionRoles.length || (hasSecondary && !secondaryModel && fresh.modelOptions?.secondary)) { + fresh.modelOptions = { ...(record(fresh.modelOptions) ? fresh.modelOptions : {}), ...requestedOptions }; + if (hasSecondary && !secondaryModel) delete fresh.modelOptions.secondary; + } + try { atomicJSON(this.connectionFile, fresh, freshBytes); } + catch (error) { secondaryFailure(error?.message === 'kimi_config_changed' ? 'connection_changed' : '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)}); @@ -183,8 +463,7 @@ export class KimiAdapter { if (baseURL(doc) !== expected) throw Error('kimi_config_changed'); if (expected === replacement) return doc; doc.model.providers[PROVIDER].baseUrl = replacement; - if (!fs.readFileSync(this.configFile).equals(before)) throw Error('kimi_config_changed'); - atomicJSON(this.configFile, doc); + atomicJSON(this.configFile, doc, before); return doc; } @@ -235,17 +514,56 @@ export class KimiAdapter { catch { error(400, 'invalid_json'); return; } if (!payload || Array.isArray(payload) || typeof payload !== 'object') { error(400, 'invalid_json'); return; } const inference = req.url === '/v1/chat/completions'; + let routing, roles, router; if (inference) { - const selected = this.catalog.find(x => x.alias === payload.model); - if (!selected && isMenuAlias(payload.model)) { error(409, 'model_no_longer_available'); return; } - const model=selected?.id||active.model; let allowed; try { allowed = await this.gatewayCatalog(active.gatewayURL, active.gatewayKey); } catch { error(503, 'catalog_unavailable'); return; } - if (!allowed.some(row => row.id === model)) { error(409, 'model_not_enabled_for_kimi'); return; } - payload.model = model; - try { payload = applyInstructions(payload, this.instructionsFile); } + // Native menu/model fields do not choose external models. Only this + // connection and authenticated child-profile bindings establish them. + const primary = active.model; + const enableRoles = this.rolesReady && Boolean(this.bridgeSecondaryModel); + roles = { primaryModel: primary, secondaryModel: this.bridgeSecondaryModel || primary, + ...(this.roleOptionsEnabled ? { roleOptionsEnabled: true } : {}) }; + const allowedModels = allowed.map(row => row.id); + const hasRoutingKey = enableRoles || /^[a-f0-9]{64}$/.test(this.state?.agentRoutingSecret || ''); + // Refresh our shared message before authenticating profile prompts. + // Shared files are user content and can quote routing source code; + // neither an old shared message nor its replacement may bind a model. + try { payload = applyInstructions(payload, this.instructionsFile, hasRoutingKey ? neutralizeRouteMarkers : undefined); } catch (failure) { error(failure.message === 'invalid_messages' ? 400 : 503, failure.message); return; } + if (hasRoutingKey) { + router = this.ensureAgentRouter(); + routing = router.prepare(payload, { ...roles, allowedModels, enableRoles, includeRouteRole: true }); + payload = routing.payload; + } else { + // A connection that never opted in keeps the 0.1.5 payload shape and + // does not mint a routing secret or profile namespace. + if (!validModelID(primary) || !allowedModels.includes(primary)) throw Error('model_not_enabled_for_kimi'); + routing = { payload, model: primary, translateResponse: false }; + payload = { ...payload, model: primary }; + } + payload = neutralPromptAliases(payload); + const preferences = readJSON(this.connectionFile); + // An authenticated historical profile without a role cannot distinguish + // primary from secondary, even when its model equals the parent's. + const role = routing.routeRole === null ? null : routing.routeRole || 'primary'; + const roleModel = role === 'secondary' ? this.bridgeSecondaryModel : primary; + const useFresh = role !== null && sameOptionConnection(active, preferences) + && roleModel === routing.model && optionModel(preferences, role) === routing.model + && (role === 'primary' || (this.rolesReady && this.roleOptionsEnabled)); + const options = validateModelOptions(role === null ? {} + : boundOptions(useFresh ? preferences : active, role, routing.model) || {}, + allowed.find(row => row.id === routing.model)?.options); + if (useFresh) { + active.modelOptions = { ...active.modelOptions, [role]: { model: routing.model, ...options } }; + } + payload = applyReasoningOptions(payload, options); + if (options.contextTokens !== null) { + const tokens = await countContextTokens(payload, { gatewayURL: active.gatewayURL, gatewayKey: active.gatewayKey, + fetchImpl: this.fetchImpl, signal: controller.signal, requestHeaders: req.headers }); + if (tokens > options.contextTokens) { error(400, 'context_length_exceeded'); return; } + } } const headers = { 'Content-Type': 'application/json', Authorization: inference ? 'Bearer ' + active.gatewayKey : req.headers.authorization }; @@ -253,20 +571,45 @@ export class KimiAdapter { if (!headers['user-agent']) headers['user-agent'] = 'Desktop Kimi Work'; const url = inference ? active.gatewayURL + '/chat/completions' : officialURL(active.originalBase) + req.url.slice('/v1'.length); + const bodyText = JSON.stringify(payload); + // No route metadata may survive the final instructions/alias passes. + if (inference && router && RESERVED_ROUTE.test(bodyText)) throw Error('agent_profile_unbound'); const upstream = await this.fetchImpl(url, { method: 'POST', headers, - body: JSON.stringify(payload), signal: controller.signal, redirect: 'error' }); + body: bodyText, signal: controller.signal, redirect: 'error' }); const responseHeaders = { 'Content-Type': upstream.headers.get('content-type') || 'application/json', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' }; const retryAfter = upstream.headers.get('retry-after'); if (retryAfter !== null) responseHeaders['Retry-After'] = retryAfter; res.writeHead(upstream.status, responseHeaders); res.flushHeaders(); - if (upstream.body) for await (const chunk of upstream.body) { + let body = upstream.body; + if (body && upstream.ok && routing?.translateResponse) { + const contentType = upstream.headers.get('content-type') || ''; + if (contentType.includes('text/event-stream')) body = router.transformSSE(body, roles); + else if (contentType.includes('application/json')) { + const chunks = []; + let length = 0; + for await (const chunk of body) { + length += chunk.length; + if (length > BODY_LIMIT) throw Error('agent_routing_response_too_large'); + chunks.push(chunk); + } + body = [JSON.stringify(router.transformJSON(JSON.parse(Buffer.concat(chunks).toString('utf8')), roles))]; + } else throw Error('agent_routing_unsupported_response'); + } + if (body) for await (const chunk of body) { if (!res.write(chunk)) await once(res, 'drain', { signal: controller.signal }); } res.end(); - } catch { + } catch (failure) { if (res.headersSent) res.destroy(); + else if (failure?.message === 'model_not_enabled_for_kimi' + || failure?.message === 'agent_profile_unbound') error(409, failure.message); + else if (failure?.message?.startsWith('agent_routing_')) error(400, failure.message); + else if (['invalid_model_options', 'unsupported_reasoning_effort', 'unsupported_context_length'].includes(failure?.code)) { + error(409, failure.code); + } + else if (failure?.code === 'context_count_unavailable' && !controller.signal.aborted) error(503, failure.code); else error(controller.signal.aborted ? 499 : 502, controller.signal.aborted ? 'request_cancelled' : 'upstream_connection_failed'); } finally { clearTimeout(timeout); @@ -274,11 +617,34 @@ export class KimiAdapter { } } + refreshLiveModelOptions(desired) { + const active = this.live; + const latest = readJSON(this.connectionFile); + const configured = modelOptionsSnapshot(desired); + const previous = active.modelOptions || {}; + active.modelOptions = Object.fromEntries(OPTION_ROLES.flatMap(role => { + const model = role === 'primary' ? active.model : this.bridgeSecondaryModel; + if (!model) return []; + // An unconfirmed secondary scope keeps the preference that this tick + // actually reconciled. Primary rules can take the latest saved update. + const source = role === 'secondary' && !this.roleOptionsEnabled ? desired : latest; + const options = sameOptionConnection(active, source) && optionModel(source, role) === model + ? optionSnapshot(source, role) : previous[role]?.model === model ? previous[role] : configured[role]; + return [[role, { model, reasoningEffort: options.reasoningEffort, contextTokens: options.contextTokens }]]; + })); + } + async restore({ reload = true } = {}) { - if (!this.state?.active) return; + // Read the two scalars before the control calls below can regenerate the + // file, so a value another writer owns survives them. + if (!this.state?.active) return { restored: true }; + this.protectNativeSecondary(); let doc = readJSON(this.configFile); let modelsRestored = false; - if (reload && baseURL(doc) === this.state.bridgeURL) { + // A synchronous exit (or an earlier partial restore) can already have + // restored the address while the menu/runtime still needs its RPCs. + const ownedAddress = baseURL(doc) === this.state.bridgeURL || baseURL(doc) === this.state.originalBase; + if (reload && ownedAddress) { try { const runner = readJSON(this.runnerFile); const current = await this.rpcCall(runner, 'conversations.getKimiModelList'); @@ -296,25 +662,30 @@ export class KimiAdapter { } } catch { this.report('restored_waiting_kimi'); } } - let changed = false; if (baseURL(doc) === this.state.bridgeURL) { this.changeBase(this.state.bridgeURL, this.state.originalBase); - changed = true; } this.live = null; + this.rolesReady = false; if (fs.existsSync(this.catalogFile)) atomicJSON(this.catalogFile, {schemaVersion: 1, enabled: false, models: [], updatedAt: new Date().toISOString()}); - this.state.active = false; - this.saveState(); - if (changed && reload) { + let runtimeRestored = false; + if (modelsRestored && reload) { try { const current = readJSON(this.configFile); await this.rpcCall(readJSON(this.runnerFile), 'conversations.setModel', { modelAlias: current.model.current }); + runtimeRestored = true; } catch { this.report('restored_waiting_kimi'); } } + // Persist the recovery obligation until both the menu and final runtime + // reload are acknowledged. A lost reply can follow a successful side effect. + const waiting = ownedAddress && !(modelsRestored && runtimeRestored); + this.state.active = waiting; + this.saveState(); // Only model fields were saved, never the full credentials-bearing config. - if (modelsRestored) { + if (modelsRestored && runtimeRestored) { try { fs.unlinkSync(this.modelBackupFile); } catch (error) { if (error.code !== 'ENOENT') throw error; } } + return { restored: !waiting, waiting }; } async syncCatalog(runner, desired, target) { @@ -328,66 +699,753 @@ export class KimiAdapter { this.catalog=this.catalog.filter(x=>keep.has(x.id)); const saved=readJSON(this.catalogFile); if(saved.enabled) this.publishCatalog(desired); + this.observeAgentCatalog([...keep], runner, desired); } catch { /* Initial connection waits until idle. */ } return; } try { const catalog = await this.gatewayCatalog(target, desired.gateway.key); - const current = await this.rpcCall(runner, 'conversations.getKimiModelList'); - if (!Array.isArray(current?.models)) throw Error('catalog_unavailable'); - const merged = mergeRuntimeModels(current.models, catalog); - const same = merged.length === current.models.length && catalog.every(wanted => { - const found = current.models.find(x => x.model === wanted.alias); - return found && Object.entries(wanted.runtime).every(([key,value]) => { - const left = Array.isArray(value) ? [...value].sort() : value; - const right = Array.isArray(found[key]) ? [...found[key]].sort() : found[key]; - return JSON.stringify(left) === JSON.stringify(right); - }); - }); - if (!same) { - if (!fs.existsSync(this.modelBackupFile)) { - atomicJSON(this.modelBackupFile, {models: current.models, current: readJSON(this.configFile).model.current}); + this.catalog = catalog; + const doc = readJSON(this.configFile); + if (Object.keys(doc.model?.models || {}).some(isMenuAlias)) { + const current = await this.rpcCall(runner, 'conversations.getKimiModelList'); + const native = current?.models?.filter(row => !isMenuAlias(row.model)); + if (!native?.length) throw Error('catalog_unavailable'); + if (isMenuAlias(doc.model.current)) { + let previous; + try { previous = readJSON(this.modelBackupFile).current; } catch {} + const alias = native.some(row => row.model === previous) ? previous : native[0].model; + await this.rpcCall(runner, 'conversations.setModel', { modelAlias: alias }); } - await this.rpcCall(runner, 'conversations.updateKimiModelList', {models: merged}); + await this.rpcCall(runner, 'conversations.updateKimiModelList', { models: native }); } - this.catalog = catalog; this.publishCatalog(desired); + this.observeAgentCatalog(catalog.map(row => row.id), runner, desired); this.catalogError = null; } catch { this.catalogError = 'catalog_sync_failed'; } } + // Earlier adapters persisted a native secondary setting. Restore any owned + // fields once, then keep role selection in the existing inference bridge. + // Desktop queue-dispatch rebuilds native TOML before every ordinary turn. + // A native control call can make Daimon rewrite its runtime TOML from a + // fixed key set. Unowned foreign values are preserved in memory for the + // duration of that tick and written back only when the call erased them. + // This is not ownership: nothing is persisted and no session reload runs. + captureForeignNative() { + this.foreignSecondary = null; + this.foreignPool = null; + if (!this.secondaryOwnership()) { + try { this.foreignSecondary = this.readNativeSecondary(); } catch { /* No readable scalar. */ } + } + if (!this.modelPoolOwnership()) { + try { this.foreignPool = inspectModelPool(fs.readFileSync(this.convertedNativeConfigFile, 'utf8')); } + catch { /* No readable pool. */ } + } + } + + restoreForeignNative() { + let conflict = false; + const scalar = this.foreignSecondary; + if (scalar && (scalar.model !== null || scalar.enabled !== null)) { + try { + const bytes = fs.readFileSync(this.nativeConfigFile); + const current = inspectSecondary(bytes.toString('utf8')); + if (current.model === null && current.enabled === null) { + const text = bytes.toString('utf8'); + const next = patchSecondary(text, scalar); + this.commitNativeSecondary(bytes, next); + } + } catch (error) { + if (error?.message === 'kimi_config_changed') conflict = true; + } + } + const pool = this.foreignPool; + if (pool && (pool.models !== null || pool.defaultModel !== null)) { + try { + const bytes = fs.readFileSync(this.convertedNativeConfigFile); + const current = inspectModelPool(bytes.toString('utf8')); + if (current.models === null && current.defaultModel === null) { + const text = bytes.toString('utf8'); + const next = patchModelPool(text, pool); + this.commitNativeSecondary(bytes, next, this.convertedNativeConfigFile); + } + } catch (error) { + if (error?.message === 'kimi_config_changed') conflict = true; + } + } + this.foreignSecondary = null; + this.foreignPool = null; + if (conflict) throw Error('kimi_config_changed'); + } + + protectNativeSecondary() { + // Foreign legacy scalars and pools are observations only. Only a baseline + // this adapter persisted earlier owns a restore obligation. + this.observeNativeSecondary(); + this.observeModelPool(); + } + + ensureAgentRouter() { + let secret = this.state?.agentRoutingSecret; + if (!/^[a-f0-9]{64}$/.test(secret || '')) { + secret = crypto.randomBytes(32).toString('hex'); + this.state.agentRoutingSecret = secret; + this.saveState(); + } + const stored = record(this.state?.agentRoutingBindings) ? this.state.agentRoutingBindings : {}; + const absence = record(this.state?.agentRoutingAbsence) ? this.state.agentRoutingAbsence : {}; + const roles = this.state?.agentRoutingRoles ?? {}; + const sameBindings = (left, right) => { + const a = Object.entries(left), b = Object.entries(right); + return a.length === b.length && a.every(([key, value], index) => + b[index] && b[index][0] === key && b[index][1] === value); + }; + if (!this.agentRouter || this.agentRouterSecret !== secret + || !sameBindings(this.agentRouter.bindings, stored) + || JSON.stringify(this.agentRouter.absence) !== JSON.stringify(absence) + || JSON.stringify(this.agentRouter.roles) !== JSON.stringify(roles)) { + try { this.agentRouter = new AgentModelRouter({ secret, bindings: stored, absence, roles }); } + catch { throw Error('agent_profile_conflict'); } + this.agentRouterSecret = secret; + } + return this.agentRouter; + } + + persistAgentBindings(router) { + const next = router.bindings; + const absence = router.absence; + const roles = router.roles; + if (JSON.stringify(next) !== JSON.stringify(this.state.agentRoutingBindings || {}) + || JSON.stringify(absence) !== JSON.stringify(this.state.agentRoutingAbsence || {}) + || JSON.stringify(roles) !== JSON.stringify(this.state.agentRoutingRoles || {})) { + const previous = this.state.agentRoutingBindings; + const previousAbsence = this.state.agentRoutingAbsence; + const previousRoles = this.state.agentRoutingRoles; + this.state.agentRoutingBindings = next; + this.state.agentRoutingAbsence = absence; + if (Object.keys(roles).length || this.state.agentRoutingRoles) this.state.agentRoutingRoles = roles; + try { this.saveState(); } + catch (error) { + this.state.agentRoutingBindings = previous; + this.state.agentRoutingAbsence = previousAbsence; + this.state.agentRoutingRoles = previousRoles; + throw error; + } + } + } + + observeAgentCatalog(allowedModels, runner, desired) { + // A primary-only connection never mints a key or a companion namespace. + if (!/^[a-f0-9]{64}$/.test(this.state?.agentRoutingSecret || '')) return; + const router = this.ensureAgentRouter(); + const secondary = desired.secondaryModel; + // Revoke only when the corresponding owned profiles can be synchronized. + // Disabled companions retain their cache for a later explicit opt-in; + // forgetting its token first would strand an immutable same-name file. + let canSynchronize = Boolean(secondary) + && (this.state.agentPlugin?.phase === 'ready' || allowedModels.includes(secondary)); + const saved = this.state.agentPlugin; + if (canSynchronize && saved?.files) { + try { + const id = 'local-work-model-roles-' + sha(this.state.agentRoutingSecret).slice(0, 12); + this.rolePluginRegistered(id); + const directory = path.join(this.stateDir, 'native-model-roles'); + const installed = path.join(path.dirname(this.convertedNativeConfigFile), 'plugins', 'managed', id); + if (!sameFiles(rolePluginFiles(directory), saved.files) + || !sameFiles(rolePluginFiles(installed), saved.files)) canSynchronize = false; + } catch { canSynchronize = false; } + } + router.observeCatalog(allowedModels, { allowRetirement: !busy(runner) && canSynchronize }); + this.persistAgentBindings(router); + } + + async reconcileSecondary(runner, wanted, { inheritModel = '', roleOptionsEnabled = false } = {}) { + if (this.secondaryOwnership() || this.modelPoolOwnership()) { + const restored = await this.restoreModelPools(runner); + if (restored.waiting) return restored; + } + if (!wanted) { + // Opted out. An owned plugin retires at idle; a busy turn keeps its + // current schema until the disable obligation can be confirmed. + if (this.state?.agentPlugin) { + const retired = await this.retireRolePlugin(runner); + if (retired.waiting) return retired; + } + this.rolesReady = false; + this.roleOptionsEnabled = false; + this.bridgeSecondaryModel = ''; + this.setAppliedSecondary(''); + return { applied: true }; + } + if (!inheritModel) return { applied: false }; + const target = wanted; + if (!this.catalog.some(row => row.id === target)) { + if (this.state.agentPlugin?.phase === 'ready') { + await this.syncRolePlugin(runner, [inheritModel].filter(model => this.catalog.some(row => row.id === model))); + } + throw Error('secondary_model_unavailable'); + } + if (this.bridgeSecondaryModel !== target && busy(runner)) return { waiting: true }; + this.ensureAgentRouter(); + if (kernelFamily(runner, { hasConverted: fs.existsSync(this.convertedNativeConfigFile) }) !== 'v2') { + this.rolesReady = false; + this.bridgeSecondaryModel = ''; + this.setAppliedSecondary(''); + throw Error('secondary_model_unsupported_runtime'); + } + const profiles = await this.syncRolePlugin(runner, [inheritModel, target], { roleOptionsEnabled, + primaryModel: inheritModel, secondaryModel: target }); + if (profiles.waiting) return profiles; + this.rolesReady = true; + this.roleOptionsEnabled = roleOptionsEnabled; + this.bridgeSecondaryModel = target; + this.setAppliedSecondary(target); + return { applied: true }; + } + + markRolePluginDisablePending() { + const saved = this.state?.agentPlugin; + if (!saved || saved.phase === 'disabled' || saved.phase === 'disabling') return false; + this.state.agentPlugin = { ...saved, phase: 'disabling' }; + this.saveState(); + return true; + } + + rolePluginRegistered(id) { + const registryFile = path.join(path.dirname(this.convertedNativeConfigFile), 'plugins', 'installed.json'); + const inspection = inspectRolePlugins({ registryFile, + sourceDirectory: path.join(this.stateDir, 'native-model-roles') }); + if (inspection.conflicts.length || inspection.owned.some(row => row.id !== id)) { + throw Error('agent_profile_conflict'); + } + return inspection.owned.some(row => row.id === id); + } + + async unregisteredRolePluginPresent(runner, saved) { + let info; + try { info = await this.rpcCall(runner, 'kimiPlugin.getPluginInfo', { id: saved.id }); } + catch (error) { + if (error?.message === 'kimi_plugin_not_found') return false; + throw Error('secondary_model_reload_failed'); + } + if (info?.id !== saved.id || typeof info.enabled !== 'boolean') throw Error('secondary_model_reload_failed'); + // A missing disk record says nothing about the already-loaded manager. + // In that case only our unchanged source/cache bytes authorize a toggle. + const directory = path.join(this.stateDir, 'native-model-roles'); + const installed = path.join(path.dirname(this.convertedNativeConfigFile), 'plugins', 'managed', saved.id); + try { + if (![directory, path.join(directory, 'agents'), installed, path.join(installed, 'agents')] + .every(file => fs.lstatSync(file).isDirectory())) throw Error('agent_profile_conflict'); + if (!sameFiles(rolePluginFiles(directory), saved.files) + || !sameFiles(rolePluginFiles(installed), saved.files)) throw Error('agent_profile_conflict'); + } catch { throw Error('agent_profile_conflict'); } + return true; + } + + // Remove only the verified native registration after disable and reload. + // Source/managed files and opaque bindings remain for later reconnects. + // The disable RPC is always sent while the obligation is pending, even when + // the on-disk state already reads false: the native side still needs the + // explicit session reload confirmation. + async retireRolePlugin(runner) { + const saved = this.state?.agentPlugin; + if (!saved) return { applied: true }; + if (saved.phase === 'disabled') return { applied: true }; + this.markRolePluginDisablePending(); + if (!runner || runner.lifecycleStatus !== 'running') return { waiting: true }; + if (busy(runner)) return { waiting: true }; + const registered = this.rolePluginRegistered(saved.id); + let present = registered || await this.unregisteredRolePluginPresent(runner, saved); + if (present) { + try { + await this.rpcCall(runner, 'kimiPlugin.setPluginEnabled', { id: saved.id, enabled: false }, { timeout: 30000 }); + } catch (error) { + if (error?.message === 'kimi_plugin_not_found') present = false; + else throw Error('secondary_model_reload_failed'); + } + } + this.useSecondaryReloadRuntime('role-disable'); + this.secondaryReloadPending = true; + const confirmation = await this.confirmModelPoolReload(runner, false); + if (confirmation.waiting) return confirmation; + // Recheck provenance after the awaited control calls. A native uninstall + // or a lost removal acknowledgement can already have removed the record. + if (this.rolePluginRegistered(saved.id)) { + if (!present) throw Error('agent_profile_conflict'); + try { await this.rpcCall(runner, 'kimiPlugin.removePlugin', { id: saved.id }, { timeout: 30000 }); } + catch { throw Error('secondary_model_reload_failed'); } + } else if (present && await this.unregisteredRolePluginPresent(runner, saved)) { + throw Error('agent_profile_conflict'); + } + const current = this.state?.agentPlugin; + if (current && current.id === saved.id) { + if (current.phase !== 'disabled') { + this.state.agentPlugin = { ...current, phase: 'disabled' }; + this.saveState(); + } + } + return { applied: true, disabled: true }; + } + + async syncRolePlugin(runner, models, { roleOptionsEnabled = false, primaryModel, secondaryModel } = {}) { + const router = this.ensureAgentRouter(); + const allowed = this.catalog.map(row => row.id); + try { + router.registerModels([...new Set(models)], allowed); + if (roleOptionsEnabled) router.registerRoles({ primaryModel, secondaryModel }, allowed); + } + catch (error) { + if (error?.message === 'model_not_enabled_for_kimi') throw Error('secondary_model_unavailable'); + throw Error('agent_profile_conflict'); + } + // The opaque token mapping has to be durable before any profile is + // written or any public RPC is sent, so a restart resumes the same routes. + this.persistAgentBindings(router); + const id = 'local-work-model-roles-' + sha(this.state.agentRoutingSecret).slice(0, 12); + const directory = path.join(this.stateDir, 'native-model-roles'); + const manifestFile = path.join(directory, 'kimi.plugin.json'); + const manifest = JSON.stringify({ name: id, version: '1.0.0', description: 'Local Work model roles', agents: ['./agents'] }, null, 2) + '\n'; + // Another role-plugin id from an earlier key (or a foreign registration) + // must be resolved before this adapter installs, enables or reloads: never + // silently leave two role plugins enabled. Owned and foreign candidates + // both stop here; nothing is written and no RPC is sent. + const registered = this.rolePluginRegistered(id); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (!fs.lstatSync(directory).isDirectory()) throw Error('agent_profile_conflict'); + try { fs.writeFileSync(manifestFile, manifest, { flag: 'wx', mode: 0o600 }); } + catch (error) { + if (error.code !== 'EEXIST') throw error; + if (!fs.lstatSync(manifestFile).isFile() || fs.readFileSync(manifestFile, 'utf8') !== manifest) throw Error('agent_profile_conflict'); + } + const saved = this.state.agentPlugin; + // Verify the bytes this adapter owned before the profiles manager may + // prune any retired one. Missing, edited or foreign files are conflicts, + // while a normal prune of an owned obsolete profile is allowed. + const ownedFiles = {}; + try { + for (const [name, digest] of Object.entries(saved?.files || {})) { + const file = path.join(directory, name); + if (!fs.lstatSync(file).isFile() || sha(fs.readFileSync(file)) !== digest) throw Error('agent_profile_conflict'); + if (name.startsWith('agents/')) ownedFiles[name.slice('agents/'.length)] = digest; + } + } catch { throw Error('agent_profile_conflict'); } + ensureAgentProfiles({ directory: path.join(directory, 'agents'), router, profiles: router.boundProfiles, + ...(saved?.files ? { ownedFiles } : {}) }); + const files = rolePluginFiles(directory); + const selection = sha(JSON.stringify(Object.keys(router.roles).length + ? router.boundProfiles.map(profile => [profile.model, profile.role || '']).sort() : [...router.boundModels].sort())); + const installed = path.join(path.dirname(this.convertedNativeConfigFile), 'plugins', 'managed', id); + let current; + try { current = rolePluginFiles(installed); } + catch (error) { + if (error.code !== 'ENOENT') throw error; + if (fs.existsSync(installed)) throw Error('agent_profile_conflict'); + } + if (current && !sameFiles(current, saved?.files) && !sameFiles(current, saved?.previousFiles) && !sameFiles(current, files)) { + throw Error('agent_profile_conflict'); + } + let needsReload = saved?.phase !== 'ready' || saved?.id !== id; + let installedOrEnabled = false; + if (!registered || !sameFiles(current, files)) { + if (busy(runner)) return { waiting: true }; + this.rolesReady = false; + this.state.agentPlugin = { id, files, previousFiles: current, selection, phase: 'installing' }; + this.saveState(); + try { + await this.rpcCall(runner, registered ? 'kimiPlugin.updatePlugin' : 'kimiPlugin.installPlugin', { source: directory }, { timeout: 30000 }); + } catch { throw Error('secondary_model_reload_failed'); } + if (!sameFiles(rolePluginFiles(installed), files)) throw Error('secondary_model_reload_failed'); + needsReload = true; + installedOrEnabled = true; + } + // A confirmed selection only needs its periodic health read. Busy turns + // never read it and a stable source never adds another RPC inside 30s. + const healthOnly = saved?.phase === 'ready' && !installedOrEnabled; + if (healthOnly && busy(runner)) return { applied: true }; + if (healthOnly && Date.now() < (this.agentPluginNextCheck || 0)) return { applied: true }; + let info; + try { info = await this.rpcCall(runner, 'kimiPlugin.getPluginInfo', { id }); } + catch (error) { + // The control transport maps every remote error to the same rejection + // code. A failed read cannot positively confirm disablement or removal. + if (healthOnly && error?.message === 'kimi_plugin_not_found') { + this.rolesReady = false; + throw Error('secondary_model_unavailable'); + } + if (healthOnly) { + this.agentPluginNextCheck = Date.now() + AGENT_PLUGIN_CONFIRM_MS; + return { applied: true }; + } + throw Error('secondary_model_reload_failed'); + } + this.agentPluginNextCheck = Date.now() + AGENT_PLUGIN_CONFIRM_MS; + if (info?.id !== id || typeof info.enabled !== 'boolean') { + if (healthOnly) return { applied: true }; + throw Error('secondary_model_reload_failed'); + } + if (!info.enabled) { + if (saved?.phase === 'ready' && saved.selection === selection) { + this.rolesReady = false; + throw Error('secondary_model_unavailable'); + } + if (busy(runner)) return { waiting: true }; + this.rolesReady = false; + this.state.agentPlugin = { id, files, selection, phase: 'installing' }; + this.saveState(); + try { await this.rpcCall(runner, 'kimiPlugin.setPluginEnabled', { id, enabled: true }, { timeout: 30000 }); } + catch { throw Error('secondary_model_reload_failed'); } + needsReload = true; + installedOrEnabled = true; + } + if (needsReload) { + this.state.agentPlugin = { id, files, selection, phase: 'reloading' }; + this.saveState(); + const starting = this.secondaryReloadRuntime !== 'role-profiles' + || this.secondaryReloadGeneration !== this.state.generation; + this.useSecondaryReloadRuntime('role-profiles'); + if (starting || installedOrEnabled || saved?.phase === 'installing') this.secondaryReloadPending = true; + const confirmation = await this.confirmModelPoolReload(runner, false); + if (confirmation.waiting) return confirmation; + } + const ready = { id, files, selection, phase: 'ready' }; + if (JSON.stringify(this.state.agentPlugin) !== JSON.stringify(ready)) { + this.state.agentPlugin = ready; + this.saveState(); + } + return { applied: true }; + } + + readNativeSecondary() { + return inspectSecondary(fs.readFileSync(this.nativeConfigFile, 'utf8')); + } + + 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; + } + + commitNativeSecondary(bytes, text, file = this.nativeConfigFile) { + const temp = file + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp'; + try { + fs.writeFileSync(temp, text, { mode: 0o600, flag: 'wx' }); + if (!fs.readFileSync(file).equals(bytes)) throw Error('kimi_config_changed'); + fs.renameSync(temp, file); + } finally { try { fs.unlinkSync(temp); } catch { /* Renamed. */ } } + } + + 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(); + } + + 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); + } + + 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, + }; + } + + modelPoolOwnership() { + const owned = this.state?.secondaryPool; + const baseline = plainPoolSnapshot(owned?.baseline); + return baseline ? { baseline, written: plainPoolSnapshot(owned.written), + previousWritten: plainPoolSnapshot(owned.previousWritten), + selection: typeof owned.selection === 'string' ? owned.selection : '', restored: owned.restored === true } : null; + } + + observeModelPool() { + const owned = this.modelPoolOwnership(); + if (!owned?.written) { this.modelPoolExternal = null; return; } + let current; + try { current = this.effectiveModelPool(inspectModelPool(fs.readFileSync(this.convertedNativeConfigFile, 'utf8'))); } + catch { return; } + if (owned.restored && samePool(current, owned.baseline)) { this.modelPoolExternal = null; return; } + if (samePool(current, owned.previousWritten)) { this.modelPoolExternal = null; return; } + const foreignModels = current.models !== null && !sameModels(current.models, owned.written.models); + const foreignDefault = current.defaultModel !== null && current.defaultModel !== owned.written.defaultModel; + // Pool keys and its default form one valid binding. Preserve the whole + // narrow snapshot if either part changed, including across regeneration. + this.modelPoolExternal = foreignModels || foreignDefault ? current : null; + } + + effectiveModelPool(current) { + const external = this.modelPoolExternal; + if (!external) return current; + const models = current.models === null && external.models !== null ? external.models : current.models; + const defaultModel = current.defaultModel === null && external.defaultModel !== null ? external.defaultModel : current.defaultModel; + return { models, defaultModel, + hasSecondaryTable: current.hasSecondaryTable || external.hasSecondaryTable, + hasModelsTable: models === current.models ? current.hasModelsTable : external.hasModelsTable }; + } + + useSecondaryReloadRuntime(runtime) { + const generation = this.state?.generation; + if (this.secondaryReloadRuntime === runtime && this.secondaryReloadGeneration === generation) return; + this.secondaryReloadRuntime = runtime; + this.secondaryReloadGeneration = generation; + this.secondaryPending.clear(); + this.secondaryReloadPending = false; + this.secondaryReloadStale = false; + this.secondaryAfter = 0; + } + + async confirmModelPoolReload(runner, requireSession) { + const retried = await this.retrySecondarySessions(runner); + if (retried.failed) throw Error('secondary_model_reload_failed'); + if (retried.pending) return { waiting: true }; + if (!this.secondaryReloadPending) return {}; + if (!runner || runner.lifecycleStatus !== 'running') return requireSession ? { waitingSession: true } : {}; + if (busy(runner)) return { waiting: true }; + if (Date.now() < this.secondaryAfter) return requireSession ? { waitingSession: true } : { waiting: true }; + const reload = await this.reloadSecondarySessions(runner); + if (reload.failed) throw Error('secondary_model_reload_failed'); + if (reload.pending) return { waiting: true }; + // Unlike legacy, an empty V2 batch does not reload the shared ConfigService. + if (requireSession && !reload.confirmed) return { waitingSession: true }; + this.secondaryReloadPending = false; + return {}; + } + + setAppliedModelPool(value) { + if (!this.state) return; + let changed = this.state.secondaryPoolModel !== value; + this.state.secondaryPoolModel = value; + if (value && this.state.secondaryPool?.restored) { delete this.state.secondaryPool.restored; changed = true; } + if (changed) this.saveState(); + } + + restoreModelPoolOnExit() { + const owned = this.modelPoolOwnership(); + if (!owned) return; + const bytes = fs.readFileSync(this.convertedNativeConfigFile); + const current = this.effectiveModelPool(inspectModelPool(bytes.toString('utf8'))); + const ownsModels = current.models === null || Boolean(owned.written && sameModels(current.models, owned.written.models)); + const ownsDefault = current.defaultModel === null || current.defaultModel === owned.written?.defaultModel; + const ownsPrevious = samePool(current, owned.previousWritten); + let fromBaseline = (ownsModels && ownsDefault) || ownsPrevious; + const doc = Bun.TOML.parse(bytes.toString('utf8')); + if (!record(doc.models)) throw Error('secondary_config_unsupported'); + const independent = alias => !isMenuAlias(alias) && record(doc.models[alias]); + const nativePool = snapshot => { + if (snapshot.models === null) { + return snapshot.defaultModel === null || independent(snapshot.defaultModel) ? snapshot : null; + } + const models = Object.fromEntries(Object.entries(snapshot.models).filter(([alias]) => independent(alias))); + const aliases = Object.keys(models); + if (!aliases.length) return null; + const defaultModel = Object.hasOwn(models, snapshot.defaultModel) ? snapshot.defaultModel + : Object.hasOwn(models, owned.baseline.defaultModel) ? owned.baseline.defaultModel : aliases[0]; + return { models, defaultModel, hasSecondaryTable: true, hasModelsTable: true }; + }; + // Managed aliases depend on the bridge/menu being removed. Preserve only + // independent native bindings; a preserved string must still resolve. + let target = nativePool(fromBaseline ? owned.baseline : current); + if (!target) { + fromBaseline = true; + target = nativePool(owned.baseline) || { models: null, defaultModel: null, + hasSecondaryTable: owned.baseline.hasSecondaryTable, hasModelsTable: false }; + } + // Removing the modern pool would reactivate this unowned compatibility + // scalar. Do not silently edit it or claim a dangling binding was restored. + if (target.models === null && target.defaultModel === null + && typeof doc.secondary_model?.model === 'string' && isMenuAlias(doc.secondary_model.model)) { + throw Error('secondary_config_unsupported'); + } + const next = patchModelPool(bytes.toString('utf8'), target); + if (next !== bytes.toString('utf8')) this.commitNativeSecondary(bytes, next, this.convertedNativeConfigFile); + if (fromBaseline) { + // The cleaned baseline is the restart boundary, so a removed managed + // binding cannot reappear or be mistaken for a foreign native edit. + this.state.secondaryPool.baseline = target; + this.state.secondaryPool.restored = true; + this.saveState(); + } + this.setAppliedModelPool(''); + } + + async restoreModelPools(runner) { + if (busy(runner)) return { waiting: true }; + const starting = this.secondaryReloadRuntime !== 'v2-restore' + || this.secondaryReloadGeneration !== this.state?.generation; + this.useSecondaryReloadRuntime('v2-restore'); + // A failed disk restoration must retain the obligation to confirm the + // baseline once a later tick can write it successfully. + if (starting) this.secondaryReloadPending = true; + // Both files return to their own baseline before confirming the final state. + this.restoreNativeSecondaryOnExit(); + this.restoreModelPoolOnExit(); + const confirmation = await this.confirmModelPoolReload(runner, false); + if (confirmation.waiting) return confirmation; + this.dropSecondaryOwnership(); + delete this.state.secondaryPool; + delete this.state.secondaryPoolModel; + this.modelPoolExternal = null; + this.saveState(); + return { applied: true }; + } + + 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, confirmed = 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) { confirmed = true; 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, confirmed }; + } + + 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 }; + } + + restoreNativeSecondaryOnExit() { + const owned = this.secondaryOwnership(); + if (!owned) return; + const bytes = fs.readFileSync(this.nativeConfigFile); + const text = bytes.toString('utf8'); + const current = this.effectiveSecondary(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; + this.nativeRewriteConflict = false; try { + await this.syncSettings?.(); if (!this.bootstrap()) return; if (!this.ownLock()) return; const desired = readJSON(this.connectionFile); let runner; try { runner = readJSON(this.runnerFile); } catch { /* Kimi isn't running yet. */ } if (!desired.enabled || !desired.model) { - if (this.state.active && busy(runner)) { this.report('waiting_kimi_idle'); return; } - await this.restore(); + if (this.state.active && busy(runner)) { + // Keep the disable/reload obligation across the busy turn; the next + // idle tick finishes it. + this.markRolePluginDisablePending(); + this.report('waiting_kimi_idle'); + return; + } + const wasActive = this.state.active; + const primaryRestore = await this.restore(); + if (wasActive && (this.secondaryOwnership() || this.modelPoolOwnership())) { + // These ids were waiting for the old preference. A full disconnect + // restores the file first and starts confirmation of that baseline. + this.secondaryPending.clear(); + this.secondaryReloadStale = false; + this.secondaryReloadPending = true; + this.secondaryAfter = 0; + } + // 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(primaryRestore.waiting ? 'restored_waiting_kimi' : secondary.waiting ? 'waiting_kimi_idle' : 'disconnected'); return; } const target = gatewayURL(desired.gateway?.url); + const wantedSecondary = typeof desired.secondaryModel === 'string' ? desired.secondaryModel : ''; + const roleOptionsEnabled = OPTION_ROLES.some(role => boundOptions(desired, role)); 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; } const bytes = fs.readFileSync(this.configFile), doc = JSON.parse(bytes.toString('utf8').replace(/^\uFEFF/, '')); - const generation = sha(JSON.stringify([runner.daemonGeneration, runner.control?.endpoint?.url, - runner.control?.endpoint?.startedAt, runner.control?.endpoint?.pid])); + const generation = runnerGeneration(runner); const signature = sha(JSON.stringify([desired.model, target, desired.gateway.key])); const fingerprint = sha(bytes); 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(); await this.syncCatalog(runner, desired, target); + const secondary = await this.reconcileSecondary(runner, wantedSecondary, { inheritModel: desired.model, roleOptionsEnabled }); + if (secondary.waitingSession) { this.report('secondary_model_waiting_session'); return; } + if (secondary.waiting) { this.report('waiting_kimi_idle'); return; } + if (secondary.applied === false) { this.report('kimi_config_changed'); return; } + this.refreshLiveModelOptions(desired); 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 +1468,19 @@ 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(); 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?.agentRoutingSecret ? { agentRoutingSecret: previous.agentRoutingSecret } : {}), + ...(previous?.agentRoutingBindings ? { agentRoutingBindings: previous.agentRoutingBindings } : {}), + ...(previous?.agentRoutingAbsence ? { agentRoutingAbsence: previous.agentRoutingAbsence } : {}), + ...(previous?.agentRoutingRoles ? { agentRoutingRoles: previous.agentRoutingRoles } : {}), + ...(previous?.agentPlugin ? { agentPlugin: previous.agentPlugin } : {}), + ...(previous?.secondary ? { secondary: previous.secondary } : {}), + ...(previous?.secondaryPool ? { secondaryPool: previous.secondaryPool, secondaryPoolModel: '' } : {}), + ...(typeof previous?.secondaryModel === 'string' ? { secondaryModel: previous.secondaryModel } : {}) }; // Persist recovery ownership before writing the Kimi file. this.saveState(); this.changeBase(oldBase, this.bridgeURL); @@ -419,19 +1488,33 @@ export class KimiAdapter { await this.rpcCall(runner, 'conversations.setModel', { modelAlias: doc.model.current }); } catch (error) { this.changeBase(this.bridgeURL, oldBase); - this.state = previous; + // The command may have regenerated the native runtime before its + // acknowledgement was lost. Keep ownership even on a first connection + // so a subsequent disconnect/exit can finish that runtime restore. + if (previous?.active) this.state = previous; this.saveState(); throw error; } - this.live = { model: desired.model, gatewayURL: target, gatewayKey: desired.gateway.key, originalBase }; + this.rolesReady = false; + this.live = { model: desired.model, gatewayURL: target, gatewayKey: desired.gateway.key, originalBase, + modelOptions: modelOptionsSnapshot(desired) }; 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, roleOptionsEnabled }); this.pausedSignature = null; + if (secondary.waitingSession) { this.report('secondary_model_waiting_session'); return; } + if (secondary.waiting) { this.report('waiting_kimi_idle'); return; } + if (secondary.applied === false) { this.report('kimi_config_changed'); return; } + this.refreshLiveModelOptions(desired); 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', + 'secondary_model_unsupported_runtime', 'agent_profile_conflict']); const code = codes.has(error.message) ? error.message : 'adapter_failed'; + if (SECONDARY_CODES.has(code) || code === 'agent_profile_conflict') this.rolesReady = false; this.report(code); } finally { this.running = false; } } @@ -446,10 +1529,45 @@ export class KimiAdapter { async stop() { clearInterval(this.timer); this.stopping = true; + await this.settingsPending; + const settingsServer = this.settingsServer; + this.settingsServer = null; + await settingsServer?.stop(); while (this.running) await new Promise(resolve => setTimeout(resolve, 20)); + this.nativeRewriteConflict = false; if (this.locked) { - try { await this.restore(); } catch { /* Never write a changed third-party configuration. */ } - this.report('disconnected'); + let code = 'disconnected'; + try { if ((await this.restore()).waiting) code = 'restored_waiting_kimi'; } + catch { code = 'kimi_config_changed'; } // Never write a changed third-party configuration. + // Restore primary/menu settings through the normal control API first. + // The owned secondary fields then return to disk without extra session + // reload calls; keep ownership for the next start, as on synchronous exit. + try { + this.restoreNativeSecondaryOnExit(); + } catch (error) { + if (SECONDARY_CODES.has(error.message)) code = error.message; + } + try { this.restoreModelPoolOnExit(); } + catch (error) { code = SECONDARY_CODES.has(error?.message) ? error.message + : error?.code === 'ENOENT' ? 'secondary_model_unavailable' : 'secondary_config_unsupported'; } + // An owned role plugin is retired while idle; a busy turn keeps the + // obligation for the next start. + if (this.state?.agentPlugin && this.state.agentPlugin.phase !== 'disabled') { + let runner; + try { runner = readJSON(this.runnerFile); } catch { /* Kimi already exited. */ } + try { + if (runner && runner.lifecycleStatus === 'running' && !busy(runner)) { + const retired = await this.retireRolePlugin(runner); + if (retired.waiting) code = 'waiting_kimi_idle'; + } else { + this.markRolePluginDisablePending(); + code = 'waiting_kimi_idle'; + } + } catch (error) { + code = SECONDARY_CODES.has(error?.message) ? error.message : 'secondary_model_reload_failed'; + } + } + this.report(code); this.releaseLock(); } for (const pending of this.inflight) pending.abort(); @@ -460,6 +1578,29 @@ export class KimiAdapter { } } + retireRolePluginOnDisk() { + const saved = this.state?.agentPlugin; + if (!saved || saved.phase === 'disabled') return; + const registryFile = path.join(path.dirname(this.convertedNativeConfigFile), 'plugins', 'installed.json'); + const sourceDirectory = path.join(this.stateDir, 'native-model-roles'); + const pending = saved.phase === 'disabling'; + let owned = [], failed = false; + try { + const inspected = inspectRolePlugins({ registryFile, sourceDirectory }); + if (inspected.conflicts.length) throw Error('agent_profile_conflict'); + owned = inspected.owned.map(row => row.id); + disableRolePluginsSync({ registryFile, sourceDirectory, + ids: owned.includes(saved.id) ? [saved.id] : [] }); + } catch { failed = true; /* Foreign or unreadable registrations stay untouched. */ } + // The disk write never acknowledges a loaded session, and a same-value + // disable (or an already-false registration) still owes the RPC/reload. + // A failed inspection must not clear that debt either. + if (failed || pending || owned.includes(saved.id)) { + this.state.agentPlugin = { ...saved, phase: 'disabling' }; + this.saveState(); + } + } + restoreFileOnExit() { if (!this.locked) return; try { @@ -467,6 +1608,13 @@ 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 { this.restoreModelPoolOnExit(); } catch { /* Leave a changed configuration alone. */ } + // The synchronous host exit cannot RPC. Mark the registration off disk and + // keep the RPC/reload debt; a failure here never skips the restore above. + try { this.retireRolePluginOnDisk(); } 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/agent-profiles.mjs b/packages/kimi-desktop/agent-profiles.mjs new file mode 100644 index 000000000..4f45c5328 --- /dev/null +++ b/packages/kimi-desktop/agent-profiles.mjs @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +const conflict = () => Object.assign(Error('agent_profile_conflict'), { code: 'agent_profile_conflict' }); +const profileFile = /^local-agent-(?:[a-f0-9]{12}-)?[a-f0-9]{24}\.md$/; +const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex'); + +// Existing files are accepted only byte-for-byte. A profile edited by the user +// is never replaced. Optional ownership permits pruning retired private files. +export function ensureAgentProfiles({ directory, router, models, profiles, ownedFiles }) { + const requested = profiles ?? (models === undefined ? router.boundProfiles + : [...new Set(models)].map(model => ({ model }))); + const entries = [...new Map(requested.map(({ model, role }) => { + const name = router.profileName(model, role); + if (!/^local-agent-[a-f0-9]{12}-[a-f0-9]{24}$/.test(name)) throw conflict(); + return [name, { name, file: path.join(directory, name + '.md'), text: router.profileText(model, role) }]; + })).values()]; + const exists = entry => { + let stat; + try { stat = fs.lstatSync(entry.file); } + catch (error) { if (error.code === 'ENOENT') return false; throw error; } + if (!stat.isFile() || fs.readFileSync(entry.file, 'utf8') !== entry.text) throw conflict(); + return true; + }; + // Check every existing name before publishing any new profile. + const missing = entries.filter(entry => !exists(entry)); + const obsolete = []; + const verifyOwned = name => { + const file = path.join(directory, name); + if (!fs.lstatSync(file).isFile() || digest(fs.readFileSync(file)) !== ownedFiles[name]) throw conflict(); + }; + if (ownedFiles !== undefined) { + if (!ownedFiles || typeof ownedFiles !== 'object' || Array.isArray(ownedFiles) + || Object.entries(ownedFiles).some(([name, hash]) => !profileFile.test(name) || !/^[a-f0-9]{64}$/.test(hash))) { + throw conflict(); + } + const wanted = new Set(entries.map(entry => path.basename(entry.file))); + for (const name of fs.existsSync(directory) ? fs.readdirSync(directory) : []) { + // Hidden metadata entries are never stat'd, read, hashed, deleted, or treated as conflicts. + if (name.startsWith('.')) continue; + // MUTATION profiles-ownership: unknown or edited files never become deletion authority. + if (Object.hasOwn(ownedFiles, name)) verifyOwned(name); + else if (!wanted.has(name)) throw conflict(); + if (!wanted.has(name)) obsolete.push(name); + } + } + let changed = false; + if (missing.length) fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + for (const entry of missing) { + const staged = entry.file + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp'; + let created = false; + try { + fs.writeFileSync(staged, entry.text, { mode: 0o600, flag: 'wx' }); + created = true; + // An exclusive link publishes a complete file without replacing one + // created or edited by another writer after the preflight check. + try { fs.linkSync(staged, entry.file); changed = true; } + catch (error) { + if (error.code !== 'EEXIST') throw error; + if (!exists(entry)) throw conflict(); + } + } finally { if (created) fs.unlinkSync(staged); } + } + for (const name of obsolete) { + // Recheck bytes immediately before deleting an obsolete owned profile. + verifyOwned(name); + fs.unlinkSync(path.join(directory, name)); + changed = true; + } + return { changed, names: entries.map(entry => entry.name) }; +} diff --git a/packages/kimi-desktop/agent-profiles.test.mjs b/packages/kimi-desktop/agent-profiles.test.mjs new file mode 100644 index 000000000..47f09cbbe --- /dev/null +++ b/packages/kimi-desktop/agent-profiles.test.mjs @@ -0,0 +1,257 @@ +import { test, expect, spyOn } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { AgentModelRouter } from './agent-routing.mjs'; +import { ensureAgentProfiles } from './agent-profiles.mjs'; + +const secret = 'a3'.repeat(32); +const models = ['synthetic/primary', 'synthetic/secondary']; +const router = new AgentModelRouter({ secret }); +function fixture(run) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-agent-profiles-')); + try { return run(directory); } finally { fs.rmSync(directory, { recursive: true, force: true }); } +} +const profileFile = (directory, model) => path.join(directory, router.profileName(model) + '.md'); +const ownedFiles = directory => Object.fromEntries(fs.readdirSync(directory).map(name => [name, + crypto.createHash('sha256').update(fs.readFileSync(path.join(directory, name))).digest('hex')])); + +test('profiles contain valid neutral frontmatter, inherit the base prompt, exclude delegation, and never contain the key', () => fixture(directory => { + const result = ensureAgentProfiles({ directory, router, models }); + expect(result).toEqual({ changed: true, names: models.map(model => router.profileName(model)) }); + for (const model of models) { + const file = profileFile(directory, model), text = fs.readFileSync(file, 'utf8'); + const [frontmatter, body] = text.slice(4).split('\n---\n'); + expect(Bun.YAML.parse(frontmatter)).toEqual({ + name: router.profileName(model), description: 'Ordinary Work worker for an assigned task.', + tools: ['*'], disallowedTools: ['Agent', 'AgentSwarm'], subagents: [], + }); + expect(body).toContain('${base_prompt}'); + expect(body).toContain('Complete only the task assigned by the parent agent.'); + expect(body).toContain('return the result to the parent for handoff.'); + expect(text).not.toContain(secret); + expect(text).not.toContain(model); + expect(text).not.toMatch(/magpie|provider|extends/i); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + } + expect(fs.readdirSync(directory).sort()).toEqual(result.names.map(name => name + '.md').sort()); +})); + +test('the same configuration is a no-op and identical primary/secondary models are deduplicated', () => fixture(directory => { + const first = ensureAgentProfiles({ directory, router, models: [models[0], models[0]] }); + const file = profileFile(directory, models[0]), before = fs.statSync(file), bytes = fs.readFileSync(file); + expect(first).toEqual({ changed: true, names: [router.profileName(models[0])] }); + expect(ensureAgentProfiles({ directory, router, models: [models[0], models[0]] })).toEqual({ changed: false, names: first.names }); + expect(fs.statSync(file).ino).toBe(before.ino); + expect(fs.statSync(file).mtimeMs).toBe(before.mtimeMs); + expect(fs.readFileSync(file).equals(bytes)).toBe(true); + expect(fs.readdirSync(directory)).toHaveLength(1); +})); + +test('old role profiles and unrelated native or user files remain when preferences change', () => fixture(directory => { + const native = path.join(directory, 'native-reader.md'), user = path.join(directory, 'user-worker.md'); + fs.writeFileSync(native, 'native profile stays'); fs.writeFileSync(user, 'user profile stays'); + ensureAgentProfiles({ directory, router, models }); + const before = fs.readFileSync(profileFile(directory, models[1])); + const updated = ensureAgentProfiles({ directory, router, models: [models[0], 'synthetic/new-secondary'] }); + expect(updated.changed).toBe(true); + expect(fs.readFileSync(profileFile(directory, models[1])).equals(before)).toBe(true); + expect(fs.readFileSync(native, 'utf8')).toBe('native profile stays'); + expect(fs.readFileSync(user, 'utf8')).toBe('user profile stays'); + expect(fs.readdirSync(directory)).toHaveLength(5); +})); + +test('a user-edited same-name profile is never overwritten and conflicts are checked before creating other profiles', () => fixture(directory => { + const edited = profileFile(directory, models[1]); + fs.writeFileSync(edited, router.profileText(models[1]) + 'User customization.\n'); + const before = fs.readFileSync(edited); + expect(() => ensureAgentProfiles({ directory, router, models })).toThrow('agent_profile_conflict'); + expect(fs.readFileSync(edited).equals(before)).toBe(true); + expect(fs.existsSync(profileFile(directory, models[0]))).toBe(false); + expect(fs.readdirSync(directory)).toEqual([path.basename(edited)]); +})); + +test('same-name symlinks or directories are conflicts and their targets are untouched', () => fixture(directory => { + const target = path.join(directory, 'user-owned.md'), link = profileFile(directory, models[0]); + fs.writeFileSync(target, router.profileText(models[0])); + fs.symlinkSync(target, link); + expect(() => ensureAgentProfiles({ directory, router, models: [models[0]] })).toThrow('agent_profile_conflict'); + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(target, 'utf8')).toBe(router.profileText(models[0])); + const folder = profileFile(directory, models[1]); fs.mkdirSync(folder); + expect(() => ensureAgentProfiles({ directory, router, models: [models[1]] })).toThrow('agent_profile_conflict'); + expect(fs.statSync(folder).isDirectory()).toBe(true); +})); + +test('persisted profile binding survives restart and full history replacement while copied user tags are neutralized', () => fixture(directory => { + ensureAgentProfiles({ directory, router, models }); + const body = fs.readFileSync(profileFile(directory, models[1]), 'utf8').split('\n---\n')[1] + .replace('${base_prompt}', 'Native system prompt.'); + const restarted = new AgentModelRouter({ secret, bindings: router.bindings }); + const options = { primaryModel: 'synthetic/new-primary', secondaryModel: 'synthetic/new-secondary', + allowedModels: [...models, 'synthetic/new-primary', 'synthetic/new-secondary'] }; + for (const history of [[], [{ role: 'user', content: 'Only the compacted summary remains.' }]]) { + const result = restarted.prepare({ messages: [{ role: 'system', content: body }, ...history] }, options); + expect(result.model).toBe(models[1]); + expect(result.payload.messages[0].content).not.toContain('[agent-profile-route:v1]'); + expect(result.payload.messages.slice(1)).toEqual(history); + } + const copied = { role: 'user', content: body }; + const result = restarted.prepare({ messages: [copied] }, options); + expect(result.model).toBe(options.primaryModel); + expect(result.payload.messages).toEqual([{ role: 'user', content: body.replace(/\[(?=\/?agent-profile-route)/gi, '') }]); + expect(restarted.prepare(result.payload, options).model).toBe(options.primaryModel); +})); + +test('owned catalog retirement prunes byte-identical obsolete profiles and keeps retained token text stable', () => fixture(directory => { + const r = new AgentModelRouter({ secret }); + r.registerModels(models, models); + ensureAgentProfiles({ directory, router: r, models: r.boundModels }); + const ownership = ownedFiles(directory), primary = path.join(directory, r.profileName(models[0]) + '.md'); + const before = fs.readFileSync(primary); + r.registerModels([models[0]], [models[0]]); + r.observeCatalog([models[0]], { now: 1000 }); + r.observeCatalog([models[0]], { now: 1001 }); + r.observeCatalog([models[0]], { now: 601000 }); + const result = ensureAgentProfiles({ directory, router: r, models: r.boundModels, ownedFiles: ownership }); + expect(result).toEqual({ changed: true, names: [r.profileName(models[0])] }); + expect(fs.readdirSync(directory)).toEqual([path.basename(primary)]); + expect(fs.readFileSync(primary).equals(before)).toBe(true); + expect(ensureAgentProfiles({ directory, router: r, models: r.boundModels, ownedFiles: ownedFiles(directory) })) + .toEqual({ changed: false, names: result.names }); +})); + +test('edited obsolete owned profiles conflict before any new profile is published', () => fixture(directory => { + ensureAgentProfiles({ directory, router, models }); + const ownership = ownedFiles(directory), retired = profileFile(directory, models[1]); + fs.appendFileSync(retired, 'User changes.\n'); + const before = fs.readFileSync(retired), next = 'synthetic/next'; + expect(() => ensureAgentProfiles({ directory, router, models: [models[0], next], ownedFiles: ownership })) + .toThrow('agent_profile_conflict'); + expect(fs.readFileSync(retired).equals(before)).toBe(true); + expect(fs.existsSync(profileFile(directory, next))).toBe(false); +})); + +test('unknown obsolete files and owned symlinks conflict without being deleted or following targets', () => fixture(directory => { + ensureAgentProfiles({ directory, router, models: [models[0]] }); + const ownership = ownedFiles(directory), unknown = path.join(directory, 'unowned.md'); + fs.writeFileSync(unknown, 'Unowned content.'); + expect(() => ensureAgentProfiles({ directory, router, models: [], ownedFiles: ownership })).toThrow('agent_profile_conflict'); + expect(fs.readFileSync(unknown, 'utf8')).toBe('Unowned content.'); + expect(fs.existsSync(profileFile(directory, models[0]))).toBe(true); + fs.unlinkSync(unknown); + const file = profileFile(directory, models[0]), bytes = fs.readFileSync(file), target = directory + '-target.md'; + fs.unlinkSync(file); fs.writeFileSync(target, bytes); fs.symlinkSync(target, file); + try { + expect(() => ensureAgentProfiles({ directory, router, models: [], ownedFiles: ownership })).toThrow('agent_profile_conflict'); + expect(fs.lstatSync(file).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(target).equals(bytes)).toBe(true); + } finally { fs.unlinkSync(target); } +})); + +test('an owned byte-identical legacy profile can be retired without retaining its old unnamespaced name', () => fixture(directory => { + const legacy = path.join(directory, 'local-agent-' + 'c'.repeat(24) + '.md'); + fs.writeFileSync(legacy, 'Synthetic old private profile.\n'); + const result = ensureAgentProfiles({ directory, router, models: [], ownedFiles: ownedFiles(directory) }); + expect(result).toEqual({ changed: true, names: [] }); + expect(fs.readdirSync(directory)).toEqual([]); +})); + +test('ownership accepts only relative profile filenames and sha256 digests', () => fixture(directory => { + const name = path.basename(profileFile(directory, models[0])); + for (const ownership of [null, [], { '../outside.md': 'a'.repeat(64) }, { [name]: 'invalid' }]) { + expect(() => ensureAgentProfiles({ directory, router, models: [models[0]], ownedFiles: ownership })) + .toThrow('agent_profile_conflict'); + expect(fs.readdirSync(directory)).toEqual([]); + } +})); + +test('obsolete profile bytes are rechecked after publication and before deletion', () => fixture(directory => { + ensureAgentProfiles({ directory, router, models: [models[0]] }); + const ownership = ownedFiles(directory), retired = profileFile(directory, models[0]), originalLink = fs.linkSync; + const link = spyOn(fs, 'linkSync').mockImplementation((source, target) => { + originalLink(source, target); + fs.writeFileSync(retired, 'Concurrent external edit.'); + }); + try { + expect(() => ensureAgentProfiles({ directory, router, models: [models[1]], ownedFiles: ownership })) + .toThrow('agent_profile_conflict'); + expect(fs.readFileSync(retired, 'utf8')).toBe('Concurrent external edit.'); + } finally { link.mockRestore(); } +})); + +test('a malformed router name cannot create a profile outside the namespaced filename format', () => fixture(directory => { + const escapeName = '../' + path.basename(directory) + '-escape', escaped = path.resolve(directory, escapeName + '.md'); + try { + for (const name of [escapeName, 'local-agent-' + 'a'.repeat(24), 'local-agent-' + 'a'.repeat(12) + '-invalid']) { + expect(() => ensureAgentProfiles({ directory, router: { profileName: () => name, profileText: () => 'text' }, models })) + .toThrow('agent_profile_conflict'); + expect(fs.readdirSync(directory)).toEqual([]); + } + } finally { if (fs.existsSync(escaped)) fs.unlinkSync(escaped); } +})); + +// Problem 3 (red): directory traversal must skip every entry whose basename +// starts '.', before stat/read/hash/delete/conflict, even when a valid +// ownedFiles map is supplied. The dot entries must remain and never be read, +// hashed, or deleted (never read implies never hashed, since hashing only +// happens inside verifyOwned after readFileSync). +test('dot-prefixed entries are skipped before stat/read/hash/delete and never conflict', () => fixture(directory => { + ensureAgentProfiles({ directory, router, models: [models[0]] }); + const ownership = ownedFiles(directory); + const profile = profileFile(directory, models[0]); + const profileBytes = fs.readFileSync(profile); + + const dotFile = path.join(directory, '.DS_Store'); + const dotMd = path.join(directory, '.hidden.md'); + const dotDir = path.join(directory, '.config'); + const dotLink = path.join(directory, '.config-link'); + fs.writeFileSync(dotFile, 'desktop metadata'); + fs.writeFileSync(dotMd, 'hidden markdown'); + fs.mkdirSync(dotDir); + // Hidden symlink example: a directory alias to .config. + fs.symlinkSync(dotDir, dotLink, process.platform === 'win32' ? 'junction' : 'dir'); + const dotPaths = new Set([dotFile, dotMd, dotDir, dotLink]); + + const read = spyOn(fs, 'readFileSync'); + const lstat = spyOn(fs, 'lstatSync'); + const stat = spyOn(fs, 'statSync'); + const unlink = spyOn(fs, 'unlinkSync'); + try { + expect(() => ensureAgentProfiles({ directory, router, models: [models[0]], ownedFiles: ownership })).not.toThrow(); + // Never stat/read/delete any dot-prefixed entry. + for (const mocked of [read, lstat, stat, unlink]) { + for (const call of mocked.mock.calls) { + if (typeof call[0] === 'string') expect(dotPaths.has(call[0])).toBe(false); + } + } + } finally { + read.mockRestore(); lstat.mockRestore(); stat.mockRestore(); unlink.mockRestore(); + } + + expect(fs.readFileSync(dotFile, 'utf8')).toBe('desktop metadata'); + expect(fs.readFileSync(dotMd, 'utf8')).toBe('hidden markdown'); + expect(fs.statSync(dotDir).isDirectory()).toBe(true); + expect(fs.lstatSync(dotLink).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(profile).equals(profileBytes)).toBe(true); +})); + +test('ordinary unknown foo.md still conflicts while dot-prefixed entries are skipped and preserved', () => fixture(directory => { + ensureAgentProfiles({ directory, router, models: [models[0]] }); + const ownership = ownedFiles(directory); + const profile = profileFile(directory, models[0]); + const profileBytes = fs.readFileSync(profile); + + const dotFile = path.join(directory, '.DS_Store'); + const foo = path.join(directory, 'foo.md'); + fs.writeFileSync(dotFile, 'desktop metadata'); + fs.writeFileSync(foo, 'ordinary unknown file'); + + expect(() => ensureAgentProfiles({ directory, router, models: [models[0]], ownedFiles: ownership })) + .toThrow('agent_profile_conflict'); + + expect(fs.readFileSync(dotFile, 'utf8')).toBe('desktop metadata'); + expect(fs.readFileSync(foo, 'utf8')).toBe('ordinary unknown file'); + expect(fs.readFileSync(profile).equals(profileBytes)).toBe(true); +})); diff --git a/packages/kimi-desktop/agent-routing.mjs b/packages/kimi-desktop/agent-routing.mjs new file mode 100644 index 000000000..4cf1db263 --- /dev/null +++ b/packages/kimi-desktop/agent-routing.mjs @@ -0,0 +1,525 @@ +import crypto from 'node:crypto'; + +const RESPONSE_LIMIT = 32 * 1024 * 1024; +const PROFILE_SECTION = /\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/g; +const PROFILE_BINDING = /^\[agent-profile-route:v1\]\r?\n([A-Za-z0-9_-]{43}):([a-f0-9]{64})\r?\n\[\/agent-profile-route\]$/; +const RESERVED_PROFILE = /\[\/?agent-profile-route\b/i; +const ROUTE_MARKER = /\[(?=\/?agent-profile-route)/gi; +const ABSENCE_COUNT_THRESHOLD = 3; +const ABSENCE_WINDOW_MS = 600000; +const PROFILE_TOKEN = /^[A-Za-z0-9_-]{43}$/; +const CALL_INTENT = /^call_rs1_([a-f0-9]{16})_([dpsn])_([a-f0-9]{24})$/; +const INTENT_ROLES = { p: 'primary', s: 'secondary', n: 'native' }; +const ROLE_DESCRIPTION = 'Available models (pass via model):\n' + + '- primary: Use a new ordinary Work worker profile on the configured primary model; replaces subagent_type.\n' + + '- secondary [default]: Use a new ordinary Work worker profile on the configured secondary model; replaces subagent_type. ' + + 'The default applies only when both model and subagent_type are omitted.\n' + + '- native: Preserve the supplied native subagent_type and its specialized behavior. ' + + 'Omitting model preserves an explicit native subagent_type. ' + + 'Resumed and forked agents keep their existing profile and model binding for every selection.'; +const MODEL_DESCRIPTION = 'Optional selection for a new subagent. primary or secondary uses a new ordinary Work worker ' + + 'profile and replaces subagent_type. When both model and subagent_type are omitted, secondary is used. ' + + 'Omitting model preserves an explicit native subagent_type. native preserves the supplied native ' + + 'subagent_type. Resuming or forking keeps the existing profile and model binding.'; +const record = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const failure = code => Object.assign(Error(code), { code }); +const modelID = value => typeof value === 'string' && value.length > 0 && value.length <= 1024 + && !/[\x00-\x1f]/.test(value); +const scopedRole = value => value === 'primary' || value === 'secondary'; +const bounded = text => { + if (Buffer.byteLength(text) > RESPONSE_LIMIT) throw failure('agent_routing_response_too_large'); + return text; +}; + +function neutralizeText(text) { + return text.replace(ROUTE_MARKER, ''); +} + +// Removes the leading bracket from route markers in any non-identity string +// without authenticating or binding it. Non-destructive and reference- +// preserving: unchanged data keeps its original reference, and function values +// are never called, so any marker produced only at JSON.stringify time (e.g. a +// toJSON hook) still trips the serialized reserved-marker guard. +export function neutralizeRouteMarkers(value) { + if (typeof value === 'string') return neutralizeText(value); + if (Array.isArray(value)) { + let changed = false; + const next = new Array(value.length); + for (let index = 0; index < value.length; index++) { + const item = neutralizeRouteMarkers(value[index]); + if (item !== value[index]) changed = true; + next[index] = item; + } + return changed ? next : value; + } + if (record(value)) { + let changed = false; + const entries = []; + for (const [key, entry] of Object.entries(value)) { + const nextKey = neutralizeText(key); + const nextEntry = neutralizeRouteMarkers(entry); + if (nextKey !== key || nextEntry !== entry) changed = true; + entries.push([nextKey, nextEntry]); + } + return changed ? Object.fromEntries(entries) : value; + } + return value; +} + +function nativeAgent(tool) { + const schema = tool?.function?.parameters; + return tool?.type === 'function' && tool.function.name === 'Agent' && schema?.type === 'object' + && record(schema.properties) && schema.properties.prompt?.type === 'string' + && schema.properties.description?.type === 'string' && Array.isArray(schema.required) + && schema.required.includes('prompt') && schema.required.includes('description'); +} + +function roleTool(tool) { + const fn = tool.function, schema = fn.parameters; + const original = typeof fn.description === 'string' ? fn.description : ''; + const models = /^Available models \(pass via model\):[^\S\r\n]*(?:\r?\n-[^\r\n]*)*/gm; + const description = models.test(original) ? original.replace(models, ROLE_DESCRIPTION) + : original + (original ? '\n\n' : '') + ROLE_DESCRIPTION; + return { ...tool, function: { ...fn, description, parameters: { ...schema, + properties: { ...schema.properties, model: { + type: 'string', enum: ['primary', 'secondary', 'native'], description: MODEL_DESCRIPTION, + } }, required: schema.required.filter(name => name !== 'model'), + } } }; +} + +// Only an authenticated section in a profile's system/developer prompt selects +// a model. Native compaction keeps this prompt even after discarding all chat +// history. The caller owns key/profile persistence; this module has no I/O. +export class AgentModelRouter { + #key; + #namespace; + #bindings = new Map(); + #roles = new Map(); + #absence = new Map(); + + constructor({ secret, bindings = {}, roles = {}, absence = {} }) { + if (typeof secret !== 'string' || !/^[a-f0-9]{64}$/i.test(secret)) { + throw failure('agent_routing_invalid_secret'); + } + this.#key = Buffer.from(secret, 'hex'); + this.#namespace = crypto.createHash('sha256').update(secret.toLowerCase()).digest('hex').slice(0, 12); + if (!record(bindings)) throw failure('agent_profile_unbound'); + for (const [token, model] of Object.entries(bindings)) { + if (!PROFILE_TOKEN.test(token) || !modelID(model)) throw failure('agent_profile_unbound'); + this.#bindings.set(token, model); + } + if (!record(roles)) throw failure('agent_profile_unbound'); + for (const [token, role] of Object.entries(roles)) { + // A role extends an owned token; it cannot introduce a route of its own. + if (!this.#bindings.has(token) || !scopedRole(role)) throw failure('agent_profile_unbound'); + this.#roles.set(token, role); + } + if (!record(absence)) throw failure('agent_profile_unbound'); + for (const [model, entry] of Object.entries(absence)) { + if (!modelID(model) || !record(entry) + || !Number.isSafeInteger(entry.count) || entry.count < 0 + || !Number.isSafeInteger(entry.firstMissingAt) || entry.firstMissingAt < 0) { + throw failure('agent_profile_unbound'); + } + this.#absence.set(model, { count: entry.count, firstMissingAt: entry.firstMissingAt }); + } + } + + get namespace() { return this.#namespace; } + get bindings() { return Object.fromEntries(this.#bindings); } + get roles() { return Object.fromEntries(this.#roles); } + get boundModels() { return [...new Set(this.#bindings.values())]; } + get boundProfiles() { + const profiles = new Map(); + for (const [token, model] of this.#bindings) { + const role = this.#roles.get(token); + profiles.set(JSON.stringify([model, role ?? null]), { model, ...(role ? { role } : {}) }); + } + return [...profiles.values()]; + } + get absence() { return Object.fromEntries([...this.#absence].map(([model, entry]) => [model, { ...entry }])); } + + #token(model, role) { + for (const [token, bound] of this.#bindings) { + if (bound === model && this.#roles.get(token) === role) return token; + } + let token; + do { token = crypto.randomBytes(32).toString('base64url'); } while (this.#bindings.has(token)); + this.#bindings.set(token, model); + if (role !== undefined) this.#roles.set(token, role); + return token; + } + + registerModels(models, allowedModels) { + if (!Array.isArray(models) || !Array.isArray(allowedModels) + || models.some(model => !modelID(model) || !allowedModels.includes(model))) { + throw failure('model_not_enabled_for_kimi'); + } + // Only validate and pre-allocate. Pruning and absence counting happen in + // observeCatalog after a successful catalog sync. + for (const model of models) this.#token(model); + return this.bindings; + } + + registerRoles(models, allowedModels) { + if (!record(models) || !Array.isArray(allowedModels) + || !modelID(models.primaryModel) || !allowedModels.includes(models.primaryModel) + || (models.secondaryModel !== undefined && models.secondaryModel !== '' + && (!modelID(models.secondaryModel) || !allowedModels.includes(models.secondaryModel)))) { + throw failure('model_not_enabled_for_kimi'); + } + return { + primary: this.#token(models.primaryModel, 'primary'), + secondary: this.#token(models.secondaryModel || models.primaryModel, 'secondary'), + }; + } + + observeCatalog(allowedModels, { now = Date.now(), allowRetirement = true } = {}) { + if (!Array.isArray(allowedModels) || !Number.isSafeInteger(now) || now < 0) { + throw failure('model_not_enabled_for_kimi'); + } + for (const model of this.boundModels) { + if (allowedModels.includes(model)) { + // Present again: clear any absence immediately and keep the original token. + this.#absence.delete(model); + } else { + const entry = this.#absence.get(model); + const count = Math.min((entry?.count ?? 0) + 1, ABSENCE_COUNT_THRESHOLD); + const firstMissingAt = entry?.firstMissingAt ?? now; + this.#absence.set(model, { count, firstMissingAt }); + if (allowRetirement && count >= ABSENCE_COUNT_THRESHOLD && now - firstMissingAt >= ABSENCE_WINDOW_MS) { + for (const [token, bound] of [...this.#bindings]) if (bound === model) { + this.#bindings.delete(token); + this.#roles.delete(token); + } + this.#absence.delete(model); + } + } + } + return { bindings: this.bindings, absence: this.absence }; + } + + #mac(token) { + return crypto.createHmac('sha256', this.#key).update('agent-profile-route:v1:' + token).digest(); + } + + #intentMAC(nonce, code, args) { + const subtype = Object.hasOwn(args, 'subagent_type') ? args.subagent_type : ''; + return crypto.createHmac('sha256', this.#key).update('agent-call-intent:v1:') + .update(JSON.stringify([nonce, code, subtype])).digest().subarray(0, 12); + } + + #intentID(code, args) { + const nonce = crypto.randomBytes(8).toString('hex'); + return `call_rs1_${nonce}_${code}_${this.#intentMAC(nonce, code, args).toString('hex')}`; + } + + // Native history stores the translated arguments. Authenticate the original + // selection in the opaque call ID so an audit can distinguish an omitted + // default from an explicit role. IDs never establish a request's route. + #restoreIntent(call) { + if (call?.type !== 'function' || call.function?.name !== 'Agent' + || typeof call.id !== 'string' || typeof call.function.arguments !== 'string') return call; + const match = CALL_INTENT.exec(call.id); + if (!match) return call; + let args; + try { args = JSON.parse(call.function.arguments); } catch { return call; } + if (!record(args) || Object.hasOwn(args, 'model') + || !crypto.timingSafeEqual(this.#intentMAC(match[1], match[2], args), Buffer.from(match[3], 'hex')) + || match[2] === 'd') return call; + return { ...call, function: { ...call.function, + arguments: JSON.stringify({ ...args, model: INTENT_ROLES[match[2]] }), + } }; + } + + profileName(model, role) { + if (!modelID(model) || (role !== undefined && !scopedRole(role))) throw failure('model_not_enabled_for_kimi'); + const identity = role === undefined ? 'agent-profile-name:v1:' + model + : 'agent-profile-name:v2:' + JSON.stringify([model, role]); + return 'local-agent-' + this.#namespace + '-' + + crypto.createHmac('sha256', this.#key).update(identity).digest('hex').slice(0, 24); + } + + profileText(model, role) { + const name = this.profileName(model, role); + const token = this.#token(model, role); + return '---\n' + + `name: ${name}\n` + + 'description: Ordinary Work worker for an assigned task.\n' + + 'tools: ["*"]\n' + + 'disallowedTools: ["Agent", "AgentSwarm"]\n' + + 'subagents: []\n' + + '---\n\n' + + '${base_prompt}\n\n' + + `[agent-profile-route:v1]\n${token}:${this.#mac(token).toString('hex')}\n[/agent-profile-route]\n\n` + + 'Complete only the task assigned by the parent agent. Work within the inherited tool permissions, ' + + 'and return the result to the parent for handoff. Do not launch or coordinate additional agents.\n'; + } + + #strip(text) { + let model = null, role, invalid = false; + const clean = text.replace(PROFILE_SECTION, (section, offset) => { + const match = PROFILE_BINDING.exec(section); + const end = offset + section.length; + // MUTATION protocol-auth: malformed, foreign, and unknown routes never fall back. + if (!match || (offset > 0 && text[offset - 1] !== '\n') + || (end < text.length && !/^\r?\n/.test(text.slice(end))) + || !this.#bindings.has(match[1]) + || !crypto.timingSafeEqual(this.#mac(match[1]), Buffer.from(match[2], 'hex'))) invalid = true; + else { + model = this.#bindings.get(match[1]); + role = this.#roles.get(match[1]); + } + return ''; + }); + if (invalid || RESERVED_PROFILE.test(clean)) throw failure('agent_profile_unbound'); + return { text: clean, model, role }; + } + + prepare(payload, { primaryModel, allowedModels, enableRoles = true, includeRouteRole = false }) { + let binding = null, routeRole, translateResponse = false; + const messages = Array.isArray(payload.messages) ? payload.messages.map(message => { + if (message?.role === 'system' || message?.role === 'developer') { + const cleanText = text => { + const clean = this.#strip(text); + if (clean.model !== null) { + binding = clean.model; + routeRole = clean.role; + } + return clean.text; + }; + if (typeof message.content === 'string') return { ...message, content: cleanText(message.content) }; + if (Array.isArray(message.content)) return { ...message, content: message.content.map(part => + part?.type === 'text' && typeof part.text === 'string' ? { ...part, text: cleanText(part.text) } : part) }; + } + if (enableRoles && message?.role === 'assistant' && Array.isArray(message.tool_calls)) { + const calls = message.tool_calls.map(call => this.#restoreIntent(call)); + if (calls.some((call, index) => call !== message.tool_calls[index])) return { ...message, tool_calls: calls }; + } + return message; + }) : payload.messages; + const tools = enableRoles && Array.isArray(payload.tools) ? payload.tools.map(tool => { + if (!nativeAgent(tool)) return tool; + translateResponse = true; + return roleTool(tool); + }) : payload.tools; + const model = binding ?? primaryModel; + if (binding !== null && (!Array.isArray(allowedModels) || !allowedModels.includes(binding))) { + throw failure('agent_profile_unbound'); + } + if (!modelID(model) || !Array.isArray(allowedModels) || !allowedModels.includes(model)) { + throw failure('model_not_enabled_for_kimi'); + } + const prepared = { ...payload, model, + ...(Array.isArray(messages) ? { messages } : {}), ...(Array.isArray(tools) ? { tools } : {}), + }; + // Identity text is already authenticated and stripped above; every other + // field is neutralized rather than bound. The serialized boundary guard + // still rejects any residual reserved marker, including one produced only + // at JSON.stringify time such as a toJSON hook. + const neutralized = neutralizeRouteMarkers(prepared); + if (RESERVED_PROFILE.test(JSON.stringify(neutralized))) throw failure('agent_profile_unbound'); + return { payload: neutralized, model, translateResponse, + ...(routeRole ? { routeRole } : includeRouteRole && binding !== null ? { routeRole: null } : {}) }; + } + + #translateCall(call, { primaryModel, secondaryModel, roleOptionsEnabled = false }) { + if (call?.type !== 'function' || call.function?.name !== 'Agent') return call; + let args; + try { args = JSON.parse(call.function.arguments); } + catch { return call; } + if (!record(args) || typeof args.prompt !== 'string' || typeof args.description !== 'string' + || (Object.hasOwn(args, 'model') && !['primary', 'secondary', 'native'].includes(args.model))) { + return call; + } + // MUTATION protocol-native: an explicitly supplied native profile is not a default worker. + const role = args.model ?? (Object.hasOwn(args, 'subagent_type') ? 'native' : 'secondary'); + const translated = { ...args }; + delete translated.model; + if (role !== 'native' && !(typeof args.resume === 'string' && args.resume.length > 0) && args.fork !== true) { + translated.subagent_type = this.profileName(role === 'primary' ? primaryModel : secondaryModel || primaryModel, + roleOptionsEnabled === true ? role : undefined); + } + const code = Object.hasOwn(args, 'model') ? { primary: 'p', secondary: 's', native: 'n' }[args.model] : 'd'; + return { ...call, id: this.#intentID(code, translated), + function: { ...call.function, arguments: bounded(JSON.stringify(translated)) } }; + } + + transformJSON(completion, models) { + bounded(JSON.stringify(completion)); + if (!Array.isArray(completion?.choices)) return completion; + const choices = completion.choices.map(choice => { + if (!Array.isArray(choice?.message?.tool_calls)) return choice; + if (choice.message.tool_calls.some(call => call?.type === 'function' && call.function?.name === 'Agent') + && ['length', 'content_filter', 'error', 'cancelled'].includes(choice.finish_reason)) { + return choice; + } + return { ...choice, message: { ...choice.message, + tool_calls: choice.message.tool_calls.map(call => this.#translateCall(call, models)), + } }; + }); + const result = { ...completion, choices }; + bounded(JSON.stringify(result)); + return result; + } + + async *transformSSE(body, models) { + const decoder = new TextDecoder('utf-8'); + const pending = new Map(), native = new Map(), passthrough = new Set(), finished = new Set(), envelopes = new Map(); + let inputBytes = 0, outputBytes = 0, buffer = '', sequence = 0, raw = false; + const output = text => { + outputBytes += Buffer.byteLength(text); + if (outputBytes > RESPONSE_LIMIT) throw failure('agent_routing_response_too_large'); + return text; + }; + const validFragment = fragment => record(fragment) && Number.isSafeInteger(fragment.index) && fragment.index >= 0 + && (!Object.hasOwn(fragment, 'id') || typeof fragment.id === 'string') + && (!Object.hasOwn(fragment, 'function') || (record(fragment.function) + && ['name', 'arguments'].every(key => !Object.hasOwn(fragment.function, key) + || typeof fragment.function[key] === 'string'))); + const release = choiceIndex => { + const calls = pending.get(choiceIndex); + pending.delete(choiceIndex); + return calls ? [...calls.values()].flatMap(call => call.fragments) + .sort((a, b) => a.sequence - b.sequence).map(entry => entry.fragment) : []; + }; + const synthetic = (choiceIndex, calls, finishReason = null) => 'data: ' + + JSON.stringify({ ...envelopes.get(choiceIndex), choices: [{ index: choiceIndex, + delta: { tool_calls: calls }, finish_reason: finishReason }] }) + '\n\n'; + const releaseAll = () => [...pending.keys()].map(index => synthetic(index, release(index))); + const append = (choiceIndex, fragment) => { + if (native.get(choiceIndex)?.has(fragment.index)) return [fragment]; + if (!pending.has(choiceIndex)) pending.set(choiceIndex, new Map()); + const calls = pending.get(choiceIndex); + if (!calls.has(fragment.index)) calls.set(fragment.index, { fields: {}, fn: {}, ids: [], names: [], args: [], + fragments: [], hasID: false, hasName: false, hasArgs: false }); + const call = calls.get(fragment.index); + call.fragments.push({ sequence: sequence++, fragment }); + for (const [key, value] of Object.entries(fragment)) { + if (key !== 'id' && key !== 'function') call.fields[key] = value; + } + if (Object.hasOwn(fragment, 'id')) { call.hasID = true; call.ids.push(fragment.id); } + if (Object.hasOwn(fragment, 'function')) { + for (const [key, value] of Object.entries(fragment.function)) { + if (key === 'name') { call.hasName = true; call.names.push(value); } + else if (key === 'arguments') { call.hasArgs = true; call.args.push(value); } + else call.fn[key] = value; + } + } + // MUTATION protocol-incremental: only Agent and its unresolved name prefix wait. + if (!'Agent'.startsWith(call.names.join('')) || (call.fields.type && call.fields.type !== 'function')) { + calls.delete(fragment.index); + if (!calls.size) pending.delete(choiceIndex); + if (!native.has(choiceIndex)) native.set(choiceIndex, new Set()); + native.get(choiceIndex).add(fragment.index); + return call.fragments.map(entry => entry.fragment); + } + return []; + }; + const flush = (choiceIndex, finishReason) => { + const calls = pending.get(choiceIndex); + pending.delete(choiceIndex); + native.delete(choiceIndex); + finished.add(choiceIndex); + return calls ? [...calls.values()].map(call => { + const full = { ...call.fields, ...(call.hasID ? { id: call.ids.join('') } : {}), function: { + ...call.fn, ...(call.hasName ? { name: call.names.join('') } : {}), + ...(call.hasArgs ? { arguments: call.args.join('') } : {}), + } }; + return ['tool_calls', 'stop'].includes(finishReason) ? this.#translateCall(full, models) : full; + }) : []; + }; + const event = (frame, delimiter) => { + if (raw) return { texts: [frame + delimiter] }; + const lines = frame.split(/\r?\n/), data = [], dataLines = []; + let eventName = ''; + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + if (line === 'data' || line.startsWith('data:')) { + data.push(line === 'data' ? '' : line.slice(5).replace(/^ /, '')); dataLines.push(index); + } else if (line.startsWith('event:')) eventName = line.slice(6).replace(/^ /, ''); + } + if (!dataLines.length) return { texts: [frame + delimiter] }; + const text = data.join('\n'); + if (text.trim() === '[DONE]') { + // MUTATION protocol-done: missing finish_reason still releases every choice before DONE. + const texts = [...pending.keys()].map(index => synthetic(index, flush(index, 'tool_calls'), 'tool_calls')); + return { texts: [...texts, frame + delimiter], done: true }; + } + if (!text.trim()) return { texts: [frame + delimiter] }; + let value; + try { value = JSON.parse(text); } + catch { raw = true; return { texts: [...releaseAll(), frame + delimiter] }; } + if (eventName === 'error' || (record(value) && Object.hasOwn(value, 'error'))) { + pending.clear(); native.clear(); raw = true; + return { texts: [frame + delimiter] }; + } + if (!Array.isArray(value?.choices)) return { texts: [frame + delimiter] }; + if (value.choices.some(choice => !record(choice) + || ((choice.delta?.tool_calls !== undefined || pending.has(choice.index)) + && (!Number.isSafeInteger(choice.index) || choice.index < 0)))) { + raw = true; + return { texts: [...releaseAll(), frame + delimiter] }; + } + const { choices: ignoredChoices, usage: ignoredUsage, ...envelope } = value; + let changed = false; + const prefixes = []; + const choices = value.choices.map(choice => { + const fragments = choice.delta?.tool_calls; + if (passthrough.has(choice.index) || finished.has(choice.index)) return choice; + envelopes.set(choice.index, envelope); + // MUTATION protocol-index: untrackable native fragments do not abort a whole turn. + if (fragments !== undefined && (!Array.isArray(fragments) || !fragments.every(validFragment))) { + passthrough.add(choice.index); + const held = release(choice.index); + if (!held.length) return choice; + if (!Array.isArray(fragments)) { + prefixes.push(synthetic(choice.index, held)); + return choice; + } + changed = true; + return { ...choice, delta: { ...choice.delta, tool_calls: [...held, ...fragments] } }; + } + if (!fragments?.length && !pending.has(choice.index)) { + if (choice.finish_reason != null) { finished.add(choice.index); native.delete(choice.index); } + return choice; + } + const forwarded = (fragments || []).flatMap(fragment => append(choice.index, fragment)); + if (choice.finish_reason != null) forwarded.push(...flush(choice.index, choice.finish_reason)); + if (forwarded.length === (fragments?.length || 0) + && forwarded.every((fragment, index) => fragment === fragments[index])) return choice; + const delta = { ...choice.delta }; + if (forwarded.length) delta.tool_calls = forwarded; + else delete delta.tool_calls; + changed = true; + return { ...choice, delta }; + }); + if (!changed) return { texts: [...prefixes, frame + delimiter] }; + const serialized = bounded(JSON.stringify({ ...value, choices })); + const first = dataLines[0], indexes = new Set(dataLines), newline = delimiter.startsWith('\r\n') ? '\r\n' : '\n'; + return { texts: [...prefixes, lines.flatMap((line, index) => index === first ? ['data: ' + serialized] + : indexes.has(index) ? [] : [line]).join(newline) + delimiter] }; + }; + for await (const chunk of body) { + const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + inputBytes += bytes.byteLength; + if (inputBytes > RESPONSE_LIMIT) throw failure('agent_routing_response_too_large'); + buffer += decoder.decode(bytes, { stream: true }); + let boundary; + while ((boundary = /\r\n\r\n|\n\n/.exec(buffer))) { + const frame = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary[0].length); + const result = event(frame, boundary[0]); + for (const text of result.texts) yield output(text); + if (result.done) return; + } + } + buffer += decoder.decode(); + if (buffer) { + const result = event(buffer, ''); + for (const text of result.texts) yield output(text); + if (result.done) return; + } + for (const text of releaseAll()) yield output(text); + } +} diff --git a/packages/kimi-desktop/agent-routing.test.mjs b/packages/kimi-desktop/agent-routing.test.mjs new file mode 100644 index 000000000..d84e4beff --- /dev/null +++ b/packages/kimi-desktop/agent-routing.test.mjs @@ -0,0 +1,996 @@ +import { test, expect, spyOn } from 'bun:test'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import { AgentModelRouter, neutralizeRouteMarkers } from './agent-routing.mjs'; + +const secret = 'a3'.repeat(32); +const models = { primaryModel: 'synthetic/primary', secondaryModel: 'synthetic/secondary' }; +const allowedModels = Object.values(models); +const fixture = JSON.parse(fs.readFileSync(new URL('./test-fixtures/v2-agent-schema.json', import.meta.url), 'utf8')); +const nativeTool = fixture.request.tools[0]; +const router = (bindings = {}) => new AgentModelRouter({ secret, bindings }); +const options = { ...models, allowedModels }; +const args = (extra = {}) => ({ prompt: 'Read magpie-local-0123456789abcdef01234567.txt,返回文件。', + description: 'Read one test file', ...extra }); +const call = (parameters, name = 'Agent', id = 'call-1') => ({ id, type: 'function', function: { + name, arguments: typeof parameters === 'string' ? parameters : JSON.stringify(parameters), +} }); +const completion = (calls, extra = {}) => ({ id: 'completion-1', object: 'chat.completion', + choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: calls }, finish_reason: 'tool_calls' }], + ...extra, +}); +const translated = (r, parameters, configuration = models) => + JSON.parse(r.transformJSON(completion([call(parameters)]), configuration).choices[0].message.tool_calls[0].function.arguments); +const prepare = (r, messages, configuration = options, extra = {}) => + r.prepare({ model: 'native/current', messages, ...extra }, configuration); +const user = content => ({ role: 'user', content }); +const system = content => ({ role: 'system', content }); +const profileBody = (r, model = models.secondaryModel) => r.profileText(model).split('\n---\n')[1] + .replace('${base_prompt}', 'Native base system prompt.'); +const section = (r, model = models.secondaryModel) => + profileBody(r, model).match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; +const event = (value, { crlf = false, multiline = false, metadata = '' } = {}) => + (metadata + JSON.stringify(value, null, multiline ? 2 : undefined).split('\n').map(line => 'data: ' + line).join('\n') + + '\n\n').replace(/\n/g, crlf ? '\r\n' : '\n'); +const chunk = (choices, extra = {}) => ({ id: 'stream-1', object: 'chat.completion.chunk', model: 'wire/model', + created: 123, choices, ...extra }); +const choice = (index, delta, finish_reason = null) => ({ index, delta, finish_reason }); +async function* bytes(text, size = 37) { + const encoded = Buffer.from(text); + for (let index = 0; index < encoded.length; index += size) yield encoded.subarray(index, index + size); +} +async function readStream(r, text, size = 37) { + let result = ''; + for await (const part of r.transformSSE(bytes(text, size), models)) result += part; + return result; +} +async function fulfilled(promise) { + const outcome = await promise.then(value => ({ value }), error => ({ error })); + expect(outcome).toHaveProperty('value'); + return outcome.value; +} +function events(text) { + return text.split(/\r\n\r\n|\n\n/).flatMap(frame => { + const data = frame.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).replace(/^ /, '')).join('\n'); + return !data || data.trim() === '[DONE]' ? [] : [JSON.parse(data)]; + }); +} +function streamCalls(text) { + return events(text).flatMap(value => value.choices || []).flatMap(value => value.delta?.tool_calls || []); +} +function assembledCalls(text) { + const calls = new Map(); + for (const choice of events(text).flatMap(value => value.choices || [])) { + for (const fragment of choice.delta?.tool_calls || []) { + const key = choice.index + ':' + fragment.index; + if (!calls.has(key)) calls.set(key, { index: fragment.index, function: {} }); + const call = calls.get(key); + for (const [name, value] of Object.entries(fragment)) { + if (name === 'id') call.id = (call.id || '') + value; + else if (name !== 'function') call[name] = value; + } + for (const [name, value] of Object.entries(fragment.function || {})) { + call.function[name] = (call.function[name] || '') + value; + } + } + } + return [...calls.values()]; +} + +test('the routing key is required and is never an enumerable property or output', () => { + for (const invalid of ['', 'test', 'a'.repeat(63), 'z'.repeat(64), null]) { + expect(() => new AgentModelRouter({ secret: invalid })).toThrow('agent_routing_invalid_secret'); + } + const r = router(); + expect(JSON.stringify(r)).toBe('{}'); + expect(r.profileText(models.primaryModel)).not.toContain(secret); + expect(r.profileName(models.primaryModel)).toMatch(/^local-agent-[a-f0-9]{12}-[a-f0-9]{24}$/); +}); + +test('opaque binding snapshots survive restart and registerModels only pre-allocates without pruning', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + const original = r.bindings, body = profileBody(r); + expect(r.bindings).toEqual(original); + expect(Object.values(original).sort()).toEqual([...allowedModels].sort()); + expect(Object.keys(original).every(token => /^[A-Za-z0-9_-]{43}$/.test(token))).toBe(true); + expect(r.namespace).toBe(crypto.createHash('sha256').update(secret).digest('hex').slice(0, 12)); + expect(new AgentModelRouter({ secret: secret.toUpperCase() }).namespace).toBe(r.namespace); + expect(r.profileName(models.primaryModel)).toStartWith('local-agent-' + r.namespace + '-'); + const suffix = crypto.createHmac('sha256', Buffer.from(secret, 'hex')) + .update('agent-profile-name:v1:' + models.primaryModel).digest('hex').slice(0, 24); + expect(r.profileName(models.primaryModel)).toBe('local-agent-' + r.namespace + '-' + suffix); + expect(suffix).not.toBe(crypto.createHash('sha256').update(models.primaryModel).digest('hex').slice(0, 24)); + expect(new AgentModelRouter({ secret: secret.toUpperCase() }).profileName(models.primaryModel)).toBe(r.profileName(models.primaryModel)); + const sameKey = router(); + expect(() => prepare(sameKey, [system(body)])).toThrow('agent_profile_unbound'); + const restarted = router(original); + expect(prepare(restarted, [system(body)]).model).toBe(models.secondaryModel); + expect(restarted.profileText(models.secondaryModel)).toBe(r.profileText(models.secondaryModel)); + original[Object.keys(original)[0]] = 'synthetic/changed'; + expect(restarted.bindings).toEqual(r.bindings); + const snapshot = restarted.bindings; + delete snapshot[Object.keys(snapshot)[0]]; + expect(restarted.boundModels).toHaveLength(2); + const retained = restarted.registerModels([models.primaryModel], allowedModels); + expect(Object.values(retained)).toContain(models.secondaryModel); + restarted.registerModels([models.primaryModel], [models.primaryModel]); + expect(restarted.boundModels).toHaveLength(2); + expect(Object.values(restarted.bindings)).toEqual([...allowedModels].sort()); + expect(prepare(restarted, [system(body)]).model).toBe(models.secondaryModel); +}); + +test('binding import and model registration reject invalid ownership without changing valid saved bindings', () => { + const token = 'A'.repeat(43); + for (const bindings of [null, [], { bad: models.primaryModel }, { [token]: '' }, { [token]: 'bad\nmodel' }]) { + expect(() => new AgentModelRouter({ secret, bindings })).toThrow('agent_profile_unbound'); + } + const r = new AgentModelRouter({ secret, bindings: { [token]: models.primaryModel, ['B'.repeat(43)]: models.primaryModel } }); + expect(r.boundModels).toEqual([models.primaryModel]); + const before = r.bindings; + for (const [requested, allowed] of [[null, allowedModels], [[], null], [[models.secondaryModel], [models.primaryModel]], + [[''], allowedModels], [[models.primaryModel, 'unavailable/model'], allowedModels]]) { + expect(() => r.registerModels(requested, allowed)).toThrow('model_not_enabled_for_kimi'); + expect(r.bindings).toEqual(before); + } +}); + +test('a random token collision never overwrites another model binding', () => { + const first = Buffer.alloc(32, 1), second = Buffer.alloc(32, 2); + const token = first.toString('base64url'); + const r = new AgentModelRouter({ secret, bindings: { [token]: models.primaryModel } }); + const random = spyOn(crypto, 'randomBytes').mockReturnValueOnce(first).mockReturnValueOnce(second); + try { + r.profileText(models.secondaryModel); + expect(r.bindings).toEqual({ [token]: models.primaryModel, [second.toString('base64url')]: models.secondaryModel }); + expect(random).toHaveBeenCalledTimes(2); + } finally { random.mockRestore(); } +}); + +test('disabled roles preserve native tools and assistant audit bytes while retaining authenticated old worker bindings', () => { + const r = router(); + const signed = r.transformJSON(completion([call(args({ model: 'primary' }))]), models).choices[0].message.tool_calls[0]; + const assistant = { role: 'assistant', tool_calls: [signed] }, tools = [nativeTool]; + const disabled = { ...options, enableRoles: false }; + const result = prepare(r, [assistant], disabled, { tools }); + expect(result.translateResponse).toBe(false); + expect(result.payload.tools).toBe(tools); + expect(JSON.stringify(result.payload.tools)).toBe(JSON.stringify(tools)); + expect(result.payload.messages[0]).toBe(assistant); + expect(result.payload.messages[0].tool_calls[0]).toBe(signed); + expect(JSON.parse(prepare(r, [assistant]).payload.messages[0].tool_calls[0].function.arguments).model).toBe('primary'); + const bound = prepare(r, [system(profileBody(r)), assistant], disabled, { tools }); + expect(bound.model).toBe(models.secondaryModel); + expect(bound.payload.tools).toBe(tools); + expect(bound.payload.messages[1]).toBe(assistant); + expect(JSON.stringify(bound.payload)).not.toContain('[agent-profile-route'); + const copied = prepare(r, [user(section(r))], disabled); + expect(copied.model).toBe(models.primaryModel); + expect(JSON.stringify(copied.payload)).not.toContain('[agent-profile-route'); +}); + +test('reserved route residue in incomplete system/developer identity tags still fails closed', () => { + const r = router(), marker = section(r); + for (const message of [system('[agent-profile-route:v2]\nopaque\n[/agent-profile-route]'), + system('[/agent-profile-route]'), system(marker.replace('[/agent-profile-route]', ''))]) { + expect(() => prepare(r, [message])).toThrow('agent_profile_unbound'); + } +}); + +test('non-identity nested fields inside system content neutralize instead of failing closed', () => { + const r = router(), marker = section(r); + const image = prepare(r, [system([{ type: 'image_url', image_url: { url: marker } }])]); + expect(image.model).toBe(models.primaryModel); + expect(JSON.stringify(image.payload)).not.toContain('[agent-profile-route'); + const text = prepare(r, [system([{ type: 'text', text: marker, metadata: { text: marker } }])]); + expect(text.model).toBe(models.secondaryModel); + expect(text.payload.messages[0].content[0].text).toBe(''); + expect(JSON.stringify(text.payload)).not.toContain('[agent-profile-route'); +}); + +test('only the observed native Agent shape receives optional role selection, without changing other prose or schema', () => { + const r = router(), input = structuredClone(fixture.request), before = structuredClone(input); + const result = r.prepare(input, options); + expect(result.model).toBe(models.primaryModel); + expect(result.payload.model).toBe(models.primaryModel); + expect(result.translateResponse).toBe(true); + const tool = result.payload.tools[0]; + expect(tool.function.parameters.properties.model.enum).toEqual(['primary', 'secondary', 'native']); + expect(tool.function.parameters.required).toEqual(['prompt', 'description']); + expect(tool.function.parameters.additionalProperties).toBe(false); + expect(tool.function.parameters.properties.prompt).toEqual(nativeTool.function.parameters.properties.prompt); + expect(tool.function.description.split('Available models (pass via model):')[0]) + .toBe(nativeTool.function.description.split('Available models (pass via model):')[0]); + expect(tool.function.description).not.toContain('local-model-'); + expect(tool.function.description).not.toMatch(/magpie|provider/i); + expect(tool.function.description).toContain('secondary [default]'); + expect(tool.function.description).toContain('Resumed and forked'); + expect(tool.function.description).toContain('ordinary Work worker profile'); + expect(tool.function.description).toContain('replaces subagent_type'); + expect(tool.function.description).toContain('native: Preserve'); + expect(r.prepare(result.payload, options).payload.tools).toEqual(result.payload.tools); + expect(input).toEqual(before); + expect(result.payload.messages).toEqual(input.messages); + const same = r.prepare({ tools: [nativeTool], messages: [] }, { ...options, secondaryModel: models.primaryModel }); + expect(same.payload.tools[0].function.parameters.properties.model.enum).toEqual(['primary', 'secondary', 'native']); +}); + +test('no tool is created or inferred from its name alone', () => { + const variants = [ + tool => { tool.function.parameters.properties.prompt.type = 'object'; }, + tool => { delete tool.function.parameters.properties.description; }, + tool => { tool.function.parameters.required = ['prompt']; }, + tool => { tool.function.parameters.type = 'array'; }, + tool => { tool.function.name = 'AgentOther'; }, + tool => { tool.type = 'custom'; }, + ]; + for (const mutate of variants) { + const tool = structuredClone(nativeTool); mutate(tool); + const result = router().prepare({ messages: [], tools: [tool] }, options); + expect(result.translateResponse).toBe(false); + expect(result.payload.tools[0]).toBe(tool); + } + const result = router().prepare({ messages: [] }, options); + expect(result.translateResponse).toBe(false); + expect(result.payload).not.toHaveProperty('tools'); +}); + +test('explicit roles select ordinary profiles while omitted model preserves a supplied native type without modifying prompt', () => { + const schema = structuredClone(nativeTool.function.parameters); + delete schema.properties.model; // The observed queue-dispatch schema has no model field. + const r = router(); + for (const [role, expected] of [[undefined, null], ['secondary', models.secondaryModel], ['primary', models.primaryModel]]) { + const input = args({ subagent_type: 'coder', run_in_background: false, ...(role ? { model: role } : {}) }); + const output = translated(r, input); + expect(output).not.toHaveProperty('model'); + for (const required of schema.required) expect(Object.hasOwn(output, required)).toBe(true); + for (const [key, value] of Object.entries(output)) { + expect(Object.hasOwn(schema.properties, key)).toBe(true); + expect(typeof value).toBe(schema.properties[key].type); + } + const forwarded = prepare(r, [...(expected ? [system(profileBody(r, expected))] : []), user(output.prompt)]); + expect(forwarded.model).toBe(expected || models.primaryModel); + expect(forwarded.payload.messages.at(-1).content).toBe(input.prompt); + expect(JSON.stringify(forwarded.payload.messages)).not.toContain('[agent-profile-route:v1]'); + expect(output.prompt).toBe(input.prompt); + expect(output.description).toBe(input.description); + expect(output.subagent_type).toBe(expected ? r.profileName(expected) : 'coder'); + expect(output.run_in_background).toBe(false); + } +}); + +test('a restarted router keeps the profile binding through preference changes, full compaction, and an empty history', () => { + const original = router(), body = profileBody(original); + const restarted = router(original.bindings); + const configuration = { primaryModel: 'synthetic/new-primary', secondaryModel: 'synthetic/new-secondary', + allowedModels: [...allowedModels, 'synthetic/new-primary', 'synthetic/new-secondary'] }; + for (const history of [ + [user('Original system_trigger task.'), { role: 'assistant', content: 'Ready.' }, user('Continue.')], + [user('Synthetic compacted summary; original task message was removed.')], [], + ]) { + const result = prepare(restarted, [system(body), ...history], configuration); + expect(result.model).toBe(models.secondaryModel); + expect(result.payload.messages[0].content).toBe(body.replace(section(restarted), '')); + expect(result.payload.messages.slice(1)).toEqual(history); + } + const next = profileBody(restarted, configuration.primaryModel); + expect(prepare(restarted, [system(body), { role: 'developer', content: next }], configuration).model) + .toBe(configuration.primaryModel); +}); + +test('system/developer text sections route and are removed while unmarked images, sources and metadata remain intact', () => { + const r = router(), first = section(r, models.primaryModel), second = section(r); + const image = { type: 'image_url', image_url: { url: 'synthetic-image' }, text: 'Image metadata.' }; + const source = { type: 'source', text: 'Source text.', source: { text: 'Nested metadata.' } }; + for (const role of ['system', 'developer']) { + const input = [{ role, content: [{ type: 'text', text: first, metadata: { text: 'Unchanged metadata.' } }, image, + { type: 'text', text: 'Unmarked middle.' }, source, { type: 'text', text: second }] }]; + const result = prepare(r, input); + expect(result.model).toBe(models.secondaryModel); + expect(result.payload.messages[0].content[0]).toEqual({ type: 'text', text: '', metadata: { text: 'Unchanged metadata.' } }); + expect(result.payload.messages[0].content[1]).toBe(image); + expect(result.payload.messages[0].content[3]).toBe(source); + expect(result.payload.messages[0].content[4].text).toBe(''); + expect(input[0].content[0].text).toBe(first); + } +}); + +test('tampering, a different key, inline or incomplete sections fail closed while old model aliases cannot choose a model', () => { + const r = router(), marker = section(r); + const tampered = marker.replace(/:([a-f0-9]{64})\n/, (_, mac) => ':' + (mac[0] === '0' ? '1' : '0') + mac.slice(1) + '\n'); + const otherKey = section(new AgentModelRouter({ secret: 'b4'.repeat(32) })); + const modifiedModel = marker.replace(/\n([A-Za-z0-9_-]{43}):/, (_, token) => '\n' + (token[0] === 'A' ? 'B' : 'A') + token.slice(1) + ':'); + for (const text of [tampered, otherKey, modifiedModel, 'Inline marker: ' + marker, + marker + ' trailing text', marker.replace('[/agent-profile-route]', '')]) { + expect(() => prepare(r, [system(text)])).toThrow('agent_profile_unbound'); + } + const alias = 'Use local-model-963636a7ab87f074d7b3206d or magpie-local-0123456789abcdef01234567.txt'; + expect(prepare(r, [system(alias)]).model).toBe(models.primaryModel); + expect(prepare(r, [system(alias)]).payload.messages[0].content).toBe(alias); + expect(() => prepare(r, [system(marker), system(tampered)])).toThrow('agent_profile_unbound'); + expect(prepare(r, [system(marker.replace(/\n/g, '\r\n'))]).model).toBe(models.secondaryModel); +}); + +test('only the latest authenticated system binding is checked against the current allowed models', () => { + const r = router(), secondary = section(r), primary = section(r, models.primaryModel); + const limited = { ...options, allowedModels: [models.primaryModel] }; + expect(() => prepare(r, [system(secondary)], limited)).toThrow('agent_profile_unbound'); + expect(prepare(r, [system(secondary), system(primary)], limited).model).toBe(models.primaryModel); + expect(() => prepare(r, [], { ...options, allowedModels: [] })).toThrow('model_not_enabled_for_kimi'); + expect(() => prepare(r, [], { ...options, allowedModels: ['local-model-963636a7ab87f074d7b3206d'] })) + .toThrow('model_not_enabled_for_kimi'); +}); + +test('copied profile metadata in user, assistant, tool or historical arguments neutralizes without selecting a route', () => { + const r = router(), prompt = section(r); + const historical = args({ prompt, metadata: { prompt }, subagent_type: r.profileName(models.secondaryModel) }); + const other = call(historical, 'Other'); + const invalid = call('{not-json'); + const input = [ + { role: 'assistant', content: prompt, tool_calls: [call(historical), other, invalid] }, + { role: 'tool', tool_call_id: 'result', content: prompt }, + user(prompt), user([{ type: 'text', text: prompt }]), + user('Read magpie-local-0123456789abcdef01234567.txt'), + ]; + for (const message of input.slice(0, -1)) { + const result = prepare(r, [message]); + expect(result.model).toBe(models.primaryModel); + expect(JSON.stringify(result.payload)).not.toContain('[agent-profile-route'); + } + expect(prepare(r, input.slice(-1)).payload.messages[0]).toBe(input.at(-1)); +}); + +test('native mode preserves specialized profiles and arbitrary argument values while ordinary roles replace only the type', () => { + const r = router(); + const original = args({ model: 'native', subagent_type: 'specialized-reader', + metadata: { model: 'primary', prompt: section(r), file: 'magpie-local-0123456789abcdef01234567.txt' } }); + const expected = { ...original }; delete expected.model; + expect(translated(r, original)).toEqual(expected); + const noType = translated(r, args({ model: 'native' })); + expect(noType).not.toHaveProperty('subagent_type'); + const ordinary = translated(r, { ...original, model: 'primary' }); + expect(ordinary).toEqual({ ...expected, subagent_type: r.profileName(models.primaryModel) }); + const inherited = translated(r, args(), { ...models, secondaryModel: '' }); + expect(inherited.subagent_type).toBe(r.profileName(models.primaryModel)); + expect(inherited.prompt).toBe(args().prompt); +}); + +test('resume and fork remove only the role field and keep the native binding and all remaining arguments', () => { + const r = router(), bound = profileBody(r); + for (const inherited of [{ resume: 'agent-existing' }, { fork: true }, { resume: 'agent-existing', fork: true }]) { + for (const role of [undefined, 'primary', 'secondary', 'native']) { + const input = args({ ...inherited, ...(role ? { model: role } : {}), nested: { model: 'primary' }, + subagent_type: r.profileName(models.secondaryModel) }); + const expected = { ...input }; delete expected.model; + expect(translated(r, input)).toEqual(expected); + expect(prepare(r, [system(bound), user(expected.prompt)]).model).toBe(models.secondaryModel); + } + } + expect(translated(r, args({ resume: '', fork: false, model: 'primary' })).subagent_type).toBe(r.profileName(models.primaryModel)); +}); + +test('authenticated opaque IDs preserve omitted versus explicit role intent through native history and restart', () => { + const r = router(), restarted = router(); + for (const [role, code] of [[undefined, 'd'], ['primary', 'p'], ['secondary', 's'], ['native', 'n']]) { + const input = args({ subagent_type: 'specialized-reader', ...(role ? { model: role } : {}) }); + const original = completion([call(input, 'Agent', 'call_0')]), before = structuredClone(original); + const native = r.transformJSON(original, models).choices[0].message.tool_calls[0]; + const nativeArgs = JSON.parse(native.function.arguments); + expect(native.id).toMatch(new RegExp('^call_rs1_[a-f0-9]{16}_' + code + '_[a-f0-9]{24}$')); + expect(native.id).toMatch(/^[A-Za-z0-9_]+$/); + expect(native.id.length).toBeLessThanOrEqual(64); + expect(native.id).not.toContain(secret); + expect(native.id).not.toMatch(/magpie|provider|synthetic/); + expect(nativeArgs).not.toHaveProperty('model'); + expect(nativeArgs.prompt).toBe(input.prompt); + expect(nativeArgs.description).toBe(input.description); + expect(original).toEqual(before); + const resultMessage = { role: 'tool', tool_call_id: native.id, content: 'Native result for ' + native.id }; + const history = [{ role: 'assistant', content: null, tool_calls: [native] }, resultMessage]; + const prepared = prepare(restarted, history); + const restored = prepared.payload.messages[0].tool_calls[0]; + expect(JSON.parse(restored.function.arguments)).toEqual({ ...nativeArgs, ...(role ? { model: role } : {}) }); + expect(restored.id).toBe(native.id); + expect(prepared.payload.messages[1]).toBe(resultMessage); + expect(prepared.payload.messages[1].tool_call_id).toBe(restored.id); + expect(prepared.model).toBe(models.primaryModel); // Call IDs are audit metadata, never routing authority. + expect(prepare(restarted, prepared.payload.messages).payload.messages).toEqual(prepared.payload.messages); + } + const repeated = r.transformJSON(completion([call(args(), 'Agent', 'call_0'), call(args(), 'Agent', 'call_0')]), models); + const first = repeated.choices[0].message.tool_calls[0], second = repeated.choices[0].message.tool_calls[1]; + const later = r.transformJSON(completion([call(args(), 'Agent', 'call_0')]), models).choices[0].message.tool_calls[0]; + expect(new Set([first.id, second.id, later.id]).size).toBe(3); +}); + +test('default signed Agent history preserves native argument bytes and tool-result linkage after formatting', () => { + const r = router(); + const native = r.transformJSON(completion([call(args())]), models).choices[0].message.tool_calls[0]; + const formatted = ' \n' + JSON.stringify(JSON.parse(native.function.arguments), null, 2) + '\n '; + const storedCall = { ...native, function: { ...native.function, arguments: formatted } }; + const storedResult = { role: 'tool', tool_call_id: native.id, content: 'NATIVE_FORMATTED_DEFAULT_OK' }; + const history = prepare(r, [{ role: 'assistant', content: null, tool_calls: [storedCall] }, storedResult]); + const restored = history.payload.messages[0].tool_calls[0]; + expect(restored.function.arguments).toBe(formatted); + expect(JSON.parse(restored.function.arguments)).not.toHaveProperty('model'); + expect(restored.id).toBe(native.id); + expect(history.payload.messages[1].tool_call_id).toBe(restored.id); + expect(history.payload.messages[1].content).toBe('NATIVE_FORMATTED_DEFAULT_OK'); + expect(history.model).toBe(models.primaryModel); +}); + +test('resume and fork retain explicit intent for audits without changing the inherited profile or prompt', () => { + const r = router(); + for (const inherited of [{ resume: 'existing-agent' }, { fork: true, subagent_type: r.profileName(models.secondaryModel) }]) { + for (const role of [undefined, 'primary', 'secondary', 'native']) { + const original = args({ ...inherited, ...(role ? { model: role } : {}) }); + const native = r.transformJSON(completion([call(original)]), models).choices[0].message.tool_calls[0]; + const expected = { ...original }; delete expected.model; + expect(JSON.parse(native.function.arguments)).toEqual(expected); + const prepared = prepare(r, [system(profileBody(r)), { role: 'assistant', tool_calls: [native] }]); + expect(prepared.model).toBe(models.secondaryModel); + expect(JSON.parse(prepared.payload.messages[1].tool_calls[0].function.arguments)).toEqual(original); + expect(prepared.payload.messages[1].tool_calls[0].id).toBe(native.id); + } + } +}); + +test('tampered IDs, changed subtypes, unsigned history and unrelated tool calls never recover a role', () => { + const r = router(); + const signed = r.transformJSON(completion([call(args({ model: 'primary' }))]), models).choices[0].message.tool_calls[0]; + const variants = [ + entry => { entry.id = entry.id.replace(/.$/, digit => digit === '0' ? '1' : '0'); }, + entry => { entry.id = entry.id.replace('_p_', '_s_'); }, + entry => { entry.id = entry.id.replace(/^(call_rs1_)(.)/, (_, prefix, digit) => prefix + (digit === '0' ? '1' : '0')); }, + entry => { entry.id = 'call_0'; }, + entry => { entry.function.arguments = JSON.stringify({ ...JSON.parse(entry.function.arguments), subagent_type: r.profileName(models.secondaryModel) }); }, + entry => { const value = JSON.parse(entry.function.arguments); delete value.subagent_type; entry.function.arguments = JSON.stringify(value); }, + entry => { entry.function.name = 'ReadFile'; }, + entry => { entry.type = 'custom'; }, + entry => { entry.function.arguments = '{invalid-json'; }, + ]; + for (const mutate of variants) { + const modified = structuredClone(signed); mutate(modified); + const message = { role: 'assistant', tool_calls: [modified] }; + expect(prepare(r, [message]).payload.messages[0]).toBe(message); + } + const message = { role: 'assistant', tool_calls: [signed] }; + expect(prepare(new AgentModelRouter({ secret: 'b4'.repeat(32) }), [message]).payload.messages[0]).toBe(message); + const alreadySpecified = { ...signed, function: { ...signed.function, + arguments: JSON.stringify({ ...JSON.parse(signed.function.arguments), model: 'secondary' }), + } }; + const existing = { role: 'assistant', tool_calls: [alreadySpecified] }; + expect(prepare(r, [existing]).payload.messages[0]).toBe(existing); + const embedded = JSON.stringify(signed); + const unrelated = [ + { role: 'assistant', content: embedded }, + { role: 'user', content: embedded, tool_calls: [signed] }, + { role: 'tool', content: embedded, tool_call_id: signed.id, tool_calls: [signed] }, + user([{ type: 'text', text: embedded }]), + ]; + const preserved = prepare(r, unrelated); + expect(preserved.model).toBe(models.primaryModel); + for (let index = 0; index < unrelated.length; index++) expect(preserved.payload.messages[index]).toBe(unrelated[index]); +}); + +test('the parent audit sees a default first worker and an explicit primary second worker after native execution', () => { + const r = router(); + const output = r.transformJSON(completion([ + call(args({ description: 'Default secondary file read' }), 'Agent', 'call_0'), + call(args({ description: 'Explicit primary file read', model: 'primary' }), 'Agent', 'call_1'), + ]), models).choices[0].message; + const [first, second] = output.tool_calls; + expect(JSON.parse(first.function.arguments).subagent_type).toBe(r.profileName(models.secondaryModel)); + expect(JSON.parse(second.function.arguments).subagent_type).toBe(r.profileName(models.primaryModel)); + const results = output.tool_calls.map(entry => ({ role: 'tool', tool_call_id: entry.id, content: 'TEST_FILE_OK' })); + const history = prepare(r, [output, ...results, user('Audit the original role selections.')]).payload.messages; + const audited = history[0].tool_calls.map(entry => JSON.parse(entry.function.arguments)); + expect(audited[0]).not.toHaveProperty('model'); + expect(audited[1].model).toBe('primary'); + expect(audited[0].prompt).toBe(args().prompt); + expect(audited[1].prompt).toBe(args().prompt); + expect(history[1].tool_call_id).toBe(history[0].tool_calls[0].id); + expect(history[2].tool_call_id).toBe(history[0].tool_calls[1].id); + expect(history[1]).toBe(results[0]); + expect(history[2]).toBe(results[1]); +}); + +test('invalid Agent JSON, required fields, and role values remain original for native validation; unrelated calls stay byte-exact', () => { + const r = router(); + for (const invalid of ['{', 'null', '[]', '{}', JSON.stringify({ prompt: 'missing description' }), + JSON.stringify({ description: 'missing prompt' }), JSON.stringify(args({ prompt: 5 })), + ...['', 'Primary', 'unknown/model', null, 0, {}, ['secondary']].map(model => JSON.stringify(args({ model })))]) { + const original = call(invalid); + expect(r.transformJSON(completion([original]), models).choices[0].message.tool_calls[0]).toBe(original); + } + const foreign = call(' { "model" : "primary", "prompt": "magpie-local-0123456789abcdef01234567.txt" } ', 'Other'); + expect(r.transformJSON(completion([foreign]), models).choices[0].message.tool_calls[0]).toBe(foreign); + const custom = { ...call(args({ model: 'unknown/model' })), type: 'custom' }; + expect(r.transformJSON(completion([custom]), models).choices[0].message.tool_calls[0]).toBe(custom); +}); + +test('JSON translation preserves text, reasoning, usage, multiple choices, and unrelated argument values', () => { + const r = router(), foreign = call(' { "arguments" : "unchanged\\n", "model": "primary" } ', 'ReadFile', 'read-1'); + const input = completion([call(args()), foreign], { usage: { prompt_tokens: 11, completion_tokens: 23 }, model: 'wire/model' }); + input.choices[0].message.content = 'Keep this message.'; + input.choices[0].message.reasoning_content = 'Keep this reasoning.'; + input.choices[0].logprobs = { content: [] }; + input.choices.push({ index: 1, message: { role: 'assistant', content: 'Other choice.' }, finish_reason: 'stop' }); + const before = structuredClone(input), result = r.transformJSON(input, models); + expect(input).toEqual(before); + expect(result.usage).toBe(input.usage); + expect(result.choices[0].message.content).toBe('Keep this message.'); + expect(result.choices[0].message.reasoning_content).toBe('Keep this reasoning.'); + expect(result.choices[0].logprobs).toBe(input.choices[0].logprobs); + expect(result.choices[0].message.tool_calls[1]).toBe(foreign); + expect(result.choices[1]).toBe(input.choices[1]); + for (const finish_reason of ['length', 'content_filter', 'error', 'cancelled']) { + const incomplete = structuredClone(input); incomplete.choices[0].finish_reason = finish_reason; + expect(() => r.transformJSON(incomplete, models)).not.toThrow(); + expect(r.transformJSON(incomplete, models).choices[0]).toBe(incomplete.choices[0]); + } +}); + +test('SSE aggregates interleaved calls and choices across UTF-8 bytes, CRLF, multiline data, names, IDs, and arguments', async () => { + const r = router(); + const agentArgs = JSON.stringify(args({ model: 'primary' })); + const secondArgs = JSON.stringify(args({ prompt: '第二个任务。' })); + const foreignArgs = ' { "file" : "magpie-local-0123456789abcdef01234567.txt", "model" : "primary" } '; + const stream = ': keepalive\r\n\r\n' + + event(chunk([choice(0, { role: 'assistant', content: '开头', reasoning_content: '思考' })]), { crlf: true }) + + event(chunk([ + choice(0, { tool_calls: [{ index: 1, id: 'read-', type: 'function', function: { name: 'Read', arguments: foreignArgs.slice(0, 17) } }, + { index: 0, id: 'ag', type: 'function', function: { name: 'Ag', arguments: agentArgs.slice(0, 21) } }] }), + choice(1, { role: 'assistant', tool_calls: [{ index: 0, id: 'second', type: 'function', function: { name: 'Agent', arguments: secondArgs.slice(0, 11) } }] }), + ]), { multiline: true, metadata: 'id: wire-event\nevent: message\nretry: 100\n' }) + + event(chunk([ + choice(1, { tool_calls: [{ index: 0, function: { arguments: secondArgs.slice(11) } }] }), + choice(0, { content: '尾巴', reasoning_content: '继续', tool_calls: [ + { index: 0, id: 'ent-1', function: { name: 'ent', arguments: agentArgs.slice(21) } }, + { index: 1, id: '1', function: { name: 'File', arguments: foreignArgs.slice(17) } }, + ] }), + ]), { crlf: true }) + + event(chunk([choice(1, {}, 'tool_calls')])) + + event(chunk([choice(0, {}, 'tool_calls')]), { crlf: true }) + + event(chunk([], { usage: { prompt_tokens: 7, completion_tokens: 19 } })) + + 'data: [DONE]\r\n\r\n'; + const output = await readStream(r, stream, 1); + expect(output).toStartWith(': keepalive\r\n\r\n'); + expect(output).toContain('id: wire-event\nevent: message\nretry: 100\n'); + expect(output).toEndWith('data: [DONE]\r\n\r\n'); + const parsed = events(output), choices = parsed.flatMap(value => value.choices || []); + expect(choices.map(value => value.delta.content || '').join('')).toBe('开头尾巴'); + expect(choices.map(value => value.delta.reasoning_content || '').join('')).toBe('思考继续'); + expect(parsed.at(-1).usage).toEqual({ prompt_tokens: 7, completion_tokens: 19 }); + expect(choices.filter(value => value.finish_reason).map(value => [value.index, value.finish_reason])).toEqual([[1, 'tool_calls'], [0, 'tool_calls']]); + const calls = assembledCalls(output); + expect(calls).toHaveLength(3); + const primary = calls.find(value => /_p_/.test(value.id)); + const secondary = calls.find(value => /_d_/.test(value.id)); + const foreign = calls.find(value => value.id === 'read-1'); + expect(primary.function.name).toBe('Agent'); + expect(JSON.parse(primary.function.arguments).subagent_type).toBe(r.profileName(models.primaryModel)); + expect(JSON.parse(primary.function.arguments).prompt).toBe(args().prompt); + expect(JSON.parse(secondary.function.arguments).subagent_type).toBe(r.profileName(models.secondaryModel)); + expect(JSON.parse(secondary.function.arguments).prompt).toBe('第二个任务。'); + expect(foreign.function.name).toBe('ReadFile'); + expect(foreign.function.arguments).toBe(foreignArgs); + expect(primary.index).toBe(0); + expect(foreign.index).toBe(1); + const audited = prepare(r, [{ role: 'assistant', tool_calls: calls }]).payload.messages[0].tool_calls; + expect(JSON.parse(audited.find(value => value.id === primary.id).function.arguments).model).toBe('primary'); + expect(JSON.parse(audited.find(value => value.id === secondary.id).function.arguments)).not.toHaveProperty('model'); + expect(audited.find(value => value.id === foreign.id)).toBe(foreign); + expect(output).not.toContain(secret); +}); + +test('ordinary SSE text remains incremental while tool fragments wait for a finish event', async () => { + const first = event(chunk([choice(0, { content: 'Visible immediately.' })])); + const partial = event(chunk([choice(0, { tool_calls: [{ index: 0, id: 'a', type: 'function', + function: { name: 'Agent', arguments: JSON.stringify(args()) } }] })])); + let reads = 0; + async function* source() { + reads++; yield Buffer.from(first); + reads++; yield Buffer.from(partial); + reads++; yield Buffer.from(event(chunk([choice(0, {}, 'tool_calls')])) + 'data: [DONE]\n\n'); + } + const iterator = router().transformSSE(source(), models); + expect((await iterator.next()).value).toBe(first); + expect(reads).toBe(1); + const buffered = (await iterator.next()).value; + expect(streamCalls(buffered)).toEqual([]); + expect(reads).toBe(2); + expect(streamCalls((await iterator.next()).value)).toHaveLength(1); + expect(reads).toBe(3); + expect((await iterator.next()).value).toBe('data: [DONE]\n\n'); + expect((await iterator.next()).done).toBe(true); +}); + +test('SSE truncation, malformed arguments and unfinished termination preserve original calls for native validation', async () => { + const partial = event(chunk([choice(0, { tool_calls: [{ index: 0, id: 'a', type: 'function', + function: { name: 'Agent', arguments: '{"prompt":"half' } }] })])); + const whole = event(chunk([choice(0, { tool_calls: [{ index: 0, id: 'a', type: 'function', + function: { name: 'Agent', arguments: JSON.stringify(args()) } }] })])); + for (const [text, expected] of [ + [partial, '{"prompt":"half'], + [partial + 'data: [DONE]\n\n', '{"prompt":"half'], + [partial + event(chunk([choice(0, {}, 'tool_calls')])), '{"prompt":"half'], + [whole + event(chunk([choice(0, {}, 'length')])) + 'data: [DONE]\n\n', JSON.stringify(args())], + ]) { + const output = await readStream(router(), text), calls = assembledCalls(output); + expect(calls).toHaveLength(1); + expect(calls[0].id).toBe('a'); + expect(calls[0].function.arguments).toBe(expected); + if (text.includes('[DONE]')) expect(output).toEndWith('data: [DONE]\n\n'); + } + const malformed = 'data: {"choices":'; + const output = await readStream(router(), whole + malformed); + expect(output).toContain(malformed); + expect(assembledCalls(output.slice(0, output.indexOf(malformed)))[0].function.arguments).toBe(JSON.stringify(args())); +}); + +test('SSE upstream errors and caller cancellation discard buffered calls and close the input iterator', async () => { + const partial = event(chunk([choice(0, { tool_calls: [{ index: 0, id: 'a', type: 'function', + function: { name: 'Agent', arguments: '{' } }] })])); + const errorEvent = event({ error: { code: 'upstream_error', message: 'Synthetic failure' } }, { metadata: 'event: error\n' }); + const output = await readStream(router(), partial + errorEvent + 'data: [DONE]\n\n'); + expect(output).toContain(errorEvent); + expect(streamCalls(output)).toEqual([]); + let closed = false, captured = ''; + async function* cancelled() { + try { yield Buffer.from(partial); throw Object.assign(Error('cancelled'), { name: 'AbortError' }); } + finally { closed = true; } + } + await expect((async () => { for await (const value of router().transformSSE(cancelled(), models)) captured += value; })()).rejects.toThrow('cancelled'); + expect(closed).toBe(true); + expect(streamCalls(captured)).toEqual([]); + closed = false; + async function* returned() { + try { yield Buffer.from(partial); yield Buffer.from('data: [DONE]\n\n'); } + finally { closed = true; } + } + const iterator = router().transformSSE(returned(), models); + expect(streamCalls((await iterator.next()).value)).toEqual([]); + await iterator.return(); + expect(closed).toBe(true); +}); + +test('SSE passes malformed UTF-8 and JSON without aborting and enforces the same finite response cap as JSON', async () => { + async function* invalidUTF8() { yield new Uint8Array([0xff]); } + let decoded = ''; + for await (const value of router().transformSSE(invalidUTF8(), models)) decoded += value; + expect(decoded).toBe('\ufffd'); + expect(await readStream(router(), 'data: {broken}\n\n')).toBe('data: {broken}\n\n'); + async function* oversized() { yield Buffer.alloc(32 * 1024 * 1024 + 1, 32); } + await expect((async () => { for await (const ignored of router().transformSSE(oversized(), models)) {} })()) + .rejects.toThrow('agent_routing_response_too_large'); + expect(() => router().transformJSON({ text: 'x'.repeat(32 * 1024 * 1024) }, models)) + .toThrow('agent_routing_response_too_large'); +}); + +test('an unresolved tool name releases its original fragments as soon as it is known to be non-Agent', async () => { + const first = event(chunk([choice(0, { tool_calls: [ + { index: 0, id: 'read-', type: 'function', function: { arguments: '{"file":' } }, + { index: 1, id: 'worker', type: 'function', function: { name: 'Agent', arguments: JSON.stringify(args()) } }, + ] })])); + const second = event(chunk([choice(0, { tool_calls: [ + { index: 0, id: '1', function: { name: 'Read', arguments: '"one"}' } }, + ] })])); + const third = event(chunk([choice(0, { tool_calls: [{ index: 0, function: { name: 'File', arguments: '' } }] })])); + let reads = 0; + async function* source() { + for (const frame of [first, second, third, event(chunk([choice(0, {}, 'tool_calls')])), 'data: [DONE]\n\n']) { + reads++; yield Buffer.from(frame); + } + } + const iterator = router().transformSSE(source(), models); + expect(streamCalls((await iterator.next()).value)).toEqual([]); + expect(reads).toBe(1); + const released = streamCalls((await iterator.next()).value); + expect(reads).toBe(2); + expect(released.map(call => call.id).filter(Boolean).join('')).toBe('read-1'); + expect(released.map(call => call.function?.arguments || '').join('')).toBe('{"file":"one"}'); + expect((await iterator.next()).value).toBe(third); + const flushed = streamCalls((await iterator.next()).value); + expect(flushed).toHaveLength(1); + expect(flushed[0].function.name).toBe('Agent'); + expect((await iterator.next()).value).toBe('data: [DONE]\n\n'); + expect((await iterator.next()).done).toBe(true); +}); + +test('a missing index releases earlier Agent fragments and keeps the rest of that choice raw', async () => { + const text = JSON.stringify(args()), split = Math.floor(text.length / 2); + const fragments = [ + { index: 0, id: 'original-agent', type: 'function', function: { name: 'Agent', arguments: text.slice(0, split) } }, + { function: { arguments: text.slice(split) } }, + { index: 1, id: 'later-agent', type: 'function', function: { name: 'Agent', arguments: JSON.stringify(args({ model: 'primary' })) } }, + ]; + const input = fragments.map(fragment => event(chunk([choice(0, { tool_calls: [fragment] })]))).join('') + + event(chunk([choice(0, {}, 'tool_calls')])) + 'data: [DONE]\n\n'; + const output = await readStream(router(), input); + expect(streamCalls(output)).toEqual(fragments); + expect(output).toEndWith('data: [DONE]\n\n'); + expect(output).not.toContain('call_rs1_'); +}); + +test('DONE flushes multiple pending choices once without repeating text, reasoning or usage', async () => { + const r = router(), usage = { prompt_tokens: 7, completion_tokens: 9 }; + const input = event(chunk([ + choice(0, { content: '一次中文', reasoning_content: '一次思考', tool_calls: [ + { index: 0, id: 'first', type: 'function', function: { name: 'Agent', arguments: JSON.stringify(args()) } }, + ] }), + choice(1, { content: 'second', tool_calls: [ + { index: 0, id: 'second', type: 'function', function: { name: 'Agent', arguments: JSON.stringify(args({ model: 'primary' })) } }, + ] }), + ]), { crlf: true }) + event(chunk([], { usage }), { crlf: true }) + 'data: [DONE]\r\n\r\n'; + const output = await readStream(r, input, 1), parsed = events(output); + const choices = parsed.flatMap(value => value.choices || []); + expect(output).toEndWith('data: [DONE]\r\n\r\n'); + expect(choices.map(value => value.delta?.content || '').join('')).toBe('一次中文second'); + expect(choices.map(value => value.delta?.reasoning_content || '').join('')).toBe('一次思考'); + expect(parsed.filter(value => value.usage)).toHaveLength(1); + expect(parsed.find(value => value.usage).usage).toEqual(usage); + expect(choices.filter(value => value.finish_reason).map(value => [value.index, value.finish_reason])) + .toEqual([[0, 'tool_calls'], [1, 'tool_calls']]); + const calls = streamCalls(output); + expect(calls).toHaveLength(2); + expect(calls.map(call => JSON.parse(call.function.arguments).subagent_type)) + .toEqual([r.profileName(models.secondaryModel), r.profileName(models.primaryModel)]); +}); + +test('unrecognized SSE shapes release pending calls and pass through instead of aborting the turn', async () => { + const held = { index: 0, id: 'original-agent', type: 'function', + function: { name: 'Agent', arguments: '{' } }; + const prefix = event(chunk([choice(0, { tool_calls: [held] })])); + for (const malformed of ['data: {broken}\n\n', event(chunk([null])), + event(chunk([choice('unknown', { tool_calls: [held] })])), + event(chunk([choice(0, { tool_calls: 'native-invalid-shape' })]))]) { + const read = readStream(router(), prefix + malformed + 'data: [DONE]\n\n'); + const output = await fulfilled(read); + expect(output).toContain(malformed); + expect(output).toContain('original-agent'); + expect(output).toEndWith('data: [DONE]\n\n'); + expect(output).not.toContain('call_rs1_'); + } +}); + +test('custom calls named Agent and fragments after a native finish event remain incremental and raw', async () => { + const custom = event(chunk([choice(0, { tool_calls: [{ index: 0, id: 'custom-agent', type: 'custom', + function: { name: 'Agent', arguments: JSON.stringify(args()) } }] })])); + const finish = event(chunk([choice(0, {}, 'tool_calls')])); + const after = event(chunk([choice(0, { tool_calls: [{ index: 1, id: 'after-finish', type: 'function', + function: { name: 'Agent', arguments: JSON.stringify(args()) } }] })])); + async function* source() { for (const frame of [custom, finish, after, 'data: [DONE]\n\n']) yield Buffer.from(frame); } + const iterator = router().transformSSE(source(), models); + expect((await iterator.next()).value).toBe(custom); + expect((await iterator.next()).value).toBe(finish); + expect((await iterator.next()).value).toBe(after); + expect((await iterator.next()).value).toBe('data: [DONE]\n\n'); + expect((await iterator.next()).done).toBe(true); +}); + +// ---- Round-2: neutralization of non-identity route markers ---- + +test('neutralizeRouteMarkers removes mixed-case marker brackets recursively and preserves unchanged references', () => { + const input = { + a: '[agent-profile-route:v1]open[/agent-profile-route]', + b: '[AGENT-PROFILE-ROUTE:v1]OPEN[/AGENT-PROFILE-ROUTE]', + c: { d: 'keep [agent-profile-route', nested: ['[/agent-profile-route]', 'plain'] }, + keys: { '[agent-profile-route:v1]key': 'value' }, + plain: 'unchanged', + num: 7, + }; + const before = structuredClone(input); + const result = neutralizeRouteMarkers(input); + expect(result.a).toBe('agent-profile-route:v1]open/agent-profile-route]'); + expect(result.b).toBe('AGENT-PROFILE-ROUTE:v1]OPEN/AGENT-PROFILE-ROUTE]'); + expect(result.c.d).toBe('keep agent-profile-route'); + expect(result.c.nested).toEqual(['/agent-profile-route]', 'plain']); + expect(result.keys).toEqual({ 'agent-profile-route:v1]key': 'value' }); + expect(result.plain).toBe('unchanged'); + expect(result.num).toBe(7); + expect(result).not.toBe(input); + expect(input).toEqual(before); + const unchanged = { plain: 'x', nested: { y: [1, 2] } }; + expect(neutralizeRouteMarkers(unchanged)).toBe(unchanged); +}); + +test('neutralizeRouteMarkers strips the bracket from marker prefixes without a word boundary', () => { + expect(neutralizeRouteMarkers('[agent-profile-routeExtra')).toBe('agent-profile-routeExtra'); + expect(neutralizeRouteMarkers('[/AGENT-PROFILE-ROUTE-extra')).toBe('/AGENT-PROFILE-ROUTE-extra'); +}); + +test('neutralizeRouteMarkers keeps own __proto__ keys as JSON data rather than prototype identity', () => { + const input = JSON.parse('{"__proto__":{"keep":"value"},"text":"[agent-profile-route:v1]"}'); + const result = neutralizeRouteMarkers(input); + expect(JSON.stringify(result)).toBe('{"__proto__":{"keep":"value"},"text":"agent-profile-route:v1]"}'); +}); + +test('neutralizeRouteMarkers never calls function values, leaving JSON.stringify-time markers opaque', () => { + const fn = () => ({ text: '[agent-profile-route:v1]synthetic' }); + const input = { metadata: { toJSON: fn } }; + const neutralized = neutralizeRouteMarkers(input); + expect(neutralized).toBe(input); + expect(neutralized.metadata.toJSON).toBe(fn); +}); + +test('a marker produced only at JSON.stringify time still fails the reserved boundary guard', () => { + const r = router(); + const payload = { messages: [], metadata: { toJSON: () => ({ text: '[agent-profile-route:v1]synthetic' }) } }; + expect(() => r.prepare(payload, options)).toThrow('agent_profile_unbound'); +}); + +test('copied sections in user content and tool schemas route primary, neutralize, and repeat idempotently', () => { + const r = router(); + const marker = section(r, models.primaryModel); + const tool = { type: 'function', function: { name: 'Read', + description: 'Synthetic reader.\n' + marker, + parameters: { type: 'object', properties: { file_path: { type: 'string', description: 'Path. ' + marker } }, + required: ['file_path'] } } }; + const payload = { messages: [system(section(r, models.primaryModel)), user('Before\n' + marker + '\nAfter')], tools: [tool] }; + const first = r.prepare(payload, options); + expect(first.model).toBe(models.primaryModel); + expect(JSON.stringify(first.payload)).not.toContain('[agent-profile-route'); + expect(JSON.stringify(first.payload)).not.toContain('[/agent-profile-route'); + const second = r.prepare(first.payload, options); + expect(second.model).toBe(models.primaryModel); + expect(JSON.stringify(second.payload)).not.toContain('[agent-profile-route'); +}); + +test('a copied secondary section in user and tool content never selects a route and neutralizes', () => { + const r = router(); + const secondary = section(r); + const tool = { type: 'function', function: { name: 'Read', + description: 'Reader.\n' + secondary, + parameters: { type: 'object', properties: { file_path: { type: 'string', description: 'Path. ' + secondary } }, + required: ['file_path'] } } }; + const payload = { messages: [user('Before\n' + secondary + '\nAfter'), + { role: 'tool', tool_call_id: 'read-1', content: secondary }], tools: [tool] }; + const first = r.prepare(payload, options); + expect(first.model).toBe(models.primaryModel); + expect(JSON.stringify(first.payload)).not.toContain('[agent-profile-route'); + expect(JSON.stringify(first.payload)).not.toContain('[/agent-profile-route'); + const second = r.prepare(first.payload, options); + expect(second.model).toBe(models.primaryModel); + expect(JSON.stringify(second.payload)).not.toContain('[agent-profile-route'); +}); + +// ---- Round-2: catalog observation and deferred pruning ---- + +test('a single observed absence is retained without pruning the binding', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + const before = r.bindings; + r.observeCatalog([models.primaryModel], { now: 0 }); + expect(r.boundModels).toHaveLength(2); + expect(r.absence[models.secondaryModel]).toEqual({ count: 1, firstMissingAt: 0 }); + expect(r.bindings).toEqual(before); +}); + +test('three absences within the ten-minute window retain the binding', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + r.observeCatalog([models.primaryModel], { now: 0 }); + r.observeCatalog([models.primaryModel], { now: 1000 }); + r.observeCatalog([models.primaryModel], { now: 2000 }); + expect(r.boundModels).toHaveLength(2); + expect(r.absence[models.secondaryModel]).toEqual({ count: 3, firstMissingAt: 0 }); +}); + +test('absence count caps at three across many observations', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + for (const now of [0, 1000, 2000, 3000, 4000]) r.observeCatalog([models.primaryModel], { now }); + expect(r.boundModels).toHaveLength(2); + expect(r.absence[models.secondaryModel]).toEqual({ count: 3, firstMissingAt: 0 }); +}); + +test('a ten-minute absence observed fewer than three times retains the binding', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + r.observeCatalog([models.primaryModel], { now: 0 }); + r.observeCatalog([models.primaryModel], { now: 600000 }); + expect(r.boundModels).toHaveLength(2); + expect(r.absence[models.secondaryModel]).toEqual({ count: 2, firstMissingAt: 0 }); +}); + +test('three absences spanning ten minutes prune all bindings and their absence entry', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + const secondaryToken = Object.entries(r.bindings).find(([, model]) => model === models.secondaryModel)[0]; + r.observeCatalog([models.primaryModel], { now: 0 }); + r.observeCatalog([models.primaryModel], { now: 1000 }); + r.observeCatalog([models.primaryModel], { now: 600000 }); + expect(r.boundModels).toEqual([models.primaryModel]); + expect(r.absence).not.toHaveProperty(models.secondaryModel); + expect(r.bindings).not.toHaveProperty(secondaryToken); +}); + +test('a model returning to the catalog clears its absence and keeps routing with the original token', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + const before = r.bindings; + r.observeCatalog([models.primaryModel], { now: 0 }); + r.observeCatalog([models.primaryModel], { now: 1000 }); + expect(r.absence[models.secondaryModel].count).toBe(2); + r.observeCatalog(allowedModels, { now: 2000 }); + expect(r.absence).not.toHaveProperty(models.secondaryModel); + expect(r.bindings).toEqual(before); + expect(prepare(r, [system(profileBody(r))]).model).toBe(models.secondaryModel); +}); + +test('allowRetirement false defers pruning while still counting, then an idle observation retires', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + r.observeCatalog([models.primaryModel], { now: 1000 }); + r.observeCatalog([models.primaryModel], { now: 1001 }); + r.observeCatalog([models.primaryModel], { now: 601000, allowRetirement: false }); + expect(r.boundModels).toHaveLength(2); + expect(r.absence[models.secondaryModel]).toEqual({ count: 3, firstMissingAt: 1000 }); + const restarted = new AgentModelRouter({ secret, bindings: r.bindings, absence: r.absence }); + restarted.observeCatalog([models.primaryModel], { now: 601001 }); + expect(restarted.boundModels).toEqual([models.primaryModel]); + expect(restarted.absence).not.toHaveProperty(models.secondaryModel); +}); + +test('allowRetirement false still clears absence when a model returns to the catalog', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + const before = r.bindings; + r.observeCatalog([models.primaryModel], { now: 0 }); + r.observeCatalog([models.primaryModel], { now: 1000, allowRetirement: false }); + expect(r.absence[models.secondaryModel].count).toBe(2); + r.observeCatalog(allowedModels, { now: 2000, allowRetirement: false }); + expect(r.absence).not.toHaveProperty(models.secondaryModel); + expect(r.bindings).toEqual(before); + expect(prepare(r, [system(profileBody(r))]).model).toBe(models.secondaryModel); +}); + +test('the absence getter returns a deep snapshot isolated from internal state', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + r.observeCatalog([models.primaryModel], { now: 0 }); + const snapshot = r.absence; + snapshot[models.secondaryModel].count = 99; + delete snapshot[models.secondaryModel]; + expect(r.absence[models.secondaryModel]).toEqual({ count: 1, firstMissingAt: 0 }); +}); + +test('a fresh router importing saved bindings and absence continues counting without losing state', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + r.observeCatalog([models.primaryModel], { now: 0 }); + r.observeCatalog([models.primaryModel], { now: 1000 }); + const restarted = new AgentModelRouter({ secret, bindings: r.bindings, absence: r.absence }); + expect(restarted.absence[models.secondaryModel]).toEqual({ count: 2, firstMissingAt: 0 }); + restarted.observeCatalog([models.primaryModel], { now: 600000 }); + expect(restarted.boundModels).toEqual([models.primaryModel]); + expect(restarted.absence).not.toHaveProperty(models.secondaryModel); +}); + +test('repeated registerModels calls never count absences or prune bindings', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + r.registerModels([models.primaryModel], [models.primaryModel]); + r.registerModels([models.primaryModel], [models.primaryModel]); + expect(r.absence).toEqual({}); + expect(r.boundModels).toHaveLength(2); +}); + +test('importing invalid absence state fails agent_profile_unbound without changing valid bindings', () => { + const valid = { ['A'.repeat(43)]: models.primaryModel }; + for (const absence of [null, [], '', { '': { count: 1, firstMissingAt: 0 } }, + { [models.primaryModel]: 'nope' }, { [models.primaryModel]: { count: -1, firstMissingAt: 0 } }, + { [models.primaryModel]: { count: 1.5, firstMissingAt: 0 } }, + { [models.primaryModel]: { count: 1, firstMissingAt: -1 } }, + { [models.primaryModel]: { count: 1, firstMissingAt: 'x' } }]) { + expect(() => new AgentModelRouter({ secret, bindings: valid, absence })).toThrow('agent_profile_unbound'); + } +}); + +test('observeCatalog rejects a non-array catalog or invalid timestamp', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + expect(() => r.observeCatalog(null, { now: 0 })).toThrow('model_not_enabled_for_kimi'); + expect(() => r.observeCatalog([models.primaryModel], { now: -1 })).toThrow('model_not_enabled_for_kimi'); + expect(() => r.observeCatalog([models.primaryModel], { now: 1.5 })).toThrow('model_not_enabled_for_kimi'); +}); diff --git a/packages/kimi-desktop/catalog-migration.test.mjs b/packages/kimi-desktop/catalog-migration.test.mjs new file mode 100644 index 000000000..731335a39 --- /dev/null +++ b/packages/kimi-desktop/catalog-migration.test.mjs @@ -0,0 +1,117 @@ +import { test, expect } from 'bun:test'; +import fs from 'node:fs'; +import { canonicalMenuAlias, isMenuAlias, normalizeModels, neutralPromptAliases } from './catalog.mjs'; +import { recoveryPeer } from './test-fixtures/control-peer.mjs'; + +const read = file => JSON.parse(fs.readFileSync(file, 'utf8')); + +test('neutral model aliases retain the old namespace for migration and cleanup', () => { + const rows = normalizeModels({ data: [{ id: 'provider/main' }] }); + const alias = rows[0].alias, legacy = alias.replace('local-model-', 'magpie-local-'); + expect(alias).toMatch(/^local-model-[a-f0-9]{24}$/); + expect(rows[0].menu.displayName).not.toMatch(/magpie/i); + expect(isMenuAlias(alias)).toBe(true); + expect(isMenuAlias(legacy)).toBe(true); + expect(canonicalMenuAlias(legacy)).toBe(alias); + const prose = { messages: [{ role: 'user', content: 'magpie is a word in my own instructions' }] }; + expect(neutralPromptAliases(prose)).toEqual(prose); +}); + +test('catalog migration acknowledges a native selection before removing the old selected alias', async () => { + const f = recoveryPeer(); + try { + await f.controller.tick(); + const oldSelection = f.selectAlias(); + const before = f.calls.length; + f.controller.catalogAfter = 0; + await f.controller.tick(); + expect(read(f.controller.configFile).model.current).toBe('native'); + expect(isMenuAlias(oldSelection)).toBe(true); + expect(f.menu.map(row => row.model)).toEqual(['native']); + expect(Object.keys(read(f.controller.configFile).model.models)).toEqual(['native']); + expect(f.calls.slice(before).filter(call => call.method.startsWith('conversations.')).map(call => [call.method, call.params])).toEqual([ + ['conversations.getKimiModelList', {}], + ['conversations.setModel', { modelAlias: 'native' }], + ['conversations.updateKimiModelList', { models: [{ model: 'native', maxContextSize: 131072, capabilities: [] }] }], + ]); + expect(f.controller.status.code).toBe('connected'); + } finally { await f.cleanup(); } +}); + +test('review guard: a native catalog containing only local aliases never selects or updates a menu', async () => { + const f = recoveryPeer(); + try { + await f.controller.tick(); + const alias = f.selectAlias({ legacy: false }); + const doc = read(f.controller.configFile); + // Keep a native current selection so removing the empty-native guard would + // reach updateKimiModelList instead of throwing while choosing native[0]. + doc.model.current = 'native'; + fs.writeFileSync(f.controller.configFile, JSON.stringify(doc)); + const control = f.controller.rpcCall; + const writes = []; + f.controller.rpcCall = async (runner, method, params, options) => { + if (method === 'conversations.getKimiModelList') return { models: [{ model: alias }] }; + if (method === 'conversations.setModel' || method === 'conversations.updateKimiModelList') { + writes.push({ method, params }); + return { ok: true }; + } + return control(runner, method, params, options); + }; + const before = fs.readFileSync(f.controller.configFile); + f.controller.catalogAfter = 0; + await f.controller.tick(); + expect(writes).toEqual([]); + expect(f.controller.catalogError).toBe('catalog_sync_failed'); + expect(fs.readFileSync(f.controller.configFile)).toEqual(before); + // Avoid exercising shutdown with the deliberately incomplete catalog. + f.controller.rpcCall = control; + } finally { await f.cleanup(); } +}); + +test('normal user data and non-Agent tools retain alias-shaped filenames and distinct keys', () => { + const alias = normalizeModels({ data: [{ id: 'provider/main' }] })[0].alias; + const old = alias.replace('local-model-', 'magpie-local-'), filename = `/tmp/${old}.json`; + const payload = { tools: [ + { type: 'function', function: { name: 'read_file', parameters: { properties: { [old]: { type: 'string' }, [alias]: { type: 'integer' } } } } }, + { type: 'function', function: { name: 'Agent', description: `Read ${filename} if needed.\nAvailable models (pass via model):\n- ${old} [default]: Secondary model`, + parameters: { properties: { model: { type: 'string', enum: [old] }, prompt: { description: filename } } } } }, + ], messages: [ + { role: 'user', content: `Read exactly ${filename}` }, + { role: 'assistant', tool_calls: [{ type: 'function', function: { name: 'read_file', arguments: JSON.stringify({ path: filename }) } }] }, + { role: 'tool', content: JSON.stringify({ [old]: 'first', [alias]: 'second' }) }, + { role: 'assistant', tool_calls: [{ type: 'function', function: { name: 'Agent', arguments: JSON.stringify({ model: old, prompt: `Read ${filename}` }) } }] }, + ] }; + const normalized = neutralPromptAliases(payload); + expect(normalized.messages.slice(0, 3)).toEqual(payload.messages.slice(0, 3)); + expect(normalized.tools[0]).toEqual(payload.tools[0]); + expect(normalized.tools[1].function.parameters.properties.prompt).toEqual(payload.tools[1].function.parameters.properties.prompt); + expect(normalized.tools[1].function.description).toContain(filename); + expect(normalized.tools[1].function.description).toContain(`- ${alias} [default]:`); + const args = JSON.parse(normalized.messages[3].tool_calls[0].function.arguments); + expect(args).toEqual({ model: alias, prompt: `Read ${filename}` }); + expect(normalized.tools[1].function.parameters.properties.model.enum).toEqual([alias]); +}); + +for (const after of [false, true]) { +test(`an unacknowledged native selection retains old definitions until retry (${after ? 'after' : 'before'} effect)`, async () => { + const f = recoveryPeer(); + try { + await f.controller.tick(); + const old = f.selectAlias(); + const before = f.calls.length; + f.fail('select', after); f.controller.catalogAfter = 0; + await f.controller.tick(); + expect(read(f.controller.configFile).model.current).toBe(after ? 'native' : old); + expect(f.menu.some(row => row.model === old)).toBe(true); + expect(read(f.controller.configFile).model.models[old]).toBeDefined(); + expect(f.calls.slice(before).some(call => call.method === 'conversations.updateKimiModelList')).toBe(false); + expect(f.controller.catalogError).toBe('catalog_sync_failed'); + f.clearFault(); f.controller.catalogAfter = 0; + await f.controller.tick(); + expect(read(f.controller.configFile).model.current).toBe('native'); + expect(f.menu.map(row => row.model)).toEqual(['native']); + expect(f.controller.catalogError).toBeNull(); + } finally { await f.cleanup(); } +}); +} diff --git a/packages/kimi-desktop/catalog-options.test.mjs b/packages/kimi-desktop/catalog-options.test.mjs new file mode 100644 index 000000000..ce29062a2 --- /dev/null +++ b/packages/kimi-desktop/catalog-options.test.mjs @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test'; +import { normalizeModels } from './catalog.mjs'; + +// Synthetic catalog rows only. No network, no real user config, no paid tools. +const row = overrides => ({ + id: 'moonshot/kimi-k2.5-options-synthetic', + display_name: 'Kimi K2.5 Synthetic', + ...overrides, +}); + +const modelFor = overrides => normalizeModels({ data: [row(overrides)] })[0]; + +describe('catalog independent options capability metadata', () => { + test('exposes explicit known efforts and explicit context_window', () => { + const model = modelFor({ + context_window: 262144, + supported_reasoning_levels: [ + { effort: 'low' }, + { effort: 'high' }, + { effort: 'low' }, + { effort: 'experimental-unknown' }, + {}, + ], + }); + expect(model.options).toEqual({ reasoningEfforts: ['low', 'high'], contextWindow: 262144 }); + }); + + test('uses explicit valid context_length only when context_window is absent', () => { + const model = modelFor({ + context_length: 200000, + supported_reasoning_levels: [{ effort: 'medium' }], + }); + expect(model.options).toEqual({ reasoningEfforts: ['medium'], contextWindow: 200000 }); + }); + + test('keeps unknown context and unknown efforts conservative, never 131072', () => { + const model = modelFor({ + supported_reasoning_levels: [{ effort: 'mystery' }, {}, { effort: '' }], + }); + expect(model.options).toEqual({ reasoningEfforts: [], contextWindow: null }); + }); + + test('reasoning=false offers no thinking effort even when levels are advertised', () => { + const model = modelFor({ + reasoning: false, + context_window: 131072, + supported_reasoning_levels: [{ effort: 'high' }, { effort: 'low' }], + }); + expect(model.options).toEqual({ reasoningEfforts: [], contextWindow: 131072 }); + }); + + test('valid context_window wins over a valid context_length', () => { + const model = modelFor({ context_window: 32000, context_length: 64000 }); + expect(model.options).toEqual({ reasoningEfforts: [], contextWindow: 32000 }); + }); + + test('unknown options context leaves the existing runtime fallback untouched', () => { + const model = modelFor({}); + expect(model.options).toEqual({ reasoningEfforts: [], contextWindow: null }); + expect(model.runtime.maxContextSize).toBe(131072); + }); + + test('non-positive or non-safe-integer declared contexts are unknown for both context fields', () => { + for (const field of ['context_window', 'context_length']) { + for (const value of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + const model = modelFor({ [field]: value }); + expect(model.options.contextWindow).toBe(null); + } + } + }); + + test('invalid context_window falls through to a valid context_length', () => { + const model = modelFor({ + context_window: -1, + context_length: 64000, + }); + expect(model.options).toEqual({ reasoningEfforts: [], contextWindow: 64000 }); + }); + + test('options has exactly the two independent capability keys, no invented default effort', () => { + const model = modelFor({ + context_window: 100000, + supported_reasoning_levels: [{ effort: 'high' }, { effort: 'medium' }], + }); + expect(model.options).toEqual({ reasoningEfforts: ['high', 'medium'], contextWindow: 100000 }); + }); +}); diff --git a/packages/kimi-desktop/catalog.mjs b/packages/kimi-desktop/catalog.mjs index 8805e6b21..d0b304d21 100644 --- a/packages/kimi-desktop/catalog.mjs +++ b/packages/kimi-desktop/catalog.mjs @@ -1,6 +1,40 @@ import crypto from 'node:crypto'; -export const isMenuAlias = value => /^magpie-local-[a-f0-9]{24}$/.test(value || ''); +export const isMenuAlias = value => /^(?:local-model-|magpie-local-)[a-f0-9]{24}$/.test(value || ''); +export const canonicalMenuAlias = value => typeof value === 'string' + ? value.replace(/^magpie-local-([a-f0-9]{24})$/, 'local-model-$1') : value; + +// Change only native Agent model identities. A filename, user message, normal +// tool result or object key can legitimately contain the old alias spelling. +export function neutralPromptAliases(payload) { + const modelSchema = value => Array.isArray(value) ? value.map(modelSchema) + : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, modelSchema(entry)])) + : canonicalMenuAlias(value); + const tool = value => { + if (value?.type !== 'function' || value.function?.name !== 'Agent') return value; + const fn = value.function; + const description = typeof fn.description === 'string' + ? fn.description.replace(/^(- )magpie-local-([a-f0-9]{24})(?=(?: \[default\])?:)/gm, '$1local-model-$2') : fn.description; + const schema = fn.parameters; + return { ...value, function: { ...fn, description, + ...(schema?.properties?.model ? { parameters: { ...schema, properties: { ...schema.properties, + model: modelSchema(schema.properties.model) } } } : {}) } }; + }; + const call = value => { + const fn = value?.function; + if (fn?.name !== 'Agent' || typeof fn.arguments !== 'string') return value; + let args; + try { args = JSON.parse(fn.arguments); } catch { return value; } + if (!args || Array.isArray(args) || typeof args !== 'object' + || canonicalMenuAlias(args.model) === args.model) return value; + return { ...value, function: { ...fn, arguments: JSON.stringify({ ...args, model: canonicalMenuAlias(args.model) }) } }; + }; + return { ...payload, + ...(Array.isArray(payload.tools) ? { tools: payload.tools.map(tool) } : {}), + ...(Array.isArray(payload.messages) ? { messages: payload.messages.map(message => message?.role === 'assistant' && Array.isArray(message.tool_calls) + ? { ...message, tool_calls: message.tool_calls.map(call) } : message) } : {}), + }; +} const clean = value => typeof value === 'string' && value.length <= 2048 && !/[\x00-\x1f]/.test(value) ? value.trim() : ''; const positive = (value, fallback) => Number.isSafeInteger(value) && value > 0 ? value : fallback; const effortNames = { none: '关闭', minimal: '极低', low: '低', medium: '中', high: '高', xhigh: '极高', max: '最高' }; @@ -11,10 +45,18 @@ export function normalizeModels(payload, {allowEmpty=false}={}) { for (const row of payload.data) { const id = clean(row.id); if (!id || !id.includes('/')) continue; - const alias = 'magpie-local-' + crypto.createHash('sha256').update(id).digest('hex').slice(0, 24); + const alias = 'local-model-' + crypto.createHash('sha256').update(id).digest('hex').slice(0, 24); const advertised = [...new Set((row.supported_reasoning_levels || []).map(x => clean(x.effort)))]; const efforts = advertised.filter(x => Object.hasOwn(effortNames, x)); const thinking = efforts.includes('high') ? 'high' : efforts.includes('medium') ? 'medium' : efforts[0] || ''; + // Independent capability metadata: only explicit row-declared values, no + // runtime fallback and no invented provider default. + const options = { + reasoningEfforts: row.reasoning === false ? [] : [...efforts], + contextWindow: Number.isSafeInteger(row.context_window) && row.context_window > 0 + ? row.context_window + : Number.isSafeInteger(row.context_length) && row.context_length > 0 ? row.context_length : null, + }; const capabilities = ['dynamically_loaded_tools']; if (row.modalities?.input?.includes('image')) capabilities.push('image_in'); if (row.reasoning) { @@ -29,8 +71,8 @@ export function normalizeModels(payload, {allowEmpty=false}={}) { capabilities, ...(row.max_output_tokens ? {maxOutputSize: positive(row.max_output_tokens, 8192)} : {}), ...(efforts.length ? {supportEfforts: efforts, defaultEffort: thinking} : {}) }; const title = clean(row.magpie_label) || clean(row.display_name) || id; - models.set(id, { id, alias, runtime, menu: { - key: alias, modelId: alias, modelAlias: alias, displayName: title + ' · Magpie', description: id, + models.set(id, { id, alias, runtime, options, menu: { + key: alias, modelId: alias, modelAlias: alias, displayName: title + ' · External', description: id, inputPlaceholder: '尽管问,或做个 Agent 任务…', label: [], scenario: 6, agentMode: 'TYPE_NORMAL', kimiPlusId: 'ok-computer', switchableTo: [], reasoningEffortOptions: efforts.map(e => ({effort: 'REASONING_EFFORT_' + e.toUpperCase(), diff --git a/packages/kimi-desktop/context-budget.mjs b/packages/kimi-desktop/context-budget.mjs new file mode 100644 index 000000000..0ed11930f --- /dev/null +++ b/packages/kimi-desktop/context-budget.mjs @@ -0,0 +1,294 @@ +// Context token counting / estimation helper (Anthropic count_tokens DTO). +// Pure conversion OpenAI-chat -> Anthropic count DTO + a single gateway POST. +// Never claims exactness: the gateway may answer with a rough estimate. + +const UNAVAILABLE = 'context_count_unavailable'; +const COUNT_PATH = '/messages/count_tokens'; +const MAX_BODY_BYTES = 8192; +const DEFAULT_USER_AGENT = 'Desktop Kimi Work'; +// Root already lowercases the incoming request headers; only these are forwarded. +const FORWARD_HEADERS = ['user-agent', 'x-msh-version', 'r-timezone', 'x-session-id', 'session_id']; + +function unavailable() { + const error = new Error(UNAVAILABLE); + error.code = UNAVAILABLE; + return error; +} + +function fail() { + throw unavailable(); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function textBlocks(text) { + if (text.length === 0) return []; + return [{ type: 'text', text }]; +} + +function imageBlockFromUrl(url) { + if (typeof url !== 'string' || url.length === 0) fail(); + const dataMatch = /^data:image\/(jpeg|png|gif|webp);base64,([A-Za-z0-9+/=\s]+)$/i.exec(url); + if (dataMatch) { + const data = dataMatch[2].replace(/\s+/g, ''); + if (data.length === 0) fail(); + return { + type: 'image', + source: { type: 'base64', media_type: `image/${dataMatch[1].toLowerCase()}`, data }, + }; + } + let parsed; + try { + parsed = new URL(url); + } catch { + fail(); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') fail(); + return { type: 'image', source: { type: 'url', url } }; +} + +function contentPartToBlocks(part) { + if (typeof part === 'string') return textBlocks(part); + if (!isPlainObject(part)) fail(); + if (part.type === 'text') { + if (typeof part.text !== 'string') fail(); + return textBlocks(part.text); + } + if (part.type === 'image_url') { + const raw = part.image_url; + const url = typeof raw === 'string' ? raw : isPlainObject(raw) ? raw.url : undefined; + return [imageBlockFromUrl(url)]; + } + // audio/video/file/input_audio/... are unsupported: refuse, never skip. + return fail(); +} + +function normalizeContent(content) { + if (typeof content === 'string') return textBlocks(content); + if (!Array.isArray(content)) fail(); + const blocks = []; + for (const part of content) blocks.push(...contentPartToBlocks(part)); + return blocks; +} + +function systemTextBlocks(content) { + if (typeof content === 'string') return textBlocks(content); + if (!Array.isArray(content)) fail(); + const blocks = []; + for (const part of content) { + if (typeof part === 'string') { + blocks.push(...textBlocks(part)); + continue; + } + // Anthropic system supports text only: images and other blocks are refused. + if (!isPlainObject(part) || part.type !== 'text' || typeof part.text !== 'string') fail(); + blocks.push(...textBlocks(part.text)); + } + return blocks; +} + +function parseToolArguments(raw) { + // Missing/null/empty arguments are a malformed tool call; legal no-arg calls use "{}". + if (typeof raw !== 'string' || raw.length === 0) fail(); + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + fail(); + } + if (!isPlainObject(parsed)) fail(); + return parsed; +} + +function toolCallToBlock(call) { + if (!isPlainObject(call)) fail(); + if (call.type !== undefined && call.type !== 'function') fail(); + const fn = call.function; + if (!isPlainObject(fn)) fail(); + if (typeof fn.name !== 'string' || fn.name.length === 0) fail(); + if (typeof call.id !== 'string' || call.id.length === 0) fail(); + return { + type: 'tool_use', + id: call.id, + name: fn.name, + input: parseToolArguments(fn.arguments), + }; +} + +function toolDefToAnthropic(tool) { + if (!isPlainObject(tool)) fail(); + if (tool.type !== 'function') fail(); + const fn = tool.function; + if (!isPlainObject(fn)) fail(); + if (typeof fn.name !== 'string' || fn.name.length === 0) fail(); + const schema = fn.parameters; + let inputSchema; + if (schema === undefined) { + inputSchema = { type: 'object', properties: {} }; // legal no-arg tool + } else if (isPlainObject(schema)) { + inputSchema = schema; // keep original value/reference + } else { + fail(); // explicit null or non-object schema is malformed + } + const out = { name: fn.name, input_schema: inputSchema }; + if (fn.description !== undefined) { + if (typeof fn.description !== 'string') fail(); + out.description = fn.description; + } + return out; +} + +export function buildCountRequest(chatPayload) { + if (!isPlainObject(chatPayload)) fail(); + if (typeof chatPayload.model !== 'string' || chatPayload.model.length === 0) fail(); + if (!Array.isArray(chatPayload.messages)) fail(); + + const systemBlocks = []; + const messages = []; + + for (const message of chatPayload.messages) { + if (!isPlainObject(message)) fail(); + const role = message.role; + // Legacy function_call and tool_calls outside assistant cannot be preserved: refuse. + if (message.function_call != null) fail(); + if (role !== 'assistant' && message.tool_calls != null) fail(); + + if (role === 'system' || role === 'developer') { + systemBlocks.push(...systemTextBlocks(message.content)); + continue; + } + + if (role === 'user') { + messages.push({ role: 'user', content: normalizeContent(message.content) }); + continue; + } + + if (role === 'assistant') { + const blocks = []; + if (message.content !== undefined && message.content !== null) { + blocks.push(...normalizeContent(message.content)); + } + if (message.reasoning_content !== undefined && message.reasoning_content !== null) { + if (typeof message.reasoning_content !== 'string') fail(); + blocks.push(...textBlocks(message.reasoning_content)); + } + if (message.tool_calls != null) { + if (!Array.isArray(message.tool_calls)) fail(); + for (const call of message.tool_calls) blocks.push(toolCallToBlock(call)); + } + if (blocks.length === 0) fail(); + messages.push({ role: 'assistant', content: blocks }); + continue; + } + + if (role === 'tool') { + if (typeof message.tool_call_id !== 'string' || message.tool_call_id.length === 0) fail(); + messages.push({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: message.tool_call_id, + content: normalizeContent(message.content), + }, + ], + }); + continue; + } + + fail(); + } + + if (messages.length === 0) fail(); + + const dto = { model: chatPayload.model }; + if (systemBlocks.length > 0) dto.system = systemBlocks; + dto.messages = messages; + + if (chatPayload.tools !== undefined) { + if (!Array.isArray(chatPayload.tools)) fail(); + dto.tools = chatPayload.tools.map(toolDefToAnthropic); + } + + return dto; +} + +async function readBoundedJson(response) { + const body = response.body; + if (!body || typeof body.getReader !== 'function') fail(); + const reader = body.getReader(); + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_BODY_BYTES) { + await reader.cancel(); + fail(); + } + chunks.push(value); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + // Decoder and JSON errors fall through to the single outer catch. + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + return JSON.parse(text); +} + +function buildHeaders(gatewayKey, requestHeaders) { + const headers = { + 'content-type': 'application/json', + authorization: `Bearer ${gatewayKey}`, + 'user-agent': DEFAULT_USER_AGENT, + }; + const source = requestHeaders ?? {}; + for (const name of FORWARD_HEADERS) { + const value = source[name]; + if (typeof value === 'string') headers[name] = value; + } + return headers; +} + +export async function countContextTokens(chatPayload, options = {}) { + try { + const { gatewayURL, gatewayKey, fetchImpl = fetch, signal, requestHeaders = {} } = options ?? {}; + if (typeof gatewayURL !== 'string' || gatewayURL.length === 0) fail(); + if (typeof gatewayKey !== 'string' || gatewayKey.length === 0) fail(); + if (typeof fetchImpl !== 'function') fail(); + + const dto = buildCountRequest(chatPayload); + const url = `${gatewayURL.replace(/\/+$/, '')}${COUNT_PATH}`; + const headers = buildHeaders(gatewayKey, requestHeaders); + const timeout = AbortSignal.timeout(6000); + + const response = await fetchImpl(url, { + method: 'POST', + redirect: 'error', + proxy: '', + headers, + body: JSON.stringify(dto), + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + + if (!response || response.ok !== true) { + await response?.body?.cancel(); + fail(); + } + + const payload = await readBoundedJson(response); + if (!isPlainObject(payload)) fail(); + const tokens = payload.input_tokens; + if (!Number.isSafeInteger(tokens) || tokens < 0) fail(); + return tokens; + } catch { + // Single fixed failure: never surface provider body, error text, or secrets. + throw unavailable(); + } +} diff --git a/packages/kimi-desktop/context-budget.test.mjs b/packages/kimi-desktop/context-budget.test.mjs new file mode 100644 index 000000000..5a7af8276 --- /dev/null +++ b/packages/kimi-desktop/context-budget.test.mjs @@ -0,0 +1,679 @@ +import { test, expect, spyOn } from 'bun:test'; +import { buildCountRequest, countContextTokens } from './context-budget.mjs'; + +const UNAVAILABLE = 'context_count_unavailable'; + +function buildSupported(payload) { + let dto; + expect(() => { dto = buildCountRequest(payload); }).not.toThrow(); + return dto; +} + +function stubFetch(handler) { + const calls = []; + const impl = async (url, init) => { + calls.push({ url, init }); + return await handler(url, init); + }; + impl.calls = calls; + return impl; +} + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function deepFreeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const key of Object.keys(value)) deepFreeze(value[key]); + } + return value; +} + +async function rejection(promise) { + try { + await promise; + } catch (error) { + return error; + } + return null; +} + +function expectUnavailable(error) { + expect(error).not.toBeNull(); + expect(error.code).toBe(UNAVAILABLE); + expect(error.message).toBe(UNAVAILABLE); +} + +const basePayload = () => ({ + model: 'kimi-real-model', + messages: [{ role: 'user', content: 'hello' }], +}); + +// ---------------------------------------------------------------- conversion + +test('buildCountRequest: plain system/user/assistant DTO object equality', () => { + const payload = { + model: 'kimi-real-model', + messages: [ + { role: 'system', content: 'be terse' }, + { role: 'developer', content: 'tool policy' }, + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + ], + }; + const dto = buildSupported(payload); + expect(dto).toStrictEqual({ + model: 'kimi-real-model', + system: [{ type: 'text', text: 'be terse' }, { type: 'text', text: 'tool policy' }], + messages: [ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'hi' }] }, + ], + }); + expect(Object.keys(dto).sort()).toStrictEqual(['messages', 'model', 'system']); + expect(payload.messages[0].content).toBe('be terse'); +}); + +test('buildCountRequest: system text parts kept in original order, images in system refused', async () => { + const dto = buildSupported({ + model: 'm', + messages: [ + { role: 'system', content: [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }] }, + { role: 'developer', content: [{ type: 'text', text: 'three' }] }, + { role: 'user', content: 'u' }, + ], + }); + expect(dto.system).toStrictEqual([ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' }, + { type: 'text', text: 'three' }, + ]); + const error = await rejection( + Promise.resolve().then(() => + buildCountRequest({ + model: 'm', + messages: [ + { role: 'system', content: [{ type: 'image_url', image_url: { url: 'https://x.example/i.png' } }] }, + { role: 'user', content: 'u' }, + ], + }), + ), + ); + expectUnavailable(error); +}); + +test('buildCountRequest: image data URLs map to base64 sources for jpeg/png/gif/webp', () => { + for (const subtype of ['jpeg', 'png', 'gif', 'webp']) { + const data = `QUJD${subtype}`; + const dto = buildSupported({ + model: 'm', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'see' }, + { type: 'image_url', image_url: { url: `data:image/${subtype};base64,${data}` } }, + ], + }, + ], + }); + expect(dto.messages[0].content).toStrictEqual([ + { type: 'text', text: 'see' }, + { type: 'image', source: { type: 'base64', media_type: `image/${subtype}`, data } }, + ]); + } +}); + +test('buildCountRequest: http(s) image_url maps to url source; malformed or non-http URLs refused', async () => { + const dto = buildSupported({ + model: 'm', + messages: [ + { + role: 'user', + content: [ + { type: 'image_url', image_url: 'https://cdn.example/a.png' }, + { type: 'image_url', image_url: { url: 'http://cdn.example/b.jpg' } }, + ], + }, + ], + }); + expect(dto.messages[0].content).toStrictEqual([ + { type: 'image', source: { type: 'url', url: 'https://cdn.example/a.png' } }, + { type: 'image', source: { type: 'url', url: 'http://cdn.example/b.jpg' } }, + ]); + + for (const url of ['http://', 'ftp://cdn.example/a.png', 'data:image/svg+xml;base64,AAAA', 'not a url', 'data:image/png;base64, ']) { + const error = await rejection( + Promise.resolve().then(() => + buildCountRequest({ + model: 'm', + messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url } }] }], + }), + ), + ); + expectUnavailable(error); + } +}); + +test('buildCountRequest: unknown audio/video parts are refused, never skipped', async () => { + const audio = await rejection( + Promise.resolve().then(() => + buildCountRequest({ + model: 'm', + messages: [ + { role: 'user', content: [{ type: 'text', text: 'a' }, { type: 'input_audio', input_audio: { data: 'x', format: 'wav' } }] }, + ], + }), + ), + ); + expectUnavailable(audio); + const video = await rejection( + Promise.resolve().then(() => + buildCountRequest({ + model: 'm', + messages: [{ role: 'user', content: [{ type: 'video_url', video_url: { url: 'https://v.example/1.mp4' } }] }], + }), + ), + ); + expectUnavailable(video); +}); + +test('buildCountRequest: assistant content + tool_calls preserved together, arguments parsed to input', () => { + const dto = buildSupported({ + model: 'm', + messages: [ + { role: 'user', content: 'run it' }, + { + role: 'assistant', + content: 'calling', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'get_balance', arguments: '{"account":"A-1","amount":12.5}' }, + }, + ], + }, + ], + }); + expect(dto.messages[1]).toStrictEqual({ + role: 'assistant', + content: [ + { type: 'text', text: 'calling' }, + { type: 'tool_use', id: 'call_1', name: 'get_balance', input: { account: 'A-1', amount: 12.5 } }, + ], + }); +}); + +test('buildCountRequest: reasoning_content becomes ordinary text, no thinking signature', () => { + const dto = buildSupported({ + model: 'm', + messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: null, reasoning_content: 'internal chain', tool_calls: [{ id: 'c1', function: { name: 'f', arguments: '{}' } }] }, + ], + }); + expect(dto.messages[1].content).toStrictEqual([ + { type: 'text', text: 'internal chain' }, + { type: 'tool_use', id: 'c1', name: 'f', input: {} }, + ]); + expect(dto.messages[1].content[0].signature).toBeUndefined(); +}); + +test('buildCountRequest: non-string reasoning_content, legacy function_call, misplaced tool_calls refused', async () => { + const cases = [ + { role: 'assistant', content: 'x', reasoning_content: 42 }, + { role: 'assistant', content: 'x', reasoning_content: { text: 'x' } }, + { role: 'assistant', content: 'x', function_call: { name: 'f', arguments: '{}' } }, + { role: 'user', content: 'x', tool_calls: [] }, + { role: 'tool', tool_call_id: 'c', content: 'r', tool_calls: [] }, + ]; + for (const message of cases) { + const error = await rejection( + Promise.resolve().then(() => buildCountRequest({ model: 'm', messages: [{ role: 'user', content: 'q' }, message] })), + ); + expectUnavailable(error); + } +}); + +test('buildCountRequest: role tool becomes user/tool_result keeping id, text and image', () => { + const dto = buildSupported({ + model: 'm', + messages: [ + { role: 'user', content: 'q' }, + { role: 'tool', tool_call_id: 'call_9', content: 'balance is 42' }, + { + role: 'tool', + tool_call_id: 'call_10', + content: [ + { type: 'text', text: 'chart' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }, + ], + }, + ], + }); + expect(dto.messages[1]).toStrictEqual({ + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_9', content: [{ type: 'text', text: 'balance is 42' }] }], + }); + expect(dto.messages[2]).toStrictEqual({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_10', + content: [ + { type: 'text', text: 'chart' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + ], + }); +}); + +test('buildCountRequest: tools map name/description/parameters, schema reference preserved, no-arg gets object schema', () => { + const schema = { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] }; + const dto = buildSupported({ + model: 'm', + messages: [{ role: 'user', content: 'q' }], + tools: [ + { type: 'function', function: { name: 'search_db', description: 'query', parameters: schema } }, + { type: 'function', function: { name: 'ping' } }, + ], + }); + expect(dto.tools).toStrictEqual([ + { name: 'search_db', description: 'query', input_schema: schema }, + { name: 'ping', input_schema: { type: 'object', properties: {} } }, + ]); + expect(dto.tools[0].input_schema).toBe(schema); +}); + +test('buildCountRequest: bad tool shapes are refused (type, args JSON/null/empty/missing, schema shape)', async () => { + const cases = [ + { model: 'm', messages: [{ role: 'user', content: 'q' }], tools: [{ type: 'retrieval', name: 'r' }] }, + { model: 'm', messages: [{ role: 'user', content: 'q' }], tools: [{ type: 'function', function: { name: 'f', parameters: [] } }] }, + { model: 'm', messages: [{ role: 'user', content: 'q' }], tools: [{ type: 'function', function: { name: 'f', parameters: null } }] }, + { + model: 'm', + messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'c', function: { name: 'f', arguments: '{not json' } }] }, + ], + }, + { + model: 'm', + messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'c', function: { name: 'f', arguments: null } }] }, + ], + }, + { + model: 'm', + messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'c', function: { name: 'f', arguments: '' } }] }, + ], + }, + { + model: 'm', + messages: [ + { role: 'user', content: 'q' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'c', function: { name: 'f' } }] }, + ], + }, + { + model: 'm', + messages: [{ role: 'user', content: 'q' }, { role: 'assistant', content: null, tool_calls: [{ function: { name: 'f', arguments: '{}' } }] }], + }, + ]; + for (const payload of cases) { + const error = await rejection(Promise.resolve().then(() => buildCountRequest(payload))); + expectUnavailable(error); + } +}); + +test('buildCountRequest: deeply frozen payload converts and is not mutated', () => { + const payload = deepFreeze({ + model: 'kimi-real-model', + messages: [ + { role: 'system', content: 'sys' }, + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: 'ok', tool_calls: [{ id: 'c1', function: { name: 'f', arguments: '{"a":1}' } }] }, + { role: 'tool', tool_call_id: 'c1', content: 'done' }, + ], + tools: [{ type: 'function', function: { name: 'f', parameters: { type: 'object', properties: {} } } }], + max_tokens: 4096, + }); + const snapshot = JSON.stringify(payload); + const dto = buildSupported(payload); + expect(dto.messages).toHaveLength(3); + expect(dto.tools[0].name).toBe('f'); + expect(JSON.stringify(payload)).toBe(snapshot); + expect(dto.max_tokens).toBeUndefined(); + expect(dto.context).toBeUndefined(); + expect(dto.usage).toBeUndefined(); +}); + +// ---------------------------------------------------------------- counting + +test('countContextTokens: stubbed fetch returns 37 via /messages/count_tokens', async () => { + const fetchImpl = stubFetch(() => jsonResponse({ input_tokens: 37 })); + const payload = basePayload(); + const n = await countContextTokens(payload, { + gatewayURL: 'https://gw.example/base/', + gatewayKey: 'sk-test', + fetchImpl, + }); + expect(n).toBe(37); + expect(fetchImpl.calls).toHaveLength(1); + expect(fetchImpl.calls[0].url).toBe('https://gw.example/base/messages/count_tokens'); + expect(fetchImpl.calls[0].init.method).toBe('POST'); + expect(JSON.parse(fetchImpl.calls[0].init.body)).toStrictEqual(buildCountRequest(payload)); + expect(payload).toStrictEqual(basePayload()); +}); + +test('countContextTokens: default headers are exactly content-type, authorization, user-agent', async () => { + const fetchImpl = stubFetch(() => jsonResponse({ input_tokens: 1 })); + await countContextTokens(basePayload(), { gatewayURL: 'https://gw.example', gatewayKey: 'sk-key', fetchImpl }); + const headers = fetchImpl.calls[0].init.headers; + expect(Object.keys(headers).sort()).toStrictEqual(['authorization', 'content-type', 'user-agent']); + expect(headers['content-type']).toBe('application/json'); + expect(headers.authorization).toBe('Bearer sk-key'); + expect(headers['user-agent']).toBe('Desktop Kimi Work'); +}); + +test('countContextTokens: requestHeaders forwards only the 5 lowercase whitelisted names, user-agent overrides', async () => { + const fetchImpl = stubFetch(() => jsonResponse({ input_tokens: 2 })); + await countContextTokens(basePayload(), { + gatewayURL: 'https://gw.example', + gatewayKey: 'sk-key', + fetchImpl, + requestHeaders: { + 'user-agent': 'Custom UA/1.0', + 'x-msh-version': '9.9', + 'r-timezone': 'Asia/Singapore', + 'x-session-id': 'sess-1', + session_id: 'sess-2', + authorization: 'Bearer native-secret', + origin: 'app://native', + cookie: 'a=b', + 'x-not-allowed': 'nope', + }, + }); + const headers = fetchImpl.calls[0].init.headers; + expect(headers['user-agent']).toBe('Custom UA/1.0'); + expect(headers['x-msh-version']).toBe('9.9'); + expect(headers['r-timezone']).toBe('Asia/Singapore'); + expect(headers['x-session-id']).toBe('sess-1'); + expect(headers.session_id).toBe('sess-2'); + expect(headers.authorization).toBe('Bearer sk-key'); + expect(headers.origin).toBeUndefined(); + expect(headers.cookie).toBeUndefined(); + expect(headers['x-not-allowed']).toBeUndefined(); + const userAgentKeys = Object.keys(headers).filter((k) => k.toLowerCase() === 'user-agent'); + expect(userAgentKeys).toStrictEqual(['user-agent']); + expect(Object.keys(headers).every((k) => k === k.toLowerCase())).toBe(true); +}); + +test('countContextTokens: strips all trailing slashes and posts once, no retry', async () => { + const fetchImpl = stubFetch(() => jsonResponse({ input_tokens: 3 })); + await countContextTokens(basePayload(), { gatewayURL: 'https://gw.example/base///', gatewayKey: 'k', fetchImpl }); + expect(fetchImpl.calls[0].url).toBe('https://gw.example/base/messages/count_tokens'); + expect(fetchImpl.calls).toHaveLength(1); +}); + +test('countContextTokens: accepts 0 and rejects invalid input_tokens shapes', async () => { + const zero = stubFetch(() => jsonResponse({ input_tokens: 0 })); + expect(await countContextTokens(basePayload(), { gatewayURL: 'https://gw.example', gatewayKey: 'k', fetchImpl: zero })).toBe(0); + + for (const bad of [-1, 1.5, '37', null, undefined, Number.MAX_SAFE_INTEGER + 2, Number.NaN]) { + const fetchImpl = stubFetch(() => jsonResponse({ input_tokens: bad })); + const error = await rejection( + countContextTokens(basePayload(), { gatewayURL: 'https://gw.example', gatewayKey: 'k', fetchImpl }), + ); + expectUnavailable(error); + } +}); + +test('countContextTokens: non-OK, bad JSON and fetch errors all map to fixed unavailable without leaking', async () => { + const errorCases = [ + stubFetch(() => jsonResponse({ input_tokens: 1 }, 500)), + stubFetch(() => new Response('not json', { status: 200 })), + stubFetch(() => new Response(new Uint8Array([ + ...new TextEncoder().encode('{"input_tokens":1,"padding":"'), 255, ...new TextEncoder().encode('"}'), + ]))), + stubFetch(() => { + throw new Error('ECONNREFUSED sk-secret-123'); + }), + stubFetch(() => jsonResponse({ nope: 1 })), + stubFetch(() => { + throw Object.assign(new Error('provider secret body'), { code: UNAVAILABLE }); + }), + ]; + for (const fetchImpl of errorCases) { + const error = await rejection( + countContextTokens(basePayload(), { gatewayURL: 'https://gw.example', gatewayKey: 'sk-secret-123', fetchImpl }), + ); + expectUnavailable(error); + expect(error.message).not.toContain('sk-secret-123'); + expect(error.message).not.toContain('ECONNREFUSED'); + expect(error.message).not.toContain('provider secret'); + expect(error.message).not.toContain('input_tokens'); + expect(fetchImpl.calls).toHaveLength(1); + } +}); + +test('countContextTokens: response body over 8KiB is refused (bounded read)', async () => { + const fetchImpl = stubFetch(() => jsonResponse({ input_tokens: 5, pad: 'a'.repeat(9000) })); + const error = await rejection( + countContextTokens(basePayload(), { gatewayURL: 'https://gw.example', gatewayKey: 'k', fetchImpl }), + ); + expectUnavailable(error); +}); + +test('countContextTokens: parent signal is honored and combined, aborted parent rejects', async () => { + const controller = new AbortController(); + controller.abort(); + const fetchImpl = stubFetch((_url, init) => { + if (init.signal && init.signal.aborted) throw new Error('aborted'); + return jsonResponse({ input_tokens: 4 }); + }); + const error = await rejection( + countContextTokens(basePayload(), { + gatewayURL: 'https://gw.example', + gatewayKey: 'k', + fetchImpl, + signal: controller.signal, + }), + ); + expectUnavailable(error); +}); + +test('countContextTokens: missing gateway config fails closed without calling fetch', async () => { + for (const options of [ + { gatewayURL: '', gatewayKey: 'k' }, + { gatewayURL: 'https://gw.example', gatewayKey: '' }, + { gatewayKey: 'k' }, + ]) { + const fetchImpl = stubFetch(() => jsonResponse({ input_tokens: 1 })); + const error = await rejection(countContextTokens(basePayload(), { ...options, fetchImpl })); + expectUnavailable(error); + expect(fetchImpl.calls).toHaveLength(0); + } +}); + + +test('buildCountRequest: nullable call fields and empty assistant text retain financial tool input and result', () => { + const payload = deepFreeze({ model: 'fixture/provider-model', messages: [ + { role: 'system', content: 'policy', function_call: null }, + { role: 'user', content: 'query', tool_calls: null }, + { role: 'assistant', content: [], function_call: null, tool_calls: [ + { id: 'finance-1', type: 'function', function: { name: 'finance_database_query', arguments: '{"ticker":"ABC"}' } }, + ] }, + { role: 'tool', tool_call_id: 'finance-1', content: '{"price":123}' }, + { role: 'assistant', content: 'answer', tool_calls: null, reasoning_content: null }, + ], authorization: { fixture: 'native-permission' }, billing: { fixture: 'native-billing' } }); + let dto; + expect(() => { dto = buildCountRequest(payload); }).not.toThrow(); + expect(dto.messages[1].content).toStrictEqual([ + { type: 'tool_use', id: 'finance-1', name: 'finance_database_query', input: { ticker: 'ABC' } }, + ]); + expect(dto.messages[2].content).toStrictEqual([ + { type: 'tool_result', tool_use_id: 'finance-1', content: [{ type: 'text', text: '{"price":123}' }] }, + ]); + expect(dto.messages[3].content).toStrictEqual([{ type: 'text', text: 'answer' }]); + expect(payload.authorization).toStrictEqual({ fixture: 'native-permission' }); + expect(payload.billing).toStrictEqual({ fixture: 'native-billing' }); + expect(dto.max_tokens).toBeUndefined(); +}); + +test('countContextTokens: non-OK response body is cancelled without reading provider text', async () => { + let cancelled = false, reads = 0; + const body = { + cancel: async () => { cancelled = true; }, + getReader: () => { reads++; throw Error('provider-secret'); }, + }; + const error = await rejection(countContextTokens(basePayload(), { + gatewayURL: 'https://gw.example', gatewayKey: 'fixture-key', fetchImpl: async () => ({ ok: false, body }), + })); + expectUnavailable(error); + expect(cancelled).toBe(true); + expect(reads).toBe(0); +}); + +test('countContextTokens: oversized count JSON cancels the reader before further consumption', async () => { + const bytes = new TextEncoder().encode(JSON.stringify({ input_tokens: 5, padding: 'x'.repeat(9000) })); + let reads = 0, cancelled = false; + const reader = { + read: async () => reads++ === 0 ? { done: false, value: bytes } : { done: true }, + cancel: async () => { cancelled = true; }, + }; + const error = await rejection(countContextTokens(basePayload(), { + gatewayURL: 'https://gw.example', gatewayKey: 'fixture-key', + fetchImpl: async () => ({ ok: true, body: { getReader: () => reader } }), + })); + expectUnavailable(error); + expect(cancelled).toBe(true); + expect(reads).toBe(1); +}); + +test('countContextTokens: a valid count JSON at exactly 8KiB is accepted', async () => { + const overhead = JSON.stringify({ input_tokens: 5, padding: '' }).length; + const json = JSON.stringify({ input_tokens: 5, padding: 'x'.repeat(8192 - overhead) }); + expect(new TextEncoder().encode(json).byteLength).toBe(8192); + await expect(countContextTokens(basePayload(), { + gatewayURL: 'https://gw.example', gatewayKey: 'fixture-key', fetchImpl: async () => new Response(json), + })).resolves.toBe(5); +}); + +test('countContextTokens: the internal six-second timeout is combined with the parent signal', async () => { + const timeout = new AbortController(), parent = new AbortController(); + const timeoutSpy = spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal); + let forwarded; + try { + await expect(countContextTokens(basePayload(), { + gatewayURL: 'https://gw.example', gatewayKey: 'fixture-key', signal: parent.signal, + fetchImpl: async (_url, options) => { forwarded = options.signal; return jsonResponse({ input_tokens: 1 }); }, + })).resolves.toBe(1); + expect(timeoutSpy).toHaveBeenCalledWith(6000); + expect(forwarded).not.toBe(parent.signal); + expect(forwarded).not.toBe(timeout.signal); + expect(forwarded.aborted).toBe(false); + timeout.abort(); + expect(forwarded.aborted).toBe(true); + } finally { timeoutSpy.mockRestore(); } +}); + + +test('countContextTokens: refuses redirects like the existing gateway transport', async () => { + const fetchImpl = stubFetch(() => jsonResponse({ input_tokens: 1 })); + await countContextTokens(basePayload(), { gatewayURL: 'https://gw.example', gatewayKey: 'synthetic-key', fetchImpl }); + expect(fetchImpl.calls[0].init.redirect).toBe('error'); +}); + +test('countContextTokens: bypasses environment proxies like the local gateway transport', async () => { + const fetchImpl = stubFetch(() => jsonResponse({ input_tokens: 1 })); + await countContextTokens(basePayload(), { gatewayURL: 'https://gw.example', gatewayKey: 'synthetic-key', fetchImpl }); + expect(fetchImpl.calls[0].init.proxy).toBe(''); +}); + + +function emptyTextPayload() { + return deepFreeze({ + model: 'fixture/provider-model', + messages: [ + { role: 'system', content: '' }, + { role: 'system', content: ['', { type: 'text', text: '' }, { type: 'text', text: ' policy ' }] }, + { role: 'developer', content: '' }, + { role: 'developer', content: ['', { type: 'text', text: '' }, '\n'] }, + { role: 'user', content: ['', { type: 'text', text: '' }, ' ', { type: 'text', text: 'query' }] }, + { role: 'assistant', content: '', reasoning_content: '', tool_calls: [ + { id: 'finance-empty', type: 'function', function: { name: 'finance_database_query', arguments: '{"ticker":"ABC","empty":""}' } }, + ] }, + { role: 'tool', tool_call_id: 'finance-empty', content: '' }, + { role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'text', text: 'answer ' }, ''] }, + ], + tools: [{ type: 'function', function: { name: 'finance_database_query', parameters: { type: 'object', properties: { ticker: { type: 'string' } } } } }], + authorization: { fixture: 'native-permission' }, billing: { fixture: 'native-billing' }, + }); +} + +test('buildCountRequest: drops only zero-length text and retains financial tool data', () => { + const payload = emptyTextPayload(), original = JSON.stringify(payload); + const dto = buildSupported(payload); + expect(dto).toStrictEqual({ + model: 'fixture/provider-model', + system: [{ type: 'text', text: ' policy ' }, { type: 'text', text: '\n' }], + messages: [ + { role: 'user', content: [{ type: 'text', text: ' ' }, { type: 'text', text: 'query' }] }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'finance-empty', name: 'finance_database_query', input: { ticker: 'ABC', empty: '' } }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'finance-empty', content: [] }] }, + { role: 'assistant', content: [{ type: 'text', text: 'answer ' }] }, + ], + tools: [{ name: 'finance_database_query', input_schema: payload.tools[0].function.parameters }], + }); + expect(JSON.stringify(payload)).toBe(original); + expect(dto.tools[0].input_schema).toBe(payload.tools[0].function.parameters); +}); + +test('buildCountRequest: omits empty system and refuses empty-only assistant without tools', async () => { + const dto = buildSupported({ model: 'm', messages: [ + { role: 'system', content: '' }, { role: 'developer', content: ['', { type: 'text', text: '' }] }, + { role: 'user', content: 'query' }, + ] }); + expect(dto.system).toBeUndefined(); + for (const content of ['', [], ['', { type: 'text', text: '' }]]) { + expectUnavailable(await rejection(Promise.resolve().then(() => buildCountRequest({ + model: 'm', messages: [{ role: 'user', content: 'query' }, { role: 'assistant', content }], + })))); + } +}); + +test('countContextTokens: empty-text source passes a provider schema check without mutating generation payload', async () => { + const payload = emptyTextPayload(), original = JSON.stringify(payload); + function hasEmptyText(value) { + if (!value || typeof value !== 'object') return false; + if (value.type === 'text' && value.text === '') return true; + return Object.values(value).some(hasEmptyText); + } + const fetchImpl = stubFetch((_url, init) => { + const dto = JSON.parse(init.body); + return hasEmptyText(dto) + ? jsonResponse({ error: 'schema_empty_text' }, 400) + : jsonResponse({ input_tokens: 59 }); + }); + await expect(countContextTokens(payload, { + gatewayURL: 'https://gw.example', gatewayKey: 'fixture-key', fetchImpl, + })).resolves.toBe(59); + expect(fetchImpl.calls).toHaveLength(1); + expect(JSON.stringify(payload)).toBe(original); + expect(payload.messages[5].content).toBe(''); + expect(payload.messages[6].content).toBe(''); + expect(payload.authorization).toStrictEqual({ fixture: 'native-permission' }); + expect(payload.billing).toStrictEqual({ fixture: 'native-billing' }); +}); diff --git a/packages/kimi-desktop/index.mjs b/packages/kimi-desktop/index.mjs index 061e3e8f3..fa5caff81 100644 --- a/packages/kimi-desktop/index.mjs +++ b/packages/kimi-desktop/index.mjs @@ -1,19 +1,58 @@ import { KimiAdapter } from './adapter.mjs'; +import { startSettingsServer, SETTINGS_PORT } from './settings.mjs'; +import { setPrimaryModel } from './primary-model.mjs'; const instance = Symbol.for('magpie.kimi-desktop.adapter.v1'); +const SETTINGS_START_ERRORS = new Set(['settings_invalid_port', 'settings_port_in_use', 'settings_bind_failed']); -export default async function KimiDesktop(input) { - if (!globalThis[instance]) { - const log = code => { +function syncSettings(adapter, log) { + if (adapter.stopping) return; + if (adapter.settingsPending) return adapter.settingsPending; + if (adapter.settingsServer?.port === adapter.settingsPort) return; + adapter.settingsPending = (async () => { + for (;;) { + const previous = adapter.settingsServer; + adapter.settingsServer = null; + await previous?.stop(); + const port = adapter.settingsPort; 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. */ } - }; + adapter.settingsServer = await startSettingsServer({ + port, + readSettings: () => adapter.readSettings(), + saveSettings: patch => adapter.saveSettings(patch), + }); + adapter.settingsFailure = null; + } catch (error) { + const code = SETTINGS_START_ERRORS.has(error?.message) ? error.message : 'settings_server_unavailable'; + if (adapter.settingsFailure !== code) log(code); + adapter.settingsFailure = code; + return; + } + // A newer initialization may change the requested port while bind awaits. + if (port === adapter.settingsPort) return; + } + })().finally(() => { adapter.settingsPending = null; }); + return adapter.settingsPending; +} + +export default async function KimiDesktop(input, options = {}) { + if (process.env.MAGPIE_KIMI_SKIP_ADAPTER === '1') return {}; + 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 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]; + adapter.setPrimaryModel = model => setPrimaryModel(model, { magpiePath: options?.magpiePath, directory: input?.directory }); + adapter.settingsPort = options?.settingsPort ?? SETTINGS_PORT; + adapter.syncSettings = () => syncSettings(adapter, log); + await adapter.syncSettings(); return {}; } diff --git a/packages/kimi-desktop/kimi-desktop.test.mjs b/packages/kimi-desktop/kimi-desktop.test.mjs index d71a3b25c..83c66d268 100644 --- a/packages/kimi-desktop/kimi-desktop.test.mjs +++ b/packages/kimi-desktop/kimi-desktop.test.mjs @@ -9,6 +9,7 @@ import { KimiAdapter } from './adapter.mjs'; import { controlEndpoint } from './rpc.mjs'; import { agent, connect } from './kimi-desktop.agent.js'; import { applyInstructions } from './library.mjs'; +import { nativeRolePluginPeer } from './test-fixtures/control-peer.mjs'; const official = 'https://agent-gw.kimi.com/coding/v1'; const read = file => JSON.parse(fs.readFileSync(file, 'utf8')); @@ -71,6 +72,7 @@ test('shared instructions hot-update in existing conversations without replacing async function fixture() { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'magpie-kimi-fixture-')); const kimiDir = path.join(directory, 'kimi'); + const plugins = nativeRolePluginPeer(path.join(kimiDir, 'runtime', 'kimi-code', 'home')); const requests = [], tools = [], calls = []; const models = [{id:'original-provider/test-model',display_name:'First'}, {id:'other-provider/other-model',display_name:'Second'}]; const gateway = http.createServer(async (req, res) => { @@ -91,6 +93,9 @@ async function fixture() { const controller = new KimiAdapter({ stateDir: path.join(directory, 'state'), kimiDir, stabilityMs: 0, intervalMs: 100000, rpcCall: async (_runner, method, params) => { calls.push({ method, params }); + if (plugins.handles(method)) return plugins.call(method, params); + if (method === 'kimiPlugin.reloadActiveSessions') return { sessions: [] }; + if (method === 'kimiPlugin.ReloadSession') return { status: 'reloaded' }; if (method === 'conversations.getKimiModelList') return {models:[{model:'k2d8-preview'}]}; return { current: 'k2d8-preview' }; }, fetchImpl: async (url, options) => { @@ -106,16 +111,79 @@ async function fixture() { models: { 'k2d8-preview': { model: 'k2d8-preview', provider: 'daimon-kimi-code' } } }, unrelated: { preserve: ['all', 'original', 'settings'] } }; write(controller.configFile, doc); - write(controller.runnerFile, { lifecycleStatus: 'running', daemonGeneration: 'synthetic-generation-1', + write(controller.runnerFile, { lifecycleStatus: 'running', daemonGeneration: 'synthetic-generation-1', kernel: { agentKernel: 'kimi-code-v2' }, activeKernelTurns: 0, activeKernelToolCalls: 0, control: { endpoint: { url: 'ws://127.0.0.1:3072/control', pid: 10, startedAt: 1 } } }); controller.bootstrap(); const connection = { schemaVersion: 1, enabled: true, model: 'original-provider/test-model', gateway: { url: 'http://127.0.0.1:' + gateway.address().port + '/v1', key: 'synthetic-magpie-key' }, requestId: 1 }; write(controller.connectionFile, connection); - return { directory, controller, doc, connection, requests, tools, calls, models, + return { directory, controller, doc, connection, requests, tools, calls, models, plugins, 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', 'primaryContextTokens', 'primaryModel', + 'primaryOptionsStatus', 'primaryReasoningEffort', 'secondaryContextTokens', 'secondaryModel', + 'secondaryOptionsStatus', 'secondaryReasoningEffort', '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 { @@ -143,11 +211,12 @@ test('Work inference uses selected model and gateway auth; SSE tools survive; of expect(current.credentials).toEqual(f.doc.credentials); expect(current.model.current).toBe('k2d8-preview'); expect(current.unrelated).toEqual(f.doc.unrelated); - expect(f.calls.map(x => x.method)).toEqual(['models.current', 'conversations.setModel', 'conversations.getKimiModelList', 'conversations.updateKimiModelList']); + // No secondary preference means no role plugin is installed or reloaded. + expect(f.calls.map(x => x.method)).toEqual(['models.current', 'conversations.setModel']); } finally { await f.cleanup(); } }); -test('thinking controls and complete tool histories survive the desktop bridge with and without Library instructions', async () => { +test('native thinking controls are ignored while complete tool histories survive with and without Library instructions', async () => { const f = await fixture(); try { await f.controller.tick(); @@ -173,6 +242,7 @@ test('thinking controls and complete tool histories survive the desktop bridge w ], ...controls}; const expected = structuredClone(original); expected.model = 'original-provider/test-model'; + for (const field of ['thinking', 'reasoning_effort', 'reasoning']) delete expected[field]; if (shared) expected.messages.splice(2, 0, {role: 'system', content: '\nsynthetic shared instruction\n'}); const response = await fetch(f.controller.bridgeURL + '/chat/completions', {method: 'POST', @@ -184,28 +254,342 @@ test('thinking controls and complete tool histories survive the desktop bridge w } finally {await f.cleanup();} }); -test('Kimi menu aliases route independently and removed models fail closed', async () => { +for (const format of ['json', 'sse']) { + test(`the bridge translates Agent roles to installed profiles and routes a child with no user history (${format})`, async () => { + const f = await fixture(); + try { + write(f.controller.connectionFile, { ...f.connection, secondaryModel: f.models[1].id }); + await f.controller.tick(); + const originalFetch = f.controller.fetchImpl; + const sent = []; + const task = 'Read the exact synthetic file /tmp/ordinary-child-task.txt'; + const tools = [{ type: 'function', function: { name: 'Agent', description: 'Create an ordinary task.', + parameters: { type: 'object', properties: { prompt: { type: 'string' }, description: { type: 'string' } }, + required: ['prompt', 'description'], additionalProperties: false } } }]; + for (const role of [undefined, 'primary', 'secondary']) { + f.controller.fetchImpl = async (url, options) => { + if (String(url).endsWith('/models')) return originalFetch(url, options); + const body = JSON.parse(options.body); sent.push(body); + if (!body.tools?.some(tool => tool.function?.name === 'Agent')) { + return new Response('{"choices":[{"message":{"role":"assistant","content":"done"}}]}', + { headers: { 'Content-Type': 'application/json' } }); + } + const args = { prompt: task, description: 'Synthetic child', ...(role ? { model: role } : {}) }; + const call = { index: 0, id: 'new-agent', type: 'function', function: { name: 'Agent', arguments: JSON.stringify(args) } }; + return format === 'json' + ? new Response(JSON.stringify({ choices: [{ index: 0, message: { role: 'assistant', tool_calls: [call] }, finish_reason: 'tool_calls' }] }), + { headers: { 'Content-Type': 'application/json' } }) + : new Response('data: ' + JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [call] } }] }) + + '\n\ndata: ' + JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }) + '\n\ndata: [DONE]\n\n', + { headers: { 'Content-Type': 'text/event-stream' } }); + }; + const parent = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, + body: JSON.stringify({ model: 'k2d8-preview', messages: [{ role: 'user', content: 'Create the synthetic child' }], tools }) }); + expect(parent.status).toBe(200); + let argumentsText; + if (format === 'json') argumentsText = (await parent.json()).choices[0].message.tool_calls[0].function.arguments; + else { + const frames = (await parent.text()).split(/\r?\n/).filter(line => line.startsWith('data: ') && line !== 'data: [DONE]') + .map(line => JSON.parse(line.slice(6))); + argumentsText = frames.flatMap(frame => frame.choices || []).flatMap(choice => choice.delta?.tool_calls || []) + .map(call => call.function?.arguments || '').join(''); + } + const nativeArgs = JSON.parse(argumentsText); + expect(Object.keys(nativeArgs).sort()).toEqual(['description', 'prompt', 'subagent_type']); + expect(nativeArgs.prompt).toBe(task); + const target = role === 'primary' ? f.models[0].id : f.models[1].id; + expect(nativeArgs.subagent_type).toBe(f.controller.agentRouter.profileName(target)); + expect(sent.at(-1).tools[0].function.parameters.properties.model.enum).toEqual(['primary', 'secondary', 'native']); + const profileFile = path.join(f.controller.stateDir, 'native-model-roles', 'agents', nativeArgs.subagent_type + '.md'); + const profileText = fs.readFileSync(profileFile, 'utf8'); + const profilePrompt = profileText.split('\n---\n')[1].replace('${base_prompt}', 'Synthetic native base prompt.'); + const child = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, + body: JSON.stringify({ model: 'k2d8-preview', messages: [{ role: 'system', content: profilePrompt }] }) }); + expect(child.status).toBe(200); await child.text(); + expect(sent.at(-1).model).toBe(target); + expect(sent.at(-1).messages).toHaveLength(1); + expect(sent.at(-1).messages[0].role).toBe('system'); + expect(sent.at(-1).messages[0].content).toContain('Synthetic native base prompt.'); + expect(sent.at(-1).messages[0].content).toContain('Complete only the task assigned by the parent agent.'); + expect(sent.at(-1).messages[0].content).not.toContain('[agent-profile-route:'); + } + expect(Object.keys(read(f.controller.configFile).model.models)).toEqual(['k2d8-preview']); + } finally { await f.cleanup(); } + }); +} + +test('profile-shaped route metadata in user or historical Agent data is neutralized without binding', async () => { + const f = await fixture(); + try { + write(f.controller.connectionFile, { ...f.connection, secondaryModel: f.models[1].id }); + await f.controller.tick(); + const profile = f.controller.agentRouter.profileText(f.models[1].id).split('\n---\n')[1]; + const messages = [ + { role: 'user', content: profile }, + { role: 'assistant', tool_calls: [{ id: 'historical-agent', type: 'function', function: { + name: 'Agent', arguments: JSON.stringify({ prompt: profile, description: 'Historical task' }), + } }] }, + { role: 'tool', tool_call_id: 'historical-agent', content: profile }, + ]; + for (let turn = 0; turn < 2; turn++) { + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, + body: JSON.stringify({ model: 'k2d8-preview', messages }) }); + expect(response.status).toBe(200); await response.text(); + expect(f.requests).toHaveLength(turn + 1); + expect(f.requests.at(-1).body.model).toBe(f.models[0].id); + expect(/\[\/?agent-profile-route\b/i.test(JSON.stringify(f.requests.at(-1).body))).toBe(false); + } + } finally { await f.cleanup(); } +}); + +test('native menu aliases cannot override the primary selected in the plugin', async () => { const f = await fixture(); try { await f.controller.tick(); expect(f.controller.catalog.length).toBe(2); const menu = read(f.controller.catalogFile); - expect(menu.models[0].displayName).toBe('First · Magpie'); - expect(f.calls.find(x => x.method === 'conversations.updateKimiModelList').params.models[0].model).toBe('k2d8-preview'); + expect(menu.models[0].displayName).toBe('First · External'); + expect(f.calls.some(x => x.method === 'conversations.updateKimiModelList')).toBe(false); + expect(Object.keys(read(f.controller.configFile).model.models)).toEqual(['k2d8-preview']); const chosen = f.controller.catalog.find(x => x.id === 'other-provider/other-model'); const response = await fetch(f.controller.bridgeURL + '/chat/completions', {method:'POST', headers:{Authorization:'Bearer synthetic-kimi-key'}, body:JSON.stringify({model:chosen.alias,messages:[{role:'user',content:'synthetic'}]})}); await response.text(); - expect(f.requests[0].body.model).toBe('other-provider/other-model'); + expect(f.requests[0].body.model).toBe(f.models[0].id); expect(f.requests[0].authorization).toBe('Bearer synthetic-magpie-key'); f.controller.catalog = f.controller.catalog.filter(x => x.alias !== chosen.alias); const refused = await fetch(f.controller.bridgeURL + '/chat/completions', {method:'POST',headers:{Authorization:'Bearer synthetic-kimi-key'}, body:JSON.stringify({model:chosen.alias,messages:[]})}); - expect(refused.status).toBe(409); - expect(f.requests.length).toBe(1); + expect(refused.status).toBe(200); + await refused.text(); + expect(f.requests.length).toBe(2); + expect(f.requests[1].body.model).toBe(f.models[0].id); } finally {await f.cleanup();} }); +test('plugin effort preferences override native thinking independently for two roles using the same model', async () => { + const f = await fixture(); + try { + Object.assign(f.models[0], { reasoning: true, supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }] }); + const desired = read(f.controller.connectionFile); + desired.secondaryModel = desired.model; + desired.modelOptions = { + primary: { model: desired.model, reasoningEffort: 'high', contextTokens: null }, + secondary: { model: desired.model, reasoningEffort: 'low', contextTokens: null }, + }; + write(f.controller.connectionFile, desired); + await f.controller.tick(); + const router = f.controller.ensureAgentRouter(); + for (const [role, effort] of [['parent', 'high'], ['primary', 'high'], ['secondary', 'low']]) { + const messages = [{ role: 'user', content: 'synthetic role request' }]; + if (role !== 'parent') messages.unshift({ role: 'system', content: router.profileText(desired.model, role) }); + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, body: JSON.stringify({ model: 'native-menu-choice', + thinking: { type: 'enabled', effort: 'max' }, reasoning: { effort: 'medium' }, reasoning_effort: 'none', messages }) }); + expect(response.status).toBe(200); await response.text(); + const sent = f.requests.at(-1).body; + expect(sent.model).toBe(desired.model); + expect(sent.reasoning_effort).toBe(effort); + expect(sent).not.toHaveProperty('thinking'); + expect(sent).not.toHaveProperty('reasoning'); + } + const saved = read(f.controller.stateFile); + expect(Object.values(saved.agentRoutingRoles)).toContain('primary'); + expect(Object.values(saved.agentRoutingRoles)).toContain('secondary'); + const profile = router.profileText(desired.model, 'secondary'); + const runner = read(f.controller.runnerFile); + runner.daemonGeneration = 'synthetic-generation-2'; + write(f.controller.runnerFile, runner); + await f.controller.tick(); + f.controller.state = read(f.controller.stateFile); + f.controller.agentRouter = null; + const restored = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, body: JSON.stringify({ model: 'native', + messages: [{ role: 'system', content: profile }, { role: 'user', content: 'synthetic restored child' }] }) }); + expect(restored.status).toBe(200); await restored.text(); + expect(f.requests.at(-1).body.reasoning_effort).toBe('low'); + } finally { await f.cleanup(); } +}); + +test('existing connections use provider defaults rather than native menu thinking even before options are saved', async () => { + const f = await fixture(); + try { + await f.controller.tick(); + const messages = [{ role: 'user', content: 'synthetic default request' }]; + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, body: JSON.stringify({ model: 'native-choice', + thinking: { type: 'enabled', effort: 'max' }, reasoning: { effort: 'high' }, reasoning_effort: 'low', messages }) }); + expect(response.status).toBe(200); await response.text(); + const body = f.requests[0].body; + expect(body.messages).toEqual(messages); + for (const field of ['thinking', 'reasoning', 'reasoning_effort']) expect(body).not.toHaveProperty(field); + expect(read(f.controller.connectionFile)).not.toHaveProperty('modelOptions'); + expect(read(f.controller.stateFile)).not.toHaveProperty('agentRoutingSecret'); + expect(f.calls.some(call => call.method.startsWith('kimiPlugin.'))).toBe(false); + } finally { await f.cleanup(); } +}); + +test('same-model role input budgets count separately and refuse only the over-budget inference', async () => { + const f = await fixture(); + try { + Object.assign(f.models[0], { context_window: 8192 }); + const desired = read(f.controller.connectionFile); + desired.secondaryModel = desired.model; + desired.modelOptions = { + primary: { model: desired.model, reasoningEffort: '', contextTokens: 2048 }, + secondary: { model: desired.model, reasoningEffort: '', contextTokens: 1024 }, + }; + write(f.controller.connectionFile, desired); + await f.controller.tick(); + const counted = [], before = f.controller.fetchImpl; + f.controller.fetchImpl = async (url, options) => { + if (String(url).endsWith('/messages/count_tokens')) { + counted.push({ url: String(url), body: JSON.parse(options.body), headers: options.headers }); + return Response.json({ input_tokens: 2048 }); + } + return before(url, options); + }; + const router = f.controller.ensureAgentRouter(); + for (const role of ['primary', 'secondary']) { + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, body: JSON.stringify({ model: 'native', + messages: [{ role: 'system', content: router.profileText(desired.model, role) }, + { role: 'user', content: 'synthetic budget input' }] }) }); + expect(response.status).toBe(role === 'primary' ? 200 : 400); + if (role === 'secondary') expect((await response.json()).error.code).toBe('context_length_exceeded'); + else await response.text(); + } + expect(counted).toHaveLength(2); + expect(f.requests).toHaveLength(1); + for (const call of counted) { + expect(call.body.model).toBe(desired.model); + expect(new Headers(call.headers).get('authorization')).toBe('Bearer ' + desired.gateway.key); + expect(call.url).toBe(desired.gateway.url + '/messages/count_tokens'); + } + const view = await f.controller.readSettings(); + expect(view.primaryOptionsStatus).toBe('applied'); + expect(view.secondaryOptionsStatus).toBe('applied'); + } finally { await f.cleanup(); } +}); + +test('input counting failure blocks generation and exposes only the fixed context error', async () => { + const f = await fixture(); + try { + Object.assign(f.models[0], { context_window: 8192 }); + const desired = read(f.controller.connectionFile); + desired.modelOptions = { primary: { model: desired.model, reasoningEffort: '', contextTokens: 2048 } }; + write(f.controller.connectionFile, desired); + await f.controller.tick(); + const before = f.controller.fetchImpl; + f.controller.fetchImpl = async (url, options) => { + if (String(url).endsWith('/messages/count_tokens')) throw Error('synthetic-private-provider-error'); + return before(url, options); + }; + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, body: JSON.stringify({ model: 'native', + messages: [{ role: 'user', content: 'synthetic budget input' }] }) }); + expect(response.status).toBe(503); + const body = await response.text(); + expect(body).toContain('context_count_unavailable'); + expect(body).not.toContain('synthetic-private-provider-error'); + expect(f.requests).toHaveLength(0); + } finally { await f.cleanup(); } +}); + +for (const format of ['json', 'sse']) test(`the bridge emits scoped role profiles for same-model Agent calls (${format})`, async () => { + const f = await fixture(); + try { + Object.assign(f.models[0], { reasoning: true, supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }] }); + const desired = read(f.controller.connectionFile); + desired.secondaryModel = desired.model; + desired.modelOptions = { primary: { model: desired.model, reasoningEffort: 'high', contextTokens: null }, + secondary: { model: desired.model, reasoningEffort: 'low', contextTokens: null } }; + write(f.controller.connectionFile, desired); + await f.controller.tick(); + const before = f.controller.fetchImpl; + f.controller.fetchImpl = async (url, options) => { + const upstream = await before(url, options); + if (!String(url).endsWith('/chat/completions')) return upstream; + await upstream.text(); + const calls = [{ id: 'synthetic-child', type: 'function', function: { name: 'Agent', + arguments: JSON.stringify({ prompt: 'synthetic task', description: 'synthetic task' }) } }]; + return format === 'json' ? Response.json({ choices: [{ message: { tool_calls: calls }, finish_reason: 'tool_calls' }] }) + : new Response('data: ' + JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: calls.map((call, index) => ({ index, ...call })) } }] }) + + '\n\ndata: ' + JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }) + '\n\ndata: [DONE]\n\n', + { headers: { 'content-type': 'text/event-stream' } }); + }; + const tool = { type: 'function', function: { name: 'Agent', parameters: { type: 'object', + required: ['prompt', 'description'], properties: { prompt: { type: 'string' }, description: { type: 'string' } } } } }; + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, body: JSON.stringify({ model: 'native', + stream: format === 'sse', tools: [tool], messages: [{ role: 'user', content: 'synthetic parent' }] }) }); + expect(response.status).toBe(200); + const text = await response.text(); + const chunks = format === 'json' ? [JSON.parse(text)] : text.split('\n\n').filter(event => event.startsWith('data: {')) + .map(event => JSON.parse(event.slice(6))); + const args = chunks.flatMap(chunk => chunk.choices || []).flatMap(choice => (choice.message || choice.delta)?.tool_calls || []) + .map(call => call.function?.arguments || '').join(''); + const selected = JSON.parse(args).subagent_type; + const router = f.controller.ensureAgentRouter(); + expect(selected).toBe(router.profileName(desired.model, 'secondary')); + expect(selected).not.toBe(router.profileName(desired.model, 'primary')); + const profile = fs.readFileSync(path.join(f.controller.stateDir, 'native-model-roles/agents', selected + '.md'), 'utf8'); + expect(router.prepare({ messages: [{ role: 'system', content: profile }] }, { + primaryModel: desired.model, allowedModels: f.models.map(model => model.id), + }).routeRole).toBe('secondary'); + } finally { await f.cleanup(); } +}); + +test('model options do not rewrite official tool requests or unrelated paid-plugin records', async () => { + const f = await fixture(); + try { + Object.assign(f.models[0], { reasoning: true, supported_reasoning_levels: [{ effort: 'high' }] }); + const connection = read(f.controller.connectionFile); + connection.modelOptions = { primary: { model: connection.model, reasoningEffort: 'high', contextTokens: null } }; + write(f.controller.connectionFile, connection); + const registry = path.join(f.controller.kimiDir, 'runtime/kimi-code/home/plugins/installed.json'); + const paid = { version: 1, plugins: [{ id: 'synthetic-financial-data', enabled: true, authorization: 'synthetic-grant' }] }; + write(registry, paid); + const before = fs.readFileSync(registry); + await f.controller.tick(); + const payload = { model: 'official-data-service', query: 'synthetic query', + thinking: { effort: 'max' }, reasoning_effort: 'low', entitlement: { plan: 'synthetic-paid-plan' } }; + for (const endpoint of ['/search', '/fetch']) { + const response = await fetch(f.controller.bridgeURL + endpoint, { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, body: JSON.stringify(payload) }); + expect(response.status).toBe(200); await response.text(); + const call = f.tools.at(-1); + expect(call.url).toBe(official + endpoint); + expect(call.authorization).toBe('Bearer synthetic-kimi-key'); + expect(call.body).toEqual(payload); + } + expect(fs.readFileSync(registry).equals(before)).toBe(true); + expect(f.calls.some(call => call.method.startsWith('kimiPlugin.'))).toBe(false); + const paidTool = { type: 'function', function: { name: 'FinancialDatasetQuery', + description: 'Synthetic paid data tool', parameters: { type: 'object', properties: { ticker: { type: 'string' } } } } }; + const messages = [{ role: 'user', content: 'synthetic data request' }, + { role: 'assistant', content: null, tool_calls: [{ id: 'synthetic-paid-call', type: 'function', + function: { name: 'FinancialDatasetQuery', arguments: '{"ticker":"SYNTHETIC"}' } }] }, + { role: 'tool', tool_call_id: 'synthetic-paid-call', content: 'synthetic licensed result' }]; + connection.secondaryModel = f.models[1].id; + write(f.controller.connectionFile, connection); + await f.controller.tick(); + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer synthetic-kimi-key' }, body: JSON.stringify({ model: 'native', tools: [paidTool], messages }) }); + expect(response.status).toBe(200); await response.text(); + expect(f.requests.at(-1).body.tools).toEqual([paidTool]); + expect(f.requests.at(-1).body.messages).toEqual(messages); + expect(read(registry).plugins.filter(plugin => plugin.id === 'synthetic-financial-data')).toEqual(paid.plugins); + connection.secondaryModel = ''; + write(f.controller.connectionFile, connection); + await f.controller.tick(); + expect(read(registry)).toEqual(paid); + } finally { await f.cleanup(); } +}); + test('rate-limit responses retain Retry-After, status and body without forwarding credentials', async () => { const f = await fixture(); try { @@ -362,11 +746,12 @@ test('disconnect restores one address while retaining refreshed credentials and } finally { await f.cleanup(); } }); -test('disconnect returns a selected menu alias to the original model without losing native entries', async () => { +test('disconnect cleans a selected alias left by an earlier plugin without losing native entries', async () => { const f=await fixture(); try { await f.controller.tick(); - let models=f.calls.find(x=>x.method==='conversations.updateKimiModelList').params.models; + const oldAlias=f.controller.catalog[1].alias.replace('local-model-', 'magpie-local-'); + let models=[{model:'k2d8-preview'}, {model:oldAlias}]; const base=f.controller.rpcCall; f.controller.rpcCall=async(runner,method,params)=>{ if(method==='conversations.getKimiModelList')return {models}; @@ -376,7 +761,9 @@ test('disconnect returns a selected menu alias to the original model without los } return base(runner,method,params); }; - const doc=read(f.controller.configFile);doc.model.current=f.controller.catalog[1].alias;write(f.controller.configFile,doc); + const doc=read(f.controller.configFile);doc.model.current=oldAlias; + doc.model.models[oldAlias]={model:oldAlias,provider:'daimon-kimi-code'};write(f.controller.configFile,doc); + write(f.controller.modelBackupFile,{current:'k2d8-preview'}); write(f.controller.connectionFile,{...f.connection,enabled:false}); await f.controller.tick(); expect(read(f.controller.configFile).model.current).toBe('k2d8-preview'); @@ -398,7 +785,7 @@ test('long Work turns keep the native menu alive without reloading the busy runt await f.controller.tick(); expect(Date.now()-Date.parse(read(f.controller.catalogFile).updatedAt)).toBeLessThan(5000); expect(read(f.controller.catalogFile).models).toEqual(catalog.models); - expect(f.calls.length).toBe(calls); + expect(f.calls.slice(calls).every(call => call.method === 'kimiPlugin.getPluginInfo')).toBe(true); }finally{await f.cleanup();} }); @@ -470,7 +857,13 @@ test('the identified gateway catalog updates visibility without opening Magpie f f.models.splice(1); const response=await fetch(f.controller.bridgeURL+'/chat/completions',{method:'POST',headers:{Authorization:'Bearer synthetic-kimi-key'}, body:JSON.stringify({model:removed.alias,messages:[]})}); - expect(response.status).toBe(409);expect(f.requests).toHaveLength(0); + expect(response.status).toBe(200);await response.text();expect(f.requests).toHaveLength(1); + expect(f.requests[0].body.model).toBe(f.models[0].id); + const activeModel=f.models[0];f.models.splice(0); + const refused=await fetch(f.controller.bridgeURL+'/chat/completions',{method:'POST',headers:{Authorization:'Bearer synthetic-kimi-key'}, + body:JSON.stringify({model:removed.alias,messages:[]})}); + expect(refused.status).toBe(409);await refused.text();expect(f.requests).toHaveLength(1); + f.models.push(activeModel); const runner=read(f.controller.runnerFile);runner.activeKernelTurns=['synthetic-turn'];write(f.controller.runnerFile,runner); f.controller.catalogAfter=0;await f.controller.tick(); expect(f.controller.catalog.map(x=>x.id)).toEqual(['original-provider/test-model']); @@ -502,13 +895,23 @@ test('disconnect restores the address even when Kimi removes a private catalog m const f = await fixture(); try { await f.controller.tick(); + const original = f.controller.rpcCall; f.controller.rpcCall = async () => {throw Error('kimi_control_rejected');}; write(f.controller.connectionFile, {...f.connection, enabled: false}); await f.controller.tick(); expect(read(f.controller.configFile).model.providers['daimon-kimi-code'].baseUrl).toBe(official); expect(read(f.controller.configFile).credentials).toEqual(f.doc.credentials); - expect(fs.existsSync(f.controller.modelBackupFile)).toBe(true); + expect(f.controller.state.active).toBe(true); + expect(f.controller.status.code).toBe('restored_waiting_kimi'); + // A rejected method must leave a recovery obligation, rather than claiming + // complete restoration and making the next available control call useless. + await f.controller.tick(); + expect(f.controller.state.active).toBe(true); + f.controller.rpcCall = original; + await f.controller.tick(); expect(f.controller.state.active).toBe(false); + expect(f.controller.status.code).toBe('disconnected'); + expect(fs.existsSync(f.controller.modelBackupFile)).toBe(false); } finally {await f.cleanup();} }); diff --git a/packages/kimi-desktop/library.mjs b/packages/kimi-desktop/library.mjs index 5778661ce..4ab76ad2d 100644 --- a/packages/kimi-desktop/library.mjs +++ b/packages/kimi-desktop/library.mjs @@ -30,7 +30,7 @@ export function prepareLibrary(stateDir, kimiDir) { linkDirectory(path.join(stateDir, 'library-skills'), path.join(kimiDir, 'skills')); } -export function applyInstructions(payload, file) { +export function applyInstructions(payload, file, transformText = value => value) { if (!Array.isArray(payload.messages)) throw Error('invalid_messages'); let text = ''; try { @@ -50,7 +50,7 @@ export function applyInstructions(payload, file) { if (text) { let at = 0; while (['system', 'developer'].includes(messages[at]?.role)) at++; - messages.splice(at, 0, {role: 'system', content: BEGIN + '\n' + text + '\n' + END}); + messages.splice(at, 0, {role: 'system', content: BEGIN + '\n' + transformText(text) + '\n' + END}); } return {...payload, messages}; } diff --git a/packages/kimi-desktop/model-options-settings.test.mjs b/packages/kimi-desktop/model-options-settings.test.mjs new file mode 100644 index 000000000..209312a8a --- /dev/null +++ b/packages/kimi-desktop/model-options-settings.test.mjs @@ -0,0 +1,430 @@ +import { test, expect } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createServer } from 'node:http'; +import { KimiAdapter } from './adapter.mjs'; +import { startSettingsServer, TOKEN_HEADER } from './settings.mjs'; + +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)); +}; +function fixture() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-option-settings-')); + const catalog = [ + { id: 'example/main', display_name: 'Main', reasoning: true, + supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }], context_window: 131072 }, + { id: 'example/other', display_name: 'Other', reasoning: false, context_window: 65536 }, + { id: 'example/unknown', display_name: 'Unknown' }, + ]; + const adapter = new KimiAdapter({ home, stateDir: path.join(home, 'state'), kimiDir: path.join(home, 'native'), + fetchImpl: async () => Response.json({ data: catalog }), + }); + const connection = { enabled: true, model: 'example/main', secondaryModel: 'example/main', + gateway: { url: 'http://127.0.0.1:34567/v1', key: 'synthetic-only-key' }, preserve: { value: 7 } }; + write(adapter.connectionFile, connection); + return { home, adapter, catalog, connection, close: () => fs.rmSync(home, { recursive: true, force: true }) }; +} + +test('settings advertise only known external effort and context capabilities', async () => { + const f = fixture(); + try { + const view = await f.adapter.readSettings(); + expect(view.models[0]).toEqual({ id: 'example/main', name: 'Main · External', + reasoningEfforts: ['low', 'high'], contextWindow: 131072 }); + expect(view.models[2].contextWindow).toBeNull(); + expect(view.models[2].reasoningEfforts).toEqual([]); + expect(view).not.toHaveProperty('gateway'); + } finally { f.close(); } +}); + +test('same-model primary and secondary preferences persist independently and bind to their selected model', async () => { + const f = fixture(); + try { + const view = await f.adapter.saveSettings({ primaryReasoningEffort: 'high', secondaryReasoningEffort: 'low' }); + expect(view.primaryReasoningEffort).toBe('high'); + expect(view.secondaryReasoningEffort).toBe('low'); + const saved = read(f.adapter.connectionFile); + expect(saved.modelOptions).toEqual({ + primary: { model: 'example/main', reasoningEffort: 'high', contextTokens: null }, + secondary: { model: 'example/main', reasoningEffort: 'low', contextTokens: null }, + }); + expect(saved.gateway).toEqual(f.connection.gateway); + expect(saved.preserve).toEqual({ value: 7 }); + expect(view.primaryOptionsStatus).not.toBe('applied'); + } finally { f.close(); } +}); + +test('unsupported effort is rejected before the official primary selector or any preference write', async () => { + const f = fixture(); + let primaryCalls = 0; + f.adapter.setPrimaryModel = async () => { primaryCalls++; }; + try { + const bytes = fs.readFileSync(f.adapter.connectionFile); + await expect(f.adapter.saveSettings({ primaryModel: 'example/other', primaryReasoningEffort: 'high' })) + .rejects.toHaveProperty('code', 'unsupported_reasoning_effort'); + expect(primaryCalls).toBe(0); + expect(fs.readFileSync(f.adapter.connectionFile).equals(bytes)).toBe(true); + } finally { f.close(); } +}); + +test('context above an advertised limit or with unknown capacity is rejected before saving', async () => { + const f = fixture(); + try { + const before = fs.readFileSync(f.adapter.connectionFile); + for (const patch of [ + { primaryContextTokens: 131073 }, + { primaryModel: 'example/unknown', primaryContextTokens: 10000 }, + { secondaryContextTokens: 0 }, { primaryContextTokens: 0.5 }, + ]) await expect(f.adapter.saveSettings(patch)).rejects.toHaveProperty('code'); + expect(fs.readFileSync(f.adapter.connectionFile).equals(before)).toBe(true); + } finally { f.close(); } +}); + +test('a supported positive input budget is saved and remains pending until the bridge is connected', async () => { + const f = fixture(); + try { + let view, code; + try { view = await f.adapter.saveSettings({ primaryContextTokens: 65536 }); } + catch (error) { code = error.code; } + expect(code).toBeUndefined(); + expect(view.primaryContextTokens).toBe(65536); + expect(view.primaryOptionsStatus).toBe('pending'); + expect(read(f.adapter.connectionFile).modelOptions.primary.contextTokens).toBe(65536); + } finally { f.close(); } +}); + +test('option failure after the official primary change reports a partial save and preserves the new connection', async () => { + const f = fixture(); + f.adapter.setPrimaryModel = async model => write(f.adapter.connectionFile, { + ...read(f.adapter.connectionFile), model, gateway: { url: f.connection.gateway.url, key: 'synthetic-new-key' }, + }); + try { + await expect(f.adapter.saveSettings({ primaryModel: 'example/other', primaryReasoningEffort: '' })) + .rejects.toHaveProperty('code', 'settings_partially_saved'); + const saved = read(f.adapter.connectionFile); + expect(saved.model).toBe('example/other'); + expect(saved.gateway.key).toBe('synthetic-new-key'); + expect(saved).not.toHaveProperty('modelOptions'); + } finally { f.close(); } +}); + +test('a host-side model change never silently applies preferences bound to the previous model', async () => { + const f = fixture(); + try { + write(f.adapter.connectionFile, { ...f.connection, model: 'example/other', modelOptions: { + primary: { model: 'example/main', reasoningEffort: 'high', contextTokens: 100000 }, + } }); + const before = fs.readFileSync(f.adapter.connectionFile); + const view = await f.adapter.readSettings(); + expect(view.primaryReasoningEffort).toBe(''); + expect(view.primaryContextTokens).toBeNull(); + expect(view.primaryOptionsStatus).toBe('not_configured'); + expect(fs.readFileSync(f.adapter.connectionFile).equals(before)).toBe(true); + } finally { f.close(); } +}); + +test('saved preferences remain visible when a model disappears or its capabilities shrink', async () => { + const f = fixture(); + try { + write(f.adapter.connectionFile, { ...f.connection, modelOptions: { + primary: { model: 'example/main', reasoningEffort: 'high', contextTokens: 100000 }, + } }); + for (const catalog of [[], [{ id: 'example/main', reasoning: false, context_window: 65536 }]]) { + f.adapter.fetchImpl = async () => Response.json({ data: catalog }); + const view = await f.adapter.readSettings(); + expect(view.primaryReasoningEffort).toBe('high'); + expect(view.primaryContextTokens).toBe(100000); + expect(view.primaryOptionsStatus).toBe('unavailable'); + } + } finally { f.close(); } +}); + +test('malformed saved option scalars are safely displayed as unavailable defaults that can be explicitly reset', async () => { + const f = fixture(); + try { + for (const contextTokens of [-1, 0, 0.5, '8192', Number.MAX_SAFE_INTEGER + 1]) { + write(f.adapter.connectionFile, { ...f.connection, modelOptions: { + primary: { model: 'example/main', reasoningEffort: { privateValue: 'test-only-private-marker' }, contextTokens }, + } }); + const view = await f.adapter.readSettings(); + expect(view.primaryReasoningEffort).toBe(''); + expect(view.primaryContextTokens).toBeNull(); + expect(view.primaryOptionsStatus).toBe('unavailable'); + expect(JSON.stringify(view)).not.toContain('test-only-private-marker'); + } + await f.adapter.saveSettings({ primaryReasoningEffort: '', primaryContextTokens: null }); + expect(read(f.adapter.connectionFile).modelOptions.primary).toEqual({ model: 'example/main', reasoningEffort: '', contextTokens: null }); + } finally { f.close(); } +}); + +test('the official primary change happens first and options merge into the newest host document', async () => { + const f = fixture(); + const calls = []; + f.adapter.setPrimaryModel = async model => { + calls.push(model); + write(f.adapter.connectionFile, { ...read(f.adapter.connectionFile), model, hostRevision: 19 }); + }; + try { + const view = await f.adapter.saveSettings({ primaryModel: 'example/other', primaryReasoningEffort: '', + secondaryReasoningEffort: 'high' }); + expect(calls).toEqual(['example/other']); + const saved = read(f.adapter.connectionFile); + expect(saved.hostRevision).toBe(19); + expect(saved.modelOptions.primary).toEqual({ model: 'example/other', reasoningEffort: '', contextTokens: null }); + expect(saved.modelOptions.secondary.reasoningEffort).toBe('high'); + expect(view.primaryModel).toBe('example/other'); + } finally { f.close(); } +}); + +test('concurrent primary replacement during catalog validation never retargets a role option', async () => { + const f = fixture(); + f.adapter.fetchImpl = async () => { + write(f.adapter.connectionFile, { ...read(f.adapter.connectionFile), model: 'example/other' }); + return Response.json({ data: f.catalog }); + }; + try { + await expect(f.adapter.saveSettings({ primaryReasoningEffort: 'high' })) + .rejects.toHaveProperty('code', 'connection_changed'); + expect(read(f.adapter.connectionFile)).not.toHaveProperty('modelOptions'); + } finally { f.close(); } +}); + +test('concurrent role selection changes are caught before the official primary selector runs', async () => { + const f = fixture(); + let calls = 0; + f.adapter.setPrimaryModel = async model => { + calls++; + write(f.adapter.connectionFile, { ...read(f.adapter.connectionFile), model }); + }; + f.adapter.fetchImpl = async () => { + write(f.adapter.connectionFile, { ...read(f.adapter.connectionFile), model: 'example/unknown' }); + return Response.json({ data: f.catalog }); + }; + try { + await expect(f.adapter.saveSettings({ primaryModel: 'example/other', primaryReasoningEffort: '' })) + .rejects.toHaveProperty('code', 'connection_changed'); + expect(calls).toBe(0); + expect(read(f.adapter.connectionFile).model).toBe('example/unknown'); + } finally { f.close(); } +}); + +test('a concurrent secondary replacement during the primary CLI is preserved and reports partial save', async () => { + const f = fixture(); + f.adapter.setPrimaryModel = async model => write(f.adapter.connectionFile, { + ...read(f.adapter.connectionFile), model, secondaryModel: 'example/other', + }); + try { + await expect(f.adapter.saveSettings({ primaryModel: 'example/other', secondaryReasoningEffort: 'high' })) + .rejects.toHaveProperty('code', 'settings_partially_saved'); + const saved = read(f.adapter.connectionFile); + expect(saved.model).toBe('example/other'); + expect(saved.secondaryModel).toBe('example/other'); + expect(saved).not.toHaveProperty('modelOptions'); + } finally { f.close(); } +}); + +test('a partial option patch keeps a concurrent update to the other option field', async () => { + const f = fixture(); + let updated = false; + write(f.adapter.connectionFile, { ...f.connection, modelOptions: { + primary: { model: 'example/main', reasoningEffort: 'high', contextTokens: null }, + } }); + f.adapter.fetchImpl = async () => { + if (!updated) { + updated = true; + const fresh = read(f.adapter.connectionFile); + fresh.modelOptions.primary.reasoningEffort = 'low'; + write(f.adapter.connectionFile, fresh); + } + return Response.json({ data: f.catalog }); + }; + try { + await f.adapter.saveSettings({ primaryContextTokens: null }); + expect(read(f.adapter.connectionFile).modelOptions.primary.reasoningEffort).toBe('low'); + } finally { f.close(); } +}); + +test('clearing the secondary also clears its stored parameter binding without touching primary preferences', async () => { + const f = fixture(); + try { + const primary = { model: 'example/main', reasoningEffort: 'high', contextTokens: null }; + write(f.adapter.connectionFile, { ...f.connection, modelOptions: { + primary, secondary: { model: 'example/main', reasoningEffort: 'low', contextTokens: null }, + } }); + await f.adapter.saveSettings({ secondaryModel: '' }); + const saved = read(f.adapter.connectionFile); + expect(saved.modelOptions).toEqual({ primary }); + expect(saved.secondaryModel).toBe(''); + } finally { f.close(); } +}); + +test('disconnected settings cannot activate new inference options', async () => { + const f = fixture(); + try { + write(f.adapter.connectionFile, { ...f.connection, enabled: false }); + await expect(f.adapter.saveSettings({ primaryReasoningEffort: 'high' })) + .rejects.toHaveProperty('code', 'connect_kimi_first'); + expect(read(f.adapter.connectionFile)).not.toHaveProperty('modelOptions'); + } finally { f.close(); } +}); + +test('a pending secondary model never reports its new parameters applied to the old active role', async () => { + const f = fixture(); + try { + write(f.adapter.connectionFile, { ...f.connection, secondaryModel: 'example/other', modelOptions: { + secondary: { model: 'example/other', reasoningEffort: '', contextTokens: null }, + } }); + Object.assign(f.adapter, { live: { model: 'example/main', gatewayURL: f.connection.gateway.url, + gatewayKey: f.connection.gateway.key }, state: { active: true }, + rolesReady: true, roleOptionsEnabled: true, bridgeSecondaryModel: 'example/main' }); + const view = await f.adapter.readSettings(); + expect(view.appliedSecondaryModel).toBe('example/main'); + expect(view.secondaryOptionsStatus).toBe('pending'); + } finally { f.close(); } +}); + +test('same model IDs on a pending gateway or key never report their options applied to the old connection', async () => { + const f = fixture(); + try { + write(f.adapter.connectionFile, { ...f.connection, modelOptions: { + primary: { model: 'example/main', reasoningEffort: 'high', contextTokens: null }, + } }); + for (const live of [ + { model: 'example/main', gatewayURL: 'http://127.0.0.1:23456/v1', gatewayKey: f.connection.gateway.key }, + { model: 'example/main', gatewayURL: f.connection.gateway.url, gatewayKey: 'test-only-old-connection' }, + ]) { + Object.assign(f.adapter, { live, state: { active: true } }); + expect((await f.adapter.readSettings()).primaryOptionsStatus).toBe('pending'); + } + } finally { f.close(); } +}); + +test('option application status requires an active primary and confirmed matching secondary role', async () => { + const f = fixture(); + try { + write(f.adapter.connectionFile, { ...f.connection, modelOptions: { + primary: { model: 'example/main', reasoningEffort: 'high', contextTokens: null }, + secondary: { model: 'example/main', reasoningEffort: 'low', contextTokens: null }, + } }); + const live = { model: 'example/main', gatewayURL: f.connection.gateway.url, gatewayKey: f.connection.gateway.key }; + Object.assign(f.adapter, { live, state: { active: true }, rolesReady: true, roleOptionsEnabled: true, + bridgeSecondaryModel: 'example/main' }); + let view = await f.adapter.readSettings(); + expect(view.primaryOptionsStatus).toBe('applied'); + expect(view.secondaryOptionsStatus).toBe('applied'); + f.adapter.state.active = false; + expect((await f.adapter.readSettings()).primaryOptionsStatus).toBe('pending'); + f.adapter.state.active = true; + f.adapter.live.model = 'example/other'; + expect((await f.adapter.readSettings()).primaryOptionsStatus).toBe('pending'); + f.adapter.live.model = 'example/main'; + for (const gate of ['rolesReady', 'roleOptionsEnabled']) { + f.adapter[gate] = false; + view = await f.adapter.readSettings(); + expect(view.primaryOptionsStatus).toBe('applied'); + expect(view.secondaryOptionsStatus).toBe('pending'); + f.adapter[gate] = true; + } + } finally { f.close(); } +}); + +async function apiFixture(transform = value => value, saveOverride) { + const probe = createServer(); + await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); + const port = probe.address().port; + await new Promise(resolve => probe.close(resolve)); + const patches = []; + const view = { enabled: true, primaryModel: 'example/main', secondaryModel: 'example/main', status: 'connected', + primaryReasoningEffort: 'high', secondaryReasoningEffort: 'low', primaryContextTokens: null, secondaryContextTokens: null, + primaryOptionsStatus: 'pending', secondaryOptionsStatus: 'pending', + models: [{ id: 'example/main', name: 'Main', reasoningEfforts: ['low', 'high'], contextWindow: 131072, + secret: 'must-not-leave' }], gateway: { key: 'must-not-leave' } }; + const server = await startSettingsServer({ port, readSettings: () => transform(view), saveSettings: patch => { + if (saveOverride) return saveOverride(patch, view); + patches.push(patch); return { ...view, ...patch }; + } }); + const url = `http://127.0.0.1:${port}`; + const html = await (await fetch(url)).text(); + const token = /name="magpie-kimi-settings-token" content="([a-f0-9]+)"/.exec(html)[1]; + const headers = { [TOKEN_HEADER]: token, 'content-type': 'application/json' }; + return { server, url, headers, patches }; +} + +test('existing settings endpoint accepts the four option fields and projects capabilities without private fields', async () => { + const f = await apiFixture(); + try { + const patch = { primaryReasoningEffort: 'high', secondaryReasoningEffort: 'low', + primaryContextTokens: 65536, secondaryContextTokens: null }; + const response = await fetch(f.url + '/api/settings', { method: 'POST', headers: f.headers, body: JSON.stringify(patch) }); + expect(response.status).toBe(200); + expect(f.patches).toEqual([patch]); + const view = await response.json(); + expect(view.primaryReasoningEffort).toBe('high'); + expect(view.secondaryReasoningEffort).toBe('low'); + expect(view.primaryContextTokens).toBe(65536); + expect(view.secondaryContextTokens).toBeNull(); + expect(view.models[0]).toEqual({ id: 'example/main', name: 'Main', reasoningEfforts: ['low', 'high'], contextWindow: 131072 }); + expect(JSON.stringify(view)).not.toContain('must-not-leave'); + expect(view.primaryOptionsStatus).toBe('pending'); + expect(view.secondaryOptionsStatus).toBe('pending'); + } finally { await f.server.stop(); } +}); + +test('each fixed option status is projected and each fixed save failure preserves its HTTP contract', async () => { + let status = 'not_configured', failure = 'invalid_model_options'; + const f = await apiFixture(view => ({ ...view, primaryOptionsStatus: status }), () => { + throw Object.assign(Error('test-only-private-error'), { code: failure }); + }); + try { + for (status of ['not_configured', 'pending', 'applied', 'context_unavailable', 'unavailable']) { + const response = await fetch(f.url + '/api/settings', { headers: f.headers }); + expect(response.status).toBe(200); + expect((await response.json()).primaryOptionsStatus).toBe(status); + } + for (const [code, http] of [['invalid_model_options', 400], ['unsupported_reasoning_effort', 400], + ['unsupported_context_length', 400], ['context_control_unavailable', 503]]) { + failure = code; + const response = await fetch(f.url + '/api/settings', { method: 'POST', headers: f.headers, + body: JSON.stringify({ primaryReasoningEffort: '' }) }); + expect(response.status).toBe(http); + expect(await response.json()).toEqual({ error: code }); + } + } finally { await f.server.stop(); } +}); + +test('the settings projection treats malformed or future capability values as unknown without leaking metadata', async () => { + const f = await apiFixture(view => ({ ...view, primaryOptionsStatus: 'constructor', + primaryReasoningEffort: { credential: 'synthetic-private-value' }, primaryContextTokens: -1, + models: [{ ...view.models[0], reasoningEfforts: ['high', { credential: 'synthetic-private-value' }, 'future'], + contextWindow: '8192' }] })); + try { + const response = await fetch(f.url + '/api/settings', { headers: f.headers }); + expect(response.status).toBe(200); + const view = await response.json(); + expect(view.models[0].reasoningEfforts).toEqual(['high']); + expect(view.models[0].contextWindow).toBeNull(); + for (const key of ['primaryOptionsStatus', 'primaryReasoningEffort', 'primaryContextTokens']) expect(view).not.toHaveProperty(key); + expect(JSON.stringify(view)).not.toContain('synthetic-private-value'); + } finally { await f.server.stop(); } +}); + +test('the options API rejects wrong primitive types, nonintegers, writable statuses and unknown fields', async () => { + const f = await apiFixture(); + try { + for (const patch of [ + { primaryReasoningEffort: null }, { secondaryReasoningEffort: {} }, + { primaryContextTokens: '64000' }, { secondaryContextTokens: -1 }, { primaryContextTokens: 0.5 }, + { primaryContextTokens: Number.MAX_SAFE_INTEGER + 1 }, { primaryOptionsStatus: 'applied' }, + { primaryReasoningEffort: 'high', gateway: { key: 'injected' } }, + ]) { + const response = await fetch(f.url + '/api/settings', { method: 'POST', headers: f.headers, body: JSON.stringify(patch) }); + expect(response.status).toBe(400); + await response.text(); + } + expect(f.patches).toHaveLength(0); + expect((await fetch(f.url + '/api/anything')).status).toBe(404); + } finally { await f.server.stop(); } +}); diff --git a/packages/kimi-desktop/model-options.mjs b/packages/kimi-desktop/model-options.mjs new file mode 100644 index 000000000..fe164189d --- /dev/null +++ b/packages/kimi-desktop/model-options.mjs @@ -0,0 +1,48 @@ +// Pure capability policy for a model's catalog-declared options. +// No provider defaults are invented here: '' asks the provider/Magpie for its +// default reasoning effort, and null adds no input budget. A numeric budget +// is validated against the catalog's explicitly published context cap. + +const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const fail = code => { + const error = Error(code); + error.code = code; + throw error; +}; + +export function validateModelOptions(options = {}, capabilities) { + if (!isRecord(options)) fail('invalid_model_options'); + + const { reasoningEfforts, contextWindow } = capabilities; + + let reasoningEffort = ''; + if (options.reasoningEffort !== undefined) { + if (typeof options.reasoningEffort !== 'string') fail('invalid_model_options'); + reasoningEffort = options.reasoningEffort; + if (reasoningEffort !== '' && !reasoningEfforts.includes(reasoningEffort)) { + fail('unsupported_reasoning_effort'); + } + } + + let contextTokens = null; + if (options.contextTokens !== undefined && options.contextTokens !== null) { + if (!Number.isSafeInteger(options.contextTokens) || options.contextTokens <= 0) fail('invalid_model_options'); + contextTokens = options.contextTokens; + if (contextWindow === null || contextTokens > contextWindow) fail('unsupported_context_length'); + } + + return { reasoningEffort, contextTokens }; +} + +// Preserve the legacy connection untouched unless an options object is present. +// A present config always strips Kimi's top-level thinking controls; an empty +// effort intentionally adds none. Context selection is never translated into a +// payload field; the caller checks an input-count copy separately. +export function applyReasoningOptions(payload, options) { + if (options === null || options === undefined) return payload; + + const { reasoning_effort: _ignoredEffort, reasoning: _ignoredReasoning, thinking: _ignoredThinking, ...rest } = payload; + const applied = { ...rest }; + if (options.reasoningEffort) applied.reasoning_effort = options.reasoningEffort; + return applied; +} diff --git a/packages/kimi-desktop/model-options.test.mjs b/packages/kimi-desktop/model-options.test.mjs new file mode 100644 index 000000000..f95c1b9af --- /dev/null +++ b/packages/kimi-desktop/model-options.test.mjs @@ -0,0 +1,211 @@ +import { describe, expect, test } from 'bun:test'; +import { normalizeModels } from './catalog.mjs'; +import { applyReasoningOptions, validateModelOptions } from './model-options.mjs'; + +// Synthetic data only. No provider call, no paid finance/database path. +const capabilities = { + reasoningEfforts: ['none', 'low', 'medium', 'high'], + contextWindow: 200000, +}; + +const expectFixedError = (run, code) => { + let caught; + try { + run(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect(caught.code).toBe(code); + expect(caught.message).toBe(code); +}; + +const makePayload = () => { + const messages = [{ role: 'user', content: 'synthetic request' }]; + const financeTool = { type: 'function', function: { name: 'synthetic_finance_query' } }; + const databaseTool = { type: 'function', function: { name: 'synthetic_database_query' } }; + const authorization = { scope: 'synthetic-authorization' }; + const billing = { plan: 'synthetic-billing' }; + return { + payload: { + model: 'synthetic/model', + thinking: 'high', + reasoning: 'high', + reasoning_effort: 'high', + max_tokens: 4096, + messages, + tools: [financeTool, databaseTool], + authorization, + billing, + }, + messages, + financeTool, + databaseTool, + authorization, + billing, + }; +}; + +describe('validateModelOptions', () => { + test('missing fields normalize to provider default and catalog cap', () => { + expect(() => validateModelOptions({}, capabilities)).not.toThrow(); + expect(validateModelOptions({}, capabilities)).toEqual({ reasoningEffort: '', contextTokens: null }); + }); + + test('accepts explicit null contextTokens as the catalog cap', () => { + expect(() => validateModelOptions({ reasoningEffort: '', contextTokens: null }, capabilities)).not.toThrow(); + expect(validateModelOptions({ reasoningEffort: '', contextTokens: null }, capabilities)) + .toEqual({ reasoningEffort: '', contextTokens: null }); + }); + + test('accepts explicit effort and context at the exact catalog maximum', () => { + expect(validateModelOptions({ reasoningEffort: 'high', contextTokens: 200000 }, capabilities)) + .toEqual({ reasoningEffort: 'high', contextTokens: 200000 }); + }); + + test('rejects effort outside the explicitly declared list', () => { + expectFixedError( + () => validateModelOptions({ reasoningEffort: 'xhigh' }, capabilities), + 'unsupported_reasoning_effort', + ); + expectFixedError( + () => validateModelOptions({ reasoningEffort: 'high' }, { reasoningEfforts: [], contextWindow: null }), + 'unsupported_reasoning_effort', + ); + }); + + test('rejects context when the catalog cap is unknown', () => { + expectFixedError( + () => validateModelOptions({ contextTokens: 1000 }, { reasoningEfforts: [], contextWindow: null }), + 'unsupported_context_length', + ); + }); + + test('rejects context above the known catalog cap', () => { + expectFixedError( + () => validateModelOptions({ contextTokens: 200001 }, capabilities), + 'unsupported_context_length', + ); + }); + + test('rejects malformed option shapes with the fixed code', () => { + expectFixedError(() => validateModelOptions(null, capabilities), 'invalid_model_options'); + expectFixedError(() => validateModelOptions(42, capabilities), 'invalid_model_options'); + expectFixedError(() => validateModelOptions([], capabilities), 'invalid_model_options'); + expectFixedError(() => validateModelOptions({ reasoningEffort: 42 }, capabilities), 'invalid_model_options'); + expectFixedError(() => validateModelOptions({ contextTokens: 0 }, capabilities), 'invalid_model_options'); + expectFixedError(() => validateModelOptions({ contextTokens: -1 }, capabilities), 'invalid_model_options'); + expectFixedError(() => validateModelOptions({ contextTokens: 1.5 }, capabilities), 'invalid_model_options'); + expectFixedError(() => validateModelOptions({ contextTokens: Number.MAX_SAFE_INTEGER + 1 }, capabilities), 'invalid_model_options'); + expectFixedError(() => validateModelOptions({ contextTokens: '1000' }, capabilities), 'invalid_model_options'); + }); + + test('returns only the two normalized keys even with extra input keys', () => { + expect(validateModelOptions({ reasoningEffort: 'low', contextTokens: 1000, defaultEffort: 'high' }, capabilities)) + .toEqual({ reasoningEffort: 'low', contextTokens: 1000 }); + }); +}); + +describe('catalog options integration', () => { + test('reasoning=false exposes no efforts and validation rejects any explicit effort', () => { + const model = normalizeModels({ + data: [{ + id: 'synthetic/no-reasoning', + reasoning: false, + context_window: 64000, + supported_reasoning_levels: [{ effort: 'high' }], + }], + })[0]; + expect(model.options).toEqual({ reasoningEfforts: [], contextWindow: 64000 }); + expectFixedError( + () => validateModelOptions({ reasoningEffort: 'high' }, model.options), + 'unsupported_reasoning_effort', + ); + }); + + test('same model primary and secondary settings stay independent', () => { + const model = normalizeModels({ + data: [{ + id: 'synthetic/shared-model', + context_window: 200000, + supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }], + }], + })[0]; + const primary = validateModelOptions({ reasoningEffort: 'low', contextTokens: 64000 }, model.options); + const secondary = validateModelOptions({ reasoningEffort: 'high', contextTokens: 128000 }, model.options); + + expect(primary).toEqual({ reasoningEffort: 'low', contextTokens: 64000 }); + expect(secondary).toEqual({ reasoningEffort: 'high', contextTokens: 128000 }); + expect(primary).not.toBe(secondary); + + const { payload, messages } = makePayload(); + const primaryPayload = applyReasoningOptions(payload, primary); + const secondaryPayload = applyReasoningOptions(payload, secondary); + expect(primaryPayload.reasoning_effort).toBe('low'); + expect(secondaryPayload.reasoning_effort).toBe('high'); + expect(primaryPayload.messages).toBe(messages); + expect(secondaryPayload.messages).toBe(messages); + }); +}); + +describe('applyReasoningOptions', () => { + test('null and undefined options are exact legacy identity, including sentinel references', () => { + const { payload, messages, financeTool, databaseTool, authorization, billing } = makePayload(); + + expect(applyReasoningOptions(payload, null)).toBe(payload); + expect(applyReasoningOptions(payload, undefined)).toBe(payload); + + expect(payload.reasoning_effort).toBe('high'); + expect(payload.thinking).toBe('high'); + expect(payload.reasoning).toBe('high'); + expect(payload.messages).toBe(messages); + expect(payload.tools[0]).toBe(financeTool); + expect(payload.tools[1]).toBe(databaseTool); + expect(payload.authorization).toBe(authorization); + expect(payload.billing).toBe(billing); + }); + + test('present config overrides Kimi menu thinking values with the plugin effort', () => { + const { payload, messages, financeTool, databaseTool, authorization, billing } = makePayload(); + const normalized = validateModelOptions({ reasoningEffort: 'low', contextTokens: 64000 }, capabilities); + const applied = applyReasoningOptions(payload, normalized); + + expect(applied).not.toBe(payload); + expect(applied.reasoning_effort).toBe('low'); + expect('thinking' in applied).toBe(false); + expect('reasoning' in applied).toBe(false); + expect(applied.messages).toBe(messages); + expect(applied.tools).toBe(payload.tools); + expect(applied.tools[0]).toBe(financeTool); + expect(applied.tools[1]).toBe(databaseTool); + expect(applied.authorization).toBe(authorization); + expect(applied.billing).toBe(billing); + expect(applied.max_tokens).toBe(4096); + + expect(payload.reasoning_effort).toBe('high'); + expect(payload.thinking).toBe('high'); + }); + + test('empty effort strips Kimi thinking controls and adds no effort', () => { + const { payload } = makePayload(); + const applied = applyReasoningOptions(payload, validateModelOptions({}, capabilities)); + + expect(applied).not.toBe(payload); + expect('reasoning_effort' in applied).toBe(false); + expect('thinking' in applied).toBe(false); + expect('reasoning' in applied).toBe(false); + expect(applied.max_tokens).toBe(4096); + }); + + test('does not add max_tokens or context fields when applying explicit context', () => { + const { payload } = makePayload(); + const normalized = validateModelOptions({ reasoningEffort: 'high', contextTokens: 100000 }, capabilities); + const applied = applyReasoningOptions(payload, normalized); + + expect(applied.reasoning_effort).toBe('high'); + expect('context' in applied).toBe(false); + expect('contextWindow' in applied).toBe(false); + expect('contextTokens' in applied).toBe(false); + expect(applied.max_tokens).toBe(4096); + }); +}); diff --git a/packages/kimi-desktop/model-pool-config.mjs b/packages/kimi-desktop/model-pool-config.mjs new file mode 100644 index 000000000..04f2c338d --- /dev/null +++ b/packages/kimi-desktop/model-pool-config.mjs @@ -0,0 +1,94 @@ +// Only the pool's alias descriptions, default alias and table presence belong +// in a snapshot. Model definitions, providers and credentials stay in the TOML. +// Bun normalizes changed documents; an effective no-op preserves the text. + +const SECONDARY_TABLE = 'secondary_model'; +const POOL_KEY = 'models'; +const DEFAULT_KEY = 'default_model'; +const SNAPSHOT_KEYS = ['models', 'defaultModel', 'hasSecondaryTable', 'hasModelsTable']; +const unsupported = () => Error('secondary_config_unsupported'); +const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key); + +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 stringMap(value) { + if (!isTable(value)) throw unsupported(); + const keys = Reflect.ownKeys(value); + if (keys.some(key => typeof key !== 'string' || typeof value[key] !== 'string')) throw unsupported(); + return Object.fromEntries(keys.map(key => [key, value[key]])); +} + +function parseDocument(text) { + if (typeof text !== 'string') throw unsupported(); + return Bun.TOML.parse(text); +} + +function readState(document) { + const hasSecondaryTable = hasOwn(document, SECONDARY_TABLE); + const table = hasSecondaryTable ? document[SECONDARY_TABLE] : {}; + if (!isTable(table)) throw unsupported(); + const hasModelsTable = hasOwn(table, POOL_KEY); + const models = hasModelsTable ? stringMap(table[POOL_KEY]) : null; + const defaultModel = hasOwn(table, DEFAULT_KEY) ? table[DEFAULT_KEY] : null; + if (defaultModel !== null && typeof defaultModel !== 'string') throw unsupported(); + return { models, defaultModel, hasSecondaryTable, hasModelsTable }; +} + +function normalizeSnapshot(snapshot) { + if (!isTable(snapshot) || Reflect.ownKeys(snapshot).length !== SNAPSHOT_KEYS.length + || SNAPSHOT_KEYS.some(key => !hasOwn(snapshot, key))) throw unsupported(); + const { models, defaultModel, hasSecondaryTable, hasModelsTable } = snapshot; + if (models !== null && !isTable(models)) throw unsupported(); + if (defaultModel !== null && typeof defaultModel !== 'string') throw unsupported(); + if (typeof hasSecondaryTable !== 'boolean' || typeof hasModelsTable !== 'boolean') throw unsupported(); + // An existing empty pool is {}, while null always means the pool is absent. + if (models === null && hasModelsTable) throw unsupported(); + return { models: models === null ? null : stringMap(models), defaultModel, + hasSecondaryTable, hasModelsTable }; +} + +function sameState(left, right) { + if (left.defaultModel !== right.defaultModel || left.hasSecondaryTable !== right.hasSecondaryTable + || left.hasModelsTable !== right.hasModelsTable) return false; + if (left.models === null || right.models === null) return left.models === right.models; + const keys = Object.keys(left.models); + return keys.length === Object.keys(right.models).length + && keys.every(key => hasOwn(right.models, key) && left.models[key] === right.models[key]); +} + +function applyState(document, target) { + let table = hasOwn(document, SECONDARY_TABLE) ? document[SECONDARY_TABLE] : null; + if (table === null) { + if (!target.hasSecondaryTable && target.models === null && target.defaultModel === null) return; + table = document[SECONDARY_TABLE] = {}; + } + if (target.models === null) delete table[POOL_KEY]; + else table[POOL_KEY] = stringMap(target.models); + if (target.defaultModel === null) delete table[DEFAULT_KEY]; + else table[DEFAULT_KEY] = target.defaultModel; + if (!target.hasSecondaryTable && Object.keys(table).length === 0) delete document[SECONDARY_TABLE]; +} + +export function inspectModelPool(text) { + try { + return readState(parseDocument(text)); + } catch { throw unsupported(); } +} + +export function patchModelPool(text, snapshot) { + try { + const document = parseDocument(text); + const target = normalizeSnapshot(snapshot); + const current = readState(document); + if (sameState(current, target)) return text; + applyState(document, target); + // Unowned siblings can prevent removal of their parent table. Do not + // normalize formatting when preserving them made the operation a no-op. + if (sameState(current, readState(document))) return text; + return Bun.TOML.stringify(document); + } catch { throw unsupported(); } +} diff --git a/packages/kimi-desktop/model-pool-config.test.mjs b/packages/kimi-desktop/model-pool-config.test.mjs new file mode 100644 index 000000000..bc97014fc --- /dev/null +++ b/packages/kimi-desktop/model-pool-config.test.mjs @@ -0,0 +1,248 @@ +import { describe, expect, test } from 'bun:test'; +import { inspectModelPool, patchModelPool } from './model-pool-config.mjs'; + +// Synthetic credentials exist only to verify that snapshots and errors omit +// every unrelated configuration field. +const CANARY = 'synthetic-pool-auth-canary'; +const NONE = { models: null, defaultModel: null, hasSecondaryTable: false, hasModelsTable: false }; +const parse = text => Bun.TOML.parse(text); +const saved = text => JSON.parse(JSON.stringify(inspectModelPool(text))); +const globalConfig = [ + 'default_model = "primary-alias"', + 'tags = ["keep", "all"]', + '', + '[auth]', + `token = "${CANARY}"`, + '', + '[providers.acme]', + 'type = "openai"', + 'base_url = "https://synthetic.invalid/v1"', + `api_key = "${CANARY}"`, + '', + '[models.primary-alias]', + 'model = "wire-primary"', + 'provider = "acme"', + '', + '[models.secondary-alias]', + 'model = "wire-secondary"', + 'provider = "acme"', + '', +].join('\n'); + +function unrelated(text) { + const document = parse(text); + if (document.secondary_model) { + delete document.secondary_model.models; + delete document.secondary_model.default_model; + if (Object.keys(document.secondary_model).length === 0) delete document.secondary_model; + } + return document; +} + +function expectUnsupported(operation) { + let error; + try { operation(); } catch (caught) { error = caught; } + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('secondary_config_unsupported'); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify({ message: error.message, stack: error.stack, cause: error.cause })).not.toContain(CANARY); +} + +describe('inspectModelPool', () => { + test('distinguishes missing nested models, an empty pool and the unrelated global models table', () => { + expect(inspectModelPool('')).toEqual(NONE); + expect(inspectModelPool(globalConfig)).toEqual(NONE); + expect(inspectModelPool('[secondary_model]\n')).toEqual({ + models: null, defaultModel: null, hasSecondaryTable: true, hasModelsTable: false, + }); + expect(inspectModelPool('[secondary_model.models]\n')).toEqual({ + models: {}, defaultModel: null, hasSecondaryTable: true, hasModelsTable: true, + }); + expect(inspectModelPool('[secondary_model]\ndefault_model = "external-default"\n')).toEqual({ + models: null, defaultModel: 'external-default', hasSecondaryTable: true, hasModelsTable: false, + }); + }); + + test('returns only four fields and string descriptions, including aliases outside the catalog', () => { + const text = globalConfig + [ + '[secondary_model]', + 'model = "legacy-alias"', + 'default_model = "external/default"', + 'extra = { retries = 3 }', + '', + '[secondary_model.models]', + 'secondary-alias = "Secondary description"', + '"external/default" = "An unknown alias still belongs to Kimi"', + '', + ].join('\n'); + const snapshot = saved(text); + expect(snapshot).toEqual({ models: { 'secondary-alias': 'Secondary description', + 'external/default': 'An unknown alias still belongs to Kimi' }, defaultModel: 'external/default', + hasSecondaryTable: true, hasModelsTable: true }); + expect(Object.keys(snapshot).sort()).toEqual(['defaultModel', 'hasModelsTable', 'hasSecondaryTable', 'models']); + expect(JSON.stringify(snapshot)).not.toContain(CANARY); + expect(JSON.stringify(snapshot)).not.toContain('wire-secondary'); + expect(JSON.stringify(snapshot)).not.toContain('legacy-alias'); + expect(JSON.stringify(snapshot)).not.toContain('base_url'); + }); +}); + +describe('patchModelPool', () => { + test('adds the pool and default without copying global definitions, then restores table absence', () => { + const target = { models: { 'secondary-alias': 'Secondary description' }, defaultModel: 'secondary-alias', + hasSecondaryTable: false, hasModelsTable: false }; + const patched = patchModelPool(globalConfig, target); + expect(inspectModelPool(patched)).toEqual({ ...target, hasSecondaryTable: true, hasModelsTable: true }); + expect(parse(patched).secondary_model).toEqual({ default_model: 'secondary-alias', models: target.models }); + expect(unrelated(patched)).toEqual(parse(globalConfig)); + expect(patchModelPool(patched, target)).toBe(patched); + + const restored = patchModelPool(patched, saved(globalConfig)); + expect(inspectModelPool(restored)).toEqual(NONE); + expect(parse(restored)).toEqual(parse(globalConfig)); + expect(restored).not.toContain('[secondary_model'); + }); + + test('round-trips an existing pool and default while preserving legacy and every extra field', () => { + const original = globalConfig + [ + '[secondary_model]', + 'model = "legacy-model-kept"', + 'default_model = "outside-catalog"', + 'default_effort = "high"', + 'extra = { count = 4, enabled = false }', + '', + '[secondary_model.models]', + 'outside-catalog = "Existing description"', + '"unknown.alias" = "Also keep this when restoring"', + '', + '[experimental]', + 'secondary-model = false', + 'other = [1, 2, 3]', + '', + ].join('\n'); + const baseline = saved(original); + const changed = patchModelPool(original, { models: { 'secondary-alias': 'New description' }, + defaultModel: 'secondary-alias', hasSecondaryTable: true, hasModelsTable: true }); + expect(inspectModelPool(changed).models).toEqual({ 'secondary-alias': 'New description' }); + expect(unrelated(changed)).toEqual(unrelated(original)); + expect(parse(changed).secondary_model.model).toBe('legacy-model-kept'); + + const restored = patchModelPool(changed, baseline); + expect(parse(restored)).toEqual(parse(original)); + expect(inspectModelPool(restored)).toEqual(baseline); + }); + + test('restores an existing empty parent and distinguishes an empty pool from an absent one', () => { + const emptyParent = '[secondary_model]\n'; + const baseline = saved(emptyParent); + const emptyPool = { models: {}, defaultModel: null, hasSecondaryTable: true, hasModelsTable: true }; + const created = patchModelPool('', emptyPool); + expect(inspectModelPool(created)).toEqual(emptyPool); + expect(parse(created).secondary_model.models).toEqual({}); + expect(patchModelPool(created, emptyPool)).toBe(created); + + const restoredParent = patchModelPool(created, baseline); + expect(inspectModelPool(restoredParent)).toEqual(baseline); + expect(parse(restoredParent).secondary_model).toEqual({}); + expect(inspectModelPool(patchModelPool(created, NONE))).toEqual(NONE); + expect(inspectModelPool(patchModelPool('', baseline))).toEqual(baseline); + }); + + test('clears only the two owned fields and retains legacy values and sibling tables', () => { + const text = [ + '[secondary_model]', + 'model = "legacy-value"', + 'default_model = "pool-default"', + 'other = { nested = "keep" }', + '', + '[secondary_model.models]', + 'pool-default = "Description"', + '', + ].join('\n'); + const cleared = patchModelPool(text, NONE); + expect(parse(cleared).secondary_model).toEqual({ model: 'legacy-value', other: { nested: 'keep' } }); + expect(inspectModelPool(cleared)).toEqual({ ...NONE, hasSecondaryTable: true }); + expect(patchModelPool(cleared, NONE)).toBe(cleared); + }); + + test('returns unchanged comments, line endings and map order byte for byte', () => { + const text = [ + '# preserve this formatting', + '[ secondary_model ] # inline comment', + 'default_model = "second"', + '', + '[ secondary_model.models ]', + 'first = "First" # trailing comment', + 'second = "Second"', + '', + ].join('\r\n'); + const snapshot = saved(text); + expect(patchModelPool(text, snapshot)).toBe(text); + expect(patchModelPool(text, { ...snapshot, models: { second: 'Second', first: 'First' } })).toBe(text); + const legacyOnly = '# still unchanged\n[secondary_model]\nmodel = "legacy" # leave me\n'; + expect(patchModelPool(legacyOnly, NONE)).toBe(legacyOnly); + }); + + test('never mutates the caller snapshot or its pool, including frozen input', () => { + const models = Object.freeze({ 'outside-catalog': 'Preserve arbitrary alias descriptions' }); + const snapshot = Object.freeze({ models, defaultModel: 'outside-catalog', + hasSecondaryTable: true, hasModelsTable: true }); + const before = JSON.stringify(snapshot); + const patched = patchModelPool(globalConfig, snapshot); + expect(inspectModelPool(patched)).toEqual(snapshot); + expect(JSON.stringify(snapshot)).toBe(before); + expect(snapshot.models).toBe(models); + expect(parse(patched).auth.token).toBe(CANARY); + }); + + test('rejects unsupported TOML types and parsing errors with a fixed content-free error', () => { + const invalidDocuments = [ + 'secondary_model = false\n', + 'secondary_model = []\n', + '[[secondary_model]]\ndefault_model = "alias"\n', + '[secondary_model]\ndefault_model = false\n', + '[secondary_model]\ndefault_model = ["alias"]\n', + '[secondary_model.default_model]\nname = "alias"\n', + '[secondary_model]\nmodels = false\n', + '[secondary_model]\nmodels = []\n', + '[secondary_model]\nmodels = "alias"\n', + '[[secondary_model.models]]\nalias = "description"\n', + '[secondary_model.models]\nalias = false\n', + '[secondary_model.models]\nalias = 42\n', + '[secondary_model.models]\nalias = ["description"]\n', + '[secondary_model.models.alias]\nmodel = "wire"\n', + `broken = ["${CANARY}",\n`, + ]; + for (const document of invalidDocuments) { + const text = `synthetic_auth_token = "${CANARY}"\n` + document; + expectUnsupported(() => inspectModelPool(text)); + expectUnsupported(() => patchModelPool(text, NONE)); + } + expectUnsupported(() => inspectModelPool(null)); + expectUnsupported(() => patchModelPool(42, NONE)); + }); + + test('accepts only the four snapshot fields and a plain string map', () => { + const valid = { models: { alias: 'Description' }, defaultModel: 'alias', + hasSecondaryTable: true, hasModelsTable: true }; + const invalidSnapshots = [ + null, [], false, new Date(), + { ...valid, models: false }, + { ...valid, models: [] }, + { ...valid, models: new Map([['alias', 'Description']]) }, + { ...valid, models: { alias: 1 } }, + { ...valid, models: { alias: false } }, + { ...valid, models: { alias: ['Description'] } }, + { ...valid, models: { alias: { model: 'wire', provider: 'acme', api_key: CANARY } } }, + { ...valid, models: null }, + { ...valid, defaultModel: false }, + { ...valid, defaultModel: ['alias'] }, + { ...valid, defaultModel: undefined }, + { ...valid, hasSecondaryTable: 1 }, + { ...valid, hasModelsTable: 'true' }, + { models: null, defaultModel: null, hasSecondaryTable: false }, + { ...valid, auth: { token: CANARY } }, + ]; + for (const snapshot of invalidSnapshots) expectUnsupported(() => patchModelPool(globalConfig, snapshot)); + }); +}); diff --git a/packages/kimi-desktop/native-guards.test.mjs b/packages/kimi-desktop/native-guards.test.mjs new file mode 100644 index 000000000..cfca0f3cf --- /dev/null +++ b/packages/kimi-desktop/native-guards.test.mjs @@ -0,0 +1,126 @@ +// These exercise the production file commit and authenticated control client. +// All files and tokens are synthetic; the WebSocket server is loopback only. +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 { rpc as rpcCall } from './rpc.mjs'; + +test('native secondary commit refuses stale bytes and preserves the other writer exactly', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'magpie-kimi-commit-')); + try { + const adapter = Object.create(KimiAdapter.prototype); + adapter.nativeConfigFile = path.join(directory, 'config.toml'); + const original = 'default_model = "k2d8-preview"\n# initial settings\n'; + fs.writeFileSync(adapter.nativeConfigFile, original, { mode: 0o600 }); + const bytes = fs.readFileSync(adapter.nativeConfigFile); + const target = original + '\n[secondary_model]\nmodel = "synthetic-new-alias"\n'; + + // Another writer changes only unrelated bytes after we computed the patch. + // Comparing parsed secondary fields would miss the comment and CRLF edit. + const otherWriter = Buffer.from('default_model = "k2d8-preview"\r\n# other writer: 保留字节\r\n'); + fs.writeFileSync(adapter.nativeConfigFile, otherWriter); + expect(() => adapter.commitNativeSecondary(bytes, target)).toThrow('kimi_config_changed'); + expect(fs.readFileSync(adapter.nativeConfigFile).equals(otherWriter)).toBe(true); + expect(fs.readFileSync(adapter.nativeConfigFile, 'utf8')).not.toContain('synthetic-new-alias'); + expect(fs.readdirSync(directory)).toEqual(['config.toml']); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); + +const kernelSessionId = 'synthetic-kernel-session'; +const reloadCases = [ + ['kimiPlugin.reloadActiveSessions', {}, { sessions: [{ kernelSessionId, status: 'reloaded' }] }], + ['kimiPlugin.ReloadSession', { kernelSessionId }, { status: 'reloaded' }], + ['kimiPlugin.installPlugin', { source: '/synthetic/role-plugin' }, { id: 'synthetic-role-plugin', enabled: true }], + ['kimiPlugin.updatePlugin', { source: '/synthetic/role-plugin' }, { id: 'synthetic-role-plugin', enabled: true }], + ['kimiPlugin.getPluginInfo', { id: 'synthetic-role-plugin' }, { id: 'synthetic-role-plugin', enabled: true }], + ['kimiPlugin.setPluginEnabled', { id: 'synthetic-role-plugin', enabled: true }, { id: 'synthetic-role-plugin', enabled: true }], + ['kimiPlugin.removePlugin', { id: 'synthetic-role-plugin' }, { ok: true }], +]; + +for (const [method, params, acknowledgement] of reloadCases) { + test(`production RPC sends ${method} and receives its acknowledgement`, async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'magpie-kimi-control-')); + const token = 'synthetic-synthetic-control-token'; + const handshakes = [], messages = []; + let server; + try { + server = Bun.serve({ + hostname: '127.0.0.1', port: 0, + fetch(request, listener) { + handshakes.push({ path: new URL(request.url).pathname, + authorization: request.headers.get('authorization') }); + if (new URL(request.url).pathname !== '/control' + || request.headers.get('authorization') !== 'Bearer ' + token) { + return new Response('synthetic unauthorized', { status: 401 }); + } + if (listener.upgrade(request)) return; + return new Response('WebSocket required', { status: 400 }); + }, + websocket: { + message(socket, data) { + const message = JSON.parse(String(data)); + messages.push(message); + socket.send(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: acknowledgement })); + }, + }, + }); + const runnerFile = path.join(directory, 'agents/main/runner.state.json'); + fs.mkdirSync(path.dirname(runnerFile), { recursive: true }); + fs.writeFileSync(runnerFile, JSON.stringify({ lifecycleStatus: 'running', + daemonGeneration: 'synthetic-generation', + control: { endpoint: { url: `ws://127.0.0.1:${server.port}/control`, auth: { token } } }, + }), { mode: 0o600 }); + const runner = JSON.parse(fs.readFileSync(runnerFile, 'utf8')); + + await expect(rpcCall(runner, method, params, { timeout: 1000 })).resolves.toEqual(acknowledgement); + expect(handshakes).toEqual([{ path: '/control', authorization: 'Bearer ' + token }]); + expect(messages).toEqual([{ jsonrpc: '2.0', id: 1, method, params }]); + + // Reject unknown names before opening another authenticated connection. + await expect(rpcCall(runner, 'kimiPlugin.unknownMethod', {}, { timeout: 1000 })) + .rejects.toThrow('unsupported_control_method'); + expect(handshakes).toHaveLength(1); + expect(messages).toHaveLength(1); + } finally { + if (server) await server.stop(true); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +} + +for (const [label, method, code, reasonCode, expected] of [ + ['confirmed plugin missing', 'kimiPlugin.getPluginInfo', -32003, 'KIMI_PLUGIN_NOT_FOUND', 'kimi_plugin_not_found'], + ['generic rejection', 'kimiPlugin.getPluginInfo', -32000, 'KIMI_PLUGIN_NOT_FOUND', 'kimi_control_rejected'], + ['unrelated not found', 'kimiPlugin.getPluginInfo', -32003, 'OTHER_NOT_FOUND', 'kimi_control_rejected'], + ['unrelated method', 'models.current', -32003, 'KIMI_PLUGIN_NOT_FOUND', 'kimi_control_rejected'], +]) { + test('production RPC exposes only the fixed category for ' + label, async () => { + const token = 'synthetic-error-envelope-token'; + const server = Bun.serve({ + hostname: '127.0.0.1', port: 0, + fetch(request, listener) { + if (request.headers.get('authorization') !== 'Bearer ' + token) return new Response('', { status: 401 }); + if (listener.upgrade(request)) return; + return new Response('', { status: 400 }); + }, + websocket: { message(socket, input) { + const request = JSON.parse(String(input)); + socket.send(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { + code, message: 'synthetic private remote detail', + data: { reasonCode, unrelated: 'synthetic private data' }, + } })); + } }, + }); + try { + const runner = { control: { endpoint: { + url: 'ws://127.0.0.1:' + server.port + '/control', auth: { token }, + } } }; + const failure = await rpcCall(runner, method, { id: 'synthetic-role-plugin' }, { timeout: 1000 }).catch(error => error); + expect(failure).toBeInstanceOf(Error); + expect(failure.message).toBe(expected); + expect(Object.keys(failure)).toEqual([]); + } finally { await server.stop(true); } + }); +} diff --git a/packages/kimi-desktop/no-magpie.test.mjs b/packages/kimi-desktop/no-magpie.test.mjs index 63f930db7..365e0e45e 100644 --- a/packages/kimi-desktop/no-magpie.test.mjs +++ b/packages/kimi-desktop/no-magpie.test.mjs @@ -7,6 +7,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import {applyInstructions} from './library.mjs'; +import {recoveryPeer} from './test-fixtures/control-peer.mjs'; function withRules(text, run) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-desktop-rules-')); @@ -36,3 +37,74 @@ test('with no shared instructions an old tagged turn is still taken out', () => expect(JSON.stringify(out)).not.toMatch(/magpie/i); expect(out.messages).toEqual([history[0], history[2], history[3]]); })); + +for (const legacy of [false, true]) { + test(`a real bridge keeps branding out of native V2 tools and Agent history (${legacy ? 'legacy aliases' : 'neutral aliases'})`, async () => { + const f = recoveryPeer({ secondaryModel: 'provider/secondary' }); + try { + await f.controller.tick(); + const fixture = JSON.parse(fs.readFileSync(new URL('./test-fixtures/v2-agent-schema.json', import.meta.url), 'utf8')); + const main = f.controller.catalog.find(row => row.id === 'provider/main').alias; + const secondary = f.controller.catalog.find(row => row.id === 'provider/secondary').alias; + let text = JSON.stringify(fixture.request) + .replaceAll(fixture.expectedAliases.primary, main).replaceAll(fixture.expectedAliases.secondary, secondary); + if (legacy) text = text.replaceAll('local-model-', 'magpie-local-'); + const request = JSON.parse(text); + request.model = legacy ? secondary.replace('local-model-', 'magpie-local-') : secondary; + const key = JSON.parse(fs.readFileSync(f.controller.configFile, 'utf8')).credentials.kimiCode.apiKey; + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' }, body: JSON.stringify(request) }); + expect(response.status).toBe(200); + await response.text(); + expect(f.requests).toHaveLength(1); + const upstream = f.requests[0].body; + expect(upstream.model).toBe('provider/main'); + expect(JSON.stringify(upstream)).not.toMatch(/magpie/i); + expect(upstream.tools[0].function.parameters.properties.model.enum).toEqual(['primary', 'secondary', 'native']); + expect(upstream.tools[0].function.description).toContain('- secondary [default]:'); + expect(upstream.tools[0].function.description).not.toContain(main); + expect(upstream.tools[0].function.description).not.toContain(secondary); + expect(JSON.stringify(upstream.messages)).toContain(secondary); + const original = JSON.parse(text).tools[0].function.parameters; + const actual = upstream.tools[0].function.parameters; + expect(actual.required).toEqual(original.required); + for (const [name, schema] of Object.entries(original.properties)) { + if (name !== 'model') expect(actual.properties[name]).toEqual(schema); + } + } finally { await f.cleanup(); } + }); +} + +test('the real bridge preserves user filenames and non-Agent data while normalizing only old Agent model identities', async () => { + const f = recoveryPeer({ secondaryModel: 'provider/secondary' }); + try { + await f.controller.tick(); + const row = f.controller.catalog.find(row => row.id === 'provider/secondary'); + const old = row.alias.replace('local-model-', 'magpie-local-'); + const filename = `/tmp/${old}.json`; + const fixture = JSON.parse(fs.readFileSync(new URL('./test-fixtures/v2-agent-schema.json', import.meta.url), 'utf8')); + const agent = structuredClone(fixture.request.tools[0]); + agent.function.parameters.properties.prompt.description = filename; + const readTool = { type: 'function', function: { name: 'read_file', parameters: { + type: 'object', properties: { [old]: { type: 'string' }, path: { type: 'string' } }, + } } }; + const request = { model: 'native', tools: [agent, readTool], messages: [ + { role: 'user', content: `Read exactly ${filename}` }, + { role: 'assistant', tool_calls: [{ id: 'old-agent', type: 'function', function: { name: 'Agent', + arguments: JSON.stringify({ model: old, prompt: `Read exactly ${filename}`, description: 'Read path' }) } }] }, + { role: 'tool', tool_call_id: 'old-agent', content: JSON.stringify({ [old]: filename }) }, + ] }; + const key = JSON.parse(fs.readFileSync(f.controller.configFile, 'utf8')).credentials.kimiCode.apiKey; + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer ' + key }, body: JSON.stringify(request) }); + expect(response.status).toBe(200); await response.text(); + const sent = f.requests.at(-1).body; + expect(sent.messages[0]).toEqual(request.messages[0]); + expect(sent.messages[2]).toEqual(request.messages[2]); + expect(sent.tools[1]).toEqual(readTool); + expect(sent.tools[0].function.parameters.properties.prompt.description).toBe(filename); + expect(sent.tools[0].function.parameters.properties.model.enum).toEqual(['primary', 'secondary', 'native']); + expect(JSON.parse(sent.messages[1].tool_calls[0].function.arguments)) + .toEqual({ model: row.alias, prompt: `Read exactly ${filename}`, description: 'Read path' }); + } finally { await f.cleanup(); } +}); diff --git a/packages/kimi-desktop/package.json b/packages/kimi-desktop/package.json index ceb3c14cc..1d7d98c2f 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", @@ -8,9 +8,21 @@ "kimi-desktop.agent.js", "index.mjs", "adapter.mjs", + "agent-routing.mjs", + "agent-profiles.mjs", + "role-plugin-state.mjs", "rpc.mjs", "catalog.mjs", + "model-options.mjs", + "context-budget.mjs", "library.mjs", + "secondary-config.mjs", + "model-pool-config.mjs", + "runtime-family.mjs", + "primary-model.mjs", + "settings.mjs", + "settings.html", + "page", "README.md", "COMPATIBILITY.md" ], @@ -28,6 +40,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, "magpiePath": "" }, "icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHAAAABwCAMAAADxPgR5AAACvlBMVEUAAAAAAAECAgMNDQ0BAQEdHR4BAQICAQGYmJgBAQEAAAAGBgVoaGhGRkYJCQgiIyJpaWkCAgWSkpIzNDMICQkDAwM4OTglJiWMjowwMTCKi4tDQ0JNTk56fHl7e3unqKd7fXwLCwsAAAB3d3dmZ2cAAAGXl5dqa2tISEgDAwKGiIYCAgQCAgJoaGh8fnxlZWVeXl4GBwbU1dTExsSpqamkpKSen56Pj48VFRURExKztLMwMTABAAUAAAIsLS2tr6q3vLp5e3m0tbWVlZUKCg7GyMgrLSsFBQUDAwQAAAEBAQP///8CAgECAwIEBAMFBQUHBwcGBwUICQgLCwsEAwcFBQMNDQ0KCgkFBQcMDQsXg/8QEBAICAoSEhMODg4KCQvf398jJCQPDhAzNDMlJSYLCw0fICAbHBxCQkJhYWEmJyciIiIeHh40NTQpKipAQUIgISEWFhUyMjFFRUUZGhoVFRU6OzsUFBMRERJISUg9Pj4XGBg5OTkoKCgODgxVVlUtLS3v7/BiY2I8PDwWFhdkZWRAQD81NTYdHR1EREM3NzYvLy8rKysHBwlJSkowMDAuLi4ZGRhTVFQbGxpsbW1aWlpRUlFHR0c/Pz9vcHBmZ2ZYWVhPUE9OTk55enppaWpnaGhLTEsyMjMKCgiSk5JWV1dNTU1cXV1bXFuGh4eEhYV7fHxXWFgUExWCg4JeXl53eHdxcnE3NzicnZyVlpWQkZFubm5fYGBSU1OhoqGNjo6Jiol1dXZ0dXSLjIxQUFCkpKR9fn2pqqqAgYBqa2snJynQ0NByc3PFxsXAwMCmpqafoJ+2t7aZmpmXl5cKM2EsLCsII0GwsbGsrax+f3/i4+K6u7rNzs3IyMnAwL+9vr0SY8Gys7INQ4EII0MECxIVc+DV1dUFEyEWe+/a2trQ0tEUa9APS5EHGzMGEyXFnQayAAAASHRSTlMA378hnxCAYCBw79/Xv5+AfHBsYFDv5eW/pYVwYDrv39/fz7+/r6Cgn49gQDAg7+/v79/f39/f39/fz8/Pz7+voKCfkIBwcHD5MRvUAAAZx0lEQVRo3pSVu64TQQyGYRMSQYGEANEjUSBo6CmR8Fq2ZxcSKURcGuAJEKKigZZH4VkQNQIhaOAtsD3za8kSbj5nZu3s0Xzn92Vy4ve2uLBeLZdddzKsPzBm/+Xcsfr4q8tdd2W5On3t+on/tFPr5SFixsvF7Due8lMU3s3levHPtNPdT0qwM4SlgQmgqLiDl+1dB+afcaszhzh4EwdcUF2fkIjv+C8APrn6K/LcGRwS1hxow4ayMX4io0Ls1FAb1gTLyfUfcYuWTBwLxgGil6qjHhvCgucrP+AaNeqw3Z5c/EVeDwWVG5ZIhOiLJDiPQptkVKFMWtFm/ISMbpz7He9qKxtwPZ5T+tBA4izfqEEoPF8c1IjMzIHEg8OLrY7zlv0sffCmtAIcwKRkCqss8BL5hoqQkoylqCktj/E69ApGDSUDM71ksfQoUl0kDVsjUqcZlZGIzB5ulboj+qAA/YlBhspJHJGA51aV1s6h+IAC47Bwyk5pRzLY2TlvhQLh4PYbx0CYNInqC9KCA3k1YiUW1WK7sRjRsCV2vl2d9WeS8sBZBt3EGZqHpqgUpkqup0VVKiINhazDSErqrpVCu8FuH8zfGVyS0xPMxKjWhoD556qoYmUS3gXEmaOpjduizBHvthd/nscjN3UWEfMWrkPDIw/CkFKPK5UyqSkvNlUHDWOhrZgYuXUTbz2nTSVs3NhItVIycqdFJJE6STSlKZkR+6ISUSnqzk5Pg7c4yb8QQUtWSFG3zB2glMcHy03CS31pnLtR+6AwvbhDevEUOvSAl0GypskIDeJEltooQZMKbAzNBV/hpG0L892R9OEtCJzxprsUkYjzwlqH4OJUAgiETUSlGHhiNvrOJiylSVwn5TCb2EQaNCqkUonT7dlgutGUBbLttl/qi1KHRIWi1FKr2B0pH5gsEkCMWtJI2XGwoBioVqNPH77u9x++RTbz/V0yyl7rMqPHRgJ1DCCulRz0OpQpY6qdbsg2GWT0cV/tY0ScbTpubecnvT31m5loeyawT3UaLAw6xDkZULOI0n+3h73Pf8tkUCPxh7w6n99Kc2vZzFHX8GMoGo+Y6vUsUmlNmaXg1Pl5D/uQ70RMdUePhPuHcYdfRhaBg0LnKXFPFLow430qNPRgPpuVqvD7frIvdUbF6KXI0PevLzkQmHlSObtOcw5y40BT5BaMTKMvROm8TxRyqkZ3xQYmuvPsPj98cOLE9TkQX1AaRioOwjWWh0tQzWLmdJoFtU2r4acDINlgLH2508v4eCz3FieuHWvSOgKBI6EIMH1GEdLhvEOdbWYKP0d8/+X4SMehUHlayvMLP9gwkx2pYSAMNxxACIl34MBjcHeCu8p24rajJB1olgGxCcQmECN2ENAXkLjAheXKI3DkDC+AuMFr8NtJyALVSU+SyeTTX4urMosD/wN2i0uXmzGaMpBxhXpWPtQ6CfaCom4xVvgjai+E0BdEuamNSdT+xb4Jqe9LYERd2AU2iI1nsq/A3vLxlTQopEHhr9g0Ki+luSK1qr8bmeybVkXnPcBQB3G1vnyutTe17H4XAzYs0+e7Gx6EmxBJ/gv8/VPksSwU/rDgdP2sFNmhxZF5+IIOGdIhdvvjxzq73FV+FzpaUsSewA2D1cT081trPwm0tiikloQiRN7wodlK2nbWmDBt+AZg2g9mEdlxr054D5iIcuLoZIrf0qSYMorV0ybZcYk4sfdfYBgoZNKuoekICHEidncGrM2a/Or9CY+sVkAyqH/XWYsMzb16sZECQp/vXRyd80alkcwVRpcCaWMg+eSEd04QWcUtEbe1O4zkriBmlRla75nOTzEV8Q2L6EkMIw/CsGkOZXdxpo9yhmolOAQY60L8hDYorEw35C9v0gzAKS+EOeD66XeqMM7wVgvWLOTMn02jo0MFwBCJPc5vZGMoWdGusFTMgW2NA9WNh2OXhn4Ypwiy7FnAn/N8sYI1B6HEbBnEFhk6cEpykxp193AATpEAxoNETrM09qYlkFpv8Yx5vugcjgSF/UcBtucYxNhC0mWNkscZ1+Lg4r+tcFg9/wKfdJOM4AqRopk+giIGJWdmB89ai5Ejx2nwZ2qWm4IeZUlW1G4x7Ulx67uhFJOkQVEwrgryYvlhylNgWW+tZc3xC3yyRO1ilNWSvEop/Sr0eqxwPGYjQUCcJo2A+Zy0FDPelrX2jI/22uqGlfaeufIU50ZxpUYihjFKmSxdzLzZZWcO1jLYWCEDKPGIWfwe60Z5r6EN3K22wAGPY2vznADlHN7xQKxfczKNYa8wwmbAOJqEpJvqu/+qYqU0zCitwmYUWAEXHAuiCjWklLye8Bk9AIc37DQ2p7abT1yKkY9YzHgXrXE2+DLQMuD8bvXI4BQ89pBNXD68fV6nkn3yTJvFf95f+nEUTDHuFu3rydUJ7/mWdNTnAXRGwZwqV6d9g2OE00Lgzh1984qR6c1U3xySpgVhHxSHfjhWGOM61ffUamssbwMOWxC5uz592ilVK8U3C03Is3vr9M7F8xftXbWWjxbj3jvojH4FcFwWweb+9OwF8gQhC7it2pqtMqYss7Iq1SpDMillS5GIS9cfyZ2kTp7NFUYiYN24PVYIvem5Y9P4bRVroy2SFDhdGdAqlzm9q5rVo7XXzrDmnR29+py9t0JSohbjAGKPyH5dS8UYKMSMdzLXxAwUWxUMUauMWWcGVGWaOlxQRKfcC3p2Wj2X4nQi6pHCvgjj3s2/+V/gWaTQjOeJ2JLOmKEQAjMHhacbUwG0akyhnAZRiDNvL944f+m14U2SXIvADjXOVdCiDUAx410lpri6VKzBC89uEMPGlK4EyayaKitwcckvbzlnKFklIpGneoVDD+ydGt6vx8DllPchdCnL2jFbb/QW7vzo4MzmY1YXwbNFkRmIJTzkZF0i3+qEBb1b9LIGZtL+EKExDsA3D46N7YaugGu8zaDNr4EzVVYVjdnNqgoam8ptQlkaAduRG7GjSmmv0JnOpV0JDsBYFuMhCuUwti8ayiokS1mvPyJo+ABpssbtNh9Pg+WAA1Cd0XzpkRTmjpBl6tWpxVB8o3+Q/u2HA3Bun5RVhfaKs4Y3iBlqAla4RlXOuY8ZzJmNUefLk9czOiNOGKncKVn+IczMepUGojhejdEHY3wzMb74NYyJz9XATNuBaSdtB8FWlE1QEIQrolfFXRD3XVE07pq4RhN3o09G44OJH8Yz01YRt8lduJDLr/9z5pxD/6PEQmnRj5/7RQ1y+Le1sgdjASNdb0Oq9P1AA4nm0OSUGCkTcACsGxq7OTqQhWuvwAxOY5JRgg4T4aIKjLxrUPj3dVfMJNgwDiAN33SCoqfcYMxnKcJMEVaOm4VWhrYakMtMaXqdAowx01k2tAAZT4gPeAD8+3qcgzyJgZQydGCaZoqYAPQJEwJ9sU81czPfSLHbsDUDV/LrjypRsw6BMdHBVHkTqsL6JxDCeozYzAGkZQDUSaVIKjOsAY4yxm4SbjtOtrhp7zRbjju6vryilw4rInBRNMOGHdrXMIr+A4T1ymqAQkfTRQqBN2QpSuiQUB/CSqmRIeVssqr6zfLqe7heL69Sxmz1mFAVOnhiOP1J4dvHE098+WhqKdG2zdQQMjgkPoBAn89tQqD0UcvQ3Bit7m11Vtn0zCaRw2hGRH45/I6cyMTKCZ6r3p2oyK3PYAo5Ogg0xN6kYrXhu8EBbJhdEs+jRmvgkkd2bcODaSXyRaE8wnjGpA8pb66RVDjOgxikJ1Xf70NRkJRhpkyfgcCAyDnLELPecqzt3e3d9Hqtc2rd7S2rIaTRBATchM+aiEfAiBfcXN+fDOt6zRG7hkAoCfOB1BZMTqE0SrhScyvFbtM8OnVwtGZfmENgSWM7ONQZ8yNX/soL17ZJkdccR1T7MJVhNvFtKpdNU8TI6k628+LoEVLIb+rplb4S9U15YxQ2GMgdMMNNM8kTr6DfwvrtjQMBJSRD7QaVQCZiarIpPa2+eb33qFbOn76cKz9V5LCVwyE8gpDQEIfQyjEeGjNM0KvJknwKORyKoucEgBy+bOYTznBV048ZLb7PLR+5aB9Q5NmcgILK0HWVQKlyLIdv6+N2CZAPrZxEPj/GiOkSAjunTQhtZ6C57fc8s2aPulP549lH/V61rgBBiIu6Z1D0P13XsYkfLenW47iVfv97H3hKTA4syhqUwTINhxoHWSO3a3rj2dHuC0c27lICgxFYkQkrHv10XX8DxqNjAhhNt34DfqC8YNfabd+nop/CxrURZqx0vdccrPJGpTNdJZxEoiIi+zz+440RHgcieCbiJVASYbW4chLYJozZN1m/DurEh2+MUEz1sjfK69bTzfdy93YqK+JCEA4OcAPPV7xz5Hv8BCIUJRFeTCYNnRu8n57odC/bjsFcG2oRBpa237FUbfUK1KnuXbWqWO151T0nlBkWRmgFAk5wKgiYSN+EQpnYuDCGAI3VpKENKFKf/AJ83uY1khlUqVczTaz1B3qsd5PqaXfDjgZfM1XYsViZAUNUi4cdFDIo/eYfVYEmcxidt1CMVK9ttBH/pSQ/u9yt7WqYu/buypgeL0xVcCyn2WV6IO8N0tlt0wuUuSsCn0p6lTgG7SwqN+lYjANRQtBQYG8bOmrozHEz9ODzMYWVTnFzaTvzNm3e7rT2VdRaze5pdQpfhfXTo/LOJcrcGEEYr9CkZSLVoUiLePgTKLdmtKhh1LCLVdUkmOsnv/zIIS/u3lWZGlT6GzuOZ/vumnJzE11eSNv8YPXMulXmAmWGaqjiNiNsIviH6xqXLWUcOHZagVWL2y4GtTp3tIr9IER+4qRdWVXA+WMbp6i3Z/XFY5d3n7mp5Yo0a1e9sxvtpcpMP4bhSjUjMOCxjJzEAXhcYUISE4ngyvbXrJSDUcJwkhm2X3dv3n68VZRFa9A/Rvf1+NG0V7roHnzonRoVzHt7mtu91tU7q6u5ecpMHEMwdSw/3CcSFPrLgH/8/v3X92Ll4ZVIuJiUOu6XhRuzXwUboaHV/YI7un7p/HQ5v+mQm/asTM3fmcPVnaX1XbVsZ6f8zt0Navn4MmUW1KEO/y8SBN4GGAcSG3DFhIwcYISAI3eNDANSh32ofU1VLdjmQ9Nu9Vyjzju94tHTvFXbt9rZudHbdP5e+Ui/pPJs8fyjS2dY/sJCZZaAQHMBmUmpL9IhwbAkP1oo4lsYMxtjJCwaB1uWSZKFTvlmbv3aQ3sKvVw9XRgcPPPwcNM9tPdCgeFmc7p4ulvZOL1QmZ0wCTIIAWdOLCyjGZARZAyLBzh4KhEgE+IqCEa+k0jqOAH2yNBCWsa0u73New7sOHkEl9yp1roNe3af3Ph60xZ7sHqN3t+7492V5snj9fnKHFXLch1pcJ0uaB07bIGqC0McR0j+GSiXfxEMbQhBMIGXNCyDkYy2uV4pXN1cXGNRtLlayJ0/cvnWqsteb2Rn+cFzt0+vOfkof2zRdyKtbkdpMIiaGBNNNBo10USNt76A0UdoDPRrCxRIKeSjWLAUaGmB8ieNsPxLWRAXV1EXddG4uLqa1UQN8WfjndlHcrpEnRsyacLJOTPDpGc4dNZBUFLYgYgQRzkOnKa/zuqyT4EQZJACyt9nyLmsJUnb/h2oSxo5l0rLczx5kqrGgulhYydVffwjdefedlWQmYTVnb2Svgp4mrt06HDIHWAwYx/dSAciicCyeG60NDvtYYBuCdHLDMgtGcInQm77lg2WAsnfLRrw6xmK5yefEjiaUyv96uCWZhEJc2tl1d9/8LjXHjwdbMsVOOYztIGgdjKoStGIZBknjQjSdpHu/tu3JIwposExRDbq0rJDQBDA3eSBQxxEnKpru/mCyHkJ3Or8io1frxnWo7pVadcX5ZXZu48r+bXWGlzXrpeSAkYE4oQ44j1A4qBJ3LQX6ICaXhqkC9DPEMACogfoLs9oy2PX8v7pCyJ153Zw8WIhBC2mpqdmk+ijMtZnt7o4n+uu1BvPR0z6db9z3D5YMsHNhnnL76oUHUruGe0P1xAQoCXOBeyQXT/AuuEBXA9g+tBBDo/8FOkOkPZYes0MMw6KfGk0lXZa/abQeynplvIp0kimLcZsd96+uVWRxx/WlSsAeIYVGamhgOege0lOAauOo/2w7ooieBIk7/SSJAvfS4XdB64vSS9HhnJ7OchADjcpJQM305YYCaUFfjxUPfxm73WM8gmdWWrPIDe2Bs07wgNVe7lbuAiAR7iskeQZk0JuzhUO6dCJfpFCzSBHUQiFqLDPhZ1OgwUkN6Ld7qWggWfkTRrmH7pNxRmmW482+/WGFlPWiJtDQkhoacc0K8hSXS/vtB88+ajNXtzXdrWjB2d1s0wyxrQpUWwxGZ5TMHougAoTKMLyPAWbL+x0+hkfsnsJKEF4KBKFoJwkIIe4omZ29ibrGt6IdmuCy0oW53upsNkxWv5Se9F+ut2abIzyn9/d6Z0/ZMcxT4L244pVxgSDc4EcRbJGGF6gKfDvwRQhwDIPwYsXZ+tnYwAKomBCaYLM+Ai5iGPptfv7upJJl8s8t9AexqPdd4N4tdmLi7hhVlLal5WN/Cj79W0LFLU1lW4zMiWXFb6IODau1pCVHRuI5knExrlwZjpkoHJenUDQHgTrAyHthWjTZVqKEh0pE1Zu1DsqHt2LaB9H3YTZGVfXM974SPaGqv31bKH36mV+PHl4DRS146Tk4uOMv1lRfU1DNCJK+GGmxa3CeE2xohvZFl4lWDUDxBCDoGegq+z+vUngWncWjVWmw1oKS5QoiLiQ/zaSh6VOaTPSQZF4xtXZ3E6NS48Ti3uf1r+c+vt3xHOcBO7masuqPcsN1dufan1BHJrm3LWfzOf4ESNkGdy46/L6/R4Hw4hehw5W6G0jbdUTkdLGllaY83H6djTB53FcqtYaopy8Uxt1x7svNna3pR/DpJjOD748uQwEl3HB9EVh4bQzhc6+WJHme8nBIISbPc6nlwPTpCzNS0nRTwm8CBW8eUMtSQQdY71NfUWqpdvZbolgAuL9ELdX3koU1/RGYBFTqvzWphh/YD5tTdoFPH0g/Lxz+tC/OFEucoUuH+2rzfWvuztxHuN+sfGwzUnyvtnev88ZdUNiOaLRH6rq0BVlXbmi1X6XejEXhcobqG4iyDR30tV0Ht8rlFdRolLaLlfyjxe9e4vq6vvfW4Unv75fPfQ//lRqZ61KhGEcwC0Igoqion2lqAhaqOiimzaKiMoysiI8YZq7Zk5amjahjWVHLG1cxhlnNMajM5pL7mtupR0Xok4r3QT1PTK6aV9+V+/d/+7hfZYNG/Fb2G2hmfPI4Ug6i8EzJyH3VcEln/DCmcDDWzmLU+Q5r2beYVmFVr7hIDuc8wMBrx65BT64egn3cIdFZ+SeVejnyflCu+yMlHPjoYf/2AJGCViHGMe8bs8E7di8gfGN/QhaVfiu6EBHOHgpieqtYiJmD8qNddV1m8Jq4l0WSi+KTA8FPq/eIkXp2/6EWOU2ILAvb40EMSb7klR2gxdqC04o2azTqqvnuJRZbAGLliho85dFtrK+Te5jfGdRPObhYADPY/OMmnAJcT+Y5bYAdAI282/xJVGt2o3lxPa8whkZ0WgCpQqC0bdtVIyr06nuCo6cfWbmM7mGmxzW0RsyjuAKR/jYHIWBKuKIikdidYDuxmcxfrC2KM3Fwr6ytnzJmEaJC/kgSPi5kXDZFtLINGYz7zTXeTv3KAIktIoqTFXHRZUW13hWX2cO+5GLZhvvEPuwVCY8c+Aa+7puxH6Ge5Kohl3nYD6UJMcS0BrGTxbEQ8U0SbrGIwFRQeKCRSXxQxgsumRF2BZpASIvDHrIegIIkmntwBYs5ULZmA4NBBSHT6tUd85KhtVOID0+orRoWN5TTh0/T4zVffeQHPgQRehVjF/YDY3BHjwJkZG7DSKUFRMBmyZZvSwwPlagYQdm4XmheiiGFAI0pS2Uu5aM0F+CNGrlyeMYVy0V2I8dV7OOjzBHrTK3CPZFEKdG600CAB4mx9cwfmlWuuTwh8B0s25MuJPFCmgleCkb7g/fdLjphBbW44NX3cwg6iogKSraS1nkNpwj4p+0alpMHvu0MHBZYTqjUF91uyfcuqTGAXhxo+hRIurdy/iN2Zu6MaiPy4PpRq4Z0YiBUiiBxOFHwUTQVo0ZCwX4CWUsPs30MjUSdYVTSU0zBo1pK4Dq1qERySghQb+0TXkFRgSrekU6p+WZuebWstmM35o6ncSaKEWQZFeU4IbicJPIpNAUTUE1utSv1d9036WG734/g6RoaOCZuINCNC2XWxzHWUqQyxfoecK2rSUZLYlG9WG4bL1pia6eyviT9dv6AJ1uUVk6DCIVKg15jFp/Ox6rRAfFUqOD1z4kahHq7UQ3hkfGeh4S83fjBrvx1o2jl+0sFtvAMvmUiqxLpSRGzTfkTs682Yy/mLpouiN9O10stkGzjqDJasmbqGTf9V68H39de98Bi69LrzIfio1OAHPjd55XU5lwWe9iC47fYKpF59g6zHRf5a8lsJDC7DIsmzmF8XdT56ymAlXoRTUizjfaHYwsjL8uddLiF+96b2KdV93+lxR6wvV8/N2g2WrXqahFefnkcOPIvCNXACyhvg4BhSzqyK9a/DXuXzKX7JqOYM+v6/BwxtunX3Ve9j6F37zNNFKNFy96mSepj/hz1ysyl0ohAMLx8zlHjtgPHRrOfw2s63d5t9PUze0L5wzT/s/SpbMW7dk5Y8GOrVtWLO+n4523oTevM1Sl1ymYBpVGqelHNVDELz4mZQ0/dkemTZq0cv7kGWvXzVw/9w/H658B4/5FYKGK3+sAAAAASUVORK5CYII=" } } diff --git a/packages/kimi-desktop/page/boot.js b/packages/kimi-desktop/page/boot.js new file mode 100644 index 000000000..dd77f57f1 --- /dev/null +++ b/packages/kimi-desktop/page/boot.js @@ -0,0 +1,75 @@ +// 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 lang = 'en'; + let port = DEFAULT_PORT; + let service = null; + try { + const ready = await window.magpie?.ready; + if (typeof ready?.dark === 'boolean') theme = ready.dark ? 'dark' : 'light'; + if (typeof ready?.lang === 'string' && ready.lang) lang = ready.lang; + } catch { /* The service can use the system theme without the bridge. */ } + const chinese = /^zh(?:-|$)/i.test(lang); + if (document.documentElement) document.documentElement.lang = chinese ? 'zh' : 'en'; + document.title = chinese ? 'Kimi Work 模型' : 'Kimi Work models'; + const heading = document.getElementById('heading'); + if (heading) heading.textContent = document.title; + const message = document.getElementById('message'); + if (message) message.textContent = chinese ? '正在连接设置服务…' : 'Connecting to the settings service…'; + 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) { + if (message) message.textContent = chinese + ? '未获得设置服务地址。请在运行 Magpie 的桌面端打开,或使用支持插件服务代理的 Magpie Web。' + : 'No settings service address is available. Open this page on the Magpie desktop, or use Magpie Web with plugin service proxy support.'; + return; + } + service = new URL(`http://127.0.0.1:${port}/`); + } + if (!service.pathname.endsWith('/')) service.pathname += '/'; + if (theme) service.searchParams.set('theme', theme); + service.searchParams.set('lang', lang); + // 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); + const retry = document.getElementById('retry'); + retry.textContent = chinese ? '重试' : 'Retry'; + let attempts = 0; + const connect = () => { + attempts += 1; + // WebKit can leave this document alive when a navigation is refused. + // Successful navigation destroys the document and its pending timer. + setTimeout(() => { + if (attempts < 3) connect(); + else { + if (message) message.textContent = chinese + ? '未能打开设置服务。请稍候,然后点击“重试”。' + : 'The settings service could not be opened. Wait a moment, then select Retry.'; + retry.hidden = false; + } + }, 3000); + location.replace(service.href); + }; + retry.addEventListener('click', () => { + attempts = 0; + retry.hidden = true; + if (message) message.textContent = chinese ? '正在连接设置服务…' : 'Connecting to the settings service…'; + connect(); + }); + connect(); +})(); diff --git a/packages/kimi-desktop/page/index.html b/packages/kimi-desktop/page/index.html new file mode 100644 index 000000000..d315929ec --- /dev/null +++ b/packages/kimi-desktop/page/index.html @@ -0,0 +1,21 @@ + + + + + +Kimi Work models + + + +

Kimi Work models

Connecting to the settings service…

+ + + + diff --git a/packages/kimi-desktop/primary-model.mjs b/packages/kimi-desktop/primary-model.mjs new file mode 100644 index 000000000..208c4652c --- /dev/null +++ b/packages/kimi-desktop/primary-model.mjs @@ -0,0 +1,70 @@ +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 ''; } +} + +async function discoveredMagpiePath(value, platform) { + const names = platform === 'win32' ? /^magpie(?:\.exe)?$/i : /^magpie$/i; + const basename = value => (platform === 'win32' ? path.win32 : path).basename(value.trim()); + if (typeof value !== 'string' || !names.test(basename(value))) return ''; + const resolved = await executablePath(value); + // A magpie symlink must not turn an unrelated executable into the host. + return resolved && names.test(basename(resolved)) ? resolved : ''; +} + +// 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 { + const validPID = Number.isSafeInteger(parentPID) && parentPID > 0; + const options = { cwd: directory || undefined, env, + timeout: 6000, encoding: 'utf8', windowsHide: true }; + if (validPID && platform === 'darwin') { + const result = await run('/bin/ps', ['-ww', '-p', String(parentPID), '-o', 'comm='], + options); + parent = String(result.stdout || '').trim(); + } else if (validPID && platform === 'linux') { + parent = await readlink(`/proc/${parentPID}/exe`); + } else if (validPID && platform === 'win32') { + const command = '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ' + + `(Get-CimInstance Win32_Process -Filter 'ProcessId=${parentPID}').ExecutablePath`; + const result = await run('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], options); + parent = String(result.stdout || '').trim(); + } + } catch { /* An explicit path or PATH may still name the host. */ } + executable = await discoveredMagpiePath(parent, platform); + if (!executable) { + try { executable = await discoveredMagpiePath(which('magpie'), platform); } 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..0f5bfc268 --- /dev/null +++ b/packages/kimi-desktop/primary-model.test.mjs @@ -0,0 +1,214 @@ +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 ')); +const environment = directory => ({ ...process.env, HOME: directory, USERPROFILE: directory, + CFFIXED_USER_HOME: directory, XDG_CONFIG_HOME: path.join(directory, '.config') }); +const fakeExecutable = (directory, name) => { + const executable = path.join(directory, name); + fs.mkdirSync(path.dirname(executable), { recursive: true }); + fs.writeFileSync(executable, '', { mode: 0o700 }); + return executable; +}; + +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 = { ...environment(directory), 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 directory spaces and a case-insensitive Magpie name before PATH', async () => { + const directory = temporary(); + const executable = fakeExecutable(directory, 'Magpie directory with spaces/MaGpIe'); + const calls = []; + try { + await setPrimaryModel('example/main', { directory, env: environment(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('a shell parent is unavailable and is never invoked as the primary setter', async () => { + const directory = temporary(); + const calls = []; + let error; + try { + try { + await setPrimaryModel('example/main', { directory, env: environment(directory), + platform: 'darwin', parentPID: 123, which: () => null, + run: async (file, args, options) => { + calls.push({ file, args, options }); + return { stdout: file === '/bin/ps' ? '/bin/sh\n' : '' }; + }, + }); + } catch (caught) { error = caught; } + expect(calls.map(call => call.file)).toEqual(['/bin/ps']); + expect(error?.code).toBe('primary_model_unavailable'); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); + +test('a shell returned by PATH discovery is unavailable and is never invoked', async () => { + const directory = temporary(); + const calls = [], names = []; + let error; + try { + try { + await setPrimaryModel('example/main', { directory, env: environment(directory), platform: 'unsupported', + which: name => { names.push(name); return '/bin/sh'; }, + run: async (file, args) => { calls.push({ file, args }); return { stdout: '' }; }, + }); + } catch (caught) { error = caught; } + expect(names).toEqual(['magpie']); + expect(calls).toEqual([]); + expect(error?.code).toBe('primary_model_unavailable'); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); + +test('automatic discovery refuses the Bun executor, including a magpie symlink to it', async () => { + const directory = temporary(); + const runtime = fakeExecutable(directory, 'magpie-bun'); + const link = path.join(directory, 'magpie'); + fs.symlinkSync(runtime, link); + const calls = []; + try { + for (const found of [runtime, link]) { + let error; + try { + await setPrimaryModel('example/main', { directory, env: environment(directory), platform: 'unsupported', + which: () => found, + run: async (file, args) => { calls.push({ file, args }); return { stdout: '' }; }, + }); + } catch (caught) { error = caught; } + expect(calls).toEqual([]); + expect(error?.code).toBe('primary_model_unavailable'); + } + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); + +test('Windows discovers its Magpie parent through CIM before PATH and keeps its profile', async () => { + const directory = temporary(); + const executable = fakeExecutable(directory, 'Magpie directory with spaces/mAgPiE.ExE'); + const env = { ...environment(directory), KIMI_PRIMARY_TEST_MARKER: 'windows-profile' }; + const calls = []; + try { + await setPrimaryModel('example/main', { directory, env, platform: 'win32', parentPID: 456, + which: () => { throw Error('PATH should not be consulted'); }, + run: async (file, args, options) => { + calls.push({ file, args, options }); + return { stdout: file.toLowerCase() === 'powershell.exe' ? ' ' + executable + '\r\n' : '' }; + }, + }); + expect(calls).toHaveLength(2); + expect(calls[0].file.toLowerCase()).toBe('powershell.exe'); + expect(calls[0].args.slice(0, 4)).toEqual(['-NoLogo', '-NoProfile', '-NonInteractive', '-Command']); + expect(calls[0].args).toHaveLength(5); + expect(calls[0].args[4]).toContain("Get-CimInstance Win32_Process -Filter 'ProcessId=456'"); + expect(calls[0].args[4]).toContain('.ExecutablePath'); + expect(calls[0].args[4]).toContain('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8'); + expect(calls[0].options).toMatchObject({ cwd: directory, env, + timeout: 6000, encoding: 'utf8', windowsHide: true }); + expect(calls[1].file).toBe(fs.realpathSync(executable)); + expect(calls[1].args).toEqual(['kimi-desktop', 'model', 'magpie/example/main']); + expect(calls[1].options).toMatchObject({ cwd: directory, timeout: 60000, windowsHide: true, + env: { ...env, MAGPIE_KIMI_SKIP_ADAPTER: '1' } }); + expect(env.MAGPIE_KIMI_SKIP_ADAPTER).toBeUndefined(); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); + +test('unavailable Windows parent discovery falls back to a Magpie PATH executable', async () => { + const directory = temporary(); + const executable = fakeExecutable(directory, 'portable directory/Magpie.exe'); + const calls = [], names = []; + try { + await setPrimaryModel('example/main', { directory, env: environment(directory), platform: 'win32', parentPID: 456, + which: name => { names.push(name); return executable; }, + run: async (file, args, options) => { + calls.push({ file, args, options }); + if (file.toLowerCase() === 'powershell.exe') throw Error('synthetic PowerShell unavailable'); + return { stdout: '' }; + }, + }); + expect(names).toEqual(['magpie']); + expect(calls.map(call => call.file)).toEqual(['powershell.exe', fs.realpathSync(executable)]); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); + +test('a nonnumeric parent PID is never placed in a discovery command', async () => { + const directory = temporary(); + const executable = fakeExecutable(directory, 'magpie.exe'); + const calls = []; + try { + await setPrimaryModel('example/main', { directory, env: environment(directory), platform: 'win32', + parentPID: "456'; Write-Output unsafe; '", which: () => executable, + run: async (file, args, options) => { calls.push({ file, args, options }); return { stdout: '' }; }, + }); + expect(calls).toHaveLength(1); + expect(calls[0].file).toBe(fs.realpathSync(executable)); + expect(calls[0].args).toEqual(['kimi-desktop', 'model', 'magpie/example/main']); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); + +test('missing executables and failed CLI updates return only fixed error codes', async () => { + const directory = temporary(); + const env = environment(directory); + try { + await expect(setPrimaryModel('example/main', { magpiePath: './relative-magpie', directory, env })) + .rejects.toHaveProperty('code', 'primary_model_unavailable'); + await expect(setPrimaryModel('example/main', { platform: 'unsupported', which: () => null, directory, env })) + .rejects.toHaveProperty('code', 'primary_model_unavailable'); + let error; + try { + await setPrimaryModel('example/main', { magpiePath: process.execPath, directory, env, + 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(); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/packages/kimi-desktop/restoration.test.mjs b/packages/kimi-desktop/restoration.test.mjs new file mode 100644 index 000000000..aaaf6e128 --- /dev/null +++ b/packages/kimi-desktop/restoration.test.mjs @@ -0,0 +1,91 @@ +// Exercise the real control WebSocket transport against a temporary Kimi peer. +// No desktop profile, application or external model is used. +import { test, expect } from 'bun:test'; +import fs from 'node:fs'; +import { isMenuAlias } from './catalog.mjs'; +import { recoveryPeer } from './test-fixtures/control-peer.mjs'; + +const official = 'https://agent-gw.kimi.com/coding/v1'; +const read = file => JSON.parse(fs.readFileSync(file, 'utf8')); + +for (const [point, after] of [['list', false], ['select', false], ['select', true], + ['menu', false], ['menu', true], ['reload', false], ['reload', true]]) { + test(`stop preserves retryable recovery when ${point} fails ${after ? 'after' : 'before'} its effect`, async () => { + const f = recoveryPeer(); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + f.selectAlias(); + f.disable(); + f.fail(point, after); + await f.controller.stop(); + expect(read(f.controller.configFile).model.providers['daimon-kimi-code'].baseUrl).toBe(official); + expect(read(f.controller.stateFile).active).toBe(true); + expect(fs.existsSync(f.controller.modelBackupFile)).toBe(true); + expect(f.controller.status.code).toBe('restored_waiting_kimi'); + f.recover(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + expect(read(f.controller.stateFile).active).toBe(false); + expect(read(f.controller.configFile).model.current).toBe('native'); + expect(f.menu.map(row => row.model)).toEqual(['native']); + expect(f.runtimeBase).toBe(official); + expect(fs.existsSync(f.controller.modelBackupFile)).toBe(false); + expect(read(f.controller.configFile).unrelated).toBe('keep'); + } finally { await f.cleanup(); } + }); +} + +test('a disabled next start completes native menu and runtime recovery after synchronous exit', async () => { + const f = recoveryPeer(); + try { + await f.controller.tick(); + f.selectAlias(); + f.controller.restoreFileOnExit(); + expect(read(f.controller.stateFile).active).toBe(true); + expect(f.menu.some(row => isMenuAlias(row.model))).toBe(true); + await f.controller.stop(); + f.disable(); + f.recover(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + expect(read(f.controller.configFile).model.current).toBe('native'); + expect(f.menu.map(row => row.model)).toEqual(['native']); + expect(f.runtimeBase).toBe(official); + expect(fs.existsSync(f.controller.modelBackupFile)).toBe(false); + } finally { await f.cleanup(); } +}); + +test('a first apply with an unacknowledged side effect retains recovery for disconnect', async () => { + const f = recoveryPeer(); + try { + f.fail('select', true); + await f.controller.tick(); + expect(f.runtimeBase).toBe(f.controller.bridgeURL); + expect(read(f.controller.configFile).model.providers['daimon-kimi-code'].baseUrl).toBe(official); + expect(read(f.controller.stateFile).active).toBe(true); + f.disable(); + await f.controller.stop(); + f.recover(); + await f.controller.tick(); + expect(read(f.controller.stateFile).active).toBe(false); + expect(f.runtimeBase).toBe(official); + expect(f.controller.status.code).toBe('disconnected'); + } finally { await f.cleanup(); } +}); + +for (const [message, code, expected] of [ + ['kimi_config_changed', undefined, 'kimi_config_changed'], + ['secondary_model_unsupported_runtime', undefined, 'secondary_model_unsupported_runtime'], + ['missing file', 'ENOENT', 'secondary_model_unavailable'], +]) { + test(`stop retains the classified pool restore error ${expected}`, async () => { + const f = recoveryPeer(); + try { + await f.controller.tick(); + f.controller.restoreModelPoolOnExit = () => { throw Object.assign(Error(message), { code }); }; + await f.controller.stop(); + expect(f.controller.status.code).toBe(expected); + } finally { await f.cleanup(); } + }); +} diff --git a/packages/kimi-desktop/review-lifecycle.test.mjs b/packages/kimi-desktop/review-lifecycle.test.mjs new file mode 100644 index 000000000..eb9ba043b --- /dev/null +++ b/packages/kimi-desktop/review-lifecycle.test.mjs @@ -0,0 +1,1875 @@ +// Review fixtures for the lifecycle contract of the optional secondary model. +// Everything here is synthetic: a temporary Kimi directory, a loopback HTTP +// gateway, a temporary state directory and the public native plugin peer. +// No desktop profile, real credential, real app or real home directory is used. +import { test, expect } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import http from 'node:http'; +import { KimiAdapter } from './adapter.mjs'; +import { AgentModelRouter } from './agent-routing.mjs'; +import { normalizeModels } from './catalog.mjs'; +import { nativeRolePluginPeer } from './test-fixtures/control-peer.mjs'; + +const official = 'https://agent-gw.kimi.com/coding/v1'; +const primaryID = 'synthetic/main'; +const secondaryID = 'synthetic/secondary'; +const thirdID = 'synthetic/third'; +const canary = 'synthetic-credential-not-for-state'; +const catalogPayload = { data: [primaryID, secondaryID, thirdID].map(id => ({ id, + context_window: 131072, reasoning: true, supported_reasoning_levels: [{ effort: 'max' }] })) }; +const aliasOf = id => normalizeModels(catalogPayload).find(row => row.id === id).alias; + +const agentTool = { type: 'function', function: { name: 'Agent', description: 'Create a new task.', + parameters: { type: 'object', properties: { + prompt: { type: 'string' }, description: { type: 'string' }, + }, required: ['prompt', 'description'] } } }; + +// One native Agent call stream, repeated verbatim so a pass-through can be +// compared byte for byte with the synthetic supplier response. +const upstreamFragments = [ + { choices: [{ index: 0, delta: { tool_calls: [{ + index: 0, id: 'call_plain', type: 'function', + function: { name: 'Agent', arguments: JSON.stringify({ prompt: 'synthetic prompt', description: 'synthetic description' }) }, + } ] } }] }, + { choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }, +]; +const upstreamSSE = upstreamFragments.map(frame => 'data: ' + JSON.stringify(frame) + '\n\n').join('') + + 'data: [DONE]\n\n'; + +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 writeText = (file, text) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, { mode: 0o600 }); +}; + +async function fixture({ secondaryModel, legacyModel = null, seedOwnedLegacy = false, regenerate = null, + aliasedKimiDir = false } = {}) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-lifecycle-')); + const physicalKimiDir = path.join(directory, 'kimi'); + const kimiDir = aliasedKimiDir ? path.join(directory, 'kimi-alias') : physicalKimiDir; + if (aliasedKimiDir) { + fs.mkdirSync(physicalKimiDir); + fs.symlinkSync(physicalKimiDir, kimiDir, process.platform === 'win32' ? 'junction' : 'dir'); + } + const stateDir = path.join(directory, 'state'); + const legacyFile = path.join(kimiDir, 'runtime/kimi-code/config.toml'); + const convertedFile = path.join(kimiDir, 'runtime/kimi-code/home/config.toml'); + const plugins = nativeRolePluginPeer(path.join(physicalKimiDir, 'runtime/kimi-code/home')); + const calls = [], requests = []; + let gatewayCatalog = catalogPayload; + let catalogStatus = 200; + const nativeModels = ['native', 'native-alt']; + const runtimeModels = nativeModels.map(model => ({ model, maxContextSize: 131072, capabilities: [] })); + let controller; + + // Loopback supplier stand-in. /models is the Magpie catalog; the chat route + // returns one Agent tool call so response translation is observable. + const gateway = http.createServer(async (request, response) => { + if (request.method === 'GET' && request.url === '/v1/models') { + response.writeHead(catalogStatus, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(gatewayCatalog)); + return; + } + let body = ''; + for await (const chunk of request) body += chunk; + requests.push({ url: request.url, body: JSON.parse(body) }); + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.end(upstreamSSE); + }); + await new Promise(resolve => gateway.listen(0, '127.0.0.1', resolve)); + + const rpcCall = async (_runner, method, params) => { + const call = { method, params }; + calls.push(call); + if (plugins.handles(method)) return plugins.call(method, params); + switch (method) { + case 'models.current': return { current: read(controller.configFile).model.current }; + case 'conversations.getKimiModelList': return { models: runtimeModels }; + case 'conversations.setModel': { + const doc = read(controller.configFile); + doc.model.current = params.modelAlias; + write(controller.configFile, doc); + regenerateAfterControl(); + return { current: params.modelAlias }; + } + case 'conversations.updateKimiModelList': { + runtimeModels.splice(0, runtimeModels.length, ...params.models); + const doc = read(controller.configFile); + doc.model.models = Object.fromEntries(params.models.map(row => [row.model, + { model: row.model, provider: 'daimon-kimi-code' }])); + write(controller.configFile, doc); + regenerateAfterControl(); + return { ok: true }; + } + case 'kimiPlugin.reloadActiveSessions': + call.runtime = controller.secondaryReloadRuntime; + return { sessions: [] }; + case 'kimiPlugin.ReloadSession': + call.runtime = controller.secondaryReloadRuntime; + return { status: 'reloaded' }; + default: throw Error('unexpected synthetic control call'); + } + }; + const fetchImpl = async (url, options) => { + if (String(url).startsWith('https://agent-gw.kimi.com/')) { + return new Response('{"result":"synthetic official response"}', { headers: { 'Content-Type': 'application/json' } }); + } + return fetch(url, options); + }; + const make = () => new KimiAdapter({ home: directory, stateDir, kimiDir, + stabilityMs: 0, intervalMs: 1000000, rpcCall, fetchImpl }); + controller = make(); + + const nativeDoc = () => ({ + default_model: 'native', + providers: { 'daimon-kimi-code': { type: 'kimi', api_key: canary, base_url: official } }, + models: Object.fromEntries(nativeModels.map(model => [model, { provider: 'daimon-kimi-code', model, + max_context_size: 131072, capabilities: ['tool_use'], api_key: canary }])), + }); + const regenerateNative = () => { + writeText(legacyFile, Bun.TOML.stringify(nativeDoc())); + writeText(convertedFile, Bun.TOML.stringify(nativeDoc())); + }; + + // Model the native generator rewriting both runtime TOMLs from its own + // fixed key set. 'drop' erases the foreign scalar; 'replace' writes a new + // non-empty value that a foreign writer owns. + const regenerateAfterControl = () => { + if (regenerate === null) return; + const doc = nativeDoc(); + if (regenerate === 'replace') doc.secondary_model = { model: 'native/other' }; + if (regenerate === 'replace-pool') doc.secondary_model = { + models: { 'native-alt': 'New external pool' }, default_model: 'native-alt', + }; + writeText(legacyFile, Bun.TOML.stringify(doc)); + writeText(convertedFile, Bun.TOML.stringify(doc)); + }; + + write(controller.configFile, { + credentials: { kimiCode: { apiKey: canary } }, + model: { current: 'native', providers: { 'daimon-kimi-code': { type: 'kimi', baseUrl: official } }, + models: Object.fromEntries(nativeModels.map(model => [model, { model, provider: 'daimon-kimi-code' }])) }, + unrelated: { preserve: 'synthetic' }, + }); + const runner = { lifecycleStatus: 'running', daemonGeneration: 'synthetic-generation-1', + kernel: { agentKernel: 'kimi-code-v2' }, + activeKernelTurns: 0, activeKernelToolCalls: 0, + control: { endpoint: { url: 'ws://127.0.0.1:3072/control', pid: 42, startedAt: 1 } } }; + write(controller.runnerFile, runner); + regenerateNative(); + if (legacyModel !== null) { + const doc = Bun.TOML.parse(fs.readFileSync(legacyFile, 'utf8')); + doc.secondary_model = { model: legacyModel }; + writeText(legacyFile, Bun.TOML.stringify(doc)); + } + controller.bootstrap(); + + let ownedAlias = null; + if (seedOwnedLegacy) { + ownedAlias = aliasOf(secondaryID); + const doc = Bun.TOML.parse(fs.readFileSync(legacyFile, 'utf8')); + doc.secondary_model = { model: ownedAlias }; + writeText(legacyFile, Bun.TOML.stringify(doc)); + write(controller.stateFile, { active: false, secondary: { + baseline: { model: 'native/first', enabled: null, hasSecondaryTable: true, hasExperimentalTable: false }, + alias: ownedAlias, + }, secondaryModel: secondaryID }); + } + + const connection = { schemaVersion: 1, enabled: true, model: primaryID, + gateway: { url: 'http://127.0.0.1:' + gateway.address().port + '/v1', key: 'synthetic-gateway' } }; + if (secondaryModel !== undefined) connection.secondaryModel = secondaryModel; + write(controller.connectionFile, connection); + + // The real adapter reads three time gates; tests that immediately re-tick + // clear them instead of waiting for wall-clock windows. + const complete = () => { + controller.retryAfter = 0; + controller.catalogAfter = 0; + controller.secondaryAfter = 0; + }; + const setBusy = value => write(controller.runnerFile, { ...read(controller.runnerFile), + activeKernelTurns: value ? 1 : 0, activeKernelToolCalls: 0 }); + const configure = patch => { + const next = { ...read(controller.connectionFile), ...patch }; + write(controller.connectionFile, next); + return next; + }; + const pluginState = () => read(controller.stateFile).agentPlugin; + const pluginEnabled = id => { + try { return plugins.call('kimiPlugin.getPluginInfo', { id }).enabled; } + catch (error) { if (error?.message === 'kimi_plugin_not_found') return false; throw error; } + }; + const registryFile = path.join(path.dirname(convertedFile), 'plugins', 'installed.json'); + const managedDirectory = path.join(path.dirname(convertedFile), 'plugins', 'managed'); + const sourceDirectory = path.join(stateDir, 'native-model-roles'); + const roleManifest = id => ({ name: id, version: '1.0.0', description: 'Local Work model roles', agents: ['./agents'] }); + const seedRegistration = (id, { enabled = true, source = 'local-path', originalSource = sourceDirectory, + root = path.join(managedDirectory, id), manifest = roleManifest(id) } = {}) => { + writeText(path.join(root, 'kimi.plugin.json'), JSON.stringify(manifest, null, 2) + '\n'); + const document = fs.existsSync(registryFile) ? read(registryFile) : { version: 1, plugins: [] }; + document.plugins = document.plugins.filter(entry => entry?.id !== id); + document.plugins.push({ id, root, source, originalSource, enabled, origin: 'custom', installedAt: 'synthetic' }); + write(registryFile, document); + return { id, root }; + }; + const registryBytes = () => fs.readFileSync(registryFile); + const methodsSince = (index, method) => calls.slice(index).filter(call => call.method === method); + const closeServer = async instance => { + if (!instance.server) return; + instance.server.closeAllConnections?.(); + await new Promise(resolve => instance.server.close(resolve)); + instance.server = null; + }; + return { + get controller() { return controller; }, + directory, kimiDir, stateDir, legacyFile, convertedFile, connection, runner, + calls, requests, plugins, complete, setBusy, configure, pluginState, pluginEnabled, methodsSince, + setCatalogIDs(ids) { gatewayCatalog = { data: catalogPayload.data.filter(row => ids.includes(row.id)) }; }, + setCatalogStatus(status) { catalogStatus = status; }, + registryFile, managedDirectory, sourceDirectory, roleManifest, seedRegistration, registryBytes, + legacyText: () => fs.readFileSync(legacyFile, 'utf8'), + async crash() { + controller.stopping = true; + controller.releaseLock(); + await closeServer(controller); + controller = make(); + controller.bootstrap(); + }, + async cleanup() { + await controller.stop(); + gateway.closeAllConnections?.(); + await new Promise(resolve => gateway.close(resolve)); + fs.rmSync(directory, { recursive: true, force: true }); + }, + }; +} + +async function chat(f, payload) { + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer ' + canary, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) }); + const text = await response.text(); + return { status: response.status, text }; +} + +// Second-round review: these cross the actual loopback inference boundary. +for (const location of ['user', 'tool', 'arguments']) { + test('R2-1 no routing key preserves route quotations in ' + location, async () => { + const f = await fixture(); + const marker = '[agent-profile-route:v1]\nquoted:source\n[/agent-profile-route]'; + const messages = location === 'arguments' + ? [{ role: 'assistant', content: null, tool_calls: [{ id: 'quoted-call', type: 'function', + function: { name: 'Read', arguments: JSON.stringify({ path: marker }) } }] }] + : [{ role: location, content: marker, ...(location === 'tool' ? { tool_call_id: 'quoted-call' } : {}) }]; + const input = { model: 'native', messages, metadata: { source: marker } }; + try { + await f.controller.tick(); + expect(read(f.controller.stateFile).agentRoutingSecret).toBeUndefined(); + const response = await chat(f, input); + expect(response.status).toBe(200); + expect(f.requests.at(-1).body).toEqual({ ...input, model: primaryID }); + expect(read(f.controller.stateFile).agentRoutingSecret).toBeUndefined(); + } finally { await f.cleanup(); } + }); +} + +for (const role of ['user', 'tool']) { + test('R2-1 valid copied profile in ' + role + ' stays primary across later history requests', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const profile = f.controller.ensureAgentRouter().profileText(secondaryID); + const marker = profile.match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; + const input = { model: 'native', messages: [{ role, content: marker, + ...(role === 'tool' ? { tool_call_id: 'read-source' } : {}) }] }; + for (let request = 0; request < 2; request++) { + const result = await chat(f, input); + expect(result.status).toBe(200); + expect(f.requests.at(-1).body.model).toBe(primaryID); + expect(JSON.stringify(f.requests.at(-1).body)).not.toMatch(/\[\/?agent-profile-route\b/i); + input.messages.push({ role: 'user', content: 'Continue reading the quoted source.' }); + } + } finally { await f.cleanup(); } + }); +} + +test('R2-1 historical shared instructions cannot bind a model or poison the next request', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const marker = f.controller.ensureAgentRouter().profileText(secondaryID) + .match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; + writeText(f.controller.instructionsFile, 'Read source safely.\n' + marker); + const input = { model: 'native', messages: [ + { role: 'system', content: '\n' + marker + '\n' }, + { role: 'user', content: 'Read the repository.' }, + ] }; + const response = await chat(f, input); + expect(response.status).toBe(200); + const body = f.requests.at(-1).body; + expect(body.model).toBe(primaryID); + expect(body.messages.filter(message => message.content?.startsWith?.(''))).toHaveLength(1); + expect(JSON.stringify(body)).not.toMatch(/\[\/?agent-profile-route\b/i); + expect(JSON.stringify(body)).toContain('Read source safely.'); + } finally { await f.cleanup(); } +}); + +test('R2-1 final request guard catches route residue returned by a broken preparation stage', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const router = f.controller.ensureAgentRouter(); + const prepare = router.prepare.bind(router); + router.prepare = (...args) => { + const result = prepare(...args); + return { ...result, payload: { ...result.payload, metadata: '[agent-profile-route:broken]' } }; + }; + const response = await chat(f, plainRequest()); + expect(response.status).toBe(409); + expect(JSON.parse(response.text).error.code).toBe('agent_profile_unbound'); + expect(f.requests).toHaveLength(0); + } finally { await f.cleanup(); } +}); + +test('R2-2 one successful catalog absence preserves the token and profile for recovery', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const router = f.controller.ensureAgentRouter(); + const marker = router.profileText(secondaryID).match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; + const profile = path.join(f.sourceDirectory, 'agents', router.profileName(secondaryID) + '.md'); + f.configure({ secondaryModel: thirdID }); + f.complete(); await f.controller.tick(); + const bindings = read(f.controller.stateFile).agentRoutingBindings; + f.setCatalogIDs([primaryID, thirdID]); + f.complete(); await f.controller.tick(); + expect(read(f.controller.stateFile).agentRoutingBindings).toEqual(bindings); + expect(fs.existsSync(profile)).toBe(true); + expect((await chat(f, { model: 'native', messages: [{ role: 'system', content: marker }] })).status).toBe(409); + f.setCatalogIDs([primaryID, secondaryID, thirdID]); + f.complete(); await f.controller.tick(); + expect(read(f.controller.stateFile).agentRoutingBindings).toEqual(bindings); + expect((await chat(f, { model: 'native', messages: [{ role: 'system', content: marker }] })).status).toBe(200); + expect(f.requests.at(-1).body.model).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('R2-2 a successful busy catalog observation persists absence without plugin RPCs', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + f.configure({ secondaryModel: thirdID }); + f.complete(); await f.controller.tick(); + f.setBusy(true); + f.setCatalogIDs([primaryID, thirdID]); + const before = f.calls.length; + f.complete(); await f.controller.tick(); + expect(read(f.controller.stateFile).agentRoutingAbsence?.[secondaryID]?.count).toBe(1); + expect(Object.values(read(f.controller.stateFile).agentRoutingBindings)).toContain(secondaryID); + expect(f.calls.slice(before).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + } finally { f.setBusy(false); await f.cleanup(); } +}); + +test('R2-2 a selected missing secondary recovers before grace and is pruned after sustained absence', async () => { + const realNow = Date.now; + let now = realNow(); + Date.now = () => now; + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const router = f.controller.ensureAgentRouter(); + const marker = router.profileText(secondaryID).match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; + const profileName = router.profileName(secondaryID) + '.md'; + const source = path.join(f.sourceDirectory, 'agents', profileName); + const managed = path.join(f.managedDirectory, f.pluginState().id, 'agents', profileName); + f.setCatalogIDs([primaryID, thirdID]); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_unavailable'); + f.setCatalogIDs([primaryID, secondaryID, thirdID]); + f.complete(); await f.controller.tick(); + expect((await chat(f, { model: 'native', messages: [{ role: 'system', content: marker }] })).status).toBe(200); + expect(f.requests.at(-1).body.model).toBe(secondaryID); + f.setCatalogIDs([primaryID, thirdID]); + for (let sync = 0; sync < 3; sync++) { + f.complete(); await f.controller.tick(); + now += 600001; + } + expect(Object.values(read(f.controller.stateFile).agentRoutingBindings)).not.toContain(secondaryID); + expect(fs.existsSync(source)).toBe(false); + expect(fs.existsSync(managed)).toBe(false); + f.setCatalogIDs([primaryID, secondaryID, thirdID]); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const fresh = f.controller.ensureAgentRouter().profileText(secondaryID) + .match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; + expect((await chat(f, { model: 'native', messages: [{ role: 'system', content: fresh }] })).status).toBe(200); + expect(f.requests.at(-1).body.model).toBe(secondaryID); + } finally { Date.now = realNow; await f.cleanup(); } +}); + +test('R2-2 clearing secondary defers expired binding deletion until profiles can safely synchronize', async () => { + const realNow = Date.now; + let now = realNow(); + Date.now = () => now; + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const bindings = read(f.controller.stateFile).agentRoutingBindings; + const marker = f.controller.ensureAgentRouter().profileText(secondaryID) + .match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + f.setCatalogIDs([primaryID, thirdID]); + const before = f.calls.length; + for (let sync = 0; sync < 3; sync++) { + f.complete(); await f.controller.tick(); + now += 600001; + } + expect(read(f.controller.stateFile).agentRoutingAbsence?.[secondaryID]?.count).toBe(3); + expect(read(f.controller.stateFile).agentRoutingBindings).toEqual(bindings); + expect(f.calls.slice(before).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + f.configure({ secondaryModel: secondaryID }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_unavailable'); + expect(f.calls.slice(before).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + f.setCatalogIDs([primaryID, secondaryID, thirdID]); + f.configure({ secondaryModel: secondaryID }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(read(f.controller.stateFile).agentRoutingBindings).toEqual(bindings); + expect((await chat(f, { model: 'native', messages: [{ role: 'system', content: marker }] })).status).toBe(200); + } finally { Date.now = realNow; await f.cleanup(); } +}); + +test('R2-2 clearing secondary exactly at eligibility keeps the cache resumable for explicit opt-in', async () => { + const realNow = Date.now; + let now = realNow(); + Date.now = () => now; + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const bindings = read(f.controller.stateFile).agentRoutingBindings; + f.setCatalogIDs([primaryID, thirdID]); + f.complete(); await f.controller.tick(); + now += 600001; + f.complete(); await f.controller.tick(); + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(read(f.controller.stateFile).agentRoutingAbsence?.[secondaryID]?.count).toBe(3); + expect(read(f.controller.stateFile).agentRoutingBindings).toEqual(bindings); + f.setCatalogIDs([primaryID, secondaryID, thirdID]); + f.configure({ secondaryModel: secondaryID }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(read(f.controller.stateFile).agentRoutingBindings).toEqual(bindings); + } finally { Date.now = realNow; await f.cleanup(); } +}); + +test('R2-2 opting in to an allowed secondary synchronizes and prunes an eligible inactive target', async () => { + const realNow = Date.now; + let now = realNow(); + Date.now = () => now; + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const oldFile = path.join(f.sourceDirectory, 'agents', f.controller.ensureAgentRouter().profileName(secondaryID) + '.md'); + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + f.setCatalogIDs([primaryID, thirdID]); + for (let sync = 0; sync < 3; sync++) { + f.complete(); await f.controller.tick(); + now += 600001; + } + f.configure({ secondaryModel: thirdID }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Object.values(read(f.controller.stateFile).agentRoutingBindings)).not.toContain(secondaryID); + expect(fs.existsSync(oldFile)).toBe(false); + } finally { Date.now = realNow; await f.cleanup(); } +}); + +test('R2-2 a busy selected secondary retains an expired token until an idle successful synchronization', async () => { + const realNow = Date.now; + let now = realNow(); + Date.now = () => now; + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + f.setBusy(true); + f.setCatalogIDs([primaryID, thirdID]); + const before = f.calls.length; + for (let sync = 0; sync < 3; sync++) { + f.complete(); await f.controller.tick(); + now += 600001; + } + expect(Object.values(read(f.controller.stateFile).agentRoutingBindings)).toContain(secondaryID); + expect(f.calls.slice(before).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + f.setBusy(false); + f.complete(); await f.controller.tick(); + expect(Object.values(read(f.controller.stateFile).agentRoutingBindings)).not.toContain(secondaryID); + expect(fs.existsSync(path.join(f.sourceDirectory, 'agents', f.controller.ensureAgentRouter().profileName(secondaryID) + '.md'))).toBe(false); + } finally { Date.now = realNow; f.setBusy(false); await f.cleanup(); } +}); + +test('R2-2 an absence save failure rolls back memory and refreshes the cached router', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + let save; + try { + await f.controller.tick(); + const beforeBindings = read(f.controller.stateFile).agentRoutingBindings; + const beforeAbsence = read(f.controller.stateFile).agentRoutingAbsence || {}; + const router = f.controller.ensureAgentRouter(); + router.observeCatalog([primaryID]); + save = f.controller.saveState; + f.controller.saveState = () => { throw Error('synthetic-disk-unavailable'); }; + expect(() => f.controller.persistAgentBindings(router)).toThrow('synthetic-disk-unavailable'); + expect(f.controller.state.agentRoutingBindings).toEqual(beforeBindings); + expect(f.controller.state.agentRoutingAbsence || {}).toEqual(beforeAbsence); + f.controller.saveState = save; + expect(f.controller.ensureAgentRouter().absence).toEqual(beforeAbsence); + expect(read(f.controller.stateFile).agentRoutingAbsence || {}).toEqual(beforeAbsence); + } finally { if (save) f.controller.saveState = save; await f.cleanup(); } +}); + +test('R2-2 successful absence count and start time survive failure, cached ticks, and restart', async () => { + const realNow = Date.now; + let now = realNow(); + Date.now = () => now; + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const router = f.controller.ensureAgentRouter(); + const profile = path.join(f.sourceDirectory, 'agents', router.profileName(secondaryID) + '.md'); + f.configure({ secondaryModel: thirdID }); + f.complete(); await f.controller.tick(); + f.setCatalogIDs([primaryID, thirdID]); + f.complete(); await f.controller.tick(); + expect(read(f.controller.stateFile).agentRoutingAbsence) + .toEqual({ [secondaryID]: { count: 1, firstMissingAt: now } }); + const absence = read(f.controller.stateFile).agentRoutingAbsence[secondaryID]; + expect(absence).toEqual({ count: 1, firstMissingAt: now }); + now++; + await f.controller.tick(); // Cached catalog: not a second successful observation. + expect(read(f.controller.stateFile).agentRoutingAbsence[secondaryID]).toEqual(absence); + now += 600001; + f.setCatalogStatus(503); + f.complete(); await f.controller.tick(); + expect(read(f.controller.stateFile).agentRoutingAbsence[secondaryID]).toEqual(absence); + expect(fs.existsSync(profile)).toBe(true); + await f.crash(); + f.setCatalogStatus(200); + f.complete(); await f.controller.tick(); + expect(read(f.controller.stateFile).agentRoutingAbsence[secondaryID]).toEqual({ ...absence, count: 2 }); + expect(fs.existsSync(profile)).toBe(true); + f.complete(); await f.controller.tick(); + expect(Object.values(read(f.controller.stateFile).agentRoutingBindings)).not.toContain(secondaryID); + expect(fs.existsSync(profile)).toBe(false); + } finally { Date.now = realNow; await f.cleanup(); } +}); + +test('R2-3 source and managed dot files stay unchanged through stable readiness and retirement', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + const sourceDot = path.join(f.sourceDirectory, 'agents', '.DS_Store'); + const managedDot = path.join(f.managedDirectory, id, 'agents', '.DS_Store'); + writeText(sourceDot, 'synthetic source metadata'); + writeText(managedDot, 'synthetic managed metadata'); + const before = f.calls.length; + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(f.controller.rolesReady).toBe(true); + expect(f.methodsSince(before, 'kimiPlugin.updatePlugin')).toHaveLength(0); + expect(f.pluginState().files['agents/.DS_Store']).toBeUndefined(); + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(read(f.registryFile).plugins).toEqual([]); + expect(fs.readFileSync(sourceDot, 'utf8')).toBe('synthetic source metadata'); + expect(fs.readFileSync(managedDot, 'utf8')).toBe('synthetic managed metadata'); + } finally { await f.cleanup(); } +}); + +for (const location of ['source', 'managed']) { + test('R2-3 an unknown ordinary profile still conflicts in ' + location, async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const directory = location === 'source' ? f.sourceDirectory + : path.join(f.managedDirectory, f.pluginState().id); + const file = path.join(directory, 'agents', 'foo.md'); + writeText(file, 'Unowned user profile.'); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('agent_profile_conflict'); + expect(fs.readFileSync(file, 'utf8')).toBe('Unowned user profile.'); + } finally { await f.cleanup(); } + }); +} + +test('R2-4 a parent directory alias remains owned during stable routing and disable-reload-remove', async () => { + const f = await fixture({ secondaryModel: secondaryID, aliasedKimiDir: true }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(f.controller.rolesReady).toBe(true); + const before = f.calls.length; + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const lifecycle = f.calls.slice(before).filter(call => + ['kimiPlugin.setPluginEnabled', 'kimiPlugin.reloadActiveSessions', 'kimiPlugin.removePlugin'].includes(call.method)); + expect(lifecycle.map(call => call.method)).toEqual([ + 'kimiPlugin.setPluginEnabled', 'kimiPlugin.reloadActiveSessions', 'kimiPlugin.removePlugin', + ]); + expect(lifecycle[0].params.enabled).toBe(false); + expect(read(f.registryFile).plugins).toEqual([]); + } finally { await f.cleanup(); } +}); + +// --------------------------------------------------------------------------- +// B1: no secondary configured means no companion plugin at all. +// --------------------------------------------------------------------------- + +test('B1 no secondary: V2 connects without installing, enabling or reloading a role plugin', async () => { + const f = await fixture(); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(f.calls.filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + expect(read(f.controller.stateFile).agentPlugin).toBeUndefined(); + expect(f.controller.rolesReady).toBe(false); + } finally { await f.cleanup(); } +}); + +const plainMessages = [{ role: 'system', content: 'synthetic native system prompt' }, + { role: 'user', content: 'synthetic request' }]; +const plainRequest = () => ({ model: 'native', stream: true, messages: plainMessages, tools: [agentTool] }); + +test('B1 no secondary: ordinary Agent tools and schemas pass through unchanged', async () => { + const f = await fixture(); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const result = await chat(f, plainRequest()); + expect(result.status).toBe(200); + const sent = f.requests.at(-1).body; + expect(sent.model).toBe(primaryID); + expect(sent.tools).toEqual([agentTool]); + expect(sent.tools[0].function.parameters.properties.model).toBeUndefined(); + expect(sent.messages).toEqual(plainMessages); + } finally { await f.cleanup(); } +}); + +test('B1 no secondary: supplier responses pass through unchanged', async () => { + const f = await fixture(); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const result = await chat(f, plainRequest()); + expect(result.status).toBe(200); + expect(result.text).toBe(upstreamSSE); + } finally { await f.cleanup(); } +}); + +test('B1 no secondary: a user disconnect reports disconnected without a plugin mutation', async () => { + const f = await fixture(); + try { + await f.controller.tick(); + const mark = f.calls.length; + f.configure({ enabled: false }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + expect(f.methodsSince(mark, 'kimiPlugin.setPluginEnabled')).toEqual([]); + expect(f.methodsSince(mark, 'kimiPlugin.installPlugin')).toEqual([]); + expect(f.methodsSince(mark, 'kimiPlugin.updatePlugin')).toEqual([]); + } finally { await f.cleanup(); } +}); + +test('B1 clearing the configured secondary disables the installed role plugin and stops translation', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + expect(f.pluginEnabled(id)).toBe(true); + const mark = f.calls.length; + f.configure({ secondaryModel: '' }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const disables = f.methodsSince(mark, 'kimiPlugin.setPluginEnabled') + .filter(call => call.params.enabled === false); + expect(disables).toHaveLength(1); + expect(disables[0].params.id).toBe(id); + expect(f.pluginEnabled(id)).toBe(false); + expect(f.controller.rolesReady).toBe(false); + } finally { await f.cleanup(); } +}); + +// --------------------------------------------------------------------------- +// B2: the owned plugin is disabled while idle, re-enabled on opt-in reconnect, +// and a busy runtime defers the disable obligation. +// --------------------------------------------------------------------------- + +test('B2 an idle disconnect tick disables the owned role plugin', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + expect(f.pluginEnabled(id)).toBe(true); + const mark = f.calls.length; + f.configure({ enabled: false }); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + const disables = f.methodsSince(mark, 'kimiPlugin.setPluginEnabled') + .filter(call => call.params.enabled === false); + expect(disables).toHaveLength(1); + expect(f.pluginEnabled(id)).toBe(false); + } finally { await f.cleanup(); } +}); + +test('B2 an idle stop disables the owned role plugin', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + const mark = f.calls.length; + await f.controller.stop(); + expect(f.controller.status.code).toBe('disconnected'); + const disables = f.methodsSince(mark, 'kimiPlugin.setPluginEnabled') + .filter(call => call.params.enabled === false); + expect(disables).toHaveLength(1); + expect(f.pluginEnabled(id)).toBe(false); + } finally { await f.cleanup(); } +}); + +test('B2 a reconnect after an idle opt-out re-enables the role plugin', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + // The user turns the connection off and the idle tick retires the plugin. + f.configure({ enabled: false }); + f.complete(); + await f.controller.tick(); + // The user opts back in; the same owned plugin has to be enabled again. + f.configure({ enabled: true }); + const mark = f.calls.length; + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const enables = f.methodsSince(mark, 'kimiPlugin.setPluginEnabled') + .filter(call => call.params.enabled === true); + expect(enables).toHaveLength(1); + expect(enables[0].params.id).toBe(id); + expect(f.pluginEnabled(id)).toBe(true); + expect(f.controller.rolesReady).toBe(true); + } finally { await f.cleanup(); } +}); + +test('B2 a busy runtime defers the disable obligation to a later idle tick', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + f.setBusy(true); + f.configure({ enabled: false }); + f.complete(); + await f.controller.tick(); + expect(f.calls.filter(call => call.method === 'kimiPlugin.setPluginEnabled' + && call.params.enabled === false)).toEqual([]); + expect(f.pluginEnabled(id)).toBe(true); + // The obligation is durable across the busy turn, not just memory. + expect(read(f.controller.stateFile).agentPlugin.phase).toBe('disabling'); + f.setBusy(false); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + const disables = f.methodsSince(0, 'kimiPlugin.setPluginEnabled') + .filter(call => call.params.enabled === false); + expect(disables).toHaveLength(1); + expect(f.pluginEnabled(id)).toBe(false); + } finally { await f.cleanup(); } +}); + +test('B2 a disconnected start disables an already-owned role plugin', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + expect(f.pluginEnabled(id)).toBe(true); + await f.crash(); + f.configure({ enabled: false }); + const mark = f.calls.length; + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + const disables = f.methodsSince(mark, 'kimiPlugin.setPluginEnabled') + .filter(call => call.params.enabled === false); + expect(disables).toHaveLength(1); + expect(f.pluginEnabled(id)).toBe(false); + } finally { await f.cleanup(); } +}); + +// --------------------------------------------------------------------------- +// B3: a stable opt-in connection confirms health at most once per 30 seconds, +// performs no polling while busy, and tolerates one lost getPluginInfo reply. +// --------------------------------------------------------------------------- + +test('B3 a stable opt-in connection polls getPluginInfo at most once per 30 seconds', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const realNow = Date.now; + let now = realNow.call(Date); + Date.now = () => now; + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const infos = () => f.calls.filter(call => call.method === 'kimiPlugin.getPluginInfo'); + expect(infos()).toHaveLength(1); + for (let second = 0; second < 5; second++) { + now += 1000; + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + } + expect(infos()).toHaveLength(1); + } finally { + Date.now = realNow; + await f.cleanup(); + } +}); + +test('B3 a busy runtime performs no getPluginInfo polling', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const realNow = Date.now; + let now = realNow.call(Date); + Date.now = () => now; + try { + await f.controller.tick(); + const infos = () => f.calls.filter(call => call.method === 'kimiPlugin.getPluginInfo'); + expect(infos()).toHaveLength(1); + f.setBusy(true); + for (let second = 0; second < 3; second++) { + now += 31000; + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + } + expect(infos()).toHaveLength(1); + } finally { + Date.now = realNow; + await f.cleanup(); + } +}); + +test('B3 one getPluginInfo transport failure keeps rolesReady and Agent schema translation', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const realNow = Date.now; + let now = realNow.call(Date); + Date.now = () => now; + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + now += 31000; + f.plugins.fail('kimiPlugin.getPluginInfo'); + f.complete(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(f.controller.rolesReady).toBe(true); + const result = await chat(f, { model: 'native', stream: true, + messages: [{ role: 'user', content: 'synthetic request' }], tools: [agentTool] }); + expect(result.status).toBe(200); + const sent = f.requests.at(-1).body; + expect(sent.tools[0].function.parameters.properties.model.enum) + .toEqual(['primary', 'secondary', 'native']); + } finally { + Date.now = realNow; + await f.cleanup(); + } +}); + +// --------------------------------------------------------------------------- +// S2: an unowned legacy scalar is observation only. Only ownership persisted +// by an earlier adapter may restore and reload. +// --------------------------------------------------------------------------- + +test('S2 an unowned legacy secondary scalar creates no ownership and no restore reload', async () => { + const f = await fixture({ legacyModel: 'native/first' }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + const restoreReloads = f.calls.filter(call => call.method === 'kimiPlugin.reloadActiveSessions' + && call.runtime === 'v2-restore'); + expect(restoreReloads).toHaveLength(0); + expect(f.calls.filter(call => call.method === 'kimiPlugin.ReloadSession' + && call.runtime === 'v2-restore')).toHaveLength(0); + expect(Bun.TOML.parse(f.legacyText()).secondary_model.model).toBe('native/first'); + } finally { await f.cleanup(); } +}); + +test('S2 guard: ownership saved by an earlier adapter still restores its baseline', async () => { + const f = await fixture({ secondaryModel: secondaryID, seedOwnedLegacy: true }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Bun.TOML.parse(f.legacyText()).secondary_model.model).toBe('native/first'); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(f.calls.some(call => call.method === 'kimiPlugin.reloadActiveSessions' + && call.runtime === 'v2-restore')).toBe(true); + } finally { await f.cleanup(); } +}); + +test('S2 an unowned foreign scalar is restored in memory across our own control rewrite', async () => { + const f = await fixture({ secondaryModel: secondaryID, legacyModel: 'native/first', regenerate: 'drop' }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Bun.TOML.parse(f.legacyText()).secondary_model.model).toBe('native/first'); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(f.calls.filter(call => call.method === 'kimiPlugin.reloadActiveSessions' + && call.runtime === 'v2-restore')).toHaveLength(0); + // A stable tick neither re-owns nor rewrites the preserved value. + const commit = f.controller.commitNativeSecondary.bind(f.controller); + let writes = 0; + f.controller.commitNativeSecondary = (...args) => { writes += 1; return commit(...args); }; + f.complete(); + await f.controller.tick(); + expect(writes).toBe(0); + expect(Bun.TOML.parse(f.legacyText()).secondary_model.model).toBe('native/first'); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +test('S2 a non-empty foreign value written during our control call wins over the snapshot', async () => { + const f = await fixture({ secondaryModel: secondaryID, legacyModel: 'native/first', regenerate: 'replace' }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Bun.TOML.parse(f.legacyText()).secondary_model.model).toBe('native/other'); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(f.calls.filter(call => call.method === 'kimiPlugin.reloadActiveSessions' + && call.runtime === 'v2-restore')).toHaveLength(0); + } finally { await f.cleanup(); } +}); + +// --------------------------------------------------------------------------- +// B2 pre-flight: another role-plugin registration/key blocks install/enable. +// --------------------------------------------------------------------------- + +test('B2 another owned role-plugin id blocks a new install before any write or RPC', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + const oldId = 'local-work-model-roles-aaaaaaaaaaaa'; + f.seedRegistration(oldId, { enabled: true }); + const beforeRegistry = f.registryBytes(); + const beforeCalls = f.calls.length; + await f.controller.tick(); + expect(f.controller.status.code).toBe('agent_profile_conflict'); + expect(f.calls.slice(beforeCalls).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + expect(f.registryBytes()).toEqual(beforeRegistry); + expect(fs.existsSync(f.sourceDirectory)).toBe(false); + expect(read(f.registryFile).plugins.find(entry => entry.id === oldId).enabled).toBe(true); + } finally { await f.cleanup(); } +}); + +test('B2 a foreign role-plugin registration blocks install without touching the registry', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + const foreignId = 'local-work-model-roles-bbbbbbbbbbbb'; + f.seedRegistration(foreignId, { enabled: true, originalSource: '/synthetic/other-source' }); + const beforeRegistry = f.registryBytes(); + const beforeCalls = f.calls.length; + await f.controller.tick(); + expect(f.controller.status.code).toBe('agent_profile_conflict'); + expect(f.calls.slice(beforeCalls).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + expect(f.registryBytes()).toEqual(beforeRegistry); + expect(fs.existsSync(f.sourceDirectory)).toBe(false); + } finally { await f.cleanup(); } +}); + +// --------------------------------------------------------------------------- +// B2 synchronous exit: helper failure still owes the pending disable/reload. +// --------------------------------------------------------------------------- + +test('B2 a helper failure on synchronous exit keeps the disable debt and restores the address', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + fs.mkdirSync(path.dirname(f.registryFile), { recursive: true }); + fs.writeFileSync(f.registryFile, 'not a registry ['); + const calls = f.calls.length; + f.controller.restoreFileOnExit(); + expect(read(f.controller.configFile).model.providers['daimon-kimi-code'].baseUrl).toBe(official); + expect(read(f.controller.configFile).model.current).toBe('native'); + expect(read(f.controller.stateFile).agentPlugin.phase).toBe('disabling'); + expect(f.calls).toHaveLength(calls); + expect(fs.readFileSync(f.registryFile, 'utf8')).toBe('not a registry ['); + } finally { await f.cleanup(); } +}); + +test('B2 an already-false registration on synchronous exit does not clear the debt', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + f.seedRegistration(id, { enabled: false }); + f.controller.state.agentPlugin = { ...f.controller.state.agentPlugin, phase: 'disabling' }; + f.controller.saveState(); + const beforeRegistry = f.registryBytes(); + f.controller.restoreFileOnExit(); + expect(read(f.controller.configFile).model.providers['daimon-kimi-code'].baseUrl).toBe(official); + expect(read(f.controller.stateFile).agentPlugin.phase).toBe('disabling'); + expect(f.registryBytes()).toEqual(beforeRegistry); + } finally { await f.cleanup(); } +}); + +// --------------------------------------------------------------------------- +// S2 stable ticks never repair a value erased concurrently outside our RPCs. +// --------------------------------------------------------------------------- + +test('S2 a stable-tick external deletion is not repaired by the foreign snapshot', async () => { + const f = await fixture({ secondaryModel: secondaryID, legacyModel: 'native/first' }); + const realNow = Date.now; + let now = realNow.call(Date); + Date.now = () => now; + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Bun.TOML.parse(f.legacyText()).secondary_model.model).toBe('native/first'); + now += 31000; // let the non-primary health read run on the stable tick + f.complete(); + const peerCall = f.plugins.call.bind(f.plugins); + f.plugins.call = (method, params) => { + if (method === 'kimiPlugin.getPluginInfo') { + const doc = Bun.TOML.parse(fs.readFileSync(f.legacyFile, 'utf8')); + delete doc.secondary_model; + writeText(f.legacyFile, Bun.TOML.stringify(doc)); + } + return peerCall(method, params); + }; + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + // The external delete happened outside our primary/menu RPC boundary. + expect(Bun.TOML.parse(f.legacyText()).secondary_model).toBeUndefined(); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(f.calls.filter(call => call.method === 'kimiPlugin.reloadActiveSessions' + && call.runtime === 'v2-restore')).toHaveLength(0); + } finally { + Date.now = realNow; + await f.cleanup(); + } +}); + +test('review guard: a bridge with rolesReady=false keeps native Agent tools and responses', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + expect(f.controller.rolesReady).toBe(true); + expect(f.controller.bridgeSecondaryModel).toBe(secondaryID); + f.controller.rolesReady = false; + const result = await chat(f, plainRequest()); + expect(result.status).toBe(200); + expect(f.requests.at(-1).body.tools).toEqual([agentTool]); + expect(f.requests.at(-1).body.messages).toEqual(plainMessages); + expect(result.text).toBe(upstreamSSE); + } finally { await f.cleanup(); } +}); + +for (const operation of ['install', 'update']) { + test('review guard: a busy runner waits before role-plugin ' + operation + ' without plugin RPC', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + if (operation === 'update') await f.controller.tick(); + else { + expect(f.controller.ownLock()).toBe(true); + f.controller.catalog = normalizeModels(catalogPayload); + } + f.setBusy(true); + const before = f.calls.length; + const result = await f.controller.syncRolePlugin(read(f.controller.runnerFile), + [primaryID, operation === 'update' ? thirdID : secondaryID]); + expect(result).toEqual({ waiting: true }); + expect(f.calls.slice(before).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + } finally { f.setBusy(false); await f.cleanup(); } + }); +} + +test('B1 a never-opted-in bridge does not create routing ownership on inference', async () => { + const f = await fixture(); + try { + await f.controller.tick(); + expect(read(f.controller.stateFile).agentRoutingSecret).toBeUndefined(); + const result = await chat(f, plainRequest()); + expect(result.status).toBe(200); + expect(result.text).toBe(upstreamSSE); + expect(read(f.controller.stateFile).agentRoutingSecret).toBeUndefined(); + expect(read(f.controller.stateFile).agentRoutingBindings).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +test('B2 profile token bindings are durable before profile publication and plugin RPC', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const link = fs.linkSync; + const peer = f.plugins.call.bind(f.plugins); + let publications = 0, installs = 0; + const durable = token => { + const bindings = read(f.controller.stateFile).agentRoutingBindings; + expect(bindings && typeof bindings).toBe('object'); + expect(Object.values(bindings).sort()).toEqual([primaryID, secondaryID].sort()); + if (token) expect(bindings[token]).toBeDefined(); + }; + fs.linkSync = (staged, file) => { + if (typeof file === 'string' && file.startsWith(path.join(f.sourceDirectory, 'agents') + path.sep)) { + const token = /\[agent-profile-route:v1\]\n([^:\n]+):/.exec(fs.readFileSync(staged, 'utf8'))?.[1]; + expect(token).toBeDefined(); + durable(token); + publications += 1; + } + return link(staged, file); + }; + f.plugins.call = (method, params) => { + if (method === 'kimiPlugin.installPlugin') { durable(); installs += 1; } + return peer(method, params); + }; + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(publications).toBe(2); + expect(installs).toBe(1); + const state = read(f.controller.stateFile); + await f.crash(); + expect(f.controller.ownLock()).toBe(true); + expect(f.controller.ensureAgentRouter().bindings).toEqual(state.agentRoutingBindings); + } finally { fs.linkSync = link; await f.cleanup(); } +}); + +test('B2 an existing router refreshes durable bindings even when its key is unchanged', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const state = read(f.controller.stateFile); + const extended = new AgentModelRouter({ secret: state.agentRoutingSecret, bindings: state.agentRoutingBindings }); + extended.registerModels([thirdID], [primaryID, secondaryID, thirdID]); + f.controller.state.agentRoutingBindings = extended.bindings; + f.controller.saveState(); + expect(f.controller.ensureAgentRouter().bindings).toEqual(extended.bindings); + } finally { await f.cleanup(); } +}); + +test('B2 current-catalog bindings survive role changes and sustained missing targets are pruned on disk', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const realNow = Date.now; + try { + await f.controller.tick(); + const router = f.controller.ensureAgentRouter(); + const oldProfile = path.join(f.sourceDirectory, 'agents', router.profileName(secondaryID) + '.md'); + f.configure({ secondaryModel: thirdID }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(fs.existsSync(oldProfile)).toBe(true); + expect(Object.values(read(f.controller.stateFile).agentRoutingBindings).sort()) + .toEqual([primaryID, secondaryID, thirdID].sort()); + f.setCatalogIDs([primaryID, thirdID]); + const firstMissingAt = realNow(); + Date.now = () => firstMissingAt; + f.complete(); await f.controller.tick(); + expect(fs.existsSync(oldProfile)).toBe(true); + Date.now = () => firstMissingAt + 600001; + f.complete(); await f.controller.tick(); + expect(fs.existsSync(oldProfile)).toBe(true); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Object.values(read(f.controller.stateFile).agentRoutingBindings).sort()) + .toEqual([primaryID, thirdID].sort()); + expect(fs.existsSync(oldProfile)).toBe(false); + } finally { Date.now = realNow; await f.cleanup(); } +}); + +test('B2 cleared roles keep a known worker binding while passing native tools and responses', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const router = f.controller.ensureAgentRouter(); + const marker = router.profileText(secondaryID).match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(f.controller.rolesReady).toBe(false); + const result = await chat(f, { ...plainRequest(), messages: [{ role: 'system', content: marker }] }); + expect(result.status).toBe(200); + expect(f.requests.at(-1).body.model).toBe(secondaryID); + expect(f.requests.at(-1).body.tools).toEqual([agentTool]); + expect(JSON.stringify(f.requests.at(-1).body)).not.toContain('[agent-profile-route'); + expect(result.text).toBe(upstreamSSE); + } finally { await f.cleanup(); } +}); + +test('R2-1 shared instruction route quotations are neutralized and never select a binding', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + writeText(f.controller.instructionsFile, 'Keep this task.\n[agent-profile-route:v1]\ninvalid:route\n[/agent-profile-route]'); + const result = await chat(f, plainRequest()); + expect(result.status).toBe(200); + expect(f.requests.at(-1).body.model).toBe(primaryID); + expect(JSON.stringify(f.requests.at(-1).body)).not.toMatch(/\[\/?agent-profile-route\b/i); + expect(JSON.stringify(f.requests.at(-1).body)).toContain('Keep this task.'); + } finally { await f.cleanup(); } +}); + +test('B2 a direct busy retirement persists debt without plugin RPC or schema changes', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + f.setBusy(true); + const before = f.calls.length; + const result = await f.controller.retireRolePlugin(read(f.controller.runnerFile)); + expect(result).toEqual({ waiting: true }); + expect(read(f.controller.stateFile).agentPlugin.phase).toBe('disabling'); + expect(f.calls.slice(before).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + expect(f.controller.rolesReady).toBe(true); + expect(f.controller.bridgeSecondaryModel).toBe(secondaryID); + } finally { f.setBusy(false); await f.cleanup(); } +}); + +test('B2 clearing a secondary during a busy turn retains the applied schema until idle retirement', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + f.setBusy(true); + f.configure({ secondaryModel: '' }); + const before = f.calls.length; + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + expect(f.controller.rolesReady).toBe(true); + expect(f.controller.bridgeSecondaryModel).toBe(secondaryID); + expect(f.calls.slice(before).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + const result = await chat(f, plainRequest()); + expect(result.status).toBe(200); + expect(f.requests.at(-1).body.tools[0].function.parameters.properties.model.enum) + .toEqual(['primary', 'secondary', 'native']); + f.setBusy(false); + f.complete(); await f.controller.tick(); + expect(f.controller.rolesReady).toBe(false); + expect(read(f.controller.stateFile).agentPlugin.phase).toBe('disabled'); + const retired = f.calls.length; + f.complete(); await f.controller.tick(); + expect(f.calls.slice(retired).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + } finally { f.setBusy(false); await f.cleanup(); } +}); + +test('B2 a lost disable reload acknowledgement keeps debt until explicit retry acknowledgement', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const control = f.controller.rpcCall; + let fail = true; + f.controller.rpcCall = async (runner, method, params, options) => { + if (method === 'kimiPlugin.reloadActiveSessions' && f.controller.secondaryReloadRuntime === 'role-disable' && fail) { + fail = false; + f.calls.push({ method, params, runtime: 'role-disable', failed: true }); + throw Error('kimi_control_timeout'); + } + return control(runner, method, params, options); + }; + f.configure({ enabled: false }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(read(f.controller.stateFile).agentPlugin.phase).toBe('disabling'); + const before = f.calls.length; + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.setPluginEnabled' + && call.params.enabled === false)).toHaveLength(1); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.reloadActiveSessions' + && call.runtime === 'role-disable')).toHaveLength(1); + expect(read(f.controller.stateFile).agentPlugin.phase).toBe('disabled'); + } finally { await f.cleanup(); } +}); + +test('B2 a disk-false pending retirement still sends disable and explicit reload before clearing debt', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + f.seedRegistration(id, { enabled: false }); + f.controller.state.agentPlugin.phase = 'disabling'; + f.controller.saveState(); + const before = f.calls.length; + f.configure({ enabled: false }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.setPluginEnabled' + && call.params.enabled === false)).toHaveLength(1); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.reloadActiveSessions' + && call.runtime === 'role-disable')).toHaveLength(1); + expect(f.pluginEnabled(id)).toBe(false); + expect(read(f.controller.stateFile).agentPlugin.phase).toBe('disabled'); + } finally { await f.cleanup(); } +}); + +test('B3 health confirmation waits the full 30-second minimum interval', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const realNow = Date.now; + let now = realNow.call(Date); + Date.now = () => now; + try { + await f.controller.tick(); + const infos = () => f.calls.filter(call => call.method === 'kimiPlugin.getPluginInfo').length; + expect(infos()).toBe(1); + now += 29999; + f.complete(); await f.controller.tick(); + expect(infos()).toBe(1); + now += 1; + f.complete(); await f.controller.tick(); + expect(infos()).toBe(2); + } finally { Date.now = realNow; await f.cleanup(); } +}); + +for (const failure of ['kimi_control_timeout', 'kimi_control_unavailable', 'kimi_control_closed', 'kimi_control_rejected']) { + test('B3 health keeps confirmed roles after an inconclusive ' + failure, async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const peer = f.plugins.call.bind(f.plugins); + try { + await f.controller.tick(); + f.plugins.call = (method, params) => { + if (method === 'kimiPlugin.getPluginInfo') throw Error(failure); + return peer(method, params); + }; + f.controller.agentPluginNextCheck = 0; + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(f.controller.rolesReady).toBe(true); + const result = await chat(f, plainRequest()); + expect(result.status).toBe(200); + expect(f.requests.at(-1).body.tools[0].function.parameters.properties.model.enum) + .toEqual(['primary', 'secondary', 'native']); + } finally { f.plugins.call = peer; await f.cleanup(); } + }); +} + +test('B3 an idle confirmed-disabled plugin stops role translation without re-enabling it', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + f.plugins.setEnabled(f.pluginState().id, false); + f.controller.agentPluginNextCheck = 0; + const before = f.calls.length; + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_unavailable'); + expect(f.controller.rolesReady).toBe(false); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.setPluginEnabled')).toEqual([]); + const result = await chat(f, plainRequest()); + expect(result.status).toBe(200); + expect(f.requests.at(-1).body.tools).toEqual([agentTool]); + } finally { await f.cleanup(); } +}); + +test('S2 an unowned foreign pool survives only our own control rewrites without ownership or reload', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: 'drop' }); + const pool = { models: { native: 'External native pool' }, default_model: 'native' }; + try { + const doc = Bun.TOML.parse(fs.readFileSync(f.convertedFile, 'utf8')); + doc.secondary_model = pool; + writeText(f.convertedFile, Bun.TOML.stringify(doc)); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Bun.TOML.parse(fs.readFileSync(f.convertedFile, 'utf8')).secondary_model).toEqual(pool); + expect(read(f.controller.stateFile).secondaryPool).toBeUndefined(); + expect(f.calls.filter(call => call.method === 'kimiPlugin.reloadActiveSessions' + && call.runtime === 'v2-restore')).toHaveLength(0); + } finally { await f.cleanup(); } +}); + +test('S2 a non-empty foreign pool written during our control call wins over the snapshot', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: 'replace-pool' }); + try { + const doc = Bun.TOML.parse(fs.readFileSync(f.convertedFile, 'utf8')); + doc.secondary_model = { models: { native: 'External native pool' }, default_model: 'native' }; + writeText(f.convertedFile, Bun.TOML.stringify(doc)); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Bun.TOML.parse(fs.readFileSync(f.convertedFile, 'utf8')).secondary_model) + .toEqual({ models: { 'native-alt': 'New external pool' }, default_model: 'native-alt' }); + expect(read(f.controller.stateFile).secondaryPool).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +test('B2 retry after a failed binding save publishes profiles only after durable recovery', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const link = fs.linkSync; + try { + const save = f.controller.saveState.bind(f.controller); + let failed = false; + f.controller.saveState = () => { + if (!failed && Object.keys(f.controller.state?.agentRoutingBindings || {}).length) { + failed = true; + throw Error('synthetic state-save failure'); + } + return save(); + }; + await f.controller.tick(); + expect(failed).toBe(true); + expect(read(f.controller.stateFile).agentRoutingBindings).toBeUndefined(); + const durable = []; + fs.linkSync = (staged, file) => { + if (typeof file === 'string' && file.startsWith(path.join(f.sourceDirectory, 'agents') + path.sep)) { + const token = /^([A-Za-z0-9_-]{43}):[a-f0-9]{64}$/m.exec(fs.readFileSync(staged, 'utf8'))?.[1]; + durable.push(Boolean(token && Object.hasOwn(read(f.controller.stateFile).agentRoutingBindings || {}, token))); + } + return link(staged, file); + }; + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(durable.length).toBeGreaterThan(0); + expect(durable.every(Boolean)).toBe(true); + } finally { fs.linkSync = link; await f.cleanup(); } +}); + +test('B2 consecutive binding-save failures publish nothing and a restart recovers without profile conflict', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + const save = f.controller.saveState.bind(f.controller); + let failures = 0; + f.controller.saveState = () => { + if (failures < 2 && Object.keys(f.controller.state?.agentRoutingBindings || {}).length) { + failures += 1; + throw Error('synthetic state-save failure'); + } + return save(); + }; + await f.controller.tick(); + f.complete(); await f.controller.tick(); + expect(failures).toBe(2); + expect(read(f.controller.stateFile).agentRoutingBindings).toBeUndefined(); + expect(fs.existsSync(path.join(f.sourceDirectory, 'agents'))).toBe(false); + await f.crash(); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(Object.values(read(f.controller.stateFile).agentRoutingBindings).sort()) + .toEqual([primaryID, secondaryID].sort()); + } finally { await f.cleanup(); } +}); + +test('B2 a confirmed external uninstall completes idle disconnect with reload and no missing-id setter loop', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + f.plugins.call('kimiPlugin.removePlugin', { id }); // Native removal retains the managed cache. + f.configure({ enabled: false }); + const before = f.calls.length; + for (let turn = 0; turn < 3; turn++) { f.complete(); await f.controller.tick(); } + expect(f.controller.status.code).toBe('disconnected'); + expect(f.pluginState().phase).toBe('disabled'); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.setPluginEnabled')).toEqual([]); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.reloadActiveSessions' + && call.runtime === 'role-disable')).toHaveLength(1); + } finally { await f.cleanup(); } +}); + +for (const target of ['scalar', 'pool']) { + test('S2 a real foreign ' + target + ' CAS race preserves competing bytes and reports conflict', async () => { + const f = await fixture({ legacyModel: target === 'scalar' ? 'native/first' : null, regenerate: 'drop' }); + const file = target === 'scalar' ? f.legacyFile : f.convertedFile; + const writeFile = fs.writeFileSync; + try { + if (target === 'pool') { + const doc = Bun.TOML.parse(fs.readFileSync(file, 'utf8')); + doc.secondary_model = { models: { native: 'External pool' }, default_model: 'native' }; + writeText(file, Bun.TOML.stringify(doc)); + } + let raced = false; + fs.writeFileSync = (staged, ...args) => { + const result = writeFile(staged, ...args); + if (!raced && typeof staged === 'string' && staged.startsWith(file + '.') && staged.endsWith('.tmp')) { + raced = true; + writeFile(file, fs.readFileSync(file, 'utf8') + '\n# synthetic concurrent writer\n'); + } + return result; + }; + await f.controller.tick(); + expect(raced).toBe(true); + expect(fs.readFileSync(file, 'utf8')).toContain('# synthetic concurrent writer'); + expect(f.controller.status.code).toBe('kimi_config_changed'); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(fs.readFileSync(file, 'utf8')).toContain('# synthetic concurrent writer'); + } finally { fs.writeFileSync = writeFile; await f.cleanup(); } + }); +} + +for (const action of ['clear', 'disconnect', 'stop']) { + test('B2 normal ' + action + ' removes only the verified registration after disable and reload', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + const unrelated = { id: 'synthetic-unrelated', source: 'catalog', enabled: true, unknown: 'keep' }; + const registry = read(f.registryFile); + registry.plugins.push(unrelated); + write(f.registryFile, registry); + const before = f.calls.length; + if (action === 'stop') await f.controller.stop(); + else { + f.configure(action === 'clear' ? { secondaryModel: '' } : { enabled: false }); + f.complete(); await f.controller.tick(); + } + expect(f.calls.slice(before).filter(call => call.method.startsWith('kimiPlugin.')) + .map(call => call.method)).toEqual([ + 'kimiPlugin.setPluginEnabled', 'kimiPlugin.reloadActiveSessions', 'kimiPlugin.removePlugin', + ]); + expect(read(f.registryFile).plugins).toEqual([unrelated]); + expect(fs.existsSync(f.plugins.installed(id))).toBe(true); + expect(fs.existsSync(f.sourceDirectory)).toBe(true); + expect(f.pluginState().phase).toBe('disabled'); + } finally { await f.cleanup(); } + }); +} + +test('B2 reconnect reinstalls a removed registration even while its managed cache remains', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(read(f.registryFile).plugins.some(row => row.id === id)).toBe(false); + expect(fs.existsSync(f.plugins.installed(id))).toBe(true); + const before = f.calls.length; + f.configure({ secondaryModel: secondaryID }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.installPlugin')).toHaveLength(1); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.updatePlugin')).toHaveLength(0); + expect(read(f.registryFile).plugins.find(row => row.id === id).enabled).toBe(true); + } finally { await f.cleanup(); } +}); + +for (const after of [false, true]) { + test('B2 a lost removal acknowledgement retains debt and recovers (' + (after ? 'after' : 'before') + ' effect)', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const peer = f.plugins.call.bind(f.plugins); + try { + await f.controller.tick(); + let failed = false; + f.plugins.call = (method, params) => { + if (!failed && method === 'kimiPlugin.removePlugin') { + failed = true; + if (after) peer(method, params); + throw Error('kimi_control_timeout'); + } + return peer(method, params); + }; + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(failed).toBe(true); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(f.pluginState().phase).toBe('disabling'); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(f.pluginState().phase).toBe('disabled'); + expect(read(f.registryFile).plugins).toEqual([]); + } finally { f.plugins.call = peer; await f.cleanup(); } + }); +} + +test('B2 a changed registration is never disabled or removed by prefix ownership alone', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + const registry = read(f.registryFile); + registry.plugins.find(row => row.id === id).originalSource = '/synthetic/foreign-source'; + write(f.registryFile, registry); + const bytes = f.registryBytes(), before = f.calls.length; + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('agent_profile_conflict'); + expect(f.calls.slice(before).filter(call => call.method.startsWith('kimiPlugin.'))).toEqual([]); + expect(f.registryBytes()).toEqual(bytes); + expect(f.pluginState().phase).toBe('disabling'); + } finally { await f.cleanup(); } +}); + +test('B2 deleting only the disk registration still disables and removes the loaded native plugin', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + write(f.registryFile, { version: 1, plugins: [] }); + expect(f.pluginEnabled(id)).toBe(true); // No native registry watcher. + const before = f.calls.length; + f.configure({ enabled: false }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('disconnected'); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.setPluginEnabled' + && call.params.enabled === false)).toHaveLength(1); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.removePlugin')).toHaveLength(1); + expect(f.pluginEnabled(id)).toBe(false); + expect(read(f.registryFile).plugins).toEqual([]); + } finally { await f.cleanup(); } +}); + +for (const location of ['source', 'managed']) { +test('B2 missing disk registration with changed ' + location + ' bytes remains an ownership conflict', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const id = f.pluginState().id; + write(f.registryFile, { version: 1, plugins: [] }); + fs.appendFileSync(path.join(location === 'source' ? f.sourceDirectory : f.plugins.installed(id), 'kimi.plugin.json'), '\n'); + const before = f.calls.length; + f.configure({ enabled: false }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('agent_profile_conflict'); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.setPluginEnabled' + || call.method === 'kimiPlugin.removePlugin')).toEqual([]); + expect(f.pluginEnabled(id)).toBe(true); + expect(f.pluginState().phase).toBe('disabling'); + } finally { await f.cleanup(); } +}); +} + +test('B3 only the fixed native missing category clears confirmed roles after a failed health read', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const peer = f.plugins.call.bind(f.plugins); + try { + await f.controller.tick(); + f.plugins.call = (method, params) => { + if (method === 'kimiPlugin.getPluginInfo') throw Error('kimi_plugin_not_found'); + return peer(method, params); + }; + f.controller.agentPluginNextCheck = 0; + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_unavailable'); + expect(f.controller.rolesReady).toBe(false); + } finally { f.plugins.call = peer; await f.cleanup(); } +}); + +for (const reply of ['rejected', 'null', 'wrong-id', 'bad-enabled']) { + test('B2 an inconclusive unregistered native reply retains retirement debt (' + reply + ')', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const peer = f.plugins.call.bind(f.plugins); + try { + await f.controller.tick(); + write(f.registryFile, { version: 1, plugins: [] }); + f.plugins.call = (method, params) => { + if (method !== 'kimiPlugin.getPluginInfo') return peer(method, params); + if (reply === 'rejected') throw Error('kimi_control_rejected'); + if (reply === 'null') return null; + if (reply === 'wrong-id') return { id: 'synthetic-other', enabled: true }; + return { id: params.id }; + }; + const before = f.calls.length; + f.configure({ enabled: false }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(f.pluginState().phase).toBe('disabling'); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.setPluginEnabled' + || call.method === 'kimiPlugin.removePlugin')).toEqual([]); + } finally { f.plugins.call = peer; await f.cleanup(); } + }); +} + +test('B2 a symlinked unregistered managed cache is not followed or claimed', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const managed = f.plugins.installed(f.pluginState().id); + fs.renameSync(managed, managed + '-target'); + fs.symlinkSync(managed + '-target', managed, 'dir'); + write(f.registryFile, { version: 1, plugins: [] }); + const before = f.calls.length; + f.configure({ enabled: false }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('agent_profile_conflict'); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.setPluginEnabled' + || call.method === 'kimiPlugin.removePlugin')).toEqual([]); + } finally { await f.cleanup(); } +}); + +test('B2 retirement waits for a skipped session reload before removing its registration', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const control = f.controller.rpcCall; + let skipped = false; + f.controller.rpcCall = async (runner, method, params, options) => { + if (!skipped && method === 'kimiPlugin.reloadActiveSessions' + && f.controller.secondaryReloadRuntime === 'role-disable') { + skipped = true; + f.calls.push({ method, params, runtime: 'role-disable' }); + return { sessions: [{ kernelSessionId: 'synthetic-busy-session', status: 'skipped-active-turn' }] }; + } + return control(runner, method, params, options); + }; + f.configure({ secondaryModel: '' }); + const before = f.calls.length; + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + expect(f.pluginState().phase).toBe('disabling'); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.removePlugin')).toEqual([]); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(f.pluginState().phase).toBe('disabled'); + expect(read(f.registryFile).plugins).toEqual([]); + } finally { await f.cleanup(); } +}); + +test('B2 provenance is rechecked after reload before removing a registration changed by another writer', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const control = f.controller.rpcCall; + let changed; + f.controller.rpcCall = async (runner, method, params, options) => { + const result = await control(runner, method, params, options); + if (method === 'kimiPlugin.reloadActiveSessions' && f.controller.secondaryReloadRuntime === 'role-disable') { + const registry = read(f.registryFile); + registry.plugins[0].originalSource = '/synthetic/new-owner'; + write(f.registryFile, registry); + changed = f.registryBytes(); + } + return result; + }; + const before = f.calls.length; + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('agent_profile_conflict'); + expect(f.pluginState().phase).toBe('disabling'); + expect(f.calls.slice(before).filter(call => call.method === 'kimiPlugin.removePlugin')).toEqual([]); + expect(f.registryBytes()).toEqual(changed); + } finally { await f.cleanup(); } +}); + +test('B2 a still-loaded plugin with no registration after toggle cannot silently clear retirement debt', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const peer = f.plugins.call.bind(f.plugins); + try { + await f.controller.tick(); + write(f.registryFile, { version: 1, plugins: [] }); + f.plugins.call = (method, params) => method === 'kimiPlugin.setPluginEnabled' ? { ok: true } : peer(method, params); + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('agent_profile_conflict'); + expect(f.pluginState().phase).toBe('disabling'); + expect(f.pluginEnabled(f.pluginState().id)).toBe(true); + } finally { f.plugins.call = peer; await f.cleanup(); } +}); + +for (const staleDisk of [false, true]) { + test('B2 native removal racing the toggle keeps disk and native confirmation distinct (' + staleDisk + ')', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + const control = f.controller.rpcCall; + const beforeRegistry = f.registryBytes(); + let raced = false; + f.controller.rpcCall = async (runner, method, params, options) => { + if (!raced && method === 'kimiPlugin.setPluginEnabled' && params.enabled === false) { + raced = true; + f.plugins.call('kimiPlugin.removePlugin', { id: params.id }); + if (staleDisk) fs.writeFileSync(f.registryFile, beforeRegistry); + throw Error('kimi_plugin_not_found'); + } + return control(runner, method, params, options); + }; + f.configure({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(raced).toBe(true); + expect(f.controller.status.code).toBe(staleDisk ? 'agent_profile_conflict' : 'connected'); + expect(f.pluginState().phase).toBe(staleDisk ? 'disabling' : 'disabled'); + expect(read(f.registryFile).plugins.length).toBe(staleDisk ? 1 : 0); + } finally { await f.cleanup(); } + }); +} + + +// Recovery contract for catalog retirement blocked by temporary ownership conflicts. +for (const [location, change] of [ + ['source', 'unknown'], ['managed', 'unknown'], + ['source', 'edited'], ['managed', 'edited'], ['registry', 'provenance'], +]) { + test('P2 retirement preflight: ' + location + ' ' + change + ' recovers with the original binding', async () => { + const realNow = Date.now; + let now = realNow(); + Date.now = () => now; + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + const router = f.controller.ensureAgentRouter(); + const originalBindings = read(f.controller.stateFile).agentRoutingBindings; + const marker = router.profileText(secondaryID) + .match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; + const filename = router.profileName(secondaryID) + '.md'; + const source = path.join(f.sourceDirectory, 'agents', filename); + const managedDirectory = path.join(f.managedDirectory, f.pluginState().id); + const managed = path.join(managedDirectory, 'agents', filename); + const profileBytes = fs.readFileSync(source); + const firstMissingAt = now; + f.setCatalogIDs([primaryID, thirdID]); + f.complete(); await f.controller.tick(); + now += 1; + f.complete(); await f.controller.tick(); + expect(read(f.controller.stateFile).agentRoutingAbsence[secondaryID]) + .toEqual({ count: 2, firstMissingAt }); + let blocker, restore; + if (change === 'provenance') { + blocker = f.registryFile; + const bytes = fs.readFileSync(blocker); + const document = read(blocker); + document.plugins.find(entry => entry.id === f.pluginState().id).originalSource = f.sourceDirectory + '-foreign'; + write(blocker, document); + restore = () => fs.writeFileSync(blocker, bytes); + } else { + const directory = location === 'source' ? f.sourceDirectory : managedDirectory; + blocker = path.join(directory, 'agents', change === 'unknown' ? 'foo.md' : filename); + if (change === 'unknown') { + writeText(blocker, 'Temporary unowned synthetic profile.'); + restore = () => fs.unlinkSync(blocker); + } else { + const bytes = fs.readFileSync(blocker); + fs.appendFileSync(blocker, '\nTemporary owned profile edit.\n'); + restore = () => fs.writeFileSync(blocker, bytes); + } + } + const blockedBytes = fs.readFileSync(blocker); + now += 600000; + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('agent_profile_conflict'); + expect(fs.readFileSync(blocker)).toEqual(blockedBytes); + const blockedState = read(f.controller.stateFile); + expect(blockedState.agentRoutingAbsence[secondaryID]).toEqual({ count: 3, firstMissingAt }); + expect(blockedState.agentRoutingBindings).toEqual(originalBindings); + restore(); + f.setCatalogIDs([primaryID, secondaryID, thirdID]); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(f.pluginState().phase).toBe('ready'); + expect(read(f.controller.stateFile).agentRoutingBindings).toEqual(originalBindings); + expect(read(f.controller.stateFile).agentRoutingAbsence).not.toHaveProperty(secondaryID); + expect(fs.readFileSync(source)).toEqual(profileBytes); + expect(fs.readFileSync(managed)).toEqual(profileBytes); + const response = await chat(f, { model: 'native', messages: [{ role: 'system', content: marker }] }); + expect(response.status).toBe(200); + expect(f.requests.at(-1).body.model).toBe(secondaryID); + } finally { + Date.now = realNow; + await f.cleanup(); + } + }); +} diff --git a/packages/kimi-desktop/review-plugin-exit.test.mjs b/packages/kimi-desktop/review-plugin-exit.test.mjs new file mode 100644 index 000000000..9fb126335 --- /dev/null +++ b/packages/kimi-desktop/review-plugin-exit.test.mjs @@ -0,0 +1,53 @@ +// Regression evidence for synchronous exit. Every profile is synthetic and +// lives under the temporary home passed explicitly to the controller. +import { test, expect } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { KimiAdapter } from './adapter.mjs'; + +const write = (file, value) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n'); +}; +const read = file => JSON.parse(fs.readFileSync(file, 'utf8')); +const hash = value => crypto.createHash('sha256').update(value).digest('hex'); + +test('synchronous exit disables the owned native plugin registration without RPC', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-plugin-exit-home-')); + let controller; + try { + const calls = []; + controller = new KimiAdapter({ home, stateDir: path.join(home, 'state'), + kimiDir: path.join(home, 'kimi'), rpcCall: (...args) => { calls.push(args); throw Error('unexpected_rpc'); }, + fetchImpl: () => { throw Error('unexpected_fetch'); } }); + write(controller.configFile, { model: { providers: { + 'daimon-kimi-code': { type: 'kimi', baseUrl: 'https://agent-gw.kimi.com/coding/v1' }, + } } }); + expect(controller.bootstrap()).toBe(true); + expect(controller.ownLock()).toBe(true); + const secret = 'synthetic-routing-secret-used-only-in-temporary-fixture'; + const id = 'local-work-model-roles-' + hash(secret).slice(0, 12); + const source = path.join(controller.stateDir, 'native-model-roles'); + const manifest = { name: id, version: '1.0.0', description: 'Local Work model roles', agents: ['./agents'] }; + write(path.join(source, 'kimi.plugin.json'), manifest); + const registryFile = path.join(path.dirname(controller.convertedNativeConfigFile), 'plugins', 'installed.json'); + const root = path.join(path.dirname(registryFile), 'managed', id); + fs.cpSync(source, root, { recursive: true }); + write(registryFile, { version: 1, plugins: [{ id, root, source: 'local-path', originalSource: source, + enabled: true, origin: 'custom', installedAt: 'synthetic-date' }] }); + controller.state = { active: false, agentRoutingSecret: secret, agentPlugin: { id, phase: 'ready', + files: { 'kimi.plugin.json': hash(fs.readFileSync(path.join(source, 'kimi.plugin.json'))) } } }; + controller.saveState(); + + controller.restoreFileOnExit(); + + expect(read(registryFile).plugins[0].enabled).toBe(false); + expect(calls).toEqual([]); + expect(fs.existsSync(path.join(root, 'kimi.plugin.json'))).toBe(true); + } finally { + controller?.releaseLock(); + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/packages/kimi-desktop/review-protocol.test.mjs b/packages/kimi-desktop/review-protocol.test.mjs new file mode 100644 index 000000000..02c619533 --- /dev/null +++ b/packages/kimi-desktop/review-protocol.test.mjs @@ -0,0 +1,393 @@ +import { test, expect } from 'bun:test'; +import { AgentModelRouter } from './agent-routing.mjs'; + +// Synthetic-only reproduction suite. It never starts a server, never reads a +// real credential, and only exercises the in-process router API. +const secret = 'a3'.repeat(32); +const otherSecret = 'b4'.repeat(32); +const models = { primaryModel: 'synthetic/primary', secondaryModel: 'synthetic/secondary' }; +const allowedModels = Object.values(models); +const options = { ...models, allowedModels }; +const router = (value = secret) => new AgentModelRouter({ secret: value }); +const args = (extra = {}) => ({ prompt: 'Read magpie-local-0123456789abcdef01234567.txt,返回文件。', + description: 'Read one test file', ...extra }); +const user = content => ({ role: 'user', content }); +const system = content => ({ role: 'system', content }); +const developer = content => ({ role: 'developer', content }); +const call = (parameters, name = 'Agent', id = 'call-1') => ({ id, type: 'function', function: { + name, arguments: typeof parameters === 'string' ? parameters : JSON.stringify(parameters), +} }); +const completion = (calls, extra = {}) => ({ id: 'completion-1', object: 'chat.completion', + choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: calls }, finish_reason: 'tool_calls' }], + ...extra }); +const prepare = (r, messages, configuration = options, extra = {}) => + r.prepare({ model: 'native/current', messages, ...extra }, configuration); +const translated = (r, parameters, configuration = models) => + JSON.parse(r.transformJSON(completion([call(parameters)]), configuration) + .choices[0].message.tool_calls[0].function.arguments); +const profileBody = (r, model = models.secondaryModel) => r.profileText(model).split('\n---\n')[1] + .replace('${base_prompt}', 'Native base system prompt.'); +const section = (r, model = models.secondaryModel) => + profileBody(r, model).match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; +const tokenOf = marker => { + const match = /\[agent-profile-route:v1\]\r?\n([^\r\n]+)\r?\n\[\/agent-profile-route\]/.exec(marker); + return match ? match[1].slice(0, match[1].lastIndexOf(':')) : ''; +}; +const event = (value, { crlf = false, multiline = false, metadata = '' } = {}) => + (metadata + JSON.stringify(value, null, multiline ? 2 : undefined).split('\n').map(line => 'data: ' + line).join('\n') + + '\n\n').replace(/\n/g, crlf ? '\r\n' : '\n'); +const chunk = (choices, extra = {}) => ({ id: 'stream-1', object: 'chat.completion.chunk', + model: 'wire/model', created: 123, choices, ...extra }); +const choice = (index, delta, finish_reason = null) => ({ index, delta, finish_reason }); +async function* bytes(text, size = 37) { + const encoded = Buffer.from(text); + for (let index = 0; index < encoded.length; index += size) yield encoded.subarray(index, index + size); +} +async function readStream(r, text, size = 37) { + let result = ''; + for await (const part of r.transformSSE(bytes(text, size), models)) result += part; + return result; +} +async function fulfilled(promise) { + const outcome = await promise.then(value => ({ value }), error => ({ error })); + expect(outcome).toHaveProperty('value'); + return outcome.value; +} +function events(text) { + return text.split(/\r\n\r\n|\n\n/).flatMap(frame => { + const data = frame.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).replace(/^ /, '')).join('\n'); + return !data || data.trim() === '[DONE]' ? [] : [JSON.parse(data)]; + }); +} +function streamCalls(text) { + return events(text).flatMap(value => value.choices || []).flatMap(value => value.delta?.tool_calls || []); +} +const readTool = marker => ({ type: 'function', function: { name: 'Read', + description: 'Synthetic reader.\n' + marker, + parameters: { type: 'object', properties: { + file_path: { type: 'string', description: 'Synthetic path. ' + marker }, + }, required: ['file_path'] } } }); +function markerMessages(marker) { + return { + messages: [ + user('Before\n' + marker + '\nAfter'), + user([{ type: 'text', text: marker }, { type: 'text', text: 'Plain part.' }]), + { role: 'tool', tool_call_id: 'read-1', content: marker }, + { role: 'assistant', content: marker }, + { role: 'assistant', content: null, tool_calls: [{ id: 'read-1', type: 'function', + function: { name: 'Read', arguments: JSON.stringify({ file_path: marker }) } }] }, + ], + tools: [readTool(marker)], + }; +} + +// B1: an omitted model with an explicit native subagent_type is native intent. +test('B1: an Agent call without model keeps an explicit native subagent_type (JSON)', () => { + const r = router(); + const output = translated(r, args({ subagent_type: 'explore' })); + expect(output.subagent_type).toBe('explore'); + expect(output).not.toHaveProperty('model'); + expect(output.prompt).toBe(args().prompt); + expect(output.description).toBe(args().description); +}); + +test('B1: a streamed Agent call without model keeps an explicit native subagent_type', async () => { + const r = router(); + const input = JSON.stringify(args({ subagent_type: 'explore' })); + const stream = event(chunk([choice(0, { tool_calls: [{ index: 0, id: 'agent-explore', type: 'function', + function: { name: 'Agent', arguments: input } }] })])) + + event(chunk([choice(0, {}, 'tool_calls')])) + + 'data: [DONE]\n\n'; + const output = await readStream(r, stream); + const calls = streamCalls(output); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0].function.arguments).subagent_type).toBe('explore'); +}); + +// B2: profileText must be opaque: no model ID, no reversible base64/base64url. +test('B2: profileText never exposes the model ID or its base64/base64url forms', () => { + const r = router(); + const model = 'vendor/review-private-target'; + const text = r.profileText(model); + const marker = section(r, model); + const token = tokenOf(marker); + const base64 = Buffer.from(model).toString('base64'); + const base64url = Buffer.from(model).toString('base64url'); + expect(marker).toContain('[agent-profile-route:v1]'); + expect(token).not.toBe(''); + expect(text).not.toContain(model); + expect(text).not.toContain(base64); + expect(text).not.toContain(base64url); + // The current token is the model ID encoded; it must not decode to it. + expect(Buffer.from(token, 'base64url').toString('utf8')).not.toBe(model); +}); + +test('B2: profileName is key-bound, so different secrets never share a name', () => { + const one = router(secret), two = router(otherSecret), again = router(secret); + for (const model of [models.primaryModel, models.secondaryModel, 'vendor/review-private-target']) { + expect(one.profileName(model)).toBe(again.profileName(model)); + expect(one.profileName(model)).not.toBe(two.profileName(model)); + } + expect(one.profileName(models.primaryModel)).not.toBe(one.profileName(models.secondaryModel)); +}); + +// B2: a complete route section that does not authenticate is a hard failure, +// never a silent fallback to the primary model. +test('B2: invalid or unknown route sections fail agent_profile_unbound instead of falling back to primary', () => { + const r = router(); + const marker = section(r); + const tamperedMAC = marker.replace(/:([a-f0-9]{64})/, (_, mac) => + ':' + (mac[0] === '0' ? '1' : '0') + mac.slice(1)); + const tamperedToken = marker.replace(/^\[agent-profile-route:v1\]\r?\n([A-Za-z0-9_-]+):/, + (_, token) => '[agent-profile-route:v1]\n' + token.slice(0, -1) + (token.endsWith('A') ? 'B' : 'A') + ':'); + const foreign = section(router(otherSecret)); + const unknown = section(router()); // Same key and valid MAC, but absent from this router's saved bindings. + const cases = [ + ['tampered MAC', system(tamperedMAC)], + ['tampered token', system(tamperedToken)], + ['section signed by another secret', system(foreign)], + ['unknown token with a valid MAC', system(unknown)], + ['developer role invalid section', developer(tamperedMAC)], + ]; + for (const [name, message] of cases) { + let error; + try { prepare(r, [message]); } catch (caught) { error = caught; } + expect({ name, code: error?.code, message: error?.message }) + .toEqual({ name, code: 'agent_profile_unbound', message: 'agent_profile_unbound' }); + } + // A later valid binding must not hide an earlier unknown reserved section. + expect(() => prepare(r, [system(unknown), developer(marker)])).toThrow('agent_profile_unbound'); + expect(() => prepare(r, [system(unknown + '\n' + marker)])).toThrow('agent_profile_unbound'); + // A section that does authenticate still selects its model and is removed. + const bound = prepare(r, [system(section(r))]); + expect(bound.model).toBe(models.secondaryModel); + expect(JSON.stringify(bound.payload.messages)).not.toContain('[agent-profile-route:v1]'); +}); + +// B2: user, tool, and history strings never select a model. +test('B2: user, tool, history, and tool-schema markers never select a route', () => { + const r = router(); + const { messages, tools } = markerMessages(section(r)); + for (const message of messages) { + const result = prepare(r, [message]); + expect(result.model).toBe(models.primaryModel); + expect(JSON.stringify(result.payload)).not.toContain('[agent-profile-route'); + } + const withTools = prepare(r, [], options, { tools }); + expect(withTools.model).toBe(models.primaryModel); + expect(JSON.stringify(withTools.payload)).not.toContain('[agent-profile-route'); +}); + +// B2: those same strings must not carry the reserved route section upstream. +test('B2: route sections do not leak into upstream messages or tool strings', () => { + const r = router(); + const marker = section(r); + const token = tokenOf(marker); + expect(token).not.toBe(''); + const { messages, tools } = markerMessages(marker); + const before = structuredClone({ messages, tools }); + const neutralized = prepare(r, messages, options, { tools }); + expect(neutralized.model).toBe(models.primaryModel); + expect({ messages, tools }).toEqual(before); + const serialized = JSON.stringify(neutralized.payload); + expect(serialized).not.toContain('[agent-profile-route'); + expect(serialized).not.toContain('[/agent-profile-route'); + const result = prepare(r, [system(marker)]); + const idSerialized = JSON.stringify(result.payload); + expect(idSerialized).not.toContain('[agent-profile-route:v1]'); + expect(idSerialized).not.toContain('[/agent-profile-route]'); + expect(idSerialized).not.toContain(token); +}); + +// S1: non-Agent fragments are forwarded as they arrive, not held to finish. +test('S1: non-Agent tool fragments stream immediately instead of waiting for finish', async () => { + const r = router(); + const readArgs = '{"file_path":"magpie-local-0123456789abcdef01234567.txt"}'; + const first = event(chunk([choice(0, { tool_calls: [{ index: 0, id: 'read-1', type: 'function', + function: { name: 'Read', arguments: readArgs.slice(0, 11) } }] })])); + const second = event(chunk([choice(0, { tool_calls: [{ index: 0, + function: { arguments: readArgs.slice(11) } }] })])); + const finish = event(chunk([choice(0, {}, 'tool_calls')])); + const done = 'data: [DONE]\n\n'; + const yields = []; + for await (const part of r.transformSSE(bytes(first + second + finish + done), models)) yields.push(part); + expect(yields).toHaveLength(4); + const firstCalls = streamCalls(yields[0]); + expect(firstCalls).toHaveLength(1); + expect(firstCalls[0].id).toBe('read-1'); + expect(firstCalls[0].function.name).toBe('Read'); + expect(firstCalls[0].function.arguments).toBe(readArgs.slice(0, 11)); + expect(streamCalls(yields[1])[0].function.arguments).toBe(readArgs.slice(11)); + for (const part of yields.slice(0, 3)) expect(part).not.toContain('[DONE]'); + expect(yields[3]).toBe(done); +}); + +// S1: native validation, not the transformer, rejects invalid Agent arguments. +test('S1: invalid Agent JSON arguments pass through to native validation and DONE completes', async () => { + const r = router(); + const invalid = '{"prompt":"half'; + const first = event(chunk([choice(0, { tool_calls: [{ index: 0, id: 'agent-bad', type: 'function', + function: { name: 'Agent', arguments: invalid.slice(0, 5) } }] })])); + const second = event(chunk([choice(0, { tool_calls: [{ index: 0, + function: { arguments: invalid.slice(5) } }] })])); + const finish = event(chunk([choice(0, {}, 'tool_calls')])); + const done = 'data: [DONE]\n\n'; + const read = readStream(r, first + second + finish + done); + const output = await fulfilled(read); + expect(output).toEndWith(done); + const calls = streamCalls(output); + expect(calls).toHaveLength(1); + expect(calls[0].function.name).toBe('Agent'); + expect(calls[0].function.arguments).toBe(invalid); +}); + +test('S1: an invalid Agent model value passes through to native validation and DONE completes', async () => { + const r = router(); + const input = JSON.stringify({ prompt: 'Read magpie-local-0123456789abcdef01234567.txt', + description: 'Read one test file', model: 'unknown/model' }); + const stream = event(chunk([choice(0, { tool_calls: [{ index: 0, id: 'agent-model', type: 'function', + function: { name: 'Agent', arguments: input } }] })])) + + event(chunk([choice(0, {}, 'tool_calls')])) + + 'data: [DONE]\n\n'; + const done = 'data: [DONE]\n\n'; + const read = readStream(r, stream); + const output = await fulfilled(read); + expect(output).toEndWith(done); + const calls = streamCalls(output); + expect(calls).toHaveLength(1); + expect(calls[0].function.arguments).toBe(input); + expect(JSON.parse(calls[0].function.arguments).model).toBe('unknown/model'); +}); + +// S1: a missing fragment index is a native-shape detail, not a stream failure. +test('S1: a non-Agent tool fragment without index does not destroy the stream', async () => { + const r = router(); + const readArgs = '{"file_path":"magpie-local-0123456789abcdef01234567.txt"}'; + const first = event(chunk([choice(0, { tool_calls: [{ id: 'read-1', type: 'function', + function: { name: 'Read', arguments: readArgs.slice(0, 7) } }] })])); + const finish = event(chunk([choice(0, {}, 'tool_calls')])); + const done = 'data: [DONE]\n\n'; + const yields = []; + const read = (async () => { + for await (const part of r.transformSSE(bytes(first + finish + done), models)) yields.push(part); + return yields; + })(); + expect(await fulfilled(read)).toHaveLength(3); + const calls = streamCalls(yields[0]); + expect(calls).toHaveLength(1); + expect(calls[0].id).toBe('read-1'); + expect(calls[0].function.name).toBe('Read'); + expect(calls[0].function.arguments).toBe(readArgs.slice(0, 7)); + expect(yields[2]).toBe(done); +}); + +test('S1: an Agent fragment without index does not destroy the stream', async () => { + const r = router(); + const content = event(chunk([choice(0, { content: 'visible before the call' })])); + const fragment = event(chunk([choice(0, { tool_calls: [{ id: 'agent-no-index', type: 'function', + function: { name: 'Agent', arguments: JSON.stringify(args()) } }] })])); + const finish = event(chunk([choice(0, {}, 'tool_calls')])); + const done = 'data: [DONE]\n\n'; + const read = readStream(r, content + fragment + finish + done); + const output = await fulfilled(read); + expect(output).toContain('visible before the call'); + expect(output).toEndWith(done); +}); + +// S1: DONE without a finish_reason still flushes buffered Agent calls. +test('S1: a stream ending at DONE without finish_reason flushes Agent calls with tool_calls semantics', async () => { + const r = router(); + const input = JSON.stringify(args()); + const first = event(chunk([choice(0, { tool_calls: [{ index: 0, id: 'agent-1', type: 'function', + function: { name: 'Agent', arguments: input.slice(0, 9) } }] })])); + const second = event(chunk([choice(0, { tool_calls: [{ index: 0, + function: { arguments: input.slice(9) } }] })])); + const done = 'data: [DONE]\n\n'; + const read = readStream(r, first + second + done); + const output = await fulfilled(read); + expect(output).toEndWith(done); + const flushed = events(output).flatMap(value => value.choices || []) + .find(value => value.delta?.tool_calls?.length); + expect(flushed).toBeTruthy(); + expect(flushed.finish_reason).toBe('tool_calls'); + const calls = streamCalls(output); + expect(calls).toHaveLength(1); + expect(calls[0].function.name).toBe('Agent'); + expect(JSON.parse(calls[0].function.arguments).subagent_type).toBe(r.profileName(models.secondaryModel)); +}); + +// S1 regression: existing multi-choice, UTF-8, CRLF, and native result-link +// behavior must keep working while the streaming rules above change. +test('S1 regression: multi-choice UTF-8 CRLF streams keep native tool IDs linked to results', async () => { + const r = router(); + const primary = { prompt: '读取中文文件。', description: '合成主要任务', model: 'primary' }; + const secondary = { prompt: '第二个任务。', description: '合成次要任务' }; + const readArgs = '{"file_path":"magpie-local-0123456789abcdef01234567.txt"}'; + const stream = event(chunk([choice(0, { role: 'assistant', content: '开头中文' })]), { crlf: true }) + + event(chunk([ + choice(0, { tool_calls: [{ index: 0, id: 'agent-primary', type: 'function', + function: { name: 'Agent', arguments: JSON.stringify(primary) } }] }), + choice(1, { tool_calls: [{ index: 0, id: 'agent-secondary', type: 'function', + function: { name: 'Agent', arguments: JSON.stringify(secondary) } }] }), + ]), { crlf: true, multiline: true }) + + event(chunk([choice(0, { tool_calls: [{ index: 1, id: 'read-1', type: 'function', + function: { name: 'Read', arguments: readArgs } }] })]), { crlf: true }) + + event(chunk([choice(0, {}, 'tool_calls'), choice(1, {}, 'tool_calls')]), { crlf: true }) + + 'data: [DONE]\r\n\r\n'; + const output = await readStream(r, stream, 1); + expect(output).toEndWith('data: [DONE]\r\n\r\n'); + const parsed = events(output); + expect(parsed.flatMap(value => value.choices || []).map(value => value.delta?.content || '').join('')) + .toContain('开头中文'); + const calls = streamCalls(output); + const primaryCall = calls.find(value => value.function?.name === 'Agent' + && JSON.parse(value.function.arguments).prompt === primary.prompt); + const secondaryCall = calls.find(value => value.function?.name === 'Agent' + && JSON.parse(value.function.arguments).prompt === secondary.prompt); + const nativeCall = calls.find(value => value.id === 'read-1'); + expect(primaryCall).toBeTruthy(); + expect(secondaryCall).toBeTruthy(); + expect(nativeCall).toBeTruthy(); + expect(JSON.parse(primaryCall.function.arguments).subagent_type).toBe(r.profileName(models.primaryModel)); + expect(JSON.parse(secondaryCall.function.arguments).subagent_type).toBe(r.profileName(models.secondaryModel)); + expect(nativeCall.function.arguments).toBe(readArgs); + expect(parsed.flatMap(value => value.choices || []).filter(value => value.finish_reason) + .map(value => value.finish_reason)).toEqual(['tool_calls', 'tool_calls']); + const results = [{ role: 'tool', tool_call_id: primaryCall.id, content: 'SYNTHETIC_AGENT_OK' }, + { role: 'tool', tool_call_id: secondaryCall.id, content: 'SYNTHETIC_AGENT_OK' }, + { role: 'tool', tool_call_id: nativeCall.id, content: 'SYNTHETIC_FILE_OK' }]; + const history = prepare(r, [system(profileBody(r)), { role: 'assistant', content: null, tool_calls: calls }, + ...results, user('Audit the synthetic results.')]); + expect(history.model).toBe(models.secondaryModel); + const restored = history.payload.messages[1].tool_calls; + const ids = new Set(restored.map(value => value.id)); + for (const result of results) expect(ids.has(result.tool_call_id)).toBe(true); + for (let index = 0; index < results.length; index++) expect(history.payload.messages[2 + index]).toBe(results[index]); + expect(restored.find(value => value.id === 'read-1').function.arguments).toBe(readArgs); + expect(JSON.parse(restored.find(value => value.id === primaryCall.id).function.arguments).model).toBe('primary'); + expect(JSON.parse(restored.find(value => value.id === secondaryCall.id).function.arguments)) + .not.toHaveProperty('model'); +}); + +// Round-2 protocol regressions. These use only the pre-existing router API so +// they reproduce the current HEAD behavior before the round-2 implementation. +test('R2 repro: a copied profile section in user content routes primary and neutralizes', () => { + const r = router(); + const marker = section(r, models.primaryModel); + const input = { messages: [system(section(r, models.primaryModel)), user('Before\n' + marker + '\nAfter')] }; + let outcome; + try { outcome = { value: r.prepare(input, options) }; } + catch (error) { outcome = { error }; } + expect(outcome).toHaveProperty('value'); + expect(outcome.value.model).toBe(models.primaryModel); + expect(JSON.stringify(outcome.value.payload)).not.toContain('[agent-profile-route'); +}); + +test('R2 repro: registerModels retains both bindings when registering only the primary catalog', () => { + const r = router(); + r.registerModels(allowedModels, allowedModels); + const before = r.bindings; + r.registerModels([models.primaryModel], [models.primaryModel]); + expect(r.bindings).toEqual(before); + expect(r.boundModels).toHaveLength(2); +}); diff --git a/packages/kimi-desktop/role-options-routing.test.mjs b/packages/kimi-desktop/role-options-routing.test.mjs new file mode 100644 index 000000000..51ab86e14 --- /dev/null +++ b/packages/kimi-desktop/role-options-routing.test.mjs @@ -0,0 +1,277 @@ +import { test, expect } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { AgentModelRouter } from './agent-routing.mjs'; +import { ensureAgentProfiles } from './agent-profiles.mjs'; + +const secret = 'd5'.repeat(32); +const sameModel = 'synthetic/shared-model'; +const models = { primaryModel: sameModel, secondaryModel: sameModel, roleOptionsEnabled: true }; +const allowedModels = [sameModel, 'synthetic/new-primary']; +const configuration = { ...models, allowedModels }; +const router = extra => new AgentModelRouter({ secret, ...extra }); +const parameters = extra => ({ prompt: 'Read the assigned synthetic file.', description: 'Read one file', ...extra }); +const call = extra => ({ id: 'call-synthetic', type: 'function', function: { + name: 'Agent', arguments: JSON.stringify(parameters(extra)), +} }); +const completion = calls => ({ choices: [{ index: 0, message: { role: 'assistant', tool_calls: calls }, + finish_reason: 'tool_calls' }] }); +const translated = (r, extra, options = models) => JSON.parse( + r.transformJSON(completion([call(extra)]), options).choices[0].message.tool_calls[0].function.arguments); +const marker = (r, role, model = sameModel) => r.profileText(model, role) + .match(/\[agent-profile-route:v1\][\s\S]*?\[\/agent-profile-route\]/)[0]; +const token = text => text.match(/\n([A-Za-z0-9_-]{43}):[a-f0-9]{64}\n/)[1]; +const system = content => ({ role: 'system', content }); +const prepare = (r, messages, options = configuration) => r.prepare({ messages }, options); +const snapshot = r => ({ bindings: r.bindings, roles: r.roles, absence: r.absence }); + +async function streamed(r, extra, options = models) { + const full = { ...call(extra), index: 0 }; + const fragments = [ + { ...full, function: { name: 'Ag', arguments: full.function.arguments.slice(0, 17) } }, + { index: 0, function: { name: 'ent', arguments: full.function.arguments.slice(17) } }, + ]; + const frames = fragments.map((fragment, index) => 'data: ' + JSON.stringify({ choices: [{ index: 0, + delta: { tool_calls: [fragment] }, finish_reason: index ? 'tool_calls' : null }] }) + '\n\n') + .join('') + 'data: [DONE]\n\n'; + async function* bytes() { + for (let index = 0; index < frames.length; index += 19) yield Buffer.from(frames.slice(index, index + 19)); + } + let output = ''; + for await (const part of r.transformSSE(bytes(), options)) output += part; + const calls = output.split('\n\n').filter(frame => frame.startsWith('data: ') && !frame.includes('[DONE]')) + .flatMap(frame => JSON.parse(frame.slice(6)).choices || []).flatMap(choice => choice.delta?.tool_calls || []); + expect(calls).toHaveLength(1); + return JSON.parse(calls[0].function.arguments); +} + +test('same actual model has distinct opaque primary and secondary profile names and tokens', () => { + const r = router(); + // First RED assertion also fails against the old API without relying on a missing method. + expect(r.profileName(sameModel, 'primary')).not.toBe(r.profileName(sameModel, 'secondary')); + const registered = r.registerRoles(models, allowedModels); + const primary = r.profileText(sameModel, 'primary'), secondary = r.profileText(sameModel, 'secondary'); + expect(token(primary)).not.toBe(token(secondary)); + expect(registered).toEqual({ primary: token(primary), secondary: token(secondary) }); + expect(r.roles).toEqual({ [token(primary)]: 'primary', [token(secondary)]: 'secondary' }); + expect(r.bindings).toEqual({ [token(primary)]: sameModel, [token(secondary)]: sameModel }); + expect(r.boundModels).toEqual([sameModel]); + expect(r.boundProfiles).toEqual([{ model: sameModel, role: 'primary' }, { model: sameModel, role: 'secondary' }]); + for (const text of [primary, secondary]) { + expect(text).not.toContain(sameModel); + expect(text).not.toContain(Buffer.from(sameModel).toString('base64')); + expect(text).not.toMatch(/magpie|provider|primary|secondary/i); + expect(text).not.toContain(secret); + expect(text.split('\n')[1]).toMatch(/^name: local-agent-[a-f0-9]{12}-[a-f0-9]{24}$/); + } + expect(r.registerRoles(models, allowedModels)).toEqual(registered); +}); + +test('signed scoped system and developer identities return the actual model and authenticated route role', () => { + const r = router(); + r.registerRoles(models, allowedModels); + for (const role of ['primary', 'secondary']) { + for (const identity of ['system', 'developer']) { + for (const content of [marker(r, role), [{ type: 'text', text: marker(r, role) }]]) { + const result = prepare(r, [{ role: identity, content }], { ...configuration, primaryModel: allowedModels[1] }); + expect(result.model).toBe(sameModel); + expect(result.payload.model).toBe(sameModel); + expect(result.routeRole).toBe(role); + expect(JSON.stringify(result.payload)).not.toContain('[agent-profile-route'); + } + } + } + expect(prepare(r, [system(marker(r, 'secondary')), system(marker(r, 'primary'))]).routeRole).toBe('primary'); + expect(prepare(r, [system(marker(r, 'secondary')), system(marker(r))])).not.toHaveProperty('routeRole'); + expect(() => prepare(r, [system(marker(r, 'secondary'))], { ...configuration, allowedModels: [allowedModels[1]] })) + .toThrow('agent_profile_unbound'); +}); + +test('forged, quoted, and nonidentity scoped markers never select a role', () => { + const r = router(), text = marker(r, 'secondary'); + const tampered = text.replace(/:([a-f0-9]{64})\n/, (_, mac) => ':' + (mac[0] === '0' ? '1' : '0') + mac.slice(1) + '\n'); + const unknown = marker(router(), 'secondary'); + for (const forged of [tampered, unknown, 'Quoted marker: ' + text, JSON.stringify(text)]) { + expect(() => prepare(r, [system(forged)])).toThrow('agent_profile_unbound'); + } + for (const role of ['user', 'assistant', 'tool']) { + for (const content of [text, '```quoted\n' + text + '\n```', [{ type: 'text', text }]]) { + const result = prepare(r, [{ role, content }], { ...configuration, primaryModel: allowedModels[1] }); + expect(result.model).toBe(allowedModels[1]); + expect(result).not.toHaveProperty('routeRole'); + expect(JSON.stringify(result.payload)).not.toContain('[agent-profile-route'); + } + } + const historical = { role: 'assistant', tool_calls: [call({ model: 'secondary', prompt: text, + subagent_type: r.profileName(sameModel, 'secondary') })] }; + expect(prepare(r, [historical])).not.toHaveProperty('routeRole'); + expect(prepare(r, [system([{ type: 'image_url', image_url: { url: text } }])])).not.toHaveProperty('routeRole'); +}); + +test('scoped roles persist through router reconstruction, selection changes, and compaction', () => { + const original = router(); + original.registerRoles(models, allowedModels); + const old = snapshot(original), primary = original.profileText(sameModel, 'primary'), secondary = original.profileText(sameModel, 'secondary'); + const restarted = router(old); + old.roles[token(primary)] = 'secondary'; + old.bindings[token(primary)] = allowedModels[1]; + const exposed = restarted.roles; delete exposed[token(secondary)]; + expect(restarted.profileText(sameModel, 'primary')).toBe(primary); + expect(restarted.profileText(sameModel, 'secondary')).toBe(secondary); + restarted.registerRoles({ primaryModel: allowedModels[1], secondaryModel: sameModel }, allowedModels); + for (const history of [[], [{ role: 'user', content: 'Only a compacted summary remains.' }]]) { + const result = prepare(restarted, [system(marker(restarted, 'primary')), ...history], + { ...configuration, primaryModel: allowedModels[1] }); + expect(result.model).toBe(sameModel); + expect(result.routeRole).toBe('primary'); + expect(result.payload.messages.slice(1)).toEqual(history); + } + expect(restarted.boundProfiles).toHaveLength(3); +}); + +test('role maps and registrations reject invalid state before allocating or modifying bindings', () => { + const r = router(), one = token(marker(r, 'primary')), state = snapshot(r); + for (const roles of [null, [], { bad: 'primary' }, { [one]: 'native' }, { ['A'.repeat(43)]: 'primary' }]) { + expect(() => router({ bindings: state.bindings, roles })).toThrow('agent_profile_unbound'); + } + for (const selection of [null, [], Object.assign([], models), {}, { primaryModel: '' }, + { ...models, primaryModel: 'synthetic/hidden' }, { ...models, secondaryModel: 'synthetic/hidden' }]) { + expect(() => r.registerRoles(selection, allowedModels)).toThrow('model_not_enabled_for_kimi'); + expect(snapshot(r)).toEqual(state); + } + for (const allowed of [null, sameModel]) { + expect(() => r.registerRoles(models, allowed)).toThrow('model_not_enabled_for_kimi'); + } + for (const role of ['primaryModel', 'secondaryModel']) { + const invalid = 'synthetic/invalid\nmodel'; + expect(() => r.registerRoles({ ...models, [role]: invalid }, [...allowedModels, invalid])) + .toThrow('model_not_enabled_for_kimi'); + } + expect(snapshot(r)).toEqual(state); + for (const role of ['native', '', null, 0]) { + expect(() => r.profileText(sameModel, role)).toThrow('model_not_enabled_for_kimi'); + expect(snapshot(r)).toEqual(state); + } +}); + +test('catalog absence grace retains both roles and retirement removes role metadata with every token', () => { + const r = router(); + r.registerModels([sameModel], allowedModels); + r.registerRoles(models, allowedModels); + const original = snapshot(r); + for (const now of [1000, 1001, 600999]) r.observeCatalog([], { now }); + expect(r.bindings).toEqual(original.bindings); + expect(r.roles).toEqual(original.roles); + expect(r.boundProfiles).toHaveLength(3); + r.observeCatalog([sameModel], { now: 601000 }); + expect(r.absence).toEqual({}); + expect(snapshot(r)).toEqual(original); + r.observeCatalog([], { now: 700000 }); + const restarted = router(snapshot(r)); + restarted.observeCatalog([], { now: 700001 }); + restarted.observeCatalog([], { now: 1300000, allowRetirement: false }); + expect(restarted.roles).toEqual(original.roles); + restarted.observeCatalog([], { now: 1300000 }); + expect(restarted.bindings).toEqual({}); + expect(restarted.roles).toEqual({}); + expect(restarted.boundModels).toEqual([]); + expect(restarted.boundProfiles).toEqual([]); + expect(restarted.absence).toEqual({}); + expect(() => prepare(restarted, [system(marker(r, 'secondary'))])).toThrow('agent_profile_unbound'); +}); + +test('JSON and fragmented SSE use scoped default secondary and explicit role profiles only when enabled', async () => { + const r = router(); + for (const extra of [undefined, { model: 'secondary' }, { model: 'primary', subagent_type: 'native-reader' }]) { + const role = extra?.model || 'secondary'; + const expected = { ...parameters(extra), subagent_type: r.profileName(sameModel, role) }; delete expected.model; + expect(translated(r, extra)).toEqual(expected); + expect(await streamed(r, extra)).toEqual(expected); + const legacyExpected = { ...expected, subagent_type: r.profileName(sameModel) }; + for (const flag of [undefined, false]) { + const options = { ...models, roleOptionsEnabled: flag }; + expect(translated(r, extra, options)).toEqual(legacyExpected); + expect(await streamed(r, extra, options)).toEqual(legacyExpected); + } + } + const fallback = { ...models, secondaryModel: '' }; + expect(translated(r, undefined, fallback).subagent_type).toBe(r.profileName(sameModel, 'secondary')); + expect(() => r.registerRoles(fallback, allowedModels)).not.toThrow(); + const registered = r.registerRoles(fallback, allowedModels); + expect(registered.secondary).toBe(token(marker(r, 'secondary'))); +}); + +test('JSON and SSE preserve native, resume, and fork arguments while scoped options are enabled', async () => { + const r = router(); + for (const extra of [{ subagent_type: 'native-reader' }, { model: 'native', subagent_type: 'native-reader' }, + { model: 'native' }, ...['primary', 'secondary', 'native'].flatMap(model => [ + { model, resume: 'synthetic-existing', subagent_type: 'existing-profile' }, + { model, fork: true, subagent_type: 'existing-profile' }, + ])]) { + const expected = parameters(extra); delete expected.model; + expect(translated(r, extra)).toEqual(expected); + expect(await streamed(r, extra)).toEqual(expected); + } +}); + +test('unscoped legacy profiles retain exact names, tokens, return shape, and independent cached identity', () => { + const r = router(); + r.registerRoles(models, allowedModels); + const legacyName = 'local-agent-' + r.namespace + '-' + crypto.createHmac('sha256', Buffer.from(secret, 'hex')) + .update('agent-profile-name:v1:' + sameModel).digest('hex').slice(0, 24); + expect(r.profileName(sameModel)).toBe(legacyName); + const oldText = r.profileText(sameModel), oldToken = token(oldText); + expect(r.roles).not.toHaveProperty(oldToken); + expect(Object.keys(r.bindings)).toHaveLength(3); + expect(r.boundModels).toEqual([sameModel]); + expect(r.boundProfiles).toContainEqual({ model: sameModel }); + const restored = router({ bindings: r.bindings, roles: r.roles }); + const result = prepare(restored, [system(marker(restored))]); + expect(result).toEqual({ payload: { messages: [system('')], model: sameModel }, model: sameModel, translateResponse: false }); + expect(prepare(restored, [])).not.toHaveProperty('routeRole'); + expect(restored.profileText(sameModel)).toBe(oldText); + expect(Object.keys(restored.roles)).toHaveLength(2); +}); + +test('optional route-role metadata distinguishes authenticated legacy profiles from parents without changing old calls', () => { + const r = router(); + r.registerModels([sameModel], allowedModels); + r.registerRoles(models, allowedModels); + const options = { ...configuration, includeRouteRole: true }; + expect(prepare(r, [system(marker(r))], options)).toHaveProperty('routeRole', null); + expect(prepare(r, [system(marker(r))])).not.toHaveProperty('routeRole'); + expect(prepare(r, [], options)).not.toHaveProperty('routeRole'); + expect(prepare(r, [{ role: 'user', content: marker(r) }], options)).not.toHaveProperty('routeRole'); + expect(prepare(r, [system(marker(r, 'secondary'))], options).routeRole).toBe('secondary'); + expect(prepare(r, [system(marker(r, 'secondary')), system(marker(r))], options).routeRole).toBeNull(); +}); + +test('profile publication supports both same-model roles plus legacy profiles without changing old models calls', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-role-options-profiles-')); + try { + const r = router(); + r.registerModels([sameModel], allowedModels); + r.registerRoles(models, allowedModels); + const result = ensureAgentProfiles({ directory, router: r, profiles: r.boundProfiles }); + expect(result.changed).toBe(true); + expect(result.names).toHaveLength(3); + expect(new Set(result.names).size).toBe(3); + for (const profile of r.boundProfiles) { + const name = r.profileName(profile.model, profile.role); + expect(fs.readFileSync(path.join(directory, name + '.md'), 'utf8')).toBe(r.profileText(profile.model, profile.role)); + } + expect(ensureAgentProfiles({ directory, router: r })).toEqual({ changed: false, names: result.names }); + expect(ensureAgentProfiles({ directory, router: r, profiles: [...r.boundProfiles, ...r.boundProfiles] })) + .toEqual({ changed: false, names: result.names }); + expect(ensureAgentProfiles({ directory, router: r, models: [sameModel, sameModel] })) + .toEqual({ changed: false, names: [r.profileName(sameModel)] }); + const ownership = Object.fromEntries(fs.readdirSync(directory).map(name => [name, + crypto.createHash('sha256').update(fs.readFileSync(path.join(directory, name))).digest('hex')])); + r.observeCatalog([], { now: 1000 }); r.observeCatalog([], { now: 1001 }); r.observeCatalog([], { now: 601000 }); + expect(ensureAgentProfiles({ directory, router: r, profiles: r.boundProfiles, ownedFiles: ownership })) + .toEqual({ changed: true, names: [] }); + expect(fs.readdirSync(directory)).toEqual([]); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/packages/kimi-desktop/role-options-transition.test.mjs b/packages/kimi-desktop/role-options-transition.test.mjs new file mode 100644 index 000000000..bbcb9d529 --- /dev/null +++ b/packages/kimi-desktop/role-options-transition.test.mjs @@ -0,0 +1,387 @@ +import { test, expect } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable, Writable } from 'node:stream'; +import { KimiAdapter } from './adapter.mjs'; + +const primary = 'synthetic/model-a', secondary = 'synthetic/model-b', next = 'synthetic/model-c'; +const gateway = { url: 'http://127.0.0.1:19000/v1', key: 'transition-only-key' }; +const nativeKey = 'transition-native-key'; +const official = 'https://agent-gw.kimi.com/coding/v1'; +const selection = (model, reasoningEffort = '', contextTokens = null) => ({ model, reasoningEffort, contextTokens }); +const write = (file, value) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value)); +}; +const read = file => JSON.parse(fs.readFileSync(file, 'utf8')); +const catalog = [primary, secondary, next].map(id => ({ id, menu: { displayName: id }, + options: { reasoningEfforts: ['low', 'high'], contextWindow: 8192 } })); + +function fixture({ primaryOptions = selection(primary, 'low', 1024), + secondaryOptions = selection(secondary, 'low', 1024), secondaryModel = secondary } = {}) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-role-transition-')); + const calls = []; + let inputTokens = 1500; + const adapter = new KimiAdapter({ home, stateDir: path.join(home, 'state'), kimiDir: path.join(home, 'native'), + fetchImpl: async (url, options) => { + calls.push({ url: String(url), body: JSON.parse(options.body) }); + return String(url).endsWith('/messages/count_tokens') ? Response.json({ input_tokens: inputTokens }) + : Response.json({ choices: [] }); + }, + }); + const desired = { enabled: true, model: primary, secondaryModel, gateway: { ...gateway }, + modelOptions: { primary: { ...primaryOptions }, ...(secondaryModel ? { secondary: { ...secondaryOptions } } : {}) } }; + write(adapter.connectionFile, desired); + write(adapter.configFile, { credentials: { kimiCode: { apiKey: nativeKey } }, + model: { current: 'synthetic-native', providers: { 'daimon-kimi-code': { baseUrl: official } }, + models: { 'synthetic-native': { model: 'synthetic-native', provider: 'daimon-kimi-code' } } } }); + write(adapter.runnerFile, { lifecycleStatus: 'running', daemonGeneration: 'synthetic-generation-1', + kernel: { agentKernel: 'kimi-code-v2' }, activeKernelTurns: 0, activeKernelToolCalls: 0 }); + adapter.state = { active: true }; + adapter.live = { model: primary, gatewayURL: gateway.url, gatewayKey: gateway.key, originalBase: official, + modelOptions: structuredClone(desired.modelOptions) }; + adapter.catalog = catalog; + adapter.gatewayCatalog = async () => catalog; + adapter.rolesReady = Boolean(secondaryModel); + adapter.roleOptionsEnabled = Boolean(secondaryModel); + adapter.bridgeSecondaryModel = secondaryModel; + function router() { + const result = adapter.ensureAgentRouter(); + result.registerModels([primary, secondary, next], [primary, secondary, next]); + if (secondaryModel) result.registerRoles({ primaryModel: primary, secondaryModel }, [primary, secondary, next]); + adapter.persistAgentBindings(result); + return result; + } + async function send({ identity, prompt = 'Synthetic transition request.' } = {}) { + const messages = [...(identity ? [{ role: 'system', content: identity }] : []), { role: 'user', content: prompt }]; + const request = Readable.from([Buffer.from(JSON.stringify({ model: 'ignored-native-choice', messages, + reasoning_effort: 'high', thinking: { effort: 'high' }, reasoning: { effort: 'high' } }))]); + Object.assign(request, { method: 'POST', url: '/v1/chat/completions', headers: { authorization: 'Bearer ' + nativeKey } }); + const chunks = []; + const response = new Writable({ write(chunk, _encoding, callback) { chunks.push(Buffer.from(chunk)); callback(); } }); + Object.assign(response, { headersSent: false, statusCode: 0, + writeHead(status) { this.statusCode = status; this.headersSent = true; return this; }, flushHeaders() {}, + }); + await adapter.forward(request, response); + return { status: response.statusCode, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }; + } + return { home, adapter, desired, calls, router, send, setTokens: value => { inputTokens = value; }, + generated: () => calls.filter(call => call.url.endsWith('/chat/completions')), + counted: () => calls.filter(call => call.url.endsWith('/messages/count_tokens')), + close: () => fs.rmSync(home, { recursive: true, force: true }) }; +} + +test('pending primary A to B keeps the old A input budget while a busy turn defers the connection', async () => { + const f = fixture(); + try { + write(f.adapter.connectionFile, { ...f.desired, model: next, modelOptions: { + ...f.desired.modelOptions, primary: selection(next, 'high', 2048), + } }); + const result = await f.send(); + expect(result.status).toBe(400); + expect(result.body.error.code).toBe('context_length_exceeded'); + expect(f.counted()).toHaveLength(1); + expect(f.counted()[0].body.model).toBe(primary); + expect(f.generated()).toHaveLength(0); + expect(f.adapter.live.modelOptions.primary).toEqual(selection(primary, 'low', 1024)); + } finally { f.close(); } +}); + +for (const changed of ['url', 'key']) test(`a pending gateway ${changed} change never applies its new effort to the old active connection`, async () => { + const f = fixture({ primaryOptions: selection(primary, 'low') }); + try { + const replacement = changed === 'url' ? 'http://127.0.0.1:19001/v1' : 'transition-new-key'; + write(f.adapter.connectionFile, { ...f.desired, gateway: { ...gateway, [changed]: replacement }, + modelOptions: { ...f.desired.modelOptions, primary: selection(primary, 'high') } }); + expect((await f.send()).status).toBe(200); + expect(f.generated()).toHaveLength(1); + expect(f.generated()[0].url).toBe(gateway.url + '/chat/completions'); + expect(f.generated()[0].body.reasoning_effort).toBe('low'); + expect(f.adapter.live.modelOptions.primary).toEqual(selection(primary, 'low')); + } finally { f.close(); } +}); + +test('an incomplete pending gateway URL keeps the active rule instead of dropping the working connection', async () => { + const f = fixture({ primaryOptions: selection(primary, 'low') }); + try { + write(f.adapter.connectionFile, { ...f.desired, gateway: { ...gateway, url: 'incomplete-url' }, + modelOptions: { ...f.desired.modelOptions, primary: selection(primary, 'high') } }); + expect((await f.send()).status).toBe(200); + expect(f.generated()[0].body.reasoning_effort).toBe('low'); + } finally { f.close(); } +}); + +test('pending secondary B to C keeps scoped B rules while the native runner is busy', async () => { + const f = fixture({ primaryOptions: selection(primary, 'high', 2048) }); + try { + const r = f.router(), profile = r.profileText(secondary, 'secondary'); + write(f.adapter.connectionFile, { ...f.desired, secondaryModel: next, modelOptions: { + ...f.desired.modelOptions, secondary: selection(next, 'high', 2048), + } }); + expect(await f.adapter.reconcileSecondary({ activeKernelTurns: 1 }, next, + { inheritModel: primary, roleOptionsEnabled: true })).toEqual({ waiting: true }); + expect((await f.send({ identity: profile })).status).toBe(400); + expect(f.counted()[0].body.model).toBe(secondary); + expect(f.generated()).toHaveLength(0); + expect((await f.send()).status).toBe(200); + expect(f.generated()[0].body.model).toBe(primary); + expect(f.generated()[0].body.reasoning_effort).toBe('high'); + } finally { f.close(); } +}); + +test('a pending primary change also preserves the active secondary rule on that connection', async () => { + const f = fixture(); + try { + const r = f.router(), profile = r.profileText(secondary, 'secondary'); + write(f.adapter.connectionFile, { ...f.desired, model: next, modelOptions: { + primary: selection(next, 'high', 2048), secondary: selection(secondary, 'high', 2048), + } }); + expect((await f.send({ identity: profile })).status).toBe(400); + expect(f.adapter.live.modelOptions.secondary).toEqual(selection(secondary, 'low', 1024)); + } finally { f.close(); } +}); + +test('a historical scoped profile for the next secondary target cannot take pending rules before confirmation', async () => { + const f = fixture(); + try { + const r = f.router(), profile = r.profileText(next, 'secondary'); + f.adapter.persistAgentBindings(r); + write(f.adapter.connectionFile, { ...f.desired, secondaryModel: next, modelOptions: { + ...f.desired.modelOptions, secondary: selection(next, 'high', 512), + } }); + expect((await f.send({ identity: profile })).status).toBe(200); + expect(f.generated()[0].body.model).toBe(next); + expect(f.generated()[0].body).not.toHaveProperty('reasoning_effort'); + expect(f.counted()).toHaveLength(0); + expect(f.adapter.live.modelOptions.secondary.model).toBe(secondary); + } finally { f.close(); } +}); + +test('same-model authenticated legacy workers use defaults while parent and scoped workers keep separate rules', async () => { + const f = fixture({ primaryOptions: selection(primary, 'high', 2048), + secondaryOptions: selection(primary, 'low', 1024), secondaryModel: primary }); + try { + const r = f.router(), legacy = r.profileText(primary); + expect((await f.send({ identity: legacy })).status).toBe(200); + const unscoped = f.generated()[0].body; + expect(unscoped.model).toBe(primary); + for (const field of ['reasoning_effort', 'thinking', 'reasoning']) expect(unscoped).not.toHaveProperty(field); + expect(f.counted()).toHaveLength(0); + expect((await f.send()).status).toBe(200); + expect(f.generated()[1].body.reasoning_effort).toBe('high'); + expect((await f.send({ identity: r.profileText(primary, 'secondary') })).status).toBe(400); + expect((await f.send({ identity: r.profileText(primary, 'primary') })).status).toBe(200); + expect(f.generated()[2].body.reasoning_effort).toBe('high'); + expect(f.counted()).toHaveLength(3); + expect(f.adapter.live.modelOptions).toEqual({ primary: selection(primary, 'high', 2048), secondary: selection(primary, 'low', 1024) }); + } finally { f.close(); } +}); + +for (const unconfirmed of ['rolesReady', 'roleOptionsEnabled']) test(`unconfirmed ${unconfirmed} keeps the last scoped secondary rules`, async () => { + const f = fixture({ secondaryOptions: selection(primary, 'low', 1024), secondaryModel: primary }); + try { + const r = f.router(), profile = r.profileText(primary, 'secondary'); + f.adapter[unconfirmed] = false; + write(f.adapter.connectionFile, { ...f.desired, modelOptions: { ...f.desired.modelOptions, + secondary: selection(primary, 'high', 2048) } }); + expect((await f.send({ identity: profile })).status).toBe(400); + expect(f.generated()).toHaveLength(0); + expect(f.adapter.live.modelOptions.secondary).toEqual(selection(primary, 'low', 1024)); + } finally { f.close(); } +}); + +test('a fresh applied rule updates the live snapshot before a later model change between ticks', async () => { + const f = fixture(); + try { + f.setTokens(400); + write(f.adapter.connectionFile, { ...f.desired, modelOptions: { ...f.desired.modelOptions, + primary: selection(primary, 'high', 512) } }); + expect((await f.send()).status).toBe(200); + expect(f.generated()[0].body.reasoning_effort).toBe('high'); + expect(f.adapter.live).toHaveProperty('modelOptions.primary', selection(primary, 'high', 512)); + write(f.adapter.connectionFile, { ...f.desired, model: next, modelOptions: { ...f.desired.modelOptions, + primary: selection(next, 'low', 2048) } }); + f.setTokens(700); + expect((await f.send()).status).toBe(400); + expect(f.generated()).toHaveLength(1); + } finally { f.close(); } +}); + +test('clearing stored preferences clears active rules and their snapshot without restoring old limits later', async () => { + const f = fixture({ primaryOptions: selection(primary, 'high', 512), secondaryModel: primary, + secondaryOptions: selection(primary, 'low', 512) }); + try { + const r = f.router(); + const cleared = { ...f.desired }; delete cleared.modelOptions; + write(f.adapter.connectionFile, cleared); + for (const identity of [undefined, r.profileText(primary, 'secondary')]) { + expect((await f.send({ identity })).status).toBe(200); + expect(f.generated().at(-1).body).not.toHaveProperty('reasoning_effort'); + } + expect(f.counted()).toHaveLength(0); + expect(f.adapter.live.modelOptions).toEqual({ primary: selection(primary), secondary: selection(primary) }); + write(f.adapter.connectionFile, { ...cleared, model: next }); + expect((await f.send()).status).toBe(200); + expect(f.counted()).toHaveLength(0); + } finally { f.close(); } +}); + +test('a resumed scoped profile outside both current model records uses defaults without reviving historical options', async () => { + const f = fixture({ secondaryModel: next, secondaryOptions: selection(next, 'high', 512) }); + try { + const r = f.router(), profile = r.profileText(secondary, 'secondary'); + f.adapter.persistAgentBindings(r); + expect((await f.send({ identity: profile })).status).toBe(200); + expect(f.generated()[0].body.model).toBe(secondary); + expect(f.generated()[0].body).not.toHaveProperty('reasoning_effort'); + expect(f.counted()).toHaveLength(0); + expect(Object.keys(f.adapter.live.modelOptions).sort()).toEqual(['primary', 'secondary']); + expect(f.adapter.live.modelOptions.secondary.model).toBe(next); + } finally { f.close(); } +}); + +function useSyntheticTick(f) { + const a = f.adapter; + a.bootstrap = () => true; + a.ownLock = () => true; + a.protectNativeSecondary = () => {}; + a.rpcCall = async () => ({}); + a.listen = async () => { a.bridgeURL = 'http://127.0.0.1:19002/v1'; }; + a.syncCatalog = async () => {}; + a.reconcileSecondary = async (_runner, wanted, { roleOptionsEnabled = false } = {}) => { + a.bridgeSecondaryModel = wanted; + a.rolesReady = Boolean(wanted); + a.roleOptionsEnabled = roleOptionsEnabled; + return { applied: true }; + }; + a.stabilityMs = 0; + a.state = {}; + a.live = null; +} + +test('successful initial, steady, and generation transitions replace only the two small live rule records', async () => { + const f = fixture({ primaryOptions: selection(primary, 'high', 2048), secondaryOptions: selection(primary, 'low', 1024), secondaryModel: primary }); + try { + useSyntheticTick(f); + await f.adapter.tick(); + expect(f.adapter.status.code).toBe('connected'); + expect(f.adapter.live.modelOptions).toEqual(f.desired.modelOptions); + const r = f.router(), profile = r.profileText(primary, 'secondary'), roles = f.adapter.state.agentRoutingRoles; + write(f.adapter.connectionFile, { ...f.desired, modelOptions: { primary: selection(primary, 'low', 1024), secondary: selection(primary, 'high', 2048) } }); + await f.adapter.tick(); + expect(f.adapter.live.modelOptions.secondary).toEqual(selection(primary, 'high', 2048)); + const runner = read(f.adapter.runnerFile); runner.daemonGeneration = 'synthetic-generation-2'; write(f.adapter.runnerFile, runner); + f.adapter.retryAfter = 0; + await f.adapter.tick(); + expect(f.adapter.state.agentRoutingRoles).toEqual(roles); + f.adapter.agentRouter = null; + expect((await f.send({ identity: profile })).status).toBe(200); + expect(f.generated().at(-1).body.reasoning_effort).toBe('high'); + expect(f.adapter.live.modelOptions).toEqual({ primary: selection(primary, 'low', 1024), secondary: selection(primary, 'high', 2048) }); + expect(JSON.stringify(f.adapter.state)).not.toContain('contextTokens'); + expect(JSON.stringify(f.adapter.live.modelOptions)).not.toContain(gateway.key); + const cleared = { ...f.desired, secondaryModel: '' }; delete cleared.modelOptions; + write(f.adapter.connectionFile, cleared); + await f.adapter.tick(); + expect(f.adapter.live.modelOptions).toEqual({ primary: selection(primary) }); + } finally { f.close(); } +}); + +test('a new primary-only live snapshot contains no absent secondary record while recovery is waiting', async () => { + const f = fixture({ secondaryModel: '' }); + try { + useSyntheticTick(f); + f.adapter.reconcileSecondary = async () => ({ waiting: true }); + await f.adapter.tick(); + expect(f.adapter.status.code).toBe('waiting_kimi_idle'); + expect(f.adapter.live.modelOptions).toEqual({ primary: selection(primary, 'low', 1024) }); + } finally { f.close(); } +}); + +test('a tick confirms its original secondary scope before adopting a newer pending scope preference', async () => { + const f = fixture(); + try { + const initial = { ...f.desired }; delete initial.modelOptions; + write(f.adapter.connectionFile, initial); + useSyntheticTick(f); + f.adapter.syncCatalog = async () => write(f.adapter.connectionFile, { ...initial, + modelOptions: { secondary: selection(secondary, 'high', 512) } }); + await f.adapter.tick(); + expect(f.adapter.roleOptionsEnabled).toBe(false); + expect(f.adapter.live.modelOptions).toEqual({ primary: selection(primary), secondary: selection(secondary) }); + } finally { f.close(); } +}); + +test('a completed steady reconciliation keeps the confirmed secondary snapshot when a new target arrives during the tick', async () => { + const f = fixture(); + try { + useSyntheticTick(f); + await f.adapter.tick(); + expect(f.adapter.live.modelOptions).toEqual(f.desired.modelOptions); + f.adapter.syncCatalog = async () => write(f.adapter.connectionFile, { ...f.desired, secondaryModel: next, + modelOptions: { ...f.desired.modelOptions, secondary: selection(next, 'high', 2048) } }); + await f.adapter.tick(); + expect(f.adapter.bridgeSecondaryModel).toBe(secondary); + expect(f.adapter.live.modelOptions.secondary).toEqual(selection(secondary, 'low', 1024)); + } finally { f.close(); } +}); + +test('a completed reconciliation keeps the old connection snapshot when a new gateway preference arrives during the tick', async () => { + const f = fixture(); + try { + useSyntheticTick(f); + await f.adapter.tick(); + f.adapter.syncCatalog = async () => write(f.adapter.connectionFile, { ...f.desired, + gateway: { ...gateway, key: 'transition-pending-key' }, + modelOptions: { ...f.desired.modelOptions, primary: selection(primary, 'high') } }); + await f.adapter.tick(); + expect(f.adapter.live.modelOptions.primary).toEqual(selection(primary, 'low', 1024)); + } finally { f.close(); } +}); + +test('a newly confirmed secondary target replaces the old snapshot even if another target is already pending', async () => { + const f = fixture(); + try { + useSyntheticTick(f); + await f.adapter.tick(); + write(f.adapter.connectionFile, { ...f.desired, secondaryModel: next, + modelOptions: { ...f.desired.modelOptions, secondary: selection(next, 'high', 2048) } }); + f.adapter.syncCatalog = async () => write(f.adapter.connectionFile, { ...f.desired, + modelOptions: { ...f.desired.modelOptions, secondary: selection(secondary, 'low', 512) } }); + await f.adapter.tick(); + expect(f.adapter.bridgeSecondaryModel).toBe(next); + expect(f.adapter.live.modelOptions.secondary).toEqual(selection(next, 'high', 2048)); + } finally { f.close(); } +}); + +test('a successful first reconciliation refreshes a newer preference saved after its live connection was initialized', async () => { + const f = fixture(); + try { + useSyntheticTick(f); + f.adapter.syncCatalog = async () => write(f.adapter.connectionFile, { ...f.desired, + modelOptions: { ...f.desired.modelOptions, primary: selection(primary, 'high', 512) } }); + await f.adapter.tick(); + expect(f.adapter.live.modelOptions.primary).toEqual(selection(primary, 'high', 512)); + } finally { f.close(); } +}); + +test('a steady tick does not replace a newer applied rule with its earlier pre-await preference', async () => { + const f = fixture(); + try { + useSyntheticTick(f); + await f.adapter.tick(); + f.adapter.syncCatalog = async () => { + write(f.adapter.connectionFile, { ...f.desired, modelOptions: { ...f.desired.modelOptions, primary: selection(primary, 'high', 512) } }); + f.setTokens(400); + expect((await f.send()).status).toBe(200); + write(f.adapter.connectionFile, { ...f.desired, model: next, + modelOptions: { ...f.desired.modelOptions, primary: selection(next, 'low', 2048) } }); + }; + await f.adapter.tick(); + expect(f.adapter.live).toHaveProperty('modelOptions.primary', selection(primary, 'high', 512)); + f.setTokens(700); + expect((await f.send()).status).toBe(400); + expect(f.generated()).toHaveLength(1); + } finally { f.close(); } +}); diff --git a/packages/kimi-desktop/role-plugin-state.mjs b/packages/kimi-desktop/role-plugin-state.mjs new file mode 100644 index 000000000..371c5c0e4 --- /dev/null +++ b/packages/kimi-desktop/role-plugin-state.mjs @@ -0,0 +1,128 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +const ROLE_ID = /^local-work-model-roles-[a-f0-9]{12}$/; +const conflict = () => Object.assign(Error('agent_profile_conflict'), { code: 'agent_profile_conflict' }); +const record = value => value !== null && typeof value === 'object' && !Array.isArray(value); + +function readRegularBytes(file, missing = false) { + let stat; + try { stat = fs.lstatSync(file); } + catch (error) { if (missing && error.code === 'ENOENT') return null; throw error; } + if (!stat.isFile()) throw conflict(); + const descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW); + try { return fs.readFileSync(descriptor); } + finally { fs.closeSync(descriptor); } +} + +function declarativeManifest(registryFile, id) { + const managed = path.join(path.dirname(registryFile), 'managed'); + const root = path.join(managed, id); + try { + if (!fs.lstatSync(managed).isDirectory() || !fs.lstatSync(root).isDirectory()) return false; + const value = JSON.parse(readRegularBytes(path.join(root, 'kimi.plugin.json')).toString('utf8')); + return record(value) && Object.keys(value).length === 4 + && value.name === id && value.version === '1.0.0' && value.description === 'Local Work model roles' + && Array.isArray(value.agents) && value.agents.length === 1 && value.agents[0] === './agents'; + } catch { return false; } +} + +// Exact string equality keeps deleted-source recovery: the caller's literal +// source path is trusted without any existence check. Only different paths +// require a proven existing-directory equivalence. +function canonicalDirectory(file) { + try { + const canonical = fs.realpathSync(file); + // A file (or a symlink resolving to a file) is never directory equivalence. + return fs.statSync(canonical).isDirectory() ? canonical : null; + } catch { return null; } +} + +function sourceMatches(sourceDirectory, originalSource) { + if (originalSource === sourceDirectory) return true; + const a = canonicalDirectory(sourceDirectory), b = canonicalDirectory(originalSource); + return a !== null && b !== null && a === b; +} + +function rootMatches(registryFile, id, entryRoot) { + const expectedRoot = path.join(path.dirname(registryFile), 'managed', id); + if (entryRoot === expectedRoot) return true; + try { + // Ancestor aliases are allowed, but a direct symlink at the persisted root + // or at its immediate managed parent is never trusted. + if (!fs.lstatSync(entryRoot).isDirectory()) return false; + if (!fs.lstatSync(path.dirname(entryRoot)).isDirectory()) return false; + // Canonical WHOLE-root equality: a sibling directory under the same managed + // parent (or any other physical root) is never accepted. + return fs.realpathSync(entryRoot) === fs.realpathSync(expectedRoot); + } catch { return false; } +} + +function snapshot({ registryFile, sourceDirectory }) { + if (typeof registryFile !== 'string' || !registryFile || typeof sourceDirectory !== 'string' || !sourceDirectory) { + throw conflict(); + } + const bytes = readRegularBytes(registryFile, true); + if (bytes === null) return { bytes, document: null, entries: [], conflicts: [] }; + const document = JSON.parse(bytes.toString('utf8')); + if (!record(document) || document.version !== 1 || !Array.isArray(document.plugins)) throw conflict(); + const candidates = document.plugins.filter(entry => record(entry) && typeof entry.id === 'string' && ROLE_ID.test(entry.id)); + const counts = new Map(); + for (const entry of candidates) counts.set(entry.id, (counts.get(entry.id) || 0) + 1); + const entries = [], conflicts = []; + for (const entry of candidates) { + if (counts.get(entry.id) !== 1 + || entry.source !== 'local-path' || !sourceMatches(sourceDirectory, entry.originalSource) + || !rootMatches(registryFile, entry.id, entry.root) + || typeof entry.enabled !== 'boolean' || !declarativeManifest(registryFile, entry.id)) { + if (!conflicts.includes(entry.id)) conflicts.push(entry.id); + } else entries.push(entry); + } + return { bytes, document, entries, conflicts }; +} + +const inspection = saved => ({ + owned: saved.entries.map(({ id, enabled }) => ({ id, enabled })), conflicts: saved.conflicts, +}); + +/** Discover role plugins from caller-supplied native paths; perform no RPC. */ +export function inspectRolePlugins(options) { + try { return inspection(snapshot(options)); } + catch { throw conflict(); } +} + +function publish(registryFile, bytes, expected) { + const temp = registryFile + '.' + process.pid + '.' + crypto.randomBytes(6).toString('hex') + '.tmp'; + let created = false; + try { + const descriptor = fs.openSync(temp, 'wx', 0o600); + created = true; + try { fs.writeFileSync(descriptor, bytes); } + finally { fs.closeSync(descriptor); } + const current = readRegularBytes(registryFile, true); + if (current === null || !current.equals(expected)) throw conflict(); + fs.renameSync(temp, registryFile); + } finally { + if (created) { try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') throw error; } } + } +} + +/** Disable only selected owned registrations. Disk state does not acknowledge + * a loaded Kimi session: the caller retains its pending runtime-refresh work. */ +export function disableRolePluginsSync(options) { + try { + const { registryFile, sourceDirectory, ids = [] } = options; + if (!Array.isArray(ids)) throw conflict(); + const saved = snapshot({ registryFile, sourceDirectory }); + const selected = new Set(ids), disabled = []; + for (const entry of saved.entries) { + if (selected.has(entry.id) && entry.enabled) { + entry.enabled = false; + disabled.push(entry.id); + } + } + if (disabled.length) publish(registryFile, Buffer.from(JSON.stringify(saved.document, null, 2) + '\n'), saved.bytes); + return { changed: disabled.length > 0, disabled, ...inspection(saved) }; + } catch { throw conflict(); } +} diff --git a/packages/kimi-desktop/role-plugin-state.test.mjs b/packages/kimi-desktop/role-plugin-state.test.mjs new file mode 100644 index 000000000..a476247da --- /dev/null +++ b/packages/kimi-desktop/role-plugin-state.test.mjs @@ -0,0 +1,625 @@ +// Native plugin registry tests use only synthetic files beneath temporary homes. +import { afterEach, expect, spyOn, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { inspectRolePlugins, disableRolePluginsSync } from './role-plugin-state.mjs'; + +const id = 'local-work-model-roles-0123456789ab'; +const oldID = 'local-work-model-roles-fedcba987654'; +const foreignID = 'local-work-model-roles-abcdef012345'; +const homes = []; +const restorers = []; +const writeJSON = (file, value) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n'); +}; +const readJSON = file => JSON.parse(fs.readFileSync(file, 'utf8')); +const manifest = name => ({ name, version: '1.0.0', description: 'Local Work model roles', agents: ['./agents'] }); +// Native directory alias semantics: a junction on Windows, a directory symlink elsewhere. +const dirSymlink = (target, alias) => + fs.symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir'); +const spy = (object, method) => { + const mocked = spyOn(object, method); + restorers.push(() => mocked.mockRestore()); + return mocked; +}; + +afterEach(() => { + for (const restore of restorers.splice(0).reverse()) restore(); + for (const home of homes.splice(0)) fs.rmSync(home, { recursive: true, force: true }); +}); + +function fixture({ plugins = true } = {}) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-role-plugin-state-home-')); + homes.push(home); + const registryFile = path.join(home, 'native-home', 'plugins', 'installed.json'); + const sourceDirectory = path.join(home, 'adapter-state', 'native-model-roles'); + const options = { registryFile, sourceDirectory }; + const rootFor = name => path.join(path.dirname(registryFile), 'managed', name); + const add = (name = id, fields = {}, content = manifest(name)) => { + const root = rootFor(name); + writeJSON(path.join(root, 'kimi.plugin.json'), content); + return { id: name, root, source: 'local-path', enabled: true, originalSource: sourceDirectory, ...fields }; + }; + if (plugins) writeJSON(registryFile, { version: 1, plugins: [add()] }); + return { home, options, registryFile, sourceDirectory, rootFor, add, + save: doc => writeJSON(registryFile, doc), + bytes: () => fs.readFileSync(registryFile), + staging: () => fs.readdirSync(path.dirname(registryFile)).filter(name => name.endsWith('.tmp')) }; +} + +function fixedConflict(action) { + let error; + try { action(); } catch (caught) { error = caught; } + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('agent_profile_conflict'); + expect(error.code).toBe('agent_profile_conflict'); + expect(error.cause).toBeUndefined(); +} + +test('inspection owns enabled, disabled and orphaned-key records with exact native provenance', () => { + const f = fixture(); + f.save({ version: 1, plugins: [f.add(), f.add(oldID, { enabled: false })] }); + expect(inspectRolePlugins(f.options)).toEqual({ + owned: [{ id, enabled: true }, { id: oldID, enabled: false }], conflicts: [], + }); +}); + +test('only the exact lowercase twelve-hex role-plugin id pattern is considered', () => { + const f = fixture(); + const names = ['ordinary-plugin', 'local-work-model-roles-0123456789a', + 'local-work-model-roles-0123456789abc', 'local-work-model-roles-ABCDEF012345', + 'prefix-local-work-model-roles-0123456789ab', 'local-work-model-roles-0123456789ab-suffix']; + f.save({ version: 1, plugins: names.map(name => ({ id: name, enabled: true })) }); + expect(inspectRolePlugins(f.options)).toEqual({ owned: [], conflicts: [] }); +}); + +for (const [name, fields] of [ + ['non-local source', { source: 'marketplace' }], + ['different original source', { originalSource: '/synthetic/another-adapter' }], + ['different managed root', { root: '/synthetic/another-plugin' }], + ['non-boolean enabled', { enabled: 'true' }], +]) { + test(`a prefix-matching record with ${name} is a conflict and stays byte-for-byte unchanged`, () => { + const f = fixture(); + f.save({ version: 1, plugins: [f.add(id, fields)] }); + const before = f.bytes(); + expect(inspectRolePlugins(f.options)).toEqual({ owned: [], conflicts: [id] }); + expect(disableRolePluginsSync({ ...f.options, ids: [id] })).toEqual({ + changed: false, disabled: [], owned: [], conflicts: [id], + }); + expect(f.bytes()).toEqual(before); + }); +} + +for (const [name, change] of [ + ['name', value => ({ ...value, name: oldID })], + ['version', value => ({ ...value, version: '2.0.0' })], + ['description', value => ({ ...value, description: 'Other roles' })], + ['agent path', value => ({ ...value, agents: ['./different-agents'] })], + ['non-array agent declaration', value => ({ ...value, agents: { 0: './agents', length: 1 } })], + ['additional agent path', value => ({ ...value, agents: ['./agents', './more-agents'] })], + ['extra hook capability', value => ({ ...value, hooks: ['./hook.mjs'] })], + ['extra MCP capability', value => ({ ...value, mcpServers: {} })], +]) { + test(`a managed manifest with changed ${name} is never claimed`, () => { + const f = fixture(); + writeJSON(path.join(f.rootFor(id), 'kimi.plugin.json'), change(manifest(id))); + expect(inspectRolePlugins(f.options)).toEqual({ owned: [], conflicts: [id] }); + const before = f.bytes(); + disableRolePluginsSync({ ...f.options, ids: [id] }); + expect(f.bytes()).toEqual(before); + }); +} + +test('manifest key order and insignificant JSON whitespace do not change ownership', () => { + const f = fixture(); + const value = manifest(id); + writeJSON(path.join(f.rootFor(id), 'kimi.plugin.json'), { + agents: value.agents, description: value.description, version: value.version, name: value.name, + }); + expect(inspectRolePlugins(f.options)).toEqual({ owned: [{ id, enabled: true }], conflicts: [] }); +}); + +test('orphaned registrations with missing managed manifests are conflicts', () => { + const f = fixture(); + fs.rmSync(f.rootFor(id), { recursive: true }); + expect(inspectRolePlugins(f.options)).toEqual({ owned: [], conflicts: [id] }); +}); + +test('malformed managed manifest contents are conflicts', () => { + const f = fixture(); + fs.writeFileSync(path.join(f.rootFor(id), 'kimi.plugin.json'), '{synthetic invalid JSON'); + expect(inspectRolePlugins(f.options)).toEqual({ owned: [], conflicts: [id] }); +}); + +for (const [name, value] of [ + ['invalid JSON', '{synthetic invalid JSON'], + ['unsupported version', JSON.stringify({ version: 2, plugins: [] })], + ['non-array plugins', JSON.stringify({ version: 1, plugins: {} })], + ['non-object registry', JSON.stringify([])], +]) { + test(`a registry with ${name} returns only the fixed conflict error`, () => { + const f = fixture(); + fs.writeFileSync(f.registryFile, value); + const before = f.bytes(); + fixedConflict(() => inspectRolePlugins(f.options)); + fixedConflict(() => disableRolePluginsSync({ ...f.options, ids: [id] })); + expect(f.bytes()).toEqual(before); + expect(f.staging()).toEqual([]); + }); +} + +test('duplicate candidate ids cannot create ambiguous ownership', () => { + const f = fixture(); + f.save({ version: 1, plugins: [f.add(), f.add(id, { source: 'foreign' })] }); + const before = f.bytes(); + expect(inspectRolePlugins(f.options)).toEqual({ owned: [], conflicts: [id] }); + disableRolePluginsSync({ ...f.options, ids: [id] }); + expect(f.bytes()).toEqual(before); +}); + +test('a registry symbolic link is rejected without following or replacing it', () => { + const f = fixture(); + const outside = path.join(f.home, 'external-registry.json'); + fs.renameSync(f.registryFile, outside); + fs.symlinkSync(outside, f.registryFile); + const before = fs.readFileSync(outside); + fixedConflict(() => inspectRolePlugins(f.options)); + fixedConflict(() => disableRolePluginsSync({ ...f.options, ids: [id] })); + expect(fs.lstatSync(f.registryFile).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(outside)).toEqual(before); +}); + +test('nonregular registry metadata is rejected before any registry content is opened', () => { + const f = fixture(); + const lstat = fs.lstatSync.bind(fs); + spy(fs, 'lstatSync').mockImplementation((file, ...args) => file === f.registryFile + ? { isFile: () => false } : lstat(file, ...args)); + const open = spy(fs, 'openSync'); + fixedConflict(() => inspectRolePlugins(f.options)); + expect(open).not.toHaveBeenCalled(); +}); + +test('a registry changed to a symbolic link after lstat is not followed by open', () => { + const f = fixture(); + const outside = path.join(f.home, 'external-registry.json'); + fs.copyFileSync(f.registryFile, outside); + const open = fs.openSync.bind(fs); + let changed = false; + spy(fs, 'openSync').mockImplementation((file, flags, mode) => { + if (file === f.registryFile && !changed) { + changed = true; + fs.unlinkSync(file); + fs.symlinkSync(outside, file); + } + return open(file, flags, mode); + }); + fixedConflict(() => inspectRolePlugins(f.options)); +}); + +test('a managed manifest symbolic link is a conflict even when its target is valid', () => { + const f = fixture(); + const file = path.join(f.rootFor(id), 'kimi.plugin.json'); + const outside = path.join(f.home, 'external-manifest.json'); + fs.renameSync(file, outside); + fs.symlinkSync(outside, file); + expect(inspectRolePlugins(f.options)).toEqual({ owned: [], conflicts: [id] }); +}); + +for (const component of ['plugin root', 'managed directory']) { + test(`a symbolic link at the ${component} cannot supply a trusted manifest`, () => { + const f = fixture(); + const from = component === 'plugin root' ? f.rootFor(id) : path.dirname(f.rootFor(id)); + const outside = path.join(f.home, 'external-managed'); + fs.renameSync(from, outside); + fs.symlinkSync(outside, from); + expect(inspectRolePlugins(f.options)).toEqual({ owned: [], conflicts: [id] }); + }); +} + +test('filesystem read failures do not expose original paths or data', () => { + const f = fixture(); + spy(fs, 'readFileSync').mockImplementation(() => { + throw Object.assign(Error('synthetic-private-data at ' + f.registryFile), { code: 'EIO' }); + }); + fixedConflict(() => inspectRolePlugins(f.options)); +}); + +test('disable changes only requested owned enabled fields and preserves all unrelated logical data', () => { + const f = fixture(); + const foreign = { id: foreignID, root: '/synthetic/foreign', source: 'marketplace', enabled: true, + originalSource: 'synthetic-foreign-source', arbitrary: { secret: 'synthetic-credential', values: [false, null, '斜线/\\'] } }; + const unselected = f.add(oldID, { arbitrary: ['keep', 42] }); + const owned = f.add(id, { installedAt: 'synthetic-date', capabilities: { agents: 1 }, unknown: ['original'] }); + const doc = { version: 1, plugins: [foreign, owned, unselected, null, { arbitrary: true }], + futureRegistryField: { arbitrary: [1, 'two', true] } }; + f.save(doc); + const agentFile = path.join(f.rootFor(id), 'agents', 'synthetic.md'); + fs.mkdirSync(path.dirname(agentFile)); + fs.writeFileSync(agentFile, 'synthetic native agent content\n'); + const agentBefore = fs.readFileSync(agentFile); + const result = disableRolePluginsSync({ ...f.options, ids: [id, foreignID] }); + expect(result).toEqual({ changed: true, disabled: [id], + owned: [{ id, enabled: false }, { id: oldID, enabled: true }], conflicts: [foreignID] }); + expect(readJSON(f.registryFile)).toEqual({ ...doc, plugins: [foreign, { ...owned, enabled: false }, unselected, null, { arbitrary: true }] }); + expect(fs.readFileSync(agentFile)).toEqual(agentBefore); + expect(f.staging()).toEqual([]); +}); + +test('already disabled and unrequested owned records produce no write or byte normalization', () => { + const f = fixture(); + f.save({ version: 1, plugins: [f.add(), f.add(oldID, { enabled: false })] }); + fs.appendFileSync(f.registryFile, '\n \t'); + const before = f.bytes(); + expect(disableRolePluginsSync({ ...f.options, ids: [oldID] })).toEqual({ changed: false, disabled: [], + owned: [{ id, enabled: true }, { id: oldID, enabled: false }], conflicts: [] }); + expect(f.bytes()).toEqual(before); + expect(f.staging()).toEqual([]); +}); + +test('a missing registry returns empty results and creates neither file nor directory', () => { + const f = fixture({ plugins: false }); + let inspected, disabled; + expect(() => { inspected = inspectRolePlugins(f.options); }).not.toThrow(); + expect(inspected).toEqual({ owned: [], conflicts: [] }); + expect(() => { disabled = disableRolePluginsSync({ ...f.options, ids: [id] }); }).not.toThrow(); + expect(disabled).toEqual({ changed: false, disabled: [], owned: [], conflicts: [] }); + expect(fs.existsSync(path.dirname(f.registryFile))).toBe(false); +}); + +test('required caller paths and selected-id lists reject malformed input with the fixed error', () => { + const f = fixture(); + for (const options of [{ registryFile: f.registryFile }, { ...f.options, sourceDirectory: '' }, + { ...f.options, registryFile: null }]) fixedConflict(() => inspectRolePlugins(options)); + fixedConflict(() => disableRolePluginsSync({ ...f.options, ids: id })); +}); + +test('absent or null options never escape as a native destructuring error', () => { + for (const options of [undefined, null]) { + fixedConflict(() => inspectRolePlugins(options)); + fixedConflict(() => disableRolePluginsSync(options)); + } +}); + +for (const kind of ['logical edit', 'whitespace-only edit', 'deletion', 'symbolic link']) { + test(`CAS preserves an external ${kind} made while the private staging file is written`, () => { + const f = fixture(); + const original = f.bytes(); + const outside = path.join(f.home, 'external-registry.json'); + fs.writeFileSync(outside, original); + const concurrent = kind === 'whitespace-only edit' ? Buffer.concat([original, Buffer.from('\n')]) + : Buffer.from(JSON.stringify({ ...readJSON(f.registryFile), concurrent: 'synthetic external edit' })); + const write = fs.writeFileSync.bind(fs); + let changed = false; + spy(fs, 'writeFileSync').mockImplementation((file, ...args) => { + const result = write(file, ...args); + if (typeof file === 'number' && !changed) { + changed = true; + if (kind === 'deletion' || kind === 'symbolic link') { + fs.unlinkSync(f.registryFile); + if (kind === 'symbolic link') fs.symlinkSync(outside, f.registryFile); + } else write(f.registryFile, concurrent); + } + return result; + }); + fixedConflict(() => disableRolePluginsSync({ ...f.options, ids: [id] })); + expect(changed).toBe(true); + if (kind === 'deletion') expect(fs.existsSync(f.registryFile)).toBe(false); + else if (kind === 'symbolic link') { + expect(fs.lstatSync(f.registryFile).isSymbolicLink()).toBe(true); + expect(fs.readFileSync(outside)).toEqual(original); + } else expect(f.bytes()).toEqual(concurrent); + expect(f.staging()).toEqual([]); + }); +} + +test('a failed staging write removes the partial private file and preserves the registry', () => { + const f = fixture(); + const before = f.bytes(); + const write = fs.writeFileSync.bind(fs); + spy(fs, 'writeFileSync').mockImplementation((file, ...args) => { + if (typeof file === 'number') { + write(file, 'partial synthetic content'); + throw Error('synthetic-private-data stage failure'); + } + return write(file, ...args); + }); + fixedConflict(() => disableRolePluginsSync({ ...f.options, ids: [id] })); + expect(f.bytes()).toEqual(before); + expect(f.staging()).toEqual([]); +}); + +test('a failed atomic rename removes staging without modifying the registry', () => { + const f = fixture(); + const before = f.bytes(); + spy(fs, 'renameSync').mockImplementation(() => { throw Error('synthetic-private-data rename failure'); }); + fixedConflict(() => disableRolePluginsSync({ ...f.options, ids: [id] })); + expect(f.bytes()).toEqual(before); + expect(f.staging()).toEqual([]); +}); + +test('published registry bytes come from a private mode-0600 staging file', () => { + const f = fixture(); + const open = fs.openSync.bind(fs); + let mode; + spy(fs, 'openSync').mockImplementation((file, flags, permissions) => { + const descriptor = open(file, flags, permissions); + if (typeof file === 'string' && file.endsWith('.tmp')) mode = fs.fstatSync(descriptor).mode & 0o777; + return descriptor; + }); + disableRolePluginsSync({ ...f.options, ids: [id] }); + expect(mode).toBe(0o600); + expect(fs.statSync(f.registryFile).mode & 0o777).toBe(0o600); +}); + +test('exclusive staging creation preserves another writer\'s colliding temporary file', () => { + const f = fixture(); + const before = f.bytes(); + const open = fs.openSync.bind(fs); + let collision; + spy(fs, 'openSync').mockImplementation((file, flags, mode) => { + if (typeof file === 'string' && file.endsWith('.tmp')) { + collision = file; + fs.writeFileSync(file, 'synthetic other writer', { flag: 'wx' }); + } + return open(file, flags, mode); + }); + fixedConflict(() => disableRolePluginsSync({ ...f.options, ids: [id] })); + expect(f.bytes()).toEqual(before); + expect(fs.existsSync(collision)).toBe(true); + expect(fs.readFileSync(collision, 'utf8')).toBe('synthetic other writer'); +}); + +// Problem 4 (red): caller paths may carry a symlink in an ancestor directory +// while the persisted root/source is the physical realpath (or vice versa). +// Ancestor aliases are allowed; direct managed/id-root symlinks stay rejected. +function symlinkFixture() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-role-plugin-path-')); + homes.push(home); + const physicalRoot = path.join(home, 'physical'); + const pluginsDir = path.join(physicalRoot, 'plugins'); + const registryFile = path.join(pluginsDir, 'installed.json'); + const managedDir = path.join(pluginsDir, 'managed'); + const sourceDirectory = path.join(home, 'adapter-state', 'native-model-roles'); + fs.mkdirSync(sourceDirectory, { recursive: true }); // must exist for directory-identity tests + const rootFor = name => path.join(managedDir, name); + const addPhysical = (name = id, fields = {}) => { + const root = rootFor(name); + writeJSON(path.join(root, 'kimi.plugin.json'), manifest(name)); + return { id: name, root, source: 'local-path', enabled: true, originalSource: sourceDirectory, ...fields }; + }; + return { home, physicalRoot, pluginsDir, registryFile, managedDir, sourceDirectory, rootFor, addPhysical }; +} + +test('a logical registry path through an ancestor alias owns a physical-root record', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + writeJSON(f.registryFile, { version: 1, plugins: [entry] }); + const alias = path.join(f.home, 'registry-alias'); + dirSymlink(f.physicalRoot, alias); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: path.join(alias, 'plugins', 'installed.json'), sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [{ id, enabled: true }], conflicts: [] }); +}); + +test('a physical registry accepts a logical entry.root reached through an ancestor alias', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + const rootAlias = path.join(f.home, 'root-alias'); + dirSymlink(f.pluginsDir, rootAlias); + const logicalEntry = { ...entry, root: path.join(rootAlias, 'managed', id) }; + writeJSON(f.registryFile, { version: 1, plugins: [logicalEntry] }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [{ id, enabled: true }], conflicts: [] }); +}); + +test('a logical sourceDirectory matches a physical originalSource by directory identity', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + writeJSON(f.registryFile, { version: 1, plugins: [entry] }); + const sourceAlias = path.join(f.home, 'source-alias'); + dirSymlink(path.dirname(f.sourceDirectory), sourceAlias); + const logicalSource = path.join(sourceAlias, 'native-model-roles'); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: logicalSource }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [{ id, enabled: true }], conflicts: [] }); +}); + +test('a logical originalSource matches a physical sourceDirectory by directory identity', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + const sourceAlias = path.join(f.home, 'source-alias'); + dirSymlink(path.dirname(f.sourceDirectory), sourceAlias); + const logicalSource = path.join(sourceAlias, 'native-model-roles'); + const logicalEntry = { ...entry, originalSource: logicalSource }; + writeJSON(f.registryFile, { version: 1, plugins: [logicalEntry] }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [{ id, enabled: true }], conflicts: [] }); +}); + +test('ordinary in-place directories with an existing sourceDirectory are owned', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + writeJSON(f.registryFile, { version: 1, plugins: [entry] }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [{ id, enabled: true }], conflicts: [] }); +}); + +test('a moved physical tree reached through its original alias is owned by directory identity', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + writeJSON(f.registryFile, { version: 1, plugins: [entry] }); + const moved = path.join(f.home, 'moved-plugins'); + fs.renameSync(f.pluginsDir, moved); + dirSymlink(moved, f.pluginsDir); // original path becomes an alias to the moved tree + const movedEntry = { ...entry, root: path.join(moved, 'managed', id) }; + fs.writeFileSync(f.registryFile, JSON.stringify({ version: 1, plugins: [movedEntry] }, null, 2) + '\n'); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [{ id, enabled: true }], conflicts: [] }); +}); + +test('disable through an ancestor-alias registry disables only the verified record', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + const unselected = f.addPhysical(oldID); + writeJSON(f.registryFile, { version: 1, plugins: [entry, unselected] }); + const alias = path.join(f.home, 'registry-alias'); + dirSymlink(f.physicalRoot, alias); + let disabled; + expect(() => { + disabled = disableRolePluginsSync({ + registryFile: path.join(alias, 'plugins', 'installed.json'), sourceDirectory: f.sourceDirectory, ids: [id], + }); + }).not.toThrow(); + expect(disabled).toEqual({ + changed: true, disabled: [id], owned: [{ id, enabled: false }, { id: oldID, enabled: true }], conflicts: [], + }); + expect(readJSON(f.registryFile).plugins.map(plugin => [plugin.id, plugin.enabled])) + .toEqual([[id, false], [oldID, true]]); +}); + +test('deleting the source at the exact same path still permits inspect/disable of a cached registration', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + writeJSON(f.registryFile, { version: 1, plugins: [entry] }); + fs.rmSync(f.sourceDirectory, { recursive: true, force: true }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [{ id, enabled: true }], conflicts: [] }); + let disabled; + expect(() => { + disabled = disableRolePluginsSync({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory, ids: [id] }); + }).not.toThrow(); + expect(disabled).toEqual({ changed: true, disabled: [id], owned: [{ id, enabled: false }], conflicts: [] }); +}); + +test('a different source path that does not exist is never proven equivalent', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + const missingSource = path.join(f.home, 'missing', 'native-model-roles'); + writeJSON(f.registryFile, { version: 1, plugins: [{ ...entry, originalSource: missingSource }] }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [], conflicts: [id] }); +}); + +test('a different physical root with the same id and manifest is never owned', () => { + const f = symlinkFixture(); + f.addPhysical(); + const decoy = path.join(f.home, 'decoy', 'managed', id); + writeJSON(path.join(decoy, 'kimi.plugin.json'), manifest(id)); + const entry = { id, root: decoy, source: 'local-path', enabled: true, originalSource: f.sourceDirectory }; + writeJSON(f.registryFile, { version: 1, plugins: [entry] }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [], conflicts: [id] }); +}); + +test('a direct managed-directory symlink supplied through entry.root is rejected', () => { + const f = symlinkFixture(); + f.addPhysical(); + const managedLink = path.join(f.home, 'managed-link'); + dirSymlink(f.managedDir, managedLink); + const entry = { id, root: path.join(managedLink, id), source: 'local-path', enabled: true, originalSource: f.sourceDirectory }; + writeJSON(f.registryFile, { version: 1, plugins: [entry] }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [], conflicts: [id] }); +}); + +test('a direct root symlink supplied as entry.root is rejected', () => { + const f = symlinkFixture(); + f.addPhysical(); + const idLink = path.join(f.managedDir, 'linked-id'); + dirSymlink(f.rootFor(id), idLink); // managed/linked-id -> managed/id + const entry = { id, root: idLink, source: 'local-path', enabled: true, originalSource: f.sourceDirectory }; + writeJSON(f.registryFile, { version: 1, plugins: [entry] }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [], conflicts: [id] }); +}); + +test('a different existing source directory is never proven equivalent', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + const otherSource = path.join(f.home, 'other', 'native-model-roles'); + fs.mkdirSync(otherSource, { recursive: true }); + writeJSON(f.registryFile, { version: 1, plugins: [{ ...entry, originalSource: otherSource }] }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [], conflicts: [id] }); +}); + +test('two different source paths resolving to the same regular file are not directory equivalence', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + const sourceDirReal = path.join(f.home, 'source-dir'); + fs.mkdirSync(sourceDirReal, { recursive: true }); + fs.writeFileSync(path.join(sourceDirReal, 'source-file.txt'), 'not a directory'); + const sourceDirAlias = path.join(f.home, 'source-dir-alias'); + dirSymlink(sourceDirReal, sourceDirAlias); // ancestor directory alias; no file symlink privileges needed + const sourceFile = path.join(sourceDirReal, 'source-file.txt'); + const sourceFileAlias = path.join(sourceDirAlias, 'source-file.txt'); + writeJSON(f.registryFile, { version: 1, plugins: [{ ...entry, originalSource: sourceFileAlias }] }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: sourceFile }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [], conflicts: [id] }); +}); + +test('a missing managed root remains a conflict even with the same literal root', () => { + const f = symlinkFixture(); + const entry = f.addPhysical(); + writeJSON(f.registryFile, { version: 1, plugins: [entry] }); + fs.rmSync(f.managedDir, { recursive: true, force: true }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [], conflicts: [id] }); +}); + +test('a sibling directory under the same managed parent is never owned even with a matching manifest', () => { + const f = symlinkFixture(); + f.addPhysical(); // healthy expected id directory + manifest at managed/id + const sibling = path.join(f.managedDir, 'sibling'); + writeJSON(path.join(sibling, 'kimi.plugin.json'), manifest(id)); // matching id manifest content + const entry = { id, root: sibling, source: 'local-path', enabled: true, originalSource: f.sourceDirectory }; + writeJSON(f.registryFile, { version: 1, plugins: [entry] }); + let inspected; + expect(() => { + inspected = inspectRolePlugins({ registryFile: f.registryFile, sourceDirectory: f.sourceDirectory }); + }).not.toThrow(); + expect(inspected).toEqual({ owned: [], conflicts: [id] }); +}); diff --git a/packages/kimi-desktop/rpc.mjs b/packages/kimi-desktop/rpc.mjs index 04174bdef..c1eece0b6 100644 --- a/packages/kimi-desktop/rpc.mjs +++ b/packages/kimi-desktop/rpc.mjs @@ -12,7 +12,9 @@ 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', 'kimiPlugin.installPlugin', 'kimiPlugin.updatePlugin', + 'kimiPlugin.getPluginInfo', 'kimiPlugin.setPluginEnabled', 'kimiPlugin.removePlugin'].includes(method)) throw Error('unsupported_control_method'); const endpoint = controlEndpoint(state); return new Promise((resolve, reject) => { let socket, settled = false; @@ -35,7 +37,10 @@ export async function rpc(state, method, params = {}, { timeout = 6000, Socket = let message; try { message = JSON.parse(String(event.data)); } catch { return; } if (message.id !== 1) return; - finish(message.error ? 'kimi_control_rejected' : null, message.result); + const pluginMissing = method.startsWith('kimiPlugin.') + && message.error?.code === -32003 + && message.error?.data?.reasonCode === 'KIMI_PLUGIN_NOT_FOUND'; + finish(message.error ? (pluginMissing ? 'kimi_plugin_not_found' : 'kimi_control_rejected') : null, message.result); }); } catch { finish('kimi_control_unavailable'); } }); diff --git a/packages/kimi-desktop/runtime-contract.test.mjs b/packages/kimi-desktop/runtime-contract.test.mjs new file mode 100644 index 000000000..3963667c5 --- /dev/null +++ b/packages/kimi-desktop/runtime-contract.test.mjs @@ -0,0 +1,127 @@ +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'; +import { nativeRolePluginPeer } from './test-fixtures/control-peer.mjs'; + +const rows = normalizeModels({ data: ['synthetic/main', 'synthetic/secondary'].map(id => ({ id })) }); +const [primary, secondary] = rows; +const write = (file, text) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); +}; +function fixture() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'kimi-bridge-contract-')); + const adapter = new KimiAdapter({ home: directory, kimiDir: path.join(directory, 'kimi'), stateDir: path.join(directory, 'state') }); + adapter.state = { active: true, model: primary.id }; + adapter.live = { model: primary.id }; + adapter.catalog = rows; + write(adapter.connectionFile, JSON.stringify({ enabled: true, model: primary.id, + secondaryModel: secondary.id, gateway: { key: '' } })); + const runner = { lifecycleStatus: 'running', kernel: { agentKernel: 'kimi-code-v2' }, activeKernelTurns: 0, activeKernelToolCalls: 0 }; + const plugins = nativeRolePluginPeer(path.dirname(adapter.convertedNativeConfigFile)); + const calls = []; + adapter.rpcCall = async (_runner, method, params) => { + calls.push({ method, params }); + if (plugins.handles(method)) return plugins.call(method, params); + if (method === 'kimiPlugin.reloadActiveSessions') return { sessions: [] }; + if (method === 'kimiPlugin.ReloadSession') return { status: 'reloaded' }; + throw Error('unexpected public RPC'); + }; + return { adapter, runner, plugins, calls, cleanup: () => fs.rmSync(directory, { recursive: true, force: true }) }; +} + +test('V2 installs declared Agent profiles without depending on converted model definitions', async () => { + const f = fixture(); + try { + let writes = 0; + f.adapter.commitNativeSecondary = () => { writes++; throw Error('unexpected-native-write'); }; + expect(await f.adapter.reconcileSecondary(f.runner, secondary.id, { inheritModel: primary.id })).toEqual({ applied: true }); + expect((await f.adapter.readSettings()).appliedSecondaryModel).toBe(secondary.id); + expect(f.plugins.calls.map(call => call.method)).toEqual([ + 'kimiPlugin.installPlugin', 'kimiPlugin.getPluginInfo', 'kimiPlugin.setPluginEnabled', + ]); + expect(f.calls.at(-1).method).toBe('kimiPlugin.reloadActiveSessions'); + expect(writes).toBe(0); + expect(fs.existsSync(f.adapter.nativeConfigFile)).toBe(false); + expect(fs.existsSync(f.adapter.convertedNativeConfigFile)).toBe(false); + } finally { f.cleanup(); } +}); + +for (const family of ['kimi-code', 'future-kernel', undefined]) { + test(`an explicit secondary remains unsupported on a non-V2 kernel (${family ?? 'missing metadata'})`, async () => { + const f = fixture(); + try { + if (family) f.runner.kernel.agentKernel = family; else delete f.runner.kernel; + await expect(f.adapter.reconcileSecondary(f.runner, secondary.id, { inheritModel: primary.id })) + .rejects.toThrow('secondary_model_unsupported_runtime'); + expect((await f.adapter.readSettings()).appliedSecondaryModel).toBeUndefined(); + expect(f.plugins.calls).toHaveLength(0); + expect(f.calls).toHaveLength(0); + expect(f.adapter.rolesReady).toBe(false); + expect(await f.adapter.reconcileSecondary(f.runner, '', { inheritModel: primary.id })).toEqual({ applied: true }); + expect(f.adapter.bridgeSecondaryModel).toBe(''); + expect((await f.adapter.readSettings()).appliedSecondaryModel).toBeUndefined(); + } finally { f.cleanup(); } + }); +} + +test('native model document rewrites do not replace the confirmed public role plugin', async () => { + const f = fixture(); + try { + await f.adapter.reconcileSecondary(f.runner, secondary.id, { inheritModel: primary.id }); + for (const native of ['', 'not a TOML document [', 'default_model = "native"\n[models.native]\nprovider = "unrelated"\n']) { + write(f.adapter.convertedNativeConfigFile, native); + expect((await f.adapter.readSettings()).appliedSecondaryModel).toBe(secondary.id); + await f.adapter.reconcileSecondary(f.runner, secondary.id, { inheritModel: primary.id }); + expect(fs.readFileSync(f.adapter.convertedNativeConfigFile, 'utf8')).toBe(native); + } + } finally { f.cleanup(); } +}); + +test('bridge application still rejects a model outside the gateway catalog', async () => { + const f = fixture(); + try { + await expect(f.adapter.reconcileSecondary(f.runner, 'hidden/model', { inheritModel: primary.id })) + .rejects.toThrow('secondary_model_unavailable'); + expect(f.adapter.bridgeSecondaryModel).toBeUndefined(); + expect(f.adapter.state.secondary).toBeUndefined(); + expect(f.adapter.state.secondaryPool).toBeUndefined(); + } finally { f.cleanup(); } +}); + +test('a changed bridge selection waits for idle while the current selection remains usable', async () => { + const f = fixture(); + try { + await f.adapter.reconcileSecondary(f.runner, secondary.id, { inheritModel: primary.id }); + f.runner.activeKernelTurns = 1; + expect(await f.adapter.reconcileSecondary(f.runner, '', { inheritModel: primary.id })).toEqual({ waiting: true }); + expect(f.adapter.bridgeSecondaryModel).toBe(secondary.id); + expect(await f.adapter.reconcileSecondary(f.runner, secondary.id, { inheritModel: primary.id })).toEqual({ applied: true }); + f.runner.activeKernelTurns = 0; + await f.adapter.reconcileSecondary(f.runner, '', { inheritModel: primary.id }); + expect(f.adapter.bridgeSecondaryModel).toBe(''); + await f.adapter.reconcileSecondary(f.runner, ''); + expect(f.adapter.bridgeSecondaryModel).toBe(''); + } finally { f.cleanup(); } +}); + +test('the adapter persists one routing secret across reconstruction without storing a native model snapshot', async () => { + const f = fixture(); + try { + await f.adapter.reconcileSecondary(f.runner, secondary.id, { inheritModel: primary.id }); + const saved = JSON.parse(fs.readFileSync(f.adapter.stateFile, 'utf8')); + expect(saved.agentRoutingSecret).toMatch(/^[a-f0-9]{64}$/); + expect(saved.secondaryPool).toBeUndefined(); + expect(saved.secondary).toBeUndefined(); + const reconstructed = new KimiAdapter({ stateDir: f.adapter.stateDir, kimiDir: f.adapter.kimiDir }); + reconstructed.state = saved; + reconstructed.catalog = rows; + reconstructed.rpcCall = f.adapter.rpcCall; + await reconstructed.reconcileSecondary(f.runner, secondary.id, { inheritModel: primary.id }); + expect(reconstructed.agentRouterSecret).toBe(saved.agentRoutingSecret); + expect(JSON.parse(fs.readFileSync(f.adapter.stateFile, 'utf8'))).toEqual(saved); + } finally { f.cleanup(); } +}); diff --git a/packages/kimi-desktop/runtime-family.mjs b/packages/kimi-desktop/runtime-family.mjs new file mode 100644 index 000000000..09c797432 --- /dev/null +++ b/packages/kimi-desktop/runtime-family.mjs @@ -0,0 +1,17 @@ +import crypto from 'node:crypto'; + +// Explicit running-backend metadata wins over any leftover configuration file. +// Older runners without that field retain the legacy path only when there is +// no converted file or V2 ownership that would make the backend ambiguous. +export function kernelFamily(runner, { hasConverted = false, hasPoolOwnership = false } = {}) { + const type = runner?.kernel?.agentKernel; + if (type === 'kimi-code-v2') return 'v2'; + if (type === 'kimi-code') return 'legacy'; + if (type !== undefined || hasConverted || hasPoolOwnership) return 'unknown'; + return 'legacy'; +} + +export function runnerGeneration(runner) { + return crypto.createHash('sha256').update(JSON.stringify([runner?.daemonGeneration, runner?.control?.endpoint?.url, + runner?.control?.endpoint?.startedAt, runner?.control?.endpoint?.pid])).digest('hex'); +} 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..fa5e361f6 --- /dev/null +++ b/packages/kimi-desktop/secondary-model.test.mjs @@ -0,0 +1,647 @@ +// 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 { createServer } from 'node:http'; +import { KimiAdapter } from './adapter.mjs'; +import { normalizeModels } from './catalog.mjs'; +import { inspectSecondary } from './secondary-config.mjs'; +import { startSettingsServer, TOKEN_HEADER } from './settings.mjs'; +import { nativeRolePluginPeer } from './test-fixtures/control-peer.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' && call.runtime === 'v2-restore'); +const sessionCalls = calls => calls.filter(call => call.method === 'kimiPlugin.ReloadSession' && call.runtime === 'v2-restore'); + +async function settingsPost(controller, body) { + const reservation = createServer(); + await new Promise((resolve, reject) => { + reservation.once('error', reject); + reservation.listen(0, '127.0.0.1', resolve); + }); + const port = reservation.address().port; + await new Promise(resolve => reservation.close(resolve)); + const service = await startSettingsServer({ port, + readSettings: () => controller.readSettings(), saveSettings: patch => controller.saveSettings(patch) }); + try { + const base = `http://127.0.0.1:${port}`; + const page = await (await fetch(base)).text(); + const token = /name="magpie-kimi-settings-token" content="([0-9a-f]{64})"/.exec(page)?.[1]; + const response = await fetch(base + '/api/settings', { + method: 'POST', headers: { Origin: 'null', [TOKEN_HEADER]: token, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: response.status, body: await response.json() }; + } finally { await service.stop(); } +} + +// 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 = {}, family = 'kimi-code-v2' } = {}) { + 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 plugins = nativeRolePluginPeer(path.join(kimiDir, 'runtime', 'kimi-code', 'home')); + 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) => { + const call = { method, params }; calls.push(call); + if (plugins.handles(method)) return plugins.call(method, params); + switch (method) { + case 'models.current': return { current: 'k2d8-preview' }; + case 'conversations.getKimiModelList': return { models: runtimeModels }; + case 'conversations.updateKimiModelList': + runtimeModels = params.models; + { + const current = read(controller.configFile); + current.model.models = Object.fromEntries(runtimeModels.map(row => [row.model, + { model: row.model, provider: 'daimon-kimi-code' }])); + write(controller.configFile, current); + } + if (regenerate) writeNative(runtimeToml()); + return { ok: true }; + case 'conversations.setModel': + { + const current = read(controller.configFile); + current.model.current = params.modelAlias; + write(controller.configFile, current); + } + if (regenerate) writeNative(runtimeToml()); + return { current: params.modelAlias }; + case 'kimiPlugin.reloadActiveSessions': + call.runtime = controller.secondaryReloadRuntime; + return call.runtime === 'role-profiles' ? { sessions: [] } : behavior.reload(); + case 'kimiPlugin.ReloadSession': + call.runtime = controller.secondaryReloadRuntime; + return call.runtime === 'role-profiles' ? { status: 'reloaded' } : 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', kernel: { agentKernel: family }, + 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, behavior, plugins, + get runtimeModels() { return runtimeModels; }, + nativeText, writeNative, + seedOwned({ baselineText = runtimeToml({ secondaryModel: 'native/first', flag: false }), currentText } = {}) { + controller.state ||= { active: false }; + controller.state.secondary = { baseline: inspectSecondary(baselineText), alias: alias.get(secondaryID) }; + controller.state.secondaryModel = secondaryID; + writeNative(currentText ?? runtimeToml({ secondaryModel: alias.get(secondaryID), flag: true })); + write(controller.stateFile, controller.state); + }, + seedMenuAlias() { + const old = alias.get(primaryID).replace('local-model-', 'magpie-local-'); + runtimeModels = [...runtimeModels, { model: old }]; + const current = read(controller.configFile); + current.model.current = old; + current.model.models[old] = { model: old, provider: 'daimon-kimi-code' }; + write(controller.configFile, current); + write(controller.modelBackupFile, { current: 'k2d8-preview' }); + return old; + }, + // 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('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('a catalog-hidden primary is rejected before the host setter or file writes', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const selected = []; + try { + const before = [f.controller.connectionFile, f.controller.configFile, f.nativeFile] + .map(file => fs.readFileSync(file, 'utf8')); + const hadState = fs.existsSync(f.controller.stateFile); + f.controller.setPrimaryModel = async model => { + selected.push(model); + write(f.controller.connectionFile, { ...read(f.controller.connectionFile), model }); + }; + const response = await settingsPost(f.controller, { primaryModel: 'hidden-provider/hidden-model' }); + expect(response).toEqual({ status: 400, body: { error: 'model_not_enabled_for_kimi' } }); + expect(selected).toEqual([]); + expect([f.controller.connectionFile, f.controller.configFile, f.nativeFile] + .map(file => fs.readFileSync(file, 'utf8'))).toEqual(before); + expect(fs.existsSync(f.controller.stateFile)).toBe(hadState); + } finally { await f.cleanup(); } +}); + +for (const includeSecondary of [false, true]) { + test(`a ${includeSecondary ? 'two-model' : 'primary-only'} POST detects a gateway change after the primary setter`, async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const selected = []; + let hostBytes; + try { + 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' } }); + hostBytes = fs.readFileSync(f.controller.connectionFile, 'utf8'); + }; + const response = await settingsPost(f.controller, { primaryModel: secondaryID, + ...(includeSecondary ? { secondaryModel: '' } : {}) }); + expect(response).toEqual({ status: 409, + body: { error: includeSecondary ? 'settings_partially_saved' : 'connection_changed' } }); + expect(selected).toEqual([secondaryID]); + expect(fs.readFileSync(f.controller.connectionFile, 'utf8')).toBe(hostBytes); + expect(read(f.controller.connectionFile).secondaryModel).toBe(secondaryID); + } finally { await f.cleanup(); } + }); +} + +test('a two-model POST reports partial persistence when only the secondary file save fails', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + const rename = fs.renameSync; + const selected = []; + let blockedWrites = 0, hostBytes; + try { + f.controller.setPrimaryModel = async model => { + selected.push(model); + write(f.controller.connectionFile, { ...read(f.controller.connectionFile), model }); + hostBytes = fs.readFileSync(f.controller.connectionFile, 'utf8'); + }; + fs.renameSync = (from, to) => { + if (to === f.controller.connectionFile) { + blockedWrites += 1; + throw Object.assign(Error('synthetic secondary persistence failure'), { code: 'EACCES' }); + } + return rename(from, to); + }; + const response = await settingsPost(f.controller, { primaryModel: secondaryID, secondaryModel: '' }); + expect(response).toEqual({ status: 409, body: { error: 'settings_partially_saved' } }); + expect(blockedWrites).toBe(1); + expect(selected).toEqual([secondaryID]); + expect(fs.readFileSync(f.controller.connectionFile, 'utf8')).toBe(hostBytes); + expect(read(f.controller.connectionFile).model).toBe(secondaryID); + expect(read(f.controller.connectionFile).secondaryModel).toBe(secondaryID); + expect(read(f.controller.connectionFile).gateway).toEqual(f.connection.gateway); + } finally { fs.renameSync = rename; await f.cleanup(); } +}); + +test('disconnect reloads a restored legacy baseline after the last primary control call', async () => { + const baseline = runtimeToml({ secondaryModel: 'native/first', flag: false }); + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + f.seedOwned({ baselineText: baseline, currentText: baseline }); f.seedMenuAlias(); + const before = f.calls.length; + write(f.controller.connectionFile, { ...f.connection, enabled: false }); + f.complete(); await f.controller.tick(); + expect(f.calls.slice(before).map(call => call.method)).toEqual([ + 'conversations.getKimiModelList', 'conversations.setModel', + 'conversations.updateKimiModelList', 'conversations.setModel', 'kimiPlugin.reloadActiveSessions', + 'kimiPlugin.setPluginEnabled', 'kimiPlugin.reloadActiveSessions', + 'kimiPlugin.removePlugin', + ]); + expect(inspectSecondary(f.nativeText())).toEqual(inspectSecondary(baseline)); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(f.controller.status.code).toBe('disconnected'); + } finally { await f.cleanup(); } +}); + +test('disconnect restores legacy scalars even if a pending old session keeps failing', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false, + script: { reload: () => ({ sessions: [{ kernelSessionId: 'old-session', status: 'skipped-active-turn' }] }), + reloadSession: () => { throw Error('synthetic reload failure'); } } }); + try { + f.seedOwned(); + await f.controller.tick(); f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + const attempts = sessionCalls(f.calls).length; + write(f.controller.connectionFile, { ...f.connection, enabled: false }); + f.complete(); await f.controller.tick(); + expect(inspectSecondary(f.nativeText())).toMatchObject({ model: 'native/first', enabled: false }); + expect(read(f.controller.configFile).model.providers['daimon-kimi-code'].baseUrl).toBe(official); + expect(sessionCalls(f.calls)).toHaveLength(attempts); + expect(f.controller.status.code).not.toBe('connected'); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +for (const malformed of [false, true]) { + test(`an ${malformed ? 'unsupported' : 'absent'} unowned legacy file does not block bridge settings or primary routing`, async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false }); + try { + if (malformed) f.writeNative('not TOML ['); else fs.unlinkSync(f.nativeFile); + await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(secondaryID); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + if (malformed) expect(f.nativeText()).toBe('not TOML ['); + else expect(fs.existsSync(f.nativeFile)).toBe(false); + 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('an unrelated converted layout does not pin or invalidate a bridge selection', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); + fs.mkdirSync(path.dirname(f.controller.convertedNativeConfigFile), { recursive: true }); + const converted = 'future_kernel_layout = true\n'; + fs.writeFileSync(f.controller.convertedNativeConfigFile, converted); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(secondaryID); + expect(fs.readFileSync(f.controller.convertedNativeConfigFile, 'utf8')).toBe(converted); + } finally { await f.cleanup(); } +}); + +for (const family of ['kimi-code', 'future-kernel']) { + test(`old legacy ownership restores before an unsupported explicit secondary is reported (${family})`, async () => { + const f = await fixture({ secondaryModel: secondaryID, family, regenerate: false }); + try { + f.seedOwned(); await f.controller.tick(); + expect(inspectSecondary(f.nativeText())).toMatchObject({ model: 'native/first', enabled: false }); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(reloadCalls(f.calls)).toHaveLength(1); + expect(f.controller.status.code).toBe('secondary_model_unsupported_runtime'); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + expect(f.plugins.calls).toHaveLength(0); + expect(f.controller.live.model).toBe(primaryID); + const key = read(f.controller.configFile).credentials.kimiCode.apiKey; + const response = await fetch(f.controller.bridgeURL + '/chat/completions', { method: 'POST', + headers: { Authorization: 'Bearer ' + key }, 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('a new secondary preference never writes a native scalar or experiment flag', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false }); + try { + const before = f.nativeText(); + await f.controller.tick(); + expect(f.nativeText()).toBe(before); + expect(inspectSecondary(f.nativeText())).toMatchObject({ model: null, enabled: null }); + expect(reloadCalls(f.calls)).toHaveLength(0); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(secondaryID); + await f.controller.saveSettings({ secondaryModel: '' }); + f.complete(); await f.controller.tick(); + expect(f.nativeText()).toBe(before); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + expect(f.controller.rolesReady).toBe(false); + } finally { await f.cleanup(); } +}); + +test('normal generator rewrites are not repaired with an old secondary alias', async () => { + const f = await fixture({ secondaryModel: secondaryID, + native: runtimeToml({ secondaryModel: 'native/first', flag: false }) }); + try { + await f.controller.tick(); + // The unowned legacy scalar is put back after the control rewrite from a + // transient memory snapshot, without ownership or a restore reload. + expect(inspectSecondary(f.nativeText())).toMatchObject({ model: 'native/first', enabled: false }); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + const calls = f.calls.length; + f.regenerate(); const regenerated = f.nativeText(); + f.complete(); await f.controller.tick(); + expect(f.nativeText()).toBe(regenerated); + expect(f.calls.slice(calls).every(call => call.method === 'kimiPlugin.getPluginInfo')).toBe(true); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('old scalar restoration is deferred while busy and uses only its narrow persisted baseline', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false }); + try { + await f.controller.tick(); f.seedOwned(); + expect(Object.keys(read(f.controller.stateFile).secondary.baseline).sort()) + .toEqual(['enabled', 'hasExperimentalTable', 'hasSecondaryTable', 'model']); + expect(JSON.stringify(read(f.controller.stateFile).secondary)).not.toContain(f.doc.credentials.kimiCode.apiKey); + write(f.controller.runnerFile, { ...f.runner, activeKernelTurns: 1 }); + const before = f.nativeText(), calls = f.calls.length; + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + expect(f.nativeText()).toBe(before); + expect(f.calls).toHaveLength(calls); + write(f.controller.runnerFile, f.runner); + f.complete(); await f.controller.tick(); + expect(inspectSecondary(f.nativeText())).toMatchObject({ model: 'native/first', enabled: false }); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(reloadCalls(f.calls)).toHaveLength(1); + } finally { await f.cleanup(); } +}); + +test('old skipped sessions are retried individually without repeating acknowledged baseline reloads', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false, + script: { reload: () => ({ sessions: [{ kernelSessionId: 'idle', status: 'reloaded' }, + { kernelSessionId: 'busy', status: 'skipped-active-turn' }] }) } }); + try { + f.seedOwned(); await f.controller.tick(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + f.complete(); await f.controller.tick(); + expect(sessionCalls(f.calls).map(call => call.params)).toEqual([{ kernelSessionId: 'busy' }]); + expect(reloadCalls(f.calls)).toHaveLength(1); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('an unacknowledged old targeted restore remains pending until the explicit reloaded result', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false, + script: { reload: () => ({ sessions: [{ kernelSessionId: 'busy', status: 'skipped-active-turn' }] }), + reloadSession: () => ({}) } }); + try { + f.seedOwned(); await f.controller.tick(); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(read(f.controller.stateFile).secondary).toBeDefined(); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + f.behavior.reloadSession = () => ({ status: 'reloaded' }); + f.complete(); await f.controller.tick(); + expect(f.controller.status.code).toBe('connected'); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBe(secondaryID); + } finally { await f.cleanup(); } +}); + +test('a mixed skipped and failed legacy restoration retries every identified session', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false, + script: { reload: () => ({ sessions: [ + { kernelSessionId: 'mixed-a', status: 'skipped-active-turn' }, { kernelSessionId: 'mixed-b', status: 'failed' }, + ] }) } }); + try { + f.seedOwned(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(read(f.controller.stateFile).secondary).toBeDefined(); + f.complete(); await f.controller.tick(); + expect(sessionCalls(f.calls).map(call => call.params)).toEqual([ + { kernelSessionId: 'mixed-a' }, { kernelSessionId: 'mixed-b' }, + ]); + expect(reloadCalls(f.calls)).toHaveLength(1); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + } finally { await f.cleanup(); } +}); + +test('an unattributed legacy reload row requires a complete later pass before dropping ownership', async () => { + let passes = 0; + const f = await fixture({ secondaryModel: secondaryID, regenerate: false, + script: { reload: () => ++passes === 1 ? { sessions: [ + { kernelSessionId: 'known', status: 'skipped-active-turn' }, { status: 'skipped-active-turn' }, + ] } : { sessions: [] } } }); + try { + f.seedOwned(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + f.complete(); await f.controller.tick(); + expect(read(f.controller.stateFile).secondary).toBeDefined(); + expect(f.controller.status.code).toBe('waiting_kimi_idle'); + f.complete(); await f.controller.tick(); + expect(reloadCalls(f.calls)).toHaveLength(2); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(f.controller.status.code).toBe('connected'); + } finally { await f.cleanup(); } +}); + +test('a foreign legacy edit survives restoration, regeneration and disconnect', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); f.seedOwned(); + f.regenerate({ secondaryModel: 'third-party/手动', flag: false, + secondaryExtra: 'unrelated = "keep"', experimentalExtra: 'other = true' }); + write(f.controller.connectionFile, { ...f.connection, enabled: false }); + await f.controller.tick(); + expect(inspectSecondary(f.nativeText())).toMatchObject({ model: 'third-party/手动', enabled: false }); + expect(read(f.controller.configFile).credentials).toEqual(f.doc.credentials); + expect(read(f.controller.stateFile).secondary).toBeUndefined(); + expect(f.controller.status.code).toBe('disconnected'); + } finally { await f.cleanup(); } +}); + +test('stop restores legacy current/menu and scalars without starting secondary-session reloads', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + try { + await f.controller.tick(); f.seedOwned(); f.seedMenuAlias(); + const before = f.calls.length; + await f.controller.stop(); + expect(read(f.controller.configFile).model.current).toBe('k2d8-preview'); + expect(f.runtimeModels.map(row => row.model)).toEqual(['k2d8-preview']); + expect(Object.keys(read(f.controller.configFile).model.models)).toEqual(['k2d8-preview']); + expect(f.calls.slice(before).map(call => call.method)).toEqual([ + 'conversations.getKimiModelList', 'conversations.setModel', + 'conversations.updateKimiModelList', 'conversations.setModel', + 'kimiPlugin.setPluginEnabled', 'kimiPlugin.reloadActiveSessions', + 'kimiPlugin.removePlugin', + ]); + expect(inspectSecondary(f.nativeText())).toMatchObject({ model: 'native/first', enabled: false }); + expect(fs.existsSync(f.controller.modelBackupFile)).toBe(false); + expect(read(f.controller.stateFile).secondary.restored).toBe(true); + expect(read(f.controller.stateFile).active).toBe(false); + } finally { await f.cleanup(); } +}); + +test('synchronous exit restores old scalars without RPC and a new adapter completes their migration', async () => { + const f = await fixture({ secondaryModel: secondaryID }); + let restarted; + try { + await f.controller.tick(); f.seedOwned(); + const before = f.calls.length; + f.controller.restoreFileOnExit(); + expect(inspectSecondary(f.nativeText())).toMatchObject({ model: 'native/first', enabled: false }); + expect(f.calls).toHaveLength(before); + expect(read(f.controller.stateFile).secondary).toBeDefined(); + 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(inspectSecondary(f.nativeText())).toMatchObject({ model: 'native/first', enabled: false }); + expect(read(restarted.stateFile).secondary).toBeUndefined(); + expect((await restarted.readSettings()).appliedSecondaryModel).toBe(secondaryID); + } finally { await restarted?.stop(); await f.cleanup(); } +}); + +test('a lost legacy restoration acknowledgement retains ownership through a new adapter instance', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false, + script: { reload: () => { throw Error('synthetic lost reply'); } } }); + let restarted; + try { + f.seedOwned(); await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_model_reload_failed'); + expect(inspectSecondary(f.nativeText())).toMatchObject({ model: 'native/first', enabled: false }); + expect(read(f.controller.stateFile).secondary).toBeDefined(); + await f.controller.stop(); + f.behavior.reload = () => ({ sessions: [] }); + 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(read(restarted.stateFile).secondary).toBeUndefined(); + expect((await restarted.readSettings()).appliedSecondaryModel).toBe(secondaryID); + } finally { await restarted?.stop(); await f.cleanup(); } +}); + +test('an unsupported owned legacy file is reported and left intact while the primary route stays available', async () => { + const f = await fixture({ secondaryModel: secondaryID, regenerate: false }); + try { + f.seedOwned({ currentText: 'secondary_model = "unknown-shape"\n' }); + const before = f.nativeText(); + await f.controller.tick(); + expect(f.controller.status.code).toBe('secondary_config_unsupported'); + expect(f.nativeText()).toBe(before); + expect(read(f.controller.stateFile).secondary).toBeDefined(); + expect((await f.controller.readSettings()).appliedSecondaryModel).toBeUndefined(); + 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(); } +}); diff --git a/packages/kimi-desktop/settings-page.test.mjs b/packages/kimi-desktop/settings-page.test.mjs new file mode 100644 index 000000000..53feea9f6 --- /dev/null +++ b/packages/kimi-desktop/settings-page.test.mjs @@ -0,0 +1,871 @@ +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.mjs b/packages/kimi-desktop/settings.mjs new file mode 100644 index 000000000..d045edcfc --- /dev/null +++ b/packages/kimi-desktop/settings.mjs @@ -0,0 +1,220 @@ +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], + ['primary_model_unavailable', 503], + ['primary_model_update_failed', 502], + ['settings_partially_saved', 409], + ['invalid_model_options', 400], + ['unsupported_reasoning_effort', 400], + ['unsupported_context_length', 400], + ['context_control_unavailable', 503], +]); +const OPTION_FIELDS = ['primaryReasoningEffort', 'secondaryReasoningEffort', 'primaryContextTokens', 'secondaryContextTokens']; +const OPTION_STATUSES = new Set(['not_configured', 'pending', 'applied', 'context_unavailable', 'unavailable']); + +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, + ...(Array.isArray(model.reasoningEfforts) ? { reasoningEfforts: model.reasoningEfforts.filter(effort => + ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort)) } : {}), + ...(Object.hasOwn(model, 'contextWindow') ? { contextWindow: + Number.isSafeInteger(model.contextWindow) && model.contextWindow > 0 ? model.contextWindow : null } : {}), + }; + }); + 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; + if (typeof state.appliedPrimaryModel === 'string') view.appliedPrimaryModel = state.appliedPrimaryModel; + for (const role of ['primary', 'secondary']) { + const effort = role + 'ReasoningEffort', context = role + 'ContextTokens', status = role + 'OptionsStatus'; + if (typeof state[effort] === 'string') view[effort] = state[effort]; + if (state[context] === null || (Number.isSafeInteger(state[context]) && state[context] > 0)) view[context] = state[context]; + if (OPTION_STATUSES.has(state[status])) view[status] = state[status]; + } + 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 === 0 + || Object.keys(body).some(key => key !== 'primaryModel' && key !== 'secondaryModel' && !OPTION_FIELDS.includes(key)) + || (Object.hasOwn(body, 'primaryModel') && (typeof body.primaryModel !== 'string' || !body.primaryModel)) + || (Object.hasOwn(body, 'secondaryModel') && typeof body.secondaryModel !== 'string') + || OPTION_FIELDS.some(key => Object.hasOwn(body, key) && (key.endsWith('ReasoningEffort') + ? typeof body[key] !== 'string' + : body[key] !== null && (!Number.isSafeInteger(body[key]) || body[key] <= 0)))) { + 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(body)), { 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..efa61afa9 --- /dev/null +++ b/packages/kimi-desktop/settings.test.mjs @@ -0,0 +1,263 @@ +import { expect, test } from 'bun:test'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { createServer, request } from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { KimiAdapter } from './adapter.mjs'; +import KimiDesktop from './index.mjs'; +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 = /