switchroom 0.19.42 → 0.19.44
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 +4 -1
- package/dist/auth-broker/index.js +284 -124
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +4094 -2711
- package/dist/host-control/main.js +472 -155
- package/dist/vault/approvals/kernel-server.js +231 -71
- package/dist/vault/broker/server.js +293 -133
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +93 -8
- 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 +1682 -936
- 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
- package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -27,6 +27,12 @@ import {
|
|
|
27
27
|
import { richMessage, isParseEntitiesError } from './rich-send.js'
|
|
28
28
|
import { renderOutboundChunks } from './render/rich-render.js'
|
|
29
29
|
import { isSendGateShed, type SendGateOpts } from './send-gate.js'
|
|
30
|
+
import {
|
|
31
|
+
buildReplyParameters,
|
|
32
|
+
dropQuoteFromSendOpts,
|
|
33
|
+
isQuoteRejectionError,
|
|
34
|
+
sendOptsHaveQuote,
|
|
35
|
+
} from './reply-quote.js'
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
38
|
* Minimal bot.api surface the controller needs. Real callers pass grammy's
|
|
@@ -59,8 +65,14 @@ export interface StreamSendOpts {
|
|
|
59
65
|
* message. Only meaningful on the initial `sendMessage` — `editMessageText`
|
|
60
66
|
* cannot add a quote reference to an existing message, so the controller
|
|
61
67
|
* strips this from edit opts internally.
|
|
68
|
+
*
|
|
69
|
+
* `quote` is a Bot API **String** — the quoted substring itself. The optional
|
|
70
|
+
* offset is a SIBLING `quote_position` Integer, which this plugin
|
|
71
|
+
* deliberately never emits. Typing it as an object is what produced
|
|
72
|
+
* `400 field "quote" must be of type String` on every quoted reply; see
|
|
73
|
+
* `reply-quote.ts`.
|
|
62
74
|
*/
|
|
63
|
-
reply_parameters?: { message_id: number; quote?:
|
|
75
|
+
reply_parameters?: { message_id: number; quote?: string }
|
|
64
76
|
/**
|
|
65
77
|
* Inline keyboard markup. Included in both sendMessage and editMessageText
|
|
66
78
|
* so that inline buttons persist through text edits. Without this,
|
|
@@ -121,7 +133,10 @@ export interface StreamControllerConfig {
|
|
|
121
133
|
/**
|
|
122
134
|
* Optional quote text for surgical quoting. When set along with
|
|
123
135
|
* `replyToMessageId`, the initial send includes
|
|
124
|
-
* `reply_parameters: { message_id, quote
|
|
136
|
+
* `reply_parameters: { message_id, quote }` — `quote` is a Bot API String
|
|
137
|
+
* (see `reply-quote.ts`). A quote Telegram cannot find in the replied-to
|
|
138
|
+
* message is dropped and the send retried unquoted, so the answer still
|
|
139
|
+
* lands.
|
|
125
140
|
*/
|
|
126
141
|
quoteText?: string
|
|
127
142
|
/**
|
|
@@ -222,14 +237,16 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
222
237
|
...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
|
|
223
238
|
...(replyMarkup != null ? { reply_markup: replyMarkup } : {}),
|
|
224
239
|
}
|
|
225
|
-
const
|
|
240
|
+
// `let`, not `const`: a quote Telegram cannot find in the replied-to message
|
|
241
|
+
// is stripped ONCE for the whole stream (see sendPieceWithQuoteFallback) —
|
|
242
|
+
// re-attaching it to the next piece would just 400 again.
|
|
243
|
+
let sendOpts: StreamSendOpts = {
|
|
226
244
|
...baseOpts,
|
|
227
245
|
...(replyToMessageId != null
|
|
228
246
|
? {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
},
|
|
247
|
+
// `quote` is a Bot API String; `quote_position` is a separate sibling
|
|
248
|
+
// Integer we deliberately don't emit. See reply-quote.ts.
|
|
249
|
+
reply_parameters: buildReplyParameters(replyToMessageId, quoteText),
|
|
233
250
|
}
|
|
234
251
|
: {}),
|
|
235
252
|
...(protectContent === true ? { protect_content: true } : {}),
|
|
@@ -263,6 +280,24 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
263
280
|
delete richOpts.link_preview_options
|
|
264
281
|
return bot.api.sendRichMessage(chatId, richMessage(piece.text), richOpts)
|
|
265
282
|
}
|
|
283
|
+
// Send a piece, degrading gracefully when the SURGICAL QUOTE is the thing
|
|
284
|
+
// Telegram rejected. `reply_parameters.quote` must be an exact substring of
|
|
285
|
+
// the replied-to message; when it isn't, the 400 kills a perfectly good
|
|
286
|
+
// answer. Drop the highlight (for this send and every later one on this
|
|
287
|
+
// stream) and re-send — the reply still lands, still threaded under the same
|
|
288
|
+
// message.
|
|
289
|
+
const sendPieceWithQuoteFallback = async (piece: { text: string; rich: boolean }) => {
|
|
290
|
+
try {
|
|
291
|
+
return await sendPiece(piece, sendOpts)
|
|
292
|
+
} catch (err) {
|
|
293
|
+
if (!isQuoteRejectionError(err) || !sendOptsHaveQuote(sendOpts)) throw err
|
|
294
|
+
warn?.(
|
|
295
|
+
`stream-controller: quote not found in the replied-to message — re-sending without the quote (${err instanceof Error ? err.message : String(err)})`,
|
|
296
|
+
)
|
|
297
|
+
sendOpts = dropQuoteFromSendOpts(sendOpts)
|
|
298
|
+
return await sendPiece(piece, sendOpts)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
266
301
|
const editPiece = (id: number, piece: { text: string; rich: boolean }, opts: StreamSendOpts) => {
|
|
267
302
|
if (!piece.rich) return bot.api.editMessageText(chatId, id, piece.text, opts)
|
|
268
303
|
return bot.api.editMessageText(chatId, id, richMessage(piece.text), opts)
|
|
@@ -395,7 +430,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
395
430
|
}
|
|
396
431
|
// First emission of this tail piece → a fresh follow-up message.
|
|
397
432
|
try {
|
|
398
|
-
const sent = await retry(() =>
|
|
433
|
+
const sent = await retry(() => sendPieceWithQuoteFallback(piece), { threadId, chat_id: chatId })
|
|
399
434
|
tailIds[ti] = sent.message_id
|
|
400
435
|
tailLastText[ti] = piece.text
|
|
401
436
|
onSend?.(sent.message_id, piece.text.length)
|
|
@@ -435,7 +470,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
435
470
|
let anchorId: number | undefined
|
|
436
471
|
try {
|
|
437
472
|
const sent = await retry(
|
|
438
|
-
() =>
|
|
473
|
+
() => sendPieceWithQuoteFallback(head),
|
|
439
474
|
{ threadId, chat_id: chatId },
|
|
440
475
|
)
|
|
441
476
|
anchorId = sent.message_id
|
|
@@ -109,9 +109,11 @@ export interface StreamReplyArgs {
|
|
|
109
109
|
disable_notification?: boolean
|
|
110
110
|
/**
|
|
111
111
|
* Optional surgical quote text. When set along with `reply_to`, the initial
|
|
112
|
-
* send includes `reply_parameters: { message_id, quote
|
|
113
|
-
*
|
|
114
|
-
* referenced message.
|
|
112
|
+
* send includes `reply_parameters: { message_id, quote }` — `quote` is a Bot
|
|
113
|
+
* API String (see `reply-quote.ts`) — so Telegram highlights the specific
|
|
114
|
+
* quoted sentence rather than the whole referenced message. It must be an
|
|
115
|
+
* exact substring of the referenced message; one Telegram cannot find is
|
|
116
|
+
* dropped and the send retried unquoted. Ignored when `reply_to` is absent.
|
|
115
117
|
*/
|
|
116
118
|
quote_text?: string
|
|
117
119
|
/**
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { describe, it, expect } from 'vitest'
|
|
16
16
|
import { renderBootCard, resolvePersonaName } from '../gateway/boot-card.js'
|
|
17
|
+
import { NESTED_PREFIX } from '../status-no-truncate.js'
|
|
17
18
|
|
|
18
19
|
describe('renderBootCard — quiet by default', () => {
|
|
19
20
|
it('returns one-line ack with default ✅ when no probes and no restart reason', () => {
|
|
@@ -153,7 +154,7 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
153
154
|
},
|
|
154
155
|
})
|
|
155
156
|
expect(out).toContain('🟡 **Skills** 10/10 dangling')
|
|
156
|
-
expect(out).toContain(
|
|
157
|
+
expect(out).toContain(`${NESTED_PREFIX}Run \`switchroom agent reconcile lawgpt\` to rebuild symlinks`)
|
|
157
158
|
})
|
|
158
159
|
|
|
159
160
|
it('crash row carries a tail-logs next-step', () => {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderStatusCard, renderCombinedWorkerFeed } from '../tool-activity-summary.js'
|
|
3
|
+
import { fitCardToBudget, hardTruncateCard, renderCardSpec } from '../card-layout.js'
|
|
4
|
+
import { STATUS_CARD_CHAR_BUDGET } from '../status-no-truncate.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* #3833 (and its duplicate #3682) — a rendered card must NEVER exceed
|
|
8
|
+
* `STATUS_CARD_CHAR_BUDGET`. An over-budget card is not a cosmetic problem: it
|
|
9
|
+
* is rejected by the Bot API, so the user's progress card simply does not
|
|
10
|
+
* appear (or stops updating) for the whole turn.
|
|
11
|
+
*
|
|
12
|
+
* Two defects produced over-budget cards, and both are asserted here:
|
|
13
|
+
*
|
|
14
|
+
* 1. UNDER-COUNTED FIXED COST. The deepest shrink level charged its
|
|
15
|
+
* header/footer as `[...chrome, ...footer].join('\n')`, but cards are
|
|
16
|
+
* rendered by `stackCard`, which emits a collapse separator plus a
|
|
17
|
+
* two-space GFM hard break at every line boundary — 4 chars per boundary,
|
|
18
|
+
* not 1 — and it also charges the collapsed parent-marker line. The budget
|
|
19
|
+
* the fitter handed the body was therefore too generous and the card came
|
|
20
|
+
* out a few chars over.
|
|
21
|
+
*
|
|
22
|
+
* 2. NO RE-CHECK ON THE LAST RESORT. `fitCardToBudget` returned the deepest
|
|
23
|
+
* render unmeasured. When the FIXED chrome alone is over budget (the #3682
|
|
24
|
+
* repro: a 40k-char worker description), no shrink level can help — every
|
|
25
|
+
* level keeps the chrome — so a 40k-char card went to the wire.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const HEADER = { emoji: '🛠', label: 'Worker', elapsedMs: 61_000, toolCount: 7, state: 'running' as const }
|
|
29
|
+
|
|
30
|
+
function card(opts: { desc?: string; steps?: string[]; children?: string[]; final?: boolean; result?: string }): string {
|
|
31
|
+
return renderStatusCard({
|
|
32
|
+
header: { ...HEADER, description: opts.desc ?? 'a task', state: opts.final === true ? 'done' : 'running' },
|
|
33
|
+
steps: opts.steps ?? ['step one'],
|
|
34
|
+
childSteps: opts.children,
|
|
35
|
+
final: opts.final ?? false,
|
|
36
|
+
stepCount: 3,
|
|
37
|
+
result: opts.result != null ? { emoji: '✅', text: opts.result } : undefined,
|
|
38
|
+
})!
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('a rendered card never exceeds the char budget (#3833 / #3682)', () => {
|
|
42
|
+
it('the #3682 repro — a 40k-char description — comes back under budget', () => {
|
|
43
|
+
// Fail-before: 40069 chars against a 32768 budget on origin/main.
|
|
44
|
+
const out = card({ desc: 'd'.repeat(40_000), steps: ['x'.repeat(4_000)], children: ['y'.repeat(4_000)] })
|
|
45
|
+
expect(out.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
|
|
46
|
+
// And it is still a card, not an empty string.
|
|
47
|
+
expect(out.length).toBeGreaterThan(100)
|
|
48
|
+
expect(out.startsWith('🛠')).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('the join-cost under-count boundary — every description length near the budget fits', () => {
|
|
52
|
+
// Fail-before: on origin/main the lengths 32693…32800 each rendered 1…98
|
|
53
|
+
// chars OVER budget. That band is exactly the fixed-cost under-count: the
|
|
54
|
+
// renderer's per-boundary separator + hard break, which the arithmetic
|
|
55
|
+
// estimate charged as one newline.
|
|
56
|
+
const over: Array<{ n: number; by: number }> = []
|
|
57
|
+
for (let n = 32_600; n <= 32_900; n++) {
|
|
58
|
+
const out = card({ desc: 'd'.repeat(n), steps: ['a', 'b', 'c'], result: 'done' })
|
|
59
|
+
if (out.length > STATUS_CARD_CHAR_BUDGET) over.push({ n, by: out.length - STATUS_CARD_CHAR_BUDGET })
|
|
60
|
+
}
|
|
61
|
+
expect(over).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('PROPERTY: no combination of description / steps / children / result overflows', () => {
|
|
65
|
+
// A deterministic sweep over the shape space rather than a handful of
|
|
66
|
+
// hand-picked cases: the guarantee is universal, so the test is too.
|
|
67
|
+
//
|
|
68
|
+
// Fillers matter as much as sizes — escaping EXPANDS text (`&` → `&`
|
|
69
|
+
// is 5×, markdown specials are 2×), which is what the fitter's clip loop
|
|
70
|
+
// has to converge against, and `👍` is astral so a naive slice could split
|
|
71
|
+
// a surrogate pair.
|
|
72
|
+
//
|
|
73
|
+
// Every filler is swept at the small/medium sizes; the huge sizes (where a
|
|
74
|
+
// single render costs seconds purely in `escapeMarkdown`, unchanged by this
|
|
75
|
+
// PR) are swept with the cheap-but-still-expanding fillers.
|
|
76
|
+
const cases: Array<{ size: number; fill: string }> = []
|
|
77
|
+
for (const size of [0, 1, 7, 200, 4_000, 16_000]) {
|
|
78
|
+
for (const fill of ['x', '&', '_*[`', '👍', 'é']) cases.push({ size, fill })
|
|
79
|
+
}
|
|
80
|
+
for (const size of [32_700, 33_000, 90_000]) {
|
|
81
|
+
for (const fill of ['x', '&', '👍']) cases.push({ size, fill })
|
|
82
|
+
}
|
|
83
|
+
for (const { size, fill } of cases) {
|
|
84
|
+
const blob = fill.repeat(Math.ceil(size / fill.length)).slice(0, size)
|
|
85
|
+
for (const final of [false, true]) {
|
|
86
|
+
for (const withChildren of [false, true]) {
|
|
87
|
+
const out = renderStatusCard({
|
|
88
|
+
header: { ...HEADER, description: blob, state: final ? 'done' : 'running' },
|
|
89
|
+
steps: [blob, blob, 'plain step'],
|
|
90
|
+
childSteps: withChildren ? [blob, blob] : undefined,
|
|
91
|
+
final,
|
|
92
|
+
liveSuffix: ' · 22s',
|
|
93
|
+
stepCount: 12,
|
|
94
|
+
result: { emoji: '✅', text: blob },
|
|
95
|
+
})
|
|
96
|
+
if (out == null) continue
|
|
97
|
+
expect(
|
|
98
|
+
out.length,
|
|
99
|
+
`over budget: size=${size} fill=${JSON.stringify(fill)} final=${final} children=${withChildren}`,
|
|
100
|
+
).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
|
|
101
|
+
// Never blanked, and never sliced through a surrogate pair.
|
|
102
|
+
expect(out.length).toBeGreaterThan(0)
|
|
103
|
+
expect(/[\uD800-\uDBFF]$/.test(out)).toBe(false)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}, 60_000)
|
|
108
|
+
|
|
109
|
+
it('PROPERTY: the combined worker card also stays under budget for any fan-out', () => {
|
|
110
|
+
for (const rows of [1, 2, 6, 25]) {
|
|
111
|
+
for (const size of [50, 5_000, 40_000]) {
|
|
112
|
+
const out = renderCombinedWorkerFeed(
|
|
113
|
+
Array.from({ length: rows }, (_, i) => ({
|
|
114
|
+
ordinal: i + 1,
|
|
115
|
+
description: 'w'.repeat(size),
|
|
116
|
+
currentStep: 's'.repeat(size),
|
|
117
|
+
historyLines: ['h'.repeat(size), 'h2'],
|
|
118
|
+
elapsedMs: 60_000,
|
|
119
|
+
toolCount: 3,
|
|
120
|
+
})),
|
|
121
|
+
{ maxRows: 8 },
|
|
122
|
+
)!
|
|
123
|
+
expect(out.length, `rows=${rows} size=${size}`).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('fitCardToBudget itself guarantees the budget even when every level overflows', () => {
|
|
129
|
+
// The mechanism, isolated from any particular card: a build function whose
|
|
130
|
+
// deepest level is still hopeless. Fail-before: this returned the raw
|
|
131
|
+
// 50k-char render.
|
|
132
|
+
const huge = 'q'.repeat(50_000)
|
|
133
|
+
const out = fitCardToBudget(() => ({ spec: { chrome: [huge], sections: [], footer: [] } }), 3)
|
|
134
|
+
expect(out.length).toBeLessThanOrEqual(STATUS_CARD_CHAR_BUDGET)
|
|
135
|
+
expect(out.length).toBeGreaterThan(0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('fitCardToBudget still returns the FIRST fitting level untouched', () => {
|
|
139
|
+
// The backstop must not change the normal path: a level that fits is
|
|
140
|
+
// returned byte-for-byte, and deeper levels are never built.
|
|
141
|
+
const built: number[] = []
|
|
142
|
+
const spec = { chrome: ['🛠 **Worker**'], sections: [], footer: ['_✓ 3 steps_'] }
|
|
143
|
+
const out = fitCardToBudget((level) => {
|
|
144
|
+
built.push(level)
|
|
145
|
+
return { spec }
|
|
146
|
+
}, 5)
|
|
147
|
+
expect(built).toEqual([0])
|
|
148
|
+
expect(out).toBe(renderCardSpec(spec))
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('hardTruncateCard cuts at a line boundary and never returns invalid UTF-16', () => {
|
|
152
|
+
const lines = ['aaaa', 'bbbb', 'cccc'].join(' \n')
|
|
153
|
+
// Budget lands inside the third line → cut back to the second boundary.
|
|
154
|
+
const cut = hardTruncateCard(lines, 12)
|
|
155
|
+
expect(cut.length).toBeLessThanOrEqual(12)
|
|
156
|
+
expect(cut).toBe('aaaa \nbbbb')
|
|
157
|
+
// Under budget → identity.
|
|
158
|
+
expect(hardTruncateCard(lines, 1000)).toBe(lines)
|
|
159
|
+
// A single over-long line with an astral char at the cut → no lone surrogate.
|
|
160
|
+
const astral = '👍'.repeat(50)
|
|
161
|
+
const sliced = hardTruncateCard(astral, 11)
|
|
162
|
+
expect(sliced.length).toBeLessThanOrEqual(11)
|
|
163
|
+
expect(/[\uD800-\uDBFF]$/.test(sliced)).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { hardTruncateCard } from '../card-layout.js'
|
|
3
|
+
import { truncateMarkdownSafe, safeMarkdownCut } from '../format.js'
|
|
4
|
+
import { validateRichMarkdown } from './rich-markdown-oracle.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* #4116 — the LAST-RESORT truncation must not be able to fail the send.
|
|
8
|
+
*
|
|
9
|
+
* `hardTruncateCard` is the path `fitCardToBudget` falls to when no shrink
|
|
10
|
+
* level can get a card under `STATUS_CARD_CHAR_BUDGET` (the #3682 repro: a
|
|
11
|
+
* 40k-char worker description, where every level keeps the same over-budget
|
|
12
|
+
* chrome). Its single-over-budget-line branch used to slice the raw markdown
|
|
13
|
+
* by character count. A cut landing between a `**` pair — or inside a
|
|
14
|
+
* `` `code` `` span, a `[label](href)`, an open ``` fence, a `~~strike~~`, a
|
|
15
|
+
* `||spoiler||` — emits a body Telegram parse-REJECTS with "can't find end of
|
|
16
|
+
* the entity". So the code path whose whole purpose is to guarantee delivery
|
|
17
|
+
* within budget could itself make the send fail.
|
|
18
|
+
*
|
|
19
|
+
* The fix routes the cut through `truncateMarkdownSafe`, which reuses the
|
|
20
|
+
* chunker's existing entity-boundary reasoning (`safeMarkdownCut` =
|
|
21
|
+
* fence → table row → inline span back-offs, plus surrogate-pair safety)
|
|
22
|
+
* rather than adding a second markdown parser.
|
|
23
|
+
*
|
|
24
|
+
* These tests assert the OUTCOME — "the emitted text parses" — using the
|
|
25
|
+
* repo's own parse-accept oracle (`rich-markdown-oracle.ts`, the same one the
|
|
26
|
+
* formatting-regression suite trusts), at EVERY cut offset rather than at a
|
|
27
|
+
* handful of hand-picked ones.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** A card body carrying one of every entity kind that can straddle a cut. */
|
|
31
|
+
const MIXED_ENTITY_CARD = [
|
|
32
|
+
'🛠 **Worker · fix/telegram-w0-de** · 2m05s · 12 tools',
|
|
33
|
+
'~~_✓ Read `telegram-plugin/card-layout.ts` and `format.ts`_~~',
|
|
34
|
+
'**→ Rewriting the last-resort clip** with a [linked issue](https://github.com/switchroom/switchroom/issues/4116) inline',
|
|
35
|
+
'⠀⠀⠀↳ _nested_ step with a ||spoiler|| and ~~a struck phrase~~ plus 👍🏽 astral text',
|
|
36
|
+
'```ts',
|
|
37
|
+
'const out = hardTruncateCard(text, budget)',
|
|
38
|
+
'const safe = safeMarkdownCut(text, cut)',
|
|
39
|
+
'```',
|
|
40
|
+
'A trailing prose line with **bold**, `code`, _italic_ and an emoji 🧵 at the end.',
|
|
41
|
+
].join('\n')
|
|
42
|
+
|
|
43
|
+
describe('the source fixture is itself valid markdown', () => {
|
|
44
|
+
it('guards the property test against a vacuous baseline', () => {
|
|
45
|
+
// If the fixture were already invalid, "every truncation is valid" could
|
|
46
|
+
// only ever fail — or, worse, a broken oracle would make it vacuously pass.
|
|
47
|
+
expect(validateRichMarkdown(MIXED_ENTITY_CARD)).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('PROPERTY: hardTruncateCard emits parseable markdown at every offset (#4116)', () => {
|
|
52
|
+
it('every budget from 1 to the full length yields a card Telegram can parse', () => {
|
|
53
|
+
const failures: Array<{ budget: number; kinds: string[]; tail: string }> = []
|
|
54
|
+
for (let budget = 1; budget <= MIXED_ENTITY_CARD.length; budget++) {
|
|
55
|
+
const out = hardTruncateCard(MIXED_ENTITY_CARD, budget)
|
|
56
|
+
const issues = validateRichMarkdown(out)
|
|
57
|
+
if (issues.length > 0) {
|
|
58
|
+
failures.push({
|
|
59
|
+
budget,
|
|
60
|
+
kinds: [...new Set(issues.map((i) => i.kind))],
|
|
61
|
+
tail: out.slice(-24),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Fail-before on origin/main: every offset whose cut lands inside a `**`
|
|
66
|
+
// pair / code span / link / fence produces an unsendable card.
|
|
67
|
+
expect(failures).toEqual([])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('every truncation stays within its budget and is valid UTF-16', () => {
|
|
71
|
+
for (let budget = 1; budget <= MIXED_ENTITY_CARD.length; budget++) {
|
|
72
|
+
const out = hardTruncateCard(MIXED_ENTITY_CARD, budget)
|
|
73
|
+
expect(out.length, `budget ${budget}`).toBeLessThanOrEqual(budget)
|
|
74
|
+
// No lone surrogate — a split astral char is invalid UTF-16 on the wire.
|
|
75
|
+
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out), `budget ${budget}`).toBe(false)
|
|
76
|
+
expect(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(out), `budget ${budget}`).toBe(false)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('a clipped card is still a card — it never collapses to (nearly) nothing', () => {
|
|
81
|
+
// The clip exists because "a clipped card beats no card". A back-off that
|
|
82
|
+
// retreated past a huge entity could return an empty string, which is the
|
|
83
|
+
// dropped card all over again. From a budget with room for real content,
|
|
84
|
+
// at least half of it survives — minus the handful of delimiter characters
|
|
85
|
+
// an opener-drop repair removes (measured worst case on this fixture: 8).
|
|
86
|
+
const REPAIR_SLACK = 12
|
|
87
|
+
for (let budget = 24; budget <= MIXED_ENTITY_CARD.length; budget++) {
|
|
88
|
+
const out = hardTruncateCard(MIXED_ENTITY_CARD, budget)
|
|
89
|
+
expect(out.length * 2 + REPAIR_SLACK, `budget ${budget}`).toBeGreaterThanOrEqual(budget)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('the specific entity kinds that can straddle a cut (#4116)', () => {
|
|
95
|
+
const cases: Array<[string, string]> = [
|
|
96
|
+
['bold', 'lead in **a bold run that is long enough to straddle** tail'],
|
|
97
|
+
['italic-underscore', 'lead in _an italic run long enough to straddle_ tail'],
|
|
98
|
+
['bold-italic', 'lead in ***a bold-italic run long enough to straddle*** tail'],
|
|
99
|
+
['inline code', 'lead in `a code span long enough to straddle the cut` tail'],
|
|
100
|
+
['link', 'lead in [a label](https://example.com/a/long/path/here) tail'],
|
|
101
|
+
['strikethrough', 'lead in ~~a struck run long enough to straddle~~ tail'],
|
|
102
|
+
['spoiler', 'lead in ||a spoiler run long enough to straddle|| tail'],
|
|
103
|
+
['table row', 'lead in\n| col a | col b | col c | col d |\ntail'],
|
|
104
|
+
['fenced block', 'lead in\n```ts\nconst x = 1\nconst y = 2\n```\ntail'],
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
for (const [kind, body] of cases) {
|
|
108
|
+
it(`never bisects a ${kind}`, () => {
|
|
109
|
+
expect(validateRichMarkdown(body)).toEqual([])
|
|
110
|
+
const bad: number[] = []
|
|
111
|
+
for (let budget = 1; budget <= body.length; budget++) {
|
|
112
|
+
if (validateRichMarkdown(hardTruncateCard(body, budget)).length > 0) bad.push(budget)
|
|
113
|
+
}
|
|
114
|
+
expect(bad).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('truncateMarkdownSafe — the shared mechanism', () => {
|
|
120
|
+
it('backs off rather than cutting inside a span, keeping formatting intact', () => {
|
|
121
|
+
const text = 'abcdefghij **bold text here** klmno'
|
|
122
|
+
// Budget 20 lands inside the bold span (which opens at 11).
|
|
123
|
+
const out = truncateMarkdownSafe(text, 20)
|
|
124
|
+
expect(out).toBe('abcdefghij ')
|
|
125
|
+
expect(validateRichMarkdown(out)).toEqual([])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('drops the opener instead of returning a near-empty result when one entity swallows the budget', () => {
|
|
129
|
+
// The #3682 shape: a single span longer than the whole window. Backing off
|
|
130
|
+
// would return `🛠 ` — a card with no content. Dropping the (unrenderable,
|
|
131
|
+
// because its closer is far past the budget) opener keeps the text.
|
|
132
|
+
const text = `🛠 **${'d'.repeat(500)}**`
|
|
133
|
+
const out = truncateMarkdownSafe(text, 64)
|
|
134
|
+
// Within budget, and only the dropped `**` short of it — the window is
|
|
135
|
+
// held fixed in SOURCE terms so the repair can never pull the span's
|
|
136
|
+
// closing delimiter into the output.
|
|
137
|
+
expect(out.length).toBe(62)
|
|
138
|
+
expect(out.startsWith('🛠 d')).toBe(true)
|
|
139
|
+
expect(out).not.toContain('*')
|
|
140
|
+
expect(validateRichMarkdown(out)).toEqual([])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('is a no-op when the text already fits', () => {
|
|
144
|
+
expect(truncateMarkdownSafe('**short**', 64)).toBe('**short**')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('safeMarkdownCut never splits a surrogate pair', () => {
|
|
148
|
+
const text = 'ab👍cd'
|
|
149
|
+
// Index 3 is between the two halves of the astral char.
|
|
150
|
+
expect(safeMarkdownCut(text, 3)).toBe(2)
|
|
151
|
+
expect(safeMarkdownCut(text, 4)).toBe(4)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -29,9 +29,9 @@ _1m35s · 12 tools · 41.2k tok · opus 4.8_
|
|
|
29
29
|
~~_✓ Searching memory for card render paths_~~
|
|
30
30
|
~~_✓ Reading telegram-plugin/tool-activity-summary.ts_~~
|
|
31
31
|
~~_✓ Reading telegram-plugin/worker-activity-feed.ts_~~
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
⠀⠀⠀↳ ~~_Reading the repo conventions_~~
|
|
33
|
+
⠀⠀⠀↳ ~~_Running the scoped test suite_~~
|
|
34
|
+
⠀⠀⠀↳ **→ Summarising findings · 24s**
|
|
35
35
|
|
|
36
36
|
===== CARD ===== Agent card — liveness early-open (no tools yet)
|
|
37
37
|
🤖 **Agent**
|
|
@@ -203,9 +203,9 @@ _✓ +9 earlier…_
|
|
|
203
203
|
_1m35s · 12 tools · 41.2k tok · opus 4.8_
|
|
204
204
|
~~_✓ Searching memory for card render paths_~~
|
|
205
205
|
~~_✓ Reading telegram-plugin/tool-activity-summary.ts_~~
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
206
|
+
⠀⠀⠀↳ _+6 earlier…_
|
|
207
|
+
⠀⠀⠀↳ ~~_Child step 7_~~
|
|
208
|
+
⠀⠀⠀↳ ~~_Child step 8_~~
|
|
209
|
+
⠀⠀⠀↳ ~~_Child step 9_~~
|
|
210
|
+
⠀⠀⠀↳ ~~_Child step 10_~~
|
|
211
|
+
⠀⠀⠀↳ **→ Child step 11**
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
floodQueueNonce,
|
|
37
37
|
FLOOD_QUEUED_SOURCE,
|
|
38
38
|
} from '../gateway/flood-reply-queue.js'
|
|
39
|
-
import { sweepOutbox } from '../gateway/outbox-sweep.js'
|
|
39
|
+
import { sweepOutbox, type OutboxSendResult } from '../gateway/outbox-sweep.js'
|
|
40
40
|
import { listPendingRecords, resolveOutboxDir, type OutboxRecord } from '../outbox.js'
|
|
41
41
|
import {
|
|
42
42
|
floodStatePath,
|
|
@@ -60,6 +60,32 @@ function wrappedReplyFailure(): Error {
|
|
|
60
60
|
return new Error('reply failed after 0 of 1 chunk(s) sent: FLOOD_WAIT_ACTIVE')
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A sweep `send` stub with the REAL {@link OutboxSendResult} shape (#4044).
|
|
65
|
+
*
|
|
66
|
+
* These tests used to stub `send` as `async () => 4242` — a bare number, the
|
|
67
|
+
* pre-#3510 contract. `sweepOutbox` has read `.messageId` / `.chunks` off the
|
|
68
|
+
* result since, so the stub was silently lying: it journalled
|
|
69
|
+
* `tgMessageId: undefined` and would have THROWN on `.chunks.length` had the
|
|
70
|
+
* test wired `recordOutbound` — which the sweep's catch turns into a
|
|
71
|
+
* `sendFailure` and a re-send on the next tick, i.e. a duplicate delivery of
|
|
72
|
+
* the very answer this file exists to protect. The `journals the landed message id`
|
|
73
|
+
* test below asserts that outcome; this helper is what makes it representable.
|
|
74
|
+
*/
|
|
75
|
+
function makeSendStub(firstMessageId = 4242) {
|
|
76
|
+
let next = firstMessageId
|
|
77
|
+
return vi.fn(
|
|
78
|
+
async (
|
|
79
|
+
_chatId: string,
|
|
80
|
+
_threadId: number | null,
|
|
81
|
+
text: string,
|
|
82
|
+
): Promise<OutboxSendResult> => {
|
|
83
|
+
const messageId = next++
|
|
84
|
+
return { messageId, chunks: [{ messageId, text }] }
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
function readRecords(dir: string): OutboxRecord[] {
|
|
64
90
|
const outbox = resolveOutboxDir(dir)
|
|
65
91
|
return readdirSync(outbox)
|
|
@@ -280,7 +306,7 @@ describe('end-to-end: queued during the window, delivered after it closes', () =
|
|
|
280
306
|
|
|
281
307
|
// 4. Sweeping WHILE the window is open must not touch the wire, and must
|
|
282
308
|
// not lose the record.
|
|
283
|
-
const send =
|
|
309
|
+
const send = makeSendStub(4242)
|
|
284
310
|
const during = await sweepOutbox({
|
|
285
311
|
stateDir: dir,
|
|
286
312
|
send,
|
|
@@ -318,6 +344,65 @@ describe('end-to-end: queued during the window, delivered after it closes', () =
|
|
|
318
344
|
expect(listPendingRecords(dir)).toHaveLength(0)
|
|
319
345
|
})
|
|
320
346
|
|
|
347
|
+
it('journals the landed message id and records history parity — not a send failure (#4044)', async () => {
|
|
348
|
+
// Regression for the stale `send` stub. `sweepOutbox` reads `.messageId`
|
|
349
|
+
// and `.chunks` off its `send` result (`OutboxSendResult`, outbox-sweep.ts
|
|
350
|
+
// :63-80). While these tests stubbed `send` as a bare number the contract
|
|
351
|
+
// was unguarded, and a stub-shaped production sender would (a) journal
|
|
352
|
+
// `tgMessageId: undefined`, losing the pointer to the delivered message,
|
|
353
|
+
// and (b) throw on `sendResult.chunks.length` — which the sweep's catch
|
|
354
|
+
// converts into `sendFailures++` and a claim RELEASE, so the next tick
|
|
355
|
+
// re-sends the operator's answer a second time. This asserts the outcome:
|
|
356
|
+
// delivered once, journalled with the REAL id, mirrored to history.
|
|
357
|
+
const answer = 'the answer that must land exactly once'
|
|
358
|
+
const t0 = 1_000_000
|
|
359
|
+
queueFloodBlockedReply({
|
|
360
|
+
err: floodWaitActiveError(30),
|
|
361
|
+
chatId: '777',
|
|
362
|
+
threadId: null,
|
|
363
|
+
text: answer,
|
|
364
|
+
priorityClass: 'critical',
|
|
365
|
+
stateDir: dir,
|
|
366
|
+
createdAt: t0,
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
const send = makeSendStub(9001)
|
|
370
|
+
const recorded: Array<[string, number | null, number[], string[]]> = []
|
|
371
|
+
const summary = await sweepOutbox({
|
|
372
|
+
stateDir: dir,
|
|
373
|
+
send,
|
|
374
|
+
recordOutbound: (chatId, threadId, messageIds, texts) => {
|
|
375
|
+
recorded.push([chatId, threadId, messageIds, texts])
|
|
376
|
+
},
|
|
377
|
+
textAlreadyDelivered: () => false,
|
|
378
|
+
now: () => t0 + 10_000,
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// Delivered — and NOT counted as a send failure.
|
|
382
|
+
expect(summary.delivered).toBe(1)
|
|
383
|
+
expect(summary.sendFailures).toBe(0)
|
|
384
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
385
|
+
|
|
386
|
+
// The journal carries the id the wire actually returned, so a duplicate is
|
|
387
|
+
// provable from the journal alone (#3510).
|
|
388
|
+
const journal = readFileSync(join(resolveOutboxDir(dir), 'delivered.jsonl'), 'utf8')
|
|
389
|
+
.split('\n')
|
|
390
|
+
.filter((l) => l.trim() !== '')
|
|
391
|
+
.map((l) => JSON.parse(l) as { tgMessageId?: number; deliverySource?: string })
|
|
392
|
+
expect(journal).toHaveLength(1)
|
|
393
|
+
expect(journal[0]!.tgMessageId).toBe(9001)
|
|
394
|
+
expect(journal[0]!.deliverySource).toBe('sweep')
|
|
395
|
+
|
|
396
|
+
// And history got the landed ids + their delivered texts.
|
|
397
|
+
expect(recorded).toHaveLength(1)
|
|
398
|
+
expect(recorded[0]![0]).toBe('777')
|
|
399
|
+
expect(recorded[0]![2]).toEqual([9001])
|
|
400
|
+
expect(recorded[0]![3]!.join('')).toContain(answer)
|
|
401
|
+
|
|
402
|
+
// Nothing left to re-send.
|
|
403
|
+
expect(listPendingRecords(dir)).toHaveLength(0)
|
|
404
|
+
})
|
|
405
|
+
|
|
321
406
|
it('does not double-deliver when the caller retries after the window closed', async () => {
|
|
322
407
|
const answer = 'the one answer'
|
|
323
408
|
const t0 = 1_000_000
|
|
@@ -331,7 +416,7 @@ describe('end-to-end: queued during the window, delivered after it closes', () =
|
|
|
331
416
|
createdAt: t0,
|
|
332
417
|
})
|
|
333
418
|
|
|
334
|
-
const send =
|
|
419
|
+
const send = makeSendStub(1)
|
|
335
420
|
await sweepOutbox({
|
|
336
421
|
stateDir: dir,
|
|
337
422
|
send,
|
|
@@ -381,7 +466,7 @@ describe('end-to-end: queued during the window, delivered after it closes', () =
|
|
|
381
466
|
})
|
|
382
467
|
})
|
|
383
468
|
|
|
384
|
-
const send =
|
|
469
|
+
const send = makeSendStub(1)
|
|
385
470
|
await sweepOutbox({
|
|
386
471
|
stateDir: dir,
|
|
387
472
|
send,
|