switchroom 0.19.19 → 0.19.22
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/auth-broker/index.js +53 -0
- package/dist/cli/switchroom.js +2444 -1264
- package/dist/host-control/main.js +54 -1
- package/dist/vault/approvals/kernel-server.js +53 -0
- package/dist/vault/broker/server.js +53 -0
- package/package.json +4 -2
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/gateway/gateway.js +769 -172
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +19 -10
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +5 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +193 -25
- package/vendor/hindsight-memory/scripts/lib/pending.py +84 -5
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +21 -10
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +158 -4
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +19 -11
- package/vendor/hindsight-memory/tests/test_drain_pending.py +28 -2
|
@@ -34,6 +34,19 @@ const gatewaySrc =
|
|
|
34
34
|
// P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
|
|
35
35
|
readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-interceptors.ts'), 'utf-8')
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* #3627 extracted the prompt COPY into `buildAccessPassphrasePromptText` and
|
|
39
|
+
* the prompt SEND into `sendAccessPassphrasePrompt`, so both the first prompt
|
|
40
|
+
* and the wrong-passphrase re-prompt share one implementation. The pins below
|
|
41
|
+
* anchor on those two blocks instead of the inline text they replaced.
|
|
42
|
+
*/
|
|
43
|
+
const promptBuilder =
|
|
44
|
+
gatewaySrc
|
|
45
|
+
.split('export function buildAccessPassphrasePromptText')[1]
|
|
46
|
+
?.split('export function createCallbackQueryHandlers')[0] ?? ''
|
|
47
|
+
const promptSender =
|
|
48
|
+
gatewaySrc.split('async function sendAccessPassphrasePrompt')[1]?.split('\n/**')[0] ?? ''
|
|
49
|
+
|
|
37
50
|
describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
|
|
38
51
|
it('declares the passphrase-for-access-approve PendingVaultOp variant', () => {
|
|
39
52
|
// fails when: the new PendingVaultOp kind is dropped. The
|
|
@@ -54,8 +67,11 @@ describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
|
|
|
54
67
|
expect(approveBlock).toMatch(/pendingVaultOps\.set/)
|
|
55
68
|
expect(approveBlock).toMatch(/passphrase-for-access-approve/)
|
|
56
69
|
// Card text must invite a passphrase reply, not punt to a
|
|
57
|
-
// /vault unlock detour.
|
|
58
|
-
|
|
70
|
+
// /vault unlock detour. #3627 moved the body into the shared
|
|
71
|
+
// `buildAccessPassphrasePromptText` builder, so the copy pin lives
|
|
72
|
+
// there now; the approve block must still route into it.
|
|
73
|
+
expect(approveBlock).toMatch(/sendAccessPassphrasePrompt\(/)
|
|
74
|
+
expect(promptBuilder).toMatch(/Reply with your passphrase/i)
|
|
59
75
|
// The "ask the agent to re-issue the request card" copy belonged
|
|
60
76
|
// to the pre-fix path. Should be gone from the cache-miss branch.
|
|
61
77
|
expect(approveBlock).not.toMatch(/ask the agent to re-issue the request card/)
|
|
@@ -67,12 +83,14 @@ describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
|
|
|
67
83
|
// edit fires no notification and stays stapled to the card's old
|
|
68
84
|
// position, so a busy topic buries it and the operator never sees
|
|
69
85
|
// the passphrase ask (the reported v0.16.45 admin-key miss). The
|
|
70
|
-
// prompt must be a fresh `sendRichMessage`
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
// prompt must be a fresh `sendRichMessage` (#3627: from the shared
|
|
87
|
+
// `sendAccessPassphrasePrompt` helper, used by BOTH the first
|
|
88
|
+
// prompt and the wrong-passphrase re-prompt).
|
|
73
89
|
// A distinct passphrase-prompt send exists (verb-tagged).
|
|
74
|
-
expect(
|
|
75
|
-
expect(
|
|
90
|
+
expect(promptSender).toMatch(/sendRichMessage\(target\.chat_id, richMessage\(promptText\)/)
|
|
91
|
+
expect(promptSender).toMatch(/vault_request_access\.passphrase_prompt/)
|
|
92
|
+
// And it is never an in-place edit of the original card.
|
|
93
|
+
expect(promptSender).not.toMatch(/editMessageText/)
|
|
76
94
|
})
|
|
77
95
|
|
|
78
96
|
it('passphrase prompt renders via richMessage — no raw literal-markdown edit', () => {
|
|
@@ -86,25 +104,26 @@ describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
|
|
|
86
104
|
const approveBlock =
|
|
87
105
|
gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
|
|
88
106
|
// The prompt text is assembled once and wrapped in richMessage.
|
|
89
|
-
expect(
|
|
107
|
+
expect(promptSender).toMatch(/const promptText = buildAccessPassphrasePromptText\(spec\)/)
|
|
90
108
|
// Regression guard: the old raw-string admin-only edit copy is gone.
|
|
91
109
|
expect(approveBlock).not.toMatch(/requires your vault passphrase to grant/)
|
|
92
110
|
// Regression guard: no string-concatenated richMessage() object
|
|
93
|
-
// (the "[object Object]" bug) remains
|
|
94
|
-
|
|
111
|
+
// (the "[object Object]" bug) remains — anywhere in the vault
|
|
112
|
+
// callback source, not just the approve block (#3627 fixed the
|
|
113
|
+
// last surviving instance, the standing-ACL resolution edit).
|
|
114
|
+
expect(gatewaySrc).not.toMatch(/\+\s*\n\s*richMessage\(/)
|
|
95
115
|
})
|
|
96
116
|
|
|
97
117
|
it('passphrase prompt is attention-grabbing and does NOT suppress notifications', () => {
|
|
98
118
|
// fails when: the prompt loses its strong header or someone adds
|
|
99
119
|
// disable_notification to it. The whole point of the fix is that
|
|
100
120
|
// the operator gets PINGED — a silent prompt is the bug.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
expect(
|
|
121
|
+
// #3627: 🚨 (not ⚠️) leads the header — this blocks an approval the
|
|
122
|
+
// operator already tapped, so it outranks a generic warning.
|
|
123
|
+
expect(promptBuilder).toMatch(/\*\*🚨🔐 ACTION NEEDED: passphrase required\*\*/)
|
|
124
|
+
expect(promptBuilder).not.toMatch(/⚠️🔐 ACTION NEEDED/)
|
|
104
125
|
// The send options for the prompt must not carry disable_notification.
|
|
105
|
-
|
|
106
|
-
approveBlock.split('const promptText =')[1]?.split('return')[0] ?? ''
|
|
107
|
-
expect(promptSend).not.toMatch(/disable_notification/)
|
|
126
|
+
expect(promptSender).not.toMatch(/disable_notification/)
|
|
108
127
|
})
|
|
109
128
|
|
|
110
129
|
it('passphrase intercept deletes the chat message and resumes mint', () => {
|
|
@@ -140,20 +159,22 @@ describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
|
|
|
140
159
|
expect(handlerBlock).toMatch(/editMessageText/)
|
|
141
160
|
})
|
|
142
161
|
|
|
143
|
-
it('mint failure (
|
|
162
|
+
it('mint failure (non-passphrase) edits the card; does not silent-drop', () => {
|
|
144
163
|
// fails when: performVaultAccessApproval's error branch returns
|
|
145
|
-
// without editing the card. Without the edit, a
|
|
164
|
+
// without editing the card. Without the edit, a failed mint
|
|
146
165
|
// attempt leaves the locked-vault prompt on screen forever and
|
|
147
166
|
// the operator can't tell whether the system saw their reply.
|
|
148
167
|
//
|
|
149
168
|
// Anchor: performVaultAccessApproval's `result.kind === 'error'`
|
|
150
|
-
// branch.
|
|
169
|
+
// branch. #3627: a PASSPHRASE-MISMATCH error now returns early for
|
|
170
|
+
// the retry loop; everything else keeps the terminal edit + drop.
|
|
151
171
|
const mintHelper =
|
|
152
172
|
gatewaySrc.split('async function performVaultAccessApproval')[1]?.split('async function handleVaultRequestAccessCallback')[0] ?? ''
|
|
153
173
|
expect(mintHelper).toMatch(/result\.kind === 'error'/)
|
|
154
174
|
// After error: card edited AND pending entry dropped (no
|
|
155
|
-
// zombie staged request).
|
|
156
|
-
|
|
175
|
+
// zombie staged request). The edit goes through `editResolvedCard`,
|
|
176
|
+
// which falls back to a fresh message when the edit itself fails.
|
|
177
|
+
expect(mintHelper).toMatch(/editResolvedCard[\s\S]{0,400}mint_grant failed/)
|
|
157
178
|
expect(mintHelper).toMatch(/pendingVaultRequestAccesses\.delete/)
|
|
158
179
|
})
|
|
159
180
|
})
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
createWorkerActivityFeed,
|
|
4
|
+
renderWorkerActivity,
|
|
4
5
|
type BotApiForWorkerFeed,
|
|
5
6
|
type WorkerActivityView,
|
|
6
7
|
} from '../worker-activity-feed.js'
|
|
7
8
|
import { renderCombinedWorkerFeed, combinedHistoryDepth } from '../tool-activity-summary.js'
|
|
8
|
-
import { STATUS_CARD_CHAR_BUDGET } from '../status-no-truncate.js'
|
|
9
|
+
import { STATUS_CARD_CHAR_BUDGET, WORKER_STEP_INDENT } from '../status-no-truncate.js'
|
|
10
|
+
import { COLLAPSE_SAFE_SEPARATOR } from '../card-format.js'
|
|
11
|
+
import { richMessage } from '../rich-send.js'
|
|
12
|
+
import { parseRichEntities, validateRichMarkdown } from './rich-markdown-oracle.js'
|
|
9
13
|
import { createSendGate, isSendGateShed, type Clock } from '../send-gate.js'
|
|
10
14
|
|
|
11
15
|
/**
|
|
@@ -1235,3 +1239,231 @@ describe('worker numbering — stable per-card ordinals end to end (#3298)', ()
|
|
|
1235
1239
|
expect(h.edits.length).toBe(landed)
|
|
1236
1240
|
})
|
|
1237
1241
|
})
|
|
1242
|
+
|
|
1243
|
+
// ── Per-worker step indent on the combined card ───────────────────────────────
|
|
1244
|
+
//
|
|
1245
|
+
// The multi-worker card put its `✓`/`→` step lines at the SAME left margin as
|
|
1246
|
+
// the numbered worker header lines, so where one worker ended and the next
|
|
1247
|
+
// began was invisible at a glance (operator report, 2026-07). Steps now nest
|
|
1248
|
+
// one level under their worker.
|
|
1249
|
+
//
|
|
1250
|
+
// The indent MUST be a U+2800 run. Card bodies go to Telegram as raw GFM
|
|
1251
|
+
// markdown (`richMessage` → `sendRichMessage` / `editMessageText({ markdown })`,
|
|
1252
|
+
// #2669) and are parsed server-side by a CommonMark/GFM-family parser that
|
|
1253
|
+
// LEFT-TRIMS a leading whitespace run — ASCII *and* Unicode. #3662 shipped three
|
|
1254
|
+
// U+00A0 and was inert: the bytes reached the Bot API intact and the card still
|
|
1255
|
+
// rendered flat, because U+00A0 is category Zs. U+2800 BRAILLE PATTERN BLANK is
|
|
1256
|
+
// category So — zero ink, non-zero width, not whitespace to any trimming rule —
|
|
1257
|
+
// and was live-verified on a phone on 2026-07-26.
|
|
1258
|
+
//
|
|
1259
|
+
// So these tests assert the indent's CHARACTER PROPERTY as well as its bytes: a
|
|
1260
|
+
// byte-only assertion is exactly what let #3662 ship green while broken.
|
|
1261
|
+
describe('combined worker card — steps indent under their worker (U+2800)', () => {
|
|
1262
|
+
/** The indent glyph. */
|
|
1263
|
+
const BLANK = '\u2800'
|
|
1264
|
+
/** The #3666 pinned-bar collapse separator — TRAILING, structurally distinct. */
|
|
1265
|
+
const NBSP = '\u00A0'
|
|
1266
|
+
const rowsFor = (n: number) =>
|
|
1267
|
+
Array.from({ length: n }, (_, i) => ({
|
|
1268
|
+
description: `worker task ${i + 1}`,
|
|
1269
|
+
elapsedMs: 60_000 * (i + 1),
|
|
1270
|
+
toolCount: 3,
|
|
1271
|
+
ordinal: i + 1,
|
|
1272
|
+
currentStep: `w${i + 1} step b`,
|
|
1273
|
+
historyLines: [`w${i + 1} step a`, `w${i + 1} step b`],
|
|
1274
|
+
}))
|
|
1275
|
+
|
|
1276
|
+
/** Card lines as sent (the body is hard-break joined by stackCardLines). */
|
|
1277
|
+
const linesOf = (body: string) => body.split('\n').map((l) => l.replace(/[ \t]+$/, ''))
|
|
1278
|
+
|
|
1279
|
+
it('every step line starts with the U+2800 indent; header and chrome lines do not', () => {
|
|
1280
|
+
const body = renderCombinedWorkerFeed(rowsFor(3), { maxRows: 8 })!
|
|
1281
|
+
const lines = linesOf(body)
|
|
1282
|
+
const stepLines = lines.filter((l) => l.includes('✓ w') || l.includes('→ w'))
|
|
1283
|
+
const headerLines = lines.filter((l) => /\*\*\d\. worker task/.test(l))
|
|
1284
|
+
|
|
1285
|
+
expect(stepLines.length).toBe(6) // 3 workers × 2 steps
|
|
1286
|
+
expect(headerLines.length).toBe(3)
|
|
1287
|
+
// Load-bearing: the exact indent bytes. `WORKER_STEP_INDENT` is U+2800 ×3;
|
|
1288
|
+
// a plain-ASCII `' '` indent fails this assertion, so does the U+00A0 run
|
|
1289
|
+
// #3662 shipped, and so does flat output with no prefix at all.
|
|
1290
|
+
for (const l of stepLines) {
|
|
1291
|
+
expect(l.startsWith(WORKER_STEP_INDENT)).toBe(true)
|
|
1292
|
+
expect(l.startsWith(BLANK)).toBe(true)
|
|
1293
|
+
expect(l.startsWith(' ')).toBe(false)
|
|
1294
|
+
expect(l.startsWith(NBSP)).toBe(false)
|
|
1295
|
+
}
|
|
1296
|
+
// Workers stay at the left margin so the nesting reads as nesting.
|
|
1297
|
+
for (const l of headerLines) expect(l.startsWith(BLANK)).toBe(false)
|
|
1298
|
+
expect(lines[0].startsWith('🛠')).toBe(true)
|
|
1299
|
+
})
|
|
1300
|
+
|
|
1301
|
+
it('golden body — each worker header is immediately followed by ITS indented steps', () => {
|
|
1302
|
+
// The assertions above are set-membership only: they count step lines and
|
|
1303
|
+
// header lines and check prefixes independently, so a refactor that emitted
|
|
1304
|
+
// all three headers and THEN all six steps — nesting completely destroyed —
|
|
1305
|
+
// would still pass every one of them. This pins the full line ORDER, which
|
|
1306
|
+
// is the actual thing the fix is for: a step must sit under its own worker.
|
|
1307
|
+
const body = renderCombinedWorkerFeed(rowsFor(3), { maxRows: 8 })!
|
|
1308
|
+
const I = WORKER_STEP_INDENT
|
|
1309
|
+
// `S` is the pinned-bar collapse separator (#3666): every line that is
|
|
1310
|
+
// followed by a hard break carries one trailing U+00A0 so the pinned-message
|
|
1311
|
+
// bar (which drops the newline and substitutes nothing) does not mash the
|
|
1312
|
+
// last glyph of a line into the first glyph of the next. It is TRAILING and
|
|
1313
|
+
// exactly one char — structurally distinct from the LEADING three-char
|
|
1314
|
+
// `WORKER_STEP_INDENT`, which is what this golden is really pinning.
|
|
1315
|
+
const S = COLLAPSE_SAFE_SEPARATOR
|
|
1316
|
+
expect(linesOf(body)).toEqual([
|
|
1317
|
+
`🛠 **Workers** · _3 running · oldest 3m00s · 9 tools_${S}`,
|
|
1318
|
+
`**1. worker task 1** _· 1m00s · 3 tools_${S}`,
|
|
1319
|
+
`${I}~~_✓ w1 step a_~~${S}`,
|
|
1320
|
+
`${I}**→ w1 step b**${S}`,
|
|
1321
|
+
`**2. worker task 2** _· 2m00s · 3 tools_${S}`,
|
|
1322
|
+
`${I}~~_✓ w2 step a_~~${S}`,
|
|
1323
|
+
`${I}**→ w2 step b**${S}`,
|
|
1324
|
+
`**3. worker task 3** _· 3m00s · 3 tools_${S}`,
|
|
1325
|
+
`${I}~~_✓ w3 step a_~~${S}`,
|
|
1326
|
+
`${I}**→ w3 step b**`, // last line: nothing follows it to collide with
|
|
1327
|
+
])
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
// ── The assertion that would have caught #3662 ──────────────────────────
|
|
1331
|
+
//
|
|
1332
|
+
// Every OTHER test in this file (and in pinned-card-collapse.test.ts) asserts
|
|
1333
|
+
// the string we hand the Bot API. #3662 shipped a U+00A0 indent that passed
|
|
1334
|
+
// ALL of them and still rendered dead flat on a phone, because the stripping
|
|
1335
|
+
// happens SERVER-SIDE: Telegram's CommonMark-family parser left-trims an
|
|
1336
|
+
// inline whitespace run at the head of a content line, and U+00A0 is Unicode
|
|
1337
|
+
// whitespace (category Zs). A byte assertion cannot observe that.
|
|
1338
|
+
//
|
|
1339
|
+
// So assert the PROPERTY that decides the outcome instead of the bytes: the
|
|
1340
|
+
// indent must not be composed of characters that ANY whitespace-trimming rule
|
|
1341
|
+
// — ASCII `/\s/`, Unicode `White_Space`, or category `Zs` — can classify as
|
|
1342
|
+
// whitespace. U+2800 BRAILLE PATTERN BLANK qualifies: category So (Symbol,
|
|
1343
|
+
// other), zero ink, non-zero width. Live-verified on a phone 2026-07-26.
|
|
1344
|
+
const isTrimmableWhitespace = (ch: string) =>
|
|
1345
|
+
/\s/u.test(ch) || /\p{White_Space}/u.test(ch) || /\p{Zs}/u.test(ch)
|
|
1346
|
+
|
|
1347
|
+
it('WORKER_STEP_INDENT is not Unicode whitespace — a Zs indent is trimmed server-side', () => {
|
|
1348
|
+
const chars = [...WORKER_STEP_INDENT]
|
|
1349
|
+
// Pin the WIDTH too: a single blank char reads as near-flat on a phone and
|
|
1350
|
+
// would silently undo this fix while keeping the character class green.
|
|
1351
|
+
expect(chars.length).toBe(3)
|
|
1352
|
+
for (const ch of chars) {
|
|
1353
|
+
const cp = `U+${ch.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`
|
|
1354
|
+
expect(
|
|
1355
|
+
isTrimmableWhitespace(ch),
|
|
1356
|
+
`WORKER_STEP_INDENT contains ${cp}, which is Unicode whitespace (\\s / White_Space / Zs). ` +
|
|
1357
|
+
`Telegram parses card bodies server-side and LEFT-TRIMS a leading whitespace run, so this ` +
|
|
1358
|
+
`indent renders FLAT on a phone — exactly the #3662 failure this test exists to catch ` +
|
|
1359
|
+
`(#3662 shipped three U+00A0; the bytes reached the Bot API intact and the card was still ` +
|
|
1360
|
+
`flat). The indent must be a zero-ink glyph that is NOT whitespace-categorised: use U+2800 ` +
|
|
1361
|
+
`BRAILLE PATTERN BLANK (category So), live-verified on a real phone 2026-07-26.`,
|
|
1362
|
+
).toBe(false)
|
|
1363
|
+
}
|
|
1364
|
+
})
|
|
1365
|
+
|
|
1366
|
+
it('the whitespace predicate actually rejects every plausible wrong choice', () => {
|
|
1367
|
+
// A property assertion is only worth having if the property discriminates.
|
|
1368
|
+
// These are the characters a future edit would reach for as "a blank-looking
|
|
1369
|
+
// indent"; every one is ASCII space/tab or category Zs, and every one is
|
|
1370
|
+
// left-trimmed server-side.
|
|
1371
|
+
const flatOnAPhone = [
|
|
1372
|
+
' ', // ASCII SPACE
|
|
1373
|
+
'\t', // TAB
|
|
1374
|
+
'\u00A0', // NO-BREAK SPACE — what #3662 shipped
|
|
1375
|
+
'\u2002', // EN SPACE
|
|
1376
|
+
'\u2003', // EM SPACE
|
|
1377
|
+
'\u2007', // FIGURE SPACE
|
|
1378
|
+
'\u200A', // HAIR SPACE
|
|
1379
|
+
'\u3000', // IDEOGRAPHIC SPACE
|
|
1380
|
+
]
|
|
1381
|
+
for (const bad of flatOnAPhone) expect(isTrimmableWhitespace(bad)).toBe(true)
|
|
1382
|
+
// …and it accepts the one we ship.
|
|
1383
|
+
expect(isTrimmableWhitespace('\u2800')).toBe(false)
|
|
1384
|
+
})
|
|
1385
|
+
|
|
1386
|
+
it('WORKER_STEP_INDENT is exactly three U+2800 BRAILLE PATTERN BLANK', () => {
|
|
1387
|
+
// Byte pin, the companion to the property test above: that one says "not
|
|
1388
|
+
// whitespace", this one says "the specific glyph we live-tested".
|
|
1389
|
+
expect(WORKER_STEP_INDENT).toBe('\u2800'.repeat(3))
|
|
1390
|
+
expect(/^[\u2800]+$/.test(WORKER_STEP_INDENT)).toBe(true)
|
|
1391
|
+
// And not a GFM block-structure lead-in: a leading `·` was also tried live
|
|
1392
|
+
// and Telegram promoted it to a real list bullet, which breaks the
|
|
1393
|
+
// `stackCardLines` precondition that card lines are never block-structure
|
|
1394
|
+
// lines (card-format.ts) — the next worker header gets absorbed as a lazy
|
|
1395
|
+
// continuation.
|
|
1396
|
+
expect(/^[-*+>|#·]/.test(WORKER_STEP_INDENT)).toBe(false)
|
|
1397
|
+
})
|
|
1398
|
+
|
|
1399
|
+
it('the `starting…` placeholder line is indented too', () => {
|
|
1400
|
+
const body = renderCombinedWorkerFeed(
|
|
1401
|
+
[
|
|
1402
|
+
{ description: 'fresh worker', elapsedMs: 1000, toolCount: 0, ordinal: 1, currentStep: '' },
|
|
1403
|
+
...rowsFor(1),
|
|
1404
|
+
],
|
|
1405
|
+
{ maxRows: 8 },
|
|
1406
|
+
)!
|
|
1407
|
+
const starting = linesOf(body).find((l) => l.includes('starting…'))!
|
|
1408
|
+
expect(starting).toBeDefined()
|
|
1409
|
+
expect(starting.startsWith(WORKER_STEP_INDENT)).toBe(true)
|
|
1410
|
+
expect(starting.startsWith(BLANK)).toBe(true)
|
|
1411
|
+
})
|
|
1412
|
+
|
|
1413
|
+
it('the indent survives the real outbound guard chain byte-for-byte and stays parseable', () => {
|
|
1414
|
+
const body = renderCombinedWorkerFeed(rowsFor(3), { maxRows: 8 })!
|
|
1415
|
+
// `richMessage` is the ONE adapter every `{ markdown }` wire send funnels
|
|
1416
|
+
// through (rich-send.ts) — it must not escape, strip, or rewrite the indent.
|
|
1417
|
+
const wire = richMessage(body).markdown
|
|
1418
|
+
expect(wire).toBe(body)
|
|
1419
|
+
expect(wire.includes(`${WORKER_STEP_INDENT}**→ w1 step b**`)).toBe(true)
|
|
1420
|
+
expect(wire.includes(`${BLANK}**→ w1 step b**`)).toBe(true)
|
|
1421
|
+
// Adding the indent introduced no fence/emphasis corruption.
|
|
1422
|
+
//
|
|
1423
|
+
// Scope, deliberately understated: `validateRichMarkdown` checks fence
|
|
1424
|
+
// balance, unterminated inline code/links, and emphasis pairing ONLY (see
|
|
1425
|
+
// tests/rich-markdown-oracle.ts). It models NEITHER leading-whitespace
|
|
1426
|
+
// stripping NOR indented code blocks, so a regression to four ASCII spaces
|
|
1427
|
+
// would ALSO return `[]` here. This is NOT rendering evidence and must not
|
|
1428
|
+
// be read as proof that the indent survives Telegram's parser — that
|
|
1429
|
+
// question is server-side and unobservable from this repo (see the honest
|
|
1430
|
+
// limit note on WORKER_STEP_INDENT in status-no-truncate.ts). The byte
|
|
1431
|
+
// assertions above are what discriminate an ASCII indent.
|
|
1432
|
+
expect(validateRichMarkdown(wire)).toEqual([])
|
|
1433
|
+
// …and the indent stays OUTSIDE the markdown spans — it must not land
|
|
1434
|
+
// inside an emphasis run (which would style the blanks). Note this checks
|
|
1435
|
+
// OUR OWN capture regexes in the oracle, not Telegram's real entity
|
|
1436
|
+
// offsets; it pins where we place the indent relative to the `**`/`~~`
|
|
1437
|
+
// delimiters, which is the part this repo actually controls.
|
|
1438
|
+
const ents = parseRichEntities(wire)
|
|
1439
|
+
expect(ents.some((e) => e.type === 'bold' && e.text === '→ w1 step b')).toBe(true)
|
|
1440
|
+
expect(ents.some((e) => e.type === 'strikethrough' && e.text === '_✓ w1 step a_')).toBe(true)
|
|
1441
|
+
expect(ents.every((e) => !e.text.includes(BLANK))).toBe(true)
|
|
1442
|
+
expect(ents.every((e) => !e.text.includes(NBSP))).toBe(true)
|
|
1443
|
+
})
|
|
1444
|
+
|
|
1445
|
+
it('the SINGLE-worker 🛠 card is untouched (no indent on its step lines)', () => {
|
|
1446
|
+
const single = renderWorkerActivity({
|
|
1447
|
+
workerId: 'w1',
|
|
1448
|
+
description: 'lone worker',
|
|
1449
|
+
latestSummary: 'step b',
|
|
1450
|
+
narrativeLines: ['step a', 'step b'],
|
|
1451
|
+
elapsedMs: 60_000,
|
|
1452
|
+
toolCount: 3,
|
|
1453
|
+
state: 'running',
|
|
1454
|
+
})
|
|
1455
|
+
expect(single).toContain('~~_✓ step a_~~')
|
|
1456
|
+
expect(single).toContain('**→ step b**')
|
|
1457
|
+
// No LEADING indent: the single-worker card has nothing to nest under, so
|
|
1458
|
+
// its step lines must sit flush at the left margin. Asserted per line on the
|
|
1459
|
+
// leading edge rather than as "no U+00A0 anywhere in the card", because
|
|
1460
|
+
// #3666 puts one TRAILING U+00A0 on every hard-broken line of every pinned
|
|
1461
|
+
// card (this one included) as the collapse separator. A regression that
|
|
1462
|
+
// leaked WORKER_STEP_INDENT onto these lines still fails here.
|
|
1463
|
+
for (const l of single.split('\n')) {
|
|
1464
|
+
expect(l.startsWith(BLANK)).toBe(false)
|
|
1465
|
+
expect(l.startsWith(NBSP)).toBe(false)
|
|
1466
|
+
expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
|
|
1467
|
+
}
|
|
1468
|
+
})
|
|
1469
|
+
})
|
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
WORKER_HISTORY_MAX,
|
|
90
90
|
STATUS_LINE_MAX,
|
|
91
91
|
NESTED_PREFIX,
|
|
92
|
+
WORKER_STEP_INDENT,
|
|
92
93
|
} from './status-no-truncate.js'
|
|
93
94
|
import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
|
|
94
95
|
import { isTelegramSurfaceTool } from './tool-names.js'
|
|
@@ -297,6 +298,11 @@ function escapeStepLine(raw: string): string {
|
|
|
297
298
|
* `allDone` — when true ALL steps render done (✓ struck italic); when false the
|
|
298
299
|
* newest renders in-progress (→ bold)
|
|
299
300
|
* `liveSuffix` — appended INSIDE the newest in-progress line (heartbeat tick)
|
|
301
|
+
* `indent` — literal prefix put on EVERY emitted line (incl. the
|
|
302
|
+
* `+N earlier…` header), OUTSIDE the markdown spans so it never
|
|
303
|
+
* lands inside an emphasis run. Default `''` — byte-identical
|
|
304
|
+
* output for callers that don't indent. The combined worker card
|
|
305
|
+
* passes `WORKER_STEP_INDENT` to nest steps under their worker.
|
|
300
306
|
*/
|
|
301
307
|
export function renderStepFeed(
|
|
302
308
|
out: string[],
|
|
@@ -304,14 +310,19 @@ export function renderStepFeed(
|
|
|
304
310
|
allDone: boolean,
|
|
305
311
|
liveSuffix = '',
|
|
306
312
|
window: number = STATUS_ROLLING_LINES,
|
|
313
|
+
indent = '',
|
|
307
314
|
): void {
|
|
308
315
|
if (steps.length === 0) return
|
|
309
316
|
const shown = steps.slice(-Math.max(1, window))
|
|
310
317
|
const hidden = steps.length - shown.length
|
|
311
|
-
if (hidden > 0) out.push(
|
|
318
|
+
if (hidden > 0) out.push(`${indent}_✓ +${hidden} earlier…_`)
|
|
312
319
|
const lastIdx = shown.length - 1
|
|
313
320
|
shown.forEach((s, i) => {
|
|
314
|
-
out.push(
|
|
321
|
+
out.push(
|
|
322
|
+
!allDone && i === lastIdx
|
|
323
|
+
? `${indent}**→ ${s}${liveSuffix}**`
|
|
324
|
+
: `${indent}~~_✓ ${s}_~~`,
|
|
325
|
+
)
|
|
315
326
|
})
|
|
316
327
|
}
|
|
317
328
|
|
|
@@ -439,7 +450,10 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
439
450
|
// Stack lines with GFM hard breaks (` \n`) so the card's styled prose lines
|
|
440
451
|
// don't collapse onto one visual line in the rich-message renderer — see
|
|
441
452
|
// stackCardLines. This is what makes a card render identically to a reply.
|
|
442
|
-
|
|
453
|
+
// collapseSafe (#3666): the agent status card and the single-worker card are
|
|
454
|
+
// both PINNED, and Telegram's pinned bar shows them collapsed to one line
|
|
455
|
+
// with the newlines dropped — the separator keeps that preview legible.
|
|
456
|
+
const joined = stackCardLines(out, { collapseSafe: true })
|
|
443
457
|
if (joined.length <= STATUS_CARD_CHAR_BUDGET) return joined
|
|
444
458
|
return fitCardToBudget(opts, headerLines)
|
|
445
459
|
}
|
|
@@ -492,7 +506,8 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
|
|
|
492
506
|
const lastIdx = shown.length - 1
|
|
493
507
|
shown.forEach((esc, i) => lines.push(buildBullet(esc, i === lastIdx)))
|
|
494
508
|
lines.push(...footerLines)
|
|
495
|
-
|
|
509
|
+
// collapseSafe: same pinned surface as renderStatusCard (#3666).
|
|
510
|
+
const candidate = stackCardLines(lines, { collapseSafe: true })
|
|
496
511
|
if (candidate.length <= STATUS_CARD_CHAR_BUDGET) return candidate
|
|
497
512
|
}
|
|
498
513
|
|
|
@@ -518,7 +533,7 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
|
|
|
518
533
|
if (parentMarker != null) lines.push(parentMarker)
|
|
519
534
|
lines.push(newestLine)
|
|
520
535
|
lines.push(...footerLines)
|
|
521
|
-
return stackCardLines(lines)
|
|
536
|
+
return stackCardLines(lines, { collapseSafe: true })
|
|
522
537
|
}
|
|
523
538
|
|
|
524
539
|
/**
|
|
@@ -729,18 +744,54 @@ export function combinedHistoryDepth(w: number): number {
|
|
|
729
744
|
/** Alias for readability at the single-worker call site — same curve. */
|
|
730
745
|
export const workerHistoryDepth = combinedHistoryDepth
|
|
731
746
|
|
|
747
|
+
/**
|
|
748
|
+
* The combined card's first line: a SELF-CONTAINED glance at the whole swarm
|
|
749
|
+
* (#3666) — `🛠 Workers · 3 running · oldest 38m16s · 196 tools · 1.2M tok`.
|
|
750
|
+
*
|
|
751
|
+
* Two jobs, both load-bearing:
|
|
752
|
+
*
|
|
753
|
+
* 1. In the feed it answers "what is my fleet doing" without reading the
|
|
754
|
+
* rows — the aggregate the per-row lines never showed.
|
|
755
|
+
* 2. In Telegram's pinned bar, where the card collapses to ONE line, it is
|
|
756
|
+
* the part most likely to survive truncation, so the preview leads with
|
|
757
|
+
* real information instead of a four-word chrome stub that ran into
|
|
758
|
+
* row 1's ordinal (`… 3 running1. Fix issue …`).
|
|
759
|
+
*
|
|
760
|
+
* It always ends in a UNIT WORD (`running` / `oldest <elapsed>` / `tools` /
|
|
761
|
+
* `tok`), never a bare number, so the first collapse seam reads as
|
|
762
|
+
* `… · 196 tools 1. Fix issue …` — a list starting after a unit — rather than
|
|
763
|
+
* two numbers colliding. Aggregates cover ALL rows (including any spilled to
|
|
764
|
+
* `+M more working…`), matching the `N running` count beside them.
|
|
765
|
+
*/
|
|
766
|
+
function glanceLine(rows: CombinedWorkerRow[]): string {
|
|
767
|
+
const oldestMs = rows.reduce((m, r) => Math.max(m, r.elapsedMs), 0)
|
|
768
|
+
const tools = rows.reduce((n, r) => n + r.toolCount, 0)
|
|
769
|
+
const tok = rows.reduce((n, r) => n + (r.totalTokens ?? 0), 0)
|
|
770
|
+
const toolWord = tools === 1 ? 'tool' : 'tools'
|
|
771
|
+
return (
|
|
772
|
+
`🛠 **Workers** · _${rows.length} running · oldest ${formatFeedElapsed(oldestMs)}` +
|
|
773
|
+
` · ${tools} ${toolWord}${tokenSegment(tok)}_`
|
|
774
|
+
)
|
|
775
|
+
}
|
|
776
|
+
|
|
732
777
|
/**
|
|
733
778
|
* Render N≥1 live workers into ONE combined feed body (ready Telegram
|
|
734
779
|
* markdown; callers send verbatim — do NOT re-escape). Layout:
|
|
735
780
|
*
|
|
736
|
-
* 🛠 **Workers** · _N
|
|
781
|
+
* 🛠 **Workers** · _N running · oldest {elapsed} · {n} tools · {t} tok_
|
|
737
782
|
* **1. {desc1}** _· {elapsed} · {n} tools_
|
|
738
|
-
*
|
|
739
|
-
*
|
|
783
|
+
* ~~_✓ {earlier step}_~~
|
|
784
|
+
* **→ {newest step}**
|
|
740
785
|
* **2. {desc2}** _· {elapsed} · {n} tools_
|
|
741
|
-
*
|
|
786
|
+
* **→ {newest step}**
|
|
742
787
|
* _+M more working…_
|
|
743
788
|
*
|
|
789
|
+
* INDENT: every step line carries a leading `WORKER_STEP_INDENT` (a U+2800 run
|
|
790
|
+
* — neither ASCII spaces nor U+00A0; Telegram left-trims BOTH, see the
|
|
791
|
+
* constant's doc comment) so the steps nest under their worker header and the
|
|
792
|
+
* per-worker blocks are scannable. Header and chrome lines stay at the left
|
|
793
|
+
* margin.
|
|
794
|
+
*
|
|
744
795
|
* NUMBERING (#3298): when the card tracks 2+ rows AND a row carries `ordinal`,
|
|
745
796
|
* its header gets a stable `{ordinal}. ` prefix. Ordinals are assigned by the
|
|
746
797
|
* caller at dispatch and kept for the card's life — after an earlier worker
|
|
@@ -755,6 +806,15 @@ export const workerHistoryDepth = combinedHistoryDepth
|
|
|
755
806
|
* card staying bounded regardless of fan-out. When a worker has no history yet
|
|
756
807
|
* it falls back to a single `→ starting…`/currentStep line.
|
|
757
808
|
*
|
|
809
|
+
* PINNED-BAR READABILITY (#3666): line 1 is a self-contained glance
|
|
810
|
+
* (`glanceLine`) and the stack is joined with `collapseSafe`, because this card
|
|
811
|
+
* is pinned and Telegram's pinned bar renders it collapsed onto ONE line with
|
|
812
|
+
* the newlines dropped and NOTHING substituted. Both halves are needed: the
|
|
813
|
+
* glance so the preview leads with information, the per-line separator so every
|
|
814
|
+
* later seam (`opus 5` → `✓ …` → `→ …`) degrades to a word gap instead of a
|
|
815
|
+
* mashed glyph. There is no Bot API for a separate pin-bar preview — the client
|
|
816
|
+
* derives it from the message text — so the text is the only lever.
|
|
817
|
+
*
|
|
758
818
|
* Pure. Rows are rendered in the order supplied (the manager passes them
|
|
759
819
|
* dispatch-order, oldest first). `maxRows` caps the visible rows; the hidden
|
|
760
820
|
* remainder collapses to a single `+M more working…` line. A total-budget
|
|
@@ -802,13 +862,13 @@ export function renderCombinedWorkerFeed(
|
|
|
802
862
|
// Per-worker depth follows Ken's deterministic curve max(3, 7−w) (#3349):
|
|
803
863
|
// the curve drives DEPTH; the total-line budget below drives ROW COUNT.
|
|
804
864
|
const depth = combinedHistoryDepth(shown.length)
|
|
805
|
-
const chrome: string[] = [
|
|
865
|
+
const chrome: string[] = [glanceLine(rows)]
|
|
806
866
|
const bodyOut: string[] = []
|
|
807
867
|
for (const r of shown) {
|
|
808
868
|
bodyOut.push(rowHeader(r))
|
|
809
869
|
const hist = rowHistory(r)
|
|
810
870
|
if (hist.length === 0) {
|
|
811
|
-
bodyOut.push(
|
|
871
|
+
bodyOut.push(`${WORKER_STEP_INDENT}→ _starting…_`)
|
|
812
872
|
continue
|
|
813
873
|
}
|
|
814
874
|
// Paint the last-K history lines with the SAME `✓`/`→` idiom as the
|
|
@@ -816,12 +876,24 @@ export function renderCombinedWorkerFeed(
|
|
|
816
876
|
// pipeline (escapeStepLine), then renderStepFeed strikes the prior steps
|
|
817
877
|
// and bolds the newest in-progress step. The window equals the depth so a
|
|
818
878
|
// per-worker `+N earlier…` marker never appears inside the combined feed.
|
|
879
|
+
//
|
|
880
|
+
// WORKER_STEP_INDENT nests every step line one level under its worker
|
|
881
|
+
// header, so the boundary between two workers is visible at a glance on a
|
|
882
|
+
// phone (the header lines stay at the left margin, their steps sit in).
|
|
883
|
+
// It is a U+2800 run. NOT ASCII spaces and NOT U+00A0: Telegram's
|
|
884
|
+
// server-side parser left-trims a leading Unicode-whitespace run, so both
|
|
885
|
+
// render flat (#3662 shipped U+00A0 and was inert). U+2800 is category So,
|
|
886
|
+
// not Zs. See the constant's doc comment for the live evidence.
|
|
819
887
|
const esc = hist.slice(-depth).map(escapeStepLine)
|
|
820
|
-
renderStepFeed(bodyOut, esc, false, '', depth)
|
|
888
|
+
renderStepFeed(bodyOut, esc, false, '', depth, WORKER_STEP_INDENT)
|
|
821
889
|
}
|
|
822
890
|
const out = [...chrome, ...bodyOut]
|
|
823
891
|
if (hidden > 0) out.push(`_+${hidden} more working…_`)
|
|
824
|
-
|
|
892
|
+
// collapseSafe (#3666): this card is PINNED, and Telegram's pinned bar
|
|
893
|
+
// renders it collapsed to one line with the newlines dropped and nothing
|
|
894
|
+
// substituted. Without the separator the glance line runs straight into
|
|
895
|
+
// row 1's ordinal and every step glyph mashes into the previous line.
|
|
896
|
+
return { body: stackCardLines(out, { collapseSafe: true }), bodyLines: bodyOut.length }
|
|
825
897
|
}
|
|
826
898
|
|
|
827
899
|
// Cap to maxRows first, then shrink the visible set while EITHER the total
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
cleanWorkerResultParagraph,
|
|
54
54
|
stripMarkdown,
|
|
55
55
|
truncate,
|
|
56
|
+
COLLAPSE_SAFE_SEPARATOR,
|
|
56
57
|
} from './card-format.js'
|
|
57
58
|
import { WORKER_HISTORY_MAX } from './status-no-truncate.js'
|
|
58
59
|
import {
|
|
@@ -245,7 +246,10 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
|
|
|
245
246
|
// Header-only running render → append the starting placeholder with a GFM
|
|
246
247
|
// hard break (` \n`) so it stacks under the header instead of collapsing
|
|
247
248
|
// onto the header line in the rich-message renderer (matches stackCardLines).
|
|
248
|
-
|
|
249
|
+
// The collapse separator is carried here too (#3666) — this card is pinned,
|
|
250
|
+
// and a hand-rolled seam would be the one boundary that still mashed in
|
|
251
|
+
// Telegram's pinned bar.
|
|
252
|
+
return `${card}${COLLAPSE_SAFE_SEPARATOR} \n_starting…_`
|
|
249
253
|
}
|
|
250
254
|
return card
|
|
251
255
|
}
|