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.
- package/dist/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1608 -841
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +524 -16
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/reply-owner-resolve.ts +160 -0
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +222 -10
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- 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')
|