switchroom 0.19.45 → 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 +483 -43
- 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/profiles/_base/start.sh.hbs +48 -4
- package/telegram-plugin/answer-ready-flush.ts +118 -6
- package/telegram-plugin/dist/gateway/gateway.js +124 -21
- package/telegram-plugin/gateway/auth-command.ts +7 -4
- package/telegram-plugin/gateway/auth-loopback-relay.ts +39 -20
- package/telegram-plugin/gateway/gateway.ts +20 -19
- 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/auth-loopback-relay.test.ts +58 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +77 -0
|
@@ -238,6 +238,18 @@ export interface HandbackPreturnSignalDeps {
|
|
|
238
238
|
* is the GLOBAL busy signal because the handback queues behind whatever turn
|
|
239
239
|
* is running, regardless of topic. Optional; defaults to "not busy". */
|
|
240
240
|
isClaudeBusy?: () => boolean
|
|
241
|
+
/** True IFF the gateway observed the SESSION consume input after `sinceTs` —
|
|
242
|
+
* a turn minting at the enqueue seam, or a main-session MCP tool call over
|
|
243
|
+
* the IPC bridge (`session-consume-signal.ts`). Consulted at the moment the
|
|
244
|
+
* reap would otherwise declare a genuine orphan: claude holds ONE FIFO input
|
|
245
|
+
* queue, so post-delivery consumption evidence means the delivered handback
|
|
246
|
+
* entered the model's context even though no adopting turn was ever minted
|
|
247
|
+
* (the 2026-08-02 invisible-consumption incident — the session answered the
|
|
248
|
+
* handback via a `reply` from idle with zero session-tail events, then the
|
|
249
|
+
* reap re-injected the already-answered report twice). When true the reap
|
|
250
|
+
* CLEANS UP (card + typing + entry) without re-injecting and without
|
|
251
|
+
* escalating. Optional; defaults to "no evidence" (legacy re-inject). */
|
|
252
|
+
sessionActivitySince?: (sinceTs: number) => boolean
|
|
241
253
|
now?: () => number
|
|
242
254
|
/** Debounce before painting the pre-turn card (kills sub-second flicker). */
|
|
243
255
|
debounceMs?: number
|
|
@@ -487,13 +499,27 @@ export function createHandbackPreturnSignal(
|
|
|
487
499
|
entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs)
|
|
488
500
|
return
|
|
489
501
|
}
|
|
502
|
+
// INVISIBLE-CONSUMPTION GATE (2026-08-02 klanker incident). We are at the
|
|
503
|
+
// point the reap would declare a genuine orphan: claude is idle, the
|
|
504
|
+
// delivered TTL elapsed, and no adopting turn ever minted. But "no adopting
|
|
505
|
+
// turn" is NOT proof the handback went un-consumed — the session can
|
|
506
|
+
// consume injected input WITHOUT the gateway minting a turn (zero
|
|
507
|
+
// session-tail events; the only trace was a `reply` tool call from idle).
|
|
508
|
+
// If the gateway observed the session consume input after this handback
|
|
509
|
+
// was delivered (a later turn minting, or a main-session tool call —
|
|
510
|
+
// claude's single FIFO queue means either proves the earlier delivery
|
|
511
|
+
// entered the model's context), the handback was already processed:
|
|
512
|
+
// re-injecting it re-runs an answered report (observed: the same worker
|
|
513
|
+
// report processed three times). Clean up WITHOUT re-injection.
|
|
514
|
+
const consumedElsewhere = deps.sessionActivitySince?.(entry.deliveredAt) === true
|
|
490
515
|
entry.consumed = true
|
|
491
|
-
// GENUINE ORPHAN. Recover DETERMINISTICALLY
|
|
492
|
-
// nudge. Stop the forever-running typing loop,
|
|
493
|
-
// not leave a stale "needs a nudge" message),
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
|
|
516
|
+
// GENUINE ORPHAN (or consumed-elsewhere cleanup). Recover DETERMINISTICALLY
|
|
517
|
+
// — never ask the operator to nudge. Stop the forever-running typing loop,
|
|
518
|
+
// DELETE the frozen card (do not leave a stale "needs a nudge" message),
|
|
519
|
+
// then — for a genuine orphan only — re-inject the handback so the machine
|
|
520
|
+
// re-processes it itself. Cap the re-injections; past the cap escalate to
|
|
521
|
+
// fleet-health telemetry (NOT a chat card).
|
|
522
|
+
stopTypingUnlessTurnLive(entry, consumedElsewhere ? 'consumed reap' : 'orphan reap')
|
|
497
523
|
if (entry.activityMessageId != null) {
|
|
498
524
|
const record: PreTurnCardRecord = {
|
|
499
525
|
turnKey: entry.syntheticTurnKey,
|
|
@@ -518,6 +544,18 @@ export function createHandbackPreturnSignal(
|
|
|
518
544
|
// synchronously) is not swallowed by the per-topic dedupe guard. The entry
|
|
519
545
|
// object stays valid for the captured re-inject below.
|
|
520
546
|
dropEntry(entry)
|
|
547
|
+
if (consumedElsewhere) {
|
|
548
|
+
// Distinct structured line so fleet-health can track how often the
|
|
549
|
+
// session consumes a handback without a gateway-visible adopting turn
|
|
550
|
+
// (the underlying session-tail anomaly stays observable even though the
|
|
551
|
+
// false re-injection is gone).
|
|
552
|
+
log(
|
|
553
|
+
`handback-preturn-signal: reap resolved key=${entry.statusKey} ` +
|
|
554
|
+
`turnId=${entry.adoptTurnId} — session consumed input after delivery ` +
|
|
555
|
+
`(no adopting turn observed); cleaned up without re-injection\n`,
|
|
556
|
+
)
|
|
557
|
+
return
|
|
558
|
+
}
|
|
521
559
|
if (entry.reinjectCount < maxReinjects) {
|
|
522
560
|
const nextCount = entry.reinjectCount + 1
|
|
523
561
|
// Stamp the counter on the inbound so it survives the buffer round trip
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-consume-signal.ts — "did the claude session consume input after T?"
|
|
3
|
+
*
|
|
4
|
+
* Built for the 2026-08-02 klanker false-orphan incident (handback turnId
|
|
5
|
+
* `#1785628912223`). A background worker's `subagent_handback` was released and
|
|
6
|
+
* DELIVERED to the bridge at 00:01:53Z; the session consumed it and answered it
|
|
7
|
+
* (a `reply` tool call at 00:02:58Z, IPC-observed, while the gateway's shadow
|
|
8
|
+
* state was `bridge_alive_idle`) — but NO gateway turn ever minted for it: the
|
|
9
|
+
* session-tail produced no `enqueue`/stream events for that consumption, so
|
|
10
|
+
* `handback-preturn-signal`'s entry was never adopted. After the delivered TTL
|
|
11
|
+
* the reap declared a genuine orphan and RE-INJECTED the already-answered
|
|
12
|
+
* handback — twice — re-processing the same worker report three times.
|
|
13
|
+
*
|
|
14
|
+
* The orphan reap's existing gates (`isClaudeBusy`, the delivered TTL) key off
|
|
15
|
+
* gateway-minted TURN state, which is exactly the signal that was absent. This
|
|
16
|
+
* module tracks the two consumption proxies the gateway CAN always observe,
|
|
17
|
+
* independent of the session-tail:
|
|
18
|
+
*
|
|
19
|
+
* - a TURN MINT (the `enqueue` seam in stream-render): claude holds a single
|
|
20
|
+
* FIFO input queue, so a turn minting for an inbound delivered AFTER the
|
|
21
|
+
* handback proves everything delivered before it entered the model's
|
|
22
|
+
* context (2026-08-02: real turn `#25821` minted 00:04:13Z and completed,
|
|
23
|
+
* while the reap still declared the 00:01:53Z handback orphaned);
|
|
24
|
+
* - a MAIN-SESSION MCP TOOL CALL over the IPC bridge (reply, progress_update,
|
|
25
|
+
* …): the session can only produce a tool call by processing input, so a
|
|
26
|
+
* tool call arriving after the delivery is direct evidence the session is
|
|
27
|
+
* alive and consuming — the 00:02:58Z reply was the ONLY observable trace
|
|
28
|
+
* of the invisible consumption. Cron/foreign sessions are excluded at the
|
|
29
|
+
* call site (their activity says nothing about the main session's queue).
|
|
30
|
+
*
|
|
31
|
+
* Consumers ask `activitySince(deliveredAt)`. The deliberate trade: when this
|
|
32
|
+
* returns true for a handback that was in fact DROPPED before reaching claude
|
|
33
|
+
* (bridge death in the release→write window) while unrelated session activity
|
|
34
|
+
* happened to occur, the reap now cleans up instead of re-injecting — a lost
|
|
35
|
+
* recovery for a rare double-fault, logged distinctly for fleet-health. The
|
|
36
|
+
* previous behaviour re-injected an already-answered handback on every
|
|
37
|
+
* invisible consumption, which is the incident that actually occurs.
|
|
38
|
+
*
|
|
39
|
+
* Pure state, no I/O; module singleton mirrors `subagentReplyAuthority`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export class SessionConsumeSignal {
|
|
43
|
+
private lastTurnMintAt = 0
|
|
44
|
+
private lastMainToolCallAt = 0
|
|
45
|
+
|
|
46
|
+
/** A gateway turn minted at the `enqueue` seam (stream-render). */
|
|
47
|
+
noteTurnMint(now: number = Date.now()): void {
|
|
48
|
+
if (now > this.lastTurnMintAt) this.lastTurnMintAt = now
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A NON-CRON client's MCP tool call arrived over the IPC bridge. The call
|
|
52
|
+
* site excludes cron identities — a Tier-1 cheap-cron session consuming its
|
|
53
|
+
* own input proves nothing about the main session's queue. */
|
|
54
|
+
noteMainToolCall(now: number = Date.now()): void {
|
|
55
|
+
if (now > this.lastMainToolCallAt) this.lastMainToolCallAt = now
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True IFF either consumption proxy was observed strictly after `sinceTs`. */
|
|
59
|
+
activitySince(sinceTs: number): boolean {
|
|
60
|
+
return this.lastTurnMintAt > sinceTs || this.lastMainToolCallAt > sinceTs
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Test-only: forget all observations. */
|
|
64
|
+
reset(): void {
|
|
65
|
+
this.lastTurnMintAt = 0
|
|
66
|
+
this.lastMainToolCallAt = 0
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The ONE live instance (one CLI session per gateway process — the same
|
|
71
|
+
* module-singleton shape as `subagentReplyAuthority`). */
|
|
72
|
+
export const sessionConsumeSignal = new SessionConsumeSignal()
|
|
@@ -75,6 +75,7 @@ import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
|
75
75
|
import { decideTurnFlush } from '../turn-flush-safety.js'
|
|
76
76
|
import { FlushCompletionTracker } from '../flushed-turn-supersede.js'
|
|
77
77
|
import { subagentReplyAuthority } from './subagent-reply-authority.js'
|
|
78
|
+
import { sessionConsumeSignal } from './session-consume-signal.js'
|
|
78
79
|
import { decideTerminalReason, deriveTurnRole } from '../turn-liveness-floor.js'
|
|
79
80
|
import { chatKey, chatKeyWithSuffix } from './chat-key.js'
|
|
80
81
|
import { deriveTurnId } from './derive-turn-id.js'
|
|
@@ -747,6 +748,10 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
|
|
|
747
748
|
process.stderr.write(
|
|
748
749
|
`telegram gateway: ${formatTurnLifecycle('set', 'enqueue', next, startedAt)}\n`,
|
|
749
750
|
)
|
|
751
|
+
// Consumption proxy for the handback orphan reap (session-consume-signal):
|
|
752
|
+
// claude holds ONE FIFO input queue, so this turn minting proves every
|
|
753
|
+
// inbound delivered before its own entered the model's context.
|
|
754
|
+
sessionConsumeSignal.noteTurnMint(startedAt)
|
|
750
755
|
// Component 3 — retain in the bounded recently-ended registry so a
|
|
751
756
|
// LATE reply (landing after currentTurn flips to a successor) can
|
|
752
757
|
// still resolve THIS turn's origin thread by its turnId.
|
|
@@ -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
|
+
})
|
|
@@ -22,6 +22,7 @@ import { describe, it, expect } from 'vitest'
|
|
|
22
22
|
import { EventEmitter } from 'node:events'
|
|
23
23
|
|
|
24
24
|
import {
|
|
25
|
+
buildRelayArgs,
|
|
25
26
|
parseLoopbackRedirect,
|
|
26
27
|
extractConsentUrl,
|
|
27
28
|
parseConsentUrl,
|
|
@@ -492,6 +493,7 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
|
|
|
492
493
|
email: 'user@example.com',
|
|
493
494
|
replace: false,
|
|
494
495
|
write: false,
|
|
496
|
+
calendar: false,
|
|
495
497
|
orgMode: false,
|
|
496
498
|
})
|
|
497
499
|
})
|
|
@@ -501,6 +503,26 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
|
|
|
501
503
|
expect(p).toMatchObject({ kind: 'provider-add', replace: true, write: true })
|
|
502
504
|
})
|
|
503
505
|
|
|
506
|
+
it('parses google --calendar, and it does not imply --write', () => {
|
|
507
|
+
const p = parseAuthCommand('/auth google add user@example.com --replace --calendar')
|
|
508
|
+
expect(p).toMatchObject({
|
|
509
|
+
kind: 'provider-add',
|
|
510
|
+
replace: true,
|
|
511
|
+
calendar: true,
|
|
512
|
+
write: false,
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('parses --write and --calendar together', () => {
|
|
517
|
+
const p = parseAuthCommand('/auth google add user@example.com --write --calendar')
|
|
518
|
+
expect(p).toMatchObject({ kind: 'provider-add', write: true, calendar: true })
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it('--calendar is google-only — microsoft rejects it as an unknown flag', () => {
|
|
522
|
+
const p = parseAuthCommand('/auth microsoft add user@corp.com --calendar')
|
|
523
|
+
expect(p).toMatchObject({ kind: 'help' })
|
|
524
|
+
})
|
|
525
|
+
|
|
504
526
|
it('parses /auth microsoft add with --org-mode', () => {
|
|
505
527
|
const p = parseAuthCommand('/auth microsoft add user@corp.com --org-mode')
|
|
506
528
|
expect(p).toMatchObject({
|
|
@@ -531,3 +553,39 @@ describe('parseAuthCommand — provider verbs (#2582)', () => {
|
|
|
531
553
|
expect(p).toMatchObject({ kind: 'help' })
|
|
532
554
|
})
|
|
533
555
|
})
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
describe('buildRelayArgs — chat flags actually reach the CLI', () => {
|
|
559
|
+
it('bare google add: no opt-in flags', () => {
|
|
560
|
+
expect(buildRelayArgs('google', 'a@b.com', {})).toEqual([
|
|
561
|
+
'auth', 'google', 'account', 'add', 'a@b.com',
|
|
562
|
+
])
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('forwards --calendar', () => {
|
|
566
|
+
expect(buildRelayArgs('google', 'a@b.com', { calendar: true })).toEqual([
|
|
567
|
+
'auth', 'google', 'account', 'add', 'a@b.com', '--calendar',
|
|
568
|
+
])
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('forwards --replace --write --calendar together, in order', () => {
|
|
572
|
+
expect(
|
|
573
|
+
buildRelayArgs('google', 'a@b.com', {
|
|
574
|
+
replace: true,
|
|
575
|
+
write: true,
|
|
576
|
+
calendar: true,
|
|
577
|
+
}),
|
|
578
|
+
).toEqual([
|
|
579
|
+
'auth', 'google', 'account', 'add', 'a@b.com',
|
|
580
|
+
'--replace', '--write', '--calendar',
|
|
581
|
+
])
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it('--calendar never leaks into the microsoft argv', () => {
|
|
585
|
+
expect(
|
|
586
|
+
buildRelayArgs('microsoft', 'a@corp.com', { calendar: true, orgMode: true }),
|
|
587
|
+
).toEqual([
|
|
588
|
+
'auth', 'microsoft', 'account', 'add', 'a@corp.com', '--org-mode',
|
|
589
|
+
])
|
|
590
|
+
})
|
|
591
|
+
})
|
|
@@ -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
|