switchroom 0.15.44 → 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.
- package/dist/agent-scheduler/index.js +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3249 -1241
- package/dist/cli/ui/index.html +1 -1
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7482 -7439
- package/dist/vault/broker/server.js +11315 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +3 -22
- 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 +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- 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 +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- 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-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
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
|
|
35
35
|
import {
|
|
36
36
|
cleanWorkerResultParagraph,
|
|
37
|
-
escapeHtml,
|
|
38
|
-
formatDuration,
|
|
39
37
|
stripMarkdown,
|
|
40
38
|
truncate,
|
|
41
39
|
} from './card-format.js'
|
|
40
|
+
import { STATUS_ROLLING_LINES } from './status-no-truncate.js'
|
|
41
|
+
import { renderStatusCard, formatFeedElapsed } from './tool-activity-summary.js'
|
|
42
42
|
|
|
43
43
|
/** Worker-activity feed is ON by default; an operator opts out with
|
|
44
44
|
* SWITCHROOM_WORKER_ACTIVITY_FEED=0. */
|
|
@@ -86,103 +86,82 @@ export interface BotApiForWorkerFeed {
|
|
|
86
86
|
): Promise<unknown>
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** Dispatch-time task description cap for the worker header. */
|
|
89
90
|
const DESC_MAX = 80
|
|
90
|
-
const STEP_MAX = 100
|
|
91
|
-
const RESULT_MAX = 320
|
|
92
|
-
/** Subtle horizontal rule between the running feed and the finished result. */
|
|
93
|
-
const RULE = '─────'
|
|
94
|
-
/**
|
|
95
|
-
* How many trailing narrative lines the live feed keeps visible. The feed
|
|
96
|
-
* grows like the main agent's answer but can't grow unbounded — Telegram
|
|
97
|
-
* caps message length and a wall of stale lines buries the live one. Six
|
|
98
|
-
* keeps recent context without dominating the chat.
|
|
99
|
-
*/
|
|
100
|
-
const NARRATIVE_MAX_LINES = 6
|
|
101
91
|
|
|
102
92
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* `steps` are already cleaned + escaped HTML.
|
|
108
|
-
*/
|
|
109
|
-
function appendStepFeed(lines: string[], steps: string[], allDone: boolean): void {
|
|
110
|
-
if (steps.length === 0) return
|
|
111
|
-
const shown = steps.slice(-NARRATIVE_MAX_LINES)
|
|
112
|
-
const hidden = steps.length - shown.length
|
|
113
|
-
if (hidden > 0) lines.push(`<i>✓ +${hidden} earlier…</i>`)
|
|
114
|
-
const lastIdx = shown.length - 1
|
|
115
|
-
shown.forEach((s, i) => {
|
|
116
|
-
lines.push(!allDone && i === lastIdx ? `<b>→ ${s}</b>` : `<i>✓ ${s}</i>`)
|
|
117
|
-
})
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Render the worker-activity message body as native Telegram HTML, matching
|
|
122
|
-
* the main agent's activity card (`renderActivityFeed` in
|
|
123
|
-
* tool-activity-summary.ts): a `🛠 Worker · <desc>` header, a one-line status,
|
|
124
|
-
* then a `✓`/`→` step feed. Worker narration is authored as Markdown, so every
|
|
125
|
-
* text fragment is run through `stripMarkdown` before escaping — without it the
|
|
126
|
-
* raw `**`/`` ` ``/`---` leak through as literal characters (the "half-done"
|
|
127
|
-
* look we're fixing).
|
|
93
|
+
* Thin adapter over the unified `renderStatusCard` primitive (emoji 🛠, label
|
|
94
|
+
* 'Worker'): builds the header, passes raw narrative steps (the primitive runs
|
|
95
|
+
* stripMarkdown → collapse ws → clip → escape per line), and on finish passes
|
|
96
|
+
* the cleaned result paragraph as the `result` block.
|
|
128
97
|
*
|
|
129
98
|
* Layout (running):
|
|
130
99
|
* 🛠 <b>Worker</b> · <i>{description}</i>
|
|
131
|
-
* <i>
|
|
100
|
+
* <i>{elapsed} · {n} tools</i>
|
|
132
101
|
* <i>✓ {earlier step}</i>
|
|
133
102
|
* <b>→ {newest step}</b>
|
|
134
103
|
*
|
|
135
104
|
* Layout (finished): the feed renders all-done, then a rule + cleaned result:
|
|
136
105
|
* 🛠 <b>Worker</b> · <i>{description}</i>
|
|
137
|
-
* <i>
|
|
106
|
+
* <i>done · {n} tools · {elapsed}</i>
|
|
138
107
|
* <i>✓ {step}</i>
|
|
139
108
|
* ─────
|
|
140
109
|
* ✅ <i>{cleaned result paragraph}</i>
|
|
141
110
|
*/
|
|
142
|
-
export function renderWorkerActivity(v: WorkerActivityView): string {
|
|
111
|
+
export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): string {
|
|
143
112
|
const desc = truncate(stripMarkdown(v.description).trim() || 'background task', DESC_MAX)
|
|
144
|
-
const elapsed = formatDuration(v.elapsedMs)
|
|
145
|
-
const toolWord = v.toolCount === 1 ? 'tool' : 'tools'
|
|
146
|
-
const header = `🛠 <b>Worker</b> · <i>${escapeHtml(desc)}</i>`
|
|
147
113
|
const finished = v.state === 'done' || v.state === 'failed'
|
|
148
114
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
115
|
+
// Raw narrative steps (unstripped/unescaped) — the unified renderer runs the
|
|
116
|
+
// full per-line pipeline (stripMarkdown → collapse ws → clip → escape).
|
|
117
|
+
const rawSteps = (v.narrativeLines ?? []).filter((s) => s != null && s.trim().length > 0)
|
|
118
|
+
|
|
119
|
+
// Back-compat for direct render callers that pass only latestSummary while
|
|
120
|
+
// RUNNING; the manager always supplies narrativeLines. On the FINISHED path
|
|
121
|
+
// latestSummary is the worker's RESULT (below), never a narrative step — so
|
|
122
|
+
// the fallback only applies while running.
|
|
123
|
+
let steps = rawSteps
|
|
124
|
+
if (steps.length === 0 && !finished) {
|
|
125
|
+
const summary = stripMarkdown(v.latestSummary).replace(/\s+/g, ' ').trim()
|
|
126
|
+
if (summary.length > 0) steps = [v.latestSummary]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const header: Parameters<typeof renderStatusCard>[0]['header'] = {
|
|
130
|
+
emoji: '🛠',
|
|
131
|
+
label: 'Worker',
|
|
132
|
+
description: desc,
|
|
133
|
+
elapsedMs: v.elapsedMs,
|
|
134
|
+
toolCount: v.toolCount,
|
|
135
|
+
state: v.state,
|
|
136
|
+
}
|
|
156
137
|
|
|
138
|
+
// Terminal: latestSummary carries the worker's final result text (gateway
|
|
139
|
+
// onFinish), distinct from the running narrative steps. Pass it as `result`.
|
|
140
|
+
let result: { emoji: string; text: string } | undefined
|
|
157
141
|
if (finished) {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
appendStepFeed(lines, steps, true)
|
|
161
|
-
// On terminal, latestSummary carries the worker's final result text
|
|
162
|
-
// (gateway onFinish), distinct from the running narrative steps.
|
|
163
|
-
const result = cleanWorkerResultParagraph(v.latestSummary)
|
|
164
|
-
if (result.length > 0) {
|
|
165
|
-
const emoji = v.state === 'done' ? '✅' : '⚠️'
|
|
166
|
-
lines.push(RULE)
|
|
167
|
-
lines.push(`${emoji} <i>${escapeHtml(truncate(result, RESULT_MAX))}</i>`)
|
|
168
|
-
}
|
|
169
|
-
return lines.join('\n')
|
|
142
|
+
const text = cleanWorkerResultParagraph(v.latestSummary)
|
|
143
|
+
if (text.length > 0) result = { emoji: v.state === 'done' ? '✅' : '⚠️', text }
|
|
170
144
|
}
|
|
171
145
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
146
|
+
// `renderStatusCard` always returns content when a header is supplied. When
|
|
147
|
+
// running with no steps it shows just the header — append a "starting…" line
|
|
148
|
+
// for parity with the prior behaviour.
|
|
149
|
+
const card = renderStatusCard({
|
|
150
|
+
header,
|
|
151
|
+
steps,
|
|
152
|
+
final: finished,
|
|
153
|
+
liveSuffix: finished ? '' : liveSuffix,
|
|
154
|
+
result,
|
|
155
|
+
})
|
|
156
|
+
if (card == null) {
|
|
157
|
+
// Unreachable (header always present) — defensive.
|
|
158
|
+
return `🛠 <b>Worker</b> · <i>starting…</i>`
|
|
184
159
|
}
|
|
185
|
-
|
|
160
|
+
if (!finished && steps.length === 0) {
|
|
161
|
+
// Header-only running render → append the starting placeholder.
|
|
162
|
+
return `${card}\n<i>starting…</i>`
|
|
163
|
+
}
|
|
164
|
+
return card
|
|
186
165
|
}
|
|
187
166
|
|
|
188
167
|
export interface WorkerActivityFeedOpts {
|
|
@@ -205,6 +184,19 @@ export interface WorkerActivityFeedOpts {
|
|
|
205
184
|
firstPaintMinMs?: number
|
|
206
185
|
/** stderr-style log sink. Defaults to noop. */
|
|
207
186
|
log?: (msg: string) => void
|
|
187
|
+
/**
|
|
188
|
+
* Heartbeat timer factory. Injectable for tests. Defaults to the real
|
|
189
|
+
* `setInterval`, `.unref()`'d so it never keeps the process alive.
|
|
190
|
+
*/
|
|
191
|
+
setInterval?: (cb: () => void, ms: number) => unknown
|
|
192
|
+
/** Heartbeat timer disposer. Injectable for tests. Defaults to `clearInterval`. */
|
|
193
|
+
clearInterval?: (handle: unknown) => void
|
|
194
|
+
/**
|
|
195
|
+
* Heartbeat tick cadence in ms. On each tick a stale, running worker is
|
|
196
|
+
* re-rendered with a climbing `· Ns` suffix so a worker that emits no new
|
|
197
|
+
* narrative still visibly advances. Default 6000ms.
|
|
198
|
+
*/
|
|
199
|
+
heartbeatTickMs?: number
|
|
208
200
|
}
|
|
209
201
|
|
|
210
202
|
interface WorkerHandle {
|
|
@@ -218,12 +210,20 @@ interface WorkerHandle {
|
|
|
218
210
|
cooldownUntil: number
|
|
219
211
|
/**
|
|
220
212
|
* Accumulated narrative lines (oldest→newest), deduped against the
|
|
221
|
-
* immediately-preceding line
|
|
222
|
-
* live render so the feed reads like the main agent's answer.
|
|
213
|
+
* immediately-preceding line. Rolling-window capped to STATUS_ROLLING_LINES.
|
|
214
|
+
* Grows the live render so the feed reads like the main agent's answer.
|
|
223
215
|
*/
|
|
224
216
|
narrative: string[]
|
|
225
217
|
/** Per-worker serialization chain so ticks can't interleave sends. */
|
|
226
218
|
chain: Promise<void>
|
|
219
|
+
/** Last view rendered into the message (drives the heartbeat re-render). */
|
|
220
|
+
lastView: WorkerActivityView | null
|
|
221
|
+
/**
|
|
222
|
+
* Wall-clock ms the worker was dispatched, derived from `now - view.elapsedMs`
|
|
223
|
+
* on the first update. The heartbeat computes a live elapsed from this so the
|
|
224
|
+
* `· Ns` suffix climbs even when no fresh view arrives.
|
|
225
|
+
*/
|
|
226
|
+
dispatchAtMs: number | null
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
const COOLDOWN_JITTER_MS = 500
|
|
@@ -257,6 +257,10 @@ export interface WorkerActivityFeed {
|
|
|
257
257
|
finish(agentId: string, view: WorkerActivityView): Promise<void>
|
|
258
258
|
/** Forget a worker's state without editing (e.g. error path). */
|
|
259
259
|
drop(agentId: string): void
|
|
260
|
+
/** Clear the heartbeat interval (gateway shutdown). Idempotent. */
|
|
261
|
+
stop(): void
|
|
262
|
+
/** Manually fire one heartbeat tick (test hook). */
|
|
263
|
+
heartbeatTick(): void
|
|
260
264
|
/** Number of tracked workers (test/inspection hook). */
|
|
261
265
|
readonly size: number
|
|
262
266
|
}
|
|
@@ -266,12 +270,28 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
266
270
|
const nowFn = opts.now ?? Date.now
|
|
267
271
|
const minEditInterval = opts.minEditIntervalMs ?? 2500
|
|
268
272
|
const firstPaintMin = opts.firstPaintMinMs ?? 8000
|
|
273
|
+
const heartbeatTickMs = opts.heartbeatTickMs ?? 6000
|
|
274
|
+
const setIntervalFn =
|
|
275
|
+
opts.setInterval ??
|
|
276
|
+
((cb: () => void, ms: number): unknown => {
|
|
277
|
+
const t = setInterval(cb, ms)
|
|
278
|
+
// Never keep the process alive on the heartbeat alone.
|
|
279
|
+
;(t as { unref?: () => void }).unref?.()
|
|
280
|
+
return t
|
|
281
|
+
})
|
|
282
|
+
const clearIntervalFn = opts.clearInterval ?? ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>))
|
|
269
283
|
const handles = new Map<string, WorkerHandle>()
|
|
284
|
+
let heartbeatTimer: unknown = null
|
|
270
285
|
|
|
271
286
|
function sendOptsFor(h: WorkerHandle): Record<string, unknown> {
|
|
272
287
|
return {
|
|
273
288
|
parse_mode: 'HTML',
|
|
274
289
|
disable_web_page_preview: true,
|
|
290
|
+
// Sub-agent progress card is a status surface, never the user's
|
|
291
|
+
// answer — silence the open ping. (editMessageText ignores
|
|
292
|
+
// disable_notification, so this is a no-op on the in-place edits
|
|
293
|
+
// that share these opts.)
|
|
294
|
+
disable_notification: true,
|
|
275
295
|
...(h.threadId != null ? { message_thread_id: h.threadId } : {}),
|
|
276
296
|
}
|
|
277
297
|
}
|
|
@@ -290,17 +310,25 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
290
310
|
// same narrative across ticks while a tool runs; we only grow on change.
|
|
291
311
|
if (h.narrative[h.narrative.length - 1] === line) return
|
|
292
312
|
h.narrative.push(line)
|
|
293
|
-
|
|
294
|
-
|
|
313
|
+
// Rolling window — keep only the last STATUS_ROLLING_LINES in memory. The
|
|
314
|
+
// render shows exactly those lines (clipped per-line by the unified pipeline);
|
|
315
|
+
// fitCardToBudget is the wire-limit backstop.
|
|
316
|
+
if (h.narrative.length > STATUS_ROLLING_LINES) {
|
|
317
|
+
h.narrative.splice(0, h.narrative.length - STATUS_ROLLING_LINES)
|
|
295
318
|
}
|
|
296
319
|
}
|
|
297
320
|
|
|
298
|
-
async function doUpdate(h: WorkerHandle, view: WorkerActivityView): Promise<void> {
|
|
321
|
+
async function doUpdate(h: WorkerHandle, view: WorkerActivityView, liveSuffix = ''): Promise<void> {
|
|
299
322
|
// Accumulate before any gate so a throttled/cooled-down tick still grows
|
|
300
323
|
// the narrative — the line surfaces on the next edit that does fire.
|
|
301
324
|
accumulateNarrative(h, view)
|
|
325
|
+
// Stamp the dispatch wall-clock once so the heartbeat can climb a live
|
|
326
|
+
// elapsed even between fresh views. lastView feeds the heartbeat re-render.
|
|
327
|
+
const merged: WorkerActivityView = { ...view, narrativeLines: [...h.narrative] }
|
|
328
|
+
h.lastView = merged
|
|
329
|
+
if (h.dispatchAtMs == null) h.dispatchAtMs = nowFn() - view.elapsedMs
|
|
302
330
|
if (nowFn() < h.cooldownUntil) return
|
|
303
|
-
const body = renderWorkerActivity(
|
|
331
|
+
const body = renderWorkerActivity(merged, liveSuffix)
|
|
304
332
|
|
|
305
333
|
// First paint: hold off until the worker has run long enough to be
|
|
306
334
|
// worth a message; trivial workers stay silent (handback covers them).
|
|
@@ -371,6 +399,44 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
371
399
|
}
|
|
372
400
|
}
|
|
373
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Heartbeat — option (a), suffix-only, NEVER opens a new message. For each
|
|
404
|
+
* handle with a posted message, enqueue a re-render through the existing
|
|
405
|
+
* chain → doUpdate path (never editMessageText directly). Skips:
|
|
406
|
+
* - handles with no posted message (messageId == null),
|
|
407
|
+
* - handles inside a 429 cooldown,
|
|
408
|
+
* - handles edited within minEditInterval (no stampede),
|
|
409
|
+
* - non-running handles (terminal/deleted).
|
|
410
|
+
* The `· Ns` liveSuffix is applied ONLY when the worker's current step is
|
|
411
|
+
* stale (now - lastEditAt >= heartbeatTickMs) so a normally-ticking worker is
|
|
412
|
+
* untouched and its body stays byte-stable for the dedup.
|
|
413
|
+
*/
|
|
414
|
+
function heartbeatTick(): void {
|
|
415
|
+
const now = nowFn()
|
|
416
|
+
for (const h of handles.values()) {
|
|
417
|
+
if (h.messageId == null) continue
|
|
418
|
+
if (h.lastView == null) continue
|
|
419
|
+
if (h.lastView.state !== 'running') continue
|
|
420
|
+
if (now < h.cooldownUntil) continue
|
|
421
|
+
if (now - h.lastEditAt < minEditInterval) continue
|
|
422
|
+
const stale = now - h.lastEditAt >= heartbeatTickMs
|
|
423
|
+
if (!stale) continue
|
|
424
|
+
const liveElapsed = h.dispatchAtMs != null ? now - h.dispatchAtMs : h.lastView.elapsedMs
|
|
425
|
+
const liveSuffix = ' · ' + formatFeedElapsed(liveElapsed)
|
|
426
|
+
// Re-render THROUGH the chain + doUpdate path — never editMessageText directly.
|
|
427
|
+
const view = h.lastView
|
|
428
|
+
h.chain = h.chain
|
|
429
|
+
.then(() => doUpdate(h, view, liveSuffix))
|
|
430
|
+
.catch((err) => {
|
|
431
|
+
log(`worker-feed: heartbeat chain error ${h.agentId}: ${(err as Error).message}`)
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Arm the heartbeat once at construction. The real timer is `.unref()`'d so
|
|
437
|
+
// it never keeps the process alive; tests inject setInterval/clearInterval.
|
|
438
|
+
heartbeatTimer = setIntervalFn(heartbeatTick, heartbeatTickMs)
|
|
439
|
+
|
|
374
440
|
return {
|
|
375
441
|
has(agentId) {
|
|
376
442
|
return handles.get(agentId)?.messageId != null
|
|
@@ -394,6 +460,8 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
394
460
|
cooldownUntil: 0,
|
|
395
461
|
narrative: [],
|
|
396
462
|
chain: Promise.resolve(),
|
|
463
|
+
lastView: null,
|
|
464
|
+
dispatchAtMs: null,
|
|
397
465
|
}
|
|
398
466
|
handles.set(agentId, h)
|
|
399
467
|
}
|
|
@@ -419,5 +487,12 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
419
487
|
drop(agentId) {
|
|
420
488
|
handles.delete(agentId)
|
|
421
489
|
},
|
|
490
|
+
heartbeatTick,
|
|
491
|
+
stop() {
|
|
492
|
+
if (heartbeatTimer != null) {
|
|
493
|
+
clearIntervalFn(heartbeatTimer)
|
|
494
|
+
heartbeatTimer = null
|
|
495
|
+
}
|
|
496
|
+
},
|
|
422
497
|
}
|
|
423
498
|
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared draft-transport helpers for answer-stream and draft-stream.
|
|
3
|
-
*
|
|
4
|
-
* Extracted from answer-stream.ts so both the narrative answer-lane and the
|
|
5
|
-
* model-driven stream_reply lane can share the same regex constants and
|
|
6
|
-
* fallback logic without duplicating them.
|
|
7
|
-
*
|
|
8
|
-
* answer-stream.ts re-exports these symbols so existing callers (including
|
|
9
|
-
* tests that import directly from answer-stream.ts) continue to work.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
// Error patterns matching OpenClaw's shouldFallbackFromDraftTransport.
|
|
13
|
-
// Exported for tests.
|
|
14
|
-
export const DRAFT_METHOD_UNAVAILABLE_RE =
|
|
15
|
-
/(unknown method|method .*not (found|available|supported)|unsupported)/i
|
|
16
|
-
export const DRAFT_CHAT_UNSUPPORTED_RE = /(can't be used|can be used only)/i
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Returns true when a sendMessageDraft rejection means "this API is not
|
|
20
|
-
* available" rather than a transient network error.
|
|
21
|
-
*/
|
|
22
|
-
export function shouldFallbackFromDraftTransport(err: unknown): boolean {
|
|
23
|
-
const text =
|
|
24
|
-
typeof err === 'string'
|
|
25
|
-
? err
|
|
26
|
-
: err instanceof Error
|
|
27
|
-
? err.message
|
|
28
|
-
: typeof err === 'object' && err != null && 'description' in err
|
|
29
|
-
? typeof (err as { description: unknown }).description === 'string'
|
|
30
|
-
? (err as { description: string }).description
|
|
31
|
-
: ''
|
|
32
|
-
: ''
|
|
33
|
-
if (!/sendMessageDraft/i.test(text)) return false
|
|
34
|
-
return DRAFT_METHOD_UNAVAILABLE_RE.test(text) || DRAFT_CHAT_UNSUPPORTED_RE.test(text)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* PR D — extract the `retry_after` seconds from a grammY 429 error.
|
|
39
|
-
* Returns null when the error isn't a 429 (or has no retry_after).
|
|
40
|
-
*
|
|
41
|
-
* Shared with `issues-card.ts:extractRetryAfterSecs`. Duck-typed on the
|
|
42
|
-
* documented grammY `GrammyError` shape to keep this module
|
|
43
|
-
* test-friendly without importing `GrammyError` directly.
|
|
44
|
-
*/
|
|
45
|
-
export function extractDraft429RetryAfterSecs(err: unknown): number | null {
|
|
46
|
-
if (err == null || typeof err !== 'object') return null
|
|
47
|
-
const e = err as { error_code?: unknown; parameters?: { retry_after?: unknown } }
|
|
48
|
-
if (e.error_code !== 429) return null
|
|
49
|
-
const ra = e.parameters?.retry_after
|
|
50
|
-
if (typeof ra === 'number' && Number.isFinite(ra) && ra > 0) return ra
|
|
51
|
-
return null
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* PR D — was this a 429 from `sendMessageDraft` specifically? Used by
|
|
56
|
-
* draft-stream to differentiate "draft is rate-limited" (transient,
|
|
57
|
-
* just back off this stream) from a non-429 send error (handled
|
|
58
|
-
* separately by `shouldFallbackFromDraftTransport`).
|
|
59
|
-
*
|
|
60
|
-
* Both cases trigger fallback to message transport for the rest of
|
|
61
|
-
* the stream, but the 429 case ALSO bumps the throttle window to
|
|
62
|
-
* honor Telegram's `retry_after` — so the message-transport fallback
|
|
63
|
-
* doesn't immediately fire a fresh send before Telegram's cooldown
|
|
64
|
-
* elapses and re-429s.
|
|
65
|
-
*/
|
|
66
|
-
export function isDraft429(err: unknown): boolean {
|
|
67
|
-
if (extractDraft429RetryAfterSecs(err) == null) return false
|
|
68
|
-
// grammY GrammyError carries the method name in its `method` field.
|
|
69
|
-
// Best-effort: match either the structured method or the error text.
|
|
70
|
-
if (typeof err === 'object' && err != null && 'method' in err) {
|
|
71
|
-
const m = (err as { method?: unknown }).method
|
|
72
|
-
if (typeof m === 'string' && /sendMessageDraft/i.test(m)) return true
|
|
73
|
-
}
|
|
74
|
-
const text =
|
|
75
|
-
typeof err === 'string'
|
|
76
|
-
? err
|
|
77
|
-
: err instanceof Error
|
|
78
|
-
? err.message
|
|
79
|
-
: typeof err === 'object' && err != null && 'description' in err
|
|
80
|
-
? typeof (err as { description: unknown }).description === 'string'
|
|
81
|
-
? (err as { description: string }).description
|
|
82
|
-
: ''
|
|
83
|
-
: ''
|
|
84
|
-
return /sendMessageDraft/i.test(text)
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Symbol-keyed shared counter for draft-id allocation across concurrent
|
|
89
|
-
* streams (mirrors openclaw's getDraftStreamState). Using Symbol.for ensures
|
|
90
|
-
* the same counter is shared even if this module is loaded multiple times
|
|
91
|
-
* (e.g. from different bundle chunks).
|
|
92
|
-
*/
|
|
93
|
-
const DRAFT_STREAM_STATE_KEY = Symbol.for('switchroom.draftStreamState')
|
|
94
|
-
|
|
95
|
-
interface DraftStreamState {
|
|
96
|
-
nextDraftId: number
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function getDraftStreamState(): DraftStreamState {
|
|
100
|
-
const g = globalThis as Record<PropertyKey, unknown>
|
|
101
|
-
let state = g[DRAFT_STREAM_STATE_KEY] as DraftStreamState | undefined
|
|
102
|
-
if (!state) {
|
|
103
|
-
state = { nextDraftId: 0 }
|
|
104
|
-
g[DRAFT_STREAM_STATE_KEY] = state
|
|
105
|
-
}
|
|
106
|
-
return state
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Allocate a unique draft ID, wrapping at 2_147_483_647 (Telegram's int32
|
|
111
|
-
* max for draft_id). IDs start at 1 and cycle.
|
|
112
|
-
*/
|
|
113
|
-
export function allocateDraftId(): number {
|
|
114
|
-
const state = getDraftStreamState()
|
|
115
|
-
state.nextDraftId = state.nextDraftId >= 2_147_483_647 ? 1 : state.nextDraftId + 1
|
|
116
|
-
return state.nextDraftId
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Reset the shared draft-id counter — for tests only. */
|
|
120
|
-
export function __resetDraftIdForTests(): void {
|
|
121
|
-
getDraftStreamState().nextDraftId = 0
|
|
122
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Answer-lane wiring guards — draft retirement + flash decoupling.
|
|
3
|
-
*
|
|
4
|
-
* History:
|
|
5
|
-
* - v0.14.52 turned the VISIBLE answer-stream OFF by default to remove the
|
|
6
|
-
* "raw preview appears, formatted reply lands, raw preview deleted" flash.
|
|
7
|
-
* - v0.14.68 retired the invisible compose-box DRAFT transport. The original
|
|
8
|
-
* wiring (and the first version of this test) conflated the two flags —
|
|
9
|
-
* `ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED` — so retiring
|
|
10
|
-
* the draft (default) silently forced a VISIBLE preview (minInitialChars:1)
|
|
11
|
-
* even with the visible flag off, re-introducing the flash FLEET-WIDE
|
|
12
|
-
* (v0.14.68 undid v0.14.52). The first revision of this file pinned that
|
|
13
|
-
* conflation as a "guard" — i.e. it asserted the bug.
|
|
14
|
-
* - 2026-06-05 flash-decouple: the VISIBLE preview now gates on the visible
|
|
15
|
-
* flag ALONE; DRAFT_ANSWER_LANE_RETIRED controls only the TRANSPORT. With
|
|
16
|
-
* defaults (visible off, draft retired) the lane is DORMANT — no preview, the
|
|
17
|
-
* reply tool is the single formatted message, no flash. The no-reply text-only
|
|
18
|
-
* answer is delivered by the turn-flush backstop (the pre-v0.14.68 path).
|
|
19
|
-
*
|
|
20
|
-
* gateway IIFE can't be instantiated in-process, so these are source-level
|
|
21
|
-
* assertions (same pattern as silence-liveness-wiring.test).
|
|
22
|
-
*/
|
|
23
|
-
import { describe, it, expect } from 'vitest'
|
|
24
|
-
import { readFileSync } from 'node:fs'
|
|
25
|
-
import { resolve } from 'node:path'
|
|
26
|
-
|
|
27
|
-
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
28
|
-
|
|
29
|
-
describe('answer-lane wiring (draft retirement + flash decoupling)', () => {
|
|
30
|
-
it('sendMessageDraftFn is gated on the retirement (the single transport chokepoint)', () => {
|
|
31
|
-
expect(gatewaySrc).toMatch(/!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === 'function'/)
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('DRAFT_ANSWER_LANE_RETIRED is declared before its first use (no TDZ at boot)', () => {
|
|
35
|
-
const declIdx = gatewaySrc.indexOf('const DRAFT_ANSWER_LANE_RETIRED =')
|
|
36
|
-
const firstUseIdx = gatewaySrc.indexOf('!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft')
|
|
37
|
-
expect(declIdx).toBeGreaterThan(0)
|
|
38
|
-
expect(firstUseIdx).toBeGreaterThan(declIdx)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('the lane behaviour is resolved by the single-source-of-truth pure function', () => {
|
|
42
|
-
// The flag→config decision lives in resolveAnswerLaneConfig (total-enumerated
|
|
43
|
-
// in answer-stream-flag.test.ts); the gateway delegates so the three use-sites
|
|
44
|
-
// can never drift apart.
|
|
45
|
-
expect(gatewaySrc).toMatch(/const ANSWER_LANE = resolveAnswerLaneConfig\(\{/)
|
|
46
|
-
expect(gatewaySrc).toMatch(/visibleEnabled: ANSWER_STREAM_VISIBLE_ENABLED/)
|
|
47
|
-
expect(gatewaySrc).toMatch(/draftFnAvailable: sendMessageDraftFn != null/)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('FLASH GUARD: the createAnswerStream config is driven by ANSWER_LANE (preview gated on the visible flag alone)', () => {
|
|
51
|
-
// The minInitialChars / draft-transport choice comes from the resolved lane,
|
|
52
|
-
// never from a `visible || retired` inline conflation.
|
|
53
|
-
expect(gatewaySrc).toMatch(/\.\.\.\(ANSWER_LANE\.usesDraftTransport/)
|
|
54
|
-
expect(gatewaySrc).toMatch(/minInitialChars: ANSWER_LANE\.minInitialChars/)
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('FLASH GUARD: the visible-OR-retired conflation is GONE everywhere (it pinned the flash)', () => {
|
|
58
|
-
// No answer-lane path may re-introduce `VISIBLE || RETIRED` — that is the
|
|
59
|
-
// exact regression this fix removes.
|
|
60
|
-
expect(gatewaySrc).not.toMatch(/ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED/)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('LOST-ANSWER GUARD: materialize-as-answer gates on ANSWER_LANE.opensVisiblePreview (only fires when a preview actually opened)', () => {
|
|
64
|
-
// A text-only no-reply turn materializes (ping + keep) ONLY when a visible
|
|
65
|
-
// preview opened. With visible off the lane is dormant (minInitialChars:MAX),
|
|
66
|
-
// so this branch is unreachable and the answer is delivered by turn-flush
|
|
67
|
-
// instead — never retracted away.
|
|
68
|
-
expect(gatewaySrc).toMatch(/\n\s*ANSWER_LANE\.opensVisiblePreview\s*\n\s*&& !turn\.replyCalled/)
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('LOST-ANSWER GUARD: turn-flush is skipped ONLY when the stream finalized as the answer (so dormant-lane no-reply turns still flush)', () => {
|
|
72
|
-
// flushDecision skips only on streamFinalizedAsAnswer; with the lane dormant
|
|
73
|
-
// that stays false, so decideTurnFlush runs and delivers the no-reply answer.
|
|
74
|
-
expect(gatewaySrc).toMatch(/const flushDecision = streamFinalizedAsAnswer\s*\n?\s*\?/)
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('the #2169 onMetric silence-liveness reset is still wired (fires on visible sends when opted in)', () => {
|
|
78
|
-
const onMetric = (gatewaySrc.split('onMetric: (metricEv) => {')[1] ?? '').split('\n },')[0]
|
|
79
|
-
expect(onMetric).toMatch(/silencePoke\.noteProduction/)
|
|
80
|
-
expect(onMetric).toMatch(/currentTurn === turn/)
|
|
81
|
-
})
|
|
82
|
-
})
|