switchroom 0.15.45 → 0.16.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +56 -15
- package/dist/auth-broker/index.js +383 -97
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +7 -4
- package/dist/cli/notion-write-pretool.mjs +35 -4
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/switchroom.js +2894 -841
- package/dist/host-control/main.js +2685 -207
- package/dist/vault/approvals/kernel-server.js +7453 -7413
- package/dist/vault/broker/server.js +11428 -11388
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +97 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +55 -12
- package/telegram-plugin/dist/gateway/gateway.js +2938 -977
- package/telegram-plugin/dist/server.js +55 -12
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1857 -292
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-command.test.ts +134 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -42,7 +42,8 @@ import { basename, join } from 'path'
|
|
|
42
42
|
import { homedir } from 'os'
|
|
43
43
|
import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
|
|
44
44
|
import { sanitiseToolArg } from './fleet-state.js'
|
|
45
|
-
import { describeToolUse } from './tool-activity-summary.js'
|
|
45
|
+
import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
|
|
46
|
+
import { REPLY_TOOLS, isDraftOfReply } from './narrative-dedup.js'
|
|
46
47
|
import { escapeHtml, truncate } from './card-format.js'
|
|
47
48
|
import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents } from './registry/subagents-schema.js'
|
|
48
49
|
import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
|
|
@@ -158,6 +159,27 @@ export interface WorkerEntry {
|
|
|
158
159
|
* failed handback's "what it reported before failing" slot when the
|
|
159
160
|
* worker left no narrative result of its own. */
|
|
160
161
|
errorDetail?: string
|
|
162
|
+
/**
|
|
163
|
+
* Narrative-dedup gate state (JSONL-text-narrative primitive). A
|
|
164
|
+
* `sub_agent_text` block is held here for ONE lookahead step so the next
|
|
165
|
+
* `sub_agent_tool_use` / `sub_agent_turn_end` can decide draft-then-send
|
|
166
|
+
* (SUPPRESS — it duplicates the worker's reply) vs working-narration (SHOW
|
|
167
|
+
* — fire `onProgress({latestSummary})`). Null when nothing is pending. The
|
|
168
|
+
* pure decision lives in narrative-dedup.ts; this slot is the per-entry
|
|
169
|
+
* cursor. Mirrors the gateway's `turn.pendingNarrative`.
|
|
170
|
+
*/
|
|
171
|
+
pendingNarrative?: { text: string } | null
|
|
172
|
+
/**
|
|
173
|
+
* NIT 3 (sub-agent turn_end symmetry). Most-recently-seen
|
|
174
|
+
* reply/stream_reply `input.text` for this sub-agent — the actual answer a
|
|
175
|
+
* FOREGROUND sub-agent delivered. `sub_agent_turn_end` resolves a trailing
|
|
176
|
+
* `sub_agent_text` block against THIS so a draft of the just-delivered
|
|
177
|
+
* answer is suppressed the same way main-agent step 3 does (conservative
|
|
178
|
+
* dedup). Undefined for background workers that never call a reply tool —
|
|
179
|
+
* their trailing narration still SHOWs, unchanged. Mirrors the gateway's
|
|
180
|
+
* `turn.lastReplyText`.
|
|
181
|
+
*/
|
|
182
|
+
lastReplyText?: string
|
|
161
183
|
}
|
|
162
184
|
|
|
163
185
|
export interface SubagentWatcherConfig {
|
|
@@ -503,14 +525,20 @@ interface FsLike {
|
|
|
503
525
|
* Backfill `jsonl_agent_id` for a sub-agent row that was inserted by the
|
|
504
526
|
* PreToolUse hook (keyed on tool_use_id) but didn't yet know the JSONL stem.
|
|
505
527
|
*
|
|
506
|
-
* Strategy: read the `agent-<id>.meta.json` sibling Claude Code
|
|
507
|
-
* to each sub-agent JSONL. It carries
|
|
508
|
-
*
|
|
509
|
-
*
|
|
528
|
+
* Strategy: read the `agent-<id>.meta.json` sibling that the Claude Code
|
|
529
|
+
* binary writes next to each sub-agent JSONL. It carries `{ agentType,
|
|
530
|
+
* description, toolUseId }` where `toolUseId` is the primary key of the
|
|
531
|
+
* `subagents` row — the same `event.tool_use_id` value the pretool hook
|
|
532
|
+
* (`subagent-tracker-pretool.mjs`) uses when it inserts the DB row. We use
|
|
533
|
+
* the direct `toolUseId` lookup first (exact PK match, race-safe); fall back
|
|
534
|
+
* to the fuzzy `(agentType, description)` match only when `toolUseId` is
|
|
535
|
+
* absent (older Claude Code versions that pre-date this field in the meta).
|
|
510
536
|
*
|
|
511
537
|
* Edge cases:
|
|
512
538
|
* - meta.json missing or unreadable: no-op (the row stays unlinked; liveness
|
|
513
539
|
* writes from this agent's JSONL won't land, but the system stays correct).
|
|
540
|
+
* - `toolUseId` present but no matching row (hook crashed / race): fall
|
|
541
|
+
* through to the fuzzy match so the link is still attempted.
|
|
514
542
|
* - Multiple in-flight rows with identical (agent_type, description): the
|
|
515
543
|
* most recently started one wins (FIFO matches dispatch order in practice).
|
|
516
544
|
* - Row already linked to a different agentId: SQL `WHERE jsonl_agent_id IS
|
|
@@ -526,7 +554,7 @@ export function backfillJsonlAgentId(
|
|
|
526
554
|
log?: (msg: string) => void,
|
|
527
555
|
): void {
|
|
528
556
|
const metaPath = jsonlPath.replace(/\.jsonl$/, '.meta.json')
|
|
529
|
-
let meta: { agentType?: string; description?: string }
|
|
557
|
+
let meta: { agentType?: string; description?: string; toolUseId?: string } | null
|
|
530
558
|
try {
|
|
531
559
|
const raw = readFileSync(metaPath, 'utf8')
|
|
532
560
|
meta = JSON.parse(raw)
|
|
@@ -534,8 +562,8 @@ export function backfillJsonlAgentId(
|
|
|
534
562
|
log?.(`subagent-watcher: backfill skip ${agentId} — meta.json not readable at ${metaPath}`)
|
|
535
563
|
return
|
|
536
564
|
}
|
|
537
|
-
if (!meta.agentType && !meta.description) {
|
|
538
|
-
log?.(`subagent-watcher: backfill skip ${agentId} — meta.json has no agentType/description`)
|
|
565
|
+
if (!meta || (!meta.agentType && !meta.description && !meta.toolUseId)) {
|
|
566
|
+
log?.(`subagent-watcher: backfill skip ${agentId} — meta.json has no agentType/description/toolUseId`)
|
|
539
567
|
return
|
|
540
568
|
}
|
|
541
569
|
|
|
@@ -545,27 +573,51 @@ export function backfillJsonlAgentId(
|
|
|
545
573
|
.get(agentId)
|
|
546
574
|
if (already != null) return
|
|
547
575
|
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
576
|
+
// Primary path (Bug 1 fix): direct PK lookup via the toolUseId Claude Code
|
|
577
|
+
// writes to meta.json. The pretool hook inserts the row with `id =
|
|
578
|
+
// event.tool_use_id`, so this is an exact match with no ambiguity — no
|
|
579
|
+
// race, no description-collision, no fuzzy-match false-negative.
|
|
580
|
+
let candidateId: string | null = null
|
|
581
|
+
if (meta.toolUseId) {
|
|
582
|
+
const direct = db
|
|
583
|
+
.prepare('SELECT id FROM subagents WHERE id = ? AND jsonl_agent_id IS NULL LIMIT 1')
|
|
584
|
+
.get(meta.toolUseId) as { id: string } | null
|
|
585
|
+
if (direct != null) {
|
|
586
|
+
candidateId = direct.id
|
|
587
|
+
log?.(`subagent-watcher: backfill direct-key match ${agentId} → ${candidateId} (toolUseId=${meta.toolUseId})`)
|
|
588
|
+
} else {
|
|
589
|
+
log?.(`subagent-watcher: backfill direct-key miss ${agentId} toolUseId=${meta.toolUseId} — falling back to fuzzy match`)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Fallback path: fuzzy (agentType, description) match for older Claude Code
|
|
594
|
+
// versions whose meta.json predates the toolUseId field.
|
|
595
|
+
if (candidateId == null && (meta.agentType || meta.description)) {
|
|
596
|
+
const fuzzy = db
|
|
597
|
+
.prepare(`
|
|
598
|
+
SELECT id FROM subagents
|
|
599
|
+
WHERE jsonl_agent_id IS NULL
|
|
600
|
+
AND agent_type IS ?
|
|
601
|
+
AND description IS ?
|
|
602
|
+
ORDER BY started_at DESC
|
|
603
|
+
LIMIT 1
|
|
604
|
+
`)
|
|
605
|
+
.get(meta.agentType ?? null, meta.description ?? null) as { id: string } | null
|
|
606
|
+
if (fuzzy != null) {
|
|
607
|
+
candidateId = fuzzy.id
|
|
608
|
+
log?.(`subagent-watcher: backfill fuzzy match ${agentId} → ${candidateId} (type=${meta.agentType} desc=${meta.description})`)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (candidateId == null) {
|
|
613
|
+
log?.(`subagent-watcher: backfill no candidate for ${agentId} (toolUseId=${meta.toolUseId} type=${meta.agentType} desc=${meta.description})`)
|
|
562
614
|
return
|
|
563
615
|
}
|
|
564
616
|
|
|
565
617
|
db
|
|
566
618
|
.prepare('UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?')
|
|
567
|
-
.run(agentId,
|
|
568
|
-
log?.(`subagent-watcher: backfill linked ${agentId} → ${
|
|
619
|
+
.run(agentId, candidateId)
|
|
620
|
+
log?.(`subagent-watcher: backfill linked ${agentId} → ${candidateId}`)
|
|
569
621
|
|
|
570
622
|
// Backfill parent_turn_key (gateway-side). The PreToolUse hook can't know
|
|
571
623
|
// the gateway-minted Telegram turn_key (a chat+topic+turn key) — it only
|
|
@@ -588,7 +640,7 @@ export function backfillJsonlAgentId(
|
|
|
588
640
|
try {
|
|
589
641
|
const linkedRow = db
|
|
590
642
|
.prepare('SELECT started_at, parent_turn_key FROM subagents WHERE id = ?')
|
|
591
|
-
.get(
|
|
643
|
+
.get(candidateId) as { started_at: number; parent_turn_key: string | null } | null
|
|
592
644
|
if (linkedRow != null && linkedRow.parent_turn_key == null) {
|
|
593
645
|
const turn = db
|
|
594
646
|
.prepare(
|
|
@@ -600,12 +652,12 @@ export function backfillJsonlAgentId(
|
|
|
600
652
|
if (turn?.turn_key != null) {
|
|
601
653
|
db
|
|
602
654
|
.prepare('UPDATE subagents SET parent_turn_key = ? WHERE id = ?')
|
|
603
|
-
.run(turn.turn_key,
|
|
604
|
-
log?.(`subagent-watcher: backfill parent_turn_key ${
|
|
655
|
+
.run(turn.turn_key, candidateId)
|
|
656
|
+
log?.(`subagent-watcher: backfill parent_turn_key ${candidateId} → ${turn.turn_key}`)
|
|
605
657
|
}
|
|
606
658
|
}
|
|
607
659
|
} catch (err) {
|
|
608
|
-
log?.(`subagent-watcher: parent_turn_key backfill skipped for ${
|
|
660
|
+
log?.(`subagent-watcher: parent_turn_key backfill skipped for ${candidateId} — ${(err as Error).message}`)
|
|
609
661
|
}
|
|
610
662
|
}
|
|
611
663
|
|
|
@@ -743,6 +795,62 @@ export function readSubTail(
|
|
|
743
795
|
if (errInfo.detail) entry.errorDetail = errInfo.detail.slice(0, SUBAGENT_RESULT_TEXT_MAX)
|
|
744
796
|
}
|
|
745
797
|
const events = projectSubagentLine(line, entry.agentId, startState)
|
|
798
|
+
// Narrative-dedup gate (JSONL-text-narrative primitive) — fire the
|
|
799
|
+
// narrative progress cue for a SHOWN sub_agent_text block. Identical
|
|
800
|
+
// shape to the inline #1720 onProgress below; factored out so the gate
|
|
801
|
+
// (stage-on-text, resolve-on-tool/turn_end) can replay a previously
|
|
802
|
+
// pending block exactly once. `latestSummary` carries the worker's
|
|
803
|
+
// narrative result (entry.lastResultText), never tool labels.
|
|
804
|
+
const fireNarrativeProgress = (): void => {
|
|
805
|
+
if (onProgress == null || entry.state !== 'running' || entry.historical) return
|
|
806
|
+
try {
|
|
807
|
+
onProgress({
|
|
808
|
+
agentId: entry.agentId,
|
|
809
|
+
description: entry.description,
|
|
810
|
+
latestSummary: entry.lastResultText,
|
|
811
|
+
elapsedMs: now - entry.dispatchedAt,
|
|
812
|
+
prevBucketIdx: entry.lastProgressBucketIdx,
|
|
813
|
+
setBucketIdx: (b: number) => {
|
|
814
|
+
entry.lastProgressBucketIdx = b
|
|
815
|
+
},
|
|
816
|
+
lastTool: entry.lastTool,
|
|
817
|
+
toolCount: entry.toolCount,
|
|
818
|
+
})
|
|
819
|
+
} catch (cbErr) {
|
|
820
|
+
log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
// Resolve a pending sub-agent narrative against a lookahead event.
|
|
824
|
+
// SUPPRESS only when the pending block drafts a reply/stream_reply
|
|
825
|
+
// tool's text; otherwise SHOW (fire the cue). See narrative-dedup.ts §2b.
|
|
826
|
+
//
|
|
827
|
+
// Two lookahead shapes:
|
|
828
|
+
// - sub_agent_tool_use: `toolName`/`toolInput` are the tool — suppress
|
|
829
|
+
// a draft of THIS tool's reply text.
|
|
830
|
+
// - sub_agent_turn_end: `toolName` is null. NIT 3 (turn_end symmetry):
|
|
831
|
+
// a FOREGROUND sub-agent that called stream_reply/reply as its final
|
|
832
|
+
// tool then emitted a trailing text block would, under the old
|
|
833
|
+
// unconditional SHOW, surface a draft of the delivered answer. So at
|
|
834
|
+
// turn_end we apply the SAME conservative dedup as main-agent step 3:
|
|
835
|
+
// compare the trailing block against the worker's last reply text
|
|
836
|
+
// (`entry.lastReplyText`) and suppress a draft. Background workers
|
|
837
|
+
// never set lastReplyText, so their trailing narration still SHOWs.
|
|
838
|
+
const resolvePendingSubNarrative = (
|
|
839
|
+
toolName: string | null,
|
|
840
|
+
toolInput: Record<string, unknown> | undefined,
|
|
841
|
+
): void => {
|
|
842
|
+
if (entry.pendingNarrative == null) return
|
|
843
|
+
const pending = entry.pendingNarrative
|
|
844
|
+
entry.pendingNarrative = null
|
|
845
|
+
if (toolName != null && REPLY_TOOLS.has(toolName)) {
|
|
846
|
+
const replyText = typeof toolInput?.text === 'string' ? (toolInput.text as string) : ''
|
|
847
|
+
if (isDraftOfReply(pending.text, replyText)) return // draft of the reply → SUPPRESS
|
|
848
|
+
} else if (toolName == null && entry.lastReplyText != null && entry.lastReplyText.length > 0) {
|
|
849
|
+
// turn_end path: suppress a trailing draft of the delivered answer.
|
|
850
|
+
if (isDraftOfReply(pending.text, entry.lastReplyText)) return
|
|
851
|
+
}
|
|
852
|
+
fireNarrativeProgress()
|
|
853
|
+
}
|
|
746
854
|
for (const ev of events) {
|
|
747
855
|
const idleSecBeforeBump = Math.round((now - entry.lastActivityAt) / 1000)
|
|
748
856
|
entry.lastActivityAt = now
|
|
@@ -783,6 +891,17 @@ export function readSubTail(
|
|
|
783
891
|
log?.(`subagent-watcher: stall cleared for ${entry.agentId} (activity resumed after ${idleSecBeforeBump}s — re-arming detection)`)
|
|
784
892
|
}
|
|
785
893
|
if (ev.kind === 'sub_agent_tool_use') {
|
|
894
|
+
// Narrative-dedup gate step 2: a sub_agent_text block was pending;
|
|
895
|
+
// this tool is the lookahead that decides it (SHOW unless it drafts
|
|
896
|
+
// a reply tool's text). Runs before the tool's own progress cue so
|
|
897
|
+
// a working preamble surfaces just ahead of its tool step.
|
|
898
|
+
resolvePendingSubNarrative(ev.toolName, ev.input)
|
|
899
|
+
// NIT 3: capture a foreground sub-agent's actual reply text so the
|
|
900
|
+
// turn_end path can suppress a trailing draft of it (see
|
|
901
|
+
// resolvePendingSubNarrative). Only REPLY_TOOLS carry the answer.
|
|
902
|
+
if (REPLY_TOOLS.has(ev.toolName) && typeof ev.input?.text === 'string') {
|
|
903
|
+
entry.lastReplyText = ev.input.text as string
|
|
904
|
+
}
|
|
786
905
|
entry.toolCount++
|
|
787
906
|
// P0 of #662: surface the most recent tool name + sanitised
|
|
788
907
|
// arg so the driver's fleet-state shadow can render the
|
|
@@ -830,7 +949,7 @@ export function readSubTail(
|
|
|
830
949
|
// set at dispatch time (from the parent Agent/Task tool_use input)
|
|
831
950
|
// and must remain stable. Overwriting it with the sub-agent's first
|
|
832
951
|
// narrative line caused a race-condition-dependent display (issue #352).
|
|
833
|
-
entry.lastSummaryLine = ev.text
|
|
952
|
+
entry.lastSummaryLine = clipNarrative(ev.text)
|
|
834
953
|
// Retain the full text of the most recent narrative emission —
|
|
835
954
|
// for a worker the final such line before turn_end IS its
|
|
836
955
|
// result summary (the worker prompt asks it to "return a
|
|
@@ -841,29 +960,28 @@ export function readSubTail(
|
|
|
841
960
|
// args or file content — consistent with the watcher's
|
|
842
961
|
// "descriptions only" privacy posture.
|
|
843
962
|
entry.lastResultText = ev.text.trim().slice(0, SUBAGENT_RESULT_TEXT_MAX)
|
|
844
|
-
// #1720
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
elapsedMs: now - entry.dispatchedAt,
|
|
855
|
-
prevBucketIdx: entry.lastProgressBucketIdx,
|
|
856
|
-
setBucketIdx: (b: number) => {
|
|
857
|
-
entry.lastProgressBucketIdx = b
|
|
858
|
-
},
|
|
859
|
-
lastTool: entry.lastTool,
|
|
860
|
-
toolCount: entry.toolCount,
|
|
861
|
-
})
|
|
862
|
-
} catch (cbErr) {
|
|
863
|
-
log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
864
|
-
}
|
|
963
|
+
// #1720 + JSONL-text-narrative gate step 1: stage this block for
|
|
964
|
+
// one lookahead step instead of firing the progress cue
|
|
965
|
+
// immediately. A previously-pending block had nothing reply-shaped
|
|
966
|
+
// after it (pure narration) → flush it as SHOWN now; then stage
|
|
967
|
+
// THIS block. Its eventual SHOW/SUPPRESS is decided by the next
|
|
968
|
+
// sub_agent_tool_use / sub_agent_turn_end. `lastResultText` /
|
|
969
|
+
// `lastSummaryLine` above already updated unconditionally — the
|
|
970
|
+
// handback payload is independent of the progress-cue decision.
|
|
971
|
+
if (entry.pendingNarrative != null) {
|
|
972
|
+
fireNarrativeProgress() // prior pending was pure narration → SHOW
|
|
865
973
|
}
|
|
974
|
+
entry.pendingNarrative = { text: ev.text }
|
|
866
975
|
} else if (ev.kind === 'sub_agent_turn_end') {
|
|
976
|
+
// Narrative-dedup gate step 3: a trailing sub_agent_text block with
|
|
977
|
+
// nothing after it. SUPPRESS only when it drafts the foreground
|
|
978
|
+
// sub-agent's delivered reply (entry.lastReplyText, set above on a
|
|
979
|
+
// REPLY_TOOL tool_use) — symmetric with main-agent step 3; otherwise
|
|
980
|
+
// SHOW. Background workers never set lastReplyText, so their trailing
|
|
981
|
+
// narration still SHOWs. The worker's result is carried separately
|
|
982
|
+
// via lastResultText/onFinish, so a SHOWN trailing cue here is purely
|
|
983
|
+
// the transient liveness beat.
|
|
984
|
+
resolvePendingSubNarrative(null, undefined)
|
|
867
985
|
if (entry.state === 'running') {
|
|
868
986
|
entry.state = 'done'
|
|
869
987
|
// Bug 2 fix (#333): mark the DB row completed via watcher's turn_end
|
|
@@ -1456,25 +1574,53 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1456
1574
|
const subagentsPath = join(projectPath, sDir, 'subagents')
|
|
1457
1575
|
if (!fs.existsSync(subagentsPath)) continue
|
|
1458
1576
|
|
|
1459
|
-
// Watch
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
const
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1577
|
+
// Watch a single flat subagents dir and scan its agent-*.jsonl files.
|
|
1578
|
+
// Reused for both the base subagents/ dir and each workflow sub-dir.
|
|
1579
|
+
const watchAndScan = (dirPath: string): void => {
|
|
1580
|
+
if (!dirWatchers.has(dirPath)) {
|
|
1581
|
+
try {
|
|
1582
|
+
const w = fs.watch(dirPath, (_event, filename) => {
|
|
1583
|
+
if (!filename || !filename.toString().startsWith('agent-') || !filename.toString().endsWith('.jsonl')) return
|
|
1584
|
+
const filePath = join(dirPath, filename.toString())
|
|
1585
|
+
if (!knownFiles.has(filePath)) {
|
|
1586
|
+
scanSubagentsDir(dirPath)
|
|
1587
|
+
}
|
|
1588
|
+
})
|
|
1589
|
+
dirWatchers.set(dirPath, w)
|
|
1590
|
+
log?.(`subagent-watcher: watching dir ${dirPath}`)
|
|
1591
|
+
} catch (err) {
|
|
1592
|
+
log?.(`subagent-watcher: dir watch failed ${dirPath}: ${(err as Error).message}`)
|
|
1593
|
+
}
|
|
1473
1594
|
}
|
|
1595
|
+
scanSubagentsDir(dirPath)
|
|
1474
1596
|
}
|
|
1475
1597
|
|
|
1476
|
-
//
|
|
1477
|
-
|
|
1598
|
+
// Register the base subagents dir
|
|
1599
|
+
watchAndScan(subagentsPath)
|
|
1600
|
+
|
|
1601
|
+
// Workflow sub-agents (spawned by the Workflow tool) write to:
|
|
1602
|
+
// subagents/workflows/wf_<id>/agent-<id>.jsonl
|
|
1603
|
+
// The flat readdir above misses these because it only sees the
|
|
1604
|
+
// "workflows" directory entry (not matching agent-*.jsonl). Descend
|
|
1605
|
+
// one level so each wf_*/ dir gets the same watch+scan treatment.
|
|
1606
|
+
const workflowsPath = join(subagentsPath, 'workflows')
|
|
1607
|
+
if (fs.existsSync(workflowsPath)) {
|
|
1608
|
+
let wfDirs: string[]
|
|
1609
|
+
try {
|
|
1610
|
+
wfDirs = fs.readdirSync(workflowsPath) as string[]
|
|
1611
|
+
} catch { continue }
|
|
1612
|
+
for (const wfDir of wfDirs) {
|
|
1613
|
+
try {
|
|
1614
|
+
const wfPath = join(workflowsPath, wfDir)
|
|
1615
|
+
// Only descend into actual directories. statSync succeeds on
|
|
1616
|
+
// regular files too (e.g. a stray journal.jsonl or lock file
|
|
1617
|
+
// sitting directly in workflows/), so check isDirectory()
|
|
1618
|
+
// explicitly rather than relying on a throw that never comes.
|
|
1619
|
+
if (!fs.statSync(wfPath).isDirectory()) continue
|
|
1620
|
+
watchAndScan(wfPath)
|
|
1621
|
+
} catch { /* skip entries we can't stat or watch */ }
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1478
1624
|
}
|
|
1479
1625
|
}
|
|
1480
1626
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M-2: `activityEverOpened` sticky-true invariant — structural assertion.
|
|
3
|
+
*
|
|
4
|
+
* `activityEverOpened` is set to `true` exactly once, when the activity feed
|
|
5
|
+
* posts its first message (the `sendMessage` path in `drainActivitySummary`).
|
|
6
|
+
* It must NEVER be reset to false or cleared — unlike `activityMessageId`, which
|
|
7
|
+
* is nulled by `clearActivitySummary` to indicate that the persistent message was
|
|
8
|
+
* finalized/deleted. The sticky-true invariant lets the turn-end DEGRADED check
|
|
9
|
+
* (`detectStatusSurfaceDegraded`) distinguish "feed never opened" (the
|
|
10
|
+
* resume-400 signature) from "feed opened + finalized".
|
|
11
|
+
*
|
|
12
|
+
* Load-bearing constraints:
|
|
13
|
+
* 1. `activityEverOpened = true` is set exactly ONCE in gateway.ts (at the
|
|
14
|
+
* send-message success site in drainActivitySummary).
|
|
15
|
+
* 2. `turn.activityEverOpened = false` NEVER appears in gateway.ts (it is only
|
|
16
|
+
* initialised to `false` in the turn-initialiser object literal, never reset
|
|
17
|
+
* via a standalone assignment).
|
|
18
|
+
*
|
|
19
|
+
* These are STRUCTURAL (source-read) assertions. Pattern: silence-liveness-wiring.test.ts.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect } from 'vitest'
|
|
22
|
+
import { readFileSync } from 'node:fs'
|
|
23
|
+
import { resolve } from 'node:path'
|
|
24
|
+
|
|
25
|
+
const gatewaySrc = readFileSync(
|
|
26
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
27
|
+
'utf-8',
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
describe('M-2: activityEverOpened sticky-true invariant', () => {
|
|
31
|
+
it('activityEverOpened = true appears exactly once (set at send-message success)', () => {
|
|
32
|
+
const setTrueMatches = [...gatewaySrc.matchAll(/activityEverOpened\s*=\s*true/g)]
|
|
33
|
+
expect(setTrueMatches).toHaveLength(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('turn.activityEverOpened = false never appears (no standalone reset)', () => {
|
|
37
|
+
// The only `false` value must be in the object literal initialiser
|
|
38
|
+
// (e.g. `activityEverOpened: false`), never a standalone reassignment.
|
|
39
|
+
const resetMatches = [...gatewaySrc.matchAll(/turn\.activityEverOpened\s*=\s*false/g)]
|
|
40
|
+
expect(resetMatches).toHaveLength(0)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('activityEverOpened is initialised false in the turn object literal (per-turn reset)', () => {
|
|
44
|
+
// The object literal form `activityEverOpened: false` must exist (per-turn init).
|
|
45
|
+
expect(gatewaySrc).toMatch(/activityEverOpened:\s*false/)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
27
|
-
import { createAnswerStream
|
|
27
|
+
import { createAnswerStream } from '../answer-stream.js'
|
|
28
28
|
|
|
29
29
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
30
30
|
|
|
@@ -47,7 +47,6 @@ function makeEditMessageText() {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
beforeEach(() => {
|
|
50
|
-
__resetDraftIdForTests()
|
|
51
50
|
nextMessageId = 2000
|
|
52
51
|
vi.useFakeTimers()
|
|
53
52
|
})
|
|
@@ -70,9 +69,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
|
|
|
70
69
|
const onMetric = vi.fn()
|
|
71
70
|
|
|
72
71
|
const stream = createAnswerStream({
|
|
73
|
-
chatId: 'chat646',
|
|
74
|
-
isPrivateChat: false,
|
|
75
|
-
minInitialChars: 0,
|
|
72
|
+
chatId: 'chat646', minInitialChars: 0,
|
|
76
73
|
throttleMs: 250,
|
|
77
74
|
sendMessage,
|
|
78
75
|
editMessageText,
|
|
@@ -112,9 +109,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
|
|
|
112
109
|
const log = vi.fn()
|
|
113
110
|
|
|
114
111
|
const stream = createAnswerStream({
|
|
115
|
-
chatId: 'chat646',
|
|
116
|
-
isPrivateChat: false,
|
|
117
|
-
minInitialChars: 0,
|
|
112
|
+
chatId: 'chat646', minInitialChars: 0,
|
|
118
113
|
throttleMs: 250,
|
|
119
114
|
sendMessage,
|
|
120
115
|
editMessageText,
|
|
@@ -158,9 +153,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
|
|
|
158
153
|
const editMessageText = makeEditMessageText()
|
|
159
154
|
|
|
160
155
|
const stream = createAnswerStream({
|
|
161
|
-
chatId: 'chat646',
|
|
162
|
-
isPrivateChat: false,
|
|
163
|
-
minInitialChars: 0,
|
|
156
|
+
chatId: 'chat646', minInitialChars: 0,
|
|
164
157
|
throttleMs: 250,
|
|
165
158
|
sendMessage,
|
|
166
159
|
editMessageText,
|
|
@@ -185,9 +178,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
|
|
|
185
178
|
const recordDedup = vi.fn()
|
|
186
179
|
|
|
187
180
|
const stream = createAnswerStream({
|
|
188
|
-
chatId: 'chat646',
|
|
189
|
-
isPrivateChat: false,
|
|
190
|
-
minInitialChars: 0,
|
|
181
|
+
chatId: 'chat646', minInitialChars: 0,
|
|
191
182
|
throttleMs: 250,
|
|
192
183
|
sendMessage: sendMessage as never,
|
|
193
184
|
editMessageText,
|
|
@@ -229,9 +220,7 @@ describe('answer-stream materialize() — dedup callbacks (#646)', () => {
|
|
|
229
220
|
const editMessageText = makeEditMessageText()
|
|
230
221
|
|
|
231
222
|
const stream = createAnswerStream({
|
|
232
|
-
chatId: 'chat646',
|
|
233
|
-
isPrivateChat: false,
|
|
234
|
-
minInitialChars: 0,
|
|
223
|
+
chatId: 'chat646', minInitialChars: 0,
|
|
235
224
|
throttleMs: 250,
|
|
236
225
|
sendMessage,
|
|
237
226
|
editMessageText,
|
|
@@ -276,9 +265,7 @@ describe('answer-stream materialize() — recordOutbound callback (#648)', () =>
|
|
|
276
265
|
const recordOutbound = vi.fn()
|
|
277
266
|
|
|
278
267
|
const stream = createAnswerStream({
|
|
279
|
-
chatId: 'chat648',
|
|
280
|
-
isPrivateChat: false,
|
|
281
|
-
minInitialChars: 0,
|
|
268
|
+
chatId: 'chat648', minInitialChars: 0,
|
|
282
269
|
throttleMs: 250,
|
|
283
270
|
sendMessage,
|
|
284
271
|
editMessageText,
|
|
@@ -303,9 +290,7 @@ describe('answer-stream materialize() — recordOutbound callback (#648)', () =>
|
|
|
303
290
|
const recordOutbound = vi.fn()
|
|
304
291
|
|
|
305
292
|
const stream = createAnswerStream({
|
|
306
|
-
chatId: 'chat648',
|
|
307
|
-
isPrivateChat: false,
|
|
308
|
-
minInitialChars: 0,
|
|
293
|
+
chatId: 'chat648', minInitialChars: 0,
|
|
309
294
|
throttleMs: 250,
|
|
310
295
|
sendMessage,
|
|
311
296
|
editMessageText,
|
|
@@ -331,9 +316,7 @@ describe('answer-stream materialize() — recordOutbound callback (#648)', () =>
|
|
|
331
316
|
const recordOutbound = vi.fn()
|
|
332
317
|
|
|
333
318
|
const stream = createAnswerStream({
|
|
334
|
-
chatId: 'chat648',
|
|
335
|
-
isPrivateChat: false,
|
|
336
|
-
minInitialChars: 0,
|
|
319
|
+
chatId: 'chat648', minInitialChars: 0,
|
|
337
320
|
throttleMs: 250,
|
|
338
321
|
sendMessage: sendMessage as never,
|
|
339
322
|
editMessageText,
|