switchroom 0.18.3 → 0.18.7
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/dist/agent-scheduler/index.js +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- 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/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/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/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
|
@@ -41,16 +41,19 @@ describe('handleMentalModelProposeCallback — authorization gate', () => {
|
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
describe('handleMentalModelProposeCallback — TTL enforced at tap time', () => {
|
|
44
|
-
it('checks staged_at against MENTAL_MODEL_PROPOSE_TTL_MS at tap and
|
|
44
|
+
it('checks staged_at against MENTAL_MODEL_PROPOSE_TTL_MS at tap and routes through the shared expiry path', () => {
|
|
45
45
|
const block = proposeCallbackBlock()
|
|
46
46
|
expect(block).toMatch(/Date\.now\(\) - pending\.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS/)
|
|
47
|
-
// The expired branch
|
|
48
|
-
//
|
|
47
|
+
// The expired branch routes through expireMentalModelProposeCard, which
|
|
48
|
+
// deletes the pending entry, clears the durable store, edits the card away
|
|
49
|
+
// (keyboard stripped), AND wakes the parked agent with a timeout synthetic —
|
|
50
|
+
// so a stale card can't be tapped into an approval and the agent isn't left
|
|
51
|
+
// parked. (The helper's delete/clear/wake behavior is pinned in
|
|
52
|
+
// pending-card-durability-wiring.test.ts.)
|
|
49
53
|
const ttlBranch =
|
|
50
54
|
block.split('Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS')[1]?.split('Single-shot')[0] ?? ''
|
|
51
|
-
expect(ttlBranch).toMatch(/
|
|
55
|
+
expect(ttlBranch).toMatch(/expireMentalModelProposeCard\(stageId, pending, Date\.now\(\)\)/)
|
|
52
56
|
expect(ttlBranch).toMatch(/expired/)
|
|
53
|
-
expect(ttlBranch).toMatch(/inline_keyboard: \[\]/)
|
|
54
57
|
})
|
|
55
58
|
})
|
|
56
59
|
|
|
@@ -50,6 +50,7 @@ function makeDeps(overrides: Partial<ModelCommandDeps> = {}) {
|
|
|
50
50
|
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"),
|
|
51
51
|
preBlock: (s) => `<pre>${s}</pre>`,
|
|
52
52
|
getActiveSessionModel: () => null,
|
|
53
|
+
isBusy: () => false,
|
|
53
54
|
scheduleRestart: async (reason) => { restartCalls.push(reason); },
|
|
54
55
|
scheduleModelRelaunch: async (model, reason) => { relaunchCalls.push({ model, reason }); },
|
|
55
56
|
...overrides,
|
|
@@ -202,6 +203,9 @@ describe("handleModelCommand — set", () => {
|
|
|
202
203
|
expect(reply.text).toContain("<pre>⏺ Set model to sonnet</pre>");
|
|
203
204
|
expect(reply.text).toContain("Session-only");
|
|
204
205
|
expect(reply.html).toBe(true);
|
|
206
|
+
// A verified confirmation records the live model so /status stays honest
|
|
207
|
+
// (bug 1: the typed path never recorded the switch before).
|
|
208
|
+
expect(reply.selectedModel).toBe("sonnet");
|
|
205
209
|
});
|
|
206
210
|
|
|
207
211
|
it("SILENT switch: suppresses raw pane scrollback instead of dumping it as a code block", async () => {
|
|
@@ -219,10 +223,13 @@ describe("handleModelCommand — set", () => {
|
|
|
219
223
|
expect(reply.text).not.toContain("summary you asked for");
|
|
220
224
|
expect(reply.text).not.toContain("rollback plan");
|
|
221
225
|
expect(reply.text).not.toContain("<pre>");
|
|
222
|
-
//
|
|
226
|
+
// No confirmation line was captured, so the switch is UNVERIFIED — report it
|
|
227
|
+
// honestly (never a false "switched") and record NO session override.
|
|
223
228
|
expect(reply.text).toContain("/model fable");
|
|
224
|
-
expect(reply.text).toContain("
|
|
225
|
-
expect(reply.text).toContain("
|
|
229
|
+
expect(reply.text).toContain("couldn't confirm the switch");
|
|
230
|
+
expect(reply.text).toContain("/status");
|
|
231
|
+
expect(reply.text).not.toContain("switched (session)");
|
|
232
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
226
233
|
expect(reply.html).toBe(true);
|
|
227
234
|
});
|
|
228
235
|
|
|
@@ -248,12 +255,15 @@ describe("handleModelCommand — set", () => {
|
|
|
248
255
|
const { deps } = makeDeps({ inject: async () => okResult(prose) });
|
|
249
256
|
const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
|
|
250
257
|
// The anchored regex rejects all three lines, so nothing leaks and there is
|
|
251
|
-
// no <pre> block.
|
|
258
|
+
// no <pre> block. With no confirmation captured, the switch is UNVERIFIED —
|
|
259
|
+
// report it honestly, never a false "switched".
|
|
252
260
|
expect(reply.text).not.toContain("<pre>");
|
|
253
261
|
expect(reply.text).not.toContain("switched the deploy");
|
|
254
262
|
expect(reply.text).not.toContain("set model behaviour");
|
|
255
263
|
expect(reply.text).not.toContain("kept model changes");
|
|
256
|
-
expect(reply.text).toContain("
|
|
264
|
+
expect(reply.text).toContain("couldn't confirm the switch");
|
|
265
|
+
expect(reply.text).not.toContain("switched (session)");
|
|
266
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
257
267
|
expect(reply.html).toBe(true);
|
|
258
268
|
});
|
|
259
269
|
|
|
@@ -327,49 +337,55 @@ describe("isSrModel / isClaudeModel helpers", () => {
|
|
|
327
337
|
});
|
|
328
338
|
|
|
329
339
|
describe("handleModelCommand — sr-* → Claude graceful restart", () => {
|
|
330
|
-
it("
|
|
331
|
-
const { deps, calls, restartCalls } = makeDeps({
|
|
340
|
+
it("carries the requested Claude model across the restart (relaunch carrier), never injects", async () => {
|
|
341
|
+
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
332
342
|
getActiveSessionModel: () => "sr-gemini-2.5-pro",
|
|
333
343
|
});
|
|
334
344
|
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
335
345
|
// Must NOT inject
|
|
336
346
|
expect(calls).toHaveLength(0);
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
expect(restartCalls
|
|
347
|
+
// sr→Claude now rides the SAME carrier mechanism as Claude→sr so the
|
|
348
|
+
// requested Claude model survives the restart (bug 2). The bespoke
|
|
349
|
+
// scheduleRestart-without-carrier path is gone.
|
|
350
|
+
expect(restartCalls).toHaveLength(0);
|
|
351
|
+
expect(relaunchCalls).toHaveLength(1);
|
|
352
|
+
expect(relaunchCalls[0].model).toBe("opus");
|
|
353
|
+
expect(relaunchCalls[0].reason).toContain("sr-to-claude");
|
|
341
354
|
// Reply mentions the sr-* model and ~30s
|
|
342
355
|
expect(reply.text).toContain("sr-gemini-2.5-pro");
|
|
343
356
|
expect(reply.text).toContain("30s");
|
|
344
357
|
expect(reply.html).toBe(true);
|
|
345
358
|
});
|
|
346
359
|
|
|
347
|
-
it("
|
|
348
|
-
const { deps, calls, restartCalls } = makeDeps({
|
|
360
|
+
it("carries a full claude-* id across the restart when session is on sr-*", async () => {
|
|
361
|
+
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
349
362
|
getActiveSessionModel: () => "sr-deepseek-r1",
|
|
350
363
|
});
|
|
351
364
|
const reply = await handleModelCommand({ kind: "set", model: "claude-opus-4-8" }, deps);
|
|
352
365
|
expect(calls).toHaveLength(0);
|
|
353
|
-
expect(restartCalls).toHaveLength(
|
|
366
|
+
expect(restartCalls).toHaveLength(0);
|
|
367
|
+
expect(relaunchCalls).toHaveLength(1);
|
|
368
|
+
expect(relaunchCalls[0].model).toBe("claude-opus-4-8");
|
|
354
369
|
expect(reply.text).toContain("sr-deepseek-r1");
|
|
355
370
|
expect(reply.text).toContain("30s");
|
|
356
371
|
});
|
|
357
372
|
|
|
358
373
|
it("does NOT restart when switching between Claude models (no sr-* session)", async () => {
|
|
359
|
-
const { deps, calls, restartCalls } = makeDeps({
|
|
374
|
+
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
360
375
|
getActiveSessionModel: () => "Opus 4.8",
|
|
361
376
|
});
|
|
362
377
|
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
363
|
-
// Normal inject path: still injects, no restart
|
|
378
|
+
// Normal inject path: still injects, no restart, no relaunch
|
|
364
379
|
expect(calls).toHaveLength(1);
|
|
365
380
|
expect(restartCalls).toHaveLength(0);
|
|
381
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
366
382
|
expect(reply.text).toContain("Set model to sonnet");
|
|
367
383
|
});
|
|
368
384
|
|
|
369
|
-
it("surfaces
|
|
385
|
+
it("surfaces relaunch dispatch failures without propagating the error", async () => {
|
|
370
386
|
const { deps, calls } = makeDeps({
|
|
371
387
|
getActiveSessionModel: () => "sr-deepseek-r1",
|
|
372
|
-
|
|
388
|
+
scheduleModelRelaunch: async () => { throw new Error("hostd unreachable"); },
|
|
373
389
|
});
|
|
374
390
|
const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
|
|
375
391
|
expect(calls).toHaveLength(0);
|
|
@@ -377,13 +393,109 @@ describe("handleModelCommand — sr-* → Claude graceful restart", () => {
|
|
|
377
393
|
expect(reply.text).toContain("hostd unreachable");
|
|
378
394
|
});
|
|
379
395
|
|
|
396
|
+
it("reports 'restart already in flight' honestly on a debounced dispatch (no silent no-op)", async () => {
|
|
397
|
+
const { deps } = makeDeps({
|
|
398
|
+
getActiveSessionModel: () => "sr-deepseek-r1",
|
|
399
|
+
scheduleModelRelaunch: async () => {
|
|
400
|
+
const e = new Error("a restart is already in flight — try again in ~15s");
|
|
401
|
+
(e as { code?: string }).code = "restart_in_flight";
|
|
402
|
+
throw e;
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
406
|
+
expect(reply.text).toContain("restart is already in flight");
|
|
407
|
+
expect(reply.text).toContain("15s");
|
|
408
|
+
// Never a false success.
|
|
409
|
+
expect(reply.text).not.toContain("Set model to");
|
|
410
|
+
});
|
|
411
|
+
|
|
380
412
|
it("null session model (no prior override) still uses normal inject path for Claude target", async () => {
|
|
381
|
-
const { deps, calls, restartCalls } = makeDeps({
|
|
413
|
+
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
382
414
|
getActiveSessionModel: () => null,
|
|
383
415
|
});
|
|
384
416
|
await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
385
417
|
expect(calls).toHaveLength(1);
|
|
386
418
|
expect(restartCalls).toHaveLength(0);
|
|
419
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe("handleModelCommand — busy gate + honest unverified reporting", () => {
|
|
424
|
+
it("refuses a typed switch while the agent is mid-turn (no inject, no relaunch)", async () => {
|
|
425
|
+
const { deps, calls, relaunchCalls } = makeDeps({ isBusy: () => true });
|
|
426
|
+
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
427
|
+
expect(calls).toHaveLength(0);
|
|
428
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
429
|
+
expect(reply.text).toContain("mid-turn");
|
|
430
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("refuses an sr-* switch too while mid-turn", async () => {
|
|
434
|
+
const { deps, relaunchCalls } = makeDeps({ isBusy: () => true });
|
|
435
|
+
const reply = await handleModelCommand({ kind: "set", model: "sr-glm-5" }, deps);
|
|
436
|
+
expect(relaunchCalls).toHaveLength(0);
|
|
437
|
+
expect(reply.text).toContain("mid-turn");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("reports a claude error output as an HONEST failure, not a switch", async () => {
|
|
441
|
+
const { deps } = makeDeps({
|
|
442
|
+
inject: async () => okResult("Error: Model not found"),
|
|
443
|
+
});
|
|
444
|
+
const reply = await handleModelCommand({ kind: "set", model: "claude-bogus" }, deps);
|
|
445
|
+
expect(reply.text).toContain("did not take");
|
|
446
|
+
expect(reply.text).toContain("Model not found");
|
|
447
|
+
expect(reply.text).not.toContain("Session-only");
|
|
448
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("detects claude v2.1.205's real error shape: ⎿ Model 'name' not found (TUI-probe verified)", async () => {
|
|
452
|
+
// Captured verbatim from a disposable claude v2.1.205 TUI, 2026-07-10.
|
|
453
|
+
const { deps } = makeDeps({
|
|
454
|
+
inject: async () => okResult("⎿ Model 'claude-bogus-99' not found"),
|
|
455
|
+
});
|
|
456
|
+
const reply = await handleModelCommand({ kind: "set", model: "claude-bogus-99" }, deps);
|
|
457
|
+
expect(reply.text).toContain("did not take");
|
|
458
|
+
expect(reply.text).toContain("not found");
|
|
459
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("scrollback prose CONTAINING 'model not found' mid-sentence is NOT a failure (anchored error scan)", async () => {
|
|
463
|
+
// The error regex is line-start anchored, like the confirmation prefix —
|
|
464
|
+
// prose that merely mentions the phrase must not flip a successful switch
|
|
465
|
+
// into a reported failure (the false-FAILURE variant of the scrollback-leak
|
|
466
|
+
// class this PR eliminates).
|
|
467
|
+
const { deps } = makeDeps({
|
|
468
|
+
inject: async () => okResult("deploy failed: model not found in registry"),
|
|
469
|
+
});
|
|
470
|
+
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
471
|
+
expect(reply.text).not.toContain("did not take");
|
|
472
|
+
expect(reply.text).not.toContain("model not found");
|
|
473
|
+
// No confirmation either → honest unverified message, nothing recorded.
|
|
474
|
+
expect(reply.text).toContain("couldn't confirm the switch");
|
|
475
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("recognises claude v2.1.205's real arg-form confirmation (⎿ glyph + 'and saved as your default')", async () => {
|
|
479
|
+
// Captured verbatim from a disposable claude v2.1.205 TUI, 2026-07-10:
|
|
480
|
+
// the arg form is NOT silent — it prints this line, and the ⎿ glyph
|
|
481
|
+
// survives the inject capture. The name extraction must stop at "and
|
|
482
|
+
// saved", not swallow the whole sentence.
|
|
483
|
+
const { deps } = makeDeps({
|
|
484
|
+
inject: async () =>
|
|
485
|
+
okResult("⎿ Set model to Opus 4.8 and saved as your default for new sessions"),
|
|
486
|
+
});
|
|
487
|
+
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
488
|
+
expect(reply.text).toContain("Set model to Opus 4.8");
|
|
489
|
+
expect(reply.selectedModel).toBe("Opus 4.8");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("does NOT record an override when the confirmation is 'Kept model as' (no change)", async () => {
|
|
493
|
+
const { deps } = makeDeps({
|
|
494
|
+
inject: async () => okResult("⏺ Kept model as Opus 4.8 (default)"),
|
|
495
|
+
});
|
|
496
|
+
const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
497
|
+
// The kept line is still relayed as a confirmation, but nothing is recorded.
|
|
498
|
+
expect(reply.selectedModel).toBeUndefined();
|
|
387
499
|
});
|
|
388
500
|
});
|
|
389
501
|
|
|
@@ -423,14 +535,15 @@ describe("handleModelCommand — Claude → sr-* session relaunch", () => {
|
|
|
423
535
|
expect(restartCalls).toHaveLength(0);
|
|
424
536
|
});
|
|
425
537
|
|
|
426
|
-
it("sr-* → Claude
|
|
538
|
+
it("sr-* → Claude rides scheduleModelRelaunch (carrier) so the Claude model survives the restart", async () => {
|
|
427
539
|
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
428
540
|
getActiveSessionModel: () => "sr-glm-5",
|
|
429
541
|
});
|
|
430
542
|
await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
431
543
|
expect(calls).toHaveLength(0);
|
|
432
|
-
expect(
|
|
433
|
-
expect(
|
|
544
|
+
expect(restartCalls).toHaveLength(0);
|
|
545
|
+
expect(relaunchCalls).toHaveLength(1);
|
|
546
|
+
expect(relaunchCalls[0].model).toBe("opus");
|
|
434
547
|
});
|
|
435
548
|
|
|
436
549
|
it("surfaces scheduleModelRelaunch failures without propagating the error", async () => {
|
|
@@ -498,14 +611,15 @@ describe("handleModelCommand — arbitrary sr-* passthrough (no whitelist)", ()
|
|
|
498
611
|
}
|
|
499
612
|
});
|
|
500
613
|
|
|
501
|
-
it("switching FROM an arbitrary sr-* back to Claude
|
|
614
|
+
it("switching FROM an arbitrary sr-* back to Claude carries the Claude model via the relaunch carrier", async () => {
|
|
502
615
|
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
503
616
|
getActiveSessionModel: () => "sr-gpt-oss-120b",
|
|
504
617
|
});
|
|
505
618
|
await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
506
619
|
expect(calls).toHaveLength(0);
|
|
507
|
-
expect(
|
|
508
|
-
expect(
|
|
620
|
+
expect(restartCalls).toHaveLength(0);
|
|
621
|
+
expect(relaunchCalls).toHaveLength(1);
|
|
622
|
+
expect(relaunchCalls[0].model).toBe("opus");
|
|
509
623
|
});
|
|
510
624
|
});
|
|
511
625
|
|
|
@@ -565,14 +679,15 @@ describe("SR_MODEL_ALIASES / expandSrAlias", () => {
|
|
|
565
679
|
expect(relaunchCalls[0].model).toBe("sr-gemini-2.5-flash");
|
|
566
680
|
});
|
|
567
681
|
|
|
568
|
-
it("handleModelCommand with alias
|
|
569
|
-
const { deps, calls, restartCalls } = makeDeps({
|
|
682
|
+
it("handleModelCommand with a Claude alias relaunches (carrier) when session is on sr-*", async () => {
|
|
683
|
+
const { deps, calls, restartCalls, relaunchCalls } = makeDeps({
|
|
570
684
|
getActiveSessionModel: () => "sr-deepseek-v3",
|
|
571
685
|
});
|
|
572
686
|
await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
573
687
|
expect(calls).toHaveLength(0);
|
|
574
|
-
expect(restartCalls).toHaveLength(
|
|
575
|
-
expect(
|
|
688
|
+
expect(restartCalls).toHaveLength(0);
|
|
689
|
+
expect(relaunchCalls).toHaveLength(1);
|
|
690
|
+
expect(relaunchCalls[0].model).toBe("opus");
|
|
576
691
|
});
|
|
577
692
|
});
|
|
578
693
|
|
|
@@ -746,6 +861,40 @@ describe("handleModelMenuCallback", () => {
|
|
|
746
861
|
// The gateway records this so /status reflects the live session model.
|
|
747
862
|
expect(out.selectedModel).toBe("Haiku 4.5");
|
|
748
863
|
});
|
|
864
|
+
|
|
865
|
+
it("tapping the Default row when already on it (Kept model as) records NO override", async () => {
|
|
866
|
+
// Bug 6: "Kept model as X" is a no-change; the old code fell back to
|
|
867
|
+
// target.label and stored "Default (recommended)" verbatim into /status.
|
|
868
|
+
const { deps } = makeMenuDeps({
|
|
869
|
+
select: async () => ({ ok: true as const, confirmation: "Kept model as Opus 4.8 (default)" }),
|
|
870
|
+
});
|
|
871
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Default (recommended)"), deps);
|
|
872
|
+
expect(out.reply.text).toContain("✅");
|
|
873
|
+
// Crucially, NOTHING is recorded — no display label leaks into the override.
|
|
874
|
+
expect(out.selectedModel).toBeUndefined();
|
|
875
|
+
expect(out.reply.text).not.toContain("Default (recommended)");
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("a real switch on the Default row stores a canonical token, never the display label", async () => {
|
|
879
|
+
// The Default row confirms with a real model name; the /status value is that
|
|
880
|
+
// name (never "Default (recommended)"), and the carrier token is canonical.
|
|
881
|
+
const { deps } = makeMenuDeps({
|
|
882
|
+
select: async () => ({ ok: true as const, confirmation: "Set model to Opus 4.8 for this session only" }),
|
|
883
|
+
});
|
|
884
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Default (recommended)"), deps);
|
|
885
|
+
expect(out.selectedModel).toBe("Opus 4.8");
|
|
886
|
+
// "Default (recommended)" yields no --model token → carrier boots the config default.
|
|
887
|
+
expect(out.selectedModelToken).toBeUndefined();
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("selecting an alias row exposes a canonical --model token for the sr→claude carrier", async () => {
|
|
891
|
+
const { deps } = makeMenuDeps({
|
|
892
|
+
select: async () => ({ ok: true as const, confirmation: "Set model to Sonnet for this session" }),
|
|
893
|
+
});
|
|
894
|
+
const out = await handleModelMenuCallback(modelSelectCallbackData("Sonnet"), deps);
|
|
895
|
+
expect(out.selectedModel).toBe("Sonnet");
|
|
896
|
+
expect(out.selectedModelToken).toBe("sonnet");
|
|
897
|
+
});
|
|
749
898
|
});
|
|
750
899
|
|
|
751
900
|
describe("sessionModelFromConfirmation", () => {
|
|
@@ -754,6 +903,11 @@ describe("sessionModelFromConfirmation", () => {
|
|
|
754
903
|
expect(sessionModelFromConfirmation("Set model to Opus 4.8 (1M context) for this session only")).toBe("Opus 4.8");
|
|
755
904
|
expect(sessionModelFromConfirmation("Switched to Haiku 4.5")).toBe("Haiku 4.5");
|
|
756
905
|
});
|
|
906
|
+
it("terminates the name at 'and saved' (claude v2.1.205 arg-form phrasing, TUI-probe verified)", () => {
|
|
907
|
+
expect(
|
|
908
|
+
sessionModelFromConfirmation("⎿ Set model to Opus 4.8 and saved as your default for new sessions"),
|
|
909
|
+
).toBe("Opus 4.8");
|
|
910
|
+
});
|
|
757
911
|
it("returns null when no recognizable name is present", () => {
|
|
758
912
|
expect(sessionModelFromConfirmation("Kept model as Opus 4.8 (default)")).toBeNull();
|
|
759
913
|
expect(sessionModelFromConfirmation("")).toBeNull();
|
|
@@ -945,29 +1099,35 @@ describe("handleModelMenuCallback — sr-* selection", () => {
|
|
|
945
1099
|
});
|
|
946
1100
|
}
|
|
947
1101
|
|
|
948
|
-
it("sr-* tap
|
|
949
|
-
|
|
1102
|
+
it("sr-* tap delegates to the carrier relaunch — never a picker-rejecting inject", async () => {
|
|
1103
|
+
// The gateway intercepts mdl:sr: before this function; if a direct caller
|
|
1104
|
+
// reaches it, delegating to scheduleModelRelaunch is the ONLY safe path (an
|
|
1105
|
+
// inject of `/model sr-*` 4xxs — picker rejects the id, base-URL never repointed).
|
|
1106
|
+
const relaunch: Array<{ model: string }> = [];
|
|
1107
|
+
const { deps, calls, injectCalls } = makeMenuDepsWithSr({
|
|
1108
|
+
scheduleModelRelaunch: async (model) => { relaunch.push({ model }); },
|
|
1109
|
+
});
|
|
950
1110
|
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
|
|
951
|
-
// inject
|
|
952
|
-
expect(injectCalls).
|
|
953
|
-
// select (cursor nav) was NOT called
|
|
1111
|
+
// No inject, no cursor nav — a carrier relaunch on the exact sr-* id.
|
|
1112
|
+
expect(injectCalls).toHaveLength(0);
|
|
954
1113
|
expect(calls.select).toHaveLength(0);
|
|
955
|
-
expect(
|
|
1114
|
+
expect(relaunch).toEqual([{ model: "sr-gemini-2.5-pro" }]);
|
|
956
1115
|
expect(out.selectedModel).toBe("sr-gemini-2.5-pro");
|
|
957
|
-
// No keyboard on success: static reply path skips discover() to avoid the
|
|
958
|
-
// spurious "(picker unavailable)" line that discover() reliably produces
|
|
959
|
-
// immediately after an inject. Operator taps /model for a fresh menu.
|
|
960
1116
|
expect(out.reply.keyboard).toBeUndefined();
|
|
961
|
-
|
|
962
|
-
expect(out.reply.text).toContain('✅');
|
|
1117
|
+
expect(out.reply.text).toContain('🔄');
|
|
963
1118
|
expect(out.reply.text).not.toContain('picker unavailable');
|
|
964
1119
|
});
|
|
965
1120
|
|
|
966
|
-
it("sr-* tap while busy returns toast-only with no
|
|
967
|
-
const
|
|
1121
|
+
it("sr-* tap while busy returns toast-only with no relaunch", async () => {
|
|
1122
|
+
const relaunch: string[] = [];
|
|
1123
|
+
const { deps, injectCalls } = makeMenuDepsWithSr({
|
|
1124
|
+
isBusy: () => true,
|
|
1125
|
+
scheduleModelRelaunch: async (model) => { relaunch.push(model); },
|
|
1126
|
+
});
|
|
968
1127
|
const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
|
|
969
1128
|
expect(out.toastOnly).toBe(true);
|
|
970
1129
|
expect(injectCalls).toHaveLength(0);
|
|
1130
|
+
expect(relaunch).toHaveLength(0);
|
|
971
1131
|
});
|
|
972
1132
|
|
|
973
1133
|
it("rejects malformed sr-* callback data", async () => {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { formatModelLabel, isModelSentinel } from '../model-label.js'
|
|
3
|
+
|
|
4
|
+
describe('isModelSentinel', () => {
|
|
5
|
+
it('flags synthetic / placeholder values starting with "<"', () => {
|
|
6
|
+
expect(isModelSentinel('<synthetic>')).toBe(true)
|
|
7
|
+
expect(isModelSentinel('<compact>')).toBe(true)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('flags empty / whitespace / non-string values', () => {
|
|
11
|
+
expect(isModelSentinel('')).toBe(true)
|
|
12
|
+
expect(isModelSentinel(' ')).toBe(true)
|
|
13
|
+
expect(isModelSentinel(undefined)).toBe(true)
|
|
14
|
+
expect(isModelSentinel(null)).toBe(true)
|
|
15
|
+
expect(isModelSentinel(42)).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('flags junk that is not shaped like a model id', () => {
|
|
19
|
+
expect(isModelSentinel('not a model')).toBe(true) // whitespace
|
|
20
|
+
expect(isModelSentinel('!!!')).toBe(true) // leading punctuation
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('accepts real resolved model ids', () => {
|
|
24
|
+
expect(isModelSentinel('claude-opus-4-8')).toBe(false)
|
|
25
|
+
expect(isModelSentinel('claude-haiku-4-5-20251001')).toBe(false)
|
|
26
|
+
expect(isModelSentinel('sr-glm-5')).toBe(false)
|
|
27
|
+
expect(isModelSentinel('sr-gpt-5.5')).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('formatModelLabel', () => {
|
|
32
|
+
it('renders the friendly short form for claude models', () => {
|
|
33
|
+
expect(formatModelLabel('claude-opus-4-8')).toBe('opus 4.8')
|
|
34
|
+
expect(formatModelLabel('claude-sonnet-5')).toBe('sonnet 5')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('drops a trailing YYYYMMDD date stamp', () => {
|
|
38
|
+
expect(formatModelLabel('claude-haiku-4-5-20251001')).toBe('haiku 4.5')
|
|
39
|
+
expect(formatModelLabel('claude-opus-4-8-20260115')).toBe('opus 4.8')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('shows sr-* ids verbatim', () => {
|
|
43
|
+
expect(formatModelLabel('sr-glm-5')).toBe('sr-glm-5')
|
|
44
|
+
expect(formatModelLabel('sr-gpt-5.5')).toBe('sr-gpt-5.5')
|
|
45
|
+
expect(formatModelLabel('sr-gemini-2.5-pro')).toBe('sr-gemini-2.5-pro')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns null for sentinels and absent values (caller omits the field)', () => {
|
|
49
|
+
expect(formatModelLabel('<synthetic>')).toBeNull()
|
|
50
|
+
expect(formatModelLabel('')).toBeNull()
|
|
51
|
+
expect(formatModelLabel(null)).toBeNull()
|
|
52
|
+
expect(formatModelLabel(undefined)).toBeNull()
|
|
53
|
+
expect(formatModelLabel('has spaces')).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shows unknown but model-shaped ids verbatim', () => {
|
|
57
|
+
expect(formatModelLabel('gpt-5')).toBe('gpt-5')
|
|
58
|
+
expect(formatModelLabel('opus')).toBe('opus')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('handles a claude id with only a family segment', () => {
|
|
62
|
+
expect(formatModelLabel('claude-opus')).toBe('opus')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -253,6 +253,21 @@ describe('renderOperatorEvent — unknown-5xx', () => {
|
|
|
253
253
|
})
|
|
254
254
|
})
|
|
255
255
|
|
|
256
|
+
describe('renderOperatorEvent — config-warning', () => {
|
|
257
|
+
it('is framed as non-urgent (dismiss-only, no restart/reauth/logs actions)', () => {
|
|
258
|
+
const { text, keyboard } = renderOperatorEvent(
|
|
259
|
+
makeEvent('config-warning', { detail: 'person_id: dropped 1 malformed users: entry at boot — lisa (empty person_id)' }),
|
|
260
|
+
)
|
|
261
|
+
expect(text).toContain('Config warning')
|
|
262
|
+
expect(text).toContain('Non-urgent')
|
|
263
|
+
const buttons = keyboard.inline_keyboard.flat()
|
|
264
|
+
expect(buttons.every(b => b.callback_data?.includes('dismiss'))).toBe(true)
|
|
265
|
+
expect(buttons.some(b => b.callback_data?.includes('restart'))).toBe(false)
|
|
266
|
+
expect(buttons.some(b => b.callback_data?.includes('reauth'))).toBe(false)
|
|
267
|
+
expect(buttons.some(b => b.callback_data?.includes('logs'))).toBe(false)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
256
271
|
describe('renderOperatorEvent — markdown escaping (#2669)', () => {
|
|
257
272
|
it('passes < > literally in agent name (markdown, #2669)', () => {
|
|
258
273
|
const { text } = renderOperatorEvent(makeEvent('unknown-4xx', { agent: '<evil>' }))
|
|
@@ -276,6 +291,8 @@ describe('renderOperatorEvent — all kinds produce valid keyboard structure', (
|
|
|
276
291
|
'agent-restarted-unexpectedly',
|
|
277
292
|
'unknown-4xx',
|
|
278
293
|
'unknown-5xx',
|
|
294
|
+
'config-warning',
|
|
295
|
+
'always-allow-persist-failed',
|
|
279
296
|
]
|
|
280
297
|
|
|
281
298
|
for (const kind of allKinds) {
|