switchroom 0.18.19 → 0.18.21
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 +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- 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 +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- 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/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- 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 +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- 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/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- 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/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- 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 +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -67,6 +67,20 @@ export interface IdleClearState {
|
|
|
67
67
|
alreadyCleared: boolean;
|
|
68
68
|
/** A turn is in flight — never clear mid-turn. */
|
|
69
69
|
turnInFlight: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* A background sub-agent (Agent/Task dispatched, turn ended before it
|
|
72
|
+
* returned) is still in flight AND within its suppression TTL (#3117). The
|
|
73
|
+
* main-turn gate (`turnInFlight`) is blind to detached background work: a
|
|
74
|
+
* worker that is alive-but-silent for a full idle window (one long tool call,
|
|
75
|
+
* no stream events to re-stamp the activity clock) would otherwise be cleared,
|
|
76
|
+
* destroying the context its handback needs. Suppress the clear while this is
|
|
77
|
+
* true. It is TTL-bounded by the caller so a leaked/never-cleared pending flag
|
|
78
|
+
* cannot disable idle-clear forever — past the TTL the caller passes false and
|
|
79
|
+
* self-healing resumes. Optional/undefined ⇒ treated as false (no background
|
|
80
|
+
* work), which preserves the pre-#3117 behaviour for callers that don't wire
|
|
81
|
+
* it (e.g. the pure-decider unit tests that exercise only the wall clock).
|
|
82
|
+
*/
|
|
83
|
+
backgroundWorkInFlight?: boolean;
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
export interface IdleClearDecision {
|
|
@@ -91,6 +105,14 @@ export function decideIdleClear(
|
|
|
91
105
|
): IdleClearDecision {
|
|
92
106
|
if (state.idleClearMs <= 0) return { clear: false }; // disabled
|
|
93
107
|
if (state.turnInFlight) return { clear: false }; // never mid-turn
|
|
108
|
+
// #3117 — a detached background sub-agent within its suppression TTL keeps the
|
|
109
|
+
// session alive even when the main-turn gate is open and the activity clock
|
|
110
|
+
// has gone cold (a silent long-running worker emits no stream events). The
|
|
111
|
+
// caller (gateway) bounds this by a TTL so a stuck pending flag can't disable
|
|
112
|
+
// idle-clear forever; here we simply honour it. Because #3116's write-time
|
|
113
|
+
// re-eval re-runs this whole function against the live state, this suppression
|
|
114
|
+
// is enforced at BOTH decision time and /clear write time for free.
|
|
115
|
+
if (state.backgroundWorkInFlight) return { clear: false };
|
|
94
116
|
if (state.alreadyCleared) return { clear: false }; // once per idle period
|
|
95
117
|
// Measured from the LAST thing that happened — activity or a turn ending —
|
|
96
118
|
// never from a turn's start. A turn that ran longer than the window ends with
|
|
@@ -133,6 +155,154 @@ export function classifyIdleEvent(
|
|
|
133
155
|
return { activity: !synthetic, turnEnded };
|
|
134
156
|
}
|
|
135
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Inputs the gateway feeds into a decision that live OUTSIDE the idle clocks:
|
|
160
|
+
* the resolved window, the main-turn gate, and the TTL-bounded background-work
|
|
161
|
+
* suppressor. Kept separate from `IdleClearState` (the clock state the tracker
|
|
162
|
+
* owns) so the tracker's decide surface is exactly "here is the world right
|
|
163
|
+
* now" and the tracker supplies its own clocks + latch.
|
|
164
|
+
*/
|
|
165
|
+
export interface IdleDecisionInputs {
|
|
166
|
+
/** Resolved idle window in ms (env → per-agent config → 3h default). <=0 disables. */
|
|
167
|
+
idleClearMs: number;
|
|
168
|
+
/** A turn is in flight (the same gate proactive-compact uses). Never clear mid-turn. */
|
|
169
|
+
turnInFlight: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* A detached background sub-agent is in flight AND within its suppression TTL
|
|
172
|
+
* (#3117). Optional; undefined ⇒ false (pre-#3117 behaviour). The caller bounds
|
|
173
|
+
* this by a TTL so a leaked pending flag can't disable idle-clear forever.
|
|
174
|
+
*/
|
|
175
|
+
backgroundWorkInFlight?: boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The gateway's idle bookkeeping as ONE stateful object (#3115).
|
|
180
|
+
*
|
|
181
|
+
* Before #3115 this lived as four bare module-level `let`s in gateway.ts
|
|
182
|
+
* (`lastIdleActivityAt`, `lastIdleTurnEndAt`, `idleAutoCleared`,
|
|
183
|
+
* `idleClearDispatching`) plus the inline `markIdleActivity` / `markIdleTurnEnd`
|
|
184
|
+
* / `maybeIdleClear` stamp+decide logic scattered across `handleSessionEvent`,
|
|
185
|
+
* `onInjectInbound`, and `handleInbound`. Nothing imported gateway.ts (~30k
|
|
186
|
+
* lines, import-time side effects), so the WIRING — that every session event
|
|
187
|
+
* actually stamps the clocks — was untestable and the test mirrored the logic
|
|
188
|
+
* in a local `IdleModel` instead of exercising it. Deleting the real stamp
|
|
189
|
+
* block left every test green while re-introducing the #3113 "productive work
|
|
190
|
+
* gets wiped" bug.
|
|
191
|
+
*
|
|
192
|
+
* Extracting the state here makes the real object importable and drivable: a
|
|
193
|
+
* deleted stamp call now fails a test because the test holds the SAME object
|
|
194
|
+
* the gateway holds. The gateway keeps ownership of its environment concerns
|
|
195
|
+
* (resolving the window, `turnInFlightForGate()`, the pending-dispatch probe)
|
|
196
|
+
* and feeds them in via `IdleDecisionInputs`; the tracker owns only the clocks
|
|
197
|
+
* and the fire-once / re-entrancy latches.
|
|
198
|
+
*
|
|
199
|
+
* The clock is injected per-call (`now`) exactly like the pure `decideIdleClear`
|
|
200
|
+
* / `classifyIdleEvent` it delegates to — the gateway passes `Date.now()`, the
|
|
201
|
+
* tests pass a deterministic virtual clock.
|
|
202
|
+
*/
|
|
203
|
+
export class IdleTracker {
|
|
204
|
+
/** Epoch ms of the last activity (inbound, cron fire, or any genuine session event). */
|
|
205
|
+
private lastActivityAt: number;
|
|
206
|
+
/** Epoch ms a turn last ended (null = none this process). */
|
|
207
|
+
private lastTurnEndedAt: number | null = null;
|
|
208
|
+
/** Already auto-cleared since the last activity? Fire-once-per-idle-period latch. */
|
|
209
|
+
private alreadyCleared = false;
|
|
210
|
+
/** A /clear dispatch is in flight — re-entrancy guard so a tick can't double-fire. */
|
|
211
|
+
private dispatching = false;
|
|
212
|
+
|
|
213
|
+
constructor(startedAt: number) {
|
|
214
|
+
this.lastActivityAt = startedAt;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Epoch ms of the last activity — for wiring assertions (was `lastIdleActivityAt`). */
|
|
218
|
+
get activityAt(): number {
|
|
219
|
+
return this.lastActivityAt;
|
|
220
|
+
}
|
|
221
|
+
/** Epoch ms a turn last ended, or null — for wiring assertions. */
|
|
222
|
+
get turnEndedAt(): number | null {
|
|
223
|
+
return this.lastTurnEndedAt;
|
|
224
|
+
}
|
|
225
|
+
/** The fire-once latch — true once cleared this idle period, until re-armed. */
|
|
226
|
+
get cleared(): boolean {
|
|
227
|
+
return this.alreadyCleared;
|
|
228
|
+
}
|
|
229
|
+
/** A /clear dispatch is currently in flight. */
|
|
230
|
+
get isDispatching(): boolean {
|
|
231
|
+
return this.dispatching;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Reset the idle timer + re-arm auto-clear. Call on ANY activity — inbound,
|
|
236
|
+
* a genuine cron fire, or (via `noteEvent`) any claude session-stream event.
|
|
237
|
+
* Was the gateway's `markIdleActivity()`.
|
|
238
|
+
*/
|
|
239
|
+
noteInbound(now: number): void {
|
|
240
|
+
this.lastActivityAt = now;
|
|
241
|
+
this.alreadyCleared = false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Feed a claude session-stream event through the classifier and stamp the
|
|
246
|
+
* clocks. Genuine activity re-arms the auto-clear; a turn ending additionally
|
|
247
|
+
* stamps the turn-end clock. Was the gateway's inline
|
|
248
|
+
* `classifyIdleEvent` + `markIdleActivity`/`markIdleTurnEnd` block in
|
|
249
|
+
* `handleSessionEvent` — the load-bearing wiring #3115 makes testable.
|
|
250
|
+
*/
|
|
251
|
+
noteEvent(kind: string, now: number, durationMs?: number): void {
|
|
252
|
+
const signal = classifyIdleEvent(kind, durationMs);
|
|
253
|
+
if (signal.activity) this.noteInbound(now);
|
|
254
|
+
if (signal.turnEnded) this.lastTurnEndedAt = now;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Snapshot the tracker's clocks into an `IdleClearState` for a decision. */
|
|
258
|
+
private stateFor(inputs: IdleDecisionInputs, alreadyCleared: boolean): IdleClearState {
|
|
259
|
+
return {
|
|
260
|
+
lastActivityAt: this.lastActivityAt,
|
|
261
|
+
lastTurnEndedAt: this.lastTurnEndedAt,
|
|
262
|
+
idleClearMs: inputs.idleClearMs,
|
|
263
|
+
alreadyCleared,
|
|
264
|
+
turnInFlight: inputs.turnInFlight,
|
|
265
|
+
backgroundWorkInFlight: inputs.backgroundWorkInFlight,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Decide whether to auto-clear right now, honouring the fire-once latch.
|
|
271
|
+
* Delegates to the pure `decideIdleClear` against the live clocks + inputs.
|
|
272
|
+
*/
|
|
273
|
+
decide(now: number, inputs: IdleDecisionInputs): IdleClearDecision {
|
|
274
|
+
return decideIdleClear(this.stateFor(inputs, this.alreadyCleared), now);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Re-evaluate idleness at /clear WRITE time (#3116 precondition), IGNORING the
|
|
279
|
+
* fire-once latch. `maybeIdleClear` latches `alreadyCleared=true` before the
|
|
280
|
+
* async inject for re-entrancy, so the write-time re-eval must judge idleness
|
|
281
|
+
* on the live clocks + turn/background gates only — any activity that arrived
|
|
282
|
+
* in the check-to-send gap must still suppress the buffered /clear.
|
|
283
|
+
*/
|
|
284
|
+
decideIgnoringLatch(now: number, inputs: IdleDecisionInputs): IdleClearDecision {
|
|
285
|
+
return decideIdleClear(this.stateFor(inputs, false), now);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Latch the fire-once guard: a /clear has been decided for this idle period. */
|
|
289
|
+
markClearFired(): void {
|
|
290
|
+
this.alreadyCleared = true;
|
|
291
|
+
}
|
|
292
|
+
/** Re-arm after a suppressed dispatch so the next idle period can clear again. */
|
|
293
|
+
reArm(): void {
|
|
294
|
+
this.alreadyCleared = false;
|
|
295
|
+
}
|
|
296
|
+
/** Mark a /clear dispatch as begun (re-entrancy latch). */
|
|
297
|
+
beginDispatch(): void {
|
|
298
|
+
this.dispatching = true;
|
|
299
|
+
}
|
|
300
|
+
/** Mark a /clear dispatch as finished (re-entrancy latch released). */
|
|
301
|
+
endDispatch(): void {
|
|
302
|
+
this.dispatching = false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
136
306
|
/**
|
|
137
307
|
* Parse a `^\d+[smh]$` duration (the SessionSchema format, e.g. "3h", "30m",
|
|
138
308
|
* "7200s") to ms. Returns null on a malformed string so the caller can fall
|
|
@@ -86,6 +86,17 @@ function shapeReply(
|
|
|
86
86
|
return { body: verbHtml, accent: 'done' }
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// outcome === 'skipped' (#3116): an opt-in write-time precondition aborted
|
|
90
|
+
// the send before any keys were sent. No inject-map caller opts in today, so
|
|
91
|
+
// this is unreachable via this surface — but handle it explicitly so a future
|
|
92
|
+
// precondition caller gets an honest "skipped" ack instead of a failure card.
|
|
93
|
+
if (result.outcome === 'skipped') {
|
|
94
|
+
return {
|
|
95
|
+
body: `${verbHtml} — skipped (precondition not met at send time)`,
|
|
96
|
+
accent: 'issue',
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
// outcome === 'failed'
|
|
90
101
|
const code = result.errorCode ?? 'tmux_failed'
|
|
91
102
|
const msg = result.errorMessage ?? 'unknown error'
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
normalizeParagraphBreaks,
|
|
28
28
|
normalizePunctuation,
|
|
29
29
|
stripExcessBold,
|
|
30
|
+
addParagraphSpacers,
|
|
30
31
|
splitMarkdownChunks,
|
|
31
32
|
hardSliceToCap,
|
|
32
33
|
RICH_MESSAGE_MAX_CHARS,
|
|
@@ -87,16 +88,15 @@ export function normalizeOutboundBody(
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
|
-
* Effective-text
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* kept for callers) so a future rich-only transform has a home. Pure.
|
|
91
|
+
* Effective-text spacing (#2669 rich-message regression fix, restored after the
|
|
92
|
+
* #3208 F1 misfire). The Bot API 10.1 rich GFM renderer (and the in-repo IR
|
|
93
|
+
* renderer that feeds it) renders a prose `\n\n` gap TIGHT, so paragraphs render
|
|
94
|
+
* jammed together. Inject a visible U+00A0 blank-line spacer into each block
|
|
95
|
+
* gap on the rich path only (idempotent — see addParagraphSpacers); the literal
|
|
96
|
+
* (`format:'text'`) path stays byte-exact. Pure.
|
|
97
97
|
*/
|
|
98
|
-
export function computeEffectiveText(text: string,
|
|
99
|
-
return text
|
|
98
|
+
export function computeEffectiveText(text: string, literalText: boolean): string {
|
|
99
|
+
return literalText ? text : addParagraphSpacers(text)
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
@@ -176,12 +176,20 @@ export interface SubagentProgressDecisionInput {
|
|
|
176
176
|
* passes it in; the decision returns the new bucket idx on
|
|
177
177
|
* `deliver: true` so the caller can update its tracker. */
|
|
178
178
|
lastBucketIdx: number | null
|
|
179
|
+
/** #3233: true for a growth-independent SKELETON liveness cue (empty
|
|
180
|
+
* `latestSummary`, no step content). It exists ONLY to first-paint /
|
|
181
|
+
* keep-alive the in-message worker-feed row; the legacy bucket relay would
|
|
182
|
+
* turn it into a synthesized "still working" inbound with no content — a
|
|
183
|
+
* blank card. Suppressed deterministically here so the worker-feed-DISABLED
|
|
184
|
+
* path degrades to a no-op rather than a blank envelope. */
|
|
185
|
+
skeleton?: boolean
|
|
179
186
|
/** Deterministic clock for tests. */
|
|
180
187
|
nowMs?: number
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
export type SubagentProgressSkipReason =
|
|
184
191
|
| 'env-disabled'
|
|
192
|
+
| 'skeleton-liveness'
|
|
185
193
|
| 'foreground'
|
|
186
194
|
| 'no-chat'
|
|
187
195
|
| 'bucket-already-fired'
|
|
@@ -199,6 +207,8 @@ export type SubagentProgressDecision =
|
|
|
199
207
|
*
|
|
200
208
|
* Gates, in order:
|
|
201
209
|
* 1. kill-switch — `SWITCHROOM_DISABLE_SUBAGENT_PROGRESS=1` disables.
|
|
210
|
+
* 1b. skeleton-liveness (#3233) — a contentless skeleton cue is never
|
|
211
|
+
* relayed as a synthesized inbound (worker-feed row only).
|
|
202
212
|
* 2. foreground — foreground sub-agents stream natively.
|
|
203
213
|
* 3. no-chat — nowhere to deliver.
|
|
204
214
|
* 4. missing-jsonl-id — the dedup key. Without it we'd lose
|
|
@@ -233,6 +243,13 @@ export function decideSubagentProgress(
|
|
|
233
243
|
if (isEnvFlagOn(input.disableEnvValue)) {
|
|
234
244
|
return { deliver: false, reason: 'env-disabled' }
|
|
235
245
|
}
|
|
246
|
+
// #3233: a skeleton liveness cue carries no step content — never relay it as
|
|
247
|
+
// a synthesized progress inbound (that would be a blank card). Its whole job
|
|
248
|
+
// is the in-message worker-feed row; when that surface is off, degrade to a
|
|
249
|
+
// no-op. Checked before bucketing so it can never advance the bucket tracker.
|
|
250
|
+
if (input.skeleton === true) {
|
|
251
|
+
return { deliver: false, reason: 'skeleton-liveness' }
|
|
252
|
+
}
|
|
236
253
|
if (!input.isBackground) {
|
|
237
254
|
return { deliver: false, reason: 'foreground' }
|
|
238
255
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Honest turn-record status derivation (PR B — "send-honesty" fix).
|
|
3
|
+
*
|
|
4
|
+
* `emitTurnRecord` historically wrote `status: finalAnswerDelivered ? 'complete'
|
|
5
|
+
* : 'no_reply'`. On the turn-flush / backstop paths `finalAnswerDelivered` is set
|
|
6
|
+
* BEFORE the async send actually runs, so a send that then throws (e.g.
|
|
7
|
+
* `FLOOD_WAIT_ACTIVE` during a flood ban) was still recorded `complete` — a turn
|
|
8
|
+
* where the user received NOTHING logged as delivered.
|
|
9
|
+
*
|
|
10
|
+
* The fix threads a per-turn `deliveryOutcome`, set only AFTER the send resolves,
|
|
11
|
+
* and derives the recorded status from that real outcome. These helpers are pure
|
|
12
|
+
* and injectable so the outcome ↔ status mapping is unit-testable without the
|
|
13
|
+
* 30k-line gateway module (mirrors `turns-jsonl-rotate.ts`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The resolved fate of a backstop (turn-flush) send, stamped on the turn only
|
|
18
|
+
* once the send has actually resolved:
|
|
19
|
+
* - `delivered` — every chunk reached Telegram.
|
|
20
|
+
* - `failed` — the send threw, OR a partial multi-chunk delivery (chunk 1 ok,
|
|
21
|
+
* a later chunk failed): the answer did not fully reach the user.
|
|
22
|
+
* - `suppressed` — the flush short-circuited because the reply tool already
|
|
23
|
+
* delivered this turn's answer (the 2s recent-outbound guard).
|
|
24
|
+
*/
|
|
25
|
+
export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
|
|
26
|
+
|
|
27
|
+
/** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
|
|
28
|
+
export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Derive the recorded turn status from the turn's flags.
|
|
32
|
+
*
|
|
33
|
+
* `deliveryOutcome` (when present) is authoritative — it reflects the REAL send
|
|
34
|
+
* resolution on the backstop paths. When it is absent (the synchronous
|
|
35
|
+
* reply-tool tail, silent-marker, and genuine no-reply paths) we fall back to the
|
|
36
|
+
* legacy `finalAnswerDelivered` reading, preserving existing semantics exactly.
|
|
37
|
+
*
|
|
38
|
+
* delivered → complete
|
|
39
|
+
* failed / partial → send_failed (NOT complete, NOT silently no_reply)
|
|
40
|
+
* suppressed → complete iff the reply path delivered, else no_reply
|
|
41
|
+
* undefined (legacy) → complete iff finalAnswerDelivered, else no_reply
|
|
42
|
+
* (fail-safe: an IIFE that dies before stamping an
|
|
43
|
+
* outcome never fabricates `complete`)
|
|
44
|
+
*/
|
|
45
|
+
export function computeTurnStatus(turn: {
|
|
46
|
+
finalAnswerDelivered: boolean
|
|
47
|
+
deliveryOutcome?: DeliveryOutcome
|
|
48
|
+
}): TurnStatus {
|
|
49
|
+
switch (turn.deliveryOutcome) {
|
|
50
|
+
case 'failed':
|
|
51
|
+
return 'send_failed'
|
|
52
|
+
case 'delivered':
|
|
53
|
+
return 'complete'
|
|
54
|
+
case 'suppressed':
|
|
55
|
+
return turn.finalAnswerDelivered ? 'complete' : 'no_reply'
|
|
56
|
+
default:
|
|
57
|
+
return turn.finalAnswerDelivered ? 'complete' : 'no_reply'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a backstop send's outcome from what actually happened on the wire.
|
|
63
|
+
* A throw is a failure; a no-throw send that delivered fewer chunks than it
|
|
64
|
+
* split into is a partial delivery and is treated as `failed` (the user did not
|
|
65
|
+
* receive the whole answer) — never silently `delivered`/`complete`.
|
|
66
|
+
*/
|
|
67
|
+
export function backstopSendOutcome(args: {
|
|
68
|
+
threw: boolean
|
|
69
|
+
sentCount: number
|
|
70
|
+
chunkCount: number
|
|
71
|
+
}): DeliveryOutcome {
|
|
72
|
+
if (args.threw) return 'failed'
|
|
73
|
+
// Defense (Fix 5): a "send" that split into zero chunks delivered nothing —
|
|
74
|
+
// do NOT let sentCount(0) < chunkCount(0) === false slip through to
|
|
75
|
+
// 'delivered'/'complete' (which would trip the silent-no-op-candidate
|
|
76
|
+
// detector on a tools:0 turn). Nothing sent → failed.
|
|
77
|
+
if (args.chunkCount === 0) return 'failed'
|
|
78
|
+
if (args.sentCount < args.chunkCount) return 'failed'
|
|
79
|
+
return 'delivered'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Stamp a turn's `deliveryOutcome` from a resolved backstop send. This is the
|
|
84
|
+
* exact accounting the turn-flush IIFE's `finally` performs — factored here so
|
|
85
|
+
* the gateway and the tests run the SAME mapping (a real send that throws or
|
|
86
|
+
* partially delivers must produce `send_failed`, never `complete`). Mutates and
|
|
87
|
+
* returns the resolved outcome.
|
|
88
|
+
*/
|
|
89
|
+
export function finalizeBackstopSend(
|
|
90
|
+
turn: { deliveryOutcome?: DeliveryOutcome },
|
|
91
|
+
send: { threw: boolean; sentCount: number; chunkCount: number },
|
|
92
|
+
): DeliveryOutcome {
|
|
93
|
+
const outcome = backstopSendOutcome(send)
|
|
94
|
+
turn.deliveryOutcome = outcome
|
|
95
|
+
return outcome
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The parsed shape of one turns.jsonl row. */
|
|
99
|
+
export interface TurnRecordRow {
|
|
100
|
+
ts: number
|
|
101
|
+
agent: string
|
|
102
|
+
duration_ms: number
|
|
103
|
+
tools: number
|
|
104
|
+
status: TurnStatus
|
|
105
|
+
turn_id: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the turns.jsonl row for a turn. The single source of truth for the
|
|
110
|
+
* `status` field — `emitTurnRecord` serializes exactly this, so a test that
|
|
111
|
+
* asserts on `buildTurnRecord(...).status` is asserting the value the gateway
|
|
112
|
+
* actually writes (proving `emitTurnRecord` derives status from the resolved
|
|
113
|
+
* `deliveryOutcome` via `computeTurnStatus`, not the speculative flag).
|
|
114
|
+
*/
|
|
115
|
+
export function buildTurnRecord(
|
|
116
|
+
turn: {
|
|
117
|
+
agent: string
|
|
118
|
+
startedAt: number
|
|
119
|
+
toolCallCount: number
|
|
120
|
+
turnId: string
|
|
121
|
+
finalAnswerDelivered: boolean
|
|
122
|
+
deliveryOutcome?: DeliveryOutcome
|
|
123
|
+
},
|
|
124
|
+
endedAt: number,
|
|
125
|
+
): TurnRecordRow {
|
|
126
|
+
return {
|
|
127
|
+
ts: Math.floor(endedAt / 1000),
|
|
128
|
+
agent: turn.agent,
|
|
129
|
+
duration_ms: turn.startedAt > 0 ? endedAt - turn.startedAt : 0,
|
|
130
|
+
tools: turn.toolCallCount ?? 0,
|
|
131
|
+
status: computeTurnStatus(turn),
|
|
132
|
+
turn_id: turn.turnId,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -172,6 +172,29 @@ function main() {
|
|
|
172
172
|
if (decision.threadId != null) {
|
|
173
173
|
nextState.threadId = decision.threadId
|
|
174
174
|
}
|
|
175
|
+
// Per-turn nonce (Finding 3, #3228). The gateway requires this to match
|
|
176
|
+
// the live turn's `turnId` before delivering `pendingText`, so a stale
|
|
177
|
+
// record left over from a prior turn on the same chat/thread can never
|
|
178
|
+
// deliver a previous turn's answer on a later one. Explicitly drop a
|
|
179
|
+
// carried-over `turnId` from the spread `...state` when THIS turn has no
|
|
180
|
+
// derivable nonce, so an old value never lingers.
|
|
181
|
+
if (decision.turnId) nextState.turnId = decision.turnId
|
|
182
|
+
else delete nextState.turnId
|
|
183
|
+
} else {
|
|
184
|
+
delete nextState.turnId
|
|
185
|
+
}
|
|
186
|
+
// Option A transcript-prose bridge: when the scan isolated a substantive
|
|
187
|
+
// final answer the model wrote as plain text but never sent through the
|
|
188
|
+
// reply tool, persist it so the gateway's turn-end path can deliver it
|
|
189
|
+
// directly on the first silent-end (instead of relying on this hook's
|
|
190
|
+
// re-prompt / the obligation represent to eventually recover it). The
|
|
191
|
+
// gateway reads this field back out of the same state file. Explicitly
|
|
192
|
+
// clear a stale carryover value from a prior turn's spread `...state` when
|
|
193
|
+
// THIS turn has no deliverable prose, so an old answer is never re-sent.
|
|
194
|
+
if (typeof decision.pendingText === 'string' && decision.pendingText.length > 0) {
|
|
195
|
+
nextState.pendingText = decision.pendingText
|
|
196
|
+
} else {
|
|
197
|
+
delete nextState.pendingText
|
|
175
198
|
}
|
|
176
199
|
try {
|
|
177
200
|
writeFileSync(statePath, JSON.stringify(nextState), 'utf8')
|
|
@@ -123,14 +123,25 @@ export function isFinalAnswerReply({ text, disableNotification, done }) {
|
|
|
123
123
|
* @returns {{ chatId: string | null, threadId: number | null }}
|
|
124
124
|
*/
|
|
125
125
|
function parseChannelEnvelope(content) {
|
|
126
|
-
if (typeof content !== 'string')
|
|
126
|
+
if (typeof content !== 'string') {
|
|
127
|
+
return { chatId: null, threadId: null, messageId: null, source: null }
|
|
128
|
+
}
|
|
127
129
|
const chatMatch = content.match(/chat_id="([^"]+)"/)
|
|
128
130
|
const threadMatch = content.match(/message_thread_id="([^"]+)"/)
|
|
131
|
+
// LEFT-ANCHOR the message_id match on an attribute boundary (start-of-string
|
|
132
|
+
// or a whitespace/quote before the name) so it matches ONLY the real
|
|
133
|
+
// `message_id` attribute — never a same-suffix sibling like
|
|
134
|
+
// `target_message_id`, `reply_to_message_id`, or `original_message_id`.
|
|
135
|
+
// Byte-identical to session-tail.ts `parseChannelMeta`'s `grab('message_id')`
|
|
136
|
+
// so the turnId the hook derives here matches the gateway's `deriveTurnId`
|
|
137
|
+
// exactly (Finding 3, #3228).
|
|
138
|
+
const msgMatch = content.match(/(?:^|[\s"'])message_id="([^"]+)"/)
|
|
129
139
|
const sourceMatch = content.match(/<channel[^>]*\bsource="([^"]+)"/)
|
|
130
140
|
const threadRaw = threadMatch ? Number(threadMatch[1]) : NaN
|
|
131
141
|
return {
|
|
132
142
|
chatId: chatMatch ? chatMatch[1] : null,
|
|
133
143
|
threadId: Number.isFinite(threadRaw) && threadRaw !== 0 ? threadRaw : null,
|
|
144
|
+
messageId: msgMatch ? msgMatch[1] : null,
|
|
134
145
|
source: sourceMatch ? sourceMatch[1] : null,
|
|
135
146
|
}
|
|
136
147
|
}
|
|
@@ -148,6 +159,27 @@ function buildTurnKey(chatId, threadId) {
|
|
|
148
159
|
return `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
|
|
149
160
|
}
|
|
150
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Build the per-turn nonce the gateway stamps as `CurrentTurn.turnId`
|
|
164
|
+
* (`gateway.ts deriveTurnId` → `${chatKey(chatId, threadId)}#${messageId}`).
|
|
165
|
+
* Unlike `buildTurnKey` (the STABLE `chatId:threadId` statusKey, shared by
|
|
166
|
+
* every turn on the same chat/thread), this is unique per inbound message —
|
|
167
|
+
* so a stale captured-prose record from a PRIOR turn on the same chat can
|
|
168
|
+
* never be misdelivered on a LATER turn (Finding 3, #3228). Returns null when
|
|
169
|
+
* the enqueue envelope carries no usable message_id (synthetic inbounds); the
|
|
170
|
+
* gateway then falls back to the turnKey-only match, unchanged.
|
|
171
|
+
*
|
|
172
|
+
* @param {string | null} chatId
|
|
173
|
+
* @param {number | null} threadId
|
|
174
|
+
* @param {string | null} messageId
|
|
175
|
+
* @returns {string | null}
|
|
176
|
+
*/
|
|
177
|
+
function buildTurnId(chatId, threadId, messageId) {
|
|
178
|
+
if (chatId == null) return null
|
|
179
|
+
if (messageId == null || messageId === '' || String(messageId) === '0') return null
|
|
180
|
+
return `${buildTurnKey(chatId, threadId)}#${messageId}`
|
|
181
|
+
}
|
|
182
|
+
|
|
151
183
|
/**
|
|
152
184
|
* Build the `{ decided: 'block', ... }` result shape, populating
|
|
153
185
|
* `turnKey`/`chatId`/`threadId` from the enqueue envelope when
|
|
@@ -155,13 +187,27 @@ function buildTurnKey(chatId, threadId) {
|
|
|
155
187
|
*
|
|
156
188
|
* @param {ReturnType<typeof parseChannelEnvelope>} envelope
|
|
157
189
|
* @param {string} reason
|
|
190
|
+
* @param {string} [pendingText] The substantive undelivered final-answer
|
|
191
|
+
* prose the model wrote as plain transcript text but never sent through a
|
|
192
|
+
* reply tool (Option A transcript-prose bridge). Only populated when it
|
|
193
|
+
* clears the substance floor, so the gateway never re-delivers a short
|
|
194
|
+
* trailing pleasantry. Omitted entirely otherwise.
|
|
158
195
|
*/
|
|
159
|
-
function buildBlockResult(envelope, reason) {
|
|
196
|
+
function buildBlockResult(envelope, reason, pendingText) {
|
|
160
197
|
const block = { decided: 'block', reason }
|
|
161
198
|
if (envelope.chatId) {
|
|
162
199
|
block.chatId = envelope.chatId
|
|
163
200
|
block.threadId = envelope.threadId
|
|
164
201
|
block.turnKey = buildTurnKey(envelope.chatId, envelope.threadId)
|
|
202
|
+
// Per-turn nonce (Finding 3, #3228). Populated only when the enqueue
|
|
203
|
+
// envelope carried a real message_id — the gateway requires it to match
|
|
204
|
+
// this turn's `turnId` before delivering `pendingText`, so a stale record
|
|
205
|
+
// carried over from a prior turn on the same chat/thread is rejected.
|
|
206
|
+
const turnId = buildTurnId(envelope.chatId, envelope.threadId, envelope.messageId)
|
|
207
|
+
if (turnId != null) block.turnId = turnId
|
|
208
|
+
}
|
|
209
|
+
if (typeof pendingText === 'string' && pendingText.length > 0) {
|
|
210
|
+
block.pendingText = pendingText
|
|
165
211
|
}
|
|
166
212
|
return block
|
|
167
213
|
}
|
|
@@ -191,6 +237,15 @@ function buildBlockResult(envelope, reason) {
|
|
|
191
237
|
* the hook's state.
|
|
192
238
|
* { decided: 'unknown', reason } — couldn't locate turn-start; caller fail-open
|
|
193
239
|
*
|
|
240
|
+
* On a 'block' decision the result MAY carry `pendingText`: the substantive
|
|
241
|
+
* final-answer prose the model wrote as plain transcript text after the last
|
|
242
|
+
* delivery event (or the whole turn's prose when nothing was ever delivered),
|
|
243
|
+
* joined and trimmed. This is the Option A transcript-prose bridge — the
|
|
244
|
+
* gateway reads it back out of the state file and delivers it directly on the
|
|
245
|
+
* first silent-end instead of waiting for the model re-prompt / obligation
|
|
246
|
+
* represent to eventually recover it. Only surfaced when it clears the same
|
|
247
|
+
* FINAL_ANSWER_MIN_CHARS substance floor the block decision uses.
|
|
248
|
+
*
|
|
194
249
|
* Turn-start anchor: the most recent `queue-operation`/`enqueue` line
|
|
195
250
|
* (the inbound message the gateway pushed onto the session). For
|
|
196
251
|
* queued mid-turn messages (multiple `enqueue` lines per "turn"), we
|
|
@@ -232,7 +287,7 @@ function buildBlockResult(envelope, reason) {
|
|
|
232
287
|
* false-positive that burned retry budget on healthy turns.
|
|
233
288
|
*
|
|
234
289
|
* @param {string} jsonl
|
|
235
|
-
* @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, chatId?: string, threadId?: number | null }}
|
|
290
|
+
* @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, turnId?: string, chatId?: string, threadId?: number | null, pendingText?: string }}
|
|
236
291
|
*/
|
|
237
292
|
export function scanTurnForFinalReply(jsonl) {
|
|
238
293
|
const lines = jsonl.split('\n')
|
|
@@ -294,7 +349,16 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
294
349
|
// the same bar `isFinalAnswerReply` uses to recognise a real
|
|
295
350
|
// answer — counts as "undelivered content the user was waiting
|
|
296
351
|
// on". #2956 review finding.
|
|
297
|
-
|
|
352
|
+
//
|
|
353
|
+
// Carry the trimmed text itself too (Option A transcript-prose
|
|
354
|
+
// bridge): when this turn ends up blocked, the joined undelivered
|
|
355
|
+
// text becomes `pendingText` so the gateway can deliver the model's
|
|
356
|
+
// real answer directly. Trimmed per-block; joined below.
|
|
357
|
+
blocks.push({
|
|
358
|
+
kind: 'text',
|
|
359
|
+
chars: String(c.text ?? '').trim().length,
|
|
360
|
+
text: String(c.text ?? '').trim(),
|
|
361
|
+
})
|
|
298
362
|
}
|
|
299
363
|
continue
|
|
300
364
|
}
|
|
@@ -339,10 +403,36 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
339
403
|
lastAllowReason = blocks[i].reason
|
|
340
404
|
}
|
|
341
405
|
}
|
|
342
|
-
const
|
|
343
|
-
|
|
406
|
+
const undeliveredSlice = blocks.slice(lastAllowBlockIdx + 1)
|
|
407
|
+
const sawUndeliveredTextAfterAllow = undeliveredSlice
|
|
344
408
|
.some((b) => b.kind === 'text' && (b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS)
|
|
345
409
|
|
|
410
|
+
// Option A transcript-prose bridge: isolate the undelivered final-answer
|
|
411
|
+
// prose so the gateway can deliver it directly.
|
|
412
|
+
//
|
|
413
|
+
// Finding 2 (#3228): a real dropped answer is a SINGLE substantive block —
|
|
414
|
+
// NOT concatenated inter-tool narration ("Let me check…", "Still querying…")
|
|
415
|
+
// that only crosses the floor once joined. The old code joined ALL post-
|
|
416
|
+
// delivery text blocks and surfaced the join whenever the COMBINED length
|
|
417
|
+
// hit the floor, so a run of short narration masqueraded as a final answer
|
|
418
|
+
// (and in the zero-delivery case that join was every text block in the
|
|
419
|
+
// turn). Instead, deliver only the LAST block that CLEARS the substance
|
|
420
|
+
// floor ON ITS OWN. This mirrors the block decision itself
|
|
421
|
+
// (`sawUndeliveredTextAfterAllow`, which requires a single ≥floor block) and
|
|
422
|
+
// handles the "big answer then short closer" shape by delivering the answer,
|
|
423
|
+
// not the closer. When no single block clears the floor, `pendingText` stays
|
|
424
|
+
// undefined and the gateway falls through to the re-prompt / represent nets.
|
|
425
|
+
const substantiveBlocks = undeliveredSlice.filter(
|
|
426
|
+
(b) =>
|
|
427
|
+
b.kind === 'text' &&
|
|
428
|
+
typeof b.text === 'string' &&
|
|
429
|
+
(b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS,
|
|
430
|
+
)
|
|
431
|
+
const pendingText =
|
|
432
|
+
substantiveBlocks.length > 0
|
|
433
|
+
? substantiveBlocks[substantiveBlocks.length - 1].text
|
|
434
|
+
: undefined
|
|
435
|
+
|
|
346
436
|
if (lastAllowBlockIdx === -1) {
|
|
347
437
|
// No qualifying delivery/silence event anywhere in the turn.
|
|
348
438
|
// Cron-fired turns (#2053): a scheduled turn that produced no
|
|
@@ -354,7 +444,7 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
354
444
|
if (envelope.source === 'cron') {
|
|
355
445
|
return { decided: 'allow', reason: 'cron-source' }
|
|
356
446
|
}
|
|
357
|
-
return buildBlockResult(envelope, 'no-final-reply')
|
|
447
|
+
return buildBlockResult(envelope, 'no-final-reply', pendingText)
|
|
358
448
|
}
|
|
359
449
|
|
|
360
450
|
if (sawUndeliveredTextAfterAllow) {
|
|
@@ -363,7 +453,7 @@ export function scanTurnForFinalReply(jsonl) {
|
|
|
363
453
|
// sent through a delivery tool. This is the "at least once" bug:
|
|
364
454
|
// an early ack (or any qualifying reply) must not amnesty
|
|
365
455
|
// everything written afterward.
|
|
366
|
-
return buildBlockResult(envelope, 'trailing-text-after-reply')
|
|
456
|
+
return buildBlockResult(envelope, 'trailing-text-after-reply', pendingText)
|
|
367
457
|
}
|
|
368
458
|
|
|
369
459
|
return { decided: 'allow', reason: lastAllowReason }
|