switchroom 0.19.30 → 0.19.31
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 +1121 -584
- package/dist/host-control/main.js +151 -73
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -1
- package/profiles/_base/start.sh.hbs +15 -1
- package/telegram-plugin/dist/gateway/gateway.js +1435 -551
- package/telegram-plugin/edit-flood-fuse.ts +70 -20
- package/telegram-plugin/gateway/boot-beacon.ts +364 -0
- package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
- package/telegram-plugin/gateway/gateway.ts +87 -88
- package/telegram-plugin/gateway/inbound-spool.ts +39 -0
- package/telegram-plugin/gateway/narrative-lane.ts +12 -0
- package/telegram-plugin/gateway/obligation-store.ts +28 -0
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
- package/telegram-plugin/gateway/status-pin-store.ts +33 -11
- package/telegram-plugin/registry/turns-schema.ts +21 -1
- package/telegram-plugin/retry-api-call.ts +46 -21
- package/telegram-plugin/shared/bot-runtime.ts +61 -17
- package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
- package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
- package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
- package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
- package/telegram-plugin/tests/obligation-store.test.ts +67 -1
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
- package/telegram-plugin/tool-activity-summary.ts +104 -38
- package/telegram-plugin/worker-activity-feed.ts +33 -16
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
- package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The edit-flood fuse's DELIBERATE drops must not be reported as failures.
|
|
3
|
+
*
|
|
4
|
+
* ── The defect ────────────────────────────────────────────────────────────
|
|
5
|
+
* `edit-flood-fuse.ts` is installed as a grammY **transformer**
|
|
6
|
+
* (`bot.api.config.use`, `installEditFloodFuse`). A transformer's return
|
|
7
|
+
* contract is the raw `ApiResponse` ENVELOPE, and grammY's `Api.callApi`
|
|
8
|
+
* unwraps it:
|
|
9
|
+
*
|
|
10
|
+
* ```js
|
|
11
|
+
* const data = await this.call(method, payload, signal)
|
|
12
|
+
* if (data.ok) return data.result
|
|
13
|
+
* else throw toGrammyError(data, method, payload)
|
|
14
|
+
* ```
|
|
15
|
+
* (grammy `out/core/client.js`)
|
|
16
|
+
*
|
|
17
|
+
* The fuse resolved a dropped call with the bare `true` — the *result*, with no
|
|
18
|
+
* envelope. `(true).ok` is `undefined`, so EVERY intentional fuse drop took the
|
|
19
|
+
* `else` branch and was raised as
|
|
20
|
+
* `GrammyError: Call to 'editMessageText' failed! (undefined: undefined)`.
|
|
21
|
+
*
|
|
22
|
+
* Downstream, `narrative-lane.ts`'s activity-summary drain caught that, found
|
|
23
|
+
* nothing in its `isTransport` allowlist matching, incremented
|
|
24
|
+
* `turn.activityDrainFailures` and wrote a `activity-summary drain failed:` line
|
|
25
|
+
* to stderr. `activityDrainFailures` is what flags a turn DEGRADED at turn-end,
|
|
26
|
+
* so a healthy turn whose progress card was correctly rate-limited was reported
|
|
27
|
+
* as broken — observed reaching `failures=8` on a single turn.
|
|
28
|
+
*
|
|
29
|
+
* ── Why the fix is the envelope, not an `isTransport` entry ───────────────
|
|
30
|
+
* Classifying the string in `isTransport` would paper over a real contract
|
|
31
|
+
* violation that ALSO corrupts every other caller of a fused API method (each
|
|
32
|
+
* one sees a synthetic transport error for a benign no-op). Restoring the
|
|
33
|
+
* envelope is the durable fix; `isTransport` then needs no new entry at all,
|
|
34
|
+
* because nothing is thrown.
|
|
35
|
+
*
|
|
36
|
+
* ── R1 on the per-MESSAGE tier ───────────────────────────────────────────
|
|
37
|
+
* The second describe covers the other half: a card must never freeze mid-step
|
|
38
|
+
* because a frame was shed. `awaitRoom`'s `dropGuard` (R1: "only drop while
|
|
39
|
+
* something NEWER for this message is still in flight to repaint it") was wired
|
|
40
|
+
* into the two per-chat tiers but NOT the per-message tier, which passed
|
|
41
|
+
* `dropGuard: undefined` — read as "drop unconditionally at the deadline". A
|
|
42
|
+
* lone terminal `finalize`, which by definition has nothing newer coming, was
|
|
43
|
+
* therefore droppable there regardless of its priority class, freezing the card
|
|
44
|
+
* on "→ in-progress".
|
|
45
|
+
*/
|
|
46
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
47
|
+
import { tmpdir } from 'node:os'
|
|
48
|
+
import { GrammyError } from 'grammy'
|
|
49
|
+
|
|
50
|
+
import { createEditFloodFuse, type EditFloodFuseConfig } from '../edit-flood-fuse.js'
|
|
51
|
+
import { withOutboundClass, type OutboundClass } from '../outbound-class.js'
|
|
52
|
+
import { createNarrativeLane } from '../gateway/narrative-lane.js'
|
|
53
|
+
import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
|
|
54
|
+
|
|
55
|
+
const CHAT = '1001'
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* grammY's `Api.callApi`, verbatim in behaviour: run the transformer chain,
|
|
59
|
+
* unwrap `ok:true`, throw `GrammyError` otherwise. This is the seam the defect
|
|
60
|
+
* lived in, so the harness must model it rather than assume the fuse's return
|
|
61
|
+
* value reaches the caller untouched (which is what the pre-existing fuse tests
|
|
62
|
+
* assumed — they hand `next` a bare `true` and never unwrap, which is exactly
|
|
63
|
+
* why the bug survived them).
|
|
64
|
+
*/
|
|
65
|
+
function makeFusedApi(config: EditFloodFuseConfig) {
|
|
66
|
+
const fuse = createEditFloodFuse(config)
|
|
67
|
+
const landed: Array<{ method: string; message_id?: number; text: string | null }> = []
|
|
68
|
+
let nextId = 3000
|
|
69
|
+
|
|
70
|
+
const callApi = async (method: string, payload: Record<string, unknown>, run: () => unknown) => {
|
|
71
|
+
const data = (await fuse.apply(method, payload, async () => ({ ok: true, result: run() }))) as {
|
|
72
|
+
ok?: boolean
|
|
73
|
+
result?: unknown
|
|
74
|
+
}
|
|
75
|
+
if (data.ok === true) return data.result
|
|
76
|
+
throw new GrammyError(`Call to '${method}' failed!`, data as never, method, payload as never)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const api = {
|
|
80
|
+
sendRichMessage: (c: string, b: { markdown: string }) => {
|
|
81
|
+
const id = ++nextId
|
|
82
|
+
return callApi('sendRichMessage', { chat_id: c }, () => {
|
|
83
|
+
landed.push({ method: 'sendRichMessage', message_id: id, text: b.markdown })
|
|
84
|
+
return { message_id: id }
|
|
85
|
+
}) as Promise<{ message_id: number }>
|
|
86
|
+
},
|
|
87
|
+
editMessageText: (c: string, m: number, b: { markdown: string }) =>
|
|
88
|
+
callApi('editMessageText', { chat_id: c, message_id: m }, () => {
|
|
89
|
+
landed.push({ method: 'editMessageText', message_id: m, text: b.markdown })
|
|
90
|
+
return true
|
|
91
|
+
}),
|
|
92
|
+
deleteMessage: (c: string, m: number) =>
|
|
93
|
+
callApi('deleteMessage', { chat_id: c, message_id: m }, () => {
|
|
94
|
+
landed.push({ method: 'deleteMessage', message_id: m, text: null })
|
|
95
|
+
return true
|
|
96
|
+
}),
|
|
97
|
+
}
|
|
98
|
+
return { api, landed, fuse }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The real `createNarrativeLane`, wired to a bot whose API traverses the real
|
|
103
|
+
* fuse. `robustApiCall` stands in for the production chain
|
|
104
|
+
* `robustApiCall → sendGate.gate → withOutboundClass → fn`: the only part of it
|
|
105
|
+
* the fuse can observe is the published outbound class, so the stub publishes it
|
|
106
|
+
* for real via `withOutboundClass` and calls straight through.
|
|
107
|
+
*/
|
|
108
|
+
function makeFusedLane(config: EditFloodFuseConfig) {
|
|
109
|
+
const { api, landed, fuse } = makeFusedApi(config)
|
|
110
|
+
const submitted: Array<{ verb?: string; priorityClass?: OutboundClass }> = []
|
|
111
|
+
const noop = () => {}
|
|
112
|
+
const fakeEA = {
|
|
113
|
+
mayDrain: () => true,
|
|
114
|
+
openOrEditCard: (_p: string, fn: () => void) => fn(),
|
|
115
|
+
finalizeCard: (fn: () => void) => fn(),
|
|
116
|
+
markSubstantiveFinalDelivered: (fn: () => void) => fn(),
|
|
117
|
+
}
|
|
118
|
+
const deps = {
|
|
119
|
+
ACTIVITY_CARD_STORE_PATH: `${tmpdir()}/lane-fuse-drop-activity-cards.json`,
|
|
120
|
+
CLEAR_STATUS_ON_COMPLETION: false,
|
|
121
|
+
FEED_HEARTBEAT_ENABLED: false,
|
|
122
|
+
FEED_HEARTBEAT_MIN_STALE_MS: 6000,
|
|
123
|
+
FEED_LIVENESS_OPEN_ENABLED: false,
|
|
124
|
+
FEED_LIVENESS_OPEN_MS: 5000,
|
|
125
|
+
PIN_STATUS_WHILE_WORKING: false,
|
|
126
|
+
POST_ANSWER_LIVENESS_STALE_MS: 90000,
|
|
127
|
+
STATIC: false,
|
|
128
|
+
activeDraftStreams: new Map(),
|
|
129
|
+
activityCardPersistEnabled: false,
|
|
130
|
+
activityCardStoreFs: {
|
|
131
|
+
readFileSync: () => '',
|
|
132
|
+
writeFileSync: noop,
|
|
133
|
+
mkdirSync: noop,
|
|
134
|
+
renameSync: noop,
|
|
135
|
+
unlinkSync: noop,
|
|
136
|
+
},
|
|
137
|
+
bot: { api },
|
|
138
|
+
cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
|
|
139
|
+
currentTurnMap: { get: () => null, byKey: new Map() },
|
|
140
|
+
earlyLivenessOpenTimers: new Map(),
|
|
141
|
+
emissionAuthorityFor: () => fakeEA,
|
|
142
|
+
feedOpenGateDeps: () => ({
|
|
143
|
+
hasOutboundDeliveredSince: () => false,
|
|
144
|
+
historyEnabled: false,
|
|
145
|
+
finalAnswerMinChars: 200,
|
|
146
|
+
}),
|
|
147
|
+
getCurrentTurn: () => null,
|
|
148
|
+
reconcileStatusPin: noop,
|
|
149
|
+
robustApiCall: (fn: () => Promise<unknown>, opts?: { verb?: string; priorityClass?: OutboundClass }) => {
|
|
150
|
+
submitted.push({ verb: opts?.verb, priorityClass: opts?.priorityClass })
|
|
151
|
+
return withOutboundClass(opts?.priorityClass ?? 'critical', fn)
|
|
152
|
+
},
|
|
153
|
+
statusKey: (c: string, t?: number | null) => `${c}:${t ?? 'main'}`,
|
|
154
|
+
} as unknown as NarrativeLaneDeps
|
|
155
|
+
const lane = createNarrativeLane(deps)
|
|
156
|
+
return { lane, landed, fuse, submitted }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function makeLaneTurn(lane: ReturnType<typeof createNarrativeLane>): CurrentTurn {
|
|
160
|
+
const turn = {
|
|
161
|
+
turnId: 'turn-fuse-drop-1',
|
|
162
|
+
sessionChatId: CHAT,
|
|
163
|
+
sessionThreadId: undefined,
|
|
164
|
+
sourceMessageId: null,
|
|
165
|
+
registryKey: null,
|
|
166
|
+
startedAt: Date.now() - 3000,
|
|
167
|
+
currentModel: null,
|
|
168
|
+
totalTokens: 0,
|
|
169
|
+
labeledToolCount: 0,
|
|
170
|
+
mirrorLines: [] as string[],
|
|
171
|
+
foregroundSubAgents: new Map<string, string[]>(),
|
|
172
|
+
activityPendingRender: null as string | null,
|
|
173
|
+
activityLastSentRender: null as string | null,
|
|
174
|
+
activityMessageId: null as number | null,
|
|
175
|
+
activityInFlight: null as Promise<void> | null,
|
|
176
|
+
activityEverOpened: false,
|
|
177
|
+
activityDrainFailures: 0,
|
|
178
|
+
finalAnswerEverDelivered: false,
|
|
179
|
+
finalAnswerDelivered: false,
|
|
180
|
+
replyCalled: false,
|
|
181
|
+
capturedText: [] as string[],
|
|
182
|
+
lastReplyText: '',
|
|
183
|
+
answerStream: null,
|
|
184
|
+
liveness: { recentlyStreaming: () => false, onStreamEvent: () => {}, note: () => {} },
|
|
185
|
+
} as unknown as CurrentTurn
|
|
186
|
+
;(turn as { narrativeGate?: unknown }).narrativeGate = lane.makeNarrativeGate(turn)
|
|
187
|
+
return turn
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const settle = () => new Promise((r) => setTimeout(r, 20))
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A fuse tuned so a cosmetic repaint is genuinely DROPPED:
|
|
194
|
+
* - one cosmetic edit per message per window, so frame 2+ is over budget;
|
|
195
|
+
* - a short defer deadline so the wait ends inside the test;
|
|
196
|
+
* - `lateReleaseMaxPerWindow: 0`, i.e. the bounded R1 overshoot budget is
|
|
197
|
+
* already spent, which is the state in which a lone cosmetic frame is
|
|
198
|
+
* dropped rather than late-released. Without this the fix in the second
|
|
199
|
+
* describe would late-release the frame and no drop would occur at all.
|
|
200
|
+
*/
|
|
201
|
+
const DROPPING_FUSE: EditFloodFuseConfig = {
|
|
202
|
+
perMessageMaxPerWindow: 1,
|
|
203
|
+
cosmeticPerMessageMaxPerWindow: 1,
|
|
204
|
+
perMessageWindowMs: 60_000,
|
|
205
|
+
maxDeferMs: 20,
|
|
206
|
+
lateReleaseMaxPerWindow: 0,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
afterEach(() => {
|
|
210
|
+
vi.restoreAllMocks()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('edit-flood fuse — a deliberate drop is not a failure', () => {
|
|
214
|
+
it('a dropped cosmetic edit RESOLVES through grammY instead of throwing "(undefined: undefined)"', async () => {
|
|
215
|
+
const { api, fuse } = makeFusedApi(DROPPING_FUSE)
|
|
216
|
+
const results: unknown[] = []
|
|
217
|
+
const errors: string[] = []
|
|
218
|
+
for (let i = 0; i < 3; i++) {
|
|
219
|
+
await withOutboundClass('cosmetic', () => api.editMessageText(CHAT, 42, { markdown: `frame ${i}` }))
|
|
220
|
+
.then((r) => results.push(r))
|
|
221
|
+
.catch((e: unknown) => errors.push((e as Error).message))
|
|
222
|
+
}
|
|
223
|
+
// Pre-fix: two of the three surface as
|
|
224
|
+
// `Call to 'editMessageText' failed! (undefined: undefined)`.
|
|
225
|
+
expect(errors).toEqual([])
|
|
226
|
+
// The drop is real — this asserts the fuse actually shed, so the test cannot
|
|
227
|
+
// pass by simply never exercising the drop path.
|
|
228
|
+
expect(fuse.stats().dropped).toBeGreaterThan(0)
|
|
229
|
+
// grammY unwraps `{ok:true, result:true}` to the `true` an edit resolves to.
|
|
230
|
+
expect(results).toEqual([true, true, true])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('the activity-summary drain records ZERO failures and logs no "drain failed" line', async () => {
|
|
234
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
|
235
|
+
const { lane, fuse } = makeFusedLane(DROPPING_FUSE)
|
|
236
|
+
const turn = makeLaneTurn(lane)
|
|
237
|
+
|
|
238
|
+
// Open the card, then repaint it past the cosmetic ceiling so the fuse drops.
|
|
239
|
+
lane.showNarrativeStep(turn, 'Step one of the work')
|
|
240
|
+
await turn.activityInFlight
|
|
241
|
+
for (let i = 2; i <= 6; i++) {
|
|
242
|
+
lane.showNarrativeStep(turn, `Step ${i} of the work`)
|
|
243
|
+
await turn.activityInFlight
|
|
244
|
+
await settle()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
expect(fuse.stats().dropped + fuse.stats().superseded).toBeGreaterThan(0)
|
|
248
|
+
// The counter that flags the turn DEGRADED at turn-end. Pre-fix this reached
|
|
249
|
+
// the number of dropped repaints (observed: 8 on a real turn).
|
|
250
|
+
expect(turn.activityDrainFailures).toBe(0)
|
|
251
|
+
const lines = stderr.mock.calls.map((c) => String(c[0]))
|
|
252
|
+
expect(lines.filter((l) => l.includes('drain failed'))).toEqual([])
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('edit-flood fuse — a terminal card render is never shed', () => {
|
|
257
|
+
it('the lane submits the finalize above `cosmetic`, at a class the fuse will not shed', async () => {
|
|
258
|
+
// REGRESSION PIN. `useful` and `critical` are the only classes above
|
|
259
|
+
// `cosmetic` (`outbound-class.ts` / `retry-api-call.ts` `priorityClass`);
|
|
260
|
+
// the finalize takes `useful` — it may be DEFERRED, but the fuse's
|
|
261
|
+
// cosmetic-only per-message/per-chat ceilings and the R1 drop path do not
|
|
262
|
+
// apply to it. Live repaints staying `cosmetic` is correct and unchanged.
|
|
263
|
+
const { lane, submitted } = makeFusedLane({ enabled: false })
|
|
264
|
+
const turn = makeLaneTurn(lane)
|
|
265
|
+
lane.showNarrativeStep(turn, 'Doing the work now')
|
|
266
|
+
await turn.activityInFlight
|
|
267
|
+
lane.clearActivitySummary(turn)
|
|
268
|
+
await settle()
|
|
269
|
+
|
|
270
|
+
const drain = submitted.filter((s) => s.verb === 'activity-summary.edit')
|
|
271
|
+
const finalize = submitted.filter((s) => s.verb === 'activity-summary.finalize')
|
|
272
|
+
expect(finalize.length).toBeGreaterThanOrEqual(1)
|
|
273
|
+
for (const f of finalize) expect(f.priorityClass).toBe('useful')
|
|
274
|
+
// The live repaints are deliberately still sheddable.
|
|
275
|
+
for (const d of drain) expect(d.priorityClass).toBe('cosmetic')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('the fuse LATE-RELEASES a lone terminal frame at the deadline instead of freezing the card', async () => {
|
|
279
|
+
// R1 on the per-MESSAGE tier. One `useful` edit fills the per-message
|
|
280
|
+
// window; the terminal frame that follows is over budget, is the ONLY frame
|
|
281
|
+
// in flight for its message (nothing newer will ever repaint it), and must
|
|
282
|
+
// therefore be released late rather than dropped.
|
|
283
|
+
const { api, landed, fuse } = makeFusedApi({
|
|
284
|
+
perMessageMaxPerWindow: 1,
|
|
285
|
+
perMessageWindowMs: 60_000,
|
|
286
|
+
maxDeferMs: 20,
|
|
287
|
+
})
|
|
288
|
+
await withOutboundClass('useful', () => api.editMessageText(CHAT, 77, { markdown: 'live frame' }))
|
|
289
|
+
// Nothing else is in flight for message 77 — this is the card's last frame.
|
|
290
|
+
const res = await withOutboundClass('useful', () =>
|
|
291
|
+
api.editMessageText(CHAT, 77, { markdown: 'TERMINAL ✅ done' }),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
// Pre-fix this frame was dropped at the deadline (`dropGuard: undefined` on
|
|
295
|
+
// the per-message tier), so it never reached the API and the card stayed on
|
|
296
|
+
// "live frame" forever — and, via the envelope defect, the caller saw a
|
|
297
|
+
// synthetic transport error on top.
|
|
298
|
+
expect(landed.map((l) => l.text)).toEqual(['live frame', 'TERMINAL ✅ done'])
|
|
299
|
+
expect(res).toBe(true)
|
|
300
|
+
expect(fuse.stats().dropped).toBe(0)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('a superseded frame is still dropped — R1 does not disable last-write-wins', async () => {
|
|
304
|
+
// The guard is a DEADLINE guard only. A genuinely newer frame for the same
|
|
305
|
+
// message must still kill the older waiter, or the fix would turn the
|
|
306
|
+
// per-message tier into an unbounded queue.
|
|
307
|
+
const { api, landed, fuse } = makeFusedApi({
|
|
308
|
+
perMessageMaxPerWindow: 1,
|
|
309
|
+
perMessageWindowMs: 60_000,
|
|
310
|
+
maxDeferMs: 5_000,
|
|
311
|
+
})
|
|
312
|
+
await withOutboundClass('cosmetic', () => api.editMessageText(CHAT, 88, { markdown: 'v1' }))
|
|
313
|
+
const stale = withOutboundClass('cosmetic', () => api.editMessageText(CHAT, 88, { markdown: 'v2' }))
|
|
314
|
+
await settle()
|
|
315
|
+
const newest = withOutboundClass('cosmetic', () => api.editMessageText(CHAT, 88, { markdown: 'v3' }))
|
|
316
|
+
// Both settle without throwing; the stale one simply never painted.
|
|
317
|
+
await expect(stale).resolves.toBe(true)
|
|
318
|
+
await settle()
|
|
319
|
+
expect(fuse.stats().superseded).toBeGreaterThan(0)
|
|
320
|
+
expect(landed.map((l) => l.text)).toEqual(['v1'])
|
|
321
|
+
// Release the still-waiting newest frame so the test does not leak a timer.
|
|
322
|
+
void newest.catch(() => {})
|
|
323
|
+
})
|
|
324
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 🤖 agent card and the 🛠 worker card must end the SAME way: a `─────`
|
|
3
|
+
* rule then `✅ _<final summary sentence>_`.
|
|
4
|
+
*
|
|
5
|
+
* Before this suite the two surfaces had already been unified for the card
|
|
6
|
+
* BODY (#3844 — one `renderStatusCard`/`card-layout` core), but the terminal
|
|
7
|
+
* RESULT footer was still forked: the derivation (clean the raw summary, pick
|
|
8
|
+
* ✅/⚠️ from the state, omit on empty, never emit one for `incomplete`) lived
|
|
9
|
+
* inline in `renderWorkerActivity`, so the agent card had no result block at
|
|
10
|
+
* all and ended on `✓ N steps`. `deriveCardResult` is now the single shared
|
|
11
|
+
* derivation and both adapters call it.
|
|
12
|
+
*
|
|
13
|
+
* These assertions fail on the forked behaviour: (1) the agent card had no
|
|
14
|
+
* `✅` footer to end with, and (2) there was no shared symbol whose output
|
|
15
|
+
* both cards' footers could be compared against byte for byte.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import {
|
|
19
|
+
deriveCardResult,
|
|
20
|
+
renderActivityFeed,
|
|
21
|
+
renderActivityFeedWithNested,
|
|
22
|
+
type SessionActivityHeader,
|
|
23
|
+
} from '../tool-activity-summary.js'
|
|
24
|
+
import { renderWorkerActivity, type WorkerActivityView } from '../worker-activity-feed.js'
|
|
25
|
+
|
|
26
|
+
/** A realistic multi-line, markdown-authored final summary. */
|
|
27
|
+
const RAW_SUMMARY = '## Result\nStanding by for the **background merge waiter** to complete.'
|
|
28
|
+
/** What the shared derivation cleans it down to. */
|
|
29
|
+
const CLEAN_SUMMARY = 'Result Standing by for the background merge waiter to complete.'
|
|
30
|
+
/** The exact two trailing card lines both surfaces must end with. */
|
|
31
|
+
const EXPECTED_FOOTER_TAIL = `─────`
|
|
32
|
+
|
|
33
|
+
function agentHeader(over: Partial<SessionActivityHeader> = {}): SessionActivityHeader {
|
|
34
|
+
return {
|
|
35
|
+
label: 'Agent',
|
|
36
|
+
elapsedMs: 64_000,
|
|
37
|
+
toolCount: 6,
|
|
38
|
+
state: 'done',
|
|
39
|
+
model: 'claude-opus-5',
|
|
40
|
+
totalTokens: 3000,
|
|
41
|
+
...over,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function workerView(over: Partial<WorkerActivityView> = {}): WorkerActivityView {
|
|
46
|
+
return {
|
|
47
|
+
description: 'Merge 3922 and cut v0.19.30',
|
|
48
|
+
lastTool: null,
|
|
49
|
+
toolCount: 6,
|
|
50
|
+
totalTokens: 3000,
|
|
51
|
+
latestSummary: RAW_SUMMARY,
|
|
52
|
+
narrativeLines: ['Reading a.ts', 'Running tests'],
|
|
53
|
+
elapsedMs: 64_000,
|
|
54
|
+
state: 'done',
|
|
55
|
+
model: 'claude-opus-5',
|
|
56
|
+
...over,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The card's trailing result block: the `─────` rule line and everything after. */
|
|
61
|
+
function footerOf(card: string): string {
|
|
62
|
+
const idx = card.lastIndexOf(EXPECTED_FOOTER_TAIL)
|
|
63
|
+
return idx === -1 ? '' : card.slice(idx)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('deriveCardResult — the ONE terminal-footer derivation', () => {
|
|
67
|
+
it('done → ✅ + the cleaned single-paragraph summary', () => {
|
|
68
|
+
expect(deriveCardResult('done', RAW_SUMMARY)).toEqual({ emoji: '✅', text: CLEAN_SUMMARY })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('failed → ⚠️ (never the green tick)', () => {
|
|
72
|
+
expect(deriveCardResult('failed', RAW_SUMMARY)).toEqual({ emoji: '⚠️', text: CLEAN_SUMMARY })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('running → no block (the card is not finished)', () => {
|
|
76
|
+
expect(deriveCardResult('running', RAW_SUMMARY)).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('incomplete → NEVER a block, even with summary text (truthful-no-result)', () => {
|
|
80
|
+
expect(deriveCardResult('incomplete', RAW_SUMMARY)).toBeUndefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('empty / whitespace / markdown-only summary → no block, never a fabricated line', () => {
|
|
84
|
+
expect(deriveCardResult('done', '')).toBeUndefined()
|
|
85
|
+
expect(deriveCardResult('done', undefined)).toBeUndefined()
|
|
86
|
+
expect(deriveCardResult('done', ' \n---\n ')).toBeUndefined()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('redacts a secret in the summary — cards bypass the outbound redact chokepoint', () => {
|
|
90
|
+
// Status cards go out via sendRichMessage, which skips
|
|
91
|
+
// `normalizeOutboundBody → redact` (outbound-send-path.ts). The agent
|
|
92
|
+
// card's summary is the turn's PRE-redaction reply text, so the scrub has
|
|
93
|
+
// to happen in this derivation or a token reaches Telegram verbatim.
|
|
94
|
+
const token = 'sk-ant-' + 'api03-' + 'A'.repeat(48)
|
|
95
|
+
const out = deriveCardResult('done', `Deployed with ${token} and all is green.`)!
|
|
96
|
+
expect(out.text).not.toContain(token)
|
|
97
|
+
expect(out.text).toContain('[REDACTED:')
|
|
98
|
+
expect(out.text).toContain('Deployed with')
|
|
99
|
+
expect(out.text).toContain('and all is green.')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('🤖 agent card ends with the ✅ + final-summary footer', () => {
|
|
104
|
+
it('final render with resultText appends the rule + ✅ summary as the LAST line', () => {
|
|
105
|
+
const card = renderActivityFeed(
|
|
106
|
+
['Reading a.ts', 'Running tests'],
|
|
107
|
+
true,
|
|
108
|
+
'',
|
|
109
|
+
6,
|
|
110
|
+
agentHeader({ resultText: RAW_SUMMARY }),
|
|
111
|
+
)!
|
|
112
|
+
expect(card).toContain('─────')
|
|
113
|
+
// The ✅ summary is the final line of the card, exactly like the worker card.
|
|
114
|
+
expect(card.endsWith(`✅ _${CLEAN_SUMMARY}_`)).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('the nested (foreground sub-agent) render gets the identical footer', () => {
|
|
118
|
+
const flat = renderActivityFeed(
|
|
119
|
+
['Delegating: x'],
|
|
120
|
+
true,
|
|
121
|
+
'',
|
|
122
|
+
6,
|
|
123
|
+
agentHeader({ resultText: RAW_SUMMARY }),
|
|
124
|
+
)!
|
|
125
|
+
const nested = renderActivityFeedWithNested(
|
|
126
|
+
['Delegating: x'],
|
|
127
|
+
['child step'],
|
|
128
|
+
true,
|
|
129
|
+
'',
|
|
130
|
+
6,
|
|
131
|
+
agentHeader({ resultText: RAW_SUMMARY }),
|
|
132
|
+
)!
|
|
133
|
+
expect(footerOf(nested)).toBe(footerOf(flat))
|
|
134
|
+
expect(nested.endsWith(`✅ _${CLEAN_SUMMARY}_`)).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('no resultText → no footer block (worker fallback), card still ends on ✓ N steps', () => {
|
|
138
|
+
const card = renderActivityFeed(['Reading a.ts'], true, '', 6, agentHeader())!
|
|
139
|
+
expect(card).not.toContain('─────')
|
|
140
|
+
expect(card).not.toContain('✅')
|
|
141
|
+
expect(card.endsWith('_✓ 6 steps_')).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('a LIVE (non-final) render never shows the footer, even if resultText is set', () => {
|
|
145
|
+
const card = renderActivityFeed(
|
|
146
|
+
['Reading a.ts'],
|
|
147
|
+
false,
|
|
148
|
+
'',
|
|
149
|
+
undefined,
|
|
150
|
+
agentHeader({ state: 'running', resultText: RAW_SUMMARY }),
|
|
151
|
+
)!
|
|
152
|
+
expect(card).not.toContain('─────')
|
|
153
|
+
expect(card).not.toContain('✅')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('both cards render their footer through the SAME shared derivation', () => {
|
|
158
|
+
it('agent + worker footers are byte-identical for the same raw summary', () => {
|
|
159
|
+
const agent = renderActivityFeed(
|
|
160
|
+
['Reading a.ts', 'Running tests'],
|
|
161
|
+
true,
|
|
162
|
+
'',
|
|
163
|
+
6,
|
|
164
|
+
agentHeader({ resultText: RAW_SUMMARY }),
|
|
165
|
+
)!
|
|
166
|
+
const worker = renderWorkerActivity(workerView())
|
|
167
|
+
const footer = footerOf(agent)
|
|
168
|
+
expect(footer).not.toBe('')
|
|
169
|
+
expect(footer).toBe(footerOf(worker))
|
|
170
|
+
// …and that shared footer is exactly what `deriveCardResult` produced.
|
|
171
|
+
const derived = deriveCardResult('done', RAW_SUMMARY)!
|
|
172
|
+
expect(footer.endsWith(`${derived.emoji} _${derived.text}_`)).toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('the shared body (stats line + struck step list) is identical too', () => {
|
|
176
|
+
// Line 2 (the `done · N tools · N tok · elapsed · model` stats run) and the
|
|
177
|
+
// struck-through step lines come from the one card core — the only
|
|
178
|
+
// legitimate difference is the title line (icon + label + task).
|
|
179
|
+
const agent = renderActivityFeed(
|
|
180
|
+
['Reading a.ts', 'Running tests'],
|
|
181
|
+
true,
|
|
182
|
+
'',
|
|
183
|
+
undefined,
|
|
184
|
+
agentHeader({ resultText: RAW_SUMMARY }),
|
|
185
|
+
)!
|
|
186
|
+
const worker = renderWorkerActivity(workerView())
|
|
187
|
+
const dropTitle = (card: string) => card.split(' \n').slice(1).join(' \n')
|
|
188
|
+
expect(dropTitle(agent)).toBe(dropTitle(worker))
|
|
189
|
+
// The title lines DO differ — that is the one parameterised difference.
|
|
190
|
+
expect(agent.split(' \n')[0]).toContain('🤖')
|
|
191
|
+
expect(worker.split(' \n')[0]).toContain('🛠')
|
|
192
|
+
})
|
|
193
|
+
})
|