switchroom 0.18.32 → 0.19.0
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/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/gateway.ts +1347 -6758
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +10 -4
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-reconcile perf regression: at boot the subagent-watcher must NOT do the
|
|
3
|
+
* full initial `readSubTail` (a whole-transcript parse + per-record sqlite
|
|
4
|
+
* liveness/backfill round-trips) for provably-dead prior-session workers.
|
|
5
|
+
*
|
|
6
|
+
* Production incident (2026-07-19): on a busy 24/7 agent ~1177 dead `running`
|
|
7
|
+
* JSONLs (last written days-to-weeks ago) each got the full register → whole-
|
|
8
|
+
* file read → liveness/backfill → staleness → FSWatcher-decision treatment on
|
|
9
|
+
* every boot. On a slow disk that O(filesize) work delayed the gateway
|
|
10
|
+
* answering Telegram by ~7 minutes after each restart, so the agent looked
|
|
11
|
+
* dead. The staleness verdict (`last write > inflightPromoteMaxAgeMs`) was
|
|
12
|
+
* already reached, but only AFTER the expensive read.
|
|
13
|
+
*
|
|
14
|
+
* The fix replaces the full read for a stale-by-mtime historical entry with a
|
|
15
|
+
* BOUNDED TAIL PROBE. Outcomes asserted here (not code paths):
|
|
16
|
+
* 1. running-at-boot-stale (no turn_end): registered historical, NO watcher,
|
|
17
|
+
* NOT swept (nothing terminal to sweep), and the disk read is bounded to
|
|
18
|
+
* the tail window even for a huge file (never a byte-0 whole-transcript
|
|
19
|
+
* read).
|
|
20
|
+
* 2. done-at-boot-stale (turn_end present): still reaches terminal cleanup —
|
|
21
|
+
* `onTerminalCleanup` fires after the grace window so the worker-feed row
|
|
22
|
+
* is swept (the regression the coordinator caught: a done-at-boot orphan
|
|
23
|
+
* must NOT be silently left as a ghost feed row).
|
|
24
|
+
* 3. fresh in-flight-at-boot: takes the full path — its JSONL IS read from
|
|
25
|
+
* byte 0 and it DOES get a per-file FSWatcher (no over-pruning).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
29
|
+
import type * as fs from 'fs'
|
|
30
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
31
|
+
|
|
32
|
+
interface FakeWatcher { path: string; close: ReturnType<typeof vi.fn> }
|
|
33
|
+
interface ReadCall { position: number; length: number }
|
|
34
|
+
|
|
35
|
+
const AGENT_DIR = '/home/user/.switchroom/agents/myagent'
|
|
36
|
+
const PROJECTS = `${AGENT_DIR}/.claude/projects`
|
|
37
|
+
const PROJECT = `${PROJECTS}/proj`
|
|
38
|
+
const SESSION = `${PROJECT}/sess`
|
|
39
|
+
const SUBAGENTS = `${SESSION}/subagents`
|
|
40
|
+
const NOW = 100_000_000
|
|
41
|
+
|
|
42
|
+
const turnEndLine = () => JSON.stringify({ type: 'system', subtype: 'turn_duration', duration_ms: 100 })
|
|
43
|
+
const toolUseLine = () =>
|
|
44
|
+
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: {} }] } })
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Harness with per-path read tracking (position + length of every readSync, the
|
|
48
|
+
* proxy for "how much of the transcript did boot read") and FSWatcher tracking.
|
|
49
|
+
* `reportedSize` can far exceed the synthetic tail content, so a test can assert
|
|
50
|
+
* that only the tail window — not the whole (huge) file — was read.
|
|
51
|
+
*/
|
|
52
|
+
function makeHarness(opts: {
|
|
53
|
+
fileName: string
|
|
54
|
+
ageMs: number
|
|
55
|
+
reportedSize: number
|
|
56
|
+
/** Synthetic bytes returned when the file is read (represents its tail). */
|
|
57
|
+
tail: Buffer
|
|
58
|
+
}) {
|
|
59
|
+
const jsonl = `${SUBAGENTS}/${opts.fileName}`
|
|
60
|
+
const reads: ReadCall[] = []
|
|
61
|
+
const watchers: FakeWatcher[] = []
|
|
62
|
+
const timeouts: Array<{ fn: () => void; fireAt: number; ref: number }> = []
|
|
63
|
+
const terminalCleanups: string[] = []
|
|
64
|
+
let currentTime = NOW
|
|
65
|
+
let nextRef = 1
|
|
66
|
+
|
|
67
|
+
const mockFs = {
|
|
68
|
+
existsSync: ((p: fs.PathLike) => {
|
|
69
|
+
const ps = String(p)
|
|
70
|
+
return ps === PROJECTS || ps === PROJECT || ps === SESSION || ps === SUBAGENTS || ps === jsonl
|
|
71
|
+
}) as typeof fs.existsSync,
|
|
72
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
73
|
+
const ps = String(p)
|
|
74
|
+
if (ps === PROJECTS) return ['proj']
|
|
75
|
+
if (ps === PROJECT) return ['sess']
|
|
76
|
+
if (ps === SESSION) return ['subagents']
|
|
77
|
+
if (ps === SUBAGENTS) return [opts.fileName]
|
|
78
|
+
return []
|
|
79
|
+
}) as unknown as typeof fs.readdirSync,
|
|
80
|
+
statSync: ((p: fs.PathLike) => {
|
|
81
|
+
if (String(p) !== jsonl) return { size: 0, mtimeMs: currentTime } as fs.Stats
|
|
82
|
+
return { size: opts.reportedSize, mtimeMs: currentTime - opts.ageMs } as fs.Stats
|
|
83
|
+
}) as typeof fs.statSync,
|
|
84
|
+
openSync: (() => 7) as unknown as typeof fs.openSync,
|
|
85
|
+
closeSync: (() => undefined) as typeof fs.closeSync,
|
|
86
|
+
readSync: ((
|
|
87
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
88
|
+
): number => {
|
|
89
|
+
reads.push({ position: position ?? 0, length })
|
|
90
|
+
const src = opts.tail.subarray(0, Math.min(length, opts.tail.length))
|
|
91
|
+
src.copy(buf as Buffer, offset)
|
|
92
|
+
return src.length
|
|
93
|
+
}) as unknown as typeof fs.readSync,
|
|
94
|
+
watch: ((p: fs.PathLike) => {
|
|
95
|
+
const w: FakeWatcher = { path: String(p), close: vi.fn() }
|
|
96
|
+
watchers.push(w)
|
|
97
|
+
return w as unknown as fs.FSWatcher
|
|
98
|
+
}) as unknown as typeof fs.watch,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const watcher = startSubagentWatcher({
|
|
102
|
+
agentDir: AGENT_DIR,
|
|
103
|
+
fs: mockFs,
|
|
104
|
+
now: () => currentTime,
|
|
105
|
+
setInterval: () => ({ ref: 0 }),
|
|
106
|
+
clearInterval: () => {},
|
|
107
|
+
setTimeout: (fn: () => void, ms: number) => {
|
|
108
|
+
const ref = nextRef++
|
|
109
|
+
timeouts.push({ fn, fireAt: currentTime + ms, ref })
|
|
110
|
+
return { ref }
|
|
111
|
+
},
|
|
112
|
+
clearTimeout: (handle) => {
|
|
113
|
+
const { ref } = handle as { ref: number }
|
|
114
|
+
const idx = timeouts.findIndex((t) => t.ref === ref)
|
|
115
|
+
if (idx !== -1) timeouts.splice(idx, 1)
|
|
116
|
+
},
|
|
117
|
+
onTerminalCleanup: (agentId: string) => { terminalCleanups.push(agentId) },
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const advance = (ms: number): void => {
|
|
121
|
+
currentTime += ms
|
|
122
|
+
for (;;) {
|
|
123
|
+
timeouts.sort((a, b) => a.fireAt - b.fireAt)
|
|
124
|
+
const next = timeouts[0]
|
|
125
|
+
if (!next || next.fireAt > currentTime) break
|
|
126
|
+
timeouts.shift()
|
|
127
|
+
next.fn()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
watcher,
|
|
133
|
+
reads,
|
|
134
|
+
advance,
|
|
135
|
+
terminalCleanups,
|
|
136
|
+
jsonl,
|
|
137
|
+
fileWatchers: () => watchers.filter((w) => w.path === jsonl),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe('subagent-watcher boot reconcile: skip full read for dead prior-session workers', () => {
|
|
142
|
+
it('running-at-boot-stale: historical, no watcher, bounded tail read (not whole transcript), not swept', () => {
|
|
143
|
+
const agentId = 'deadrunning'
|
|
144
|
+
// A huge (50 MB) JSONL last written 3 days ago whose tail has NO turn_end.
|
|
145
|
+
const tail = Buffer.from(`${toolUseLine()}\n${toolUseLine()}\n${toolUseLine()}\n`, 'utf-8')
|
|
146
|
+
const h = makeHarness({
|
|
147
|
+
fileName: `agent-${agentId}.jsonl`,
|
|
148
|
+
ageMs: 3 * 24 * 3600_000,
|
|
149
|
+
reportedSize: 50_000_000,
|
|
150
|
+
tail,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const entry = h.watcher.getRegistry().get(agentId)
|
|
154
|
+
expect(entry?.historical).toBe(true)
|
|
155
|
+
expect(entry?.state).toBe('running') // never promoted, never done
|
|
156
|
+
expect(h.fileWatchers()).toHaveLength(0) // no FD leak for a dead worker
|
|
157
|
+
expect(h.terminalCleanups).not.toContain(agentId) // nothing terminal to sweep
|
|
158
|
+
|
|
159
|
+
// The disk read was BOUNDED to the tail window — the 50 MB file was NEVER
|
|
160
|
+
// read from byte 0 (that whole-transcript read is the ~7-min boot cost).
|
|
161
|
+
expect(h.reads.length).toBeGreaterThan(0)
|
|
162
|
+
for (const r of h.reads) {
|
|
163
|
+
expect(r.position).toBeGreaterThan(0) // tail offset, not byte 0
|
|
164
|
+
expect(r.length).toBeLessThanOrEqual(512 * 1024)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
h.watcher.stop()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('done-at-boot-stale: still reaches terminal cleanup (onTerminalCleanup fires → feed row swept)', () => {
|
|
171
|
+
const agentId = 'deaddone'
|
|
172
|
+
// A stale JSONL whose tail ends in a turn_duration line → done at boot.
|
|
173
|
+
const tail = Buffer.from(`${toolUseLine()}\n${turnEndLine()}\n`, 'utf-8')
|
|
174
|
+
const h = makeHarness({
|
|
175
|
+
fileName: `agent-${agentId}.jsonl`,
|
|
176
|
+
ageMs: 3 * 24 * 3600_000,
|
|
177
|
+
reportedSize: 50_000_000,
|
|
178
|
+
tail,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const entry = h.watcher.getRegistry().get(agentId)
|
|
182
|
+
expect(entry?.historical).toBe(true)
|
|
183
|
+
expect(entry?.state).toBe('done') // detected terminal via the probe
|
|
184
|
+
expect(h.fileWatchers()).toHaveLength(0) // no watcher for a done-at-boot worker
|
|
185
|
+
expect(h.terminalCleanups).toHaveLength(0) // not yet — waits out the grace window
|
|
186
|
+
|
|
187
|
+
// Fire the scheduled terminal cleanup (30s grace) → onTerminalCleanup so the
|
|
188
|
+
// gateway sweeps the ghost feed row. THIS is the regression guard.
|
|
189
|
+
h.advance(30_000)
|
|
190
|
+
expect(h.terminalCleanups).toContain(agentId)
|
|
191
|
+
|
|
192
|
+
// Still bounded — the probe read only the tail, never the whole 50 MB file.
|
|
193
|
+
for (const r of h.reads) {
|
|
194
|
+
expect(r.position).toBeGreaterThan(0)
|
|
195
|
+
expect(r.length).toBeLessThanOrEqual(512 * 1024)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
h.watcher.stop()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('fresh in-flight-at-boot: full path — read from byte 0 and given an FSWatcher (no over-pruning)', () => {
|
|
202
|
+
const agentId = 'liveone'
|
|
203
|
+
const tail = Buffer.from(`${toolUseLine()}\n`, 'utf-8')
|
|
204
|
+
const h = makeHarness({
|
|
205
|
+
fileName: `agent-${agentId}.jsonl`,
|
|
206
|
+
ageMs: 30_000, // 30s old — inside the freshness window
|
|
207
|
+
reportedSize: tail.length,
|
|
208
|
+
tail,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Full path: the JSONL is read from byte 0 (the whole transcript) and a
|
|
212
|
+
// per-file FSWatcher is opened so live transitions are observed.
|
|
213
|
+
expect(h.reads.some((r) => r.position === 0)).toBe(true)
|
|
214
|
+
expect(h.fileWatchers()).toHaveLength(1)
|
|
215
|
+
|
|
216
|
+
h.watcher.stop()
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -59,6 +59,7 @@ function makeHarness(opts: { agentId?: string } = {}) {
|
|
|
59
59
|
const progressCalls: ProgressCall[] = []
|
|
60
60
|
const finishCalls: Array<{ agentId: string; outcome: string }> = []
|
|
61
61
|
const terminalCleanupCalls: string[] = []
|
|
62
|
+
const resumeCalls: Array<{ agentId: string; description: string }> = []
|
|
62
63
|
const logs: string[] = []
|
|
63
64
|
|
|
64
65
|
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
@@ -133,6 +134,7 @@ function makeHarness(opts: { agentId?: string } = {}) {
|
|
|
133
134
|
progressCalls.push({ agentId: id, skeleton: skeleton === true, progressLine, toolCount }),
|
|
134
135
|
onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
|
|
135
136
|
onTerminalCleanup: (id) => terminalCleanupCalls.push(id),
|
|
137
|
+
onResume: (id, description) => resumeCalls.push({ agentId: id, description }),
|
|
136
138
|
now: () => currentTime,
|
|
137
139
|
setInterval: (fn, ms) => {
|
|
138
140
|
const ref = nextRef++
|
|
@@ -191,6 +193,7 @@ function makeHarness(opts: { agentId?: string } = {}) {
|
|
|
191
193
|
progressCalls,
|
|
192
194
|
finishCalls,
|
|
193
195
|
terminalCleanupCalls,
|
|
196
|
+
resumeCalls,
|
|
194
197
|
logs,
|
|
195
198
|
advance,
|
|
196
199
|
watcher,
|
|
@@ -240,6 +243,13 @@ describe('subagent-watcher resume re-registration (issue #3315)', () => {
|
|
|
240
243
|
expect(revived!.historical).toBe(false)
|
|
241
244
|
expect(h.logs.some((l) => l.includes('resumed after terminal cleanup') && l.includes(agentId))).toBe(true)
|
|
242
245
|
|
|
246
|
+
// Issue #3373: onResume fired exactly once for the resumed worker, BEFORE
|
|
247
|
+
// registration re-fires onProgress — this is what clears the feed's terminal
|
|
248
|
+
// `finalized` latch so the resumed cues re-surface a card instead of being
|
|
249
|
+
// swallowed. (The feed-side re-surface behaviour is covered in
|
|
250
|
+
// worker-activity-feed.test.ts.)
|
|
251
|
+
expect(h.resumeCalls.filter((c) => c.agentId === agentId)).toHaveLength(1)
|
|
252
|
+
|
|
243
253
|
// Progress cues resume (the card updates again) — the user-visible fix.
|
|
244
254
|
expect(h.progressCalls.length).toBeGreaterThan(progressBeforeResume)
|
|
245
255
|
expect(h.progressCalls.slice(progressBeforeResume).some((c) => c.agentId === agentId)).toBe(true)
|
|
@@ -285,6 +295,10 @@ describe('subagent-watcher resume re-registration (issue #3315)', () => {
|
|
|
285
295
|
expect(h.progressCalls).toHaveLength(progressAfterCleanup) // no card churn
|
|
286
296
|
// Cleanup fired exactly once — no leaked/duplicated terminal sweep.
|
|
287
297
|
expect(h.terminalCleanupCalls.filter((id) => id === agentId)).toHaveLength(1)
|
|
298
|
+
// Issue #3373 anti-zombie: a genuinely-finished worker that never grew past
|
|
299
|
+
// its terminal boundary must NEVER fire onResume — otherwise the feed's
|
|
300
|
+
// finalized latch would be cleared and a dead worker could zombie-resurface.
|
|
301
|
+
expect(h.resumeCalls.filter((c) => c.agentId === agentId)).toHaveLength(0)
|
|
288
302
|
|
|
289
303
|
h.watcher.stop()
|
|
290
304
|
})
|
|
@@ -647,6 +647,36 @@ describe('startSubagentWatcher', () => {
|
|
|
647
647
|
h.poll()
|
|
648
648
|
// The draft is staged, not yet resolved — no cue yet.
|
|
649
649
|
// The very next event is a reply tool with matching text → SUPPRESS.
|
|
650
|
+
// #3231: feed the PREFIXED prod wire shape (mcp__…__stream_reply), the
|
|
651
|
+
// form real jsonl actually carries. Before the isReplyTool fix a bare
|
|
652
|
+
// REPLY_TOOLS.has() missed this and the draft was WRONGLY surfaced.
|
|
653
|
+
appendFileSync(jsonlPath, buildJSONL({
|
|
654
|
+
type: 'assistant',
|
|
655
|
+
message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
|
|
656
|
+
}))
|
|
657
|
+
h.poll()
|
|
658
|
+
expect(narrativeCues.length).toBe(0)
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it('narrative gate: a draft-then-reply sub_agent_text is SUPPRESSED with a BARE reply tool name too (#3231)', () => {
|
|
662
|
+
// Symmetric with the prefixed case above: bare 'stream_reply' can still
|
|
663
|
+
// arrive from non-MCP sources, so isReplyTool must match it as well.
|
|
664
|
+
const narrativeCues: string[] = []
|
|
665
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
666
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
667
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
668
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
669
|
+
const h = startWatcherSync({
|
|
670
|
+
agentDir,
|
|
671
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
672
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
673
|
+
},
|
|
674
|
+
})
|
|
675
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo path')))
|
|
676
|
+
h.poll()
|
|
677
|
+
const answer = 'The repo is at /home/user/code/switchroom.'
|
|
678
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText(answer)))
|
|
679
|
+
h.poll()
|
|
650
680
|
appendFileSync(jsonlPath, buildJSONL({
|
|
651
681
|
type: 'assistant',
|
|
652
682
|
message: { content: [{ type: 'tool_use', name: 'stream_reply', id: 'r1', input: { text: answer } }] },
|
|
@@ -786,10 +816,11 @@ describe('startSubagentWatcher', () => {
|
|
|
786
816
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
|
|
787
817
|
h.poll()
|
|
788
818
|
const answer = 'The fix touches three files and adds a unit test for the double-Done case.'
|
|
789
|
-
// Final tool of the turn is stream_reply carrying the answer.
|
|
819
|
+
// Final tool of the turn is stream_reply carrying the answer. #3231: use
|
|
820
|
+
// the PREFIXED prod wire shape so lastReplyText capture (isReplyTool) fires.
|
|
790
821
|
appendFileSync(jsonlPath, buildJSONL({
|
|
791
822
|
type: 'assistant',
|
|
792
|
-
message: { content: [{ type: 'tool_use', name: '
|
|
823
|
+
message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
|
|
793
824
|
}))
|
|
794
825
|
h.poll()
|
|
795
826
|
// Trailing text block (separate message) that drafts the delivered answer.
|
|
@@ -819,9 +850,10 @@ describe('startSubagentWatcher', () => {
|
|
|
819
850
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
|
|
820
851
|
h.poll()
|
|
821
852
|
const answer = 'The fix touches three files and adds a unit test for the double-Done case.'
|
|
853
|
+
// #3231: prefixed prod wire shape.
|
|
822
854
|
appendFileSync(jsonlPath, buildJSONL({
|
|
823
855
|
type: 'assistant',
|
|
824
|
-
message: { content: [{ type: 'tool_use', name: '
|
|
856
|
+
message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
|
|
825
857
|
}))
|
|
826
858
|
h.poll()
|
|
827
859
|
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('Done — cleaning up the worktree now.')))
|
|
@@ -261,6 +261,19 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
|
|
|
261
261
|
new URL('../gateway/gateway.ts', import.meta.url),
|
|
262
262
|
'utf8',
|
|
263
263
|
)
|
|
264
|
+
// #2996 P4-A: the turn_flush backstop (redact → normalize → scrub on
|
|
265
|
+
// `capturedText`) moved VERBATIM into stream-render.ts with
|
|
266
|
+
// handleSessionEvent. The reply path's identical `...(text))` wrapper lives
|
|
267
|
+
// in outbound-send-path.ts (`normalizeOutboundBody`, #2996 P2). Parity is
|
|
268
|
+
// pinned across the two extracted homes.
|
|
269
|
+
const streamSrc = readFileSync(
|
|
270
|
+
new URL('../gateway/stream-render.ts', import.meta.url),
|
|
271
|
+
'utf8',
|
|
272
|
+
)
|
|
273
|
+
const replyModuleSrc = readFileSync(
|
|
274
|
+
new URL('../gateway/outbound-send-path.ts', import.meta.url),
|
|
275
|
+
'utf8',
|
|
276
|
+
)
|
|
264
277
|
|
|
265
278
|
it('reply path: normalizes AFTER redact and BEFORE the voice scrub', () => {
|
|
266
279
|
// #2996: the reply-path entry pipeline moved into outbound-send-path.ts
|
|
@@ -290,13 +303,13 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
|
|
|
290
303
|
})
|
|
291
304
|
|
|
292
305
|
it('turn-flush backstop: applies the SAME normalization in the SAME slot (REDS if the line is removed)', () => {
|
|
293
|
-
const redactIdx =
|
|
306
|
+
const redactIdx = streamSrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
|
|
294
307
|
// The normalization call the reply path uses, verbatim, on the turn_flush
|
|
295
308
|
// variable. This indexOf is what returns -1 (→ assertion fails) if the
|
|
296
309
|
// `stripExcessBold(normalizePunctuation(capturedText))` line is deleted
|
|
297
310
|
// from the turn_flush branch.
|
|
298
|
-
const normIdx =
|
|
299
|
-
const scrubIdx =
|
|
311
|
+
const normIdx = streamSrc.indexOf('stripExcessBold(normalizePunctuation(capturedText))', redactIdx)
|
|
312
|
+
const scrubIdx = streamSrc.indexOf('scrubVoice(capturedText)', redactIdx)
|
|
300
313
|
expect(redactIdx).toBeGreaterThan(0)
|
|
301
314
|
expect(normIdx).toBeGreaterThan(redactIdx) // normalize AFTER the turn_flush redact
|
|
302
315
|
expect(scrubIdx).toBeGreaterThan(normIdx) // ...and BEFORE the voice scrub — mirrors reply
|
|
@@ -305,8 +318,8 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
|
|
|
305
318
|
it('both send sites share the identical `stripExcessBold(normalizePunctuation(` wrapper', () => {
|
|
306
319
|
// Parity, structurally: the exact normalization wrapper the reply path uses
|
|
307
320
|
// is the one the turn_flush branch uses — same call, not a lookalike.
|
|
308
|
-
expect(
|
|
309
|
-
expect(
|
|
321
|
+
expect(replyModuleSrc).toContain('stripExcessBold(normalizePunctuation(text))')
|
|
322
|
+
expect(streamSrc).toContain('stripExcessBold(normalizePunctuation(capturedText))')
|
|
310
323
|
})
|
|
311
324
|
|
|
312
325
|
// Behavioural coverage (kept from the original suite): reconstruct the
|
|
@@ -678,3 +691,168 @@ describe('selectFlushDeliveryText — deliver the terminal answer, strip only na
|
|
|
678
691
|
}
|
|
679
692
|
})
|
|
680
693
|
})
|
|
694
|
+
|
|
695
|
+
// ── #3237: STRUCTURAL block-provenance discriminator ───────────────────────
|
|
696
|
+
// The opener heuristic cannot tell a narration preamble from a real answer
|
|
697
|
+
// paragraph that merely opens with "Let me explain…". The structural flag
|
|
698
|
+
// (`followedByToolUse`, = `!lastInMessage`) can: a narration preamble is
|
|
699
|
+
// followed by a tool_use in its message; a terminal answer paragraph is not.
|
|
700
|
+
describe('selectFlushDeliveryText — structural provenance (followedByToolUse) #3237', () => {
|
|
701
|
+
// The exact real-answer shape from #3237: a genuine two-paragraph answer
|
|
702
|
+
// whose FIRST block opens with a narration phrase, written as plain text with
|
|
703
|
+
// NO tool calls (the model forgot the reply tool → flush path). Structure
|
|
704
|
+
// says neither block was followed by a tool_use, so nothing is narration.
|
|
705
|
+
const p1 =
|
|
706
|
+
'Let me explain how the flush path works: it fires at turn_end when the ' +
|
|
707
|
+
'model never called the reply tool, joining the captured assistant text ' +
|
|
708
|
+
'blocks with a paragraph break so the answer renders with real separation.'
|
|
709
|
+
const p2 =
|
|
710
|
+
'The terminal block is always delivered, so the answer is never fully ' +
|
|
711
|
+
'lost — but the opening paragraph could be dropped by the opener-only strip.'
|
|
712
|
+
|
|
713
|
+
it('#3237 regression: a real 2-paragraph answer opening with "Let me explain…" is NOT truncated when structure says neither block was followed by a tool_use', () => {
|
|
714
|
+
// With NO structural signal (legacy string[]) the opener strip DROPS p1.
|
|
715
|
+
expect(selectFlushDeliveryText([p1, p2])).toBe(p2) // documents the bug
|
|
716
|
+
// With structure present ([false,false]) the full answer is delivered.
|
|
717
|
+
const out = selectFlushDeliveryText([p1, p2], [false, false])
|
|
718
|
+
expect(out).toBe(`${p1}\n\n${p2}`)
|
|
719
|
+
expect(out).toContain('Let me explain how the flush path works')
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it('duplicate-reply fix preserved: a narration block FOLLOWED by a tool_use is stripped, terminal answer delivered', () => {
|
|
723
|
+
const narration = 'Let me pull the numbers together before I answer.'
|
|
724
|
+
const realAnswer = 'Revenue was 4.2M, up 12% year over year.'
|
|
725
|
+
// followedByToolUse[0]=true (the model drafted then acted), [1]=false (terminal).
|
|
726
|
+
const out = selectFlushDeliveryText([narration, realAnswer], [true, false])
|
|
727
|
+
expect(out).toBe(realAnswer)
|
|
728
|
+
expect(out).not.toContain('Let me pull the numbers')
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('structure alone drives the strip — a preamble the opener regex would MISS is still stripped when it was followed by a tool_use', () => {
|
|
732
|
+
// "Here are the figures" matches no NARRATION_OPENER, but a tool_use
|
|
733
|
+
// followed it → structural narration → stripped.
|
|
734
|
+
const preamble = 'Here are the figures you asked about.'
|
|
735
|
+
const realAnswer = 'The three services are all green.'
|
|
736
|
+
const out = selectFlushDeliveryText([preamble, realAnswer], [true, false])
|
|
737
|
+
expect(out).toBe(realAnswer)
|
|
738
|
+
expect(out).not.toContain('Here are the figures')
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it('structure WINS over the opener heuristic — a "Let me…" opener NOT followed by a tool_use is kept (never truncated)', () => {
|
|
742
|
+
const first = 'Let me walk you through the three steps in order.'
|
|
743
|
+
const second = 'Step one is to branch off fresh main.'
|
|
744
|
+
// Opener-only would strip `first`; structure ([false,false]) keeps it.
|
|
745
|
+
expect(selectFlushDeliveryText([first, second], [false, false])).toBe(
|
|
746
|
+
`${first}\n\n${second}`,
|
|
747
|
+
)
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
it('per-block fallback: an UNDEFINED entry falls back to the opener heuristic for that block only', () => {
|
|
751
|
+
const narration = 'Let me check that.'
|
|
752
|
+
const realAnswer = 'Done — all green.'
|
|
753
|
+
// meta[0] undefined → fall back to isNarrationBlock("Let me check that.") = true → stripped.
|
|
754
|
+
const out = selectFlushDeliveryText([narration, realAnswer], [undefined, false])
|
|
755
|
+
expect(out).toBe(realAnswer)
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it('flag-absent back-compat: omitting the meta array reproduces today\'s opener-strip EXACTLY', () => {
|
|
759
|
+
// Every legacy case behaves identically with no meta argument.
|
|
760
|
+
expect(selectFlushDeliveryText(['Let me check.', 'the answer'])).toBe('the answer')
|
|
761
|
+
expect(selectFlushDeliveryText(['Checking now…', 'The build is green.'])).toBe(
|
|
762
|
+
'The build is green.',
|
|
763
|
+
)
|
|
764
|
+
expect(selectFlushDeliveryText([p1, p2])).toBe(p2) // opener strip drops p1
|
|
765
|
+
const first = 'B'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 5)
|
|
766
|
+
const last = 'C'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 5)
|
|
767
|
+
expect(selectFlushDeliveryText([first, last])).toBe(`${first}\n\n${last}`)
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it('meta stays ALIGNED across the empty-block filter (zip-before-filter)', () => {
|
|
771
|
+
// An empty block between narration and answer must not shift the meta index.
|
|
772
|
+
const narration = 'Let me look that up.'
|
|
773
|
+
const realAnswer = 'The value is 42.'
|
|
774
|
+
// blocks[1] is empty (dropped); meta must still map [narration→true, ''→_, answer→false].
|
|
775
|
+
const out = selectFlushDeliveryText([narration, ' ', realAnswer], [true, false, false])
|
|
776
|
+
expect(out).toBe(realAnswer)
|
|
777
|
+
expect(out).not.toContain('Let me look that up')
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
it('cross-message shape: narration (msg1, tool_use after) + answer (msg2, terminal) → narration stripped', () => {
|
|
781
|
+
// lastInMessage semantics are per-message; a narration that is the last text
|
|
782
|
+
// of msg1 but has a tool_use after it in msg1 is followedByToolUse=true.
|
|
783
|
+
const narration = 'Let me search the codebase for the symbol.'
|
|
784
|
+
const realAnswer = 'It is defined in session-tail.ts.'
|
|
785
|
+
const out = selectFlushDeliveryText([narration, realAnswer], [true, false])
|
|
786
|
+
expect(out).toBe(realAnswer)
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
it('decideTurnFlush plumbs capturedBlockMeta through to the strip (#3237 end-to-end)', () => {
|
|
790
|
+
// Real-answer case: structure keeps both paragraphs.
|
|
791
|
+
const keep = decideTurnFlush({
|
|
792
|
+
chatId: 'chat1',
|
|
793
|
+
replyCalled: false,
|
|
794
|
+
capturedText: [p1, p2],
|
|
795
|
+
capturedBlockMeta: [false, false],
|
|
796
|
+
})
|
|
797
|
+
expect(keep.kind).toBe('flush')
|
|
798
|
+
if (keep.kind === 'flush') expect(keep.text).toBe(`${p1}\n\n${p2}`)
|
|
799
|
+
|
|
800
|
+
// Narration case: structure strips the preamble.
|
|
801
|
+
const strip = decideTurnFlush({
|
|
802
|
+
chatId: 'chat1',
|
|
803
|
+
replyCalled: false,
|
|
804
|
+
capturedText: ['Let me pull the numbers.', 'Revenue was 4.2M.'],
|
|
805
|
+
capturedBlockMeta: [true, false],
|
|
806
|
+
})
|
|
807
|
+
expect(strip.kind).toBe('flush')
|
|
808
|
+
if (strip.kind === 'flush') expect(strip.text).toBe('Revenue was 4.2M.')
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
// Regression for the post-#3338 review finding: a SUBSTANTIAL real-content
|
|
812
|
+
// block (≥ FLUSH_SUBSTANTIVE_MIN_CHARS, NOT narration-shaped) that happens to
|
|
813
|
+
// be followed by a tool_use must be KEPT — the model can write a real answer
|
|
814
|
+
// paragraph, then call a memory/verify tool, then a short wrap-up. Trusting
|
|
815
|
+
// followedByToolUse ALONE (the pre-fix behaviour) dropped the real answer and
|
|
816
|
+
// delivered only the wrap-up. The structural strip is gated by the substantive
|
|
817
|
+
// floor, so the substantial block survives.
|
|
818
|
+
it('substantial real-content block followed by a tool_use is KEPT (structural flag gated by the substantive floor)', () => {
|
|
819
|
+
const realAnswer =
|
|
820
|
+
'The migration completed cleanly across all five shards: each shard was ' +
|
|
821
|
+
'drained, the schema applied under an advisory lock, and traffic cut back ' +
|
|
822
|
+
'over with zero dropped requests. The new index brought the p99 query time ' +
|
|
823
|
+
'down from 840ms to 60ms, and the old columns are now safe to remove in ' +
|
|
824
|
+
'the next release once the read path is confirmed off them entirely.'
|
|
825
|
+
expect(realAnswer.length).toBeGreaterThanOrEqual(FLUSH_SUBSTANTIVE_MIN_CHARS)
|
|
826
|
+
// Not narration-shaped: no first-person "about to do X" opener, no
|
|
827
|
+
// trailing ellipsis/colon — so only the structural flag could strip it.
|
|
828
|
+
const wrapUp = 'Saved that to memory.'
|
|
829
|
+
// meta[0]=true (a memory tool_use followed the real answer), [1]=false.
|
|
830
|
+
const out = selectFlushDeliveryText([realAnswer, wrapUp], [true, false])
|
|
831
|
+
expect(out).toBe(`${realAnswer}\n\n${wrapUp}`)
|
|
832
|
+
expect(out).toContain('The migration completed cleanly')
|
|
833
|
+
|
|
834
|
+
// End-to-end through decideTurnFlush.
|
|
835
|
+
const decision = decideTurnFlush({
|
|
836
|
+
chatId: 'chat1',
|
|
837
|
+
replyCalled: false,
|
|
838
|
+
capturedText: [realAnswer, wrapUp],
|
|
839
|
+
capturedBlockMeta: [true, false],
|
|
840
|
+
})
|
|
841
|
+
expect(decision.kind).toBe('flush')
|
|
842
|
+
if (decision.kind === 'flush') {
|
|
843
|
+
expect(decision.text).toBe(`${realAnswer}\n\n${wrapUp}`)
|
|
844
|
+
}
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
// A SHORT block followed by a tool_use is still stripped via the length floor
|
|
848
|
+
// (the pre-#3237 "short preamble the opener misses" behaviour is preserved):
|
|
849
|
+
// it is below FLUSH_SUBSTANTIVE_MIN_CHARS, so the gated strip still fires.
|
|
850
|
+
it('short block followed by a tool_use is still stripped (length-floor branch of the gate)', () => {
|
|
851
|
+
const shortPreamble = 'Here are the figures you asked about.'
|
|
852
|
+
expect(shortPreamble.length).toBeLessThan(FLUSH_SUBSTANTIVE_MIN_CHARS)
|
|
853
|
+
const realAnswer = 'The three services are all green.'
|
|
854
|
+
const out = selectFlushDeliveryText([shortPreamble, realAnswer], [true, false])
|
|
855
|
+
expect(out).toBe(realAnswer)
|
|
856
|
+
expect(out).not.toContain('Here are the figures')
|
|
857
|
+
})
|
|
858
|
+
})
|
|
@@ -22,6 +22,11 @@ import { readFileSync } from 'node:fs'
|
|
|
22
22
|
import { resolve } from 'node:path'
|
|
23
23
|
|
|
24
24
|
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
25
|
+
// #2996 P4-A: the turn-flush / turn_end / `thinking`-arm code moved VERBATIM
|
|
26
|
+
// into stream-render.ts with handleSessionEvent. Windows that used to live in
|
|
27
|
+
// the gateway switch are grepped from the extracted module now.
|
|
28
|
+
const streamSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf-8')
|
|
29
|
+
const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc
|
|
25
30
|
|
|
26
31
|
function between(src: string, startMarker: string, endMarker: string): string {
|
|
27
32
|
const after = src.split(startMarker)[1] ?? ''
|
|
@@ -42,7 +47,7 @@ describe('S1 wiring — turn-flush suppression call site', () => {
|
|
|
42
47
|
// The whole suppression branch: predicate call through the end of the
|
|
43
48
|
// `if (suppress)` early-return.
|
|
44
49
|
const branch = between(
|
|
45
|
-
|
|
50
|
+
streamSrc,
|
|
46
51
|
'const { shouldSuppressTurnFlush }',
|
|
47
52
|
'// #3276 guard 5',
|
|
48
53
|
)
|
|
@@ -72,7 +77,7 @@ describe('S1 wiring — turn-flush suppression call site', () => {
|
|
|
72
77
|
it('the old predicate is gone from the whole gateway turn-flush path', () => {
|
|
73
78
|
// getRecentOutboundCount must not be consulted anywhere in gateway.ts —
|
|
74
79
|
// any reintroduction re-opens the chat-wide suppression class.
|
|
75
|
-
expect(codeOnly(
|
|
80
|
+
expect(codeOnly(gatewayAndStreamSrc)).not.toMatch(/getRecentOutboundCount/)
|
|
76
81
|
})
|
|
77
82
|
})
|
|
78
83
|
|
|
@@ -81,7 +86,7 @@ describe('S2 wiring — thinking events re-arm the answer-ready quiescence flush
|
|
|
81
86
|
// Without this, "prose → >1s thinking pause → trailing NO_REPLY" lets the
|
|
82
87
|
// quiescence timer fire mid-pause and deliver a turn the model intended
|
|
83
88
|
// silent. Scope to the thinking case arm only.
|
|
84
|
-
const arm = between(
|
|
89
|
+
const arm = between(streamSrc, "case 'thinking': {", "case 'tool_use': {")
|
|
85
90
|
expect(arm.length).toBeGreaterThan(50)
|
|
86
91
|
expect(codeOnly(arm)).toMatch(/resetAnswerReadyFlushTimeout\(\)/)
|
|
87
92
|
})
|
|
@@ -107,6 +112,6 @@ describe('S4 wiring — flushed answers quote-anchor to the inbound they answer'
|
|
|
107
112
|
it('the turn-flush call site passes the turn’s inbound id (bare for synthesized turns)', () => {
|
|
108
113
|
// The anchor must be the message this TURN answers — turn.sourceMessageId
|
|
109
114
|
// is null for cron/handback turns, which therefore still send bare.
|
|
110
|
-
expect(codeOnly(
|
|
115
|
+
expect(codeOnly(gatewayAndStreamSrc)).toMatch(/replyToMessageId:\s*turn\.sourceMessageId/)
|
|
111
116
|
})
|
|
112
117
|
})
|
|
@@ -48,7 +48,10 @@ import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
|
|
|
48
48
|
const gatewaySrc =
|
|
49
49
|
readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
|
|
50
50
|
'\n' +
|
|
51
|
-
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
|
|
51
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
|
|
52
|
+
'\n' +
|
|
53
|
+
// P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
|
|
54
|
+
readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-interceptors.ts'), 'utf-8')
|
|
52
55
|
|
|
53
56
|
function sliceAccessApproveBlock(): string {
|
|
54
57
|
const fn =
|
|
@@ -102,7 +105,10 @@ describe('handleVaultRequestAccessCallback — posture branch', () => {
|
|
|
102
105
|
expect(approveBlock).toMatch(/passphrase-for-access-approve/)
|
|
103
106
|
// Pinned: the queued-drain path passes the typed passphrase via
|
|
104
107
|
// the new attestation shape `{ kind: 'passphrase', passphrase }`.
|
|
105
|
-
|
|
108
|
+
// P7 PR-8: the queued-drain call site moved into interceptVault
|
|
109
|
+
// (inbound-interceptors.ts) — ctx is `p.ctx` and the handler resolves via
|
|
110
|
+
// the lazy deps accessor. Same attestation shape pinned.
|
|
111
|
+
expect(gatewaySrc).toMatch(/performVaultAccessApproval\((?:p\.)?ctx, stagedAccess, item\.stageId, item\.senderId, \{ kind: ['"]passphrase['"], passphrase \}\)/)
|
|
106
112
|
})
|
|
107
113
|
})
|
|
108
114
|
|
|
@@ -29,7 +29,10 @@ import { resolve } from "node:path";
|
|
|
29
29
|
const gatewaySrc =
|
|
30
30
|
readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
|
|
31
31
|
"\n" +
|
|
32
|
-
readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8")
|
|
32
|
+
readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8") +
|
|
33
|
+
"\n" +
|
|
34
|
+
// P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
|
|
35
|
+
readFileSync(resolve(__dirname, "..", "gateway", "inbound-interceptors.ts"), "utf-8");
|
|
33
36
|
|
|
34
37
|
function extractPerformBlock(): string {
|
|
35
38
|
const start = gatewaySrc.indexOf("async function performVaultAccessApproval");
|