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.
@@ -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 { handleSessionEvent, type StreamRenderDeps } from '../gateway/stream-render.js'
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 enqueue seams / restarts.
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++) handleSessionEvent(rig.h.deps, enqueue())
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++) handleSessionEvent(rig.h.deps, enqueue())
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
+ })