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
|
@@ -23,6 +23,30 @@ import {
|
|
|
23
23
|
getTurnByKey,
|
|
24
24
|
} from './turns-schema.js'
|
|
25
25
|
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Concurrency PRAGMAs — applySchema must arm busy_timeout so concurrent
|
|
28
|
+
// writers (the subagent-tracker hooks + the gateway watcher) wait-and-retry
|
|
29
|
+
// instead of failing with SQLITE_BUSY ("database is locked").
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
describe('registry concurrency PRAGMAs', () => {
|
|
33
|
+
it('arms busy_timeout (5000ms) on every opened connection', () => {
|
|
34
|
+
const db = openTurnsDbInMemory()
|
|
35
|
+
const row = db.prepare('PRAGMA busy_timeout').get() as { timeout: number }
|
|
36
|
+
expect(row.timeout).toBe(5000)
|
|
37
|
+
db.close()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('uses WAL journal mode for concurrent readers', () => {
|
|
41
|
+
const db = openTurnsDbInMemory()
|
|
42
|
+
const row = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }
|
|
43
|
+
// `:memory:` reports 'memory'; a file DB reports 'wal'. Either way the
|
|
44
|
+
// exec ran without error — the file-path open (openTurnsDb) yields 'wal'.
|
|
45
|
+
expect(['wal', 'memory']).toContain(String(row.journal_mode).toLowerCase())
|
|
46
|
+
db.close()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
26
50
|
// ---------------------------------------------------------------------------
|
|
27
51
|
// Test 1 — empty DB
|
|
28
52
|
// ---------------------------------------------------------------------------
|
|
@@ -172,6 +172,15 @@ const PHASE2_MIGRATIONS = [
|
|
|
172
172
|
function applySchema(db: SqliteDatabase): void {
|
|
173
173
|
db.exec('PRAGMA journal_mode = WAL')
|
|
174
174
|
db.exec('PRAGMA synchronous = NORMAL')
|
|
175
|
+
// Concurrency: multiple writers contend on this registry (the PreToolUse
|
|
176
|
+
// subagent-tracker hook, the gateway's subagent-watcher backfill, the turns
|
|
177
|
+
// writer) — especially when several sub-agents dispatch at once. Without a
|
|
178
|
+
// busy_timeout, bun:sqlite/better-sqlite3 default to 0ms and the second
|
|
179
|
+
// contending write fails IMMEDIATELY with SQLITE_BUSY ("database is locked"),
|
|
180
|
+
// which the watcher swallows → jsonl_agent_id / parent_turn_key left NULL →
|
|
181
|
+
// worker card mis-routes to the operator DM + false silent-stall synthesis.
|
|
182
|
+
// 5s of wait-and-retry serializes the contenders instead of dropping writes.
|
|
183
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
175
184
|
db.exec(SCHEMA_SQL)
|
|
176
185
|
// Run migrations. SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so
|
|
177
186
|
// we swallow the "duplicate column" error to stay idempotent on
|
|
@@ -77,6 +77,19 @@ export type RuntimeMetricEvent =
|
|
|
77
77
|
fallback_kind: 'working' | 'thinking'
|
|
78
78
|
silence_ms: number
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* #2527 — mid-turn liveness floor decision. `decision: 'fire'` when the
|
|
82
|
+
* quiet "still on it" beat was sent; otherwise the machine-readable skip
|
|
83
|
+
* reason for a declined forced ("Status?") poke. `forced` distinguishes
|
|
84
|
+
* the timer beat from a user-asked one.
|
|
85
|
+
*/
|
|
86
|
+
| {
|
|
87
|
+
kind: 'mid_turn_floor'
|
|
88
|
+
key: string
|
|
89
|
+
silence_ms: number
|
|
90
|
+
forced: boolean
|
|
91
|
+
decision: string
|
|
92
|
+
}
|
|
80
93
|
/**
|
|
81
94
|
* #1445 cross-turn pending-async ambient lifecycle. `started` fires
|
|
82
95
|
* when a turn ends with a captured anchor AND a pending Agent/Task/
|
|
@@ -98,7 +98,17 @@ export type SessionEvent =
|
|
|
98
98
|
// the lazily-flushed transcript. The draft-mirror drives off THIS, not
|
|
99
99
|
// the flush-gated `tool_use`, so activity streams deterministically.
|
|
100
100
|
| { kind: 'tool_label'; toolUseId: string; label: string; toolName: string }
|
|
101
|
-
|
|
101
|
+
// `blockIndex` = index of this text block in the assistant message's
|
|
102
|
+
// content[] — load-bearing: it keys the returned Map so callers emit
|
|
103
|
+
// events in source order. `lastInMessage` = true iff no tool_use block
|
|
104
|
+
// follows it in the SAME message. NOTE: `lastInMessage` is a PROJECTION
|
|
105
|
+
// ARTIFACT only — the current reducer-side narrative-dedup gate
|
|
106
|
+
// (narrative-dedup.ts) decides draft-then-send vs working-narration by
|
|
107
|
+
// LOOKAHEAD (the next tool_use / turn_end), NOT by reading this flag. It
|
|
108
|
+
// is retained as a stable projection output (pinned by the kernel test)
|
|
109
|
+
// and reserved for a future staging-skip optimization; do not assume the
|
|
110
|
+
// gate keys on it.
|
|
111
|
+
| { kind: 'text'; text: string; blockIndex: number; lastInMessage: boolean }
|
|
102
112
|
| { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string }
|
|
103
113
|
| { kind: 'turn_end'; durationMs: number }
|
|
104
114
|
// Multi-agent: sub-agent-scoped events. agentId is the sub-agent JSONL
|
|
@@ -106,8 +116,12 @@ export type SessionEvent =
|
|
|
106
116
|
// as parent events; the reducer fans them out to per-sub-agent state.
|
|
107
117
|
| { kind: 'sub_agent_started'; agentId: string; firstPromptText: string; subagentType?: string }
|
|
108
118
|
| { kind: 'sub_agent_tool_use'; agentId: string; toolUseId: string | null; toolName: string; input?: Record<string, unknown>; precomputedLabel?: string }
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
// Same shared contract as the main-agent `text` kind — see its doc above
|
|
120
|
+
// (including the `lastInMessage` projection-artifact note). The wire-kind
|
|
121
|
+
// stays distinct (the gateway/watcher split is load-bearing) but the
|
|
122
|
+
// payload + `lastInMessage` derivation are identical so ONE shared dedup
|
|
123
|
+
// gate handles both tiers.
|
|
124
|
+
| { kind: 'sub_agent_text'; agentId: string; text: string; blockIndex: number; lastInMessage: boolean }
|
|
111
125
|
| { kind: 'sub_agent_tool_result'; agentId: string; toolUseId: string; isError?: boolean; errorText?: string }
|
|
112
126
|
| { kind: 'sub_agent_turn_end'; agentId: string }
|
|
113
127
|
| { kind: 'sub_agent_nested_spawn'; agentId: string }
|
|
@@ -182,6 +196,49 @@ function extractToolResultErrorText(content: unknown): string {
|
|
|
182
196
|
return ''
|
|
183
197
|
}
|
|
184
198
|
|
|
199
|
+
/**
|
|
200
|
+
* THE single text→narrative projection primitive. Both projectTranscriptLine
|
|
201
|
+
* and projectSubagentLine derive their text events through this helper so
|
|
202
|
+
* main-agent, sub-agent, worker, and every other execution shape inherit
|
|
203
|
+
* identical text-block semantics from ONE place: empty/whitespace blocks are
|
|
204
|
+
* dropped, and each surviving block carries its `blockIndex` plus the
|
|
205
|
+
* `lastInMessage` signal (no tool_use follows it in this message). NOTE:
|
|
206
|
+
* `lastInMessage` is a projection artifact — the reducer-side dedup gate
|
|
207
|
+
* decides SHOW/SUPPRESS by lookahead, not by reading this flag (see the
|
|
208
|
+
* SessionEvent `text` doc); it is reserved for a future staging-skip
|
|
209
|
+
* optimization.
|
|
210
|
+
*
|
|
211
|
+
* `make` adapts the shared payload into the tier-specific wire kind
|
|
212
|
+
* (`text` vs `sub_agent_text`); the contract — what counts as a text block,
|
|
213
|
+
* how `lastInMessage` is computed — lives here, not in the callers.
|
|
214
|
+
*
|
|
215
|
+
* Returns a `Map<blockIndex, SessionEvent>` keyed by the text block's source
|
|
216
|
+
* index, NOT a flat list. This is the load-bearing design choice: the callers
|
|
217
|
+
* must emit thinking / tool_use / text events in SOURCE ORDER (the reducer
|
|
218
|
+
* pairs a preamble to the immediately-next tool_use), so they iterate
|
|
219
|
+
* `content` once and, at each text position, emit the precomputed event from
|
|
220
|
+
* this map. The kernel owns the contract; the caller owns only the ordering.
|
|
221
|
+
*/
|
|
222
|
+
export function projectAssistantTextBlocks(
|
|
223
|
+
content: Array<Record<string, unknown>>,
|
|
224
|
+
make: (text: string, blockIndex: number, lastInMessage: boolean) => SessionEvent,
|
|
225
|
+
): Map<number, SessionEvent> {
|
|
226
|
+
const out = new Map<number, SessionEvent>()
|
|
227
|
+
// Precompute the index of the last tool_use so each text block knows
|
|
228
|
+
// whether a tool_use follows it in THIS message (the draft-then-send signal).
|
|
229
|
+
let lastToolUseIdx = -1
|
|
230
|
+
content.forEach((c, i) => {
|
|
231
|
+
if (c.type === 'tool_use') lastToolUseIdx = i
|
|
232
|
+
})
|
|
233
|
+
content.forEach((c, i) => {
|
|
234
|
+
if (c.type !== 'text') return
|
|
235
|
+
const text = (c.text as string | undefined) ?? ''
|
|
236
|
+
if (text.trim().length === 0) return // drop empty/whitespace-only blocks
|
|
237
|
+
out.set(i, make(text, i, i > lastToolUseIdx))
|
|
238
|
+
})
|
|
239
|
+
return out
|
|
240
|
+
}
|
|
241
|
+
|
|
185
242
|
/**
|
|
186
243
|
* Project a single transcript line into a SessionEvent (or null if it's
|
|
187
244
|
* uninteresting noise). Caller is responsible for the JSON parse — if a
|
|
@@ -218,7 +275,16 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
|
|
|
218
275
|
const content = message?.content as Array<Record<string, unknown>> | undefined
|
|
219
276
|
if (!Array.isArray(content)) return []
|
|
220
277
|
const events: SessionEvent[] = []
|
|
221
|
-
|
|
278
|
+
// Text→narrative projection comes from the ONE shared kernel
|
|
279
|
+
// (projectAssistantTextBlocks): it owns the empty-drop + blockIndex +
|
|
280
|
+
// lastInMessage contract. We emit its events at their source positions
|
|
281
|
+
// so thinking / tool_use / text stay in source order (the reducer pairs
|
|
282
|
+
// a preamble to the immediately-next tool_use).
|
|
283
|
+
const textEvents = projectAssistantTextBlocks(
|
|
284
|
+
content,
|
|
285
|
+
(text, blockIndex, lastInMessage): SessionEvent => ({ kind: 'text', text, blockIndex, lastInMessage }),
|
|
286
|
+
)
|
|
287
|
+
content.forEach((c, i) => {
|
|
222
288
|
const ct = c.type as string | undefined
|
|
223
289
|
if (ct === 'thinking') {
|
|
224
290
|
events.push({ kind: 'thinking' })
|
|
@@ -237,10 +303,10 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
|
|
|
237
303
|
input: input && typeof input === 'object' ? input : undefined,
|
|
238
304
|
})
|
|
239
305
|
} else if (ct === 'text') {
|
|
240
|
-
const
|
|
241
|
-
events.push(
|
|
306
|
+
const ev = textEvents.get(i)
|
|
307
|
+
if (ev != null) events.push(ev)
|
|
242
308
|
}
|
|
243
|
-
}
|
|
309
|
+
})
|
|
244
310
|
return events
|
|
245
311
|
}
|
|
246
312
|
|
|
@@ -357,7 +423,25 @@ export function projectSubagentLine(
|
|
|
357
423
|
const content = message?.content as Array<Record<string, unknown>> | undefined
|
|
358
424
|
if (!Array.isArray(content)) return []
|
|
359
425
|
const events: SessionEvent[] = []
|
|
360
|
-
|
|
426
|
+
// Text→narrative projection comes from the SAME shared kernel as the
|
|
427
|
+
// main agent (projectAssistantTextBlocks): one source for the empty-drop
|
|
428
|
+
// + blockIndex + lastInMessage contract. The `make` adapter only changes
|
|
429
|
+
// the wire kind to `sub_agent_text`. A nested Agent/Task tool_use still
|
|
430
|
+
// counts as a tool_use that follows a preceding text block — handled by
|
|
431
|
+
// the kernel — so a sub-agent preamble before a nested spawn is correctly
|
|
432
|
+
// NOT `lastInMessage`. We emit at source positions so text + tool_use
|
|
433
|
+
// stay in source order (the reducer pairs preamble → next tool_use).
|
|
434
|
+
const textEvents = projectAssistantTextBlocks(
|
|
435
|
+
content,
|
|
436
|
+
(text, blockIndex, lastInMessage): SessionEvent => ({
|
|
437
|
+
kind: 'sub_agent_text',
|
|
438
|
+
agentId,
|
|
439
|
+
text,
|
|
440
|
+
blockIndex,
|
|
441
|
+
lastInMessage,
|
|
442
|
+
}),
|
|
443
|
+
)
|
|
444
|
+
content.forEach((c, i) => {
|
|
361
445
|
const ct = c.type as string | undefined
|
|
362
446
|
if (ct === 'tool_use') {
|
|
363
447
|
const name = (c.name as string | undefined) ?? ''
|
|
@@ -386,10 +470,11 @@ export function projectSubagentLine(
|
|
|
386
470
|
// in the SAME assistant message must be emitted in source order
|
|
387
471
|
// so the reducer consumes the preamble on the immediately-next
|
|
388
472
|
// tool_use and sibling tool_uses fall back to filename/pattern.
|
|
389
|
-
|
|
390
|
-
|
|
473
|
+
// The event itself comes from the shared kernel (textEvents above).
|
|
474
|
+
const ev = textEvents.get(i)
|
|
475
|
+
if (ev != null) events.push(ev)
|
|
391
476
|
}
|
|
392
|
-
}
|
|
477
|
+
})
|
|
393
478
|
// Authoritative early terminal: a background `Agent` worker's JSONL on
|
|
394
479
|
// claude ≥2.1.156 never writes the `system/turn_duration` line below, so
|
|
395
480
|
// the watcher used to only learn the worker finished via the ~5-min
|
|
@@ -48,6 +48,12 @@
|
|
|
48
48
|
* pacing prompt + draft still apply; only the framework safety net is off.
|
|
49
49
|
*/
|
|
50
50
|
|
|
51
|
+
import {
|
|
52
|
+
decideMidTurnFloor,
|
|
53
|
+
midTurnFloorEnabled,
|
|
54
|
+
type LoopRole,
|
|
55
|
+
} from './turn-liveness-floor.js'
|
|
56
|
+
|
|
51
57
|
/** #1292: snapshot of an in-flight tool call, surfaced in the 300s
|
|
52
58
|
* framework-fallback message so the user sees the actual observable
|
|
53
59
|
* ("running Grep \"foo\" for 4m") instead of the dishonest generic
|
|
@@ -73,6 +79,10 @@ export interface SilencePokeState {
|
|
|
73
79
|
lastThinkingAt: number | null
|
|
74
80
|
/** True once the 300s framework fallback has fired this turn. */
|
|
75
81
|
fallbackFired: boolean
|
|
82
|
+
/** #2527: true once the mid-turn liveness floor has fired this turn.
|
|
83
|
+
* Independent of `fallbackFired` — the floor is the early (45s) quiet
|
|
84
|
+
* beat, the fallback the late (300s) loud unwedge. Fire-once each. */
|
|
85
|
+
floorFired: boolean
|
|
76
86
|
/** #1292: in-flight tool calls keyed by toolUseId. Populated by
|
|
77
87
|
* `noteToolStart` on every parent-agent `tool_use` event the gateway
|
|
78
88
|
* sees and drained by `noteToolEnd` on the matching `tool_result`.
|
|
@@ -99,6 +109,14 @@ export interface ThresholdsMs {
|
|
|
99
109
|
* defer is on; defaults to no ceiling (Infinity) when omitted.
|
|
100
110
|
*/
|
|
101
111
|
fallbackHardCeiling?: number
|
|
112
|
+
/**
|
|
113
|
+
* #2527 — mid-turn liveness floor threshold. After this much busy-silence
|
|
114
|
+
* on a `user` turn that hasn't delivered a substantive answer, the floor
|
|
115
|
+
* fires ONE quiet (no-ping) interim so the user isn't left staring at the
|
|
116
|
+
* ambient 👀. Strictly below `fallback` (which owns the beat above it).
|
|
117
|
+
* Omitted (undefined) disables the floor entirely.
|
|
118
|
+
*/
|
|
119
|
+
floor?: number
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
export const DEFAULT_THRESHOLDS: ThresholdsMs = {
|
|
@@ -127,8 +145,26 @@ export interface FrameworkFallbackContext {
|
|
|
127
145
|
inFlightTools: ToolSnapshot[]
|
|
128
146
|
}
|
|
129
147
|
|
|
148
|
+
/**
|
|
149
|
+
* #2527 — context handed to the gateway when the mid-turn floor fires. The
|
|
150
|
+
* gateway formats the honest text (from `inFlightTools`) and sends it through
|
|
151
|
+
* the SAME path a model reply takes — no parallel send. Mirrors
|
|
152
|
+
* `FrameworkFallbackContext` minus the wedge semantics: the floor never
|
|
153
|
+
* unwedges the turn, it just speaks.
|
|
154
|
+
*/
|
|
155
|
+
export interface MidTurnFloorContext {
|
|
156
|
+
key: string
|
|
157
|
+
chatId: string
|
|
158
|
+
threadId: number | null
|
|
159
|
+
silenceMs: number
|
|
160
|
+
inFlightTools: ToolSnapshot[]
|
|
161
|
+
/** True when fired by a user "Status?" mid-turn inbound rather than the timer. */
|
|
162
|
+
forced: boolean
|
|
163
|
+
}
|
|
164
|
+
|
|
130
165
|
export type SilencePokeMetric =
|
|
131
166
|
| { kind: 'silence_fallback_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
|
|
167
|
+
| { kind: 'mid_turn_floor'; key: string; silence_ms: number; forced: boolean; decision: 'fire' | string }
|
|
132
168
|
|
|
133
169
|
export interface SilencePokeDeps {
|
|
134
170
|
/** Called when the 300s fallback fires. Caller sends the user-visible
|
|
@@ -141,20 +177,40 @@ export interface SilencePokeDeps {
|
|
|
141
177
|
/** Poll interval (tests). */
|
|
142
178
|
pollIntervalMs?: number
|
|
143
179
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* demonstrably working
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* Default false (legacy behaviour) — enable per-agent to canary.
|
|
180
|
+
* Feed-survival predicate callback. When provided, the 300s framework
|
|
181
|
+
* fallback is DEFERRED while this function returns true: the agent is
|
|
182
|
+
* demonstrably working (in-flight tool, detached background process, or a
|
|
183
|
+
* human-wait tool like ask_user), and since #2162 the live activity feed
|
|
184
|
+
* shows that work, so nulling `currentTurn` would darken a feed the user is
|
|
185
|
+
* actively watching. The defer is bounded by `thresholdsMs.fallbackHardCeiling`
|
|
186
|
+
* so a hung-or-missing-work-signal turn still unwedges eventually.
|
|
152
187
|
*
|
|
153
|
-
*
|
|
154
|
-
* (
|
|
155
|
-
*
|
|
188
|
+
* This supersedes `deferFallbackWhileToolInFlight`. When present it is ALWAYS
|
|
189
|
+
* consulted (no extra env flag required). Set SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0
|
|
190
|
+
* in the environment to force-disable the defer even when this callback is wired.
|
|
191
|
+
*/
|
|
192
|
+
isLegitimatelyWorking?: (key: string) => boolean
|
|
193
|
+
/**
|
|
194
|
+
* Legacy boolean flag — honoured when `isLegitimatelyWorking` is absent.
|
|
195
|
+
* When true, the 300s fallback is deferred while `inFlightTools` is non-empty,
|
|
196
|
+
* bounded by `thresholdsMs.fallbackHardCeiling`.
|
|
197
|
+
* @deprecated Prefer `isLegitimatelyWorking` which covers detached work and
|
|
198
|
+
* human-wait tools in addition to foreground in-flight tool calls.
|
|
156
199
|
*/
|
|
157
200
|
deferFallbackWhileToolInFlight?: boolean
|
|
201
|
+
/**
|
|
202
|
+
* #2527 — called when the mid-turn liveness floor fires. The gateway sends
|
|
203
|
+
* the honest "still on it" interim through the shared reply path. Optional:
|
|
204
|
+
* when absent the floor never fires (back-compat for test harnesses).
|
|
205
|
+
*/
|
|
206
|
+
onMidTurnFloor?: (ctx: MidTurnFloorContext) => Promise<void> | void
|
|
207
|
+
/**
|
|
208
|
+
* #2527 — the gateway-owned half of the floor decision: the turn's loop
|
|
209
|
+
* role and whether a substantive answer has already landed. silence-poke
|
|
210
|
+
* owns the timing/working/fire-once half; the pure `decideMidTurnFloor`
|
|
211
|
+
* combines both. Returns null when there is no live turn for `key`.
|
|
212
|
+
*/
|
|
213
|
+
floorState?: (key: string) => { role: LoopRole; finalAnswerDelivered: boolean } | null
|
|
158
214
|
}
|
|
159
215
|
|
|
160
216
|
const state = new Map<string, SilencePokeState>()
|
|
@@ -180,6 +236,7 @@ export function startTurn(key: string, now: number): void {
|
|
|
180
236
|
lastOutboundAt: null,
|
|
181
237
|
lastThinkingAt: null,
|
|
182
238
|
fallbackFired: false,
|
|
239
|
+
floorFired: false,
|
|
183
240
|
inFlightTools: new Map(),
|
|
184
241
|
})
|
|
185
242
|
}
|
|
@@ -409,6 +466,76 @@ function truncateLabel(label: string): string {
|
|
|
409
466
|
return label.slice(0, MAX - 1) + '…'
|
|
410
467
|
}
|
|
411
468
|
|
|
469
|
+
/** Snapshot in-flight tools sorted longest-running first — for the honest
|
|
470
|
+
* floor/fallback message body. */
|
|
471
|
+
function snapshotInFlight(s: SilencePokeState, now: number): ToolSnapshot[] {
|
|
472
|
+
return Array.from(s.inFlightTools.values())
|
|
473
|
+
.sort((a, b) => a.startedAt - b.startedAt)
|
|
474
|
+
.map((t) => ({ name: t.name, label: t.label, durationMs: now - t.startedAt }))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* #2527 — evaluate and (if eligible) fire the mid-turn liveness floor for one
|
|
479
|
+
* turn. silence-poke owns the timing/working/fire-once half; the gateway
|
|
480
|
+
* provides the role + delivery half via `floorState`; the pure
|
|
481
|
+
* `decideMidTurnFloor` combines them so the policy lives in one tested place.
|
|
482
|
+
* `forced=true` is a user "Status?" poke (bypasses timing + working).
|
|
483
|
+
*/
|
|
484
|
+
function tryMidTurnFloor(key: string, s: SilencePokeState, now: number, forced: boolean): void {
|
|
485
|
+
if (activeDeps == null) return
|
|
486
|
+
const { onMidTurnFloor, floorState, isLegitimatelyWorking } = activeDeps
|
|
487
|
+
if (onMidTurnFloor == null || floorState == null) return
|
|
488
|
+
const thresholds = activeDeps.thresholdsMs ?? DEFAULT_THRESHOLDS
|
|
489
|
+
if (thresholds.floor == null) return
|
|
490
|
+
const fs = floorState(key)
|
|
491
|
+
if (fs == null) return
|
|
492
|
+
const silence = now - (s.lastOutboundAt ?? s.turnStartedAt)
|
|
493
|
+
if (silence < 0) return
|
|
494
|
+
const decision = decideMidTurnFloor({
|
|
495
|
+
enabled: midTurnFloorEnabled(),
|
|
496
|
+
role: fs.role,
|
|
497
|
+
finalAnswerDelivered: fs.finalAnswerDelivered,
|
|
498
|
+
silenceMs: silence,
|
|
499
|
+
floorThresholdMs: thresholds.floor,
|
|
500
|
+
fallbackThresholdMs: thresholds.fallback,
|
|
501
|
+
legitimatelyWorking: isLegitimatelyWorking?.(key) ?? false,
|
|
502
|
+
alreadyFired: s.floorFired,
|
|
503
|
+
force: forced,
|
|
504
|
+
})
|
|
505
|
+
if (decision.kind !== 'fire') {
|
|
506
|
+
// Per-tick skips are noise; only surface a declined FORCED poke (the
|
|
507
|
+
// user asked "Status?" and we chose not to speak — worth seeing).
|
|
508
|
+
if (forced) {
|
|
509
|
+
activeDeps.emitMetric({ kind: 'mid_turn_floor', key, silence_ms: silence, forced, decision: decision.reason })
|
|
510
|
+
}
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
s.floorFired = true
|
|
514
|
+
activeDeps.emitMetric({ kind: 'mid_turn_floor', key, silence_ms: silence, forced, decision: 'fire' })
|
|
515
|
+
const { chatId, threadId } = parseKey(key)
|
|
516
|
+
try {
|
|
517
|
+
const r = onMidTurnFloor({ key, chatId, threadId, silenceMs: silence, inFlightTools: snapshotInFlight(s, now), forced })
|
|
518
|
+
if (r != null && typeof (r as Promise<void>).catch === 'function') {
|
|
519
|
+
;(r as Promise<void>).catch((err) => {
|
|
520
|
+
process.stderr.write(`silence-poke: mid-turn floor handler rejected: ${err}\n`)
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
} catch (err) {
|
|
524
|
+
process.stderr.write(`silence-poke: mid-turn floor handler threw: ${err}\n`)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* #2527 — fire the mid-turn floor immediately for `key` (a user "Status?"
|
|
530
|
+
* mid-turn inbound landed during a silent stretch). No-op if there is no
|
|
531
|
+
* live turn for the key or the floor is ineligible/already-fired.
|
|
532
|
+
*/
|
|
533
|
+
export function pokeFloorNow(key: string, now: number): void {
|
|
534
|
+
const s = state.get(key)
|
|
535
|
+
if (s == null) return
|
|
536
|
+
tryMidTurnFloor(key, s, now, true)
|
|
537
|
+
}
|
|
538
|
+
|
|
412
539
|
/**
|
|
413
540
|
* Internal tick — iterates active states and fires the 300s framework
|
|
414
541
|
* fallback (which the gateway turns into a user-visible message + an
|
|
@@ -423,20 +550,39 @@ function tick(now: number): void {
|
|
|
423
550
|
const silence = now - zeroAt
|
|
424
551
|
if (silence < 0) continue
|
|
425
552
|
|
|
553
|
+
// #2527 — the early, quiet mid-turn liveness beat (below the fallback
|
|
554
|
+
// window). Evaluated every tick; fires at most once per turn.
|
|
555
|
+
tryMidTurnFloor(key, s, now, false)
|
|
556
|
+
|
|
426
557
|
if (!s.fallbackFired && silence >= thresholds.fallback) {
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
558
|
+
// Feed-survival defer: hold back the unwedge while the agent is
|
|
559
|
+
// demonstrably working — an in-flight tool, a detached background process,
|
|
560
|
+
// or a human-wait tool (ask_user). Since #2162 the live activity feed
|
|
561
|
+
// renders that work, so nulling currentTurn would darken a feed the user
|
|
562
|
+
// is actively watching. Bounded by fallbackHardCeiling so a
|
|
563
|
+
// hung-or-leaked-signal turn still unwedges eventually.
|
|
564
|
+
//
|
|
565
|
+
// Two defer paths (tried in priority order):
|
|
566
|
+
// 1. `isLegitimatelyWorking(key)` — new single source of truth covering
|
|
567
|
+
// foreground in-flight tools, detached background work, and human-wait
|
|
568
|
+
// tools. Active by default when the callback is wired; force-disabled
|
|
569
|
+
// by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0.
|
|
570
|
+
// 2. Legacy `deferFallbackWhileToolInFlight` boolean — covers only
|
|
571
|
+
// `inFlightTools.size > 0`; kept for test fixtures that set it
|
|
572
|
+
// directly without wiring the callback.
|
|
573
|
+
//
|
|
574
|
+
// In both cases: `continue` WITHOUT setting fallbackFired so the next
|
|
575
|
+
// tick re-checks. Once the work signal clears and the turn stays silent
|
|
576
|
+
// past the base threshold, or the ceiling is crossed, the fallback fires.
|
|
577
|
+
const ceiling = thresholds.fallbackHardCeiling ?? Number.POSITIVE_INFINITY
|
|
578
|
+
const underCeiling = silence < ceiling
|
|
579
|
+
if (underCeiling) {
|
|
580
|
+
const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
|
|
581
|
+
if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
|
|
582
|
+
if (activeDeps.isLegitimatelyWorking(key)) continue
|
|
583
|
+
} else if (!forceDisable && activeDeps.deferFallbackWhileToolInFlight === true && s.inFlightTools.size > 0) {
|
|
584
|
+
continue
|
|
585
|
+
}
|
|
440
586
|
}
|
|
441
587
|
s.fallbackFired = true
|
|
442
588
|
const { chatId, threadId } = parseKey(key)
|
|
@@ -110,6 +110,9 @@ export async function refreshBanner(
|
|
|
110
110
|
sent = await args.bot.api.sendMessage(args.ownerChatId, action.text, {
|
|
111
111
|
parse_mode: 'HTML',
|
|
112
112
|
link_preview_options: { is_disabled: true },
|
|
113
|
+
// OAuth slot banner is a status notice — silence the open ping.
|
|
114
|
+
// (the pin below is already silent; the edit path doesn't ping.)
|
|
115
|
+
disable_notification: true,
|
|
113
116
|
});
|
|
114
117
|
} catch (err) {
|
|
115
118
|
args.onError?.('pin', err);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-card shared constants.
|
|
3
|
+
*
|
|
4
|
+
* Both status surfaces — the main-session agent activity card
|
|
5
|
+
* (`tool-activity-summary.ts`) and the background-worker activity feed
|
|
6
|
+
* (`worker-activity-feed.ts`) — render through the single
|
|
7
|
+
* `renderStatusCard` primitive in `tool-activity-summary.ts`. This module
|
|
8
|
+
* holds the tuning constants that primitive (and its internal helpers)
|
|
9
|
+
* read, so a forked renderer never re-derives them.
|
|
10
|
+
*
|
|
11
|
+
* The former `SWITCHROOM_STATUS_NO_TRUNCATE` feature flag was retired:
|
|
12
|
+
* rolling-window-with-char-budget is now the only behaviour. The per-line
|
|
13
|
+
* cap (`STATUS_LINE_MAX`) and rolling window (`STATUS_ROLLING_LINES`) apply
|
|
14
|
+
* universally on BOTH surfaces; the total char budget
|
|
15
|
+
* (`STATUS_CARD_CHAR_BUDGET`) is the wire-limit backstop.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Number of trailing narrative/step lines shown in the rolling window.
|
|
20
|
+
* The feed is a fixed-height rolling window: oldest drops off as new arrive.
|
|
21
|
+
* Overflow surfaces a `+N earlier…` header on BOTH surfaces.
|
|
22
|
+
*/
|
|
23
|
+
export const STATUS_ROLLING_LINES = 5
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Per-line character cap, applied to every step + child step on BOTH
|
|
27
|
+
* surfaces before HTML-escaping (clip raw → escape last). A line longer
|
|
28
|
+
* than this is truncated with a trailing `…`.
|
|
29
|
+
*/
|
|
30
|
+
export const STATUS_LINE_MAX = 200
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The safe char budget for a rendered Telegram status card. Telegram's hard
|
|
34
|
+
* cap is 4096; we use 4000 to leave 96 chars of headroom for HTML framing,
|
|
35
|
+
* emoji, and escape expansion — matching the convention in
|
|
36
|
+
* pending-work-progress.ts (TELEGRAM_MSG_CAP = 4000).
|
|
37
|
+
*
|
|
38
|
+
* With STATUS_ROLLING_LINES=5 lines each ≤ STATUS_LINE_MAX this backstop
|
|
39
|
+
* effectively never fires in practice, but is kept as a wire-limit safety net.
|
|
40
|
+
*/
|
|
41
|
+
export const STATUS_CARD_CHAR_BUDGET = 4000
|
|
42
|
+
|
|
43
|
+
/** Indent marker for a nested (foreground sub-agent) step line. */
|
|
44
|
+
export const NESTED_PREFIX = ' ↳ '
|
|
@@ -55,6 +55,7 @@ export type ReactionState =
|
|
|
55
55
|
| 'compacting'
|
|
56
56
|
| 'awaiting'
|
|
57
57
|
| 'done'
|
|
58
|
+
| 'undelivered'
|
|
58
59
|
| 'error'
|
|
59
60
|
| 'stallSoft'
|
|
60
61
|
| 'stallHard'
|
|
@@ -80,7 +81,11 @@ export const REACTION_VARIANTS: Record<ReactionState, string[]> = {
|
|
|
80
81
|
web: ['⚡', '🤔', '👌'], // WORKING: lookup in motion
|
|
81
82
|
compacting:['✍', '🤔', '👀'],
|
|
82
83
|
awaiting: ['🙏', '🤔', '👀'], // BLOCKED ON HUMAN: parked on a permission card
|
|
83
|
-
done: ['👍', '💯', '🎉'], // FINISHED: turn_end
|
|
84
|
+
done: ['👍', '💯', '🎉'], // FINISHED: turn_end delivered an answer
|
|
85
|
+
// #2527 — FINISHED but the user turn produced NO answer. A gentle,
|
|
86
|
+
// non-celebratory terminal so the ambient signal never reads as "done"
|
|
87
|
+
// over an undelivered turn. The silent-end fallback text carries the why.
|
|
88
|
+
undelivered:['😐', '🤷', '🤔'],
|
|
84
89
|
error: ['😱', '😨', '🤯'], // NON-TERMINAL — recovery allowed
|
|
85
90
|
stallSoft: ['🥱', '😴', '🤔'],
|
|
86
91
|
stallHard: ['😨', '🤯', '😱'],
|
|
@@ -103,7 +108,7 @@ export function resolveToolReactionState(toolName: string): ReactionState {
|
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
/** Reason passed to `finalize()` — selects the terminal emoji. */
|
|
106
|
-
export type FinalizeReason = 'done' | 'error'
|
|
111
|
+
export type FinalizeReason = 'done' | 'undelivered' | 'error'
|
|
107
112
|
|
|
108
113
|
/** Configuration knobs the controller respects. */
|
|
109
114
|
export interface StatusReactionConfig {
|
|
@@ -115,6 +120,14 @@ export interface StatusReactionConfig {
|
|
|
115
120
|
stallHardMs?: number
|
|
116
121
|
/** Optional logger for debugging — receives a single string per event. */
|
|
117
122
|
log?: (msg: string) => void
|
|
123
|
+
/**
|
|
124
|
+
* Optional structured callback fired on every emoji transition (after the
|
|
125
|
+
* API call succeeds). Used by the gateway to emit `status_reaction_transition`
|
|
126
|
+
* streaming-metrics events for #2527 observability. Kept out of the main
|
|
127
|
+
* `log` callback so callers that only want the human-readable log string
|
|
128
|
+
* don't need to parse it.
|
|
129
|
+
*/
|
|
130
|
+
onTransition?: (emoji: string) => void
|
|
118
131
|
}
|
|
119
132
|
|
|
120
133
|
/**
|
|
@@ -152,6 +165,7 @@ export class StatusReactionController {
|
|
|
152
165
|
private readonly stallSoftMs: number
|
|
153
166
|
private readonly stallHardMs: number
|
|
154
167
|
private readonly log?: (msg: string) => void
|
|
168
|
+
private readonly onTransition?: (emoji: string) => void
|
|
155
169
|
|
|
156
170
|
constructor(
|
|
157
171
|
private readonly emit: ReactionEmitter,
|
|
@@ -163,6 +177,7 @@ export class StatusReactionController {
|
|
|
163
177
|
this.stallSoftMs = config.stallSoftMs ?? 30000
|
|
164
178
|
this.stallHardMs = config.stallHardMs ?? 90000
|
|
165
179
|
this.log = config.log
|
|
180
|
+
this.onTransition = config.onTransition
|
|
166
181
|
}
|
|
167
182
|
|
|
168
183
|
/** 👀 — message received and queued for processing. Bypasses debounce. */
|
|
@@ -222,7 +237,8 @@ export class StatusReactionController {
|
|
|
222
237
|
* lands promptly. Subsequent calls are no-ops.
|
|
223
238
|
*/
|
|
224
239
|
finalize(reason: FinalizeReason = 'done'): void {
|
|
225
|
-
const state: ReactionState =
|
|
240
|
+
const state: ReactionState =
|
|
241
|
+
reason === 'error' ? 'error' : reason === 'undelivered' ? 'undelivered' : 'done'
|
|
226
242
|
this.finishWithState(state)
|
|
227
243
|
}
|
|
228
244
|
|
|
@@ -348,6 +364,7 @@ export class StatusReactionController {
|
|
|
348
364
|
this.currentEmoji = emoji
|
|
349
365
|
if (this.pendingEmoji === emoji) this.pendingEmoji = null
|
|
350
366
|
this.log?.(`reaction → ${emoji}`)
|
|
367
|
+
this.onTransition?.(emoji)
|
|
351
368
|
} catch (err) {
|
|
352
369
|
this.log?.(`reaction emit failed (${emoji}): ${(err as Error).message}`)
|
|
353
370
|
}
|