switchroom 0.13.63 → 0.13.64

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.
@@ -3192,6 +3192,26 @@ const ANSWER_STREAM_VISIBLE_ENABLED = (() => {
3192
3192
  if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false
3193
3193
  return true
3194
3194
  })()
3195
+
3196
+ // Draft-mirror preview (RFC docs/rfcs/draft-mirror-preview.md), Phase 1.
3197
+ // When enabled, the model's prose narration streams into the ephemeral
3198
+ // compose-area draft (sendMessageDraft) instead of a visible real
3199
+ // message — a live "what's it doing" preview that clears when the
3200
+ // reply lands. Default OFF (canary flag). When on it (a) forces the
3201
+ // answer-stream onto draft transport regardless of
3202
+ // ANSWER_STREAM_VISIBLE_ENABLED, and (b) suppresses the activity-summary
3203
+ // tool-count draft so the two don't collide on the single per-chat
3204
+ // draft slot. Delivery on a no-reply turn is owned by turn-flush
3205
+ // (decideTurnFlush → capturedText fresh send), NOT answer-stream
3206
+ // materialize() — which is dead on the draft-only path (streamMsgId
3207
+ // stays null, so its turn-end gate is false). Kill switch:
3208
+ // SWITCHROOM_DRAFT_MIRROR unset/0/false/off/no.
3209
+ const DRAFT_MIRROR_ENABLED = (() => {
3210
+ const raw = process.env.SWITCHROOM_DRAFT_MIRROR
3211
+ if (raw == null) return false
3212
+ const v = raw.trim().toLowerCase()
3213
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
3214
+ })()
3195
3215
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3196
3216
  const progressDriver: any = null
3197
3217
  const unpinProgressCardForChat: ((chatId: string, threadId: number | undefined) => void) | null = null
@@ -7110,7 +7130,13 @@ function handleSessionEvent(ev: SessionEvent): void {
7110
7130
  // exactly once at a time and re-running until pending matches
7111
7131
  // the last-sent. Captures `turn` so a late drain after turn-swap
7112
7132
  // can't corrupt the next turn's atom.
7113
- if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
7133
+ // DRAFT_MIRROR (RFC draft-mirror-preview, Phase 1): the model's
7134
+ // prose narration owns the single per-chat draft slot. Suppress
7135
+ // the activity-summary tool-count draft so the two don't collide
7136
+ // (Telegram shows one draft per chat — the later write clobbers
7137
+ // the earlier). The activity-summary code stays intact for the
7138
+ // kill-switch path; it's retired for good only in Phase 4.
7139
+ if (!DRAFT_MIRROR_ENABLED && !turn.replyCalled && !isTelegramSurfaceTool(name)) {
7114
7140
  const rendered = registerAndRender(turn.toolActivity, name)
7115
7141
  if (rendered != null) {
7116
7142
  turn.activityPendingRender = rendered
@@ -7158,13 +7184,20 @@ function handleSessionEvent(ev: SessionEvent): void {
7158
7184
  chatId: turn.sessionChatId,
7159
7185
  isPrivateChat: turn.isDm,
7160
7186
  threadId: turn.sessionThreadId,
7161
- // #869-Phase1 visible-answer-stream: omit the draft API so
7162
- // the lane uses the real sendMessage / editMessageText path
7163
- // and edits a user-visible chat-timeline message instead
7164
- // of the invisible compose-box draft.
7165
- ...(ANSWER_STREAM_VISIBLE_ENABLED
7166
- ? { minInitialChars: 1 }
7167
- : { sendMessageDraft: sendMessageDraftFn }),
7187
+ // Transport selection:
7188
+ // - DRAFT_MIRROR (RFC draft-mirror-preview, Phase 1): force
7189
+ // the ephemeral compose-area draft so narration is a
7190
+ // clears-on-reply preview. Wins over visible-answer-stream.
7191
+ // No-reply delivery is owned by turn-flush, not materialize.
7192
+ // - else #869-Phase1 visible-answer-stream: omit the draft
7193
+ // API so the lane edits a user-visible chat-timeline
7194
+ // message (minInitialChars:1 opens it on the first chunk).
7195
+ // - else legacy: draft transport.
7196
+ ...(DRAFT_MIRROR_ENABLED
7197
+ ? { sendMessageDraft: sendMessageDraftFn }
7198
+ : ANSWER_STREAM_VISIBLE_ENABLED
7199
+ ? { minInitialChars: 1 }
7200
+ : { sendMessageDraft: sendMessageDraftFn }),
7168
7201
  // #1075: route through robustApiCall so flood-wait,
7169
7202
  // benign-400, and THREAD_NOT_FOUND are handled uniformly
7170
7203
  // instead of crashing the answer-stream loop on a deleted
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Greeting reply scenario — driver DMs the test bot a bare "hi", bot
3
+ * MUST reply (not NO_REPLY).
4
+ *
5
+ * Regression gate for the v0.13.61 turn-pacing v3 over-correction: the
6
+ * "don't ack" directive made the model classify a bare greeting as
7
+ * "not substantive" and end the turn with NO_REPLY, leaving the user
8
+ * staring at silence. v4 (this fix) re-asserts "always reply to a
9
+ * direct message; a greeting gets a greeting."
10
+ *
11
+ * Runs against real Telegram. Same env requirements as
12
+ * smoke-dm-reply.test.ts. Invoke via `bun run test:uat greeting`.
13
+ */
14
+
15
+ import { describe, it, expect } from "vitest";
16
+ import { spinUp } from "../harness.js";
17
+
18
+ describe("uat: greeting gets a reply (v4 turn-pacing regression gate)", () => {
19
+ it(
20
+ "driver DMs a bare 'hi' and the bot replies within 60s (not NO_REPLY)",
21
+ async () => {
22
+ const sc = await spinUp({ agent: "test-harness" });
23
+
24
+ try {
25
+ const started = Date.now();
26
+ await sc.sendDM("hi");
27
+
28
+ // A greeting should come back fast — well under the silence-poke
29
+ // soft window. 60s budget tolerates a cold turn but a NO_REPLY
30
+ // (the bug) would blow past it via the 300s framework fallback.
31
+ const reply = await sc.expectMessage(/.+/, {
32
+ from: "bot",
33
+ timeout: 60_000,
34
+ });
35
+ const elapsed = Date.now() - started;
36
+
37
+ expect(reply.text.length).toBeGreaterThan(0);
38
+ expect(reply.senderUserId).toBe(sc.botUserId);
39
+ // The bug path replies only after the 300s framework fallback.
40
+ // A real greeting reply lands fast; assert it beat the fallback.
41
+ expect(elapsed).toBeLessThan(60_000);
42
+ } finally {
43
+ await sc.tearDown();
44
+ }
45
+ },
46
+ 90_000,
47
+ );
48
+ });