switchroom 0.19.29 → 0.19.31

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 (43) hide show
  1. package/dist/cli/switchroom.js +2044 -900
  2. package/dist/host-control/main.js +162 -80
  3. package/package.json +1 -1
  4. package/profiles/_base/cron-session.sh.hbs +5 -1
  5. package/profiles/_base/start.sh.hbs +15 -1
  6. package/telegram-plugin/dist/gateway/gateway.js +1435 -551
  7. package/telegram-plugin/edit-flood-fuse.ts +70 -20
  8. package/telegram-plugin/gateway/boot-beacon.ts +364 -0
  9. package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
  10. package/telegram-plugin/gateway/gateway.ts +87 -88
  11. package/telegram-plugin/gateway/inbound-spool.ts +39 -0
  12. package/telegram-plugin/gateway/narrative-lane.ts +12 -0
  13. package/telegram-plugin/gateway/obligation-store.ts +28 -0
  14. package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
  15. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
  16. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
  17. package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
  18. package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
  19. package/telegram-plugin/gateway/status-pin-store.ts +33 -11
  20. package/telegram-plugin/registry/turns-schema.ts +21 -1
  21. package/telegram-plugin/retry-api-call.ts +46 -21
  22. package/telegram-plugin/shared/bot-runtime.ts +61 -17
  23. package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
  24. package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
  25. package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
  26. package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
  27. package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
  28. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
  29. package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
  30. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
  31. package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
  32. package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
  33. package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
  34. package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
  35. package/telegram-plugin/tests/obligation-store.test.ts +67 -1
  36. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  37. package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
  38. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
  39. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
  40. package/telegram-plugin/tool-activity-summary.ts +104 -38
  41. package/telegram-plugin/worker-activity-feed.ts +33 -16
  42. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
  43. package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
@@ -91,7 +91,8 @@ import {
91
91
  NESTED_PREFIX,
92
92
  WORKER_STEP_INDENT,
93
93
  } from './status-no-truncate.js'
94
- import { escapeMarkdown, truncate } from './card-format.js'
94
+ import { cleanWorkerResultParagraph, escapeMarkdown, truncate } from './card-format.js'
95
+ import { redact } from './secret-detect/redact.js'
95
96
  import { isTelegramSurfaceTool } from './tool-names.js'
96
97
  // The card layout core. Header composition (`metricsRun` /
97
98
  // `renderActivityHeader`), the per-line escape pipeline (`escapeStepLine`), the
@@ -158,6 +159,15 @@ export interface SessionActivityHeader {
158
159
  * line via `tokenSegment`. Omitted (0/undefined) → no token segment, same
159
160
  * clean-omit behavior as the worker feed. */
160
161
  totalTokens?: number
162
+ /**
163
+ * RAW final-summary text for the terminal `✅ <summary>` footer — the agent
164
+ * card's analogue of the worker card's `latestSummary` (the gateway passes
165
+ * the turn's delivered answer, `turn.lastReplyText`). Rendered ONLY on a
166
+ * final render, through the SHARED `deriveCardResult` the worker card uses,
167
+ * so both surfaces clean/cap/emoji it identically. Absent or empty → no
168
+ * footer block (the worker card's own fallback), never a fabricated line.
169
+ */
170
+ resultText?: string
161
171
  }
162
172
 
