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.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +36 -10
- package/telegram-plugin/gateway/gateway.ts +117 -17
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +12 -10
- package/telegram-plugin/uat/scenarios/visible-answer-stream-dm.test.ts +219 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -47331,8 +47331,8 @@ var {
|
|
|
47331
47331
|
} = import__.default;
|
|
47332
47332
|
|
|
47333
47333
|
// src/build-info.ts
|
|
47334
|
-
var VERSION = "0.13.
|
|
47335
|
-
var COMMIT_SHA = "
|
|
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
|
@@ -48154,10 +48154,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48154
48154
|
}
|
|
48155
48155
|
|
|
48156
48156
|
// ../src/build-info.ts
|
|
48157
|
-
var VERSION = "0.13.
|
|
48158
|
-
var COMMIT_SHA = "
|
|
48159
|
-
var COMMIT_DATE = "2026-05-
|
|
48160
|
-
var LATEST_PR =
|
|
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
|
-
|
|
51982
|
-
|
|
51983
|
-
|
|
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
|
-
|
|
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:
|
|
6193
|
-
//
|
|
6194
|
-
//
|
|
6195
|
-
//
|
|
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
|
-
|
|
6199
|
-
|
|
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
|
|
6202
|
-
|
|
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
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
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
|
-
`
|
|
62
|
-
`
|
|
63
|
-
`
|
|
64
|
-
`
|
|
65
|
-
`
|
|
66
|
-
|
|
67
|
-
`
|
|
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
|
+
});
|