switchroom 0.19.46 → 0.19.47
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/agent-scheduler/index.js +12 -3
- package/dist/auth-broker/index.js +15 -5
- package/dist/cli/notion-write-pretool.mjs +12 -3
- package/dist/cli/switchroom.js +400 -48
- package/dist/host-control/main.js +16 -6
- package/dist/vault/approvals/kernel-server.js +15 -5
- package/dist/vault/broker/server.js +15 -5
- package/package.json +1 -1
- package/telegram-plugin/answer-ready-flush.ts +118 -6
- package/telegram-plugin/dist/gateway/gateway.js +111 -13
- package/telegram-plugin/gateway/gateway.ts +19 -18
- package/telegram-plugin/gateway/handback-preturn-signal.ts +44 -6
- package/telegram-plugin/gateway/session-consume-signal.ts +72 -0
- package/telegram-plugin/gateway/stream-render.ts +5 -0
- package/telegram-plugin/tests/answer-ready-flush.test.ts +147 -3
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +77 -0
|
@@ -30,11 +30,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
30
30
|
import {
|
|
31
31
|
shouldArmAnswerReadyFlush,
|
|
32
32
|
resolveAnswerReadyFlushMs,
|
|
33
|
+
resolveAnswerStageMs,
|
|
33
34
|
ANSWER_READY_FLUSH_MS,
|
|
35
|
+
ANSWER_STAGE_MS,
|
|
34
36
|
AnswerReadyFlushController,
|
|
35
37
|
type AnswerReadyArmInput,
|
|
36
38
|
type FlushTimerHandle,
|
|
37
39
|
} from '../answer-ready-flush.js'
|
|
40
|
+
import { decideTurnFlush } from '../turn-flush-safety.js'
|
|
38
41
|
import { ToolFlightTracker } from '../gateway/interrupt-defer.js'
|
|
39
42
|
|
|
40
43
|
const CHAT = '12345'
|
|
@@ -174,9 +177,10 @@ class GatewayWorld {
|
|
|
174
177
|
window = WINDOW
|
|
175
178
|
readonly controller: AnswerReadyFlushController<FakeTurn>
|
|
176
179
|
|
|
177
|
-
constructor() {
|
|
180
|
+
constructor(stageMs = 0) {
|
|
178
181
|
this.currentTurn = { capturedText: [], replyCalled: false, answerReadyFlushTimeoutId: null }
|
|
179
182
|
this.controller = new AnswerReadyFlushController<FakeTurn>({
|
|
183
|
+
stageWindowMs: stageMs,
|
|
180
184
|
getCurrentTurn: () => this.currentTurn,
|
|
181
185
|
getArmInput: (turn) => ({
|
|
182
186
|
flush: { chatId: CHAT, replyCalled: turn.replyCalled, capturedText: turn.capturedText, flushEnabled: true },
|
|
@@ -203,14 +207,32 @@ class GatewayWorld {
|
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
/** = handleSessionEvent({turn_end}). A null atom short-circuits (no second
|
|
206
|
-
* send); otherwise
|
|
210
|
+
* send); otherwise the turn-flush branch runs the REAL `decideTurnFlush`
|
|
211
|
+
* classifier (exactly as stream-render does at turn_end): a reply-served
|
|
212
|
+
* turn tears down without a flush send; a captured-answer turn delivers
|
|
213
|
+
* once. Then tear the turn down. */
|
|
207
214
|
dispatchTurnEnd(): void {
|
|
208
215
|
const turn = this.currentTurn
|
|
209
216
|
if (turn == null) return // atom already gone → exactly-once
|
|
210
|
-
|
|
217
|
+
const d = decideTurnFlush({
|
|
218
|
+
chatId: CHAT,
|
|
219
|
+
replyCalled: turn.replyCalled,
|
|
220
|
+
capturedText: turn.capturedText,
|
|
221
|
+
flushEnabled: true,
|
|
222
|
+
})
|
|
223
|
+
if (d.kind === 'flush') this.sends++
|
|
211
224
|
this.endCurrentTurnAtomic(turn)
|
|
212
225
|
}
|
|
213
226
|
|
|
227
|
+
/** = the model's `reply` tool call landing on the live turn: the canonical
|
|
228
|
+
* answer message is sent on the normal reply path and the turn is marked
|
|
229
|
+
* reply-served (turn teardown waits for the real turn_end). */
|
|
230
|
+
onReply(): void {
|
|
231
|
+
if (this.currentTurn == null) return
|
|
232
|
+
this.currentTurn.replyCalled = true
|
|
233
|
+
this.sends++ // the ONE canonical user-visible message
|
|
234
|
+
}
|
|
235
|
+
|
|
214
236
|
/** = case 'text': push the chunk, then (re)arm via the REAL controller. */
|
|
215
237
|
onText(chunk: string): void {
|
|
216
238
|
if (this.currentTurn != null) this.currentTurn.capturedText.push(chunk)
|
|
@@ -341,3 +363,125 @@ describe('answer-ready quiescence flush — real controller integration', () =>
|
|
|
341
363
|
expect(vi.getTimerCount()).toBe(0)
|
|
342
364
|
})
|
|
343
365
|
})
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Stage-don't-send (2026-08-02 live repro — klanker DM, msgs 25843/25844).
|
|
369
|
+
// The quiescence flush posted the composed answer at 00:22:02Z; the model's
|
|
370
|
+
// `reply` tool call landed 4 s later (delivered 00:22:36Z as msg 25844, a
|
|
371
|
+
// REWORDING — not containment-equal) while a background sub-agent was live, so
|
|
372
|
+
// the #4176 content-gate hold shipped it FRESH: two near-identical messages.
|
|
373
|
+
// With staging, the quiescence expiry HOLDS the answer on the live turn and
|
|
374
|
+
// only promotes when the completion window closes with no reply — the
|
|
375
|
+
// provisional send (and everything downstream that had to claw it back) never
|
|
376
|
+
// happens. These timelines assert OUTCOMES: user-visible messages sent.
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
const STAGE = 30_000
|
|
380
|
+
|
|
381
|
+
describe('answer-ready stage-don\'t-send — completion-window staging', () => {
|
|
382
|
+
beforeEach(() => vi.useFakeTimers())
|
|
383
|
+
afterEach(() => vi.useRealTimers())
|
|
384
|
+
|
|
385
|
+
it('LIVE REPRO (msgs 25843/25844): a reply landing inside the stage window → exactly ONE message', () => {
|
|
386
|
+
const w = new GatewayWorld(STAGE)
|
|
387
|
+
// 00:21:30–00:22:00 — the model streams its terminal narration.
|
|
388
|
+
w.onText('Yes, that one\'s done, not pending. …the composed terminal answer…')
|
|
389
|
+
// 00:22:01 — quiescence fires. Pre-fix this SENT msg 25843; staged, it must
|
|
390
|
+
// send NOTHING (the turn stays live — typing hold — awaiting completion).
|
|
391
|
+
vi.advanceTimersByTime(WINDOW)
|
|
392
|
+
expect(w.sends).toBe(0) // ← RED on the immediate-flush behaviour
|
|
393
|
+
expect(w.currentTurn).not.toBeNull() // turn NOT ended by a provisional flush
|
|
394
|
+
// 00:22:06 — the model's `reply` lands (the reworded canonical answer).
|
|
395
|
+
vi.advanceTimersByTime(5_000)
|
|
396
|
+
w.onReply()
|
|
397
|
+
expect(w.sends).toBe(1) // the one canonical message (msg 25844)
|
|
398
|
+
// The stage promotion timer expires → fire-time re-verify sees replyCalled
|
|
399
|
+
// and DISCARDS the staged text. No second message, ever.
|
|
400
|
+
vi.advanceTimersByTime(STAGE)
|
|
401
|
+
expect(w.sends).toBe(1)
|
|
402
|
+
// The real turn_end finally lands: reply-served → teardown, no flush.
|
|
403
|
+
w.dispatchTurnEnd()
|
|
404
|
+
expect(w.sends).toBe(1)
|
|
405
|
+
expect(w.records).toBe(1)
|
|
406
|
+
vi.advanceTimersByTime(BACKSTOP_MS)
|
|
407
|
+
expect(w.sends).toBe(1)
|
|
408
|
+
expect(vi.getTimerCount()).toBe(0)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('promotes the staged answer when the completion window closes with NO reply (delivery preserved)', () => {
|
|
412
|
+
const w = new GatewayWorld(STAGE)
|
|
413
|
+
w.onText('The composed final answer.')
|
|
414
|
+
vi.advanceTimersByTime(WINDOW)
|
|
415
|
+
expect(w.sends).toBe(0) // staged, not sent
|
|
416
|
+
// No reply, no real turn_end (the KNOWN-UNRELIABLE turn_end case PR A was
|
|
417
|
+
// built for): the stage window closes → promote through the same flush path.
|
|
418
|
+
vi.advanceTimersByTime(STAGE - 1)
|
|
419
|
+
expect(w.sends).toBe(0)
|
|
420
|
+
vi.advanceTimersByTime(1)
|
|
421
|
+
expect(w.sends).toBe(1)
|
|
422
|
+
expect(w.records).toBe(1)
|
|
423
|
+
// Exactly once: a late real turn_end short-circuits on the null atom.
|
|
424
|
+
w.dispatchTurnEnd()
|
|
425
|
+
expect(w.sends).toBe(1)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('a REAL turn_end during the stage window delivers via the turn-flush branch — no double-send', () => {
|
|
429
|
+
const w = new GatewayWorld(STAGE)
|
|
430
|
+
w.onText('The composed final answer.')
|
|
431
|
+
vi.advanceTimersByTime(WINDOW) // staged
|
|
432
|
+
expect(w.sends).toBe(0)
|
|
433
|
+
vi.advanceTimersByTime(5_000)
|
|
434
|
+
w.dispatchTurnEnd() // the true completion signal lands first
|
|
435
|
+
expect(w.sends).toBe(1) // delivered by the turn-flush branch at turn_end
|
|
436
|
+
// endCurrentTurnAtomic's clear() cancelled the pending promotion timer.
|
|
437
|
+
expect(vi.getTimerCount()).toBe(0)
|
|
438
|
+
vi.advanceTimersByTime(BACKSTOP_MS)
|
|
439
|
+
expect(w.sends).toBe(1)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
it('new text during the stage window re-stages (model still composing) and promotes once', () => {
|
|
443
|
+
const w = new GatewayWorld(STAGE)
|
|
444
|
+
w.onText('First part of the answer. ')
|
|
445
|
+
vi.advanceTimersByTime(WINDOW) // staged
|
|
446
|
+
expect(w.sends).toBe(0)
|
|
447
|
+
vi.advanceTimersByTime(10_000)
|
|
448
|
+
w.onText('Second part — the model was still composing.') // re-arms the debounce
|
|
449
|
+
vi.advanceTimersByTime(WINDOW) // re-staged with a fresh window
|
|
450
|
+
expect(w.sends).toBe(0)
|
|
451
|
+
vi.advanceTimersByTime(STAGE)
|
|
452
|
+
expect(w.sends).toBe(1) // promoted exactly once, with the full text captured
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('a tool starting during the stage window discards the stage (model resumed work)', () => {
|
|
456
|
+
const w = new GatewayWorld(STAGE)
|
|
457
|
+
w.onText('Interim narration that looked terminal.')
|
|
458
|
+
vi.advanceTimersByTime(WINDOW) // staged
|
|
459
|
+
w.onToolUse('bash_1') // clear() → unstaged + promotion timer cancelled
|
|
460
|
+
vi.advanceTimersByTime(BACKSTOP_MS)
|
|
461
|
+
expect(w.sends).toBe(0)
|
|
462
|
+
expect(vi.getTimerCount()).toBe(0)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('stageMs=0 keeps the legacy immediate flush at quiescence', () => {
|
|
466
|
+
const w = new GatewayWorld(0)
|
|
467
|
+
w.onText('The composed final answer.')
|
|
468
|
+
vi.advanceTimersByTime(WINDOW)
|
|
469
|
+
expect(w.sends).toBe(1) // legacy: delivered at the ~1 s debounce
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
describe('resolveAnswerStageMs', () => {
|
|
474
|
+
it('defaults to ANSWER_STAGE_MS when unset', () => {
|
|
475
|
+
expect(resolveAnswerStageMs({})).toBe(ANSWER_STAGE_MS)
|
|
476
|
+
})
|
|
477
|
+
it('parses a positive override', () => {
|
|
478
|
+
expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: '15000' })).toBe(15_000)
|
|
479
|
+
})
|
|
480
|
+
it('treats 0 (and negatives) as the legacy immediate flush (0)', () => {
|
|
481
|
+
expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: '0' })).toBe(0)
|
|
482
|
+
expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: '-5' })).toBe(0)
|
|
483
|
+
})
|
|
484
|
+
it('fails safe to the default on an unparseable value', () => {
|
|
485
|
+
expect(resolveAnswerStageMs({ SWITCHROOM_ANSWER_STAGE_MS: 'soon' })).toBe(ANSWER_STAGE_MS)
|
|
486
|
+
})
|
|
487
|
+
})
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type PreTurnCardRecord,
|
|
13
13
|
} from '../gateway/handback-preturn-signal.js'
|
|
14
14
|
import { createTurnTypingLoop } from '../gateway/turn-typing-loop.js'
|
|
15
|
+
import { SessionConsumeSignal } from '../gateway/session-consume-signal.js'
|
|
15
16
|
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -471,6 +472,82 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
|
|
|
471
472
|
expect(h.signal.pendingCount()).toBe(0)
|
|
472
473
|
})
|
|
473
474
|
|
|
475
|
+
// ── 2026-08-02 LIVE REPRO REPLAY (klanker, turnId #1785628912223) ──
|
|
476
|
+
// A handback was released+delivered at T0; the session CONSUMED it and
|
|
477
|
+
// answered (a reply tool call from idle at T0+65s — the only observable
|
|
478
|
+
// trace; no adopting turn ever minted), a later real turn minted and
|
|
479
|
+
// completed — yet at T0+TTL the reap declared a genuine orphan and
|
|
480
|
+
// RE-INJECTED the already-answered report (attempt 1/2 at 00:06:53Z,
|
|
481
|
+
// 2/2 at 00:11:58Z, escalation 00:17:34Z). The invisible-consumption gate
|
|
482
|
+
// must clean up WITHOUT re-injection. This test is RED on the pre-fix seam
|
|
483
|
+
// (it re-injects) and GREEN with `sessionActivitySince` consulted.
|
|
484
|
+
it('does NOT re-inject a handback the session already consumed (reply-from-idle after delivery)', async () => {
|
|
485
|
+
const consume = new SessionConsumeSignal()
|
|
486
|
+
const h = makeHarness({
|
|
487
|
+
deliveredTtlMs: 300_000,
|
|
488
|
+
sessionActivitySince: (sinceTs) => consume.activitySince(sinceTs),
|
|
489
|
+
})
|
|
490
|
+
// T0: release (= delivery) — pre-turn entry armed, card painted at debounce.
|
|
491
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatConsumed', messageId: 900 }))
|
|
492
|
+
await h.sched.advance(700)
|
|
493
|
+
const cardId = await h.openCard.mock.results[0]!.value
|
|
494
|
+
expect(h.records.size).toBe(1)
|
|
495
|
+
|
|
496
|
+
// T0+65s: the session answers the handback — a main-session reply tool
|
|
497
|
+
// call observed over IPC while the gateway shows no live turn (the
|
|
498
|
+
// 00:02:58Z reply). NO gateway turn ever mints, so tryAdopt never fires.
|
|
499
|
+
await h.sched.advance(65_000)
|
|
500
|
+
consume.noteMainToolCall(h.sched.now())
|
|
501
|
+
|
|
502
|
+
// T0+TTL and beyond: the reap fires (idle, delivered TTL elapsed). The
|
|
503
|
+
// consumption evidence must resolve the entry WITHOUT re-injection and
|
|
504
|
+
// WITHOUT escalation — the frozen card is deleted, typing stops, entry
|
|
505
|
+
// dropped. OUTCOME: zero re-injected inbounds ever reach the buffer.
|
|
506
|
+
await h.sched.advance(600_000)
|
|
507
|
+
expect(h.reinjected).toEqual([])
|
|
508
|
+
expect(h.escalations).toEqual([])
|
|
509
|
+
expect(h.deletedIds).toEqual([cardId])
|
|
510
|
+
expect(h.records.size).toBe(0)
|
|
511
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
512
|
+
expect(h.typing.activeCount()).toBe(0)
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('a later turn MINT after delivery also counts as consumption (coalesced-turn case)', async () => {
|
|
516
|
+
const consume = new SessionConsumeSignal()
|
|
517
|
+
const h = makeHarness({
|
|
518
|
+
deliveredTtlMs: 300_000,
|
|
519
|
+
sessionActivitySince: (sinceTs) => consume.activitySince(sinceTs),
|
|
520
|
+
})
|
|
521
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatCoalesced', messageId: 901 }))
|
|
522
|
+
await h.sched.advance(700)
|
|
523
|
+
// A real user turn mints AFTER the delivery (the 00:04:13Z turn #25821):
|
|
524
|
+
// FIFO queue ⇒ the earlier-delivered handback entered the model context.
|
|
525
|
+
await h.sched.advance(140_000)
|
|
526
|
+
consume.noteTurnMint(h.sched.now())
|
|
527
|
+
await h.sched.advance(600_000)
|
|
528
|
+
expect(h.reinjected).toEqual([])
|
|
529
|
+
expect(h.escalations).toEqual([])
|
|
530
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('still re-injects a GENUINE orphan (no session activity since delivery)', async () => {
|
|
534
|
+
const consume = new SessionConsumeSignal()
|
|
535
|
+
const h = makeHarness({
|
|
536
|
+
deliveredTtlMs: 300_000,
|
|
537
|
+
sessionActivitySince: (sinceTs) => consume.activitySince(sinceTs),
|
|
538
|
+
})
|
|
539
|
+
// Activity BEFORE the delivery must not mask a genuine orphan.
|
|
540
|
+
consume.noteMainToolCall(h.sched.now())
|
|
541
|
+
await h.sched.advance(10)
|
|
542
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatDead', messageId: 902 }))
|
|
543
|
+
await h.sched.advance(700)
|
|
544
|
+
// Nothing consumes anything; past the TTL the deterministic recovery must
|
|
545
|
+
// still fire exactly as before the gate (delete + re-inject, no nudge).
|
|
546
|
+
await h.sched.advance(600_000)
|
|
547
|
+
expect(h.reinjected).toHaveLength(1)
|
|
548
|
+
expect(h.deletedIds).toHaveLength(1)
|
|
549
|
+
})
|
|
550
|
+
|
|
474
551
|
// ── deterministic-recovery retry cap: re-inject up to 2×, THEN escalate ──
|
|
475
552
|
// A genuine orphan whose re-injections keep coming back orphaned must not loop
|
|
476
553
|
// forever, and must NEVER fall back to a chat nudge. After `maxReinjects` it
|