switchroom 0.18.18 → 0.18.20
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/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +36 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1131 -285
- package/telegram-plugin/dist/server.js +6 -0
- package/telegram-plugin/format.ts +208 -125
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +800 -107
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/llm-error-present.ts +68 -30
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/subagent-watcher.ts +244 -81
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +39 -4
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
- package/telegram-plugin/tests/telegram-format.test.ts +72 -4
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +2 -1
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Residual A regression: a worker / sub-agent's OPENING narration must paint
|
|
3
|
+
* EARLY on its card (~the next poll after PENDING_NARRATIVE_FLUSH_MS) WITHOUT
|
|
4
|
+
* waiting for the worker's first tool — the same time-box the main-agent card
|
|
5
|
+
* got in #3227, applied uniformly to the poll-driven worker/sub-agent path via
|
|
6
|
+
* the SHARED NarrativeFlushController kernel driven by the injected poll clock.
|
|
7
|
+
*
|
|
8
|
+
* These assert OUTCOMES on the onProgress cue stream (the worker card is
|
|
9
|
+
* replace-on-write via onProgress), driven by an INJECTED clock so the timing
|
|
10
|
+
* is deterministic:
|
|
11
|
+
* - a parked opening narration fires a narrative cue after the flush window
|
|
12
|
+
* with NO tool event (RED if the early-paint tick is removed);
|
|
13
|
+
* - it fires at more than one entry (depth-generic — the registry is flat, so
|
|
14
|
+
* every sub-agent/worker/nested sub-worker is a WorkerEntry and the gate is
|
|
15
|
+
* per-entry);
|
|
16
|
+
* - the early-painted block is NOT re-fired (no double-print) and does not
|
|
17
|
+
* leave a stale duplicate when the first real tool step lands after it;
|
|
18
|
+
* - a tool arriving WITHIN the window still resolves the block exactly once
|
|
19
|
+
* (no early-paint + resolve double).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
23
|
+
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'fs'
|
|
24
|
+
import { tmpdir } from 'os'
|
|
25
|
+
import { join } from 'path'
|
|
26
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
27
|
+
import { PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
|
|
28
|
+
|
|
29
|
+
function buildJSONL(...lines: object[]): string {
|
|
30
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
31
|
+
}
|
|
32
|
+
function subAgentUserMsg(promptText: string) {
|
|
33
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
34
|
+
}
|
|
35
|
+
function subAgentAssistantText(text: string) {
|
|
36
|
+
return { type: 'assistant', message: { content: [{ type: 'text', text }] } }
|
|
37
|
+
}
|
|
38
|
+
function subAgentToolUse(name: string, id: string) {
|
|
39
|
+
return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Cue { progressLine?: string; latestSummary: string }
|
|
43
|
+
|
|
44
|
+
describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
|
|
45
|
+
let tmpRoot = ''
|
|
46
|
+
const started: Array<ReturnType<typeof startSubagentWatcher>> = []
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
while (started.length) {
|
|
50
|
+
try { started.pop()?.stop() } catch { /* ignore */ }
|
|
51
|
+
}
|
|
52
|
+
if (tmpRoot) {
|
|
53
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
54
|
+
tmpRoot = ''
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/** Watcher over a real tmpdir with an INJECTED, manually-advanced clock. */
|
|
59
|
+
function startWatcher(agentDir: string) {
|
|
60
|
+
let currentTime = 100_000
|
|
61
|
+
const cues: Array<{ agentId: string } & Cue> = []
|
|
62
|
+
const intervals: Array<{ fn: () => void; ref: number }> = []
|
|
63
|
+
let nextRef = 1
|
|
64
|
+
const watcher = startSubagentWatcher({
|
|
65
|
+
agentDir,
|
|
66
|
+
onFinish: () => {},
|
|
67
|
+
onProgress: ({ agentId, progressLine, latestSummary }) => {
|
|
68
|
+
cues.push({ agentId, progressLine, latestSummary })
|
|
69
|
+
},
|
|
70
|
+
stallThresholdMs: 600_000,
|
|
71
|
+
silentSynthesisStallThresholdMs: 600_000,
|
|
72
|
+
rescanMs: 1000,
|
|
73
|
+
now: () => currentTime,
|
|
74
|
+
setInterval: (fn) => { const ref = nextRef++; intervals.push({ fn, ref }); return { ref } },
|
|
75
|
+
clearInterval: (handle) => {
|
|
76
|
+
const { ref } = handle as { ref: number }
|
|
77
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
78
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
79
|
+
},
|
|
80
|
+
setTimeout: () => ({ ref: nextRef++ }),
|
|
81
|
+
clearTimeout: () => {},
|
|
82
|
+
log: () => {},
|
|
83
|
+
})
|
|
84
|
+
started.push(watcher)
|
|
85
|
+
return {
|
|
86
|
+
watcher,
|
|
87
|
+
cues,
|
|
88
|
+
poll: () => intervals[0]?.fn(),
|
|
89
|
+
advance: (ms: number) => { currentTime += ms },
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function narrativeCues(cues: Cue[]): string[] {
|
|
94
|
+
// Narrative cues carry NO progressLine; tool-label cues do.
|
|
95
|
+
return cues.filter((c) => c.progressLine == null).map((c) => c.latestSummary)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
it('paints a parked opening narration after the flush window with NO tool event', () => {
|
|
99
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-'))
|
|
100
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
101
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
102
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
103
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
104
|
+
|
|
105
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
106
|
+
const h = startWatcher(agentDir)
|
|
107
|
+
h.poll() // register + promote to live
|
|
108
|
+
|
|
109
|
+
// Opening narration arrives; the worker then "thinks" (no tool yet).
|
|
110
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — pulling the logs first.')))
|
|
111
|
+
h.poll() // parks the block, arms the early-paint timer — NO cue yet
|
|
112
|
+
expect(narrativeCues(h.cues), 'parked block must not paint before the window').toHaveLength(0)
|
|
113
|
+
|
|
114
|
+
// Advance past the flush window and poll again — STILL no new jsonl event.
|
|
115
|
+
h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
|
|
116
|
+
h.poll()
|
|
117
|
+
|
|
118
|
+
const narr = narrativeCues(h.cues)
|
|
119
|
+
expect(narr, 'opening narration must early-paint without a tool').toHaveLength(1)
|
|
120
|
+
expect(narr[0]).toContain('pulling the logs')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('does NOT paint before the flush window elapses', () => {
|
|
124
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-nofire-'))
|
|
125
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
126
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
127
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
128
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
129
|
+
|
|
130
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
131
|
+
const h = startWatcher(agentDir)
|
|
132
|
+
h.poll()
|
|
133
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — pulling the logs first.')))
|
|
134
|
+
h.poll()
|
|
135
|
+
|
|
136
|
+
// Advance LESS than the window — the tick must not fire yet.
|
|
137
|
+
h.advance(PENDING_NARRATIVE_FLUSH_MS - 50)
|
|
138
|
+
h.poll()
|
|
139
|
+
expect(narrativeCues(h.cues), 'must not paint before the window').toHaveLength(0)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('is depth-generic: two independent entries each early-paint their opening narration', () => {
|
|
143
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-depth-'))
|
|
144
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
145
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
146
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
147
|
+
// Two entries stand in for two nesting levels: the registry is FLAT, so a
|
|
148
|
+
// top worker and a nested depth-2 sub-worker are both plain WorkerEntry
|
|
149
|
+
// rows and take the identical per-entry gate — this proves the mechanism
|
|
150
|
+
// is depth-generic (it operates on the generic node, not a fixed level).
|
|
151
|
+
const aPath = join(subagentsDir, 'agent-aaaa0000.jsonl')
|
|
152
|
+
const bPath = join(subagentsDir, 'agent-bbbb1111.jsonl')
|
|
153
|
+
writeFileSync(aPath, buildJSONL(subAgentUserMsg('Level 1 task')))
|
|
154
|
+
writeFileSync(bPath, buildJSONL(subAgentUserMsg('Level 2 task')))
|
|
155
|
+
const h = startWatcher(agentDir)
|
|
156
|
+
h.poll()
|
|
157
|
+
appendFileSync(aPath, buildJSONL(subAgentAssistantText('Worker A: reading the config.')))
|
|
158
|
+
appendFileSync(bPath, buildJSONL(subAgentAssistantText('Worker B: cloning the repo.')))
|
|
159
|
+
h.poll()
|
|
160
|
+
expect(narrativeCues(h.cues)).toHaveLength(0)
|
|
161
|
+
|
|
162
|
+
h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
|
|
163
|
+
h.poll()
|
|
164
|
+
|
|
165
|
+
const aCues = h.cues.filter((c) => c.agentId === 'aaaa0000' && c.progressLine == null)
|
|
166
|
+
const bCues = h.cues.filter((c) => c.agentId === 'bbbb1111' && c.progressLine == null)
|
|
167
|
+
expect(aCues.map((c) => c.latestSummary).join(' ')).toContain('reading the config')
|
|
168
|
+
expect(bCues.map((c) => c.latestSummary).join(' ')).toContain('cloning the repo')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('does not double-print: an early-painted block is not re-shown when the first real tool lands', () => {
|
|
172
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-nodup-'))
|
|
173
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
174
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
175
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
176
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
177
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
178
|
+
const h = startWatcher(agentDir)
|
|
179
|
+
h.poll()
|
|
180
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — pulling the logs first.')))
|
|
181
|
+
h.poll()
|
|
182
|
+
h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
|
|
183
|
+
h.poll() // early-paint fires (1 narrative cue)
|
|
184
|
+
expect(narrativeCues(h.cues)).toHaveLength(1)
|
|
185
|
+
|
|
186
|
+
// Now the worker's first real tool lands. The parked block was already
|
|
187
|
+
// consumed by the timer → it must NOT re-fire as a narrative cue, and the
|
|
188
|
+
// tool-label cue is a SEPARATE step (no stale duplicate narration).
|
|
189
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
|
|
190
|
+
h.advance(10)
|
|
191
|
+
h.poll()
|
|
192
|
+
|
|
193
|
+
expect(narrativeCues(h.cues), 'narration must not be re-shown').toHaveLength(1)
|
|
194
|
+
const toolCues = h.cues.filter((c) => c.progressLine != null)
|
|
195
|
+
expect(toolCues.length, 'the tool step fires as its own cue').toBeGreaterThanOrEqual(1)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('a tool arriving WITHIN the window resolves the block exactly once (no early-paint + resolve double)', () => {
|
|
199
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'sr-early-paint-within-'))
|
|
200
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
201
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
202
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
203
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
204
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
|
|
205
|
+
const h = startWatcher(agentDir)
|
|
206
|
+
h.poll()
|
|
207
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('On it — let me find the repo.')))
|
|
208
|
+
h.poll()
|
|
209
|
+
// Tool lands well within the window → resolve shows it once; timer never fires.
|
|
210
|
+
h.advance(50)
|
|
211
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
|
|
212
|
+
h.poll()
|
|
213
|
+
// Advance past the window: the (already-consumed) block must not re-paint.
|
|
214
|
+
h.advance(PENDING_NARRATIVE_FLUSH_MS + 50)
|
|
215
|
+
h.poll()
|
|
216
|
+
expect(narrativeCues(h.cues), 'resolved narration shows exactly once').toHaveLength(1)
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -235,10 +235,6 @@ describe('splitMarkdownChunks', () => {
|
|
|
235
235
|
// chunker's normal space-boundary behaviour, orthogonal to spacers.)
|
|
236
236
|
const words = (s: string): string[] =>
|
|
237
237
|
s.split(/\s+/).filter((w) => w.length > 0 && w !== PARAGRAPH_SPACER)
|
|
238
|
-
// Join chunks with a space — each chunk is a separate Telegram message,
|
|
239
|
-
// so inter-chunk whitespace is irrelevant; what matters is no word is
|
|
240
|
-
// lost or fused. (A small cap may end a chunk mid-sentence at a space
|
|
241
|
-
// boundary, e.g. "...here." | "Paragraph 1...", which is normal.)
|
|
242
238
|
expect(words(chunks.join(' '))).toEqual(words(paras.join(' ')))
|
|
243
239
|
}
|
|
244
240
|
})
|
|
@@ -251,6 +247,78 @@ describe('splitMarkdownChunks', () => {
|
|
|
251
247
|
const rejoined = chunks.join('\n').replace(/\n+/g, '\n')
|
|
252
248
|
expect(rejoined).toBe(text.replace(/\n+/g, '\n'))
|
|
253
249
|
})
|
|
250
|
+
|
|
251
|
+
// -------------------------------------------------------------------------
|
|
252
|
+
// Entity-aware chunking (#finding-3): a cut must never bisect an inline
|
|
253
|
+
// span (`**bold**`, `` `code` ``, `_italic_`, `[label](href)`), which would
|
|
254
|
+
// strand an unclosed delimiter in the emitted chunk.
|
|
255
|
+
// -------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
test('a bold/code/link span straddling the cap is not bisected (balanced delimiters)', () => {
|
|
258
|
+
// Build a body where each inline span sits right around a small cap so the
|
|
259
|
+
// naive space/newline cut would land inside it. Every span is shorter than
|
|
260
|
+
// the smallest cap tried (the longest is the 34-char link), so a straddling
|
|
261
|
+
// span can always be kept whole — the entity-aware back-off must do so.
|
|
262
|
+
const filler = 'x'.repeat(30)
|
|
263
|
+
const body =
|
|
264
|
+
`${filler} **bold span here** ` +
|
|
265
|
+
`${filler} \`code span here\` ` +
|
|
266
|
+
`${filler} [label here](https://ex.com/a-b-c) ` +
|
|
267
|
+
`${filler} _italic span here_ ${filler}`
|
|
268
|
+
for (const cap of [40, 50, 60, 70, 80]) {
|
|
269
|
+
const chunks = splitMarkdownChunks(body, cap)
|
|
270
|
+
for (const c of chunks) {
|
|
271
|
+
// Balanced `**` and single-backtick delimiters in every chunk.
|
|
272
|
+
expect((c.match(/\*\*/g) ?? []).length % 2).toBe(0)
|
|
273
|
+
expect((c.match(/`/g) ?? []).length % 2).toBe(0)
|
|
274
|
+
// No chunk ends mid-link (an open `](` with no closing `)`), and no
|
|
275
|
+
// chunk starts with an orphan link tail.
|
|
276
|
+
const opens = (c.match(/\]\(/g) ?? []).length
|
|
277
|
+
const closesAfterOpen = (c.match(/\]\([^)\n]*\)/g) ?? []).length
|
|
278
|
+
expect(opens).toBe(closesAfterOpen)
|
|
279
|
+
}
|
|
280
|
+
// Nothing is dropped.
|
|
281
|
+
const words = (s: string): string[] => s.split(/\s+/).filter((w) => w.length > 0)
|
|
282
|
+
expect(words(chunks.join(' '))).toEqual(words(body))
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('a `***bold-italic***` / `___…___` span straddling the cap keeps SINGLE-marker balance', () => {
|
|
287
|
+
// Regression for the triple-marker case: without the `***…***` pattern, the
|
|
288
|
+
// bold pattern matches only the inner `**…**`, so a cut inside `***x***`
|
|
289
|
+
// strands the lone outer `*` (odd asterisk count → the italic is lost). A
|
|
290
|
+
// `**`-PAIR balance check misses that, so assert SINGLE-`*` / SINGLE-`_`
|
|
291
|
+
// balance in every chunk.
|
|
292
|
+
const filler = 'x'.repeat(30)
|
|
293
|
+
const body =
|
|
294
|
+
`${filler} ***bold italic here*** ` +
|
|
295
|
+
`${filler} ___under bold here___ ${filler}`
|
|
296
|
+
for (const cap of [40, 50, 60, 70]) {
|
|
297
|
+
const chunks = splitMarkdownChunks(body, cap)
|
|
298
|
+
for (const c of chunks) {
|
|
299
|
+
// SINGLE-marker balance: an even count of `*` and of `_` in every chunk
|
|
300
|
+
// (a stranded lone `*`/`_` from a bisected triple span makes it odd).
|
|
301
|
+
expect((c.match(/\*/g) ?? []).length % 2).toBe(0)
|
|
302
|
+
expect((c.match(/_/g) ?? []).length % 2).toBe(0)
|
|
303
|
+
}
|
|
304
|
+
// Each triple span survives intact in exactly one chunk.
|
|
305
|
+
const rejoined = chunks.join('\n')
|
|
306
|
+
expect(rejoined).toContain('***bold italic here***')
|
|
307
|
+
expect(rejoined).toContain('___under bold here___')
|
|
308
|
+
// Nothing dropped.
|
|
309
|
+
const words = (s: string): string[] => s.split(/\s+/).filter((w) => w.length > 0)
|
|
310
|
+
expect(words(chunks.join(' '))).toEqual(words(body))
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('a boundary with NO spacer is unaffected (legacy ^\\n+ behaviour preserved)', () => {
|
|
315
|
+
const text = Array.from({ length: 10 }, (_, i) => `plain line ${i}`).join('\n\n')
|
|
316
|
+
const chunks = splitMarkdownChunks(text, 40)
|
|
317
|
+
// No spacer was ever present, so no chunk gains/loses anything beyond the
|
|
318
|
+
// normal leading-newline strip; content is preserved.
|
|
319
|
+
const rejoined = chunks.join('\n').replace(/\n+/g, '\n')
|
|
320
|
+
expect(rejoined).toBe(text.replace(/\n+/g, '\n'))
|
|
321
|
+
})
|
|
254
322
|
})
|
|
255
323
|
|
|
256
324
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
computeTurnStatus,
|
|
5
|
+
backstopSendOutcome,
|
|
6
|
+
finalizeBackstopSend,
|
|
7
|
+
buildTurnRecord,
|
|
8
|
+
type DeliveryOutcome,
|
|
9
|
+
} from '../gateway/turn-record-status.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* PR B — send-honesty. The turns.jsonl `status` must reflect the REAL send
|
|
13
|
+
* outcome, not the speculative `finalAnswerDelivered` flag the turn-flush
|
|
14
|
+
* backstop sets before its async send runs.
|
|
15
|
+
*
|
|
16
|
+
* These assert the recorded status OUTCOME for each turn shape — the exact
|
|
17
|
+
* string `emitTurnRecord` writes — not merely that a code path ran.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
describe('computeTurnStatus — recorded turn status reflects real outcome', () => {
|
|
21
|
+
it('genuine no-reply turn → no_reply', () => {
|
|
22
|
+
expect(computeTurnStatus({ finalAnswerDelivered: false })).toBe('no_reply')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('synchronous reply-tool delivery (no deliveryOutcome) → complete', () => {
|
|
26
|
+
expect(computeTurnStatus({ finalAnswerDelivered: true })).toBe('complete')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('reply-tool short-circuit suppressed the flush → complete (reply delivered)', () => {
|
|
30
|
+
expect(
|
|
31
|
+
computeTurnStatus({ finalAnswerDelivered: true, deliveryOutcome: 'suppressed' }),
|
|
32
|
+
).toBe('complete')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('fail-safe: undefined outcome + finalAnswerDelivered false never fabricates complete', () => {
|
|
36
|
+
expect(computeTurnStatus({ finalAnswerDelivered: false, deliveryOutcome: undefined })).toBe(
|
|
37
|
+
'no_reply',
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('backstopSendOutcome — resolve outcome from what happened on the wire', () => {
|
|
43
|
+
it('throw → failed', () => {
|
|
44
|
+
expect(backstopSendOutcome({ threw: true, sentCount: 0, chunkCount: 1 })).toBe('failed')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('partial (no throw, short delivery) → failed', () => {
|
|
48
|
+
expect(backstopSendOutcome({ threw: false, sentCount: 1, chunkCount: 2 })).toBe('failed')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('full delivery → delivered', () => {
|
|
52
|
+
expect(backstopSendOutcome({ threw: false, sentCount: 3, chunkCount: 3 })).toBe('delivered')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('Fix 5 — empty split (0 chunks, no throw) → failed, not delivered', () => {
|
|
56
|
+
expect(backstopSendOutcome({ threw: false, sentCount: 0, chunkCount: 0 })).toBe('failed')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fix 3 — WIRING integration. These drive the SAME seams the gateway
|
|
62
|
+
* turn-flush IIFE runs — `finalizeBackstopSend` (the stamp) feeding
|
|
63
|
+
* `buildTurnRecord` (which `emitTurnRecord` serializes verbatim) — and assert
|
|
64
|
+
* the RECORDED status string. If the accounting stamps the wrong branch, or the
|
|
65
|
+
* record builder ever reverted to the speculative `finalAnswerDelivered`
|
|
66
|
+
* ternary, these fail — not just if the pure predicate is wrong.
|
|
67
|
+
*/
|
|
68
|
+
describe('turn-flush wiring → recorded turns.jsonl status', () => {
|
|
69
|
+
const ENDED_AT = 1_700_000_500_000
|
|
70
|
+
const mkTurn = () => ({
|
|
71
|
+
agent: 'test-agent',
|
|
72
|
+
startedAt: ENDED_AT - 5_000,
|
|
73
|
+
toolCallCount: 0,
|
|
74
|
+
turnId: 'turn-abc',
|
|
75
|
+
// speculatively set at gateway.ts flush site BEFORE the send runs:
|
|
76
|
+
finalAnswerDelivered: true,
|
|
77
|
+
deliveryOutcome: undefined as DeliveryOutcome | undefined,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const recordAfterSend = (send: { threw: boolean; sentCount: number; chunkCount: number }) => {
|
|
81
|
+
const turn = mkTurn()
|
|
82
|
+
finalizeBackstopSend(turn, send) // mutates turn.deliveryOutcome — as the IIFE does
|
|
83
|
+
return buildTurnRecord(turn, ENDED_AT)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
it('send SUCCEEDS (all chunks delivered) → complete', () => {
|
|
87
|
+
expect(recordAfterSend({ threw: false, sentCount: 2, chunkCount: 2 }).status).toBe('complete')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('send THROWS (simulated FLOOD_WAIT_ACTIVE) → send_failed, never complete', () => {
|
|
91
|
+
// BUG ORACLE: pre-fix, `finalAnswerDelivered=true` was written BEFORE the
|
|
92
|
+
// send ran and the record read that flag → 'complete' even though the send
|
|
93
|
+
// threw and the user got nothing. Assert the wired outcome is honest.
|
|
94
|
+
const rec = recordAfterSend({ threw: true, sentCount: 0, chunkCount: 1 })
|
|
95
|
+
expect(rec.status).toBe('send_failed')
|
|
96
|
+
expect(rec.status).not.toBe('complete')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('PARTIAL multi-chunk (chunk 1 ok, chunk 2 throws) → send_failed', () => {
|
|
100
|
+
expect(recordAfterSend({ threw: true, sentCount: 1, chunkCount: 3 }).status).toBe('send_failed')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('reply-tool suppressed the flush → complete (reply delivered), via the stamp', () => {
|
|
104
|
+
const turn = mkTurn()
|
|
105
|
+
turn.deliveryOutcome = 'suppressed' // the IIFE's suppressed-branch stamp
|
|
106
|
+
expect(buildTurnRecord(turn, ENDED_AT).status).toBe('complete')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('record carries the honest tuple (tools + duration) alongside status', () => {
|
|
110
|
+
const rec = recordAfterSend({ threw: true, sentCount: 0, chunkCount: 1 })
|
|
111
|
+
expect(rec).toMatchObject({
|
|
112
|
+
status: 'send_failed',
|
|
113
|
+
tools: 0,
|
|
114
|
+
duration_ms: 5_000,
|
|
115
|
+
turn_id: 'turn-abc',
|
|
116
|
+
agent: 'test-agent',
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type BotApiForWorkerFeed,
|
|
5
5
|
type WorkerActivityView,
|
|
6
6
|
} from '../worker-activity-feed.js'
|
|
7
|
-
import { renderCombinedWorkerFeed } from '../tool-activity-summary.js'
|
|
7
|
+
import { renderCombinedWorkerFeed, combinedHistoryDepth } from '../tool-activity-summary.js'
|
|
8
8
|
import { STATUS_CARD_CHAR_BUDGET } from '../status-no-truncate.js'
|
|
9
9
|
import { createSendGate, isSendGateShed, type Clock } from '../send-gate.js'
|
|
10
10
|
|
|
@@ -489,4 +489,221 @@ describe('renderCombinedWorkerFeed (pure)', () => {
|
|
|
489
489
|
it('returns null for an empty worker set', () => {
|
|
490
490
|
expect(renderCombinedWorkerFeed([], { maxRows: 8 })).toBeNull()
|
|
491
491
|
})
|
|
492
|
+
|
|
493
|
+
// ── Adaptive density: per-worker rolling history within a line budget ──────
|
|
494
|
+
const rowH = (i: number, history: string[]) => ({
|
|
495
|
+
description: `task number ${i}`,
|
|
496
|
+
elapsedMs: 12_000 + i * 1000,
|
|
497
|
+
toolCount: i,
|
|
498
|
+
currentStep: history[history.length - 1] ?? '',
|
|
499
|
+
historyLines: history,
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
// A history line rendered as a PRIOR (done) step in the single-worker idiom.
|
|
503
|
+
const struck = (s: string) => `~~_✓ ${s}_~~`
|
|
504
|
+
// A history line rendered as the NEWEST in-progress step.
|
|
505
|
+
const current = (s: string) => `**→ ${s}**`
|
|
506
|
+
|
|
507
|
+
it('with 2 workers paints each worker MULTIPLE history lines with the ✓/→ strikethrough idiom', () => {
|
|
508
|
+
const body = renderCombinedWorkerFeed(
|
|
509
|
+
[
|
|
510
|
+
rowH(1, ['a first', 'a second', 'a third']),
|
|
511
|
+
rowH(2, ['b first', 'b second', 'b third']),
|
|
512
|
+
],
|
|
513
|
+
{ maxRows: 8 },
|
|
514
|
+
)!
|
|
515
|
+
// Prior steps struck-through, newest bold — same idiom as the single card.
|
|
516
|
+
expect(body).toContain(struck('a first'))
|
|
517
|
+
expect(body).toContain(struck('a second'))
|
|
518
|
+
expect(body).toContain(current('a third'))
|
|
519
|
+
expect(body).toContain(struck('b first'))
|
|
520
|
+
expect(body).toContain(struck('b second'))
|
|
521
|
+
expect(body).toContain(current('b third'))
|
|
522
|
+
// This is the regression assertion: the OLD single-line-only render would
|
|
523
|
+
// have shown only 'a third'/'b third' as `→ _step_`, never the earlier
|
|
524
|
+
// struck lines. Prove the trail is restored.
|
|
525
|
+
expect(body).toContain('a first')
|
|
526
|
+
expect(body).toContain('b first')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('degrades to ONE history line per worker at a large fan-out and stays within the body budget', () => {
|
|
530
|
+
const rows = Array.from({ length: 6 }, (_, i) =>
|
|
531
|
+
rowH(i, [`w${i} oldest`, `w${i} middle`, `w${i} newest`]),
|
|
532
|
+
)
|
|
533
|
+
const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
|
|
534
|
+
// Only the newest step of each worker survives — the earlier lines are
|
|
535
|
+
// dropped by the per-worker depth clamp (floor((13-6)/6)=1).
|
|
536
|
+
for (let i = 0; i < 6; i++) {
|
|
537
|
+
expect(body).toContain(current(`w${i} newest`))
|
|
538
|
+
expect(body).not.toContain(`w${i} oldest`)
|
|
539
|
+
expect(body).not.toContain(`w${i} middle`)
|
|
540
|
+
}
|
|
541
|
+
// Total body lines (worker headers + history) stay within the budget: 6
|
|
542
|
+
// header lines + 6 history lines = 12 ≤ MAX_COMBINED_BODY_LINES (13). Count
|
|
543
|
+
// only the per-worker body lines (exclude the top count line + any spill).
|
|
544
|
+
const bodyLines = body
|
|
545
|
+
.split('\n')
|
|
546
|
+
.map((l) => l.trim())
|
|
547
|
+
.filter((l) => l.length > 0)
|
|
548
|
+
const headerAndHistory = bodyLines.filter(
|
|
549
|
+
(l) => !l.startsWith('🛠') && !l.includes('more working'),
|
|
550
|
+
)
|
|
551
|
+
expect(headerAndHistory.length).toBeLessThanOrEqual(13)
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('exposes the deterministic depth formula (2→5, 3→3, 4→2, 6→1)', () => {
|
|
555
|
+
expect(combinedHistoryDepth(2)).toBe(5)
|
|
556
|
+
expect(combinedHistoryDepth(3)).toBe(3)
|
|
557
|
+
expect(combinedHistoryDepth(4)).toBe(2)
|
|
558
|
+
expect(combinedHistoryDepth(6)).toBe(1)
|
|
559
|
+
expect(combinedHistoryDepth(8)).toBe(1)
|
|
560
|
+
})
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Worker-feed ghost-leak (immortal/unpinned/buried card) — outcome tests.
|
|
565
|
+
*
|
|
566
|
+
* Root cause: the feed removed a worker's row ONLY from the gateway's
|
|
567
|
+
* `onFinish` handler. Terminal paths that never fire `onFinish` (the watcher's
|
|
568
|
+
* JSONL-vanished `onFileVanished` → `cleanupTerminalAgent`, and boot done-at-
|
|
569
|
+
* boot orphans) left the row in the feed forever — the shared card never
|
|
570
|
+
* emptied, so it never collapsed/unpinned and heartbeat-edited indefinitely
|
|
571
|
+
* while buried up-chat. The fix wires feed removal to the watcher's
|
|
572
|
+
* authoritative terminal sweep (`terminate`, driven by `onTerminalCleanup`)
|
|
573
|
+
* PLUS a backstop TTL sweep. These assert the OUTCOMES, not the code paths.
|
|
574
|
+
*/
|
|
575
|
+
function ghostHarness(opts: { staleWorkerTtlMs?: number; now: () => number }) {
|
|
576
|
+
const edits: { messageId: number; text: string }[] = []
|
|
577
|
+
const sends: { text: string }[] = []
|
|
578
|
+
const pins: { messageId: number | null }[] = []
|
|
579
|
+
let seq = 500
|
|
580
|
+
const bot: BotApiForWorkerFeed = {
|
|
581
|
+
sendMessage: async (_chatId, text) => {
|
|
582
|
+
sends.push({ text })
|
|
583
|
+
return { message_id: seq++ }
|
|
584
|
+
},
|
|
585
|
+
editMessageText: async (_chatId, messageId, text) => {
|
|
586
|
+
edits.push({ messageId, text })
|
|
587
|
+
return true
|
|
588
|
+
},
|
|
589
|
+
}
|
|
590
|
+
const feed = createWorkerActivityFeed({
|
|
591
|
+
bot,
|
|
592
|
+
now: opts.now,
|
|
593
|
+
minEditIntervalMs: 0,
|
|
594
|
+
heartbeatTickMs: 1000,
|
|
595
|
+
firstPaintMinMs: 0,
|
|
596
|
+
setInterval: () => 0,
|
|
597
|
+
clearInterval: () => {},
|
|
598
|
+
staleWorkerTtlMs: opts.staleWorkerTtlMs,
|
|
599
|
+
reconcilePin: ({ messageId }) => pins.push({ messageId }),
|
|
600
|
+
})
|
|
601
|
+
return { feed, edits, sends, pins }
|
|
602
|
+
}
|
|
603
|
+
async function drain(): Promise<void> {
|
|
604
|
+
for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r))
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
describe('worker-feed ghost-leak — deterministic terminal removal + backstop', () => {
|
|
608
|
+
it('finish() on the LAST worker removes its row, collapses to the terminal summary, and UNPINS', async () => {
|
|
609
|
+
let t = 0
|
|
610
|
+
const { feed, sends, edits, pins } = ghostHarness({ now: () => t })
|
|
611
|
+
await feed.update('a', 'chat', view('task a', 'reading files', 0))
|
|
612
|
+
await drain()
|
|
613
|
+
expect(sends.length).toBe(1)
|
|
614
|
+
expect(feed.size).toBe(1)
|
|
615
|
+
// Painting the group pins the shared message.
|
|
616
|
+
expect(pins.at(-1)?.messageId).not.toBeNull()
|
|
617
|
+
|
|
618
|
+
t = 5000
|
|
619
|
+
await feed.finish('a', {
|
|
620
|
+
description: 'task a',
|
|
621
|
+
lastTool: null,
|
|
622
|
+
toolCount: 3,
|
|
623
|
+
latestSummary: 'all done',
|
|
624
|
+
elapsedMs: 5000,
|
|
625
|
+
state: 'done',
|
|
626
|
+
})
|
|
627
|
+
await drain()
|
|
628
|
+
// Row gone → the active set empties.
|
|
629
|
+
expect(feed.size).toBe(0)
|
|
630
|
+
// Collapsed to a terminal summary (a distinct edit landed, showing 'done').
|
|
631
|
+
expect(edits.length).toBeGreaterThan(0)
|
|
632
|
+
expect(edits.at(-1)?.text).toContain('done')
|
|
633
|
+
// And UNPINNED (group empty → reconcilePin messageId null).
|
|
634
|
+
expect(pins.at(-1)?.messageId).toBeNull()
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
it('terminate() (authoritative onTerminalCleanup sweep) reaps a worker whose onFinish NEVER fired — collapses + unpins', async () => {
|
|
638
|
+
let t = 0
|
|
639
|
+
const { feed, edits, pins } = ghostHarness({ now: () => t })
|
|
640
|
+
await feed.update('b', 'chat', view('task b', 'running a command', 0))
|
|
641
|
+
await drain()
|
|
642
|
+
expect(feed.size).toBe(1)
|
|
643
|
+
expect(pins.at(-1)?.messageId).not.toBeNull()
|
|
644
|
+
|
|
645
|
+
// Simulate the watcher's JSONL-vanished sweep: cleanupTerminalAgent → this,
|
|
646
|
+
// with NO onFinish ever delivered.
|
|
647
|
+
t = 3000
|
|
648
|
+
await feed.terminate('b')
|
|
649
|
+
await drain()
|
|
650
|
+
expect(feed.size).toBe(0)
|
|
651
|
+
expect(pins.at(-1)?.messageId).toBeNull()
|
|
652
|
+
// The card stopped editing: no further heartbeat edits after termination.
|
|
653
|
+
const after = edits.length
|
|
654
|
+
t = 20000
|
|
655
|
+
feed.heartbeatTick()
|
|
656
|
+
await drain()
|
|
657
|
+
expect(edits.length).toBe(after)
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('backstop TTL sweep force-reaps a leaked slot (terminal signal never delivered), then the card collapses + unpins', async () => {
|
|
661
|
+
let t = 0
|
|
662
|
+
const { feed, edits, pins } = ghostHarness({ staleWorkerTtlMs: 1000, now: () => t })
|
|
663
|
+
await feed.update('c', 'chat', view('task c', 'thinking', 0))
|
|
664
|
+
await drain()
|
|
665
|
+
expect(feed.size).toBe(1)
|
|
666
|
+
|
|
667
|
+
// No finish, no terminate — the worker is a pure leak. Advance past the TTL.
|
|
668
|
+
t = 2500
|
|
669
|
+
feed.heartbeatTick()
|
|
670
|
+
await drain()
|
|
671
|
+
expect(feed.size).toBe(0)
|
|
672
|
+
expect(pins.at(-1)?.messageId).toBeNull()
|
|
673
|
+
|
|
674
|
+
// Immortality closed: subsequent heartbeats produce no further edits.
|
|
675
|
+
const after = edits.length
|
|
676
|
+
t = 10000
|
|
677
|
+
feed.heartbeatTick()
|
|
678
|
+
await drain()
|
|
679
|
+
expect(edits.length).toBe(after)
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
it('a still-live worker (fresh update within the TTL) is NOT reaped by the backstop sweep', async () => {
|
|
683
|
+
let t = 0
|
|
684
|
+
const { feed } = ghostHarness({ staleWorkerTtlMs: 1000, now: () => t })
|
|
685
|
+
await feed.update('d', 'chat', view('task d', 's0', 0))
|
|
686
|
+
await drain()
|
|
687
|
+
// A fresh cue just before the sweep keeps it live.
|
|
688
|
+
t = 900
|
|
689
|
+
await feed.update('d', 'chat', view('task d', 's1', 900))
|
|
690
|
+
await drain()
|
|
691
|
+
t = 1500
|
|
692
|
+
feed.heartbeatTick()
|
|
693
|
+
await drain()
|
|
694
|
+
// Still tracked — the sweep only reaps rows silent PAST the TTL.
|
|
695
|
+
expect(feed.size).toBe(1)
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('no-op re-render is skipped (byte-identical body → no redundant edit)', async () => {
|
|
699
|
+
let t = 0
|
|
700
|
+
const { feed, edits } = ghostHarness({ now: () => t })
|
|
701
|
+
await feed.update('e', 'chat', view('task e', 'same step', 0))
|
|
702
|
+
await drain()
|
|
703
|
+
const afterPaint = edits.length // first paint is a send, not an edit
|
|
704
|
+
// Identical view (same elapsed → byte-identical rendered body): dedup skips.
|
|
705
|
+
await feed.update('e', 'chat', view('task e', 'same step', 0))
|
|
706
|
+
await drain()
|
|
707
|
+
expect(edits.length).toBe(afterPaint)
|
|
708
|
+
})
|
|
492
709
|
})
|