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.
- package/dist/cli/switchroom.js +1121 -584
- package/dist/host-control/main.js +151 -73
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -1
- package/profiles/_base/start.sh.hbs +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +3 -0
- package/telegram-plugin/dist/gateway/gateway.js +1660 -677
- package/telegram-plugin/dist/server.js +3 -0
- package/telegram-plugin/edit-flood-fuse.ts +70 -20
- package/telegram-plugin/gateway/boot-beacon.ts +364 -0
- package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
- package/telegram-plugin/gateway/gateway.ts +87 -88
- package/telegram-plugin/gateway/inbound-spool.ts +39 -0
- package/telegram-plugin/gateway/narrative-lane.ts +12 -0
- package/telegram-plugin/gateway/obligation-store.ts +28 -0
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
- package/telegram-plugin/gateway/status-pin-store.ts +33 -11
- package/telegram-plugin/gateway/stream-render.ts +578 -321
- package/telegram-plugin/registry/turns-schema.ts +21 -1
- package/telegram-plugin/retry-api-call.ts +46 -21
- package/telegram-plugin/session-tail.ts +13 -0
- package/telegram-plugin/shared/bot-runtime.ts +61 -17
- package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
- package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
- package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
- package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
- package/telegram-plugin/tests/obligation-store.test.ts +67 -1
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +20 -5
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
- 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
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
- package/telegram-plugin/tool-activity-summary.ts +104 -38
- package/telegram-plugin/worker-activity-feed.ts +33 -16
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
- 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 {
|
|
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,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
|
+
|