switchroom 0.19.25 → 0.19.27
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 +61 -2
- package/dist/auth-broker/index.js +125 -8
- package/dist/cli/notion-write-pretool.mjs +61 -2
- package/dist/cli/switchroom.js +2347 -1104
- package/dist/host-control/main.js +126 -9
- package/dist/vault/approvals/kernel-server.js +124 -8
- package/dist/vault/broker/server.js +124 -8
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +14 -0
- package/profiles/_base/start.sh.hbs +145 -4
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +103 -112
- 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/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -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 +11 -0
- package/telegram-plugin/status-pin-driver.ts +33 -17
- package/telegram-plugin/status-pin.ts +51 -5
- 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 +291 -0
- 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 +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -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/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/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
- 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 +244 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
- package/telegram-plugin/tests/status-pin.test.ts +184 -7
- 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 +29 -19
- 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/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +239 -322
- package/telegram-plugin/uat/assertions.ts +33 -3
- package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +109 -30
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- 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_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3842 — every status card is FLUSH at the left margin, and the only
|
|
3
|
+
* indentation any card carries is the step indent that separates one worker's
|
|
4
|
+
* steps from the next worker's on the combined (2+ worker) card.
|
|
5
|
+
*
|
|
6
|
+
* #3820/#3821 had made the worker card structurally subordinate to the 🤖 agent
|
|
7
|
+
* card: line 1 prefixed with `└─ ` and every later line indented by a U+2800
|
|
8
|
+
* run. Ken rejected that shape — it burns a level of horizontal space on a
|
|
9
|
+
* phone, and the worker card does not always sit below the agent card, so a
|
|
10
|
+
* card-LEVEL subordination marker asserts a relationship that is not always
|
|
11
|
+
* true. Net effect of this file's expectations: everything shifted left by one
|
|
12
|
+
* level; worker-vs-worker separation is carried by the surviving step indent
|
|
13
|
+
* plus the numbered row headers.
|
|
14
|
+
*
|
|
15
|
+
* These tests assert the RENDERED OUTPUT of the real renderers, and they fail
|
|
16
|
+
* on the pre-#3842 shape (which put `└─ ` on line 1 and U+2800 on every other
|
|
17
|
+
* line of both worker surfaces).
|
|
18
|
+
*
|
|
19
|
+
* What is NOT asserted here any more, deliberately: that no rendered line is
|
|
20
|
+
* shared between the agent card and the worker card. With whole-card nesting
|
|
21
|
+
* gone and identical step text, the two cards' BODY lines coincide byte for
|
|
22
|
+
* byte — see the explicit characterization test at the end. Line 1 (🤖 `Agent`
|
|
23
|
+
* vs 🛠 `WORKER`) is now the only type cue, which is the accepted trade.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, it, expect } from 'vitest'
|
|
26
|
+
import {
|
|
27
|
+
renderActivityFeed,
|
|
28
|
+
renderStatusCard,
|
|
29
|
+
renderCombinedWorkerFeed,
|
|
30
|
+
} from '../tool-activity-summary.js'
|
|
31
|
+
import { renderWorkerActivity, type WorkerActivityView } from '../worker-activity-feed.js'
|
|
32
|
+
import { WORKER_STEP_INDENT, STATUS_CARD_CHAR_BUDGET } from '../status-no-truncate.js'
|
|
33
|
+
import { richMessage } from '../rich-send.js'
|
|
34
|
+
|
|
35
|
+
/** Split a rendered card into display lines, dropping the hard-break padding. */
|
|
36
|
+
function lines(card: string): string[] {
|
|
37
|
+
return card.split('\n').map((l) => l.replace(/[ \t\r]+$/, ''))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The exact scenario from #3820: parent narrating the task it delegated. */
|
|
41
|
+
const STEPS = ['PR 2502 details', 'Commits since v0.8.5']
|
|
42
|
+
|
|
43
|
+
/** The card-level chrome #3842 removed. Asserted absent, never re-emitted. */
|
|
44
|
+
const REMOVED_HEADER_PREFIX = '└─ '
|
|
45
|
+
|
|
46
|
+
function agentCard(): string {
|
|
47
|
+
const out = renderActivityFeed(STEPS, false, '', undefined, {
|
|
48
|
+
label: 'Agent',
|
|
49
|
+
elapsedMs: 108_000,
|
|
50
|
+
toolCount: 15,
|
|
51
|
+
state: 'running',
|
|
52
|
+
model: 'claude-opus-5',
|
|
53
|
+
totalTokens: 16_400,
|
|
54
|
+
})
|
|
55
|
+
expect(out).not.toBeNull()
|
|
56
|
+
return out as string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function workerView(over: Partial<WorkerActivityView> = {}): WorkerActivityView {
|
|
60
|
+
return {
|
|
61
|
+
description: 'Check upstream hindsight changes',
|
|
62
|
+
elapsedMs: 108_000,
|
|
63
|
+
toolCount: 15,
|
|
64
|
+
latestSummary: '',
|
|
65
|
+
narrativeLines: STEPS,
|
|
66
|
+
state: 'running',
|
|
67
|
+
model: 'claude-opus-5',
|
|
68
|
+
totalTokens: 16_400,
|
|
69
|
+
...over,
|
|
70
|
+
} as WorkerActivityView
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function combinedCard(): string {
|
|
74
|
+
const card = renderCombinedWorkerFeed(
|
|
75
|
+
[
|
|
76
|
+
{
|
|
77
|
+
description: 'Check upstream hindsight changes',
|
|
78
|
+
elapsedMs: 55_000,
|
|
79
|
+
toolCount: 9,
|
|
80
|
+
currentStep: 'Commits since v0.8.5',
|
|
81
|
+
historyLines: STEPS,
|
|
82
|
+
ordinal: 1,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
description: 'Fix the card renderer',
|
|
86
|
+
elapsedMs: 30_000,
|
|
87
|
+
toolCount: 4,
|
|
88
|
+
currentStep: 'Reading render.ts',
|
|
89
|
+
historyLines: ['Reading render.ts'],
|
|
90
|
+
ordinal: 2,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
{ maxRows: 4 },
|
|
94
|
+
)
|
|
95
|
+
expect(card).not.toBeNull()
|
|
96
|
+
return card as string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every worker surface, in each of its render states, as rendered strings. */
|
|
100
|
+
function everyWorkerSurface(): Array<{ name: string; card: string }> {
|
|
101
|
+
return [
|
|
102
|
+
{ name: 'running', card: renderWorkerActivity(workerView()) },
|
|
103
|
+
{
|
|
104
|
+
name: 'just dispatched',
|
|
105
|
+
card: renderWorkerActivity(workerView({ narrativeLines: [], latestSummary: '' })),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'done + result block',
|
|
109
|
+
card: renderWorkerActivity(
|
|
110
|
+
workerView({ state: 'done', latestSummary: 'Rebased and opened the PR.' }),
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'failed + result block',
|
|
115
|
+
card: renderWorkerActivity(
|
|
116
|
+
workerView({ state: 'failed', latestSummary: 'Scoped tests went red.' }),
|
|
117
|
+
),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'incomplete',
|
|
121
|
+
card: renderWorkerActivity(workerView({ state: 'incomplete', latestSummary: '' })),
|
|
122
|
+
},
|
|
123
|
+
{ name: 'combined (2 workers)', card: combinedCard() },
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
describe('#3842 every card is flush at the left margin', () => {
|
|
128
|
+
it('emits no `└─ ` header prefix on any surface', () => {
|
|
129
|
+
for (const { name, card } of [{ name: 'agent', card: agentCard() }, ...everyWorkerSurface()]) {
|
|
130
|
+
expect(card.includes(REMOVED_HEADER_PREFIX), `${name} card carries └─`).toBe(false)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('leaves every line of the SINGLE-worker card unindented', () => {
|
|
135
|
+
// Pre-#3842 line 1 started `└─ ` and lines 2..n started with a U+2800 run.
|
|
136
|
+
const surfaces = everyWorkerSurface()
|
|
137
|
+
for (const name of [
|
|
138
|
+
'running',
|
|
139
|
+
'just dispatched',
|
|
140
|
+
'done + result block',
|
|
141
|
+
'failed + result block',
|
|
142
|
+
'incomplete',
|
|
143
|
+
]) {
|
|
144
|
+
const card = surfaces.find((s) => s.name === name)?.card as string
|
|
145
|
+
const l = lines(card)
|
|
146
|
+
expect(l.length).toBeGreaterThan(1)
|
|
147
|
+
for (const line of l) {
|
|
148
|
+
expect(line.startsWith(WORKER_STEP_INDENT), `${name}: "${line}"`).toBe(false)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('keeps the just-dispatched `starting…` tail flush too', () => {
|
|
154
|
+
// The one render state a user always sees first takes a hand-rolled append
|
|
155
|
+
// path outside renderStatusCard — the place a shape change silently misses.
|
|
156
|
+
const l = lines(renderWorkerActivity(workerView({ narrativeLines: [], latestSummary: '' })))
|
|
157
|
+
expect(l[0].startsWith('🛠 **WORKER**')).toBe(true)
|
|
158
|
+
expect(l[l.length - 1]).toBe('_starting…_')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('keeps the terminal result block flush', () => {
|
|
162
|
+
const l = lines(
|
|
163
|
+
renderWorkerActivity(
|
|
164
|
+
workerView({ state: 'done', latestSummary: 'Rebased and opened the PR.' }),
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
expect(l.some((x) => x.startsWith('✅ '))).toBe(true)
|
|
168
|
+
expect(l.some((x) => x.startsWith(`${WORKER_STEP_INDENT}✅ `))).toBe(false)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('#3842 the combined card keeps exactly ONE level of indent', () => {
|
|
173
|
+
it('puts the glance line and row headers flush', () => {
|
|
174
|
+
const l = lines(combinedCard())
|
|
175
|
+
expect(l[0]).toBe('🛠 **WORKERS** · _2 running · oldest 55s · 13 tools_')
|
|
176
|
+
const rowHeaders = l.filter((x) => x.includes('**1. ') || x.includes('**2. '))
|
|
177
|
+
expect(rowHeaders.length).toBe(2)
|
|
178
|
+
for (const h of rowHeaders) expect(h.startsWith(WORKER_STEP_INDENT)).toBe(false)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('indents step lines exactly one level — never two', () => {
|
|
182
|
+
const l = lines(combinedCard())
|
|
183
|
+
const stepLines = l.filter((x) => x.includes('→ ') || x.includes('✓ '))
|
|
184
|
+
expect(stepLines.length).toBeGreaterThan(0)
|
|
185
|
+
for (const s of stepLines) {
|
|
186
|
+
expect(s.startsWith(WORKER_STEP_INDENT)).toBe(true)
|
|
187
|
+
// The pre-#3842 double indent (SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT).
|
|
188
|
+
expect(s.startsWith(WORKER_STEP_INDENT + WORKER_STEP_INDENT)).toBe(false)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('puts the `+M more working…` spill line flush', () => {
|
|
193
|
+
const rows = Array.from({ length: 5 }, (_, i) => ({
|
|
194
|
+
description: `task ${i + 1}`,
|
|
195
|
+
elapsedMs: 10_000,
|
|
196
|
+
toolCount: 1,
|
|
197
|
+
currentStep: 'working',
|
|
198
|
+
historyLines: ['working'],
|
|
199
|
+
ordinal: i + 1,
|
|
200
|
+
}))
|
|
201
|
+
const card = renderCombinedWorkerFeed(rows, { maxRows: 2 }) as string
|
|
202
|
+
const spill = lines(card).find((x) => x.includes('more working…'))
|
|
203
|
+
expect(spill).toBe('_+3 more working…_')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('#3842 char-budget accounting matches the flush shape', () => {
|
|
208
|
+
it('keeps an oversized worker card under the wire budget', () => {
|
|
209
|
+
// fitCardToBudget has its own line assembly + arithmetic. With the per-line
|
|
210
|
+
// nest cost removed it must still land under the cap, and must not emit any
|
|
211
|
+
// chrome the card no longer carries.
|
|
212
|
+
const huge = Array.from({ length: 500 }, (_, i) => `${i} `.repeat(100))
|
|
213
|
+
const card = renderStatusCard({
|
|
214
|
+
header: {
|
|
215
|
+
emoji: '🛠',
|
|
216
|
+
label: 'WORKER',
|
|
217
|
+
description: 'big',
|
|
218
|
+
elapsedMs: 1000,
|
|
219
|
+
toolCount: 1,
|
|
220
|
+
state: 'running',
|
|
221
|
+
},
|
|
222
|
+
steps: huge,
|
|
223
|
+
historyWindow: 6,
|
|
224
|
+
})
|
|
225
|
+
expect(card).not.toBeNull()
|
|
226
|
+
const s = card as string
|
|
227
|
+
expect(s.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
|
|
228
|
+
expect(s.includes(REMOVED_HEADER_PREFIX)).toBe(false)
|
|
229
|
+
for (const l of lines(s)) expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('keeps an oversized combined card under the wire budget', () => {
|
|
233
|
+
const rows = Array.from({ length: 6 }, (_, i) => ({
|
|
234
|
+
description: 'D'.repeat(72),
|
|
235
|
+
elapsedMs: 10_000,
|
|
236
|
+
toolCount: 3,
|
|
237
|
+
currentStep: 'x',
|
|
238
|
+
historyLines: Array.from({ length: 6 }, (_, k) => `${'s'.repeat(180)}-${i}-${k}`),
|
|
239
|
+
ordinal: i + 1,
|
|
240
|
+
}))
|
|
241
|
+
const card = renderCombinedWorkerFeed(rows, { maxRows: 6 }) as string
|
|
242
|
+
expect(card.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('#3842 the surviving type cues', () => {
|
|
247
|
+
it('uses a high-contrast caps type label on the worker card only', () => {
|
|
248
|
+
const agent = agentCard()
|
|
249
|
+
const worker = renderWorkerActivity(workerView())
|
|
250
|
+
expect(worker).toContain('🛠 **WORKER**')
|
|
251
|
+
expect(agent).toContain('🤖 **Agent**')
|
|
252
|
+
expect(agent).not.toContain('WORKER')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('distinguishes the two cards on LINE 1 — the only remaining cue', () => {
|
|
256
|
+
// Characterization, not aspiration: with whole-card nesting gone and
|
|
257
|
+
// identical step text + stats, the two cards' BODY lines coincide byte for
|
|
258
|
+
// byte. Line 1 is doing all of the work, so it must never converge.
|
|
259
|
+
const agent = lines(agentCard())
|
|
260
|
+
const worker = lines(renderWorkerActivity(workerView()))
|
|
261
|
+
expect(agent[0]).not.toBe(worker[0])
|
|
262
|
+
expect(agent[0].startsWith('🤖 ')).toBe(true)
|
|
263
|
+
expect(worker[0].startsWith('🛠 ')).toBe(true)
|
|
264
|
+
// The documented cost of #3842, pinned so a future change to it is visible:
|
|
265
|
+
// every line BELOW line 1 is now shared between the two cards.
|
|
266
|
+
const overlap = agent.filter((a) => worker.includes(a))
|
|
267
|
+
expect(overlap).toEqual(agent.slice(1))
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('#3842 the surviving step indent survives Telegram reality', () => {
|
|
272
|
+
it('passes every card through the real outbound markdown guard byte-identical', () => {
|
|
273
|
+
// richMessage() is the ONE adapter every `{ markdown }` wire send funnels
|
|
274
|
+
// through (rich-send.ts) — it runs the line-start / heading / dollar-math
|
|
275
|
+
// guards. Chrome that got escaped there would render as literal markup.
|
|
276
|
+
for (const { card } of [{ card: agentCard() }, ...everyWorkerSurface()]) {
|
|
277
|
+
expect(richMessage(card).markdown).toBe(card)
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('uses an indent that no whitespace-trimming rule can eat (#3662 lesson)', () => {
|
|
282
|
+
// U+00A0 shipped in #3662 and was INERT: Telegram left-trims a leading
|
|
283
|
+
// Unicode-whitespace run, and U+00A0 is category Zs. Assert the PROPERTY,
|
|
284
|
+
// not the bytes — a byte-only assertion is what let #3662 ship green.
|
|
285
|
+
for (const ch of Array.from(WORKER_STEP_INDENT)) {
|
|
286
|
+
expect(/\s/.test(ch)).toBe(false)
|
|
287
|
+
expect(/\p{White_Space}/u.test(ch)).toBe(false)
|
|
288
|
+
expect(/\p{Zs}/u.test(ch)).toBe(false)
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
})
|