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,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderStatusCard } from '../tool-activity-summary.js'
|
|
3
|
+
import { NESTED_PREFIX } from '../status-no-truncate.js'
|
|
4
|
+
import { COLLAPSE_SAFE_SEPARATOR } from '../card-format.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* #3668 — the nested (foreground sub-agent) child block must actually LOOK
|
|
8
|
+
* nested on a phone.
|
|
9
|
+
*
|
|
10
|
+
* `NESTED_PREFIX` used to be three ASCII spaces plus `↳ `. Card bodies reach
|
|
11
|
+
* Telegram as GFM markdown parsed SERVER-SIDE, and that parser left-trims a
|
|
12
|
+
* leading whitespace run off a content line, so the indent was dropped on the
|
|
13
|
+
* wire and every child step rendered FLAT against its parent's steps — the
|
|
14
|
+
* whole nesting cue reduced to one `↳` glyph.
|
|
15
|
+
*
|
|
16
|
+
* The replacement is the repo's already-live-verified indent idiom, U+2800
|
|
17
|
+
* BRAILLE PATTERN BLANK: general category So (Symbol, other), not Zs, so no
|
|
18
|
+
* whitespace-trimming rule can classify it as whitespace, yet it renders as
|
|
19
|
+
* blank width. Evidence, in order of strength:
|
|
20
|
+
*
|
|
21
|
+
* 1. LIVE: on 2026-07-26 four candidate indents were sent to a real phone in
|
|
22
|
+
* one message (recorded at `status-no-truncate.ts`, WORKER_STEP_INDENT) —
|
|
23
|
+
* three U+00A0 rendered FLAT, three U+2800 INDENTED CORRECTLY, a leading
|
|
24
|
+
* `↳ ` rendered as visible ink, a leading `· ` was promoted by Telegram
|
|
25
|
+
* into a real list bullet.
|
|
26
|
+
* 2. SHIPPED: `WORKER_STEP_INDENT` (the combined worker card) has used U+2800
|
|
27
|
+
* since that check and renders indented in production.
|
|
28
|
+
* 3. CATEGORY: the property below — U+2800 is not `\s`, not `White_Space`,
|
|
29
|
+
* not `Zs`.
|
|
30
|
+
*
|
|
31
|
+
* Note what is NOT claimed: no character was chosen on inference. #3662 shipped
|
|
32
|
+
* U+00A0 on exactly that kind of inference, was merged green, and was inert on
|
|
33
|
+
* a phone — which is why these tests assert the PROPERTY, not just the bytes.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Every rule a server-side trimmer could plausibly use. */
|
|
37
|
+
const isTrimmableWhitespace = (ch: string): boolean =>
|
|
38
|
+
/\s/u.test(ch) || /\p{White_Space}/u.test(ch) || /\p{Zs}/u.test(ch)
|
|
39
|
+
|
|
40
|
+
function nestedCard(children: string[]): string {
|
|
41
|
+
return renderStatusCard({
|
|
42
|
+
header: { emoji: '🤖', label: 'Agent', elapsedMs: 95_000, toolCount: 12, state: 'running' },
|
|
43
|
+
steps: ['Reading gateway.ts'],
|
|
44
|
+
childSteps: children,
|
|
45
|
+
final: false,
|
|
46
|
+
})!
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The card's pre-join lines, hard-break chrome removed. */
|
|
50
|
+
function linesOf(card: string): string[] {
|
|
51
|
+
return card
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map((l) => l.replace(/[ \t]+$/, '').replace(new RegExp(`${COLLAPSE_SAFE_SEPARATOR}$`), ''))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('NESTED_PREFIX renders as a real indent (#3668)', () => {
|
|
57
|
+
it('contains no character any whitespace rule would trim', () => {
|
|
58
|
+
// The `↳ ` glyph is visible ink and is kept; the run BEFORE it is the
|
|
59
|
+
// indent, and every char of THAT must survive a left-trim. Pin the width
|
|
60
|
+
// too — a single blank char reads as near-flat on a phone and would undo
|
|
61
|
+
// the fix while keeping the character-class assertion green.
|
|
62
|
+
const indent = [...NESTED_PREFIX.slice(0, NESTED_PREFIX.indexOf('↳'))]
|
|
63
|
+
expect(indent.length).toBe(3)
|
|
64
|
+
for (const ch of indent) {
|
|
65
|
+
const cp = `U+${ch.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`
|
|
66
|
+
expect(
|
|
67
|
+
isTrimmableWhitespace(ch),
|
|
68
|
+
`NESTED_PREFIX's indent contains ${cp}, which is Unicode whitespace ` +
|
|
69
|
+
`(\\s / White_Space / Zs). Telegram left-trims a leading whitespace run off a ` +
|
|
70
|
+
`content line, so this indent renders FLAT on a phone — the #3662/#3668 failure. ` +
|
|
71
|
+
`Use U+2800 BRAILLE PATTERN BLANK (category So), live-verified 2026-07-26.`,
|
|
72
|
+
).toBe(false)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('leads with exactly three U+2800 and keeps the ↳ glyph', () => {
|
|
77
|
+
// Byte pin, companion to the property above: that one says "not
|
|
78
|
+
// whitespace", this one says "the specific glyph we live-tested", in the
|
|
79
|
+
// order that matters (blanks LEAD, so the glyph is never what gets trimmed).
|
|
80
|
+
expect(NESTED_PREFIX).toBe('\u2800\u2800\u2800↳ ')
|
|
81
|
+
// Not a GFM block-structure lead-in: `stackCardLines` promotes every
|
|
82
|
+
// inter-line break to a hard break *because* card lines are never block
|
|
83
|
+
// lines, and a live check showed Telegram promotes a leading `·` to a real
|
|
84
|
+
// list bullet.
|
|
85
|
+
expect(/^[-*+>|#·]/.test(NESTED_PREFIX)).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('every rendered child line survives a server-side leading-whitespace trim with its indent intact', () => {
|
|
89
|
+
const card = nestedCard(['Searching memory', 'Running tests'])
|
|
90
|
+
const childLines = linesOf(card).filter((l) => l.includes('↳'))
|
|
91
|
+
expect(childLines.length).toBe(2)
|
|
92
|
+
for (const line of childLines) {
|
|
93
|
+
// Fail-before assertion: with the old ASCII prefix, `line` started with
|
|
94
|
+
// `' '` and this strip removed the whole indent.
|
|
95
|
+
expect(line.startsWith(NESTED_PREFIX)).toBe(true)
|
|
96
|
+
expect(line.replace(/^[\s\p{White_Space}]+/u, '').startsWith(NESTED_PREFIX)).toBe(true)
|
|
97
|
+
// And it leads with neither of the two characters already proven flat.
|
|
98
|
+
expect(line.startsWith(' ')).toBe(false)
|
|
99
|
+
expect(line.startsWith('\u00A0')).toBe(false)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('golden line ordering — parent steps stay flush, child steps sit one level in', () => {
|
|
104
|
+
// Set-membership assertions alone would pass a render that emitted all the
|
|
105
|
+
// parents and then all the children. This pins the ORDER, which is the
|
|
106
|
+
// thing "nested" actually means.
|
|
107
|
+
const card = nestedCard(['Searching memory', 'Running tests'])
|
|
108
|
+
const body = linesOf(card).slice(2)
|
|
109
|
+
expect(body).toEqual([
|
|
110
|
+
'~~_✓ Reading gateway.ts_~~',
|
|
111
|
+
`${NESTED_PREFIX}~~_Searching memory_~~`,
|
|
112
|
+
`${NESTED_PREFIX}**→ Running tests**`,
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createRobustApiCall` must stay deleted (#3863).
|
|
3
|
+
*
|
|
4
|
+
* It was a second `createRetryApiCall` wiring in `shared/bot-runtime.ts`,
|
|
5
|
+
* described as "exactly how gateway.ts constructs its robustApiCall". It had
|
|
6
|
+
* ZERO production callers — and, worse, its flood-breaker hooks were OPTIONAL
|
|
7
|
+
* (`floodStatePath` defaulted to absent), so the default construction was a
|
|
8
|
+
* send path blind to the shared flood window: the exact failure
|
|
9
|
+
* `scripts/check-retry-flood-hooks.mjs` exists to prevent, sitting in the file
|
|
10
|
+
* a future caller would naturally reach for.
|
|
11
|
+
*
|
|
12
|
+
* It was also load-bearing DOCUMENTATION: `flood-circuit-breaker.ts` and
|
|
13
|
+
* `flood-429-ledger.ts` both cited it as a live wiring, so the breaker's
|
|
14
|
+
* "every onFloodWait goes through here" completeness argument was partly
|
|
15
|
+
* anchored to dead code.
|
|
16
|
+
*
|
|
17
|
+
* This is a source-level guard on purpose: the defect is the existence of the
|
|
18
|
+
* identifier, and there is no runtime behaviour to assert.
|
|
19
|
+
*
|
|
20
|
+
* The rule is "the name may appear only inside backticks" — i.e. as PROSE in a
|
|
21
|
+
* docblock explaining why it is gone. Any bare occurrence is code (a
|
|
22
|
+
* declaration, an import, a call) and fails.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, it, expect } from 'vitest'
|
|
25
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
26
|
+
import { join, extname } from 'node:path'
|
|
27
|
+
import { fileURLToPath } from 'node:url'
|
|
28
|
+
|
|
29
|
+
const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url))
|
|
30
|
+
const SEARCH_ROOTS = ['telegram-plugin', 'src', 'bin', 'scripts', 'docs']
|
|
31
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'vendor', 'coverage'])
|
|
32
|
+
const SEARCH_EXTS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.md'])
|
|
33
|
+
|
|
34
|
+
function* walk(dir: string): Generator<string> {
|
|
35
|
+
for (const entry of readdirSync(dir)) {
|
|
36
|
+
if (SKIP_DIRS.has(entry)) continue
|
|
37
|
+
const full = join(dir, entry)
|
|
38
|
+
if (statSync(full).isDirectory()) yield* walk(full)
|
|
39
|
+
else if (SEARCH_EXTS.has(extname(entry))) yield full
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('#3863 — the dead createRobustApiCall factory', () => {
|
|
44
|
+
it('appears in no source, script or doc file except as backticked prose', () => {
|
|
45
|
+
const selfPath = fileURLToPath(import.meta.url)
|
|
46
|
+
const offenders: string[] = []
|
|
47
|
+
for (const root of SEARCH_ROOTS) {
|
|
48
|
+
for (const file of walk(join(REPO_ROOT, root))) {
|
|
49
|
+
if (file === selfPath) continue
|
|
50
|
+
// Drop the backticked (prose) occurrences; anything left is code.
|
|
51
|
+
const bare = readFileSync(file, 'utf8').replaceAll('`createRobustApiCall`', '')
|
|
52
|
+
if (bare.includes('createRobustApiCall')) offenders.push(file.slice(REPO_ROOT.length))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
expect(offenders).toEqual([])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('is not exported by shared/bot-runtime.ts', async () => {
|
|
59
|
+
const mod = (await import('../shared/bot-runtime.js')) as Record<string, unknown>
|
|
60
|
+
expect(Object.keys(mod)).not.toContain('createRobustApiCall')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
loadObligations,
|
|
4
4
|
persistObligations,
|
|
5
|
+
removeUnmaintainedSnapshot,
|
|
5
6
|
type ObligationStoreFsSeam,
|
|
6
7
|
} from "../gateway/obligation-store.js";
|
|
7
8
|
import type { Obligation } from "../gateway/obligation-ledger.js";
|
|
@@ -33,6 +34,10 @@ function memFs(seed: Record<string, string> = {}) {
|
|
|
33
34
|
fsyncDirSync: (p) => {
|
|
34
35
|
calls.push(`fsyncDir:${p}`);
|
|
35
36
|
},
|
|
37
|
+
unlinkSync: (p) => {
|
|
38
|
+
calls.push(`unlink:${p}`);
|
|
39
|
+
files.delete(p);
|
|
40
|
+
},
|
|
36
41
|
};
|
|
37
42
|
return { fs, files, calls };
|
|
38
43
|
}
|
|
@@ -240,4 +245,50 @@ describe("obligation-store — power-cut durability (fsync ordering)", () => {
|
|
|
240
245
|
expect(files.has(PATH)).toBe(true);
|
|
241
246
|
expect(loadObligations(PATH, fs)).toHaveLength(1);
|
|
242
247
|
});
|
|
248
|
+
|
|
249
|
+
// ── #4146: a snapshot nobody maintains must not read as "nobody waiting" ──
|
|
250
|
+
|
|
251
|
+
it("removeUnmaintainedSnapshot deletes a leftover snapshot and says why", () => {
|
|
252
|
+
// The exact hazard: the agent once ran with the ledger on, so an EMPTY
|
|
253
|
+
// open set is on disk. Persistence is now off, so that emptiness is a
|
|
254
|
+
// leftover — and the W1-d audience classifier would read it as positive
|
|
255
|
+
// proof that nobody is waiting, and suppress a message owed to a human.
|
|
256
|
+
const { fs, files } = memFs({ [PATH]: JSON.stringify({ v: 1, obligations: [] }) });
|
|
257
|
+
const logs: string[] = [];
|
|
258
|
+
expect(removeUnmaintainedSnapshot(PATH, fs, "static=true enabled=false", (l) => logs.push(l))).toBe(true);
|
|
259
|
+
expect(files.has(PATH)).toBe(false);
|
|
260
|
+
// ...and the classifier's precondition now genuinely holds: absent file.
|
|
261
|
+
expect(fs.existsSync(PATH)).toBe(false);
|
|
262
|
+
expect(logs.join("")).toContain("static=true enabled=false");
|
|
263
|
+
expect(logs.join("")).toContain("#4146");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("removeUnmaintainedSnapshot is a no-op when there is nothing to remove", () => {
|
|
267
|
+
const { fs } = memFs();
|
|
268
|
+
const logs: string[] = [];
|
|
269
|
+
expect(removeUnmaintainedSnapshot(PATH, fs, "reason", (l) => logs.push(l))).toBe(false);
|
|
270
|
+
expect(logs).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("removeUnmaintainedSnapshot never throws, and reports failure rather than claiming success", () => {
|
|
274
|
+
// A failure here must degrade to the reader-side ledger-off guard, not
|
|
275
|
+
// crash gateway boot and not be mistaken for a completed cleanup.
|
|
276
|
+
const { fs } = memFs({ [PATH]: "{}" });
|
|
277
|
+
const logs: string[] = [];
|
|
278
|
+
const failing: ObligationStoreFsSeam = {
|
|
279
|
+
...fs,
|
|
280
|
+
unlinkSync: () => {
|
|
281
|
+
throw new Error("EACCES: permission denied, unlink");
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
expect(removeUnmaintainedSnapshot(PATH, failing, "reason", (l) => logs.push(l))).toBe(false);
|
|
285
|
+
expect(logs.join("")).toContain("could not remove unmaintained snapshot");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("a seam with no unlinkSync degrades to no cleanup rather than throwing", () => {
|
|
289
|
+
const { fs, files } = memFs({ [PATH]: "{}" });
|
|
290
|
+
const { unlinkSync: _dropped, ...legacySeam } = fs;
|
|
291
|
+
expect(removeUnmaintainedSnapshot(PATH, legacySeam, "reason", () => {})).toBe(false);
|
|
292
|
+
expect(files.has(PATH)).toBe(true);
|
|
293
|
+
});
|
|
243
294
|
});
|