switchroom 0.17.6 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome-based regression test for the deterministic silent-turn card climb.
|
|
3
|
+
*
|
|
4
|
+
* Phase 4(b) of `reference/rfcs/deterministic-turn-liveness.md`; serves
|
|
5
|
+
* `reference/jobs/know-what-my-agent-is-doing.md` (a busy-but-silent turn must
|
|
6
|
+
* visibly escalate, never freeze).
|
|
7
|
+
*
|
|
8
|
+
* ## What is REAL here, and what is a spy (no overclaim)
|
|
9
|
+
*
|
|
10
|
+
* The gateway is a start-on-import IIFE that cannot be instantiated in-process
|
|
11
|
+
* (the documented constraint behind every `*-wiring` structural test in this
|
|
12
|
+
* suite). So the shipped 0-label tick body was EXTRACTED into
|
|
13
|
+
* `feed-heartbeat-climb.ts` (`runSilentTurnHeartbeatTick`) and the gateway's
|
|
14
|
+
* `feedHeartbeatTick` calls it with its real deps — meaning the function under
|
|
15
|
+
* test IS the production decision logic, byte-for-byte, not a hand-copied
|
|
16
|
+
* mirror. What this test drives:
|
|
17
|
+
*
|
|
18
|
+
* - REAL: `runSilentTurnHeartbeatTick` + `silentTurnClimbRender` — the shipped
|
|
19
|
+
* guard placement (0-label check, open-vs-climb split, stale-guard, null
|
|
20
|
+
* render), the gate SEQUENCE (cardDrainGate → mayDrain → openOrEditCard →
|
|
21
|
+
* drain), and the rendered card bodies.
|
|
22
|
+
* - SPY: the injected deps. The `drain` seam is where the gateway's
|
|
23
|
+
* `drainActivitySummary` → `robustApiCall(bot.api.sendMessage /
|
|
24
|
+
* bot.api.editMessageText)` lives in production; the spy implements the
|
|
25
|
+
* drain's transport contract (send when no card id, edit once set) and
|
|
26
|
+
* records every call with a timestamp. The real `drainActivitySummary` /
|
|
27
|
+
* grammY transport is NOT executed here — the gateway wiring of the real
|
|
28
|
+
* deps (and the real drain call) is pinned structurally by
|
|
29
|
+
* `tests/feed-heartbeat-liveness-open.test.ts`, and end-to-end by the UAT
|
|
30
|
+
* liveness scenarios.
|
|
31
|
+
*
|
|
32
|
+
* ## What it asserts
|
|
33
|
+
*
|
|
34
|
+
* 1. A ~40s silent tool call produces >=4 card EDITS with a CLIMBING elapsed —
|
|
35
|
+
* the card never freezes. (Acceptance criterion of the RFC.)
|
|
36
|
+
* 2. Every climb mutation is an EDIT of the already-open card — never a new
|
|
37
|
+
* send — so no mid-turn mutation can push-notify the device.
|
|
38
|
+
* 3. Each climb edit passes through the full gate sequence in order:
|
|
39
|
+
* setPendingRender → cardDrainGate → mayDrain → openOrEditCard → drain
|
|
40
|
+
* (mis-wiring the guards inside the tick body fails this).
|
|
41
|
+
* 4. `mayDrain === false` (drain in flight) suppresses the edit — the
|
|
42
|
+
* single-flight guard is honoured.
|
|
43
|
+
* 5. The climb yields to model narration: a surfaced tool label
|
|
44
|
+
* (`mirrorLineCount > 0`) returns `false` (labelled branch owns the tick)
|
|
45
|
+
* and emits nothing.
|
|
46
|
+
* 6. The stale-guard: a turn younger than `minStaleMs` produces no edit (same
|
|
47
|
+
* symmetry the labelled branch has).
|
|
48
|
+
*
|
|
49
|
+
* ## Why this FAILS on pre-fix code (its acceptance criterion)
|
|
50
|
+
*
|
|
51
|
+
* Pre-fix, the 0-label branch delegated solely to `openLivenessFeedIfDue`,
|
|
52
|
+
* whose WHEN-gate returns false the moment a card is open — an open 0-label
|
|
53
|
+
* card received ZERO edits during a silent tool. Reverting
|
|
54
|
+
* `runSilentTurnHeartbeatTick` to that behaviour (the open-path delegate with
|
|
55
|
+
* no climb) drives this test's edit count to 0, failing assertion 1 — through
|
|
56
|
+
* the same shipped function the gateway calls. Removing the gateway call site
|
|
57
|
+
* instead is caught by the structural pin in
|
|
58
|
+
* `tests/feed-heartbeat-liveness-open.test.ts`.
|
|
59
|
+
*/
|
|
60
|
+
import { describe, it, expect } from 'vitest'
|
|
61
|
+
import {
|
|
62
|
+
runSilentTurnHeartbeatTick,
|
|
63
|
+
silentTurnClimbRender,
|
|
64
|
+
type SilentTurnTickDeps,
|
|
65
|
+
} from '../feed-heartbeat-climb.js'
|
|
66
|
+
|
|
67
|
+
// The gateway constants that drive the tick cadence (gateway.ts).
|
|
68
|
+
const FEED_HEARTBEAT_TICK_MS = 6_000
|
|
69
|
+
const FEED_HEARTBEAT_MIN_STALE_MS = 6_000
|
|
70
|
+
const FEED_LIVENESS_OPEN_MS = 1_200
|
|
71
|
+
|
|
72
|
+
/** A recorded transport call with the simulated wall-clock time it happened at. */
|
|
73
|
+
interface TransportCall {
|
|
74
|
+
kind: 'sendMessage' | 'editMessageText'
|
|
75
|
+
at: number
|
|
76
|
+
text: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Parse the climbing elapsed (in seconds) out of a rendered card header line
|
|
80
|
+
* (`_6s · 0 tools_`, `_1m05s · 0 tools_`). Returns null if not present. */
|
|
81
|
+
function parseHeaderElapsedSeconds(text: string): number | null {
|
|
82
|
+
const m = text.match(/_((?:(\d+)m)?(\d+)s) · \d+ tools?_/)
|
|
83
|
+
if (m == null) return null
|
|
84
|
+
const minutes = m[2] ? Number(m[2]) : 0
|
|
85
|
+
const seconds = Number(m[3])
|
|
86
|
+
return minutes * 60 + seconds
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Test double for the turn's card surface + injected deps. The `drain` spy
|
|
91
|
+
* implements the drain's transport contract — `sendMessage` (an OPEN, the only
|
|
92
|
+
* mutation that push-notifies) when no card id exists, `editMessageText` (a
|
|
93
|
+
* silent card EDIT) once it does — standing in for `drainActivitySummary` →
|
|
94
|
+
* `robustApiCall(bot.api.*)`. Also records the gate-call ORDER per tick so the
|
|
95
|
+
* shipped sequence is pinned, and simulates the shared OPEN path
|
|
96
|
+
* (`openLivenessFeedIfDue`) opening the placeholder card.
|
|
97
|
+
*/
|
|
98
|
+
class TurnHarness {
|
|
99
|
+
calls: TransportCall[] = []
|
|
100
|
+
order: string[] = []
|
|
101
|
+
activityMessageId: number | null = null
|
|
102
|
+
pendingRender: string | null = null
|
|
103
|
+
mayDrainResult = true
|
|
104
|
+
private nextId = 100
|
|
105
|
+
now = 0
|
|
106
|
+
|
|
107
|
+
/** The real gateway wires these exact seams; each spy records its call. */
|
|
108
|
+
deps(openRender: () => string | null): SilentTurnTickDeps {
|
|
109
|
+
return {
|
|
110
|
+
openLivenessFeedIfDue: () => {
|
|
111
|
+
this.order.push('openLivenessFeedIfDue')
|
|
112
|
+
// Mirror of the shared OPEN path's contract: opens once, past its
|
|
113
|
+
// threshold, via a fresh sendMessage.
|
|
114
|
+
if (this.activityMessageId != null) return
|
|
115
|
+
const rendered = openRender()
|
|
116
|
+
if (rendered == null) return
|
|
117
|
+
this.activityMessageId = this.nextId++
|
|
118
|
+
this.calls.push({ kind: 'sendMessage', at: this.now, text: rendered })
|
|
119
|
+
},
|
|
120
|
+
setPendingRender: (rendered) => {
|
|
121
|
+
this.order.push('setPendingRender')
|
|
122
|
+
this.pendingRender = rendered
|
|
123
|
+
},
|
|
124
|
+
cardDrainGate: (run) => {
|
|
125
|
+
this.order.push('cardDrainGate')
|
|
126
|
+
run()
|
|
127
|
+
},
|
|
128
|
+
mayDrain: () => {
|
|
129
|
+
this.order.push('mayDrain')
|
|
130
|
+
return this.mayDrainResult
|
|
131
|
+
},
|
|
132
|
+
openOrEditCard: (apply) => {
|
|
133
|
+
this.order.push('openOrEditCard')
|
|
134
|
+
apply()
|
|
135
|
+
},
|
|
136
|
+
drain: () => {
|
|
137
|
+
this.order.push('drain')
|
|
138
|
+
// drainActivitySummary's transport contract: OPEN when no card id,
|
|
139
|
+
// EDIT in place once set. Here the card is always open (the tick only
|
|
140
|
+
// reaches drain via the climb), so this is always an edit.
|
|
141
|
+
const text = this.pendingRender ?? ''
|
|
142
|
+
if (this.activityMessageId == null) {
|
|
143
|
+
this.activityMessageId = this.nextId++
|
|
144
|
+
this.calls.push({ kind: 'sendMessage', at: this.now, text })
|
|
145
|
+
} else {
|
|
146
|
+
this.calls.push({ kind: 'editMessageText', at: this.now, text })
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
get sends(): TransportCall[] {
|
|
153
|
+
return this.calls.filter((c) => c.kind === 'sendMessage')
|
|
154
|
+
}
|
|
155
|
+
get edits(): TransportCall[] {
|
|
156
|
+
return this.calls.filter((c) => c.kind === 'editMessageText')
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Drive the REAL tick across a silent window, the way the gateway's 6s
|
|
161
|
+
* setInterval does: same view fields, real deps thunks. */
|
|
162
|
+
function runSilentWindow(harness: TurnHarness, turnStart: number, windowMs: number): void {
|
|
163
|
+
const deps = harness.deps(() => 'placeholder')
|
|
164
|
+
for (let now = turnStart; now <= turnStart + windowMs; now += FEED_HEARTBEAT_TICK_MS) {
|
|
165
|
+
harness.now = now
|
|
166
|
+
const handled = runSilentTurnHeartbeatTick(
|
|
167
|
+
{
|
|
168
|
+
mirrorLineCount: 0,
|
|
169
|
+
activityMessageId: harness.activityMessageId,
|
|
170
|
+
labeledToolCount: 0,
|
|
171
|
+
ageMs: now - turnStart,
|
|
172
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
173
|
+
},
|
|
174
|
+
deps,
|
|
175
|
+
)
|
|
176
|
+
expect(handled).toBe(true) // 0-label turn: this branch owns every tick
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
describe('deterministic silent-turn card climb — real tick against a spy transport', () => {
|
|
181
|
+
it('a ~40s silent tool call yields >=4 card edits with climbing elapsed (never freezes)', () => {
|
|
182
|
+
const harness = new TurnHarness()
|
|
183
|
+
const turnStart = 1_000_000 // arbitrary simulated epoch
|
|
184
|
+
// Pre-open the card the way production does (early open ~1.2s in via the
|
|
185
|
+
// shared OPEN path) — the freeze reproduced specifically on an OPEN card.
|
|
186
|
+
harness.now = turnStart + FEED_LIVENESS_OPEN_MS
|
|
187
|
+
harness.deps(() => 'placeholder').openLivenessFeedIfDue()
|
|
188
|
+
expect(harness.activityMessageId).not.toBeNull()
|
|
189
|
+
|
|
190
|
+
runSilentWindow(harness, turnStart, 40_000)
|
|
191
|
+
|
|
192
|
+
// Exactly one OPEN (the pre-opened card) — the climb never sends.
|
|
193
|
+
expect(harness.sends.length).toBe(1)
|
|
194
|
+
|
|
195
|
+
// The acceptance criterion: the card is EDITED at least 4 times across the gap.
|
|
196
|
+
expect(
|
|
197
|
+
harness.edits.length,
|
|
198
|
+
`expected >=4 climbing card edits across a 40s silent tool, got ${harness.edits.length} ` +
|
|
199
|
+
`(pre-fix code freezes the 0-label card and produces 0 edits)`,
|
|
200
|
+
).toBeGreaterThanOrEqual(4)
|
|
201
|
+
|
|
202
|
+
// The elapsed on each successive edit strictly climbs — no frozen counter.
|
|
203
|
+
const elapsedSeries = harness.edits
|
|
204
|
+
.map((c) => parseHeaderElapsedSeconds(c.text))
|
|
205
|
+
.filter((n): n is number => n != null)
|
|
206
|
+
expect(elapsedSeries.length).toBe(harness.edits.length)
|
|
207
|
+
for (let i = 1; i < elapsedSeries.length; i++) {
|
|
208
|
+
expect(
|
|
209
|
+
elapsedSeries[i],
|
|
210
|
+
`card elapsed must climb: edit[${i}]=${elapsedSeries[i]}s not > edit[${i - 1}]=${elapsedSeries[i - 1]}s`,
|
|
211
|
+
).toBeGreaterThan(elapsedSeries[i - 1])
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Worst-case visible-update gap stays within ~two tick windows (RFC bound).
|
|
215
|
+
const editTimes = [harness.sends[0].at, ...harness.edits.map((c) => c.at)]
|
|
216
|
+
for (let i = 1; i < editTimes.length; i++) {
|
|
217
|
+
expect(editTimes[i] - editTimes[i - 1]).toBeLessThanOrEqual(2 * FEED_HEARTBEAT_TICK_MS)
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('every climb mutation is an editMessageText, never a new sendMessage (edits do not push-notify)', () => {
|
|
222
|
+
const harness = new TurnHarness()
|
|
223
|
+
const turnStart = 2_000_000
|
|
224
|
+
harness.now = turnStart + FEED_LIVENESS_OPEN_MS
|
|
225
|
+
harness.deps(() => 'placeholder').openLivenessFeedIfDue()
|
|
226
|
+
runSilentWindow(harness, turnStart, 40_000)
|
|
227
|
+
|
|
228
|
+
expect(harness.calls[0].kind).toBe('sendMessage')
|
|
229
|
+
for (const call of harness.calls.slice(1)) {
|
|
230
|
+
expect(call.kind).toBe('editMessageText')
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('each climb edit passes the full shipped gate sequence in order (pendingRender → cardDrainGate → mayDrain → openOrEditCard → drain)', () => {
|
|
235
|
+
const harness = new TurnHarness()
|
|
236
|
+
harness.activityMessageId = 55 // card already open
|
|
237
|
+
harness.now = 12_000
|
|
238
|
+
runSilentTurnHeartbeatTick(
|
|
239
|
+
{
|
|
240
|
+
mirrorLineCount: 0,
|
|
241
|
+
activityMessageId: harness.activityMessageId,
|
|
242
|
+
labeledToolCount: 0,
|
|
243
|
+
ageMs: 12_000,
|
|
244
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
245
|
+
},
|
|
246
|
+
harness.deps(() => null),
|
|
247
|
+
)
|
|
248
|
+
expect(harness.order).toEqual([
|
|
249
|
+
'setPendingRender',
|
|
250
|
+
'cardDrainGate',
|
|
251
|
+
'mayDrain',
|
|
252
|
+
'openOrEditCard',
|
|
253
|
+
'drain',
|
|
254
|
+
])
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('a drain already in flight (mayDrain=false) suppresses the edit — single-flight honoured', () => {
|
|
258
|
+
const harness = new TurnHarness()
|
|
259
|
+
harness.activityMessageId = 55
|
|
260
|
+
harness.mayDrainResult = false
|
|
261
|
+
harness.now = 12_000
|
|
262
|
+
runSilentTurnHeartbeatTick(
|
|
263
|
+
{
|
|
264
|
+
mirrorLineCount: 0,
|
|
265
|
+
activityMessageId: 55,
|
|
266
|
+
labeledToolCount: 0,
|
|
267
|
+
ageMs: 12_000,
|
|
268
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
269
|
+
},
|
|
270
|
+
harness.deps(() => null),
|
|
271
|
+
)
|
|
272
|
+
expect(harness.edits.length).toBe(0)
|
|
273
|
+
// The gate was consulted, then the drain was NOT reached.
|
|
274
|
+
expect(harness.order).toContain('mayDrain')
|
|
275
|
+
expect(harness.order).not.toContain('drain')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('the climb yields to model narration (a surfaced tool label hands the tick to the labelled branch)', () => {
|
|
279
|
+
const harness = new TurnHarness()
|
|
280
|
+
harness.activityMessageId = 55
|
|
281
|
+
const handled = runSilentTurnHeartbeatTick(
|
|
282
|
+
{
|
|
283
|
+
mirrorLineCount: 3, // narration / tool labels surfaced
|
|
284
|
+
activityMessageId: 55,
|
|
285
|
+
labeledToolCount: 2,
|
|
286
|
+
ageMs: 30_000,
|
|
287
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
288
|
+
},
|
|
289
|
+
harness.deps(() => null),
|
|
290
|
+
)
|
|
291
|
+
expect(handled).toBe(false) // labelled branch owns the tick
|
|
292
|
+
expect(harness.calls.length).toBe(0)
|
|
293
|
+
expect(harness.order.length).toBe(0)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('the stale-guard: a turn younger than minStaleMs produces no climb edit (labelled-branch symmetry)', () => {
|
|
297
|
+
const harness = new TurnHarness()
|
|
298
|
+
harness.activityMessageId = 55
|
|
299
|
+
const handled = runSilentTurnHeartbeatTick(
|
|
300
|
+
{
|
|
301
|
+
mirrorLineCount: 0,
|
|
302
|
+
activityMessageId: 55,
|
|
303
|
+
labeledToolCount: 0,
|
|
304
|
+
ageMs: FEED_HEARTBEAT_MIN_STALE_MS - 1,
|
|
305
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
306
|
+
},
|
|
307
|
+
harness.deps(() => null),
|
|
308
|
+
)
|
|
309
|
+
expect(handled).toBe(true) // still the 0-label branch's tick
|
|
310
|
+
expect(harness.calls.length).toBe(0)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('the climb does not OPEN a card — no card yet delegates to the one shared OPEN path', () => {
|
|
314
|
+
const harness = new TurnHarness()
|
|
315
|
+
harness.now = 20_000
|
|
316
|
+
runSilentTurnHeartbeatTick(
|
|
317
|
+
{
|
|
318
|
+
mirrorLineCount: 0,
|
|
319
|
+
activityMessageId: null,
|
|
320
|
+
labeledToolCount: 0,
|
|
321
|
+
ageMs: 20_000,
|
|
322
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
323
|
+
},
|
|
324
|
+
harness.deps(() => 'open-render'),
|
|
325
|
+
)
|
|
326
|
+
// Delegated to openLivenessFeedIfDue; nothing else ran.
|
|
327
|
+
expect(harness.order).toEqual(['openLivenessFeedIfDue'])
|
|
328
|
+
expect(harness.sends.length).toBe(1) // the OPEN path opened it
|
|
329
|
+
expect(harness.edits.length).toBe(0)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('silentTurnClimbRender refuses when no card is open (opening is reply-is-last gated elsewhere)', () => {
|
|
333
|
+
expect(
|
|
334
|
+
silentTurnClimbRender({ mirrorLineCount: 0, activityMessageId: null, labeledToolCount: 0, ageMs: 30_000 }),
|
|
335
|
+
).toBeNull()
|
|
336
|
+
})
|
|
337
|
+
})
|
|
@@ -1296,6 +1296,145 @@ describe('startSubagentWatcher', () => {
|
|
|
1296
1296
|
})
|
|
1297
1297
|
})
|
|
1298
1298
|
|
|
1299
|
+
// ─── deterministic-turn-liveness.md Known-Gap 2 — worktree-isolated sub-agents ──
|
|
1300
|
+
|
|
1301
|
+
describe('worktree-isolated sub-agent visibility (turn-liveness Known Gap 2)', () => {
|
|
1302
|
+
/**
|
|
1303
|
+
* A sub-agent dispatched with `switchroom worktree claim` runs under a
|
|
1304
|
+
* different cwd than the parent agent, so Claude Code mints a
|
|
1305
|
+
* *different* `.claude/projects/<slug>/` tree for it. Pre-fix, the
|
|
1306
|
+
* #1116 foreign-slug filter above skips that slug forever — no
|
|
1307
|
+
* activity stamp, no `🛠 Worker` feed, for the whole worktree worker's
|
|
1308
|
+
* run. `extraWatchCwdsProvider` closes that gap: any cwd it returns is
|
|
1309
|
+
* treated as a first-class watched slug, same as `agentCwd`.
|
|
1310
|
+
*/
|
|
1311
|
+
|
|
1312
|
+
let tmpRoot = ''
|
|
1313
|
+
const startedWatchers: Array<{ stop(): void }> = []
|
|
1314
|
+
|
|
1315
|
+
beforeEach(() => {
|
|
1316
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'switchroom-watcher-worktree-gap2-'))
|
|
1317
|
+
})
|
|
1318
|
+
|
|
1319
|
+
afterEach(() => {
|
|
1320
|
+
while (startedWatchers.length) {
|
|
1321
|
+
try { startedWatchers.pop()?.stop() } catch { /* ignore */ }
|
|
1322
|
+
}
|
|
1323
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
function startWatcherSync(opts: {
|
|
1327
|
+
agentDir: string
|
|
1328
|
+
agentCwd?: string
|
|
1329
|
+
extraWatchCwdsProvider?: () => string[]
|
|
1330
|
+
}): {
|
|
1331
|
+
logs: string[]
|
|
1332
|
+
poll: () => void
|
|
1333
|
+
watcher: ReturnType<typeof startSubagentWatcher>
|
|
1334
|
+
} {
|
|
1335
|
+
const logs: string[] = []
|
|
1336
|
+
const intervals: Array<{ fn: () => void; ref: number }> = []
|
|
1337
|
+
let nextRef = 1
|
|
1338
|
+
const watcher = startSubagentWatcher({
|
|
1339
|
+
agentDir: opts.agentDir,
|
|
1340
|
+
...(opts.agentCwd !== undefined ? { agentCwd: opts.agentCwd } : {}),
|
|
1341
|
+
...(opts.extraWatchCwdsProvider !== undefined
|
|
1342
|
+
? { extraWatchCwdsProvider: opts.extraWatchCwdsProvider }
|
|
1343
|
+
: {}),
|
|
1344
|
+
stallThresholdMs: 60_000,
|
|
1345
|
+
rescanMs: 500,
|
|
1346
|
+
now: () => Date.now(),
|
|
1347
|
+
setInterval: (fn) => {
|
|
1348
|
+
const ref = nextRef++
|
|
1349
|
+
intervals.push({ fn, ref })
|
|
1350
|
+
return { ref }
|
|
1351
|
+
},
|
|
1352
|
+
clearInterval: () => {},
|
|
1353
|
+
setTimeout: () => ({ ref: 0 }),
|
|
1354
|
+
clearTimeout: () => {},
|
|
1355
|
+
log: (msg) => logs.push(msg),
|
|
1356
|
+
})
|
|
1357
|
+
startedWatchers.push(watcher)
|
|
1358
|
+
return { logs, poll: () => intervals[0]?.fn(), watcher }
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
it('watches a worktree-isolated cwd slug returned by extraWatchCwdsProvider', () => {
|
|
1362
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
1363
|
+
const agentCwd = '/home/test/agent-own'
|
|
1364
|
+
const worktreeCwd = '/home/test/.switchroom/worktrees/wt-abc123'
|
|
1365
|
+
const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
|
|
1366
|
+
const worktreeSlug = join(
|
|
1367
|
+
agentDir,
|
|
1368
|
+
'.claude',
|
|
1369
|
+
'projects',
|
|
1370
|
+
'-home-test--switchroom-worktrees-wt-abc123',
|
|
1371
|
+
'sess-B',
|
|
1372
|
+
'subagents',
|
|
1373
|
+
)
|
|
1374
|
+
mkdirSync(ownSubagents, { recursive: true })
|
|
1375
|
+
mkdirSync(worktreeSlug, { recursive: true })
|
|
1376
|
+
writeFileSync(
|
|
1377
|
+
join(ownSubagents, 'agent-ownworker.jsonl'),
|
|
1378
|
+
buildJSONL(subAgentUserMsg('legit work')),
|
|
1379
|
+
)
|
|
1380
|
+
writeFileSync(
|
|
1381
|
+
join(worktreeSlug, 'agent-worktreeworker.jsonl'),
|
|
1382
|
+
buildJSONL(subAgentUserMsg('worktree-isolated work')),
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
// Pre-fix acceptance: without extraWatchCwdsProvider, the worktree
|
|
1386
|
+
// slug is a "foreign" slug per #1116 and is skipped — this is the
|
|
1387
|
+
// regression this test guards against.
|
|
1388
|
+
const preFix = startWatcherSync({ agentDir, agentCwd })
|
|
1389
|
+
preFix.poll()
|
|
1390
|
+
expect(preFix.watcher.getRegistry().has('worktreeworker')).toBe(false)
|
|
1391
|
+
preFix.watcher.stop()
|
|
1392
|
+
|
|
1393
|
+
const h = startWatcherSync({
|
|
1394
|
+
agentDir,
|
|
1395
|
+
agentCwd,
|
|
1396
|
+
extraWatchCwdsProvider: () => [worktreeCwd],
|
|
1397
|
+
})
|
|
1398
|
+
h.poll()
|
|
1399
|
+
|
|
1400
|
+
const reg = h.watcher.getRegistry()
|
|
1401
|
+
expect(reg.has('ownworker')).toBe(true)
|
|
1402
|
+
expect(reg.has('worktreeworker')).toBe(true)
|
|
1403
|
+
})
|
|
1404
|
+
|
|
1405
|
+
it('still skips a genuinely foreign project dir not returned by extraWatchCwdsProvider', () => {
|
|
1406
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
1407
|
+
const agentCwd = '/home/test/agent-own'
|
|
1408
|
+
const worktreeCwd = '/home/test/.switchroom/worktrees/wt-abc123'
|
|
1409
|
+
const ownSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-own', 'sess-A', 'subagents')
|
|
1410
|
+
const foreignSubagents = join(agentDir, '.claude', 'projects', '-home-test-agent-foreign', 'sess-C', 'subagents')
|
|
1411
|
+
mkdirSync(ownSubagents, { recursive: true })
|
|
1412
|
+
mkdirSync(foreignSubagents, { recursive: true })
|
|
1413
|
+
writeFileSync(
|
|
1414
|
+
join(ownSubagents, 'agent-ownworker.jsonl'),
|
|
1415
|
+
buildJSONL(subAgentUserMsg('legit work')),
|
|
1416
|
+
)
|
|
1417
|
+
writeFileSync(
|
|
1418
|
+
join(foreignSubagents, 'agent-foreignworker.jsonl'),
|
|
1419
|
+
buildJSONL(subAgentUserMsg('a sibling agent, not a registered worktree')),
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
const h = startWatcherSync({
|
|
1423
|
+
agentDir,
|
|
1424
|
+
agentCwd,
|
|
1425
|
+
// Registers a worktree cwd that is NOT the foreign dir above —
|
|
1426
|
+
// proves extraWatchCwdsProvider adds only what it explicitly
|
|
1427
|
+
// names, never a wildcard.
|
|
1428
|
+
extraWatchCwdsProvider: () => [worktreeCwd],
|
|
1429
|
+
})
|
|
1430
|
+
h.poll()
|
|
1431
|
+
|
|
1432
|
+
const reg = h.watcher.getRegistry()
|
|
1433
|
+
expect(reg.has('ownworker')).toBe(true)
|
|
1434
|
+
expect(reg.has('foreignworker')).toBe(false)
|
|
1435
|
+
})
|
|
1436
|
+
})
|
|
1437
|
+
|
|
1299
1438
|
describe('issue #1116 — terminal cleanup must not re-fire completion (Bug B)', () => {
|
|
1300
1439
|
/**
|
|
1301
1440
|
* Pre-#1116: after `TERMINAL_CLEANUP_GRACE_MS` (30s) elapsed,
|
|
@@ -40,7 +40,12 @@ import { describe, it, expect } from 'vitest'
|
|
|
40
40
|
import * as realFs from 'fs'
|
|
41
41
|
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
42
42
|
import { mayOpenActivityCard } from '../gateway/feed-open-gate.js'
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
clipNarrative,
|
|
45
|
+
appendActivityLabel,
|
|
46
|
+
renderActivityFeedWithNested,
|
|
47
|
+
formatStepSuffix,
|
|
48
|
+
} from '../tool-activity-summary.js'
|
|
44
49
|
import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
|
|
45
50
|
import {
|
|
46
51
|
createWorkerActivityFeed,
|
|
@@ -249,6 +254,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
|
|
|
249
254
|
finalAnswerDeliveredAt: currentTurn!.finalAnswerDeliveredAt,
|
|
250
255
|
now: currentTime + 5, // a heartbeat tick moments after the stamp
|
|
251
256
|
staleCapMs: 30_000,
|
|
257
|
+
stillDispatched: false,
|
|
252
258
|
})
|
|
253
259
|
expect(verdictInWindow).toBe('emit')
|
|
254
260
|
})
|
|
@@ -262,6 +268,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
|
|
|
262
268
|
finalAnswerDeliveredAt: 1000,
|
|
263
269
|
now: 50_000,
|
|
264
270
|
staleCapMs: 30_000,
|
|
271
|
+
stillDispatched: false,
|
|
265
272
|
})
|
|
266
273
|
expect(verdict).toBe('idle')
|
|
267
274
|
})
|
|
@@ -273,6 +280,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
|
|
|
273
280
|
finalAnswerDeliveredAt: 1000,
|
|
274
281
|
now: 1500,
|
|
275
282
|
staleCapMs: 30_000,
|
|
283
|
+
stillDispatched: false,
|
|
276
284
|
}),
|
|
277
285
|
).toBe('idle')
|
|
278
286
|
})
|
|
@@ -310,9 +318,14 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
|
|
|
310
318
|
|
|
311
319
|
// The gateway's feedHeartbeatTick post-answer entry, reduced to its decision:
|
|
312
320
|
// `if (turn == null) return` (no-turn), else the REAL evaluatePostAnswerLiveness.
|
|
321
|
+
// `stillDispatched` defaults to false (a purely-background worker, no
|
|
322
|
+
// foreground `turn.foregroundSubAgents` tracking) so these existing tests
|
|
323
|
+
// keep proving the ORIGINAL staleness-cap behaviour is preserved for that
|
|
324
|
+
// case; a dedicated Fix-3 describe block below covers `stillDispatched: true`.
|
|
313
325
|
function heartbeatVerdict(
|
|
314
326
|
currentTurn: { finalAnswerDelivered: boolean; finalAnswerDeliveredAt?: number; subagentActivityAt?: number } | null,
|
|
315
327
|
now: number,
|
|
328
|
+
stillDispatched = false,
|
|
316
329
|
): 'no-turn' | 'pre-answer' | ReturnType<typeof evaluatePostAnswerLiveness> {
|
|
317
330
|
const turn = currentTurn
|
|
318
331
|
if (turn == null) return 'no-turn' // gateway: `if (turn == null) return`
|
|
@@ -322,6 +335,7 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
|
|
|
322
335
|
finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
|
|
323
336
|
now,
|
|
324
337
|
staleCapMs: 30_000,
|
|
338
|
+
stillDispatched,
|
|
325
339
|
})
|
|
326
340
|
}
|
|
327
341
|
|
|
@@ -377,6 +391,146 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
|
|
|
377
391
|
})
|
|
378
392
|
})
|
|
379
393
|
|
|
394
|
+
// ─── Fix 3 — sub-agent-delegation freeze (operator-confirmed: card frozen on
|
|
395
|
+
// "Running a command" for 41s / 3m12s while a sub-agent ran underneath) ──
|
|
396
|
+
|
|
397
|
+
describe('Fix 3: a still-tracked foreground sub-agent bypasses the staleness cap (no mid-delegation freeze)', () => {
|
|
398
|
+
/**
|
|
399
|
+
* Reproduces the EXACT scenario the operator's 3 screenshots showed: a
|
|
400
|
+
* foreground `Task`/`Agent` dispatch is still outstanding (one long silent
|
|
401
|
+
* step — no new watcher narrative tick), so `subagentActivityAt` stops
|
|
402
|
+
* advancing, yet the worker has NOT reported finished. Before Fix 3 the
|
|
403
|
+
* staleness cap could not tell this apart from genuine completion and froze
|
|
404
|
+
* the card after 30s regardless. `stillDispatched: true` — sourced from
|
|
405
|
+
* `turn.foregroundSubAgents.size > 0` at the real call site — is the
|
|
406
|
+
* positive signal that closes the gap.
|
|
407
|
+
*/
|
|
408
|
+
|
|
409
|
+
// Local copy of the gateway's post-answer decision (same as the "Fix 2 /
|
|
410
|
+
// concern 2" describe block above — duplicated here rather than hoisted to
|
|
411
|
+
// module scope so each describe stays self-contained/readable).
|
|
412
|
+
function heartbeatVerdict(
|
|
413
|
+
currentTurn: { finalAnswerDelivered: boolean; finalAnswerDeliveredAt?: number; subagentActivityAt?: number } | null,
|
|
414
|
+
now: number,
|
|
415
|
+
stillDispatched = false,
|
|
416
|
+
): 'no-turn' | 'pre-answer' | ReturnType<typeof evaluatePostAnswerLiveness> {
|
|
417
|
+
const turn = currentTurn
|
|
418
|
+
if (turn == null) return 'no-turn'
|
|
419
|
+
if (!turn.finalAnswerDelivered) return 'pre-answer'
|
|
420
|
+
return evaluatePostAnswerLiveness({
|
|
421
|
+
subagentActivityAt: turn.subagentActivityAt,
|
|
422
|
+
finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
|
|
423
|
+
now,
|
|
424
|
+
staleCapMs: 30_000,
|
|
425
|
+
stillDispatched,
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
it('long silent single step (41s, no new narrative) — bypasses the cap while still dispatched', () => {
|
|
430
|
+
const turn = {
|
|
431
|
+
finalAnswerDelivered: true,
|
|
432
|
+
finalAnswerDeliveredAt: 1000,
|
|
433
|
+
subagentActivityAt: 2000 as number | undefined,
|
|
434
|
+
}
|
|
435
|
+
const currentTurn: typeof turn | null = turn
|
|
436
|
+
// 41s after the last (and only) narrative advance — well past the 30s cap.
|
|
437
|
+
// Legacy (stillDispatched: false) behaviour would already be 'stale' here
|
|
438
|
+
// (proven by the concern-3 test above); with a tracked foreground worker
|
|
439
|
+
// it must stay 'emit' so the card keeps climbing instead of freezing.
|
|
440
|
+
expect(heartbeatVerdict(currentTurn, 2000 + 41_000, /* stillDispatched */ true)).toBe('emit')
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it('long silent single step (3m12s) — still bypasses the cap while still dispatched', () => {
|
|
444
|
+
const turn = {
|
|
445
|
+
finalAnswerDelivered: true,
|
|
446
|
+
finalAnswerDeliveredAt: 1000,
|
|
447
|
+
subagentActivityAt: 2000 as number | undefined,
|
|
448
|
+
}
|
|
449
|
+
const currentTurn: typeof turn | null = turn
|
|
450
|
+
const threeMinTwelveSecMs = 3 * 60_000 + 12_000
|
|
451
|
+
expect(heartbeatVerdict(currentTurn, 2000 + threeMinTwelveSecMs, true)).toBe('emit')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('once the foreground sub-agent is no longer tracked (finished), the cap re-applies normally', () => {
|
|
455
|
+
// The gateway removes the agentId from `turn.foregroundSubAgents` when it
|
|
456
|
+
// finishes (gateway.ts ~27032/27037) — `stillDispatched` then reads false
|
|
457
|
+
// again and the ORIGINAL runaway-climb protection re-engages exactly as
|
|
458
|
+
// before Fix 3. This is the adversarial check that Fix 3 doesn't remove
|
|
459
|
+
// the cap's protection outright, only bypasses it while genuinely unknown.
|
|
460
|
+
const turn = {
|
|
461
|
+
finalAnswerDelivered: true,
|
|
462
|
+
finalAnswerDeliveredAt: 1000,
|
|
463
|
+
subagentActivityAt: 2000 as number | undefined,
|
|
464
|
+
}
|
|
465
|
+
const currentTurn: typeof turn | null = turn
|
|
466
|
+
expect(heartbeatVerdict(currentTurn, 2000 + 41_000, false)).toBe('stale')
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('idle-gap suppression still wins over stillDispatched (no watcher activity ever ⇒ silent, reply-is-last preserved)', () => {
|
|
470
|
+
// Adversarial check: a turn that dispatched a foreground sub-agent but has
|
|
471
|
+
// had NO watcher tick at all since the answer (subagentActivityAt
|
|
472
|
+
// undefined) must stay 'idle', never 'emit', even if stillDispatched is
|
|
473
|
+
// true — a still-registered worker with zero ticks yet is not the same as
|
|
474
|
+
// one whose last tick went stale. This preserves the reply-is-last
|
|
475
|
+
// invariant for a turn that answered and dispatched but hasn't surfaced
|
|
476
|
+
// any post-answer step yet.
|
|
477
|
+
const turn = {
|
|
478
|
+
finalAnswerDelivered: true,
|
|
479
|
+
finalAnswerDeliveredAt: 1000,
|
|
480
|
+
subagentActivityAt: undefined as number | undefined,
|
|
481
|
+
}
|
|
482
|
+
const currentTurn: typeof turn | null = turn
|
|
483
|
+
expect(heartbeatVerdict(currentTurn, 50_000, true)).toBe('idle')
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('end-to-end: the RENDERED card text keeps changing across real heartbeat ticks while a foreground sub-agent is silently working (not just the verdict function)', () => {
|
|
487
|
+
// Full simulated turn: dispatch a foreground Task, one narrative tick
|
|
488
|
+
// lands, then NOTHING further for over three minutes (one long silent
|
|
489
|
+
// Bash inside the sub-agent) — the exact operator-reported shape. Drives
|
|
490
|
+
// the REAL `evaluatePostAnswerLiveness` verdict AND the REAL
|
|
491
|
+
// `renderActivityFeedWithNested`/`composeTurnActivity`-shaped render at
|
|
492
|
+
// each tick, asserting the VISIBLE text (not an internal label) actually
|
|
493
|
+
// climbs instead of freezing.
|
|
494
|
+
const turn = {
|
|
495
|
+
finalAnswerDelivered: true,
|
|
496
|
+
finalAnswerDeliveredAt: 1_000,
|
|
497
|
+
subagentActivityAt: 2_000 as number | undefined,
|
|
498
|
+
mirrorLines: ['Delegating to a sub-agent'],
|
|
499
|
+
foregroundSubAgents: new Map<string, string[]>([['agent-1', ['Running a command']]]),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderCardAt(now: number): string | null {
|
|
503
|
+
const verdict = evaluatePostAnswerLiveness({
|
|
504
|
+
subagentActivityAt: turn.subagentActivityAt,
|
|
505
|
+
finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
|
|
506
|
+
now,
|
|
507
|
+
staleCapMs: 30_000,
|
|
508
|
+
stillDispatched: turn.foregroundSubAgents.size > 0,
|
|
509
|
+
})
|
|
510
|
+
if (verdict !== 'emit') return null // the pre-Fix-3 freeze point
|
|
511
|
+
const childLines = [...turn.foregroundSubAgents.values()].flat()
|
|
512
|
+
const header = { label: 'Agent', elapsedMs: now - 0, toolCount: 1, state: 'running' as const }
|
|
513
|
+
return renderActivityFeedWithNested(turn.mirrorLines, childLines, false, formatStepSuffix(now - turn.subagentActivityAt!), undefined, header)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const tick1 = renderCardAt(2_000 + 6_000) // 6s after the last tick
|
|
517
|
+
const tick2 = renderCardAt(2_000 + 41_000) // 41s — the first operator-reported freeze point
|
|
518
|
+
const tick3 = renderCardAt(2_000 + (3 * 60_000 + 12_000)) // 3m12s — the second
|
|
519
|
+
|
|
520
|
+
expect(tick1).not.toBeNull()
|
|
521
|
+
expect(tick2).not.toBeNull()
|
|
522
|
+
expect(tick3).not.toBeNull()
|
|
523
|
+
// The visible text must actually differ tick-over-tick (climbing elapsed),
|
|
524
|
+
// not be frozen at the same string for minutes.
|
|
525
|
+
expect(tick2).not.toBe(tick1)
|
|
526
|
+
expect(tick3).not.toBe(tick2)
|
|
527
|
+
// And it must contain the growing wall-clock evidence the operator was
|
|
528
|
+
// missing — a live elapsed suffix on the nested step.
|
|
529
|
+
expect(tick2).toContain('41s')
|
|
530
|
+
expect(tick3).toContain('3m12s')
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
|
|
380
534
|
// ─── Fix 2 — concern 2 resolution: the decoupled worker surfaces via the
|
|
381
535
|
// currentTurn-INDEPENDENT workerActivityFeed (and is bounded) ────────────
|
|
382
536
|
|