switchroom 0.18.24 → 0.18.26

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 (54) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +2 -2
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1827 -831
  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 +314 -3
  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/render/code-segments.ts +210 -0
  15. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  16. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  17. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  18. package/telegram-plugin/render/line-start-guard.ts +167 -0
  19. package/telegram-plugin/render/rich-render.ts +7 -0
  20. package/telegram-plugin/rich-send.ts +48 -2
  21. package/telegram-plugin/session-tail.ts +185 -0
  22. package/telegram-plugin/subagent-watcher.ts +45 -0
  23. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  24. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  25. package/telegram-plugin/tests/history.test.ts +91 -0
  26. package/telegram-plugin/tests/model-command.test.ts +189 -12
  27. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  28. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  29. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  30. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  31. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  32. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  33. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  35. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  36. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  37. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  38. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  39. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  40. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  41. package/telegram-plugin/tool-activity-summary.ts +54 -3
  42. package/telegram-plugin/worker-activity-feed.ts +104 -0
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  44. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  45. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  46. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  47. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  48. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  49. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  50. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  51. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  54. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -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
+ })
@@ -20,6 +20,8 @@ import {
20
20
  markOrphanedWithTimeoutClassification,
21
21
  findLatestTurnIfInterrupted,
22
22
  markTurnResumed,
23
+ markAnswerRedelivered,
24
+ stampTurnSessionId,
23
25
  getTurnByKey,
24
26
  } from '../registry/turns-schema.js'
25
27
 
@@ -574,3 +576,52 @@ describe('markTurnResumed', () => {
574
576
  db.close()
575
577
  })
