switchroom 0.18.32 → 0.19.0
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/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- 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/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/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/gateway.ts +1347 -6758
- 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-wiring.ts +333 -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/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- 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/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 +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -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 +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- 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-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- 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 +27 -9
- 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/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- 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/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 +28 -18
- 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 +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -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 +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- 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 +10 -4
|
@@ -186,12 +186,20 @@ describe('coalesced worker feed — one message per chat under load', () => {
|
|
|
186
186
|
// would shed ~ (N-1)/N of every second's cosmetic edits.
|
|
187
187
|
expect(gate.stats().global.shed).toBeLessThanOrEqual(3)
|
|
188
188
|
|
|
189
|
-
// Liveness
|
|
190
|
-
//
|
|
189
|
+
// Liveness + bounded card (#3349): under Ken's curve every SHOWN worker
|
|
190
|
+
// keeps depth 3, so a 15-way storm cannot render every row — the 24-line
|
|
191
|
+
// body ceiling collapses the newest rows into a `+N more working…` spill.
|
|
192
|
+
// The last landed edit still carries the VISIBLE workers' latest steps
|
|
193
|
+
// (refreshed together in the one message) and a spill marker for the rest.
|
|
191
194
|
const lastBody = edits[edits.length - 1].text
|
|
192
|
-
|
|
193
|
-
|
|
195
|
+
// 6 rows fit the 24-line budget (6·(1 header + 3 depth) = 24); the oldest
|
|
196
|
+
// rows (w0…w5) stay visible, the 9 newest spill.
|
|
197
|
+
for (let i = 0; i < 6; i++) {
|
|
198
|
+
expect(lastBody).toContain(latest[`w${i}`])
|
|
194
199
|
}
|
|
200
|
+
expect(lastBody).toContain('more working')
|
|
201
|
+
// Still exactly ONE message — the spill is a render detail, not a new send.
|
|
202
|
+
expect(sends.length).toBe(1)
|
|
195
203
|
})
|
|
196
204
|
|
|
197
205
|
it('admits a critical reply mid-storm (never shed or starved by the cosmetic feed)', async () => {
|
|
@@ -722,21 +730,23 @@ describe('renderCombinedWorkerFeed (pure)', () => {
|
|
|
722
730
|
expect(body).toContain('b first')
|
|
723
731
|
})
|
|
724
732
|
|
|
725
|
-
it('
|
|
733
|
+
it('holds the MIN_WORKER_DEPTH (3) floor per shown worker at a large fan-out and stays within the body budget (#3349)', () => {
|
|
734
|
+
// 6 workers each with a deep (5-line) history. Ken's curve pins depth=3 at
|
|
735
|
+
// 4+ workers, so every SHOWN worker paints its last 3 steps. 6·(1 header +
|
|
736
|
+
// 3 history) = 24 lines = MAX_COMBINED_BODY_LINES, so all 6 stay visible.
|
|
726
737
|
const rows = Array.from({ length: 6 }, (_, i) =>
|
|
727
|
-
rowH(i, [`w${i}
|
|
738
|
+
rowH(i, [`w${i} s1`, `w${i} s2`, `w${i} s3`, `w${i} s4`, `w${i} s5`]),
|
|
728
739
|
)
|
|
729
740
|
const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
|
|
730
|
-
// Only the newest step of each worker survives — the earlier lines are
|
|
731
|
-
// dropped by the per-worker depth clamp (floor((13-6)/6)=1).
|
|
732
741
|
for (let i = 0; i < 6; i++) {
|
|
733
|
-
|
|
734
|
-
expect(body).
|
|
735
|
-
expect(body).
|
|
742
|
+
// Last 3 steps shown; the two oldest dropped by the depth-3 window.
|
|
743
|
+
expect(body).toContain(current(`w${i} s5`))
|
|
744
|
+
expect(body).toContain(struck(`w${i} s4`))
|
|
745
|
+
expect(body).toContain(struck(`w${i} s3`))
|
|
746
|
+
expect(body).not.toContain(`w${i} s1`)
|
|
747
|
+
expect(body).not.toContain(`w${i} s2`)
|
|
736
748
|
}
|
|
737
|
-
//
|
|
738
|
-
// header lines + 6 history lines = 12 ≤ MAX_COMBINED_BODY_LINES (13). Count
|
|
739
|
-
// only the per-worker body lines (exclude the top count line + any spill).
|
|
749
|
+
// Body lines (worker headers + history) stay within the re-derived budget.
|
|
740
750
|
const bodyLines = body
|
|
741
751
|
.split('\n')
|
|
742
752
|
.map((l) => l.trim())
|
|
@@ -744,15 +754,43 @@ describe('renderCombinedWorkerFeed (pure)', () => {
|
|
|
744
754
|
const headerAndHistory = bodyLines.filter(
|
|
745
755
|
(l) => !l.startsWith('🛠') && !l.includes('more working'),
|
|
746
756
|
)
|
|
747
|
-
expect(headerAndHistory.length).toBeLessThanOrEqual(
|
|
757
|
+
expect(headerAndHistory.length).toBeLessThanOrEqual(24)
|
|
748
758
|
})
|
|
749
759
|
|
|
750
|
-
it('
|
|
760
|
+
it('drops NEWEST (trailing) rows into the spill when the fan-out overflows the 24-line body budget, keeping per-worker depth at 3 (#3349)', () => {
|
|
761
|
+
// 8 workers × (1 header + 3 depth) = 32 > 24 → the backstop collapses the 2
|
|
762
|
+
// newest (trailing) visible rows into `+M more working…`, so 6 rows render at full
|
|
763
|
+
// depth-3 (24 lines) rather than every worker losing a step.
|
|
764
|
+
const rows = Array.from({ length: 8 }, (_, i) =>
|
|
765
|
+
rowH(i, [`w${i} s1`, `w${i} s2`, `w${i} s3`]),
|
|
766
|
+
)
|
|
767
|
+
const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
|
|
768
|
+
expect(body).toContain('more working')
|
|
769
|
+
const bodyLines = body
|
|
770
|
+
.split('\n')
|
|
771
|
+
.map((l) => l.trim())
|
|
772
|
+
.filter((l) => l.length > 0)
|
|
773
|
+
const headerAndHistory = bodyLines.filter(
|
|
774
|
+
(l) => !l.startsWith('🛠') && !l.includes('more working'),
|
|
775
|
+
)
|
|
776
|
+
expect(headerAndHistory.length).toBeLessThanOrEqual(24)
|
|
777
|
+
expect(body).toContain('+2 more working')
|
|
778
|
+
// The 6 kept rows render at full depth-3 (last row kept shows all 3 steps).
|
|
779
|
+
expect(body).toContain(current('w5 s3'))
|
|
780
|
+
expect(body).toContain(struck('w5 s2'))
|
|
781
|
+
expect(body).toContain(struck('w5 s1'))
|
|
782
|
+
// The 2 trailing rows spilled — none of worker 7's lines render.
|
|
783
|
+
expect(body).not.toContain('w7 s3')
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
it("exposes Ken's deterministic depth curve max(3, 7−w): 1→6, 2→5, 3→4, 4→3, 5+→3 (#3349)", () => {
|
|
787
|
+
expect(combinedHistoryDepth(1)).toBe(6)
|
|
751
788
|
expect(combinedHistoryDepth(2)).toBe(5)
|
|
752
|
-
expect(combinedHistoryDepth(3)).toBe(
|
|
753
|
-
expect(combinedHistoryDepth(4)).toBe(
|
|
754
|
-
expect(combinedHistoryDepth(
|
|
755
|
-
expect(combinedHistoryDepth(
|
|
789
|
+
expect(combinedHistoryDepth(3)).toBe(4)
|
|
790
|
+
expect(combinedHistoryDepth(4)).toBe(3)
|
|
791
|
+
expect(combinedHistoryDepth(5)).toBe(3)
|
|
792
|
+
expect(combinedHistoryDepth(6)).toBe(3)
|
|
793
|
+
expect(combinedHistoryDepth(8)).toBe(3)
|
|
756
794
|
})
|
|
757
795
|
|
|
758
796
|
// ── Worker numbering (#3298): stable ordinal prefix at 2+ workers ──────────
|
|
@@ -798,6 +836,58 @@ describe('renderCombinedWorkerFeed (pure)', () => {
|
|
|
798
836
|
})
|
|
799
837
|
})
|
|
800
838
|
|
|
839
|
+
// ── #3349: Ken's per-worker depth curve 6/5/4/3, exact rendered depth ─────────
|
|
840
|
+
describe("Ken's per-worker step-trail depth curve (#3349)", () => {
|
|
841
|
+
// A worker with a DEEP history (8 lines) so the depth curve — not the buffer —
|
|
842
|
+
// is always the binding limit. Each history line is uniquely tokenised
|
|
843
|
+
// (`w{i}-s{n}`) so we can count exactly how many of a worker's steps rendered.
|
|
844
|
+
const deepRow = (i: number) => ({
|
|
845
|
+
description: `task w${i}`,
|
|
846
|
+
elapsedMs: 12_000 + i * 1000,
|
|
847
|
+
toolCount: 3,
|
|
848
|
+
ordinal: i + 1,
|
|
849
|
+
currentStep: `w${i}-s8`,
|
|
850
|
+
historyLines: Array.from({ length: 8 }, (_, n) => `w${i}-s${n + 1}`),
|
|
851
|
+
})
|
|
852
|
+
// Count how many of worker `i`'s step tokens appear in the rendered body.
|
|
853
|
+
const shownSteps = (body: string, i: number): number =>
|
|
854
|
+
(body.match(new RegExp(`w${i}-s\\d`, 'g')) ?? []).length
|
|
855
|
+
|
|
856
|
+
it.each([
|
|
857
|
+
[1, 6],
|
|
858
|
+
[2, 5],
|
|
859
|
+
[3, 4],
|
|
860
|
+
[4, 3],
|
|
861
|
+
[5, 3],
|
|
862
|
+
])('%i worker(s) → exactly %i steps rendered per worker', (workers, expectedDepth) => {
|
|
863
|
+
const rows = Array.from({ length: workers }, (_, i) => deepRow(i))
|
|
864
|
+
const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
|
|
865
|
+
expect(combinedHistoryDepth(workers)).toBe(expectedDepth)
|
|
866
|
+
for (let i = 0; i < workers; i++) {
|
|
867
|
+
expect(shownSteps(body, i)).toBe(expectedDepth)
|
|
868
|
+
}
|
|
869
|
+
// Newest step last (bottom-anchored → bold), oldest rendered step struck.
|
|
870
|
+
const worker0 = `**→ w0-s8**`
|
|
871
|
+
expect(body).toContain(worker0)
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
it('a worker with FEWER steps than the depth shows all it has, no padding', () => {
|
|
875
|
+
// 3 workers → depth 4, but this worker only has 2 steps: render both, no more.
|
|
876
|
+
const rows = [
|
|
877
|
+
{ description: 'shallow', elapsedMs: 12_000, toolCount: 1, ordinal: 1,
|
|
878
|
+
currentStep: 'only-b', historyLines: ['only-a', 'only-b'] },
|
|
879
|
+
deepRow(1),
|
|
880
|
+
deepRow(2),
|
|
881
|
+
]
|
|
882
|
+
const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
|
|
883
|
+
expect(combinedHistoryDepth(3)).toBe(4)
|
|
884
|
+
expect(body).toContain('~~_✓ only-a_~~')
|
|
885
|
+
expect(body).toContain('**→ only-b**')
|
|
886
|
+
// Deep workers still get their full 4.
|
|
887
|
+
expect(shownSteps(body, 1)).toBe(4)
|
|
888
|
+
})
|
|
889
|
+
})
|
|
890
|
+
|
|
801
891
|
/**
|
|
802
892
|
* Worker-feed ghost-leak (immortal/unpinned/buried card) — outcome tests.
|
|
803
893
|
*
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #3373 follow-up (PR #3376 adversarial review) — guards for the
|
|
3
|
+
* SendMessage-resume → feed re-surface seam.
|
|
4
|
+
*
|
|
5
|
+
* The #3376 fix has three links: the watcher fires `onResume` on jsonl growth
|
|
6
|
+
* past a genuine terminal (pinned by subagent-watcher-resume-reregister.test.ts),
|
|
7
|
+
* the gateway's `onResume` handler delegates to `handleWorkerResume`, and
|
|
8
|
+
* `handleWorkerResume` calls `feed.resurrect(agentId)` to clear the terminal
|
|
9
|
+
* `finalized` latch. The review found the middle + last links untested: deleting
|
|
10
|
+
* the gateway handler (or gutting its body) left every test green. This file
|
|
11
|
+
* closes both:
|
|
12
|
+
*
|
|
13
|
+
* 1. UNIT — `handleWorkerResume` calls `feed.resurrect` with the agentId,
|
|
14
|
+
* never throws when resurrect throws or the feed is absent, and always
|
|
15
|
+
* emits the re-surface audit line.
|
|
16
|
+
* 2. SOURCE SCAN (mirrors permission-verdict-resume-guard.test.ts) — the
|
|
17
|
+
* gateway wires an `onResume:` callback into the watcher config AND that
|
|
18
|
+
* callback delegates to `handleWorkerResume`. Deleting the handler, or
|
|
19
|
+
* replacing the delegation with a no-op body, reds this suite.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest'
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
import { dirname, resolve } from 'node:path'
|
|
26
|
+
import { handleWorkerResume } from '../gateway/worker-feed-dispatch.js'
|
|
27
|
+
|
|
28
|
+
describe('handleWorkerResume (issue #3373 seam unit)', () => {
|
|
29
|
+
it('calls feed.resurrect with the agentId and logs the re-surface line', () => {
|
|
30
|
+
const calls: string[] = []
|
|
31
|
+
const logs: string[] = []
|
|
32
|
+
handleWorkerResume({ resurrect: (id) => calls.push(id) }, 'w1', (m) => logs.push(m))
|
|
33
|
+
expect(calls).toEqual(['w1'])
|
|
34
|
+
expect(logs.some((l) => l.includes('w1') && l.includes('RE-SURFACED'))).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('a throwing resurrect is logged, never propagated (watcher poll loop safety)', () => {
|
|
38
|
+
const logs: string[] = []
|
|
39
|
+
expect(() =>
|
|
40
|
+
handleWorkerResume(
|
|
41
|
+
{ resurrect: () => { throw new Error('boom') } },
|
|
42
|
+
'w2',
|
|
43
|
+
(m) => logs.push(m),
|
|
44
|
+
),
|
|
45
|
+
).not.toThrow()
|
|
46
|
+
expect(logs.some((l) => l.includes('boom') && l.includes('w2'))).toBe(true)
|
|
47
|
+
// The audit line still lands after the failure.
|
|
48
|
+
expect(logs.some((l) => l.includes('RE-SURFACED'))).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('a null/undefined feed (feed disabled) is a safe no-op that still audits', () => {
|
|
52
|
+
const logs: string[] = []
|
|
53
|
+
expect(() => handleWorkerResume(null, 'w3', (m) => logs.push(m))).not.toThrow()
|
|
54
|
+
expect(logs.some((l) => l.includes('w3') && l.includes('RE-SURFACED'))).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('gateway onResume wiring (issue #3373 source-scan guard)', () => {
|
|
59
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
60
|
+
const GATEWAY_SRC = readFileSync(
|
|
61
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
62
|
+
'utf8',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
it('the watcher config carries an onResume callback', () => {
|
|
66
|
+
expect(/\bonResume:\s*\(/.test(GATEWAY_SRC)).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('the onResume callback delegates to handleWorkerResume (not an inline no-op)', () => {
|
|
70
|
+
// Match the `onResume: (…) => { … }` callback body and require the
|
|
71
|
+
// delegation call inside it. A gutted handler (empty body / dropped
|
|
72
|
+
// resurrect) fails here even though tsc stays green.
|
|
73
|
+
const m = GATEWAY_SRC.match(/\bonResume:\s*\([^)]*\)\s*=>\s*\{([\s\S]*?)\n\s{14}\},/)
|
|
74
|
+
expect(m, 'onResume callback not found in gateway.ts').not.toBeNull()
|
|
75
|
+
expect(m![1]).toMatch(/\bhandleWorkerResume\s*\(/)
|
|
76
|
+
expect(m![1]).toMatch(/\bworkerActivityFeed\b/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('handleWorkerResume is imported from worker-feed-dispatch (the tested seam)', () => {
|
|
80
|
+
expect(
|
|
81
|
+
/import\s*\{[^}]*\bhandleWorkerResume\b[^}]*\}\s*from\s*'\.\/worker-feed-dispatch\.js'/.test(
|
|
82
|
+
GATEWAY_SRC,
|
|
83
|
+
),
|
|
84
|
+
).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -86,6 +86,7 @@ export function describeToolUse(
|
|
|
86
86
|
import {
|
|
87
87
|
STATUS_CARD_CHAR_BUDGET,
|
|
88
88
|
STATUS_ROLLING_LINES,
|
|
89
|
+
WORKER_HISTORY_MAX,
|
|
89
90
|
STATUS_LINE_MAX,
|
|
90
91
|
NESTED_PREFIX,
|
|
91
92
|
} from './status-no-truncate.js'
|
|
@@ -286,7 +287,8 @@ function escapeStepLine(raw: string): string {
|
|
|
286
287
|
|
|
287
288
|
/**
|
|
288
289
|
* Shared step-feed emitter. Appends `✓`/`→` bullet lines to `out` for the
|
|
289
|
-
* given ALREADY-ESCAPED step strings, windowing to
|
|
290
|
+
* given ALREADY-ESCAPED step strings, windowing to `window` (default
|
|
291
|
+
* STATUS_ROLLING_LINES; the worker surfaces pass a deeper window) and
|
|
290
292
|
* prepending a `+N earlier…` header when the feed overflows the window (on
|
|
291
293
|
* BOTH surfaces now). The worker feed imports this directly.
|
|
292
294
|
*
|
|
@@ -301,9 +303,10 @@ export function renderStepFeed(
|
|
|
301
303
|
steps: string[],
|
|
302
304
|
allDone: boolean,
|
|
303
305
|
liveSuffix = '',
|
|
306
|
+
window: number = STATUS_ROLLING_LINES,
|
|
304
307
|
): void {
|
|
305
308
|
if (steps.length === 0) return
|
|
306
|
-
const shown = steps.slice(-
|
|
309
|
+
const shown = steps.slice(-Math.max(1, window))
|
|
307
310
|
const hidden = steps.length - shown.length
|
|
308
311
|
if (hidden > 0) out.push(`_✓ +${hidden} earlier…_`)
|
|
309
312
|
const lastIdx = shown.length - 1
|
|
@@ -351,6 +354,13 @@ export interface StatusCardOpts {
|
|
|
351
354
|
stepCount?: number
|
|
352
355
|
/** Optional terminal result block (worker recap), already-cleaned text + emoji. */
|
|
353
356
|
result?: { emoji: string; text: string }
|
|
357
|
+
/**
|
|
358
|
+
* How many trailing step/child lines the rolling window shows. Defaults to
|
|
359
|
+
* STATUS_ROLLING_LINES (the 🤖 agent card). The 🛠 single-worker card passes
|
|
360
|
+
* a deeper window (`workerHistoryDepth(1)` = 6) so a lone worker can show its
|
|
361
|
+
* full recent trail — see WORKER_HISTORY_MAX.
|
|
362
|
+
*/
|
|
363
|
+
historyWindow?: number
|
|
354
364
|
}
|
|
355
365
|
|
|
356
366
|
/**
|
|
@@ -364,6 +374,7 @@ export interface StatusCardOpts {
|
|
|
364
374
|
*/
|
|
365
375
|
export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
366
376
|
const { header, final = false, liveSuffix = '', stepCount, result } = opts
|
|
377
|
+
const window = Math.max(1, opts.historyWindow ?? STATUS_ROLLING_LINES)
|
|
367
378
|
const rawSteps = opts.steps.filter((s) => s != null)
|
|
368
379
|
const rawChildren = (opts.childSteps ?? []).map((s) => s.trim()).filter((s) => s.length > 0)
|
|
369
380
|
const hasChildren = rawChildren.length > 0
|
|
@@ -393,12 +404,12 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
393
404
|
|
|
394
405
|
if (hasChildren) {
|
|
395
406
|
// Parent lines all render done — the live → step lives in the nested block.
|
|
396
|
-
const shownParent = steps.slice(-
|
|
407
|
+
const shownParent = steps.slice(-window)
|
|
397
408
|
const hiddenParent = steps.length - shownParent.length
|
|
398
409
|
if (hiddenParent > 0) out.push(`_✓ +${hiddenParent} earlier…_`)
|
|
399
410
|
for (const s of shownParent) out.push(`~~_✓ ${s}_~~`)
|
|
400
411
|
// Child block.
|
|
401
|
-
const shownChild = children.slice(-
|
|
412
|
+
const shownChild = children.slice(-window)
|
|
402
413
|
const hiddenChild = children.length - shownChild.length
|
|
403
414
|
if (hiddenChild > 0) out.push(`${NESTED_PREFIX}_+${hiddenChild} earlier…_`)
|
|
404
415
|
const lastChildIdx = shownChild.length - 1
|
|
@@ -410,7 +421,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
410
421
|
)
|
|
411
422
|
})
|
|
412
423
|
} else {
|
|
413
|
-
renderStepFeed(out, steps, final, liveSuffix)
|
|
424
|
+
renderStepFeed(out, steps, final, liveSuffix, window)
|
|
414
425
|
}
|
|
415
426
|
|
|
416
427
|
if (final && stepCount != null && stepCount > 0) {
|
|
@@ -659,38 +670,65 @@ export interface CombinedWorkerRow {
|
|
|
659
670
|
|
|
660
671
|
export interface CombinedWorkerFeedOpts {
|
|
661
672
|
/** Max worker rows rendered before the `+M more working…` spill line.
|
|
662
|
-
*
|
|
663
|
-
*
|
|
673
|
+
* Rows are kept head-first (`rows.slice(0, visibleCount)`): the earliest
|
|
674
|
+
* supplied (oldest dispatch-order) workers stay visible and the
|
|
675
|
+
* newest/trailing rows spill. */
|
|
664
676
|
maxRows: number
|
|
665
677
|
}
|
|
666
678
|
|
|
679
|
+
/** Each visible worker costs one header line before any history. */
|
|
680
|
+
const PER_WORKER_HEADER_COST = 1
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Floor of the per-worker depth curve — every SHOWN worker renders at least
|
|
684
|
+
* this many recent steps, no matter how large the fan-out (Ken's "4+ → 3 each").
|
|
685
|
+
*/
|
|
686
|
+
const MIN_WORKER_DEPTH = 3
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Design fan-out: the largest concurrent-worker count whose full Ken curve is
|
|
690
|
+
* allowed to render before the total-line backstop starts collapsing the
|
|
691
|
+
* NEWEST (trailing) rows into `+M more working…`. Chosen at 6 — beyond six live workers a
|
|
692
|
+
* per-worker trail is no longer a glanceable card, so extra rows spill rather
|
|
693
|
+
* than every shown worker losing depth. Every worker that IS shown keeps its
|
|
694
|
+
* full curve depth; the ceiling trims row COUNT, never per-worker depth.
|
|
695
|
+
*/
|
|
696
|
+
const DESIGN_FANOUT = 6
|
|
697
|
+
|
|
667
698
|
/**
|
|
668
699
|
* Total per-worker BODY line budget for the combined feed — the sum, across all
|
|
669
700
|
* visible workers, of (one header line + that worker's history lines). The top
|
|
670
701
|
* `🛠 Workers · N running` line and the `+M more working…` spill are OUTSIDE
|
|
671
|
-
* this budget (fixed chrome).
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
702
|
+
* this budget (fixed chrome).
|
|
703
|
+
*
|
|
704
|
+
* Re-derived (#3349) to fit Ken's per-worker curve `max(3, 7 − w)` rather than
|
|
705
|
+
* to DRIVE the depth: at the flat tail (w ≥ 4, depth = MIN_WORKER_DEPTH) each
|
|
706
|
+
* worker costs `PER_WORKER_HEADER_COST + MIN_WORKER_DEPTH` = 4 lines, so a
|
|
707
|
+
* DESIGN_FANOUT of 6 fully-rendered workers needs 6 × 4 = 24 lines. That fully
|
|
708
|
+
* fits every fan-out through 6 workers (w1=7, w2=12, w3=15, w4=16, w5=20,
|
|
709
|
+
* w6=24); a 7th/8th concurrent worker overflows and the backstop drops the
|
|
710
|
+
* newest (trailing) visible rows to the spill — bounding the card at 24 body lines so a
|
|
711
|
+
* big swarm never explodes it. The per-worker curve wins on DEPTH; this ceiling
|
|
712
|
+
* wins on ROW COUNT.
|
|
676
713
|
*/
|
|
677
|
-
const MAX_COMBINED_BODY_LINES =
|
|
678
|
-
/** Each visible worker costs one header line before any history. */
|
|
679
|
-
const PER_WORKER_HEADER_COST = 1
|
|
714
|
+
const MAX_COMBINED_BODY_LINES = DESIGN_FANOUT * (PER_WORKER_HEADER_COST + MIN_WORKER_DEPTH)
|
|
680
715
|
|
|
681
716
|
/**
|
|
682
717
|
* Deterministic per-worker history depth for `w` visible workers:
|
|
683
|
-
* clamp(
|
|
684
|
-
* So
|
|
685
|
-
*
|
|
686
|
-
*
|
|
718
|
+
* clamp( max(MIN_WORKER_DEPTH, 7 − w), 1, WORKER_HISTORY_MAX )
|
|
719
|
+
* So 1 worker → 6, 2 → 5, 3 → 4, 4 → 3, ≥4 → 3 (Ken's 6/5/4/3 curve, #3349).
|
|
720
|
+
* Pure function of the visible worker count — no model input, consistent with
|
|
721
|
+
* deterministic controls. Replaces the former budget-driven divide (which
|
|
722
|
+
* yielded 5/5/3/2/… and could never reach 6 for a lone worker).
|
|
687
723
|
*/
|
|
688
724
|
export function combinedHistoryDepth(w: number): number {
|
|
689
725
|
if (w <= 0) return 1
|
|
690
|
-
|
|
691
|
-
return Math.max(1, Math.min(STATUS_ROLLING_LINES, raw))
|
|
726
|
+
return Math.min(WORKER_HISTORY_MAX, Math.max(MIN_WORKER_DEPTH, 7 - w))
|
|
692
727
|
}
|
|
693
728
|
|
|
729
|
+
/** Alias for readability at the single-worker call site — same curve. */
|
|
730
|
+
export const workerHistoryDepth = combinedHistoryDepth
|
|
731
|
+
|
|
694
732
|
/**
|
|
695
733
|
* Render N≥1 live workers into ONE combined feed body (ready Telegram
|
|
696
734
|
* markdown; callers send verbatim — do NOT re-escape). Layout:
|
|
@@ -720,7 +758,7 @@ export function combinedHistoryDepth(w: number): number {
|
|
|
720
758
|
* Pure. Rows are rendered in the order supplied (the manager passes them
|
|
721
759
|
* dispatch-order, oldest first). `maxRows` caps the visible rows; the hidden
|
|
722
760
|
* remainder collapses to a single `+M more working…` line. A total-budget
|
|
723
|
-
* backstop drops the
|
|
761
|
+
* backstop drops the NEWEST (trailing) visible rows one at a time (growing the spill)
|
|
724
762
|
* until the body fits STATUS_CARD_CHAR_BUDGET, so a burst of long descriptions
|
|
725
763
|
* can never overflow the wire limit. Returns null only when `rows` is empty.
|
|
726
764
|
*/
|
|
@@ -758,37 +796,47 @@ export function renderCombinedWorkerFeed(
|
|
|
758
796
|
return src.filter((s) => s != null && stripMarkdown(s).replace(/\s+/g, ' ').trim().length > 0)
|
|
759
797
|
}
|
|
760
798
|
|
|
761
|
-
const compose = (visibleCount: number): string => {
|
|
799
|
+
const compose = (visibleCount: number): { body: string; bodyLines: number } => {
|
|
762
800
|
const shown = rows.slice(0, visibleCount)
|
|
763
801
|
const hidden = rows.length - shown.length
|
|
764
|
-
//
|
|
765
|
-
//
|
|
802
|
+
// Per-worker depth follows Ken's deterministic curve max(3, 7−w) (#3349):
|
|
803
|
+
// the curve drives DEPTH; the total-line budget below drives ROW COUNT.
|
|
766
804
|
const depth = combinedHistoryDepth(shown.length)
|
|
767
|
-
const
|
|
805
|
+
const chrome: string[] = [`🛠 **Workers** · _${rows.length} running_`]
|
|
806
|
+
const bodyOut: string[] = []
|
|
768
807
|
for (const r of shown) {
|
|
769
|
-
|
|
808
|
+
bodyOut.push(rowHeader(r))
|
|
770
809
|
const hist = rowHistory(r)
|
|
771
810
|
if (hist.length === 0) {
|
|
772
|
-
|
|
811
|
+
bodyOut.push('→ _starting…_')
|
|
773
812
|
continue
|
|
774
813
|
}
|
|
775
814
|
// Paint the last-K history lines with the SAME `✓`/`→` idiom as the
|
|
776
815
|
// single-worker card: escape each raw line through the shared per-line
|
|
777
816
|
// pipeline (escapeStepLine), then renderStepFeed strikes the prior steps
|
|
778
|
-
// and bolds the newest in-progress step.
|
|
817
|
+
// and bolds the newest in-progress step. The window equals the depth so a
|
|
818
|
+
// per-worker `+N earlier…` marker never appears inside the combined feed.
|
|
779
819
|
const esc = hist.slice(-depth).map(escapeStepLine)
|
|
780
|
-
renderStepFeed(
|
|
820
|
+
renderStepFeed(bodyOut, esc, false, '', depth)
|
|
781
821
|
}
|
|
822
|
+
const out = [...chrome, ...bodyOut]
|
|
782
823
|
if (hidden > 0) out.push(`_+${hidden} more working…_`)
|
|
783
|
-
return stackCardLines(out)
|
|
824
|
+
return { body: stackCardLines(out), bodyLines: bodyOut.length }
|
|
784
825
|
}
|
|
785
826
|
|
|
786
|
-
// Cap to maxRows first, then shrink
|
|
827
|
+
// Cap to maxRows first, then shrink the visible set while EITHER the total
|
|
828
|
+
// body-line budget (#3349: bounds a big swarm without stealing depth from the
|
|
829
|
+
// shown workers) OR the wire char budget is exceeded. Newest (trailing) rows
|
|
830
|
+
// collapse into the `+M more working…` spill (`rows.slice(0, visibleCount)`
|
|
831
|
+
// keeps the head of the list).
|
|
787
832
|
let visible = Math.min(rows.length, maxRows)
|
|
788
|
-
let body = compose(visible)
|
|
789
|
-
while (
|
|
833
|
+
let { body, bodyLines } = compose(visible)
|
|
834
|
+
while (
|
|
835
|
+
(bodyLines > MAX_COMBINED_BODY_LINES || body.length > STATUS_CARD_CHAR_BUDGET) &&
|
|
836
|
+
visible > 1
|
|
837
|
+
) {
|
|
790
838
|
visible -= 1
|
|
791
|
-
body = compose(visible)
|
|
839
|
+
;({ body, bodyLines } = compose(visible))
|
|
792
840
|
}
|
|
793
841
|
return body
|
|
794
842
|
}
|
|
@@ -155,28 +155,80 @@ export const FLUSH_SUBSTANTIVE_MIN_CHARS = 200
|
|
|
155
155
|
* `[verboseNarration(250), realAnswer(150)]` a reversed length scan returns the
|
|
156
156
|
* 250-char narration and DROPS the 150-char real answer. Instead we take the
|
|
157
157
|
* last non-empty block as the answer and strip only the EARLIER blocks — and
|
|
158
|
-
* only when they look like intent-narration
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
158
|
+
* only when they look like intent-narration. If the earlier blocks are
|
|
159
|
+
* themselves substantial (a genuine multi-paragraph answer written as several
|
|
160
|
+
* blocks) we keep the whole thing joined, so we never truncate a real long
|
|
161
|
+
* answer down to its last paragraph.
|
|
162
|
+
*
|
|
163
|
+
* #3237 — the STRUCTURAL discriminator. The opener heuristic below
|
|
164
|
+
* (`isNarrationBlock`) cannot tell a narration preamble ("Let me pull the
|
|
165
|
+
* numbers…" followed by a separate reply) from a real answer paragraph that
|
|
166
|
+
* merely OPENS with "Let me explain…": both match the same regex, and length
|
|
167
|
+
* cannot separate them (the observed narration was itself ≥200 chars). The one
|
|
168
|
+
* signal that DOES separate them is structural — did a `tool_use` follow this
|
|
169
|
+
* block in the model's actual message? A narration preamble is drafted, then
|
|
170
|
+
* the model ACTS (a tool call follows it); a terminal answer paragraph is not
|
|
171
|
+
* followed by any tool call. That per-block flag (`lastInMessage`) is computed
|
|
172
|
+
* upstream by `projectAssistantTextBlocks` (session-tail.ts) and, when the
|
|
173
|
+
* caller plumbs it through as `followedByToolUse`, we consult STRUCTURE, but
|
|
174
|
+
* asymmetrically:
|
|
175
|
+
* - present-and-FALSE (no tool_use followed) is purely additive — it can only
|
|
176
|
+
* RESCUE a block the opener regex would have mis-stripped (a terminal answer
|
|
177
|
+
* opening "Let me explain…"); it never drops a block the heuristic kept.
|
|
178
|
+
* - present-and-TRUE (a tool_use followed) is NOT taken as narration on its
|
|
179
|
+
* own: a substantial real-content paragraph can precede a tool call, so the
|
|
180
|
+
* strip is GATED by the substance check (narration only if it also matches
|
|
181
|
+
* the opener/trailer heuristic OR falls below the substantive floor). This
|
|
182
|
+
* is deliberately not "strictly additive" over the opener-only strip — it
|
|
183
|
+
* both rescues real content the old flag-alone path would have dropped and
|
|
184
|
+
* stays truncation-free.
|
|
185
|
+
* Where the structural flag is ABSENT for a block (legacy `string[]` caller, or
|
|
186
|
+
* the accumulator lost provenance) we fall back to the opener/trailer heuristic
|
|
187
|
+
* for that block.
|
|
188
|
+
*
|
|
189
|
+
* `followedByToolUse` is a parallel array aligned to `blocks` (index `i` ⇒
|
|
190
|
+
* `blocks[i]`); it is zipped BEFORE the empty-block filter so alignment holds
|
|
191
|
+
* even if the caller passes empty/whitespace blocks. `undefined` at an index
|
|
192
|
+
* (or a missing/short array) means "no reliable structural signal for this
|
|
193
|
+
* block" → opener-heuristic fallback.
|
|
162
194
|
*
|
|
163
195
|
* `blocks` are already trimmed/non-empty candidates (silent markers removed by
|
|
164
196
|
* the caller's guards). Returns the chosen delivery text.
|
|
165
197
|
*/
|
|
166
|
-
export function selectFlushDeliveryText(
|
|
198
|
+
export function selectFlushDeliveryText(
|
|
199
|
+
blocks: string[],
|
|
200
|
+
followedByToolUse?: ReadonlyArray<boolean | undefined>,
|
|
201
|
+
): string {
|
|
167
202
|
const candidates = blocks
|
|
168
|
-
.map(b => b.trim())
|
|
169
|
-
.filter(
|
|
203
|
+
.map((b, i) => ({ text: b.trim(), followedByToolUse: followedByToolUse?.[i] }))
|
|
204
|
+
.filter(c => c.text.length > 0)
|
|
170
205
|
if (candidates.length === 0) return ''
|
|
171
|
-
if (candidates.length === 1) return candidates[0]
|
|
172
|
-
const answer = candidates[candidates.length - 1]
|
|
206
|
+
if (candidates.length === 1) return candidates[0].text
|
|
207
|
+
const answer = candidates[candidates.length - 1].text
|
|
173
208
|
const preceding = candidates.slice(0, -1)
|
|
174
209
|
// Deliver only the terminal answer when every earlier block is
|
|
175
|
-
// intent-narration
|
|
210
|
+
// intent-narration. Per block:
|
|
211
|
+
// - structural flag TRUE ⇒ a tool_use followed this block, but that alone
|
|
212
|
+
// is NOT sufficient to drop it: a substantial real-content paragraph can
|
|
213
|
+
// legitimately precede a tool call (the model writes a real answer, then
|
|
214
|
+
// calls a memory/verify tool, then a short wrap-up). So gate the
|
|
215
|
+
// structural strip with the substantive floor — narration only if the
|
|
216
|
+
// block ALSO looks like narration OR is below the substantive floor.
|
|
217
|
+
// - structural flag FALSE ⇒ present-and-false definitively overrides the
|
|
218
|
+
// opener regex: no tool_use followed, so it is a terminal-style block,
|
|
219
|
+
// never narration (#3237).
|
|
220
|
+
// - structural flag ABSENT ⇒ fall back to the opener/trailer heuristic.
|
|
176
221
|
// Otherwise the earlier blocks carry real content — keep the full joined text
|
|
177
|
-
// so a legitimate multi-block answer is never truncated to its last
|
|
178
|
-
|
|
179
|
-
|
|
222
|
+
// so a legitimate multi-block answer is never truncated to its last
|
|
223
|
+
// paragraph (#3237).
|
|
224
|
+
const allNarration = preceding.every(c =>
|
|
225
|
+
c.followedByToolUse === true
|
|
226
|
+
? isNarrationBlock(c.text) || c.text.trim().length < FLUSH_SUBSTANTIVE_MIN_CHARS
|
|
227
|
+
: c.followedByToolUse === false
|
|
228
|
+
? false
|
|
229
|
+
: isNarrationBlock(c.text),
|
|
230
|
+
)
|
|
231
|
+
return allNarration ? answer : candidates.map(c => c.text).join('\n\n')
|
|
180
232
|
}
|
|
181
233
|
|
|
182
234
|
/**
|
|
@@ -244,6 +296,17 @@ export interface FlushDecisionInput {
|
|
|
244
296
|
* is false — once the model has called reply / stream_reply the turn is
|
|
245
297
|
* served and trailing terminal text is dropped (see `decideTurnFlush`). */
|
|
246
298
|
capturedText: string[]
|
|
299
|
+
/** Optional per-block structural provenance, aligned to `capturedText`
|
|
300
|
+
* (index `i` describes `capturedText[i]`). `true` ⇒ a `tool_use` followed
|
|
301
|
+
* this text block in its assistant message (the draft-then-send narration
|
|
302
|
+
* signal — the negation of `projectAssistantTextBlocks`' `lastInMessage`).
|
|
303
|
+
* Consumed by `selectFlushDeliveryText` to separate a narration preamble from
|
|
304
|
+
* a real answer paragraph that merely opens with a narration phrase (#3237).
|
|
305
|
+
* When absent (legacy caller / lost provenance) the strip falls back to the
|
|
306
|
+
* opener/trailer heuristic. Present-and-false is additive (only rescues a
|
|
307
|
+
* mis-stripped terminal answer); present-and-true is gated by the substantive
|
|
308
|
+
* floor rather than trusted alone. */
|
|
309
|
+
capturedBlockMeta?: boolean[]
|
|
247
310
|
/** Feature flag — defaults to true. Pass `false` to force skip everywhere. */
|
|
248
311
|
flushEnabled?: boolean
|
|
249
312
|
}
|
|
@@ -316,7 +379,10 @@ export function decideTurnFlush(input: FlushDecisionInput): FlushDecision {
|
|
|
316
379
|
// blob (see `selectFlushDeliveryText`). The silent-marker / empty guards above
|
|
317
380
|
// still run on the full `joined` string so a partly-silent turn is classified
|
|
318
381
|
// correctly; only the DELIVERED text is narrowed to the answer.
|
|
319
|
-
return {
|
|
382
|
+
return {
|
|
383
|
+
kind: 'flush',
|
|
384
|
+
text: selectFlushDeliveryText(input.capturedText, input.capturedBlockMeta),
|
|
385
|
+
}
|
|
320
386
|
}
|
|
321
387
|
|
|
322
388
|
/**
|