switchroom 0.18.31 → 0.18.33
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 +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome pins for the on-demand voice ('🔊 Listen') callback family
|
|
3
|
+
* (`voice:<token>`) extracted from the gateway's callback_query dispatcher
|
|
4
|
+
* (switchroom#2996 P6). Uses the REAL voice-ondemand token helpers and the
|
|
5
|
+
* REAL sendVoiceReusingFileId contract via injected send fns: asserts the
|
|
6
|
+
* fall-through for non-voice data, the auth gate, expiry toast, the
|
|
7
|
+
* file_id fast path (no upload), the upload path capturing the returned
|
|
8
|
+
* file_id, and the single-use keyboard strip on success only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
12
|
+
import {
|
|
13
|
+
handleVoiceOnDemandCallback,
|
|
14
|
+
type VoiceOnDemandCallbackDeps,
|
|
15
|
+
} from '../gateway/voice-ondemand-callback-handler.js'
|
|
16
|
+
|
|
17
|
+
const TOKEN = 'tok123'
|
|
18
|
+
const DATA = `voice:${TOKEN}`
|
|
19
|
+
|
|
20
|
+
function makeCtx(over: Record<string, unknown> = {}) {
|
|
21
|
+
return {
|
|
22
|
+
from: { id: 42 },
|
|
23
|
+
chat: { id: 42 },
|
|
24
|
+
callbackQuery: { message: { message_id: 77 } },
|
|
25
|
+
answerCallbackQuery: vi.fn(async () => {}),
|
|
26
|
+
...over,
|
|
27
|
+
} as any
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeDeps(over: Partial<VoiceOnDemandCallbackDeps> = {}) {
|
|
31
|
+
const setTelegramFileId = vi.fn()
|
|
32
|
+
const sendVoiceByFileId = vi.fn(async () => ({ voice: { file_id: 'same' } }))
|
|
33
|
+
const sendVoiceByUpload = vi.fn(async () => ({ voice: { file_id: 'fresh-id' } }))
|
|
34
|
+
const stripListenKeyboard = vi.fn(async () => {})
|
|
35
|
+
const log = vi.fn()
|
|
36
|
+
const deps: VoiceOnDemandCallbackDeps = {
|
|
37
|
+
loadAccess: () => ({ allowFrom: ['42'] }),
|
|
38
|
+
voiceOnDemandCache: {
|
|
39
|
+
get: () => null,
|
|
40
|
+
setTelegramFileId,
|
|
41
|
+
},
|
|
42
|
+
sendVoiceByFileId,
|
|
43
|
+
sendVoiceByUpload,
|
|
44
|
+
stripListenKeyboard,
|
|
45
|
+
log,
|
|
46
|
+
...over,
|
|
47
|
+
}
|
|
48
|
+
return { deps, setTelegramFileId, sendVoiceByFileId, sendVoiceByUpload, stripListenKeyboard, log }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('handleVoiceOnDemandCallback — routing + gates', () => {
|
|
52
|
+
it('returns false for non-voice callback data', async () => {
|
|
53
|
+
const { deps } = makeDeps()
|
|
54
|
+
const ctx = makeCtx()
|
|
55
|
+
expect(await handleVoiceOnDemandCallback(ctx, 'agent:whatever', deps)).toBe(false)
|
|
56
|
+
expect(ctx.answerCallbackQuery).not.toHaveBeenCalled()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('gates non-allowlisted senders', async () => {
|
|
60
|
+
const { deps, sendVoiceByFileId } = makeDeps()
|
|
61
|
+
const ctx = makeCtx({ from: { id: 666 } })
|
|
62
|
+
expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
|
|
63
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
|
|
64
|
+
expect(sendVoiceByFileId).not.toHaveBeenCalled()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('acks an expired token politely', async () => {
|
|
68
|
+
const { deps } = makeDeps() // cache.get → null
|
|
69
|
+
const ctx = makeCtx()
|
|
70
|
+
expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
|
|
71
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({
|
|
72
|
+
text: 'Voice expired — send again to hear it.',
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('handleVoiceOnDemandCallback — send paths', () => {
|
|
78
|
+
it('file_id fast path: sends BY id, strips the keyboard, no upload', async () => {
|
|
79
|
+
const { deps, sendVoiceByFileId, sendVoiceByUpload, stripListenKeyboard } = makeDeps({
|
|
80
|
+
voiceOnDemandCache: {
|
|
81
|
+
get: () => ({ text: 'hello', speed: 1, telegramFileId: 'cached-id' }),
|
|
82
|
+
setTelegramFileId: vi.fn(),
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
const ctx = makeCtx()
|
|
86
|
+
expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
|
|
87
|
+
expect(sendVoiceByFileId).toHaveBeenCalledWith(
|
|
88
|
+
'42',
|
|
89
|
+
'cached-id',
|
|
90
|
+
expect.objectContaining({ reply_parameters: { message_id: 77 } }),
|
|
91
|
+
undefined,
|
|
92
|
+
)
|
|
93
|
+
expect(sendVoiceByUpload).not.toHaveBeenCalled()
|
|
94
|
+
expect(stripListenKeyboard).toHaveBeenCalledWith('42', 77, undefined)
|
|
95
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '🔊' })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('pre-synth file path: uploads the on-disk audio and captures the returned file_id', async () => {
|
|
99
|
+
const { mkdtempSync, writeFileSync } = await import('node:fs')
|
|
100
|
+
const { tmpdir } = await import('node:os')
|
|
101
|
+
const { join } = await import('node:path')
|
|
102
|
+
const dir = mkdtempSync(join(tmpdir(), 'voice-od-'))
|
|
103
|
+
const fp = join(dir, 'a.ogg')
|
|
104
|
+
writeFileSync(fp, Buffer.from([1, 2, 3]))
|
|
105
|
+
const setTelegramFileId = vi.fn()
|
|
106
|
+
const { deps, sendVoiceByUpload, stripListenKeyboard } = makeDeps({
|
|
107
|
+
voiceOnDemandCache: {
|
|
108
|
+
get: () => ({ text: 'hello', speed: 1, filePath: fp }),
|
|
109
|
+
setTelegramFileId,
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
const ctx = makeCtx()
|
|
113
|
+
await handleVoiceOnDemandCallback(ctx, DATA, deps)
|
|
114
|
+
expect(sendVoiceByUpload).toHaveBeenCalled()
|
|
115
|
+
// Returned message's file_id captured back into the cache for reuse.
|
|
116
|
+
expect(setTelegramFileId).toHaveBeenCalledWith(TOKEN, 'fresh-id')
|
|
117
|
+
expect(stripListenKeyboard).toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('send failure: logs non-fatally and leaves the keyboard intact (retryable)', async () => {
|
|
121
|
+
const { deps, stripListenKeyboard, log } = makeDeps({
|
|
122
|
+
voiceOnDemandCache: {
|
|
123
|
+
get: () => ({ text: 'hello', speed: 1, telegramFileId: 'cached-id' }),
|
|
124
|
+
setTelegramFileId: vi.fn(),
|
|
125
|
+
},
|
|
126
|
+
sendVoiceByFileId: vi.fn(async () => {
|
|
127
|
+
throw new Error('boom')
|
|
128
|
+
}),
|
|
129
|
+
// Stale-id fallback then tries upload; make it fail too so the send
|
|
130
|
+
// resolves as send-failed overall. filePath absent → lazy synth path
|
|
131
|
+
// would need the sidecar; keep it simple: upload throws.
|
|
132
|
+
sendVoiceByUpload: vi.fn(async () => {
|
|
133
|
+
throw new Error('boom2')
|
|
134
|
+
}),
|
|
135
|
+
})
|
|
136
|
+
const ctx = makeCtx()
|
|
137
|
+
expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
|
|
138
|
+
expect(stripListenKeyboard).not.toHaveBeenCalled()
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type WorkerActivityView,
|
|
7
7
|
type BotApiForWorkerFeed,
|
|
8
8
|
} from '../worker-activity-feed.js'
|
|
9
|
-
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from '../status-no-truncate.js'
|
|
9
|
+
import { STATUS_ROLLING_LINES, WORKER_HISTORY_MAX, STATUS_LINE_MAX } from '../status-no-truncate.js'
|
|
10
10
|
import { SEND_GATE_SHED } from '../send-gate.js'
|
|
11
11
|
|
|
12
12
|
describe('isWorkerActivityFeedEnabled (default ON)', () => {
|
|
@@ -188,17 +188,31 @@ describe('renderWorkerActivity', () => {
|
|
|
188
188
|
expect(stepCount(out)).toBe(2)
|
|
189
189
|
})
|
|
190
190
|
|
|
191
|
-
it('shows a "+N earlier…" header when the feed exceeds
|
|
192
|
-
const total =
|
|
191
|
+
it('shows a "+N earlier…" header when the feed exceeds WORKER_HISTORY_MAX (worker surface, #3349)', () => {
|
|
192
|
+
const total = WORKER_HISTORY_MAX + 3
|
|
193
193
|
const lines = Array.from({ length: total }, (_, i) => `step ${i + 1}`)
|
|
194
194
|
const out = renderWorkerActivity(view({ narrativeLines: lines }))
|
|
195
|
-
expect(out).toContain(`_✓ +${total -
|
|
195
|
+
expect(out).toContain(`_✓ +${total - WORKER_HISTORY_MAX} earlier…_`)
|
|
196
196
|
expect(out).not.toContain('step 1<')
|
|
197
|
-
const firstVisible = total -
|
|
197
|
+
const firstVisible = total - WORKER_HISTORY_MAX + 1
|
|
198
198
|
expect(out).toContain(`~~_✓ step ${firstVisible}_~~`)
|
|
199
199
|
expect(out).toContain(`**→ step ${total}**`)
|
|
200
|
-
//
|
|
201
|
-
expect(out.match(/step \d/g) ?? []).toHaveLength(
|
|
200
|
+
// WORKER_HISTORY_MAX visible step lines (the overflow header isn't a step).
|
|
201
|
+
expect(out.match(/step \d/g) ?? []).toHaveLength(WORKER_HISTORY_MAX)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('the lone-worker card shows the full 6-step trail — the raised ceiling (#3349)', () => {
|
|
205
|
+
// Ken's curve at w=1 is 6; the single-worker surface must reach it (the old
|
|
206
|
+
// STATUS_ROLLING_LINES=5 cap could never show the 6th step).
|
|
207
|
+
expect(WORKER_HISTORY_MAX).toBe(6)
|
|
208
|
+
const lines = Array.from({ length: 8 }, (_, n) => `ln-${n + 1}`)
|
|
209
|
+
const out = renderWorkerActivity(view({ narrativeLines: lines }))
|
|
210
|
+
// Last 6 render (ln-3 … ln-8); the two oldest collapse into a "+2 earlier…".
|
|
211
|
+
for (let n = 3; n <= 8; n++) expect(out).toContain(`ln-${n}`)
|
|
212
|
+
expect(out).toContain('_✓ +2 earlier…_')
|
|
213
|
+
expect(out).toContain('**→ ln-8**') // newest last, bold in-progress
|
|
214
|
+
// Exactly 6 rendered step bullets (the +N header is not a bullet).
|
|
215
|
+
expect((out.match(/ln-\d/g) ?? []).length).toBe(6)
|
|
202
216
|
})
|
|
203
217
|
|
|
204
218
|
it('strips the CONTENT Markdown markup (the card keeps its own ** / _ wrappers, #2669)', () => {
|
|
@@ -457,7 +471,7 @@ describe('createWorkerActivityFeed', () => {
|
|
|
457
471
|
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(1)
|
|
458
472
|
})
|
|
459
473
|
|
|
460
|
-
it('rolls the narrative block to the last
|
|
474
|
+
it('rolls the narrative block to the last WORKER_HISTORY_MAX lines (#3349)', async () => {
|
|
461
475
|
const bot = makeFakeBot()
|
|
462
476
|
let clock = 10_000
|
|
463
477
|
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
@@ -469,12 +483,34 @@ describe('createWorkerActivityFeed', () => {
|
|
|
469
483
|
}
|
|
470
484
|
|
|
471
485
|
const last = bot.edits.at(-1)!
|
|
472
|
-
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(
|
|
473
|
-
const firstVisible = total -
|
|
486
|
+
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(WORKER_HISTORY_MAX)
|
|
487
|
+
const firstVisible = total - WORKER_HISTORY_MAX + 1
|
|
474
488
|
for (let i = 1; i < firstVisible; i++) expect(last.text).not.toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
475
489
|
for (let i = firstVisible; i <= total; i++) expect(last.text).toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
476
490
|
})
|
|
477
491
|
|
|
492
|
+
it('the deeper 6-step trail does NOT add edits — one landed render per substantive step (#3349, no #3270 flood)', async () => {
|
|
493
|
+
// Render-only change: edit frequency is a function of SUBSTANTIVE changes
|
|
494
|
+
// (new narrative step), never of how many trail lines a render carries. Push
|
|
495
|
+
// 8 distinct steps to a lone worker; with the edit floor at 0 exactly one
|
|
496
|
+
// render lands per step: 1 send + 7 edits = 8 landed renders, regardless of
|
|
497
|
+
// the 6-line trail depth. A widened window that re-fired edits would break
|
|
498
|
+
// this (that is the #3270 flood we must not reintroduce).
|
|
499
|
+
const bot = makeFakeBot()
|
|
500
|
+
let clock = 10_000
|
|
501
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
502
|
+
for (let i = 1; i <= 8; i++) {
|
|
503
|
+
clock += 1000
|
|
504
|
+
await feed.update('w1', 'chat', view({ toolCount: i, latestSummary: `edit-step-${i}` }))
|
|
505
|
+
}
|
|
506
|
+
expect(bot.sent).toHaveLength(1)
|
|
507
|
+
expect(bot.edits).toHaveLength(7)
|
|
508
|
+
// Re-pushing the SAME newest step (no substance change) fires NO extra edit.
|
|
509
|
+
clock += 1000
|
|
510
|
+
await feed.update('w1', 'chat', view({ toolCount: 8, latestSummary: 'edit-step-8' }))
|
|
511
|
+
expect(bot.edits).toHaveLength(7)
|
|
512
|
+
})
|
|
513
|
+
|
|
478
514
|
it('grows the narrative even while throttled (line surfaces on next edit)', async () => {
|
|
479
515
|
const bot = makeFakeBot()
|
|
480
516
|
let clock = 10_000
|
|
@@ -576,10 +612,10 @@ describe('createWorkerActivityFeed — log sink', () => {
|
|
|
576
612
|
// char-budget backstop is the only wire-limit ceiling.
|
|
577
613
|
|
|
578
614
|
describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
|
|
579
|
-
it('with 12 narrative lines, exactly the last
|
|
615
|
+
it('with 12 narrative lines, exactly the last WORKER_HISTORY_MAX render + a +N earlier header (#3349)', () => {
|
|
580
616
|
const narrativeLines = Array.from({ length: 12 }, (_, i) => `stp-${String(i + 1).padStart(3, '0')}`)
|
|
581
617
|
const out = renderWorkerActivity(view({ narrativeLines }))
|
|
582
|
-
const firstVisible = 12 -
|
|
618
|
+
const firstVisible = 12 - WORKER_HISTORY_MAX + 1
|
|
583
619
|
for (let i = firstVisible; i <= 12; i++) {
|
|
584
620
|
expect(out).toContain(`stp-${String(i).padStart(3, '0')}`)
|
|
585
621
|
}
|
|
@@ -587,7 +623,7 @@ describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
|
|
|
587
623
|
expect(out).not.toContain(`stp-${String(i).padStart(3, '0')}`)
|
|
588
624
|
}
|
|
589
625
|
// Overflow header now appears on the worker surface too.
|
|
590
|
-
expect(out).toContain(`_✓ +${12 -
|
|
626
|
+
expect(out).toContain(`_✓ +${12 - WORKER_HISTORY_MAX} earlier…_`)
|
|
591
627
|
expect(out).toContain('**→ stp-012**')
|
|
592
628
|
})
|
|
593
629
|
|
|
@@ -623,7 +659,7 @@ describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
|
|
|
623
659
|
})
|
|
624
660
|
|
|
625
661
|
describe('rolling window — createWorkerActivityFeed narrative accumulation', () => {
|
|
626
|
-
it('with 12 pushes, only the last
|
|
662
|
+
it('with 12 pushes, only the last WORKER_HISTORY_MAX appear in the render (#3349)', async () => {
|
|
627
663
|
const bot = makeFakeBot()
|
|
628
664
|
let clock = 10_000
|
|
629
665
|
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
@@ -634,16 +670,16 @@ describe('rolling window — createWorkerActivityFeed narrative accumulation', (
|
|
|
634
670
|
}
|
|
635
671
|
|
|
636
672
|
const last = bot.edits.at(-1)!
|
|
637
|
-
const firstVisible = 12 -
|
|
673
|
+
const firstVisible = 12 - WORKER_HISTORY_MAX + 1
|
|
638
674
|
for (let i = firstVisible; i <= 12; i++) {
|
|
639
675
|
expect(last.text).toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
640
676
|
}
|
|
641
677
|
for (let i = 1; i < firstVisible; i++) {
|
|
642
678
|
expect(last.text).not.toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
643
679
|
}
|
|
644
|
-
// The manager caps the in-memory narrative at
|
|
680
|
+
// The manager caps the in-memory narrative at WORKER_HISTORY_MAX, so the
|
|
645
681
|
// render never sees overflow — no "+N earlier…" marker on the manager path
|
|
646
|
-
// (it surfaces only on direct renderWorkerActivity calls with >
|
|
682
|
+
// (it surfaces only on direct renderWorkerActivity calls with >6 lines).
|
|
647
683
|
expect(last.text).not.toContain('earlier…')
|
|
648
684
|
expect(last.text).toContain('**→ ln-012**')
|
|
649
685
|
})
|
|
@@ -1027,6 +1063,37 @@ describe('createWorkerActivityFeed — resurrection guard + deferred finalize',
|
|
|
1027
1063
|
expect(bot.sent[1].text).toContain('back to work')
|
|
1028
1064
|
})
|
|
1029
1065
|
|
|
1066
|
+
it('#3373: a GENUINE terminal then a SendMessage resume re-surfaces a live card (resurrect+update), while an un-resumed terminal stays dead', async () => {
|
|
1067
|
+
const bot = makeFakeBot()
|
|
1068
|
+
let clock = 10_000
|
|
1069
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
1070
|
+
|
|
1071
|
+
// Worker runs, then GENUINELY completes (a real turn_end handback) — the
|
|
1072
|
+
// row is dropped and the agentId latched into the terminal `finalized` gate.
|
|
1073
|
+
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'step one' }))
|
|
1074
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'real result' }))
|
|
1075
|
+
expect(feed.has('w1')).toBe(false)
|
|
1076
|
+
|
|
1077
|
+
// Anti-zombie: with NO resume signal, later cues stay swallowed forever — a
|
|
1078
|
+
// genuinely-finished worker must never zombie-resurface on a stray tick.
|
|
1079
|
+
clock = 12_000
|
|
1080
|
+
await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'stray tick' }))
|
|
1081
|
+
expect(bot.sent).toHaveLength(1)
|
|
1082
|
+
expect(feed.has('w1')).toBe(false)
|
|
1083
|
+
|
|
1084
|
+
// Now the worker is RESUMED via SendMessage: the watcher observes jsonl
|
|
1085
|
+
// growth past the terminal boundary (#3315) and fires onResume, which the
|
|
1086
|
+
// gateway wires to feed.resurrect — clearing the finalized latch.
|
|
1087
|
+
feed.resurrect('w1')
|
|
1088
|
+
|
|
1089
|
+
// The resumed worker's next progress cue re-surfaces a fresh live card.
|
|
1090
|
+
clock = 13_000
|
|
1091
|
+
await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'resumed work' }))
|
|
1092
|
+
expect(bot.sent).toHaveLength(2)
|
|
1093
|
+
expect(feed.has('w1')).toBe(true)
|
|
1094
|
+
expect(bot.sent[1].text).toContain('resumed work')
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1030
1097
|
it('#3023: resurrect() on a never-finalized worker is a harmless no-op', () => {
|
|
1031
1098
|
const bot = makeFakeBot()
|
|
1032
1099
|
const feed = createWorkerActivityFeed({ bot, now: () => 10_000 })
|
|
@@ -1337,8 +1404,8 @@ describe('narrative dedup — non-adjacent repeats collapse (A,B,A)', () => {
|
|
|
1337
1404
|
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
1338
1405
|
|
|
1339
1406
|
await feed.update('w2', 'chat', view({ latestSummary: 'step-repeat' }))
|
|
1340
|
-
// Push
|
|
1341
|
-
for (let i = 0; i <
|
|
1407
|
+
// Push WORKER_HISTORY_MAX distinct lines so 'step-repeat' scrolls out (#3349).
|
|
1408
|
+
for (let i = 0; i < WORKER_HISTORY_MAX; i++) {
|
|
1342
1409
|
clock += 1000
|
|
1343
1410
|
await feed.update('w2', 'chat', view({ latestSummary: `filler-${i}` }))
|
|
1344
1411
|
}
|
|
@@ -186,12 +186,20 @@ describe('coalesced worker feed — one message per chat under load', () => {
|
|
|
186
186
|
// would shed ~ (N-1)/N of every second's cosmetic edits.
|
|
187
187
|
expect(gate.stats().global.shed).toBeLessThanOrEqual(3)
|
|
188
188
|
|
|
189
|
-
// Liveness
|
|
190
|
-
//
|
|
189
|
+
// Liveness + bounded card (#3349): under Ken's curve every SHOWN worker
|
|
190
|
+
// keeps depth 3, so a 15-way storm cannot render every row — the 24-line
|
|
191
|
+
// body ceiling collapses the newest rows into a `+N more working…` spill.
|
|
192
|
+
// The last landed edit still carries the VISIBLE workers' latest steps
|
|
193
|
+
// (refreshed together in the one message) and a spill marker for the rest.
|
|
191
194
|
const lastBody = edits[edits.length - 1].text
|
|
192
|
-
|
|
193
|
-
|
|
195
|
+
// 6 rows fit the 24-line budget (6·(1 header + 3 depth) = 24); the oldest
|
|
196
|
+
// rows (w0…w5) stay visible, the 9 newest spill.
|
|
197
|
+
for (let i = 0; i < 6; i++) {
|
|
198
|
+
expect(lastBody).toContain(latest[`w${i}`])
|
|
194
199
|
}
|
|
200
|
+
expect(lastBody).toContain('more working')
|
|
201
|
+
// Still exactly ONE message — the spill is a render detail, not a new send.
|
|
202
|
+
expect(sends.length).toBe(1)
|
|
195
203
|
})
|
|
196
204
|
|
|
197
205
|
it('admits a critical reply mid-storm (never shed or starved by the cosmetic feed)', async () => {
|
|
@@ -722,21 +730,23 @@ describe('renderCombinedWorkerFeed (pure)', () => {
|
|
|
722
730
|
expect(body).toContain('b first')
|
|
723
731
|
})
|
|
724
732
|
|
|
725
|
-
it('
|
|
733
|
+
it('holds the MIN_WORKER_DEPTH (3) floor per shown worker at a large fan-out and stays within the body budget (#3349)', () => {
|
|
734
|
+
// 6 workers each with a deep (5-line) history. Ken's curve pins depth=3 at
|
|
735
|
+
// 4+ workers, so every SHOWN worker paints its last 3 steps. 6·(1 header +
|
|
736
|
+
// 3 history) = 24 lines = MAX_COMBINED_BODY_LINES, so all 6 stay visible.
|
|
726
737
|
const rows = Array.from({ length: 6 }, (_, i) =>
|
|
727
|
-
rowH(i, [`w${i}
|
|
738
|
+
rowH(i, [`w${i} s1`, `w${i} s2`, `w${i} s3`, `w${i} s4`, `w${i} s5`]),
|
|
728
739
|
)
|
|
729
740
|
const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
|
|
730
|
-
// Only the newest step of each worker survives — the earlier lines are
|
|
731
|
-
// dropped by the per-worker depth clamp (floor((13-6)/6)=1).
|
|
732
741
|
for (let i = 0; i < 6; i++) {
|
|
733
|
-
|
|
734
|
-
expect(body).
|
|
735
|
-
expect(body).
|
|
742
|
+
// Last 3 steps shown; the two oldest dropped by the depth-3 window.
|
|
743
|
+
expect(body).toContain(current(`w${i} s5`))
|
|
744
|
+
expect(body).toContain(struck(`w${i} s4`))
|
|
745
|
+
expect(body).toContain(struck(`w${i} s3`))
|
|
746
|
+
expect(body).not.toContain(`w${i} s1`)
|
|
747
|
+
expect(body).not.toContain(`w${i} s2`)
|
|
736
748
|
}
|
|
737
|
-
//
|
|
738
|
-
// header lines + 6 history lines = 12 ≤ MAX_COMBINED_BODY_LINES (13). Count
|
|
739
|
-
// only the per-worker body lines (exclude the top count line + any spill).
|
|
749
|
+
// Body lines (worker headers + history) stay within the re-derived budget.
|
|
740
750
|
const bodyLines = body
|
|
741
751
|
.split('\n')
|
|
742
752
|
.map((l) => l.trim())
|
|
@@ -744,15 +754,137 @@ describe('renderCombinedWorkerFeed (pure)', () => {
|
|
|
744
754
|
const headerAndHistory = bodyLines.filter(
|
|
745
755
|
(l) => !l.startsWith('🛠') && !l.includes('more working'),
|
|
746
756
|
)
|
|
747
|
-
expect(headerAndHistory.length).toBeLessThanOrEqual(
|
|
757
|
+
expect(headerAndHistory.length).toBeLessThanOrEqual(24)
|
|
748
758
|
})
|
|
749
759
|
|
|
750
|
-
it('
|
|
760
|
+
it('drops NEWEST (trailing) rows into the spill when the fan-out overflows the 24-line body budget, keeping per-worker depth at 3 (#3349)', () => {
|
|
761
|
+
// 8 workers × (1 header + 3 depth) = 32 > 24 → the backstop collapses the 2
|
|
762
|
+
// newest (trailing) visible rows into `+M more working…`, so 6 rows render at full
|
|
763
|
+
// depth-3 (24 lines) rather than every worker losing a step.
|
|
764
|
+
const rows = Array.from({ length: 8 }, (_, i) =>
|
|
765
|
+
rowH(i, [`w${i} s1`, `w${i} s2`, `w${i} s3`]),
|
|
766
|
+
)
|
|
767
|
+
const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
|
|
768
|
+
expect(body).toContain('more working')
|
|
769
|
+
const bodyLines = body
|
|
770
|
+
.split('\n')
|
|
771
|
+
.map((l) => l.trim())
|
|
772
|
+
.filter((l) => l.length > 0)
|
|
773
|
+
const headerAndHistory = bodyLines.filter(
|
|
774
|
+
(l) => !l.startsWith('🛠') && !l.includes('more working'),
|
|
775
|
+
)
|
|
776
|
+
expect(headerAndHistory.length).toBeLessThanOrEqual(24)
|
|
777
|
+
expect(body).toContain('+2 more working')
|
|
778
|
+
// The 6 kept rows render at full depth-3 (last row kept shows all 3 steps).
|
|
779
|
+
expect(body).toContain(current('w5 s3'))
|
|
780
|
+
expect(body).toContain(struck('w5 s2'))
|
|
781
|
+
expect(body).toContain(struck('w5 s1'))
|
|
782
|
+
// The 2 trailing rows spilled — none of worker 7's lines render.
|
|
783
|
+
expect(body).not.toContain('w7 s3')
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
it("exposes Ken's deterministic depth curve max(3, 7−w): 1→6, 2→5, 3→4, 4→3, 5+→3 (#3349)", () => {
|
|
787
|
+
expect(combinedHistoryDepth(1)).toBe(6)
|
|
751
788
|
expect(combinedHistoryDepth(2)).toBe(5)
|
|
752
|
-
expect(combinedHistoryDepth(3)).toBe(
|
|
753
|
-
expect(combinedHistoryDepth(4)).toBe(
|
|
754
|
-
expect(combinedHistoryDepth(
|
|
755
|
-
expect(combinedHistoryDepth(
|
|
789
|
+
expect(combinedHistoryDepth(3)).toBe(4)
|
|
790
|
+
expect(combinedHistoryDepth(4)).toBe(3)
|
|
791
|
+
expect(combinedHistoryDepth(5)).toBe(3)
|
|
792
|
+
expect(combinedHistoryDepth(6)).toBe(3)
|
|
793
|
+
expect(combinedHistoryDepth(8)).toBe(3)
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
// ── Worker numbering (#3298): stable ordinal prefix at 2+ workers ──────────
|
|
797
|
+
const rowN = (i: number, ordinal: number) => ({ ...row(i, `step ${i}`), ordinal })
|
|
798
|
+
|
|
799
|
+
it('numbers the workers with a literal "N. " prefix inside the bold header when 2+ run', () => {
|
|
800
|
+
const body = renderCombinedWorkerFeed([rowN(1, 1), rowN(2, 2)], { maxRows: 8 })!
|
|
801
|
+
// The literal `1. ` must survive markdown rendering, inside the bold span
|
|
802
|
+
// before the escaped description.
|
|
803
|
+
expect(body).toContain('**1. task number 1**')
|
|
804
|
+
expect(body).toContain('**2. task number 2**')
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
it('renders non-contiguous ordinals as-is (survivors keep their numbers after a finish)', () => {
|
|
808
|
+
// Worker 1 finished upstream — the surviving rows arrive with their
|
|
809
|
+
// ORIGINAL ordinals. Positional numbering would render 1./2. here; the
|
|
810
|
+
// stable scheme must render 2./3. and never a `1. `.
|
|
811
|
+
const body = renderCombinedWorkerFeed([rowN(2, 2), rowN(3, 3)], { maxRows: 8 })!
|
|
812
|
+
expect(body).toContain('**2. task number 2**')
|
|
813
|
+
expect(body).toContain('**3. task number 3**')
|
|
814
|
+
expect(body).not.toContain('**1. ')
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
it('a single row is never numbered, even when it carries an ordinal', () => {
|
|
818
|
+
const body = renderCombinedWorkerFeed([rowN(1, 1)], { maxRows: 8 })!
|
|
819
|
+
expect(body).toContain('**task number 1**')
|
|
820
|
+
expect(body).not.toContain('**1. ')
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
it('rows without ordinals render unnumbered (back-compat for direct callers)', () => {
|
|
824
|
+
const body = renderCombinedWorkerFeed([row(1, 'alpha'), row(2, 'beta')], { maxRows: 8 })!
|
|
825
|
+
expect(body).toContain('**task number 1**')
|
|
826
|
+
expect(body).toContain('**task number 2**')
|
|
827
|
+
expect(body).not.toContain('1. task')
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
it('spilled rows take their ordinals with them; visible ordinals do not shift', () => {
|
|
831
|
+
const rows = Array.from({ length: 10 }, (_, i) => rowN(i + 1, i + 1))
|
|
832
|
+
const body = renderCombinedWorkerFeed(rows, { maxRows: 4 })!
|
|
833
|
+
expect(body).toContain('+6 more working')
|
|
834
|
+
for (let n = 1; n <= 4; n++) expect(body).toContain(`**${n}. task number ${n}**`)
|
|
835
|
+
expect(body).not.toContain('**5. ')
|
|
836
|
+
})
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
// ── #3349: Ken's per-worker depth curve 6/5/4/3, exact rendered depth ─────────
|
|
840
|
+
describe("Ken's per-worker step-trail depth curve (#3349)", () => {
|
|
841
|
+
// A worker with a DEEP history (8 lines) so the depth curve — not the buffer —
|
|
842
|
+
// is always the binding limit. Each history line is uniquely tokenised
|
|
843
|
+
// (`w{i}-s{n}`) so we can count exactly how many of a worker's steps rendered.
|
|
844
|
+
const deepRow = (i: number) => ({
|
|
845
|
+
description: `task w${i}`,
|
|
846
|
+
elapsedMs: 12_000 + i * 1000,
|
|
847
|
+
toolCount: 3,
|
|
848
|
+
ordinal: i + 1,
|
|
849
|
+
currentStep: `w${i}-s8`,
|
|
850
|
+
historyLines: Array.from({ length: 8 }, (_, n) => `w${i}-s${n + 1}`),
|
|
851
|
+
})
|
|
852
|
+
// Count how many of worker `i`'s step tokens appear in the rendered body.
|
|
853
|
+
const shownSteps = (body: string, i: number): number =>
|
|
854
|
+
(body.match(new RegExp(`w${i}-s\\d`, 'g')) ?? []).length
|
|
855
|
+
|
|
856
|
+
it.each([
|
|
857
|
+
[1, 6],
|
|
858
|
+
[2, 5],
|
|
859
|
+
[3, 4],
|
|
860
|
+
[4, 3],
|
|
861
|
+
[5, 3],
|
|
862
|
+
])('%i worker(s) → exactly %i steps rendered per worker', (workers, expectedDepth) => {
|
|
863
|
+
const rows = Array.from({ length: workers }, (_, i) => deepRow(i))
|
|
864
|
+
const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
|
|
865
|
+
expect(combinedHistoryDepth(workers)).toBe(expectedDepth)
|
|
866
|
+
for (let i = 0; i < workers; i++) {
|
|
867
|
+
expect(shownSteps(body, i)).toBe(expectedDepth)
|
|
868
|
+
}
|
|
869
|
+
// Newest step last (bottom-anchored → bold), oldest rendered step struck.
|
|
870
|
+
const worker0 = `**→ w0-s8**`
|
|
871
|
+
expect(body).toContain(worker0)
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
it('a worker with FEWER steps than the depth shows all it has, no padding', () => {
|
|
875
|
+
// 3 workers → depth 4, but this worker only has 2 steps: render both, no more.
|
|
876
|
+
const rows = [
|
|
877
|
+
{ description: 'shallow', elapsedMs: 12_000, toolCount: 1, ordinal: 1,
|
|
878
|
+
currentStep: 'only-b', historyLines: ['only-a', 'only-b'] },
|
|
879
|
+
deepRow(1),
|
|
880
|
+
deepRow(2),
|
|
881
|
+
]
|
|
882
|
+
const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
|
|
883
|
+
expect(combinedHistoryDepth(3)).toBe(4)
|
|
884
|
+
expect(body).toContain('~~_✓ only-a_~~')
|
|
885
|
+
expect(body).toContain('**→ only-b**')
|
|
886
|
+
// Deep workers still get their full 4.
|
|
887
|
+
expect(shownSteps(body, 1)).toBe(4)
|
|
756
888
|
})
|
|
757
889
|
})
|
|
758
890
|
|
|
@@ -1019,3 +1151,87 @@ describe('worker-feed ghost-leak — deterministic terminal removal + backstop',
|
|
|
1019
1151
|
expect(edits.length).toBe(afterPaint)
|
|
1020
1152
|
})
|
|
1021
1153
|
})
|
|
1154
|
+
|
|
1155
|
+
describe('worker numbering — stable per-card ordinals end to end (#3298)', () => {
|
|
1156
|
+
const doneView = (desc: string, elapsedMs: number): WorkerActivityView => ({
|
|
1157
|
+
description: desc,
|
|
1158
|
+
lastTool: null,
|
|
1159
|
+
toolCount: 3,
|
|
1160
|
+
latestSummary: `${desc} result`,
|
|
1161
|
+
elapsedMs,
|
|
1162
|
+
state: 'done',
|
|
1163
|
+
})
|
|
1164
|
+
const lastBody = (h: { sends: { text: string }[]; edits: { messageId: number; text: string }[] }): string => {
|
|
1165
|
+
if (h.edits.length > 0) return h.edits[h.edits.length - 1].text
|
|
1166
|
+
return h.sends[h.sends.length - 1].text
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
it('assigns 1./2./3. at dispatch and survivors KEEP their numbers when an earlier worker finishes', async () => {
|
|
1170
|
+
let clock = 1000
|
|
1171
|
+
const h = ghostHarness({ now: () => clock })
|
|
1172
|
+
await h.feed.update('a', 'chat', view('task alpha', 'a-doing', 0))
|
|
1173
|
+
await h.feed.update('b', 'chat', view('task beta', 'b-doing', 0))
|
|
1174
|
+
await h.feed.update('c', 'chat', view('task gamma', 'c-doing', 0))
|
|
1175
|
+
await drain()
|
|
1176
|
+
|
|
1177
|
+
let body = lastBody(h)
|
|
1178
|
+
expect(body).toContain('**1. task alpha**')
|
|
1179
|
+
expect(body).toContain('**2. task beta**')
|
|
1180
|
+
expect(body).toContain('**3. task gamma**')
|
|
1181
|
+
|
|
1182
|
+
// Worker 1 finishes: the survivors must STILL be 2. and 3. — positional
|
|
1183
|
+
// numbering would renumber them 1./2. and this assertion would fail.
|
|
1184
|
+
clock = 2000
|
|
1185
|
+
await h.feed.finish('a', doneView('task alpha', 1000))
|
|
1186
|
+
await drain()
|
|
1187
|
+
body = lastBody(h)
|
|
1188
|
+
expect(body).toContain('2 running')
|
|
1189
|
+
expect(body).toContain('**2. task beta**')
|
|
1190
|
+
expect(body).toContain('**3. task gamma**')
|
|
1191
|
+
expect(body).not.toContain('**1. ')
|
|
1192
|
+
})
|
|
1193
|
+
|
|
1194
|
+
it('a single running worker is never numbered (single-worker layout)', async () => {
|
|
1195
|
+
const h = ghostHarness({ now: () => 1000 })
|
|
1196
|
+
await h.feed.update('solo', 'chat', view('task solo', 'working', 0))
|
|
1197
|
+
await drain()
|
|
1198
|
+
const body = lastBody(h)
|
|
1199
|
+
expect(body).toContain('task solo')
|
|
1200
|
+
expect(body).not.toContain('1. task solo')
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
it('a fresh card after ALL workers finish numbers from 1 again', async () => {
|
|
1204
|
+
let clock = 1000
|
|
1205
|
+
const h = ghostHarness({ now: () => clock })
|
|
1206
|
+
await h.feed.update('a', 'chat', view('task alpha', 'a-doing', 0))
|
|
1207
|
+
await h.feed.update('b', 'chat', view('task beta', 'b-doing', 0))
|
|
1208
|
+
await drain()
|
|
1209
|
+
clock = 2000
|
|
1210
|
+
await h.feed.finish('a', doneView('task alpha', 1000))
|
|
1211
|
+
await h.feed.finish('b', doneView('task beta', 1000))
|
|
1212
|
+
await drain()
|
|
1213
|
+
expect(h.feed.size).toBe(0)
|
|
1214
|
+
|
|
1215
|
+
// New fan-out in the same chat → a NEW card that starts at 1., not 3.
|
|
1216
|
+
clock = 3000
|
|
1217
|
+
await h.feed.update('c', 'chat', view('task gamma', 'c-doing', 0))
|
|
1218
|
+
await h.feed.update('d', 'chat', view('task delta', 'd-doing', 0))
|
|
1219
|
+
await drain()
|
|
1220
|
+
const body = lastBody(h)
|
|
1221
|
+
expect(body).toContain('**1. task gamma**')
|
|
1222
|
+
expect(body).toContain('**2. task delta**')
|
|
1223
|
+
})
|
|
1224
|
+
|
|
1225
|
+
it('introducing ordinals causes no dedup churn (same substance → edit still skipped)', async () => {
|
|
1226
|
+
const h = ghostHarness({ now: () => 1000 })
|
|
1227
|
+
await h.feed.update('a', 'chat', view('task alpha', 'same step', 0))
|
|
1228
|
+
await h.feed.update('b', 'chat', view('task beta', 'same step', 0))
|
|
1229
|
+
await drain()
|
|
1230
|
+
const landed = h.edits.length
|
|
1231
|
+
// Byte-identical substance re-update: the dedup/no-op skip must still fire
|
|
1232
|
+
// (a stable ordinal never varies the substance between identical renders).
|
|
1233
|
+
await h.feed.update('a', 'chat', view('task alpha', 'same step', 0))
|
|
1234
|
+
await drain()
|
|
1235
|
+
expect(h.edits.length).toBe(landed)
|
|
1236
|
+
})
|
|
1237
|
+
})
|