switchroom 0.18.17 → 0.18.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +13 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/notion-write-pretool.mjs +13 -0
- package/dist/cli/switchroom.js +605 -479
- package/dist/host-control/main.js +17 -1
- package/dist/vault/approvals/kernel-server.js +13 -0
- package/dist/vault/broker/server.js +13 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1544 -619
- package/telegram-plugin/dist/server.js +32 -1
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/format.ts +137 -213
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
- package/telegram-plugin/gateway/gateway.ts +399 -85
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +103 -0
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/llm-error-present.ts +474 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/send-gate-degraded.test.ts +152 -1
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +212 -19
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/stream-reply-handler.ts +5 -14
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
- package/telegram-plugin/tests/format-consistency.test.ts +68 -53
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/llm-error-present.test.ts +481 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
- package/telegram-plugin/tests/telegram-format.test.ts +86 -31
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/turn-flush-safety.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +509 -409
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the MODEL-TIER downgrade failover (second recovery tier).
|
|
3
|
+
*
|
|
4
|
+
* The pure `decideTierDowngrade` owns the decision the gateway consults inside
|
|
5
|
+
* the `all-blocked` branch of doFireFleetAutoFallback — i.e. AFTER account-swap
|
|
6
|
+
* has been tried and found no account still serving the walled premium model.
|
|
7
|
+
* `planTierDowngrade` then routes that decision + the resume-gate verdict into
|
|
8
|
+
* what the gateway should DO (downgrade / suppress the give-up / fall through),
|
|
9
|
+
* and `renderTierDowngradeNotice` owns the exact user-facing wording. All three
|
|
10
|
+
* are pure so the decision, the concurrent-turn suppression (LOW-9a), and the
|
|
11
|
+
* honesty of the broadcast (no "revert to the premium model" promise) are pinned
|
|
12
|
+
* without a process restart.
|
|
13
|
+
*
|
|
14
|
+
* Contract (post loop-guard reconciliation, option b — the ceremonial
|
|
15
|
+
* `.tier-downgrade-attempts` counter was REMOVED):
|
|
16
|
+
* - overload + the session is NOT on a premium model (override null, or the
|
|
17
|
+
* override resolves to the configured default) → NO downgrade ('skip'); the
|
|
18
|
+
* account-swap path already handled it or there is no lower tier to fall to.
|
|
19
|
+
* This is also the natural loop bound: after the downgrade boot the override
|
|
20
|
+
* is gone, so a re-entry lands on 'on-default' and never re-downgrades.
|
|
21
|
+
* - overload + a premium model walled across ALL accounts → 'downgrade' to the
|
|
22
|
+
* configured default.
|
|
23
|
+
* - a resume restart already armed in this process (gate 'skip-inflight') →
|
|
24
|
+
* 'suppress': no give-up card, no re-downgrade (the armed restart resumes).
|
|
25
|
+
* - the downgrade broadcast states resume-on-default + manual re-issue and
|
|
26
|
+
* carries NO promise of an automatic revert to the premium model.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect } from 'vitest'
|
|
30
|
+
import {
|
|
31
|
+
decideTierDowngrade,
|
|
32
|
+
planTierDowngrade,
|
|
33
|
+
renderTierDowngradeNotice,
|
|
34
|
+
} from '../tier-downgrade.js'
|
|
35
|
+
|
|
36
|
+
// The gateway passes resolveMainModel; here a near-identity resolver (maps the
|
|
37
|
+
// unset/`default` sentinel to the switchroom default, exactly like the real one)
|
|
38
|
+
// is enough to exercise the canonicalization guard.
|
|
39
|
+
const resolve = (t: string): string =>
|
|
40
|
+
t === '' || t === 'default' ? 'claude-sonnet-5' : t
|
|
41
|
+
|
|
42
|
+
describe('decideTierDowngrade — precedence + downgrade target', () => {
|
|
43
|
+
it('NO downgrade when the session is on the configured default (override null)', () => {
|
|
44
|
+
// Overload + account-swap all-blocked, but nothing premium is active — the
|
|
45
|
+
// account-swap path owns this and there is no lower tier. => skip.
|
|
46
|
+
const d = decideTierDowngrade({ sessionOverride: null, configuredDefault: 'opus', resolve })
|
|
47
|
+
expect(d).toEqual({ action: 'skip', reason: 'on-default' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('NO downgrade when the override resolves to the configured default (alias vs id)', () => {
|
|
51
|
+
// A premium-looking override that is actually the default in another spelling
|
|
52
|
+
// must never read as a premium tier (that would loop). Same resolver both
|
|
53
|
+
// sides → on-default.
|
|
54
|
+
const d = decideTierDowngrade({
|
|
55
|
+
sessionOverride: 'default',
|
|
56
|
+
configuredDefault: 'claude-sonnet-5',
|
|
57
|
+
resolve,
|
|
58
|
+
})
|
|
59
|
+
expect(d).toEqual({ action: 'skip', reason: 'on-default' })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('NO downgrade (and no blind fall) when the configured default is unreadable', () => {
|
|
63
|
+
const d = decideTierDowngrade({ sessionOverride: 'fable', configuredDefault: null, resolve })
|
|
64
|
+
expect(d).toEqual({ action: 'skip', reason: 'unresolved' })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('DOWNGRADES a walled premium model to the configured default', () => {
|
|
68
|
+
const d = decideTierDowngrade({ sessionOverride: 'fable', configuredDefault: 'opus', resolve })
|
|
69
|
+
expect(d).toEqual({ action: 'downgrade', toModel: 'opus', fromModel: 'fable' })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('downgrade target is GENERAL — any premium model → the agent default (not hardcoded fable→opus)', () => {
|
|
73
|
+
const d = decideTierDowngrade({
|
|
74
|
+
sessionOverride: 'sr-x-ai/grok-4',
|
|
75
|
+
configuredDefault: 'claude-sonnet-5',
|
|
76
|
+
resolve,
|
|
77
|
+
})
|
|
78
|
+
expect(d).toEqual({ action: 'downgrade', toModel: 'claude-sonnet-5', fromModel: 'sr-x-ai/grok-4' })
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('renderTierDowngradeNotice — HONEST wording (no revert-to-premium promise)', () => {
|
|
83
|
+
const notice = renderTierDowngradeNotice('fable', 'opus', 'klanker')
|
|
84
|
+
|
|
85
|
+
it('states the turn resumes on the DEFAULT to keep working', () => {
|
|
86
|
+
expect(notice).toContain('opus')
|
|
87
|
+
expect(notice.toLowerCase()).toContain('resuming on the default')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('tells the user to RE-ISSUE the premium /model themselves', () => {
|
|
91
|
+
expect(notice).toContain('/model fable')
|
|
92
|
+
expect(notice.toLowerCase()).toContain("won't switch back on its own")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("carries NO promise of an automatic revert to the premium/fable model", () => {
|
|
96
|
+
// The HIGH finding: the prior wording lied ("It reverts to `fable` on the
|
|
97
|
+
// next restart"). Assert none of those false promises can creep back.
|
|
98
|
+
const lower = notice.toLowerCase()
|
|
99
|
+
expect(lower).not.toMatch(/reverts? to `?fable/)
|
|
100
|
+
expect(lower).not.toContain('reverts to `fable`')
|
|
101
|
+
expect(lower).not.toContain('on the next restart')
|
|
102
|
+
expect(lower).not.toMatch(/revert(s|ing)? to the premium/)
|
|
103
|
+
expect(lower).not.toMatch(/back to `?fable`? (on|at|after)/)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('planTierDowngrade — routing (downgrade / suppress / skip)', () => {
|
|
108
|
+
const skipDecision = { action: 'skip', reason: 'on-default' } as const
|
|
109
|
+
const downgradeDecision = { action: 'downgrade', toModel: 'opus', fromModel: 'fable' } as const
|
|
110
|
+
|
|
111
|
+
it("a 'skip' decision routes to 'skip' regardless of the gate verdict", () => {
|
|
112
|
+
expect(planTierDowngrade(skipDecision, 'resume', 'ag').kind).toBe('skip')
|
|
113
|
+
expect(planTierDowngrade(skipDecision, 'skip-inflight', 'ag').kind).toBe('skip')
|
|
114
|
+
expect(planTierDowngrade(skipDecision, 'skip-stale', 'ag').kind).toBe('skip')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("downgrade + gate 'resume' → 'downgrade' with the honest notice", () => {
|
|
118
|
+
const plan = planTierDowngrade(downgradeDecision, 'resume', 'klanker')
|
|
119
|
+
expect(plan.kind).toBe('downgrade')
|
|
120
|
+
if (plan.kind === 'downgrade') {
|
|
121
|
+
expect(plan.toModel).toBe('opus')
|
|
122
|
+
expect(plan.fromModel).toBe('fable')
|
|
123
|
+
// The plan carries EXACTLY the honest renderer output — no revert promise.
|
|
124
|
+
expect(plan.notice).toBe(renderTierDowngradeNotice('fable', 'opus', 'klanker'))
|
|
125
|
+
expect(plan.notice.toLowerCase()).not.toContain('on the next restart')
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("LOW-9a: downgrade + gate 'skip-inflight' → 'suppress' (a resume restart is already armed, no give-up card)", () => {
|
|
130
|
+
// Two turns hit all-blocked in the same pre-restart process. Turn 1 armed a
|
|
131
|
+
// downgrade (or account-swap) restart. Turn 2 must NOT broadcast a "could
|
|
132
|
+
// not be recovered" give-up — the armed restart resumes the interrupted turn.
|
|
133
|
+
const plan = planTierDowngrade(downgradeDecision, 'skip-inflight', 'klanker')
|
|
134
|
+
expect(plan.kind).toBe('suppress')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("downgrade + gate 'skip-stale' → 'skip' (turn too old to resume; fall through to the all-blocked card)", () => {
|
|
138
|
+
const plan = planTierDowngrade(downgradeDecision, 'skip-stale', 'klanker')
|
|
139
|
+
expect(plan.kind).toBe('skip')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -31,9 +31,7 @@ import {
|
|
|
31
31
|
normalizeParagraphBreaks,
|
|
32
32
|
normalizePunctuation,
|
|
33
33
|
stripExcessBold,
|
|
34
|
-
addParagraphSpacers,
|
|
35
34
|
splitMarkdownChunks,
|
|
36
|
-
PARAGRAPH_SPACER,
|
|
37
35
|
RICH_MESSAGE_MAX_CHARS,
|
|
38
36
|
} from '../format.js'
|
|
39
37
|
|
|
@@ -130,7 +128,9 @@ describe('decideTurnFlush — prose+trailing-sentinel is suppressed, not leaked
|
|
|
130
128
|
// the real gateway turn-flush render pipeline (post-#2669 rich-markdown path):
|
|
131
129
|
// decideTurnFlush -> join('\n\n')
|
|
132
130
|
// -> repairEscapedWhitespace -> normalizeParagraphBreaks
|
|
133
|
-
// ->
|
|
131
|
+
// -> splitMarkdownChunks -> sendRichMessage
|
|
132
|
+
// (no paragraph-spacer pass — the NBSP spacer was removed in the #2669
|
|
133
|
+
// follow-up; gaps are plain `\n\n`, one visible blank line)
|
|
134
134
|
// so it pins the end-to-end fix, not just the pure decision. The corpus is a
|
|
135
135
|
// REAL captured-transcript shape (three separate content[i].text blocks, one
|
|
136
136
|
// stored UNTRIMMED with a trailing '\n' exactly as session-tail.ts pushes
|
|
@@ -156,8 +156,7 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
|
|
|
156
156
|
const d = decideTurnFlush({ chatId: '12345', replyCalled: false, capturedText: blocks })
|
|
157
157
|
expect(d.kind).toBe('flush')
|
|
158
158
|
const joined = (d as { kind: 'flush'; text: string }).text
|
|
159
|
-
|
|
160
|
-
return addParagraphSpacers(normalized)
|
|
159
|
+
return normalizeParagraphBreaks(repairEscapedWhitespace(joined))
|
|
161
160
|
}
|
|
162
161
|
|
|
163
162
|
it('separates whole blocks with a visible paragraph gap, not a wall-of-text', () => {
|
|
@@ -167,32 +166,29 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
|
|
|
167
166
|
expect(out).toContain('The `auth` handler looks correct')
|
|
168
167
|
expect(out).toContain('Want me to open a PR')
|
|
169
168
|
// The wall-of-text failure mode glues two blocks one '\n' apart. Assert the
|
|
170
|
-
// boundary carries a real paragraph break
|
|
171
|
-
//
|
|
172
|
-
expect(out).toContain(
|
|
169
|
+
// boundary carries a real paragraph break (plain `\n\n`, one blank line —
|
|
170
|
+
// no NBSP spacer), and NOT a single-newline join.
|
|
171
|
+
expect(out).toContain('flagged.\n\nThe `auth`')
|
|
173
172
|
expect(out).not.toContain('flagged.\nThe `auth`')
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
const b2 = out.indexOf('The `auth` handler')
|
|
177
|
-
expect(out.slice(b1, b2)).toContain(PARAGRAPH_SPACER)
|
|
173
|
+
// No U+00A0 anywhere.
|
|
174
|
+
expect(out).not.toContain(String.fromCharCode(0xa0))
|
|
178
175
|
})
|
|
179
176
|
|
|
180
177
|
it('collapses the untrimmed-trailing-newline stack — no 3+ newline run reaches the wire', () => {
|
|
181
178
|
const out = renderLikeTurnFlush(realBlocks)
|
|
182
179
|
// Block 2's trailing '\n' + the '\n\n' join = 3 newlines; normalize
|
|
183
|
-
// collapses 3+ runs to '\n\n'
|
|
184
|
-
// spacer, so no doubled/stacked blank run survives.
|
|
180
|
+
// collapses 3+ runs to '\n\n', so no doubled/stacked blank run survives.
|
|
185
181
|
expect(out).not.toMatch(/\n{3,}/)
|
|
186
|
-
// One
|
|
187
|
-
const
|
|
188
|
-
expect(
|
|
182
|
+
// One blank-line gap per block transition: 3 blocks → 2 gaps.
|
|
183
|
+
const gapCount = (out.match(/\n\n/g) ?? []).length
|
|
184
|
+
expect(gapCount).toBe(2)
|
|
189
185
|
})
|
|
190
186
|
|
|
191
187
|
it('the whole separated answer stays in one rich chunk here (well under 32768)', () => {
|
|
192
188
|
const out = renderLikeTurnFlush(realBlocks)
|
|
193
189
|
const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
|
|
194
190
|
expect(chunks.length).toBe(1)
|
|
195
|
-
expect(chunks[0]).toContain(
|
|
191
|
+
expect(chunks[0]).toContain('\n\n')
|
|
196
192
|
})
|
|
197
193
|
|
|
198
194
|
it('still SUPPRESSES a real transcript that deliberately terminates with a bare NO_REPLY (#2053 guard intact)', () => {
|
|
@@ -228,9 +224,9 @@ describe('#2798 turn-flush block separation — real multi-block transcript shap
|
|
|
228
224
|
// runs (gateway executeReply):
|
|
229
225
|
// repairEscapedWhitespace -> normalizeParagraphBreaks -> redactOutboundText
|
|
230
226
|
// -> stripExcessBold(normalizePunctuation) -> scrubVoice
|
|
231
|
-
//
|
|
227
|
+
// (no send-side paragraph-spacer pass — removed in the #2669 follow-up).
|
|
232
228
|
// The original #2798 change gave turn-flush the paragraph steps + redact +
|
|
233
|
-
// scrub
|
|
229
|
+
// scrub but OMITTED `stripExcessBold(normalizePunctuation(...))`.
|
|
234
230
|
// This suite reconstructs the deterministic format chain of BOTH paths (the
|
|
235
231
|
// runtime-only redact + voice-scrub steps are literally the same calls on both
|
|
236
232
|
// paths and are out of scope here) and pins that turn-flush now matches reply
|
|
@@ -312,7 +308,7 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
|
|
|
312
308
|
function formatChain(text: string): string {
|
|
313
309
|
let t = normalizeParagraphBreaks(repairEscapedWhitespace(text))
|
|
314
310
|
t = stripExcessBold(normalizePunctuation(t))
|
|
315
|
-
return
|
|
311
|
+
return t
|
|
316
312
|
}
|
|
317
313
|
|
|
318
314
|
const input =
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { describe, it, expect } from 'bun:test'
|
|
12
12
|
import { GrammyError } from 'grammy'
|
|
13
13
|
import { classifyRejection } from '../gateway/unhandled-rejection-policy.js'
|
|
14
|
-
import { FLOOD_WAIT_ACTIVE } from '../retry-api-call.js'
|
|
14
|
+
import { FLOOD_WAIT_ACTIVE, LOCAL_RESOURCE_EXHAUSTED } from '../retry-api-call.js'
|
|
15
15
|
|
|
16
16
|
// ── Real GrammyError fixtures ──────────────────────────────────────────────
|
|
17
17
|
|
|
@@ -241,3 +241,29 @@ describe('classifyRejection — FLOOD_WAIT_ACTIVE marker (#3084)', () => {
|
|
|
241
241
|
expect(classifyRejection(new Error('FLOOD_WAIT_ACTIVE-ish but not it'))).toBe('shutdown')
|
|
242
242
|
})
|
|
243
243
|
})
|
|
244
|
+
|
|
245
|
+
describe('classifyRejection — LOCAL_RESOURCE_EXHAUSTED marker (#3099)', () => {
|
|
246
|
+
// retry-api-call throws this plain Error marker (retry-api-call.ts:344) when a
|
|
247
|
+
// send fails on LOCAL disk/memory exhaustion (ENOSPC/EDQUOT/EIO/ENOMEM) rather
|
|
248
|
+
// than retrying it (#2923). Like its sibling FLOOD_WAIT_ACTIVE it is a plain
|
|
249
|
+
// Error, not a GrammyError, so without an explicit entry it fell into the
|
|
250
|
+
// `!isGrammy → shutdown` branch — crashing the gateway when the box is ALREADY
|
|
251
|
+
// out of disk, which drives a fresh round of boot-time sends/staging writes at
|
|
252
|
+
// an exhausted resource (the amplification the #2923 marker exists to avoid).
|
|
253
|
+
it('returns "log_only" for a leaked LOCAL_RESOURCE_EXHAUSTED marker', () => {
|
|
254
|
+
// Mirror the real throw shape from retry-api-call.ts:344 —
|
|
255
|
+
// `Object.assign(new Error(LOCAL_RESOURCE_EXHAUSTED), { original: err })`.
|
|
256
|
+
const err = Object.assign(new Error(LOCAL_RESOURCE_EXHAUSTED), {
|
|
257
|
+
original: Object.assign(new Error('ENOSPC: no space left on device'), {
|
|
258
|
+
code: 'ENOSPC',
|
|
259
|
+
}),
|
|
260
|
+
})
|
|
261
|
+
expect(classifyRejection(err)).toBe('log_only')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('still returns "shutdown" for an unrelated plain Error', () => {
|
|
265
|
+
expect(
|
|
266
|
+
classifyRejection(new Error('LOCAL_RESOURCE_EXHAUSTED-ish but not it')),
|
|
267
|
+
).toBe('shutdown')
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -533,17 +533,20 @@ describe('createWorkerActivityFeed — log sink', () => {
|
|
|
533
533
|
const edit = logs.find((l) => l.startsWith('worker-feed: edit'))
|
|
534
534
|
const finish = logs.find((l) => l.startsWith('worker-feed: finish'))
|
|
535
535
|
|
|
536
|
+
// The feed message is per-(chat,thread) now (workers coalesce), so the
|
|
537
|
+
// paint/edit lines are feed-scoped; the terminal `finish` line still names
|
|
538
|
+
// the finishing worker + its state.
|
|
536
539
|
expect(paint).toBeDefined()
|
|
537
|
-
expect(paint).toContain('agent=w-research')
|
|
538
540
|
expect(paint).toContain('chat=chat-9')
|
|
539
541
|
expect(paint).toContain('thread=7')
|
|
540
542
|
expect(paint).toMatch(/msgId=\d+/)
|
|
541
543
|
expect(paint).toMatch(/bytes=\d+/)
|
|
542
544
|
|
|
543
545
|
expect(edit).toBeDefined()
|
|
544
|
-
expect(edit).toContain('
|
|
546
|
+
expect(edit).toContain('chat=chat-9')
|
|
545
547
|
|
|
546
548
|
expect(finish).toBeDefined()
|
|
549
|
+
expect(finish).toContain('agent=w-research')
|
|
547
550
|
expect(finish).toContain('state=done')
|
|
548
551
|
})
|
|
549
552
|
|