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
@@ -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,279 @@
1
+ /**
2
+ * Regression coverage for the 2026-07 double-reply-on-DM fix — completes the
3
+ * #3236 turnId-keyed dedup (`flushed-turn-supersede.ts`) for the late-reply / DM
4
+ * path.
5
+ *
6
+ * ## The incident these tests pin
7
+ *
8
+ * On a DM agent a turn DOUBLE-SENT: the answer-ready quiescence flush posted the
9
+ * composed terminal answer as message A (no quote), then the model's REAL
10
+ * `reply` tool call landed and sent message B (quoted). The user should have
11
+ * received exactly ONE message (the quoted reply).
12
+ *
13
+ * #3236's supersede is turnId-identity-keyed and text-agnostic BY DESIGN, so the
14
+ * 275-vs-249-char rewording is NOT why it missed. It missed because the reply's
15
+ * owner turn resolved to `null` on the late path: `currentTurn` was nulled by the
16
+ * flush's synthetic turn_end, and `origin_turn_id` (a forum-supergroup field) is
17
+ * absent in DMs — so the OLD 2-tier chain `currentTurn ?? findTurnByOriginId`
18
+ * yielded null, and `decideSupersede` never lets a null live turn supersede a
19
+ * turnId-bearing record.
20
+ *
21
+ * The router recovered the SAME reply's owner via `findTurnByQuotedMessageId` and
22
+ * `findLatestEndedTurnForChat`; the supersede chain omitted BOTH. The fix
23
+ * (`resolveReplyOwnerTurnId`) unifies the two onto one precedence.
24
+ *
25
+ * These tests exercise the extracted pure cores — the exact precedence and latch
26
+ * decision the gateway runs (`gateway.ts` is not importable in tests; the repo's
27
+ * `decideTurnFlush` / `decideSupersede` pattern). The core test also asserts the
28
+ * OLD 2-tier chain FAILS to recover the owner (the red-on-main contrast) while
29
+ * the unified chain recovers it and the supersede fires.
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest'
33
+ import {
34
+ resolveReplyOwnerTurnId,
35
+ decideAnswerLatchSuppression,
36
+ type ReplyOwnerCandidates,
37
+ } from '../reply-owner-resolve.js'
38
+ import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
39
+
40
+ const NONE: ReplyOwnerCandidates = {
41
+ liveTurnId: null,
42
+ originTurnId: null,
43
+ quotedTurnId: null,
44
+ latestEndedTurnId: null,
45
+ }
46
+
47
+ /** The OLD (pre-fix) resolver chain the gateway ran on main — the 2-tier
48
+ * `currentTurn ?? findTurnByOriginId` — reproduced here to prove the incident
49
+ * went unrecovered before the fix (red-on-main contrast). */
50
+ function oldTwoTierResolve(c: ReplyOwnerCandidates): string | null {
51
+ return c.liveTurnId ?? c.originTurnId ?? null
52
+ }
53
+
54
+ describe('resolveReplyOwnerTurnId — unified owner-turn precedence (Part 1)', () => {
55
+ it('prefers the live currentTurn when present', () => {
56
+ expect(
57
+ resolveReplyOwnerTurnId({ ...NONE, liveTurnId: 'live', originTurnId: 'origin', latestEndedTurnId: 'ended' }),
58
+ ).toBe('live')
59
+ })
60
+
61
+ it('falls to the model-echoed origin turn when no live turn', () => {
62
+ expect(
63
+ resolveReplyOwnerTurnId({ ...NONE, originTurnId: 'origin', quotedTurnId: 'quoted', latestEndedTurnId: 'ended' }),
64
+ ).toBe('origin')
65
+ })
66
+
67
+ it('falls to the framework-owned quoted-message turn when no live/origin', () => {
68
+ expect(
69
+ resolveReplyOwnerTurnId({ ...NONE, quotedTurnId: 'quoted', latestEndedTurnId: 'ended' }),
70
+ ).toBe('quoted')
71
+ })
72
+
73
+ it('falls to the chat latest-ended turn as the final tier — the DM recovery', () => {
74
+ expect(resolveReplyOwnerTurnId({ ...NONE, latestEndedTurnId: 'ended' })).toBe('ended')
75
+ })
76
+
77
+ it('returns null only when every lookup missed', () => {
78
+ expect(resolveReplyOwnerTurnId(NONE)).toBeNull()
79
+ })
80
+
81
+ it('RED-ON-MAIN CONTRAST: the DM late reply (no live turn, no origin) is ' +
82
+ 'unrecovered by the old 2-tier chain but recovered by the unified chain', () => {
83
+ // The exact incident inputs: flush nulled currentTurn (liveTurnId=null),
84
+ // DM has no origin_turn_id (originTurnId=null); the owner survives only in
85
+ // the latest-ended registry (latestEndedTurnId).
86
+ const incident: ReplyOwnerCandidates = { ...NONE, latestEndedTurnId: 'turn-T' }
87
+ // Old behaviour (what shipped on main): null → no supersede → duplicate.
88
+ expect(oldTwoTierResolve(incident)).toBeNull()
89
+ // Fixed behaviour: recovers the owning turn T.
90
+ expect(resolveReplyOwnerTurnId(incident)).toBe('turn-T')
91
+ })
92
+ })
93
+
94
+ describe('Part 1 end-to-end: unified resolver drives the flush supersede', () => {
95
+ const CHAT = '424242'
96
+
97
+ it('CORE INCIDENT: flush records message A for turn T, then a late DM reply ' +
98
+ '(currentTurn=null, no origin_turn_id) SUPERSEDES via the recovered owner', () => {
99
+ const reg = new FlushedTurnSupersedeRegistry()
100
+ const now = 1_000
101
+ // Flush commits message A for turn T.
102
+ reg.record(CHAT, undefined, { turnId: 'turn-T', messageIds: [5001], text: 'A' }, now)
103
+
104
+ // Late DM reply: no live turn, no origin echo; owner survives in the
105
+ // latest-ended registry as turn-T.
106
+ const incident: ReplyOwnerCandidates = { ...NONE, latestEndedTurnId: 'turn-T' }
107
+
108
+ // Old 2-tier chain → null → supersede DECLINES (the duplicate ships).
109
+ const oldId = oldTwoTierResolve(incident)
110
+ expect(reg.peek(CHAT, undefined, { liveTurnId: oldId, now: now + 10 }).supersede).toBe(false)
111
+
112
+ // Unified chain → turn-T → supersede FIRES and deletes message A, so the
113
+ // reply below delivers as the single clean message.
114
+ const newId = resolveReplyOwnerTurnId(incident)
115
+ const decision = reg.take(CHAT, undefined, { liveTurnId: newId, now: now + 10 })
116
+ expect(decision.supersede).toBe(true)
117
+ expect(decision.deleteMessageIds).toEqual([5001])
118
+ })
119
+
120
+ it('a DIFFERENT newer turn recovered as owner does NOT supersede turn T', () => {
121
+ const reg = new FlushedTurnSupersedeRegistry()
122
+ const now = 2_000
123
+ reg.record(CHAT, undefined, { turnId: 'turn-T', messageIds: [7001], text: 'A' }, now)
124
+ // Owner recovers to a newer turn (its own flush isn't recorded here).
125
+ const id = resolveReplyOwnerTurnId({ ...NONE, latestEndedTurnId: 'turn-NEWER' })
126
+ expect(reg.take(CHAT, undefined, { liveTurnId: id, now: now + 10 }).supersede).toBe(false)
127
+ })
128
+ })
129
+
130
+ describe('decideAnswerLatchSuppression — race backstop (Part 2)', () => {
131
+ it('RACE: a late substantive reply lands in the flush post-fire pre-record ' +
132
+ 'window (no supersede record yet) → SUPPRESSED by the latch', () => {
133
+ expect(
134
+ decideAnswerLatchSuppression({
135
+ superseded: false,
136
+ replySubstantive: true,
137
+ isLateReply: true,
138
+ ownerAnswerDelivered: true,
139
+ }),
140
+ ).toBe(true)
141
+ })
142
+
143
+ it('does NOT double-suppress when Part 1 already superseded (else the turn ' +
144
+ 'ends with ZERO messages)', () => {
145
+ expect(
146
+ decideAnswerLatchSuppression({
147
+ superseded: true,
148
+ replySubstantive: true,
149
+ isLateReply: true,
150
+ ownerAnswerDelivered: true,
151
+ }),
152
+ ).toBe(false)
153
+ })
154
+
155
+ it('NEGATIVE: an interim sub-floor ack (not substantive) is never suppressed ' +
156
+ '— so interim-ack-then-final both send', () => {
157
+ expect(
158
+ decideAnswerLatchSuppression({
159
+ superseded: false,
160
+ replySubstantive: false,
161
+ isLateReply: true,
162
+ ownerAnswerDelivered: true,
163
+ }),
164
+ ).toBe(false)
165
+ })
166
+
167
+ it('NEGATIVE: a normal in-turn reply with a live currentTurn is never ' +
168
+ 'suppressed (a legitimate second substantive reply still sends)', () => {
169
+ expect(
170
+ decideAnswerLatchSuppression({
171
+ superseded: false,
172
+ replySubstantive: true,
173
+ isLateReply: false,
174
+ ownerAnswerDelivered: true,
175
+ }),
176
+ ).toBe(false)
177
+ })
178
+
179
+ it('does not suppress when the owner latch is unset (a normal answer)', () => {
180
+ expect(
181
+ decideAnswerLatchSuppression({
182
+ superseded: false,
183
+ replySubstantive: true,
184
+ isLateReply: true,
185
+ ownerAnswerDelivered: false,
186
+ }),
187
+ ).toBe(false)
188
+ })
189
+ })
190
+
191
+ describe('F1 — flush send failure must NOT suppress the late reply (zero-message guard)', () => {
192
+ // The flush arms `answerDelivered` synchronously at FIRE time, BEFORE the async
193
+ // send. If the send then throws and NOTHING was delivered, the supersede record
194
+ // is never written (gated on sentIds>0), so Part 1 cannot fire. Leaving the
195
+ // latch armed would make a genuine late reply suppress itself → the user gets
196
+ // ZERO messages. The gateway's send-failure catch resets `answerDelivered =
197
+ // false`; these assert the resulting coordination outcome at the pure core.
198
+ const lateSubstantiveReply = (ownerAnswerDelivered: boolean) =>
199
+ decideAnswerLatchSuppression({
200
+ superseded: false,
201
+ replySubstantive: true,
202
+ isLateReply: true,
203
+ ownerAnswerDelivered,
204
+ })
205
+
206
+ it('WITHOUT the catch-reset (latch still armed) the late reply is suppressed ' +
207
+ '— the zero-message bug', () => {
208
+ // Models the buggy state: flush armed the latch, send failed, latch left true.
209
+ expect(lateSubstantiveReply(true)).toBe(true)
210
+ })
211
+
212
+ it('WITH the catch-reset (answerDelivered=false) the late reply DELIVERS', () => {
213
+ // Models the fixed state: catch reset the latch, so the genuine late reply
214
+ // is not suppressed and the user still receives the answer.
215
+ expect(lateSubstantiveReply(false)).toBe(false)
216
+ })
217
+ })
218
+
219
+ describe('F2 — recency-bound the destructive latest-ended supersede tier', () => {
220
+ const CHAT = '515151'
221
+ const base: ReplyOwnerCandidates = {
222
+ liveTurnId: null,
223
+ originTurnId: null,
224
+ quotedTurnId: null,
225
+ latestEndedTurnId: null,
226
+ }
227
+
228
+ it('accepts a latest-ended turn that ended within the supersede TTL', () => {
229
+ expect(
230
+ resolveReplyOwnerTurnId({
231
+ ...base,
232
+ latestEndedTurnId: 'turn-T',
233
+ latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS - 1,
234
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
235
+ }),
236
+ ).toBe('turn-T')
237
+ })
238
+
239
+ it('REJECTS a STALE latest-ended turn (ended past the supersede TTL) so it ' +
240
+ 'cannot inherit deletion authority — resolver returns null', () => {
241
+ expect(
242
+ resolveReplyOwnerTurnId({
243
+ ...base,
244
+ latestEndedTurnId: 'turn-STALE',
245
+ latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 5_000,
246
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
247
+ }),
248
+ ).toBeNull()
249
+ })
250
+
251
+ it('end-to-end: a stale latest-ended turn does NOT delete a live flush ' +
252
+ "record it doesn't own", () => {
253
+ const reg = new FlushedTurnSupersedeRegistry()
254
+ const now = 1_000_000
255
+ // A fresh flush record for the newer turn T2 (its owner, well within TTL).
256
+ reg.record(CHAT, undefined, { turnId: 'turn-T2', messageIds: [9001], text: 'A2' }, now)
257
+ // A late reply whose only recoverable owner is a STALE turn (ended long ago).
258
+ // Without the recency bound the resolver would hand back the stale turn id
259
+ // and a take() keyed on it could mis-target; with the bound it resolves null,
260
+ // so no deletion authority is granted and T2's record survives untouched.
261
+ const ownerId = resolveReplyOwnerTurnId({
262
+ ...base,
263
+ latestEndedTurnId: 'turn-STALE',
264
+ latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 5_000,
265
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
266
+ })
267
+ expect(ownerId).toBeNull()
268
+ const decision = reg.take(CHAT, undefined, { liveTurnId: ownerId, now: now + 10 })
269
+ expect(decision.supersede).toBe(false)
270
+ // T2's record is intact (a null owner never reaches its turnId-keyed lane).
271
+ expect(reg.peek(CHAT, undefined, { liveTurnId: 'turn-T2', now: now + 10 }).supersede).toBe(true)
272
+ })
273
+
274
+ it('unbounded when no age is supplied (back-compat: pre-F2 precedence intact)', () => {
275
+ expect(
276
+ resolveReplyOwnerTurnId({ ...base, latestEndedTurnId: 'turn-T' }),
277
+ ).toBe('turn-T')
278
+ })
279
+ })
@@ -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')