switchroom 0.18.23 → 0.18.25

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 (45) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1608 -841
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +524 -16
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/reply-owner-resolve.ts +160 -0
  15. package/telegram-plugin/session-tail.ts +185 -0
  16. package/telegram-plugin/subagent-watcher.ts +45 -0
  17. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  18. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  19. package/telegram-plugin/tests/history.test.ts +91 -0
  20. package/telegram-plugin/tests/model-command.test.ts +189 -12
  21. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  22. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  23. package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
  24. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  25. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  26. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  27. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  28. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  29. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  30. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
  31. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  32. package/telegram-plugin/tool-activity-summary.ts +54 -3
  33. package/telegram-plugin/worker-activity-feed.ts +222 -10
  34. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  35. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  36. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  37. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  38. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  39. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  40. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  42. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  45. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { decideRedeliverCapture } from '../gateway/redelivery-decision.js'
3
+ import {
4
+ decideBootResumeKind,
5
+ RESUME_SYNTHETIC_PROMPT_PREFIX,
6
+ } from '../gateway/resume-inbound-builder.js'
7
+ import type { Turn, TurnEndedVia } from '../registry/turns-schema.js'
8
+
9
+ /**
10
+ * Pins the MUTUAL EXCLUSION between crash-survival redelivery and the resume
11
+ * synthetic — the double-send guard. The gateway boot block composes exactly
12
+ * these two pure predicates: `decideBootResumeKind` classifies the interrupted
13
+ * turn, then `decideRedeliverCapture({ willBeResumed: kind === 'resume', ... })`
14
+ * decides whether to ALSO stage a redelivery.
15
+ *
16
+ * The load-bearing outcome: an interrupted turn that WILL be resumed (the model
17
+ * re-runs and emits a fresh answer) must NOT also redeliver its recovered draft
18
+ * — otherwise the same answer reaches the user twice. Conversely, a turn that
19
+ * will NOT be resumed (watchdog report, boot_resume:never suppression,
20
+ * resume-of-a-resume loop-guard) MUST redeliver, because nothing else re-answers.
21
+ *
22
+ * This mirrors the gateway wiring exactly, so it asserts the real composed
23
+ * outcome, not an isolated code path.
24
+ */
25
+
26
+ const RESUME_MAX_AGE_MS = 10_800_000 // 3h, gateway default
27
+
28
+ function makeTurn(over: Partial<Turn> = {}): Turn {
29
+ return {
30
+ turn_key: '900001:_#7',
31
+ chat_id: '900001',
32
+ thread_id: null,
33
+ started_at: Date.now() - 60_000, // 1 min ago — well within maxAge
34
+ ended_at: null,
35
+ ended_via: null,
36
+ last_assistant_msg_id: null,
37
+ last_assistant_done: null,
38
+ last_user_msg_id: null,
39
+ user_prompt_preview: 'deploy the staging stack',
40
+ assistant_reply_preview: null,
41
+ tool_call_count: 2,
42
+ interrupt_reason: null,
43
+ resumed_at: null,
44
+ session_id: 'sess-abcd', // durably pinned → redelivery is eligible on the floor
45
+ ...over,
46
+ } as Turn
47
+ }
48
+
49
+ /** Compose the two predicates exactly as the gateway boot block does. */
50
+ function composeGate(turn: Turn, suppressed: boolean) {
51
+ const bootResumeKind = decideBootResumeKind({
52
+ pending: turn,
53
+ suppressed,
54
+ ageMs: Math.max(0, Date.now() - turn.started_at),
55
+ maxAgeMs: RESUME_MAX_AGE_MS,
56
+ })
57
+ const capture = decideRedeliverCapture({
58
+ willBeResumed: bootResumeKind === 'resume',
59
+ hasSessionId: Boolean(turn.session_id),
60
+ })
61
+ return { bootResumeKind, capture }
62
+ }
63
+
64
+ describe('crash-redelivery ↔ resume mutual exclusion (no double-send)', () => {
65
+ it('(a) an interrupted turn that WILL be resumed does NOT also redeliver', () => {
66
+ // ended_via 'restart' → decideBootResumeKind returns 'resume': the model
67
+ // re-runs and emits a fresh answer that supersedes any recovered draft.
68
+ const turn = makeTurn({ ended_via: 'restart' as TurnEndedVia })
69
+ const { bootResumeKind, capture } = composeGate(turn, /* suppressed */ false)
70
+ expect(bootResumeKind).toBe('resume')
71
+ expect(capture.capture).toBe(false)
72
+ expect(capture.skipReason).toBe('will-be-resumed')
73
+ })
74
+
75
+ it('(a2) a still-open (ended_via=null) killed-mid-flight turn is resumed, not redelivered', () => {
76
+ const turn = makeTurn({ ended_via: null })
77
+ const { bootResumeKind, capture } = composeGate(turn, false)
78
+ expect(bootResumeKind).toBe('resume')
79
+ expect(capture.capture).toBe(false)
80
+ })
81
+
82
+ it('(b) a watchdog-timeout turn (report, no auto re-answer) DOES redeliver', () => {
83
+ // ended_via 'timeout' → 'report': the synthetic only ASKS the user whether
84
+ // to retry — it does not auto-re-answer. Redelivery is the correct recovery.
85
+ const turn = makeTurn({ ended_via: 'timeout' as TurnEndedVia })
86
+ const { bootResumeKind, capture } = composeGate(turn, false)
87
+ expect(bootResumeKind).toBe('report')
88
+ expect(capture.capture).toBe(true)
89
+ expect(capture.skipReason).toBeUndefined()
90
+ })
91
+
92
+ it('(b2) a boot_resume:never-suppressed turn (defer-suppressed) DOES redeliver', () => {
93
+ // suppressed=true → 'defer-suppressed': no synthetic re-run, so redelivery
94
+ // is the ONLY recovery send.
95
+ const turn = makeTurn({ ended_via: 'restart' as TurnEndedVia })
96
+ const { bootResumeKind, capture } = composeGate(turn, /* suppressed */ true)
97
+ expect(bootResumeKind).toBe('defer-suppressed')
98
+ expect(capture.capture).toBe(true)
99
+ })
100
+
101
+ it('(b3) a resume-of-a-resume (loop-guard → defer-loop) turn DOES redeliver', () => {
102
+ // A turn whose prompt is itself a resume synthetic → 'defer-loop': the chain
103
+ // is capped, no re-run happens, so redelivery must still recover the answer.
104
+ const turn = makeTurn({
105
+ ended_via: 'restart' as TurnEndedVia,
106
+ user_prompt_preview: `${RESUME_SYNTHETIC_PROMPT_PREFIX} Continue the interrupted deploy.`,
107
+ })
108
+ const { bootResumeKind, capture } = composeGate(turn, false)
109
+ expect(bootResumeKind).toBe('defer-loop')
110
+ expect(capture.capture).toBe(true)
111
+ })
112
+
113
+ it('(b4) a STALE resume downgraded to report DOES redeliver (not suppressed)', () => {
114
+ // A 'restart' turn older than maxAge downgrades resume→report in
115
+ // selectResumeBuilder. Because the final kind is 'report' (no re-run),
116
+ // redelivery must fire — the gate keys on the FINAL kind, not the raw
117
+ // ended_via.
118
+ const turn = makeTurn({
119
+ ended_via: 'restart' as TurnEndedVia,
120
+ started_at: Date.now() - (RESUME_MAX_AGE_MS + 60_000),
121
+ })
122
+ const { bootResumeKind, capture } = composeGate(turn, false)
123
+ expect(bootResumeKind).toBe('report')
124
+ expect(capture.capture).toBe(true)
125
+ })
126
+
127
+ it('eligibility floor still applies: no session_id → no redelivery even when not resumed', () => {
128
+ const turn = makeTurn({ ended_via: 'timeout' as TurnEndedVia, session_id: null })
129
+ const { capture } = composeGate(turn, false)
130
+ expect(capture.capture).toBe(false)
131
+ expect(capture.skipReason).toBe('no-session-id')
132
+ })
133
+ })
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { projectTrailingAnswerFromTranscript } from '../session-tail.js'
3
+ import { decideRedeliver, REDELIVERY_PREFIX } from '../gateway/redelivery-decision.js'
4
+
5
+ /**
6
+ * Pins the crash-survival redelivery WIRING GLUE — the composition
7
+ * `maybeRedeliverUndeliveredAnswer` performs in the boot send: project the
8
+ * interrupted turn's trailing answer from its transcript, then feed
9
+ * (`text`, `trailingIsText`) plus the durable oracle result into
10
+ * `decideRedeliver`. The oracle (`hasOutboundWithText`) and the projector are
11
+ * unit-tested in their own suites (history.test.ts / trailing-answer-projector
12
+ * .test.ts); this asserts the two ends plumb together — the projected answer is
13
+ * what gets framed, and a dangling mid-tool turn is refused before any send.
14
+ *
15
+ * The raw Telegram socket send and the post-`getMe()` connect sequencing are NOT
16
+ * unit-testable here (they require a connected grammy client); the ordering
17
+ * guarantee is documented on `maybeRedeliverUndeliveredAnswer` and enforced by
18
+ * its single call site inside the `didOneTimeSetup` block, which is unreachable
19
+ * until `bot.api.getMe()` resolves.
20
+ */
21
+
22
+ const FINAL_ANSWER = 'The deploy finished — all three services are green and healthy.'
23
+
24
+ function compose(transcript: string, hasDeliveredText: boolean) {
25
+ const projected = projectTrailingAnswerFromTranscript(transcript)
26
+ return decideRedeliver({
27
+ capturedText: projected.text,
28
+ trailingIsText: projected.trailingIsText,
29
+ hasDeliveredText,
30
+ alreadyRedelivered: false,
31
+ ageMs: 60_000,
32
+ maxAgeMs: 10_800_000,
33
+ })
34
+ }
35
+
36
+ function line(obj: unknown): string {
37
+ return JSON.stringify(obj)
38
+ }
39
+ const completedTurn = [
40
+ line({ type: 'user', message: { role: 'user', content: 'deploy status?' } }),
41
+ line({ type: 'assistant', message: { content: [{ type: 'text', text: 'Checking.' }] } }),
42
+ line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', id: 'toolu_x', input: {} }] } }),
43
+ line({ type: 'assistant', message: { content: [{ type: 'text', text: FINAL_ANSWER }] } }),
44
+ ].join('\n')
45
+ const danglingToolTurn = [
46
+ line({ type: 'user', message: { role: 'user', content: 'run the migration' } }),
47
+ line({ type: 'assistant', message: { content: [{ type: 'text', text: 'On it.' }] } }),
48
+ line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Bash', id: 'toolu_y', input: {} }] } }),
49
+ ].join('\n')
50
+
51
+ describe('crash-redelivery wiring glue (project → decide)', () => {
52
+ it('frames the PROJECTED trailing answer when the oracle says it was not delivered', () => {
53
+ const d = compose(completedTurn, /* hasDeliveredText */ false)
54
+ expect(d.redeliver).toBe(true)
55
+ expect(d.framedText?.startsWith(REDELIVERY_PREFIX)).toBe(true)
56
+ expect(d.framedText).toContain(FINAL_ANSWER)
57
+ })
58
+
59
+ it('skips when the oracle reports the projected answer was already delivered', () => {
60
+ expect(compose(completedTurn, /* hasDeliveredText */ true).skipReason).toBe('already-delivered')
61
+ })
62
+
63
+ it('refuses a turn killed mid-tool before any send', () => {
64
+ // The projector RESETS the buffer on the trailing tool_use, so the recovered
65
+ // text is empty AND trailingIsText is false — either guard refuses. The
66
+ // decision reports 'empty-text' (checked first); the load-bearing property is
67
+ // simply that no send happens for a dangling mid-tool turn.
68
+ const d = compose(danglingToolTurn, false)
69
+ expect(d.redeliver).toBe(false)
70
+ expect(d.skipReason).toBe('empty-text')
71
+ })
72
+ })
@@ -14,6 +14,8 @@ import {
14
14
  getRecentOutboundCount,
15
15
  getLatestInboundMessageId,
16
16
  hasOutboundDeliveredSince,
17
+ hasOutboundWithText,
18
+ normalizeDeliveryText,
17
19
  _resetForTests,
18
20
  } from '../history.js'
