switchroom 0.13.12 → 0.13.14
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/switchroom.js +60 -5
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +290 -88
- package/telegram-plugin/final-answer-detect.ts +83 -0
- package/telegram-plugin/gateway/gateway.ts +213 -11
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +17 -5
- package/telegram-plugin/pending-work-progress.ts +377 -0
- package/telegram-plugin/runtime-metrics.ts +20 -0
- package/telegram-plugin/silent-end.ts +37 -11
- package/telegram-plugin/tests/final-answer-detect.test.ts +89 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +354 -0
- package/telegram-plugin/tests/silent-end.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +237 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* final-answer-detect.ts — #1664 "did this reply deliver the final answer?"
|
|
3
|
+
*
|
|
4
|
+
* Background. An agent often ends a turn with its real answer as plain
|
|
5
|
+
* assistant transcript text instead of a `reply` / `stream_reply` tool
|
|
6
|
+
* call. The gateway renders that transcript as a live Telegram draft
|
|
7
|
+
* (`sendMessageDraft`) and, at turn_end, retracts the draft — so the
|
|
8
|
+
* answer is never finalized and the user watches it vanish (#1664).
|
|
9
|
+
*
|
|
10
|
+
* The gateway's `replyCalled` flag flips on the FIRST reply / stream_reply
|
|
11
|
+
* tool use and stays true for the rest of the turn. It cannot distinguish
|
|
12
|
+
* "the model sent an interim ack" from "the model sent its real answer" —
|
|
13
|
+
* both set `replyCalled`. The silent-end re-prompt safety net needs a
|
|
14
|
+
* finer signal: it must engage when a turn ended with only an interim
|
|
15
|
+
* ack and the real answer left as transcript text.
|
|
16
|
+
*
|
|
17
|
+
* This module is that finer signal — a pure predicate the gateway calls
|
|
18
|
+
* for each reply that lands. A turn whose every reply was classified
|
|
19
|
+
* "interim" ends with `CurrentTurn.finalAnswerDelivered === false`, which
|
|
20
|
+
* triggers the re-prompt; a turn with at least one "final" reply does not.
|
|
21
|
+
*
|
|
22
|
+
* Keeping the policy in one unit-testable function is the point — the
|
|
23
|
+
* gateway is a multi-thousand-line module that's expensive to import in a
|
|
24
|
+
* test. See `telegram-plugin/tests/final-answer-detect.test.ts`.
|
|
25
|
+
*
|
|
26
|
+
* The fix re-prompts the model; it never materializes the draft into a
|
|
27
|
+
* message (`reference/principles.md`: the model communicates, the
|
|
28
|
+
* framework is the safety net). So a false "interim" classification is
|
|
29
|
+
* cheap (one extra re-prompt) and a false "final" classification is the
|
|
30
|
+
* dangerous one (a real answer left undelivered) — the length backstop
|
|
31
|
+
* exists to make the dangerous miss rare.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Length backstop for the final-answer classification. The pacing
|
|
36
|
+
* contract (`docs/telegram-style.md`) says interim updates pass
|
|
37
|
+
* `disable_notification: true` and the final answer omits it — so a
|
|
38
|
+
* notification-bearing reply is the primary "final answer" signal. But a
|
|
39
|
+
* model that mis-marks a genuinely substantive reply as interim
|
|
40
|
+
* (`disable_notification: true` on what is really the answer) would
|
|
41
|
+
* otherwise leave the turn looking undelivered. Any reply at or above
|
|
42
|
+
* this many characters therefore ALSO counts as the final answer,
|
|
43
|
+
* regardless of the notification flag. 200 chars is comfortably longer
|
|
44
|
+
* than a typical interim ack ("on it", "looking into that…", "give me a
|
|
45
|
+
* sec") and short enough that a real answer almost always clears it.
|
|
46
|
+
*/
|
|
47
|
+
export const FINAL_ANSWER_MIN_CHARS = 200
|
|
48
|
+
|
|
49
|
+
export interface FinalAnswerReplyInput {
|
|
50
|
+
/** The reply text the model sent (the model's own answer text, before
|
|
51
|
+
* any HTML conversion or Telegraph-link substitution). */
|
|
52
|
+
text: string
|
|
53
|
+
/** The `disable_notification` argument the reply tool was called with.
|
|
54
|
+
* `true` is the pacing contract's "interim update" marker; the final
|
|
55
|
+
* answer omits it (effectively `false`). */
|
|
56
|
+
disableNotification: boolean
|
|
57
|
+
/** For `stream_reply` only: whether this call carried `done: true`. A
|
|
58
|
+
* `done: true` call explicitly closes the stream and IS the final
|
|
59
|
+
* answer by definition. Pass `false` for the plain `reply` tool. */
|
|
60
|
+
done?: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pure predicate: did this reply deliver the turn's final answer (as
|
|
65
|
+
* opposed to an interim ack)? `true` if ANY of:
|
|
66
|
+
*
|
|
67
|
+
* - `done === true` — a `stream_reply` terminal call; the model
|
|
68
|
+
* explicitly closed the stream, so this is the final answer.
|
|
69
|
+
* - `disableNotification === false` — the pacing contract's explicit
|
|
70
|
+
* "final answer" signal (interim updates set it `true`).
|
|
71
|
+
* - `text.length >= FINAL_ANSWER_MIN_CHARS` — the length backstop for
|
|
72
|
+
* a substantive answer mis-marked as interim.
|
|
73
|
+
*
|
|
74
|
+
* The gateway ORs this across every reply in a turn; once one reply
|
|
75
|
+
* qualifies, `CurrentTurn.finalAnswerDelivered` latches true and the
|
|
76
|
+
* silent-end re-prompt will not engage for that turn.
|
|
77
|
+
*/
|
|
78
|
+
export function isFinalAnswerReply(input: FinalAnswerReplyInput): boolean {
|
|
79
|
+
if (input.done === true) return true
|
|
80
|
+
if (!input.disableNotification) return true
|
|
81
|
+
if (input.text.length >= FINAL_ANSWER_MIN_CHARS) return true
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
@@ -76,7 +76,9 @@ import {
|
|
|
76
76
|
import { emitRuntimeMetric } from '../runtime-metrics.js'
|
|
77
77
|
import { classifyInbound } from '../inbound-classifier.js'
|
|
78
78
|
import * as silencePoke from '../silence-poke.js'
|
|
79
|
-
import
|
|
79
|
+
import * as pendingProgress from '../pending-work-progress.js'
|
|
80
|
+
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
|
|
81
|
+
import { isFinalAnswerReply } from '../final-answer-detect.js'
|
|
80
82
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
81
83
|
import { type SessionEvent } from '../session-tail.js'
|
|
82
84
|
import {
|
|
@@ -1191,6 +1193,19 @@ type CurrentTurn = {
|
|
|
1191
1193
|
startedAt: number
|
|
1192
1194
|
gatewayReceiveAt: number
|
|
1193
1195
|
replyCalled: boolean
|
|
1196
|
+
// #1664 — whether the model has delivered its *final answer* this turn
|
|
1197
|
+
// (as opposed to only an interim ack). `replyCalled` flips on the first
|
|
1198
|
+
// reply / stream_reply tool_use and stays true for the rest of the turn,
|
|
1199
|
+
// so it cannot tell "ack only" from "ack + real answer". This flag is the
|
|
1200
|
+
// finer signal the silent-end re-prompt needs: it is set only when a reply
|
|
1201
|
+
// actually lands AND `isFinalAnswerReply` (final-answer-detect.ts)
|
|
1202
|
+
// classifies it as the final answer — notification-bearing, or long
|
|
1203
|
+
// enough to be substantive, or a stream_reply done=true — OR when the
|
|
1204
|
+
// turn-flush safety net legitimately emits the model's terminal text. A
|
|
1205
|
+
// turn that ends with this still `false` triggers the silent-end re-prompt
|
|
1206
|
+
// even though `replyCalled` is true — the #1664 case where the real answer
|
|
1207
|
+
// ended up as plain transcript text rendered into an ephemeral draft.
|
|
1208
|
+
finalAnswerDelivered: boolean
|
|
1194
1209
|
capturedText: string[]
|
|
1195
1210
|
orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
|
|
1196
1211
|
registryKey: string | null
|
|
@@ -3135,6 +3150,7 @@ silencePoke.startTimer({
|
|
|
3135
3150
|
// Drop silence-poke state and clear turn-active so the next inbound
|
|
3136
3151
|
// for this chat starts a fresh turn instead of queueing forever.
|
|
3137
3152
|
silencePoke.endTurn(fbKey)
|
|
3153
|
+
pendingProgress.noteTurnEnd(fbKey)
|
|
3138
3154
|
purgeReactionTracking(fbKey)
|
|
3139
3155
|
// Defense-in-depth: the fallback's purgeReactionTracking above
|
|
3140
3156
|
// clears the canonical statusKey(chatId, threadId) for fbKey
|
|
@@ -3192,6 +3208,34 @@ silencePoke.startTimer({
|
|
|
3192
3208
|
},
|
|
3193
3209
|
})
|
|
3194
3210
|
|
|
3211
|
+
// #1445 cross-turn pending-async ambient. When a turn ends after the
|
|
3212
|
+
// model dispatched background async work (Agent / Task / Bash run-in-
|
|
3213
|
+
// background) and the model has stopped speaking, keep editing the
|
|
3214
|
+
// model's last reply in place at 60s intervals so the user sees
|
|
3215
|
+
// ambient liveness during the wait. Edits are silent, never spawn a
|
|
3216
|
+
// new pinged message, and stop the moment the user re-engages or the
|
|
3217
|
+
// model synthesises a handback. The full design rationale lives in
|
|
3218
|
+
// `pending-work-progress.ts`'s header docblock. Kill switch:
|
|
3219
|
+
// `SWITCHROOM_DISABLE_PENDING_PROGRESS=1`.
|
|
3220
|
+
pendingProgress.startTimer({
|
|
3221
|
+
editMessage: async (ctx) => {
|
|
3222
|
+
await swallowingApiCall(
|
|
3223
|
+
() =>
|
|
3224
|
+
lockedBot.api.editMessageText(
|
|
3225
|
+
ctx.chatId,
|
|
3226
|
+
ctx.messageId,
|
|
3227
|
+
ctx.newText,
|
|
3228
|
+
),
|
|
3229
|
+
{
|
|
3230
|
+
chat_id: ctx.chatId,
|
|
3231
|
+
verb: 'pending-progress-edit',
|
|
3232
|
+
...(ctx.threadId != null ? { threadId: ctx.threadId } : {}),
|
|
3233
|
+
},
|
|
3234
|
+
)
|
|
3235
|
+
},
|
|
3236
|
+
emitMetric: (event) => emitRuntimeMetric(event),
|
|
3237
|
+
})
|
|
3238
|
+
|
|
3195
3239
|
// Per-agent buffer for synthetic inbounds the gateway couldn't deliver
|
|
3196
3240
|
// because the bridge wasn't connected at send-time. Drained on
|
|
3197
3241
|
// bridge-register so a fresh client picks up missed wake-ups before
|
|
@@ -3564,6 +3608,22 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3564
3608
|
label.length > 0 ? label : null,
|
|
3565
3609
|
Date.now(),
|
|
3566
3610
|
)
|
|
3611
|
+
// #1445 cross-turn pending-async ambient. Mark the chat as
|
|
3612
|
+
// having dispatched background work this turn so a turn_end
|
|
3613
|
+
// that follows activates the edit-in-place ambient line.
|
|
3614
|
+
// Covers `Agent` / `Task` (the harness-managed async path
|
|
3615
|
+
// — handback channel turn clears it) and `Bash` with
|
|
3616
|
+
// run_in_background:true (model is expected to poll
|
|
3617
|
+
// BashOutput; the ambient ticks until next inbound or the
|
|
3618
|
+
// 30-min budget cap).
|
|
3619
|
+
const evInput = ev.input as { run_in_background?: boolean } | undefined
|
|
3620
|
+
if (
|
|
3621
|
+
ev.toolName === 'Agent'
|
|
3622
|
+
|| ev.toolName === 'Task'
|
|
3623
|
+
|| (ev.toolName === 'Bash' && evInput?.run_in_background === true)
|
|
3624
|
+
) {
|
|
3625
|
+
pendingProgress.noteAsyncDispatch(key)
|
|
3626
|
+
}
|
|
3567
3627
|
}
|
|
3568
3628
|
} else if (ev.kind === 'tool_result') {
|
|
3569
3629
|
// #1292: drain the in-flight entry. Idempotent on unknown ids
|
|
@@ -4066,6 +4126,13 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
|
|
|
4066
4126
|
}
|
|
4067
4127
|
|
|
4068
4128
|
async function executeReply(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
4129
|
+
// #1664 — pin the turn this reply belongs to at entry. The
|
|
4130
|
+
// finalAnswerDelivered write near the end of this function runs after
|
|
4131
|
+
// several awaits; turn-pinning (the #1067 pattern used across the
|
|
4132
|
+
// gateway) keeps the write attributed to THIS turn rather than reading
|
|
4133
|
+
// module-scope currentTurn, which a future refactor could let roll over
|
|
4134
|
+
// mid-call.
|
|
4135
|
+
const turn = currentTurn
|
|
4069
4136
|
const chat_id = args.chat_id as string
|
|
4070
4137
|
if (!chat_id) throw new Error('reply: chat_id is required')
|
|
4071
4138
|
const rawText = args.text as string | undefined
|
|
@@ -4370,6 +4437,22 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4370
4437
|
}
|
|
4371
4438
|
}
|
|
4372
4439
|
|
|
4440
|
+
// #1445 cross-turn pending-async ambient. Capture the last text
|
|
4441
|
+
// chunk as the anchor — if this turn ends with a pending async
|
|
4442
|
+
// dispatch, the framework edits THIS message in place every 60s
|
|
4443
|
+
// with a `— still working (Nm)` suffix until the user re-engages.
|
|
4444
|
+
// Multi-chunk replies: anchor is the LAST chunk (edits append to
|
|
4445
|
+
// the visually-trailing message; earlier chunks are left intact).
|
|
4446
|
+
if (sentIds.length === chunks.length && chunks.length > 0) {
|
|
4447
|
+
const anchorMsgId = sentIds[chunks.length - 1]
|
|
4448
|
+
if (typeof anchorMsgId === 'number') {
|
|
4449
|
+
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
4450
|
+
messageId: anchorMsgId,
|
|
4451
|
+
text: chunks[chunks.length - 1],
|
|
4452
|
+
})
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4373
4456
|
// #273: when files is 2-10 photos, batch them into a single
|
|
4374
4457
|
// sendMediaGroup album rather than N separate sendPhoto calls. The
|
|
4375
4458
|
// user's device fires one notification for the album instead of N
|
|
@@ -4488,6 +4571,19 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4488
4571
|
} catch (err) {
|
|
4489
4572
|
process.stderr.write(`telegram gateway: reply: endStatusReaction hook threw: ${err}\n`)
|
|
4490
4573
|
}
|
|
4574
|
+
// #1664 — mark the turn's final answer as delivered when this reply
|
|
4575
|
+
// looks like the real answer rather than an interim ack. The
|
|
4576
|
+
// classification (notification-bearing OR substantive length) lives
|
|
4577
|
+
// in `isFinalAnswerReply`. Without this, a turn that ack'd then ended
|
|
4578
|
+
// with the real answer as plain transcript text (#1664) would look
|
|
4579
|
+
// "delivered" because replyCalled is true — and the silent-end
|
|
4580
|
+
// re-prompt would never engage. `rawText` is the model's own answer
|
|
4581
|
+
// text, measured before HTML conversion / Telegraph-link
|
|
4582
|
+
// substitution. Writes `turn` (pinned at executeReply entry) so the
|
|
4583
|
+
// flag always lands on the turn this reply belongs to.
|
|
4584
|
+
if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
|
|
4585
|
+
turn.finalAnswerDelivered = true
|
|
4586
|
+
}
|
|
4491
4587
|
}
|
|
4492
4588
|
|
|
4493
4589
|
process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(',')}] chunks=${chunks.length}\n`)
|
|
@@ -4501,6 +4597,8 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4501
4597
|
}
|
|
4502
4598
|
|
|
4503
4599
|
async function executeStreamReply(args: Record<string, unknown>): Promise<unknown> {
|
|
4600
|
+
// #1664 — pin the turn at entry; see executeReply for the rationale.
|
|
4601
|
+
const turn = currentTurn
|
|
4504
4602
|
if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
|
|
4505
4603
|
if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
|
|
4506
4604
|
|
|
@@ -4679,6 +4777,32 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
4679
4777
|
const sChatId = args.chat_id as string
|
|
4680
4778
|
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
4681
4779
|
outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now())
|
|
4780
|
+
// #1445 cross-turn pending-async ambient. The terminal stream_reply
|
|
4781
|
+
// (done=true) is the user-visible anchor for any cross-turn wait
|
|
4782
|
+
// that follows. Capture it so if this turn ends with a pending
|
|
4783
|
+
// async dispatch, the framework edits THIS message in place at
|
|
4784
|
+
// intervals.
|
|
4785
|
+
pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
|
|
4786
|
+
messageId: result.messageId,
|
|
4787
|
+
text: args.text as string,
|
|
4788
|
+
})
|
|
4789
|
+
}
|
|
4790
|
+
// #1664 — mark the turn's final answer as delivered. For stream_reply a
|
|
4791
|
+
// call with done=true IS the final answer by definition (the model
|
|
4792
|
+
// explicitly closed the stream). A non-terminal stream_reply chunk also
|
|
4793
|
+
// counts when it carries the final-answer signals — notification-bearing
|
|
4794
|
+
// OR substantive length — via the same `isFinalAnswerReply` predicate
|
|
4795
|
+
// executeReply uses. See the CurrentTurn.finalAnswerDelivered doc-comment
|
|
4796
|
+
// for why replyCalled is not a sufficient signal here.
|
|
4797
|
+
if (
|
|
4798
|
+
turn != null &&
|
|
4799
|
+
isFinalAnswerReply({
|
|
4800
|
+
text: (args.text as string | undefined) ?? '',
|
|
4801
|
+
disableNotification: args.disable_notification === true,
|
|
4802
|
+
done: args.done === true,
|
|
4803
|
+
})
|
|
4804
|
+
) {
|
|
4805
|
+
turn.finalAnswerDelivered = true
|
|
4682
4806
|
}
|
|
4683
4807
|
return { content: [{ type: 'text', text: `${result.status} (id: ${result.messageId ?? 'pending'})` }] }
|
|
4684
4808
|
}
|
|
@@ -5675,6 +5799,25 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5675
5799
|
// Drain any orphaned typing-wrap entries left over from a crashed
|
|
5676
5800
|
// prior turn before resetting focus.
|
|
5677
5801
|
typingWrapper.drainAll()
|
|
5802
|
+
if (ev.chatId) {
|
|
5803
|
+
// #1445 cross-turn pending-async ambient — backstop for the
|
|
5804
|
+
// `handleInbound` path's `clearPending('inbound')`. The
|
|
5805
|
+
// inbound path covers real user messages, but synthesised
|
|
5806
|
+
// wakes (subagent-handback channel turn, cron fires, vault
|
|
5807
|
+
// grant resumes, restart markers) push directly to
|
|
5808
|
+
// `pendingInboundBuffer` and bypass `handleInbound`. The
|
|
5809
|
+
// `enqueue` session-event fires for EVERY fresh turn atom
|
|
5810
|
+
// regardless of source — clearing here drops any prior turn's
|
|
5811
|
+
// ambient before the new turn's `noteOutbound` lands. The
|
|
5812
|
+
// call is idempotent so it's safe to fire in addition to the
|
|
5813
|
+
// inbound-path clear (for the real-inbound case, this is a
|
|
5814
|
+
// no-op because state was already deleted by then).
|
|
5815
|
+
const enqThreadId = ev.threadId != null ? Number(ev.threadId) : undefined
|
|
5816
|
+
pendingProgress.clearPending(
|
|
5817
|
+
statusKey(ev.chatId, enqThreadId),
|
|
5818
|
+
'handback',
|
|
5819
|
+
)
|
|
5820
|
+
}
|
|
5678
5821
|
if (ev.chatId) {
|
|
5679
5822
|
// Issue #195: if a previous turn left an answer-lane stream open
|
|
5680
5823
|
// (rapid steer/queue), force it to a new generation so its in-flight
|
|
@@ -5697,6 +5840,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5697
5840
|
startedAt,
|
|
5698
5841
|
gatewayReceiveAt: startedAt,
|
|
5699
5842
|
replyCalled: false,
|
|
5843
|
+
finalAnswerDelivered: false,
|
|
5700
5844
|
capturedText: [],
|
|
5701
5845
|
orphanedReplyTimeoutId: null,
|
|
5702
5846
|
registryKey: null,
|
|
@@ -5815,6 +5959,22 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5815
5959
|
// #1067: snapshot at entry. The answer-stream creation closures
|
|
5816
5960
|
// below also read `turn` instead of currentTurn so they pin to
|
|
5817
5961
|
// this turn's chat for the stream's lifetime.
|
|
5962
|
+
//
|
|
5963
|
+
// #1664 ordering note: a `text` event can arrive AFTER turn_end has
|
|
5964
|
+
// nulled currentTurn (the issue observed `answer_lane_update
|
|
5965
|
+
// transport:"draft"` firing post-turn_end). Such a late event is
|
|
5966
|
+
// dropped here by the `turn != null` guard — it is NOT folded back
|
|
5967
|
+
// into the just-ended turn. That is deliberate and safe: by the
|
|
5968
|
+
// time this fires, the turn atom has been handed to
|
|
5969
|
+
// endCurrentTurnAtomic and turn_end has already run its flush /
|
|
5970
|
+
// silent-end decision; re-opening a closed turn (re-creating an
|
|
5971
|
+
// answer stream, re-evaluating decideTurnFlush) would be a large,
|
|
5972
|
+
// race-prone change. The #1664 safety net does not depend on
|
|
5973
|
+
// catching the late text: a turn whose real answer lost the race
|
|
5974
|
+
// ends with finalAnswerDelivered=false, so recordUndeliveredTurnEnd
|
|
5975
|
+
// engages the Stop-hook re-prompt and the model re-delivers the
|
|
5976
|
+
// answer through the reply tool. The dropped draft text is
|
|
5977
|
+
// recovered by re-prompt, not by post-hoc materialization.
|
|
5818
5978
|
const turn = currentTurn
|
|
5819
5979
|
if (turn != null) {
|
|
5820
5980
|
turn.capturedText.push(ev.text)
|
|
@@ -5975,6 +6135,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5975
6135
|
// full message above). Match the pattern used at the regular
|
|
5976
6136
|
// turn-end path (line ~5039) and the wedged-turn path (~5290).
|
|
5977
6137
|
silencePoke.endTurn(ceKey)
|
|
6138
|
+
pendingProgress.noteTurnEnd(ceKey)
|
|
5978
6139
|
// Issue #195: tear down the answer-lane stream on context-exhaustion
|
|
5979
6140
|
// bail-out. The user is being told the session needs /restart, so any
|
|
5980
6141
|
// partially-streamed answer would be misleading.
|
|
@@ -6160,6 +6321,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6160
6321
|
try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
|
|
6161
6322
|
signalTracker.clear(tKey)
|
|
6162
6323
|
silencePoke.endTurn(tKey)
|
|
6324
|
+
pendingProgress.noteTurnEnd(tKey)
|
|
6163
6325
|
}
|
|
6164
6326
|
lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
|
|
6165
6327
|
pendingPtyPartial = null
|
|
@@ -6181,6 +6343,18 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6181
6343
|
const backstopThreadId = threadId
|
|
6182
6344
|
const backstopCtrl = ctrl
|
|
6183
6345
|
|
|
6346
|
+
// #1664 — turn-flush only fires when !replyCalled (decideTurnFlush
|
|
6347
|
+
// returns 'reply-called' otherwise). It legitimately delivers the
|
|
6348
|
+
// model's terminal text as the answer, so the turn IS answered.
|
|
6349
|
+
// Mark it now so the early-return below skips the silent-end
|
|
6350
|
+
// re-prompt for a turn whose answer is genuinely on its way out.
|
|
6351
|
+
// (The IIFE that actually sends runs after this branch's `return`;
|
|
6352
|
+
// since the silent-end block is on the sibling reply-called path
|
|
6353
|
+
// that this branch never reaches, this set is belt-and-braces —
|
|
6354
|
+
// it keeps the captured `turn` atom internally consistent for any
|
|
6355
|
+
// future reader.)
|
|
6356
|
+
turn.finalAnswerDelivered = true
|
|
6357
|
+
|
|
6184
6358
|
// #654 deterministic double-message fix. Hand off the pinned
|
|
6185
6359
|
// progress card BEFORE state reset so the driver doesn't keep
|
|
6186
6360
|
// editing it while turn-flush is rewriting it with the answer.
|
|
@@ -6222,6 +6396,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6222
6396
|
const tKey = statusKey(chatId, threadId)
|
|
6223
6397
|
signalTracker.clear(tKey)
|
|
6224
6398
|
silencePoke.endTurn(tKey)
|
|
6399
|
+
pendingProgress.noteTurnEnd(tKey)
|
|
6225
6400
|
}
|
|
6226
6401
|
|
|
6227
6402
|
void (async () => {
|
|
@@ -6413,17 +6588,31 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6413
6588
|
longest_silent_gap_ms: outboundMetrics.longestOutboundGapMs,
|
|
6414
6589
|
ended_via: outboundMetrics.outboundCount > 0 ? 'reply' : 'silent',
|
|
6415
6590
|
})
|
|
6416
|
-
// #1122 PR4 / #1161: deterministic
|
|
6417
|
-
// silent-marker path above for the rationale).
|
|
6418
|
-
// - first
|
|
6419
|
-
// file so the Stop hook (silent-end-interrupt-stop.mjs)
|
|
6420
|
-
// blocks the session-end and re-prompts the agent to
|
|
6591
|
+
// #1122 PR4 / #1161 / #1664: deterministic undelivered-turn
|
|
6592
|
+
// handling (see the silent-marker path above for the rationale).
|
|
6593
|
+
// - first undelivered turn-end → recordSilentTurnEnd writes the
|
|
6594
|
+
// state file so the Stop hook (silent-end-interrupt-stop.mjs)
|
|
6595
|
+
// blocks the session-end and re-prompts the agent to deliver.
|
|
6421
6596
|
// - the Stop-hook re-prompt is already spent and the agent is
|
|
6422
|
-
// STILL
|
|
6423
|
-
// deliver a user-facing fallback so the turn
|
|
6424
|
-
// vanishes (the user otherwise only sees the card
|
|
6425
|
-
|
|
6426
|
-
|
|
6597
|
+
// STILL undelivered → recordSilentTurnEnd returns
|
|
6598
|
+
// exhausted:true; deliver a user-facing fallback so the turn
|
|
6599
|
+
// never just vanishes (the user otherwise only sees the card
|
|
6600
|
+
// disappear).
|
|
6601
|
+
//
|
|
6602
|
+
// #1664 — the trigger is "no final answer delivered", not "zero
|
|
6603
|
+
// outbound". `outboundCount === 0` is now just the special case
|
|
6604
|
+
// where nothing landed at all. The added case: the model sent an
|
|
6605
|
+
// interim ack via reply/stream_reply (outboundCount > 0,
|
|
6606
|
+
// replyCalled = true) but ended the turn with its real answer as
|
|
6607
|
+
// plain transcript text — rendered into an ephemeral answer-lane
|
|
6608
|
+
// draft and retracted at turn_end, never finalized. finalAnswer-
|
|
6609
|
+
// Delivered stays false there, so the re-prompt engages and the
|
|
6610
|
+
// model re-delivers the answer through the reply tool. NO_REPLY /
|
|
6611
|
+
// HEARTBEAT_OK silent-marker turns return earlier and never reach
|
|
6612
|
+
// this path. The turn-flush 'flush' branch also returns earlier
|
|
6613
|
+
// (and sets finalAnswerDelivered=true defensively).
|
|
6614
|
+
if (turn.finalAnswerDelivered === false) {
|
|
6615
|
+
const silentEnd = recordUndeliveredTurnEnd({
|
|
6427
6616
|
chatId,
|
|
6428
6617
|
threadId: threadId ?? null,
|
|
6429
6618
|
turnKey: tKey,
|
|
@@ -6454,6 +6643,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6454
6643
|
}
|
|
6455
6644
|
signalTracker.clear(tKey)
|
|
6456
6645
|
silencePoke.endTurn(tKey)
|
|
6646
|
+
pendingProgress.noteTurnEnd(tKey)
|
|
6457
6647
|
}
|
|
6458
6648
|
lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
|
|
6459
6649
|
pendingPtyPartial = null
|
|
@@ -7676,6 +7866,18 @@ async function handleInbound(
|
|
|
7676
7866
|
// the framework can nudge the model if it goes quiet past the
|
|
7677
7867
|
// soft / firm thresholds.
|
|
7678
7868
|
silencePoke.startTurn(statusKey(chat_id, messageThreadId), Date.now())
|
|
7869
|
+
// #1445 cross-turn pending-async ambient. A new turn starting
|
|
7870
|
+
// (user inbound, synthesised wake, or handback channel) is the
|
|
7871
|
+
// signal that the model is about to re-engage — clear any
|
|
7872
|
+
// pending-progress edits anchored to the *prior* turn's
|
|
7873
|
+
// outbound so the framework stops talking over the new turn.
|
|
7874
|
+
// clearPending drops the per-key state outright, so the new
|
|
7875
|
+
// turn's `tool_use(Agent|Task|Bash bg)` + outbound capture
|
|
7876
|
+
// afresh via `noteAsyncDispatch` / `noteOutbound`.
|
|
7877
|
+
pendingProgress.clearPending(
|
|
7878
|
+
statusKey(chat_id, messageThreadId),
|
|
7879
|
+
'inbound',
|
|
7880
|
+
)
|
|
7679
7881
|
// Human-feel UX: hold a continuous `typing…` indicator for the
|
|
7680
7882
|
// WHOLE turn, not just the split-second a reply is transmitted.
|
|
7681
7883
|
// A person you message shows as typing the entire time they
|
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Stop hook — auto-interrupt for silent-end turns.
|
|
4
4
|
*
|
|
5
|
-
* When a Claude Code session ends without the agent
|
|
6
|
-
*
|
|
5
|
+
* When a Claude Code session ends without the agent delivering a final
|
|
6
|
+
* answer to the user, the Telegram gateway writes a state file at
|
|
7
7
|
* $TELEGRAM_STATE_DIR/silent-end-pending.json. This hook reads that file and,
|
|
8
8
|
* if a first-time silent-end is detected (retryCount === 0), returns a
|
|
9
9
|
* decision:block to re-prompt the agent instead of letting the session close.
|
|
10
10
|
*
|
|
11
|
+
* #1664 — "no final answer delivered" covers two cases: (a) the turn ended
|
|
12
|
+
* with zero outbound (the original case), and (b) the model sent only an
|
|
13
|
+
* interim ack via reply/stream_reply but left its real answer as plain
|
|
14
|
+
* transcript text, which the gateway renders into an ephemeral draft and
|
|
15
|
+
* never finalizes. The re-prompt below tells the model to send its answer
|
|
16
|
+
* through the reply tool, or reply NO_REPLY if it genuinely has nothing to
|
|
17
|
+
* add / already delivered.
|
|
18
|
+
*
|
|
11
19
|
* On the second silent-end (retryCount >= MAX_RETRIES), the hook allows the
|
|
12
20
|
* stop. The gateway's turn-end path (recordSilentTurnEnd in silent-end.ts)
|
|
13
21
|
* detects the exhausted re-prompt and delivers a user-facing fallback
|
|
@@ -104,9 +112,13 @@ function main() {
|
|
|
104
112
|
JSON.stringify({
|
|
105
113
|
decision: 'block',
|
|
106
114
|
reason:
|
|
107
|
-
'
|
|
108
|
-
'
|
|
109
|
-
'
|
|
115
|
+
'This turn is ending without your final answer reaching the user. ' +
|
|
116
|
+
'If you wrote an answer as plain text (not via a tool), the user ' +
|
|
117
|
+
'cannot see it — only text sent through the reply tool is delivered. ' +
|
|
118
|
+
'Send your final answer now by calling mcp__switchroom-telegram__reply ' +
|
|
119
|
+
'(or mcp__switchroom-telegram__stream_reply with done=true). ' +
|
|
120
|
+
'If your final answer has already reached the user, or you ' +
|
|
121
|
+
'intentionally have nothing to add, reply with exactly NO_REPLY.',
|
|
110
122
|
}),
|
|
111
123
|
)
|
|
112
124
|
process.exit(0)
|