switchroom 0.13.14 → 0.13.15

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.
@@ -47331,8 +47331,8 @@ var {
47331
47331
  } = import__.default;
47332
47332
 
47333
47333
  // src/build-info.ts
47334
- var VERSION = "0.13.14";
47335
- var COMMIT_SHA = "0cf961a6";
47334
+ var VERSION = "0.13.15";
47335
+ var COMMIT_SHA = "bc0b5540";
47336
47336
 
47337
47337
  // src/cli/agent.ts
47338
47338
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.14",
3
+ "version": "0.13.15",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48154,10 +48154,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48154
48154
  }
48155
48155
 
48156
48156
  // ../src/build-info.ts
48157
- var VERSION = "0.13.14";
48158
- var COMMIT_SHA = "0cf961a6";
48159
- var COMMIT_DATE = "2026-05-23T01:15:10Z";
48160
- var LATEST_PR = 1670;
48157
+ var VERSION = "0.13.15";
48158
+ var COMMIT_SHA = "bc0b5540";
48159
+ var COMMIT_DATE = "2026-05-23T02:55:43Z";
48160
+ var LATEST_PR = 1673;
48161
48161
  var COMMITS_AHEAD_OF_TAG = 0;
48162
48162
 
48163
48163
  // gateway/boot-version.ts
@@ -49857,6 +49857,7 @@ var STREAM_THROTTLE_MS_OVERRIDE = (() => {
49857
49857
  return Number.isFinite(n) && n >= 0 ? n : undefined;
49858
49858
  })();
49859
49859
  var TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled();
49860
+ var ANSWER_STREAM_VISIBLE_ENABLED = process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM === "1" || process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM === "true";
49860
49861
  var progressDriver = null;
49861
49862
  var unpinProgressCardForChat = null;
49862
49863
  var getPinnedProgressCardMessageId = null;
