switchroom 0.19.26 → 0.19.28
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/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +60 -2
- package/dist/auth-broker/index.js +244 -13
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +60 -2
- package/dist/cli/switchroom.js +2843 -1220
- package/dist/host-control/main.js +245 -14
- package/dist/vault/approvals/kernel-server.js +242 -13
- package/dist/vault/broker/server.js +242 -13
- package/package.json +7 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +175 -15
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +94 -1
- package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
- package/telegram-plugin/dist/server.js +97 -1
- package/telegram-plugin/edit-flood-fuse.ts +841 -57
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +67 -70
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +110 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GOLDEN SUITE — every status/progress card variant, pinned byte-for-byte.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: card rendering now flows through ONE layout core
|
|
5
|
+
* (`card-layout.ts`). That is the point — but it also means a change made for
|
|
6
|
+
* one card silently reshapes the other twenty. This suite is the alarm: it
|
|
7
|
+
* renders all variants in `card-variants.ts` through the real renderers and
|
|
8
|
+
* compares the exact wire text against a checked-in golden file. Any diff, in
|
|
9
|
+
* any card, fails.
|
|
10
|
+
*
|
|
11
|
+
* Regenerate deliberately (and read the diff in the PR):
|
|
12
|
+
* UPDATE_CARD_GOLDEN=1 npx vitest run telegram-plugin/tests/card-golden.test.ts
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
import { describe, expect, it } from 'vitest'
|
|
18
|
+
|
|
19
|
+
import { CARD_VARIANTS, GOLDEN_DELIMITER, renderGoldenDocument } from './card-variants.js'
|
|
20
|
+
|
|
21
|
+
const GOLDEN_PATH = join(dirname(fileURLToPath(import.meta.url)), 'card-variants.golden.txt')
|
|
22
|
+
|
|
23
|
+
describe('card golden suite', () => {
|
|
24
|
+
it('every card variant renders exactly its pinned golden text', () => {
|
|
25
|
+
const actual = renderGoldenDocument()
|
|
26
|
+
if (process.env.UPDATE_CARD_GOLDEN === '1') {
|
|
27
|
+
writeFileSync(GOLDEN_PATH, actual, 'utf8')
|
|
28
|
+
}
|
|
29
|
+
const expected = readFileSync(GOLDEN_PATH, 'utf8')
|
|
30
|
+
// Compare per-variant first: a one-card regression should name that card,
|
|
31
|
+
// not dump a 21-card diff.
|
|
32
|
+
const split = (doc: string): Map<string, string> => {
|
|
33
|
+
const out = new Map<string, string>()
|
|
34
|
+
for (const chunk of doc.split(GOLDEN_DELIMITER).slice(1)) {
|
|
35
|
+
const nl = chunk.indexOf('\n')
|
|
36
|
+
out.set(chunk.slice(0, nl).trim(), chunk.slice(nl + 1))
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
const exp = split(expected)
|
|
41
|
+
const act = split(actual)
|
|
42
|
+
for (const [name, body] of act) {
|
|
43
|
+
expect(exp.has(name), `golden has no entry for "${name}" — regenerate it`).toBe(true)
|
|
44
|
+
expect(body, `card "${name}" no longer renders its golden text`).toBe(exp.get(name))
|
|
45
|
+
}
|
|
46
|
+
// And the whole document, so a REMOVED or reordered variant also fails.
|
|
47
|
+
expect(actual).toBe(expected)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('the golden covers every variant in the catalogue and nothing else', () => {
|
|
51
|
+
const golden = readFileSync(GOLDEN_PATH, 'utf8')
|
|
52
|
+
const namesInGolden = golden
|
|
53
|
+
.split(GOLDEN_DELIMITER)
|
|
54
|
+
.slice(1)
|
|
55
|
+
.map((c) => c.slice(0, c.indexOf('\n')).trim())
|
|
56
|
+
expect(namesInGolden).toEqual(CARD_VARIANTS.map((v) => v.name))
|
|
57
|
+
// Guards against a fixture that silently stops rendering (e.g. a renderer
|
|
58
|
+
// starting to return null) and thus stops asserting anything real.
|
|
59
|
+
expect(namesInGolden.length).toBeGreaterThanOrEqual(21)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('no variant renders empty or null', () => {
|
|
63
|
+
for (const v of CARD_VARIANTS) {
|
|
64
|
+
const text = v.render()
|
|
65
|
+
expect(text, `variant "${v.name}" rendered null`).not.toBeNull()
|
|
66
|
+
expect((text ?? '').trim().length, `variant "${v.name}" rendered empty`).toBeGreaterThan(0)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* END-TO-END card lifecycle — drives the REAL feed manager
|
|
3
|
+
* (`createWorkerActivityFeed`) with realistic worker cues and asserts the
|
|
4
|
+
* EXACT text that reaches Telegram at every stage of a worker's life:
|
|
5
|
+
*
|
|
6
|
+
* dispatch → running → a second worker joins (coalesce) → spill/overflow
|
|
7
|
+
* → one finishes (survivors keep ordinals) → failure recap → supersede.
|
|
8
|
+
*
|
|
9
|
+
* This is deliberately NOT `renderStatusCard(opts)` in isolation. The seam is
|
|
10
|
+
* the highest one drivable in-process: feed state in, `sendMessage` /
|
|
11
|
+
* `editMessageText` bodies out — the same bytes the user reads on their phone.
|
|
12
|
+
* The golden suite (`card-golden.test.ts`) pins each variant's shape; this
|
|
13
|
+
* suite proves the lifecycle actually PRODUCES those variants, in order.
|
|
14
|
+
*
|
|
15
|
+
* Every assertion here is one that fails if the shared card layout core
|
|
16
|
+
* (`card-layout.ts`) changes header composition, step-block drawing, the
|
|
17
|
+
* rolling window, the placeholder, the spill line or the budget backstop.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, expect, it } from 'vitest'
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
createWorkerActivityFeed,
|
|
23
|
+
WORKER_CARD_SUPERSEDED_BODY,
|
|
24
|
+
type BotApiForWorkerFeed,
|
|
25
|
+
type WorkerActivityView,
|
|
26
|
+
} from '../worker-activity-feed.js'
|
|
27
|
+
import { WORKER_STEP_INDENT } from '../status-no-truncate.js'
|
|
28
|
+
|
|
29
|
+
const CHAT = '4242'
|
|
30
|
+
|
|
31
|
+
interface Painted {
|
|
32
|
+
kind: 'send' | 'edit'
|
|
33
|
+
messageId: number
|
|
34
|
+
text: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function harness(opts: { maxRows?: number; groupMessageLifetimeCapMs?: number } = {}) {
|
|
38
|
+
let now = 1_000_000
|
|
39
|
+
const painted: Painted[] = []
|
|
40
|
+
let nextId = 500
|
|
41
|
+
const bot: BotApiForWorkerFeed = {
|
|
42
|
+
async sendMessage(_chatId, text) {
|
|
43
|
+
const message_id = ++nextId
|
|
44
|
+
painted.push({ kind: 'send', messageId: message_id, text })
|
|
45
|
+
return { message_id }
|
|
46
|
+
},
|
|
47
|
+
async editMessageText(_chatId, messageId, text) {
|
|
48
|
+
painted.push({ kind: 'edit', messageId, text })
|
|
49
|
+
return {}
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
const feed = createWorkerActivityFeed({
|
|
53
|
+
bot,
|
|
54
|
+
now: () => now,
|
|
55
|
+
// Zero the rate limiters so every cue paints — this suite is about the
|
|
56
|
+
// RENDERED BYTES, not the edit budget (that is worker-feed-coalesce's job).
|
|
57
|
+
minEditIntervalMs: 0,
|
|
58
|
+
elapsedRefreshMs: 0,
|
|
59
|
+
firstPaintMinMs: 0,
|
|
60
|
+
maxRows: opts.maxRows ?? 6,
|
|
61
|
+
groupMessageLifetimeCapMs: opts.groupMessageLifetimeCapMs,
|
|
62
|
+
// No real timers in the test process.
|
|
63
|
+
setInterval: () => null,
|
|
64
|
+
clearInterval: () => {},
|
|
65
|
+
})
|
|
66
|
+
return {
|
|
67
|
+
feed,
|
|
68
|
+
painted,
|
|
69
|
+
advance: (ms: number) => {
|
|
70
|
+
now += ms
|
|
71
|
+
},
|
|
72
|
+
nowMs: () => now,
|
|
73
|
+
/** The most recent body sent or edited — what the user is looking at. */
|
|
74
|
+
last: (): string => painted[painted.length - 1]?.text ?? '',
|
|
75
|
+
reset: () => {
|
|
76
|
+
painted.length = 0
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const view = (over: Partial<WorkerActivityView> = {}): WorkerActivityView => ({
|
|
82
|
+
description: 'Audit every card render path',
|
|
83
|
+
state: 'running',
|
|
84
|
+
elapsedMs: 12_000,
|
|
85
|
+
toolCount: 3,
|
|
86
|
+
lastTool: null,
|
|
87
|
+
latestSummary: '',
|
|
88
|
+
narrativeLines: [],
|
|
89
|
+
model: 'claude-opus-4-8',
|
|
90
|
+
totalTokens: 10_000,
|
|
91
|
+
...over,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/** Await the feed's internal promise chain. */
|
|
95
|
+
const settle = async (): Promise<void> => {
|
|
96
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('worker card lifecycle — the bytes the user actually sees', () => {
|
|
100
|
+
it('dispatch: the first paint is a real card with a starting… placeholder, not a bare header', async () => {
|
|
101
|
+
const h = harness()
|
|
102
|
+
await h.feed.update('w1', CHAT, view({ elapsedMs: 1_200, toolCount: 0, narrativeLines: [] }))
|
|
103
|
+
await settle()
|
|
104
|
+
|
|
105
|
+
expect(h.painted.length).toBeGreaterThan(0)
|
|
106
|
+
expect(h.painted[0].kind).toBe('send')
|
|
107
|
+
const card = h.painted[0].text
|
|
108
|
+
// Title line, metrics line, placeholder — three lines, all from the layout core.
|
|
109
|
+
expect(card).toContain('🛠 **WORKER**')
|
|
110
|
+
expect(card).toContain('Audit every card render path')
|
|
111
|
+
expect(card).toMatch(/_1s · 0 tools · 10\.0k tok · opus 4\.8_/)
|
|
112
|
+
expect(card).toContain('_starting…_')
|
|
113
|
+
// #3846: the placeholder is a SECTION of the card, so it carries the same
|
|
114
|
+
// collapse-safe line joining as every other line — a hand-appended tail did not.
|
|
115
|
+
const lines = card.split('\n')
|
|
116
|
+
expect(lines.length).toBe(3)
|
|
117
|
+
for (const l of lines.slice(0, -1)) expect(l.endsWith(' ')).toBe(true)
|
|
118
|
+
expect(card).not.toContain('starting…\n_starting…_')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('running: narrative steps become a ✓/→ trail under one shared header', async () => {
|
|
122
|
+
const h = harness()
|
|
123
|
+
await h.feed.update('w1', CHAT, view({ latestSummary: 'Fetching origin' }))
|
|
124
|
+
await settle()
|
|
125
|
+
h.reset()
|
|
126
|
+
// The watcher emits ONE latest step per tick; the feed accumulates them.
|
|
127
|
+
await h.feed.update('w1', CHAT, view({ toolCount: 10, latestSummary: 'Reading the renderer' }))
|
|
128
|
+
await settle()
|
|
129
|
+
await h.feed.update(
|
|
130
|
+
'w1',
|
|
131
|
+
CHAT,
|
|
132
|
+
view({ elapsedMs: 143_000, toolCount: 31, totalTokens: 88_400, latestSummary: 'Mapping callers' }),
|
|
133
|
+
)
|
|
134
|
+
await settle()
|
|
135
|
+
|
|
136
|
+
const card = h.last()
|
|
137
|
+
expect(card).toContain('~~_✓ Fetching origin_~~')
|
|
138
|
+
expect(card).toContain('~~_✓ Reading the renderer_~~')
|
|
139
|
+
expect(card).toContain('**→ Mapping callers**')
|
|
140
|
+
expect(card).not.toContain('_starting…_')
|
|
141
|
+
// The metrics line is composed once, by metricsRun — running order is
|
|
142
|
+
// `{elapsed} · {tools} · {tok} · {model}`.
|
|
143
|
+
expect(card).toContain('_2m23s · 31 tools · 88.4k tok · opus 4.8_')
|
|
144
|
+
// Single-worker card carries NO indent (#3843 de-indent).
|
|
145
|
+
expect(card).not.toContain(WORKER_STEP_INDENT)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('depth budget: a row deeper than its per-worker budget shows only its newest steps', async () => {
|
|
149
|
+
// The feed retains WORKER_HISTORY_MAX (6) steps; with 2 workers the shared
|
|
150
|
+
// layout gives each row a depth of 5, so the oldest step must drop out.
|
|
151
|
+
const h = harness()
|
|
152
|
+
for (let i = 1; i <= 6; i++) {
|
|
153
|
+
await h.feed.update('w1', CHAT, view({ toolCount: i, latestSummary: `s${i}` }))
|
|
154
|
+
await settle()
|
|
155
|
+
}
|
|
156
|
+
await h.feed.update('w2', CHAT, view({ description: 'Second worker', latestSummary: 'b1' }))
|
|
157
|
+
await settle()
|
|
158
|
+
|
|
159
|
+
const card = h.last()
|
|
160
|
+
expect(card).not.toContain('s1_')
|
|
161
|
+
expect(card).toContain(`${WORKER_STEP_INDENT}~~_✓ s2_~~`)
|
|
162
|
+
expect(card).toContain(`${WORKER_STEP_INDENT}**→ s6**`)
|
|
163
|
+
// Exactly depth-1 done steps plus the one live step.
|
|
164
|
+
expect(card.split('\n').filter((l) => /~~_✓ s\d_~~/.test(l)).length).toBe(4)
|
|
165
|
+
expect(card.split('\n').filter((l) => l.includes('**→ ')).length).toBe(2)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('two workers coalesce into ONE message whose rows share the header composer', async () => {
|
|
169
|
+
const h = harness()
|
|
170
|
+
await h.feed.update('w1', CHAT, view({ latestSummary: 'audit 1' }))
|
|
171
|
+
await settle()
|
|
172
|
+
const firstId = h.painted[0].messageId
|
|
173
|
+
h.reset()
|
|
174
|
+
await h.feed.update(
|
|
175
|
+
'w2',
|
|
176
|
+
CHAT,
|
|
177
|
+
view({
|
|
178
|
+
description: 'Fix the hindsight recall floor',
|
|
179
|
+
elapsedMs: 60_000,
|
|
180
|
+
toolCount: 7,
|
|
181
|
+
totalTokens: 20_000,
|
|
182
|
+
latestSummary: 'recall 1',
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
await settle()
|
|
186
|
+
|
|
187
|
+
// One message, edited — not a second send.
|
|
188
|
+
expect(h.painted.every((p) => p.messageId === firstId)).toBe(true)
|
|
189
|
+
expect(h.painted.some((p) => p.kind === 'send')).toBe(false)
|
|
190
|
+
|
|
191
|
+
const card = h.last()
|
|
192
|
+
expect(card).toContain('🛠 **WORKERS**')
|
|
193
|
+
expect(card).toContain('2 running')
|
|
194
|
+
expect(card).toContain('**1. Audit every card render path**')
|
|
195
|
+
expect(card).toContain('**2. Fix the hindsight recall floor**')
|
|
196
|
+
// Row headers use the SAME metricsRun as the single-worker card.
|
|
197
|
+
expect(card).toContain('_· 1m00s · 7 tools · 20.0k tok · opus 4.8_')
|
|
198
|
+
// Rows are indented with the braille blank so Telegram cannot left-trim.
|
|
199
|
+
expect(card).toContain(`${WORKER_STEP_INDENT}**→ recall 1**`)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('a worker with no history yet renders the shared placeholder inside its row', async () => {
|
|
203
|
+
const h = harness()
|
|
204
|
+
await h.feed.update('w1', CHAT, view({ latestSummary: 'audit 1' }))
|
|
205
|
+
await settle()
|
|
206
|
+
h.reset()
|
|
207
|
+
await h.feed.update('w2', CHAT, view({ description: 'Freshly dispatched', latestSummary: '' }))
|
|
208
|
+
await settle()
|
|
209
|
+
|
|
210
|
+
const card = h.last()
|
|
211
|
+
expect(card).toContain('**2. Freshly dispatched**')
|
|
212
|
+
expect(card).toContain(`${WORKER_STEP_INDENT}→ _starting…_`)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('spill: beyond maxRows the newest rows collapse into one +M more working… line', async () => {
|
|
216
|
+
const h = harness({ maxRows: 6 })
|
|
217
|
+
for (let i = 1; i <= 8; i++) {
|
|
218
|
+
await h.feed.update(`w${i}`, CHAT, view({ description: `Background task ${i}`, latestSummary: `t${i} a` }))
|
|
219
|
+
await settle()
|
|
220
|
+
}
|
|
221
|
+
const card = h.last()
|
|
222
|
+
expect(card).toContain('8 running')
|
|
223
|
+
expect(card).toContain('**6. Background task 6**')
|
|
224
|
+
expect(card).not.toContain('**7. Background task 7**')
|
|
225
|
+
expect(card).toContain('_+2 more working…_')
|
|
226
|
+
// The spill line is the LAST line of the card (a footer, not a stray row).
|
|
227
|
+
const lines = card.split('\n')
|
|
228
|
+
expect(lines[lines.length - 1]).toContain('+2 more working…')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('spill honours the body-line budget: many deep rows still shrink to fit', async () => {
|
|
232
|
+
const h = harness({ maxRows: 6 })
|
|
233
|
+
for (let i = 1; i <= 6; i++) {
|
|
234
|
+
await h.feed.update(
|
|
235
|
+
`w${i}`,
|
|
236
|
+
CHAT,
|
|
237
|
+
view({
|
|
238
|
+
description: `Background task ${i}`,
|
|
239
|
+
latestSummary: `t${i} step`,
|
|
240
|
+
}),
|
|
241
|
+
)
|
|
242
|
+
await settle()
|
|
243
|
+
for (const step of ['b', 'c', 'd', 'e', 'f']) {
|
|
244
|
+
await h.feed.update(`w${i}`, CHAT, view({ description: `Background task ${i}`, latestSummary: `t${i} ${step}` }))
|
|
245
|
+
await settle()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const card = h.last()
|
|
249
|
+
// MAX_COMBINED_BODY_LINES = 6 rows × (1 header + 3 steps) = 24.
|
|
250
|
+
const body = card.split('\n').filter((l) => !l.includes('🛠 **WORKERS**') && !l.includes('more working…'))
|
|
251
|
+
expect(body.length).toBeLessThanOrEqual(24)
|
|
252
|
+
// Shrinking drops ROWS, never the glance chrome.
|
|
253
|
+
expect(card).toContain('🛠 **WORKERS**')
|
|
254
|
+
expect(card).toContain('6 running')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('one worker finishing leaves the survivors numbered as they were dispatched', async () => {
|
|
258
|
+
const h = harness()
|
|
259
|
+
await h.feed.update('w1', CHAT, view({ latestSummary: 'audit 1' }))
|
|
260
|
+
await settle()
|
|
261
|
+
await h.feed.update('w2', CHAT, view({ description: 'Second worker', latestSummary: 'b1' }))
|
|
262
|
+
await settle()
|
|
263
|
+
await h.feed.update('w3', CHAT, view({ description: 'Third worker', latestSummary: 'c1' }))
|
|
264
|
+
await settle()
|
|
265
|
+
expect(h.last()).toContain('**1. Audit every card render path**')
|
|
266
|
+
h.reset()
|
|
267
|
+
|
|
268
|
+
await h.feed.finish('w1', view({ state: 'done', latestSummary: 'All done.' }))
|
|
269
|
+
await settle()
|
|
270
|
+
|
|
271
|
+
const card = h.last()
|
|
272
|
+
expect(card).toContain('2 running')
|
|
273
|
+
expect(card).toContain('**2. Second worker**')
|
|
274
|
+
expect(card).toContain('**3. Third worker**')
|
|
275
|
+
expect(card).not.toContain('**1. ')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('completion: the last worker collapses to a struck feed plus the ✅ result recap', async () => {
|
|
279
|
+
const h = harness()
|
|
280
|
+
await h.feed.update('w1', CHAT, view({ latestSummary: 'Fetching origin' }))
|
|
281
|
+
await settle()
|
|
282
|
+
await h.feed.update('w1', CHAT, view({ toolCount: 20, latestSummary: 'Mapping callers' }))
|
|
283
|
+
await settle()
|
|
284
|
+
h.reset()
|
|
285
|
+
await h.feed.finish(
|
|
286
|
+
'w1',
|
|
287
|
+
view({
|
|
288
|
+
state: 'done',
|
|
289
|
+
elapsedMs: 902_000,
|
|
290
|
+
toolCount: 44,
|
|
291
|
+
latestSummary: 'Inventoried every card variant and unified the renderer. PR #0000.',
|
|
292
|
+
}),
|
|
293
|
+
)
|
|
294
|
+
await settle()
|
|
295
|
+
|
|
296
|
+
const card = h.last()
|
|
297
|
+
// Terminal metrics order flips: `{state} · {tools} · {tok} · {elapsed}`.
|
|
298
|
+
expect(card).toContain('_done · 44 tools · 10.0k tok · 15m02s · opus 4.8_')
|
|
299
|
+
// No live `→` marker survives on a finished card.
|
|
300
|
+
expect(card).not.toContain('**→ ')
|
|
301
|
+
expect(card).toContain('~~_✓ Mapping callers_~~')
|
|
302
|
+
// The result block: shared rule, then the ✅ recap paragraph.
|
|
303
|
+
expect(card).toContain('─────')
|
|
304
|
+
expect(card).toContain('✅ _Inventoried')
|
|
305
|
+
expect(card).toContain('Inventoried every card variant and unified the renderer.')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('failure: the card reads failed and carries the error recap', async () => {
|
|
309
|
+
const h = harness()
|
|
310
|
+
await h.feed.update('w1', CHAT, view({ latestSummary: 'Running the scoped suite' }))
|
|
311
|
+
await settle()
|
|
312
|
+
h.reset()
|
|
313
|
+
await h.feed.finish(
|
|
314
|
+
'w1',
|
|
315
|
+
view({
|
|
316
|
+
state: 'failed',
|
|
317
|
+
elapsedMs: 61_000,
|
|
318
|
+
toolCount: 9,
|
|
319
|
+
latestSummary: 'Scoped test run failed: 3 assertions.',
|
|
320
|
+
}),
|
|
321
|
+
)
|
|
322
|
+
await settle()
|
|
323
|
+
|
|
324
|
+
const card = h.last()
|
|
325
|
+
expect(card).toContain('_failed · 9 tools · 10.0k tok · 1m01s · opus 4.8_')
|
|
326
|
+
expect(card).toContain('⚠️ _Scoped test run failed: 3 assertions._')
|
|
327
|
+
expect(card).not.toContain('**→ ')
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('supersede: the retired message is finalised to the shared-chrome notice, and a fresh card opens', async () => {
|
|
331
|
+
const h = harness({ groupMessageLifetimeCapMs: 60_000 })
|
|
332
|
+
await h.feed.update('w1', CHAT, view({ latestSummary: 'step one' }))
|
|
333
|
+
await settle()
|
|
334
|
+
const firstId = h.painted[0].messageId
|
|
335
|
+
h.reset()
|
|
336
|
+
|
|
337
|
+
// Push the group's shared message past its lifetime cap, then let the
|
|
338
|
+
// heartbeat (the real rotation trigger) run.
|
|
339
|
+
h.advance(120_000)
|
|
340
|
+
await h.feed.update('w1', CHAT, view({ elapsedMs: 132_000, toolCount: 9, latestSummary: 'step two' }))
|
|
341
|
+
await settle()
|
|
342
|
+
h.feed.heartbeatTick()
|
|
343
|
+
await settle()
|
|
344
|
+
|
|
345
|
+
const superseded = h.painted.find((p) => p.text === WORKER_CARD_SUPERSEDED_BODY)
|
|
346
|
+
expect(superseded, 'the retired message must be finalised to the superseded notice').toBeDefined()
|
|
347
|
+
expect(superseded?.kind).toBe('edit')
|
|
348
|
+
expect(superseded?.messageId).toBe(firstId)
|
|
349
|
+
// Shared chrome: same title composer as every live card.
|
|
350
|
+
expect(WORKER_CARD_SUPERSEDED_BODY.startsWith('🛠 **WORKER** · _continued_')).toBe(true)
|
|
351
|
+
// The retired notice carries no live-styled rows — it must not read as a
|
|
352
|
+
// stuck worker.
|
|
353
|
+
expect(WORKER_CARD_SUPERSEDED_BODY).not.toContain('**→ ')
|
|
354
|
+
|
|
355
|
+
// A fresh, live card was opened for the still-running worker.
|
|
356
|
+
const fresh = h.painted.filter((p) => p.kind === 'send')
|
|
357
|
+
expect(fresh.length).toBe(1)
|
|
358
|
+
expect(fresh[0].messageId).not.toBe(firstId)
|
|
359
|
+
expect(fresh[0].text).toContain('🛠 **WORKER**')
|
|
360
|
+
expect(fresh[0].text).toContain('step two')
|
|
361
|
+
})
|
|
362
|
+
})
|