switchroom 0.18.18 → 0.18.20
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/cli/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +36 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1131 -285
- package/telegram-plugin/dist/server.js +6 -0
- package/telegram-plugin/format.ts +208 -125
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +800 -107
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +5 -3
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/llm-error-present.ts +68 -30
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/subagent-watcher.ts +244 -81
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +39 -4
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +2 -0
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
- package/telegram-plugin/tests/telegram-format.test.ts +72 -4
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +2 -1
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -34,7 +34,6 @@ import {
|
|
|
34
34
|
} from './model-unavailable.js'
|
|
35
35
|
import { classify429Detail } from './throttle-tier.js'
|
|
36
36
|
import { classifyClaudeError } from './operator-events.js'
|
|
37
|
-
import type { InlineKeyboardMarkup } from './operator-events.js'
|
|
38
37
|
import { stripRawErrorBytes, extractRequestId } from './raw-error-scrub.js'
|
|
39
38
|
import { fmtLocalClock, tzAbbrev } from './shared/local-time.js'
|
|
40
39
|
|
|
@@ -226,7 +225,6 @@ function buildCoreText(kind: LlmErrorKind, source: LlmErrorSource): string {
|
|
|
226
225
|
|
|
227
226
|
export interface RenderedLlmError {
|
|
228
227
|
text: string
|
|
229
|
-
keyboard?: InlineKeyboardMarkup
|
|
230
228
|
}
|
|
231
229
|
|
|
232
230
|
/**
|
|
@@ -257,9 +255,49 @@ function formatRelativeTail(deltaMs: number): string {
|
|
|
257
255
|
}
|
|
258
256
|
|
|
259
257
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
258
|
+
* A short local-tz clock+abbrev for a reset instant (`4:52pm AEST`), or '' when
|
|
259
|
+
* there is no finite reset to name. Used to embed the reset in the recommendation
|
|
260
|
+
* line. Shares the same tz-formatting primitives as {@link formatResetLocal}, so
|
|
261
|
+
* an invalid IANA tz throws here too (guarded by {@link renderLlmErrorSafe}).
|
|
262
|
+
*/
|
|
263
|
+
function formatResetClock(resetAt: Date | undefined, tz: string): string {
|
|
264
|
+
if (resetAt == null) return ''
|
|
265
|
+
const ms = resetAt.getTime()
|
|
266
|
+
if (!Number.isFinite(ms)) return ''
|
|
267
|
+
return `${fmtLocalClock(ms, tz)} ${tzAbbrev(ms, tz)}`
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The plain-text "what to DO" line for the actionable error classes. Replaces
|
|
272
|
+
* the former dead action buttons (auth → Reauth, quota_wall → Wait): those
|
|
273
|
+
* inline_keyboard callbacks were only ever exercised in tests — the gateway
|
|
274
|
+
* wiring routes renderLlmError solely for the transient rate_limit/overload_529
|
|
275
|
+
* kinds, so the auth/quota buttons were unreachable in production (Ken, CPO,
|
|
276
|
+
* 2026-07: drop the buttons, recommend in text). Transient kinds return undefined
|
|
277
|
+
* — their coreText already says "retrying automatically", no operator action.
|
|
278
|
+
*/
|
|
279
|
+
function buildRecommendation(parsed: ParsedLlmError, tz: string): string | undefined {
|
|
280
|
+
switch (parsed.kind) {
|
|
281
|
+
case 'auth':
|
|
282
|
+
return '→ Re-authenticate this account to continue.'
|
|
283
|
+
case 'quota_wall': {
|
|
284
|
+
const reset = formatResetClock(parsed.resetAt, tz)
|
|
285
|
+
return reset
|
|
286
|
+
? `→ Switch to another account, or wait for the quota to reset at ${reset}.`
|
|
287
|
+
: '→ Switch to another account, or wait for the quota to reset.'
|
|
288
|
+
}
|
|
289
|
+
default:
|
|
290
|
+
return undefined
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Render ONE clean card for a parsed LLM error. No action buttons — the
|
|
296
|
+
* actionable kinds (auth, quota_wall) carry a plain-text recommendation line
|
|
297
|
+
* instead (see {@link buildRecommendation}). `agent` is the headline label; `tz`
|
|
298
|
+
* localizes the reset. Throws if `tz` is an invalid IANA zone AND a reset is
|
|
299
|
+
* present (Intl.DateTimeFormat rejects the zone at construction) — call
|
|
300
|
+
* {@link renderLlmErrorSafe} from any crash-sensitive surface.
|
|
263
301
|
*/
|
|
264
302
|
export function renderLlmError(
|
|
265
303
|
parsed: ParsedLlmError,
|
|
@@ -276,32 +314,32 @@ export function renderLlmError(
|
|
|
276
314
|
|
|
277
315
|
if (parsed.model) lines.push(`_model: ${escapeAgent(parsed.model)}_`)
|
|
278
316
|
|
|
279
|
-
const
|
|
317
|
+
const recommendation = buildRecommendation(parsed, tz)
|
|
318
|
+
if (recommendation) lines.push(recommendation)
|
|
280
319
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
return { text }
|
|
320
|
+
return { text: lines.join('\n') }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Crash-guarded wrapper around {@link renderLlmError}. An invalid IANA timezone
|
|
325
|
+
* makes Intl.DateTimeFormat throw a RangeError at construction time — the
|
|
326
|
+
* local-time.ts "never throws" contract does NOT cover construction-time zone
|
|
327
|
+
* validation, so a bad `SWITCHROOM_TIMEZONE`/`TZ` would otherwise crash the
|
|
328
|
+
* operator-event turn. On ANY formatting failure, degrade to a minimal, tz-free
|
|
329
|
+
* safe line built only from the JSON-stripped coreText + agent. Total — never
|
|
330
|
+
* throws.
|
|
331
|
+
*/
|
|
332
|
+
export function renderLlmErrorSafe(
|
|
333
|
+
parsed: ParsedLlmError,
|
|
334
|
+
agent: string,
|
|
335
|
+
tz: string,
|
|
336
|
+
now: Date = new Date(),
|
|
337
|
+
): RenderedLlmError {
|
|
338
|
+
try {
|
|
339
|
+
return renderLlmError(parsed, agent, tz, now)
|
|
340
|
+
} catch {
|
|
341
|
+
const safeAgent = escapeAgent(agent)
|
|
342
|
+
return { text: `${kindEmoji(parsed.kind)} ${parsed.coreText} (**${safeAgent}**)` }
|
|
305
343
|
}
|
|
306
344
|
}
|
|
307
345
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time-boxed narrative early-paint — the TIMER half of the JSONL-text-narrative
|
|
3
|
+
* primitive, and the pure, fully-unit-testable kernel of it (companion to
|
|
4
|
+
* `narrative-dedup.ts`, which owns the SHOW-vs-SUPPRESS decision).
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists
|
|
7
|
+
* A `text` / `sub_agent_text` block is parked for ONE lookahead step so the
|
|
8
|
+
* reducer can tell DRAFT-THEN-SEND (suppress — it duplicates the reply) from
|
|
9
|
+
* WORKING NARRATION (show). On a normal turn the lookahead is the FIRST real
|
|
10
|
+
* `tool_use`, so the opening narration ("On it, pulling the logs…") only painted
|
|
11
|
+
* when that tool landed — a visible gap while the agent thinks before its first
|
|
12
|
+
* tool.
|
|
13
|
+
*
|
|
14
|
+
* This kernel adds a deterministic timer: when a block is parked, arm a short
|
|
15
|
+
* timer. If a lookahead event (tool_use / next text / turn_end) arrives first,
|
|
16
|
+
* the normal path resolves the block and the timer is CANCELLED. If the timer
|
|
17
|
+
* fires first, the parked narration is painted EARLY — so it surfaces
|
|
18
|
+
* ~`flushMs` into the turn instead of waiting for the first tool.
|
|
19
|
+
*
|
|
20
|
+
* ## Anti-double-print guarantee (the correctness core)
|
|
21
|
+
* The whole reason the decision is deferred is that a parked block might turn out
|
|
22
|
+
* to be the outgoing reply, which must NEVER surface as a card narration step AND
|
|
23
|
+
* as the canonical reply. The timer introduces a window where a block is painted
|
|
24
|
+
* BEFORE its lookahead reply arrives. This kernel keeps the guarantee
|
|
25
|
+
* deterministically, NOT by hoping the window hides the race:
|
|
26
|
+
* - A block painted by the timer is remembered (`timerShown`).
|
|
27
|
+
* - When the reply/stream_reply lookahead finally arrives (on a tool or at
|
|
28
|
+
* turn_end), if it is a draft-then-send of the timer-painted block
|
|
29
|
+
* (`isDraftOfReply`), the kernel emits a RETRACT effect so the caller removes
|
|
30
|
+
* the prematurely-shown step. Correctness therefore does not depend on the
|
|
31
|
+
* 250ms window winning the race — the retract is the guarantee; the timer is
|
|
32
|
+
* only the early-paint trigger.
|
|
33
|
+
*
|
|
34
|
+
* ## Purity
|
|
35
|
+
* No I/O, no real timers, no state of its own beyond the two explicit slots. All
|
|
36
|
+
* effects (paint a step, retract a step, arm/disarm a real timer) are injected,
|
|
37
|
+
* so the kernel is driven deterministically in tests with fake timers. The caller
|
|
38
|
+
* (gateway reducer for the main agent, subagent-watcher for sub/worker) owns the
|
|
39
|
+
* effects and the per-turn lifetime.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { REPLY_TOOLS, isDraftOfReply } from './narrative-dedup.js'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Time-box for the parked-narrative early-paint (the kernel's home for the
|
|
46
|
+
* constant so BOTH callers share ONE source of truth):
|
|
47
|
+
* - the main-agent gateway path drives the kernel with a real `setTimeout`
|
|
48
|
+
* scheduler (`makeNarrativeGate`, gateway.ts);
|
|
49
|
+
* - the worker / sub-agent watcher drives the SAME kernel with a POLL-driven
|
|
50
|
+
* scheduler (stamp `deadline = nowFn()+flushMs`; flush on the next poll
|
|
51
|
+
* tick once `nowFn() >= deadline`) — see `subagent-watcher.ts`.
|
|
52
|
+
* If a parked opening narration gets no lookahead event (tool_use / next text /
|
|
53
|
+
* turn_end) within this window — the "narrate, then think before the first
|
|
54
|
+
* tool" gap — it is painted EARLY instead of waiting for the first tool. Chosen
|
|
55
|
+
* so a normal draft-then-send `reply` (emitted right after the text block)
|
|
56
|
+
* resolves the block FIRST; correctness does NOT depend on that race — a
|
|
57
|
+
* timer-painted block that later proves to be the reply is deterministically
|
|
58
|
+
* retracted (main path) or is structurally harmless (worker path: the reply is
|
|
59
|
+
* a Telegram-surface tool that never renders on the card). Named const so a
|
|
60
|
+
* test can pin it.
|
|
61
|
+
*/
|
|
62
|
+
export const PENDING_NARRATIVE_FLUSH_MS = 250
|
|
63
|
+
|
|
64
|
+
/** Side effects the kernel drives. The caller owns rendering + retraction. */
|
|
65
|
+
export interface NarrativeFlushEffects {
|
|
66
|
+
/** Paint a narrative block as a transient liveness step (SHOW). */
|
|
67
|
+
show(text: string): void
|
|
68
|
+
/**
|
|
69
|
+
* Retract a previously-SHOWN narration step (it turned out to draft the reply).
|
|
70
|
+
* Caller removes it from the feed. No-op-safe if already gone.
|
|
71
|
+
*/
|
|
72
|
+
retractShown(text: string): void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Arms / disarms the real early-paint timer. Injected so tests can fake it. */
|
|
76
|
+
export interface NarrativeFlushScheduler {
|
|
77
|
+
/** Arm the timer; MUST cancel any prior armed callback first (at-most-one). */
|
|
78
|
+
arm(fn: () => void, ms: number): void
|
|
79
|
+
/** Cancel the armed callback if any. Idempotent. */
|
|
80
|
+
disarm(): void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Pure park/timer/retract state machine for one turn (or one sub-agent entry).
|
|
85
|
+
* Construct once per turn with the effect + scheduler wiring; discard at teardown.
|
|
86
|
+
*/
|
|
87
|
+
export class NarrativeFlushController {
|
|
88
|
+
/** Block parked awaiting its lookahead event. Null when nothing pending. */
|
|
89
|
+
private pending: string | null = null
|
|
90
|
+
/** Block the timer painted EARLY, retained for possible retract. */
|
|
91
|
+
private timerShown: string | null = null
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
private readonly effects: NarrativeFlushEffects,
|
|
95
|
+
private readonly scheduler: NarrativeFlushScheduler,
|
|
96
|
+
private readonly flushMs: number,
|
|
97
|
+
) {}
|
|
98
|
+
|
|
99
|
+
/** Test/inspection accessors (no behaviour). */
|
|
100
|
+
get pendingText(): string | null {
|
|
101
|
+
return this.pending
|
|
102
|
+
}
|
|
103
|
+
get timerShownText(): string | null {
|
|
104
|
+
return this.timerShown
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Gate step 1: a new narrative block arrived. It is itself the lookahead for
|
|
109
|
+
* the previously-parked block (pure narration — a draft is followed by a reply,
|
|
110
|
+
* not more text) → SHOW the old one, then park the new one and arm the timer.
|
|
111
|
+
*/
|
|
112
|
+
stage(text: string): void {
|
|
113
|
+
this.scheduler.disarm()
|
|
114
|
+
if (this.pending != null) {
|
|
115
|
+
this.effects.show(this.pending)
|
|
116
|
+
}
|
|
117
|
+
this.pending = text
|
|
118
|
+
this.scheduler.arm(() => this.onTimerFire(), this.flushMs)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The early-paint timer fired: no lookahead arrived within the window. If the
|
|
123
|
+
* block is still parked, SHOW it now and remember it so a later draft-then-send
|
|
124
|
+
* reply can retract it.
|
|
125
|
+
*/
|
|
126
|
+
private onTimerFire(): void {
|
|
127
|
+
if (this.pending == null) return // a lookahead already consumed it
|
|
128
|
+
const text = this.pending
|
|
129
|
+
this.pending = null
|
|
130
|
+
this.timerShown = text
|
|
131
|
+
this.effects.show(text)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Gate step 2: a tool_use lookahead arrived. Cancel the timer, retract a
|
|
136
|
+
* timer-painted block if THIS reply drafts it, then resolve the parked block:
|
|
137
|
+
* SUPPRESS a reply-draft, else SHOW.
|
|
138
|
+
*/
|
|
139
|
+
resolveOnTool(toolName: string, input: Record<string, unknown> | undefined): void {
|
|
140
|
+
this.scheduler.disarm()
|
|
141
|
+
const replyText =
|
|
142
|
+
REPLY_TOOLS.has(toolName) && typeof input?.text === 'string' ? (input.text as string) : null
|
|
143
|
+
if (replyText != null) this.maybeRetract(replyText)
|
|
144
|
+
const pending = this.pending
|
|
145
|
+
if (pending == null) return
|
|
146
|
+
this.pending = null
|
|
147
|
+
if (replyText != null && isDraftOfReply(pending, replyText)) return // draft → SUPPRESS
|
|
148
|
+
this.effects.show(pending)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Gate step 3: turn_end — the terminal lookahead. Cancel the timer, retract a
|
|
153
|
+
* timer-painted block if the delivered reply drafts it, then SHOW a genuine
|
|
154
|
+
* trailing narration (or SUPPRESS a trailing draft of the answer).
|
|
155
|
+
*/
|
|
156
|
+
flushAtTurnEnd(lastReplyText: string): void {
|
|
157
|
+
this.scheduler.disarm()
|
|
158
|
+
if (lastReplyText.length > 0) this.maybeRetract(lastReplyText)
|
|
159
|
+
const pending = this.pending
|
|
160
|
+
if (pending == null) return
|
|
161
|
+
this.pending = null
|
|
162
|
+
if (lastReplyText.length > 0 && isDraftOfReply(pending, lastReplyText)) return // trailing draft
|
|
163
|
+
this.effects.show(pending)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Turn teardown: cancel the timer so it can neither leak nor fire late. */
|
|
167
|
+
teardown(): void {
|
|
168
|
+
this.scheduler.disarm()
|
|
169
|
+
this.pending = null
|
|
170
|
+
this.timerShown = null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Retract the timer-painted block iff this reply is its draft-then-send. */
|
|
174
|
+
private maybeRetract(replyText: string): void {
|
|
175
|
+
const shown = this.timerShown
|
|
176
|
+
if (shown == null) return
|
|
177
|
+
if (!isDraftOfReply(shown, replyText)) return
|
|
178
|
+
this.timerShown = null
|
|
179
|
+
this.effects.retractShown(shown)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -64,6 +64,19 @@
|
|
|
64
64
|
export const EDIT_INTERVAL_MS = 60_000
|
|
65
65
|
export const POLL_INTERVAL_MS = 5_000
|
|
66
66
|
export const MAX_LIFETIME_MS = 30 * 60_000
|
|
67
|
+
/**
|
|
68
|
+
* TTL bounding how long an in-flight background dispatch (Agent/Task) suppresses
|
|
69
|
+
* the idle auto-/clear gate (#3117). Deliberately tied to `MAX_LIFETIME_MS` (the
|
|
70
|
+
* cross-turn ambient budget cap): the two express the same "how long do we
|
|
71
|
+
* believe a background worker is still legitimately running" budget, so keeping
|
|
72
|
+
* them equal means the idle-suppression window and the ambient-liveness window
|
|
73
|
+
* agree by construction. A SHORTER idle TTL would risk clobbering a legitimate
|
|
74
|
+
* ~25-min worker mid-flight; a LONGER one would weaken the self-healing bound
|
|
75
|
+
* (a leaked `pending=true` that never clears would disable idle-clear for
|
|
76
|
+
* longer). 30 min is the deliberate midpoint the design settled on. The gate
|
|
77
|
+
* suppresses only while `dispatch age < this`; past it the suppression lapses so
|
|
78
|
+
* a stuck flag can never disable idle-clear permanently. */
|
|
79
|
+
export const BACKGROUND_WORK_SUPPRESS_TTL_MS = MAX_LIFETIME_MS
|
|
67
80
|
/** Rich-message wire cap is 32768 (#2669); budget headroom for the
|
|
68
81
|
* suffix and any escape expansion. If the anchor text plus suffix
|
|
69
82
|
* would exceed this, we skip the edit (the user still sees the
|
|
@@ -142,6 +155,14 @@ interface State {
|
|
|
142
155
|
/** True after a `tool_use(Agent|Task)` was observed for this key in
|
|
143
156
|
* the current turn. Cleared on next turn start. */
|
|
144
157
|
pending: boolean
|
|
158
|
+
/** Epoch ms when `pending` was last set true (#3117). null when not
|
|
159
|
+
* pending. Load-bearing for the idle-clear suppression TTL: without a
|
|
160
|
+
* timestamp the gate cannot bound how long an in-flight background dispatch
|
|
161
|
+
* holds off idle-clear, so a leaked `pending=true` would disable idle-clear
|
|
162
|
+
* forever. Re-stamped on each `noteAsyncDispatch` (a fresh dispatch re-arms
|
|
163
|
+
* the TTL) and cleared when `pending` is set false (`startTurn`); a full
|
|
164
|
+
* `clearPending` drops the whole entry, which is equivalent. */
|
|
165
|
+
dispatchedAt: number | null
|
|
145
166
|
/** The captured anchor — last outbound reply message_id for this
|
|
146
167
|
* key. */
|
|
147
168
|
anchorMessageId: number | null
|
|
@@ -179,6 +200,7 @@ function ensure(key: string): State {
|
|
|
179
200
|
if (!s) {
|
|
180
201
|
s = {
|
|
181
202
|
pending: false,
|
|
203
|
+
dispatchedAt: null,
|
|
182
204
|
anchorMessageId: null,
|
|
183
205
|
anchorOriginalText: '',
|
|
184
206
|
anchorLiteralText: false,
|
|
@@ -217,6 +239,7 @@ export function startTurn(key: string): void {
|
|
|
217
239
|
// Only the per-turn fields reset. activatedAt/lastEditAt belong to
|
|
218
240
|
// the prior turn's pending-progress and are cleared separately.
|
|
219
241
|
s.pending = false
|
|
242
|
+
s.dispatchedAt = null
|
|
220
243
|
s.anchorMessageId = null
|
|
221
244
|
s.anchorOriginalText = ''
|
|
222
245
|
s.anchorLiteralText = false
|
|
@@ -229,7 +252,13 @@ export function startTurn(key: string): void {
|
|
|
229
252
|
*/
|
|
230
253
|
export function noteAsyncDispatch(key: string): void {
|
|
231
254
|
if (!enabled()) return
|
|
232
|
-
ensure(key)
|
|
255
|
+
const s = ensure(key)
|
|
256
|
+
s.pending = true
|
|
257
|
+
// Stamp (and re-stamp) the dispatch epoch so the idle-clear suppression TTL
|
|
258
|
+
// (#3117) can bound how long this holds off /clear. Re-stamping on each
|
|
259
|
+
// dispatch means a fresh Agent/Task within the same wait re-arms the TTL —
|
|
260
|
+
// freshest legitimate work wins.
|
|
261
|
+
s.dispatchedAt = nowMs()
|
|
233
262
|
}
|
|
234
263
|
|
|
235
264
|
/**
|
|
@@ -296,6 +325,41 @@ export function hasPendingAsyncDispatch(key: string): boolean {
|
|
|
296
325
|
return stateByKey.get(key)?.pending === true
|
|
297
326
|
}
|
|
298
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Age in ms since the current pending async dispatch was stamped for `key`
|
|
330
|
+
* (#3117), or null if there is no pending dispatch (or, defensively, no
|
|
331
|
+
* timestamp — a pre-stamp entry from a rolling upgrade). Uses the same clock
|
|
332
|
+
* override as the rest of the module so tests drive it deterministically.
|
|
333
|
+
*/
|
|
334
|
+
export function asyncDispatchAgeMs(key: string): number | null {
|
|
335
|
+
const s = stateByKey.get(key)
|
|
336
|
+
if (s == null || s.pending !== true || s.dispatchedAt == null) return null
|
|
337
|
+
return nowMs() - s.dispatchedAt
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* True iff ANY chat key currently has a pending async dispatch whose age is
|
|
342
|
+
* within `ttlMs` (#3117). The idle auto-/clear gate is agent-wide (not scoped
|
|
343
|
+
* to a single chat key), so it consults this aggregate: while a background
|
|
344
|
+
* sub-agent is in flight AND fresh, suppress the clear; once every pending
|
|
345
|
+
* dispatch has aged past the TTL (a leaked/stuck flag), the suppression lapses
|
|
346
|
+
* and idle-clear self-heals. A non-positive `ttlMs` disables suppression.
|
|
347
|
+
*/
|
|
348
|
+
export function anyPendingAsyncDispatchWithin(ttlMs: number): boolean {
|
|
349
|
+
if (ttlMs <= 0) return false
|
|
350
|
+
const now = nowMs()
|
|
351
|
+
for (const s of stateByKey.values()) {
|
|
352
|
+
if (
|
|
353
|
+
s.pending === true &&
|
|
354
|
+
s.dispatchedAt != null &&
|
|
355
|
+
now - s.dispatchedAt < ttlMs
|
|
356
|
+
) {
|
|
357
|
+
return true
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return false
|
|
361
|
+
}
|
|
362
|
+
|
|
299
363
|
/**
|
|
300
364
|
* Clear pending-progress for a chat — reasons:
|
|
301
365
|
* 'inbound' — user sent a new message, they're re-engaged
|
|
@@ -119,7 +119,12 @@ export type SessionEvent =
|
|
|
119
119
|
// gate keys on it.
|
|
120
120
|
| { kind: 'text'; text: string; blockIndex: number; lastInMessage: boolean }
|
|
121
121
|
| { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string }
|
|
122
|
-
|
|
122
|
+
// `reason` is set ONLY by an internal gateway-synthesized turn_end (never by
|
|
123
|
+
// the JSONL projection). `answer-ready-quiescence` (PR A) marks the positive
|
|
124
|
+
// deterministic quiescence-flush signal, which — unlike the orphaned-reply
|
|
125
|
+
// backstop's bare `durationMs:-1` — deliberately bypasses the recently-
|
|
126
|
+
// streaming suppression guard (quiescence IS the "streaming settled" signal).
|
|
127
|
+
| { kind: 'turn_end'; durationMs: number; reason?: 'answer-ready-quiescence' }
|
|
123
128
|
// Multi-agent: sub-agent-scoped events. agentId is the sub-agent JSONL
|
|
124
129
|
// filename stem (e.g. "aac6f1…"). Routed through the same ingest path
|
|
125
130
|
// as parent events; the reducer fans them out to per-sub-agent state.
|
|
@@ -38,10 +38,34 @@ export interface SilentEndState {
|
|
|
38
38
|
threadId: number | null
|
|
39
39
|
/** Stable identifier for the in-flight turn (statusKey shape). */
|
|
40
40
|
turnKey: string
|
|
41
|
+
/**
|
|
42
|
+
* Per-turn nonce — `deriveTurnId`'s `${chatKey}#${messageId}` shape (#3228).
|
|
43
|
+
* Unlike `turnKey` (the STABLE `chatId:threadId` statusKey, identical across
|
|
44
|
+
* every turn on the same chat/thread), this is unique per inbound message.
|
|
45
|
+
* The Stop hook stamps it from the enqueue envelope's `message_id`; the
|
|
46
|
+
* gateway's `decideCapturedProseDelivery` requires it to match the LIVE
|
|
47
|
+
* turn's `turnId` before delivering `pendingText`, so a stale captured-prose
|
|
48
|
+
* record from a PRIOR turn on the same chat can never be misdelivered on a
|
|
49
|
+
* later turn (Finding 3). Optional — absent when no message_id was derivable
|
|
50
|
+
* (synthetic inbounds), in which case delivery falls back to the turnKey
|
|
51
|
+
* match alone.
|
|
52
|
+
*/
|
|
53
|
+
turnId?: string
|
|
41
54
|
/** Incremented each time the Stop hook blocks for this turn. */
|
|
42
55
|
retryCount: number
|
|
43
56
|
/** Wall-clock ms of last write. */
|
|
44
57
|
timestamp: number
|
|
58
|
+
/**
|
|
59
|
+
* Option A transcript-prose bridge. The substantive final-answer prose the
|
|
60
|
+
* model wrote as plain transcript text but never sent through the reply
|
|
61
|
+
* tool, as isolated by the Stop hook's transcript scan
|
|
62
|
+
* (`scanTurnForFinalReply` → `pendingText`). Present only on a hook-written
|
|
63
|
+
* state file for a turn that ended silently WITH deliverable prose; the
|
|
64
|
+
* gateway's own `writeSilentEndState` never sets it. The gateway reads it
|
|
65
|
+
* back to deliver the answer directly on the first silent-end. Optional —
|
|
66
|
+
* absent for the zero-prose (genuinely empty) silent-end.
|
|
67
|
+
*/
|
|
68
|
+
pendingText?: string
|
|
45
69
|
}
|
|
46
70
|
|
|
47
71
|
export interface SilentEndDeps {
|
|
@@ -256,6 +280,164 @@ export function clearSilentEndState(turnKey: string, deps?: SilentEndDeps): void
|
|
|
256
280
|
}
|
|
257
281
|
}
|
|
258
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Minimum length (chars, trimmed) of captured prose the gateway will deliver
|
|
285
|
+
* directly on a silent-end. Mirrors `FINAL_ANSWER_MIN_CHARS` in
|
|
286
|
+
* `hooks/silent-end-scan.mjs` and `isFinalAnswerReply`'s 200-char substantive
|
|
287
|
+
* backstop — the same bar used to recognise a real answer. A shorter trailing
|
|
288
|
+
* fragment is not a dropped answer and must not be re-materialised.
|
|
289
|
+
*/
|
|
290
|
+
export const CAPTURED_PROSE_MIN_CHARS = 200
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Result of the captured-prose delivery decision (Option A transcript-prose
|
|
294
|
+
* bridge). Pure, side-effect-free — the gateway consumes `deliver`/`text` and
|
|
295
|
+
* owns the actual send + dedup + obligation-close bookkeeping.
|
|
296
|
+
*/
|
|
297
|
+
export interface CapturedProseDecision {
|
|
298
|
+
/** True → the gateway should deliver `text` directly on this silent-end. */
|
|
299
|
+
deliver: boolean
|
|
300
|
+
/** The prose to deliver; present iff `deliver === true`. */
|
|
301
|
+
text?: string
|
|
302
|
+
/** Machine-readable reason (for logs / tests). */
|
|
303
|
+
reason:
|
|
304
|
+
| 'captured-prose'
|
|
305
|
+
| 'no-state'
|
|
306
|
+
| 'turnkey-mismatch'
|
|
307
|
+
| 'turnid-mismatch'
|
|
308
|
+
| 'no-substantive-prose'
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Decide whether the current silent-end turn has a substantive undelivered
|
|
313
|
+
* final answer that the gateway should deliver directly (Option A).
|
|
314
|
+
*
|
|
315
|
+
* Reads the same `silent-end-pending.json` the Stop hook wrote. Delivers ONLY
|
|
316
|
+
* when (a) the record belongs to THIS turn — BOTH the stable `turnKey` AND, when
|
|
317
|
+
* present, the per-turn `turnId` nonce must match (#3228 Finding 3: `turnKey`
|
|
318
|
+
* alone is `chatId:threadId` and identical across turns on the same chat, so it
|
|
319
|
+
* cannot by itself prove the record is this turn's rather than a stale
|
|
320
|
+
* carryover) — and (b) the persisted `pendingText` clears the substance floor.
|
|
321
|
+
* Otherwise returns `deliver:false` and the caller falls through to the existing
|
|
322
|
+
* re-prompt / represent safety nets unchanged.
|
|
323
|
+
*
|
|
324
|
+
* turnId matching is enforced ONLY when BOTH sides carry one. A record written
|
|
325
|
+
* without a nonce (synthetic inbound with no message_id) or a caller that has
|
|
326
|
+
* no live turnId falls back to the turnKey match alone — never suppress
|
|
327
|
+
* delivery on doubt, and never break the pre-nonce path.
|
|
328
|
+
*
|
|
329
|
+
* Extracted as a pure core (mirrors `decideTurnFlush` / `decideTurnEndGate`)
|
|
330
|
+
* so the gateway runs the exact code the regression tests exercise —
|
|
331
|
+
* `gateway.ts` is not importable in tests.
|
|
332
|
+
*/
|
|
333
|
+
export function decideCapturedProseDelivery(
|
|
334
|
+
args: { turnKey: string; turnId?: string | null; minChars?: number },
|
|
335
|
+
deps?: SilentEndDeps,
|
|
336
|
+
): CapturedProseDecision {
|
|
337
|
+
const minChars = args.minChars ?? CAPTURED_PROSE_MIN_CHARS
|
|
338
|
+
const state = readSilentEndState(deps)
|
|
339
|
+
if (state == null) return { deliver: false, reason: 'no-state' }
|
|
340
|
+
if (state.turnKey !== args.turnKey) return { deliver: false, reason: 'turnkey-mismatch' }
|
|
341
|
+
// Per-turn nonce guard (#3228 Finding 3). When the on-disk record was stamped
|
|
342
|
+
// with a `turnId` AND the caller passes the live turn's `turnId`, they MUST
|
|
343
|
+
// match — otherwise the record belongs to a different turn on the same chat.
|
|
344
|
+
if (
|
|
345
|
+
typeof state.turnId === 'string' &&
|
|
346
|
+
state.turnId !== '' &&
|
|
347
|
+
args.turnId != null &&
|
|
348
|
+
args.turnId !== '' &&
|
|
349
|
+
state.turnId !== args.turnId
|
|
350
|
+
) {
|
|
351
|
+
return { deliver: false, reason: 'turnid-mismatch' }
|
|
352
|
+
}
|
|
353
|
+
const text = typeof state.pendingText === 'string' ? state.pendingText : ''
|
|
354
|
+
if (text.trim().length < minChars) return { deliver: false, reason: 'no-substantive-prose' }
|
|
355
|
+
return { deliver: true, text, reason: 'captured-prose' }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Outcome of a captured-prose send attempt (Option A transcript-prose bridge).
|
|
360
|
+
* - `sent` — the answer was delivered fresh this call.
|
|
361
|
+
* - `skipped-dedup`— the exact answer already went out (dedup hit); nothing
|
|
362
|
+
* new was sent, but the answer IS with the user.
|
|
363
|
+
* - `failed` — the send threw; the answer did NOT reach the user.
|
|
364
|
+
*/
|
|
365
|
+
export type CapturedProseSendOutcome = 'sent' | 'skipped-dedup' | 'failed'
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The bookkeeping effects the gateway applies after a captured-prose send
|
|
369
|
+
* attempt, injected so the settlement decision is a pure, testable core
|
|
370
|
+
* (`gateway.ts` itself is not importable in tests).
|
|
371
|
+
*/
|
|
372
|
+
export interface CapturedProseSettlementEffects {
|
|
373
|
+
/** Close the ending turn's delivery obligation. */
|
|
374
|
+
closeObligation: () => void
|
|
375
|
+
/** Clear the silent-end state file for this turn. */
|
|
376
|
+
clearState: () => void
|
|
377
|
+
/**
|
|
378
|
+
* Arm the deterministic Stop-hook re-prompt (recordUndeliveredTurnEnd) so
|
|
379
|
+
* the answer is recoverable — the send-failure safety net. Returns the
|
|
380
|
+
* `{ exhausted }` verdict from `recordUndeliveredTurnEnd`: `true` when the
|
|
381
|
+
* re-prompt budget was ALREADY spent (retryCount >= SILENT_END_MAX_RETRIES)
|
|
382
|
+
* on the attempt that failed, so the re-prompt can no longer recover the
|
|
383
|
+
* answer and the caller must deliver a user-facing fallback instead (#3228
|
|
384
|
+
* exhaustion-boundary gap).
|
|
385
|
+
*/
|
|
386
|
+
recordUndelivered: () => { exhausted: boolean }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Result of `settleCapturedProseDelivery`. `exhausted` is meaningful only on
|
|
391
|
+
* the `failed` outcome — `true` means the Stop-hook re-prompt net is spent, so
|
|
392
|
+
* the caller must fire the user-facing fallback (a plain-text retry of the
|
|
393
|
+
* captured prose, then the generic apology) so the turn never goes silent.
|
|
394
|
+
* Always `false` for `sent` / `skipped-dedup` (the answer is already with the
|
|
395
|
+
* user; no fallback).
|
|
396
|
+
*/
|
|
397
|
+
export interface CapturedProseSettlementResult {
|
|
398
|
+
exhausted: boolean
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Apply the correct bookkeeping after a captured-prose send attempt (#3228
|
|
403
|
+
* Finding 1). This is the deterministic core the gateway's
|
|
404
|
+
* `deliverCapturedProse` routes ALL three of its settlement points through.
|
|
405
|
+
*
|
|
406
|
+
* - `sent` / `skipped-dedup` → the answer is with the user, so CLOSE the
|
|
407
|
+
* obligation and CLEAR the silent-end state; the represent and the
|
|
408
|
+
* exhausted fallback must not re-fire for the same answer.
|
|
409
|
+
*
|
|
410
|
+
* - `failed` → the send threw, so the answer is NOT with the user. We must
|
|
411
|
+
* NOT close the obligation or clear the state; instead we ARM the Stop-hook
|
|
412
|
+
* re-prompt net (`recordUndelivered`). This is the regression fix: the
|
|
413
|
+
* shared turn-end teardown (`decideObligationTurnEnd`) already CLOSES the
|
|
414
|
+
* obligation whenever `replyCalled === true` (the interim-ack case that
|
|
415
|
+
* reaches captured-prose delivery), so "just leave the obligation open" is
|
|
416
|
+
* NOT a real net — without recordUndelivered a thrown send permanently
|
|
417
|
+
* loses the answer, strictly worse than the pre-bridge behaviour.
|
|
418
|
+
*
|
|
419
|
+
* The `{ exhausted }` verdict from `recordUndelivered` is threaded back out
|
|
420
|
+
* to the caller (#3228 exhaustion-boundary gap): when the failed send
|
|
421
|
+
* happened on the attempt where the re-prompt budget was ALREADY spent,
|
|
422
|
+
* `recordUndeliveredTurnEnd` clears the state and reports `exhausted:true` —
|
|
423
|
+
* the Stop-hook re-prompt can no longer recover the answer, so the caller
|
|
424
|
+
* MUST deliver a user-facing fallback (mirroring the non-captured
|
|
425
|
+
* exhausted path) or the user gets NEITHER the answer NOR the apology.
|
|
426
|
+
*
|
|
427
|
+
* Pure — no IO, no module state; the caller injects the effects.
|
|
428
|
+
*/
|
|
429
|
+
export function settleCapturedProseDelivery(
|
|
430
|
+
outcome: CapturedProseSendOutcome,
|
|
431
|
+
effects: CapturedProseSettlementEffects,
|
|
432
|
+
): CapturedProseSettlementResult {
|
|
433
|
+
if (outcome === 'failed') {
|
|
434
|
+
return { exhausted: effects.recordUndelivered().exhausted }
|
|
435
|
+
}
|
|
436
|
+
effects.closeObligation()
|
|
437
|
+
effects.clearState()
|
|
438
|
+
return { exhausted: false }
|
|
439
|
+
}
|
|
440
|
+
|
|
259
441
|
/**
|
|
260
442
|
* Read the state file (for tests + diagnostics). Returns null when
|
|
261
443
|
* absent or unparsable.
|