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.
- package/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- 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", "
|
|
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
|
|
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
|
-
|
|
440
|
-
expect(calls
|
|
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
|
+
})
|