19
21
 
@@ -872,3 +874,92 @@ describe('forwarded-message origin columns', () => {
872
874
  expect(stored).toContain('Bob') // surrounding name preserved
873
875
  })
874
876
  })
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // hasOutboundWithText — durable text-identity delivery oracle (crash-survival
880
+ // redelivery). Keys on the ANSWER TEXT, not a chat+time window, so an interim
881
+ // progress_update earlier in the same turn does NOT false-positive "delivered".
882
+ // ---------------------------------------------------------------------------
883
+
884
+ describe('hasOutboundWithText (durable text-identity oracle)', () => {
885
+ it('does NOT match an interim progress message against the (undelivered) final answer', () => {
886
+ initHistory(stateDir, 30)
887
+ // The turn sent only an interim progress_update; the real final answer was
888
+ // lost in the crash and never recorded. The time-windowed oracle would say
889
+ // "delivered" off the progress row — the text-identity oracle must not.
890
+ recordOutbound({
891
+ chat_id: '1', thread_id: null, message_ids: [10],
892
+ texts: ['on it — pulling yesterday’s GitHub activity'], ts: 200,
893
+ })
894
+ const finalAnswer = 'The deploy finished — all three services are green.'
895
+ expect(hasOutboundWithText('1', finalAnswer, null)).toBe(false)
896
+ // sanity: the coarse time-window oracle DOES false-positive here (this is
897
+ // exactly why we cannot use it as the redelivery gate).
898
+ expect(hasOutboundDeliveredSince('1', 100 * 1000, null, 1)).toBe(true)
899
+ })
900
+
901
+ it('matches when the final answer text was actually delivered', () => {
902
+ initHistory(stateDir, 30)
903
+ const finalAnswer = 'The deploy finished — all three services are green.'
904
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [11], texts: [finalAnswer], ts: 300 })
905
+ expect(hasOutboundWithText('1', finalAnswer, null)).toBe(true)
906
+ })
907
+
908
+ it('matches a delivered chunk-1 against a longer projected answer (multi-chunk, no double-send)', () => {
909
+ initHistory(stateDir, 30)
910
+ const chunk1 = 'Part one of a long answer that was split across chunks.'
911
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [12], texts: [chunk1], ts: 300 })
912
+ // The re-projected full answer starts with chunk-1 → treated as delivered.
913
+ expect(hasOutboundWithText('1', chunk1 + ' Part two continues here.', null)).toBe(true)
914
+ })
915
+
916
+ it('ignores whitespace/spacer differences via normalization', () => {
917
+ initHistory(stateDir, 30)
918
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [13], texts: ['hello world'], ts: 300 })
919
+ expect(hasOutboundWithText('1', 'hello world', null)).toBe(true)
920
+ expect(normalizeDeliveryText('hello world')).toBe('hello world')
921
+ })
922
+
923
+ it('empty/whitespace text never matches', () => {
924
+ initHistory(stateDir, 30)
925
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [14], texts: ['real'], ts: 300 })
926
+ expect(hasOutboundWithText('1', ' ', null)).toBe(false)
927
+ })
928
+
929
+ it('scopes by chat (a different chat does not satisfy the match)', () => {
930
+ initHistory(stateDir, 30)
931
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [15], texts: ['scoped answer'], ts: 300 })
932
+ expect(hasOutboundWithText('2', 'scoped answer', null)).toBe(false)
933
+ expect(hasOutboundWithText('1', 'scoped answer', null)).toBe(true)
934
+ })
935
+
936
+ // diff-review defect #1 — a SHORT final answer must not false-positive-match an
937
+ // unrelated earlier row via the bidirectional-prefix rule (that would suppress a
938
+ // genuine redelivery = permanent silence). Short texts require full equality.
939
+ it('does NOT suppress a short answer that merely shares a prefix with an unrelated row', () => {
940
+ initHistory(stateDir, 30)
941
+ // An earlier turn delivered a longer line that starts with the short answer.
942
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [20], texts: ['Done, deploying now.'], ts: 300 })
943
+ // The interrupted turn's real final answer was the short "Done." — never sent.
944
+ expect(hasOutboundWithText('1', 'Done.', null)).toBe(false)
945
+ })
946
+
947
+ it('still suppresses a short answer that was genuinely delivered (exact match)', () => {
948
+ initHistory(stateDir, 30)
949
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [21], texts: ['Done.'], ts: 300 })
950
+ expect(hasOutboundWithText('1', 'Done.', null)).toBe(true)
951
+ })
952
+
953
+ // sinceMs scope: only rows delivered at/after the interrupted turn's started_at
954
+ // count, so an unrelated PRIOR turn's identical text can never suppress.
955
+ it('scopes by sinceMs (a prior-turn row before the floor does not match)', () => {
956
+ initHistory(stateDir, 30)
957
+ // Prior turn delivered this exact text at ts=200s.
958
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [22], texts: ['repeated answer'], ts: 200 })
959
+ // Interrupted turn started at 250s (250_000 ms) — the prior row is out of scope.
960
+ expect(hasOutboundWithText('1', 'repeated answer', null, 250_000)).toBe(false)
961
+ // A row delivered within the turn window (ts=300s) does match.
962
+ recordOutbound({ chat_id: '1', thread_id: null, message_ids: [23], texts: ['repeated answer'], ts: 300 })
963
+ expect(hasOutboundWithText('1', 'repeated answer', null, 250_000)).toBe(true)
964
+ })
965
+ })
@@ -200,6 +200,91 @@ describe("handleModelCommand — show / help never inject (picker-wedge guard)",
200
200
  });