163
173
  /**
@@ -456,32 +466,71 @@ const WORKER_RESULT_RULE = '─────'
456
466
  const WORKER_RESULT_MAX = 320
457
467
 
458
468
  /**
459
- * Render the accumulated feed as ready Telegram HTML one action per line,
460
- * newest last. The current (newest) step is bold with a `→`; finished steps
461
- * are italic with a `✓`. Capped to the last STATUS_ROLLING_LINES with a dim
462
- * `✓ +N earlier…` header when the turn ran longer. Returns null when empty.
463
- * Callers send the result verbatim do NOT re-escape or re-wrap it.
469
+ * The ONE derivation of a card's terminal `✅/⚠️ <summary>` footer block, shared
470
+ * by the 🤖 agent card and the 🛠 worker card (#3844 unified the card BODY; the
471
+ * footer stayed forked until this landed the agent card silently had no
472
+ * result block at all, so it ended on `✓ N steps` while the worker card ended
473
+ * on the green tick + summary sentence).
464
474
  *
465
- * Thin adapter over `renderStatusCard` (emoji 🤖, label 'Agent').
475
+ * Contract (the worker card's long-standing behaviour, now the spec for both):
476
+ * - `running` → no block. The card is not finished.
477
+ * - `incomplete` → NEVER a block, whatever `summary` carries. Truthful-no-
478
+ * result invariant: a reaped/abandoned unit produced no
479
+ * result, so it must not render a `⚠️`-prefixed paragraph
480
+ * out of stray summary text. Enforced HERE (deterministic
481
+ * mechanism) rather than left to caller discipline.
482
+ * - `done` → `✅` + the cleaned paragraph.
483
+ * - `failed` → `⚠️` + the cleaned paragraph.
484
+ * - empty/absent summary, or one that cleans to nothing → no block. Omitting
485
+ * is the fallback; the card never fabricates a sentence.
466
486
  *
467
- * `stepCount` (optional): when `final=true` and `stepCount > 0`, appends a
468
- * `✓ N steps` footer line.
487
+ * `summary` is RAW model-authored text (markdown, multi-line). It is scrubbed
488
+ * with `redact()` and cleaned with `cleanWorkerResultParagraph`;
489
+ * `renderStatusCard` does the final truncate + escape when it emits the block.
469
490
  *
470
- * `header` (optional): when provided, the two-line activity header carries
471
- * elapsed + tool count.
491
+ * The `redact()` is load-bearing, not belt-and-braces: status cards are sent
492
+ * via `sendRichMessage` and BYPASS the outbound redact chokepoint
493
+ * (`normalizeOutboundBody` → `redact`, outbound-send-path.ts:192) that scrubs
494
+ * every ordinary reply — the same gap `emitGatewayOperatorEvent` closes in
495
+ * gateway.ts. The agent card's summary is the turn's delivered answer text
496
+ * taken PRE-redaction, so scrubbing here is what keeps a token smuggled into a
497
+ * summary from reaching Telegram verbatim. It runs BEFORE markdown stripping /
498
+ * escaping, which is the order the outbound pipeline requires (redacting
499
+ * already-escaped text lets url-query-param secrets slip past url-redact).
472
500
  */
