switchroom 0.19.30 → 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.
Files changed (51) hide show
  1. package/dist/cli/switchroom.js +1121 -584
  2. package/dist/host-control/main.js +151 -73
  3. package/package.json +1 -1
  4. package/profiles/_base/cron-session.sh.hbs +5 -1
  5. package/profiles/_base/start.sh.hbs +15 -1
  6. package/telegram-plugin/dist/bridge/bridge.js +3 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +1660 -677
  8. package/telegram-plugin/dist/server.js +3 -0
  9. package/telegram-plugin/edit-flood-fuse.ts +70 -20
  10. package/telegram-plugin/gateway/boot-beacon.ts +364 -0
  11. package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
  12. package/telegram-plugin/gateway/gateway.ts +87 -88
  13. package/telegram-plugin/gateway/inbound-spool.ts +39 -0
  14. package/telegram-plugin/gateway/narrative-lane.ts +12 -0
  15. package/telegram-plugin/gateway/obligation-store.ts +28 -0
  16. package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
  17. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
  18. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
  19. package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
  20. package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
  21. package/telegram-plugin/gateway/status-pin-store.ts +33 -11
  22. package/telegram-plugin/gateway/stream-render.ts +578 -321
  23. package/telegram-plugin/registry/turns-schema.ts +21 -1
  24. package/telegram-plugin/retry-api-call.ts +46 -21
  25. package/telegram-plugin/session-tail.ts +13 -0
  26. package/telegram-plugin/shared/bot-runtime.ts +61 -17
  27. package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
  28. package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
  29. package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
  30. package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
  31. package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
  32. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
  33. package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
  34. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
  35. package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
  36. package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
  37. package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
  38. package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
  39. package/telegram-plugin/tests/obligation-store.test.ts +67 -1
  40. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  41. package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
  42. package/telegram-plugin/tests/stream-render-golden.test.ts +20 -5
  43. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
  44. package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +196 -0
  45. package/telegram-plugin/tests/turn-mint-harness.ts +155 -0
  46. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +124 -0
  47. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
  48. package/telegram-plugin/tool-activity-summary.ts +104 -38
  49. package/telegram-plugin/worker-activity-feed.ts +33 -16
  50. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
  51. package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
