switchroom 0.18.17 → 0.18.18

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.
Files changed (54) hide show
  1. package/dist/agent-scheduler/index.js +13 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/notion-write-pretool.mjs +13 -0
  4. package/dist/cli/switchroom.js +605 -479
  5. package/dist/host-control/main.js +17 -1
  6. package/dist/vault/approvals/kernel-server.js +13 -0
  7. package/dist/vault/broker/server.js +13 -0
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +7 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +1401 -431
  12. package/telegram-plugin/dist/server.js +26 -1
  13. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  14. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  15. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
  16. package/telegram-plugin/gateway/gateway.ts +362 -71
  17. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  18. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  19. package/telegram-plugin/gateway/session-model-file.ts +103 -0
  20. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  21. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  22. package/telegram-plugin/llm-error-present.ts +436 -0
  23. package/telegram-plugin/operator-events.ts +7 -1
  24. package/telegram-plugin/permission-title.ts +172 -10
  25. package/telegram-plugin/premium-recovery.ts +101 -0
  26. package/telegram-plugin/raw-error-scrub.ts +73 -0
  27. package/telegram-plugin/retry-api-call.ts +8 -2
  28. package/telegram-plugin/send-gate-degraded.test.ts +152 -1
  29. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  30. package/telegram-plugin/send-gate-observability.ts +65 -20
  31. package/telegram-plugin/send-gate.test.ts +143 -1
  32. package/telegram-plugin/send-gate.ts +212 -19
  33. package/telegram-plugin/session-tail.ts +16 -0
  34. package/telegram-plugin/shared/local-time.ts +69 -0
  35. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  36. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  37. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  38. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  39. package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
  40. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  41. package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
  42. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  43. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  44. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  45. package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
  46. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  47. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  48. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  49. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  50. package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
  51. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  52. package/telegram-plugin/tier-downgrade.ts +198 -0
  53. package/telegram-plugin/tool-activity-summary.ts +99 -0
  54. package/telegram-plugin/worker-activity-feed.ts +509 -409
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Integration test for the tier-downgrade GLUE (`runTierDowngrade`,
3
+ * tier-downgrade-wiring.ts) — the gateway orchestration the pure
4
+ * `decideTierDowngrade` / `renderTierDowngradeNotice` tests do NOT cover.
5
+ *
6
+ * This is the test the @overlord review asked for (the MEDIUM "reverts to
7
+ * fable" misinform slipped through precisely because the glue that emits the
8
+ * BROADCAST notice text had no test — only the pure renderer did). It drives
9
+ * the real runner with injected seams and PINS the broadcast notice:
10
+ * - asserts the resume-on-DEFAULT wording + the manual re-issue instruction;
11
+ * - FAILS on any revert-to-premium phrasing (the exact regression).
12
+ * It also covers the resume-gate suppression path and the carrier-before-arm
13
+ * ordering invariant.
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest'
17
+ import {
18
+ runTierDowngrade,
19
+ type TierDowngradeRunnerDeps,
20
+ type TierDowngradeOutcome,
21
+ } from '../gateway/tier-downgrade-wiring.js'
22
+
23
+ interface Recorder {
24
+ carrier: Array<{ dir: string; toModel: string; cfg: string }>
25
+ arms: number
26
+ markers: Array<{ dir: string; premiumModel: string }>
27
+ notices: string[]
28
+ restarts: string[]
29
+ logs: string[]
30
+ }
31
+
32
+ function makeDeps(
33
+ over: Partial<TierDowngradeRunnerDeps> & {
34
+ sessionOverride?: string | null
35
+ configuredDefault?: string | null
36
+ gateVerdict?: 'resume' | 'skip-inflight' | 'skip-stale'
37
+ carrierThrows?: boolean
38
+ markerThrows?: boolean
39
+ agentDir?: string | null
40
+ } = {},
41
+ ): { deps: TierDowngradeRunnerDeps; rec: Recorder } {
42
+ const rec: Recorder = { carrier: [], arms: 0, markers: [], notices: [], restarts: [], logs: [] }
43
+ // A near-identity resolver like the real resolveMainModel: the unset/`default`
44
+ // sentinel maps to the switchroom default, everything else is itself.
45
+ const resolve = (t: string): string => (t === '' || t === 'default' ? 'claude-opus-4-8' : t)
46
+ const deps: TierDowngradeRunnerDeps = {
47
+ getAgentDir: () => (over.agentDir === undefined ? '/tmp/agent' : over.agentDir),
48
+ getConfiguredDefault: () => (over.configuredDefault === undefined ? 'opus' : over.configuredDefault),
49
+ getSessionOverride: () => (over.sessionOverride === undefined ? 'fable' : over.sessionOverride),
50
+ resolve,
51
+ peekResumeGate: () => over.gateVerdict ?? 'resume',
52
+ writeCarrier: (dir, toModel, cfg) => {
53
+ if (over.carrierThrows) throw new Error('disk full')
54
+ rec.carrier.push({ dir, toModel, cfg })
55
+ },
56
+ armResumeGate: () => {
57
+ rec.arms += 1
58
+ },
59
+ writeRecoveryMarker: (dir, premiumModel) => {
60
+ if (over.markerThrows) throw new Error('marker write failed')
61
+ rec.markers.push({ dir, premiumModel })
62
+ },
63
+ broadcastNotice: (md) => rec.notices.push(md),
64
+ selfRestart: (agent) => rec.restarts.push(agent),
65
+ selfAgent: (t) => `self-${t}`,
66
+ log: (msg) => rec.logs.push(msg),
67
+ ...over,
68
+ }
69
+ return { deps, rec }
70
+ }
71
+
72
+ describe('runTierDowngrade — broadcast notice honesty (the pinned regression)', () => {
73
+ it('downgrade path broadcasts EXACTLY ONE notice with resume-on-default wording', () => {
74
+ const { deps, rec } = makeDeps({ sessionOverride: 'fable', configuredDefault: 'opus' })
75
+ const outcome: TierDowngradeOutcome = runTierDowngrade('klanker', deps)
76
+ expect(outcome).toBe('downgraded')
77
+ expect(rec.notices).toHaveLength(1)
78
+ const notice = rec.notices[0]
79
+ // Resume-on-DEFAULT + the manual re-issue instruction.
80
+ expect(notice).toContain('`opus`')
81
+ expect(notice).toContain('/model fable')
82
+ expect(notice.toLowerCase()).toContain("won't switch back on its own")
83
+ })
84
+
85
+ it('the notice NEVER promises a revert to the premium model (the MEDIUM regression)', () => {
86
+ const { deps, rec } = makeDeps({ sessionOverride: 'fable', configuredDefault: 'opus' })
87
+ runTierDowngrade('klanker', deps)
88
+ const notice = rec.notices[0].toLowerCase()
89
+ // The exact false phrasing from the pre-5d9bbf44 revision, and its kin.
90
+ expect(notice).not.toContain('reverts to `fable`')
91
+ expect(notice).not.toContain('revert to `fable`')
92
+ expect(notice).not.toMatch(/reverts? to fable/)
93
+ expect(notice).not.toMatch(/switch(es)? back.*on (the )?next restart/)
94
+ expect(notice).not.toMatch(/automatically.*(fable|premium)/)
95
+ })
96
+ })
97
+
98
+ describe('runTierDowngrade — side-effect ordering + outcomes', () => {
99
+ it('downgrade: carrier written BEFORE the arm, marker + notice + restart all fire', () => {
100
+ const { deps, rec } = makeDeps({ sessionOverride: 'fable', configuredDefault: 'opus' })
101
+ const outcome = runTierDowngrade('klanker', deps)
102
+ expect(outcome).toBe('downgraded')
103
+ expect(rec.carrier).toEqual([{ dir: '/tmp/agent', toModel: 'opus', cfg: 'opus' }])
104
+ expect(rec.arms).toBe(1)
105
+ expect(rec.markers).toEqual([{ dir: '/tmp/agent', premiumModel: 'fable' }])
106
+ expect(rec.restarts).toEqual(['self-klanker'])
107
+ })
108
+
109
+ it('carrier write THROWS → abort: NO arm, NO marker, NO notice, NO restart (skip)', () => {
110
+ const { deps, rec } = makeDeps({ carrierThrows: true })
111
+ const outcome = runTierDowngrade('klanker', deps)
112
+ expect(outcome).toBe('skip')
113
+ expect(rec.arms).toBe(0)
114
+ expect(rec.markers).toHaveLength(0)
115
+ expect(rec.notices).toHaveLength(0)
116
+ expect(rec.restarts).toHaveLength(0)
117
+ })
118
+
119
+ it('marker write THROWS → downgrade still completes (marker is best-effort)', () => {
120
+ const { deps, rec } = makeDeps({ markerThrows: true })
121
+ const outcome = runTierDowngrade('klanker', deps)
122
+ expect(outcome).toBe('downgraded')
123
+ expect(rec.arms).toBe(1)
124
+ expect(rec.notices).toHaveLength(1)
125
+ expect(rec.restarts).toEqual(['self-klanker'])
126
+ expect(rec.markers).toHaveLength(0)
127
+ })
128
+
129
+ it('resume-gate skip-inflight → restart-pending, NO notice, NO restart, NO carrier', () => {
130
+ const { deps, rec } = makeDeps({ gateVerdict: 'skip-inflight' })
131
+ const outcome = runTierDowngrade('klanker', deps)
132
+ expect(outcome).toBe('restart-pending')
133
+ expect(rec.carrier).toHaveLength(0)
134
+ expect(rec.arms).toBe(0)
135
+ expect(rec.notices).toHaveLength(0)
136
+ expect(rec.restarts).toHaveLength(0)
137
+ })
138
+
139
+ it('resume-gate skip-stale → skip (give up), NO notice/restart', () => {
140
+ const { deps, rec } = makeDeps({ gateVerdict: 'skip-stale' })
141
+ const outcome = runTierDowngrade('klanker', deps)
142
+ expect(outcome).toBe('skip')
143
+ expect(rec.notices).toHaveLength(0)
144
+ expect(rec.restarts).toHaveLength(0)
145
+ })
146
+
147
+ it('session on the configured default (override null) → skip, no downgrade', () => {
148
+ const { deps, rec } = makeDeps({ sessionOverride: null })
149
+ const outcome = runTierDowngrade('klanker', deps)
150
+ expect(outcome).toBe('skip')
151
+ expect(rec.notices).toHaveLength(0)
152
+ })
153
+
154
+ it('unresolved configured default → skip (never downgrade blind)', () => {
155
+ const { deps, rec } = makeDeps({ configuredDefault: '' })
156
+ const outcome = runTierDowngrade('klanker', deps)
157
+ expect(outcome).toBe('skip')
158
+ expect(rec.carrier).toHaveLength(0)
159
+ })
160
+
161
+ it('no agent dir → skip immediately', () => {
162
+ const { deps } = makeDeps({ agentDir: null })
163
+ expect(runTierDowngrade('klanker', deps)).toBe('skip')
164
+ })
165
+ })
@@ -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
+ })
@@ -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('agent=w-research')
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