switchroom 0.19.0 → 0.19.2

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 (34) hide show
  1. package/dist/agent-scheduler/index.js +29 -1
  2. package/dist/auth-broker/index.js +552 -48
  3. package/dist/cli/autoaccept-poll.js +29 -1
  4. package/dist/cli/drive-write-pretool.mjs +30 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +30 -2
  6. package/dist/cli/switchroom.js +751 -36
  7. package/dist/host-control/main.js +3 -3
  8. package/dist/vault/approvals/kernel-server.js +2 -2
  9. package/dist/vault/broker/server.js +2 -2
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +1 -0
  12. package/skills/switchroom-cli/SKILL.md +25 -0
  13. package/telegram-plugin/auth-snapshot-format.ts +39 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +363 -25
  15. package/telegram-plugin/external-spend.ts +135 -0
  16. package/telegram-plugin/gateway/gateway.ts +83 -67
  17. package/telegram-plugin/gateway/model-command.ts +106 -0
  18. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  19. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  20. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  21. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  22. package/telegram-plugin/quota-bar-format.ts +18 -0
  23. package/telegram-plugin/quota-check.ts +17 -2
  24. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  25. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  26. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +57 -23
  27. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  28. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  29. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  30. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  31. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  32. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  33. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  34. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
@@ -7,10 +7,13 @@ import {
7
7
  pinnedMessageIsOurs,
8
8
  reconcileAndPersistStatusPin,
9
9
  runStatusPinBootCleanup,
10
+ withPinReconcileLock,
10
11
  type PersistedStatusPin,
11
12
  type StatusPinStoreFsSeam,
12
13
  type TrackedStatusPin,
13
14
  } from "../gateway/status-pin-store.js";
15
+ import { decidePinAction, type PinState, type DesiredPin } from "../status-pin.js";
16
+ import { reconcilePin, type PinBotApi } from "../status-pin-driver.js";
14
17
 
15
18
  /** In-memory fs seam with an atomic rename, so the store's tmp→rename
16
19
  * crash-safety contract is exercised without touching the real disk. */
@@ -718,4 +721,199 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
718
721
  expect(next).toBeNull();
719
722
  expect(loadStatusPins(PATH, fs)).toEqual([]);
720
723
  });
