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.
- package/dist/agent-scheduler/index.js +6 -2
- package/dist/auth-broker/index.js +9 -2
- package/dist/cli/notion-write-pretool.mjs +6 -2
- package/dist/cli/switchroom.js +908 -527
- package/dist/host-control/main.js +10 -3
- package/dist/vault/approvals/kernel-server.js +10 -2
- package/dist/vault/broker/server.js +10 -2
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +6 -0
- package/profiles/_base/start.sh.hbs +40 -4
- package/telegram-plugin/dist/gateway/gateway.js +275 -109
- package/telegram-plugin/gateway/gateway.ts +53 -52
- package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
- package/telegram-plugin/status-no-truncate.ts +49 -0
- package/telegram-plugin/status-pin-driver.ts +28 -0
- package/telegram-plugin/status-pin.ts +33 -4
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
- package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
- package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
- package/telegram-plugin/tests/status-pin.test.ts +128 -2
- package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +61 -18
- package/telegram-plugin/uat/assertions.ts +21 -2
- package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +38 -17
- 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
|
-
//
|
|
139
|
-
//
|
|
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
|
|
156
|
+
// header (post-#3820 that last seam is separator + card indent).
|
|
155
157
|
expect(collapsed).not.toContain('gateway.tsβ')
|
|
156
|
-
expect(collapsed).toContain(
|
|
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
|
|
174
|
-
//
|
|
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
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
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('
|
|
186
|
-
expect(collapsed).toContain('
|
|
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)', () => {
|