switchroom 0.16.47 → 0.17.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/agent-scheduler/index.js +3 -1
- package/dist/auth-broker/index.js +24 -8
- package/dist/cli/drive-write-pretool.mjs +0 -5
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +1358 -1030
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +53 -17
- package/dist/vault/approvals/kernel-server.js +4 -1
- package/dist/vault/broker/server.js +201 -56
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +8 -43
- package/telegram-plugin/dist/gateway/gateway.js +682 -773
- package/telegram-plugin/dist/server.js +8 -43
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +518 -624
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import { readFileSync } from 'node:fs'
|
|
17
18
|
import {
|
|
18
19
|
decideTurnFlush,
|
|
19
20
|
isSilentFlushMarker,
|
|
@@ -21,6 +22,20 @@ import {
|
|
|
21
22
|
endsWithSilentMarker,
|
|
22
23
|
isTurnFlushSafetyEnabled,
|
|
23
24
|
} from '../turn-flush-safety.js'
|
|
25
|
+
// Rich-message send-path primitives (Bot API 10.1, #2669/#2692). The #2798
|
|
26
|
+
// regression suite below reconstructs the exact gateway turn-flush render
|
|
27
|
+
// pipeline from these so it pins end-to-end behaviour, not just the pure
|
|
28
|
+
// decision function.
|
|
29
|
+
import {
|
|
30
|
+
repairEscapedWhitespace,
|
|
31
|
+
normalizeParagraphBreaks,
|
|
32
|
+
normalizePunctuation,
|
|
33
|
+
stripExcessBold,
|
|
34
|
+
addParagraphSpacers,
|
|
35
|
+
splitMarkdownChunks,
|
|
36
|
+
PARAGRAPH_SPACER,
|
|
37
|
+
RICH_MESSAGE_MAX_CHARS,
|
|
38
|
+
} from '../format.js'
|
|
24
39
|
|
|
25
40
|
describe('isCompositeSilentNoise — Stop-hook re-prompt leak backstop', () => {
|
|
26
41
|
it('suppresses the observed leak "Sent.\\nNO_REPLY\\nNO_REPLY"', () => {
|
|
@@ -109,6 +124,209 @@ describe('decideTurnFlush — prose+trailing-sentinel is suppressed, not leaked
|
|
|
109
124
|
})
|
|
110
125
|
})
|
|
111
126
|
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// #2798 — turn-flush must render multiple WHOLE assistant text blocks with
|
|
129
|
+
// paragraph separation, not a collapsed wall-of-text. This suite reconstructs
|
|
130
|
+
// the real gateway turn-flush render pipeline (post-#2669 rich-markdown path):
|
|
131
|
+
// decideTurnFlush -> join('\n\n')
|
|
132
|
+
// -> repairEscapedWhitespace -> normalizeParagraphBreaks
|
|
133
|
+
// -> addParagraphSpacers -> splitMarkdownChunks -> sendRichMessage
|
|
134
|
+
// so it pins the end-to-end fix, not just the pure decision. The corpus is a
|
|
135
|
+
// REAL captured-transcript shape (three separate content[i].text blocks, one
|
|
136
|
+
// stored UNTRIMMED with a trailing '\n' exactly as session-tail.ts pushes
|
|
137
|
+
// them), NOT a hand-authored ['para A','para B'] that would pass by
|
|
138
|
+
// construction regardless of the join separator.
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
describe('#2798 turn-flush block separation — real multi-block transcript shape', () => {
|
|
141
|
+
const realBlocks = [
|
|
142
|
+
"I've finished reviewing the three files you flagged.",
|
|
143
|
+
// Untrimmed: a real assistant text block keeps its trailing newline
|
|
144
|
+
// (session-tail.ts drops only empty/whitespace-only blocks). This is the
|
|
145
|
+
// "join('\\n\\n') on a block already ending in '\\n' stacks 3 newlines"
|
|
146
|
+
// case the design note calls out.
|
|
147
|
+
'The `auth` handler looks correct. The token-refresh path has a race, ' +
|
|
148
|
+
'though: two concurrent requests can both trigger a refresh and the ' +
|
|
149
|
+
'second clobbers the first.\n',
|
|
150
|
+
'Want me to open a PR with the mutex fix, or would you rather patch it ' +
|
|
151
|
+
'inline first?',
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
// Mirror the gateway turn-flush send pipeline exactly.
|
|
155
|
+
function renderLikeTurnFlush(blocks: string[]): string {
|
|
156
|
+
const d = decideTurnFlush({ chatId: '12345', replyCalled: false, capturedText: blocks })
|
|
157
|
+
expect(d.kind).toBe('flush')
|
|
158
|
+
const joined = (d as { kind: 'flush'; text: string }).text
|
|
159
|
+
const normalized = normalizeParagraphBreaks(repairEscapedWhitespace(joined))
|
|
160
|
+
return addParagraphSpacers(normalized)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it('separates whole blocks with a visible paragraph gap, not a wall-of-text', () => {
|
|
164
|
+
const out = renderLikeTurnFlush(realBlocks)
|
|
165
|
+
// Every block's content survives.
|
|
166
|
+
expect(out).toContain("I've finished reviewing")
|
|
167
|
+
expect(out).toContain('The `auth` handler looks correct')
|
|
168
|
+
expect(out).toContain('Want me to open a PR')
|
|
169
|
+
// The wall-of-text failure mode glues two blocks one '\n' apart. Assert the
|
|
170
|
+
// boundary carries a real paragraph break with the injected visible spacer
|
|
171
|
+
// line (#2692 rich-path spacer), and NOT a single-newline join.
|
|
172
|
+
expect(out).toContain(`\n\n${PARAGRAPH_SPACER}\n\n`)
|
|
173
|
+
expect(out).not.toContain('flagged.\nThe `auth`')
|
|
174
|
+
// A spacer sits specifically between block 1 and block 2.
|
|
175
|
+
const b1 = out.indexOf('flagged.')
|
|
176
|
+
const b2 = out.indexOf('The `auth` handler')
|
|
177
|
+
expect(out.slice(b1, b2)).toContain(PARAGRAPH_SPACER)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('collapses the untrimmed-trailing-newline stack — no 3+ newline run reaches the wire', () => {
|
|
181
|
+
const out = renderLikeTurnFlush(realBlocks)
|
|
182
|
+
// Block 2's trailing '\n' + the '\n\n' join = 3 newlines; normalize
|
|
183
|
+
// collapses 3+ runs to '\n\n' and addParagraphSpacers wedges exactly one
|
|
184
|
+
// spacer, so no doubled/stacked blank run survives.
|
|
185
|
+
expect(out).not.toMatch(/\n{3,}/)
|
|
186
|
+
// One spacer per block transition: 3 blocks → 2 gaps → 2 spacers.
|
|
187
|
+
const spacerCount = out.split(PARAGRAPH_SPACER).length - 1
|
|
188
|
+
expect(spacerCount).toBe(2)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('the whole separated answer stays in one rich chunk here (well under 32768)', () => {
|
|
192
|
+
const out = renderLikeTurnFlush(realBlocks)
|
|
193
|
+
const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
|
|
194
|
+
expect(chunks.length).toBe(1)
|
|
195
|
+
expect(chunks[0]).toContain(PARAGRAPH_SPACER)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('still SUPPRESSES a real transcript that deliberately terminates with a bare NO_REPLY (#2053 guard intact)', () => {
|
|
199
|
+
// Same real prose shape, but the model closed with NO_REPLY as its final
|
|
200
|
+
// block (intentional silence). The '\n\n' join must NOT defeat the
|
|
201
|
+
// trailing-marker guard.
|
|
202
|
+
const d = decideTurnFlush({
|
|
203
|
+
chatId: '12345',
|
|
204
|
+
replyCalled: false,
|
|
205
|
+
capturedText: [
|
|
206
|
+
'Reviewed the overnight digest. Nothing needs your attention: all ' +
|
|
207
|
+
'three checks are green and the backup completed at 04:12.',
|
|
208
|
+
'NO_REPLY',
|
|
209
|
+
],
|
|
210
|
+
})
|
|
211
|
+
expect(d).toEqual({ kind: 'skip', reason: 'silent-marker' })
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('still SUPPRESSES the composite silent-noise blob under the new join', () => {
|
|
215
|
+
const d = decideTurnFlush({
|
|
216
|
+
chatId: '12345',
|
|
217
|
+
replyCalled: false,
|
|
218
|
+
capturedText: ['Sent.', 'NO_REPLY', 'NO_REPLY'],
|
|
219
|
+
})
|
|
220
|
+
expect(d).toEqual({ kind: 'skip', reason: 'silent-marker' })
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// #2798 reply-parity follow-up — the turn-flush normalization chain must apply
|
|
226
|
+
// the SAME steps in the SAME order as executeReply, so a backstop-delivered
|
|
227
|
+
// answer renders byte-for-byte like the identical text sent via `reply`. Reply
|
|
228
|
+
// runs (gateway executeReply):
|
|
229
|
+
// repairEscapedWhitespace -> normalizeParagraphBreaks -> redactOutboundText
|
|
230
|
+
// -> stripExcessBold(normalizePunctuation) -> scrubVoice
|
|
231
|
+
// -> addParagraphSpacers (send side)
|
|
232
|
+
// The original #2798 change gave turn-flush the paragraph steps + redact +
|
|
233
|
+
// scrub + spacers but OMITTED `stripExcessBold(normalizePunctuation(...))`.
|
|
234
|
+
// This suite reconstructs the deterministic format chain of BOTH paths (the
|
|
235
|
+
// runtime-only redact + voice-scrub steps are literally the same calls on both
|
|
236
|
+
// paths and are out of scope here) and pins that turn-flush now matches reply
|
|
237
|
+
// on an input that exercises the two added steps: unicode bullets + a spaced
|
|
238
|
+
// en-/em-dash (normalizePunctuation) and an over-bolded prose block
|
|
239
|
+
// (stripExcessBold).
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
describe('#2798 turn-flush punctuation/bold parity with reply', () => {
|
|
242
|
+
// SOURCE-STRUCTURAL guard (house pattern: gateway-outbound-redact.test.ts
|
|
243
|
+
// reads gateway.ts source and asserts the relative position of calls).
|
|
244
|
+
//
|
|
245
|
+
// The reply-parity contract is that the turn_flush backstop applies the SAME
|
|
246
|
+
// `stripExcessBold(normalizePunctuation(...))` normalization the reply path
|
|
247
|
+
// applies, in the SAME slot — after redactOutboundText, before scrubVoice.
|
|
248
|
+
//
|
|
249
|
+
// A prior version of this suite "guarded" that contract by defining two
|
|
250
|
+
// BYTE-IDENTICAL local functions (replyFormatChain / turnFlushFormatChain)
|
|
251
|
+
// and asserting `toBe` between them. That was tautological: the two locals
|
|
252
|
+
// were equal by construction regardless of what gateway.ts did, so deleting
|
|
253
|
+
// the normalization line from the turn_flush branch reddened NO test. The
|
|
254
|
+
// assertions below instead read the gateway source and pin the actual call
|
|
255
|
+
// ordering, so removing the `stripExcessBold(normalizePunctuation(capturedText))`
|
|
256
|
+
// line from the turn_flush branch REDS this suite — which is the exact
|
|
257
|
+
// regression #2798/#2813 shipped to prevent.
|
|
258
|
+
const gatewaySrc = readFileSync(
|
|
259
|
+
new URL('../gateway/gateway.ts', import.meta.url),
|
|
260
|
+
'utf8',
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
it('reply path: normalizes AFTER redact and BEFORE the voice scrub', () => {
|
|
264
|
+
const start = gatewaySrc.indexOf('async function executeReply(')
|
|
265
|
+
const redactIdx = gatewaySrc.indexOf(`redactOutboundText(text, 'reply')`, start)
|
|
266
|
+
const normIdx = gatewaySrc.indexOf('stripExcessBold(normalizePunctuation(text))', start)
|
|
267
|
+
const scrubIdx = gatewaySrc.indexOf('scrubVoice(text)', start)
|
|
268
|
+
expect(start).toBeGreaterThan(0)
|
|
269
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
270
|
+
expect(normIdx).toBeGreaterThan(redactIdx) // normalize AFTER the reply redact
|
|
271
|
+
expect(scrubIdx).toBeGreaterThan(normIdx) // ...and BEFORE the voice scrub
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('turn-flush backstop: applies the SAME normalization in the SAME slot (REDS if the line is removed)', () => {
|
|
275
|
+
const redactIdx = gatewaySrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
|
|
276
|
+
// The normalization call the reply path uses, verbatim, on the turn_flush
|
|
277
|
+
// variable. This indexOf is what returns -1 (→ assertion fails) if the
|
|
278
|
+
// `stripExcessBold(normalizePunctuation(capturedText))` line is deleted
|
|
279
|
+
// from the turn_flush branch.
|
|
280
|
+
const normIdx = gatewaySrc.indexOf('stripExcessBold(normalizePunctuation(capturedText))', redactIdx)
|
|
281
|
+
const scrubIdx = gatewaySrc.indexOf('scrubVoice(capturedText)', redactIdx)
|
|
282
|
+
expect(redactIdx).toBeGreaterThan(0)
|
|
283
|
+
expect(normIdx).toBeGreaterThan(redactIdx) // normalize AFTER the turn_flush redact
|
|
284
|
+
expect(scrubIdx).toBeGreaterThan(normIdx) // ...and BEFORE the voice scrub — mirrors reply
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('both send sites share the identical `stripExcessBold(normalizePunctuation(` wrapper', () => {
|
|
288
|
+
// Parity, structurally: the exact normalization wrapper the reply path uses
|
|
289
|
+
// is the one the turn_flush branch uses — same call, not a lookalike.
|
|
290
|
+
expect(gatewaySrc).toContain('stripExcessBold(normalizePunctuation(text))')
|
|
291
|
+
expect(gatewaySrc).toContain('stripExcessBold(normalizePunctuation(capturedText))')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// Behavioural coverage (kept from the original suite): reconstruct the
|
|
295
|
+
// deterministic format chain both sites apply and assert the REAL
|
|
296
|
+
// strip/normalize behaviour the added step provides — the over-bold block is
|
|
297
|
+
// stripped, unicode bullets become GFM list items, dashes are normalized.
|
|
298
|
+
function formatChain(text: string): string {
|
|
299
|
+
let t = normalizeParagraphBreaks(repairEscapedWhitespace(text))
|
|
300
|
+
t = stripExcessBold(normalizePunctuation(t))
|
|
301
|
+
return addParagraphSpacers(t)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const input =
|
|
305
|
+
'**This whole paragraph is bolded and is deliberately long enough to ' +
|
|
306
|
+
'exceed the hundred non-code character floor so the over-bold tripwire ' +
|
|
307
|
+
'fires on it every time.**\n\n' +
|
|
308
|
+
'• first bullet\n• second bullet\n\n' +
|
|
309
|
+
'A range like 2019 – 2024 and a spaced em-dash a — b for good measure.'
|
|
310
|
+
|
|
311
|
+
it('strips the over-bold block and normalizes bullets/dashes exactly as reply does', () => {
|
|
312
|
+
const out = formatChain(input)
|
|
313
|
+
// stripExcessBold removed the ** markers from the over-bolded paragraph
|
|
314
|
+
// (the step turn-flush was missing) — the text survives, the markers do not.
|
|
315
|
+
expect(out).not.toContain('**This whole paragraph')
|
|
316
|
+
expect(out).toContain('This whole paragraph is bolded')
|
|
317
|
+
// normalizePunctuation turned unicode bullets into GFM '- ' list items.
|
|
318
|
+
expect(out).toContain('- first bullet')
|
|
319
|
+
expect(out).toContain('- second bullet')
|
|
320
|
+
expect(out).not.toContain('• first bullet')
|
|
321
|
+
// and normalized the dashes: spaced en-dash numeric range -> hyphen,
|
|
322
|
+
// spaced em-dash between words -> comma. Same treatment reply applies.
|
|
323
|
+
expect(out).toContain('2019-2024')
|
|
324
|
+
expect(out).toContain('a, b')
|
|
325
|
+
expect(out).not.toContain('2019 – 2024')
|
|
326
|
+
expect(out).not.toContain('a — b')
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
112
330
|
describe('decideTurnFlush', () => {
|
|
113
331
|
it('(a) does NOT flush when the reply tool was called', () => {
|
|
114
332
|
const decision = decideTurnFlush({
|
|
@@ -125,9 +343,12 @@ describe('decideTurnFlush', () => {
|
|
|
125
343
|
replyCalled: false,
|
|
126
344
|
capturedText: ['here is the answer', 'more detail'],
|
|
127
345
|
})
|
|
346
|
+
// #2798 — whole authored text blocks are joined with a PARAGRAPH break
|
|
347
|
+
// ('\n\n'), not a lone '\n', so adjacent blocks don't collapse into a
|
|
348
|
+
// single run on the rich-markdown path.
|
|
128
349
|
expect(decision).toEqual({
|
|
129
350
|
kind: 'flush',
|
|
130
|
-
text: 'here is the answer\nmore detail',
|
|
351
|
+
text: 'here is the answer\n\nmore detail',
|
|
131
352
|
})
|
|
132
353
|
})
|
|
133
354
|
|
|
@@ -637,7 +637,7 @@ describe('rolling window — createWorkerActivityFeed narrative accumulation', (
|
|
|
637
637
|
// ─── Worker heartbeat (option a — suffix-only, never opens a new message) ─────
|
|
638
638
|
|
|
639
639
|
describe('createWorkerActivityFeed — heartbeat', () => {
|
|
640
|
-
it('(i) a tick fires a re-render with a climbing · Ns suffix
|
|
640
|
+
it('(i) a tick fires a re-render with a climbing · Ns suffix showing the current step\'s OWN elapsed', async () => {
|
|
641
641
|
const bot = makeFakeBot()
|
|
642
642
|
let clock = 10_000
|
|
643
643
|
const feed = createWorkerActivityFeed({
|
|
@@ -649,22 +649,50 @@ describe('createWorkerActivityFeed — heartbeat', () => {
|
|
|
649
649
|
setInterval: () => 1,
|
|
650
650
|
clearInterval: () => {},
|
|
651
651
|
})
|
|
652
|
-
// First paint at elapsed 0 (firstPaintMin default 8000 — use 9000).
|
|
652
|
+
// First paint at elapsed 0 (firstPaintMin default 8000 — use 9000). The
|
|
653
|
+
// narrative line 'pulling data' lands here, so the current step starts now.
|
|
653
654
|
clock = 19_000
|
|
654
655
|
await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'pulling data' }))
|
|
655
656
|
expect(bot.sent).toHaveLength(1)
|
|
656
|
-
const
|
|
657
|
+
const stepStart = clock // step began when the '→' line first appeared
|
|
657
658
|
|
|
658
|
-
// Advance past the
|
|
659
|
-
clock =
|
|
659
|
+
// Advance well past the per-step 10s gate so the heartbeat renders a suffix.
|
|
660
|
+
clock = 32_000 // step is now 13s old; lastEditAt(19000)+13000 ≥ heartbeatTickMs
|
|
660
661
|
feed.heartbeatTick()
|
|
661
|
-
await feed.update('w1', 'chat', view({ elapsedMs:
|
|
662
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 22_000, latestSummary: 'pulling data' })).catch(() => {})
|
|
662
663
|
// Drain the chain.
|
|
663
|
-
await feed.update('w1', 'chat', view({ elapsedMs:
|
|
664
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 22_000, latestSummary: 'pulling data' }))
|
|
664
665
|
const edit1 = bot.edits.find((e) => /· \d+s\*\*/.test(e.text))
|
|
665
666
|
expect(edit1).toBeDefined()
|
|
666
|
-
// The suffix reflects the
|
|
667
|
-
|
|
667
|
+
// The step suffix reflects the STEP's OWN elapsed (now - stepStart), NOT
|
|
668
|
+
// the worker total — the header already carries the total.
|
|
669
|
+
expect(edit1!.text).toContain(`· ${Math.floor((32_000 - stepStart) / 1000)}s`)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('(i-a) suppresses the step suffix while the current step is younger than the 10s gate', async () => {
|
|
673
|
+
const bot = makeFakeBot()
|
|
674
|
+
let clock = 10_000
|
|
675
|
+
const feed = createWorkerActivityFeed({
|
|
676
|
+
bot,
|
|
677
|
+
now: () => clock,
|
|
678
|
+
minEditIntervalMs: 2500,
|
|
679
|
+
heartbeatTickMs: 6000,
|
|
680
|
+
setInterval: () => 1,
|
|
681
|
+
clearInterval: () => {},
|
|
682
|
+
})
|
|
683
|
+
// Step begins here.
|
|
684
|
+
clock = 19_000
|
|
685
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'pulling data' }))
|
|
686
|
+
expect(bot.sent).toHaveLength(1)
|
|
687
|
+
|
|
688
|
+
// Heartbeat only 7s into the step — under STEP_TIMER_MIN_MS (10s), so no
|
|
689
|
+
// `· Ns` suffix on the '→' line yet (the header total still climbs).
|
|
690
|
+
clock = 26_000
|
|
691
|
+
feed.heartbeatTick()
|
|
692
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' })).catch(() => {})
|
|
693
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' }))
|
|
694
|
+
const stepSuffixEdit = bot.edits.find((e) => /· \d+s\*\*/.test(e.text))
|
|
695
|
+
expect(stepSuffixEdit).toBeUndefined()
|
|
668
696
|
})
|
|
669
697
|
|
|
670
698
|
it('(i-b) heartbeat repaint keeps the header master elapsed >= the step timer (same clock anchor)', async () => {
|
|
@@ -801,6 +829,120 @@ describe('createWorkerActivityFeed — heartbeat', () => {
|
|
|
801
829
|
await feed.update('w1', 'chat', view({ elapsedMs: 2000, latestSummary: 'go' })).catch(() => {})
|
|
802
830
|
expect(bot.edits.length).toBe(editsBefore)
|
|
803
831
|
})
|
|
832
|
+
|
|
833
|
+
// ─── Prose-silent worker: first paint driven by the heartbeat ──────────────
|
|
834
|
+
// The user-visible bug: a background worker that dives straight into quiet
|
|
835
|
+
// work (a long `Bash` / `npm test`) fires ONE `sub_agent_tool_use` tick when
|
|
836
|
+
// the command starts, then no more JSONL lines for the whole run. If that one
|
|
837
|
+
// tick lands before `firstPaintMin` the paint is held — and pre-fix the
|
|
838
|
+
// heartbeat skipped `messageId == null` handles, so nothing ever re-drove the
|
|
839
|
+
// paint and the worker showed NOTHING for its entire run. The heartbeat now
|
|
840
|
+
// performs the first paint once the held handle is past `firstPaintMin`.
|
|
841
|
+
it('(vi) paints a prose-silent worker whose only tick arrived before firstPaintMin, via a later heartbeat', async () => {
|
|
842
|
+
const bot = makeFakeBot()
|
|
843
|
+
let clock = 0
|
|
844
|
+
const feed = createWorkerActivityFeed({
|
|
845
|
+
bot,
|
|
846
|
+
now: () => clock,
|
|
847
|
+
firstPaintMinMs: 8000,
|
|
848
|
+
heartbeatTickMs: 6000,
|
|
849
|
+
minEditIntervalMs: 2500,
|
|
850
|
+
setInterval: () => 1,
|
|
851
|
+
clearInterval: () => {},
|
|
852
|
+
})
|
|
853
|
+
const drain = () => new Promise((r) => setTimeout(r, 0))
|
|
854
|
+
|
|
855
|
+
// A single tool tick at 2s (the Bash invocation) — before firstPaintMin,
|
|
856
|
+
// so the paint is held. This is the ONLY update the worker ever sends.
|
|
857
|
+
clock = 2000
|
|
858
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 1, latestSummary: '' }))
|
|
859
|
+
expect(bot.sent).toHaveLength(0)
|
|
860
|
+
expect(feed.messageIdOf('w1')).toBeNull()
|
|
861
|
+
|
|
862
|
+
// A heartbeat still before firstPaintMin holds — trivial workers stay silent.
|
|
863
|
+
clock = 6000
|
|
864
|
+
feed.heartbeatTick()
|
|
865
|
+
await drain()
|
|
866
|
+
expect(bot.sent).toHaveLength(0)
|
|
867
|
+
expect(feed.messageIdOf('w1')).toBeNull()
|
|
868
|
+
|
|
869
|
+
// A heartbeat PAST firstPaintMin performs the first paint — the worker
|
|
870
|
+
// becomes visible even though it never emitted prose or a second tick.
|
|
871
|
+
clock = 12_000
|
|
872
|
+
feed.heartbeatTick()
|
|
873
|
+
await drain()
|
|
874
|
+
expect(bot.sent).toHaveLength(1)
|
|
875
|
+
expect(feed.messageIdOf('w1')).toBe(1000)
|
|
876
|
+
expect(bot.sent[0].text).toContain('🛠 **Worker**')
|
|
877
|
+
|
|
878
|
+
// And it keeps updating: a later heartbeat edits the message with a
|
|
879
|
+
// climbing `· Ns` suffix so the still-alive worker visibly advances.
|
|
880
|
+
clock = 20_000
|
|
881
|
+
feed.heartbeatTick()
|
|
882
|
+
await drain()
|
|
883
|
+
expect(bot.edits.length).toBeGreaterThanOrEqual(1)
|
|
884
|
+
// No narrative step (prose-silent) → the advance shows in the header's
|
|
885
|
+
// climbing master elapsed rather than a `· Ns` step suffix.
|
|
886
|
+
expect(bot.edits[bot.edits.length - 1].text).toMatch(/_\d+s · 1 tool_/)
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
it('(vii) still holds a trivial sub-firstPaintMin worker silent (heartbeat never force-paints early)', async () => {
|
|
890
|
+
const bot = makeFakeBot()
|
|
891
|
+
let clock = 0
|
|
892
|
+
const feed = createWorkerActivityFeed({
|
|
893
|
+
bot,
|
|
894
|
+
now: () => clock,
|
|
895
|
+
firstPaintMinMs: 8000,
|
|
896
|
+
heartbeatTickMs: 6000,
|
|
897
|
+
setInterval: () => 1,
|
|
898
|
+
clearInterval: () => {},
|
|
899
|
+
})
|
|
900
|
+
const drain = () => new Promise((r) => setTimeout(r, 0))
|
|
901
|
+
// Worker ticks once at 1s then finishes at 3s (handback covers it). No
|
|
902
|
+
// heartbeat before firstPaintMin may paint it.
|
|
903
|
+
clock = 1000
|
|
904
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1, latestSummary: '' }))
|
|
905
|
+
clock = 3000
|
|
906
|
+
feed.heartbeatTick()
|
|
907
|
+
await drain()
|
|
908
|
+
expect(bot.sent).toHaveLength(0)
|
|
909
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 1 }))
|
|
910
|
+
// finish with no posted message → no recap edit (handback covers the result).
|
|
911
|
+
expect(bot.edits).toHaveLength(0)
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
it('(viii) a finished, still-unpainted worker is NOT orphan-painted by a later heartbeat', async () => {
|
|
915
|
+
// Regression guard: the heartbeat first-paint branch must never send a
|
|
916
|
+
// fresh `running` message on an already-finished handle. finish() deletes
|
|
917
|
+
// the handle; a subsequent heartbeat (even one past firstPaintMin) must
|
|
918
|
+
// skip it — otherwise a permanently orphaned card that never finalizes.
|
|
919
|
+
const bot = makeFakeBot()
|
|
920
|
+
let clock = 0
|
|
921
|
+
const feed = createWorkerActivityFeed({
|
|
922
|
+
bot,
|
|
923
|
+
now: () => clock,
|
|
924
|
+
firstPaintMinMs: 8000,
|
|
925
|
+
heartbeatTickMs: 6000,
|
|
926
|
+
setInterval: () => 1,
|
|
927
|
+
clearInterval: () => {},
|
|
928
|
+
})
|
|
929
|
+
const drain = () => new Promise((r) => setTimeout(r, 0))
|
|
930
|
+
// One held tick before firstPaintMin — no paint yet.
|
|
931
|
+
clock = 2000
|
|
932
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 1, latestSummary: '' }))
|
|
933
|
+
expect(bot.sent).toHaveLength(0)
|
|
934
|
+
expect(feed.messageIdOf('w1')).toBeNull()
|
|
935
|
+
// Worker finishes while still unpainted (finish drops the handle).
|
|
936
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 1 }))
|
|
937
|
+
expect(feed.messageIdOf('w1')).toBeNull()
|
|
938
|
+
// A heartbeat well past firstPaintMin must NOT paint a new message.
|
|
939
|
+
clock = 20_000
|
|
940
|
+
feed.heartbeatTick()
|
|
941
|
+
await drain()
|
|
942
|
+
expect(bot.sent).toHaveLength(0)
|
|
943
|
+
expect(bot.edits).toHaveLength(0)
|
|
944
|
+
expect(feed.messageIdOf('w1')).toBeNull()
|
|
945
|
+
})
|
|
804
946
|
})
|
|
805
947
|
|
|
806
948
|
// ─── Extreme-edge: single oversized narrative line (no-truncate ON) ──────────
|
|
@@ -998,3 +1140,54 @@ describe('header row + rolling overflow survive in the unified worker render', (
|
|
|
998
1140
|
expect(isValidWorkerMarkdown(out)).toBe(true)
|
|
999
1141
|
})
|
|
1000
1142
|
})
|
|
1143
|
+
|
|
1144
|
+
// ─── Dedup — non-adjacent repeats within the rolling window (unified cards) ──
|
|
1145
|
+
|
|
1146
|
+
describe('narrative dedup — non-adjacent repeats collapse (A,B,A)', () => {
|
|
1147
|
+
it('a line already in the rolling window is not re-appended', async () => {
|
|
1148
|
+
const bot = makeFakeBot()
|
|
1149
|
+
let clock = 10_000
|
|
1150
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
1151
|
+
|
|
1152
|
+
// The live-observed duplication: a preamble ("Look for nested subagents
|
|
1153
|
+
// structure") surfaces, another step lands, then the same text re-fires
|
|
1154
|
+
// (e.g. as the Task tool's describeToolUse label for the same step).
|
|
1155
|
+
const seq = [
|
|
1156
|
+
'Look for nested subagents structure',
|
|
1157
|
+
'Inspect subagents dir files',
|
|
1158
|
+
'Look for nested subagents structure', // non-adjacent repeat — must collapse
|
|
1159
|
+
'Inspect subagents dir files', // non-adjacent repeat — must collapse
|
|
1160
|
+
'Tail the child jsonl',
|
|
1161
|
+
]
|
|
1162
|
+
for (const line of seq) {
|
|
1163
|
+
clock += 1000
|
|
1164
|
+
await feed.update('w-dedup', 'chat', view({ latestSummary: line }))
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const last = bot.edits.at(-1) ?? bot.sent.at(-1)!
|
|
1168
|
+
const count = (needle: string): number => last.text.split(needle).length - 1
|
|
1169
|
+
expect(count('Look for nested subagents structure')).toBe(1)
|
|
1170
|
+
expect(count('Inspect subagents dir files')).toBe(1)
|
|
1171
|
+
expect(last.text).toContain('Tail the child jsonl')
|
|
1172
|
+
// Source order preserved: first occurrence wins its slot.
|
|
1173
|
+
expect(last.text.indexOf('Look for nested subagents structure'))
|
|
1174
|
+
.toBeLessThan(last.text.indexOf('Inspect subagents dir files'))
|
|
1175
|
+
})
|
|
1176
|
+
|
|
1177
|
+
it('a legitimate revisit re-appears once the earlier copy scrolls out of the window', async () => {
|
|
1178
|
+
const bot = makeFakeBot()
|
|
1179
|
+
let clock = 10_000
|
|
1180
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
1181
|
+
|
|
1182
|
+
await feed.update('w2', 'chat', view({ latestSummary: 'step-repeat' }))
|
|
1183
|
+
// Push STATUS_ROLLING_LINES distinct lines so 'step-repeat' scrolls out.
|
|
1184
|
+
for (let i = 0; i < STATUS_ROLLING_LINES; i++) {
|
|
1185
|
+
clock += 1000
|
|
1186
|
+
await feed.update('w2', 'chat', view({ latestSummary: `filler-${i}` }))
|
|
1187
|
+
}
|
|
1188
|
+
clock += 1000
|
|
1189
|
+
await feed.update('w2', 'chat', view({ latestSummary: 'step-repeat' }))
|
|
1190
|
+
const last = bot.edits.at(-1)!
|
|
1191
|
+
expect(last.text).toContain('step-repeat')
|
|
1192
|
+
})
|
|
1193
|
+
})
|
|
@@ -16,6 +16,7 @@ function makeSub(over: Partial<Subagent>): Subagent {
|
|
|
16
16
|
status: 'running',
|
|
17
17
|
result_summary: null,
|
|
18
18
|
jsonl_agent_id: 'a37ad7639ae61476c',
|
|
19
|
+
parent_agent_id: null,
|
|
19
20
|
...over,
|
|
20
21
|
}
|
|
21
22
|
}
|
|
@@ -138,3 +139,27 @@ describe('resolveWorkerFeedDispatch — randomized property sweep', () => {
|
|
|
138
139
|
}
|
|
139
140
|
})
|
|
140
141
|
})
|
|
142
|
+
|
|
143
|
+
describe('resolveWorkerFeedDispatch — nested/row-presence signals (unified progress cards)', () => {
|
|
144
|
+
it('hasRow=false for a missing registry row (never silently foreground-nest it)', () => {
|
|
145
|
+
const out = resolveWorkerFeedDispatch(null, 'sub-agent')
|
|
146
|
+
expect(out.hasRow).toBe(false)
|
|
147
|
+
expect(out.isNested).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('hasRow=true for any present row', () => {
|
|
151
|
+
expect(resolveWorkerFeedDispatch(makeSub({}), 'sub-agent').hasRow).toBe(true)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('isNested=true when parent_agent_id is set (depth-2+ dispatch)', () => {
|
|
155
|
+
const sub = makeSub({ parent_agent_id: 'ac15e1e3528f421d6', background: false })
|
|
156
|
+
const out = resolveWorkerFeedDispatch(sub, 'sub-agent')
|
|
157
|
+
expect(out.isNested).toBe(true)
|
|
158
|
+
// Its own background flag stays honest — the caller ORs isNested in.
|
|
159
|
+
expect(out.isBackground).toBe(false)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('isNested=false for a main-session dispatch (parent_agent_id null)', () => {
|
|
163
|
+
expect(resolveWorkerFeedDispatch(makeSub({ parent_agent_id: null }), 's').isNested).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
})
|