switchroom 0.19.31 → 0.19.32
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 +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +3 -0
- package/telegram-plugin/dist/gateway/gateway.js +229 -130
- package/telegram-plugin/dist/server.js +3 -0
- package/telegram-plugin/gateway/stream-render.ts +578 -321
- package/telegram-plugin/session-tail.ts +13 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +20 -5
- package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +196 -0
- package/telegram-plugin/tests/turn-mint-harness.ts +155 -0
- package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +124 -0
|
@@ -93,6 +93,13 @@ export function findActiveSessionFile(projectsDir: string): string | null {
|
|
|
93
93
|
export type SessionEvent =
|
|
94
94
|
| { kind: 'enqueue'; chatId: string | null; messageId: string | null; threadId: string | null; rawContent: string; isSync?: boolean }
|
|
95
95
|
| { kind: 'dequeue' }
|
|
96
|
+
// #3927 — `queue-operation` op `remove`: the queued item was folded into the
|
|
97
|
+
// ALREADY-RUNNING turn as a `queued_command` attachment rather than drained
|
|
98
|
+
// into a new turn. It is the OTHER terminal of an `enqueue` (the CLI writes
|
|
99
|
+
// exactly one of `dequeue` / `remove` per enqueue), and unlike `dequeue` it
|
|
100
|
+
// REPLAYS the enqueue's `content` byte-for-byte — which is what lets the
|
|
101
|
+
// gateway discard the right parked turn-start by identity.
|
|
102
|
+
| { kind: 'queue_remove'; rawContent: string }
|
|
96
103
|
| { kind: 'thinking' }
|
|
97
104
|
// Live model in use for the MAIN session, extracted from `message.model` on
|
|
98
105
|
// each `type:"assistant"` transcript line (the exact model that served that
|
|
@@ -566,6 +573,12 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
|
|
|
566
573
|
if (op === 'dequeue') {
|
|
567
574
|
return [{ kind: 'dequeue' }]
|
|
568
575
|
}
|
|
576
|
+
// #3927 — the enqueue's OTHER terminal. Previously dropped, which left the
|
|
577
|
+
// gateway unable to tell "queued message folded into the running turn"
|
|
578
|
+
// (`remove`) from "queue drained into a new turn" (`dequeue`).
|
|
579
|
+
if (op === 'remove') {
|
|
580
|
+
return [{ kind: 'queue_remove', rawContent: (obj.content as string | undefined) ?? '' }]
|
|
581
|
+
}
|
|
569
582
|
return []
|
|
570
583
|
}
|
|
571
584
|
|
|
@@ -19,10 +19,14 @@
|
|
|
19
19
|
* the duplicate. This is the exact duplicate-reply class the shared-singleton
|
|
20
20
|
* injection (never a re-`new`) exists to kill.
|
|
21
21
|
*/
|
|
22
|
-
import { describe, it, expect } from 'vitest'
|
|
22
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
23
23
|
import { readFileSync } from 'node:fs'
|
|
24
24
|
import { tmpdir } from 'node:os'
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
handleSessionEvent,
|
|
27
|
+
__resetParkedTurnStartsForTest,
|
|
28
|
+
type StreamRenderDeps,
|
|
29
|
+
} from '../gateway/stream-render.js'
|
|
26
30
|
import {
|
|
27
31
|
sendReply,
|
|
28
32
|
type SendReplyGatewayDeps,
|
|
@@ -612,6 +616,9 @@ describe('structural — the singleton lives once in gateway, never in the modul
|
|
|
612
616
|
// the assertion is the OUTCOME the user sees — chat actions on the wire — not
|
|
613
617
|
// the call.
|
|
614
618
|
describe('#3544 — turn-start typing is unconditional at the enqueue seam', () => {
|
|
619
|
+
// #3927's parked turn-start store is module-scope (one CLI session, one
|
|
620
|
+
// queue), so reset it between cases for determinism.
|
|
621
|
+
beforeEach(() => { __resetParkedTurnStartsForTest() })
|
|
615
622
|
interface TypingRig {
|
|
616
623
|
h: StreamHarness
|
|
617
624
|
sent: Array<{ chatId: string; threadId: number | null }>
|
|
@@ -669,15 +676,23 @@ describe('#3544 — turn-start typing is unconditional at the enqueue seam', ()
|
|
|
669
676
|
|
|
670
677
|
it('FLOOD GUARD: N turns arming on ONE chat inside the floor cost at most ONE chat action', () => {
|
|
671
678
|
const rig = makeTypingRig({ adopts: false })
|
|
679
|
+
// #3927: a repeated bare `enqueue` no longer mints a turn — while one is
|
|
680
|
+
// live it PARKS, and the CLI's `dequeue` is what starts the next turn. This
|
|
681
|
+
// guard is about N turn STARTS coalescing, so drive real starts: the first
|
|
682
|
+
// enqueue mints (idle) and each later enqueue+dequeue pair mints one more.
|
|
683
|
+
const startTurn = () => {
|
|
684
|
+
handleSessionEvent(rig.h.deps, enqueue())
|
|
685
|
+
handleSessionEvent(rig.h.deps, { kind: 'dequeue' })
|
|
686
|
+
}
|
|
672
687
|
// 12 arms on one chat inside a single floor window: a real-inbound arm
|
|
673
|
-
// (turn-start-surfaces) plus repeated
|
|
688
|
+
// (turn-start-surfaces) plus repeated turn-start seams / restarts.
|
|
674
689
|
rig.loop.start(CHAT, null) // stand-in for the real-inbound path's arm
|
|
675
|
-
for (let i = 0; i < 11; i++)
|
|
690
|
+
for (let i = 0; i < 11; i++) startTurn()
|
|
676
691
|
expect(rig.sent).toHaveLength(1) // the floor coalesced all 12
|
|
677
692
|
expect(rig.loop.activeCount()).toBe(1) // restart-safe: no interval pile-up
|
|
678
693
|
// Crossing the floor lets exactly one more through, then the floor holds again.
|
|
679
694
|
rig.clock.t += TYPING_FLOOR_MS
|
|
680
|
-
for (let i = 0; i < 5; i++)
|
|
695
|
+
for (let i = 0; i < 5; i++) startTurn()
|
|
681
696
|
expect(rig.sent).toHaveLength(2)
|
|
682
697
|
rig.loop.stopAll()
|
|
683
698
|
rig.emitter.reset()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3927 FIX A — a QUEUE event is not a TURN-START event.
|
|
3
|
+
*
|
|
4
|
+
* Pre-fix, `case 'enqueue'` in stream-render.ts unconditionally minted a fresh
|
|
5
|
+
* `CurrentTurn` and swapped it into the per-topic slot, even with a turn
|
|
6
|
+
* already live. The claude CLI writes `{"type":"queue-operation","operation":
|
|
7
|
+
* "enqueue"}` at QUEUE time, so a message the operator sent mid-turn produced
|
|
8
|
+
* a brand-new card (reset `startedAt` / `toolCallCount` / `mirrorLines`) that
|
|
9
|
+
* then streamed the STILL-RUNNING previous turn's tool labels into it, while
|
|
10
|
+
* the real turn's card froze on its last edit.
|
|
11
|
+
*
|
|
12
|
+
* Ground truth (60 real agent transcripts, 372 enqueues): every enqueue is
|
|
13
|
+
* terminated by exactly one of
|
|
14
|
+
* • `dequeue` — queue drained into a NEW turn (199/200 dequeues are directly
|
|
15
|
+
* preceded by their enqueue; median gap 6 ms idle, 2–14 s when queued
|
|
16
|
+
* behind a running turn), or
|
|
17
|
+
* • `remove` — folded into the ALREADY-RUNNING turn as a `queued_command`
|
|
18
|
+
* attachment, replaying the enqueue's `content` byte-for-byte.
|
|
19
|
+
*
|
|
20
|
+
* These drive the REAL `handleSessionEvent` (extracted-module golden-harness
|
|
21
|
+
* oracle, same standard as `stream-render-golden.test.ts`).
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
24
|
+
import {
|
|
25
|
+
handleSessionEvent,
|
|
26
|
+
__resetParkedTurnStartsForTest,
|
|
27
|
+
__parkedTurnStartCountForTest,
|
|
28
|
+
} from '../gateway/stream-render.js'
|
|
29
|
+
import { projectTranscriptLine } from '../session-tail.js'
|
|
30
|
+
import { CHAT, enqueue, inbound, makeHarness } from './turn-mint-harness.js'
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
__resetParkedTurnStartsForTest()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('#3927 FIX A — a mid-turn enqueue parks; the turn mints on dequeue', () => {
|
|
37
|
+
it('a second enqueue while turn A is live opens NO card, does not steal the slot, ' +
|
|
38
|
+
'and later tool labels still land on A — then dequeue mints B', () => {
|
|
39
|
+
const h = makeHarness()
|
|
40
|
+
|
|
41
|
+
// ── message A arrives on an idle session: mints immediately (unchanged) ──
|
|
42
|
+
handleSessionEvent(h.deps, enqueue('501'))
|
|
43
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' }) // the CLI's ms-later pair
|
|
44
|
+
const turnA = h.current()!
|
|
45
|
+
expect(turnA).not.toBeNull()
|
|
46
|
+
expect(turnA.sourceMessageId).toBe(501)
|
|
47
|
+
expect(h.cardsOpened).toHaveLength(1)
|
|
48
|
+
// The idle enqueue already minted, so the paired dequeue found nothing
|
|
49
|
+
// parked and was a no-op — NOT a second turn.
|
|
50
|
+
expect(h.cardsOpened[0]!.sourceMessageId).toBe(501)
|
|
51
|
+
|
|
52
|
+
// ── A does tool work ────────────────────────────────────────────────────
|
|
53
|
+
handleSessionEvent(h.deps, { kind: 'tool_label', toolName: 'Read', label: 'Reading config.ts' })
|
|
54
|
+
expect(turnA.labeledToolCount).toBe(1)
|
|
55
|
+
|
|
56
|
+
// ── the operator sends message B MID-TURN (no turn_end for A) ───────────
|
|
57
|
+
handleSessionEvent(h.deps, enqueue('502', 'also check the vault'))
|
|
58
|
+
|
|
59
|
+
// No second card. This is BUG 1: pre-fix a fresh card appeared here with
|
|
60
|
+
// reset stats while A's froze.
|
|
61
|
+
expect(h.cardsOpened).toHaveLength(1)
|
|
62
|
+
// The topic slot still points at A — object identity, not just shape.
|
|
63
|
+
expect(h.current()).toBe(turnA)
|
|
64
|
+
// This is BUG 2: pre-fix the slot pointed at B, whose card quoted B's
|
|
65
|
+
// message id while streaming A's still-running work.
|
|
66
|
+
expect(h.current()!.sourceMessageId).toBe(501)
|
|
67
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
68
|
+
|
|
69
|
+
// ── labels emitted AFTER B still belong to A's card ─────────────────────
|
|
70
|
+
handleSessionEvent(h.deps, { kind: 'tool_label', toolName: 'Bash', label: 'Running tests' })
|
|
71
|
+
expect(turnA.labeledToolCount).toBe(2)
|
|
72
|
+
expect(turnA.mirrorLines.join('\n')).toContain('Running tests')
|
|
73
|
+
expect(h.drains.every((d) => d.turnId === turnA.turnId)).toBe(true)
|
|
74
|
+
|
|
75
|
+
// ── A ends, the CLI drains the queue → B's turn mints NOW ───────────────
|
|
76
|
+
turnA.endedAt = Date.now()
|
|
77
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
78
|
+
const turnB = h.current()!
|
|
79
|
+
expect(turnB).not.toBe(turnA)
|
|
80
|
+
expect(turnB.sourceMessageId).toBe(502)
|
|
81
|
+
expect(h.cardsOpened).toHaveLength(2)
|
|
82
|
+
expect(h.cardsOpened[1]!.sourceMessageId).toBe(502)
|
|
83
|
+
// Fresh stats belong to B and only B.
|
|
84
|
+
expect(turnB.labeledToolCount).toBe(0)
|
|
85
|
+
expect(turnB.mirrorLines).toEqual([])
|
|
86
|
+
expect(__parkedTurnStartCountForTest()).toBe(0)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('a `remove` terminal discards the parked start, so a LATER dequeue cannot ' +
|
|
90
|
+
'mint a spurious turn for an already-folded message', () => {
|
|
91
|
+
const h = makeHarness()
|
|
92
|
+
handleSessionEvent(h.deps, enqueue('601'))
|
|
93
|
+
const turnA = h.current()!
|
|
94
|
+
|
|
95
|
+
// Queued mid-turn, then folded into A as a `queued_command` attachment.
|
|
96
|
+
const queued = enqueue('602', 'while you are in there…')
|
|
97
|
+
handleSessionEvent(h.deps, queued)
|
|
98
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
99
|
+
handleSessionEvent(h.deps, { kind: 'queue_remove', rawContent: queued.rawContent })
|
|
100
|
+
expect(__parkedTurnStartCountForTest()).toBe(0)
|
|
101
|
+
|
|
102
|
+
// A stray dequeue now must NOT resurrect 602 as its own turn.
|
|
103
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
104
|
+
expect(h.current()).toBe(turnA)
|
|
105
|
+
expect(h.cardsOpened).toHaveLength(1)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('dequeue pairs with the MOST RECENT parked start (evidence: max observed ' +
|
|
109
|
+
'gap to the newest enqueue is 14.2 s; to the oldest, hours)', () => {
|
|
110
|
+
const h = makeHarness()
|
|
111
|
+
handleSessionEvent(h.deps, enqueue('701'))
|
|
112
|
+
const turnA = h.current()!
|
|
113
|
+
|
|
114
|
+
handleSessionEvent(h.deps, enqueue('702'))
|
|
115
|
+
handleSessionEvent(h.deps, enqueue('703'))
|
|
116
|
+
expect(__parkedTurnStartCountForTest()).toBe(2)
|
|
117
|
+
|
|
118
|
+
turnA.endedAt = Date.now()
|
|
119
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
120
|
+
expect(h.current()!.sourceMessageId).toBe(703)
|
|
121
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('a chat-less enqueue never parks (it can never mint a turn)', () => {
|
|
125
|
+
const h = makeHarness()
|
|
126
|
+
handleSessionEvent(h.deps, { kind: 'enqueue', chatId: null, messageId: null, threadId: null, rawContent: 'x' })
|
|
127
|
+
expect(__parkedTurnStartCountForTest()).toBe(0)
|
|
128
|
+
expect(h.current()).toBeNull()
|
|
129
|
+
expect(h.cardsOpened).toHaveLength(0)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('a parked start expires after its TTL, so a dequeue that never arrives can ' +
|
|
133
|
+
'never mint a stale turn later', () => {
|
|
134
|
+
// RUNNER-AGNOSTIC CLOCK. This file dual-runs (vitest + `bun test`), and
|
|
135
|
+
// bun's vitest shim does NOT implement `vi.setSystemTime` — calling it
|
|
136
|
+
// threw `TypeError: vi.setSystemTime is not a function` and made bun-test
|
|
137
|
+
// deterministically red. The TTL is a pure elapsed-time comparison
|
|
138
|
+
// (`now - parkedAt > PARKED_TURN_START_TTL_MS`), so the absolute wall
|
|
139
|
+
// clock is irrelevant; only the 31-minute JUMP matters. `useFakeTimers()`
|
|
140
|
+
// + `advanceTimersByTime()` moves `Date.now()` by exactly that delta on
|
|
141
|
+
// BOTH runners (same insight as races.test.ts:275-281), so the assertion
|
|
142
|
+
// below really executes under bun instead of being guarded away.
|
|
143
|
+
vi.useFakeTimers()
|
|
144
|
+
try {
|
|
145
|
+
const h = makeHarness()
|
|
146
|
+
handleSessionEvent(h.deps, enqueue('1201'))
|
|
147
|
+
const turnA = h.current()!
|
|
148
|
+
handleSessionEvent(h.deps, enqueue('1202'))
|
|
149
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
150
|
+
|
|
151
|
+
// The CLI died mid-turn: neither `dequeue` nor `remove` ever arrives.
|
|
152
|
+
vi.advanceTimersByTime(31 * 60_000) // TTL is 30 min
|
|
153
|
+
turnA.endedAt = Date.now()
|
|
154
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
155
|
+
|
|
156
|
+
// 1202 was pruned, not minted 31 minutes late.
|
|
157
|
+
expect(__parkedTurnStartCountForTest()).toBe(0)
|
|
158
|
+
expect(h.current()).toBe(turnA)
|
|
159
|
+
expect(h.cardsOpened).toHaveLength(1)
|
|
160
|
+
} finally {
|
|
161
|
+
vi.useRealTimers()
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('the parked store is bounded — the 17th mid-turn enqueue evicts the oldest, ' +
|
|
166
|
+
'so a dequeue that never arrives cannot grow it without limit', () => {
|
|
167
|
+
const h = makeHarness()
|
|
168
|
+
handleSessionEvent(h.deps, enqueue('801'))
|
|
169
|
+
for (let i = 0; i < 40; i++) handleSessionEvent(h.deps, enqueue(String(900 + i)))
|
|
170
|
+
expect(__parkedTurnStartCountForTest()).toBe(16)
|
|
171
|
+
// The newest message — the one the user is actually waiting on — survived.
|
|
172
|
+
h.current()!.endedAt = Date.now()
|
|
173
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
174
|
+
expect(h.current()!.sourceMessageId).toBe(939)
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('#3927 — session-tail projects the `remove` queue operation', () => {
|
|
179
|
+
it('projects op=remove as queue_remove carrying the enqueue content verbatim', () => {
|
|
180
|
+
const content = inbound('901', 'queued while busy')
|
|
181
|
+
const evs = projectTranscriptLine(
|
|
182
|
+
JSON.stringify({ type: 'queue-operation', operation: 'remove', content }),
|
|
183
|
+
)
|
|
184
|
+
expect(evs).toEqual([{ kind: 'queue_remove', rawContent: content }])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('still projects enqueue and dequeue unchanged', () => {
|
|
188
|
+
const content = inbound('902', 'hello')
|
|
189
|
+
expect(projectTranscriptLine(
|
|
190
|
+
JSON.stringify({ type: 'queue-operation', operation: 'enqueue', content }),
|
|
191
|
+
)).toEqual([{ kind: 'enqueue', chatId: CHAT, messageId: '902', threadId: null, rawContent: content }])
|
|
192
|
+
expect(projectTranscriptLine(
|
|
193
|
+
JSON.stringify({ type: 'queue-operation', operation: 'dequeue' }),
|
|
194
|
+
)).toEqual([{ kind: 'dequeue' }])
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared harness for the #3927 turn-mint tests (FIX A / FIX B). NOT a test file
|
|
3
|
+
* — it drives the REAL `handleSessionEvent` against fake gateway closures, the
|
|
4
|
+
* extracted-module golden-harness oracle used by `stream-render-golden.test.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import type { CurrentTurn, StreamRenderDeps } from '../gateway/gateway.js'
|
|
8
|
+
|
|
9
|
+
export const CHAT = '1001'
|
|
10
|
+
|
|
11
|
+
/** `<channel …>` envelope shaped like the real inbound wrapper the CLI queues. */
|
|
12
|
+
export function inbound(messageId: string, text: string): string {
|
|
13
|
+
return (
|
|
14
|
+
`<channel source="switchroom-telegram" chat_id="${CHAT}" ` +
|
|
15
|
+
`message_id="${messageId}" user="tester">${text}</channel>`
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Harness {
|
|
20
|
+
deps: StreamRenderDeps
|
|
21
|
+
/** Every card the early-liveness open posted, in order (one per minted turn). */
|
|
22
|
+
cardsOpened: Array<{ turnId: string; sourceMessageId: number | null }>
|
|
23
|
+
/** Turns handed to `clearActivitySummary`, in order (FIX B's observable). */
|
|
24
|
+
finalized: CurrentTurn[]
|
|
25
|
+
/** `drainActivitySummary` calls: which turn's feed was drained, and its text. */
|
|
26
|
+
drains: Array<{ turnId: string; render: string | null }>
|
|
27
|
+
/** Ordered trace of surface effects: `finalize:<turnId>` / `open:<turnId>`. */
|
|
28
|
+
seq: string[]
|
|
29
|
+
current: () => CurrentTurn | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function makeHarness(): Harness {
|
|
33
|
+
let curTurn: CurrentTurn | null = null
|
|
34
|
+
const cardsOpened: Harness['cardsOpened'] = []
|
|
35
|
+
const finalized: CurrentTurn[] = []
|
|
36
|
+
const drains: Harness['drains'] = []
|
|
37
|
+
const seq: string[] = []
|
|
38
|
+
const noop = () => {}
|
|
39
|
+
const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
|
|
40
|
+
|
|
41
|
+
const deps = {
|
|
42
|
+
// config
|
|
43
|
+
ANSWER_LANE: { visibleEnabled: false },
|
|
44
|
+
CAPTURED_PROSE_DELIVERY_ENABLED: false,
|
|
45
|
+
CONTEXT_EXHAUSTION_COOLDOWN_MS: 600000,
|
|
46
|
+
DELIVERY_CONFIRM_ENABLED: false,
|
|
47
|
+
FEED_REOPEN_AFTER_ACK_ENABLED: false,
|
|
48
|
+
HANDBACK_PRETURN_ENABLED: false,
|
|
49
|
+
HISTORY_ENABLED: false,
|
|
50
|
+
LIVENESS_TERMINAL_HONESTY: true,
|
|
51
|
+
OBLIGATION_LEDGER_ENABLED: false,
|
|
52
|
+
ORPHANED_REPLY_STREAM_WINDOW_MS: 120000,
|
|
53
|
+
SILENCE_LIVENESS_PRODUCTION: false,
|
|
54
|
+
STATE_DIR: tmpdir(),
|
|
55
|
+
TURN_FLUSH_SAFETY_ENABLED: true,
|
|
56
|
+
TURN_PREVIEW_MAX: 200,
|
|
57
|
+
// state
|
|
58
|
+
activeDraftStreams: new Map(),
|
|
59
|
+
activeStatusReactions: new Map(),
|
|
60
|
+
activeTurnStartedAt: new Map(),
|
|
61
|
+
backstopDeliveryLedger: { has: () => false, record: noop },
|
|
62
|
+
bot: { api: {} },
|
|
63
|
+
deliveryQueue: {},
|
|
64
|
+
flushedTurnSupersede: { record: noop },
|
|
65
|
+
handbackPreturnSignal: { tryAdopt: () => null },
|
|
66
|
+
idleTracker: { noteEvent: noop },
|
|
67
|
+
lastPtyPreviewByChat: new Map(),
|
|
68
|
+
obligationLedger: { close: noop, noteTurnEnded: noop },
|
|
69
|
+
outboundDedup: { check: () => null, record: noop },
|
|
70
|
+
pendingCrossTurnGate: new Map(),
|
|
71
|
+
preambleSuppressor: { dropNow: noop, flushNow: noop, onText: noop, onTool: noop, reset: noop },
|
|
72
|
+
progressDriver: null,
|
|
73
|
+
reactionTransitionCounts: new Map(),
|
|
74
|
+
sessionModelSource: { noteTranscriptModel: noop },
|
|
75
|
+
suppressPtyPreview: new Set(),
|
|
76
|
+
toolFlightTracker: { inFlightCount: () => 0 },
|
|
77
|
+
typingWrapper: { drainAll: noop, onToolResult: noop, onToolUse: noop },
|
|
78
|
+
robustApiCall: (fn: () => Promise<unknown>) => fn(),
|
|
79
|
+
swallowingApiCall: async (fn: () => Promise<unknown>) => { try { return await fn() } catch { return undefined } },
|
|
80
|
+
// accessors
|
|
81
|
+
getCurrentTurn: () => curTurn,
|
|
82
|
+
setCurrentTurn: (t: CurrentTurn) => { curTurn = t },
|
|
83
|
+
getLastContextExhaustionWarningAt: () => 0,
|
|
84
|
+
setLastContextExhaustionWarningAt: noop,
|
|
85
|
+
getPendingPtyPartial: () => null,
|
|
86
|
+
setPendingPtyPartial: noop,
|
|
87
|
+
// closures we observe
|
|
88
|
+
clearActivitySummary: (t: CurrentTurn) => { finalized.push(t); seq.push(`finalize:${t.turnId}`) },
|
|
89
|
+
scheduleEarlyLivenessOpen: (t: CurrentTurn) => {
|
|
90
|
+
// Models the real early-open: ONE card per minted turn. Pre-fix this ran
|
|
91
|
+
// for every enqueue, which is exactly how the duplicate card appeared.
|
|
92
|
+
cardsOpened.push({ turnId: t.turnId, sourceMessageId: t.sourceMessageId })
|
|
93
|
+
seq.push(`open:${t.turnId}`)
|
|
94
|
+
},
|
|
95
|
+
drainActivitySummary: (t: CurrentTurn) => {
|
|
96
|
+
drains.push({ turnId: t.turnId, render: t.activityPendingRender })
|
|
97
|
+
return Promise.resolve()
|
|
98
|
+
},
|
|
99
|
+
// rest of the closure surface
|
|
100
|
+
cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
|
|
101
|
+
clearAnswerReadyFlushTimeout: noop,
|
|
102
|
+
closeActivityLane: noop,
|
|
103
|
+
closeProgressLane: noop,
|
|
104
|
+
completeProgressCardTurn: null,
|
|
105
|
+
composeTurnActivity: () => null,
|
|
106
|
+
confirmMemoryLegibility: noop,
|
|
107
|
+
deliverAnswer: async () => ({ sentIds: [], chunkCount: 0, delivered: false, exhausted: false }),
|
|
108
|
+
deliverCapturedProse: async () => {},
|
|
109
|
+
emissionAuthorityFor: () => ({
|
|
110
|
+
mayDrain: () => true,
|
|
111
|
+
openOrEditCard: (_p: string, run: () => void) => run(),
|
|
112
|
+
claimOrDowngradePing: (_i: unknown, _s: unknown, _a: unknown, disabled: () => void) => disabled(),
|
|
113
|
+
markSubstantiveFinalDelivered: (fn: () => void) => fn(),
|
|
114
|
+
finalizeCard: (fn: () => void) => fn(),
|
|
115
|
+
}),
|
|
116
|
+
emitTurnRecord: noop,
|
|
117
|
+
endCurrentTurnAtomic: () => null,
|
|
118
|
+
extractUserPromptPreview: () => null,
|
|
119
|
+
finalizeStatusReaction: noop,
|
|
120
|
+
flushPendingNarrativeAtTurnEnd: noop,
|
|
121
|
+
getPinnedProgressCardMessageId: null,
|
|
122
|
+
handlePtyPartial: noop,
|
|
123
|
+
isDmChatId: () => true,
|
|
124
|
+
isLegitimatelyWorking: () => false,
|
|
125
|
+
makeNarrativeGate: () => ({ show: noop, stage: noop, resolveOnTool: noop, flushAtTurnEnd: noop, teardown: noop }),
|
|
126
|
+
promoteQueuedStatus: noop,
|
|
127
|
+
purgeReactionTracking: noop,
|
|
128
|
+
redactOutboundText: (t: string) => t,
|
|
129
|
+
rememberRecentTurn: noop,
|
|
130
|
+
resetAnswerReadyFlushTimeout: noop,
|
|
131
|
+
resetOrphanedReplyTimeout: noop,
|
|
132
|
+
resolvePendingNarrativeOnTool: noop,
|
|
133
|
+
stagePendingNarrative: noop,
|
|
134
|
+
startTurnTypingLoop: noop,
|
|
135
|
+
statusKey: key,
|
|
136
|
+
streamKey: key,
|
|
137
|
+
surfaceMemoryLegibility: noop,
|
|
138
|
+
turnLiveForItsTopic: () => true,
|
|
139
|
+
turnsDb: null,
|
|
140
|
+
unpinProgressCardForChat: null,
|
|
141
|
+
} as unknown as StreamRenderDeps
|
|
142
|
+
|
|
143
|
+
return { deps, cardsOpened, finalized, drains, seq, current: () => curTurn }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function enqueue(messageId: string | null, text = 'hi', threadId: string | null = null) {
|
|
147
|
+
return {
|
|
148
|
+
kind: 'enqueue' as const,
|
|
149
|
+
chatId: CHAT,
|
|
150
|
+
messageId,
|
|
151
|
+
threadId,
|
|
152
|
+
rawContent: messageId == null ? text : inbound(messageId, text),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3927 FIX B — a superseded turn must never be left holding an orphaned card.
|
|
3
|
+
*
|
|
4
|
+
* The turn-supersession teardown in `stream-render.ts` tore down the prior
|
|
5
|
+
* turn's `answerStream`, its orphaned-reply fuse and its `narrativeGate`, but
|
|
6
|
+
* NEVER called `clearActivitySummary(prior)`. So a clobbered turn's activity
|
|
7
|
+
* card was never finalized, never unpinned (`fg:<statusKey>` stayed claimed
|
|
8
|
+
* forever), froze on its last landed edit, and left NO `turn-lifecycle clear`
|
|
9
|
+
* line to explain it. Proof from the field: carrie's turn
|
|
10
|
+
* `-1004223464247:_#1078` has a `turn-lifecycle set reason=enqueue` at
|
|
11
|
+
* 2026-07-28T18:13:37.554Z and no `clear` anywhere in the log; its stale pinned
|
|
12
|
+
* card was still on screen in the operator's screenshot.
|
|
13
|
+
*
|
|
14
|
+
* ── Which path still supersedes, after FIX A ──────────────────────────────
|
|
15
|
+
* FIX A parks EVERY mid-turn enqueue, synthetic ones included — that is not a
|
|
16
|
+
* policy choice, it is what the claude CLI does (carrie's `obligation_represent`
|
|
17
|
+
* enqueue at 18:19:07.032Z was terminated by a `remove` at 18:19:59.794Z, i.e.
|
|
18
|
+
* folded into the running turn as a `queued_command` attachment, never by a
|
|
19
|
+
* `dequeue`). So an enqueue no longer preempts, and the case below asserts that
|
|
20
|
+
* explicitly.
|
|
21
|
+
*
|
|
22
|
+
* Supersession is still REACHABLE, though: a turn whose `turn_end` the gateway
|
|
23
|
+
* never observed (bridge death, transcript gap, unclean restart) leaves a
|
|
24
|
+
* live-looking atom in the slot, and the next genuine dequeue-driven turn start
|
|
25
|
+
* mints straight on top of it. That is the exact orphan carrie hit, and it is
|
|
26
|
+
* what FIX B finalizes.
|
|
27
|
+
*/
|
|
28
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
29
|
+
import {
|
|
30
|
+
handleSessionEvent,
|
|
31
|
+
__resetParkedTurnStartsForTest,
|
|
32
|
+
__parkedTurnStartCountForTest,
|
|
33
|
+
} from '../gateway/stream-render.js'
|
|
34
|
+
import { enqueue, makeHarness } from './turn-mint-harness.js'
|
|
35
|
+
|
|
36
|
+
/** A cron / handback / wake enqueue: no `message_id`, so `deriveTurnId` falls
|
|
37
|
+
* back to `…#synthetic-<startedAt>`. */
|
|
38
|
+
function syntheticEnqueue(chatId: string, text: string) {
|
|
39
|
+
return {
|
|
40
|
+
kind: 'enqueue' as const,
|
|
41
|
+
chatId,
|
|
42
|
+
messageId: null,
|
|
43
|
+
threadId: null,
|
|
44
|
+
rawContent: `<channel source="switchroom-telegram" source="cron">${text}</channel>`,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
__resetParkedTurnStartsForTest()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('#3927 FIX B — superseding a live turn finalizes its card', () => {
|
|
53
|
+
it('a dequeue-driven mint on top of a never-ended turn calls clearActivitySummary ' +
|
|
54
|
+
'for the PRIOR turn, and does so BEFORE the successor opens its card', () => {
|
|
55
|
+
const h = makeHarness()
|
|
56
|
+
|
|
57
|
+
// Turn A starts and opens a card with real work on it.
|
|
58
|
+
handleSessionEvent(h.deps, enqueue('1078'))
|
|
59
|
+
const turnA = h.current()!
|
|
60
|
+
handleSessionEvent(h.deps, { kind: 'tool_label', toolName: 'Read', label: 'Reading the log' })
|
|
61
|
+
expect(turnA.labeledToolCount).toBe(1)
|
|
62
|
+
expect(h.finalized).toHaveLength(0)
|
|
63
|
+
|
|
64
|
+
// A's `turn_end` is NEVER observed — `endedAt` stays null and the atom
|
|
65
|
+
// stays in the slot (exactly carrie's `#1078`).
|
|
66
|
+
expect(turnA.endedAt).toBeNull()
|
|
67
|
+
|
|
68
|
+
// A later message is queued and the CLI drains it into a new turn.
|
|
69
|
+
handleSessionEvent(h.deps, enqueue('1086'))
|
|
70
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
71
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
72
|
+
|
|
73
|
+
const turnB = h.current()!
|
|
74
|
+
expect(turnB).not.toBe(turnA)
|
|
75
|
+
|
|
76
|
+
// FIX B: the orphan was finalized — pre-fix there was NO call at all.
|
|
77
|
+
expect(h.finalized).toHaveLength(1)
|
|
78
|
+
expect(h.finalized[0]).toBe(turnA)
|
|
79
|
+
|
|
80
|
+
// …and it happened BEFORE the successor's card opened, so the old card is
|
|
81
|
+
// finalized/unpinned rather than left frozen above a fresh one.
|
|
82
|
+
expect(h.seq).toEqual([
|
|
83
|
+
`open:${turnA.turnId}`,
|
|
84
|
+
`finalize:${turnA.turnId}`,
|
|
85
|
+
`open:${turnB.turnId}`,
|
|
86
|
+
])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('a turn that ENDED normally is not re-finalized when its successor mints', () => {
|
|
90
|
+
const h = makeHarness()
|
|
91
|
+
handleSessionEvent(h.deps, enqueue('1090'))
|
|
92
|
+
const turnA = h.current()!
|
|
93
|
+
// turn-end.ts stamps `endedAt` and runs its own clearActivitySummary.
|
|
94
|
+
turnA.endedAt = Date.now()
|
|
95
|
+
|
|
96
|
+
handleSessionEvent(h.deps, enqueue('1091'))
|
|
97
|
+
// Idle by `endedAt`, so this mints straight away — and must NOT double-
|
|
98
|
+
// finalize A's already-closed card.
|
|
99
|
+
expect(h.current()).not.toBe(turnA)
|
|
100
|
+
expect(h.finalized).toHaveLength(0)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('FIX A is uniform across sources: a SYNTHETIC (cron/handback) enqueue parks ' +
|
|
104
|
+
'behind a live turn instead of preempting it', () => {
|
|
105
|
+
const h = makeHarness()
|
|
106
|
+
handleSessionEvent(h.deps, enqueue('1100'))
|
|
107
|
+
const turnA = h.current()!
|
|
108
|
+
|
|
109
|
+
handleSessionEvent(h.deps, syntheticEnqueue('1001', 'time for the daily digest'))
|
|
110
|
+
|
|
111
|
+
// No preemption: no new card, no slot steal, no orphaned finalize.
|
|
112
|
+
expect(h.current()).toBe(turnA)
|
|
113
|
+
expect(h.cardsOpened).toHaveLength(1)
|
|
114
|
+
expect(h.finalized).toHaveLength(0)
|
|
115
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
116
|
+
|
|
117
|
+
// It mints on the CLI's own turn-start signal, like every other source.
|
|
118
|
+
turnA.endedAt = Date.now()
|
|
119
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
120
|
+
expect(h.current()).not.toBe(turnA)
|
|
121
|
+
expect(h.current()!.turnId).toContain('#synthetic-')
|
|
122
|
+
expect(h.cardsOpened).toHaveLength(2)
|
|
123
|
+
})
|
|
124
|
+
})
|