switchroom 0.19.25 β†’ 0.19.26

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 (35) hide show
  1. package/dist/agent-scheduler/index.js +6 -2
  2. package/dist/auth-broker/index.js +9 -2
  3. package/dist/cli/notion-write-pretool.mjs +6 -2
  4. package/dist/cli/switchroom.js +908 -527
  5. package/dist/host-control/main.js +10 -3
  6. package/dist/vault/approvals/kernel-server.js +10 -2
  7. package/dist/vault/broker/server.js +10 -2
  8. package/package.json +1 -1
  9. package/profiles/_base/cron-session.sh.hbs +6 -0
  10. package/profiles/_base/start.sh.hbs +40 -4
  11. package/telegram-plugin/dist/gateway/gateway.js +275 -109
  12. package/telegram-plugin/gateway/gateway.ts +53 -52
  13. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  14. package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
  15. package/telegram-plugin/status-no-truncate.ts +49 -0
  16. package/telegram-plugin/status-pin-driver.ts +28 -0
  17. package/telegram-plugin/status-pin.ts +33 -4
  18. package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
  19. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  20. package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
  21. package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
  22. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  23. package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
  24. package/telegram-plugin/tests/status-pin.test.ts +128 -2
  25. package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
  26. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
  27. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  28. package/telegram-plugin/tier-downgrade.ts +3 -2
  29. package/telegram-plugin/tool-activity-summary.ts +61 -18
  30. package/telegram-plugin/uat/assertions.ts +21 -2
  31. package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
  32. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  33. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  34. package/telegram-plugin/worker-activity-feed.ts +38 -17
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * #3820 β€” the πŸ€– agent card and the πŸ›  worker card must not be visually
3
+ * interchangeable when both are live in one chat.
4
+ *
5
+ * Both surfaces render through the SAME primitive (`renderStatusCard`,
6
+ * tool-activity-summary.ts), so before this fix they were byte-for-byte the
7
+ * same layout β€” two header lines, the identical `Ns Β· N tools Β· Nk tok Β· model`
8
+ * stat row, the identical `βœ“`/`β†’` step trail β€” differing only by an emoji and
9
+ * one word. When a parent narrates the task it delegated, the two cards' step
10
+ * lines are literally identical strings and the pair reads as a duplicate
11
+ * rather than as parent + child.
12
+ *
13
+ * These tests assert the RENDERED OUTPUT differs structurally, using the same
14
+ * inputs on both surfaces so a shared-layout regression cannot pass:
15
+ * - the worker card's line 1 carries `└─ ` and the agent card's never does;
16
+ * - every later worker line carries the subordinate indent;
17
+ * - with identical step text and identical stats, NO line of one card equals
18
+ * any line of the other.
19
+ *
20
+ * Plus the Telegram-reality guards: the prefixes survive the real outbound
21
+ * markdown guard (`richMessage`) byte-identical, the indent is not whitespace
22
+ * under any trimming rule (the #3662 lesson), and `└─` is not a line-start
23
+ * block-construct trigger.
24
+ */
25
+ import { describe, it, expect } from 'vitest'
26
+ import {
27
+ renderActivityFeed,
28
+ renderStatusCard,
29
+ renderCombinedWorkerFeed,
30
+ } from '../tool-activity-summary.js'
31
+ import { renderWorkerActivity, type WorkerActivityView } from '../worker-activity-feed.js'
32
+ import {
33
+ SUBORDINATE_HEADER_PREFIX,
34
+ SUBORDINATE_LINE_INDENT,
35
+ WORKER_STEP_INDENT,
36
+ STATUS_CARD_CHAR_BUDGET,
37
+ nestSubordinateCardLines,
38
+ } from '../status-no-truncate.js'
39
+ import { richMessage } from '../rich-send.js'
40
+
41
+ /** Split a rendered card into display lines, dropping the hard-break padding. */
42
+ function lines(card: string): string[] {
43
+ return card.split('\n').map((l) => l.replace(/[\u00A0 \t\r]+$/, ''))
44
+ }
45
+
46
+ /** The exact scenario from the issue: parent narrating the task it delegated. */
47
+ const STEPS = ['PR 2502 details', 'Commits since v0.8.5']
48
+
49
+ function agentCard(): string {
50
+ const out = renderActivityFeed(STEPS, false, '', undefined, {
51
+ label: 'Agent',
52
+ elapsedMs: 108_000,
53
+ toolCount: 15,
54
+ state: 'running',
55
+ model: 'claude-opus-5',
56
+ totalTokens: 16_400,
57
+ })
58
+ expect(out).not.toBeNull()
59
+ return out as string
60
+ }
61
+
62
+ function workerView(over: Partial<WorkerActivityView> = {}): WorkerActivityView {
63
+ return {
64
+ description: 'Check upstream hindsight changes',
65
+ elapsedMs: 108_000,
66
+ toolCount: 15,
67
+ latestSummary: '',
68
+ narrativeLines: STEPS,
69
+ state: 'running',
70
+ model: 'claude-opus-5',
71
+ totalTokens: 16_400,
72
+ ...over,
73
+ } as WorkerActivityView
74
+ }
75
+
76
+ describe('#3820 agent vs worker card distinguishability', () => {
77
+ it('marks the worker card subordinate on line 1 and never the agent card', () => {
78
+ const agent = lines(agentCard())
79
+ const worker = lines(renderWorkerActivity(workerView()))
80
+
81
+ expect(worker[0].startsWith(SUBORDINATE_HEADER_PREFIX)).toBe(true)
82
+ expect(agent[0].startsWith(SUBORDINATE_HEADER_PREFIX)).toBe(false)
83
+ // The agent card stays flush at the left margin on EVERY line β€” it is the
84
+ // parent, so nothing about it may read as nested.
85
+ for (const l of agent) {
86
+ expect(l.startsWith(SUBORDINATE_HEADER_PREFIX)).toBe(false)
87
+ expect(l.startsWith(SUBORDINATE_LINE_INDENT)).toBe(false)
88
+ }
89
+ })
90
+
91
+ it('indents every worker card line after line 1', () => {
92
+ const worker = lines(renderWorkerActivity(workerView()))
93
+ expect(worker.length).toBeGreaterThan(2)
94
+ for (const l of worker.slice(1)) {
95
+ expect(l.startsWith(SUBORDINATE_LINE_INDENT)).toBe(true)
96
+ }
97
+ })
98
+
99
+ it('shares no rendered line between the two cards even with identical steps and stats', () => {
100
+ // This is the issue's actual symptom: the parent narrates the task it
101
+ // delegated, so the step text overlaps. On the pre-#3820 renderer the
102
+ // stat row AND both step lines were byte-identical across the two cards.
103
+ const agent = lines(agentCard())
104
+ const worker = lines(renderWorkerActivity(workerView()))
105
+ const overlap = agent.filter((a) => worker.includes(a))
106
+ expect(overlap).toEqual([])
107
+ })
108
+
109
+ it('uses a high-contrast caps type label on the worker card only', () => {
110
+ const agent = agentCard()
111
+ const worker = renderWorkerActivity(workerView())
112
+ expect(worker).toContain('πŸ›  **WORKER**')
113
+ expect(agent).toContain('πŸ€– **Agent**')
114
+ expect(agent).not.toContain('WORKER')
115
+ })
116
+
117
+ it('keeps the terminal (result-block) worker render subordinate too', () => {
118
+ const worker = lines(
119
+ renderWorkerActivity(
120
+ workerView({ state: 'done', latestSummary: 'Rebased and opened the PR.' }),
121
+ ),
122
+ )
123
+ expect(worker[0].startsWith(SUBORDINATE_HEADER_PREFIX)).toBe(true)
124
+ for (const l of worker.slice(1)) {
125
+ expect(l.startsWith(SUBORDINATE_LINE_INDENT)).toBe(true)
126
+ }
127
+ // The result block is present and nested, not floated back to the margin.
128
+ expect(worker.some((l) => l.startsWith(`${SUBORDINATE_LINE_INDENT}βœ… `))).toBe(true)
129
+ })
130
+
131
+ it('keeps the just-dispatched (starting…) worker render subordinate', () => {
132
+ // The first state a user ever sees for a worker takes a hand-rolled append
133
+ // path outside renderStatusCard β€” the one place a nesting fix can silently
134
+ // miss.
135
+ const worker = lines(
136
+ renderWorkerActivity(workerView({ narrativeLines: [], latestSummary: '' })),
137
+ )
138
+ expect(worker[0].startsWith(SUBORDINATE_HEADER_PREFIX)).toBe(true)
139
+ expect(worker[worker.length - 1]).toBe(`${SUBORDINATE_LINE_INDENT}_starting…_`)
140
+ })
141
+
142
+ it('nests the combined (2+ worker) card and keeps its internal hierarchy', () => {
143
+ const card = renderCombinedWorkerFeed(
144
+ [
145
+ {
146
+ description: 'Check upstream hindsight changes',
147
+ elapsedMs: 55_000,
148
+ toolCount: 9,
149
+ currentStep: 'Commits since v0.8.5',
150
+ historyLines: STEPS,
151
+ ordinal: 1,
152
+ },
153
+ {
154
+ description: 'Fix the card renderer',
155
+ elapsedMs: 30_000,
156
+ toolCount: 4,
157
+ currentStep: 'Reading render.ts',
158
+ historyLines: ['Reading render.ts'],
159
+ ordinal: 2,
160
+ },
161
+ ],
162
+ { maxRows: 4 },
163
+ )
164
+ expect(card).not.toBeNull()
165
+ const l = lines(card as string)
166
+ expect(l[0]).toBe(`${SUBORDINATE_HEADER_PREFIX}πŸ›  **WORKERS** Β· _2 running Β· oldest 55s Β· 13 tools_`)
167
+ // Row headers sit at one level; their steps stay one level deeper, so the
168
+ // card's own parent/child structure survives the shift right.
169
+ const rowHeaders = l.filter((x) => x.includes('**1. ') || x.includes('**2. '))
170
+ expect(rowHeaders.length).toBe(2)
171
+ for (const h of rowHeaders) {
172
+ expect(h.startsWith(SUBORDINATE_LINE_INDENT)).toBe(true)
173
+ expect(h.startsWith(SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT)).toBe(false)
174
+ }
175
+ const stepLines = l.filter((x) => x.includes('β†’ ') || x.includes('βœ“ '))
176
+ expect(stepLines.length).toBeGreaterThan(0)
177
+ for (const s of stepLines) {
178
+ expect(s.startsWith(SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT)).toBe(true)
179
+ }
180
+ })
181
+
182
+ it('stays under the wire char budget on the subordinate over-budget path', () => {
183
+ // fitCardToBudget has its own line assembly + its own arithmetic; the
184
+ // per-line nesting cost must be charged there or an oversized worker card
185
+ // can overflow the wire cap.
186
+ const huge = Array.from({ length: 500 }, (_, i) => `${i} `.repeat(100))
187
+ const card = renderStatusCard({
188
+ header: {
189
+ emoji: 'πŸ› ',
190
+ label: 'WORKER',
191
+ description: 'big',
192
+ elapsedMs: 1000,
193
+ toolCount: 1,
194
+ state: 'running',
195
+ },
196
+ steps: huge,
197
+ subordinate: true,
198
+ })
199
+ expect(card).not.toBeNull()
200
+ expect((card as string).length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
201
+ expect((card as string).startsWith(SUBORDINATE_HEADER_PREFIX)).toBe(true)
202
+ })
203
+
204
+ it('leaves a non-subordinate card byte-identical to the un-nested render', () => {
205
+ const opts = {
206
+ header: {
207
+ emoji: 'πŸ€–',
208
+ label: 'Agent',
209
+ elapsedMs: 1000,
210
+ toolCount: 2,
211
+ state: 'running' as const,
212
+ },
213
+ steps: STEPS,
214
+ }
215
+ expect(renderStatusCard({ ...opts, subordinate: false })).toBe(renderStatusCard(opts))
216
+ })
217
+ })
218
+
219
+ describe('#3820 subordinate prefixes survive Telegram reality', () => {
220
+ it('passes through the real outbound markdown guard byte-identical', () => {
221
+ // richMessage() is the ONE adapter every `{ markdown }` wire send funnels
222
+ // through (rich-send.ts) β€” it runs the line-start / heading / dollar-math
223
+ // guards. A prefix that gets escaped there would render as literal markup.
224
+ for (const card of [
225
+ agentCard(),
226
+ renderWorkerActivity(workerView()),
227
+ renderCombinedWorkerFeed(
228
+ [{ description: 'a', elapsedMs: 1, toolCount: 1, currentStep: 'b', ordinal: 1 }],
229
+ { maxRows: 4 },
230
+ ) as string,
231
+ ]) {
232
+ expect(richMessage(card).markdown).toBe(card)
233
+ }
234
+ })
235
+
236
+ it('uses an indent that no whitespace-trimming rule can eat (#3662 lesson)', () => {
237
+ // U+00A0 shipped in #3662 and was INERT: Telegram left-trims a leading
238
+ // Unicode-whitespace run, and U+00A0 is category Zs. Assert the PROPERTY,
239
+ // not the bytes β€” a byte-only assertion is what let #3662 ship green.
240
+ for (const ch of Array.from(SUBORDINATE_LINE_INDENT)) {
241
+ expect(/\s/.test(ch)).toBe(false)
242
+ expect(/\p{White_Space}/u.test(ch)).toBe(false)
243
+ expect(/\p{Zs}/u.test(ch)).toBe(false)
244
+ }
245
+ })
246
+
247
+ it('uses a header prefix that is ink, not a line-start block trigger', () => {
248
+ const first = SUBORDINATE_HEADER_PREFIX[0]
249
+ // Not whitespace (would be trimmed), and not one of the GFM line-start
250
+ // block-construct triggers guarded in render/line-start-guard.ts.
251
+ expect(/\s/.test(first)).toBe(false)
252
+ expect('#>-+*'.includes(first)).toBe(false)
253
+ expect(/^\d+[.)]/.test(SUBORDINATE_HEADER_PREFIX)).toBe(false)
254
+ })
255
+
256
+ it('charges one flat per-line cost: header prefix and indent are equal length', () => {
257
+ // fitCardToBudget's arithmetic depends on this invariant.
258
+ expect(SUBORDINATE_HEADER_PREFIX.length).toBe(SUBORDINATE_LINE_INDENT.length)
259
+ })
260
+
261
+ it('nestSubordinateCardLines is pure and handles the empty case', () => {
262
+ expect(nestSubordinateCardLines([])).toEqual([])
263
+ expect(nestSubordinateCardLines(['a', 'b'])).toEqual([
264
+ `${SUBORDINATE_HEADER_PREFIX}a`,
265
+ `${SUBORDINATE_LINE_INDENT}b`,
266
+ ])
267
+ })
268
+ })
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createPeriodicSweepGuard } from "../gateway/periodic-sweep-guard.js";
3
+
4
+ /**
5
+ * Single-flight guard for the interval-driven mid-session card reaper.
6
+ *
7
+ * Pre-fix the interval dispatched `void runMidSessionCardReaper()` directly, so
8
+ * a pass that parked behind a flood-wait (this gateway took a 62-minute flood
9
+ * ban on 2026-07-25) let EVERY subsequent tick start another concurrent pass β€”
10
+ * unbounded fan-out racing the same store rows and firing duplicate unpins into
11
+ * the rate limit that caused the stall. These assert the observable outcome:
12
+ * the number of times `run` actually started.
13
+ */
14
+ describe("createPeriodicSweepGuard", () => {
15
+ /** A pass we can hold open, counting starts and completions. */
16
+ function heldRun() {
17
+ let release!: () => void;
18
+ let gate = new Promise<void>((r) => {
19
+ release = r;
20
+ });
21
+ let starts = 0;
22
+ let finishes = 0;
23
+ const run = async () => {
24
+ starts += 1;
25
+ await gate;
26
+ finishes += 1;
27
+ };
28
+ return {
29
+ run,
30
+ starts: () => starts,
31
+ finishes: () => finishes,
32
+ release: () => {
33
+ release();
34
+ // Re-arm so a later pass can be held again.
35
+ gate = new Promise<void>((r) => {
36
+ release = r;
37
+ });
38
+ },
39
+ };
40
+ }
41
+
42
+ it("runs the pass when idle", async () => {
43
+ let ran = 0;
44
+ const g = createPeriodicSweepGuard({
45
+ run: async () => {
46
+ ran += 1;
47
+ },
48
+ });
49
+ await g.tick();
50
+ expect(ran).toBe(1);
51
+ expect(g.isRunning()).toBe(false);
52
+ expect(g.skipped()).toBe(0);
53
+ });
54
+
55
+ it("SKIPS every tick that arrives while a pass is still in flight (the fan-out fix)", async () => {
56
+ const h = heldRun();
57
+ const skips: number[] = [];
58
+ const g = createPeriodicSweepGuard({ run: h.run, onSkip: () => skips.push(1) });
59
+
60
+ const inFlight = g.tick();
61
+ await Promise.resolve();
62
+ expect(g.isRunning()).toBe(true);
63
+ expect(h.starts()).toBe(1);
64
+
65
+ // Ten more interval ticks land during the stall. Pre-fix these were ten
66
+ // more concurrent passes; now they are dropped.
67
+ await Promise.all([
68
+ g.tick(),
69
+ g.tick(),
70
+ g.tick(),
71
+ g.tick(),
72
+ g.tick(),
73
+ g.tick(),
74
+ g.tick(),
75
+ g.tick(),
76
+ g.tick(),
77
+ g.tick(),
78
+ ]);
79
+ expect(h.starts()).toBe(1); // still exactly ONE pass ever started
80
+ expect(g.skipped()).toBe(10);
81
+ expect(skips).toHaveLength(10);
82
+
83
+ h.release();
84
+ await inFlight;
85
+ expect(h.finishes()).toBe(1);
86
+ expect(g.isRunning()).toBe(false);
87
+ });
88
+
89
+ it("re-arms after the in-flight pass settles, so the sweep is not disabled forever", async () => {
90
+ const h = heldRun();
91
+ const g = createPeriodicSweepGuard({ run: h.run });
92
+ const first = g.tick();
93
+ await Promise.resolve();
94
+ await g.tick(); // skipped
95
+ h.release();
96
+ await first;
97
+
98
+ const second = g.tick();
99
+ await Promise.resolve();
100
+ expect(h.starts()).toBe(2); // the NEXT tick genuinely ran
101
+ h.release();
102
+ await second;
103
+ });
104
+
105
+ it("absorbs a throwing pass: tick never rejects, the guard re-arms, onError sees it", async () => {
106
+ const seen: unknown[] = [];
107
+ let calls = 0;
108
+ const g = createPeriodicSweepGuard({
109
+ run: async () => {
110
+ calls += 1;
111
+ throw new Error(`boom ${calls}`);
112
+ },
113
+ onError: (e) => seen.push(e),
114
+ });
115
+
116
+ // A rejection escaping here reaches the gateway's `unhandledRejection`
117
+ // handler, which CRASHES the process β€” a cosmetic sweep must never do that.
118
+ await expect(g.tick()).resolves.toBeUndefined();
119
+ expect(g.isRunning()).toBe(false);
120
+ await expect(g.tick()).resolves.toBeUndefined();
121
+ expect(calls).toBe(2); // a throw does not wedge the running flag
122
+ expect(seen).toHaveLength(2);
123
+ expect((seen[0] as Error).message).toBe("boom 1");
124
+ });
125
+
126
+ it("a throwing observer cannot break the guard", async () => {
127
+ const h = heldRun();
128
+ const g = createPeriodicSweepGuard({
129
+ run: h.run,
130
+ onSkip: () => {
131
+ throw new Error("broken logger");
132
+ },
133
+ });
134
+ const first = g.tick();
135
+ await Promise.resolve();
136
+ await expect(g.tick()).resolves.toBeUndefined();
137
+ expect(g.skipped()).toBe(1);
138
+ h.release();
139
+ await first;
140
+ });
141
+
142
+ it("onError is optional β€” a throwing pass with no observer is still absorbed", async () => {
143
+ const g = createPeriodicSweepGuard({
144
+ run: async () => {
145
+ throw new Error("boom");
146
+ },
147
+ });
148
+ await expect(g.tick()).resolves.toBeUndefined();
149
+ expect(g.isRunning()).toBe(false);
150
+ });
151
+ });
@@ -33,6 +33,7 @@ import {
33
33
  STATUS_CARD_CHAR_BUDGET,
34
34
  STATUS_LINE_MAX,
35
35
  WORKER_STEP_INDENT,
36
+ SUBORDINATE_LINE_INDENT,
36
37
  } from '../status-no-truncate.js'
