switchroom 0.19.42 → 0.19.43
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 +1 -1
- package/dist/auth-broker/index.js +32 -7
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +1187 -669
- package/dist/host-control/main.js +205 -28
- package/dist/vault/approvals/kernel-server.js +32 -7
- package/dist/vault/broker/server.js +32 -7
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +82 -4
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1225 -614
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +5 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reply that was rate-limited and then DELIVERED on retry must not raise a
|
|
3
|
+
* fleet-health delivery-failure alert (#3931).
|
|
4
|
+
*
|
|
5
|
+
* ── The bug ──────────────────────────────────────────────────────────────
|
|
6
|
+
* `installTgPostLogger` is a grammy API transformer, so it runs INSIDE the
|
|
7
|
+
* function the retry policy calls — it observes one POST ATTEMPT, never the
|
|
8
|
+
* logical send's outcome. A 429 that `createRetryApiCall` slept and retried
|
|
9
|
+
* successfully therefore left a `tg-post method=sendRichMessage … status=err`
|
|
10
|
+
* line in the gateway log, and fleet-health's `reply-delivery-failure`
|
|
11
|
+
* signature (`src/fleet-health/detect.ts`) matches per LINE. Result: a
|
|
12
|
+
* severity-3 "the answer never reached the principal" escalation for an answer
|
|
13
|
+
* the operator had already read. Under flood pressure (the class of event that
|
|
14
|
+
* produces 429s in the first place) that is the alarm that fires most.
|
|
15
|
+
*
|
|
16
|
+
* ── The fix these tests pin ──────────────────────────────────────────────
|
|
17
|
+
* The retry policy publishes its attempt context; the transformer labels an
|
|
18
|
+
* attempt the policy is about to repeat `status=retry`. `status=err` now means
|
|
19
|
+
* the logical send is OVER and nothing landed. Nothing is suppressed — the
|
|
20
|
+
* retry lines are still written, they just no longer masquerade as outcomes.
|
|
21
|
+
*
|
|
22
|
+
* These tests drive a REAL grammy `Bot` (a mock-`api` harness sits above the
|
|
23
|
+
* transformer layer, so it would be a false guard) through the REAL retry
|
|
24
|
+
* policy, and feed the REAL emitted lines to the REAL detector.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
27
|
+
import { Bot } from 'grammy'
|
|
28
|
+
import { installTgPostLogger } from '../shared/bot-runtime.js'
|
|
29
|
+
import { createRetryApiCall, willRetryTelegramFailure } from '../retry-api-call.js'
|
|
30
|
+
import { detectGatewayFindings, scanAgent } from '../../src/fleet-health/detect.js'
|
|
31
|
+
|
|
32
|
+
/** A real Bot whose transport answers with `envelopes[i]` for the i-th call. */
|
|
33
|
+
function makeBot(envelopes: Array<Record<string, unknown>>): Bot {
|
|
34
|
+
let i = 0
|
|
35
|
+
const fakeFetch = (async () => {
|
|
36
|
+
const envelope = envelopes[Math.min(i, envelopes.length - 1)]
|
|
37
|
+
i++
|
|
38
|
+
// A Bot API rejection is an HTTP 200 carrying `ok:false` — which is
|
|
39
|
+
// precisely why it RESOLVES the transformer chain (see #3927).
|
|
40
|
+
return { ok: true, status: 200, json: async () => envelope } as unknown as Response
|
|
41
|
+
}) as unknown as typeof fetch
|
|
42
|
+
|
|
43
|
+
const bot = new Bot('123456:TEST_TOKEN', {
|
|
44
|
+
botInfo: {
|
|
45
|
+
id: 123456,
|
|
46
|
+
is_bot: true,
|
|
47
|
+
first_name: 'Test',
|
|
48
|
+
username: 'test_bot',
|
|
49
|
+
can_join_groups: false,
|
|
50
|
+
can_read_all_group_messages: false,
|
|
51
|
+
supports_inline_queries: false,
|
|
52
|
+
can_connect_to_business: false,
|
|
53
|
+
has_main_web_app: false,
|
|
54
|
+
},
|
|
55
|
+
client: { fetch: fakeFetch },
|
|
56
|
+
})
|
|
57
|
+
installTgPostLogger(bot)
|
|
58
|
+
return bot
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const OK_ENVELOPE = {
|
|
62
|
+
ok: true,
|
|
63
|
+
result: { message_id: 7, date: 0, chat: { id: 4242, type: 'private' } },
|
|
64
|
+
}
|
|
65
|
+
const FLOOD_429 = {
|
|
66
|
+
ok: false,
|
|
67
|
+
error_code: 429,
|
|
68
|
+
description: 'Too Many Requests: retry after 3',
|
|
69
|
+
parameters: { retry_after: 3 },
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Send one user-facing reply through the production composition
|
|
74
|
+
* (retry policy → transformer → wire) and return the gateway log lines it
|
|
75
|
+
* produced, shaped exactly as `gateway-supervisor.log` carries them.
|
|
76
|
+
*/
|
|
77
|
+
async function gatewayLinesForReply(
|
|
78
|
+
envelopes: Array<Record<string, unknown>>,
|
|
79
|
+
): Promise<{ lines: string[]; threw: boolean }> {
|
|
80
|
+
const written: string[] = []
|
|
81
|
+
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
|
|
82
|
+
written.push(String(chunk))
|
|
83
|
+
return true
|
|
84
|
+
}) as unknown as typeof process.stderr.write)
|
|
85
|
+
let threw = false
|
|
86
|
+
try {
|
|
87
|
+
const bot = makeBot(envelopes)
|
|
88
|
+
// The real policy, with sleeping stubbed out so a 3s flood-wait doesn't
|
|
89
|
+
// cost the suite 3 seconds. Everything else is production behaviour.
|
|
90
|
+
const robustApiCall = createRetryApiCall({ sleep: async () => {} })
|
|
91
|
+
await robustApiCall(() =>
|
|
92
|
+
bot.api.sendRichMessage(4242, { markdown: 'the answer' }),
|
|
93
|
+
).catch(() => {
|
|
94
|
+
threw = true
|
|
95
|
+
})
|
|
96
|
+
} finally {
|
|
97
|
+
spy.mockRestore()
|
|
98
|
+
}
|
|
99
|
+
const lines = written
|
|
100
|
+
.filter((l) => l.startsWith('tg-post '))
|
|
101
|
+
.map((l) => `2026-08-01T12:00:00Z gateway: ${l.trimEnd()}`)
|
|
102
|
+
return { lines, threw }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
vi.restoreAllMocks()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('#3931 — a retried-and-delivered reply raises no delivery-failure alert', () => {
|
|
110
|
+
it('429-then-ok: fleet-health sees zero reply-delivery-failures and does not escalate', async () => {
|
|
111
|
+
const { lines, threw } = await gatewayLinesForReply([FLOOD_429, OK_ENVELOPE])
|
|
112
|
+
|
|
113
|
+
// Ground truth: the reply WAS delivered. If this ever fails, the fixture
|
|
114
|
+
// stopped modelling the scenario and the assertions below mean nothing.
|
|
115
|
+
expect(threw).toBe(false)
|
|
116
|
+
expect(lines).toHaveLength(2)
|
|
117
|
+
expect(lines[1]).toContain('status=ok')
|
|
118
|
+
|
|
119
|
+
// THE outcome: the detector raises nothing, and the agent is not escalated.
|
|
120
|
+
const { gw_hits, findings } = detectGatewayFindings('alpha', lines.join('\n'))
|
|
121
|
+
expect(gw_hits['reply-delivery-failure']).toBe(0)
|
|
122
|
+
expect(findings.filter((f) => f.signal === 'reply-delivery-failure')).toHaveLength(0)
|
|
123
|
+
expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(false)
|
|
124
|
+
|
|
125
|
+
// The evidence is not hidden — the rate-limit attempt is still on the log,
|
|
126
|
+
// honestly labelled as an attempt rather than an outcome.
|
|
127
|
+
expect(lines[0]).toContain('status=retry')
|
|
128
|
+
expect(lines[0]).toContain('code=429')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('an ok:true reply is untouched — no retry tier leaks onto the happy path', async () => {
|
|
132
|
+
const { lines, threw } = await gatewayLinesForReply([OK_ENVELOPE])
|
|
133
|
+
expect(threw).toBe(false)
|
|
134
|
+
expect(lines).toHaveLength(1)
|
|
135
|
+
expect(lines[0]).toContain('status=ok')
|
|
136
|
+
expect(lines[0]).toContain('err=- code=- desc=-')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('#3931 — a genuinely undelivered reply still escalates', () => {
|
|
141
|
+
it('a terminal 403 escalates', async () => {
|
|
142
|
+
const { lines, threw } = await gatewayLinesForReply([
|
|
143
|
+
{ ok: false, error_code: 403, description: 'Forbidden: bot was blocked by the user' },
|
|
144
|
+
])
|
|
145
|
+
expect(threw).toBe(true)
|
|
146
|
+
expect(lines).toHaveLength(1)
|
|
147
|
+
expect(lines[0]).toContain('status=err')
|
|
148
|
+
|
|
149
|
+
expect(detectGatewayFindings('alpha', lines.join('\n')).gw_hits['reply-delivery-failure']).toBe(1)
|
|
150
|
+
expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('a 429 on EVERY attempt (retries exhausted, nothing landed) escalates exactly once', async () => {
|
|
154
|
+
const { lines, threw } = await gatewayLinesForReply([FLOOD_429])
|
|
155
|
+
expect(threw).toBe(true)
|
|
156
|
+
// Three attempts: two survivable, the last one terminal.
|
|
157
|
+
expect(lines).toHaveLength(3)
|
|
158
|
+
expect(lines.filter((l) => l.includes('status=retry'))).toHaveLength(2)
|
|
159
|
+
expect(lines.filter((l) => l.includes('status=err'))).toHaveLength(1)
|
|
160
|
+
|
|
161
|
+
// One alert for one lost answer — not one per attempt.
|
|
162
|
+
expect(detectGatewayFindings('alpha', lines.join('\n')).gw_hits['reply-delivery-failure']).toBe(1)
|
|
163
|
+
expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('willRetryTelegramFailure — the predicate mirrors the retry loop', () => {
|
|
168
|
+
const ctx = { attempt: 0, maxRetries: 3, maxFloodSleepMs: 120_000 }
|
|
169
|
+
|
|
170
|
+
it('is false with no enclosing retry policy (a raw bot.api.* call is its own outcome)', () => {
|
|
171
|
+
expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, undefined)).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('is false on the last attempt — the loop falls through to give-up', () => {
|
|
175
|
+
expect(
|
|
176
|
+
willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, { ...ctx, attempt: 2 }),
|
|
177
|
+
).toBe(false)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('is false for a 429 over the in-process sleep ceiling (FLOOD_WAIT_ACTIVE, not retried)', () => {
|
|
181
|
+
expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 15908 }, ctx)).toBe(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('is true for a 429 under the ceiling and for a transient transport error', () => {
|
|
185
|
+
expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, ctx)).toBe(true)
|
|
186
|
+
expect(willRetryTelegramFailure({ message: 'ECONNRESET' }, ctx)).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('is false for the error classes the loop rethrows immediately', () => {
|
|
190
|
+
expect(willRetryTelegramFailure({ errorCode: 400, message: 'Bad Request' }, ctx)).toBe(false)
|
|
191
|
+
expect(willRetryTelegramFailure({ errorCode: 403 }, ctx)).toBe(false)
|
|
192
|
+
// Local disk exhaustion must NOT look retryable — retrying it is what
|
|
193
|
+
// trips the flood ban in the first place (#2923).
|
|
194
|
+
expect(willRetryTelegramFailure({ message: 'ENOSPC: no space left on device' }, ctx)).toBe(false)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -14,7 +14,13 @@ import {
|
|
|
14
14
|
STEP_TIMER_MIN_MS,
|
|
15
15
|
type SessionActivityHeader,
|
|
16
16
|
} from "../tool-activity-summary.js";
|
|
17
|
-
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from "../status-no-truncate.js";
|
|
17
|
+
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX, NESTED_PREFIX } from "../status-no-truncate.js";
|
|
18
|
+
|
|
19
|
+
// The nested (foreground sub-agent) child-block indent. Asserted through the
|
|
20
|
+
// constant, never as a literal: #3668 replaced its ASCII spaces with U+2800
|
|
21
|
+
// (Telegram left-trims a leading ASCII/Zs run off a content line), and a test
|
|
22
|
+
// that hardcodes the bytes pins the defect instead of the behaviour.
|
|
23
|
+
const NP = NESTED_PREFIX;
|
|
18
24
|
import { COLLAPSE_SAFE_SEPARATOR } from "../card-format.js";
|
|
19
25
|
|
|
20
26
|
/** The pinned-card collapse separator (#3666) every status-card line now
|
|
@@ -352,29 +358,29 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
352
358
|
expect(out).toContain("~~_✓ Delegating: review the migration_~~");
|
|
353
359
|
expect(out).not.toContain("**→ Delegating");
|
|
354
360
|
// The live → step is the newest nested child line; earlier child = italic.
|
|
355
|
-
expect(out).toContain("
|
|
356
|
-
expect(out).toContain("
|
|
361
|
+
expect(out).toContain(NP + "~~_Reading schema.ts_~~");
|
|
362
|
+
expect(out).toContain(NP + "**→ Looking for foreign keys**");
|
|
357
363
|
});
|
|
358
364
|
|
|
359
365
|
it("windows the nested block to STATUS_ROLLING_LINES with a '↳ +N earlier…' header", () => {
|
|
360
366
|
const total = STATUS_ROLLING_LINES + 3;
|
|
361
367
|
const child = Array.from({ length: total }, (_, i) => `step ${i + 1}`);
|
|
362
368
|
const out = renderActivityFeedWithNested(["Delegating: x"], child)!;
|
|
363
|
-
expect(out).toContain(
|
|
369
|
+
expect(out).toContain(`${NP}_+${total - STATUS_ROLLING_LINES} earlier…_`);
|
|
364
370
|
// newest nested line is the live → step
|
|
365
|
-
expect(out).toContain(
|
|
371
|
+
expect(out).toContain(`${NP}**→ step ${total}**`);
|
|
366
372
|
// the oldest (collapsed) lines are not rendered verbatim
|
|
367
373
|
expect(out).not.toContain("step 1<");
|
|
368
374
|
});
|
|
369
375
|
|
|
370
376
|
it("renders the child block even when the parent feed is empty", () => {
|
|
371
377
|
const out = renderActivityFeedWithNested([], ["Reading a.ts"]);
|
|
372
|
-
expect(out).toBe("
|
|
378
|
+
expect(out).toBe(NP + "**→ Reading a.ts**");
|
|
373
379
|
});
|
|
374
380
|
|
|
375
381
|
it("markdown-escapes nested child text (emphasis specials only)", () => {
|
|
376
382
|
const out = renderActivityFeedWithNested(["Delegating: x"], ["touch a_b & 2 * 3"])!;
|
|
377
|
-
expect(out).toContain("
|
|
383
|
+
expect(out).toContain(NP + "**→ touch a\\_b & 2 \\* 3**");
|
|
378
384
|
});
|
|
379
385
|
|
|
380
386
|
it("final=true: the nested newest step renders done (✓), not in-progress (→)", () => {
|
|
@@ -383,7 +389,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
383
389
|
["Reading schema.ts", "Looking for foreign keys"],
|
|
384
390
|
true,
|
|
385
391
|
)!;
|
|
386
|
-
expect(out).toContain("
|
|
392
|
+
expect(out).toContain(NP + "~~_Looking for foreign keys_~~"); // newest now struck italic done
|
|
387
393
|
expect(out).not.toContain("→"); // no in-progress arrow in the finalized feed
|
|
388
394
|
});
|
|
389
395
|
|
|
@@ -400,7 +406,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
|
|
|
400
406
|
false,
|
|
401
407
|
" · 22s",
|
|
402
408
|
)!;
|
|
403
|
-
expect(out).toContain("
|
|
409
|
+
expect(out).toContain(NP + "**→ Looking for foreign keys · 22s**");
|
|
404
410
|
expect(out).not.toContain("Reading schema.ts · "); // only the newest line ticks
|
|
405
411
|
});
|
|
406
412
|
|
|
@@ -567,7 +573,7 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
|
|
|
567
573
|
expect(out).not.toContain(`Parent ${i}`);
|
|
568
574
|
}
|
|
569
575
|
expect(out).toContain(`_✓ +${totalParent - STATUS_ROLLING_LINES} earlier…_`);
|
|
570
|
-
expect(out).toContain("
|
|
576
|
+
expect(out).toContain(NP + "**→ Child step B**");
|
|
571
577
|
});
|
|
572
578
|
|
|
573
579
|
it("many child lines → only the last STATUS_ROLLING_LINES child lines render with a ↳ +N earlier header", () => {
|
|
@@ -582,8 +588,8 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
|
|
|
582
588
|
for (let i = 1; i < firstVisible; i++) {
|
|
583
589
|
expect(out).not.toContain(`Child ${i}`);
|
|
584
590
|
}
|
|
585
|
-
expect(out).toContain(
|
|
586
|
-
expect(out).toContain(
|
|
591
|
+
expect(out).toContain(`${NP}_+${totalChild - STATUS_ROLLING_LINES} earlier…_`);
|
|
592
|
+
expect(out).toContain(`${NP}**→ Child ${totalChild}**`);
|
|
587
593
|
});
|
|
588
594
|
|
|
589
595
|
it("STATUS_LINE_MAX=200: a 250-char child line is clipped to 200 (both surfaces)", () => {
|
|
@@ -600,7 +606,7 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
|
|
|
600
606
|
const child = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
|
|
601
607
|
const out = renderActivityFeedWithNested(parent, child)!;
|
|
602
608
|
expect(out.length).toBeLessThanOrEqual(4096);
|
|
603
|
-
expect(out).toContain(
|
|
609
|
+
expect(out).toContain(NP);
|
|
604
610
|
});
|
|
605
611
|
});
|
|
606
612
|
|
|
@@ -678,11 +684,11 @@ describe("extreme-edge: single oversized line with markdown specials & && _ *",
|
|
|
678
684
|
expect(out.length).toBeLessThanOrEqual(4000);
|
|
679
685
|
expect(isValidMarkdown(out)).toBe(true);
|
|
680
686
|
// Must contain the nested prefix.
|
|
681
|
-
expect(out).toContain(
|
|
687
|
+
expect(out).toContain(NP);
|
|
682
688
|
// Regression guard: an operator-precedence bug made wrapperOverhead a string
|
|
683
689
|
// (NESTED_PREFIX + n) → NaN budget → slice(0, NaN) === "" → the child content
|
|
684
|
-
// was silently discarded, leaving only
|
|
685
|
-
// output still satisfies the toContain(
|
|
690
|
+
// was silently discarded, leaving only `${NESTED_PREFIX}**→ **`. The empty-wrapper
|
|
691
|
+
// output still satisfies the toContain(NP) check above, so assert that
|
|
686
692
|
// real child content actually survives.
|
|
687
693
|
expect(out).toContain("Child step");
|
|
688
694
|
expect(out.length).toBeGreaterThan(100);
|
|
@@ -1025,7 +1031,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
1025
1031
|
// Parent step is done-styled (child is the live step).
|
|
1026
1032
|
expect(out).toContain("~~_✓ Delegating: review_~~");
|
|
1027
1033
|
// Child step is the in-progress step.
|
|
1028
|
-
expect(out).toContain("
|
|
1034
|
+
expect(out).toContain(NP + "**→ Reading schema.ts**");
|
|
1029
1035
|
});
|
|
1030
1036
|
});
|
|
1031
1037
|
|
|
@@ -389,13 +389,14 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
389
389
|
* an already-escaped markdown escape is never sliced.
|
|
390
390
|
*/
|
|
391
391
|
const deepest = Math.max(1, escapedBody.length)
|
|
392
|
+
/** The collapsed parent trail, kept at every shrink level below level 0. */
|
|
393
|
+
const markerSection: CardSection = {
|
|
394
|
+
steps: [],
|
|
395
|
+
window: 1,
|
|
396
|
+
placeholder: parentMarker ?? undefined,
|
|
397
|
+
}
|
|
392
398
|
return fitCardToBudget((level) => {
|
|
393
399
|
if (level === 0) return { spec: fullSpec() }
|
|
394
|
-
const markerSection: CardSection = {
|
|
395
|
-
steps: [],
|
|
396
|
-
window: 1,
|
|
397
|
-
placeholder: parentMarker ?? undefined,
|
|
398
|
-
}
|
|
399
400
|
if (level < escapedBody.length) {
|
|
400
401
|
return {
|
|
401
402
|
spec: {
|
|
@@ -427,39 +428,66 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
427
428
|
}
|
|
428
429
|
}, deepest)
|
|
429
430
|
|
|
431
|
+
/** The deepest shrink level's spec, for a given already-wrapped body line. */
|
|
432
|
+
function deepestSpec(bodyLine: string): CardSpec {
|
|
433
|
+
return {
|
|
434
|
+
chrome,
|
|
435
|
+
sections: [markerSection, { steps: [], window: 1, placeholder: bodyLine }],
|
|
436
|
+
footer,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Wrap an ALREADY-ESCAPED newest step in its bullet chrome. */
|
|
441
|
+
function wrapNewest(escaped: string): string {
|
|
442
|
+
return final
|
|
443
|
+
? `${bodyIndent}_✓ ${escaped}_`
|
|
444
|
+
: `${bodyIndent}**→ ${escaped}${liveSuffix}**`
|
|
445
|
+
}
|
|
446
|
+
|
|
430
447
|
/**
|
|
431
|
-
* The deepest shrink level's single bullet
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
448
|
+
* The deepest shrink level's single bullet: clip the RAW newest text to
|
|
449
|
+
* whatever the fixed chrome/footer leaves of `STATUS_CARD_CHAR_BUDGET`,
|
|
450
|
+
* escape, then re-measure — escaping can expand the string (`&` → `&`).
|
|
451
|
+
*
|
|
452
|
+
* Everything is measured by RENDERING the level's real spec (#3833). The
|
|
453
|
+
* previous arithmetic estimate charged `[...chrome, ...footer].join('\n')`,
|
|
454
|
+
* but the renderer is `stackCard`, which emits a collapse separator plus a
|
|
455
|
+
* two-space GFM hard break at every line boundary — 4 chars, not 1 — and it
|
|
456
|
+
* also charges the marker line and the section boundaries the estimate only
|
|
457
|
+
* partly counted. That under-count let a card render OVER the wire budget.
|
|
458
|
+
* Measuring with the renderer cannot drift from it by construction.
|
|
435
459
|
*/
|
|
436
460
|
function truncatedNewestLine(): string {
|
|
437
|
-
const fixedCost = [...chrome, ...footer].join('\n').length
|
|
438
461
|
const rawNewest = rawBody.length > 0 ? cleanStepLine(rawBody[rawBody.length - 1]) : ''
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
let
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
raw.length
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
newest = escapeMarkdown(raw)
|
|
462
|
+
const empty = wrapNewest('')
|
|
463
|
+
const fixedCost = renderCardSpec(deepestSpec(empty)).length
|
|
464
|
+
const budget = STATUS_CARD_CHAR_BUDGET - fixedCost
|
|
465
|
+
// Chrome alone is over budget (a 40k-char worker description, #3682). No
|
|
466
|
+
// amount of body clipping helps; `fitCardToBudget`'s hard-truncate backstop
|
|
467
|
+
// is what keeps the card under the wire limit in that case.
|
|
468
|
+
if (budget <= 0) return empty
|
|
469
|
+
let raw = clipRaw(rawNewest, budget)
|
|
470
|
+
let line = wrapNewest(escapeMarkdown(raw))
|
|
471
|
+
for (;;) {
|
|
472
|
+
const excess = renderCardSpec(deepestSpec(line)).length - STATUS_CARD_CHAR_BUDGET
|
|
473
|
+
if (excess <= 0 || raw.length === 0) return line
|
|
474
|
+
// Dropping k RAW chars drops at least k escaped chars (escaping never
|
|
475
|
+
// shrinks), so this strictly converges.
|
|
476
|
+
raw = clipRaw(raw, Math.max(0, raw.length - excess))
|
|
477
|
+
line = wrapNewest(escapeMarkdown(raw))
|
|
456
478
|
}
|
|
457
|
-
return final
|
|
458
|
-
? `${bodyIndent}_✓ ${newest}_`
|
|
459
|
-
: `${bodyIndent}**→ ${newest}${liveSuffix}**`
|
|
460
479
|
}
|
|
461
480
|
}
|
|
462
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Slice raw (pre-escape) text to `n` chars without leaving a dangling lone
|
|
484
|
+
* high surrogate — an unpaired surrogate is invalid UTF-16 on the wire.
|
|
485
|
+
*/
|
|
486
|
+
function clipRaw(text: string, n: number): string {
|
|
487
|
+
const out = text.slice(0, Math.max(0, n))
|
|
488
|
+
return /[\uD800-\uDBFF]$/.test(out) ? out.slice(0, -1) : out
|
|
489
|
+
}
|
|
490
|
+
|
|
463
491
|
/** Subtle horizontal rule between the running feed and the finished result. */
|
|
464
492
|
const WORKER_RESULT_RULE = '─────'
|
|
465
493
|
/** Hard cap on the terminal result paragraph. */
|
|
@@ -51,8 +51,9 @@ const FG_DISPATCH_PROMPT =
|
|
|
51
51
|
`one-line summary." Wait for the foreground sub-agent to finish, then send ` +
|
|
52
52
|
`me a brief reply telling me it's done.`;
|
|
53
53
|
|
|
54
|
-
// The nested foreground block renders each child line prefixed with
|
|
55
|
-
//
|
|
54
|
+
// The nested foreground block renders each child line prefixed with a three
|
|
55
|
+
// U+2800 indent then "↳" (NESTED_PREFIX; #3668 replaced the old ASCII spaces,
|
|
56
|
+
// which Telegram left-trims), newest as a bold "→ …" current step. Telegram
|
|
56
57
|
// strips the bold but keeps the literal ↳ / → glyphs in message text.
|
|
57
58
|
const NESTED_RE = /↳/;
|
|
58
59
|
|