switchroom 0.19.26 → 0.19.27

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.
Files changed (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Fence for the "test file runs in NEITHER CI runner" class (#3756).
3
+ *
4
+ * The repo has two runners wired independently — vitest (everything minus
5
+ * `vitest.config.ts`'s `exclude`) and `bun test` with an explicit target list
6
+ * in `telegram-plugin/scripts/bun-test-ci.sh`. A file excluded from vitest and
7
+ * never named in the bun list is executed by nothing, and nothing goes red.
8
+ * That is how `telegram-plugin/uat/feed-matcher.test.ts` — a file whose own
9
+ * docblock calls it "the CI-verifiable floor" for the worker-feed matcher, and
10
+ * which #3821 extended with `stripCardNesting` assertions — sat unexecuted.
11
+ *
12
+ * The regression assertions below read the REAL config off disk, so they fail
13
+ * on `origin/main` at f9314b89e and can only be made green by actually giving
14
+ * those files a runner.
15
+ */
16
+ import { describe, it, expect } from "vitest";
17
+ // @ts-expect-error — plain .mjs lint script, no type declarations by design.
18
+ import { findOrphanTestFiles, loadRepoInputs } from "../../scripts/check-test-runner-coverage.mjs";
19
+
20
+ type Inputs = {
21
+ files: string[];
22
+ excludeGlobs: string[];
23
+ bunTargets: string[];
24
+ allowGlobs: string[];
25
+ };
26
+
27
+ const findOrphans = findOrphanTestFiles as (i: Inputs) => string[];
28
+ const realInputs = loadRepoInputs as () => Inputs;
29
+
30
+ describe("findOrphanTestFiles (detection logic)", () => {
31
+ const base: Inputs = { files: [], excludeGlobs: [], bunTargets: [], allowGlobs: [] };
32
+
33
+ it("flags a vitest-excluded file that no bun target names", () => {
34
+ expect(
35
+ findOrphans({
36
+ ...base,
37
+ files: ["telegram-plugin/uat/feed-matcher.test.ts"],
38
+ excludeGlobs: ["**/telegram-plugin/uat/**"],
39
+ }),
40
+ ).toEqual(["telegram-plugin/uat/feed-matcher.test.ts"]);
41
+ });
42
+
43
+ it("does not flag a file vitest still runs", () => {
44
+ expect(
45
+ findOrphans({ ...base, files: ["telegram-plugin/tests/status-pin.test.ts"] }),
46
+ ).toEqual([]);
47
+ });
48
+
49
+ it("does not flag a vitest-excluded file the bun job names by path", () => {
50
+ expect(
51
+ findOrphans({
52
+ ...base,
53
+ files: ["telegram-plugin/uat/feed-matcher.test.ts"],
54
+ excludeGlobs: ["**/telegram-plugin/uat/**"],
55
+ bunTargets: ["uat/feed-matcher.test.ts"],
56
+ }),
57
+ ).toEqual([]);
58
+ });
59
+
60
+ it("does not flag a vitest-excluded file the bun job covers by directory", () => {
61
+ expect(
62
+ findOrphans({
63
+ ...base,
64
+ files: ["telegram-plugin/tests/history.test.ts"],
65
+ excludeGlobs: ["**/telegram-plugin/tests/history.test.ts"],
66
+ bunTargets: ["tests"],
67
+ }),
68
+ ).toEqual([]);
69
+ });
70
+
71
+ it("a bun target only covers files UNDER telegram-plugin/ (that is the job's cwd)", () => {
72
+ // The bun job runs `working-directory: telegram-plugin`, so a repo-root
73
+ // path can never be matched by its filters — the exact reason the whole
74
+ // src/vault bun suite is orphaned.
75
+ expect(
76
+ findOrphans({
77
+ ...base,
78
+ files: ["src/vault/broker/server.test.ts"],
79
+ excludeGlobs: ["**/src/vault/broker/server.test.ts"],
80
+ bunTargets: ["src/vault/broker/server.test.ts"],
81
+ }),
82
+ ).toEqual(["src/vault/broker/server.test.ts"]);
83
+ });
84
+
85
+ it("honours the allowlist", () => {
86
+ expect(
87
+ findOrphans({
88
+ ...base,
89
+ files: ["telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts"],
90
+ excludeGlobs: ["**/telegram-plugin/uat/**"],
91
+ allowGlobs: ["**/telegram-plugin/uat/scenarios/**"],
92
+ }),
93
+ ).toEqual([]);
94
+ });
95
+ });
96
+
97
+ describe("real repo configuration", () => {
98
+ it("has no un-allowlisted orphans", () => {
99
+ expect(findOrphans(realInputs())).toEqual([]);
100
+ });
101
+
102
+ // The specific regression. All five are hosted (no creds, no live Telegram)
103
+ // and were swallowed wholesale by vitest's `**/telegram-plugin/uat/**`
104
+ // exclude with nothing on the bun side naming them.
105
+ it.each([
106
+ "telegram-plugin/uat/feed-matcher.test.ts",
107
+ "telegram-plugin/uat/load-env.test.ts",
108
+ "telegram-plugin/uat/uat-driver.test.ts",
109
+ "telegram-plugin/uat/runners/scorer.test.ts",
110
+ "telegram-plugin/uat/runners/skill-coverage.test.ts",
111
+ ])("%s is executed by a CI runner", (file) => {
112
+ const inputs = realInputs();
113
+ expect(inputs.files).toContain(file);
114
+ // Not merely allowlisted away — genuinely covered by a runner.
115
+ expect(findOrphans({ ...inputs, allowGlobs: [] })).not.toContain(file);
116
+ });
117
+
118
+ it("no bun target is broad enough to drag in the live-Telegram scenarios", () => {
119
+ // The other half of the contract. `uat/scenarios/**` drives a real MTProto
120
+ // session; a bare `uat` filter in BUN_TEST_ARGS would pull every scenario
121
+ // into the ordinary bun-test job, where it would fail (or worse, message a
122
+ // real chat). Assert no scenario file is reachable from the bun list.
123
+ const inputs = realInputs();
124
+ const scenarios = inputs.files.filter((f) =>
125
+ f.startsWith("telegram-plugin/uat/scenarios/"),
126
+ );
127
+ expect(scenarios.length).toBeGreaterThan(50);
128
+ const reachable = scenarios.filter((f) =>
129
+ inputs.bunTargets.some((t) => f.slice("telegram-plugin/".length).includes(t)),
130
+ );
131
+ expect(reachable).toEqual([]);
132
+ });
133
+ });
@@ -76,7 +76,8 @@ describe('renderWorkerActivity', () => {
76
76
 
77
77
  it('renders the native header + running status + step feed', () => {
78
78
  const out = renderWorkerActivity(view())
79
- expect(out).toContain('└─ 🛠 **WORKER** · _research competitors_')
79
+ // #3842: flush at the left margin — no `└─ ` card-level prefix.
80
+ expect(out.startsWith('🛠 **WORKER** · _research competitors_')).toBe(true)
80
81
  // Unified header: running shows "<elapsed> · N tools" (no "running ·" word).
81
82
  expect(out).toContain('_10s · 3 tools_')
82
83
  expect(out).toContain('3 tools')
@@ -100,7 +101,8 @@ describe('renderWorkerActivity', () => {
100
101
 
101
102
  it('shows a "starting…" line when no step has run yet', () => {
102
103
  const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
103
- expect(out).toContain('└─ 🛠 **WORKER**')
104
+ // #3842: flush at the left margin — no `└─ ` card-level prefix.
105
+ expect(out.startsWith('🛠 **WORKER**')).toBe(true)
104
106
  expect(out).toContain('starting…')
105
107
  expect(out).not.toContain('→')
106
108
  })
@@ -121,7 +123,7 @@ describe('renderWorkerActivity', () => {
121
123
  const out = renderWorkerActivity(
122
124
  view({ state: 'done', toolCount: 5, latestSummary: 'PR #21 opened' }),
123
125
  )
124
- expect(out).toContain('└─ 🛠 **WORKER** · _research competitors_')
126
+ expect(out).toContain('🛠 **WORKER** · _research competitors_')
125
127
  // Unified done header: "done · N tools · <elapsed>".
126
128
  expect(out).toContain('_done · 5 tools · ')
127
129
  expect(out).toContain('─────')
@@ -227,7 +229,7 @@ describe('renderWorkerActivity', () => {
227
229
  narrativeLines: ['- ran the **full** suite', '`git push`'],
228
230
  }),
229
231
  )
230
- expect(running).toContain('└─ 🛠 **WORKER** · _Build the sync_')
232
+ expect(running).toContain('🛠 **WORKER** · _Build the sync_')
231
233
  expect(running).toContain('ran the full suite')
232
234
  expect(running).toContain('git push')
233
235
  // The CONTENT bold/code markers were stripped — no `**full**`, no backticks.
@@ -298,7 +300,7 @@ describe('createWorkerActivityFeed', () => {
298
300
  // #2669: the worker feed body is raw GFM markdown sent through the rich
299
301
  // path (the gateway wires sendMessage → sendRichMessage). No parse_mode.
300
302
  expect(bot.sent[0].opts?.parse_mode).toBeUndefined()
301
- expect(bot.sent[0].text).toContain('└─ 🛠 **WORKER**')
303
+ expect(bot.sent[0].text).toContain('🛠 **WORKER**')
302
304
  expect(feed.has('w1')).toBe(true)
303
305
  })
304
306
 
@@ -932,7 +934,7 @@ describe('createWorkerActivityFeed — heartbeat', () => {
932
934
  await drain()
933
935
  expect(bot.sent).toHaveLength(1)
934
936
  expect(feed.messageIdOf('w1')).toBe(1000)
935
- expect(bot.sent[0].text).toContain('└─ 🛠 **WORKER**')
937
+ expect(bot.sent[0].text).toContain('🛠 **WORKER**')
936
938
 
937
939
  // And it keeps updating: a later heartbeat edits the message with a
938
940
  // climbing `· Ns` suffix so the still-alive worker visibly advances.
@@ -1328,7 +1330,7 @@ describe('header row + rolling overflow survive in the unified worker render', (
1328
1330
  )
1329
1331
  const out = renderWorkerActivity(view({ narrativeLines, toolCount: 7 }))
1330
1332
 
1331
- expect(out).toContain('└─ 🛠 **WORKER**')
1333
+ expect(out).toContain('🛠 **WORKER**')
1332
1334
  // Unified running status line.
1333
1335
  expect(out).toContain('_10s · 7 tools_')
1334
1336
  expect(out).toContain('7 tools')
@@ -1344,7 +1346,7 @@ describe('header row + rolling overflow survive in the unified worker render', (
1344
1346
 
1345
1347
  expect(out.length).toBeLessThanOrEqual(4096)
1346
1348
  expect(out).toContain('earlier…')
1347
- expect(out).toContain('└─ 🛠 **WORKER**')
1349
+ expect(out).toContain('🛠 **WORKER**')
1348
1350
  expect(out).toContain('_10s · ')
1349
1351
  })
1350
1352
 
@@ -1359,7 +1361,7 @@ describe('header row + rolling overflow survive in the unified worker render', (
1359
1361
  expect(out.length).toBeLessThanOrEqual(4096)
1360
1362
  const hasBullet = out.includes('→') || out.includes('✓')
1361
1363
  expect(hasBullet).toBe(true)
1362
- expect(out).toContain('└─ 🛠 **WORKER**')
1364
+ expect(out).toContain('🛠 **WORKER**')
1363
1365
  expect(out).toContain('_10s · ')
1364
1366
  expect(isValidWorkerMarkdown(out)).toBe(true)
1365
1367
  })
@@ -1525,7 +1527,7 @@ describe('worker-feed send-gate shed contract', () => {
1525
1527
  await feed.update('w1', 'chat', view({ elapsedMs: clock }))
1526
1528
  expect(bot.sendCalls).toBe(1)
1527
1529
  expect(bot.sent).toHaveLength(1)
1528
- expect(bot.sent[0].text).toContain('└─ 🛠 **WORKER**')
1530
+ expect(bot.sent[0].text).toContain('🛠 **WORKER**')
1529
1531
  expect(feed.has('w1')).toBe(true)
1530
1532
  })
1531
1533
 
@@ -6,12 +6,7 @@ import {
6
6
  type WorkerActivityView,
7
7
  } from '../worker-activity-feed.js'
8
8
  import { renderCombinedWorkerFeed, combinedHistoryDepth } from '../tool-activity-summary.js'
9
- import {
10
- STATUS_CARD_CHAR_BUDGET,
11
- WORKER_STEP_INDENT,
12
- SUBORDINATE_HEADER_PREFIX,
13
- SUBORDINATE_LINE_INDENT,
14
- } from '../status-no-truncate.js'
9
+ import { STATUS_CARD_CHAR_BUDGET, WORKER_STEP_INDENT } from '../status-no-truncate.js'
15
10
  import { COLLAPSE_SAFE_SEPARATOR } from '../card-format.js'
16
11
  import { richMessage } from '../rich-send.js'
17
12
  import { parseRichEntities, validateRichMarkdown } from './rich-markdown-oracle.js'
@@ -1292,22 +1287,22 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
1292
1287
  // Load-bearing: the exact indent bytes. `WORKER_STEP_INDENT` is U+2800 ×3;
1293
1288
  // a plain-ASCII `' '` indent fails this assertion, so does the U+00A0 run
1294
1289
  // #3662 shipped, and so does flat output with no prefix at all.
1295
- // Since #3820 the whole card is nested one level under the 🤖 agent card,
1296
- // so a step sits at SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT while its
1297
- // worker header sits at SUBORDINATE_LINE_INDENT alone the RELATIVE
1298
- // one-level nesting this test guards is unchanged, just shifted right.
1290
+ // #3842 removed the whole-card nesting #3820 added, so a step sits at
1291
+ // EXACTLY one WORKER_STEP_INDENT not two and its worker header sits
1292
+ // flush at the left margin. The RELATIVE one-level nesting this test guards
1293
+ // is unchanged; the whole block shifted back left.
1299
1294
  for (const l of stepLines) {
1300
- expect(l.startsWith(SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT)).toBe(true)
1295
+ expect(l.startsWith(WORKER_STEP_INDENT)).toBe(true)
1296
+ expect(l.startsWith(WORKER_STEP_INDENT + WORKER_STEP_INDENT)).toBe(false)
1301
1297
  expect(l.startsWith(BLANK)).toBe(true)
1302
1298
  expect(l.startsWith(' ')).toBe(false)
1303
1299
  expect(l.startsWith(NBSP)).toBe(false)
1304
1300
  }
1305
1301
  // Workers stay one level ABOVE their steps so the nesting reads as nesting.
1306
1302
  for (const l of headerLines) {
1307
- expect(l.startsWith(SUBORDINATE_LINE_INDENT)).toBe(true)
1308
- expect(l.startsWith(SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT)).toBe(false)
1303
+ expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
1309
1304
  }
1310
- expect(lines[0].startsWith(`${SUBORDINATE_HEADER_PREFIX}🛠`)).toBe(true)
1305
+ expect(lines[0].startsWith('🛠')).toBe(true)
1311
1306
  })
1312
1307
 
1313
1308
  it('golden body — each worker header is immediately followed by ITS indented steps', () => {
@@ -1317,8 +1312,8 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
1317
1312
  // would still pass every one of them. This pins the full line ORDER, which
1318
1313
  // is the actual thing the fix is for: a step must sit under its own worker.
1319
1314
  const body = renderCombinedWorkerFeed(rowsFor(3), { maxRows: 8 })!
1320
- const I = SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT
1321
- const N = SUBORDINATE_LINE_INDENT
1315
+ const I = WORKER_STEP_INDENT
1316
+ const N = ''
1322
1317
  // `S` is the pinned-bar collapse separator (#3666): every line that is
1323
1318
  // followed by a hard break carries one trailing U+00A0 so the pinned-message
1324
1319
  // bar (which drops the newline and substitutes nothing) does not mash the
@@ -1327,7 +1322,7 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
1327
1322
  // `WORKER_STEP_INDENT`, which is what this golden is really pinning.
1328
1323
  const S = COLLAPSE_SAFE_SEPARATOR
1329
1324
  expect(linesOf(body)).toEqual([
1330
- `${SUBORDINATE_HEADER_PREFIX}🛠 **WORKERS** · _3 running · oldest 3m00s · 9 tools_${S}`,
1325
+ `🛠 **WORKERS** · _3 running · oldest 3m00s · 9 tools_${S}`,
1331
1326
  `${N}**1. worker task 1** _· 1m00s · 3 tools_${S}`,
1332
1327
  `${I}~~_✓ w1 step a_~~${S}`,
1333
1328
  `${I}**→ w1 step b**${S}`,
@@ -1455,7 +1450,7 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
1455
1450
  expect(ents.every((e) => !e.text.includes(NBSP))).toBe(true)
1456
1451
  })
1457
1452
 
1458
- it('the SINGLE-worker 🛠 card nests ONE level (card-level only, no per-worker indent)', () => {
1453
+ it('the SINGLE-worker 🛠 card is entirely FLUSH — no card indent, no step indent (#3842)', () => {
1459
1454
  const single = renderWorkerActivity({
1460
1455
  workerId: 'w1',
1461
1456
  description: 'lone worker',
@@ -1467,18 +1462,17 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
1467
1462
  })
1468
1463
  expect(single).toContain('~~_✓ step a_~~')
1469
1464
  expect(single).toContain('**→ step b**')
1470
- // The lone-worker card has no worker HEADER to nest steps under, so it must
1471
- // never carry the per-worker indent — but since #3820 the whole card nests
1472
- // ONE level under the 🤖 agent card, so its body lines sit at exactly
1473
- // SUBORDINATE_LINE_INDENT and never at two levels. Asserted per line on the
1474
- // leading edge rather than as "no U+00A0 anywhere in the card", because
1475
- // #3666 puts one TRAILING U+00A0 on every hard-broken line of every pinned
1476
- // card (this one included) as the collapse separator.
1465
+ // The lone-worker card has no worker HEADER to nest steps under, so there is
1466
+ // nothing for an indent to distinguish and #3842 removed the whole-card
1467
+ // nesting #3820 added, so EVERY line sits at the left margin. Asserted per
1468
+ // line on the leading edge rather than as "no U+2800 anywhere in the card",
1469
+ // because #3666 puts one TRAILING U+00A0 on every hard-broken line of every
1470
+ // pinned card (this one included) as the collapse separator.
1477
1471
  const ls = single.split('\n')
1478
- expect(ls[0].startsWith(SUBORDINATE_HEADER_PREFIX)).toBe(true)
1479
- for (const l of ls.slice(1)) {
1480
- expect(l.startsWith(SUBORDINATE_LINE_INDENT)).toBe(true)
1481
- expect(l.startsWith(SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT)).toBe(false)
1472
+ expect(ls[0].startsWith('🛠 **WORKER**')).toBe(true)
1473
+ for (const l of ls) {
1474
+ expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
1475
+ expect(l.startsWith('└─')).toBe(false)
1482
1476
  expect(l.startsWith(NBSP)).toBe(false)
1483
1477
  }
1484
1478
  })
@@ -4,13 +4,22 @@ import {
4
4
  type WorkerActivityView,
5
5
  type BotApiForWorkerFeed,
6
6
  } from '../worker-activity-feed.js'
7
- import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
8
- import { decidePinAction, type PinState, type DesiredPin } from '../status-pin.js'
7
+ import { executePinLeg, type PinBotApi } from '../status-pin-driver.js'
8
+ import type { PinState, DesiredPin } from '../status-pin.js'
9
9
  import {
10
- reconcileAndPersistStatusPin,
11
10
  loadStatusPins,
11
+ type PersistedStatusPin,
12
12
  type StatusPinStoreFsSeam,
13
13
  } from '../gateway/status-pin-store.js'
14
+ import {
15
+ runStatusPinReconcile,
16
+ type StatusPinClaim,
17
+ } from '../gateway/status-pin-retarget.js'
18
+
19
+ /** Drop the #3810 claim-age stamp so a row's identity can be compared literally. */
20
+ function idOnly(rows: PersistedStatusPin[]): Omit<PersistedStatusPin, 'pinnedAt'>[] {
21
+ return rows.map(({ pinnedAt: _age, ...rest }) => rest)
22
+ }
14
23
 
15
24
  /**
16
25
  * Outcome tests for the invisible-worker-cards fix (2026-07-15).
@@ -21,9 +30,10 @@ import {
21
30
  * the pin was lost out-of-band (its in-memory claim dropped), the reused message
22
31
  * kept being edited live but was never re-pinned → scroll-buried / invisible.
23
32
  *
24
- * These tests wire the feed's `reconcilePin` hook into the REAL status-pin
25
- * driver + a claim map exactly as the gateway does (`wk:group:<feedKey>` key,
26
- * `void reconcileStatusPin`), and assert the PIN-CALL OUTCOMES:
33
+ * These tests wire the feed's `reconcilePin` hook into the REAL unified reconcile
34
+ * path (`runStatusPinReconcile` over `executePinLeg`) + a claim map exactly as
35
+ * the gateway does (`wk:group:<feedKey>` key, `void reconcileStatusPin`), and
36
+ * assert the PIN-CALL OUTCOMES:
27
37
  * 1. a steady-state edit re-pins when the claim was lost — and does NOT re-pin
28
38
  * (no storm) when the claim is already correct.
29
39
  * 2. routing an unpin through the reconciler CLEARS the claim, so a later edit
@@ -68,13 +78,13 @@ function makeFakeBot(): FakeBot {
68
78
  }
69
79
 
70
80
  /**
71
- * Mirror the gateway's status-pin reconcile: a `Map<key,PinState>` claim store
72
- * driven through the real `reconcilePin` driver against a fake pin API that
73
- * records every pin/unpin call. This is the seam that turns a `reconcilePin`
74
- * hook call into an observable Telegram pin/unpin — proving OUTCOMES, not paths.
81
+ * Mirror the gateway's status-pin reconcile: ONE claim registry driven through
82
+ * the real orchestrator + driver against a fake pin API that records every
83
+ * pin/unpin call. This is the seam that turns a `reconcilePin` hook call into an
84
+ * observable Telegram pin/unpin — proving OUTCOMES, not paths.
75
85
  */
76
86
  function makePinHarness() {
77
- const state = new Map<string, PinState>()
87
+ const claims = new Map<string, StatusPinClaim>()
78
88
  const pinCalls: number[] = []
79
89
  const unpinCalls: number[] = []
80
90
  let lastKey: string | null = null
@@ -87,10 +97,16 @@ function makePinHarness() {
87
97
  },
88
98
  }
89
99
  async function reconcile(key: string, chatId: string, desired: DesiredPin): Promise<void> {
90
- const prev = state.get(key) ?? null
91
- const next = await reconcilePin({ api, chatId, prevState: prev, desired })
92
- if (next == null) state.delete(key)
93
- else state.set(key, next)
100
+ const claim = claims.get(key)
101
+ await runStatusPinReconcile({
102
+ pinKey: key,
103
+ chatId,
104
+ prev: claim == null ? null : { messageId: claim.messageId },
105
+ desired,
106
+ persist: null,
107
+ runPin: (action, from) => executePinLeg({ api, chatId, prevState: from, action }),
108
+ claims,
109
+ })
94
110
  }
95
111
  const reconcilePinFn = (args: {
96
112
  feedKey: string
@@ -104,7 +120,8 @@ function makePinHarness() {
104
120
  else void reconcile(key, args.chatId, { pinned: false })
105
121
  }
106
122
  return {
107
- state,
123
+ claims,
124
+ reconcile,
108
125
  pinCalls,
109
126
  unpinCalls,
110
127
  reconcilePinFn,
@@ -114,7 +131,7 @@ function makePinHarness() {
114
131
  },
115
132
  /** Directly drop the claim to simulate a pin lost out-of-band. */
116
133
  dropClaim(): void {
117
- state.clear()
134
+ claims.clear()
118
135
  },
119
136
  }
120
137
  }
@@ -147,14 +164,15 @@ function memFs(): { fs: StatusPinStoreFsSeam; files: Map<string, string> } {
147
164
 
148
165
  /**
149
166
  * A pin harness that routes the feed's `reconcilePin` hook through the REAL
150
- * persistence path (`reconcileAndPersistStatusPin`) against a memFs-backed
151
- * status-pins.json — exactly as the gateway wires it: read prev claim → decide
152
- * map to a persist op reconcileAndPersistStatusPin(applyPin=reconcilePin).
153
- * This lets a test INSPECT THE FILE across steady-state edits (F1).
167
+ * persistence path against a memFs-backed status-pins.json — exactly as the
168
+ * gateway wires it: read prev claim → runStatusPinReconcile (which decides ONCE,
169
+ * maps each leg to a persist op, and drives executePinLeg through
170
+ * reconcileAndPersistStatusPin). This lets a test INSPECT THE FILE across
171
+ * steady-state edits (F1).
154
172
  */
155
173
  function makePersistingPinHarness(path: string) {
156
174
  const { fs } = memFs()
157
- const claims = new Map<string, PinState>()
175
+ const claims = new Map<string, StatusPinClaim>()
158
176
  const pinCalls: number[] = []
159
177
  const unpinCalls: number[] = []
160
178
  const api: PinBotApi = {
@@ -166,22 +184,16 @@ function makePersistingPinHarness(path: string) {
166
184
  },
167
185
  }
168
186
  async function reconcile(key: string, chatId: string, desired: DesiredPin): Promise<void> {
169
- const prev = claims.get(key) ?? null
170
- const action = decidePinAction(prev, desired)
171
- const op = action.kind === 'pin'
172
- ? ({ kind: 'pin', messageId: action.messageId } as const)
173
- : ({ kind: 'clear' } as const)
174
- const next = await reconcileAndPersistStatusPin({
175
- path,
176
- fs,
187
+ const claim = claims.get(key)
188
+ await runStatusPinReconcile({
177
189
  pinKey: key,
178
190
  chatId,
179
- op,
180
- applyPin: () => reconcilePin({ api, chatId, prevState: prev, desired }),
181
- log: () => {},
191
+ prev: claim == null ? null : { messageId: claim.messageId },
192
+ desired,
193
+ persist: { path, fs },
194
+ runPin: (action, from) => executePinLeg({ api, chatId, prevState: from, action }),
195
+ claims,
182
196
  })
183
- if (next == null) claims.delete(key)
184
- else claims.set(key, next)
185
197
  }
186
198
  const reconcilePinFn = (args: {
187
199
  feedKey: string
@@ -226,7 +238,7 @@ describe('worker-feed pin persistence — durable status-pins.json survives stea
226
238
  expect(pin.rows()).toHaveLength(1)
227
239
  const pinKey = pin.rows()[0].pinKey
228
240
  expect(pinKey).toMatch(/^wk:group:/)
229
- expect(pin.rows()).toEqual([
241
+ expect(idOnly(pin.rows())).toEqual([
230
242
  { pinKey, chatId: 'chat', messageId: msgId },
231
243
  ])
232
244
 
@@ -241,7 +253,7 @@ describe('worker-feed pin persistence — durable status-pins.json survives stea
241
253
 
242
254
  // The durable row is STILL on disk (inspect the file), and no extra pin/
243
255
  // unpin API call was issued across all those steady-state edits.
244
- expect(pin.rows()).toEqual([
256
+ expect(idOnly(pin.rows())).toEqual([
245
257
  { pinKey, chatId: 'chat', messageId: msgId },
246
258
  ])
247
259
  expect(pin.pinCalls).toEqual([msgId]) // exactly one pin, ever
@@ -269,7 +281,7 @@ describe('worker-feed pin persistence — steady-state re-pin (invisible-worker-
269
281
  expect(bot.sent).toHaveLength(1)
270
282
  const msgId = bot.sent[0].messageId
271
283
  expect(pin.pinCalls).toEqual([msgId])
272
- expect(pin.state.get(pin.key())?.messageId).toBe(msgId)
284
+ expect(pin.claims.get(pin.key())?.messageId).toBe(msgId)
273
285
 
274
286
  // Steady-state edit while the pin is already correct → NO new pin (no storm).
275
287
  clock = 2000
@@ -284,7 +296,7 @@ describe('worker-feed pin persistence — steady-state re-pin (invisible-worker-
284
296
  await feed.update('w1', 'chat', view({ elapsedMs: 3000, toolCount: 9 }))
285
297
  await flush()
286
298
  expect(pin.pinCalls).toEqual([msgId, msgId]) // re-pinned the SAME live message
287
- expect(pin.state.get(pin.key())?.messageId).toBe(msgId)
299
+ expect(pin.claims.get(pin.key())?.messageId).toBe(msgId)
288
300
  })
289
301
  })
290
302
 
@@ -306,27 +318,12 @@ describe('worker-feed pin persistence — unpin clears the claim', () => {
306
318
  await flush()
307
319
  const msgId = bot.sent[0].messageId
308
320
  const key = pin.key()
309
- expect(pin.state.get(key)?.messageId).toBe(msgId)
321
+ expect(pin.claims.get(key)?.messageId).toBe(msgId)
310
322
 
311
323
  // Route an unpin of the worker message through the reconciler (the sanctioned
312
324
  // path): the claim is dropped AND the Telegram unpin is issued.
313
- await (async () => {
314
- const prev = pin.state.get(key) ?? null
315
- const next = await reconcilePin({
316
- api: {
317
- pinChatMessage: async () => {},
318
- unpinChatMessage: async (_c, id) => {
319
- pin.unpinCalls.push(id)
320
- },
321
- },
322
- chatId: 'chat',
323
- prevState: prev,
324
- desired: { pinned: false },
325
- })
326
- if (next == null) pin.state.delete(key)
327
- else pin.state.set(key, next)
328
- })()
329
- expect(pin.state.has(key)).toBe(false) // claim cleared
325
+ await pin.reconcile(key, 'chat', { pinned: false })
326
+ expect(pin.claims.has(key)).toBe(false) // claim cleared
330
327
  expect(pin.unpinCalls).toContain(msgId)
331
328
 
332
329
  // A later steady edit now re-pins (the claim being clear is what lets
@@ -335,7 +332,7 @@ describe('worker-feed pin persistence — unpin clears the claim', () => {
335
332
  await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 7 }))
336
333
  await flush()
337
334
  expect(pin.pinCalls).toContain(msgId)
338
- expect(pin.state.get(key)?.messageId).toBe(msgId)
335
+ expect(pin.claims.get(key)?.messageId).toBe(msgId)
339
336
  })
340
337
  })
341
338
 
@@ -378,7 +375,7 @@ describe('worker-feed pin persistence — group-message lifetime cap rotation',
378
375
  // re-established on the new id (deterministic, no burst: one unpin + one pin).
379
376
  expect(pin.unpinCalls).toContain(msgA)
380
377
  expect(pin.pinCalls).toContain(msgB)
381
- expect(pin.state.get(pin.key())?.messageId).toBe(msgB)
378
+ expect(pin.claims.get(pin.key())?.messageId).toBe(msgB)
382
379
 
383
380
  // FIX 1: the retired message was collapsed to the honest "moved" note with
384
381
  // exactly ONE edit (not left frozen showing live rows, not re-edited per tick).