switchroom 0.17.6 → 0.18.3

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.
Files changed (197) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +65 -5
  3. package/dist/auth-broker/index.js +6623 -514
  4. package/dist/cli/notion-write-pretool.mjs +64 -4
  5. package/dist/cli/switchroom.js +1888 -1162
  6. package/dist/host-control/main.js +6306 -162
  7. package/dist/vault/approvals/kernel-server.js +6014 -202
  8. package/dist/vault/broker/server.js +6741 -940
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +218 -25
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +116 -0
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/skills/mental-model-curator/SKILL.md +162 -0
  18. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  19. package/telegram-plugin/bridge/bridge.ts +80 -1
  20. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  21. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  22. package/telegram-plugin/consolidation-legibility.ts +279 -0
  23. package/telegram-plugin/context-exhaustion.ts +124 -0
  24. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  25. package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
  26. package/telegram-plugin/dist/server.js +86 -2
  27. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  28. package/telegram-plugin/gateway/activity-card-store.ts +369 -0
  29. package/telegram-plugin/gateway/gateway.ts +1861 -172
  30. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  31. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  32. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  33. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  34. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  35. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  36. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  37. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  38. package/telegram-plugin/gateway/model-command.ts +70 -10
  39. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  40. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  41. package/telegram-plugin/memory-legibility.ts +217 -0
  42. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  43. package/telegram-plugin/package.json +6 -0
  44. package/telegram-plugin/quota-watch.ts +4 -6
  45. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  46. package/telegram-plugin/registry/turns-schema.ts +78 -0
  47. package/telegram-plugin/render/ir.ts +209 -0
  48. package/telegram-plugin/render/parse.ts +363 -0
  49. package/telegram-plugin/render/render.ts +440 -0
  50. package/telegram-plugin/render/rich-render.ts +72 -0
  51. package/telegram-plugin/scoped-approval.ts +59 -0
  52. package/telegram-plugin/silent-end.ts +78 -0
  53. package/telegram-plugin/stream-controller.ts +14 -3
  54. package/telegram-plugin/subagent-watcher.ts +60 -6
  55. package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
  56. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  57. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  58. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  59. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  60. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  61. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  62. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
  63. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  64. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  65. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  66. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  67. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  68. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  69. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  70. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  71. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  72. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  73. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  74. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  75. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  76. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  77. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  78. package/telegram-plugin/tests/model-command.test.ts +193 -16
  79. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  80. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  81. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  82. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  83. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  84. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  85. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  86. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  87. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  88. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  89. package/telegram-plugin/tests/render/render.test.ts +436 -0
  90. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  91. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  92. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  93. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  94. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  95. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  96. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
  97. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  98. package/telegram-plugin/uat/assertions.ts +88 -4
  99. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  100. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  101. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  102. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  103. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  104. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  105. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  106. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  107. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  108. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  109. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  110. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  111. package/telegram-plugin/worktree-watch-cwds.ts +135 -0
  112. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  113. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  119. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  132. package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
  133. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  134. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  135. package/vendor/hindsight-memory/scripts/recall.py +217 -10
  136. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  137. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  138. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  157. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  158. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  159. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  160. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  161. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  162. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  163. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  164. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  165. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  166. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  167. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  168. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  169. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  170. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  171. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  172. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  173. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  174. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  175. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  176. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  177. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  178. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  179. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  180. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  181. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  182. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  183. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  184. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  185. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  186. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  187. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  188. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  189. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  190. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  191. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  192. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  193. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  194. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  195. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  196. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
  197. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -38,6 +38,7 @@ function okResult(output: string): InjectResult {
38
38
  function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
39
39
  const calls: Array<{ agent: string; command: string }> = [];
40
40
  const restartCalls: string[] = [];
41
+ const relaunchCalls: Array<{ model: string; reason: string }> = [];
41
42
  const deps: ModelCommandDeps = {
42
43
  inject: async (agent, command) => {
43
44
  calls.push({ agent, command });
@@ -50,9 +51,10 @@ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
50
51
  preBlock: (s) => `<pre>${s}</pre>`,
51
52
  getActiveSessionModel: () => null,
52
53
  scheduleRestart: async (reason) => { restartCalls.push(reason); },
54
+ scheduleModelRelaunch: async (model, reason) => { relaunchCalls.push({ model, reason }); },
53
55
  ...overrides,
54
56
  };
55
- return { deps, calls, restartCalls };
57
+ return { deps, calls, restartCalls, relaunchCalls };
56
58
  }
57
59
 
58
60
  describe("parseModelCommand", () => {
@@ -111,8 +113,17 @@ describe("isValidModelArg", () => {
111
113
  expect(isValidModelArg(good), good).toBe(true);
112
114
  }
113
115
  });
116
+ it("accepts OpenRouter-style sr-vendor/model ids (embedded slash)", () => {
117
+ // `/` is a legal char in a model id — needed for OpenRouter-style
118
+ // `sr-vendor/model` routing. It is NOT a shell metachar inside the
119
+ // double-quoted `claude --model "$_EFFECTIVE_MODEL"` launch, so it is
120
+ // safe. Kept aligned with the shell shape gate in profiles/_base/start.sh.hbs.
121
+ for (const good of ["sr-openrouter/gpt-5", "sr-vendor/model", "sr-mistralai/mixtral-8x7b"]) {
122
+ expect(isValidModelArg(good), good).toBe(true);
123
+ }
124
+ });
114
125
  it("rejects whitespace, metacharacters, and over-long strings", () => {
115
- for (const bad of ["", " ", "a b", "a;b", "a/b", "-x", "a".repeat(120), "a\tb", "a\nb"]) {
126
+ for (const bad of ["", " ", "a b", "a;b", "-x", "a".repeat(120), "a\tb", "a\nb", "/leading"]) {
116
127
  expect(isValidModelArg(bad), JSON.stringify(bad)).toBe(false);
117
128
  }
118
129
  });
@@ -355,16 +366,6 @@ describe("handleModelCommand — sr-* → Claude graceful restart", () => {
355
366
  expect(reply.text).toContain("Set model to sonnet");
356
367
  });
357
368
 
358
- it("does NOT restart when switching from Claude to sr-* (no session override)", async () => {
359
- const { deps, calls, restartCalls } = makeDeps({
360
- getActiveSessionModel: () => null,
361
- });
362
- await handleModelCommand({ kind: "set", model: "sr-gemini-2.5-pro" }, deps);
363
- // sr-* is not a Claude model — no restart
364
- expect(restartCalls).toHaveLength(0);
365
- expect(calls).toHaveLength(1);
366
- });
367
-
368
369
  it("surfaces scheduleRestart failures without propagating the error", async () => {
369
370
  const { deps, calls } = makeDeps({
370
371
  getActiveSessionModel: () => "sr-deepseek-r1",
@@ -386,6 +387,128 @@ describe("handleModelCommand — sr-* → Claude graceful restart", () => {
386
387
  });
387
388
  });
388
389
 
390
+ describe("handleModelCommand — Claude → sr-* session relaunch", () => {
391
+ it("schedules a model relaunch (carrier) instead of injecting for an sr-* target", async () => {
392
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
393
+ getActiveSessionModel: () => null,
394
+ });
395
+ const reply = await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
396
+ // Must NOT inject (claude's picker rejects sr-* ids)
397
+ expect(calls).toHaveLength(0);
398
+ // Must NOT use the sr→claude scheduleRestart path
399
+ expect(restartCalls).toHaveLength(0);
400
+ // Must schedule the carrier-based relaunch with the full sr-* id
401
+ expect(relaunchCalls).toHaveLength(1);
402
+ expect(relaunchCalls[0].model).toBe("sr-glm-5");
403
+ expect(reply.text).toContain("sr-glm-5");
404
+ expect(reply.text).toContain("30s");
405
+ expect(reply.html).toBe(true);
406
+ });
407
+
408
+ it("expands a short sr alias then relaunches on the full id", async () => {
409
+ const { deps, calls, relaunchCalls } = makeDeps({ getActiveSessionModel: () => null });
410
+ await handleModelCommand({ kind: "set", model: "glm" }, deps);
411
+ expect(calls).toHaveLength(0);
412
+ expect(relaunchCalls).toHaveLength(1);
413
+ expect(relaunchCalls[0].model).toBe("sr-glm-5");
414
+ });
415
+
416
+ it("Claude target still uses the instant inject path (no relaunch)", async () => {
417
+ const { deps, calls, relaunchCalls, restartCalls } = makeDeps({
418
+ getActiveSessionModel: () => null,
419
+ });
420
+ await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
421
+ expect(calls).toHaveLength(1);
422
+ expect(relaunchCalls).toHaveLength(0);
423
+ expect(restartCalls).toHaveLength(0);
424
+ });
425
+
426
+ it("sr-* → Claude still takes the scheduleRestart path (not scheduleModelRelaunch)", async () => {
427
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
428
+ getActiveSessionModel: () => "sr-glm-5",
429
+ });
430
+ await handleModelCommand({ kind: "set", model: "opus" }, deps);
431
+ expect(calls).toHaveLength(0);
432
+ expect(relaunchCalls).toHaveLength(0);
433
+ expect(restartCalls).toHaveLength(1);
434
+ });
435
+
436
+ it("surfaces scheduleModelRelaunch failures without propagating the error", async () => {
437
+ const { deps, calls } = makeDeps({
438
+ getActiveSessionModel: () => null,
439
+ scheduleModelRelaunch: async () => { throw new Error("carrier write failed"); },
440
+ });
441
+ const reply = await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
442
+ expect(calls).toHaveLength(0);
443
+ expect(reply.text).toContain("Could not schedule model switch");
444
+ expect(reply.text).toContain("carrier write failed");
445
+ });
446
+ });
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Manual full-sr-* passthrough (Ken 2026-07-08): the /model MENU stays curated
450
+ // to the main SR_MODEL_ALIASES set, but typing `/model <any registered sr-*>`
451
+ // must switch to that exact model — the set path is a SHAPE gate only, with NO
452
+ // whitelist against SR_MODEL_LABELS / SR_MODEL_ALIASES. These guard that any
453
+ // arbitrary sr-* id passes through verbatim and schedules the relaunch on the
454
+ // exact id (not rejected, not remapped, not injected).
455
+ // ---------------------------------------------------------------------------
456
+ describe("handleModelCommand — arbitrary sr-* passthrough (no whitelist)", () => {
457
+ // sr-* names that are NOT in SR_MODEL_ALIASES (so not menu-reachable) but ARE
458
+ // registered in the live litellm config — must still switch when typed.
459
+ const ARBITRARY_SR = [
460
+ "sr-gpt-oss-120b",
461
+ "sr-gpt-oss-20b",
462
+ "sr-minimax-m3",
463
+ "sr-gemini-flash-lite",
464
+ "sr-gpt-5.5",
465
+ "sr-gpt-5-codex",
466
+ "sr-gpt-5.2-codex",
467
+ "sr-deepseek-v4-flash",
468
+ ];
469
+
470
+ it("each arbitrary sr-* passes the shape gate and isSrModel", () => {
471
+ for (const name of ARBITRARY_SR) {
472
+ expect(isValidModelArg(name), `${name} must pass MODEL_ARG_RE`).toBe(true);
473
+ expect(isSrModel(name), `${name} must be recognised as sr-*`).toBe(true);
474
+ expect(isClaudeModel(name)).toBe(false);
475
+ }
476
+ });
477
+
478
+ it("relaunches on the exact typed id — not rejected, not remapped", async () => {
479
+ for (const name of ARBITRARY_SR) {
480
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
481
+ getActiveSessionModel: () => null,
482
+ });
483
+ const reply = await handleModelCommand({ kind: "set", model: name }, deps);
484
+ // Never injected (claude's picker rejects sr-* ids).
485
+ expect(calls, `${name} must not inject`).toHaveLength(0);
486
+ // Never the sr→claude restart path (source session is Claude here).
487
+ expect(restartCalls, `${name} must not scheduleRestart`).toHaveLength(0);
488
+ // Scheduled the carrier relaunch on the EXACT id (no alias remap).
489
+ expect(relaunchCalls).toHaveLength(1);
490
+ expect(relaunchCalls[0].model, `${name} must relaunch verbatim`).toBe(name);
491
+ expect(reply.text).toContain(name);
492
+ }
493
+ });
494
+
495
+ it("parseModelCommand accepts arbitrary sr-* ids as a set command", () => {
496
+ for (const name of ARBITRARY_SR) {
497
+ expect(parseModelCommand(`/model ${name}`)).toEqual({ kind: "set", model: name });
498
+ }
499
+ });
500
+
501
+ it("switching FROM an arbitrary sr-* back to Claude takes the restart path", async () => {
502
+ const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
503
+ getActiveSessionModel: () => "sr-gpt-oss-120b",
504
+ });
505
+ await handleModelCommand({ kind: "set", model: "opus" }, deps);
506
+ expect(calls).toHaveLength(0);
507
+ expect(relaunchCalls).toHaveLength(0);
508
+ expect(restartCalls).toHaveLength(1);
509
+ });
510
+ });
511
+
389
512
  describe("inject allowlist contract", () => {
390
513
  it("/model stays on the inject allowlist (the set path depends on it)", async () => {
391
514
  const { INJECT_COMMANDS } = await import("../../src/agents/inject.js");
@@ -433,11 +556,13 @@ describe("SR_MODEL_ALIASES / expandSrAlias", () => {
433
556
  }
434
557
  });
435
558
 
436
- it("handleModelCommand injects expanded sr-* id, not the short alias", async () => {
437
- const { deps, calls } = makeDeps({ getActiveSessionModel: () => null });
559
+ it("handleModelCommand relaunches on the expanded sr-* id, not the short alias", async () => {
560
+ const { deps, calls, relaunchCalls } = makeDeps({ getActiveSessionModel: () => null });
438
561
  await handleModelCommand({ kind: "set", model: "flash" }, deps);
439
- expect(calls).toHaveLength(1);
440
- expect(calls[0].command).toBe("/model sr-gemini-2.5-flash");
562
+ // sr-* targets no longer inject — they carrier-relaunch on the full id.
563
+ expect(calls).toHaveLength(0);
564
+ expect(relaunchCalls).toHaveLength(1);
565
+ expect(relaunchCalls[0].model).toBe("sr-gemini-2.5-flash");
441
566
  });
442
567
 
443
568
  it("handleModelCommand with alias schedules restart when session is on sr-*", async () => {
@@ -688,6 +813,58 @@ describe("SR_MODEL_LABELS", () => {
688
813
  expect(SR_MODEL_LABELS["sr-gemini-2.5-pro"]).toBe("Gemini 2.5 Pro");
689
814
  expect(SR_MODEL_LABELS["sr-deepseek-r1"]).toBe("DeepSeek R1");
690
815
  });
816
+
817
+ it("bumps sr-glm-5 label to GLM-5.2 (now targets glm-5.2 in litellm)", () => {
818
+ expect(SR_MODEL_LABELS["sr-glm-5"]).toBe("GLM-5.2");
819
+ });
820
+
821
+ it("has friendly names for the new OpenRouter sr-* models (display-only)", () => {
822
+ expect(SR_MODEL_LABELS["sr-gpt-oss-20b"]).toBe("GPT-OSS 20B");
823
+ expect(SR_MODEL_LABELS["sr-gpt-oss-120b"]).toBe("GPT-OSS 120B");
824
+ expect(SR_MODEL_LABELS["sr-gpt-5.5"]).toBe("GPT-5.5");
825
+ expect(SR_MODEL_LABELS["sr-gpt-5-codex"]).toBe("GPT-5 Codex");
826
+ expect(SR_MODEL_LABELS["sr-gpt-5.2-codex"]).toBe("GPT-5.2 Codex");
827
+ expect(SR_MODEL_LABELS["sr-gemini-flash-lite"]).toBe("Gemini 3.1 Flash Lite");
828
+ expect(SR_MODEL_LABELS["sr-minimax-m3"]).toBe("MiniMax M3");
829
+ expect(SR_MODEL_LABELS["sr-deepseek-v4-flash"]).toBe("DeepSeek V4 Flash");
830
+ });
831
+ });
832
+
833
+ describe("menu stays curated — new OpenRouter models are display-only, not in the picker", () => {
834
+ // The 8 new models are typeable (manual passthrough) but must NOT bloat the
835
+ // /model keyboard. externalModelNames() seeds the picker from SR_MODEL_ALIASES
836
+ // values, so these ids must be ABSENT from both the alias table and the picker
837
+ // list. This locks in Ken's "menu = main models only" decision.
838
+ const NEW_SR = [
839
+ "sr-gpt-oss-20b",
840
+ "sr-gpt-oss-120b",
841
+ "sr-gpt-5.5",
842
+ "sr-gpt-5-codex",
843
+ "sr-gpt-5.2-codex",
844
+ "sr-gemini-flash-lite",
845
+ "sr-minimax-m3",
846
+ "sr-deepseek-v4-flash",
847
+ ];
848
+
849
+ it("SR_MODEL_ALIASES stays the curated 6-entry main set (no new short aliases)", () => {
850
+ expect(Object.keys(SR_MODEL_ALIASES).sort()).toEqual(
851
+ ["codex", "deepseek", "flash", "gemini", "glm", "r1"],
852
+ );
853
+ // None of the new sr-* ids are an alias target.
854
+ const targets = new Set(Object.values(SR_MODEL_ALIASES));
855
+ for (const name of NEW_SR) {
856
+ expect(targets.has(name), `${name} must NOT be an alias target`).toBe(false);
857
+ }
858
+ });
859
+
860
+ it("externalModelNames (picker seed) excludes the new models", () => {
861
+ const picker = externalModelNames([]);
862
+ for (const name of NEW_SR) {
863
+ expect(picker.includes(name), `${name} must NOT appear in the picker`).toBe(false);
864
+ }
865
+ // The curated main set is still exactly the alias targets.
866
+ expect(picker.sort()).toEqual([...new Set(Object.values(SR_MODEL_ALIASES))].sort());
867
+ });
691
868
  });
692
869
 
693
870
  describe("buildModelMenu — with sr-* models", () => {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * narrative-render.test.ts — T6: end-to-end narration through the REAL pure
3
+ * render helpers, tied to the REAL LivenessTracker (thinking-pause fix).
4
+ *
5
+ * Proves the narration surface still behaves after the orphaned-reply fix:
6
+ * (a) a long narration is clipped and rendered as ONE feed line;
7
+ * (b) a narration that is a draft of the delivered reply is SUPPRESSED,
8
+ * while a differing narration is SHOWN;
9
+ * (c) a narration after a >30s stream gap keeps the turn alive (the tracker
10
+ * stamps liveness) AND renders; thinking / tool_result carry no narration
11
+ * text so they add NO feed line (thinking only drives the 🤔 reaction).
12
+ *
13
+ * All assertions drive the REAL helpers — no replicas.
14
+ *
15
+ * DEVIATION NOTE: the fix spec's T6(a) wrote "≤120-char line". The real clip
16
+ * bound is `STATUS_LINE_MAX` (200) in status-no-truncate.ts, so this test pins
17
+ * the real constant instead of the stale 120.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import {
22
+ clipNarrative,
23
+ appendActivityLabel,
24
+ renderActivityFeedWithNested,
25
+ } from '../tool-activity-summary.js'
26
+ import { isDraftOfReply, REPLY_TOOLS } from '../narrative-dedup.js'
27
+ import { STATUS_LINE_MAX } from '../status-no-truncate.js'
28
+ import { LivenessTracker, ORPHANED_REPLY_STREAM_WINDOW_MS } from '../context-exhaustion.js'
29
+
30
+ const W = ORPHANED_REPLY_STREAM_WINDOW_MS
31
+
32
+ describe('T6(a) — long narration is clipped to one feed line', () => {
33
+ it('clipNarrative → appendActivityLabel → renderActivityFeedWithNested yields one clipped line', () => {
34
+ const long = 'x'.repeat(STATUS_LINE_MAX + 50) // 250 chars, no newlines
35
+ const clipped = clipNarrative(long)
36
+ expect(clipped.length).toBe(STATUS_LINE_MAX)
37
+
38
+ const lines: string[] = []
39
+ appendActivityLabel(lines, clipped)
40
+ expect(lines).toHaveLength(1)
41
+
42
+ const rendered = renderActivityFeedWithNested(lines, [])
43
+ expect(rendered).not.toBeNull()
44
+ expect(rendered).toContain(clipped)
45
+ })
46
+
47
+ it('clipNarrative keeps only the first line, trimmed', () => {
48
+ const multi = ' first line of narration \nsecond line should be dropped'
49
+ expect(clipNarrative(multi)).toBe('first line of narration')
50
+ })
51
+ })
52
+
53
+ describe('T6(b) — a narration that drafts the reply is suppressed', () => {
54
+ // Model the reducer decision: only render narration that is NOT a draft of
55
+ // the delivered reply (narrative-dedup gate).
56
+ function renderNarrationUnlessDraft(narration: string, replyText: string): string | null {
57
+ if (isDraftOfReply(narration, replyText)) return null // suppressed
58
+ const lines: string[] = []
59
+ appendActivityLabel(lines, clipNarrative(narration))
60
+ return renderActivityFeedWithNested(lines, [])
61
+ }
62
+
63
+ it('REPLY_TOOLS is the reply set (sanity — draft dedup only runs for reply tools)', () => {
64
+ expect(REPLY_TOOLS.has('reply')).toBe(true)
65
+ expect(REPLY_TOOLS.has('stream_reply')).toBe(true)
66
+ })
67
+
68
+ it('suppresses a trailing narration that is a draft of the delivered reply', () => {
69
+ const reply = 'The build passed and I deployed the fix to staging.'
70
+ const draft = 'The build passed and I deployed the fix to staging' // ~identical
71
+ expect(isDraftOfReply(draft, reply)).toBe(true)
72
+ expect(renderNarrationUnlessDraft(draft, reply)).toBeNull()
73
+ })
74
+
75
+ it('shows a genuinely different narration line', () => {
76
+ const reply = 'The build passed and I deployed the fix to staging.'
77
+ const working = 'Running the integration test suite'
78
+ expect(isDraftOfReply(working, reply)).toBe(false)
79
+ const rendered = renderNarrationUnlessDraft(working, reply)
80
+ expect(rendered).not.toBeNull()
81
+ expect(rendered).toContain('Running the integration test suite')
82
+ })
83
+ })
84
+
85
+ describe('T6(c) — narration after a stream gap: alive + rendered; thinking/tool_result add no line', () => {
86
+ it('a narration text event after a >30s gap stamps liveness (turn alive) and renders', () => {
87
+ const t = new LivenessTracker(0)
88
+ t.onStreamEvent('text', undefined, 0)
89
+ // >30s gap with no events — turn would previously have been at risk.
90
+ const gapNow = 35_000
91
+ // Narration lands: stamps liveness, keeping the turn alive.
92
+ t.onStreamEvent('text', undefined, gapNow)
93
+ expect(t.lastStreamEventAt).toBe(gapNow)
94
+ expect(t.recentlyStreaming(gapNow, W)).toBe(true)
95
+
96
+ const lines: string[] = []
97
+ appendActivityLabel(lines, clipNarrative('Compiling the gateway bundle'))
98
+ const rendered = renderActivityFeedWithNested(lines, [])
99
+ expect(rendered).toContain('Compiling the gateway bundle')
100
+ })
101
+
102
+ it('thinking and tool_result stamp liveness but carry NO narration text → no feed line', () => {
103
+ const t = new LivenessTracker(0)
104
+ const lines: string[] = []
105
+ // A rendered narration line already present.
106
+ appendActivityLabel(lines, clipNarrative('Editing gateway.ts'))
107
+ expect(lines).toHaveLength(1)
108
+
109
+ // thinking: keeps the turn alive (liveness stamp) but produces no text.
110
+ t.onStreamEvent('thinking', undefined, 5_000)
111
+ expect(t.lastStreamEventAt).toBe(5_000)
112
+ // A thinking/tool_result event has no label → appendActivityLabel adds nothing.
113
+ expect(appendActivityLabel(lines, undefined)).toBeNull()
114
+ expect(lines).toHaveLength(1)
115
+
116
+ // tool_result: same — stamps liveness, no narration line.
117
+ t.onStreamEvent('tool_result', undefined, 6_000)
118
+ expect(t.lastStreamEventAt).toBe(6_000)
119
+ expect(appendActivityLabel(lines, '')).toBeNull()
120
+ expect(lines).toHaveLength(1)
121
+
122
+ const rendered = renderActivityFeedWithNested(lines, [])
123
+ expect(rendered).toContain('Editing gateway.ts')
124
+ })
125
+ })