201
201
  });
202
202
 
203
+ describe("handleModelCommand — set — #3241 poll-until-signal wiring", () => {
204
+ it("forwards successPattern + errorPattern + settleBeforeSendMs to the inject primitive", async () => {
205
+ const seen: Array<{ command: string; opts: unknown }> = [];
206
+ const { deps } = makeDeps({
207
+ inject: async (_agent, command, opts) => {
208
+ seen.push({ command, opts });
209
+ return okResult("⏺ Set model to Sonnet 5 for this session");
210
+ },
211
+ });
212
+ await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
213
+ expect(seen).toHaveLength(1);
214
+ const opts = seen[0].opts as {
215
+ successPattern?: RegExp;
216
+ errorPattern?: RegExp;
217
+ settleBeforeSendMs?: number;
218
+ };
219
+ // A success pattern that matches claude's confirmation line, an error pattern
220
+ // that matches the "not found" line, and a clean-prompt pre-send wait.
221
+ expect(opts.successPattern?.test("⏺ Set model to Sonnet 5")).toBe(true);
222
+ expect(opts.errorPattern?.test("⎿ Model 'x' not found")).toBe(true);
223
+ expect(typeof opts.settleBeforeSendMs).toBe("number");
224
+ expect(opts.settleBeforeSendMs).toBeGreaterThan(0);
225
+ });
226
+
227
+ it("confirmation that lands after a banner → records the live model for /status", async () => {
228
+ // The inject primitive (poll-until-signal) is responsible for returning the
229
+ // confirmation and not the banner; here the handler receives that confirmation
230
+ // and must record it as the session override.
231
+ const { deps } = makeDeps({
232
+ inject: async () =>
233
+ okResult("⠋ extending Claude Fable 5 access…\n⎿ Set model to Fable 5 for this session"),
234
+ });
235
+ const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
236
+ expect(reply.selectedModel).toBe("Fable 5");
237
+ expect(reply.optimistic).toBeUndefined();
238
+ expect(reply.text).toContain("Set model to Fable 5");
239
+ // The banner is scrollback and must not leak as prose above the confirmation.
240
+ expect(reply.text).not.toContain("extending Claude Fable 5 access");
241
+ });
242
+
243
+ it("scraped error line → failure verdict AND no override recorded (retract)", async () => {
244
+ const { deps } = makeDeps({
245
+ inject: async () => okResult("⎿ Model 'claude-bogus-99' not found"),
246
+ });
247
+ const reply = await handleModelCommand({ kind: "set", model: "claude-bogus-99" }, deps);
248
+ expect(reply.text).toContain("did not take");
249
+ expect(reply.selectedModel).toBeUndefined();
250
+ expect(reply.optimistic).toBeUndefined();
251
+ });
252
+
253
+ // #3242 review MEDIUM 1 — an access/entitlement denial must NOT be recorded
254
+ // optimistically. Each of these lines matches neither the confirmation prefix
255
+ // nor the OLD bad-id error regex, so before the widen they slipped into the
256
+ // optimistic branch and falsely recorded the switch.
257
+ for (const denial of [
258
+ "⎿ Fable is not available on your plan",
259
+ "⎿ access denied",
260
+ "⎿ Fable requires a Pro subscription",
261
+ "⎿ This model is not enabled for your account",
262
+ "⎿ No access to Fable on this tier",
263
+ "⎿ Fable 5 is currently unavailable",
264
+ ]) {
265
+ it(`access-denial line → failure verdict AND no override (retract): ${JSON.stringify(denial)}`, async () => {
266
+ const { deps } = makeDeps({ inject: async () => okResult(denial) });
267
+ const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
268
+ expect(reply.text).toContain("did not take");
269
+ expect(reply.selectedModel).toBeUndefined();
270
+ expect(reply.optimistic).toBeUndefined();
271
+ });
272
+ }
273
+
274
+ it("a genuine confirmation is NEVER flipped to a failure by the widened denial regex", async () => {
275
+ // Confirmation-first ordering: even if scrollback in the same region says
276
+ // something "unavailable", a real "Set model to …" line wins.
277
+ const mixed = [
278
+ "the metrics endpoint was unavailable earlier",
279
+ "⎿ Set model to Fable 5 for this session",
280
+ ].join("\n");
281
+ const { deps } = makeDeps({ inject: async () => okResult(mixed) });
282
+ const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
283
+ expect(reply.text).not.toContain("did not take");
284
+ expect(reply.selectedModel).toBe("Fable 5");
285
+ });
286
+ });
287
+
203
288
  describe("handleModelCommand — set", () => {
204
289
  it("injects exactly `/model <name>` once and relays a genuine confirmation + persistence note", async () => {
205
290
  const { deps, calls } = makeDeps();
@@ -228,13 +313,17 @@ describe("handleModelCommand — set", () => {
228
313
  expect(reply.text).not.toContain("summary you asked for");
229
314
  expect(reply.text).not.toContain("rollback plan");
230
315
  expect(reply.text).not.toContain("<pre>");
231
- // No confirmation line was captured, so the switch is UNVERIFIED — report it
232
- // honestly (never a false "switched") and record NO session override.
316
+ // #3241 part B poll-until-signal already waited the full window, so a
317
+ // missing confirmation line with NO scraped error is a SILENT switch, not a
318
+ // failure. Record the requested model optimistically so /status is right,
319
+ // and say so honestly (no false "switched (session)", no scrollback leak).
233
320
  expect(reply.text).toContain("/model fable");
234
- expect(reply.text).toContain("couldn't confirm the switch");
321
+ expect(reply.text).toContain("couldn't read a confirmation");
235
322
  expect(reply.text).toContain("/status");
323
+ expect(reply.text).not.toContain("Recorded");
236
324
  expect(reply.text).not.toContain("switched (session)");
237
- expect(reply.selectedModel).toBeUndefined();
325
+ expect(reply.selectedModel).toBe("Fable");
326
+ expect(reply.optimistic).toBe(true);
238
327
  expect(reply.html).toBe(true);
239
328
  });
240
329
 
@@ -260,15 +349,17 @@ describe("handleModelCommand — set", () => {
260
349
  const { deps } = makeDeps({ inject: async () => okResult(prose) });
261
350
  const reply = await handleModelCommand({ kind: "set", model: "fable" }, deps);
262
351
  // The anchored regex rejects all three lines, so nothing leaks and there is
263
- // no <pre> block. With no confirmation captured, the switch is UNVERIFIED
264
- // report it honestly, never a false "switched".
352
+ // no <pre> block. No confirmation AND no scraped error #3241 optimistic
353
+ // record: the switch is reported as sent + recorded, never a false
354
+ // "switched", and no scrollback prose leaks.
265
355
  expect(reply.text).not.toContain("<pre>");
266
356
  expect(reply.text).not.toContain("switched the deploy");
267
357
  expect(reply.text).not.toContain("set model behaviour");
268
358
  expect(reply.text).not.toContain("kept model changes");
269
- expect(reply.text).toContain("couldn't confirm the switch");
359
+ expect(reply.text).toContain("couldn't read a confirmation");
270
360
  expect(reply.text).not.toContain("switched (session)");
271
- expect(reply.selectedModel).toBeUndefined();
361
+ expect(reply.selectedModel).toBe("Fable");
362
+ expect(reply.optimistic).toBe(true);
272
363
  expect(reply.html).toBe(true);
273
364
  });
274
365
 
@@ -290,7 +381,13 @@ describe("handleModelCommand — set", () => {
290
381
  }),
291
382
  });
292
383
  const reply = await handleModelCommand({ kind: "set", model: "sonnet" }, deps);
293
- expect(reply.text).toContain("no response captured");
384
+ // #3241 part B — an empty capture can't carry an error line, so the send is
385
+ // treated as a silent switch and the requested model is recorded
386
+ // optimistically (was: "no response captured", recorded nothing).
387
+ expect(reply.text).toContain("couldn't read a confirmation");
388
+ expect(reply.text).toContain("/status");
389
+ expect(reply.selectedModel).toBe("Sonnet");
390
+ expect(reply.optimistic).toBe(true);
294
391
  });
