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.
- package/dist/cli/switchroom.js +2044 -900
- package/dist/host-control/main.js +162 -80
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -1
- package/profiles/_base/start.sh.hbs +15 -1
- package/telegram-plugin/dist/gateway/gateway.js +1435 -551
- package/telegram-plugin/edit-flood-fuse.ts +70 -20
- package/telegram-plugin/gateway/boot-beacon.ts +364 -0
- package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
- package/telegram-plugin/gateway/gateway.ts +87 -88
- package/telegram-plugin/gateway/inbound-spool.ts +39 -0
- package/telegram-plugin/gateway/narrative-lane.ts +12 -0
- package/telegram-plugin/gateway/obligation-store.ts +28 -0
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
- package/telegram-plugin/gateway/status-pin-store.ts +33 -11
- package/telegram-plugin/registry/turns-schema.ts +21 -1
- package/telegram-plugin/retry-api-call.ts +46 -21
- package/telegram-plugin/shared/bot-runtime.ts +61 -17
- package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
- package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
- package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
- package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
- package/telegram-plugin/tests/obligation-store.test.ts +67 -1
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
- package/telegram-plugin/tool-activity-summary.ts +104 -38
- package/telegram-plugin/worker-activity-feed.ts +33 -16
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
- 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
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* `
|
|
468
|
-
*
|
|
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
|
-
* `
|
|
471
|
-
*
|
|
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
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
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
|
|
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` (
|
|
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
|
|
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}
|
|
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
|
-
})
|