switchroom 0.14.69 → 0.14.71
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 +6 -2
- package/package.json +1 -1
- package/telegram-plugin/answer-stream-flag.ts +69 -0
- package/telegram-plugin/dist/gateway/gateway.js +56 -13
- package/telegram-plugin/gateway/gateway.ts +53 -18
- package/telegram-plugin/runtime-metrics.ts +1 -1
- package/telegram-plugin/tests/answer-stream-flag.test.ts +73 -1
- package/telegram-plugin/tests/answer-stream.test.ts +30 -0
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +53 -23
- package/telegram-plugin/tests/text-voice-scrub.test.ts +89 -0
- package/telegram-plugin/text-voice-scrub.ts +78 -17
- package/telegram-plugin/uat/scenarios/fuzz-voice-scrub-dm.test.ts +104 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49601,8 +49601,8 @@ var {
|
|
|
49601
49601
|
} = import__.default;
|
|
49602
49602
|
|
|
49603
49603
|
// src/build-info.ts
|
|
49604
|
-
var VERSION = "0.14.
|
|
49605
|
-
var COMMIT_SHA = "
|
|
49604
|
+
var VERSION = "0.14.71";
|
|
49605
|
+
var COMMIT_SHA = "314a0e0e";
|
|
49606
49606
|
|
|
49607
49607
|
// src/cli/agent.ts
|
|
49608
49608
|
init_source();
|
|
@@ -52176,6 +52176,10 @@ function buildSettingsHooksBlock(p) {
|
|
|
52176
52176
|
|
|
52177
52177
|
` + 'Do NOT send a trailing confirmation after your answer \u2014 no "Done.", ' + '"Sent.", "Hope that helps." as a separate message once you have ' + "already replied. Your answer is the last thing the user should " + `see; a follow-up "Done." is dead-air clutter (and the user's ` + `device already pinged on the answer). Stop after the answer.
|
|
52178
52178
|
|
|
52179
|
+
` + "GROUND BEFORE YOU ASSERT. Any fact in your reply that can change " + "(a number, a status, a price, a date, who-uses-what, anything " + '"current" or "latest") must come from a source you actually checked ' + "THIS turn: your data tool, a file, the web. Memory and what you " + '"already know" are leads to verify, not sources. If you have not ' + "checked it this turn, do not state it as fact: go get it now, or tell " + "the user you will confirm and then do it. A confident wrong number is " + `worse than "let me check".
|
|
52180
|
+
|
|
52181
|
+
` + "VOICE: write like a sharp colleague, not a chatbot. Do not open with " + `affirmation ("You're absolutely right", "Great question", "Great ` + 'catch", "Exactly!"); just answer. Skip AI-tell filler ("smoking ' + `gun", "delve", "it's worth noting", "a testament to", "in today's ` + 'fast-paced..."). Lead with the answer, plain words, kept short. When ' + `the user is wrong, say so directly; flattery is not help.
|
|
52182
|
+
|
|
52179
52183
|
` + 'CRITICAL: "answer" means a call to the reply tool ' + "(mcp__switchroom-telegram__reply, or stream_reply with done=true). " + "Your terminal/transcript text is NEVER delivered to Telegram \u2014 the " + "user sees only what you send through the reply tool. After a long " + "tool sequence (scheduling, multi-step research, sub-agent handback), " + "do not let your closing narration stand as the answer: end the turn " + "by passing that narration to the reply tool. No reply tool call = the " + "user got nothing, however much text you wrote. Call the reply tool as " + "your FIRST action when you have the answer \u2014 do not write it out as " + "transcript text first and call reply afterward: a framework backstop " + "flushes unsent text after a delay and then your real reply lands late " + "and out of order.</turn-pacing>";
|
|
52180
52184
|
const switchroomUserPromptSubmit = [
|
|
52181
52185
|
...useHotReloadStable ? [
|
package/package.json
CHANGED
|
@@ -35,3 +35,72 @@ export function parseDraftLaneRetiredEnabled(raw: string | undefined): boolean {
|
|
|
35
35
|
const v = raw.trim().toLowerCase()
|
|
36
36
|
return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `minInitialChars` sentinel meaning "never open a visible chat-timeline
|
|
41
|
+
* preview" — mirrors the `Number.MAX_SAFE_INTEGER` gate the createAnswerStream
|
|
42
|
+
* call site uses so the lane stays silent.
|
|
43
|
+
*/
|
|
44
|
+
export const ANSWER_LANE_NEVER_OPENS = Number.MAX_SAFE_INTEGER
|
|
45
|
+
|
|
46
|
+
export type AnswerLaneState = 'visible' | 'draft' | 'dormant'
|
|
47
|
+
|
|
48
|
+
export interface AnswerLaneConfig {
|
|
49
|
+
/** `minInitialChars` for createAnswerStream: `1` opens a visible preview on
|
|
50
|
+
* the first text chunk; `ANSWER_LANE_NEVER_OPENS` suppresses it. */
|
|
51
|
+
minInitialChars: number
|
|
52
|
+
/** Whether the lane streams to the invisible compose-box draft transport. */
|
|
53
|
+
usesDraftTransport: boolean
|
|
54
|
+
/** Whether a USER-VISIBLE chat-timeline preview opens — i.e. the surface that
|
|
55
|
+
* flashed (raw preview → formatted reply → preview deleted). This is THE
|
|
56
|
+
* regression invariant: it must equal `visibleEnabled`, never depend on the
|
|
57
|
+
* draft flag. */
|
|
58
|
+
opensVisiblePreview: boolean
|
|
59
|
+
/** Label for the boot log. */
|
|
60
|
+
state: AnswerLaneState
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the answer-lane config from the two INDEPENDENT inputs.
|
|
65
|
+
*
|
|
66
|
+
* The visible PREVIEW (the flash surface) is gated on `visibleEnabled` ALONE;
|
|
67
|
+
* draft retirement controls only the TRANSPORT (whether `sendMessageDraft` is
|
|
68
|
+
* available). Conflating them was the v0.14.68 regression: retiring the draft
|
|
69
|
+
* (the default) forced a visible preview that flashed on every streaming turn,
|
|
70
|
+
* re-opening the flash v0.14.52 had removed. The load-bearing invariant —
|
|
71
|
+
* `opensVisiblePreview === visibleEnabled` for EVERY `draftFnAvailable` — is
|
|
72
|
+
* what this function exists to make total-enumerable (the gateway IIFE is not).
|
|
73
|
+
*
|
|
74
|
+
* visibleEnabled → 'visible' (preview opens, minChars 1)
|
|
75
|
+
* !visible, draft transport available → 'draft' (no preview; draft renders)
|
|
76
|
+
* !visible, no draft transport → 'dormant' (no preview, no draft:
|
|
77
|
+
* the reply tool is the
|
|
78
|
+
* only message — the default)
|
|
79
|
+
*/
|
|
80
|
+
export function resolveAnswerLaneConfig(input: {
|
|
81
|
+
visibleEnabled: boolean
|
|
82
|
+
draftFnAvailable: boolean
|
|
83
|
+
}): AnswerLaneConfig {
|
|
84
|
+
if (input.visibleEnabled) {
|
|
85
|
+
return {
|
|
86
|
+
minInitialChars: 1,
|
|
87
|
+
usesDraftTransport: false,
|
|
88
|
+
opensVisiblePreview: true,
|
|
89
|
+
state: 'visible',
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (input.draftFnAvailable) {
|
|
93
|
+
return {
|
|
94
|
+
minInitialChars: ANSWER_LANE_NEVER_OPENS,
|
|
95
|
+
usesDraftTransport: true,
|
|
96
|
+
opensVisiblePreview: false,
|
|
97
|
+
state: 'draft',
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
minInitialChars: ANSWER_LANE_NEVER_OPENS,
|
|
102
|
+
usesDraftTransport: false,
|
|
103
|
+
opensVisiblePreview: false,
|
|
104
|
+
state: 'dormant',
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -39767,6 +39767,31 @@ function parseDraftLaneRetiredEnabled(raw) {
|
|
|
39767
39767
|
const v = raw.trim().toLowerCase();
|
|
39768
39768
|
return !(v === "0" || v === "false" || v === "off" || v === "no");
|
|
39769
39769
|
}
|
|
39770
|
+
var ANSWER_LANE_NEVER_OPENS = Number.MAX_SAFE_INTEGER;
|
|
39771
|
+
function resolveAnswerLaneConfig(input) {
|
|
39772
|
+
if (input.visibleEnabled) {
|
|
39773
|
+
return {
|
|
39774
|
+
minInitialChars: 1,
|
|
39775
|
+
usesDraftTransport: false,
|
|
39776
|
+
opensVisiblePreview: true,
|
|
39777
|
+
state: "visible"
|
|
39778
|
+
};
|
|
39779
|
+
}
|
|
39780
|
+
if (input.draftFnAvailable) {
|
|
39781
|
+
return {
|
|
39782
|
+
minInitialChars: ANSWER_LANE_NEVER_OPENS,
|
|
39783
|
+
usesDraftTransport: true,
|
|
39784
|
+
opensVisiblePreview: false,
|
|
39785
|
+
state: "draft"
|
|
39786
|
+
};
|
|
39787
|
+
}
|
|
39788
|
+
return {
|
|
39789
|
+
minInitialChars: ANSWER_LANE_NEVER_OPENS,
|
|
39790
|
+
usesDraftTransport: false,
|
|
39791
|
+
opensVisiblePreview: false,
|
|
39792
|
+
state: "dormant"
|
|
39793
|
+
};
|
|
39794
|
+
}
|
|
39770
39795
|
|
|
39771
39796
|
// pty-tail.ts
|
|
39772
39797
|
var import_headless = __toESM(require_xterm_headless(), 1);
|
|
@@ -42518,6 +42543,18 @@ function enabled4() {
|
|
|
42518
42543
|
const v = process.env.SWITCHROOM_DISABLE_VOICE_SCRUB;
|
|
42519
42544
|
return !(v === "1" || v === "true");
|
|
42520
42545
|
}
|
|
42546
|
+
var LEADING_AFFIRMATION_RE = /^(\s*)(you(?:['\u2019]| a)re absolutely right|you(?:['\u2019]| a)re so right|you(?:['\u2019]| a)re absolutely correct|absolutely right|exactly right|great catch|good catch|nice catch|spot on)\b(?:\s*$|\s*[!.,;:\u2014\u2013-][\s!.,;:\u2014\u2013-]*)/i;
|
|
42547
|
+
function stripLeadingAffirmation(text) {
|
|
42548
|
+
const m = LEADING_AFFIRMATION_RE.exec(text);
|
|
42549
|
+
if (!m)
|
|
42550
|
+
return { out: text, count: 0 };
|
|
42551
|
+
const leadingWs = m[1] ?? "";
|
|
42552
|
+
const rest = text.slice(m[0].length);
|
|
42553
|
+
if (rest.trim().length === 0)
|
|
42554
|
+
return { out: text, count: 0 };
|
|
42555
|
+
const recapped = rest.replace(/^(\s*)([a-z])/, (_m, ws, ch) => ws + ch.toUpperCase());
|
|
42556
|
+
return { out: leadingWs + recapped, count: 1 };
|
|
42557
|
+
}
|
|
42521
42558
|
function park(text) {
|
|
42522
42559
|
const parts = [];
|
|
42523
42560
|
let parked = text;
|
|
@@ -42580,14 +42617,16 @@ function replaceDashes(text) {
|
|
|
42580
42617
|
}
|
|
42581
42618
|
function scrubVoice(text) {
|
|
42582
42619
|
if (!enabled4() || text.length === 0) {
|
|
42583
|
-
return { scrubbed: text, replaced: 0 };
|
|
42620
|
+
return { scrubbed: text, replaced: 0, openersStripped: 0 };
|
|
42584
42621
|
}
|
|
42585
42622
|
const { parked, parts } = park(text);
|
|
42586
|
-
const
|
|
42587
|
-
|
|
42588
|
-
|
|
42623
|
+
const opener = stripLeadingAffirmation(parked);
|
|
42624
|
+
const { out, replaced } = replaceDashes(opener.out);
|
|
42625
|
+
const total = replaced + opener.count;
|
|
42626
|
+
if (total === 0) {
|
|
42627
|
+
return { scrubbed: text, replaced: 0, openersStripped: 0 };
|
|
42589
42628
|
}
|
|
42590
|
-
return { scrubbed: restore(out, parts), replaced };
|
|
42629
|
+
return { scrubbed: restore(out, parts), replaced: total, openersStripped: opener.count };
|
|
42591
42630
|
}
|
|
42592
42631
|
|
|
42593
42632
|
// telegram-button-constraints.ts
|
|
@@ -52776,11 +52815,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52776
52815
|
}
|
|
52777
52816
|
|
|
52778
52817
|
// ../src/build-info.ts
|
|
52779
|
-
var VERSION = "0.14.
|
|
52780
|
-
var COMMIT_SHA = "
|
|
52781
|
-
var COMMIT_DATE = "2026-06-
|
|
52782
|
-
var LATEST_PR =
|
|
52783
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
52818
|
+
var VERSION = "0.14.71";
|
|
52819
|
+
var COMMIT_SHA = "314a0e0e";
|
|
52820
|
+
var COMMIT_DATE = "2026-06-05T14:23:58Z";
|
|
52821
|
+
var LATEST_PR = 2181;
|
|
52822
|
+
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52784
52823
|
|
|
52785
52824
|
// gateway/boot-version.ts
|
|
52786
52825
|
function formatRelativeAgo(iso) {
|
|
@@ -55213,6 +55252,10 @@ var STREAM_THROTTLE_MS_OVERRIDE = (() => {
|
|
|
55213
55252
|
})();
|
|
55214
55253
|
var TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled();
|
|
55215
55254
|
var ANSWER_STREAM_VISIBLE_ENABLED = parseVisibleAnswerStreamEnabled(process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM);
|
|
55255
|
+
var ANSWER_LANE = resolveAnswerLaneConfig({
|
|
55256
|
+
visibleEnabled: ANSWER_STREAM_VISIBLE_ENABLED,
|
|
55257
|
+
draftFnAvailable: sendMessageDraftFn != null
|
|
55258
|
+
});
|
|
55216
55259
|
var CLEAR_STATUS_ON_COMPLETION = (() => {
|
|
55217
55260
|
const raw = process.env.SWITCHROOM_TG_CLEAR_STATUS_ON_COMPLETION;
|
|
55218
55261
|
if (raw == null)
|
|
@@ -58151,7 +58194,7 @@ function handleSessionEvent(ev) {
|
|
|
58151
58194
|
chatId: turn.sessionChatId,
|
|
58152
58195
|
isPrivateChat: turn.isDm,
|
|
58153
58196
|
threadId: turn.sessionThreadId,
|
|
58154
|
-
...
|
|
58197
|
+
...ANSWER_LANE.usesDraftTransport ? { sendMessageDraft: sendMessageDraftFn, minInitialChars: ANSWER_LANE.minInitialChars } : { minInitialChars: ANSWER_LANE.minInitialChars },
|
|
58155
58198
|
sendMessage: async (chatId, text, params) => {
|
|
58156
58199
|
const tid = params?.message_thread_id;
|
|
58157
58200
|
const silent = params?.purpose !== "materialize";
|
|
@@ -58283,7 +58326,7 @@ function handleSessionEvent(ev) {
|
|
|
58283
58326
|
const stream = turn.answerStream;
|
|
58284
58327
|
const streamedMsgId = stream.messageId();
|
|
58285
58328
|
const streamedFinalText = turn.capturedText.join("").trim();
|
|
58286
|
-
if (
|
|
58329
|
+
if (ANSWER_LANE.opensVisiblePreview && !turn.replyCalled && streamedMsgId != null && streamedFinalText.length > 0) {
|
|
58287
58330
|
turn.answerStream = null;
|
|
58288
58331
|
streamFinalizedAsAnswer = true;
|
|
58289
58332
|
turn.finalAnswerDelivered = true;
|
|
@@ -64619,7 +64662,7 @@ var didOneTimeSetup = false;
|
|
|
64619
64662
|
}
|
|
64620
64663
|
}
|
|
64621
64664
|
}
|
|
64622
|
-
process.stderr.write(`telegram gateway: answer-stream lane=${
|
|
64665
|
+
process.stderr.write(`telegram gateway: answer-stream lane=${ANSWER_LANE.state} draftFn=${sendMessageDraftFn != null ? "available" : "off"} visible=${ANSWER_STREAM_VISIBLE_ENABLED} draftRetired=${DRAFT_ANSWER_LANE_RETIRED} grammy=${GRAMMY_VERSION}
|
|
64623
64666
|
`);
|
|
64624
64667
|
process.stderr.write(`telegram gateway: starting bot polling pid=${process.pid} agent=${process.env.SWITCHROOM_AGENT_NAME ?? "-"} stateDir=${STATE_DIR} historyEnabled=${HISTORY_ENABLED} streamMode=${process.env.SWITCHROOM_TG_STREAM_MODE ?? "checklist"}
|
|
64625
64668
|
`);
|
|
@@ -98,7 +98,7 @@ import * as pendingProgress from '../pending-work-progress.js'
|
|
|
98
98
|
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
|
|
99
99
|
import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
|
|
100
100
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
101
|
-
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled } from '../answer-stream-flag.js'
|
|
101
|
+
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled, resolveAnswerLaneConfig } from '../answer-stream-flag.js'
|
|
102
102
|
import { type SessionEvent } from '../session-tail.js'
|
|
103
103
|
import {
|
|
104
104
|
shouldSuppressToolActivity,
|
|
@@ -4589,6 +4589,16 @@ const TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled()
|
|
|
4589
4589
|
const ANSWER_STREAM_VISIBLE_ENABLED = parseVisibleAnswerStreamEnabled(
|
|
4590
4590
|
process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM,
|
|
4591
4591
|
)
|
|
4592
|
+
// Single source of truth for the answer-lane behaviour (flash-decouple,
|
|
4593
|
+
// 2026-06-05). The visible preview gates on the visible flag ALONE; the draft
|
|
4594
|
+
// flag controls only the transport. Resolved here once and consulted at the
|
|
4595
|
+
// createAnswerStream config, the materialize-as-answer guard, and the boot log,
|
|
4596
|
+
// so all three can never drift back into the `visible || retired` conflation
|
|
4597
|
+
// that re-opened the flash. Total-enumerated in answer-stream-flag.test.ts.
|
|
4598
|
+
const ANSWER_LANE = resolveAnswerLaneConfig({
|
|
4599
|
+
visibleEnabled: ANSWER_STREAM_VISIBLE_ENABLED,
|
|
4600
|
+
draftFnAvailable: sendMessageDraftFn != null,
|
|
4601
|
+
})
|
|
4592
4602
|
|
|
4593
4603
|
// Whether to DELETE the activity/status feed when the final answer lands.
|
|
4594
4604
|
// Default OFF (2026-06-04, operator request): the status message stays in the
|
|
@@ -9673,15 +9683,29 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
9673
9683
|
// General). With the gate unreachable the only posted message is
|
|
9674
9684
|
// the canonical reply. (The gate is bypassed for DM draft
|
|
9675
9685
|
// transport, so DM draft streaming is unaffected.)
|
|
9676
|
-
//
|
|
9677
|
-
//
|
|
9678
|
-
//
|
|
9679
|
-
//
|
|
9680
|
-
//
|
|
9681
|
-
//
|
|
9682
|
-
|
|
9683
|
-
|
|
9684
|
-
|
|
9686
|
+
// VISIBLE preview gating decoupled from the draft-transport flag
|
|
9687
|
+
// (2026-06-05 flash regression fix). The visible flag ALONE decides
|
|
9688
|
+
// whether a user-visible preview opens; DRAFT_ANSWER_LANE_RETIRED
|
|
9689
|
+
// controls only the TRANSPORT (whether sendMessageDraftFn exists).
|
|
9690
|
+
// The earlier `|| DRAFT_ANSWER_LANE_RETIRED` here meant retiring the
|
|
9691
|
+
// draft (the default since v0.14.68) silently forced minInitialChars:1
|
|
9692
|
+
// → a visible preliminary opened on every streaming turn and was then
|
|
9693
|
+
// retracted (deleted) when the reply tool fired — the exact "raw bubble
|
|
9694
|
+
// appears, formatted reply lands, raw bubble vanishes" flash that
|
|
9695
|
+
// turning the visible stream OFF (v0.14.52) was meant to remove. So
|
|
9696
|
+
// v0.14.68 silently undid v0.14.52 fleet-wide. Now:
|
|
9697
|
+
// - VISIBLE on (opt-in) → minInitialChars:1, a real edit-in-place
|
|
9698
|
+
// preview (observable by UAT, silence-liveness reset on its sends).
|
|
9699
|
+
// - VISIBLE off (default) → minInitialChars:MAX so NO visible preview
|
|
9700
|
+
// ever opens; the reply tool is the single canonical formatted
|
|
9701
|
+
// message (no flash). With the draft retired (default) there is no
|
|
9702
|
+
// transport either, so the lane stays dormant; with the kill switch
|
|
9703
|
+
// DRAFT_ANSWER_LANE=0 the legacy compose-box draft transport is
|
|
9704
|
+
// restored (sendMessageDraftFn defined above, gate bypassed for DM
|
|
9705
|
+
// draft so #1664 DM draft streaming is unaffected).
|
|
9706
|
+
...(ANSWER_LANE.usesDraftTransport
|
|
9707
|
+
? { sendMessageDraft: sendMessageDraftFn, minInitialChars: ANSWER_LANE.minInitialChars }
|
|
9708
|
+
: { minInitialChars: ANSWER_LANE.minInitialChars }),
|
|
9685
9709
|
// #1075: route through robustApiCall so flood-wait,
|
|
9686
9710
|
// benign-400, and THREAD_NOT_FOUND are handled uniformly
|
|
9687
9711
|
// instead of crashing the answer-stream loop on a deleted
|
|
@@ -9969,13 +9993,18 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
9969
9993
|
const streamedMsgId = stream.messageId()
|
|
9970
9994
|
const streamedFinalText = turn.capturedText.join('').trim()
|
|
9971
9995
|
if (
|
|
9972
|
-
//
|
|
9973
|
-
//
|
|
9974
|
-
// delete the preview
|
|
9975
|
-
//
|
|
9976
|
-
//
|
|
9977
|
-
//
|
|
9978
|
-
(
|
|
9996
|
+
// Only when a VISIBLE preview actually opened (visible flag on): a
|
|
9997
|
+
// text-only no-reply turn that streamed a visible preview must
|
|
9998
|
+
// materialize a pinged final answer + delete the preview, NOT fall into
|
|
9999
|
+
// the else-branch retract() which would delete the user's only copy of
|
|
10000
|
+
// the answer (a lost-answer bug). Gated on the visible flag alone (the
|
|
10001
|
+
// flash-regression decoupling): with the visible stream OFF (default)
|
|
10002
|
+
// no preview opens (minInitialChars:MAX), so streamedMsgId is null and
|
|
10003
|
+
// this branch is unreachable — the no-reply answer is delivered by the
|
|
10004
|
+
// turn-flush backstop below instead, the pre-v0.14.68 path. The
|
|
10005
|
+
// reply-tool branch hits retract() on a non-opened lane (a no-op), so
|
|
10006
|
+
// there is no preliminary to flash.
|
|
10007
|
+
ANSWER_LANE.opensVisiblePreview
|
|
9979
10008
|
&& !turn.replyCalled
|
|
9980
10009
|
&& streamedMsgId != null
|
|
9981
10010
|
&& streamedFinalText.length > 0
|
|
@@ -20705,7 +20734,13 @@ void (async () => {
|
|
|
20705
20734
|
}
|
|
20706
20735
|
}
|
|
20707
20736
|
|
|
20708
|
-
|
|
20737
|
+
// Lane state (post flash-decouple): VISIBLE only when the visible flag is
|
|
20738
|
+
// Lane state from the single-source-of-truth resolver: 'visible' (preview
|
|
20739
|
+
// on), 'draft' (compose-box transport), or 'dormant' (the default: no
|
|
20740
|
+
// preview, no draft — reply tool is the only message). The old label
|
|
20741
|
+
// wrongly reported 'visible(draft-retired)' for the dormant default, which
|
|
20742
|
+
// masked the flash regression.
|
|
20743
|
+
process.stderr.write(`telegram gateway: answer-stream lane=${ANSWER_LANE.state} draftFn=${sendMessageDraftFn != null ? 'available' : 'off'} visible=${ANSWER_STREAM_VISIBLE_ENABLED} draftRetired=${DRAFT_ANSWER_LANE_RETIRED} grammy=${GRAMMY_VERSION}\n`)
|
|
20709
20744
|
process.stderr.write(`telegram gateway: starting bot polling pid=${process.pid} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} stateDir=${STATE_DIR} historyEnabled=${HISTORY_ENABLED} streamMode=${process.env.SWITCHROOM_TG_STREAM_MODE ?? 'checklist'}\n`)
|
|
20710
20745
|
runnerHandle = run(bot, {
|
|
20711
20746
|
runner: {
|
|
@@ -124,7 +124,7 @@ export type RuntimeMetricEvent =
|
|
|
124
124
|
* losing ground; a per-agent spike is prompt drift on that agent.
|
|
125
125
|
*
|
|
126
126
|
* chatKey → `<chatId>:<threadIdOrEmpty>` (statusKey shape)
|
|
127
|
-
* replaced →
|
|
127
|
+
* replaced → total voice changes in this message (dash rewrites + leading-affirmation strips)
|
|
128
128
|
* site → which reply path saw the scrub (executeReply / edit / answer-stream)
|
|
129
129
|
*/
|
|
130
130
|
| {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect } from 'vitest'
|
|
9
|
-
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled } from '../answer-stream-flag.js'
|
|
9
|
+
import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled, resolveAnswerLaneConfig } from '../answer-stream-flag.js'
|
|
10
10
|
|
|
11
11
|
describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
|
|
12
12
|
it('defaults OFF when unset', () => {
|
|
@@ -43,3 +43,75 @@ describe('parseDraftLaneRetiredEnabled — default RETIRED (2026-06-05), kill-sw
|
|
|
43
43
|
}
|
|
44
44
|
})
|
|
45
45
|
})
|
|
46
|
+
|
|
47
|
+
// ── resolveAnswerLaneConfig — TOTAL-ENUMERATION REGRESSION PROOF ─────────────
|
|
48
|
+
//
|
|
49
|
+
// This is the behavioural guard for the flash regression (the gateway IIFE is
|
|
50
|
+
// not importable, so the decision lives in this pure function and the gateway
|
|
51
|
+
// delegates to it). The input space is finite — visibleEnabled × draftFnAvailable
|
|
52
|
+
// = 4 — so we enumerate ALL of it and assert the full decision table plus the
|
|
53
|
+
// load-bearing INVARIANT: opensVisiblePreview === visibleEnabled, ALWAYS. That
|
|
54
|
+
// invariant is exactly what v0.14.68 broke (it made the preview depend on the
|
|
55
|
+
// draft flag), so a future change that re-conflates them fails here, not in prod.
|
|
56
|
+
describe('resolveAnswerLaneConfig — total enumeration (flash-regression proof)', () => {
|
|
57
|
+
const MAX = Number.MAX_SAFE_INTEGER
|
|
58
|
+
const ALL = [
|
|
59
|
+
{ visibleEnabled: false, draftFnAvailable: false }, // the DEFAULT (visible off, draft retired)
|
|
60
|
+
{ visibleEnabled: false, draftFnAvailable: true }, // draft kill switch on
|
|
61
|
+
{ visibleEnabled: true, draftFnAvailable: false }, // opt-in visible
|
|
62
|
+
{ visibleEnabled: true, draftFnAvailable: true }, // visible wins over draft
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
it('the input space is exactly 4 rows (2×2)', () => {
|
|
66
|
+
expect(ALL.length).toBe(4)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('INVARIANT (the regression guard): opensVisiblePreview === visibleEnabled for EVERY draftFnAvailable', () => {
|
|
70
|
+
for (const input of ALL) {
|
|
71
|
+
expect(resolveAnswerLaneConfig(input).opensVisiblePreview).toBe(input.visibleEnabled)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('TOTAL: every input returns a defined config and never throws', () => {
|
|
76
|
+
for (const input of ALL) {
|
|
77
|
+
expect(() => resolveAnswerLaneConfig(input)).not.toThrow()
|
|
78
|
+
expect(resolveAnswerLaneConfig(input).state).toBeDefined()
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('DEFAULT (visible off, draft retired) → DORMANT: no preview, no draft, MAX gate (no flash)', () => {
|
|
83
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false })).toEqual({
|
|
84
|
+
minInitialChars: MAX,
|
|
85
|
+
usesDraftTransport: false,
|
|
86
|
+
opensVisiblePreview: false,
|
|
87
|
+
state: 'dormant',
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('visible off + draft transport available → DRAFT: no visible preview, draft renders', () => {
|
|
92
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: true })).toEqual({
|
|
93
|
+
minInitialChars: MAX,
|
|
94
|
+
usesDraftTransport: true,
|
|
95
|
+
opensVisiblePreview: false,
|
|
96
|
+
state: 'draft',
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('visible on → VISIBLE: preview opens on the first chunk (minChars 1), no draft', () => {
|
|
101
|
+
for (const draftFnAvailable of [false, true]) {
|
|
102
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: true, draftFnAvailable })).toEqual({
|
|
103
|
+
minInitialChars: 1,
|
|
104
|
+
usesDraftTransport: false,
|
|
105
|
+
opensVisiblePreview: true,
|
|
106
|
+
state: 'visible',
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('a visible preview NEVER opens unless explicitly enabled (no draftFnAvailable forces it on)', () => {
|
|
112
|
+
// The exact v0.14.68 failure shape: retiring the draft (draftFnAvailable=false)
|
|
113
|
+
// must NOT open a visible preview.
|
|
114
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false }).opensVisiblePreview).toBe(false)
|
|
115
|
+
expect(resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false }).minInitialChars).toBe(MAX)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
DRAFT_METHOD_UNAVAILABLE_RE,
|
|
7
7
|
DRAFT_CHAT_UNSUPPORTED_RE,
|
|
8
8
|
} from '../answer-stream.js'
|
|
9
|
+
import { resolveAnswerLaneConfig, ANSWER_LANE_NEVER_OPENS } from '../answer-stream-flag.js'
|
|
9
10
|
|
|
10
11
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
11
12
|
|
|
@@ -96,6 +97,35 @@ describe('answer-stream — minInitialChars threshold', () => {
|
|
|
96
97
|
expect(editMessageText).not.toHaveBeenCalled()
|
|
97
98
|
})
|
|
98
99
|
|
|
100
|
+
it('FLASH REGRESSION: the DORMANT config (visible off, draft retired) sends NOTHING, even for a full answer', async () => {
|
|
101
|
+
// End-to-end proof that the resolved default config produces no visible
|
|
102
|
+
// preview → nothing to retract → no flash. Wires the ACTUAL resolver output
|
|
103
|
+
// (not a hand-picked threshold) into the stream.
|
|
104
|
+
const lane = resolveAnswerLaneConfig({ visibleEnabled: false, draftFnAvailable: false })
|
|
105
|
+
expect(lane.state).toBe('dormant')
|
|
106
|
+
expect(lane.minInitialChars).toBe(ANSWER_LANE_NEVER_OPENS)
|
|
107
|
+
|
|
108
|
+
const sendMessage = makeSendMessage()
|
|
109
|
+
const editMessageText = makeEditMessageText()
|
|
110
|
+
const stream = createAnswerStream({
|
|
111
|
+
chatId: 'chat1',
|
|
112
|
+
isPrivateChat: false,
|
|
113
|
+
minInitialChars: lane.minInitialChars,
|
|
114
|
+
throttleMs: 250,
|
|
115
|
+
sendMessage,
|
|
116
|
+
editMessageText,
|
|
117
|
+
// no sendMessageDraft — dormant lane has no transport
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// A realistic full answer — 2000 chars, far above any normal threshold.
|
|
121
|
+
stream.update('The answer is '.repeat(150))
|
|
122
|
+
vi.advanceTimersByTime(5000)
|
|
123
|
+
await flushMicrotasks()
|
|
124
|
+
|
|
125
|
+
expect(sendMessage).not.toHaveBeenCalled()
|
|
126
|
+
expect(editMessageText).not.toHaveBeenCalled()
|
|
127
|
+
})
|
|
128
|
+
|
|
99
129
|
it('calls transport exactly once when text meets minInitialChars', async () => {
|
|
100
130
|
const sendMessage = makeSendMessage()
|
|
101
131
|
const editMessageText = makeEditMessageText()
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Answer-lane wiring guards — draft retirement + flash decoupling.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* History:
|
|
5
|
+
* - v0.14.52 turned the VISIBLE answer-stream OFF by default to remove the
|
|
6
|
+
* "raw preview appears, formatted reply lands, raw preview deleted" flash.
|
|
7
|
+
* - v0.14.68 retired the invisible compose-box DRAFT transport. The original
|
|
8
|
+
* wiring (and the first version of this test) conflated the two flags —
|
|
9
|
+
* `ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED` — so retiring
|
|
10
|
+
* the draft (default) silently forced a VISIBLE preview (minInitialChars:1)
|
|
11
|
+
* even with the visible flag off, re-introducing the flash FLEET-WIDE
|
|
12
|
+
* (v0.14.68 undid v0.14.52). The first revision of this file pinned that
|
|
13
|
+
* conflation as a "guard" — i.e. it asserted the bug.
|
|
14
|
+
* - 2026-06-05 flash-decouple: the VISIBLE preview now gates on the visible
|
|
15
|
+
* flag ALONE; DRAFT_ANSWER_LANE_RETIRED controls only the TRANSPORT. With
|
|
16
|
+
* defaults (visible off, draft retired) the lane is DORMANT — no preview, the
|
|
17
|
+
* reply tool is the single formatted message, no flash. The no-reply text-only
|
|
18
|
+
* answer is delivered by the turn-flush backstop (the pre-v0.14.68 path).
|
|
9
19
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* answer-lane status AND the #2169 onMetric silence-liveness reset.
|
|
13
|
-
* SECONDARY: flip the lane to visible but FORGET to broaden the
|
|
14
|
-
* materialize-as-answer guard → a text-only no-reply turn falls into retract()
|
|
15
|
-
* and deletes the user's only copy of the answer (a lost-answer bug).
|
|
20
|
+
* gateway IIFE can't be instantiated in-process, so these are source-level
|
|
21
|
+
* assertions (same pattern as silence-liveness-wiring.test).
|
|
16
22
|
*/
|
|
17
23
|
import { describe, it, expect } from 'vitest'
|
|
18
24
|
import { readFileSync } from 'node:fs'
|
|
@@ -20,8 +26,8 @@ import { resolve } from 'node:path'
|
|
|
20
26
|
|
|
21
27
|
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
22
28
|
|
|
23
|
-
describe('draft
|
|
24
|
-
it('sendMessageDraftFn is gated on the retirement (the single chokepoint)', () => {
|
|
29
|
+
describe('answer-lane wiring (draft retirement + flash decoupling)', () => {
|
|
30
|
+
it('sendMessageDraftFn is gated on the retirement (the single transport chokepoint)', () => {
|
|
25
31
|
expect(gatewaySrc).toMatch(/!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === 'function'/)
|
|
26
32
|
})
|
|
27
33
|
|
|
@@ -32,19 +38,43 @@ describe('draft-retirement wiring', () => {
|
|
|
32
38
|
expect(firstUseIdx).toBeGreaterThan(declIdx)
|
|
33
39
|
})
|
|
34
40
|
|
|
35
|
-
it('
|
|
36
|
-
// The config
|
|
37
|
-
//
|
|
38
|
-
|
|
41
|
+
it('the lane behaviour is resolved by the single-source-of-truth pure function', () => {
|
|
42
|
+
// The flag→config decision lives in resolveAnswerLaneConfig (total-enumerated
|
|
43
|
+
// in answer-stream-flag.test.ts); the gateway delegates so the three use-sites
|
|
44
|
+
// can never drift apart.
|
|
45
|
+
expect(gatewaySrc).toMatch(/const ANSWER_LANE = resolveAnswerLaneConfig\(\{/)
|
|
46
|
+
expect(gatewaySrc).toMatch(/visibleEnabled: ANSWER_STREAM_VISIBLE_ENABLED/)
|
|
47
|
+
expect(gatewaySrc).toMatch(/draftFnAvailable: sendMessageDraftFn != null/)
|
|
39
48
|
})
|
|
40
49
|
|
|
41
|
-
it('
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
expect(gatewaySrc).toMatch(
|
|
50
|
+
it('FLASH GUARD: the createAnswerStream config is driven by ANSWER_LANE (preview gated on the visible flag alone)', () => {
|
|
51
|
+
// The minInitialChars / draft-transport choice comes from the resolved lane,
|
|
52
|
+
// never from a `visible || retired` inline conflation.
|
|
53
|
+
expect(gatewaySrc).toMatch(/\.\.\.\(ANSWER_LANE\.usesDraftTransport/)
|
|
54
|
+
expect(gatewaySrc).toMatch(/minInitialChars: ANSWER_LANE\.minInitialChars/)
|
|
45
55
|
})
|
|
46
56
|
|
|
47
|
-
it('
|
|
57
|
+
it('FLASH GUARD: the visible-OR-retired conflation is GONE everywhere (it pinned the flash)', () => {
|
|
58
|
+
// No answer-lane path may re-introduce `VISIBLE || RETIRED` — that is the
|
|
59
|
+
// exact regression this fix removes.
|
|
60
|
+
expect(gatewaySrc).not.toMatch(/ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('LOST-ANSWER GUARD: materialize-as-answer gates on ANSWER_LANE.opensVisiblePreview (only fires when a preview actually opened)', () => {
|
|
64
|
+
// A text-only no-reply turn materializes (ping + keep) ONLY when a visible
|
|
65
|
+
// preview opened. With visible off the lane is dormant (minInitialChars:MAX),
|
|
66
|
+
// so this branch is unreachable and the answer is delivered by turn-flush
|
|
67
|
+
// instead — never retracted away.
|
|
68
|
+
expect(gatewaySrc).toMatch(/\n\s*ANSWER_LANE\.opensVisiblePreview\s*\n\s*&& !turn\.replyCalled/)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('LOST-ANSWER GUARD: turn-flush is skipped ONLY when the stream finalized as the answer (so dormant-lane no-reply turns still flush)', () => {
|
|
72
|
+
// flushDecision skips only on streamFinalizedAsAnswer; with the lane dormant
|
|
73
|
+
// that stays false, so decideTurnFlush runs and delivers the no-reply answer.
|
|
74
|
+
expect(gatewaySrc).toMatch(/const flushDecision = streamFinalizedAsAnswer\s*\n?\s*\?/)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('the #2169 onMetric silence-liveness reset is still wired (fires on visible sends when opted in)', () => {
|
|
48
78
|
const onMetric = (gatewaySrc.split('onMetric: (metricEv) => {')[1] ?? '').split('\n },')[0]
|
|
49
79
|
expect(onMetric).toMatch(/silencePoke\.noteProduction/)
|
|
50
80
|
expect(onMetric).toMatch(/currentTurn === turn/)
|
|
@@ -172,3 +172,92 @@ describe('scrubVoice — em / en dash replacement', () => {
|
|
|
172
172
|
})
|
|
173
173
|
})
|
|
174
174
|
})
|
|
175
|
+
|
|
176
|
+
describe('scrubVoice — leading sycophancy openers', () => {
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
delete process.env.SWITCHROOM_DISABLE_VOICE_SCRUB
|
|
179
|
+
})
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
delete process.env.SWITCHROOM_DISABLE_VOICE_SCRUB
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('strips a leading "You\'re absolutely right" and recapitalizes', () => {
|
|
185
|
+
const r = scrubVoice("You're absolutely right, the build is broken.")
|
|
186
|
+
expect(r.scrubbed).toBe('The build is broken.')
|
|
187
|
+
expect(r.openersStripped).toBe(1)
|
|
188
|
+
expect(r.replaced).toBeGreaterThan(0) // total counts the opener
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('strips the affirmation even when only an opener changed (no dashes)', () => {
|
|
192
|
+
// Regression: the gateway gates on `replaced > 0`; an opener-only
|
|
193
|
+
// strip MUST still report replaced > 0 or the scrub is discarded.
|
|
194
|
+
const r = scrubVoice('Great catch! I fixed the off-by-one.')
|
|
195
|
+
expect(r.scrubbed).toBe('I fixed the off-by-one.')
|
|
196
|
+
expect(r.replaced).toBe(1)
|
|
197
|
+
expect(r.openersStripped).toBe(1)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('consumes a trailing em-dash after the opener (no leftover dash)', () => {
|
|
201
|
+
const r = scrubVoice('Exactly right — the token had expired.')
|
|
202
|
+
expect(r.scrubbed).toBe('The token had expired.')
|
|
203
|
+
expect(r.openersStripped).toBe(1)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('handles curly apostrophe and "you are" form', () => {
|
|
207
|
+
expect(scrubVoice('You’re absolutely right. Done.').scrubbed).toBe('Done.')
|
|
208
|
+
expect(scrubVoice('You are absolutely right, done.').scrubbed).toBe('Done.')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('leaves a standalone affirmation ack intact (no content follows)', () => {
|
|
212
|
+
const r = scrubVoice("You're absolutely right!")
|
|
213
|
+
expect(r.scrubbed).toBe("You're absolutely right!")
|
|
214
|
+
expect(r.openersStripped).toBe(0)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('does NOT strip bare "you\'re right" (often load-bearing)', () => {
|
|
218
|
+
const r = scrubVoice("You're right that the config drifted.")
|
|
219
|
+
expect(r.scrubbed).toBe("You're right that the config drifted.")
|
|
220
|
+
expect(r.openersStripped).toBe(0)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('does NOT strip an affirmation mid-message', () => {
|
|
224
|
+
const r = scrubVoice('I checked the logs. Great catch on the typo.')
|
|
225
|
+
expect(r.scrubbed).toBe('I checked the logs. Great catch on the typo.')
|
|
226
|
+
expect(r.openersStripped).toBe(0)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('does NOT over-strip when the phrase is a literal sentence start (no separator)', () => {
|
|
230
|
+
// The affirmation must be followed by a separator/end, not a bare
|
|
231
|
+
// space into more words — otherwise "Spot on the map..." loses "Spot
|
|
232
|
+
// on". These are real sentences, not detachable affirmations.
|
|
233
|
+
for (const s of [
|
|
234
|
+
'Spot on the map shows three sites.',
|
|
235
|
+
'Good catch basin overflow is the root cause.',
|
|
236
|
+
'Exactly right now, the count is 3.',
|
|
237
|
+
'Absolutely right turns are banned on that road.',
|
|
238
|
+
]) {
|
|
239
|
+
const r = scrubVoice(s)
|
|
240
|
+
expect(r.scrubbed, s).toBe(s)
|
|
241
|
+
expect(r.openersStripped, s).toBe(0)
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('still strips when a separator follows (comma / period / dash)', () => {
|
|
246
|
+
expect(scrubVoice('Spot on, the value is 5.').scrubbed).toBe('The value is 5.')
|
|
247
|
+
expect(scrubVoice('Good catch. Fixed it.').scrubbed).toBe('Fixed it.')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('does not touch an opener-like phrase inside code', () => {
|
|
251
|
+
const r = scrubVoice('`spot on` is the variable name. Here is the value.')
|
|
252
|
+
expect(r.scrubbed).toContain('`spot on`')
|
|
253
|
+
expect(r.openersStripped).toBe(0)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('kill switch disables opener strip too', () => {
|
|
257
|
+
process.env.SWITCHROOM_DISABLE_VOICE_SCRUB = '1'
|
|
258
|
+
const r = scrubVoice("You're absolutely right, the build is broken.")
|
|
259
|
+
expect(r.scrubbed).toBe("You're absolutely right, the build is broken.")
|
|
260
|
+
expect(r.replaced).toBe(0)
|
|
261
|
+
expect(r.openersStripped).toBe(0)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -12,13 +12,22 @@
|
|
|
12
12
|
* owns enforcement, soft instructions fail under load. Make the
|
|
13
13
|
* framework do it.
|
|
14
14
|
*
|
|
15
|
-
* Scope.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
15
|
+
* Scope. Two mechanical transforms, both semantically safe:
|
|
16
|
+
* 1. Em / en dashes -> comma/period/hyphen. Pure transform with no
|
|
17
|
+
* semantic loss on whitespace-separated prose; a no-op inside code
|
|
18
|
+
* or a URL.
|
|
19
|
+
* 2. Leading sycophancy openers ("You're absolutely right", "Great
|
|
20
|
+
* catch", "Exactly right") -> deleted, next word recapitalized. A
|
|
21
|
+
* leading pure-affirmation clause carries near-zero meaning, so
|
|
22
|
+
* removing it strips the AI-tell without touching the substance.
|
|
23
|
+
* Conservative by construction: only at the very start, only the
|
|
24
|
+
* known affirmation set, only when real content follows (a
|
|
25
|
+
* standalone "You're absolutely right!" ack is left intact).
|
|
26
|
+
*
|
|
27
|
+
* Still scoped OUT: the wider mid-sentence "AI-tell phrase denylist"
|
|
28
|
+
* (smoking gun, delve, etc.). Substituting those mid-clause risks
|
|
29
|
+
* semantic loss, so they stay with the prompt-side voice guidance
|
|
30
|
+
* (the turn-pacing VOICE directive), not this mechanical gate.
|
|
22
31
|
*
|
|
23
32
|
* Pipeline integration. Apply BEFORE markdownToHtml so the scrub
|
|
24
33
|
* runs on the original model text, not on rendered HTML where
|
|
@@ -46,10 +55,17 @@ export interface VoiceScrubResult {
|
|
|
46
55
|
/** The scrubbed text. Equal to input when no replacements made or
|
|
47
56
|
* when the kill switch is set. */
|
|
48
57
|
scrubbed: string
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
58
|
+
/** TOTAL voice changes across the whole input = dash replacements +
|
|
59
|
+
* leading-affirmation strips. Callers gate on `replaced > 0` to decide
|
|
60
|
+
* whether to apply `scrubbed`, so this MUST count every change (an
|
|
61
|
+
* opener-only strip with zero dashes still needs `replaced > 0`).
|
|
62
|
+
* Surfaces to the runtime-metrics fan-out as the fleet voice-scrub
|
|
63
|
+
* rate. */
|
|
52
64
|
replaced: number
|
|
65
|
+
/** Breakdown: leading sycophancy openers stripped (subset of
|
|
66
|
+
* `replaced`). Lets the dashboard separate opener-strips from dash
|
|
67
|
+
* fixes. */
|
|
68
|
+
openersStripped: number
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
const NULL = '\x00'
|
|
@@ -66,6 +82,44 @@ function enabled(): boolean {
|
|
|
66
82
|
return !(v === '1' || v === 'true')
|
|
67
83
|
}
|
|
68
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Leading sycophancy/affirmation openers. Matched ONLY at the very start
|
|
87
|
+
* of the message, ONLY this known pure-filler set, and the trailing
|
|
88
|
+
* punctuation/separators (incl. em/en dash) are consumed with it.
|
|
89
|
+
*
|
|
90
|
+
* Deliberately excludes bare "you're right" (often load-bearing, e.g.
|
|
91
|
+
* "you're right that X") and "great/good question" (overlaps the
|
|
92
|
+
* legitimate short-ack pattern). Kept to phrases whose only content is
|
|
93
|
+
* the affirmation itself. Apostrophe matches straight or curly.
|
|
94
|
+
*
|
|
95
|
+
* The affirmation must be followed by end-of-string OR a clause/sentence
|
|
96
|
+
* separator (punctuation, possibly with surrounding whitespace) — NOT a
|
|
97
|
+
* bare space into more words. This is what stops over-strips like
|
|
98
|
+
* "Spot on the map shows...", "Good catch basin overflow...", "Exactly
|
|
99
|
+
* right now, the count is 3" — there the phrase is a literal sentence
|
|
100
|
+
* start, not a detachable affirmation. "Spot on, the value is 5" (comma)
|
|
101
|
+
* still strips.
|
|
102
|
+
*/
|
|
103
|
+
const LEADING_AFFIRMATION_RE =
|
|
104
|
+
/^(\s*)(you(?:['’]| a)re absolutely right|you(?:['’]| a)re so right|you(?:['’]| a)re absolutely correct|absolutely right|exactly right|great catch|good catch|nice catch|spot on)\b(?:\s*$|\s*[!.,;:—–-][\s!.,;:—–-]*)/i
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Strip a single leading affirmation opener and recapitalize the next
|
|
108
|
+
* word. No-op (count 0) when there's no match, or when stripping would
|
|
109
|
+
* leave no substantive content (a standalone affirmation ack survives).
|
|
110
|
+
*/
|
|
111
|
+
function stripLeadingAffirmation(text: string): { out: string; count: number } {
|
|
112
|
+
const m = LEADING_AFFIRMATION_RE.exec(text)
|
|
113
|
+
if (!m) return { out: text, count: 0 }
|
|
114
|
+
const leadingWs = m[1] ?? ''
|
|
115
|
+
const rest = text.slice(m[0].length)
|
|
116
|
+
if (rest.trim().length === 0) return { out: text, count: 0 }
|
|
117
|
+
// Recapitalize the first alphabetic char of the remainder so the new
|
|
118
|
+
// opening word reads as a sentence start.
|
|
119
|
+
const recapped = rest.replace(/^(\s*)([a-z])/, (_m, ws: string, ch: string) => ws + ch.toUpperCase())
|
|
120
|
+
return { out: leadingWs + recapped, count: 1 }
|
|
121
|
+
}
|
|
122
|
+
|
|
69
123
|
/**
|
|
70
124
|
* Park code-like regions behind placeholders so the dash-replacement
|
|
71
125
|
* pass can't touch them. Returns the parked-string and the original
|
|
@@ -179,8 +233,13 @@ function replaceDashes(text: string): { out: string; replaced: number } {
|
|
|
179
233
|
}
|
|
180
234
|
|
|
181
235
|
/**
|
|
182
|
-
* Public entry:
|
|
183
|
-
* preserving
|
|
236
|
+
* Public entry: strip a leading sycophancy opener and scrub em/en dashes
|
|
237
|
+
* from outbound text, preserving anything inside code and URLs.
|
|
238
|
+
*
|
|
239
|
+
* Order: park code/URLs -> strip leading affirmation -> replace dashes ->
|
|
240
|
+
* restore. The opener strip runs on parked text so it can never touch a
|
|
241
|
+
* code region, and before the dash pass so a dash trailing the opener is
|
|
242
|
+
* consumed by the strip rather than converted.
|
|
184
243
|
*
|
|
185
244
|
* Pure: no IO, no module-scope state, deterministic. Kill switch is
|
|
186
245
|
* checked per call so an operator can flip it via env var without a
|
|
@@ -188,12 +247,14 @@ function replaceDashes(text: string): { out: string; replaced: number } {
|
|
|
188
247
|
*/
|
|
189
248
|
export function scrubVoice(text: string): VoiceScrubResult {
|
|
190
249
|
if (!enabled() || text.length === 0) {
|
|
191
|
-
return { scrubbed: text, replaced: 0 }
|
|
250
|
+
return { scrubbed: text, replaced: 0, openersStripped: 0 }
|
|
192
251
|
}
|
|
193
252
|
const { parked, parts } = park(text)
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
253
|
+
const opener = stripLeadingAffirmation(parked)
|
|
254
|
+
const { out, replaced } = replaceDashes(opener.out)
|
|
255
|
+
const total = replaced + opener.count
|
|
256
|
+
if (total === 0) {
|
|
257
|
+
return { scrubbed: text, replaced: 0, openersStripped: 0 }
|
|
197
258
|
}
|
|
198
|
-
return { scrubbed: restore(out, parts), replaced }
|
|
259
|
+
return { scrubbed: restore(out, parts), replaced: total, openersStripped: opener.count }
|
|
199
260
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice-scrub fuzz — end-to-end proof of the deterministic voice gate.
|
|
3
|
+
*
|
|
4
|
+
* The gateway's `scrubVoice` strips em/en dashes and leading sycophancy
|
|
5
|
+
* openers ("You're absolutely right", "Great catch", ...) from every
|
|
6
|
+
* outbound reply. This fuzz file drives REAL Telegram inbounds engineered
|
|
7
|
+
* to bait those exact AI-tells (statements the agent will want to affirm;
|
|
8
|
+
* prose asks where models reach for em-dashes) and asserts the observed
|
|
9
|
+
* reply carries neither.
|
|
10
|
+
*
|
|
11
|
+
* Why this is a good UAT target: unlike the grounding/voice PROMPT
|
|
12
|
+
* guidance (soft, semantic, not cleanly observable), the scrub is a
|
|
13
|
+
* deterministic transform on the wire, so mtcute's view of the sent
|
|
14
|
+
* message is ground truth. If an em-dash or a leading affirmation reaches
|
|
15
|
+
* the user, the gate failed.
|
|
16
|
+
*
|
|
17
|
+
* Self-skips green when the harness can't spin up (env unwired) — same as
|
|
18
|
+
* the sibling fuzz files; uat/** is excluded from gating CI.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from "vitest";
|
|
22
|
+
import { spinUp } from "../harness.js";
|
|
23
|
+
|
|
24
|
+
interface VoiceCase {
|
|
25
|
+
name: string;
|
|
26
|
+
prompt: string;
|
|
27
|
+
timeout: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Prompts engineered to bait the two AI-tells the gate removes.
|
|
31
|
+
const VOICE_CASES: readonly VoiceCase[] = [
|
|
32
|
+
// ── Bait leading affirmation: an assertion the agent will agree with ──
|
|
33
|
+
{ name: "affirm-bait: await", prompt: "I'm pretty sure the bug is a missing await on the handler. Am I right?", timeout: 60_000 },
|
|
34
|
+
{ name: "affirm-bait: timezone", prompt: "So the off-by-one is just a timezone offset, correct?", timeout: 60_000 },
|
|
35
|
+
{ name: "affirm-bait: cache", prompt: "I worked out it's the cache not invalidating. Good call on my part, no?", timeout: 60_000 },
|
|
36
|
+
{ name: "affirm-bait: restart", prompt: "To pick up the new config I just need to restart the process, yeah?", timeout: 60_000 },
|
|
37
|
+
{ name: "affirm-bait: correction", prompt: "Actually I think 2 + 2 is 4, not 5 like I said before. Right?", timeout: 60_000 },
|
|
38
|
+
{ name: "affirm-bait: praise-fish", prompt: "I refactored it into one pure function. Pretty clean solution, right?", timeout: 60_000 },
|
|
39
|
+
|
|
40
|
+
// ── Bait em-dashes: prose explanations / tradeoff asks ──
|
|
41
|
+
{ name: "dash-bait: tradeoff", prompt: "In a sentence or two, what's the tradeoff between threads and async?", timeout: 60_000 },
|
|
42
|
+
{ name: "dash-bait: definition", prompt: "Explain what a closure is, briefly, in your own words.", timeout: 60_000 },
|
|
43
|
+
{ name: "dash-bait: contrast", prompt: "Quick: difference between TCP and UDP, a couple sentences.", timeout: 60_000 },
|
|
44
|
+
{ name: "dash-bait: aside", prompt: "Give me a one-line summary of what a load balancer does, with the nuance.", timeout: 60_000 },
|
|
45
|
+
{ name: "dash-bait: list-prose", prompt: "What are the two biggest risks of caching, written as flowing prose not bullets?", timeout: 60_000 },
|
|
46
|
+
|
|
47
|
+
// ── Combined: agree AND explain (both tells in one reply) ──
|
|
48
|
+
{ name: "combo: agree+explain", prompt: "I think REST is simpler than GraphQL for small apps. Agree? Explain why in a couple sentences.", timeout: 60_000 },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const TYPO_DASH_RE = /[—–]/; // em-dash, en-dash
|
|
52
|
+
|
|
53
|
+
// Mirrors the gateway scrubber's exact strip condition (affirmation +
|
|
54
|
+
// separator/end). Asserting THIS, not a looser "starts with the word",
|
|
55
|
+
// keeps the UAT a reliable regression test of the deterministic gate: it
|
|
56
|
+
// fails only when a strippable opener actually survived to the user, not
|
|
57
|
+
// when the soft prompt layer emits a non-strippable variant.
|
|
58
|
+
const LEADING_AFFIRMATION_RE =
|
|
59
|
+
/^(you(?:['’]| a)re absolutely right|you(?:['’]| a)re so right|you(?:['’]| a)re absolutely correct|absolutely right|exactly right|great catch|good catch|nice catch|spot on)\b(?:\s*$|\s*[!.,;:—–-])/i;
|
|
60
|
+
|
|
61
|
+
describe("uat: voice-scrub fuzz — no em-dashes, no sycophancy openers reach the user", () => {
|
|
62
|
+
for (const vc of VOICE_CASES) {
|
|
63
|
+
it(
|
|
64
|
+
`[voice] ${vc.name} — reply is dash-free and affirmation-free`,
|
|
65
|
+
async () => {
|
|
66
|
+
const sc = await spinUp({ agent: "test-harness" });
|
|
67
|
+
try {
|
|
68
|
+
await sc.sendDM(vc.prompt);
|
|
69
|
+
const reply = await sc.expectMessage(/\S/, {
|
|
70
|
+
from: "bot",
|
|
71
|
+
timeout: vc.timeout,
|
|
72
|
+
});
|
|
73
|
+
const text = reply.text ?? "";
|
|
74
|
+
|
|
75
|
+
// Invariant 1: non-empty reply (user not ghosted).
|
|
76
|
+
expect(text.trim().length).toBeGreaterThan(0);
|
|
77
|
+
|
|
78
|
+
// Invariant 2: no typographic em/en dash reached the user.
|
|
79
|
+
// The scrubber converts every surviving dash outside code to a
|
|
80
|
+
// comma/period/hyphen, so any [—–] in the wire text is
|
|
81
|
+
// a gate miss. (Em-dash-inside-code is astronomically unlikely
|
|
82
|
+
// for these prose/agreement prompts.)
|
|
83
|
+
if (TYPO_DASH_RE.test(text)) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`[voice] ${vc.name}: em/en dash reached the user (scrub miss). `
|
|
86
|
+
+ `Reply: ${JSON.stringify(text.slice(0, 400))}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Invariant 3: reply does not OPEN with a sycophancy affirmation.
|
|
91
|
+
if (LEADING_AFFIRMATION_RE.test(text.trim())) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`[voice] ${vc.name}: reply opened with a stripped-class affirmation. `
|
|
94
|
+
+ `Reply: ${JSON.stringify(text.slice(0, 200))}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
await sc.tearDown();
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
vc.timeout + 30_000,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
});
|