724
+
725
+ // F1 (invisible-worker-cards review): a `clear` op is emitted for BOTH a real
726
+ // unpin AND a `noop: already pinned`. For a noop, reconcilePin issues NO
727
+ // Telegram call and returns the LIVE claim (non-null). The pre-fix clear
728
+ // branch dropped the row unconditionally, so the worker feed's per-edit
729
+ // syncPin (every steady-state edit maps to a noop-clear) erased the durable
730
+ // row for a still-live pin — a crash after that left a stuck pinned card boot
731
+ // cleanup could never see. The row MUST be preserved when applyPin returns a
732
+ // live claim.
733
+ it("clear op with a LIVE claim (noop-already-pinned) PRESERVES the durable row", async () => {
734
+ const { fs, calls } = memFs();
735
+ persistStatusPins(PATH, fs, [pin({ pinKey: "wk:group:g1", messageId: 715 })]);
736
+ const before = calls.length;
737
+ // applyPin returns the live claim unchanged (what reconcilePin does on noop).
738
+ const next = await reconcileAndPersistStatusPin({
739
+ path: PATH,
740
+ fs,
741
+ pinKey: "wk:group:g1",
742
+ chatId: "-100123",
743
+ op: { kind: "clear" },
744
+ applyPin: async () => ({ messageId: 715 }),
745
+ log: () => {},
746
+ });
747
+ expect(next).toEqual({ messageId: 715 });
748
+ // Row survives (rewritten confirmed, no `pending`).
749
+ expect(loadStatusPins(PATH, fs)).toEqual([
750
+ { pinKey: "wk:group:g1", chatId: "-100123", messageId: 715 },
751
+ ]);
752
+ // Sanity: at least one disk write happened (the confirm rewrite) — the fix
753
+ // does not skip persistence, it changes the CONTENT from delete to upsert.
754
+ expect(calls.length).toBeGreaterThan(before);
755
+ });
756
+
757
+ it("repeated steady-state noop-clears keep the row present across many edits", async () => {
758
+ const { fs } = memFs();
759
+ persistStatusPins(PATH, fs, [pin({ pinKey: "wk:group:g1", messageId: 715 })]);
760
+ for (let i = 0; i < 5; i++) {
761
+ await reconcileAndPersistStatusPin({
762
+ path: PATH,
763
+ fs,
764
+ pinKey: "wk:group:g1",
765
+ chatId: "-100123",
766
+ op: { kind: "clear" },
767
+ applyPin: async () => ({ messageId: 715 }),
768
+ log: () => {},
769
+ });
770
+ }
771
+ expect(loadStatusPins(PATH, fs)).toEqual([
772
+ { pinKey: "wk:group:g1", chatId: "-100123", messageId: 715 },
773
+ ]);
774
+ });
775
+ });
776
+
777
+ /**
778
+ * F2 (invisible-worker-cards review): the gateway's status-pin reconcile reads
779
+ * its `prev` claim from an in-memory map at the top of the reconcile, then
780
+ * awaits. Two overlapping reconciles for ONE key with OPPOSITE desires could
781
+ * both capture a stale `prev` → a turn-end `{pinned:false}` clears the durable
782
+ * row while a flood-delayed open-pin lands, leaving a stuck pin with no row.
783
+ *
784
+ * These tests exercise the REAL fix primitives — `withPinReconcileLock` +
785
+ * `reconcileAndPersistStatusPin` + `reconcilePin` + `decidePinAction` — composed
786
+ * exactly as the gateway wires them (read prev INSIDE the per-key lock), and
787
+ * prove the row survives. The `noLock` control models the pre-fix ordering
788
+ * (prev read OUTSIDE the lock) and shows it regresses — so the lock, not luck,
789
+ * is what fixes it.
790
+ */
791
+ describe("status-pin-store — F2 per-key reconcile serialization", () => {
792
+ // A gateway-faithful reconcile: an in-memory claim map (the `prev` source read
793
+ // at the TOP of the reconcile), the real decide→op mapping, and
794
+ // reconcileAndPersistStatusPin driving reconcilePin against a call-counting
795
+ // pin API. An optional gate lets a test hold ONE reconcile's applyPin open to
796
+ // force the exact interleave. `serialize` toggles the F2 fix.
797
+ function makeReconciler(fs: StatusPinStoreFsSeam, opts: { serialize: boolean }) {
798
+ const claims = new Map<string, PinState>();
799
+ const pinCalls: number[] = [];
800
+ const unpinCalls: number[] = [];
801
+ const api: PinBotApi = {
802
+ pinChatMessage: async (_c, id) => {
803
+ pinCalls.push(id);
804
+ },
805
+ unpinChatMessage: async (_c, id) => {
806
+ unpinCalls.push(id);
807
+ },
808
+ };
809
+ async function core(
810
+ pinKey: string,
811
+ chatId: string,
812
+ desired: DesiredPin,
813
+ gate?: Promise<void>,
814
+ ) {
815
+ const prev = claims.get(pinKey) ?? null; // read BEFORE the store op (the F2 window)
816
+ const action = decidePinAction(prev, desired);
817
+ const op =
818
+ action.kind === "pin"
819
+ ? ({ kind: "pin", messageId: action.messageId } as const)
820
+ : ({ kind: "clear" } as const);
821
+ const next = await reconcileAndPersistStatusPin({
822
+ path: PATH,
823
+ fs,
824
+ pinKey,
825
+ chatId,
826
+ op,
827
+ applyPin: async () => {
828
+ if (gate) await gate; // hold the pin open inside the store lock
829
+ return reconcilePin({ api, chatId, prevState: prev, desired });
830
+ },
831
+ log: () => {},
832
+ });
833
+ if (next == null) claims.delete(pinKey);
834
+ else claims.set(pinKey, next);
835
+ }
836
+ function reconcile(
837
+ pinKey: string,
838
+ chatId: string,
839
+ desired: DesiredPin,
840
+ gate?: Promise<void>,
841
+ ) {
842
+ return opts.serialize
843
+ ? withPinReconcileLock(pinKey, () => core(pinKey, chatId, desired, gate))
844
+ : core(pinKey, chatId, desired, gate);
845
+ }
846
+ return { reconcile, claims, pinCalls, unpinCalls };
847
+ }
848
+
849
+ // The exact review sequence: a flood-delayed OPEN-pin (A) is in-flight inside
850
+ // the store lock; a turn-end UNPIN (B) starts during A's await window and
851
+ // reads prev=null (A has not set the claim yet). Pre-fix, B's clear then drops
852
+ // the disk row A wrote — stranding a live in-memory claim with NO durable row.
853
+ async function runStrandingSequence(serialize: boolean) {
854
+ const { fs } = memFs();
855
+ const r = makeReconciler(fs, { serialize });
856
+ let releaseA!: () => void;
857
+ const gateA = new Promise<void>((res) => {
858
+ releaseA = res;
859
+ });
860
+ // A: open-pin 900, held open at applyPin.
861
+ const aDone = r.reconcile("fg:c:3", "-100123", { pinned: true, messageId: 900 }, gateA);
862
+ // Let A reach its applyPin await (pending row on disk; claim NOT yet set).
863
+ await Promise.resolve();
864
+ await Promise.resolve();
865
+ // B: turn-end unpin, starts now — reads prev from the in-memory claim map.
866
+ const bDone = r.reconcile("fg:c:3", "-100123", { pinned: false });
867
+ await Promise.resolve();
868
+ releaseA();
869
+ await Promise.all([aDone, bDone]);
870
+ return { fs, claims: r.claims, unpinCalls: r.unpinCalls, pinCalls: r.pinCalls };
871
+ }
872
+
873
+ it("serialized: turn-end unpin waits for the in-flight open-pin, so the pinned message is genuinely UNPINNED (not stranded)", async () => {
874
+ const { fs, claims, pinCalls, unpinCalls } = await runStrandingSequence(true);
875
+ const rows = loadStatusPins(PATH, fs);
876
+ // Under serialization B runs only after A fully settles, so B reads
877
+ // prev={900}, issues a REAL unpin of the pinned message, and clears the row
878
+ // and claim together. The Telegram pin is cleaned up — nothing stuck.
879
+ expect(pinCalls).toEqual([900]); // A pinned it
880
+ expect(unpinCalls).toEqual([900]); // B unpinned it (the fix)
881
+ expect(rows).toEqual([]);
882
+ expect(claims.get("fg:c:3") ?? null).toBeNull();
883
+ });
884
+
885
+ it("pre-fix control (no per-key lock): the same interleave STRANDS the pinned message — pinned, row cleared, NEVER unpinned", async () => {
886
+ const { fs, unpinCalls, pinCalls } = await runStrandingSequence(false);
887
+ const rows = loadStatusPins(PATH, fs);
888
+ // Reproduces the F2 defect the lock removes: B read prev=null during A's
889
+ // await window, so its clear was a NO-OP unpin (issued no Telegram unpin)
890
+ // yet still dropped the durable row A wrote — while A's pin call landed. The
891
+ // message 900 stays pinned on Telegram with NO durable row and no unpin ever
892
+ // issued → stuck until a boot cleanup that can no longer see it.
893
+ expect(pinCalls).toEqual([900]); // message 900 WAS pinned
894
+ expect(unpinCalls).toEqual([]); // …but never unpinned (no-op clear on prev=null)
895
+ expect(rows).toEqual([]); // …and the durable row is gone → unreapable
896
+ });
897
+
898
+ it("serialized steady-state noop reconciles add ZERO pin/unpin API calls (finn-flood guard)", async () => {
899
+ const { fs } = memFs();
900
+ const r = makeReconciler(fs, { serialize: true });
901
+ // First open pins exactly once.
902
+ await r.reconcile("wk:group:g1", "-100123", { pinned: true, messageId: 900 });
903
+ expect(r.pinCalls).toEqual([900]);
904
+ expect(r.unpinCalls).toEqual([]);
905
+
906
+ // 20 steady-state edits — each a re-pin of the SAME id → decidePinAction
907
+ // noop → reconcilePin issues NO Telegram call. The serialization + F1
908
+ // row-preservation must not add a single pin/unpin API call.
909
+ for (let i = 0; i < 20; i++) {
910
+ await r.reconcile("wk:group:g1", "-100123", { pinned: true, messageId: 900 });
911
+ }
912
+ expect(r.pinCalls).toEqual([900]); // still exactly one pin, ever
913
+ expect(r.unpinCalls).toEqual([]); // never unpinned
914
+ // The durable row is intact throughout (F1).
915
+ expect(loadStatusPins(PATH, fs)).toEqual([
916
+ { pinKey: "wk:group:g1", chatId: "-100123", messageId: 900 },
917
+ ]);
918
+ });
721
919
  });
