switchroom 0.19.41 → 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 +23 -3
- package/dist/auth-broker/index.js +54 -9
- package/dist/cli/notion-write-pretool.mjs +23 -3
- package/dist/cli/switchroom.js +1956 -900
- package/dist/host-control/main.js +227 -30
- package/dist/vault/approvals/kernel-server.js +54 -9
- package/dist/vault/broker/server.js +54 -9
- 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 +1318 -693
- 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/approval-callback-consume-record.test.ts +132 -0
- 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 +56 -58
- 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/liveness-wiring.ts +12 -0
- package/telegram-plugin/gateway/model-command.ts +21 -109
- 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 +62 -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/framework-fallback-drains-parked.test.ts +134 -0
- 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/helpers/liveness-wiring-fixture.ts +3 -0
- 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/CHANGELOG.md +28 -0
- package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
- package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +18 -3
- package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
- package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
- package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
|
@@ -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,
|
|
@@ -397,20 +397,166 @@ describe('stripExcessBold', () => {
|
|
|
397
397
|
expect(stripExcessBold(input)).toBe(input)
|
|
398
398
|
})
|
|
399
399
|
|
|
400
|
-
test('
|
|
401
|
-
// Heading length is measured WITH the `**` markers.
|
|
402
|
-
// total (exempt);
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
expect(
|
|
406
|
-
expect(
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
expect(
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
expect(
|
|
413
|
-
expect(
|
|
400
|
+
test('64/65-char pseudo-heading boundary honoured under global strip', () => {
|
|
401
|
+
// Heading length is measured WITH the `**` markers. 60 inner chars → 64
|
|
402
|
+
// total (exempt); 61 inner chars → 65 total (stripped).
|
|
403
|
+
const heading64 = '**' + 'H'.repeat(60) + '**' // length 64
|
|
404
|
+
const heading65 = '**' + 'H'.repeat(61) + '**' // length 65
|
|
405
|
+
expect(heading64.length).toBe(64)
|
|
406
|
+
expect(heading65.length).toBe(65)
|
|
407
|
+
|
|
408
|
+
const out64 = stripExcessBold(`${heading64}\n\n${boldDenseBody}`)
|
|
409
|
+
expect(out64).toContain(heading64)
|
|
410
|
+
|
|
411
|
+
const out65 = stripExcessBold(`${heading65}\n\n${boldDenseBody}`)
|
|
412
|
+
expect(out65).not.toContain(heading65)
|
|
413
|
+
expect(out65).toContain('H'.repeat(61))
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// ── #4021: long-but-legitimate bold section labels survive ───────────────
|
|
417
|
+
// At PSEUDO_HEADING_MAX_CHARS=48 a real section label of 50-63 chars was
|
|
418
|
+
// read as an over-bolded paragraph and flattened. The cap is 64.
|
|
419
|
+
|
|
420
|
+
test.each([
|
|
421
|
+
// [label, survives?]
|
|
422
|
+
['**Deployment status across all three regions:**', true], // 46
|
|
423
|
+
['**Deployment status across all three AWS regions:**', true], // 50
|
|
424
|
+
['**What changed in the delivery path since last Tuesday:**', true], // 56
|
|
425
|
+
['**What actually changed in the delivery path since Tue:**', true], // 56
|
|
426
|
+
['**Everything that changed across the delivery path this week:**', true], // 62
|
|
427
|
+
['**Everything that changed across the whole delivery path today:**', false], // 65
|
|
428
|
+
])('bold section label %s survives=%s under global strip', (label, survives) => {
|
|
429
|
+
// Sanity: the fixtures must straddle the 64-char boundary.
|
|
430
|
+
expect(label.length <= 64).toBe(survives)
|
|
431
|
+
|
|
432
|
+
const out = stripExcessBold(`${label}\n\n${boldDenseBody}`)
|
|
433
|
+
const inner = label.slice(2, -2)
|
|
434
|
+
if (survives) {
|
|
435
|
+
expect(out).toContain(label)
|
|
436
|
+
} else {
|
|
437
|
+
expect(out).not.toContain(label)
|
|
438
|
+
expect(out).toContain(inner)
|
|
439
|
+
}
|
|
440
|
+
// Either way the body is still flattened — the ratio guard is intact.
|
|
441
|
+
expect(out).not.toContain('**alpha**')
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
// ── #4017: a message that is ALL pseudo-headings still strips ────────────
|
|
445
|
+
// Every block exempted meant `out === masked`: a 100%-bold digest stayed
|
|
446
|
+
// fully bold and onStrip never fired.
|
|
447
|
+
|
|
448
|
+
test('all-pseudo-heading message strips (no body to contrast against)', () => {
|
|
449
|
+
const input = [
|
|
450
|
+
'**Deploy status**',
|
|
451
|
+
'**Open incidents**',
|
|
452
|
+
'**Merged today**',
|
|
453
|
+
'**Blocked on review**',
|
|
454
|
+
'**Rollout window tomorrow**',
|
|
455
|
+
'**Next steps:**',
|
|
456
|
+
].join('\n\n')
|
|
457
|
+
expect(input.length).toBeGreaterThan(100)
|
|
458
|
+
|
|
459
|
+
const calls: Array<{ rule: string; ratio: number }> = []
|
|
460
|
+
const out = stripExcessBold(input, (d) => calls.push(d))
|
|
461
|
+
|
|
462
|
+
expect(out).not.toContain('**')
|
|
463
|
+
expect(out).toContain('Deploy status')
|
|
464
|
+
expect(out).toContain('Next steps:')
|
|
465
|
+
// Blank-line gaps between the headings are preserved.
|
|
466
|
+
expect(out).toBe(input.replace(/\*\*/g, ''))
|
|
467
|
+
// …and the strip is logged.
|
|
468
|
+
expect(calls).toHaveLength(1)
|
|
469
|
+
expect(calls[0].rule).toBe('global')
|
|
470
|
+
expect(calls[0].ratio).toBeGreaterThan(0.3)
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
test.each([
|
|
474
|
+
[
|
|
475
|
+
'all headings, no body → strip',
|
|
476
|
+
`**Deploy status**\n\n**Open incidents this morning**\n\n**Merged today since the last release**\n\n**Next steps:**`,
|
|
477
|
+
false,
|
|
478
|
+
],
|
|
479
|
+
[
|
|
480
|
+
'headings + bold-dense body → headings survive',
|
|
481
|
+
`**Deploy status**\n\n${boldDenseBody}`,
|
|
482
|
+
true,
|
|
483
|
+
],
|
|
484
|
+
[
|
|
485
|
+
'headings around a bold-dense body → headings survive',
|
|
486
|
+
`**Deploy status**\n\n**Open incidents**\n\n${boldDenseBody}\n\n**Next steps:**`,
|
|
487
|
+
true,
|
|
488
|
+
],
|
|
489
|
+
])('all-heading guard: %s', (_name, input, headingSurvives) => {
|
|
490
|
+
expect(input.length).toBeGreaterThan(100)
|
|
491
|
+
const out = stripExcessBold(input)
|
|
492
|
+
expect(out.includes('**Deploy status**')).toBe(headingSurvives)
|
|
493
|
+
expect(out).toContain('Deploy status')
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
// ── #4114: a code-only block is NOT body for the all-heading guard ───────
|
|
497
|
+
// #4108's short-circuit tested `blocks.every(isPseudoHeadingBlock)` on every
|
|
498
|
+
// non-blank block. A masked code block satisfies neither side, so ONE code
|
|
499
|
+
// fence in an all-headings digest flipped `every(...)` to false, re-exempted
|
|
500
|
+
// every heading, and reproduced #4017 exactly: 100% bold, onStrip silent.
|
|
501
|
+
// Decision pinned here: masked code is not body — it is invisible to every
|
|
502
|
+
// other measurement in stripExcessBold (it is stripped before the ratio is
|
|
503
|
+
// taken), so it does not buy the headings an exemption either.
|
|
504
|
+
|
|
505
|
+
const HEADINGS = ['**Deploy status**', '**Open incidents**', '**Merged today**']
|
|
506
|
+
const TAIL = ['**Blocked on review**', '**Rollout window tomorrow**', '**Next steps:**']
|
|
507
|
+
|
|
508
|
+
test.each([
|
|
509
|
+
['fenced code block', '```\nswitchroom agent restart klanker\n```'],
|
|
510
|
+
['inline code span', '`switchroom agent restart klanker`'],
|
|
511
|
+
['fenced block with a language tag', '```bash\nswitchroom agent restart klanker\n```'],
|
|
512
|
+
['several inline spans on one line', '`alpha` `beta` `gamma` `delta` `epsilon`'],
|
|
513
|
+
])('all-heading guard: headings + a code-only block (%s) still strips', (_name, code) => {
|
|
514
|
+
const input = [...HEADINGS, code, ...TAIL].join('\n\n')
|
|
515
|
+
expect(input.length).toBeGreaterThan(100)
|
|
516
|
+
|
|
517
|
+
const calls: Array<{ rule: string; ratio: number }> = []
|
|
518
|
+
const out = stripExcessBold(input, (d) => calls.push(d))
|
|
519
|
+
|
|
520
|
+
// Outcome 1: no bold survives — this is the assertion that fails on the
|
|
521
|
+
// real bug, where `out` was the input verbatim.
|
|
522
|
+
expect(out).not.toContain('**')
|
|
523
|
+
expect(out).toContain('Deploy status')
|
|
524
|
+
expect(out).toContain('Next steps:')
|
|
525
|
+
// Outcome 2: the code block itself is preserved byte-for-byte.
|
|
526
|
+
expect(out).toContain(code)
|
|
527
|
+
expect(out).toBe(input.replace(/\*\*/g, ''))
|
|
528
|
+
// Outcome 3: the strip is logged (the #4017 silence is what made this
|
|
529
|
+
// class of bug invisible in the first place).
|
|
530
|
+
expect(calls).toHaveLength(1)
|
|
531
|
+
expect(calls[0].rule).toBe('global')
|
|
532
|
+
expect(calls[0].ratio).toBeGreaterThan(0.3)
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('all-heading guard: real prose body CONTAINING inline code keeps headings', () => {
|
|
536
|
+
// The counterpart that stops the #4114 fix from over-correcting: a block
|
|
537
|
+
// with visible text around its code span IS body, so the headings keep
|
|
538
|
+
// their bold. Only blocks that are *nothing but* code are skipped.
|
|
539
|
+
const input = [
|
|
540
|
+
...HEADINGS,
|
|
541
|
+
'Run `switchroom agent restart klanker` now, then check the gateway log for errors.',
|
|
542
|
+
...TAIL,
|
|
543
|
+
].join('\n\n')
|
|
544
|
+
|
|
545
|
+
const calls: Array<{ rule: string; ratio: number }> = []
|
|
546
|
+
const out = stripExcessBold(input, (d) => calls.push(d))
|
|
547
|
+
|
|
548
|
+
expect(out).toContain('**Deploy status**')
|
|
549
|
+
expect(out).toContain('**Next steps:**')
|
|
550
|
+
expect(calls).toHaveLength(0)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
test('all-heading guard: a code-only block does not rescue a headings-only digest', () => {
|
|
554
|
+
// Degenerate shape: the code block is the ONLY non-heading block and sits
|
|
555
|
+
// last. Same verdict, so the rule does not depend on block position.
|
|
556
|
+
const input = [...HEADINGS, ...TAIL, '```\nok\n```'].join('\n\n')
|
|
557
|
+
const out = stripExcessBold(input)
|
|
558
|
+
expect(out).not.toContain('**')
|
|
559
|
+
expect(out).toContain('```\nok\n```')
|
|
414
560
|
})
|
|
415
561
|
|
|
416
562
|
test('multi-line fully-bolded block is NOT mislabelled a heading (global)', () => {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-state desync wedge — the silence-fallback must drain `parkedTurnStarts`.
|
|
3
|
+
*
|
|
4
|
+
* The gateway carries TWO independent "turn in flight" states. The park gate in
|
|
5
|
+
* `stream-render.ts` (`case 'enqueue'`) treats the session as busy if EITHER a
|
|
6
|
+
* live `currentTurn` exists OR `parkedTurnStarts` is non-empty. The 300 s
|
|
7
|
+
* framework-fallback (`liveness-wiring.ts` onFrameworkFallback) that recovers a
|
|
8
|
+
* hung turn nulls `currentTurn` and redelivers `pendingInboundBuffer` — but
|
|
9
|
+
* before the fix it NEVER touched `parkedTurnStarts`. That store's only real
|
|
10
|
+
* drains are the CLI's own `dequeue` / `remove` stream events, so when the
|
|
11
|
+
* claude REPL hangs mid-turn those never arrive, a leftover parked envelope
|
|
12
|
+
* latches the busy gate permanently, and every later inbound parks unseen until
|
|
13
|
+
* a manual container restart (gymbro parked msgs 4502→4504 behind a dead lock
|
|
14
|
+
* until SIGTERM). #3927 introduced the parked store but never wired it into
|
|
15
|
+
* fallback recovery.
|
|
16
|
+
*
|
|
17
|
+
* This drives the REAL `handleSessionEvent` (the extracted-module golden
|
|
18
|
+
* harness, same standard as `turn-mint-defers-until-dequeue.test.ts`) and the
|
|
19
|
+
* REAL `onFrameworkFallback` (via `buildSilencePokeOptions`, same standard as
|
|
20
|
+
* `silence-poke-teardown-notice.test.ts`), sharing ONE world: the fallback's
|
|
21
|
+
* `drainParkedTurnStarts` dep routes to the real store + `beginTurn` over the
|
|
22
|
+
* harness deps. It asserts the OUTCOME, not a code path: after the fallback the
|
|
23
|
+
* parked store is emptied AND the parked message was handed back into turn
|
|
24
|
+
* processing (its turn begun, its card opened) rather than silently dropped.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
27
|
+
import {
|
|
28
|
+
handleSessionEvent,
|
|
29
|
+
drainParkedTurnStartsForChat,
|
|
30
|
+
__resetParkedTurnStartsForTest,
|
|
31
|
+
__parkedTurnStartCountForTest,
|
|
32
|
+
} from '../gateway/stream-render.js'
|
|
33
|
+
import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
|
|
34
|
+
import { makeLivenessFixture, makeTurn, statusKeyForTests } from './helpers/liveness-wiring-fixture.js'
|
|
35
|
+
import { CHAT, enqueue, makeHarness } from './turn-mint-harness.js'
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
__resetParkedTurnStartsForTest()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('silence-fallback drains parkedTurnStarts so a hung REPL cannot wedge the park gate', () => {
|
|
42
|
+
it('empties the parked store AND redelivers the parked message into turn processing', async () => {
|
|
43
|
+
const h = makeHarness()
|
|
44
|
+
|
|
45
|
+
// ── turn A mints on the idle enqueue and its ms-later dequeue ─────────────
|
|
46
|
+
handleSessionEvent(h.deps, enqueue('501'))
|
|
47
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
48
|
+
const turnA = h.current()!
|
|
49
|
+
expect(turnA.sourceMessageId).toBe(501)
|
|
50
|
+
|
|
51
|
+
// ── the operator sends B mid-turn; the CLI queues it (enqueue) but the
|
|
52
|
+
// REPL hangs, so the paired `dequeue` never comes and B stays parked ──
|
|
53
|
+
handleSessionEvent(h.deps, enqueue('502', 'still there?'))
|
|
54
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
55
|
+
|
|
56
|
+
// ── the REAL 300 s framework-fallback fires, wired to the REAL parked
|
|
57
|
+
// drain over the harness deps so both states share one world ──────────
|
|
58
|
+
const KEY = statusKeyForTests(CHAT, null)
|
|
59
|
+
const fx = makeLivenessFixture({
|
|
60
|
+
drainParkedTurnStarts: (chatId: string, threadId: number | null) =>
|
|
61
|
+
drainParkedTurnStartsForChat(
|
|
62
|
+
h.deps,
|
|
63
|
+
chatId,
|
|
64
|
+
threadId != null ? String(threadId) : null,
|
|
65
|
+
).length,
|
|
66
|
+
})
|
|
67
|
+
// The wedged turn the fallback tears down — same chat/thread as parked B.
|
|
68
|
+
fx.activeTurnStartedAt.set(KEY, 0)
|
|
69
|
+
fx.setCurrentTurn(makeTurn({ sessionChatId: CHAT, sessionThreadId: undefined, turnId: `${KEY}#42` }))
|
|
70
|
+
|
|
71
|
+
const opts = buildSilencePokeOptions(fx.deps)
|
|
72
|
+
await opts.onFrameworkFallback({
|
|
73
|
+
key: KEY,
|
|
74
|
+
chatId: CHAT,
|
|
75
|
+
threadId: null,
|
|
76
|
+
fallbackKind: 'working',
|
|
77
|
+
silenceMs: 302_000,
|
|
78
|
+
inFlightTools: [],
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// The fallback nulled the wedged turn (its one load-bearing job).
|
|
82
|
+
expect(fx.endedKeys).toContain(KEY)
|
|
83
|
+
|
|
84
|
+
// OUTCOME 1 — the parked store is emptied, so the busy park gate unlatches
|
|
85
|
+
// and the NEXT inbound can mint its own turn instead of parking unseen.
|
|
86
|
+
expect(__parkedTurnStartCountForTest()).toBe(0)
|
|
87
|
+
|
|
88
|
+
// OUTCOME 2 — B was actually dispatched back into turn processing, not
|
|
89
|
+
// dropped: its turn was begun (it is now the live turn) and its progress
|
|
90
|
+
// card was opened. Pre-fix, B stayed parked and this never happened.
|
|
91
|
+
expect(h.current()!.sourceMessageId).toBe(502)
|
|
92
|
+
expect(h.cardsOpened.some((c) => c.sourceMessageId === 502)).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('is scoped to the wedged chat — a fallback for chat A leaves chat B parked', () => {
|
|
96
|
+
const h = makeHarness()
|
|
97
|
+
|
|
98
|
+
// A live turn on CHAT, plus a parked entry on CHAT.
|
|
99
|
+
handleSessionEvent(h.deps, enqueue('601'))
|
|
100
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
101
|
+
handleSessionEvent(h.deps, enqueue('602'))
|
|
102
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
103
|
+
|
|
104
|
+
// A fallback for a DIFFERENT chat must not touch CHAT's parked entry.
|
|
105
|
+
const drainedOther = drainParkedTurnStartsForChat(h.deps, '9999', null)
|
|
106
|
+
expect(drainedOther).toHaveLength(0)
|
|
107
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
108
|
+
|
|
109
|
+
// The correctly-scoped drain releases it.
|
|
110
|
+
const drainedHere = drainParkedTurnStartsForChat(h.deps, CHAT, null)
|
|
111
|
+
expect(drainedHere).toHaveLength(1)
|
|
112
|
+
expect(drainedHere[0]!.messageId).toBe('602')
|
|
113
|
+
expect(__parkedTurnStartCountForTest()).toBe(0)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('is idempotent w.r.t. a late CLI dequeue — no double-start', () => {
|
|
117
|
+
const h = makeHarness()
|
|
118
|
+
handleSessionEvent(h.deps, enqueue('701'))
|
|
119
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
120
|
+
handleSessionEvent(h.deps, enqueue('702'))
|
|
121
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
122
|
+
|
|
123
|
+
// Fallback drains + begins 702.
|
|
124
|
+
expect(drainParkedTurnStartsForChat(h.deps, CHAT, null)).toHaveLength(1)
|
|
125
|
+
expect(h.current()!.sourceMessageId).toBe(702)
|
|
126
|
+
const cardsAfterDrain = h.cardsOpened.length
|
|
127
|
+
|
|
128
|
+
// The REPL finally un-hangs and emits its late `dequeue`. The store is empty
|
|
129
|
+
// so `takeParkedTurnStart` returns null and no second turn/card is minted.
|
|
130
|
+
handleSessionEvent(h.deps, { kind: 'dequeue' })
|
|
131
|
+
expect(h.cardsOpened).toHaveLength(cardsAfterDrain)
|
|
132
|
+
expect(h.current()!.sourceMessageId).toBe(702)
|
|
133
|
+
})
|
|
134
|
+
})
|