295
392
 
296
393
  it("session_missing failure surfaces the tmux-supervisor hint", async () => {
@@ -475,9 +572,12 @@ describe("handleModelCommand — busy gate + honest unverified reporting", () =>
475
572
  const reply = await handleModelCommand({ kind: "set", model: "opus" }, deps);
476
573
  expect(reply.text).not.toContain("did not take");
477
574
  expect(reply.text).not.toContain("model not found");
478
- // No confirmation either honest unverified message, nothing recorded.
479
- expect(reply.text).toContain("couldn't confirm the switch");
480
- expect(reply.selectedModel).toBeUndefined();
575
+ // No LINE-ANCHORED error and no confirmation → #3241 optimistic record: the
576
+ // mid-sentence "model not found" prose does NOT flip the switch to a failure,
577
+ // and the requested model is recorded (was: "couldn't confirm", nothing).
578
+ expect(reply.text).toContain("couldn't read a confirmation");
579
+ expect(reply.selectedModel).toBe("Opus");
580
+ expect(reply.optimistic).toBe(true);
481
581
  });
482
582
 
483
583
  it("recognises claude v2.1.205's real arg-form confirmation (⎿ glyph + 'and saved as your default')", async () => {
@@ -713,6 +813,7 @@ import {
713
813
  EXTRA_CLAUDE_ALIASES,
714
814
  externalModelNames,
715
815
  isSrToClaudeTransition,
816
+ optimisticModelRecordLabel,
716
817
  type ModelMenuDeps,
717
818
  } from "../gateway/model-command.js";
718
819
  import { labelTag } from "../../src/agents/model-picker.js";
@@ -909,6 +1010,62 @@ describe("handleModelMenuCallback", () => {
909
1010
  });
910
1011
  });
911
1012
 
1013
+ // #3242 review MEDIUM 2 — the alias BUTTON (mdl:alias:<alias>, e.g. the Fable
1014
+ // button) must be symmetric with the typed set path: record-on-send / retract-
1015
+ // on-scraped-error, handling BOTH ok and ok_no_output. Before the fix a silent
1016
+ // successful button-switch (ok_no_output, or ok with no confirmation line) fell
1017
+ // through to "Switch failed" and dropped the override.
1018
+ describe("handleModelMenuCallback — alias button symmetry (#3242 MEDIUM 2)", () => {
1019
+ it("ok_no_output (silent switch via the button) → optimistic record, not a failure", async () => {
1020
+ const { deps } = makeMenuDeps({
1021
+ inject: async () => ({
1022
+ outcome: "ok_no_output" as const,
1023
+ output: "",
1024
+ truncated: false,
1025
+ command: "/model",
1026
+ meta: { description: "Open model picker", expectsOutput: true },
1027
+ }),
1028
+ });
1029
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
1030
+ expect(out.selectedModel).toBe("Fable"); // normalized display form
1031
+ expect(out.selectedModelToken).toBe("fable");
1032
+ // Provisional copy (#3242 FIX 2): doesn't assert the switch succeeded, points
1033
+ // at /status, and never reads as a failure.
1034
+ expect(out.reply.text).not.toContain("failed");
1035
+ expect(out.reply.text).toContain("/status");
1036
+ expect(out.reply.text).toContain("/model fable");
1037
+ });
1038
+
1039
+ it("ok with no confirmation line (banner-only) → optimistic record", async () => {
1040
+ const { deps } = makeMenuDeps({
1041
+ inject: async () => okResult("⠋ extending Claude Fable 5 access…"),
1042
+ });
1043
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
1044
+ expect(out.selectedModel).toBe("Fable");
1045
+ expect(out.selectedModelToken).toBe("fable");
1046
+ // The banner must not leak as the confirmation.
1047
+ expect(out.reply.text).not.toContain("extending Claude Fable 5 access");
1048
+ });
1049
+
1050
+ it("scraped access-denial line via the button → failure, no override (retract)", async () => {
1051
+ const { deps } = makeMenuDeps({
1052
+ inject: async () => okResult("⎿ Fable is not available on your plan"),
1053
+ });
1054
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
1055
+ expect(out.selectedModel).toBeUndefined();
1056
+ expect(out.reply.text).toContain("did not take");
1057
+ });
1058
+
1059
+ it("genuine confirmation via the button still records the display name", async () => {
1060
+ const { deps } = makeMenuDeps({
1061
+ inject: async () => okResult("⎿ Set model to Fable 5 for this session"),
1062
+ });
1063
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_ALIAS}fable`, deps);
1064
+ expect(out.selectedModel).toBe("Fable 5");
1065
+ expect(out.selectedModelToken).toBe("fable");
1066
+ });
1067
+ });
1068
+
912
1069
  describe("sessionModelFromConfirmation", () => {
913
1070
  it("pulls the model name from claude's session-switch confirmation", () => {
914
1071
  expect(sessionModelFromConfirmation("Set model to Fable 5 for this session only")).toBe("Fable 5");
@@ -1170,6 +1327,26 @@ describe("isSrToClaudeTransition", () => {
1170
1327
  expect(isSrToClaudeTransition("sr-gemini-2.5-pro", "sr-deepseek-r1")).toBe(false);
1171
1328
  });
1172
1329
 
1330
+ // #3242 review FIX 1 — optimisticModelRecordLabel must NOT de-prefix an sr-*
1331
+ // token: the stored selectedModel doubles as the sr-*→Claude sentinel that
1332
+ // isSrToClaudeTransition (prevModel.startsWith('sr-')) reads. De-prefixing to
1333
+ // a friendly label ("kimi k2") would silently kill the graceful restart that
1334
+ // tears down LiteLLM routing on a subsequent Claude switch.
1335
+ it("optimisticModelRecordLabel keeps sr-* tokens verbatim so the sentinel survives", () => {
1336
+ const recorded = optimisticModelRecordLabel("sr-kimi-k2");
1337
+ expect(recorded).toBe("sr-kimi-k2"); // NOT "kimi k2"
1338
+ // The recorded value still trips the sr→Claude transition on a later switch.
1339
+ expect(isSrToClaudeTransition(recorded, "opus")).toBe(true);
1340
+ // Regression guard: the de-prefixed form would NOT — that was the bug.
1341
+ expect(isSrToClaudeTransition("kimi k2", "opus")).toBe(false);
1342
+ });
1343
+
1344
+ it("optimisticModelRecordLabel Title-cases a bare Claude alias, leaves full ids as-is", () => {
1345
+ expect(optimisticModelRecordLabel("fable")).toBe("Fable");
1346
+ expect(optimisticModelRecordLabel("opus")).toBe("Opus");
1347
+ expect(optimisticModelRecordLabel("claude-opus-4-8")).toBe("claude-opus-4-8");
1348
+ });
1349
+
1173
1350
  it("false when switching to sr-* from Claude (Claude → sr-*)", () => {
1174
1351
  expect(isSrToClaudeTransition("Sonnet", "sr-gemini-2.5-pro")).toBe(false);
1175
1352
  });
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ decideRedeliver,
4
+ frameRedelivery,
5
+ REDELIVERY_PREFIX,
6
+ type RedeliverDecisionInput,
7
+ } from '../gateway/redelivery-decision.js'
8
+
9
+ /**
10
+ * Pins the crash-survival redelivery decision predicate. The load-bearing
11
+ * property is the DURABLE TEXT-IDENTITY oracle: an interim `progress_update`
12
+ * earlier in the same turn must NOT suppress a genuinely-undelivered final
13
+ * answer (the exact MISS = permanent-silence bug this fix closes), while an
14
+ * already-delivered final answer MUST be suppressed.
15
+ */
16
+
17
+ const base: RedeliverDecisionInput = {
18
+ capturedText: 'The deploy finished — all three services are green.',
19
+ trailingIsText: true,
20
+ hasDeliveredText: false,
21
+ alreadyRedelivered: false,
22
+ ageMs: 60_000,
23
+ maxAgeMs: 10_800_000, // 3h
24
+ }
25
+
26
+ describe('decideRedeliver — text-identity oracle', () => {
27
+ it('REDELIVERS an undelivered final answer even when an interim message was sent (the MISS bug)', () => {
28
+ // The MISS scenario: a progress_update landed a role=assistant row earlier
29
+ // in the turn, so a chat+time-window oracle would say "delivered" and skip.
30
+ // The text-identity oracle keys on the ANSWER text — which was never sent —
31
+ // so hasDeliveredText is false and redelivery fires.
32
+ const d = decideRedeliver({ ...base, hasDeliveredText: false })
33
+ expect(d.redeliver).toBe(true)
34
+ expect(d.framedText).toContain(base.capturedText)
35
+ expect(d.framedText?.startsWith(REDELIVERY_PREFIX)).toBe(true)
36
+ })
37
+
38
+ it('SUPPRESSES when the final answer text was already delivered', () => {
39
+ const d = decideRedeliver({ ...base, hasDeliveredText: true })
40
+ expect(d.redeliver).toBe(false)
41
+ expect(d.skipReason).toBe('already-delivered')
42
+ })
43
+ })
44
+
45
+ describe('decideRedeliver — other skip reasons', () => {
46
+ it('skips empty / whitespace text', () => {
47
+ expect(decideRedeliver({ ...base, capturedText: ' ' }).skipReason).toBe('empty-text')
48
+ })
49
+
50
+ it('skips when trailing content is a dangling tool_use (mid-stream, not an answer)', () => {
51
+ expect(decideRedeliver({ ...base, trailingIsText: false }).skipReason).toBe('trailing-not-text')
52
+ })
53
+
54
+ it('skips an already-redelivered turn (at-most-once ledger)', () => {
55
+ expect(decideRedeliver({ ...base, alreadyRedelivered: true }).skipReason).toBe('already-redelivered')
56
+ })
57
+
58
+ it('skips a stale turn beyond maxAgeMs', () => {
59
+ expect(decideRedeliver({ ...base, ageMs: 4 * 3_600_000 }).skipReason).toBe('stale')
60
+ })
61
+
62
+ it('precedence: empty-text wins over delivered/redelivered/stale', () => {
63
+ const d = decideRedeliver({
64
+ ...base,
65
+ capturedText: '',
66
+ hasDeliveredText: true,
67
+ alreadyRedelivered: true,
68
+ ageMs: 999_999_999,
69
+ })
70
+ expect(d.skipReason).toBe('empty-text')
71
+ })
72
+
73
+ it('precedence: already-redelivered is checked before the age failsafe', () => {
74
+ const d = decideRedeliver({ ...base, alreadyRedelivered: true, ageMs: 999_999_999 })
75
+ expect(d.skipReason).toBe('already-redelivered')
76
+ })
77
+ })
78
+
79
+ describe('frameRedelivery', () => {
80
+ it('frames as a recovered draft, never a clean final answer', () => {
81
+ const framed = frameRedelivery(' hello world ')
82
+ expect(framed).toBe(`${REDELIVERY_PREFIX}\n\nhello world`)
83
+ })
84
+ })