switchroom 0.15.45 → 0.16.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/agent-scheduler/index.js +122 -88
  2. package/dist/auth-broker/index.js +463 -177
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +17 -14
  5. package/dist/cli/notion-write-pretool.mjs +117 -86
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/skill-validate-pretool.mjs +72 -72
  9. package/dist/cli/switchroom.js +3158 -1178
  10. package/dist/host-control/main.js +2833 -355
  11. package/dist/vault/approvals/kernel-server.js +7479 -7439
  12. package/dist/vault/broker/server.js +11312 -11272
  13. package/examples/minimal.yaml +1 -0
  14. package/examples/switchroom.yaml +1 -0
  15. package/package.json +3 -3
  16. package/profiles/_base/start.sh.hbs +88 -1
  17. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  18. package/profiles/default/CLAUDE.md.hbs +0 -19
  19. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  20. package/telegram-plugin/answer-stream-flag.ts +12 -49
  21. package/telegram-plugin/answer-stream.ts +5 -150
  22. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  23. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  24. package/telegram-plugin/context-exhaustion.ts +12 -0
  25. package/telegram-plugin/demo-mask.ts +154 -0
  26. package/telegram-plugin/dist/bridge/bridge.js +167 -124
  27. package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
  28. package/telegram-plugin/dist/server.js +215 -172
  29. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  30. package/telegram-plugin/draft-stream.ts +47 -410
  31. package/telegram-plugin/final-answer-detect.ts +17 -12
  32. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  33. package/telegram-plugin/format.ts +56 -19
  34. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  35. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  36. package/telegram-plugin/gateway/auth-command.ts +70 -14
  37. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  38. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  39. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  40. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  41. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  42. package/telegram-plugin/gateway/effort-command.ts +8 -3
  43. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  44. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  45. package/telegram-plugin/gateway/gateway.ts +1837 -291
  46. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  103. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  104. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  105. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  106. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  107. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  108. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  109. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  110. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  111. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  112. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  113. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  114. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  115. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  116. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  117. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  118. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  119. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  120. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  121. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  122. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  123. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  124. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  125. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  126. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  127. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  128. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  129. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  130. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  131. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  132. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  133. package/telegram-plugin/tool-activity-summary.ts +375 -58
  134. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  135. package/telegram-plugin/uat/assertions.ts +115 -0
  136. package/telegram-plugin/uat/driver.ts +68 -0
  137. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  138. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  139. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  145. package/telegram-plugin/welcome-text.ts +13 -1
  146. package/telegram-plugin/worker-activity-feed.ts +157 -82
  147. package/telegram-plugin/draft-transport.ts +0 -122
  148. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  149. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -0,0 +1,146 @@
