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
@@ -56,6 +56,17 @@ describe('createSessionModelSource — freshest observation wins', () => {
56
56
  expect(s.resolve()).toEqual({ model: 'claude-opus-4-8', source: 'transcript' })
57
57
  })
58
58
 
59
+ it('#3241 optimistic override: a silently-switched model recorded without a confirmation still wins /status', () => {
60
+ // Part B — the typed set path records the REQUESTED model optimistically when
61
+ // the confirmation line was missed. That override, being the freshest write,
62
+ // must reclaim /status from the stale transcript line just like a confirmed
63
+ // switch (symptom 4: "/status shows old model" after an in-place typed switch).
64
+ const s = createSessionModelSource()
65
+ s.noteTranscriptModel('claude-opus-4-8') // old model's last assistant line
66
+ s.setOverride('fable') // optimistic record of the requested model (no confirmation read)
67
+ expect(s.resolve()).toEqual({ model: 'fable', source: 'override' })
68
+ })
69
+
59
70
  it('getOverride reports the override independent of freshness', () => {
60
71
  const s = createSessionModelSource()
61
72
  s.setOverride('sr-glm-5')
@@ -6,12 +6,65 @@ import {
6
6
  projectTranscriptLine,
7
7
  projectSubagentLine,
8
8
  projectAssistantTextBlocks,
9
+ sumUsageTokens,
9
10
  sanitizeCwdToProjectName,
10
11
  getProjectsDirForCwd,
11
12
  startSessionTail,
12
13
  type SessionEvent,
13
14
  } from '../session-tail.js'
14
15
 
16
+ describe('sumUsageTokens', () => {
17
+ it('sums input + output + cache_creation (cache_read excluded)', () => {
18
+ expect(
19
+ sumUsageTokens({
20
+ input_tokens: 100,
21
+ output_tokens: 50,
22
+ cache_read_input_tokens: 1000,
23
+ cache_creation_input_tokens: 200,
24
+ }),
25
+ ).toBe(350)
26
+ })
27
+
28
+ it('excludes a large cache_read_input_tokens from the total', () => {
29
+ // Locks in the deliberate exclusion: replayed cached context must not
30
+ // inflate the "new work this turn" figure. A future regression that
31
+ // re-adds cache_read makes this fail.
32
+ expect(
33
+ sumUsageTokens({
34
+ input_tokens: 100,
35
+ output_tokens: 50,
36
+ cache_read_input_tokens: 999_999,
37
+ cache_creation_input_tokens: 200,
38
+ }),
39
+ ).toBe(350)
40
+ })
41
+
42
+ it('guards missing fields with 0', () => {
43
+ expect(sumUsageTokens({ output_tokens: 42 })).toBe(42)
44
+ expect(sumUsageTokens({})).toBe(0)
45
+ })
46
+
47
+ it('ignores the nested iterations / cache_creation breakdown (no double count)', () => {
48
+ expect(
49
+ sumUsageTokens({
50
+ input_tokens: 2,
51
+ output_tokens: 3,
52
+ cache_read_input_tokens: 4,
53
+ cache_creation_input_tokens: 5,
54
+ cache_creation: { ephemeral_1h_input_tokens: 5, ephemeral_5m_input_tokens: 0 },
55
+ iterations: [{ input_tokens: 2, output_tokens: 3 }],
56
+ }),
57
+ ).toBe(10)
58
+ })
59
+
60
+ it('returns 0 for null / non-object / non-numeric fields', () => {
61
+ expect(sumUsageTokens(null)).toBe(0)
62
+ expect(sumUsageTokens(undefined)).toBe(0)
63
+ expect(sumUsageTokens('nope')).toBe(0)
64
+ expect(sumUsageTokens({ input_tokens: 'x', output_tokens: null })).toBe(0)
65
+ })
66
+ })
67
+
15
68
  describe('sanitizeCwdToProjectName', () => {
16
69
  it('replaces non-alphanumeric chars with hyphens', () => {
17
70
  expect(sanitizeCwdToProjectName('/home/user/.switchroom/agents/assistant')).toBe(
@@ -381,6 +434,49 @@ describe('projectTranscriptLine', () => {
381
434
  })
382
435
  expect(projectTranscriptLine(line)).toEqual([{ kind: 'thinking' }])
383
436
  })
437
+
438
+ it('emits a main-tier usage event with the summed per-message token delta + message.id', () => {
439
+ const line = JSON.stringify({
440
+ type: 'assistant',
441
+ message: {
442
+ id: 'msg_main_1',
443
+ model: 'claude-opus-4-8',
444
+ usage: {
445
+ input_tokens: 100,
446
+ output_tokens: 50,
447
+ cache_read_input_tokens: 1000,
448
+ cache_creation_input_tokens: 200,
449
+ },
450
+ content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
451
+ },
452
+ })
453
+ const usage = projectTranscriptLine(line).find((e) => e.kind === 'usage')
454
+ expect(usage).toEqual({ kind: 'usage', messageId: 'msg_main_1', totalTokens: 350 })
455
+ })
456
+
457
+ it('emits no main-tier usage event when the assistant line carries no usage', () => {
458
+ const line = JSON.stringify({
459
+ type: 'assistant',
460
+ message: {
461
+ id: 'msg_main_2',
462
+ model: 'claude-opus-4-8',
463
+ content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
464
+ },
465
+ })
466
+ expect(projectTranscriptLine(line).some((e) => e.kind === 'usage')).toBe(false)
467
+ })
468
+
469
+ it('carries a null messageId on the main-tier usage event when message.id is absent', () => {
470
+ const line = JSON.stringify({
471
+ type: 'assistant',
472
+ message: {
473
+ usage: { input_tokens: 5, output_tokens: 5 },
474
+ content: [{ type: 'text', text: 'working' }],
475
+ },
476
+ })
477
+ const usage = projectTranscriptLine(line).find((e) => e.kind === 'usage')
478
+ expect(usage).toEqual({ kind: 'usage', messageId: null, totalTokens: 10 })
479
+ })
384
480
  })
385
481
 
386
482
  // ─── Bug 1 regression: per-file cursor state survives re-attachment ────
@@ -554,6 +650,55 @@ describe('projectSubagentLine', () => {
554
650
  expect(events[0].kind).toBe('sub_agent_tool_use')
555
651
  })
556
652
 
653
+ it('emits sub_agent_usage with the summed per-message token delta + message.id', () => {
654
+ const st = { hasEmittedStart: true }
655
+ const line = JSON.stringify({
656
+ type: 'assistant',
657
+ message: {
658
+ id: 'msg_1',
659
+ model: 'claude-opus-4-8',
660
+ usage: {
661
+ input_tokens: 100,
662
+ output_tokens: 50,
663
+ cache_read_input_tokens: 1000,
664
+ cache_creation_input_tokens: 200,
665
+ },
666
+ content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
667
+ },
668
+ })
669
+ const events = projectSubagentLine(line, 'X', st)
670
+ const usage = events.find((e) => e.kind === 'sub_agent_usage')
671
+ expect(usage).toEqual({ kind: 'sub_agent_usage', agentId: 'X', messageId: 'msg_1', totalTokens: 350 })
672
+ })
673
+
674
+ it('emits no sub_agent_usage when the assistant line carries no usage', () => {
675
+ const st = { hasEmittedStart: true }
676
+ const line = JSON.stringify({
677
+ type: 'assistant',
678
+ message: {
679
+ id: 'msg_2',
680
+ model: 'claude-opus-4-8',
681
+ content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
682
+ },
683
+ })
684
+ const events = projectSubagentLine(line, 'X', st)
685
+ expect(events.some((e) => e.kind === 'sub_agent_usage')).toBe(false)
686
+ })
687
+
688
+ it('carries a null messageId when message.id is absent', () => {
689
+ const st = { hasEmittedStart: true }
690
+ const line = JSON.stringify({
691
+ type: 'assistant',
692
+ message: {
693
+ usage: { input_tokens: 5, output_tokens: 5 },
694
+ content: [{ type: 'text', text: 'working' }],
695
+ },
696
+ })
697
+ const events = projectSubagentLine(line, 'X', st)
698
+ const usage = events.find((e) => e.kind === 'sub_agent_usage')
699
+ expect(usage).toEqual({ kind: 'sub_agent_usage', agentId: 'X', messageId: null, totalTokens: 10 })
700
+ })
701
+
557
702
  it('emits sub_agent_tool_use for regular tools; nested Agent fires ONLY nested_spawn', () => {
558
703
  const st = { hasEmittedStart: true }
559
704
  const line = JSON.stringify({
@@ -550,6 +550,56 @@ describe('startSubagentWatcher', () => {
550
550
  expect(modelled?.model).toBe('claude-opus-4-8')
551
551
  })
552
552
 
553
+ it('accumulates total tokens across assistant messages, deduped by message.id', () => {
554
+ const progress: Array<{ totalTokens: number }> = []
555
+ const agentDir = join(tmpRoot, 'agent')
556
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
557
+ mkdirSync(subagentsDir, { recursive: true })
558
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
559
+
560
+ const h = startWatcherSync({
561
+ agentDir,
562
+ onProgress: ({ totalTokens }) => { progress.push({ totalTokens }) },
563
+ })
564
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Research the competitors')))
565
+ h.poll()
566
+
567
+ // Message 1 persisted as TWO JSONL lines sharing one message.id + the
568
+ // SAME usage block (the ≥2.1.x split-message shape). Must count ONCE.
569
+ const usage1 = {
570
+ input_tokens: 100,
571
+ output_tokens: 50,
572
+ cache_read_input_tokens: 1000,
573
+ cache_creation_input_tokens: 200,
574
+ }
575
+ appendFileSync(jsonlPath, buildJSONL({
576
+ type: 'assistant',
577
+ message: { id: 'msg_1', model: 'claude-opus-4-8', usage: usage1, content: [{ type: 'text', text: 'thinking about it' }] },
578
+ }))
579
+ appendFileSync(jsonlPath, buildJSONL({
580
+ type: 'assistant',
581
+ message: { id: 'msg_1', model: 'claude-opus-4-8', usage: usage1, content: [{ type: 'tool_use', name: 'Read', id: 'r1', input: { file_path: '/x/a.ts' } }] },
582
+ }))
583
+ h.poll()
584
+
585
+ // A DISTINCT message adds on top; a line with NO usage contributes nothing.
586
+ appendFileSync(jsonlPath, buildJSONL({
587
+ type: 'assistant',
588
+ message: { id: 'msg_2', model: 'claude-opus-4-8', usage: { input_tokens: 2, output_tokens: 40 }, content: [{ type: 'tool_use', name: 'Bash', id: 'r2', input: {} }] },
589
+ }))
590
+ appendFileSync(jsonlPath, buildJSONL({
591
+ type: 'assistant',
592
+ message: { id: 'msg_3', model: 'claude-opus-4-8', content: [{ type: 'tool_use', name: 'Edit', id: 'r3', input: {} }] },
593
+ }))
594
+ h.poll()
595
+
596
+ // msg_1 (350, counted once despite two lines; cache_read excluded)
597
+ // + msg_2 (42) = 392.
598
+ expect(h.watcher.getRegistry().get('deadbeef')?.totalTokens).toBe(392)
599
+ const last = progress[progress.length - 1]
600
+ expect(last.totalTokens).toBe(392)
601
+ })
602
+
553
603
  it('ignores a synthetic model sentinel, keeping the last real model', () => {
554
604
  const agentDir = join(tmpRoot, 'agent')
555
605
  const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
@@ -7,7 +7,9 @@ import {
7
7
  renderActivityFeed,
8
8
  renderActivityFeedWithNested,
9
9
  renderActivityHeader,
10
+ renderCombinedWorkerFeed,
10
11
  formatFeedElapsed,
12
+ formatTokenCount,
11
13
  formatStepSuffix,
12
14
  STEP_TIMER_MIN_MS,
13
15
  type SessionActivityHeader,
@@ -745,6 +747,80 @@ describe("renderActivityHeader — two-line header builder", () => {
745
747
  const [, h2synth] = renderActivityHeader("🤖", "Agent", "", 15_000, 7, "running", "<synthetic>");
746
748
  expect(h2synth).toBe("_15s · 7 tools_");
747
749
  });
750
+
751
+ it("inserts the token segment between tool count and model (running)", () => {
752
+ const [, h2] = renderActivityHeader("🛠", "Worker", "run tests", 120_000, 14, "running", "claude-opus-4-8", 12_400);
753
+ expect(h2).toBe("_2m00s · 14 tools · 12.4k tok · opus 4.8_");
754
+ });
755
+
756
+ it("inserts the token segment before elapsed on the done line", () => {
757
+ const [, h2] = renderActivityHeader("🛠", "Worker", "run tests", 65_000, 3, "done", undefined, 940);
758
+ expect(h2).toBe("_done · 3 tools · 940 tok · 1m05s_");
759
+ });
760
+
761
+ it("omits the token segment when total is 0 / undefined (both variants consistent)", () => {
762
+ const [, running] = renderActivityHeader("🛠", "Worker", "", 15_000, 7, "running", undefined, 0);
763
+ expect(running).toBe("_15s · 7 tools_");
764
+ const [, undef] = renderActivityHeader("🛠", "Worker", "", 15_000, 7, "running");
765
+ expect(undef).toBe("_15s · 7 tools_");
766
+ const [, doneZero] = renderActivityHeader("🛠", "Worker", "", 65_000, 3, "done", undefined, 0);
767
+ expect(doneZero).toBe("_done · 3 tools · 1m05s_");
768
+ });
769
+
770
+ it("renders tokens and model together, in order", () => {
771
+ const [, h2] = renderActivityHeader("🛠", "Worker", "", 10_000, 2, "running", "sr-glm-5", 1_200_000);
772
+ expect(h2).toBe("_10s · 2 tools · 1.2M tok · sr-glm-5_");
773
+ });
774
+ });
775
+
776
+ describe("formatTokenCount — compact token formatter", () => {
777
+ it("renders sub-1000 as a raw integer", () => {
778
+ expect(formatTokenCount(0)).toBe("0");
779
+ expect(formatTokenCount(1)).toBe("1");
780
+ expect(formatTokenCount(999)).toBe("999");
781
+ });
782
+
783
+ it("renders >=1000 as one-decimal k at the boundary", () => {
784
+ expect(formatTokenCount(1000)).toBe("1.0k");
785
+ expect(formatTokenCount(12_400)).toBe("12.4k");
786
+ expect(formatTokenCount(999_499)).toBe("999.5k");
787
+ });
788
+
789
+ it("renders >=1e6 as one-decimal M at the boundary", () => {
790
+ expect(formatTokenCount(1_000_000)).toBe("1.0M");
791
+ expect(formatTokenCount(1_200_000)).toBe("1.2M");
792
+ });
793
+
794
+ it("promotes the k→M rounding boundary (never renders 1000.0k)", () => {
795
+ // 999_949 rounds down to 999.9k; from 999_950 the 1-decimal k rounds to
796
+ // 1000.0 which must roll into the M branch as 1.0M.
797
+ expect(formatTokenCount(999_949)).toBe("999.9k");
798
+ expect(formatTokenCount(999_950)).toBe("1.0M");
799
+ expect(formatTokenCount(999_999)).toBe("1.0M");
800
+ expect(formatTokenCount(1_000_000)).toBe("1.0M");
801
+ });
802
+
803
+ it("clamps negatives / non-finite to 0", () => {
804
+ expect(formatTokenCount(-5)).toBe("0");
805
+ expect(formatTokenCount(Number.NaN)).toBe("0");
806
+ expect(formatTokenCount(Number.POSITIVE_INFINITY)).toBe("0");
807
+ });
808
+ });
809
+
810
+ describe("renderCombinedWorkerFeed — token segment on the row header", () => {
811
+ it("shows `· N tok` per worker row and omits it at 0", () => {
812
+ const body = renderCombinedWorkerFeed(
813
+ [
814
+ { description: "alpha", elapsedMs: 15_000, toolCount: 3, currentStep: "Reading a.ts", totalTokens: 12_400 },
815
+ { description: "beta", elapsedMs: 20_000, toolCount: 5, currentStep: "Running tests", totalTokens: 0 },
816
+ ],
817
+ { maxRows: 5 },
818
+ );
819
+ expect(body).not.toBeNull();
820
+ expect(body!).toContain("**alpha** _· 15s · 3 tools · 12.4k tok_");
821
+ expect(body!).toContain("**beta** _· 20s · 5 tools_");
822
+ expect(body!).not.toContain("beta** _· 20s · 5 tools · 0 tok");
823
+ });
748
824
  });
749
825
 
750
826
  describe("agent flat path routes through the shared step-feed primitive", () => {
@@ -852,6 +928,39 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
852
928
  expect(nested).toContain("· opus 4.8_");
853
929
  });
854
930
 
931
+ it("threads the parent's OWN totalTokens through the flat and nested turn-card paths", () => {
932
+ const header: SessionActivityHeader = {
933
+ label: "Agent",
934
+ elapsedMs: 120_000,
935
+ toolCount: 14,
936
+ state: "running",
937
+ model: "claude-opus-4-8",
938
+ totalTokens: 12_400,
939
+ };
940
+ // Flat path (no foreground sub-agent).
941
+ const flat = renderActivityFeed(["Searching memory"], false, "", undefined, header)!;
942
+ expect(flat).toContain("_2m00s · 14 tools · 12.4k tok · opus 4.8_");
943
+ // Nested path (with a child line) carries the same token segment.
944
+ const nested = renderActivityFeedWithNested(["Reading"], ["nested step"], false, "", undefined, header)!;
945
+ expect(nested).toContain("· 12.4k tok ·");
946
+ });
947
+
948
+ it("omits the token segment on the turn card when totalTokens is 0 / undefined", () => {
949
+ const zero: SessionActivityHeader = {
950
+ label: "Agent",
951
+ elapsedMs: 15_000,
952
+ toolCount: 7,
953
+ state: "running",
954
+ totalTokens: 0,
955
+ };
956
+ const outZero = renderActivityFeed(["Searching memory"], false, "", undefined, zero)!;
957
+ expect(outZero).toContain("_15s · 7 tools_");
958
+ expect(outZero).not.toContain("tok");
959
+ // undefined behaves identically.
960
+ const undef: SessionActivityHeader = { label: "Agent", elapsedMs: 15_000, toolCount: 7, state: "running" };
961
+ expect(renderActivityFeed(["Searching memory"], false, "", undefined, undef)).toBe(outZero);
962
+ });
963
+
855
964
  it("prepends the done header when final=true", () => {
856
965
  const header: SessionActivityHeader = {
857
966
  label: "Agent",
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { projectTrailingAnswerFromTranscript } from '../session-tail.js'
3
+
4
+ /**
5
+ * Pins the crash-survival redelivery transcript projector: recovering the
6
+ * finished-but-never-sent trailing answer from a session JSONL after a
7
+ * pre-flush crash, and refusing to redeliver a dangling mid-tool preamble.
8
+ */
9
+
10
+ function assistantText(text: string): string {
11
+ return JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } })
12
+ }
13
+ function assistantToolUse(name: string): string {
14
+ return JSON.stringify({
15
+ type: 'assistant',
16
+ message: { content: [{ type: 'tool_use', name, id: 'toolu_x', input: {} }] },
17
+ })
18
+ }
19
+ function enqueue(text: string): string {
20
+ return JSON.stringify({ type: 'queue-operation', operation: 'enqueue', content: text })
21
+ }
22
+ /** A real inbound user message line (string content) — a turn separator the
23
+ * projection kernel emits NO event for, unlike an `enqueue`. */
24
+ function userMessage(text: string): string {
25
+ return JSON.stringify({ type: 'user', message: { role: 'user', content: text } })
26
+ }
27
+ /** A tool_result carrier user line — NOT a turn boundary. */
28
+ function toolResult(toolUseId: string): string {
29
+ return JSON.stringify({
30
+ type: 'user',
31
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'ok' }] },
32
+ })
33
+ }
34
+
35
+ describe('projectTrailingAnswerFromTranscript', () => {
36
+ it('recovers the trailing answer text after the last tool_use', () => {
37
+ const transcript = [
38
+ enqueue('what is the deploy status?'),
39
+ assistantText('Let me check.'),
40
+ assistantToolUse('Bash'),
41
+ assistantText('The deploy finished — all three services are green.'),
42
+ ].join('\n')
43
+ const r = projectTrailingAnswerFromTranscript(transcript)
44
+ expect(r.trailingIsText).toBe(true)
45
+ expect(r.text).toBe('The deploy finished — all three services are green.')
46
+ })
47
+
48
+ it('does NOT treat a dangling tool_use as a redeliverable answer', () => {
49
+ const transcript = [
50
+ enqueue('run the migration'),
51
+ assistantText('On it — running the migration now.'),
52
+ assistantToolUse('Bash'),
53
+ ].join('\n')
54
+ const r = projectTrailingAnswerFromTranscript(transcript)
55
+ expect(r.trailingIsText).toBe(false)
56
+ // the preamble was reset by the tool_use; nothing trailing to send
57
+ expect(r.text).toBe('')
58
+ })
59
+
60
+ it('scopes to the LAST turn only (a prior turn answer is not resurfaced)', () => {
61
+ const transcript = [
62
+ enqueue('first question'),
63
+ assistantText('First answer.'),
64
+ enqueue('second question'),
65
+ assistantText('Second answer.'),
66
+ ].join('\n')
67
+ const r = projectTrailingAnswerFromTranscript(transcript)
68
+ expect(r.text).toBe('Second answer.')
69
+ })
70
+
71
+ it('suppresses an isApiErrorMessage synthetic line (never redeliver an error string)', () => {
72
+ const transcript = [
73
+ enqueue('do the thing'),
74
+ JSON.stringify({
75
+ type: 'assistant',
76
+ isApiErrorMessage: true,
77
+ message: { content: [{ type: 'text', text: "You've hit your limit · resets 5pm" }] },
78
+ }),
79
+ ].join('\n')
80
+ const r = projectTrailingAnswerFromTranscript(transcript)
81
+ expect(r.text).toBe('')
82
+ expect(r.trailingIsText).toBe(false)
83
+ })
84
+
85
+ // diff-review defect #2 — a real claude .jsonl separates turns with a plain
86
+ // `type:"user"` message line the kernel ignores, NOT always an interleaved
87
+ // enqueue queue-operation. Two turns with no intervening tool_use must not
88
+ // concatenate; only the LAST turn's trailing text is projected.
89
+ it('breaks turns on a plain user-message separator (no enqueue, no tool_use)', () => {
90
+ const transcript = [
91
+ userMessage('first question'),
92
+ assistantText('First answer.'),
93
+ userMessage('second question'),
94
+ assistantText('Second answer.'),
95
+ ].join('\n')
96
+ const r = projectTrailingAnswerFromTranscript(transcript)
97
+ expect(r.text).toBe('Second answer.')
98
+ expect(r.trailingIsText).toBe(true)
99
+ })
100
+
101
+ it('does NOT treat a tool_result user line as a turn boundary (answer still accretes)', () => {
102
+ const transcript = [
103
+ userMessage('do the thing'),
104
+ assistantToolUse('Bash'),
105
+ toolResult('toolu_x'),
106
+ assistantText('All done — the thing is done.'),
107
+ ].join('\n')
108
+ const r = projectTrailingAnswerFromTranscript(transcript)
109
+ expect(r.text).toBe('All done — the thing is done.')
110
+ expect(r.trailingIsText).toBe(true)
111
+ })
112
+
113
+ it('concatenates multiple trailing text blocks of the final answer', () => {
114
+ const transcript = [
115
+ enqueue('q'),
116
+ assistantToolUse('Read'),
117
+ assistantText('Part one. '),
118
+ assistantText('Part two.'),
119
+ ].join('\n')
120
+ const r = projectTrailingAnswerFromTranscript(transcript)
121
+ expect(r.text).toBe('Part one. Part two.')
122
+ expect(r.trailingIsText).toBe(true)
123
+ })
124
+ })
@@ -16,6 +16,8 @@ import { describe, it, expect } from 'vitest'
16
16
  import {
17
17
  buildVaultGrantApprovedInbound,
18
18
  buildVaultGrantApprovedCardText,
19
+ normalizeGrantReason,
20
+ MAX_GRANT_REASON_CHARS,
19
21
  buildVaultGrantDeniedInbound,
20
22
  buildVaultSaveCompletedInbound,
21
23
  buildVaultSaveFailedInbound,
@@ -85,6 +87,55 @@ describe('buildVaultGrantApprovedCardText — the [object Object] regression gua
85
87
  expect(noFooter.endsWith('(grant `vg_a1b2c3`)')).toBe(true)
86
88
  })
87
89
 
90
+ it('renders the reason as a trailing italic clause when provided, before the footer', () => {
91
+ const text = buildVaultGrantApprovedCardText({
92
+ ...BASE,
93
+ reasonEscaped: 'deploy the staging build',
94
+ footer: '\n_Approver verified by Telegram identity._',
95
+ })
96
+ expect(text).toContain('_Reason: deploy the staging build_')
97
+ // reason clause sits BEFORE the footer (auth-mode note stays last)
98
+ expect(text.indexOf('_Reason:')).toBeLessThan(text.indexOf('_Approver verified'))
99
+ // still one clean string
100
+ const rich = richMessage(text)
101
+ expect(rich.markdown).toBe(text)
102
+ expect(rich.markdown).not.toContain('[object Object]')
103
+ })
104
+
105
+ it('omits the reason clause when reason is undefined or empty — no dangling "Reason:" label', () => {
106
+ const none = buildVaultGrantApprovedCardText(BASE)
107
+ expect(none).not.toContain('Reason:')
108
+ expect(none.endsWith('(grant `vg_a1b2c3`)')).toBe(true)
109
+
110
+ const empty = buildVaultGrantApprovedCardText({ ...BASE, reasonEscaped: '' })
111
+ expect(empty).not.toContain('Reason:')
112
+ expect(empty.endsWith('(grant `vg_a1b2c3`)')).toBe(true)
113
+ })
114
+
115
+ it('reason clause + reason-less both stay clean when combined with a footer', () => {
116
+ const footer = '\n_Approver verified by Telegram identity._'
117
+ const withReason = buildVaultGrantApprovedCardText({ ...BASE, reasonEscaped: 'x', footer })
118
+ expect(withReason.endsWith(footer)).toBe(true)
119
+ const noReason = buildVaultGrantApprovedCardText({ ...BASE, footer })
120
+ expect(noReason.endsWith(footer)).toBe(true)
121
+ expect(noReason).not.toContain('Reason:')
122
+ })
123
+
124
+ it('carries an already-escaped reason verbatim — HTML-special chars stay escaped, no corruption', () => {
125
+ // The callsite escapes agent-supplied free text with escapeHtmlForTg
126
+ // BEFORE passing it (same contract as agentEscaped). Simulate that:
127
+ // `<b> & "co" *_` → escaped form. The builder must not re-corrupt it,
128
+ // and the single-richMessage wrap must still hold.
129
+ const escaped = '&lt;b&gt; &amp; drop &amp;&amp; run *_'
130
+ const text = buildVaultGrantApprovedCardText({ ...BASE, reasonEscaped: escaped })
131
+ expect(text).toContain(`_Reason: ${escaped}_`)
132
+ // No raw unescaped angle brackets leaked into the card text.
133
+ expect(text).not.toContain('<b>')
134
+ const rich = richMessage(text)
135
+ expect(rich.markdown).toBe(text)
136
+ expect(rich.markdown).not.toContain('[object Object]')
137
+ })
138
+
88
139
  it('DOCUMENTS the original bug: bare-string + richMessage() coerces to [object Object]', () => {
89
140
  // Demonstrates why the fix matters — if a future edit reintroduces the
90
141
  // "concatenate a string onto the richMessage object" pattern, the result
@@ -94,6 +145,80 @@ describe('buildVaultGrantApprovedCardText — the [object Object] regression gua
94
145
  })
95
146
  })
96
147
 
148
+ describe('normalizeGrantReason — single-line, trimmed, length-capped', () => {
149
+ it('returns "" for undefined, null, empty, or whitespace-only input (clean no-op)', () => {
150
+ expect(normalizeGrantReason(undefined)).toBe('')
151
+ expect(normalizeGrantReason(null)).toBe('')
152
+ expect(normalizeGrantReason('')).toBe('')
153
+ expect(normalizeGrantReason(' ')).toBe('')
154
+ expect(normalizeGrantReason('\n\t \n')).toBe('')
155
+ })
156
+
157
+ it('collapses newlines and whitespace runs to a single space (protects the _…_ emphasis)', () => {
158
+ expect(normalizeGrantReason('deploy\nthe staging\tbuild')).toBe('deploy the staging build')
159
+ // no newline survives to break the italic clause
160
+ expect(normalizeGrantReason('a\nb')).not.toContain('\n')
161
+ })
162
+
163
+ it('trims leading/trailing whitespace', () => {
164
+ expect(normalizeGrantReason(' clone the repo ')).toBe('clone the repo')
165
+ })
166
+
167
+ it('caps over-long input with a trailing ellipsis, at MAX_GRANT_REASON_CHARS', () => {
168
+ const long = 'x'.repeat(MAX_GRANT_REASON_CHARS + 50)
169
+ const out = normalizeGrantReason(long)
170
+ expect(out.length).toBe(MAX_GRANT_REASON_CHARS)
171
+ expect(out.endsWith('…')).toBe(true)
172
+ expect(out.slice(0, -1)).toBe('x'.repeat(MAX_GRANT_REASON_CHARS - 1))
173
+ })
174
+
175
+ it('leaves an at-limit reason untouched (no spurious ellipsis)', () => {
176
+ const exact = 'y'.repeat(MAX_GRANT_REASON_CHARS)
177
+ const out = normalizeGrantReason(exact)
178
+ expect(out).toBe(exact)
179
+ expect(out.endsWith('…')).toBe(false)
180
+ })
181
+
182
+ it('feeds cleanly through the card builder: whitespace-only → no clause, newline → single-line clause', () => {
183
+ // whitespace-only normalizes to '' → builder omits the clause
184
+ const wsCard = buildVaultGrantApprovedCardText({
185
+ agentEscaped: 'gymbro',
186
+ scope: 'read',
187
+ key: 'k',
188
+ days: 30,
189
+ grantId: 'vg_x',
190
+ reasonEscaped: normalizeGrantReason(' ') || undefined,
191
+ })
192
+ expect(wsCard).not.toContain('Reason:')
193
+
194
+ // newline reason renders as a single-line italic clause, no broken emphasis
195
+ const nlCard = buildVaultGrantApprovedCardText({
196
+ agentEscaped: 'gymbro',
197
+ scope: 'read',
198
+ key: 'k',
199
+ days: 30,
200
+ grantId: 'vg_x',
201
+ reasonEscaped: normalizeGrantReason('line one\nline two') || undefined,
202
+ })
203
+ expect(nlCard).toContain('_Reason: line one line two_')
204
+ expect(nlCard).not.toContain('\nline two')
205
+ })
206
+
207
+ it('over-long reason renders truncated with ellipsis inside the clause', () => {
208
+ const reason = normalizeGrantReason('z'.repeat(MAX_GRANT_REASON_CHARS + 100)) || undefined
209
+ const card = buildVaultGrantApprovedCardText({
210
+ agentEscaped: 'gymbro',
211
+ scope: 'read',
212
+ key: 'k',
213
+ days: 30,
214
+ grantId: 'vg_x',
215
+ reasonEscaped: reason,
216
+ })
217
+ expect(card).toContain('…_')
218
+ expect(card).toContain(`_Reason: ${'z'.repeat(MAX_GRANT_REASON_CHARS - 1)}…_`)
219
+ })
220
+ })
221
+
97
222
  describe('buildVaultGrantApprovedInbound', () => {
98
223
  it('emits the canonical envelope (type, chat_id, user, userId, ts, messageId)', () => {
99
224
  const msg = buildVaultGrantApprovedInbound({