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.
- package/dist/agent-scheduler/index.js +29 -1
- package/dist/auth-broker/index.js +552 -48
- package/dist/cli/autoaccept-poll.js +29 -1
- package/dist/cli/drive-write-pretool.mjs +30 -2
- package/dist/cli/ms-365-write-pretool.mjs +30 -2
- package/dist/cli/switchroom.js +751 -36
- package/dist/host-control/main.js +3 -3
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +1 -0
- package/skills/switchroom-cli/SKILL.md +25 -0
- package/telegram-plugin/auth-snapshot-format.ts +39 -0
- package/telegram-plugin/dist/gateway/gateway.js +363 -25
- package/telegram-plugin/external-spend.ts +135 -0
- package/telegram-plugin/gateway/gateway.ts +83 -67
- package/telegram-plugin/gateway/model-command.ts +106 -0
- package/telegram-plugin/gateway/narrative-lane.ts +23 -9
- package/telegram-plugin/gateway/status-pin-store.ts +64 -4
- package/telegram-plugin/gateway/usage-mask.ts +29 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
- package/telegram-plugin/quota-bar-format.ts +18 -0
- package/telegram-plugin/quota-check.ts +17 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
- package/telegram-plugin/tests/external-spend.test.ts +168 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +57 -23
- package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
- package/telegram-plugin/tests/quota-check.test.ts +57 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
- package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
- package/telegram-plugin/tests/usage-mask.test.ts +35 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
- 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
|
|
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()
|