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.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +55 -0
- package/dist/auth-broker/index.js +116 -6
- package/dist/cli/notion-write-pretool.mjs +55 -0
- package/dist/cli/switchroom.js +2055 -1193
- package/dist/host-control/main.js +117 -7
- package/dist/vault/approvals/kernel-server.js +114 -6
- package/dist/vault/broker/server.js +114 -6
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +105 -0
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +58 -68
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- 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
|
-
|
|
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
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
1321
|
-
const N =
|
|
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
|
-
|
|
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
|
|
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
|
|
1471
|
-
//
|
|
1472
|
-
//
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
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(
|
|
1479
|
-
for (const l of ls
|
|
1480
|
-
expect(l.startsWith(
|
|
1481
|
-
expect(l.startsWith(
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
25
|
-
*
|
|
26
|
-
* `void reconcileStatusPin`), and
|
|
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:
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* This lets a test INSPECT THE FILE across
|
|
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,
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
314
|
-
|
|
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.
|
|
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.
|
|
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).
|