@@ -51854,7 +51855,7 @@ function handleSessionEvent(ev) {
51854
51855
  chatId: turn.sessionChatId,
51855
51856
  isPrivateChat: turn.isDm,
51856
51857
  threadId: turn.sessionThreadId,
51857
- sendMessageDraft: sendMessageDraftFn,
51858
+ ...ANSWER_STREAM_VISIBLE_ENABLED ? { minInitialChars: 1 } : { sendMessageDraft: sendMessageDraftFn },
51858
51859
  sendMessage: async (chatId, text, params) => {
51859
51860
  const tid = params?.message_thread_id;
51860
51861
  const msg = await robustApiCall(() => bot.api.sendMessage(chatId, text, {
@@ -51976,20 +51977,45 @@ function handleSessionEvent(ev) {
51976
51977
  turn.orphanedReplyTimeoutId = null;
51977
51978
  }
51978
51979
  preambleSuppressor.flushNow();
51980
+ let streamFinalizedAsAnswer = false;
51979
51981
  if (turn?.answerStream != null) {
51980
51982
  const stream = turn.answerStream;
51981
- turn.answerStream = null;
51982
- stream.retract().catch((err) => {
51983
- process.stderr.write(`telegram gateway: answer-stream retract failed: ${err instanceof Error ? err.message : String(err)}
51983
+ const streamedMsgId = stream.messageId();
51984
+ const streamedFinalText = turn.capturedText.join("").trim();
51985
+ if (ANSWER_STREAM_VISIBLE_ENABLED && !turn.replyCalled && streamedMsgId != null && streamedFinalText.length > 0) {
51986
+ turn.answerStream = null;
51987
+ stream.stop();
51988
+ streamFinalizedAsAnswer = true;
51989
+ turn.finalAnswerDelivered = true;
51990
+ try {
51991
+ outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, streamedFinalText, Date.now());
51992
+ } catch {}
51993
+ if (HISTORY_ENABLED) {
51994
+ try {
51995
+ recordOutbound({
51996
+ chat_id: turn.sessionChatId,
51997
+ thread_id: turn.sessionThreadId ?? null,
51998
+ message_ids: [streamedMsgId],
51999
+ texts: [streamedFinalText]
52000
+ });
52001
+ } catch {}
52002
+ }
52003
+ process.stderr.write(`telegram gateway: answer-stream finalized as answer chat=${turn.sessionChatId} msg=${streamedMsgId} chars=${streamedFinalText.length}
51984
52004
  `);
51985
- });
52005
+ } else {
52006
+ turn.answerStream = null;
52007
+ stream.retract().catch((err) => {
52008
+ process.stderr.write(`telegram gateway: answer-stream retract failed: ${err instanceof Error ? err.message : String(err)}
52009
+ `);
52010
+ });
52011
+ }
51986
52012
  }
51987
52013
  if (turn == null)
51988
52014
  return;
51989
52015
  const chatId = turn.sessionChatId;
51990
52016
  const threadId = turn.sessionThreadId;
51991
52017
  const ctrl = activeStatusReactions.get(statusKey(chatId, threadId));
51992
- const flushDecision = decideTurnFlush({
52018
+ const flushDecision = streamFinalizedAsAnswer ? { kind: "skip", reason: "reply-called" } : decideTurnFlush({
51993
52019
  chatId: turn.sessionChatId,
51994
52020
  replyCalled: turn.replyCalled,
51995
52021
  capturedText: turn.capturedText,
@@ -2855,6 +2855,42 @@ const STREAM_THROTTLE_MS_OVERRIDE: number | undefined = (() => {
2855
2855
  return Number.isFinite(n) && n >= 0 ? n : undefined
2856
2856
  })()
2857
2857
  const TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled()
2858
+
2859
+ // #869-Phase1 / openclaw-pattern. When SET, the answer-lane stream
2860
+ // (telegram-plugin/answer-stream.ts) renders the model's transcript
2861
+ // text as a USER-VISIBLE edit-in-place message instead of writing to
2862
+ // Telegram's invisible compose-box draft (which is the default and
2863
+ // supports the #1664 "retract + re-prompt" contract). With this flag
2864
+ // on:
2865
+ // 1. createAnswerStream is instantiated without `sendMessageDraft`,
2866
+ // so it falls back to `sendMessage` + `editMessageText` for a
2867
+ // real chat-timeline message (`answer-stream.ts:212-214`).
2868
+ // 2. minInitialChars is set to 1 — the first text chunk pushes a
2869
+ // visible message immediately (TTFO under 5s for short turns).
2870
+ // 3. At turn_end, if the model never called reply / stream_reply
2871
+ // AND the streamed message has substantive captured text, the
2872
+ // gateway DOES NOT retract (which would delete a user-visible
2873
+ // message the user has been reading live); it calls
2874
+ // `stream.stop()` to freeze the current text as the final
2875
+ // answer, records the message in dedup + history, and marks
2876
+ // `turn.finalAnswerDelivered = true` so the #1664 silent-end
2877
+ // re-prompt does not fire. Turn-flush is suppressed for this
2878
+ // branch — its job (deliver captured text) is structurally
2879
+ // already done by the visible stream.
2880
+ // 4. The reply-tool / stream_reply path is unchanged — when the
2881
+ // model uses an explicit reply tool the prior streamed message
2882
+ // is retracted (delete) and the reply takes over as before.
2883
+ // Trade-off: a stream-as-final-answer turn does NOT push a device
2884
+ // notification (Telegram does not notify on edits, and we choose
2885
+ // not to send a duplicate fresh message for the ping). For short
2886
+ // turns where the user is actively watching, this is the right
2887
+ // shape — they see the answer materialise live. For longer waits,
2888
+ // the cross-turn pending-progress system (#1445/#1669) is the
2889
+ // canonical surface and DOES ping at the appropriate boundaries.
2890
+ // Default OFF; flip per-agent via env to canary the new behaviour.
2891
+ const ANSWER_STREAM_VISIBLE_ENABLED =
2892
+ process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM === '1'
2893
+ || process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM === 'true'
2858
2894
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2859
2895
  const progressDriver: any = null
2860
2896
  const unpinProgressCardForChat: ((chatId: string, threadId: number | undefined) => void) | null = null
@@ -5986,7 +6022,13 @@ function handleSessionEvent(ev: SessionEvent): void {
5986
6022
  chatId: turn.sessionChatId,
5987
6023
  isPrivateChat: turn.isDm,
5988
6024
  threadId: turn.sessionThreadId,
5989
- sendMessageDraft: sendMessageDraftFn,
6025
+ // #869-Phase1 visible-answer-stream: omit the draft API so
6026
+ // the lane uses the real sendMessage / editMessageText path
6027
+ // and edits a user-visible chat-timeline message instead
6028
+ // of the invisible compose-box draft.
6029
+ ...(ANSWER_STREAM_VISIBLE_ENABLED
6030
+ ? { minInitialChars: 1 }
6031
+ : { sendMessageDraft: sendMessageDraftFn }),
5990
6032
  // #1075: route through robustApiCall so flood-wait,
5991
6033
  // benign-400, and THREAD_NOT_FOUND are handled uniformly
5992
6034
  // instead of crashing the answer-stream loop on a deleted
@@ -6189,20 +6231,71 @@ function handleSessionEvent(ev: SessionEvent): void {
6189
6231
  // (regression for short no-tool replies). Order matters here: this
6190
6232
  // call must come before the retract/null block.
6191
6233
  preambleSuppressor.flushNow()
6192
- // #656: always retract the answer-lane stream at turn_end. Turn-flush
6193
- // (gateway.ts ~3475) is the sole canonical emitter for no-reply turns —
6194
- // it runs markdownToHtml and records to outboundDedup. Materializing
6195
- // here would race turn-flush and post raw model text (no HTML conv).
6234
+ // #656: by default we ALWAYS retract the answer-lane stream at
6235
+ // turn_end. Turn-flush is the canonical emitter for no-reply
6236
+ // turns; materialising here would race it and post raw model
6237
+ // text (no HTML conv).
6238
+ //
6239
+ // #869-Phase1 override: when `ANSWER_STREAM_VISIBLE_ENABLED` is
6240
+ // on, the stream is rendering a USER-VISIBLE message in the
6241
+ // chat timeline. Retracting (delete) destroys content the user
6242
+ // has been reading live — the worst possible UX flicker. So
6243
+ // when the stream is the de-facto final answer (model never
6244
+ // called reply, captured text is substantive) we instead call
6245
+ // `stream.stop()` to freeze it as the final state, record the
6246
+ // outbound for history + dedup, mark the turn answered, and
6247
+ // suppress the turn-flush IIFE downstream.
6248
+ let streamFinalizedAsAnswer = false
6196
6249
  if (turn?.answerStream != null) {
6197
6250
  const stream = turn.answerStream
6198
- turn.answerStream = null
6199
- void stream.retract().catch((err) => {
6251
+ const streamedMsgId = stream.messageId()
6252
+ const streamedFinalText = turn.capturedText.join('').trim()
6253
+ if (
6254
+ ANSWER_STREAM_VISIBLE_ENABLED
6255
+ && !turn.replyCalled
6256
+ && streamedMsgId != null
6257
+ && streamedFinalText.length > 0
6258
+ ) {
6259
+ turn.answerStream = null
6260
+ stream.stop()
6261
+ streamFinalizedAsAnswer = true
6262
+ turn.finalAnswerDelivered = true
6263
+ // Record as canonical outbound so retries dedup against it
6264
+ // and the SQLite history can surface it. Mirrors the
6265
+ // hooks turn-flush + reply both run.
6266
+ try {
6267
+ outboundDedup.record(
6268
+ turn.sessionChatId,
6269
+ turn.sessionThreadId,
6270
+ streamedFinalText,
6271
+ Date.now(),
6272
+ )
6273
+ } catch { /* best-effort */ }
6274
+ if (HISTORY_ENABLED) {
6275
+ try {
6276
+ recordOutbound({
6277
+ chat_id: turn.sessionChatId,
6278
+ thread_id: turn.sessionThreadId ?? null,
6279
+ message_ids: [streamedMsgId],
6280
+ texts: [streamedFinalText],
6281
+ })
6282
+ } catch { /* best-effort */ }
6283
+ }
6200
6284
  process.stderr.write(
6201
- `telegram gateway: answer-stream retract failed: ${
6202
- err instanceof Error ? err.message : String(err)
6203
- }\n`,
6285
+ `telegram gateway: answer-stream finalized as answer ` +
6286
+ `chat=${turn.sessionChatId} msg=${streamedMsgId} ` +
6287
+ `chars=${streamedFinalText.length}\n`,
6204
6288
  )
6205
- })
6289
+ } else {
6290
+ turn.answerStream = null
6291
+ void stream.retract().catch((err) => {
6292
+ process.stderr.write(
6293
+ `telegram gateway: answer-stream retract failed: ${
6294
+ err instanceof Error ? err.message : String(err)
6295
+ }\n`,
6296
+ )
6297
+ })
6298
+ }
6206
6299
  }
6207
6300
  if (turn == null) return
6208
6301
  const chatId = turn.sessionChatId
@@ -6214,12 +6307,19 @@ function handleSessionEvent(ev: SessionEvent): void {
6214
6307
  // surface to recover from. The decideTurnFlush 'empty-text'
6215
6308
  // path now relies on capturedText alone.
6216
6309
 
6217
- const flushDecision = decideTurnFlush({
6218
- chatId: turn.sessionChatId,
6219
- replyCalled: turn.replyCalled,
6220
- capturedText: turn.capturedText,
6221
- flushEnabled: TURN_FLUSH_SAFETY_ENABLED,
6222
- })
6310
+ // #869-Phase1: when the answer-stream finalised as the answer
6311
+ // above, skip the turn-flush IIFE entirely — its job (deliver
6312
+ // captured text) is already done by the visible stream, and
6313
+ // running it would race a duplicate fresh-sendMessage against
6314
+ // the user-visible edited message.
6315
+ const flushDecision = streamFinalizedAsAnswer
6316
+ ? ({ kind: 'skip', reason: 'reply-called' } as ReturnType<typeof decideTurnFlush>)
6317
+ : decideTurnFlush({
6318
+ chatId: turn.sessionChatId,
6319
+ replyCalled: turn.replyCalled,
6320
+ capturedText: turn.capturedText,
6321
+ flushEnabled: TURN_FLUSH_SAFETY_ENABLED,
6322
+ })
6223
6323
  if (flushDecision.kind === 'skip' && flushDecision.reason !== 'reply-called') {
6224
6324
  process.stderr.write(
6225
6325
  `telegram gateway: turn-flush skipped — reason=${flushDecision.reason}\n`,
@@ -57,17 +57,19 @@ import type { ObservedMessage } from "../driver.js";
57
57
 
58
58
  const SLEEP_SECONDS = 350;
59
59
 
60
+ // Engineered to elicit the natural production pattern: the model
61
+ // sends a quick ack reply ("on it — background sleep running"),
62
+ // dispatches the sleep as a background Bash, ends its turn, then
63
+ // returns with "done" once the sleep completes. The framework
64
+ // fix-under-test owns the in-between ambient.
60
65
  const PROMPT =
61
- `This is an instrumented stress test of cross-turn pending-async ` +
62
- `progress. Please run exactly this command via the Bash tool, and ` +
63
- `ONLY this command, as a SINGLE call with run_in_background=true ` +
64
- `(do not break it up, do not send any further reply until it ` +
65
- `completes):\n\n` +
66
- "```bash\n" +
67
- `sleep ${SLEEP_SECONDS}\n` +
68
- "```\n\n" +
69
- `After the bash command returns, send exactly the single word ` +
70
- `"done" as your final reply.`;
66
+ `Please run \`sleep ${SLEEP_SECONDS}\` in the background using the ` +
67
+ `Bash tool with \`run_in_background: true\` this is a stress ` +
68
+ `test of the cross-turn ambient progress surface, so the sleep ` +
69
+ `duration matters. Send a brief one-line acknowledgement that ` +
70
+ `you've dispatched it (your natural beat-1 ack is fine), then ` +
71
+ `wait for it to complete. When it finishes, reply with exactly ` +
72
+ `the single word "done".`;
71
73
 
72
74
  const OVERALL_DEADLINE_MS = (SLEEP_SECONDS + 240) * 1000;
73
75
 
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Visible answer-stream — UAT for the openclaw-pattern TTFO fix
3
+ * (#869 Phase 1 narrow scope).
4
+ *
5
+ * Validates that when `SWITCHROOM_VISIBLE_ANSWER_STREAM=1` is set on
6
+ * the target agent, the framework auto-renders the model's transcript
7
+ * text as a user-visible edit-in-place message starting within ~5s of
8
+ * inbound — instead of writing to Telegram's invisible compose-box
9
+ * draft (the default #1664 behaviour).
10
+ *
11
+ * ## Required setup
12
+ *
13
+ * The target agent (default `test-harness`) MUST have
14
+ * `SWITCHROOM_VISIBLE_ANSWER_STREAM=1` in its container environment.
15
+ * Without that env var the scenario will (correctly) fail — the
16
+ * default behaviour writes to a draft the mtcute driver cannot see.
17
+ *
18
+ * ## What this asserts
19
+ *
20
+ * 1. The first user-visible bot output (fresh `sendMessage`) lands
21
+ * within `VISIBLE_TTFO_BUDGET_MS` (default 8 s) of the inbound.
22
+ * Today's median TTFO across the fleet is 17–69 s; the visible
23
+ * lane should drop it well under 10 s for any reply long enough
24
+ * to emit a text chunk.
25
+ * 2. The initial fresh message is silent (the answer-stream emits
26
+ * with `disable_notification: true` so mid-turn edits never ping).
27
+ * 3. Subsequent edits land on the SAME message_id — single in-place
28
+ * surface, not a chain of pinged sends.
29
+ * 4. At least one edit growth event happens between first send and
30
+ * turn-end (the streaming property — TTFO is fast, then content
31
+ * grows live).
32
+ *
33
+ * The captured trail is dumped to console for forensic inspection
34
+ * regardless of pass/fail.
35
+ *
36
+ * Wall-clock budget: ~90 s.
37
+ */
38
+
39
+ import { describe, expect, it } from "vitest";
40
+ import { spinUp } from "../harness.js";
41
+ import type { ObservedMessage } from "../driver.js";
42
+
43
+ const VISIBLE_TTFO_BUDGET_MS = 8_000;
44
+ const OVERALL_DEADLINE_MS = 90_000;
45
+ const QUIESCENCE_MS = 8_000;
46
+
47
+ // Prompt engineered to make the model emit a multi-sentence answer
48
+ // over a few seconds — long enough that the streaming behaviour
49
+ // is observable, short enough that turn-flush isn't tempted to fire.
50
+ // Deliberately does NOT instruct the model to call `reply` — we want
51
+ // to exercise the transcript-only path that the visible-answer-stream
52
+ // covers.
53
+ const PROMPT =
54
+ `Please give a four-sentence overview of how Linux page-cache ` +
55
+ `interacts with mmap on a typical x86_64 server. Reply in a single ` +
56
+ `message, with substantive prose. No code blocks.`;
57
+
58
+ interface TrailEntry {
59
+ relMs: number;
60
+ kind: "fresh" | "edit";
61
+ silent: boolean;
62
+ messageId: number;
63
+ textPreview: string;
64
+ textLength: number;
65
+ }
66
+
67
+ function pad(s: string, n: number): string {
68
+ return s.length >= n ? s : s + " ".repeat(n - s.length);
69
+ }
70
+
71
+ describe("uat: visible answer-stream — model transcript renders live (#869 Phase 1)", () => {
72
+ it(
73
+ "first fresh message lands within VISIBLE_TTFO_BUDGET_MS; subsequent edits grow it in place",
74
+ async () => {
75
+ const sc = await spinUp({ agent: "test-harness" });
76
+ try {
77
+ const startedAt = Date.now();
78
+ await sc.sendDM(PROMPT);
79
+ console.log(`[visible-answer-stream] t=0 prompt sent`);
80
+
81
+ const trail: TrailEntry[] = [];
82
+ let firstAnchorMsgId: number | null = null;
83
+ let quiescenceDeadline = startedAt + 30_000;
84
+ const overallDeadline = startedAt + OVERALL_DEADLINE_MS;
85
+
86
+ while (Date.now() < overallDeadline) {
87
+ const remaining = Math.min(
88
+ quiescenceDeadline - Date.now(),
89
+ overallDeadline - Date.now(),
90
+ );
91
+ if (remaining <= 0) break;
92
+ try {
93
+ const msg = await sc.expectMessage(
94
+ (m: ObservedMessage) => m.fromBot,
95
+ { from: "bot", timeout: remaining },
96
+ );
97
+ const rel = Date.now() - startedAt;
98
+ const entry: TrailEntry = {
99
+ relMs: rel,
100
+ kind: msg.edited ? "edit" : "fresh",
101
+ silent: msg.silent,
102
+ messageId: msg.messageId,
103
+ textPreview: msg.text
104
+ .slice(0, 120)
105
+ .replace(/\n/g, " ⏎ "),
106
+ textLength: msg.text.length,
107
+ };
108
+ trail.push(entry);
109
+ if (firstAnchorMsgId == null && entry.kind === "fresh") {
110
+ firstAnchorMsgId = entry.messageId;
111
+ }
112
+ console.log(
113
+ `[visible-answer-stream] +${(rel / 1000).toFixed(1)}s ` +
114
+ `${entry.kind.toUpperCase()} msg=${entry.messageId} ` +
115
+ `silent=${entry.silent} len=${entry.textLength} ` +
116
+ `text=${JSON.stringify(entry.textPreview)}`,
117
+ );
118
+ quiescenceDeadline = Date.now() + QUIESCENCE_MS;
119
+ } catch {
120
+ break;
121
+ }
122
+ }
123
+
124
+ console.log("\n========== VISIBLE-ANSWER-STREAM TRAIL ==========");
125
+ console.log(`total bot messages observed: ${trail.length}`);
126
+ console.log(`first anchor messageId: ${firstAnchorMsgId}`);
127
+ console.log("");
128
+ console.log(" rel(s) kind silent msg len text");
129
+ console.log(" ------- ----- ------ ----------- ---- ----");
130
+ for (const e of trail) {
131
+ console.log(
132
+ ` ${pad((e.relMs / 1000).toFixed(1) + "s", 8)} ` +
133
+ `${pad(e.kind, 6)} ${pad(String(e.silent), 7)} ` +
134
+ `${pad(String(e.messageId), 12)} ${pad(String(e.textLength), 5)} ` +
135
+ `${e.textPreview}`,
136
+ );
137
+ }
138
+ console.log("=================================================\n");
139
+
140
+ // ── Regression assertions ─────────────────────────────────
141
+
142
+ const fresh = trail.filter((e) => e.kind === "fresh");
143
+ const edits = trail.filter((e) => e.kind === "edit");
144
+
145
+ // (1) at least one fresh message landed
146
+ expect(
147
+ fresh.length,
148
+ `no fresh bot replies observed — either the agent isn't ` +
149
+ `responding OR the visible-answer-stream flag is OFF ` +
150
+ `(SWITCHROOM_VISIBLE_ANSWER_STREAM not set on the target ` +
151
+ `agent's container env). Re-check the agent's compose ` +
152
+ `environment.`,
153
+ ).toBeGreaterThanOrEqual(1);
154
+
155
+ // (2) first fresh landed within the TTFO budget
156
+ const ttfoMs = fresh[0].relMs;
157
+ expect(
158
+ ttfoMs,
159
+ `TTFO ${ttfoMs}ms exceeded the visible-answer-stream ` +
160
+ `budget of ${VISIBLE_TTFO_BUDGET_MS}ms. Either the model ` +
161
+ `was unusually slow to emit its first text chunk, OR the ` +
162
+ `visible answer-stream is not active. Default behaviour ` +
163
+ `(invisible draft) would never have surfaced a fresh ` +
164
+ `message at all, so the most likely cause is model latency.`,
165
+ ).toBeLessThanOrEqual(VISIBLE_TTFO_BUDGET_MS);
166
+
167
+ // (3) first fresh message was silent (mid-turn edits don't ping)
168
+ expect(
169
+ fresh[0].silent,
170
+ `the first fresh message pinged the user — answer-stream ` +
171
+ `should send silently (disable_notification:true). A ping ` +
172
+ `here means an explicit \`reply\` tool may have fired instead.`,
173
+ ).toBe(true);
174
+
175
+ // (4) at least one in-place EDIT landed on the same messageId
176
+ // (this is the "live streaming" assertion — TTFO is fast AND
177
+ // content grows on the same surface, not a chain of new sends).
178
+ const sameAnchorEdits = edits.filter(
179
+ (e) => e.messageId === firstAnchorMsgId,
180
+ );
181
+ expect(
182
+ sameAnchorEdits.length,
183
+ `no in-place edits to the anchor message landed — the model ` +
184
+ `either replied in a single shot (very short answer) or ` +
185
+ `the streaming path isn't running. Edits observed: ` +
186
+ `${edits.length}, on anchor: ${sameAnchorEdits.length}.`,
187
+ ).toBeGreaterThanOrEqual(1);
188
+
189
+ // (5) every edit is silent (Telegram edits don't push, but
190
+ // we double-check via mtcute's flag in case the framework
191
+ // ever swaps to a fresh-send pattern by accident)
192
+ const loudEdits = edits.filter((e) => !e.silent);
193
+ expect(
194
+ loudEdits.length,
195
+ `${loudEdits.length} edit(s) pinged the device.`,
196
+ ).toBe(0);
197
+
198
+ // (6) text length grows monotonically on the anchor (streaming
199
+ // by construction — once content is on the anchor, it only
200
+ // accumulates)
201
+ const anchorTrail = trail.filter(
202
+ (e) => e.messageId === firstAnchorMsgId,
203
+ );
204
+ for (let i = 1; i < anchorTrail.length; i++) {
205
+ expect(
206
+ anchorTrail[i].textLength,
207
+ `anchor message #${firstAnchorMsgId} text shrank between ` +
208
+ `events ${i - 1} (len=${anchorTrail[i - 1].textLength}) ` +
209
+ `and ${i} (len=${anchorTrail[i].textLength}) — ` +
210
+ `streaming text should only grow.`,
211
+ ).toBeGreaterThanOrEqual(anchorTrail[i - 1].textLength);
212
+ }
213
+ } finally {
214
+ await sc.tearDown();
215
+ }
216
+ },
217
+ OVERALL_DEADLINE_MS + 30_000,
218
+ );
219
+ });