switchroom 0.19.25 → 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 +61 -2
- package/dist/auth-broker/index.js +125 -8
- package/dist/cli/notion-write-pretool.mjs +61 -2
- package/dist/cli/switchroom.js +2347 -1104
- package/dist/host-control/main.js +126 -9
- package/dist/vault/approvals/kernel-server.js +124 -8
- package/dist/vault/broker/server.js +124 -8
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +14 -0
- package/profiles/_base/start.sh.hbs +145 -4
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
- 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 +103 -112
- 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/periodic-sweep-guard.ts +86 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
- 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 +11 -0
- package/telegram-plugin/status-pin-driver.ts +33 -17
- package/telegram-plugin/status-pin.ts +51 -5
- 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 +291 -0
- 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/periodic-sweep-guard.test.ts +151 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
- 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 +244 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
- package/telegram-plugin/tests/status-pin.test.ts +184 -7
- 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 +29 -19
- 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/tier-downgrade.ts +3 -2
- package/telegram-plugin/tool-activity-summary.ts +239 -322
- package/telegram-plugin/uat/assertions.ts +33 -3
- package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
- package/telegram-plugin/worker-activity-feed.ts +109 -30
- 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/scripts/tests/test_recall_request_timeout.py +241 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -2,8 +2,59 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
import { GrammyError } from 'grammy'
|
|
3
3
|
import { decidePinAction, isPinRightsError, isUnpinTerminalError, PinRightsCache } from '../status-pin.js'
|
|
4
4
|
import { makeFloodWaitActiveError, GIVE_UP_MESSAGE } from '../retry-api-call.js'
|
|
5
|
-
import {
|
|
6
|
-
import type { PinState } from '../status-pin.js'
|
|
5
|
+
import { executePinLeg, type PinBotApi } from '../status-pin-driver.js'
|
|
6
|
+
import type { DesiredPin, PinState, PinRightsCache as _RightsCache } from '../status-pin.js'
|
|
7
|
+
import {
|
|
8
|
+
runStatusPinReconcile,
|
|
9
|
+
type StatusPinClaim,
|
|
10
|
+
} from '../gateway/status-pin-retarget.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Drive a whole desired-state reconcile through the REAL unified path — the
|
|
14
|
+
* orchestrator (`runStatusPinReconcile`, which owns the single retarget
|
|
15
|
+
* expansion) composed with the driver (`executePinLeg`), exactly as gateway.ts
|
|
16
|
+
* wires them. `persist: null` mirrors the STATIC / feature-off binding.
|
|
17
|
+
*
|
|
18
|
+
* These cases used to call `reconcilePin(prev, desired)` directly, back when the
|
|
19
|
+
* driver re-decided the action itself and carried its OWN copy of the retarget
|
|
20
|
+
* expansion (#3831). The driver can no longer express a retarget, so exercising
|
|
21
|
+
* the same behaviours now means exercising the composition — which is what
|
|
22
|
+
* production actually runs.
|
|
23
|
+
*/
|
|
24
|
+
async function reconcilePin(args: {
|
|
25
|
+
api: PinBotApi
|
|
26
|
+
chatId: string
|
|
27
|
+
prevState: PinState | null
|
|
28
|
+
desired: DesiredPin
|
|
29
|
+
rightsCache?: _RightsCache
|
|
30
|
+
onError?: (phase: 'pin' | 'unpin', err: unknown) => void
|
|
31
|
+
onPinRightsDisabled?: (chatId: string) => void
|
|
32
|
+
}): Promise<PinState | null> {
|
|
33
|
+
const claims = new Map<string, StatusPinClaim>()
|
|
34
|
+
if (args.prevState != null) {
|
|
35
|
+
claims.set('k', { messageId: args.prevState.messageId, chatId: args.chatId, pinnedAt: 1 })
|
|
36
|
+
}
|
|
37
|
+
await runStatusPinReconcile({
|
|
38
|
+
pinKey: 'k',
|
|
39
|
+
chatId: args.chatId,
|
|
40
|
+
prev: args.prevState,
|
|
41
|
+
desired: args.desired,
|
|
42
|
+
persist: null,
|
|
43
|
+
runPin: (action, from) =>
|
|
44
|
+
executePinLeg({
|
|
45
|
+
api: args.api,
|
|
46
|
+
chatId: args.chatId,
|
|
47
|
+
prevState: from,
|
|
48
|
+
action,
|
|
49
|
+
rightsCache: args.rightsCache,
|
|
50
|
+
onError: args.onError,
|
|
51
|
+
onPinRightsDisabled: args.onPinRightsDisabled,
|
|
52
|
+
}),
|
|
53
|
+
claims,
|
|
54
|
+
})
|
|
55
|
+
const claim = claims.get('k')
|
|
56
|
+
return claim == null ? null : { messageId: claim.messageId }
|
|
57
|
+
}
|
|
7
58
|
|
|
8
59
|
/** A real GrammyError with the given code + description, mirroring the wire
|
|
9
60
|
* shape Telegram returns for the pin-rights failure that crashed marko. */
|
|
@@ -37,9 +88,14 @@ describe('decidePinAction (pure)', () => {
|
|
|
37
88
|
expect(action.kind).toBe('noop')
|
|
38
89
|
})
|
|
39
90
|
|
|
40
|
-
it('
|
|
91
|
+
it('RETARGETS (unpin old + pin new) when the wanted message id changed (surface re-posted)', () => {
|
|
92
|
+
// Regression: this used to report a bare `unpin`, on the assumption that a
|
|
93
|
+
// later reconcile would pin the new message. Single-shot callers (the
|
|
94
|
+
// foreground activity card, which reconciles only when a card OPENs) have
|
|
95
|
+
// no later reconcile, so the new card was left unpinned for the rest of
|
|
96
|
+
// the turn. The decision must carry BOTH message ids.
|
|
41
97
|
const action = decidePinAction({ messageId: 42 }, { pinned: true, messageId: 99 })
|
|
42
|
-
expect(action).toEqual({ kind: '
|
|
98
|
+
expect(action).toEqual({ kind: 'repin', unpinMessageId: 42, pinMessageId: 99 })
|
|
43
99
|
})
|
|
44
100
|
})
|
|
45
101
|
|
|
@@ -59,7 +115,7 @@ function fakeApi(opts: { pinThrows?: boolean; unpinThrows?: boolean } = {}) {
|
|
|
59
115
|
return { api, calls }
|
|
60
116
|
}
|
|
61
117
|
|
|
62
|
-
describe('
|
|
118
|
+
describe('unified reconcile path (orchestrator + driver)', () => {
|
|
63
119
|
it('pin on in-progress: pins the EXISTING message SILENTLY and claims it', async () => {
|
|
64
120
|
const { api, calls } = fakeApi()
|
|
65
121
|
const next = await reconcilePin({
|
|
@@ -177,6 +233,127 @@ describe('reconcilePin (driver)', () => {
|
|
|
177
233
|
expect(attempts).toBe(2)
|
|
178
234
|
})
|
|
179
235
|
|
|
236
|
+
// ── Retarget (`repin`) — the single-shot-caller leak ──────────────────────
|
|
237
|
+
// The foreground activity card reconciles its `fg:` pin EXACTLY ONCE, when a
|
|
238
|
+
// card OPENs (narrative-lane.ts; the edit branch never touches the pin). When
|
|
239
|
+
// the ack-first reopen path (feed-reopen-gate.ts) nulls `activityMessageId`
|
|
240
|
+
// and a FRESH card opens mid-turn, that single reconcile must leave the NEW
|
|
241
|
+
// card pinned. Previously it emitted only an unpin and the turn ran on with
|
|
242
|
+
// nothing pinned.
|
|
243
|
+
it('RETARGET: one reconcile unpins the stale message AND pins the new one', async () => {
|
|
244
|
+
const { api, calls } = fakeApi()
|
|
245
|
+
const next = await reconcilePin({
|
|
246
|
+
api,
|
|
247
|
+
chatId: '123',
|
|
248
|
+
prevState: { messageId: 42 },
|
|
249
|
+
desired: { pinned: true, messageId: 99 },
|
|
250
|
+
})
|
|
251
|
+
expect(next).toEqual({ messageId: 99 })
|
|
252
|
+
expect(calls.map((c) => [c.verb, c.messageId])).toEqual([
|
|
253
|
+
['unpin', 42],
|
|
254
|
+
['pin', 99],
|
|
255
|
+
])
|
|
256
|
+
// The new pin must still be silent.
|
|
257
|
+
expect((calls[1].opts as Record<string, unknown>).disable_notification).toBe(true)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('RETARGET: a TERMINAL unpin failure still pins the new message', async () => {
|
|
261
|
+
// "message to unpin not found" means the old surface is already gone — the
|
|
262
|
+
// retarget must complete, not abort.
|
|
263
|
+
const calls: { verb: string; messageId: number }[] = []
|
|
264
|
+
const next = await reconcilePin({
|
|
265
|
+
api: {
|
|
266
|
+
pinChatMessage: async (_c, message_id) => {
|
|
267
|
+
calls.push({ verb: 'pin', messageId: message_id })
|
|
268
|
+
},
|
|
269
|
+
unpinChatMessage: async (_c, message_id) => {
|
|
270
|
+
calls.push({ verb: 'unpin', messageId: message_id })
|
|
271
|
+
throw grammyError(400, 'Bad Request: message to unpin not found')
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
chatId: '123',
|
|
275
|
+
prevState: { messageId: 42 },
|
|
276
|
+
desired: { pinned: true, messageId: 99 },
|
|
277
|
+
})
|
|
278
|
+
expect(next).toEqual({ messageId: 99 })
|
|
279
|
+
expect(calls).toEqual([
|
|
280
|
+
{ verb: 'unpin', messageId: 42 },
|
|
281
|
+
{ verb: 'pin', messageId: 99 },
|
|
282
|
+
])
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('RETARGET: a NEVER-CONFIRMED unpin retains the OLD claim and does NOT pin the new one', async () => {
|
|
286
|
+
// The old message is provably still pinned (#3664 Defect B). Pinning the
|
|
287
|
+
// new one now would leave two pins in the chat with a durable record of
|
|
288
|
+
// only one — the exact orphan class the retain rule exists to prevent.
|
|
289
|
+
const calls: { verb: string; messageId: number }[] = []
|
|
290
|
+
const next = await reconcilePin({
|
|
291
|
+
api: {
|
|
292
|
+
pinChatMessage: async (_c, message_id) => {
|
|
293
|
+
calls.push({ verb: 'pin', messageId: message_id })
|
|
294
|
+
},
|
|
295
|
+
unpinChatMessage: async (_c, message_id) => {
|
|
296
|
+
calls.push({ verb: 'unpin', messageId: message_id })
|
|
297
|
+
throw makeFloodWaitActiveError(300, Date.now() + 300_000, null)
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
chatId: '123',
|
|
301
|
+
prevState: { messageId: 42 },
|
|
302
|
+
desired: { pinned: true, messageId: 99 },
|
|
303
|
+
})
|
|
304
|
+
expect(next).toEqual({ messageId: 42 })
|
|
305
|
+
expect(calls).toEqual([{ verb: 'unpin', messageId: 42 }])
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('RETARGET: a failed pin leg drops the claim (nothing is pinned, nothing is tracked)', async () => {
|
|
309
|
+
const calls: { verb: string; messageId: number }[] = []
|
|
310
|
+
const next = await reconcilePin({
|
|
311
|
+
api: {
|
|
312
|
+
pinChatMessage: async (_c, message_id) => {
|
|
313
|
+
calls.push({ verb: 'pin', messageId: message_id })
|
|
314
|
+
throw new Error('fetch failed')
|
|
315
|
+
},
|
|
316
|
+
unpinChatMessage: async (_c, message_id) => {
|
|
317
|
+
calls.push({ verb: 'unpin', messageId: message_id })
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
chatId: '123',
|
|
321
|
+
prevState: { messageId: 42 },
|
|
322
|
+
desired: { pinned: true, messageId: 99 },
|
|
323
|
+
})
|
|
324
|
+
// prevState for the pin leg is null (the unpin landed), so a failed pin
|
|
325
|
+
// returns null — no phantom claim on a message we never pinned.
|
|
326
|
+
expect(next).toBeNull()
|
|
327
|
+
expect(calls).toEqual([
|
|
328
|
+
{ verb: 'unpin', messageId: 42 },
|
|
329
|
+
{ verb: 'pin', messageId: 99 },
|
|
330
|
+
])
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('RETARGET: a pin-rights-blocked chat makes NO API calls and drops the claim', async () => {
|
|
334
|
+
const calls: string[] = []
|
|
335
|
+
const cache = new PinRightsCache()
|
|
336
|
+
cache.block('123')
|
|
337
|
+
const next = await reconcilePin({
|
|
338
|
+
api: {
|
|
339
|
+
pinChatMessage: async () => {
|
|
340
|
+
calls.push('pin')
|
|
341
|
+
},
|
|
342
|
+
unpinChatMessage: async () => {
|
|
343
|
+
calls.push('unpin')
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
chatId: '123',
|
|
347
|
+
prevState: { messageId: 42 },
|
|
348
|
+
desired: { pinned: true, messageId: 99 },
|
|
349
|
+
rightsCache: cache,
|
|
350
|
+
})
|
|
351
|
+
expect(calls).toEqual([])
|
|
352
|
+
// unpin leg returns null (claim dropped, nothing worth tracking); the pin
|
|
353
|
+
// leg is skipped from a null prev and returns null too.
|
|
354
|
+
expect(next).toBeNull()
|
|
355
|
+
})
|
|
356
|
+
|
|
180
357
|
it('does NOT claim a message whose pin failed (retries next reconcile)', async () => {
|
|
181
358
|
const errors: string[] = []
|
|
182
359
|
const { api } = fakeApi({ pinThrows: true })
|
|
@@ -207,7 +384,7 @@ describe('reconcilePin (driver)', () => {
|
|
|
207
384
|
// the whole gateway down. Auto status-pin is best-effort/cosmetic — a
|
|
208
385
|
// pin-rights failure must be absorbed, never fatal, and never reach the
|
|
209
386
|
// process-level unhandledRejection handler.
|
|
210
|
-
describe('
|
|
387
|
+
describe('unified reconcile path — pin-rights 400 must never crash (marko 2026-07-01)', () => {
|
|
211
388
|
it('absorbs the "not enough rights" 400 via onError and never rejects', async () => {
|
|
212
389
|
const rightsErr = grammyError(
|
|
213
390
|
400,
|
|
@@ -342,7 +519,7 @@ function countingApi(pinError?: unknown) {
|
|
|
342
519
|
}
|
|
343
520
|
}
|
|
344
521
|
|
|
345
|
-
describe('
|
|
522
|
+
describe('unified reconcile path — rights-aware negative cache (#3024)', () => {
|
|
346
523
|
const rightsErr = () =>
|
|
347
524
|
grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat')
|
|
348
525
|
|
|
@@ -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
|
|
|
@@ -661,7 +661,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
|
|
|
661
661
|
|
|
662
662
|
it('renders one row-block per worker with a running count header', () => {
|
|
663
663
|
const body = renderCombinedWorkerFeed([row(1, 'alpha step'), row(2, 'beta step')], { maxRows: 8 })!
|
|
664
|
-
expect(body).toContain('
|
|
664
|
+
expect(body).toContain('WORKERS')
|
|
665
665
|
expect(body).toContain('2 running')
|
|
666
666
|
expect(body).toContain('task number 1')
|
|
667
667
|
expect(body).toContain('task number 2')
|
|
@@ -756,7 +756,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
|
|
|
756
756
|
.map((l) => l.trim())
|
|
757
757
|
.filter((l) => l.length > 0)
|
|
758
758
|
const headerAndHistory = bodyLines.filter(
|
|
759
|
-
(l) => !l.
|
|
759
|
+
(l) => !l.includes('**WORKERS**') && !l.includes('more working'),
|
|
760
760
|
)
|
|
761
761
|
expect(headerAndHistory.length).toBeLessThanOrEqual(24)
|
|
762
762
|
})
|
|
@@ -775,7 +775,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
|
|
|
775
775
|
.map((l) => l.trim())
|
|
776
776
|
.filter((l) => l.length > 0)
|
|
777
777
|
const headerAndHistory = bodyLines.filter(
|
|
778
|
-
(l) => !l.
|
|
778
|
+
(l) => !l.includes('**WORKERS**') && !l.includes('more working'),
|
|
779
779
|
)
|
|
780
780
|
expect(headerAndHistory.length).toBeLessThanOrEqual(24)
|
|
781
781
|
expect(body).toContain('+2 more working')
|
|
@@ -1287,14 +1287,21 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
|
|
|
1287
1287
|
// Load-bearing: the exact indent bytes. `WORKER_STEP_INDENT` is U+2800 ×3;
|
|
1288
1288
|
// a plain-ASCII `' '` indent fails this assertion, so does the U+00A0 run
|
|
1289
1289
|
// #3662 shipped, and so does flat output with no prefix at all.
|
|
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.
|
|
1290
1294
|
for (const l of stepLines) {
|
|
1291
1295
|
expect(l.startsWith(WORKER_STEP_INDENT)).toBe(true)
|
|
1296
|
+
expect(l.startsWith(WORKER_STEP_INDENT + WORKER_STEP_INDENT)).toBe(false)
|
|
1292
1297
|
expect(l.startsWith(BLANK)).toBe(true)
|
|
1293
1298
|
expect(l.startsWith(' ')).toBe(false)
|
|
1294
1299
|
expect(l.startsWith(NBSP)).toBe(false)
|
|
1295
1300
|
}
|
|
1296
|
-
// Workers stay
|
|
1297
|
-
for (const l of headerLines)
|
|
1301
|
+
// Workers stay one level ABOVE their steps so the nesting reads as nesting.
|
|
1302
|
+
for (const l of headerLines) {
|
|
1303
|
+
expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
|
|
1304
|
+
}
|
|
1298
1305
|
expect(lines[0].startsWith('🛠')).toBe(true)
|
|
1299
1306
|
})
|
|
1300
1307
|
|
|
@@ -1306,6 +1313,7 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
|
|
|
1306
1313
|
// is the actual thing the fix is for: a step must sit under its own worker.
|
|
1307
1314
|
const body = renderCombinedWorkerFeed(rowsFor(3), { maxRows: 8 })!
|
|
1308
1315
|
const I = WORKER_STEP_INDENT
|
|
1316
|
+
const N = ''
|
|
1309
1317
|
// `S` is the pinned-bar collapse separator (#3666): every line that is
|
|
1310
1318
|
// followed by a hard break carries one trailing U+00A0 so the pinned-message
|
|
1311
1319
|
// bar (which drops the newline and substitutes nothing) does not mash the
|
|
@@ -1314,14 +1322,14 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
|
|
|
1314
1322
|
// `WORKER_STEP_INDENT`, which is what this golden is really pinning.
|
|
1315
1323
|
const S = COLLAPSE_SAFE_SEPARATOR
|
|
1316
1324
|
expect(linesOf(body)).toEqual([
|
|
1317
|
-
`🛠 **
|
|
1318
|
-
|
|
1325
|
+
`🛠 **WORKERS** · _3 running · oldest 3m00s · 9 tools_${S}`,
|
|
1326
|
+
`${N}**1. worker task 1** _· 1m00s · 3 tools_${S}`,
|
|
1319
1327
|
`${I}~~_✓ w1 step a_~~${S}`,
|
|
1320
1328
|
`${I}**→ w1 step b**${S}`,
|
|
1321
|
-
|
|
1329
|
+
`${N}**2. worker task 2** _· 2m00s · 3 tools_${S}`,
|
|
1322
1330
|
`${I}~~_✓ w2 step a_~~${S}`,
|
|
1323
1331
|
`${I}**→ w2 step b**${S}`,
|
|
1324
|
-
|
|
1332
|
+
`${N}**3. worker task 3** _· 3m00s · 3 tools_${S}`,
|
|
1325
1333
|
`${I}~~_✓ w3 step a_~~${S}`,
|
|
1326
1334
|
`${I}**→ w3 step b**`, // last line: nothing follows it to collide with
|
|
1327
1335
|
])
|
|
@@ -1442,7 +1450,7 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
|
|
|
1442
1450
|
expect(ents.every((e) => !e.text.includes(NBSP))).toBe(true)
|
|
1443
1451
|
})
|
|
1444
1452
|
|
|
1445
|
-
it('the SINGLE-worker 🛠 card is
|
|
1453
|
+
it('the SINGLE-worker 🛠 card is entirely FLUSH — no card indent, no step indent (#3842)', () => {
|
|
1446
1454
|
const single = renderWorkerActivity({
|
|
1447
1455
|
workerId: 'w1',
|
|
1448
1456
|
description: 'lone worker',
|
|
@@ -1454,16 +1462,18 @@ describe('combined worker card — steps indent under their worker (U+2800)', ()
|
|
|
1454
1462
|
})
|
|
1455
1463
|
expect(single).toContain('~~_✓ step a_~~')
|
|
1456
1464
|
expect(single).toContain('**→ step b**')
|
|
1457
|
-
//
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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.
|
|
1471
|
+
const ls = single.split('\n')
|
|
1472
|
+
expect(ls[0].startsWith('🛠 **WORKER**')).toBe(true)
|
|
1473
|
+
for (const l of ls) {
|
|
1466
1474
|
expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
|
|
1475
|
+
expect(l.startsWith('└─')).toBe(false)
|
|
1476
|
+
expect(l.startsWith(NBSP)).toBe(false)
|
|
1467
1477
|
}
|
|
1468
1478
|
})
|
|
1469
1479
|
})
|