37
38
 
38
39
  /**
@@ -135,27 +136,30 @@ describe('combined worker card survives the pinned-bar collapse (#3666)', () =>
135
136
  it('kills the exact artifacts from the report', () => {
136
137
  const collapsed = collapsePreview(body)
137
138
  // 1. the count/ordinal collision β€” glance line into row 1's ordinal.
138
- // This seam is owned by the collapse separator (a worker HEADER carries
139
- // no leading indent, so nothing else separates it).
139
+ // Since #3820 a worker header also carries the card-level
140
+ // SUBORDINATE_LINE_INDENT (the whole worker card nests under the πŸ€–
141
+ // agent card), so this seam is separator + card indent.
140
142
  // (The reported spelling was `3 running1.`; with the packed glance line
141
143
  // the same seam now reads `… 512.3k tok` -> `1. Fix issue`, so assert on
142
144
  // the CURRENT last token of line 1 β€” an assertion on the old spelling
143
145
  // alone would be vacuously green.)
144
146
  expect(collapsed).not.toContain('running1.')
145
147
  expect(collapsed).not.toContain('tok1.')
146
- expect(collapsed).toContain(`tok${NB}1. Fix issue`)
148
+ expect(collapsed).toContain(`tok${NB}${SUBORDINATE_LINE_INDENT}1. Fix issue`)
147
149
  // 2. the mid-word βœ“ (model tag running into the step trail). Here the
148
150
  // separator and WORKER_STEP_INDENT (three U+2800 leading a step line)
149
151
  // stack, so the seam is separator + indent, asserted against both
150
152
  // constants rather than a hardcoded run of spaces.
151
153
  expect(collapsed).not.toContain('opus 5βœ“')
152
- expect(collapsed).toContain(`opus 5${NB}${WORKER_STEP_INDENT}βœ“`)
154
+ expect(collapsed).toContain(`opus 5${NB}${SUBORDINATE_LINE_INDENT}${WORKER_STEP_INDENT}βœ“`)
153
155
  // 3. the step trail running into the next step, and into the next row's
154
- // header (that last seam is separator-only: headers are unindented).
156
+ // header (post-#3820 that last seam is separator + card indent).
155
157
  expect(collapsed).not.toContain('gateway.tsβ†’')
156
- expect(collapsed).toContain(`gateway.ts${NB}${WORKER_STEP_INDENT}β†’`)
158
+ expect(collapsed).toContain(
159
+ `gateway.ts${NB}${SUBORDINATE_LINE_INDENT}${WORKER_STEP_INDENT}β†’`,
160
+ )
157
161
  expect(collapsed).not.toContain('search2.')
158
- expect(collapsed).toContain(`search${NB}2.`)
162
+ expect(collapsed).toContain(`search${NB}${SUBORDINATE_LINE_INDENT}2.`)
159
163
  })
160
164
 
161
165
  it('leads with a self-contained glance that ends in a unit word, not a bare number', () => {
@@ -170,20 +174,27 @@ describe('combined worker card survives the pinned-bar collapse (#3666)', () =>
170
174
  })
171
175
 
172
176
  it('CONTROL: the same lines joined without collapseSafe still mash (pre-fix shape)', () => {
173
- // Discriminator: re-join the SAME rendered lines with the separator removed
174
- // and show the collapsed preview mashes again. Without this, the assertions
177
+ // Discriminator: re-join rendered card lines with the separator removed and
178
+ // show the collapsed preview mashes again. Without this, the assertions
175
179
  // above could all be passing for reasons unrelated to the fix.
176
180
  //
177
- // The seams asserted here are the ones the separator alone owns: a worker
178
- // HEADER line carries no leading WORKER_STEP_INDENT, so the
179
- // glance->row-1 and step->next-row seams have nothing else holding them
180
- // apart. (The header->step and step->step seams are separated by the indent
181
- // even pre-fix, which is why they are not the control.)
182
- const lines = rawCardLines(body).map((l) => l.replace(new RegExp(NB + '$'), ''))
181
+ // The control runs on the πŸ€– AGENT card, not the worker card: since #3820
182
+ // every line of a worker card after line 1 carries a leading
183
+ // SUBORDINATE_LINE_INDENT, which separates its seams independently of the
184
+ // collapse separator β€” so a worker-card control would no longer isolate the
185
+ // separator's contribution. The agent card is the surface where the
186
+ // separator is still the ONLY thing holding the seams apart, which is
187
+ // exactly what this control must measure.
188
+ const agent = renderActivityFeed([
189
+ 'Reading gateway.ts',
190
+ 'Searching memory',
191
+ 'Running tests',
192
+ ])!
193
+ const lines = rawCardLines(agent).map((l) => l.replace(new RegExp(NB + '$'), ''))
183
194
  const preFix = stackCardLines(lines)
184
195
  const collapsed = collapsePreview(preFix)
185
- expect(collapsed).toContain('tok1.')
186
- expect(collapsed).toContain('search2.')
196
+ expect(collapsed).toContain('gateway.tsβœ“ Searching')
197
+ expect(collapsed).toContain('memory→ Running')
187
198
  // …and the property assertion itself would have failed on it.
188
199
  expect(() => expectNoMashedSeams(preFix)).toThrow()
189
200
  })
@@ -217,7 +228,7 @@ describe('single-worker / agent status card survives the collapse too (#3666)',
217
228
  expectNoMashedSeams(body)
218
229
  const collapsed = collapsePreview(body)
219
230
  expect(collapsed).not.toContain('toolsstarting')
220
- expect(collapsed).toContain(`0 tools${NB}starting`)
231
+ expect(collapsed).toContain(`0 tools${NB}${SUBORDINATE_LINE_INDENT}starting`)
221
232
  })
222
233
 
223
234
  it('the nested child block stays separated even though its indent is ASCII (#3668)', () => {