switchroom 0.18.31 → 0.18.33
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 +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -46,7 +46,7 @@ import { homedir } from 'os'
|
|
|
46
46
|
import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
|
|
47
47
|
import { sanitiseToolArg } from './fleet-state.js'
|
|
48
48
|
import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
|
|
49
|
-
import {
|
|
49
|
+
import { isReplyTool } from './narrative-dedup.js'
|
|
50
50
|
import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from './narrative-flush.js'
|
|
51
51
|
import { truncate } from './card-format.js'
|
|
52
52
|
import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents, recordNestedSubagentDispatch, recordSubagentModel } from './registry/subagents-schema.js'
|
|
@@ -551,6 +551,31 @@ export interface SubagentWatcherConfig {
|
|
|
551
551
|
* that the chain is bounded, not that a lost worker gets a fresh card.
|
|
552
552
|
*/
|
|
553
553
|
onWorkerLost?: (agentId: string, description: string) => void
|
|
554
|
+
/**
|
|
555
|
+
* Issue #3373 (SendMessage-resume feed re-surface). Fires when a worker that
|
|
556
|
+
* had a GENUINE terminal completion (a real `sub_agent_turn_end`, then swept
|
|
557
|
+
* by `cleanupTerminalAgent`) is RESUMED via SendMessage — its
|
|
558
|
+
* `agent-<id>.jsonl` grows PAST the byte size recorded at terminal cleanup,
|
|
559
|
+
* and `scanSubagentsDir` re-registers it as a fresh live entry (#3315).
|
|
560
|
+
*
|
|
561
|
+
* This is DISTINCT from `onResurrect` (#3023), which reverses a FALSE finish
|
|
562
|
+
* from silent-stall synthesis and carries bounded-chain semantics. A
|
|
563
|
+
* SendMessage resume is a real, legitimately-unbounded lifecycle event (a
|
|
564
|
+
* worker may be stopped and resumed many times), so it does NOT route through
|
|
565
|
+
* the resurrection budget — it fires here every time growth-past-terminal is
|
|
566
|
+
* observed.
|
|
567
|
+
*
|
|
568
|
+
* Why it exists: the live worker-activity feed latches a terminal `finalized`
|
|
569
|
+
* gate per agentId (worker-activity-feed.ts) so a late zombie tick can't
|
|
570
|
+
* repaint a done worker. The #3315 re-registration re-arms the watcher and
|
|
571
|
+
* re-fires `onProgress`, but those cues are SWALLOWED by that latch — so a
|
|
572
|
+
* resumed long-running worker reads as silence with no card. The gateway
|
|
573
|
+
* wires this to `feed.resurrect(agentId)` (clear the latch) so the subsequent
|
|
574
|
+
* `onProgress` cue repaints a fresh live row. Deterministic: gated solely on
|
|
575
|
+
* the file growing past the recorded terminal boundary, never on model
|
|
576
|
+
* discipline. Best-effort; the callback is expected to be idempotent.
|
|
577
|
+
*/
|
|
578
|
+
onResume?: (agentId: string, description: string) => void
|
|
554
579
|
/**
|
|
555
580
|
* Called exactly once per sub-agent when its watcher observes a terminal
|
|
556
581
|
* transition (`done` or `failed`). Mirrors the existing `sub_agent_started`
|
|
@@ -846,6 +871,17 @@ const DEFAULT_REAPER_INTERVAL_MS = 15 * 60_000 // 15 minutes
|
|
|
846
871
|
*/
|
|
847
872
|
const TERMINAL_CLEANUP_GRACE_MS = 30_000
|
|
848
873
|
|
|
874
|
+
/**
|
|
875
|
+
* Bounded tail window (bytes) the boot-reconcile fast-path reads to decide
|
|
876
|
+
* whether a stale historical worker already reached `turn_end` before this
|
|
877
|
+
* boot (see `probeReachedTurnEndAtBoot`). Large enough to contain any realistic
|
|
878
|
+
* final line (a `turn_duration` line is ~100 bytes; an `end_turn` assistant
|
|
879
|
+
* answer line is well under this), yet a tiny fraction of the multi-MB
|
|
880
|
+
* transcripts a long-running worker accumulates — so the boot read is O(window)
|
|
881
|
+
* per record instead of O(filesize). 512 KiB.
|
|
882
|
+
*/
|
|
883
|
+
const BOOT_TERMINAL_PROBE_WINDOW_BYTES = 512 * 1024
|
|
884
|
+
|
|
849
885
|
/**
|
|
850
886
|
* Throttle for the liveness-path retry of `backfillJsonlAgentId` (see
|
|
851
887
|
* WorkerEntry.lastBackfillAttemptAt). Cheap (one meta.json read + two
|
|
@@ -855,13 +891,14 @@ const BACKFILL_RETRY_INTERVAL_MS = 3000
|
|
|
855
891
|
|
|
856
892
|
/**
|
|
857
893
|
* Fix #9a: cap on `terminatedAgentIds` (the re-discovery dedup guard in
|
|
858
|
-
* `cleanupTerminalAgent` / `scanSubagentsDir`). Pre-fix the
|
|
894
|
+
* `cleanupTerminalAgent` / `scanSubagentsDir`). Pre-fix the map only grew
|
|
859
895
|
* (added on every terminal cleanup, only ever cleared wholesale in
|
|
860
896
|
* `stop()`), so a long-lived gateway with sustained sub-agent throughput
|
|
861
|
-
* accumulated ids without bound. A `
|
|
897
|
+
* accumulated ids without bound. A `Map` preserves insertion order, so once
|
|
862
898
|
* the cap is hit the OLDEST id is evicted on each new insert (simple ring
|
|
863
899
|
* buffer semantics) — recently-terminated ids (the ones actually at risk of
|
|
864
|
-
* a re-discovery race) always stay
|
|
900
|
+
* a re-discovery race, or of a SendMessage resume — issue #3315) always stay
|
|
901
|
+
* covered.
|
|
865
902
|
*/
|
|
866
903
|
const TERMINATED_AGENT_IDS_CAP = 5000
|
|
867
904
|
|
|
@@ -1550,8 +1587,8 @@ export function readSubTail(
|
|
|
1550
1587
|
const narrativeJustFired = resolvePendingSubNarrative(ev.toolName, ev.input)
|
|
1551
1588
|
// NIT 3: capture a foreground sub-agent's actual reply text so the
|
|
1552
1589
|
// turn_end path can suppress a trailing draft of it (see
|
|
1553
|
-
// resolvePendingSubNarrative). Only
|
|
1554
|
-
if (
|
|
1590
|
+
// resolvePendingSubNarrative). Only reply tools carry the answer.
|
|
1591
|
+
if (isReplyTool(ev.toolName) && typeof ev.input?.text === 'string') {
|
|
1555
1592
|
entry.lastReplyText = ev.input.text as string
|
|
1556
1593
|
}
|
|
1557
1594
|
entry.toolCount++
|
|
@@ -1861,18 +1898,26 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1861
1898
|
const historicalFiles = new Set<string>()
|
|
1862
1899
|
/**
|
|
1863
1900
|
* AgentIds that have transitioned to a terminal state and been swept
|
|
1864
|
-
* out of `registry` by `cleanupTerminalAgent
|
|
1865
|
-
*
|
|
1866
|
-
* the file on disk after the sub-agent
|
|
1867
|
-
* the next `rescanSubagentDirs` poll
|
|
1868
|
-
* `registerAgent`, the fresh entry read the
|
|
1869
|
-
* line, and `maybySendStateTransition` fired a
|
|
1870
|
-
* notification — looping forever every grace-window.
|
|
1901
|
+
* out of `registry` by `cleanupTerminalAgent`, mapped to the JSONL byte
|
|
1902
|
+
* SIZE at cleanup time. Issue #1116 (Bug B): the JSONL file outlives the
|
|
1903
|
+
* registry entry — Claude Code leaves the file on disk after the sub-agent
|
|
1904
|
+
* finishes. Without this guard, the next `rescanSubagentDirs` poll
|
|
1905
|
+
* re-discovered the file, called `registerAgent`, the fresh entry read the
|
|
1906
|
+
* terminal `turn_duration` line, and `maybySendStateTransition` fired a
|
|
1907
|
+
* duplicate "Worker done" notification — looping forever every grace-window.
|
|
1871
1908
|
*
|
|
1872
|
-
*
|
|
1873
|
-
*
|
|
1909
|
+
* Issue #3315 (SendMessage resume): a resumed sub-agent reuses the SAME
|
|
1910
|
+
* `agent-<id>.jsonl`, appending a NEW turn to it — so the file's size grows
|
|
1911
|
+
* PAST the size it had at terminal cleanup. `scanSubagentsDir` compares the
|
|
1912
|
+
* live file size against this recorded terminal size: at/below it the JSONL
|
|
1913
|
+
* is a static leftover and stays suppressed (#1116 Bug B); above it the
|
|
1914
|
+
* worker resumed and is re-registered live (#3315) so its progress card
|
|
1915
|
+
* updates again. Storing the size (not a bare membership flag) is what makes
|
|
1916
|
+
* that distinction deterministic and lifecycle-driven rather than a
|
|
1917
|
+
* heuristic. Map iteration order is insertion order, so the eviction ring
|
|
1918
|
+
* buffer (Fix #9a) still evicts the oldest id.
|
|
1874
1919
|
*/
|
|
1875
|
-
const terminatedAgentIds = new
|
|
1920
|
+
const terminatedAgentIds = new Map<string, number>()
|
|
1876
1921
|
/**
|
|
1877
1922
|
* Issue #3023 (card resurrection). Per-worker record of a FALSE terminal
|
|
1878
1923
|
* finish — a terminal state produced by silent-stall synthesis (NOT a real
|
|
@@ -1914,9 +1959,71 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1914
1959
|
|
|
1915
1960
|
let stopped = false
|
|
1916
1961
|
|
|
1962
|
+
/**
|
|
1963
|
+
* Cheap boot-terminal probe. Reads only a BOUNDED TAIL of a JSONL (not the
|
|
1964
|
+
* whole multi-MB transcript) and runs the canonical `projectSubagentLine`
|
|
1965
|
+
* projector over its complete lines to decide whether the worker had already
|
|
1966
|
+
* reached `sub_agent_turn_end` before this boot. Used by the boot-reconcile
|
|
1967
|
+
* fast-path to tell a done-at-boot worker (which still needs terminal
|
|
1968
|
+
* cleanup — feed-row sweep, card collapse/unpin) apart from a running-at-boot
|
|
1969
|
+
* dead prior-session worker (which needs nothing) WITHOUT the expensive full
|
|
1970
|
+
* read. `done` is the only terminal boot state (`WorkerState` never flips to
|
|
1971
|
+
* `'failed'` at boot — see its doc), and its signal (`system/turn_duration`
|
|
1972
|
+
* or an `end_turn` assistant line) is always the tail of the file, so a
|
|
1973
|
+
* bounded window catches it. Conservative on error: an unreadable/oversized
|
|
1974
|
+
* final line returns `false`, so the entry falls through to the running-stale
|
|
1975
|
+
* skip rather than being mis-swept.
|
|
1976
|
+
*/
|
|
1977
|
+
function probeReachedTurnEndAtBoot(filePath: string, size: number): boolean {
|
|
1978
|
+
if (size <= 0) return false
|
|
1979
|
+
const start = Math.max(0, size - BOOT_TERMINAL_PROBE_WINDOW_BYTES)
|
|
1980
|
+
let raw: string
|
|
1981
|
+
try {
|
|
1982
|
+
const len = size - start
|
|
1983
|
+
const buf = Buffer.alloc(len)
|
|
1984
|
+
const fd = fs.openSync(filePath, 'r')
|
|
1985
|
+
try {
|
|
1986
|
+
fs.readSync(fd, buf, 0, len, start)
|
|
1987
|
+
} finally {
|
|
1988
|
+
fs.closeSync(fd)
|
|
1989
|
+
}
|
|
1990
|
+
raw = buf.toString('utf-8')
|
|
1991
|
+
} catch {
|
|
1992
|
+
return false
|
|
1993
|
+
}
|
|
1994
|
+
const lines = raw.split('\n')
|
|
1995
|
+
// If we started mid-file, the first fragment is a partial line — drop it
|
|
1996
|
+
// (projectSubagentLine would JSON-parse-fail on it anyway, but be explicit).
|
|
1997
|
+
if (start > 0) lines.shift()
|
|
1998
|
+
const probeState = { hasEmittedStart: true } // suppress start events; irrelevant here
|
|
1999
|
+
for (const line of lines) {
|
|
2000
|
+
if (!line) continue
|
|
2001
|
+
let events
|
|
2002
|
+
try {
|
|
2003
|
+
events = projectSubagentLine(line, 'probe', probeState)
|
|
2004
|
+
} catch {
|
|
2005
|
+
continue
|
|
2006
|
+
}
|
|
2007
|
+
for (const ev of events) {
|
|
2008
|
+
if (ev.kind === 'sub_agent_turn_end') return true
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
return false
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1917
2014
|
// ─── Per-agent registration ─────────────────────────────────────────────
|
|
1918
2015
|
|
|
1919
|
-
|
|
2016
|
+
/**
|
|
2017
|
+
* @param resumeFromCursor Issue #3315: when re-registering a worker whose
|
|
2018
|
+
* JSONL resumed growing after a terminal cleanup (a SendMessage resume),
|
|
2019
|
+
* start the tail cursor at the terminal byte boundary instead of 0. The
|
|
2020
|
+
* already-handed-back completed turn (its `sub_agent_turn_end` and all
|
|
2021
|
+
* prior events) is therefore never re-read — so the fresh entry stays
|
|
2022
|
+
* `running` on the NEW turn's content rather than instantly re-reading the
|
|
2023
|
+
* old terminal line and re-finalising. Omitted (⇒ 0) for every normal
|
|
2024
|
+
* registration, which reads from the start.
|
|
2025
|
+
*/
|
|
2026
|
+
function registerAgent(filePath: string, agentId: string, resumeFromCursor?: number): void {
|
|
1920
2027
|
if (registry.has(agentId)) return
|
|
1921
2028
|
const n = nowFn()
|
|
1922
2029
|
const isHistorical = historicalFiles.has(filePath)
|
|
@@ -1950,6 +2057,85 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1950
2057
|
}
|
|
1951
2058
|
registry.set(agentId, entry)
|
|
1952
2059
|
|
|
2060
|
+
// Boot reconcile fast-path (perf): a historical boot entry whose file's
|
|
2061
|
+
// LAST WRITE already exceeds the in-flight promotion freshness window is a
|
|
2062
|
+
// provably-dead prior-session worker. The freshness gate below reaches the
|
|
2063
|
+
// exact same verdict ("stale — leaving historical, no live transition to
|
|
2064
|
+
// observe"), but only AFTER the full initial `readSubTail` (a whole-
|
|
2065
|
+
// transcript parse plus per-record sqlite liveness/backfill round-trips).
|
|
2066
|
+
// On a busy 24/7 agent, hundreds of these dead JSONLs accumulate, and doing
|
|
2067
|
+
// that O(n) work synchronously at boot delayed the gateway answering
|
|
2068
|
+
// Telegram by minutes after every restart — the agent looked dead. Since
|
|
2069
|
+
// the age check ALONE decides "dead prior-session worker" (identical
|
|
2070
|
+
// threshold `inflightPromoteMaxAgeMs`), compute it up front and short-
|
|
2071
|
+
// circuit: register a minimal historical entry, skip the read, the
|
|
2072
|
+
// promotion attempt, and the FSWatcher. A resume re-registration (#3315)
|
|
2073
|
+
// passes isHistorical=false (historicalFiles.delete precedes it) and a
|
|
2074
|
+
// genuinely fresh in-flight-at-boot worker fails the age check, so both
|
|
2075
|
+
// still take the full path below.
|
|
2076
|
+
if (isHistorical) {
|
|
2077
|
+
let mtimeMs: number | null = null
|
|
2078
|
+
let fileSize = 0
|
|
2079
|
+
try {
|
|
2080
|
+
const st = fs.statSync(filePath)
|
|
2081
|
+
if (typeof st.size === 'number') fileSize = st.size
|
|
2082
|
+
if (typeof st.mtimeMs === 'number') mtimeMs = st.mtimeMs
|
|
2083
|
+
} catch {
|
|
2084
|
+
/* unreadable → mtimeMs stays null → conservative full path below */
|
|
2085
|
+
}
|
|
2086
|
+
// Only fast-path when the mtime is KNOWN and already past the freshness
|
|
2087
|
+
// window — a confidently-dead prior-session worker. A missing/unreadable
|
|
2088
|
+
// mtime is NOT treated as dead here (unlike the freshness gate's
|
|
2089
|
+
// Infinity-is-stale rule): we conservatively fall through to the full
|
|
2090
|
+
// path so the read still happens, since the whole optimisation targets
|
|
2091
|
+
// real on-disk files (which always carry an mtime) and the boot cost only
|
|
2092
|
+
// bites at fleet scale where mtimes are present.
|
|
2093
|
+
if (mtimeMs != null && n - mtimeMs > inflightPromoteMaxAgeMs) {
|
|
2094
|
+
const fileAgeMs = n - mtimeMs
|
|
2095
|
+
// The expensive boot work was the full initial `readSubTail` (whole-
|
|
2096
|
+
// transcript parse + per-record sqlite liveness/backfill round-trips)
|
|
2097
|
+
// done for EVERY historical `running` JSONL, including the hundreds a
|
|
2098
|
+
// busy 24/7 agent hoards that were last written days-to-weeks ago. It
|
|
2099
|
+
// produced nothing actionable for a worker the freshness gate was about
|
|
2100
|
+
// to leave historical anyway, yet its O(filesize) disk reads (×~1177 on
|
|
2101
|
+
// the incident agent) delayed the gateway answering Telegram by minutes
|
|
2102
|
+
// after every restart. Replace it with a BOUNDED tail probe.
|
|
2103
|
+
//
|
|
2104
|
+
// The probe distinguishes the two stale-at-boot populations WITHOUT the
|
|
2105
|
+
// full read, so we don't over-skip: a *done*-at-boot worker (its JSONL
|
|
2106
|
+
// already reached `turn_end`) must still reach `scheduleTerminalCleanup`
|
|
2107
|
+
// — otherwise its worker-feed row leaks as a ghost (card never
|
|
2108
|
+
// collapsed/unpinned; #<worker-feed ghost-leak>). A *running*-at-boot
|
|
2109
|
+
// dead prior-session worker (never wrote a terminal `turn_end` — the
|
|
2110
|
+
// incident population) needs nothing: no read, no backfill, no watcher,
|
|
2111
|
+
// no cleanup.
|
|
2112
|
+
const doneAtBoot = probeReachedTurnEndAtBoot(filePath, fileSize)
|
|
2113
|
+
// Park the tail cursor at the boot-time EOF either way. A historical
|
|
2114
|
+
// entry whose cursor sits at EOF is a cheap no-op in the poll loop
|
|
2115
|
+
// (readSubTail statSyncs, sees `size === cursor`, returns before any
|
|
2116
|
+
// openSync/sqlite — the skeleton-cue path is gated on `!historical`), so
|
|
2117
|
+
// no poll-loop special-casing is needed; a genuine post-boot resume that
|
|
2118
|
+
// grows the file PAST this EOF is still read on the next poll. No
|
|
2119
|
+
// FSWatcher: a dead prior-session worker has no live transition to
|
|
2120
|
+
// observe. And a stale worker's pre-existing `turn_end` sits BEFORE the
|
|
2121
|
+
// parked cursor, so it is never replayed as a spurious completion (the
|
|
2122
|
+
// v0.14.23 stale-handback regression stays fixed).
|
|
2123
|
+
tails.set(agentId, { cursor: fileSize, pendingPartial: '', hasEmittedStart: false, watcher: null })
|
|
2124
|
+
if (doneAtBoot) {
|
|
2125
|
+
// Mirror the done-at-boot branch of the full path (below), minus the
|
|
2126
|
+
// whole-transcript parse: suppress a spurious completion notification
|
|
2127
|
+
// and schedule the terminal cleanup so the feed row is swept.
|
|
2128
|
+
entry.state = 'done'
|
|
2129
|
+
entry.completionNotified = true
|
|
2130
|
+
log?.(`subagent-watcher: ${agentId} done at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — historical; scheduling terminal cleanup (bounded tail probe, no whole-transcript parse, no watcher)`)
|
|
2131
|
+
scheduleTerminalCleanup(agentId)
|
|
2132
|
+
} else {
|
|
2133
|
+
log?.(`subagent-watcher: ${agentId} running at boot but stale (last write ${Math.round(fileAgeMs / 1000)}s ago > ${Math.round(inflightPromoteMaxAgeMs / 1000)}s) — leaving historical, skipping reconcile read (dead prior-session worker, not in-flight; no whole-transcript parse, no backfill, no watcher)`)
|
|
2134
|
+
}
|
|
2135
|
+
return
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
1953
2139
|
// Backfill jsonl_agent_id linkage. The PreToolUse hook inserts the row
|
|
1954
2140
|
// keyed on tool_use_id and doesn't know the JSONL stem yet (the JSONL
|
|
1955
2141
|
// doesn't exist when PreToolUse fires). We bridge that gap here: read
|
|
@@ -1976,7 +2162,12 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1976
2162
|
}
|
|
1977
2163
|
|
|
1978
2164
|
const tail: SubTail = {
|
|
1979
|
-
|
|
2165
|
+
// Normally read from the start (to replay the full transcript); on a
|
|
2166
|
+
// resume re-registration (#3315) start at the terminal boundary so only
|
|
2167
|
+
// the new turn is read. A resumed cursor sits on a JSONL newline boundary
|
|
2168
|
+
// (the prior turn ended with a complete `turn_end` line), so no partial
|
|
2169
|
+
// line is straddled.
|
|
2170
|
+
cursor: resumeFromCursor ?? 0,
|
|
1980
2171
|
pendingPartial: '',
|
|
1981
2172
|
hasEmittedStart: false,
|
|
1982
2173
|
watcher: null,
|
|
@@ -2274,24 +2465,42 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2274
2465
|
try { tail.watcher.close() } catch { /* ignore */ }
|
|
2275
2466
|
tail.watcher = null
|
|
2276
2467
|
}
|
|
2277
|
-
tails.delete(agentId)
|
|
2278
2468
|
const entry = registry.get(agentId)
|
|
2469
|
+
// Issue #3315: snapshot the JSONL byte size at cleanup so a later
|
|
2470
|
+
// SendMessage-resume — which appends a NEW turn to the SAME
|
|
2471
|
+
// agent-<id>.jsonl — is detected by `scanSubagentsDir` as growth PAST this
|
|
2472
|
+
// size and re-registered. The on-disk stat is the source of truth (it
|
|
2473
|
+
// captures any bytes that landed during the terminal-cleanup grace window,
|
|
2474
|
+
// so only genuinely post-cleanup writes trip a resume); fall back to the
|
|
2475
|
+
// tail cursor, then 0, when the file is unreadable.
|
|
2476
|
+
let terminalSize = tail?.cursor ?? 0
|
|
2477
|
+
if (entry?.filePath) {
|
|
2478
|
+
try {
|
|
2479
|
+
terminalSize = fs.statSync(entry.filePath).size
|
|
2480
|
+
} catch { /* keep the cursor-based fallback */ }
|
|
2481
|
+
}
|
|
2482
|
+
tails.delete(agentId)
|
|
2279
2483
|
if (entry?.filePath) {
|
|
2280
2484
|
knownFiles.delete(entry.filePath)
|
|
2281
2485
|
}
|
|
2282
2486
|
registry.delete(agentId)
|
|
2283
2487
|
// Issue #1116 (Bug B): record that this agent has been fully
|
|
2284
|
-
// processed so a rescan that rediscovers the still-present
|
|
2285
|
-
// doesn't re-register and re-notify.
|
|
2488
|
+
// processed so a rescan that rediscovers the still-present (unchanged)
|
|
2489
|
+
// JSONL doesn't re-register and re-notify. The recorded value is the
|
|
2490
|
+
// terminal byte size (issue #3315): `scanSubagentsDir` keeps suppressing
|
|
2491
|
+
// while the file stays at/below it, and re-registers once it grows past it
|
|
2492
|
+
// (a resume).
|
|
2286
2493
|
//
|
|
2287
|
-
// Fix #9a: bound the
|
|
2288
|
-
// gateway — evict the oldest id once the cap is hit (
|
|
2289
|
-
// order is insertion order, so
|
|
2494
|
+
// Fix #9a: bound the Map so it can't grow unboundedly over a long-lived
|
|
2495
|
+
// gateway — evict the oldest id once the cap is hit (Map iteration
|
|
2496
|
+
// order is insertion order, so the first key is the oldest). A re-set of
|
|
2497
|
+
// an existing key updates its value without moving it, so a re-completed
|
|
2498
|
+
// (resumed-then-finished-again) agent just refreshes its terminal size.
|
|
2290
2499
|
if (!terminatedAgentIds.has(agentId) && terminatedAgentIds.size >= terminatedAgentIdsCap) {
|
|
2291
|
-
const oldest = terminatedAgentIds.
|
|
2500
|
+
const oldest = terminatedAgentIds.keys().next().value
|
|
2292
2501
|
if (oldest != null) terminatedAgentIds.delete(oldest)
|
|
2293
2502
|
}
|
|
2294
|
-
terminatedAgentIds.
|
|
2503
|
+
terminatedAgentIds.set(agentId, terminalSize)
|
|
2295
2504
|
log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`)
|
|
2296
2505
|
// Authoritative terminal sweep → notify the worker-activity feed so its row
|
|
2297
2506
|
// for this agent is removed even on the paths that never fire `onFinish`
|
|
@@ -2865,11 +3074,68 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2865
3074
|
const filePath = join(subagentsPath, e)
|
|
2866
3075
|
if (knownFiles.has(filePath)) continue
|
|
2867
3076
|
const agentId = e.slice('agent-'.length, -'.jsonl'.length)
|
|
2868
|
-
// Issue #1116 (Bug B)
|
|
2869
|
-
//
|
|
2870
|
-
//
|
|
2871
|
-
//
|
|
2872
|
-
|
|
3077
|
+
// Issue #1116 (Bug B) + issue #3315 (SendMessage resume). A rediscovered
|
|
3078
|
+
// JSONL whose agent already went terminal + was swept is normally a
|
|
3079
|
+
// static leftover Claude Code left on disk — re-registering it would
|
|
3080
|
+
// re-read the terminal turn and re-fire a duplicate handback, looping
|
|
3081
|
+
// every grace-window (#1116). BUT a SendMessage resume appends a NEW turn
|
|
3082
|
+
// to the SAME agent-<id>.jsonl, growing the file PAST the size it had at
|
|
3083
|
+
// terminal cleanup. That growth is the deterministic, lifecycle-driven
|
|
3084
|
+
// signal (not a heuristic) that the worker resumed and must be tracked
|
|
3085
|
+
// again so its progress card resumes updating. Distinguish the two by the
|
|
3086
|
+
// recorded terminal size.
|
|
3087
|
+
const terminalSize = terminatedAgentIds.get(agentId)
|
|
3088
|
+
if (terminalSize !== undefined) {
|
|
3089
|
+
// A worker whose terminal state was a FALSE finish (silent-stall
|
|
3090
|
+
// synthesis) is owned by the issue-#3023 resurrection path
|
|
3091
|
+
// (`checkResurrections`), which carries the bounded-chain +
|
|
3092
|
+
// `onResurrect`/`onWorkerLost` semantics and re-reads from cursor 0 to
|
|
3093
|
+
// rebuild the still-in-progress turn. Skipping it here avoids
|
|
3094
|
+
// double-handling the same growth in one poll; this branch's concern is
|
|
3095
|
+
// GENUINE terminal completions (real `turn_end`, no false-finish record)
|
|
3096
|
+
// that were resumed via SendMessage (issue #3315). A false finish that
|
|
3097
|
+
// has since been evicted from `falseFinishTracker` (512-cap) is no
|
|
3098
|
+
// longer double-handled, so falling through to re-register it live here
|
|
3099
|
+
// is correct.
|
|
3100
|
+
if (falseFinishTracker.has(agentId)) continue
|
|
3101
|
+
let currentSize: number
|
|
3102
|
+
try {
|
|
3103
|
+
currentSize = fs.statSync(filePath).size
|
|
3104
|
+
} catch {
|
|
3105
|
+
continue // unreadable — nothing to resume; keep suppressing
|
|
3106
|
+
}
|
|
3107
|
+
// At/below the terminal snapshot ⇒ static leftover file (#1116 Bug B):
|
|
3108
|
+
// stay suppressed, do NOT re-register.
|
|
3109
|
+
if (currentSize <= terminalSize) continue
|
|
3110
|
+
// Grew past it ⇒ the worker resumed (#3315). Re-register as a fresh
|
|
3111
|
+
// LIVE worker, reading only from the terminal boundary so the
|
|
3112
|
+
// already-completed turn is not replayed. At-most-once per resume:
|
|
3113
|
+
// dropping the id from terminatedAgentIds means the next completion
|
|
3114
|
+
// re-adds it with the new (larger) terminal size, so one resume yields
|
|
3115
|
+
// exactly one re-registration.
|
|
3116
|
+
log?.(`subagent-watcher: agent ${agentId} resumed after terminal cleanup — JSONL grew ${terminalSize} → ${currentSize} bytes (SendMessage resume); re-registering as live so its progress card resumes (issue #3315)`)
|
|
3117
|
+
terminatedAgentIds.delete(agentId)
|
|
3118
|
+
historicalFiles.delete(filePath)
|
|
3119
|
+
knownFiles.add(filePath)
|
|
3120
|
+
// Issue #3373: the worker-activity feed latched a terminal `finalized`
|
|
3121
|
+
// gate for this agentId at its genuine completion, so the `onProgress`
|
|
3122
|
+
// cues the re-registration is about to re-fire would be SWALLOWED and no
|
|
3123
|
+
// card would re-surface. Fire onResume FIRST (before registerAgent, whose
|
|
3124
|
+
// initial read can synchronously re-emit onProgress) so the gateway clears
|
|
3125
|
+
// that gate; the subsequent live cue then repaints a fresh row. Fired only
|
|
3126
|
+
// on growth-past-terminal — never for a static leftover (the
|
|
3127
|
+
// `currentSize <= terminalSize` continue above), so the anti-zombie latch
|
|
3128
|
+
// still blocks genuinely-finished workers.
|
|
3129
|
+
if (config.onResume != null) {
|
|
3130
|
+
try {
|
|
3131
|
+
config.onResume(agentId, registry.get(agentId)?.description ?? 'sub-agent')
|
|
3132
|
+
} catch (cbErr) {
|
|
3133
|
+
log?.(`subagent-watcher: onResume callback error ${agentId}: ${(cbErr as Error).message}`)
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
registerAgent(filePath, agentId, terminalSize)
|
|
3137
|
+
continue
|
|
3138
|
+
}
|
|
2873
3139
|
knownFiles.add(filePath)
|
|
2874
3140
|
// During the initial boot scan, mark every discovered file as
|
|
2875
3141
|
// historical so stall-detection and completion notifications are
|
|
@@ -20,6 +20,11 @@ import { readFileSync } from 'node:fs'
|
|
|
20
20
|
import { resolve } from 'node:path'
|
|
21
21
|
|
|
22
22
|
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
23
|
+
// #2996 P4-B: drainActivitySummary (the card-OPEN persist site) and
|
|
24
|
+
// clearActivitySummary (the normal-CLOSE clear site) moved VERBATIM into
|
|
25
|
+
// narrative-lane.ts (bodies indented +2 inside the factory — multi-line
|
|
26
|
+
// markers below use the lane spelling).
|
|
27
|
+
const laneSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'), 'utf-8')
|
|
23
28
|
|
|
24
29
|
function between(src: string, startMarker: string, endMarker: string): string {
|
|
25
30
|
const after = src.split(startMarker)[1] ?? ''
|
|
@@ -30,7 +35,7 @@ describe('activity-card durability wiring', () => {
|
|
|
30
35
|
it('the card-OPEN path persists the durable handle (writeActivityCardRecord)', () => {
|
|
31
36
|
// The open branch runs when activityMessageId transitions null → set.
|
|
32
37
|
const openBranch = between(
|
|
33
|
-
|
|
38
|
+
laneSrc,
|
|
34
39
|
'if (turn.activityMessageId == null) {',
|
|
35
40
|
'turn.activityLastSentRender = target',
|
|
36
41
|
)
|
|
@@ -55,8 +60,8 @@ describe('activity-card durability wiring', () => {
|
|
|
55
60
|
|
|
56
61
|
it('the normal-CLOSE path clears the durable handle, id-scoped (reap-race guard)', () => {
|
|
57
62
|
const closeBody = between(
|
|
58
|
-
|
|
59
|
-
'const id = turn.activityMessageId\n
|
|
63
|
+
laneSrc,
|
|
64
|
+
'const id = turn.activityMessageId\n turn.activityMessageId = null',
|
|
60
65
|
'if (CLEAR_STATUS_ON_COMPLETION)',
|
|
61
66
|
)
|
|
62
67
|
expect(closeBody.length).toBeGreaterThan(50)
|
|
@@ -32,6 +32,21 @@ const gatewaySrc = readFileSync(
|
|
|
32
32
|
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
33
33
|
'utf-8',
|
|
34
34
|
)
|
|
35
|
+
// #2996 P4-A: one sticky-latch set site + the per-turn object-literal init live
|
|
36
|
+
// in handleSessionEvent (the tool-label/answer send-success + the `enqueue`
|
|
37
|
+
// turn ctor), which moved VERBATIM to stream-render.ts. The population spans
|
|
38
|
+
// gateway.ts + that module.
|
|
39
|
+
const streamSrc = readFileSync(
|
|
40
|
+
resolve(__dirname, '..', 'gateway', 'stream-render.ts'),
|
|
41
|
+
'utf-8',
|
|
42
|
+
)
|
|
43
|
+
// #2996 P4-B: the drain-side set-TRUE site (drainActivitySummary) moved on
|
|
44
|
+
// into narrative-lane.ts.
|
|
45
|
+
const laneSrc = readFileSync(
|
|
46
|
+
resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'),
|
|
47
|
+
'utf-8',
|
|
48
|
+
)
|
|
49
|
+
const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc + '\n' + laneSrc
|
|
35
50
|
|
|
36
51
|
describe('M-2: activityEverOpened sticky-true invariant', () => {
|
|
37
52
|
it('activityEverOpened = true appears only at the two feed-OPEN signal sites', () => {
|
|
@@ -39,19 +54,19 @@ describe('M-2: activityEverOpened sticky-true invariant', () => {
|
|
|
39
54
|
// handback pre-turn ADOPTION seed (an adopted turn only edits, so it stamps
|
|
40
55
|
// the flag itself). Both are set-TRUE; the sticky invariant (no reset to
|
|
41
56
|
// false) is enforced by the next test.
|
|
42
|
-
const setTrueMatches = [...
|
|
57
|
+
const setTrueMatches = [...gatewayAndStreamSrc.matchAll(/activityEverOpened\s*=\s*true/g)]
|
|
43
58
|
expect(setTrueMatches).toHaveLength(2)
|
|
44
59
|
})
|
|
45
60
|
|
|
46
61
|
it('turn.activityEverOpened = false never appears (no standalone reset)', () => {
|
|
47
62
|
// The only `false` value must be in the object literal initialiser
|
|
48
63
|
// (e.g. `activityEverOpened: false`), never a standalone reassignment.
|
|
49
|
-
const resetMatches = [...
|
|
64
|
+
const resetMatches = [...gatewayAndStreamSrc.matchAll(/turn\.activityEverOpened\s*=\s*false/g)]
|
|
50
65
|
expect(resetMatches).toHaveLength(0)
|
|
51
66
|
})
|
|
52
67
|
|
|
53
68
|
it('activityEverOpened is initialised false in the turn object literal (per-turn reset)', () => {
|
|
54
69
|
// The object literal form `activityEverOpened: false` must exist (per-turn init).
|
|
55
|
-
expect(
|
|
70
|
+
expect(gatewayAndStreamSrc).toMatch(/activityEverOpened:\s*false/)
|
|
56
71
|
})
|
|
57
72
|
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome pins for the agent-emitted inline-keyboard callback family
|
|
3
|
+
* (`agent:` prefix, #271) extracted from the gateway's callback_query
|
|
4
|
+
* dispatcher (switchroom#2996 P6). Drives handleAgentButtonCallback with a
|
|
5
|
+
* mock ctx + injected deps and asserts: fall-through for non-agent data, the
|
|
6
|
+
* allowFrom gate, the ack-toast-first contract (#710), the exact InboundMessage
|
|
7
|
+
* envelope routed through the injected turn-safe delivery, the restarting
|
|
8
|
+
* notice only on bridge-offline, and the single-use keyboard strip + meta
|
|
9
|
+
* cleanup.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
13
|
+
import {
|
|
14
|
+
handleAgentButtonCallback,
|
|
15
|
+
type AgentButtonCallbackDeps,
|
|
16
|
+
} from '../gateway/agent-button-callback-handler.js'
|
|
17
|
+
|
|
18
|
+
const DATA = 'agent:approve_pr'
|
|
19
|
+
|
|
20
|
+
function makeCtx(over: Record<string, unknown> = {}) {
|
|
21
|
+
return {
|
|
22
|
+
from: { id: 42, username: 'ken' },
|
|
23
|
+
chat: { id: 42 },
|
|
24
|
+
callbackQuery: {
|
|
25
|
+
message: {
|
|
26
|
+
message_id: 77,
|
|
27
|
+
text: 'Deploy?',
|
|
28
|
+
reply_markup: {
|
|
29
|
+
inline_keyboard: [[{ text: 'Approve', callback_data: DATA }]],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
answerCallbackQuery: vi.fn(async () => {}),
|
|
34
|
+
editMessageText: vi.fn(async () => {}),
|
|
35
|
+
editMessageReplyMarkup: vi.fn(async () => {}),
|
|
36
|
+
...over,
|
|
37
|
+
} as any
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeDeps(over: Partial<AgentButtonCallbackDeps> = {}) {
|
|
41
|
+
const deliverButtonTapInbound = vi.fn(async () => 'delivered')
|
|
42
|
+
const sendRestartingNotice = vi.fn()
|
|
43
|
+
const metaDelete = vi.fn()
|
|
44
|
+
const deps: AgentButtonCallbackDeps = {
|
|
45
|
+
loadAccess: () => ({ allowFrom: ['42'] }),
|
|
46
|
+
agentButtonMeta: { get: () => undefined, delete: metaDelete },
|
|
47
|
+
deliverButtonTapInbound,
|
|
48
|
+
sendRestartingNotice,
|
|
49
|
+
buttonConfirmSingleUseWarned: new Set(),
|
|
50
|
+
buttonConfirmParseModeWarned: new Set(),
|
|
51
|
+
log: vi.fn(),
|
|
52
|
+
...over,
|
|
53
|
+
}
|
|
54
|
+
return { deps, deliverButtonTapInbound, sendRestartingNotice, metaDelete }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('handleAgentButtonCallback — routing + gates', () => {
|
|
58
|
+
it('returns false for non-agent callback data', async () => {
|
|
59
|
+
const { deps } = makeDeps()
|
|
60
|
+
const ctx = makeCtx()
|
|
61
|
+
expect(await handleAgentButtonCallback(ctx, 'perm:allow:abcde', deps)).toBe(false)
|
|
62
|
+
expect(ctx.answerCallbackQuery).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('gates non-allowlisted senders before any delivery', async () => {
|
|
66
|
+
const { deps, deliverButtonTapInbound } = makeDeps()
|
|
67
|
+
const ctx = makeCtx({ from: { id: 666 } })
|
|
68
|
+
expect(await handleAgentButtonCallback(ctx, DATA, deps)).toBe(true)
|
|
69
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
|
|
70
|
+
expect(deliverButtonTapInbound).not.toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('handleAgentButtonCallback — delivery envelope', () => {
|
|
75
|
+
it('acks FIRST with the default toast, then delivers the exact inbound (#710/#271)', async () => {
|
|
76
|
+
const { deps, deliverButtonTapInbound } = makeDeps()
|
|
77
|
+
const ctx = makeCtx()
|
|
78
|
+
process.env.SWITCHROOM_AGENT_NAME = 'klanker'
|
|
79
|
+
expect(await handleAgentButtonCallback(ctx, DATA, deps)).toBe(true)
|
|
80
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '✓ received' })
|
|
81
|
+
// Ack precedes delivery.
|
|
82
|
+
expect(ctx.answerCallbackQuery.mock.invocationCallOrder[0]).toBeLessThan(
|
|
83
|
+
deliverButtonTapInbound.mock.invocationCallOrder[0],
|
|
84
|
+
)
|
|
85
|
+
const [agent, msg] = deliverButtonTapInbound.mock.calls[0] as unknown as [string, any]
|
|
86
|
+
expect(agent).toBe('klanker')
|
|
87
|
+
expect(msg).toMatchObject({
|
|
88
|
+
type: 'inbound',
|
|
89
|
+
chatId: '42',
|
|
90
|
+
messageId: 77,
|
|
91
|
+
user: 'ken',
|
|
92
|
+
userId: 42,
|
|
93
|
+
text: '[user tapped button: "Approve" → approve_pr]',
|
|
94
|
+
meta: expect.objectContaining({
|
|
95
|
+
button_callback: 'true',
|
|
96
|
+
button_callback_data: 'approve_pr',
|
|
97
|
+
button_text: 'Approve',
|
|
98
|
+
}),
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('uses the stashed per-button ack_text when present', async () => {
|
|
103
|
+
// meta is keyed by the RAW payload (prefix stripped).
|
|
104
|
+
const metaMap = new Map([['approve_pr', { ack_text: 'On it!' }]])
|
|
105
|
+
const { deps } = makeDeps({
|
|
106
|
+
agentButtonMeta: { get: () => metaMap as any, delete: vi.fn() },
|
|
107
|
+
})
|
|
108
|
+
const ctx = makeCtx()
|
|
109
|
+
await handleAgentButtonCallback(ctx, DATA, deps)
|
|
110
|
+
expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'On it!' })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('shows the restarting notice ONLY on bridge-offline', async () => {
|
|
114
|
+
const { deps, sendRestartingNotice } = makeDeps({
|
|
115
|
+
deliverButtonTapInbound: vi.fn(async () => 'buffered-bridge-offline'),
|
|
116
|
+
})
|
|
117
|
+
await handleAgentButtonCallback(makeCtx(), DATA, deps)
|
|
118
|
+
expect(sendRestartingNotice).toHaveBeenCalledWith('42', undefined)
|
|
119
|
+
|
|
120
|
+
const second = makeDeps({
|
|
121
|
+
deliverButtonTapInbound: vi.fn(async () => 'buffered-mid-turn'),
|
|
122
|
+
})
|
|
123
|
+
await handleAgentButtonCallback(makeCtx(), DATA, second.deps)
|
|
124
|
+
expect(second.sendRestartingNotice).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('handleAgentButtonCallback — single-use strip', () => {
|
|
129
|
+
it('strips the keyboard by default (no stashed meta) after the tap', async () => {
|
|
130
|
+
const { deps } = makeDeps()
|
|
131
|
+
const ctx = makeCtx()
|
|
132
|
+
await handleAgentButtonCallback(ctx, DATA, deps)
|
|
133
|
+
expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({
|
|
134
|
+
reply_markup: { inline_keyboard: [] },
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('preserves the keyboard when a button opts out via single_use:false', async () => {
|
|
139
|
+
const metaMap = new Map([['approve_pr', { single_use: false }]])
|
|
140
|
+
const metaDelete = vi.fn()
|
|
141
|
+
const { deps } = makeDeps({
|
|
142
|
+
agentButtonMeta: { get: () => metaMap as any, delete: metaDelete },
|
|
143
|
+
})
|
|
144
|
+
const ctx = makeCtx()
|
|
145
|
+
await handleAgentButtonCallback(ctx, DATA, deps)
|
|
146
|
+
expect(ctx.editMessageReplyMarkup).not.toHaveBeenCalled()
|
|
147
|
+
expect(metaDelete).not.toHaveBeenCalled()
|
|
148
|
+
})
|
|
149
|
+
})
|