1
+ /**
2
+ * H-1: feedHeartbeatTick structural assertions.
3
+ *
4
+ * Pins load-bearing constraints of the branches inside `feedHeartbeatTick`
5
+ * (gateway.ts):
6
+ *
7
+ * Post-answer branch (Fix 2 / #2587 supersede, concern 3 staleness cap):
8
+ * - `turn.subagentActivityAt` is the signal (NOT `lastToolLabelAt`) that
9
+ * drives post-answer liveness — the watcher updates this independently of
10
+ * the tool_label / drop-guard path.
11
+ * - The idle-gap suppression AND the staleness cap are a single pure decision
12
+ * `evaluatePostAnswerLiveness(...)` consulted each tick, with the cap fed
13
+ * from `POST_ANSWER_LIVENESS_STALE_MS`.
14
+ *
15
+ * Pre-answer liveness-open branch:
16
+ * 1. The SWITCHROOM_FEED_LIVENESS_OPEN kill-switch (default ON, i.e. `!== '0'`)
17
+ * gates the liveness-open path — operators can disable it with =0.
18
+ * 2. FEED_LIVENESS_OPEN_MS is parsed from env with a sane default (12 000 ms).
19
+ * 3. The `age < FEED_LIVENESS_OPEN_MS` return-guard fires BEFORE the 0-tool
20
+ * feed opens — a turn that hasn't yet passed the threshold returns early.
21
+ *
22
+ * These are STRUCTURAL (source-read) assertions; the gateway IIFE can't be
23
+ * instantiated in-process. Pattern matches silence-liveness-wiring.test.ts.
24
+ */
25
+ import { describe, it, expect } from 'vitest'
26
+ import { readFileSync } from 'node:fs'
27
+ import { resolve } from 'node:path'
28
+
29
+ const gatewaySrc = readFileSync(
30
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
31
+ 'utf-8',
32
+ )
33
+
34
+ /** Return the source text of `feedHeartbeatTick` (everything up to the next top-level function). */
35
+ function feedHeartbeatTickSrc(): string {
36
+ const after = gatewaySrc.split('function feedHeartbeatTick(): void {')[1] ?? ''
37
+ // Stop at the next top-level function definition.
38
+ return after.split('\nfunction ')[0] ?? after
39
+ }
40
+
41
+ /**
42
+ * Extract the 0-tool liveness-open branch: the section from
43
+ * `if (turn.mirrorLines.length === 0)` to its closing `return`. This scopes
44
+ * the FEED_LIVENESS_OPEN_ENABLED and age-guard assertions to the correct branch
45
+ * (the post-answer Fix-2 block now precedes it and also has a drain call).
46
+ */
47
+ function liveness0ToolBranchSrc(): string {
48
+ const body = feedHeartbeatTickSrc()
49
+ const start = body.indexOf('if (turn.mirrorLines.length === 0)')
50
+ if (start === -1) return ''
51
+ // Capture until the closing `return\n }` of this if-block (the next blank-line-terminated return).
52
+ const after = body.slice(start)
53
+ // Take up to the labelled-feed heartbeat comment that follows.
54
+ const end = after.indexOf('// Labelled-feed heartbeat')
55
+ return end === -1 ? after : after.slice(0, end)
56
+ }
57
+
58
+ describe('H-1: feedHeartbeatTick liveness-open threshold', () => {
59
+ it('SWITCHROOM_FEED_LIVENESS_OPEN kill-switch uses !== "0" semantic (default ON)', () => {
60
+ // Must be defined as `!== '0'` so that an unset env var is truthy (default on).
61
+ expect(gatewaySrc).toMatch(
62
+ /FEED_LIVENESS_OPEN_ENABLED\s*=\s*process\.env\.SWITCHROOM_FEED_LIVENESS_OPEN\s*!==\s*'0'/,
63
+ )
64
+ })
65
+
66
+ it('FEED_LIVENESS_OPEN_MS defaults to 12_000 ms when env is unset', () => {
67
+ // The IIFE initialiser `const FEED_LIVENESS_OPEN_MS = (() => { ... })()` must
68
+ // contain the fallback 12_000. Split on the const definition (not the comment).
69
+ const afterConst = gatewaySrc.split('const FEED_LIVENESS_OPEN_MS')[1] ?? ''
70
+ // The IIFE closes with `})()` — take everything before that.
71
+ const initBlock = afterConst.split('})()')[0] ?? ''
72
+ expect(initBlock).toMatch(/12[_]?000/)
73
+ })
74
+
75
+ it('age < FEED_LIVENESS_OPEN_MS return-guard exists inside the 0-tool liveness branch', () => {
76
+ const branch = liveness0ToolBranchSrc()
77
+ expect(branch).toMatch(/if\s*\(age\s*<\s*FEED_LIVENESS_OPEN_MS\)\s*return/)
78
+ })
79
+
80
+ it('FEED_LIVENESS_OPEN_ENABLED check precedes the drain call in the 0-tool liveness branch', () => {
81
+ const branch = liveness0ToolBranchSrc()
82
+ const enabledIdx = branch.indexOf('FEED_LIVENESS_OPEN_ENABLED')
83
+ // Use the actual call site (assignment to activityInFlight) not the comment mention.
84
+ const drainCallIdx = branch.indexOf('turn.activityInFlight = drainActivitySummary')
85
+ expect(enabledIdx).toBeGreaterThan(-1)
86
+ expect(drainCallIdx).toBeGreaterThan(enabledIdx)
87
+ })
88
+
89
+ it('age < FEED_LIVENESS_OPEN_MS guard precedes the drain call in the 0-tool branch (early-return)', () => {
90
+ const branch = liveness0ToolBranchSrc()
91
+ const guardIdx = branch.indexOf('age < FEED_LIVENESS_OPEN_MS')
92
+ // Use the actual call site (assignment to activityInFlight) not the comment mention.
93
+ const drainCallIdx = branch.indexOf('turn.activityInFlight = drainActivitySummary')
94
+ expect(guardIdx).toBeGreaterThan(-1)
95
+ expect(drainCallIdx).toBeGreaterThan(guardIdx)
96
+ })
97
+ })
98
+
99
+ describe('H-2: feedHeartbeatTick post-answer background-agent liveness (Fix 2 / #2587 supersede, concern 3 cap)', () => {
100
+ // Structural assertions pinning Fix 2: the post-answer branch reads
101
+ // `turn.subagentActivityAt` (not `lastToolLabelAt`) and routes the idle-gap +
102
+ // staleness decision through the pure `evaluatePostAnswerLiveness` helper.
103
+
104
+ it('post-answer branch reads subagentActivityAt (the watcher signal)', () => {
105
+ const body = feedHeartbeatTickSrc()
106
+ // The post-answer block starts with `if (turn.finalAnswerDelivered)`
107
+ const afterPostAnswer = body.split('if (turn.finalAnswerDelivered)')[1] ?? ''
108
+ // Extract up to the closing return of that block
109
+ const postAnswerBlock = afterPostAnswer.split('\n }\n')[0] ?? ''
110
+ // subagentActivityAt must appear as a live variable (not just in a comment)
111
+ // — verified by the `const subagentAt = turn.subagentActivityAt` assignment.
112
+ expect(postAnswerBlock).toMatch(/const\s+subagentAt\s*=\s*turn\.subagentActivityAt/)
113
+ })
114
+
115
+ it('idle-gap + staleness cap routed through evaluatePostAnswerLiveness with the stale cap', () => {
116
+ const body = feedHeartbeatTickSrc()
117
+ const afterPostAnswer = body.split('if (turn.finalAnswerDelivered)')[1] ?? ''
118
+ const postAnswerBlock = afterPostAnswer.split('\n }\n')[0] ?? ''
119
+ // The single pure decision is consulted, fed the staleness cap, and a
120
+ // non-'emit' verdict returns early (silent).
121
+ expect(postAnswerBlock).toMatch(/evaluatePostAnswerLiveness\(/)
122
+ expect(postAnswerBlock).toMatch(/staleCapMs:\s*POST_ANSWER_LIVENESS_STALE_MS/)
123
+ expect(postAnswerBlock).toMatch(/livenessVerdict\s*!==\s*'emit'/)
124
+ })
125
+
126
+ it('staleness cap (concern 3) is parsed default-ON (30s) from SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS', () => {
127
+ // The cap const must default to 30_000 when the env is unset (the `|| 30_000`
128
+ // fallback over the positive-or-0 parse) so the post-answer card stops
129
+ // climbing once the worker goes stale, even with no operator config.
130
+ const afterConst = gatewaySrc.split('const POST_ANSWER_LIVENESS_STALE_MS')[1] ?? ''
131
+ const initBlock = afterConst.split('\n')[0] + (afterConst.split('||')[1] ?? '')
132
+ expect(afterConst).toMatch(/SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS/)
133
+ expect(afterConst).toMatch(/\|\|\s*30[_]?000/)
134
+ expect(initBlock).toBeTruthy()
135
+ })
136
+
137
+ it('post-answer drain uses tool producer + postAnswerSubagentActivity flag', () => {
138
+ const body = feedHeartbeatTickSrc()
139
+ const afterPostAnswer = body.split('if (turn.finalAnswerDelivered)')[1] ?? ''
140
+ const postAnswerBlock = afterPostAnswer.split('\n }\n')[0] ?? ''
141
+ // Must pass postAnswerSubagentActivity: true to drainActivitySummary
142
+ expect(postAnswerBlock).toMatch(/postAnswerSubagentActivity:\s*true/)
143
+ // Must use 'tool' producer for the Lever 1 exception
144
+ expect(postAnswerBlock).toMatch(/'tool'/)
145
+ })
146
+ })
@@ -0,0 +1,259 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { mayOpenActivityCard } from '../gateway/feed-open-gate.js'
4
+
5
+ /**
6
+ * Feed-OPEN gate — pure decision (design `docs/message-emission-determinism.md`
7
+ * §9 levers 1 + 4). Gates WHEN the activity card may first OPEN (fresh
8
+ * sendMessage). An EDIT of an already-open card is never routed through this
9
+ * (the drain only consults it when activityMessageId == null).
10
+ *
11
+ * Lever 5 — INERT (#2588 fix): the original Lever 5 blocked narrative from
12
+ * opening a card on 0-tool turns to prevent a "triplication" reorder. This
13
+ * over-suppressed conversational turns. Lever 2 (clearActivitySummary, gateway
14
+ * executeReply) already guarantees reply-is-last ordering by editing the
15
+ * narrative card in-place before the reply sends — Lever 5 was redundant.
16
+ * Pre-answer narrative now DOES open a card; post-answer is blocked by Lever 1.
17
+ *
18
+ * Lever 1 (reply-is-last): once a SUBSTANTIVE final answer has been delivered
19
+ * (the sticky finalAnswerEverDelivered latch), no card may open below it — for
20
+ * ANY producer, EXCEPT when `postAnswerSubagentActivity === true && producer ===
21
+ * 'tool'` (Fix 2 / #2587 supersede: background-agent liveness below the reply).
22
+ * The latch is NOT the mutable finalAnswerDelivered (reopen clears that
23
+ * mid-turn, #2141); an ack does not set it, so the ack-then-work feed opens.
24
+ */
25
+ describe('mayOpenActivityCard — Lever 5 INERT: narrative opens pre-answer (Fix 1 / #2588)', () => {
26
+ it('narrative SHOW on a 0-tool turn DOES open a card pre-answer (Lever 5 removed)', () => {
27
+ // Lever 5 was: producer === "narrative" && labeledToolCount === 0 → false.
28
+ // Now it is INERT: pre-answer narrative may open. Lever 2 (clearActivitySummary)
29
+ // guarantees the card edits in-place before the reply sends — reply-is-last
30
+ // is preserved without Lever 5. This is the #2588 fix.
31
+ expect(
32
+ mayOpenActivityCard({
33
+ producer: 'narrative',
34
+ finalAnswerEverDelivered: false,
35
+ labeledToolCount: 0,
36
+ }),
37
+ ).toBe(true)
38
+ })
39
+
40
+ it('a tool label DOES open a card (producer B, unchanged)', () => {
41
+ expect(
42
+ mayOpenActivityCard({
43
+ producer: 'tool',
44
+ finalAnswerEverDelivered: false,
45
+ labeledToolCount: 1,
46
+ }),
47
+ ).toBe(true)
48
+ })
49
+
50
+ it('the liveness timer DOES open a 0-tool thinking-gap card (producer C, unchanged)', () => {
51
+ expect(
52
+ mayOpenActivityCard({
53
+ producer: 'liveness',
54
+ finalAnswerEverDelivered: false,
55
+ labeledToolCount: 0,
56
+ }),
57
+ ).toBe(true)
58
+ })
59
+
60
+ it('narrative SHOW once a tool label has landed DOES open (the accumulated narration renders; R4)', () => {
61
+ // A turn that starts conversational then dispatches a tool: labeledToolCount
62
+ // is now > 0, so even a narrative-driven drain may open and render the
63
+ // accumulated narration.
64
+ expect(
65
+ mayOpenActivityCard({
66
+ producer: 'narrative',
67
+ finalAnswerEverDelivered: false,
68
+ labeledToolCount: 1,
69
+ }),
70
+ ).toBe(true)
71
+ })
72
+ })
73
+
74
+ describe('mayOpenActivityCard — lever 1 (no OPEN after a substantive final)', () => {
75
+ it('blocks a tool-label OPEN after a substantive final (race A/B/E)', () => {
76
+ expect(
77
+ mayOpenActivityCard({
78
+ producer: 'tool',
79
+ finalAnswerEverDelivered: true,
80
+ labeledToolCount: 3,
81
+ }),
82
+ ).toBe(false)
83
+ })
84
+
85
+ it('blocks a liveness OPEN after a substantive final', () => {
86
+ expect(
87
+ mayOpenActivityCard({
88
+ producer: 'liveness',
89
+ finalAnswerEverDelivered: true,
90
+ labeledToolCount: 0,
91
+ }),
92
+ ).toBe(false)
93
+ })
94
+
95
+ it('blocks a narrative OPEN after a substantive final', () => {
96
+ expect(
97
+ mayOpenActivityCard({
98
+ producer: 'narrative',
99
+ finalAnswerEverDelivered: true,
100
+ labeledToolCount: 2,
101
+ }),
102
+ ).toBe(false)
103
+ })
104
+
105
+ it('does NOT block when no substantive final yet — an ack (latch false) leaves opening allowed (#2141)', () => {
106
+ // An ack sets finalAnswerDelivered (mutable) but NOT the sticky latch, so
107
+ // a post-ack tool label still opens the reopened feed.
108
+ expect(
109
+ mayOpenActivityCard({
110
+ producer: 'tool',
111
+ finalAnswerEverDelivered: false,
112
+ labeledToolCount: 1,
113
+ }),
114
+ ).toBe(true)
115
+ })
116
+ })
117
+
118
+ describe('mayOpenActivityCard — lever 1 exception: post-answer sub-agent liveness (Fix 2 / #2587 supersede)', () => {
119
+ // When a background sub-agent continues working after the parent's substantive
120
+ // reply, `postAnswerSubagentActivity=true` + `producer='tool'` lifts Lever 1
121
+ // so the heartbeat can open a liveness card below the reply. This signal is
122
+ // driven by `turn.subagentActivityAt`, written by the watcher's onProgress
123
+ // callback INDEPENDENTLY of the tool_label path (so the drop-guard cannot gate
124
+ // it). Idle producers (liveness, narrative) remain blocked — the reply-is-last
125
+ // invariant holds for idle post-answer gaps.
126
+
127
+ it('post-answer REAL sub-agent activity DOES open a card (tool + postAnswerSubagentActivity)', () => {
128
+ // The core Fix 2 assertion: a background worker is still active after the
129
+ // answer → a liveness card surfaces below the reply.
130
+ expect(
131
+ mayOpenActivityCard({
132
+ producer: 'tool',
133
+ finalAnswerEverDelivered: true,
134
+ labeledToolCount: 3,
135
+ postAnswerSubagentActivity: true,
136
+ }),
137
+ ).toBe(true)
138
+ })
139
+
140
+ it('post-answer idle liveness does NOT open (no postAnswerSubagentActivity)', () => {
141
+ // Without the signal, Lever 1 stays fully active — idle heartbeat after the
142
+ // answer is still suppressed. Idle-gap suppression preserved.
143
+ expect(
144
+ mayOpenActivityCard({
145
+ producer: 'tool',
146
+ finalAnswerEverDelivered: true,
147
+ labeledToolCount: 3,
148
+ postAnswerSubagentActivity: false,
149
+ }),
150
+ ).toBe(false)
151
+ })
152
+
153
+ it('post-answer liveness producer stays blocked even with the signal (not tool producer)', () => {
154
+ // Only 'tool' is exempted. A 'liveness' producer (wall-clock heartbeat
155
+ // with no new watcher step) may not open a card after the final answer.
156
+ expect(
157
+ mayOpenActivityCard({
158
+ producer: 'liveness',
159
+ finalAnswerEverDelivered: true,
160
+ labeledToolCount: 0,
161
+ postAnswerSubagentActivity: true,
162
+ }),
163
+ ).toBe(false)
164
+ })
165
+
166
+ it('post-answer narrative producer stays blocked even with the signal', () => {
167
+ // Narrative alone after the final answer may not open — reply-is-last.
168
+ expect(
169
+ mayOpenActivityCard({
170
+ producer: 'narrative',
171
+ finalAnswerEverDelivered: true,
172
+ labeledToolCount: 2,
173
+ postAnswerSubagentActivity: true,
174
+ }),
175
+ ).toBe(false)
176
+ })
177
+ })
178
+
179
+ describe('mayOpenActivityCard — lever 4 (no OPEN below an EARLIER turn answer; race C/D)', () => {
180
+ // The cross-turn case: a synthetic represent/owed-reply turn starts with a
181
+ // CLEARED per-turn `finalAnswerEverDelivered` latch even though a substantive
182
+ // answer already reached the user in a PRIOR turn. The caller computes
183
+ // `crossTurnAnswerDelivered` (via hasOutboundDeliveredSince, obligation
184
+ // openedAt cutoff, 200-char substantive threshold) and passes it here.
185
+
186
+ it('blocks a tool-label OPEN when a substantive answer was already delivered cross-turn', () => {
187
+ expect(
188
+ mayOpenActivityCard({
189
+ producer: 'tool',
190
+ finalAnswerEverDelivered: false, // fresh synthetic turn — its own latch is clear
191
+ labeledToolCount: 2,
192
+ crossTurnAnswerDelivered: true, // but the exchange already got an answer
193
+ }),
194
+ ).toBe(false)
195
+ })
196
+
197
+ it('blocks a liveness/heartbeat OPEN cross-turn (the heartbeat surface on the synthetic turn)', () => {
198
+ expect(
199
+ mayOpenActivityCard({
200
+ producer: 'liveness',
201
+ finalAnswerEverDelivered: false,
202
+ labeledToolCount: 0,
203
+ crossTurnAnswerDelivered: true,
204
+ }),
205
+ ).toBe(false)
206
+ })
207
+
208
+ it('blocks a narrative OPEN cross-turn', () => {
209
+ expect(
210
+ mayOpenActivityCard({
211
+ producer: 'narrative',
212
+ finalAnswerEverDelivered: false,
213
+ labeledToolCount: 3,
214
+ crossTurnAnswerDelivered: true,
215
+ }),
216
+ ).toBe(false)
217
+ })
218
+
219
+ it('does NOT block when the obligation is genuinely unanswered (no substantive reply since it was raised) — represent still surfaces', () => {
220
+ // The inverse / no-regression: an obligation with NO delivered answer since
221
+ // it was raised → crossTurnAnswerDelivered false → the represent turn's card
222
+ // opens as normal. (The represent SEND itself is owned by the represent
223
+ // guard, not this gate; this asserts the gate does not over-suppress.)
224
+ expect(
225
+ mayOpenActivityCard({
226
+ producer: 'tool',
227
+ finalAnswerEverDelivered: false,
228
+ labeledToolCount: 1,
229
+ crossTurnAnswerDelivered: false,
230
+ }),
231
+ ).toBe(true)
232
+ })
233
+
234
+ it('is inert on a normal foreground turn (crossTurnAnswerDelivered omitted/false)', () => {
235
+ // A foreground turn never carries the cross-turn gate, so the caller passes
236
+ // false/undefined and lever 4 cannot affect it — the foreground card opens.
237
+ expect(
238
+ mayOpenActivityCard({
239
+ producer: 'tool',
240
+ finalAnswerEverDelivered: false,
241
+ labeledToolCount: 1,
242
+ }),
243
+ ).toBe(true)
244
+ })
245
+
246
+ it('does NOT regress #2141: an ack-then-work cross-turn surface with no SUBSTANTIVE reply still opens', () => {
247
+ // crossTurnAnswerDelivered keys on SUBSTANTIVE (≥200-char) delivery. An ack
248
+ // ("On it…") is not substantive, so the caller computes false and the
249
+ // ack-then-work feed still opens.
250
+ expect(
251
+ mayOpenActivityCard({
252
+ producer: 'tool',
253
+ finalAnswerEverDelivered: false,
254
+ labeledToolCount: 1,
255
+ crossTurnAnswerDelivered: false,
256
+ }),
257
+ ).toBe(true)
258
+ })
259
+ })