opencode-cache-engine 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ import {
5
5
  DIGEST_TEMPLATE,
6
6
  POLICY_GLM53,
7
7
  POLICY_GPT56,
8
+ POLICY_MIMO26,
8
9
  POLICY_NEUTRAL,
9
10
  createRecorder,
10
11
  detectPolicy,
@@ -16,10 +17,13 @@ import {
16
17
  gptCacheOptionsDelta,
17
18
  hitRatePct,
18
19
  loadConfig,
20
+ mimoHitRate,
21
+ mimoSessionIdFor,
19
22
  nextProcessedCursor,
20
23
  observeReasoningEffort,
21
24
  policyEnabled,
22
25
  prefixChangeReasons,
26
+ providerChangeEvent,
23
27
  reasoningEffortFromOptions,
24
28
  reasoningIssueReasons,
25
29
  relocateVolatileEnvBlock,
@@ -37,7 +41,7 @@ import {
37
41
  // cache-engine
38
42
  //
39
43
  // Provider-aware prompt-cache observability + conservative cache-shape
40
- // preservation for ONE OpenCode TUI across three model families:
44
+ // preservation for ONE OpenCode TUI across four model families:
41
45
  //
42
46
  // DeepSeek V4.1 Flash -> pure passive. >99.66% hit rate is preserved by never
43
47
  // mutating system/options/requests. Observability only.
@@ -52,16 +56,28 @@ import {
52
56
  // history stable, and INSTRUMENT preserved-thinking
53
57
  // integrity (duplicate/reorder/modified reasoning). No
54
58
  // invented cache key (Z.ai exposes none).
59
+ // MiMo-V2.6 -> prefix stability + OpenRouter session affinity. The
60
+ // safe env-block relocation is applied (stabilizeSystem)
61
+ // and provider switches within a session are diagnosed.
62
+ // MiMo caching is provider-managed implicit context
63
+ // caching; no cache key/breakpoint/TTL is invented. The
64
+ // OpenRouter sticky-session id is derived but currently
65
+ // NOT injected: this runtime's OpenRouter request
66
+ // adapter forwards only usage/reasoning/prompt_cache_key
67
+ // and exposes no top-level `session_id` path (verified
68
+ // against the installed runtime; see README).
55
69
  //
56
70
  // The engine remains conservative: it observes, hashes, compares, records,
57
71
  // appends a compaction continuation template, and (for GPT-5.6 only) injects
58
72
  // documented cache options. It never rewrites message history, reorders tools,
59
73
  // or alters user content. DeepSeek and neutral models are byte-untouched.
74
+ // MiMo/GLM only relocate the identifiable volatile env block, content-preserving.
60
75
  //
61
76
  // IMPORTANT (terminology): local hashes describe the *observed* prefix shape.
62
77
  // A changed hash means request bytes changed; it is NOT proof the provider's
63
78
  // cache key changed or that a cache miss occurred. Provider-reported cache
64
- // token counts are authoritative; hashes are diagnostics only.
79
+ // token counts are authoritative; hashes are diagnostics only. For MiMo, the
80
+ // authoritative cache signal is provider-reported cached_tokens.
65
81
  // ---------------------------------------------------------------------------
66
82
 
67
83
  const TOOL_FETCH_TTL_MS = 1500
@@ -113,6 +129,7 @@ type SessionState = {
113
129
  cacheRootAt: number | null
114
130
  reasoningSeen: Map<string, number>
115
131
  reasoningLastSeq: string[] | null
132
+ mimoProvider: { providerID: string; modelID: string } | null
116
133
  }
117
134
 
118
135
  const emptyShape = (): Shape => ({
@@ -154,6 +171,7 @@ export const CacheEngine: Plugin = async ({ client, directory }) => {
154
171
  cacheRootAt: null,
155
172
  reasoningSeen: new Map(),
156
173
  reasoningLastSeq: null,
174
+ mimoProvider: null,
157
175
  }
158
176
  sessions.set(sid, s)
159
177
  }
@@ -397,6 +415,25 @@ export const CacheEngine: Plugin = async ({ client, directory }) => {
397
415
  recFields.promptTokens = read + write + input
398
416
  recFields.glmHitRate = glmHitRatio(read, write, input)
399
417
  }
418
+ if (family === POLICY_MIMO26) {
419
+ // Provider-reported cached tokens / total prompt tokens. The runtime's
420
+ // `input` is the non-cached prompt input and `cache.read` is the
421
+ // cached prompt input, so total prompt tokens are derived as read +
422
+ // input (cache.write is a separate accounting bucket). No cache-write
423
+ // value is fabricated; the ratio is null when prompt tokens are 0.
424
+ const promptTokens = read + input
425
+ recFields.promptTokens = promptTokens
426
+ recFields.cachedTokens = read
427
+ recFields.cacheHitRate = mimoHitRate(read, promptTokens)
428
+ if (cfg.policies?.[POLICY_MIMO26]?.stickySession === true) {
429
+ recFields.stickySessionId = mimoSessionIdFor(sid)
430
+ }
431
+ // Prefer the latest live provider identity over the latched one.
432
+ if (s.mimoProvider) {
433
+ recFields.provider = s.mimoProvider.providerID
434
+ recFields.model = s.mimoProvider.modelID
435
+ }
436
+ }
400
437
  if (family === POLICY_GPT56 && s.gptInjected) {
401
438
  recFields.keyStrategy = "session"
402
439
  recFields.mode = cfg.policies?.[POLICY_GPT56]?.mode
@@ -445,6 +482,48 @@ export const CacheEngine: Plugin = async ({ client, directory }) => {
445
482
  try {
446
483
  const info = rememberModel(input.sessionID, input.model as unknown as ChatParamsModel)
447
484
  const family = info?.family
485
+
486
+ // ---- MiMo-V2.6: provider-switch diagnostics (telemetry only) ---------
487
+ // MiMo cache lives at the provider side, so a provider change within one
488
+ // OpenCode session can silently invalidate it. We record identity on every
489
+ // MiMo request and emit a diagnostic when the OpenCode providerID changes.
490
+ // This never forces or overrides provider routing.
491
+ // NOTE: OpenRouter's *upstream* provider selection (e.g. xiaomi/fp8) is
492
+ // not exposed to plugins; only the OpenCode providerID/modelID are
493
+ // observable here.
494
+ if (family === POLICY_MIMO26 && policyEnabled(cfg, POLICY_MIMO26) && info) {
495
+ // Use the LIVE model identity (not the latched one) so a provider
496
+ // switch within the session is actually observable.
497
+ const live = input.model as unknown as ChatParamsModel
498
+ const cur = {
499
+ providerID: String(live?.providerID ?? ""),
500
+ modelID: String(live?.api?.id ?? live?.id ?? ""),
501
+ }
502
+ if (cur.providerID) {
503
+ const s = get(input.sessionID)
504
+ const ev = providerChangeEvent(s.mimoProvider, cur)
505
+ if (ev.changed) {
506
+ const sticky =
507
+ cfg.policies?.[POLICY_MIMO26]?.stickySession === true
508
+ ? { stickySessionId: mimoSessionIdFor(input.sessionID) }
509
+ : {}
510
+ rec.record({
511
+ kind: "boundary",
512
+ sid: input.sessionID,
513
+ ts: Date.now(),
514
+ reason: "mimo_provider_changed",
515
+ policy: POLICY_MIMO26,
516
+ from: ev.from,
517
+ to: ev.to,
518
+ ...sticky,
519
+ note: "OpenCode providerID changed; upstream routing is not plugin-visible",
520
+ })
521
+ }
522
+ s.mimoProvider = cur
523
+ }
524
+ return
525
+ }
526
+
448
527
  if (!(family === POLICY_GPT56 && policyEnabled(cfg, POLICY_GPT56))) {
449
528
  // DeepSeek / GLM / neutral: nothing to inject. GLM has no cache-key API;
450
529
  // DeepSeek caching is fully passive; we never mutate requests for them.
@@ -626,28 +705,47 @@ export const CacheEngine: Plugin = async ({ client, directory }) => {
626
705
  const s = get(sid)
627
706
  const family = s.modelInfo?.family
628
707
 
629
- // ---- GLM-5.3 input-shape stabilization -----------------------------
708
+ // ---- GLM-5.3 / MiMo-V2.6 input-shape stabilization ------------------
630
709
  // Relocate the identifiable volatile env block (per-day date) to the
631
710
  // tail of the single system string, content-preserving, ONLY when the
632
- // model is GLM-5.3 and the block markers are present exactly. Never
633
- // touches other content/order; never applied to other families.
711
+ // family opts into system stabilization and the block markers are
712
+ // present exactly. Never touches other content/order; never applied to
713
+ // other families. The runtime passes a single-element system array
714
+ // (verified against the installed runtime), so no generic reordering is
715
+ // involved.
634
716
  //
635
717
  // In-place mutation note: request.ts keeps using its own local `system`
636
718
  // array after the hook (the trigger's returned output is ignored), so
637
719
  // reassigning `output.system = [...]` would be lost. We rewrite the
638
720
  // single element in place instead.
639
721
  let systemText = output.system.join("\n")
640
- if (
722
+ const glmStabilize =
641
723
  family === POLICY_GLM53 &&
642
724
  policyEnabled(cfg, POLICY_GLM53) &&
643
- cfg.policies?.[POLICY_GLM53]?.stabilizeSystem === true &&
644
- output.system.length === 1
645
- ) {
725
+ cfg.policies?.[POLICY_GLM53]?.stabilizeSystem === true
726
+ const mimoStabilize =
727
+ family === POLICY_MIMO26 &&
728
+ policyEnabled(cfg, POLICY_MIMO26) &&
729
+ cfg.policies?.[POLICY_MIMO26]?.stabilizeSystem === true
730
+ if ((glmStabilize || mimoStabilize) && output.system.length === 1) {
646
731
  const rel = relocateVolatileEnvBlock(output.system[0])
647
732
  if (rel.changed) {
648
733
  output.system[0] = rel.text
649
734
  systemText = rel.text
650
- log("debug", "glm system env block relocated to suffix", { sid })
735
+ if (family === POLICY_MIMO26) {
736
+ log("debug", "mimo system env block relocated to suffix", { sid })
737
+ rec.record({
738
+ kind: "boundary",
739
+ sid,
740
+ ts: Date.now(),
741
+ reason: "mimo_system_env_relocated",
742
+ policy: POLICY_MIMO26,
743
+ provider: s.modelInfo?.providerID,
744
+ model: s.modelInfo?.modelID,
745
+ })
746
+ } else {
747
+ log("debug", "glm system env block relocated to suffix", { sid })
748
+ }
651
749
  }
652
750
  }
653
751
 
@@ -724,6 +822,22 @@ export const CacheEngine: Plugin = async ({ client, directory }) => {
724
822
  ? { toolCount, prevToolCount: prevCount, semanticToolsChanged: semanticChanged, wireToolsChanged: wireChanged }
725
823
  : {}),
726
824
  })
825
+ // MiMo-specific explicit diagnostic: the STABLE prefix changed (not
826
+ // just the relocated volatile env suffix). Reported only; the new
827
+ // content is never overwritten with a stale snapshot.
828
+ if (family === POLICY_MIMO26 && reasons.includes("system_stable_prefix_changed")) {
829
+ rec.record({
830
+ kind: "boundary",
831
+ sid,
832
+ ts: Date.now(),
833
+ reason: "mimo_system_prefix_changed",
834
+ policy: POLICY_MIMO26,
835
+ provider: s.modelInfo?.providerID,
836
+ model: s.modelInfo?.modelID,
837
+ changedFields: granular,
838
+ reasons,
839
+ })
840
+ }
727
841
  if (cfg.logPrefixChanges) {
728
842
  log("warn", "observed prefix shape change", {
729
843
  sid,
@@ -16,6 +16,7 @@ import {
16
16
  POLICY_DEEPSEEK,
17
17
  POLICY_GLM53,
18
18
  POLICY_GPT56,
19
+ POLICY_MIMO26,
19
20
  POLICY_NEUTRAL,
20
21
  canonicalStringify,
21
22
  commonPrefixLength,
@@ -28,8 +29,11 @@ import {
28
29
  gptCacheOptionsDelta,
29
30
  hitRatePct,
30
31
  loadConfig,
32
+ mimoHitRate,
33
+ mimoSessionIdFor,
31
34
  nextProcessedCursor,
32
35
  parseConfig,
36
+ providerChangeEvent,
33
37
  relocateVolatileEnvBlock,
34
38
  scanPage,
35
39
  shapeDiff,
@@ -221,6 +225,30 @@ test("recorder writes valid JSONL to a real file", () => {
221
225
  assert.deepEqual(JSON.parse(lines[0]), { kind: "prefix-change", sid: "s", dimensions: ["system"] })
222
226
  })
223
227
 
228
+ // --- 14. TUI target test -----------------------------------------------------
229
+
230
+ test("TUI target exports the expected plugin module", async () => {
231
+ const mod = await import("../src/tui.mjs")
232
+ assert.equal(mod.default.id, "opencode-cache-engine")
233
+ assert.equal(typeof mod.default.tui, "function")
234
+ assert.equal("server" in mod.default, false)
235
+ })
236
+
237
+ // --- 15. Package manifest test with TUI --------------------------------------
238
+
239
+ test("package exposes separate server and TUI targets", async () => {
240
+ const { readFileSync } = await import("node:fs")
241
+ const { join } = await import("node:path")
242
+
243
+ const pkg = JSON.parse(
244
+ readFileSync(join(process.cwd(), "package.json"), "utf8")
245
+ )
246
+
247
+ assert.equal(pkg.exports["./server"], "./src/cache-engine.ts")
248
+ assert.equal(pkg.exports["./tui"], "./src/tui.mjs")
249
+ })
250
+
251
+
224
252
  // ===========================================================================
225
253
  // Provider-aware model detection
226
254
  // ===========================================================================
@@ -269,6 +297,32 @@ test("unrelated GLM models do NOT match GLM-5.3 policy", () => {
269
297
  assert.equal(detectPolicy(M("zai", "glm-4.5")), POLICY_NEUTRAL)
270
298
  })
271
299
 
300
+ test("MiMo V2.6 Flash/Pro match MiMo policy (openrouter + direct)", () => {
301
+ assert.equal(detectPolicy(M("openrouter", "xiaomi/mimo-v2.6-flash")), POLICY_MIMO26)
302
+ assert.equal(detectPolicy(M("openrouter", "xiaomi/mimo-v2.6-pro")), POLICY_MIMO26)
303
+ assert.equal(detectPolicy(M("xiaomi", "mimo-v2.6-flash")), POLICY_MIMO26)
304
+ assert.equal(detectPolicy(M("xiaomi", "mimo-v2.6-pro")), POLICY_MIMO26)
305
+ // full Model shape via api.id
306
+ assert.equal(
307
+ detectPolicy({ providerID: "openrouter", api: { id: "xiaomi/mimo-v2.6-flash", npm: "@openrouter/ai-sdk-provider" } }),
308
+ POLICY_MIMO26,
309
+ )
310
+ })
311
+
312
+ test("MiMo V2.5 and Pro-UltraSpeed do NOT match MiMo policy", () => {
313
+ assert.equal(detectPolicy(M("openrouter", "xiaomi/mimo-v2.5")), POLICY_NEUTRAL)
314
+ assert.equal(detectPolicy(M("openrouter", "xiaomi/mimo-v2.5-pro")), POLICY_NEUTRAL)
315
+ assert.equal(detectPolicy(M("xiaomi", "mimo-v2.5")), POLICY_NEUTRAL)
316
+ assert.equal(detectPolicy(M("xiaomi", "mimo-v2.6-pro-ultraspeed")), POLICY_NEUTRAL)
317
+ assert.equal(detectPolicy(M("xiaomi", "mimo-v2.6-flashx")), POLICY_NEUTRAL)
318
+ })
319
+
320
+ test("unrelated MiMo/other models do NOT match MiMo policy", () => {
321
+ assert.equal(detectPolicy(M("openrouter", "xiaomi/mimo-v2")), POLICY_NEUTRAL)
322
+ assert.equal(detectPolicy(M("openrouter", "xiaomi/mimo-v2-flash")), POLICY_NEUTRAL)
323
+ assert.equal(detectPolicy(M("anthropic", "claude-sonnet-4-5")), POLICY_NEUTRAL)
324
+ })
325
+
272
326
  test("unrelated models match neutral policy", () => {
273
327
  assert.equal(detectPolicy(M("anthropic", "claude-sonnet-4-5")), POLICY_NEUTRAL)
274
328
  assert.equal(detectPolicy(M("openrouter", "x-ai/grok-4")), POLICY_NEUTRAL)
@@ -535,10 +589,16 @@ test("empty current sequence -> no anomalies", () => {
535
589
  // Config: provider policies
536
590
  // ===========================================================================
537
591
 
538
- test("config defaults enable all three policies", () => {
592
+ test("config defaults enable all four policies", () => {
539
593
  const cfg = parseConfig({}, {})
540
594
  assert.deepEqual(cfg.policies.deepseek, { enabled: true })
541
595
  assert.deepEqual(cfg.policies.glm53, { enabled: true, stabilizeSystem: true, preserveThinkingIntegrity: true })
596
+ assert.deepEqual(cfg.policies.mimo26, {
597
+ enabled: true,
598
+ stabilizeSystem: true,
599
+ stickySession: true,
600
+ preserveThinkingIntegrity: true,
601
+ })
542
602
  assert.deepEqual(cfg.policies.gpt56, {
543
603
  enabled: true,
544
604
  promptCacheKey: true,
@@ -565,6 +625,7 @@ test("config policy overrides are honored", () => {
565
625
  },
566
626
  glm53: { stabilizeSystem: false },
567
627
  deepseek: { enabled: true },
628
+ mimo26: { enabled: false, stabilizeSystem: false, stickySession: false },
568
629
  },
569
630
  },
570
631
  {},
@@ -578,6 +639,10 @@ test("config policy overrides are honored", () => {
578
639
  assert.equal(cfg.policies.gpt56.ttl, "1h")
579
640
  assert.equal(cfg.policies.glm53.stabilizeSystem, false)
580
641
  assert.equal(cfg.policies.glm53.preserveThinkingIntegrity, true)
642
+ assert.equal(cfg.policies.mimo26.enabled, false)
643
+ assert.equal(cfg.policies.mimo26.stabilizeSystem, false)
644
+ assert.equal(cfg.policies.mimo26.stickySession, false)
645
+ assert.equal(cfg.policies.mimo26.preserveThinkingIntegrity, true)
581
646
  })
582
647
 
583
648
  test("config invalid policy values fall back to defaults", () => {
@@ -763,3 +828,143 @@ test("boundary: reasoning-integrity reason tokens", () => {
763
828
  ])
764
829
  assert.deepEqual(reasoningIssueReasons(null), [])
765
830
  })
831
+
832
+ // ===========================================================================
833
+ // MiMo-V2.6: system env relocation (shared content-preserving helper)
834
+ // ===========================================================================
835
+
836
+ const MIMO_SYSTEM = [
837
+ "You are a senior software engineer.",
838
+ "You are powered by the model named mimo-v2.6-flash. The exact model ID is xiaomi/mimo-v2.6-flash",
839
+ "Here is some useful information about the environment you are running in:",
840
+ "<env>",
841
+ "Working directory: /home/dev/project",
842
+ "Today's date: 2026-08-17",
843
+ "</env>",
844
+ "You MUST follow AGENTS.md instructions and keep your responses concise.",
845
+ ].join("\n")
846
+
847
+ test("MiMo env block is relocated to the tail, contents preserved exactly", () => {
848
+ const { text, changed } = relocateVolatileEnvBlock(MIMO_SYSTEM)
849
+ assert.equal(changed, true)
850
+ assert.ok(text.endsWith("</env>"))
851
+ const norm = (t) => t.split("\n").filter((l) => l).sort().join("\n")
852
+ assert.equal(norm(text), norm(MIMO_SYSTEM))
853
+ assert.ok(text.indexOf("You MUST follow") < text.indexOf("You are powered"))
854
+ })
855
+
856
+ test("MiMo relocation is a no-op when the block is already at the tail", () => {
857
+ const already = relocateVolatileEnvBlock(MIMO_SYSTEM).text
858
+ const again = relocateVolatileEnvBlock(already)
859
+ assert.equal(again.changed, false)
860
+ assert.equal(again.text, already)
861
+ })
862
+
863
+ test("MiMo relocation is a no-op when start/end markers are missing", () => {
864
+ const missingStart = "Just a system prompt.\n<env>\nToday's date: x\n</env>"
865
+ assert.equal(relocateVolatileEnvBlock(missingStart).changed, false)
866
+ const missingEnd = "You are powered by the model named mimo-v2.6-flash\nbut never closed"
867
+ assert.equal(relocateVolatileEnvBlock(missingEnd).changed, false)
868
+ })
869
+
870
+ // ===========================================================================
871
+ // MiMo-V2.6: sticky-session identity (pure, derived but not injected)
872
+ // ===========================================================================
873
+
874
+ test("mimoSessionIdFor: deterministic + stable for the same session", () => {
875
+ assert.equal(mimoSessionIdFor("ses_abc123"), mimoSessionIdFor("ses_abc123"))
876
+ })
877
+
878
+ test("mimoSessionIdFor: distinct sessions produce distinct ids", () => {
879
+ assert.notEqual(mimoSessionIdFor("ses_abc"), mimoSessionIdFor("ses_xyz"))
880
+ })
881
+
882
+ test("mimoSessionIdFor: printable, no whitespace, within 256 chars", () => {
883
+ const id = mimoSessionIdFor("ses_" + "a".repeat(500))
884
+ assert.ok(id.length <= 256)
885
+ assert.ok(!/\s/.test(id))
886
+ assert.ok(/^[\x20-\x7E]+$/.test(id))
887
+ })
888
+
889
+ test("mimoSessionIdFor: invalid input -> null (no fabrication)", () => {
890
+ assert.equal(mimoSessionIdFor(undefined), null)
891
+ assert.equal(mimoSessionIdFor(null), null)
892
+ assert.equal(mimoSessionIdFor(""), null)
893
+ assert.equal(mimoSessionIdFor(123), null)
894
+ })
895
+
896
+ test("mimoSessionIdFor: transient request fields cannot alter the id", () => {
897
+ // the helper is a pure function of the session id; extra args are ignored
898
+ assert.equal(mimoSessionIdFor("ses_stable"), mimoSessionIdFor("ses_stable", { turn: 7, temperature: 0.9 }))
899
+ })
900
+
901
+ // ===========================================================================
902
+ // MiMo-V2.6: cached/prompt token metrics
903
+ // ===========================================================================
904
+
905
+ test("mimoHitRate = cachedTokens / promptTokens", () => {
906
+ assert.equal(mimoHitRate(47000, 50000), 94)
907
+ assert.equal(mimoHitRate(0, 100), 0)
908
+ assert.equal(mimoHitRate(100, 100), 100)
909
+ })
910
+
911
+ test("mimoHitRate returns null for zero/unknown denominators (no fabrication)", () => {
912
+ assert.equal(mimoHitRate(0, 0), null)
913
+ assert.equal(mimoHitRate(10, 0), null)
914
+ assert.equal(mimoHitRate(NaN, 100), null)
915
+ assert.equal(mimoHitRate(10, NaN), null)
916
+ assert.equal(mimoHitRate(undefined, 100), null)
917
+ assert.equal(mimoHitRate(10, undefined), null)
918
+ })
919
+
920
+ test("mimoHitRate is NOT the read/(read+write) form", () => {
921
+ // read=80, input=20 -> promptTokens derived 100 -> 80%. The read/(read+write)
922
+ // form would give 100% for (80, 0); they must not be conflated.
923
+ assert.equal(mimoHitRate(80, 80 + 20), 80)
924
+ assert.equal(hitRatePct(80, 0), 100)
925
+ })
926
+
927
+ // ===========================================================================
928
+ // MiMo-V2.6: provider-switch diagnostics
929
+ // ===========================================================================
930
+
931
+ test("providerChangeEvent: no event until two real observations exist", () => {
932
+ assert.deepEqual(providerChangeEvent(null, { providerID: "openrouter", modelID: "xiaomi/mimo-v2.6-flash" }), {
933
+ changed: false,
934
+ from: null,
935
+ to: null,
936
+ })
937
+ assert.equal(providerChangeEvent({ providerID: "openrouter" }, null).changed, false)
938
+ })
939
+
940
+ test("providerChangeEvent: same provider -> no change", () => {
941
+ const a = { providerID: "openrouter", modelID: "xiaomi/mimo-v2.6-flash" }
942
+ const b = { providerID: "openrouter", modelID: "xiaomi/mimo-v2.6-pro" }
943
+ assert.equal(providerChangeEvent(a, b).changed, false)
944
+ })
945
+
946
+ test("providerChangeEvent: different provider -> change with from/to", () => {
947
+ const a = { providerID: "openrouter", modelID: "xiaomi/mimo-v2.6-flash" }
948
+ const b = { providerID: "xiaomi", modelID: "mimo-v2.6-flash" }
949
+ const ev = providerChangeEvent(a, b)
950
+ assert.equal(ev.changed, true)
951
+ assert.deepEqual(ev.from, a)
952
+ assert.deepEqual(ev.to, b)
953
+ })
954
+
955
+ // ===========================================================================
956
+ // MiMo-V2.6: no tool-definition mutation
957
+ // ===========================================================================
958
+
959
+ test("MiMo policy performs no tool mutation (fingerprints are pure inputs)", () => {
960
+ // The plugin never adds a MiMo tool-ordering pass: this runtime already sorts
961
+ // tools alphabetically before the wire. Classifying a model as MiMo must not
962
+ // affect tool fingerprints, which are a pure function of the tool definitions.
963
+ const model = { providerID: "openrouter", modelID: "xiaomi/mimo-v2.6-flash" }
964
+ assert.equal(detectPolicy(model), POLICY_MIMO26)
965
+ const before = { sem: toolFingerprint(TOOLS), wire: toolWireFingerprint(TOOLS) }
966
+ const after = { sem: toolFingerprint(TOOLS), wire: toolWireFingerprint(TOOLS) }
967
+ assert.deepEqual(after, before)
968
+ // semantic fingerprint stays order-insensitive regardless of the policy
969
+ assert.equal(toolFingerprint([...TOOLS].reverse()), before.sem)
970
+ })