473
- export function renderActivityFeed(
501
+ export function deriveCardResult(
502
+ state: CardState,
503
+ summary: string | undefined,
504
+ ): { emoji: string; text: string } | undefined {
505
+ if (state !== 'done' && state !== 'failed') return undefined
506
+ const text = cleanWorkerResultParagraph(redact(summary ?? ''))
507
+ if (text.length === 0) return undefined
508
+ return { emoji: state === 'done' ? '✅' : '⚠️', text }
509
+ }
510
+
511
+ /** Leading emoji for the main-session (🤖 Agent) card. */
512
+ const AGENT_CARD_EMOJI = '🤖'
513
+
514
+ /**
515
+ * The single 🤖-agent-card configuration of `renderStatusCard`. Both public
516
+ * agent-card entrypoints (`renderActivityFeed`, `renderActivityFeedWithNested`)
517
+ * funnel through this so the header mapping, the empty-guard, and the terminal
518
+ * result footer cannot drift between the flat and nested renders — they used
519
+ * to be two byte-copies of the same object literal.
520
+ */
521
+ function renderAgentCard(
474
522
  lines: string[],
475
- final = false,
476
- liveSuffix = "",
477
- stepCount?: number,
478
- header?: SessionActivityHeader,
523
+ children: string[],
524
+ final: boolean,
525
+ liveSuffix: string,
526
+ stepCount: number | undefined,
527
+ header: SessionActivityHeader | undefined,
479
528
  ): string | null {
480
- if (lines.length === 0 && header == null) return null;
529
+ if (lines.length === 0 && children.length === 0 && header == null) return null
481
530
  return renderStatusCard({
482
531
  header: header != null
483
532
  ? {
484
- emoji: '🤖',
533
+ emoji: AGENT_CARD_EMOJI,
485
534
  label: header.label,
486
535
  elapsedMs: header.elapsedMs,
487
536
  toolCount: header.toolCount,
@@ -491,12 +540,46 @@ export function renderActivityFeed(
491
540
  }
492
541
  : undefined,
493
542
  steps: lines,
543
+ ...(children.length > 0 ? { childSteps: children } : {}),
494
544
  final,
495
545
  liveSuffix,
496
546
  stepCount,
547
+ // Same footer derivation the 🛠 worker card uses — one code path, so the
548
+ // two surfaces cannot drift apart again. Only a FINAL render can carry a
549
+ // result; a live render passes 'running' through and gets undefined.
550
+ result: final && header != null
551
+ ? deriveCardResult(header.state, header.resultText)
552
+ : undefined,
497
553
  })
498
554
  }
499
555
 
556
+ /**
557
+ * Render the accumulated feed as ready Telegram HTML — one action per line,
558
+ * newest last. The current (newest) step is bold with a `→`; finished steps
559
+ * are italic with a `✓`. Capped to the last STATUS_ROLLING_LINES with a dim
560
+ * `✓ +N earlier…` header when the turn ran longer. Returns null when empty.
561
+ * Callers send the result verbatim — do NOT re-escape or re-wrap it.
562
+ *
563
+ * Thin adapter over `renderStatusCard` (emoji 🤖, label 'Agent') via the shared
564
+ * `renderAgentCard` configuration.
565
+ *
566
+ * `stepCount` (optional): when `final=true` and `stepCount > 0`, appends a
567
+ * `✓ N steps` footer line.
568
+ *
569
+ * `header` (optional): when provided, the two-line activity header carries
570
+ * elapsed + tool count — and, on a final render, `header.resultText` supplies
571
+ * the terminal `✅ <summary>` footer (identical derivation to the worker card).
572
+ */
573
+ export function renderActivityFeed(
574
+ lines: string[],
575
+ final = false,
576
+ liveSuffix = "",
577
+ stepCount?: number,
578
+ header?: SessionActivityHeader,
579
+ ): string | null {
580
+ return renderAgentCard(lines, [], final, liveSuffix, stepCount, header)
581
+ }
582
+
500
583
  // ─── Foreground sub-agent nesting (Model A) ─────────────────────────────────
501
584
  //
502
585
  // A foreground sub-agent (Task/Agent with no `run_in_background`) runs INSIDE
@@ -519,7 +602,8 @@ export const NESTED_MAX_LINES = 4;
519
602
  * a `↳ +N earlier…` header when it overflows. Returns ready Telegram HTML
520
603
  * (callers must NOT re-escape) or null when there is nothing to show.
521
604
  *
522
- * Thin adapter over `renderStatusCard` (emoji 🤖, label 'Agent', childSteps).
605
+ * Thin adapter over `renderStatusCard` (emoji 🤖, label 'Agent', childSteps) via
606
+ * the shared `renderAgentCard` configuration.
523
607
  */
524
608
  export function renderActivityFeedWithNested(
525
609
  lines: string[],
@@ -530,25 +614,7 @@ export function renderActivityFeedWithNested(
530
614
  header?: SessionActivityHeader,
531
615
  ): string | null {
532
616
  const children = childLines.map((s) => s.trim()).filter((s) => s.length > 0);
533
- if (children.length === 0) return renderActivityFeed(lines, final, liveSuffix, stepCount, header);
534
- return renderStatusCard({
535
- header: header != null
536
- ? {
537
- emoji: '🤖',
538
- label: header.label,
539
- elapsedMs: header.elapsedMs,
540
- toolCount: header.toolCount,
541
- state: header.state,
542
- model: header.model,
543
- totalTokens: header.totalTokens,
544
- }
545
- : undefined,
546
- steps: lines,
547
- childSteps: children,
548
- final,
549
- liveSuffix,
550
- stepCount,
551
- })
617
+ return renderAgentCard(lines, children, final, liveSuffix, stepCount, header)
552
618
  }
553
619
 
554
620
  // ─── Combined multi-worker feed (coalesced one-message-per-chat) ────────────
@@ -50,13 +50,13 @@
50
50
  */
51
51
 
52
52
  import {
53
- cleanWorkerResultParagraph,
54
53
  stripMarkdown,
55
54
  truncate,
56
55
  } from './card-format.js'
57
56
  import { WORKER_HISTORY_MAX } from './status-no-truncate.js'
58
57
  import { renderCardTitleLine } from './card-layout.js'
59
58
  import {
59
+ deriveCardResult,
60
60
  renderStatusCard,
61
61
  formatStepSuffix,
62
62
  renderCombinedWorkerFeed,
@@ -274,17 +274,12 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
274
274
  // Terminal: latestSummary carries the worker's final result text (gateway
275
275
  // onFinish), distinct from the running narrative steps. Pass it as `result`.
276
276
  //
277
- // Truthful-no-result invariant (deterministic control, not caller-discipline):
278
- // an `incomplete` worker produced NO result, so it must NEVER render a result
279
- // block regardless of whatever `latestSummary` happens to carry. The current
280
- // call site (terminateWorker) always sets latestSummary:'' for incomplete, but
281
- // enforce the invariant HERE so any future/direct caller can't fabricate a
282
- // `⚠️`-prefixed result paragraph out of stray summary text.
283
- let result: { emoji: string; text: string } | undefined
284
- if (finished && v.state !== 'incomplete') {
285
- const text = cleanWorkerResultParagraph(v.latestSummary)
286
- if (text.length > 0) result = { emoji: v.state === 'done' ? '✅' : '⚠️', text }
287
- }
277
+ // The whole derivation — state gating, the truthful-no-result invariant for
278
+ // `incomplete`, ✅/⚠️ selection, and omission on an empty summary lives ONCE
279
+ // in `deriveCardResult` and is SHARED with the 🤖 agent card. It used to be
280
+ // inline here, which is precisely why the agent card had no result footer at
281
+ // all: the logic was unreachable from the other surface.
282
+ const result = deriveCardResult(v.state, v.latestSummary)
288
283
 
289
284
  const card = renderStatusCard({
290
285
  header,
@@ -581,7 +576,7 @@ interface WorkerRow {
581
576
  * a 2+ worker group renders the combined `renderCombinedWorkerFeed` body.
582
577
  */
583
578
  interface FeedGroup {
584
- /** Stable key `${chatId} ${threadId ?? ''}`. */
579
+ /** Stable `(chat, thread)` key see `feedKeyOf`: `<chatId>:<threadId|->`. */
585
580
  feedKey: string
586
581
  chatId: string
587
582
  threadId?: number
@@ -735,7 +730,7 @@ export interface WorkerActivityFeed {
735
730
  * re-post). Lets the gateway pin the EXISTING `🛠 Worker` message. Note:
736
731
  * siblings sharing the chat/thread return the SAME id (one message). */
737
732
  messageIdOf(agentId: string): number | null
738
- /** True while the feed group `feedKey` (`${chatId} ${threadId ?? ''}`) still
733
+ /** True while the feed group `feedKey` (see `feedKeyOf` `<chatId>:<threadId|->`) still
739
734
  * tracks live work — used by the gateway's `wk:group:` pin reaper to exempt
740
735
  * a live group's pin from the stale-TTL sweep (#3207). */
741
736
  hasRunningInFeed(feedKey: string): boolean
@@ -822,7 +817,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
822
817
  })
823
818
  const clearIntervalFn = opts.clearInterval ?? ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>))
824
819
 
825
- /** Feed groups keyed by `${chatId} ${threadId ?? ''}`. */
820
+ /** Feed groups keyed by `feedKeyOf(chatId, threadId)` `<chatId>:<threadId|->`. */
826
821
  const groups = new Map<string, FeedGroup>()
827
822
  /** Reverse index agentId → feedKey, so the agentId-keyed public API resolves
828
823
  * its group in O(1). Cleared when a worker's row is removed. */
@@ -849,8 +844,30 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
849
844
  }
850
845
  let heartbeatTimer: unknown = null
851
846
 
847
+ /**
848
+ * The `(chat, thread)` identity of a feed group — and, prefixed with
849
+ * `wk:group:`, the pin key that gets PERSISTED into status-pins.json.
850
+ *
851
+ * The separator and the topic-less sentinel are both load-bearing. This used
852
+ * to be `` `${chatId} ${threadId ?? ''}` ``, which for a topic-less group
853
+ * rendered as `-1004223464247 ` — a key ending in a bare space. On disk that
854
+ * became `"pinKey": "wk:group:-1004223464247 "`: trailing whitespace that
855
+ * survives JSON round-trips, is invisible in every log line and grep, and
856
+ * cannot be distinguished by eye from the same chat WITH a topic. Any operator
857
+ * or tool reconciling rows by chat would silently treat the two as one.
858
+ *
859
+ * `<chatId>:<threadId|->` is unambiguous in both directions: the topic-less
860
+ * case is spelled explicitly (`-`), and a topic key can never be confused with
861
+ * a topic-less one. Note chat ids are negative, so the `-` sentinel and the
862
+ * sign of the id never occupy the same position.
863
+ *
864
+ * The value is opaque to every consumer (it round-trips through the pin key
865
+ * and back into `hasRunningInFeed`), so the format may change; a key written
866
+ * by an older build is work-scoped and is unpinned unconditionally by the next
867
+ * boot's cleanup, so there is nothing to migrate.
868
+ */
852
869
  function feedKeyOf(chatId: string, threadId?: number): string {
853
- return `${chatId} ${threadId ?? ''}`
870
+ return `${chatId}:${threadId ?? '-'}`
854
871
  }
855
872
  function groupOfAgent(agentId: string): FeedGroup | undefined {
856
873
  const key = agentIndex.get(agentId)
@@ -1,251 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest'
2
- import {
3
- isDmChatId,
4
- collectDmChatIdsFromStores,
5
- createDmPinSweeper,
6
- unexpiredStoreRepinIds,
7
- type DmPinSweeperDeps,
8
- } from './dm-pin-sweep'
9
-
10
- // Synthetic chat IDs only (check-no-pii-secrets): positive = DM, negative =
11
- // group/supergroup.
12
- const DM_A = '5000001'
13
- const DM_B = '5000002'
14
- const GROUP = '-1002000000001'
15
-
16
- describe('isDmChatId', () => {
17
- it('true for positive integer DM ids', () => {
18
- expect(isDmChatId(DM_A)).toBe(true)
19
- expect(isDmChatId('1')).toBe(true)
20
- })
21
- it('false for group/supergroup (negative), zero, non-integer, non-numeric', () => {
22
- expect(isDmChatId(GROUP)).toBe(false)
23
- expect(isDmChatId('-1')).toBe(false)
24
- expect(isDmChatId('0')).toBe(false)
25
- expect(isDmChatId('1.5')).toBe(false)
26
- expect(isDmChatId('abc')).toBe(false)
27
- expect(isDmChatId('')).toBe(false)
28
- expect(isDmChatId('Infinity')).toBe(false)
29
- })
30
- })
31
-
32
- describe('collectDmChatIdsFromStores', () => {
33
- it('collects distinct DM chat ids across all three stores and drops non-DMs', () => {
34
- const ids = collectDmChatIdsFromStores({
35
- statusPins: [{ chatId: DM_A }, { chatId: GROUP }, { chatId: DM_A }],
36
- activityCards: [{ chatId: DM_B }, { chatId: '0' }],
37
- queuedCards: [{ chatId: DM_A }, { chatId: '-77' }],
38
- })
39
- expect(new Set(ids)).toEqual(new Set([DM_A, DM_B]))
40
- expect(ids).toHaveLength(2)
41
- })
42
- it('handles missing store arrays', () => {
43
- expect(collectDmChatIdsFromStores({})).toEqual([])
44
- expect(collectDmChatIdsFromStores({ statusPins: [{ chatId: GROUP }] })).toEqual([])
45
- })
46
- })
47
-
48
- function makeDeps(overrides: Partial<DmPinSweeperDeps> = {}): {
49
- deps: DmPinSweeperDeps
50
- unpinAll: ReturnType<typeof vi.fn>
51
- pinSilent: ReturnType<typeof vi.fn>
52
- } {
53
- const unpinAll = vi.fn(async () => undefined)
54
- const pinSilent = vi.fn(async () => undefined)
55
- const deps: DmPinSweeperDeps = {
56
- unpinAll,
57
- pinSilent,
58
- liveTrackedMessageIds: () => [],
59
- eligible: () => true,
60
- log: () => {},
61
- ...overrides,
62
- }
63
- return { deps, unpinAll, pinSilent }
64
- }
65
-
66
- describe('createDmPinSweeper', () => {
67
- it('DM with tracked pins: unpin-all exactly once, then re-pin exactly the live ids', async () => {
68
- const { deps, unpinAll, pinSilent } = makeDeps({
69
- liveTrackedMessageIds: (chatId) => (chatId === DM_A ? [111, 222] : []),
70
- })
71
- const sweeper = createDmPinSweeper(deps)
72
- await sweeper.sweep(DM_A)
73
-
74
- expect(unpinAll).toHaveBeenCalledTimes(1)
75
- expect(unpinAll).toHaveBeenCalledWith(DM_A)
76
- expect(pinSilent.mock.calls).toEqual([
77
- [DM_A, 111],
78
- [DM_A, 222],
79
- ])
80
- expect(sweeper.hasSwept(DM_A)).toBe(true)
81
- })
82
-
83
- it('DM with no tracked pins: unpin-all once, no re-pin', async () => {
84
- const { deps, unpinAll, pinSilent } = makeDeps()
85
- const sweeper = createDmPinSweeper(deps)
86
- await sweeper.sweep(DM_A)
87
- expect(unpinAll).toHaveBeenCalledTimes(1)
88
- expect(pinSilent).not.toHaveBeenCalled()
89
- })
90
-
91
- it('group/supergroup id: never unpin-all (keeps human pins)', async () => {
92
- const { deps, unpinAll, pinSilent } = makeDeps()
93
- const sweeper = createDmPinSweeper(deps)
94
- await sweeper.sweep(GROUP)
95
- await sweeper.sweep('-1')
96
- expect(unpinAll).not.toHaveBeenCalled()
97
- expect(pinSilent).not.toHaveBeenCalled()
98
- })
99
-
100
- it('not eligible (lost startup mutex): does nothing', async () => {
101
- const { deps, unpinAll } = makeDeps({ eligible: () => false })
102
- const sweeper = createDmPinSweeper(deps)
103
- await sweeper.sweep(DM_A)
104
- expect(unpinAll).not.toHaveBeenCalled()
105
- expect(sweeper.hasSwept(DM_A)).toBe(false)
106
- })
107
-
108
- it('second sweep of same chat does not re-sweep', async () => {
109
- const { deps, unpinAll } = makeDeps()
110
- const sweeper = createDmPinSweeper(deps)
111
- await sweeper.sweep(DM_A)
112
- await sweeper.sweep(DM_A)
113
- expect(unpinAll).toHaveBeenCalledTimes(1)
114
- })
115
-
116
- it('concurrent triggers on the same chat fire unpin-all only once', async () => {
117
- let resolveUnpin: () => void = () => {}
118
- const unpinAll = vi.fn(
119
- () =>
120
- new Promise<void>((res) => {
121
- resolveUnpin = res
122
- }),
123
- )
124
- const { deps } = makeDeps({ unpinAll })
125
- const sweeper = createDmPinSweeper(deps)
126
- const p1 = sweeper.sweep(DM_A)
127
- const p2 = sweeper.sweep(DM_A)
128
- resolveUnpin()
129
- await Promise.all([p1, p2])
130
- expect(unpinAll).toHaveBeenCalledTimes(1)
131
- })
132
-
133
- it('429 / unpin-all rejects: degrades without throwing, still re-pins live ids', async () => {
134
- const unpinAll = vi.fn(async () => {
135
- throw new Error('429: Too Many Requests: retry after 30')
136
- })
137
- const { deps, pinSilent } = makeDeps({
138
- unpinAll,
139
- liveTrackedMessageIds: () => [333],
140
- })
141
- const sweeper = createDmPinSweeper(deps)
142
- // Must not reject.
143
- await expect(sweeper.sweep(DM_A)).resolves.toBeUndefined()
144
- expect(unpinAll).toHaveBeenCalledTimes(1)
145
- expect(pinSilent).toHaveBeenCalledWith(DM_A, 333)
146
- })
147
-
148
- it('re-pin failure is absorbed and does not reject', async () => {
149
- const pinSilent = vi.fn(async () => {
150
- throw new Error('pin failed')
151
- })
152
- const { deps } = makeDeps({
153
- pinSilent,
154
- liveTrackedMessageIds: () => [444],
155
- })
156
- const sweeper = createDmPinSweeper(deps)
157
- await expect(sweeper.sweep(DM_A)).resolves.toBeUndefined()
158
- })
159
-
160
- it('no unhandled rejection escapes a failing sweep (fire-and-forget safe)', async () => {
161
- const unhandled: unknown[] = []
162
- const onUnhandled = (r: unknown): void => {
163
- unhandled.push(r)
164
- }
165
- process.on('unhandledRejection', onUnhandled)
166
- try {
167
- const { deps } = makeDeps({
168
- unpinAll: async () => {
169
- throw new Error('boom')
170
- },
171
- liveTrackedMessageIds: () => [555],
172
- })
173
- const sweeper = createDmPinSweeper(deps)
174
- // Fire-and-forget, as the gateway does.
175
- void sweeper.sweep(DM_A)
176
- await new Promise((r) => setTimeout(r, 10))
177
- } finally {
178
- process.off('unhandledRejection', onUnhandled)
179
- }
180
- expect(unhandled).toEqual([])
181
- })
182
- })
183
-
184
- describe('tool-pin survival (#3001 contract, review blocker on #3074)', () => {
185
- const NOW = 1_752_000_000_000
186
-
187
- it('unexpiredStoreRepinIds keeps unexpired tool rows for the chat, drops expired / work-scoped / other-chat rows', () => {
188
- const rows = [
189
- // Unexpired tool pin in this chat — MUST survive.
190
- { chatId: DM_A, messageId: 10, expiresAt: NOW + 60_000 },
191
- // Expired tool pin — due for sweeping, NOT re-pinned.
192
- { chatId: DM_A, messageId: 11, expiresAt: NOW - 1 },
193
- // Work-scoped row (no expiresAt) — stale after restart, NOT re-pinned.
194
- { chatId: DM_A, messageId: 12 },
195
- // Unexpired tool pin in a DIFFERENT chat — not this chat's re-pin set.
196
- { chatId: DM_B, messageId: 13, expiresAt: NOW + 60_000 },
197
- ]
198
- expect(unexpiredStoreRepinIds(rows, DM_A, NOW)).toEqual([10])
199
- expect(unexpiredStoreRepinIds(rows, DM_B, NOW)).toEqual([13])
200
- })
201
-
202
- // Composition test mirroring the gateway wiring: liveTrackedMessageIds
203
- // unions in-memory claims with unexpired store rows.
204
- function gatewayStyleDeps(args: {
205
- inMemory: number[]
206
- storeRows: { chatId: string; messageId: number; expiresAt?: number }[]
207
- }): ReturnType<typeof makeDeps> {
208
- return makeDeps({
209
- liveTrackedMessageIds: (chatId) => [
210
- ...args.inMemory,
211
- ...unexpiredStoreRepinIds(args.storeRows, chatId, NOW),
212
- ],
213
- })
214
- }
215
-
216
- it('(a) boot sweep: DM with an unexpired tool: row → unpinAll once, then that messageId re-pinned', async () => {
217
- const { deps, unpinAll, pinSilent } = gatewayStyleDeps({
218
- inMemory: [], // fresh boot — in-memory claims empty
219
- storeRows: [{ chatId: DM_A, messageId: 777, expiresAt: NOW + 60_000 }],
220
- })
221
- const sweeper = createDmPinSweeper(deps)
222
- await sweeper.sweep(DM_A)
223
- expect(unpinAll).toHaveBeenCalledTimes(1)
224
- expect(pinSilent.mock.calls).toEqual([[DM_A, 777]])
225
- })
226
-
227
- it('(b) expired tool: row is NOT re-pinned', async () => {
228
- const { deps, unpinAll, pinSilent } = gatewayStyleDeps({
229
- inMemory: [],
230
- storeRows: [{ chatId: DM_A, messageId: 778, expiresAt: NOW - 1 }],
231
- })
232
- const sweeper = createDmPinSweeper(deps)
233
- await sweeper.sweep(DM_A)
234
- expect(unpinAll).toHaveBeenCalledTimes(1)
235
- expect(pinSilent).not.toHaveBeenCalled()
236
- })
237
-
238
- it('(c) first-inbound sweep preserves an unexpired tool pin alongside a live in-memory claim, deduped', async () => {
239
- const { deps, pinSilent } = gatewayStyleDeps({
240
- inMemory: [500, 777], // live fg: claim + the tool pin also claimed in-memory
241
- storeRows: [{ chatId: DM_A, messageId: 777, expiresAt: NOW + 60_000 }],
242
- })
243
- const sweeper = createDmPinSweeper(deps)
244
- await sweeper.sweep(DM_A)
245
- // 777 appears in BOTH sources but is re-pinned exactly once.
246
- expect(pinSilent.mock.calls).toEqual([
247
- [DM_A, 500],
248
- [DM_A, 777],
249
- ])
250
- })
251
- })