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,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration guard for the worker-feed ghost-leak fix (PR #3226 review,
|
|
3
|
+
* Finding 1). The unit tests in `worker-feed-coalesce.test.ts` call
|
|
4
|
+
* `feed.terminate()` DIRECTLY — they prove the feed collapses/unpins when
|
|
5
|
+
* asked, but they do NOT prove the two halves of the actual wire that was
|
|
6
|
+
* broken:
|
|
7
|
+
*
|
|
8
|
+
* (a) the subagent-watcher FIRES `onTerminalCleanup` from its authoritative
|
|
9
|
+
* `cleanupTerminalAgent` sweep — on BOTH the JSONL-vanished path
|
|
10
|
+
* (`onFileVanished` → `cleanupTerminalAgent`) AND the boot done-at-boot
|
|
11
|
+
* orphan path — the exact paths that never fire `onFinish`; and
|
|
12
|
+
* (b) wiring that callback to `workerActivityFeed.terminate` removes the row
|
|
13
|
+
* and collapses/unpins the shared card end-to-end.
|
|
14
|
+
*
|
|
15
|
+
* These drive the REAL `startSubagentWatcher` (with a mock fs) wired to a REAL
|
|
16
|
+
* `createWorkerActivityFeed` exactly as the gateway wires them
|
|
17
|
+
* (`onTerminalCleanup: (id) => feed.terminate(id)`). If a future refactor drops
|
|
18
|
+
* the `config.onTerminalCleanup(agentId)` call in `cleanupTerminalAgent`, the
|
|
19
|
+
* callback never fires, the feed row survives, and these go RED — the
|
|
20
|
+
* regression the divergence-based leak represented.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
23
|
+
import * as fs from 'fs'
|
|
24
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
25
|
+
import {
|
|
26
|
+
createWorkerActivityFeed,
|
|
27
|
+
type BotApiForWorkerFeed,
|
|
28
|
+
type WorkerActivityView,
|
|
29
|
+
} from '../worker-activity-feed.js'
|
|
30
|
+
|
|
31
|
+
function buildJSONL(...lines: object[]): string {
|
|
32
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
33
|
+
}
|
|
34
|
+
const subAgentUserMsg = (t: string) => ({ type: 'user', message: { content: [{ type: 'text', text: t }] } })
|
|
35
|
+
const subAgentTurnEnd = () => ({ type: 'system', subtype: 'turn_duration', duration_ms: 100 })
|
|
36
|
+
|
|
37
|
+
function view(desc: string, step: string, elapsedMs: number): WorkerActivityView {
|
|
38
|
+
return { description: desc, lastTool: null, toolCount: 2, latestSummary: step, elapsedMs, state: 'running' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A real feed with a fake bot + reconcilePin capture, driven off `now`. */
|
|
42
|
+
function makeFeed(now: () => number) {
|
|
43
|
+
const sends: { text: string }[] = []
|
|
44
|
+
const edits: { messageId: number; text: string }[] = []
|
|
45
|
+
const pins: { messageId: number | null }[] = []
|
|
46
|
+
let seq = 700
|
|
47
|
+
const bot: BotApiForWorkerFeed = {
|
|
48
|
+
sendMessage: async (_c, text) => {
|
|
49
|
+
sends.push({ text })
|
|
50
|
+
return { message_id: seq++ }
|
|
51
|
+
},
|
|
52
|
+
editMessageText: async (_c, messageId, text) => {
|
|
53
|
+
edits.push({ messageId, text })
|
|
54
|
+
return true
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
const feed = createWorkerActivityFeed({
|
|
58
|
+
bot,
|
|
59
|
+
now,
|
|
60
|
+
minEditIntervalMs: 0,
|
|
61
|
+
heartbeatTickMs: 1000,
|
|
62
|
+
firstPaintMinMs: 0,
|
|
63
|
+
setInterval: () => 0,
|
|
64
|
+
clearInterval: () => {},
|
|
65
|
+
reconcilePin: ({ messageId }) => pins.push({ messageId }),
|
|
66
|
+
})
|
|
67
|
+
return { feed, sends, edits, pins }
|
|
68
|
+
}
|
|
69
|
+
async function drain(): Promise<void> {
|
|
70
|
+
for (let i = 0; i < 12; i++) await new Promise((r) => setImmediate(r))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A mock fs over a single subagents dir with one worker JSONL. `vanished`
|
|
75
|
+
* flips the file-read calls to throw ENOENT (Claude Code reaped the parent
|
|
76
|
+
* session's `subagents/` dir) to exercise the vanished terminal path.
|
|
77
|
+
*/
|
|
78
|
+
function mockWatcherFs(opts: {
|
|
79
|
+
agentDir: string
|
|
80
|
+
fileName: string
|
|
81
|
+
content: Buffer
|
|
82
|
+
vanished: () => boolean
|
|
83
|
+
}) {
|
|
84
|
+
const projectsRoot = `${opts.agentDir}/.claude/projects`
|
|
85
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
86
|
+
const sessionDir = `${projectDir}/sess`
|
|
87
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
88
|
+
const filePath = `${subagentsDir}/${opts.fileName}`
|
|
89
|
+
let lastOpened: string | null = null
|
|
90
|
+
const enoent = (): never => {
|
|
91
|
+
const e = new Error('ENOENT') as NodeJS.ErrnoException
|
|
92
|
+
e.code = 'ENOENT'
|
|
93
|
+
throw e
|
|
94
|
+
}
|
|
95
|
+
const mock = {
|
|
96
|
+
existsSync: ((p: fs.PathLike) => {
|
|
97
|
+
const ps = String(p)
|
|
98
|
+
if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
|
|
99
|
+
return ps === filePath && !opts.vanished()
|
|
100
|
+
}) as typeof fs.existsSync,
|
|
101
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
102
|
+
const ps = String(p)
|
|
103
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
104
|
+
if (ps === projectDir) return ['sess']
|
|
105
|
+
if (ps === sessionDir) return ['subagents']
|
|
106
|
+
if (ps === subagentsDir) return opts.vanished() ? [] : [opts.fileName]
|
|
107
|
+
return []
|
|
108
|
+
}) as unknown as typeof fs.readdirSync,
|
|
109
|
+
statSync: ((p: fs.PathLike) => {
|
|
110
|
+
if (opts.vanished()) return enoent()
|
|
111
|
+
return { size: opts.content.length, mtimeMs: 0 } as fs.Stats
|
|
112
|
+
}) as typeof fs.statSync,
|
|
113
|
+
openSync: ((p: fs.PathLike) => {
|
|
114
|
+
if (opts.vanished()) return enoent()
|
|
115
|
+
lastOpened = String(p)
|
|
116
|
+
return 7
|
|
117
|
+
}) as unknown as typeof fs.openSync,
|
|
118
|
+
closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
|
|
119
|
+
readSync: ((_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null): number => {
|
|
120
|
+
if (opts.vanished() || lastOpened == null) return 0
|
|
121
|
+
const src = opts.content.slice(position ?? 0, (position ?? 0) + length)
|
|
122
|
+
src.copy(buf as Buffer, offset)
|
|
123
|
+
return src.length
|
|
124
|
+
}) as unknown as typeof fs.readSync,
|
|
125
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
126
|
+
}
|
|
127
|
+
return { mock, filePath }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Deterministic clock + injectable timers shared by watcher + feed. */
|
|
131
|
+
function makeClock() {
|
|
132
|
+
let currentTime = 1_000_000
|
|
133
|
+
const intervals: Array<{ fn: () => void; ms: number; fireAt: number }> = []
|
|
134
|
+
const timeouts: Array<{ fn: () => void; fireAt: number; ref: number }> = []
|
|
135
|
+
let nextRef = 1
|
|
136
|
+
const now = () => currentTime
|
|
137
|
+
const advance = (ms: number): void => {
|
|
138
|
+
currentTime += ms
|
|
139
|
+
for (;;) {
|
|
140
|
+
timeouts.sort((a, b) => a.fireAt - b.fireAt)
|
|
141
|
+
const next = timeouts[0]
|
|
142
|
+
if (!next || next.fireAt > currentTime) break
|
|
143
|
+
timeouts.shift()
|
|
144
|
+
next.fn()
|
|
145
|
+
}
|
|
146
|
+
for (const iv of intervals) {
|
|
147
|
+
while (iv.fireAt <= currentTime) {
|
|
148
|
+
iv.fn()
|
|
149
|
+
iv.fireAt += iv.ms
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const timers = {
|
|
154
|
+
setInterval: (fn: () => void, ms: number) => {
|
|
155
|
+
intervals.push({ fn, ms, fireAt: currentTime + ms })
|
|
156
|
+
return { ref: 0 }
|
|
157
|
+
},
|
|
158
|
+
clearInterval: () => {},
|
|
159
|
+
setTimeout: (fn: () => void, ms: number) => {
|
|
160
|
+
const ref = nextRef++
|
|
161
|
+
timeouts.push({ fn, fireAt: currentTime + ms, ref })
|
|
162
|
+
return { ref }
|
|
163
|
+
},
|
|
164
|
+
clearTimeout: (handle: { ref: number }) => {
|
|
165
|
+
const idx = timeouts.findIndex((t) => t.ref === handle.ref)
|
|
166
|
+
if (idx !== -1) timeouts.splice(idx, 1)
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
return { now, advance, timers }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
describe('worker-feed ghost-leak — watcher terminal sweep → feed removal (integration)', () => {
|
|
173
|
+
it('boot done-at-boot orphan: cleanupTerminalAgent fires onTerminalCleanup → feed row removed, card collapsed + unpinned', async () => {
|
|
174
|
+
const agentDir = '/home/user/.switchroom/agents/x'
|
|
175
|
+
const { now, advance, timers } = makeClock()
|
|
176
|
+
const { feed, pins } = makeFeed(now)
|
|
177
|
+
|
|
178
|
+
// The worker was live in the feed (its progress had surfaced there).
|
|
179
|
+
await feed.update('boot1', 'chat', view('task boot1', 'reading', 0))
|
|
180
|
+
await drain()
|
|
181
|
+
expect(feed.size).toBe(1)
|
|
182
|
+
expect(pins.at(-1)?.messageId).not.toBeNull() // pinned
|
|
183
|
+
|
|
184
|
+
const { mock } = mockWatcherFs({
|
|
185
|
+
agentDir,
|
|
186
|
+
fileName: 'agent-boot1.jsonl',
|
|
187
|
+
// Already `done` at boot (turn_end present) → registerAgent schedules a
|
|
188
|
+
// terminal cleanup with NO onFinish (the boot-orphan bypass path).
|
|
189
|
+
content: Buffer.from(buildJSONL(subAgentUserMsg('done task'), subAgentTurnEnd()), 'utf-8'),
|
|
190
|
+
vanished: () => false,
|
|
191
|
+
})
|
|
192
|
+
const watcher = startSubagentWatcher({
|
|
193
|
+
agentDir,
|
|
194
|
+
fs: mock,
|
|
195
|
+
now,
|
|
196
|
+
// Wire EXACTLY as the gateway does.
|
|
197
|
+
onTerminalCleanup: (agentId) => { void feed.terminate(agentId) },
|
|
198
|
+
...timers,
|
|
199
|
+
})
|
|
200
|
+
expect(watcher.getRegistry().has('boot1')).toBe(true)
|
|
201
|
+
|
|
202
|
+
// Fire the scheduled terminal cleanup (30s grace) → onTerminalCleanup.
|
|
203
|
+
advance(30_000)
|
|
204
|
+
await drain()
|
|
205
|
+
|
|
206
|
+
expect(feed.size).toBe(0) // row removed by the sweep
|
|
207
|
+
expect(pins.at(-1)?.messageId).toBeNull() // card collapsed + UNPINNED
|
|
208
|
+
watcher.stop()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('JSONL-vanished path: onFileVanished → cleanupTerminalAgent → onTerminalCleanup → feed row removed + unpinned', async () => {
|
|
212
|
+
const agentDir = '/home/user/.switchroom/agents/y'
|
|
213
|
+
const { now, advance, timers } = makeClock()
|
|
214
|
+
const { feed, pins } = makeFeed(now)
|
|
215
|
+
|
|
216
|
+
await feed.update('van1', 'chat', view('task van1', 'running a command', 0))
|
|
217
|
+
await drain()
|
|
218
|
+
expect(feed.size).toBe(1)
|
|
219
|
+
|
|
220
|
+
let vanished = false
|
|
221
|
+
const { mock } = mockWatcherFs({
|
|
222
|
+
agentDir,
|
|
223
|
+
fileName: 'agent-van1.jsonl',
|
|
224
|
+
// Running at boot (no turn_end) — stays registered; the poll loop reads
|
|
225
|
+
// it defensively, so when the file vanishes the read throws ENOENT and
|
|
226
|
+
// the watcher takes onFileVanished → cleanupTerminalAgent.
|
|
227
|
+
content: Buffer.from(buildJSONL(subAgentUserMsg('long task')), 'utf-8'),
|
|
228
|
+
vanished: () => vanished,
|
|
229
|
+
})
|
|
230
|
+
const captured: string[] = []
|
|
231
|
+
const watcher = startSubagentWatcher({
|
|
232
|
+
agentDir,
|
|
233
|
+
fs: mock,
|
|
234
|
+
now,
|
|
235
|
+
onTerminalCleanup: (agentId) => {
|
|
236
|
+
captured.push(agentId)
|
|
237
|
+
void feed.terminate(agentId)
|
|
238
|
+
},
|
|
239
|
+
...timers,
|
|
240
|
+
})
|
|
241
|
+
expect(watcher.getRegistry().has('van1')).toBe(true)
|
|
242
|
+
|
|
243
|
+
// The parent session ends → Claude Code reaps subagents/ → next poll read
|
|
244
|
+
// hits ENOENT.
|
|
245
|
+
vanished = true
|
|
246
|
+
advance(2_000) // drive the rescan/poll interval
|
|
247
|
+
await drain()
|
|
248
|
+
|
|
249
|
+
expect(captured).toContain('van1') // vanished path funnels here
|
|
250
|
+
expect(feed.size).toBe(0) // wired removal happened
|
|
251
|
+
expect(pins.at(-1)?.messageId).toBeNull() // unpinned
|
|
252
|
+
watcher.stop()
|
|
253
|
+
})
|
|
254
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Residual B regression: a worker cleaned up by the TTL / authoritative
|
|
3
|
+
* `terminate` sweep — i.e. reaped / vanished / crashed WITHOUT ever delivering
|
|
4
|
+
* a clean `onFinish` result — must render a TRUTHFUL terminal state, NOT "done".
|
|
5
|
+
*
|
|
6
|
+
* `terminateWorker` synthesises the recap when NO clean finish arrived (a clean
|
|
7
|
+
* `onFinish` removes the row first, making `terminate` a no-op; an errored
|
|
8
|
+
* worker goes through `onFinish(outcome:'failed')`). So a row still present at
|
|
9
|
+
* terminate time genuinely ended without a result → it must render as the
|
|
10
|
+
* `incomplete` terminal ("incomplete · …"), never "done".
|
|
11
|
+
*
|
|
12
|
+
* OUTCOME assertions on the finalized card body (RED if `terminate` reverts to
|
|
13
|
+
* `state:'done'`), applied to the generic worker row so the guarantee holds at
|
|
14
|
+
* every nesting level (the feed keys rows by agentId, depth-agnostic):
|
|
15
|
+
* - a solo reaped worker's terminal card reads `incomplete`, never `done`;
|
|
16
|
+
* - a genuinely-`finish`ed worker still reads `done` with its result.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from 'vitest'
|
|
19
|
+
import {
|
|
20
|
+
createWorkerActivityFeed,
|
|
21
|
+
renderWorkerActivity,
|
|
22
|
+
type BotApiForWorkerFeed,
|
|
23
|
+
type WorkerActivityView,
|
|
24
|
+
} from '../worker-activity-feed.js'
|
|
25
|
+
|
|
26
|
+
function makeFeed(now: () => number) {
|
|
27
|
+
const sends: { text: string }[] = []
|
|
28
|
+
const edits: { messageId: number; text: string }[] = []
|
|
29
|
+
let seq = 900
|
|
30
|
+
const bot: BotApiForWorkerFeed = {
|
|
31
|
+
sendMessage: async (_c, text) => { sends.push({ text }); return { message_id: seq++ } },
|
|
32
|
+
editMessageText: async (_c, messageId, text) => { edits.push({ messageId, text }); return true },
|
|
33
|
+
}
|
|
34
|
+
const feed = createWorkerActivityFeed({
|
|
35
|
+
bot,
|
|
36
|
+
now,
|
|
37
|
+
minEditIntervalMs: 0,
|
|
38
|
+
firstPaintMinMs: 0,
|
|
39
|
+
setInterval: () => 0,
|
|
40
|
+
clearInterval: () => {},
|
|
41
|
+
})
|
|
42
|
+
return { feed, sends, edits }
|
|
43
|
+
}
|
|
44
|
+
const runningView = (desc: string, step: string, elapsedMs: number): WorkerActivityView => ({
|
|
45
|
+
description: desc, lastTool: null, toolCount: 3, latestSummary: step, elapsedMs, state: 'running',
|
|
46
|
+
})
|
|
47
|
+
async function drain(): Promise<void> {
|
|
48
|
+
for (let i = 0; i < 12; i++) await new Promise((r) => setImmediate(r))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('worker terminal state is truthful for reaped workers (Residual B)', () => {
|
|
52
|
+
it('a reaped/vanished worker (terminate, no clean finish) renders `incomplete`, never `done`', async () => {
|
|
53
|
+
let clock = 1000
|
|
54
|
+
const { feed, edits } = makeFeed(() => clock)
|
|
55
|
+
clock = 1000
|
|
56
|
+
await feed.update('w', 'chat', runningView('background job', 'doing work', 1000))
|
|
57
|
+
await drain()
|
|
58
|
+
clock = 5000
|
|
59
|
+
// Authoritative sweep / TTL backstop: no onFinish ever arrived.
|
|
60
|
+
await feed.terminate('w')
|
|
61
|
+
await drain()
|
|
62
|
+
|
|
63
|
+
const last = edits[edits.length - 1].text
|
|
64
|
+
expect(last, 'reaped worker must NOT read as done').not.toMatch(/\bdone\b/)
|
|
65
|
+
expect(last, 'reaped worker reads the truthful incomplete terminal').toContain('incomplete')
|
|
66
|
+
expect(feed.size).toBe(0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('a genuinely finished worker still renders `done` with its result', async () => {
|
|
70
|
+
let clock = 1000
|
|
71
|
+
const { feed, edits } = makeFeed(() => clock)
|
|
72
|
+
await feed.update('w', 'chat', runningView('background job', 'doing work', 1000))
|
|
73
|
+
await drain()
|
|
74
|
+
clock = 2000
|
|
75
|
+
await feed.finish('w', {
|
|
76
|
+
description: 'background job',
|
|
77
|
+
lastTool: null,
|
|
78
|
+
toolCount: 5,
|
|
79
|
+
latestSummary: 'the delivered result paragraph',
|
|
80
|
+
elapsedMs: 2000,
|
|
81
|
+
state: 'done',
|
|
82
|
+
})
|
|
83
|
+
await drain()
|
|
84
|
+
const last = edits[edits.length - 1].text
|
|
85
|
+
expect(last).toContain('done')
|
|
86
|
+
expect(last).toContain('the delivered result paragraph')
|
|
87
|
+
expect(last).not.toContain('incomplete')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('renderWorkerActivity renders the `incomplete` state as a finished card without a fabricated result', () => {
|
|
91
|
+
const card = renderWorkerActivity({
|
|
92
|
+
description: 'background job',
|
|
93
|
+
lastTool: null,
|
|
94
|
+
toolCount: 3,
|
|
95
|
+
latestSummary: '', // reaped: no result text
|
|
96
|
+
elapsedMs: 4000,
|
|
97
|
+
state: 'incomplete',
|
|
98
|
+
})
|
|
99
|
+
expect(card).toContain('incomplete')
|
|
100
|
+
expect(card).not.toMatch(/\bdone\b/)
|
|
101
|
+
// No fabricated result paragraph (latestSummary empty) → no ✅ result block.
|
|
102
|
+
expect(card).not.toContain('✅')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('renderWorkerActivity NEVER renders a result/⚠️ block for `incomplete`, even with a non-empty latestSummary (deterministic guard, not caller-discipline)', () => {
|
|
106
|
+
// Latent-trap regression guard: the live call site (terminateWorker) always
|
|
107
|
+
// passes latestSummary:'' for a reaped worker, but a future/direct caller
|
|
108
|
+
// could pass stray text. The truthful-no-result invariant must be enforced
|
|
109
|
+
// in the renderer — an `incomplete` worker produced no result, so it must
|
|
110
|
+
// never fabricate a ⚠️-prefixed result paragraph regardless of the summary.
|
|
111
|
+
const card = renderWorkerActivity({
|
|
112
|
+
description: 'background job',
|
|
113
|
+
lastTool: null,
|
|
114
|
+
toolCount: 3,
|
|
115
|
+
latestSummary: 'this looks like a real result but the worker never finished',
|
|
116
|
+
elapsedMs: 4000,
|
|
117
|
+
state: 'incomplete',
|
|
118
|
+
})
|
|
119
|
+
expect(card).toContain('incomplete')
|
|
120
|
+
// The renderer maps a non-`done` finished result to the ⚠️ emoji; the guard
|
|
121
|
+
// must suppress that block entirely for `incomplete`.
|
|
122
|
+
expect(card).not.toContain('⚠️')
|
|
123
|
+
expect(card).not.toContain('this looks like a real result')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -170,8 +170,10 @@ export function clipNarrative(s: string): string {
|
|
|
170
170
|
* `description` — italicised task description (optional)
|
|
171
171
|
* `elapsedMs` — wall-clock elapsed, rendered via `formatFeedElapsed`
|
|
172
172
|
* `toolCount` — labeled tool calls this turn
|
|
173
|
-
* `state` — 'running' | 'done' | 'failed' (controls the
|
|
174
|
-
*
|
|
173
|
+
* `state` — 'running' | 'done' | 'failed' | 'incomplete' (controls the
|
|
174
|
+
* status line wording; 'failed'/'incomplete' render
|
|
175
|
+
* `failed · …` / `incomplete · …` so a failed or reaped worker
|
|
176
|
+
* never reads as done)
|
|
175
177
|
*
|
|
176
178
|
* Returns a two-element array of ready Telegram HTML lines (no trailing newline).
|
|
177
179
|
*/
|
|
@@ -181,7 +183,7 @@ export function renderActivityHeader(
|
|
|
181
183
|
description: string,
|
|
182
184
|
elapsedMs: number,
|
|
183
185
|
toolCount: number,
|
|
184
|
-
state: 'running' | 'done' | 'failed',
|
|
186
|
+
state: 'running' | 'done' | 'failed' | 'incomplete',
|
|
185
187
|
model?: string,
|
|
186
188
|
): [string, string] {
|
|
187
189
|
const toolWord = toolCount === 1 ? 'tool' : 'tools'
|
|
@@ -283,7 +285,7 @@ export interface StatusCardHeader {
|
|
|
283
285
|
description?: string
|
|
284
286
|
elapsedMs: number
|
|
285
287
|
toolCount: number
|
|
286
|
-
state: 'running' | 'done' | 'failed'
|
|
288
|
+
state: 'running' | 'done' | 'failed' | 'incomplete'
|
|
287
289
|
/** Live model id (raw, e.g. `claude-opus-4-8`) — rendered as a short friendly
|
|
288
290
|
* tag on the metrics line via `formatModelLabel`. Omitted when unknown. */
|
|
289
291
|
model?: string
|
|
@@ -582,6 +584,16 @@ export interface CombinedWorkerRow {
|
|
|
582
584
|
toolCount: number
|
|
583
585
|
/** The worker's latest step line (raw prose or friendly tool label). */
|
|
584
586
|
currentStep: string
|
|
587
|
+
/**
|
|
588
|
+
* The worker's accumulated narrative history (oldest→newest, raw/unescaped),
|
|
589
|
+
* already deduped + rolling-window capped upstream (STATUS_ROLLING_LINES).
|
|
590
|
+
* When present + non-empty, the combined feed paints the worker's last-K
|
|
591
|
+
* lines as a `✓`/`→` step trail (prior steps struck, newest in-progress) —
|
|
592
|
+
* the same idiom as the single-worker card — with K set by the adaptive
|
|
593
|
+
* per-worker line budget. Absent/empty → falls back to the single
|
|
594
|
+
* `currentStep` line (back-compat for direct callers).
|
|
595
|
+
*/
|
|
596
|
+
historyLines?: string[]
|
|
585
597
|
/** Live model id (raw, e.g. `claude-opus-4-8`); omitted when unknown. */
|
|
586
598
|
model?: string
|
|
587
599
|
}
|
|
@@ -593,17 +605,53 @@ export interface CombinedWorkerFeedOpts {
|
|
|
593
605
|
maxRows: number
|
|
594
606
|
}
|
|
595
607
|
|
|
608
|
+
/**
|
|
609
|
+
* Total per-worker BODY line budget for the combined feed — the sum, across all
|
|
610
|
+
* visible workers, of (one header line + that worker's history lines). The top
|
|
611
|
+
* `🛠 Workers · N running` line and the `+M more working…` spill are OUTSIDE
|
|
612
|
+
* this budget (fixed chrome). 13 is chosen so the card stays a compact glance,
|
|
613
|
+
* not a wall: at 2 workers it yields the full 5-line history each (2·1 header +
|
|
614
|
+
* 2·5 history = 12 ≤ 13), and it degrades to a single history line each by ~6
|
|
615
|
+
* workers — matching the pre-adaptive one-line-per-worker floor while never
|
|
616
|
+
* letting a 2–3 worker fan-out lose its narrative trail.
|
|
617
|
+
*/
|
|
618
|
+
const MAX_COMBINED_BODY_LINES = 13
|
|
619
|
+
/** Each visible worker costs one header line before any history. */
|
|
620
|
+
const PER_WORKER_HEADER_COST = 1
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Deterministic per-worker history depth for `w` visible workers:
|
|
624
|
+
* clamp( floor( (BUDGET − headerCost·w) / w ), 1, STATUS_ROLLING_LINES )
|
|
625
|
+
* So 2 workers → 5 lines each, 3 → 3, 4 → 2, ≥6 → 1 (today's single-line floor
|
|
626
|
+
* is the graceful-degradation floor, never below it). Pure function of the
|
|
627
|
+
* visible worker count — no model input, consistent with deterministic controls.
|
|
628
|
+
*/
|
|
629
|
+
export function combinedHistoryDepth(w: number): number {
|
|
630
|
+
if (w <= 0) return 1
|
|
631
|
+
const raw = Math.floor((MAX_COMBINED_BODY_LINES - PER_WORKER_HEADER_COST * w) / w)
|
|
632
|
+
return Math.max(1, Math.min(STATUS_ROLLING_LINES, raw))
|
|
633
|
+
}
|
|
634
|
+
|
|
596
635
|
/**
|
|
597
636
|
* Render N≥1 live workers into ONE combined feed body (ready Telegram
|
|
598
637
|
* markdown; callers send verbatim — do NOT re-escape). Layout:
|
|
599
638
|
*
|
|
600
639
|
* 🛠 **Workers** · _N running_
|
|
601
640
|
* **{desc1}** _· {elapsed} · {n} tools_
|
|
602
|
-
*
|
|
641
|
+
* ~~_✓ {earlier step}_~~
|
|
642
|
+
* **→ {newest step}**
|
|
603
643
|
* **{desc2}** _· {elapsed} · {n} tools_
|
|
604
|
-
*
|
|
644
|
+
* **→ {newest step}**
|
|
605
645
|
* _+M more working…_
|
|
606
646
|
*
|
|
647
|
+
* ADAPTIVE DENSITY: each visible worker renders its last-K narrative lines as a
|
|
648
|
+
* `✓`/`→` trail (prior steps struck, newest bold in-progress) — the single-
|
|
649
|
+
* worker card's idiom — where K = `combinedHistoryDepth(visibleCount)` splits a
|
|
650
|
+
* fixed body-line budget across the running workers. So 2 workers each show
|
|
651
|
+
* their full recent history and a 6-way fan-out degrades to one line each, the
|
|
652
|
+
* card staying bounded regardless of fan-out. When a worker has no history yet
|
|
653
|
+
* it falls back to a single `→ starting…`/currentStep line.
|
|
654
|
+
*
|
|
607
655
|
* Pure. Rows are rendered in the order supplied (the manager passes them
|
|
608
656
|
* dispatch-order, oldest first). `maxRows` caps the visible rows; the hidden
|
|
609
657
|
* remainder collapses to a single `+M more working…` line. A total-budget
|
|
@@ -618,29 +666,43 @@ export function renderCombinedWorkerFeed(
|
|
|
618
666
|
if (rows.length === 0) return null
|
|
619
667
|
const maxRows = Math.max(1, Math.floor(opts.maxRows))
|
|
620
668
|
|
|
621
|
-
const
|
|
669
|
+
const rowHeader = (r: CombinedWorkerRow): string => {
|
|
622
670
|
const desc = escapeMarkdown(
|
|
623
671
|
truncate(stripMarkdown(r.description).replace(/\s+/g, ' ').trim() || 'background task', COMBINED_ROW_DESC_MAX),
|
|
624
672
|
)
|
|
625
673
|
const toolWord = r.toolCount === 1 ? 'tool' : 'tools'
|
|
626
674
|
const modelLabel = formatModelLabel(r.model)
|
|
627
675
|
const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
676
|
+
return `**${desc}** _· ${formatFeedElapsed(r.elapsedMs)} · ${r.toolCount} ${toolWord}${modelPart}_`
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Raw (unescaped) history for a worker, oldest→newest, empty lines stripped.
|
|
680
|
+
// Falls back to the single currentStep when no history was supplied.
|
|
681
|
+
const rowHistory = (r: CombinedWorkerRow): string[] => {
|
|
682
|
+
const src = r.historyLines != null && r.historyLines.length > 0 ? r.historyLines : [r.currentStep]
|
|
683
|
+
return src.filter((s) => s != null && stripMarkdown(s).replace(/\s+/g, ' ').trim().length > 0)
|
|
635
684
|
}
|
|
636
685
|
|
|
637
686
|
const compose = (visibleCount: number): string => {
|
|
638
687
|
const shown = rows.slice(0, visibleCount)
|
|
639
688
|
const hidden = rows.length - shown.length
|
|
689
|
+
// Adaptive depth: split the fixed body-line budget across the VISIBLE
|
|
690
|
+
// workers so the card stays bounded regardless of fan-out.
|
|
691
|
+
const depth = combinedHistoryDepth(shown.length)
|
|
640
692
|
const out: string[] = [`🛠 **Workers** · _${rows.length} running_`]
|
|
641
693
|
for (const r of shown) {
|
|
642
|
-
|
|
643
|
-
|
|
694
|
+
out.push(rowHeader(r))
|
|
695
|
+
const hist = rowHistory(r)
|
|
696
|
+
if (hist.length === 0) {
|
|
697
|
+
out.push('→ _starting…_')
|
|
698
|
+
continue
|
|
699
|
+
}
|
|
700
|
+
// Paint the last-K history lines with the SAME `✓`/`→` idiom as the
|
|
701
|
+
// single-worker card: escape each raw line through the shared per-line
|
|
702
|
+
// pipeline (escapeStepLine), then renderStepFeed strikes the prior steps
|
|
703
|
+
// and bolds the newest in-progress step.
|
|
704
|
+
const esc = hist.slice(-depth).map(escapeStepLine)
|
|
705
|
+
renderStepFeed(out, esc, false)
|
|
644
706
|
}
|
|
645
707
|
if (hidden > 0) out.push(`_+${hidden} more working…_`)
|
|
646
708
|
return stackCardLines(out)
|
|
@@ -199,7 +199,8 @@ export function decideTurnFlush(input: FlushDecisionInput): FlushDecision {
|
|
|
199
199
|
// render as an undifferentiated wall-of-text. `\n\n` is the GFM paragraph
|
|
200
200
|
// separator; the gateway send path then wedges visible spacers into those
|
|
201
201
|
// gaps via addParagraphSpacers (mirroring the reply path, #2692) so the
|
|
202
|
-
// paragraphs render with real separation
|
|
202
|
+
// paragraphs render with real separation (the rich GFM renderer otherwise
|
|
203
|
+
// shows a bare `\n\n` gap TIGHT).
|
|
203
204
|
//
|
|
204
205
|
// The silent-marker guards below are unaffected by this change:
|
|
205
206
|
// isSilentFlushMarker length-guards the whole joined string; the composite /
|