576
578
  })
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // stampTurnSessionId + markAnswerRedelivered — crash-survival redelivery
582
+ // ---------------------------------------------------------------------------
583
+
584
+ describe('stampTurnSessionId', () => {
585
+ it('pins the session id on the turn (first-write-wins) and defaults null', () => {
586
+ const db = openTurnsDbInMemory()
587
+ recordTurnStart(db, { turnKey: 'sx:1', chatId: 'sx' })
588
+ expect(getTurnByKey(db, 'sx:1')!.session_id).toBeNull()
589
+ stampTurnSessionId(db, 'sx:1', 'sess-abc')
590
+ expect(getTurnByKey(db, 'sx:1')!.session_id).toBe('sess-abc')
591
+ // first-write-wins: a later different session id does not overwrite
592
+ stampTurnSessionId(db, 'sx:1', 'sess-def')
593
+ expect(getTurnByKey(db, 'sx:1')!.session_id).toBe('sess-abc')
594
+ db.close()
595
+ })
596
+
597
+ it('ignores an empty session id and no-ops on unknown key', () => {
598
+ const db = openTurnsDbInMemory()
599
+ recordTurnStart(db, { turnKey: 'sx:2', chatId: 'sx' })
600
+ stampTurnSessionId(db, 'sx:2', '')
601
+ expect(getTurnByKey(db, 'sx:2')!.session_id).toBeNull()
602
+ expect(() => stampTurnSessionId(db, 'nope', 'x')).not.toThrow()
603
+ db.close()
604
+ })
605
+ })
606
+
607
+ describe('markAnswerRedelivered', () => {
608
+ it('stamps answer_redelivered_at (first-write-wins) on a SEPARATE marker from resumed_at', () => {
609
+ const db = openTurnsDbInMemory()
610
+ recordTurnStart(db, { turnKey: 'rd:1', chatId: 'rd' })
611
+ recordTurnEnd(db, { turnKey: 'rd:1', endedVia: 'restart' })
612
+ expect(getTurnByKey(db, 'rd:1')!.answer_redelivered_at).toBeNull()
613
+ markAnswerRedelivered(db, 'rd:1', 1_700_000_000_000)
614
+ expect(getTurnByKey(db, 'rd:1')!.answer_redelivered_at).toBe(1_700_000_000_000)
615
+ // does not touch resumed_at — the two ledgers are independent
616
+ expect(getTurnByKey(db, 'rd:1')!.resumed_at).toBeNull()
617
+ markAnswerRedelivered(db, 'rd:1', 1_800_000_000_000)
618
+ expect(getTurnByKey(db, 'rd:1')!.answer_redelivered_at).toBe(1_700_000_000_000)
619
+ db.close()
620
+ })
621
+
622
+ it('no-ops for an unknown turn_key', () => {
623
+ const db = openTurnsDbInMemory()
624
+ expect(() => markAnswerRedelivered(db, 'nope:1')).not.toThrow()
625
+ db.close()
626
+ })
627
+ })
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { guardDollarMath } from "../../render/dollar-math-guard.js";
3
+ import { richMessage } from "../../rich-send.js";
4
+ import { computeReplyChunks } from "../../gateway/outbound-send-path.js";
5
+ import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
6
+
7
+ // Any U+1D400–U+1D7FF codepoint = a mathematical-alphanumeric (math-italic /
8
+ // math-bold) glyph — what a math renderer produces from a `$…$` span.
9
+ const MATH_GLYPH = /[\u{1D400}-\u{1D7FF}]/u;
10
+
11
+ /** Strip zero-width chars + defusing backslashes so we can assert the amount
12
+ * the reader copies is byte-identical to the original ASCII currency token.
13
+ * The wire body now flows through the composed richMessage guard, so besides
14
+ * the dollar defuser (`\$`) it may also carry the inline-pairs defuser (`\~`)
15
+ * for approximation tildes (`~$0.5M`) — strip both so the round-trip holds. */
16
+ function copyText(s: string): string {
17
+ return s.replace(/[​⁠]/g, "").replace(/\\([$~])/g, "$1");
18
+ }
19
+
20
+ describe("guardDollarMath (#3252)", () => {
21
+ const OFFENDING =
22
+ "acquisition ceiling is ~$0.5-0.9M but nearly all in small dealers ... is ~$150-450k";
23
+
24
+ it("neutralises the two-dollar-amount currency string so no `$…$` span can form", () => {
25
+ const out = guardDollarMath(OFFENDING);
26
+ // Both currency dollars are backslash-escaped → no unescaped `$` remains to
27
+ // open/close a math span.
28
+ expect(out).not.toMatch(/(?<!\\)\$/);
29
+ expect(out).toContain("\\$0.5-0.9M");
30
+ expect(out).toContain("\\$150-450k");
31
+ });
32
+
33
+ it("carries no math-italic glyphs (switchroom never emits U+1D400-range)", () => {
34
+ expect(guardDollarMath(OFFENDING)).not.toMatch(MATH_GLYPH);
35
+ // …nor does the raw source; the guard is what keeps the WIRE bytes clean.
36
+ expect(OFFENDING).not.toMatch(MATH_GLYPH);
37
+ });
38
+
39
+ it("preserves the visible amounts: stripping the defuser yields the original", () => {
40
+ expect(copyText(guardDollarMath(OFFENDING))).toBe(OFFENDING);
41
+ });
42
+
43
+ it("is a strict no-op for a single `$digit` prose token", () => {
44
+ const s = "grab a $5 coffee on the way";
45
+ expect(guardDollarMath(s)).toBe(s);
46
+ });
47
+
48
+ it("is a strict no-op when there is no `$digit` at all", () => {
49
+ const s = "the cost is unknown but the $ sign appears twice: $ and $";
50
+ expect(guardDollarMath(s)).toBe(s);
51
+ });
52
+
53
+ it("never touches `$` inside a code span", () => {
54
+ const s = "shell math `$x = $y` is fine and `$z = $w` too";
55
+ // Two code spans, each with 2 `$digit`? No — non-digit. Use digits to prove
56
+ // code is skipped even when it WOULD otherwise trigger.
57
+ const withDigits = "compute `$1 + $2` and also `$3 + $4` inline";
58
+ expect(guardDollarMath(s)).toBe(s);
59
+ expect(guardDollarMath(withDigits)).toBe(withDigits);
60
+ });
61
+
62
+ it("escapes prose dollars but leaves an adjacent code span verbatim", () => {
63
+ const s = "prices $10 and $20 — the var is `$PRICE = $10`";
64
+ const out = guardDollarMath(s);
65
+ // Prose amounts escaped:
66
+ expect(out).toContain("\\$10 and \\$20");
67
+ // Code span verbatim (its `$10` NOT escaped):
68
+ expect(out).toContain("`$PRICE = $10`");
69
+ });
70
+
71
+ // ── F3: widened detection (trailing `$`, `$.50`, `$ ` with a bare gap) ────
72
+ it("escapes a mix of leading- and trailing-`$` amounts (`$50 or 50$`)", () => {
73
+ const out = guardDollarMath("It costs $50 or 50$ depending on the vendor");
74
+ // Both dollars gone from the unescaped set → no `$…$` pair can form.
75
+ expect(out).not.toMatch(/(?<!\\)\$/);
76
+ expect(out).toContain("\\$50 or 50\\$");
77
+ });
78
+
79
+ it("escapes when one dollar is bare (`$10 today (down from $ yesterday)`)", () => {
80
+ const out = guardDollarMath("$10 today (down from $ yesterday)");
81
+ expect(out).not.toMatch(/(?<!\\)\$/);
82
+ });
83
+
84
+ it("escapes a `$.50` amount whose `$` is not digit-adjacent by one char", () => {
85
+ const out = guardDollarMath("$.50 here and $5 there");
86
+ expect(out).not.toMatch(/(?<!\\)\$/);
87
+ expect(out).toContain("\\$.50 here and \\$5 there");
88
+ });
89
+
90
+ it("leaves two non-currency `$` (shell-var prose) untouched — no false positive", () => {
91
+ const s = "use $foo and $bar as the two variables";
92
+ expect(guardDollarMath(s)).toBe(s);
93
+ });
94
+
95
+ // ── F5: idempotent — running the guard twice never doubles the backslash ──
96
+ it("is idempotent: guarding already-guarded text is a strict no-op", () => {
97
+ const once = guardDollarMath(OFFENDING);
98
+ expect(guardDollarMath(once)).toBe(once);
99
+ expect(once).not.toContain("\\\\$"); // never a doubled `\\$`
100
+ });
101
+ });
102
+
103
+ // F1/F2 regression: exercise the ACTUAL reply pipe. The `reply` tool's final
104
+ // answer flows executeReply → computeReplyChunks → sendReplyChunks →
105
+ // richMessage(chunk). `richMessage` is the single wire seam the guard now lives
106
+ // in; these assertions FAIL against the pre-fix code (bare `{ markdown }` wrap,
107
+ // no guard) and pass after. We drive the real `computeReplyChunks` output
108
+ // through the real `richMessage`, not `guardDollarMath` in isolation.
109
+ describe("reply-path integration (#3252 F1/F2)", () => {
110
+ const OFFENDING =
111
+ "acquisition ceiling is ~$0.5-0.9M but nearly all in small dealers ... is ~$150-450k";
112
+
113
+ it("richMessage() escapes the `$…$` pair on the markdown wire body", () => {
114
+ const wire = richMessage(OFFENDING);
115
+ // The bytes handed to sendRichMessage/editMessageText carry no unescaped `$`.
116
+ expect(wire.markdown).not.toMatch(/(?<!\\)\$/);
117
+ expect(wire.markdown).not.toMatch(MATH_GLYPH);
118
+ // Reader-visible amounts intact once the defuser is stripped.
119
+ expect(copyText(wire.markdown)).toBe(OFFENDING);
120
+ });
121
+
122
+ it("the full computeReplyChunks → richMessage reply pipe emits no unescaped `$`", () => {
123
+ // Exactly what executeReply feeds sendReplyChunks for a non-literal reply.
124
+ const chunks = computeReplyChunks({
125
+ effectiveText: OFFENDING,
126
+ literalText: false,
127
+ limit: RICH_MESSAGE_MAX_CHARS,
128
+ chunkMode: "newline",
129
+ });
130
+ expect(chunks.length).toBeGreaterThan(0);
131
+ for (const chunk of chunks) {
132
+ const wire = richMessage(chunk); // sendReplyChunks wraps each chunk this way
133
+ expect(wire.markdown).not.toMatch(/(?<!\\)\$/);
134
+ expect(wire.markdown).not.toMatch(MATH_GLYPH);
135
+ }
136
+ });
137
+
138
+ it("a single-amount reply is left with its dollar untouched on the wire", () => {
139
+ const wire = richMessage("the retainer is $2500 per month");
140
+ expect(wire.markdown).toContain("$2500");
141
+ expect(wire.markdown).not.toContain("\\$2500");
142
+ });
143
+ });
144
+
145
+ describe("guardDollarMath — link / table awareness (findings 1 & 3)", () => {
146
+ it("does NOT escape a `$` inside a markdown link destination", () => {
147
+ // Two dollars + a digit-adjacent one would normally arm the guard, but both
148
+ // live in URL query strings → structural → left verbatim.
149
+ const s = "buy [x](https://x.io?price=$5) or [y](https://y.io?price=$9)";
150
+ expect(guardDollarMath(s)).toBe(s);
151
+ });
152
+
153
+ it("does NOT escape `$` inside a real table's cells", () => {
154
+ const s = ["| Item | Cost |", "| --- | --- |", "| a | $5 |", "| b | $9 |"].join("\n");
155
+ expect(guardDollarMath(s)).toBe(s);
156
+ });
157
+
158
+ it("STILL escapes two currency dollars in ordinary prose", () => {
159
+ const out = guardDollarMath("spend was $5 today and $9 tomorrow");
160
+ expect(out).not.toMatch(/(?<!\\)\$/);
161
+ });
162
+ });