switchroom 0.18.23 → 0.18.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1608 -841
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +524 -16
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/reply-owner-resolve.ts +160 -0
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +222 -10
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createWorkerActivityFeed,
|
|
4
|
+
type WorkerActivityView,
|
|
5
|
+
type BotApiForWorkerFeed,
|
|
6
|
+
} from '../worker-activity-feed.js'
|
|
7
|
+
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
8
|
+
import type { PinState, DesiredPin } from '../status-pin.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Outcome tests for the invisible-worker-cards fix (2026-07-15).
|
|
12
|
+
*
|
|
13
|
+
* Root cause: the worker feed reuses ONE shared group message per chat for the
|
|
14
|
+
* whole time workers overlap, but `syncPin` was called only at lifecycle edges
|
|
15
|
+
* (first-paint / terminal / GC) — NEVER on the steady-state edit path. So once
|
|
16
|
+
* the pin was lost out-of-band (its in-memory claim dropped), the reused message
|
|
17
|
+
* kept being edited live but was never re-pinned → scroll-buried / invisible.
|
|
18
|
+
*
|
|
19
|
+
* These tests wire the feed's `reconcilePin` hook into the REAL status-pin
|
|
20
|
+
* driver + a claim map exactly as the gateway does (`wk:group:<feedKey>` key,
|
|
21
|
+
* `void reconcileStatusPin`), and assert the PIN-CALL OUTCOMES:
|
|
22
|
+
* 1. a steady-state edit re-pins when the claim was lost — and does NOT re-pin
|
|
23
|
+
* (no storm) when the claim is already correct.
|
|
24
|
+
* 2. routing an unpin through the reconciler CLEARS the claim, so a later edit
|
|
25
|
+
* re-pins.
|
|
26
|
+
* 3. the group-message lifetime cap ROTATES to a fresh message id and re-
|
|
27
|
+
* establishes the pin on the new message.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
|
|
31
|
+
return {
|
|
32
|
+
description: 'background task',
|
|
33
|
+
lastTool: { name: 'Bash', sanitisedArg: 'grep -r x' },
|
|
34
|
+
toolCount: 1,
|
|
35
|
+
latestSummary: 'working',
|
|
36
|
+
elapsedMs: 10_000,
|
|
37
|
+
state: 'running',
|
|
38
|
+
...partial,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface FakeBot extends BotApiForWorkerFeed {
|
|
43
|
+
sent: Array<{ chatId: string; messageId: number; text: string }>
|
|
44
|
+
edits: Array<{ messageId: number; text: string }>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeFakeBot(): FakeBot {
|
|
48
|
+
let nextId = 1000
|
|
49
|
+
const fb: FakeBot = {
|
|
50
|
+
sent: [],
|
|
51
|
+
edits: [],
|
|
52
|
+
sendMessage: async (chatId, text) => {
|
|
53
|
+
const message_id = nextId++
|
|
54
|
+
fb.sent.push({ chatId, messageId: message_id, text })
|
|
55
|
+
return { message_id }
|
|
56
|
+
},
|
|
57
|
+
editMessageText: async (_chatId, messageId, text) => {
|
|
58
|
+
fb.edits.push({ messageId, text })
|
|
59
|
+
return {}
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
return fb
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mirror the gateway's status-pin reconcile: a `Map<key,PinState>` claim store
|
|
67
|
+
* driven through the real `reconcilePin` driver against a fake pin API that
|
|
68
|
+
* records every pin/unpin call. This is the seam that turns a `reconcilePin`
|
|
69
|
+
* hook call into an observable Telegram pin/unpin — proving OUTCOMES, not paths.
|
|
70
|
+
*/
|
|
71
|
+
function makePinHarness() {
|
|
72
|
+
const state = new Map<string, PinState>()
|
|
73
|
+
const pinCalls: number[] = []
|
|
74
|
+
const unpinCalls: number[] = []
|
|
75
|
+
let lastKey: string | null = null
|
|
76
|
+
const api: PinBotApi = {
|
|
77
|
+
pinChatMessage: async (_chat, message_id) => {
|
|
78
|
+
pinCalls.push(message_id)
|
|
79
|
+
},
|
|
80
|
+
unpinChatMessage: async (_chat, message_id) => {
|
|
81
|
+
unpinCalls.push(message_id)
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
async function reconcile(key: string, chatId: string, desired: DesiredPin): Promise<void> {
|
|
85
|
+
const prev = state.get(key) ?? null
|
|
86
|
+
const next = await reconcilePin({ api, chatId, prevState: prev, desired })
|
|
87
|
+
if (next == null) state.delete(key)
|
|
88
|
+
else state.set(key, next)
|
|
89
|
+
}
|
|
90
|
+
const reconcilePinFn = (args: {
|
|
91
|
+
feedKey: string
|
|
92
|
+
chatId: string
|
|
93
|
+
threadId?: number
|
|
94
|
+
messageId: number | null
|
|
95
|
+
}): void => {
|
|
96
|
+
const key = `wk:group:${args.feedKey}`
|
|
97
|
+
lastKey = key
|
|
98
|
+
if (args.messageId != null) void reconcile(key, args.chatId, { pinned: true, messageId: args.messageId })
|
|
99
|
+
else void reconcile(key, args.chatId, { pinned: false })
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
state,
|
|
103
|
+
pinCalls,
|
|
104
|
+
unpinCalls,
|
|
105
|
+
reconcilePinFn,
|
|
106
|
+
key: (): string => {
|
|
107
|
+
if (lastKey == null) throw new Error('no reconcile happened yet')
|
|
108
|
+
return lastKey
|
|
109
|
+
},
|
|
110
|
+
/** Directly drop the claim to simulate a pin lost out-of-band. */
|
|
111
|
+
dropClaim(): void {
|
|
112
|
+
state.clear()
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Flush the microtask queue so fire-and-forget `void reconcile(...)` settles. */
|
|
118
|
+
async function flush(): Promise<void> {
|
|
119
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe('worker-feed pin persistence — steady-state re-pin (invisible-worker-cards)', () => {
|
|
123
|
+
it('re-pins on a steady-state edit when the claim was lost, and does NOT re-pin when already correct (no storm)', async () => {
|
|
124
|
+
const bot = makeFakeBot()
|
|
125
|
+
const pin = makePinHarness()
|
|
126
|
+
let clock = 0
|
|
127
|
+
const feed = createWorkerActivityFeed({
|
|
128
|
+
bot,
|
|
129
|
+
now: () => clock,
|
|
130
|
+
firstPaintMinMs: 0,
|
|
131
|
+
minEditIntervalMs: 0,
|
|
132
|
+
reconcilePin: pin.reconcilePinFn,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// First paint → message posted AND pinned once.
|
|
136
|
+
clock = 1000
|
|
137
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
|
|
138
|
+
await flush()
|
|
139
|
+
expect(bot.sent).toHaveLength(1)
|
|
140
|
+
const msgId = bot.sent[0].messageId
|
|
141
|
+
expect(pin.pinCalls).toEqual([msgId])
|
|
142
|
+
expect(pin.state.get(pin.key())?.messageId).toBe(msgId)
|
|
143
|
+
|
|
144
|
+
// Steady-state edit while the pin is already correct → NO new pin (no storm).
|
|
145
|
+
clock = 2000
|
|
146
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 5 }))
|
|
147
|
+
await flush()
|
|
148
|
+
expect(bot.edits.length).toBeGreaterThanOrEqual(1)
|
|
149
|
+
expect(pin.pinCalls).toEqual([msgId]) // still exactly one pin — decidePinAction no-op'd
|
|
150
|
+
|
|
151
|
+
// Pin lost out-of-band (claim dropped) → next steady edit must re-pin.
|
|
152
|
+
pin.dropClaim()
|
|
153
|
+
clock = 3000
|
|
154
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 3000, toolCount: 9 }))
|
|
155
|
+
await flush()
|
|
156
|
+
expect(pin.pinCalls).toEqual([msgId, msgId]) // re-pinned the SAME live message
|
|
157
|
+
expect(pin.state.get(pin.key())?.messageId).toBe(msgId)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('worker-feed pin persistence — unpin clears the claim', () => {
|
|
162
|
+
it('an unpin routed through the reconciler clears the claim so a later edit re-pins', async () => {
|
|
163
|
+
const bot = makeFakeBot()
|
|
164
|
+
const pin = makePinHarness()
|
|
165
|
+
let clock = 0
|
|
166
|
+
const feed = createWorkerActivityFeed({
|
|
167
|
+
bot,
|
|
168
|
+
now: () => clock,
|
|
169
|
+
firstPaintMinMs: 0,
|
|
170
|
+
minEditIntervalMs: 0,
|
|
171
|
+
reconcilePin: pin.reconcilePinFn,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
clock = 1000
|
|
175
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
|
|
176
|
+
await flush()
|
|
177
|
+
const msgId = bot.sent[0].messageId
|
|
178
|
+
const key = pin.key()
|
|
179
|
+
expect(pin.state.get(key)?.messageId).toBe(msgId)
|
|
180
|
+
|
|
181
|
+
// Route an unpin of the worker message through the reconciler (the sanctioned
|
|
182
|
+
// path): the claim is dropped AND the Telegram unpin is issued.
|
|
183
|
+
await (async () => {
|
|
184
|
+
const prev = pin.state.get(key) ?? null
|
|
185
|
+
const next = await reconcilePin({
|
|
186
|
+
api: {
|
|
187
|
+
pinChatMessage: async () => {},
|
|
188
|
+
unpinChatMessage: async (_c, id) => {
|
|
189
|
+
pin.unpinCalls.push(id)
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
chatId: 'chat',
|
|
193
|
+
prevState: prev,
|
|
194
|
+
desired: { pinned: false },
|
|
195
|
+
})
|
|
196
|
+
if (next == null) pin.state.delete(key)
|
|
197
|
+
else pin.state.set(key, next)
|
|
198
|
+
})()
|
|
199
|
+
expect(pin.state.has(key)).toBe(false) // claim cleared
|
|
200
|
+
expect(pin.unpinCalls).toContain(msgId)
|
|
201
|
+
|
|
202
|
+
// A later steady edit now re-pins (the claim being clear is what lets
|
|
203
|
+
// decidePinAction issue a fresh pin instead of a stale-id no-op).
|
|
204
|
+
clock = 2000
|
|
205
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 7 }))
|
|
206
|
+
await flush()
|
|
207
|
+
expect(pin.pinCalls).toContain(msgId)
|
|
208
|
+
expect(pin.state.get(key)?.messageId).toBe(msgId)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('worker-feed pin persistence — group-message lifetime cap rotation', () => {
|
|
213
|
+
it('rotates to a fresh message past the cap and re-establishes the pin on the new id', async () => {
|
|
214
|
+
const bot = makeFakeBot()
|
|
215
|
+
const pin = makePinHarness()
|
|
216
|
+
let clock = 0
|
|
217
|
+
const feed = createWorkerActivityFeed({
|
|
218
|
+
bot,
|
|
219
|
+
now: () => clock,
|
|
220
|
+
firstPaintMinMs: 0,
|
|
221
|
+
minEditIntervalMs: 0,
|
|
222
|
+
heartbeatTickMs: 6000,
|
|
223
|
+
groupMessageLifetimeCapMs: 30_000,
|
|
224
|
+
reconcilePin: pin.reconcilePinFn,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// First paint → message A posted + pinned.
|
|
228
|
+
clock = 1000
|
|
229
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
|
|
230
|
+
await flush()
|
|
231
|
+
const msgA = bot.sent[0].messageId
|
|
232
|
+
expect(pin.pinCalls).toEqual([msgA])
|
|
233
|
+
|
|
234
|
+
// Worker keeps running; advance well past the 30s message cap, then tick.
|
|
235
|
+
clock = 40_000
|
|
236
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 40_000, toolCount: 4 }))
|
|
237
|
+
await flush()
|
|
238
|
+
feed.heartbeatTick()
|
|
239
|
+
await flush()
|
|
240
|
+
|
|
241
|
+
// A SECOND message was posted (rotation) and it is a DIFFERENT id.
|
|
242
|
+
expect(bot.sent).toHaveLength(2)
|
|
243
|
+
const msgB = bot.sent[1].messageId
|
|
244
|
+
expect(msgB).not.toBe(msgA)
|
|
245
|
+
expect(feed.messageIdOf('w1')).toBe(msgB)
|
|
246
|
+
|
|
247
|
+
// The stale message was unpinned and the fresh one pinned → pin surface
|
|
248
|
+
// re-established on the new id (deterministic, no burst: one unpin + one pin).
|
|
249
|
+
expect(pin.unpinCalls).toContain(msgA)
|
|
250
|
+
expect(pin.pinCalls).toContain(msgB)
|
|
251
|
+
expect(pin.state.get(pin.key())?.messageId).toBe(msgB)
|
|
252
|
+
|
|
253
|
+
// FIX 1: the retired message was collapsed to the honest "moved" note with
|
|
254
|
+
// exactly ONE edit (not left frozen showing live rows, not re-edited per tick).
|
|
255
|
+
const supersedeEdits = bot.edits.filter(
|
|
256
|
+
(e) => e.messageId === msgA && e.text.includes('Live progress moved to a fresh card'),
|
|
257
|
+
)
|
|
258
|
+
expect(supersedeEdits).toHaveLength(1)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('rotation preserves ALL live overlapping worker rows on the fresh message (none dropped/double-counted)', async () => {
|
|
262
|
+
const bot = makeFakeBot()
|
|
263
|
+
const pin = makePinHarness()
|
|
264
|
+
let clock = 0
|
|
265
|
+
const feed = createWorkerActivityFeed({
|
|
266
|
+
bot,
|
|
267
|
+
now: () => clock,
|
|
268
|
+
firstPaintMinMs: 0,
|
|
269
|
+
minEditIntervalMs: 0,
|
|
270
|
+
heartbeatTickMs: 6000,
|
|
271
|
+
groupMessageLifetimeCapMs: 30_000,
|
|
272
|
+
reconcilePin: pin.reconcilePinFn,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
// Two workers overlap in the SAME chat → one combined group message.
|
|
276
|
+
clock = 1000
|
|
277
|
+
await feed.update('w1', 'chat', view({ description: 'alpha task', elapsedMs: 1000 }))
|
|
278
|
+
await flush()
|
|
279
|
+
clock = 1100
|
|
280
|
+
await feed.update('w2', 'chat', view({ description: 'beta task', elapsedMs: 1100 }))
|
|
281
|
+
await flush()
|
|
282
|
+
const msgA = bot.sent[0].messageId
|
|
283
|
+
// Both workers share the one message.
|
|
284
|
+
expect(feed.messageIdOf('w1')).toBe(msgA)
|
|
285
|
+
expect(feed.messageIdOf('w2')).toBe(msgA)
|
|
286
|
+
|
|
287
|
+
// Advance past the cap with BOTH still live, then tick to rotate.
|
|
288
|
+
clock = 40_000
|
|
289
|
+
await feed.update('w1', 'chat', view({ description: 'alpha task', elapsedMs: 40_000, toolCount: 3 }))
|
|
290
|
+
await flush()
|
|
291
|
+
feed.heartbeatTick()
|
|
292
|
+
await flush()
|
|
293
|
+
|
|
294
|
+
// A fresh message was posted and BOTH live workers moved to it — no row
|
|
295
|
+
// dropped, no worker orphaned on the retired card.
|
|
296
|
+
expect(bot.sent).toHaveLength(2)
|
|
297
|
+
const fresh = bot.sent[1]
|
|
298
|
+
expect(fresh.messageId).not.toBe(msgA)
|
|
299
|
+
expect(feed.messageIdOf('w1')).toBe(fresh.messageId)
|
|
300
|
+
expect(feed.messageIdOf('w2')).toBe(fresh.messageId)
|
|
301
|
+
// The fresh combined body renders BOTH workers' rows.
|
|
302
|
+
expect(fresh.text).toContain('alpha task')
|
|
303
|
+
expect(fresh.text).toContain('beta task')
|
|
304
|
+
expect(feed.size).toBe(2)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
@@ -115,6 +115,11 @@ export interface SessionActivityHeader {
|
|
|
115
115
|
* `formatModelLabel` (model-label.ts).
|
|
116
116
|
*/
|
|
117
117
|
model?: string
|
|
118
|
+
/** Running total tokens for THIS turn (the parent agent's OWN per-message
|
|
119
|
+
* usage, summed + deduped upstream) — rendered as `· {N} tok` on the metrics
|
|
120
|
+
* line via `tokenSegment`. Omitted (0/undefined) → no token segment, same
|
|
121
|
+
* clean-omit behavior as the worker feed. */
|
|
122
|
+
totalTokens?: number
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
/**
|
|
@@ -185,21 +190,57 @@ export function renderActivityHeader(
|
|
|
185
190
|
toolCount: number,
|
|
186
191
|
state: 'running' | 'done' | 'failed' | 'incomplete',
|
|
187
192
|
model?: string,
|
|
193
|
+
totalTokens?: number,
|
|
188
194
|
): [string, string] {
|
|
189
195
|
const toolWord = toolCount === 1 ? 'tool' : 'tools'
|
|
190
196
|
const elapsed = formatFeedElapsed(elapsedMs)
|
|
191
197
|
const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
|
|
192
198
|
const line1 = `${emoji} **${escapeMarkdown(label)}**${descPart}`
|
|
199
|
+
// Running total tokens: joins the dot-separated metrics between the tool
|
|
200
|
+
// count and the model tag. Omitted (empty) when the total is 0/unknown.
|
|
201
|
+
const tokPart = tokenSegment(totalTokens)
|
|
193
202
|
// Subtle live-model tag: joins the existing dot-separated metrics (never a new
|
|
194
203
|
// line). formatModelLabel returns null for absent/sentinel values → no suffix.
|
|
195
204
|
const modelLabel = formatModelLabel(model)
|
|
196
205
|
const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
|
|
197
206
|
const line2 = state === 'running'
|
|
198
|
-
? `_${elapsed} · ${toolCount} ${toolWord}${modelPart}_`
|
|
199
|
-
: `_${state} · ${toolCount} ${toolWord} · ${elapsed}${modelPart}_`
|
|
207
|
+
? `_${elapsed} · ${toolCount} ${toolWord}${tokPart}${modelPart}_`
|
|
208
|
+
: `_${state} · ${toolCount} ${toolWord}${tokPart} · ${elapsed}${modelPart}_`
|
|
200
209
|
return [line1, line2]
|
|
201
210
|
}
|
|
202
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Compact token-count formatter for the activity card's metrics line:
|
|
214
|
+
* <1000 → raw ("940")
|
|
215
|
+
* ≥1000, <1e6 → one-decimal k ("12.4k", "1.0k")
|
|
216
|
+
* ≥1e6 → one-decimal M ("1.2M")
|
|
217
|
+
* Negative / non-finite inputs clamp to "0". The caller appends " tok".
|
|
218
|
+
*/
|
|
219
|
+
export function formatTokenCount(n: number): string {
|
|
220
|
+
if (!Number.isFinite(n) || n <= 0) return '0'
|
|
221
|
+
if (n < 1000) return String(Math.floor(n))
|
|
222
|
+
if (n < 1_000_000) {
|
|
223
|
+
// Round to the displayed 1-decimal k FIRST: inputs in [999_950, 999_999]
|
|
224
|
+
// round to "1000.0k", which must promote into the M branch rather than
|
|
225
|
+
// render a nonsense "1000.0k". Fall through when the rounded k reaches 1000.
|
|
226
|
+
const k = Number((n / 1000).toFixed(1))
|
|
227
|
+
if (k < 1000) return `${k.toFixed(1)}k`
|
|
228
|
+
}
|
|
229
|
+
return `${(n / 1_000_000).toFixed(1)}M`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The ` · {N} tok` metrics segment, or '' when there are no tokens to show.
|
|
234
|
+
* A 0 / undefined total (a worker that emitted no usage — e.g. a non-Claude
|
|
235
|
+
* transcript) OMITS the segment entirely so the line stays clean; the same
|
|
236
|
+
* predicate is used by BOTH render variants (single-worker header + combined
|
|
237
|
+
* row) so they never diverge.
|
|
238
|
+
*/
|
|
239
|
+
function tokenSegment(totalTokens: number | undefined): string {
|
|
240
|
+
if (totalTokens == null || totalTokens <= 0) return ''
|
|
241
|
+
return ` · ${formatTokenCount(totalTokens)} tok`
|
|
242
|
+
}
|
|
243
|
+
|
|
203
244
|
/** Format elapsed milliseconds for display in the activity header (e.g. "12s", "2m05s"). */
|
|
204
245
|
export function formatFeedElapsed(ms: number): string {
|
|
205
246
|
const s = Math.floor(ms / 1000)
|
|
@@ -289,6 +330,9 @@ export interface StatusCardHeader {
|
|
|
289
330
|
/** Live model id (raw, e.g. `claude-opus-4-8`) — rendered as a short friendly
|
|
290
331
|
* tag on the metrics line via `formatModelLabel`. Omitted when unknown. */
|
|
291
332
|
model?: string
|
|
333
|
+
/** Running total tokens for the worker/turn — rendered as `· {N} tok` on the
|
|
334
|
+
* metrics line. Omitted (0/undefined) → no token segment. */
|
|
335
|
+
totalTokens?: number
|
|
292
336
|
}
|
|
293
337
|
|
|
294
338
|
/** Inputs to the unified status-card renderer. */
|
|
@@ -341,6 +385,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
341
385
|
// 'failed', so only the worker card is affected.
|
|
342
386
|
header.state,
|
|
343
387
|
header.model,
|
|
388
|
+
header.totalTokens,
|
|
344
389
|
)
|
|
345
390
|
: []
|
|
346
391
|
|
|
@@ -497,6 +542,7 @@ export function renderActivityFeed(
|
|
|
497
542
|
toolCount: header.toolCount,
|
|
498
543
|
state: header.state,
|
|
499
544
|
model: header.model,
|
|
545
|
+
totalTokens: header.totalTokens,
|
|
500
546
|
}
|
|
501
547
|
: undefined,
|
|
502
548
|
steps: lines,
|
|
@@ -549,6 +595,7 @@ export function renderActivityFeedWithNested(
|
|
|
549
595
|
toolCount: header.toolCount,
|
|
550
596
|
state: header.state,
|
|
551
597
|
model: header.model,
|
|
598
|
+
totalTokens: header.totalTokens,
|
|
552
599
|
}
|
|
553
600
|
: undefined,
|
|
554
601
|
steps: lines,
|
|
@@ -596,6 +643,9 @@ export interface CombinedWorkerRow {
|
|
|
596
643
|
historyLines?: string[]
|
|
597
644
|
/** Live model id (raw, e.g. `claude-opus-4-8`); omitted when unknown. */
|
|
598
645
|
model?: string
|
|
646
|
+
/** Running total tokens for this worker — rendered as `· {N} tok` on the row
|
|
647
|
+
* header. Omitted (0/undefined) → no token segment. */
|
|
648
|
+
totalTokens?: number
|
|
599
649
|
}
|
|
600
650
|
|
|
601
651
|
export interface CombinedWorkerFeedOpts {
|
|
@@ -671,9 +721,10 @@ export function renderCombinedWorkerFeed(
|
|
|
671
721
|
truncate(stripMarkdown(r.description).replace(/\s+/g, ' ').trim() || 'background task', COMBINED_ROW_DESC_MAX),
|
|
672
722
|
)
|
|
673
723
|
const toolWord = r.toolCount === 1 ? 'tool' : 'tools'
|
|
724
|
+
const tokPart = tokenSegment(r.totalTokens)
|
|
674
725
|
const modelLabel = formatModelLabel(r.model)
|
|
675
726
|
const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
|
|
676
|
-
return `**${desc}** _· ${formatFeedElapsed(r.elapsedMs)} · ${r.toolCount} ${toolWord}${modelPart}_`
|
|
727
|
+
return `**${desc}** _· ${formatFeedElapsed(r.elapsedMs)} · ${r.toolCount} ${toolWord}${tokPart}${modelPart}_`
|
|
677
728
|
}
|
|
678
729
|
|
|
679
730
|
// Raw (unescaped) history for a worker, oldest→newest, empty lines stripped.
|