switchroom 0.19.44 → 0.19.46
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 +848 -826
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +48 -4
- package/telegram-plugin/dist/gateway/gateway.js +499 -322
- package/telegram-plugin/flushed-turn-supersede.ts +190 -16
- package/telegram-plugin/gateway/auth-command.ts +7 -4
- package/telegram-plugin/gateway/auth-loopback-relay.ts +39 -20
- package/telegram-plugin/gateway/cron-session.ts +31 -0
- package/telegram-plugin/gateway/gateway.ts +67 -66
- package/telegram-plugin/gateway/outbound-send-path.ts +60 -8
- package/telegram-plugin/gateway/reply-owner-wiring.ts +128 -0
- package/telegram-plugin/gateway/stream-render.ts +91 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +49 -9
- package/telegram-plugin/gateway/subagent-reply-authority.ts +147 -0
- package/telegram-plugin/gateway/turn-end.ts +9 -1
- package/telegram-plugin/reply-owner-resolve.ts +102 -26
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +58 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +110 -8
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +297 -27
- package/telegram-plugin/tests/reply-quote-wire.test.ts +2 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +567 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +204 -1
|
@@ -23,7 +23,9 @@ import {
|
|
|
23
23
|
decideSupersedeCorrection,
|
|
24
24
|
flushedAnswerMatchesReply,
|
|
25
25
|
FlushedTurnSupersedeRegistry,
|
|
26
|
-
|
|
26
|
+
FlushCompletionTracker,
|
|
27
|
+
SUPERSEDE_OPEN_WINDOW_CAP_MS,
|
|
28
|
+
SUPERSEDE_COMPLETED_GRACE_MS,
|
|
27
29
|
SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS,
|
|
28
30
|
type FlushedTurnRecord,
|
|
29
31
|
} from '../flushed-turn-supersede.js'
|
|
@@ -33,6 +35,8 @@ const rec = (over: Partial<FlushedTurnRecord> = {}): FlushedTurnRecord => ({
|
|
|
33
35
|
messageIds: [101, 102],
|
|
34
36
|
text: 'narration\n\nthe real answer',
|
|
35
37
|
ts: 1_000_000,
|
|
38
|
+
// #4173 — default fixture is an OPEN window (real turn_end not yet observed).
|
|
39
|
+
completedAt: null,
|
|
36
40
|
...over,
|
|
37
41
|
})
|
|
38
42
|
|
|
@@ -133,10 +137,60 @@ describe('decideSupersede — the duplicate-reply decision core', () => {
|
|
|
133
137
|
expect(d.reason).toBe('different-turn')
|
|
134
138
|
})
|
|
135
139
|
|
|
136
|
-
it('
|
|
140
|
+
it('2026-08-01 incident gap: an OPEN record is still live 143 s after the flush ' +
|
|
141
|
+
'(Stop-hook nudge + proactive /compact delayed the canonical reply)', () => {
|
|
142
|
+
// klanker msgs 25680/25682: under the old 60 s TTL this returned 'expired'
|
|
143
|
+
// and the reply shipped as a second bubble. With the turn-completion window
|
|
144
|
+
// (#4173) the record stays claimable while the session is still composing
|
|
145
|
+
// (real turn_end not yet observed — completedAt null).
|
|
137
146
|
const d = decideSupersede(rec(), {
|
|
138
147
|
liveTurnId: 'turn-A',
|
|
139
|
-
now: 1_000_000 +
|
|
148
|
+
now: 1_000_000 + 143_000,
|
|
149
|
+
})
|
|
150
|
+
expect(d.supersede).toBe(true)
|
|
151
|
+
expect(d.reason).toBe('supersede')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('#4173 outcome: an OPEN record survives a 20-minute /compact — any fixed ' +
|
|
155
|
+
'sub-20-min TTL turns this red', () => {
|
|
156
|
+
// The point of the completion signal over a widened constant: a compaction
|
|
157
|
+
// longer than any tuned TTL still collapses to ONE bubble, because the
|
|
158
|
+
// claude session has not stopped (no real turn_end observed).
|
|
159
|
+
const d = decideSupersede(rec(), {
|
|
160
|
+
liveTurnId: 'turn-A',
|
|
161
|
+
now: 1_000_000 + 20 * 60_000,
|
|
162
|
+
})
|
|
163
|
+
expect(d.supersede).toBe(true)
|
|
164
|
+
expect(d.reason).toBe('supersede')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('an OPEN record past the crash-backstop cap does NOT supersede', () => {
|
|
168
|
+
const d = decideSupersede(rec(), {
|
|
169
|
+
liveTurnId: 'turn-A',
|
|
170
|
+
now: 1_000_000 + SUPERSEDE_OPEN_WINDOW_CAP_MS + 1,
|
|
171
|
+
})
|
|
172
|
+
expect(d.supersede).toBe(false)
|
|
173
|
+
expect(d.reason).toBe('expired')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('#4173: a COMPLETED record stays claimable through the replay grace ' +
|
|
177
|
+
'(the bridge-reconnect tool_call replay class) …', () => {
|
|
178
|
+
const d = decideSupersede(rec({ completedAt: 1_010_000 }), {
|
|
179
|
+
liveTurnId: 'turn-A',
|
|
180
|
+
now: 1_010_000 + SUPERSEDE_COMPLETED_GRACE_MS - 1,
|
|
181
|
+
})
|
|
182
|
+
expect(d.supersede).toBe(true)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('… and EXPIRES past the grace — a late claimant (the Task-sub-agent-after-' +
|
|
186
|
+
'turn-end shape) can no longer reach the record at all', () => {
|
|
187
|
+
// Once the session stopped and the grace lapsed, nothing may edit/delete
|
|
188
|
+
// the flushed answer — a genuinely late duplicate ships fresh (safe).
|
|
189
|
+
const d = decideSupersede(rec({ completedAt: 1_010_000 }), {
|
|
190
|
+
liveTurnId: 'turn-A',
|
|
191
|
+
replyText: 'unrelated worker handback content, definitely not the answer',
|
|
192
|
+
positiveAttribution: true,
|
|
193
|
+
now: 1_010_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1,
|
|
140
194
|
})
|
|
141
195
|
expect(d.supersede).toBe(false)
|
|
142
196
|
expect(d.reason).toBe('expired')
|
|
@@ -249,7 +303,7 @@ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () =
|
|
|
249
303
|
})
|
|
250
304
|
|
|
251
305
|
it('size() evicts expired records and prunes emptied lanes', () => {
|
|
252
|
-
const reg = new FlushedTurnSupersedeRegistry({
|
|
306
|
+
const reg = new FlushedTurnSupersedeRegistry({ openCapMs: 1000 })
|
|
253
307
|
reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1000)
|
|
254
308
|
reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b' }, 1000)
|
|
255
309
|
expect(reg.size(1500)).toBe(2)
|
|
@@ -257,7 +311,7 @@ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () =
|
|
|
257
311
|
})
|
|
258
312
|
|
|
259
313
|
it('record() actively sweeps expired records (LOW-2 GC — no orphan accumulation)', () => {
|
|
260
|
-
const reg = new FlushedTurnSupersedeRegistry({
|
|
314
|
+
const reg = new FlushedTurnSupersedeRegistry({ openCapMs: 1000 })
|
|
261
315
|
reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1000)
|
|
262
316
|
// A much later flush on a DIFFERENT lane sweeps the now-expired turn-A record.
|
|
263
317
|
reg.record('chat2', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b' }, 5000)
|
|
@@ -434,12 +488,12 @@ describe('positiveAttribution — tier-gated content check', () => {
|
|
|
434
488
|
expect(d.reason).toBe('different-turn')
|
|
435
489
|
})
|
|
436
490
|
|
|
437
|
-
it('positiveAttribution=true still respects the
|
|
438
|
-
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
491
|
+
it('positiveAttribution=true still respects the window (expired record never supersedes)', () => {
|
|
492
|
+
const d = decideSupersede(rec({ text: FLUSHED, completedAt: 1_000_000 }), {
|
|
439
493
|
liveTurnId: 'turn-A',
|
|
440
494
|
replyText: REWORDED,
|
|
441
495
|
positiveAttribution: true,
|
|
442
|
-
now: 1_000_000 +
|
|
496
|
+
now: 1_000_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1,
|
|
443
497
|
})
|
|
444
498
|
expect(d.supersede).toBe(false)
|
|
445
499
|
expect(d.reason).toBe('expired')
|
|
@@ -470,3 +524,51 @@ describe('positiveAttribution — tier-gated content check', () => {
|
|
|
470
524
|
expect(reg.peek('chatT', undefined, { liveTurnId: 'turn-Q', now: now + 7_000 }).reason).toBe('no-record')
|
|
471
525
|
})
|
|
472
526
|
})
|
|
527
|
+
|
|
528
|
+
describe('#4173 — turn-completion window plumbing (completeAll / FlushCompletionTracker)', () => {
|
|
529
|
+
it('completeAll(now) flips OPEN records to grace-bounded; already-completed keep their earlier stamp', () => {
|
|
530
|
+
const reg = new FlushedTurnSupersedeRegistry()
|
|
531
|
+
reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1_000)
|
|
532
|
+
reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b', completedAt: 2_000 }, 1_000)
|
|
533
|
+
reg.completeAll(50_000)
|
|
534
|
+
// turn-A: completed at 50s → claimable until 50s+grace, expired after.
|
|
535
|
+
expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 50_000 + SUPERSEDE_COMPLETED_GRACE_MS - 1 }).supersede).toBe(true)
|
|
536
|
+
expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 50_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1 }).reason).toBe('expired')
|
|
537
|
+
// turn-B kept its ORIGINAL earlier completion (2s) — completeAll never widens.
|
|
538
|
+
expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-B', now: 2_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1 }).reason).toBe('expired')
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('a record born completed (flush send finished AFTER the close) is grace-bounded, never open', () => {
|
|
542
|
+
const reg = new FlushedTurnSupersedeRegistry()
|
|
543
|
+
// The close ran at t=5s while the flush send was in flight; record lands at
|
|
544
|
+
// t=6s carrying the atom's stamped completion.
|
|
545
|
+
reg.record('chatX', undefined, { turnId: 'turn-C', messageIds: [9], text: 'c', completedAt: 5_000 }, 6_000)
|
|
546
|
+
expect(reg.peek('chatX', undefined, { liveTurnId: 'turn-C', now: 5_000 + SUPERSEDE_COMPLETED_GRACE_MS + 1 }).reason).toBe('expired')
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('FlushCompletionTracker: open() nulls realEndObservedAt; closeAll() stamps every pending atom once', () => {
|
|
550
|
+
const t = new FlushCompletionTracker()
|
|
551
|
+
const turnA = { realEndObservedAt: 111 as number | null }
|
|
552
|
+
const turnB = { realEndObservedAt: 222 as number | null }
|
|
553
|
+
t.open(turnA)
|
|
554
|
+
t.open(turnB)
|
|
555
|
+
expect(turnA.realEndObservedAt).toBeNull()
|
|
556
|
+
expect(turnB.realEndObservedAt).toBeNull()
|
|
557
|
+
expect(t.pendingCount()).toBe(2)
|
|
558
|
+
expect(t.closeAll(9_000)).toBe(2)
|
|
559
|
+
expect(turnA.realEndObservedAt).toBe(9_000)
|
|
560
|
+
expect(turnB.realEndObservedAt).toBe(9_000)
|
|
561
|
+
expect(t.pendingCount()).toBe(0)
|
|
562
|
+
// Idempotent: nothing pending → nothing closed, stamps untouched.
|
|
563
|
+
expect(t.closeAll(10_000)).toBe(0)
|
|
564
|
+
expect(turnA.realEndObservedAt).toBe(9_000)
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
it('FlushCompletionTracker: re-open of the same atom does not double-register', () => {
|
|
568
|
+
const t = new FlushCompletionTracker()
|
|
569
|
+
const turn = { realEndObservedAt: null as number | null }
|
|
570
|
+
t.open(turn)
|
|
571
|
+
t.open(turn)
|
|
572
|
+
expect(t.pendingCount()).toBe(1)
|
|
573
|
+
})
|
|
574
|
+
})
|
|
@@ -423,6 +423,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
|
423
423
|
streamKey: key,
|
|
424
424
|
resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
|
|
425
425
|
getLastSubagentHandbackAt: () => null,
|
|
426
|
+
// #4176 — no sub-agent live on this session (see subagent-reply-authority.ts).
|
|
427
|
+
subagentReplyAuthority: { subagentCouldOwnReply: () => false },
|
|
426
428
|
findTurnByOriginId: () => null,
|
|
427
429
|
findTurnByQuotedMessageId: () => null,
|
|
428
430
|
resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
|