@@ -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,161 @@
1
+ /**
2
+ * Wire-level outcome tests for the `tg-post` logger's error shape (#3927).
3
+ *
4
+ * The bug: grammY resolves its transformer chain with the raw `ApiResponse`
5
+ * and only converts `{ok:false}` into a thrown `GrammyError` AFTER the chain
6
+ * returns (grammy `out/core/client.js`, `callApi`). Every Telegram-level
7
+ * rejection — 429 flood-wait, 400, 403 — therefore reached
8
+ * `installTgPostLogger` as a RESOLVED value and was logged
9
+ * `status=ok err=- code=- desc=-`. Only transport `HttpError`s ever hit the
10
+ * `catch`. A rate-limited `unpinAllChatMessages` on clerk logged as SUCCESS
11
+ * 3ms before the retry policy logged `429 rate limited, waiting 3s`.
12
+ *
13
+ * These tests MUST go through a REAL grammy `Bot` with a stubbed transport
14
+ * (the same reason `rich-markdown-guard-transformer.test.ts` does): the
15
+ * mock-`api` harnesses sit ABOVE the transformer layer, so `api.config.use`
16
+ * transformers never run there and a test written through them would be a
17
+ * false guard. Stubbing `client.fetch` with an `ok:false` envelope is the
18
+ * only way to reproduce the exact resolved-rejection path.
19
+ */
20
+ import { describe, it, expect, vi, afterEach } from 'vitest'
21
+ import { Bot } from 'grammy'
22
+ import { installTgPostLogger } from '../shared/bot-runtime.js'
23
+
24
+ /**
25
+ * A real Bot whose transport always answers with the given Telegram
26
+ * envelope. HTTP status stays 200 — a Bot API rejection is a 200 response
27
+ * carrying `ok:false`, which is precisely why it resolves the chain.
28
+ */
29
+ function makeBot(envelope: Record<string, unknown>): Bot {
30
+ const fakeFetch = (async () =>
31
+ ({
32
+ ok: true,
33
+ status: 200,
34
+ json: async () => envelope,
35
+ }) as unknown as Response) as unknown as typeof fetch
36
+
37
+ const bot = new Bot('123456:TEST_TOKEN', {
38
+ botInfo: {
39
+ id: 123456,
40
+ is_bot: true,
41
+ first_name: 'Test',
42
+ username: 'test_bot',
43
+ can_join_groups: false,
44
+ can_read_all_group_messages: false,
45
+ supports_inline_queries: false,
46
+ can_connect_to_business: false,
47
+ has_main_web_app: false,
48
+ },
49
+ client: { fetch: fakeFetch },
50
+ })
51
+ installTgPostLogger(bot)
52
+ return bot
53
+ }
54
+
55
+ /** Run one API call against the stub and return every tg-post line emitted. */
56
+ async function tgPostLinesFor(envelope: Record<string, unknown>): Promise<string[]> {
57
+ const written: string[] = []
58
+ const spy = vi
59
+ .spyOn(process.stderr, 'write')
60
+ .mockImplementation(((chunk: unknown) => {
61
+ written.push(String(chunk))
62
+ return true
63
+ }) as unknown as typeof process.stderr.write)
64
+ try {
65
+ const bot = makeBot(envelope)
66
+ // grammy converts the ok:false envelope into a thrown GrammyError once
67
+ // the transformer chain has returned — expected, and not what we assert.
68
+ await bot.api.sendMessage(4242, 'hello').catch(() => undefined)
69
+ } finally {
70
+ spy.mockRestore()
71
+ }
72
+ return written.filter(l => l.startsWith('tg-post '))
73
+ }
74
+
75
+ afterEach(() => {
76
+ vi.restoreAllMocks()
77
+ })
78
+
79
+ describe('tg-post logger — resolved ok:false responses (#3927)', () => {
80
+ it('logs a 429 flood-wait as status=err code=429 (FAILS before the fix: logged status=ok)', async () => {
81
+ const lines = await tgPostLinesFor({
82
+ ok: false,
83
+ error_code: 429,
84
+ description: 'Too Many Requests: retry after 3',
85
+ parameters: { retry_after: 3 },
86
+ })
87
+
88
+ expect(lines).toHaveLength(1)
89
+ const line = lines[0]
90
+ expect(line).toContain('status=err')
91
+ expect(line).toContain('code=429')
92
+ expect(line).toContain('err=telegram_429')
93
+ expect(line).toContain('desc=Too Many Requests: retry after 3')
94
+ // The precise regression: it must NOT claim success.
95
+ expect(line).not.toContain('status=ok')
96
+ })
97
+
98
+ it('logs a non-benign 400 as status=err carrying the rejection reason', async () => {
99
+ const lines = await tgPostLinesFor({
100
+ ok: false,
101
+ error_code: 400,
102
+ description: "Bad Request: can't parse entities: unsupported start tag",
103
+ })
104
+
105
+ expect(lines).toHaveLength(1)
106
+ expect(lines[0]).toContain('status=err')
107
+ expect(lines[0]).toContain('code=400')
108
+ expect(lines[0]).toContain("desc=Bad Request: can't parse entities")
109
+ })
110
+
111
+ it('logs a 403 as status=err', async () => {
112
+ const lines = await tgPostLinesFor({
113
+ ok: false,
114
+ error_code: 403,
115
+ description: 'Forbidden: bot was blocked by the user',
116
+ })
117
+
118
+ expect(lines).toHaveLength(1)
119
+ expect(lines[0]).toContain('status=err')
120
+ expect(lines[0]).toContain('code=403')
121
+ })
122
+ })
123
+
124
+ describe('tg-post logger — benign 400s do not become error noise (#3927)', () => {
125
+ // Promoting every ok:false to status=err would flood the log with the
126
+ // high-volume no-ops the retry policy already swallows, burying the signal
127
+ // this change exists to create. They get their own `benign` tier instead:
128
+ // still logged (same volume as the status=ok line they replace), never
129
+ // matched by `grep status=err`.
130
+ const benign: Array<[string, string]> = [
131
+ ['not modified', 'Bad Request: message is not modified'],
132
+ ['edit target gone', 'Bad Request: message to edit not found'],
133
+ ['delete target gone', 'Bad Request: message to delete not found'],
134
+ ]
135
+
136
+ for (const [label, description] of benign) {
137
+ it(`logs "${label}" as status=benign, never status=err`, async () => {
138
+ const lines = await tgPostLinesFor({ ok: false, error_code: 400, description })
139
+
140
+ expect(lines).toHaveLength(1)
141
+ expect(lines[0]).toContain('status=benign')
142
+ expect(lines[0]).not.toContain('status=err')
143
+ // Still honest about what happened — the reason is on the line.
144
+ expect(lines[0]).toContain('code=400')
145
+ expect(lines[0]).toContain('desc=Bad Request: message')
146
+ })
147
+ }
148
+ })
149
+
150
+ describe('tg-post logger — success is unchanged', () => {
151
+ it('still logs a genuine ok:true response as status=ok with empty error fields', async () => {
152
+ const lines = await tgPostLinesFor({
153
+ ok: true,
154
+ result: { message_id: 7, date: 0, chat: { id: 4242, type: 'private' } },
155
+ })
156
+
157
+ expect(lines).toHaveLength(1)
158
+ expect(lines[0]).toContain('status=ok')
159
+ expect(lines[0]).toContain('err=- code=- desc=-')
160
+ })
161
+ })
@@ -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
+