@@ -147,6 +147,56 @@ describe('subagent-tracker-pretool', () => {
147
147
  expect(row?.model ?? null).toBeNull()
148
148
  })
149
149
 
150
+ // F3 (progress-card fork model): a fork dispatch inherits the parent's model
151
+ // and IGNORES tool_input.model, so seeding the row's first-paint model from
152
+ // that ignored override made the worker card show a WRONG model (e.g. "sonnet"
153
+ // while the fork runs Opus) until the transcript overwrote it. The seed must
154
+ // be suppressed for forks — model stays NULL and the card omits it until the
155
+ // watcher records the real model from the fork's own transcript.
156
+ it('leaves model null for a FORK dispatch even when tool_input.model is set (F3)', () => {
157
+ const event = {
158
+ session_id: 'sess-fork',
159
+ tool_name: 'Agent',
160
+ tool_use_id: 'toolu_fork001',
161
+ tool_input: {
162
+ subagent_type: 'fork',
163
+ description: 'Fork the session',
164
+ run_in_background: true,
165
+ model: 'sonnet', // override a fork ignores — must NOT be persisted
166
+ },
167
+ }
168
+ const result = runHook(PRETOOL_SCRIPT, event)
169
+ expect(result.status).toBe(0)
170
+
171
+ const db = openDb()
172
+ const row = db.prepare('SELECT model FROM subagents WHERE id = ?').get('toolu_fork001') as
173
+ | { model: string | null }
174
+ | undefined
175
+ expect(row?.model ?? null).toBeNull()
176
+ })
177
+
178
+ it('still persists tool_input.model for a NON-fork dispatch (fork suppression is scoped)', () => {
179
+ const event = {
180
+ session_id: 'sess-nonfork',
181
+ tool_name: 'Agent',
182
+ tool_use_id: 'toolu_nonfork001',
183
+ tool_input: {
184
+ subagent_type: 'researcher',
185
+ description: 'Research with a pinned model',
186
+ run_in_background: true,
187
+ model: 'claude-opus-4-8',
188
+ },
189
+ }
190
+ const result = runHook(PRETOOL_SCRIPT, event)
191
+ expect(result.status).toBe(0)
192
+
193
+ const db = openDb()
194
+ const row = db.prepare('SELECT model FROM subagents WHERE id = ?').get('toolu_nonfork001') as
195
+ | { model: string | null }
196
+ | undefined
197
+ expect(row?.model).toBe('claude-opus-4-8')
198
+ })
199
+
150
200
  it('does not write a row when tool_name is not Agent', () => {
151
201
  const event = {
152
202
  session_id: 'sess-abc123',
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Adversarial-review F1 + F2 — the /usage card must NEVER stamp "Live" when
3
+ * no account row carries usable live data.
4
+ *
5
+ * Root cause (F1): the gateway `/usage` handler decided the freshness footer
6
+ * from `probeResp.results.length > 0`. But opProbeQuota
7
+ * (src/auth/broker/server.ts) returns `{result:{ok:false}, served:"live"}`
8
+ * for a failed live probe against an EMPTY cache — a non-empty results array
9
+ * where every row is `ok:false`. `zipProbeResults` only marks
10
+ * `served==="cache"` rows stale, so those failed rows carry no cache stamp
11
+ * either. Result: on a fresh restart (empty cache) + a transient probe
12
+ * failure, every row rendered "⚠️ no data — probe failed" UNDER a false
13
+ * "Live · refreshed 0s ago" stamp.
14
+ *
15
+ * F2 (test gap): the footer decision had zero coverage. This drives the
16
+ * extracted decision (`deriveUsageFooterFreshness`) composed with the real
17
+ * card renderer (`renderUsageCard`) with an all-`ok:false`, no-cache broker
18
+ * stub and asserts the rendered footer is NOT "Live" — i.e. it would fail
19
+ * against the pre-fix `.length > 0` gate.
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest'
23
+ import {
24
+ deriveUsageFooterFreshness,
25
+ type AccountSnapshot,
26
+ type ProbeQuotaResultRow,
27
+ } from '../auth-snapshot-format.js'
28
+ import { renderUsageCard } from '../quota-bar-format.js'
29
+
30
+ const NOW = new Date('2026-07-19T12:00:00Z')
31
+
32
+ // Two accounts, no usable quota (the probe failed, nothing cached) — mirrors
33
+ // what the gateway hands renderUsageCard when the broker cache is empty and
34
+ // the live probe threw for every account.
35
+ const NO_DATA_SNAPSHOTS: AccountSnapshot[] = [
36
+ { label: 'alice@example.com', isActive: true, quota: null, quotaError: 'probe failed' },
37
+ { label: 'bob@example.com', isActive: false, quota: null, quotaError: 'probe failed' },
38
+ ]
39
+
40
+ // A broker probe-quota response where every row failed and nothing was served
41
+ // from cache — the exact shape opProbeQuota returns for a failed live probe
42
+ // against an empty cache.
43
+ const ALL_FAILED_NO_CACHE: ProbeQuotaResultRow[] = [
44
+ { label: 'alice@example.com', result: { ok: false, reason: 'network error' }, served: 'live' },
45
+ { label: 'bob@example.com', result: { ok: false, reason: 'network error' }, served: 'live' },
46
+ ]
47
+
48
+ const EXHAUSTED = new Map<string, boolean>()
49
+
50
+ describe('deriveUsageFooterFreshness (F1)', () => {
51
+ it('flags probeFailed when every row is ok:false and there is no cache', () => {
52
+ const opts = deriveUsageFooterFreshness(ALL_FAILED_NO_CACHE, undefined, NOW.getTime())
53
+ // Would have been { liveProbedAtMs } under the old `.length > 0` gate.
54
+ expect(opts).toEqual({ probeFailed: true })
55
+ expect(opts.liveProbedAtMs).toBeUndefined()
56
+ })
57
+
58
+ it('stamps liveProbedAtMs when at least one row carries usable data', () => {
59
+ const rows: ProbeQuotaResultRow[] = [
60
+ ALL_FAILED_NO_CACHE[0],
61
+ {
62
+ label: 'bob@example.com',
63
+ result: {
64
+ ok: true,
65
+ data: {
66
+ fiveHourUtilizationPct: 12,
67
+ sevenDayUtilizationPct: 34,
68
+ fiveHourResetAt: null,
69
+ sevenDayResetAt: null,
70
+ representativeClaim: null,
71
+ overageStatus: null,
72
+ overageDisabledReason: null,
73
+ fiveHourUtilPresent: true,
74
+ sevenDayUtilPresent: true,
75
+ },
76
+ },
77
+ served: 'live',
78
+ },
79
+ ]
80
+ const opts = deriveUsageFooterFreshness(rows, undefined, NOW.getTime())
81
+ expect(opts).toEqual({ liveProbedAtMs: NOW.getTime() })
82
+ expect(opts.probeFailed).toBeUndefined()
83
+ })
84
+
85
+ it('gives cache-served data precedence over both live and failed', () => {
86
+ const capturedAt = NOW.getTime() - 60_000
87
+ expect(deriveUsageFooterFreshness(ALL_FAILED_NO_CACHE, capturedAt, NOW.getTime())).toEqual({
88
+ staleCachedAtMs: capturedAt,
89
+ })
90
+ })
91
+
92
+ it('flags probeFailed for a totally empty results array', () => {
93
+ expect(deriveUsageFooterFreshness([], undefined, NOW.getTime())).toEqual({ probeFailed: true })
94
+ })
95
+ })
96
+
97
+ describe('/usage card footer end-to-end (F2)', () => {
98
+ it('renders "probe failed", NOT "Live", when no row carries live data', () => {
99
+ const freshness = deriveUsageFooterFreshness(ALL_FAILED_NO_CACHE, undefined, NOW.getTime())
100
+ const card = renderUsageCard(NO_DATA_SNAPSHOTS, EXHAUSTED, { now: NOW, ...freshness })
101
+ expect(card).toContain('probe failed — no live data')
102
+ // The honesty invariant: no "Live" stamp when every row shows no data.
103
+ expect(card).not.toContain('Live')
104
+ // And the rows themselves are the no-data warning, not a fake 0% bar.
105
+ expect(card).toContain('no data — probe failed')
106
+ })
107
+
108
+ it('renders a "Live" footer when a row carries usable data', () => {
109
+ const liveSnapshots: AccountSnapshot[] = [
110
+ {
111
+ label: 'bob@example.com',
112
+ isActive: true,
113
+ quota: {
114
+ fiveHourUtilizationPct: 12,
115
+ sevenDayUtilizationPct: 34,
116
+ fiveHourResetAt: null,
117
+ sevenDayResetAt: null,
118
+ representativeClaim: null,
119
+ overageStatus: null,
120
+ overageDisabledReason: null,
121
+ fiveHourUtilPresent: true,
122
+ sevenDayUtilPresent: true,
123
+ },
124
+ },
125
+ ]
126
+ const freshness = deriveUsageFooterFreshness(
127
+ [
128
+ {
129
+ label: 'bob@example.com',
130
+ result: { ok: true, data: liveSnapshots[0].quota! },
131
+ served: 'live',
132
+ },
133
+ ],
134
+ undefined,
135
+ NOW.getTime(),
136
+ )
137
+ const card = renderUsageCard(liveSnapshots, EXHAUSTED, { now: NOW, ...freshness })
138
+ expect(card).toContain('Live · refreshed')
139
+ expect(card).not.toContain('probe failed')
140
+ })
141
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Adversarial-review F4 — `/usage` account-label masking policy.
3
+ *
4
+ * A group with an empty `allowFrom` authorizes every member, so the quota
5
+ * card would leak per-account email labels to a broadly shared group unless
6
+ * masked. `shouldMaskUsageLabels` (gateway/usage-mask.ts) is the pure
7
+ * decision the gateway feeds into the existing demo-mask machinery.
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest'
11
+ import { shouldMaskUsageLabels } from '../gateway/usage-mask.js'
12
+
13
+ describe('shouldMaskUsageLabels (F4)', () => {
14
+ it('never masks in a private (operator DM) chat', () => {
15
+ expect(shouldMaskUsageLabels('private', undefined)).toBe(false)
16
+ expect(shouldMaskUsageLabels('private', [])).toBe(false)
17
+ expect(shouldMaskUsageLabels('private', ['123'])).toBe(false)
18
+ })
19
+
20
+ it('masks in a group with an EMPTY allowFrom (open membership)', () => {
21
+ expect(shouldMaskUsageLabels('group', [])).toBe(true)
22
+ expect(shouldMaskUsageLabels('group', undefined)).toBe(true)
23
+ expect(shouldMaskUsageLabels('supergroup', [])).toBe(true)
24
+ })
25
+
26
+ it('does NOT mask in a group with a curated non-empty allowFrom', () => {
27
+ expect(shouldMaskUsageLabels('group', ['123'])).toBe(false)
28
+ expect(shouldMaskUsageLabels('supergroup', ['123', '456'])).toBe(false)
29
+ })
30
+
31
+ it('masks defensively for any other chat type reaching a quota render', () => {
32
+ expect(shouldMaskUsageLabels('channel', undefined)).toBe(true)
33
+ expect(shouldMaskUsageLabels(undefined, undefined)).toBe(true)
34
+ })
35
+ })
@@ -72,6 +72,33 @@ describe('resolveWorkerFeedDispatch (#2002 regression pin)', () => {
72
72
  expect(resolveWorkerFeedDispatch(null, 'sub-agent').feedModel).toBeNull()
73
73
  expect(resolveWorkerFeedDispatch(makeSub({ model: null }), 'sub-agent').feedModel).toBeNull()
74
74
  })
75
+
76
+ // F3 (progress-card fork model) — outcome coverage. The dispatch-time seed is
77
+ // suppressed for forks at the hook (subagent-tracker-pretool.mjs), so a fork
78
+ // row carries model=null and the card omits the model until the watcher writes
79
+ // the transcript model. resolveWorkerFeedDispatch simply surfaces the row's
80
+ // CURRENT model, so these assert the feed-level outcomes on top of that.
81
+ it('(b) a fork row carries no dispatch-time model → feedModel null (card omits it until transcript)', () => {
82
+ // What the hook now writes for a fork dispatch: agent_type 'fork', model null
83
+ // (the ignored override is not persisted).
84
+ const forkRow = makeSub({ background: true, agent_type: 'fork', model: null })
85
+ expect(resolveWorkerFeedDispatch(forkRow, 'sub-agent').feedModel).toBeNull()
86
+ })
87
+
88
+ it('(a) once the watcher overwrites the row model from the transcript, feedModel reflects the transcript model', () => {
89
+ // Transcript wins: the watcher (recordSubagentModel) overwrites the row's
90
+ // model column in place, so the resolved feedModel is the transcript value —
91
+ // even for a fork that started with model=null.
92
+ const afterTranscript = makeSub({ background: true, agent_type: 'fork', model: 'claude-opus-4-8' })
93
+ expect(resolveWorkerFeedDispatch(afterTranscript, 'sub-agent').feedModel).toBe('claude-opus-4-8')
94
+ })
95
+
96
+ it('(c) a mid-run model switch is reflected: feedModel tracks the row’s latest recorded model', () => {
97
+ // The watcher writes model-on-change; the row holds the LATEST model, so a
98
+ // later substantive tick resolves the switched-to model.
99
+ const switchedTo = makeSub({ background: true, model: 'sr-glm-5' })
100
+ expect(resolveWorkerFeedDispatch(switchedTo, 'sub-agent').feedModel).toBe('sr-glm-5')
101
+ })
75
102
  })
76
103
 
77
104
  describe('gateway onFinish — fix #1: resultText-driven handback fallback (model)', () => {
@@ -5,7 +5,12 @@ import {
5
5
  type BotApiForWorkerFeed,
6
6
  } from '../worker-activity-feed.js'
7
7
  import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
8
- import type { PinState, DesiredPin } from '../status-pin.js'
8
+ import { decidePinAction, type PinState, type DesiredPin } from '../status-pin.js'
9
+ import {
10
+ reconcileAndPersistStatusPin,
11
+ loadStatusPins,
12
+ type StatusPinStoreFsSeam,
13
+ } from '../gateway/status-pin-store.js'
9
14
 
10
15
  /**
11
16
  * Outcome tests for the invisible-worker-cards fix (2026-07-15).
@@ -119,6 +124,131 @@ async function flush(): Promise<void> {
119
124
  for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r))
120
125
  }
121
126
 
127
+ /** In-memory fs seam with atomic rename, mirroring status-pin-store.test.ts. */
128
+ function memFs(): { fs: StatusPinStoreFsSeam; files: Map<string, string> } {
129
+ const files = new Map<string, string>()
130
+ const fs: StatusPinStoreFsSeam = {
131
+ readFileSync: (p) => {
132
+ if (!files.has(p)) throw new Error(`ENOENT ${p}`)
133
+ return files.get(p)!
134
+ },
135
+ writeFileSync: (p, d) => {
136
+ files.set(p, d)
137
+ },
138
+ renameSync: (a, b) => {
139
+ if (!files.has(a)) throw new Error(`ENOENT ${a}`)
140
+ files.set(b, files.get(a)!)
141
+ files.delete(a)
142
+ },
143
+ existsSync: (p) => files.has(p),
144
+ }
145
+ return { fs, files }
146
+ }
147
+
148
+ /**
149
+ * 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).
154
+ */
155
+ function makePersistingPinHarness(path: string) {
156
+ const { fs } = memFs()
157
+ const claims = new Map<string, PinState>()
158
+ const pinCalls: number[] = []
159
+ const unpinCalls: number[] = []
160
+ const api: PinBotApi = {
161
+ pinChatMessage: async (_c, id) => {
162
+ pinCalls.push(id)
163
+ },
164
+ unpinChatMessage: async (_c, id) => {
165
+ unpinCalls.push(id)
166
+ },
167
+ }
168
+ 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,
177
+ pinKey: key,
178
+ chatId,
179
+ op,
180
+ applyPin: () => reconcilePin({ api, chatId, prevState: prev, desired }),
181
+ log: () => {},
182
+ })
183
+ if (next == null) claims.delete(key)
184
+ else claims.set(key, next)
185
+ }
186
+ const reconcilePinFn = (args: {
187
+ feedKey: string
188
+ chatId: string
189
+ threadId?: number
190
+ messageId: number | null
191
+ }): void => {
192
+ const key = `wk:group:${args.feedKey}`
193
+ if (args.messageId != null) void reconcile(key, args.chatId, { pinned: true, messageId: args.messageId })
194
+ else void reconcile(key, args.chatId, { pinned: false })
195
+ }
196
+ return {
197
+ reconcilePinFn,
198
+ fs,
199
+ pinCalls,
200
+ unpinCalls,
201
+ rows: () => loadStatusPins(path, fs),
202
+ }
203
+ }
204
+
205
+ describe('worker-feed pin persistence — durable status-pins.json survives steady-state edits (F1)', () => {
206
+ const PATH = '/state/agent/telegram/status-pins.json'
207
+
208
+ it('preserves the wk:group row across many steady-state edits (noop-clear must NOT delete it)', async () => {
209
+ const bot = makeFakeBot()
210
+ const pin = makePersistingPinHarness(PATH)
211
+ let clock = 0
212
+ const feed = createWorkerActivityFeed({
213
+ bot,
214
+ now: () => clock,
215
+ firstPaintMinMs: 0,
216
+ minEditIntervalMs: 0,
217
+ reconcilePin: pin.reconcilePinFn,
218
+ })
219
+
220
+ // First paint → message posted, pinned once, durable row written.
221
+ clock = 1000
222
+ await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
223
+ await flush()
224
+ const msgId = bot.sent[0].messageId
225
+ expect(pin.pinCalls).toEqual([msgId])
226
+ expect(pin.rows()).toHaveLength(1)
227
+ const pinKey = pin.rows()[0].pinKey
228
+ expect(pinKey).toMatch(/^wk:group:/)
229
+ expect(pin.rows()).toEqual([
230
+ { pinKey, chatId: 'chat', messageId: msgId },
231
+ ])
232
+
233
+ // Many steady-state edits — the feed calls syncPin on EVERY edit, each a
234
+ // re-pin of the SAME id → noop → a `clear` op with a LIVE claim. Pre-fix the
235
+ // clear branch deleted the durable row here; the fix preserves it.
236
+ for (let i = 2; i <= 8; i++) {
237
+ clock = i * 1000
238
+ await feed.update('w1', 'chat', view({ elapsedMs: i * 1000, toolCount: i }))
239
+ await flush()
240
+ }
241
+
242
+ // The durable row is STILL on disk (inspect the file), and no extra pin/
243
+ // unpin API call was issued across all those steady-state edits.
244
+ expect(pin.rows()).toEqual([
245
+ { pinKey, chatId: 'chat', messageId: msgId },
246
+ ])
247
+ expect(pin.pinCalls).toEqual([msgId]) // exactly one pin, ever
248
+ expect(pin.unpinCalls).toEqual([]) // never unpinned
249
+ })
250
+ })
251
+
122
252
  describe('worker-feed pin persistence — steady-state re-pin (invisible-worker-cards)', () => {
123
253
  it('re-pins on a steady-state edit when the claim was lost, and does NOT re-pin when already correct (no storm)', async () => {
124
254
  const bot = makeFakeBot()