switchroom 0.18.30 → 0.18.31
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 +16 -3
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +99 -2
- package/telegram-plugin/dist/gateway/gateway.js +45 -12
- package/telegram-plugin/gateway/gateway.ts +68 -16
- package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
- package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.18.
|
|
2123
|
+
var VERSION = "0.18.31", COMMIT_SHA = "486b55f9";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -26179,7 +26179,10 @@ function webkiteDenyForAgent(agentConfig) {
|
|
|
26179
26179
|
function resolveMainModel(model) {
|
|
26180
26180
|
if (model === undefined || model === "default")
|
|
26181
26181
|
return SWITCHROOM_DEFAULT_MAIN_MODEL;
|
|
26182
|
-
return model;
|
|
26182
|
+
return normalizeModelAlias(model);
|
|
26183
|
+
}
|
|
26184
|
+
function normalizeModelAlias(model) {
|
|
26185
|
+
return model === "claude-fable-5" ? "fable" : model;
|
|
26183
26186
|
}
|
|
26184
26187
|
function dedupe2(items) {
|
|
26185
26188
|
const seen = new Set;
|
|
@@ -66965,7 +66968,7 @@ function registerAgentCommand(program3) {
|
|
|
66965
66968
|
name,
|
|
66966
66969
|
status: status?.active ?? "unknown",
|
|
66967
66970
|
uptime: formatUptime2(status?.uptime ?? null),
|
|
66968
|
-
model: resolved.model
|
|
66971
|
+
model: resolveMainModel(resolved.model),
|
|
66969
66972
|
thinking_effort: resolved.thinking_effort ?? SWITCHROOM_DEFAULT_THINKING_EFFORT,
|
|
66970
66973
|
extends: agentConfig.extends ?? "default",
|
|
66971
66974
|
topic_name: agentConfig.topic_name,
|
|
@@ -66999,6 +67002,16 @@ function registerAgentCommand(program3) {
|
|
|
66999
67002
|
printTable(headers, rows, widths);
|
|
67000
67003
|
console.log();
|
|
67001
67004
|
}));
|
|
67005
|
+
agent.command("effective-model <name>").description("Print the cascade-resolved model the agent's launcher boots with").action(withConfigError(async (name) => {
|
|
67006
|
+
const config = getConfig(program3);
|
|
67007
|
+
const agentConfig = config.agents[name];
|
|
67008
|
+
if (!agentConfig) {
|
|
67009
|
+
console.error(source_default.red(`Agent "${name}" is not defined in switchroom.yaml`));
|
|
67010
|
+
process.exit(1);
|
|
67011
|
+
}
|
|
67012
|
+
const resolved = resolveAgentConfig(config.defaults, config.profiles, agentConfig);
|
|
67013
|
+
console.log(resolveMainModel(resolved.model));
|
|
67014
|
+
}));
|
|
67002
67015
|
agent.command("status <name>").description("Show health status for a single agent (PID, polling, Hindsight, last messages)").option("--json", "Output as JSON").action(withConfigError(async (name, opts) => {
|
|
67003
67016
|
const config = getConfig(program3);
|
|
67004
67017
|
const agentsDir = resolveAgentsDir(config);
|
|
@@ -26645,7 +26645,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
|
|
|
26645
26645
|
import { dirname as dirname4, join as join7 } from "node:path";
|
|
26646
26646
|
|
|
26647
26647
|
// src/build-info.ts
|
|
26648
|
-
var VERSION = "0.18.
|
|
26648
|
+
var VERSION = "0.18.31";
|
|
26649
26649
|
|
|
26650
26650
|
// src/cli/resolve-version.ts
|
|
26651
26651
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.31",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -1170,15 +1170,92 @@ fi
|
|
|
1170
1170
|
# which the gateway relays to the operator chat once at boot. The normal apply
|
|
1171
1171
|
# path is silent here — the gateway already acked the `/model` in chat.
|
|
1172
1172
|
#
|
|
1173
|
+
# --- Live configured-default resolution (Defect B / drift fix) ---
|
|
1174
|
+
#
|
|
1175
|
+
# The apply-time bake ({{{modelQ}}}) is only the LAST-DITCH fallback: editing
|
|
1176
|
+
# `model:` in switchroom.yaml and restarting must take effect WITHOUT a full
|
|
1177
|
+
# `switchroom apply`. The launcher asks the SAME resolver the gateway's
|
|
1178
|
+
# /status uses (`switchroom agent effective-model`, which applies
|
|
1179
|
+
# resolveMainModel server-side) against the live mounted yaml — one resolver,
|
|
1180
|
+
# never re-implemented in shell, so what-you-see and what-runs cannot split.
|
|
1181
|
+
#
|
|
1182
|
+
# Fallback chain, never silent past step 1:
|
|
1183
|
+
# 1. live read of $SWITCHROOM_CONFIG (one bounded retry — a host-side
|
|
1184
|
+
# in-place rewrite can present a torn file for a moment);
|
|
1185
|
+
# 2. `.configured-default-model` — the last-known-good live value this
|
|
1186
|
+
# launcher recorded on every prior boot (read BEFORE it is overwritten);
|
|
1187
|
+
# 3. the apply-time bake.
|
|
1188
|
+
# Steps 2/3 append to `.session-model-alert` so the gateway tells the
|
|
1189
|
+
# operator once at boot. No SWITCHROOM_CONFIG mount at all means there is
|
|
1190
|
+
# nothing to read live — the bake IS the truth there (stderr note only; an
|
|
1191
|
+
# operator alert every boot forever would be noise with no action).
|
|
1192
|
+
#
|
|
1193
|
+
# The yaml file bind mount re-resolves by PATH at container start, so a
|
|
1194
|
+
# host-side atomic-rename write is always picked up by the NEXT boot — the
|
|
1195
|
+
# path this block runs on. Only mid-life reads by long-running processes can
|
|
1196
|
+
# see a stale inode; that pre-existing edge is out of scope here (tracked as
|
|
1197
|
+
# a follow-up on the drift fix).
|
|
1198
|
+
#
|
|
1173
1199
|
# NB {{{modelQ}}} is already shell-single-quoted by the scaffold (it renders as
|
|
1174
1200
|
# a quoted token, e.g. 'claude-sonnet-5'), so it is assigned BARE here — never
|
|
1175
1201
|
# inside additional double quotes, which would embed the literal quote chars in
|
|
1176
1202
|
# the value and break `claude --model`.
|
|
1177
|
-
|
|
1203
|
+
_BAKED_MODEL={{{modelQ}}}
|
|
1204
|
+
_LKG_MODEL="$(cat "{{agentDir}}/.configured-default-model" 2>/dev/null | tr -d '[:space:]' || true)"
|
|
1205
|
+
_EFFECTIVE_MODEL=""
|
|
1206
|
+
if [ -n "$SWITCHROOM_CONFIG" ] && [ -f "$SWITCHROOM_CONFIG" ] && command -v switchroom >/dev/null 2>&1; then
|
|
1207
|
+
_lm_live=""
|
|
1208
|
+
for _lm_try in 1 2; do
|
|
1209
|
+
_lm_live="$(timeout 20 switchroom --config "$SWITCHROOM_CONFIG" agent effective-model "{{name}}" 2>/dev/null | tail -n 1 | tr -d '[:space:]')" || _lm_live=""
|
|
1210
|
+
# Shape gate — same regex as the .session-model carrier below (byte-kept
|
|
1211
|
+
# with MODEL_ARG_RE); the tr above already guarantees single-line.
|
|
1212
|
+
if [ -n "$_lm_live" ] && printf '%s' "$_lm_live" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
|
|
1213
|
+
break
|
|
1214
|
+
fi
|
|
1215
|
+
_lm_live=""
|
|
1216
|
+
if [ "$_lm_try" -eq 1 ]; then sleep 1; fi
|
|
1217
|
+
done
|
|
1218
|
+
if [ -n "$_lm_live" ]; then
|
|
1219
|
+
# Routing class (Claude passthrough vs sr-* router root) is baked into
|
|
1220
|
+
# compose at render time, so a Claude<->sr-* class flip CANNOT be applied
|
|
1221
|
+
# by a bare restart — half-applying would run the new model against the
|
|
1222
|
+
# old routing. Detect the flip, keep the baked model, and tell the
|
|
1223
|
+
# operator to run a real apply.
|
|
1224
|
+
case "$_BAKED_MODEL" in sr-*) _lm_baked_class=router ;; *) _lm_baked_class=claude ;; esac
|
|
1225
|
+
case "$_lm_live" in sr-*) _lm_live_class=router ;; *) _lm_live_class=claude ;; esac
|
|
1226
|
+
if [ "$_lm_live_class" != "$_lm_baked_class" ]; then
|
|
1227
|
+
echo "session-model: configured model class changed ('$_BAKED_MODEL' -> '$_lm_live', $_lm_baked_class -> $_lm_live_class) — routing is render-time, NOT applying; run 'switchroom apply' then restart" >&2
|
|
1228
|
+
printf 'The configured model changed from `%s` to `%s`, which switches routing class — a restart alone cannot apply that. The agent booted on `%s`. Run `switchroom apply` on the host, then restart this agent.\n' "$_BAKED_MODEL" "$_lm_live" "$_BAKED_MODEL" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1229
|
+
_EFFECTIVE_MODEL="$_BAKED_MODEL"
|
|
1230
|
+
else
|
|
1231
|
+
_EFFECTIVE_MODEL="$_lm_live"
|
|
1232
|
+
if [ "$_lm_live" != "$_BAKED_MODEL" ]; then
|
|
1233
|
+
echo "session-model: live configured model '$_lm_live' differs from apply-time bake '$_BAKED_MODEL' — using the live value (switchroom.yaml is the source of truth)" >&2
|
|
1234
|
+
fi
|
|
1235
|
+
fi
|
|
1236
|
+
elif [ -n "$_LKG_MODEL" ] && printf '%s' "$_LKG_MODEL" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
|
|
1237
|
+
# Live read failed (torn write mid-read, yaml schema error, CLI wedge).
|
|
1238
|
+
# Last-known-good beats the bake: it was live truth on a prior boot,
|
|
1239
|
+
# while the bake can predate any number of yaml edits. Loud either way —
|
|
1240
|
+
# a silent fallback here would resurrect Defect B as an intermittent flap.
|
|
1241
|
+
_EFFECTIVE_MODEL="$_LKG_MODEL"
|
|
1242
|
+
echo "session-model: live config read FAILED — using last-known-good configured model '$_LKG_MODEL' (apply-time bake: '$_BAKED_MODEL')" >&2
|
|
1243
|
+
printf 'switchroom.yaml could not be read at boot, so the agent booted on the last-known-good configured model `%s`. If you just changed `model:`, check the yaml for errors and restart the agent.\n' "$_LKG_MODEL" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1244
|
+
else
|
|
1245
|
+
_EFFECTIVE_MODEL="$_BAKED_MODEL"
|
|
1246
|
+
echo "session-model: live config read FAILED and no last-known-good value — using apply-time baked model '$_BAKED_MODEL'" >&2
|
|
1247
|
+
printf 'switchroom.yaml could not be read at boot and no prior boot recorded a model, so the agent booted on the apply-time default `%s`. If you just changed `model:`, check the yaml for errors and restart the agent.\n' "$_BAKED_MODEL" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1248
|
+
fi
|
|
1249
|
+
unset _lm_live _lm_try _lm_baked_class _lm_live_class
|
|
1250
|
+
else
|
|
1251
|
+
_EFFECTIVE_MODEL="$_BAKED_MODEL"
|
|
1252
|
+
fi
|
|
1253
|
+
unset _BAKED_MODEL _LKG_MODEL
|
|
1178
1254
|
# Record the RESOLVED configured default (raw, unquoted) every boot, before
|
|
1179
1255
|
# override resolution. The gateway copies this into the carrier's
|
|
1180
1256
|
# `configuredDefaultAtWrite`, so both sides of the invalidation compare below
|
|
1181
|
-
# come from the same resolver. Overwrite, not consumed
|
|
1257
|
+
# come from the same resolver. Overwrite, not consumed — and doubling as the
|
|
1258
|
+
# last-known-good live value the fallback chain above reads on the NEXT boot.
|
|
1182
1259
|
printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.configured-default-model" 2>/dev/null || true
|
|
1183
1260
|
|
|
1184
1261
|
# Rev-4 hygiene (#3184 review LOW-2): remove the files retired with the
|
|
@@ -1304,6 +1381,26 @@ if [ -f "{{agentDir}}/.session-model" ]; then
|
|
|
1304
1381
|
unset _smf _sm_model _sm_cfg _sm_attempts _SM_MAX_ATTEMPTS
|
|
1305
1382
|
fi
|
|
1306
1383
|
|
|
1384
|
+
# Configured-default LiteLLM guard. A proxy-only *configured* model with
|
|
1385
|
+
# LiteLLM unreachable at boot would exec against the /anthropic passthrough,
|
|
1386
|
+
# where `fable`/`claude-fable-5` is a retired codename and EVERY call 4xxs —
|
|
1387
|
+
# a total outage. The carrier block above already drops proxy-only OVERRIDES
|
|
1388
|
+
# on a down proxy; this extends the same guard to the configured default,
|
|
1389
|
+
# which the live-config read above makes a mainline path (pinning `model:
|
|
1390
|
+
# fable` in yaml is now the normal way to run Fable). Claude agents have a
|
|
1391
|
+
# safe same-class fallback (opus); sr-* configured defaults keep today's
|
|
1392
|
+
# loud-degraded behavior — there is no Claude fallback for them and the
|
|
1393
|
+
# repoint case below already leaves their routing to self-heal.
|
|
1394
|
+
case "$_EFFECTIVE_MODEL" in
|
|
1395
|
+
fable|claude-fable-5)
|
|
1396
|
+
if [ -z "$_LITELLM_OK" ]; then
|
|
1397
|
+
echo "session-model: configured model '$_EFFECTIVE_MODEL' is proxy-only but LiteLLM is unreachable at boot — booting 'opus' until the proxy recovers" >&2
|
|
1398
|
+
printf 'LiteLLM was unreachable at boot, so the configured model `%s` (proxy-only) could not be used — the agent booted on `opus` instead. Restart the agent once the proxy is back to return to `%s`.\n' "$_EFFECTIVE_MODEL" "$_EFFECTIVE_MODEL" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1399
|
+
_EFFECTIVE_MODEL="opus"
|
|
1400
|
+
fi
|
|
1401
|
+
;;
|
|
1402
|
+
esac
|
|
1403
|
+
|
|
1307
1404
|
# --- Session effort resolution (consume-once .session-effort carrier, #3186) ---
|
|
1308
1405
|
#
|
|
1309
1406
|
# The `/effort` sibling of the block above, session-scoped like /model
|
|
@@ -37467,6 +37467,26 @@ var init_config_approval_handler = __esm(() => {
|
|
|
37467
37467
|
pending = new Map;
|
|
37468
37468
|
});
|
|
37469
37469
|
|
|
37470
|
+
// final-answer-detect.ts
|
|
37471
|
+
var FINAL_ANSWER_MIN_CHARS2 = 200;
|
|
37472
|
+
|
|
37473
|
+
// gateway/turn-flush-suppression.ts
|
|
37474
|
+
var exports_turn_flush_suppression = {};
|
|
37475
|
+
__export(exports_turn_flush_suppression, {
|
|
37476
|
+
shouldSuppressTurnFlush: () => shouldSuppressTurnFlush,
|
|
37477
|
+
FLUSH_SUPPRESSION_WINDOW_MS: () => FLUSH_SUPPRESSION_WINDOW_MS
|
|
37478
|
+
});
|
|
37479
|
+
function shouldSuppressTurnFlush(deps, args) {
|
|
37480
|
+
const minChars = Math.max(1, Math.min(FINAL_ANSWER_MIN_CHARS2, args.answerLength));
|
|
37481
|
+
try {
|
|
37482
|
+
return deps.hasSubstantiveOutbound(args.chatId, args.nowMs - FLUSH_SUPPRESSION_WINDOW_MS, args.threadId, minChars);
|
|
37483
|
+
} catch {
|
|
37484
|
+
return false;
|
|
37485
|
+
}
|
|
37486
|
+
}
|
|
37487
|
+
var FLUSH_SUPPRESSION_WINDOW_MS = 2000;
|
|
37488
|
+
var init_turn_flush_suppression = () => {};
|
|
37489
|
+
|
|
37470
37490
|
// ../src/vault/approvals/client.ts
|
|
37471
37491
|
function resolveKernelSocketPath2(opts) {
|
|
37472
37492
|
if (opts?.socket)
|
|
@@ -72647,7 +72667,10 @@ var SWITCHROOM_DEFAULT_THINKING_EFFORT = "low";
|
|
|
72647
72667
|
function resolveMainModel(model) {
|
|
72648
72668
|
if (model === undefined || model === "default")
|
|
72649
72669
|
return SWITCHROOM_DEFAULT_MAIN_MODEL;
|
|
72650
|
-
return model;
|
|
72670
|
+
return normalizeModelAlias(model);
|
|
72671
|
+
}
|
|
72672
|
+
function normalizeModelAlias(model) {
|
|
72673
|
+
return model === "claude-fable-5" ? "fable" : model;
|
|
72651
72674
|
}
|
|
72652
72675
|
var CLAUDE_MD_YOURS_PLACEHOLDER = "This space is yours. Add per-agent rules, exceptions, or context the " + "Switchroom template doesn't capture. Everything above the marker line is " + "regenerated on every apply; this section is preserved.";
|
|
72653
72676
|
var SWITCHROOM_OWNED_SETTINGS_KEYS = new Set([
|
|
@@ -84570,10 +84593,10 @@ function effectiveTurnAgeMs(markerAgeMs, turnStartedAt, now) {
|
|
|
84570
84593
|
}
|
|
84571
84594
|
|
|
84572
84595
|
// ../src/build-info.ts
|
|
84573
|
-
var VERSION = "0.18.
|
|
84574
|
-
var COMMIT_SHA = "
|
|
84575
|
-
var COMMIT_DATE = "2026-07-
|
|
84576
|
-
var LATEST_PR =
|
|
84596
|
+
var VERSION = "0.18.31";
|
|
84597
|
+
var COMMIT_SHA = "486b55f9";
|
|
84598
|
+
var COMMIT_DATE = "2026-07-17T07:36:42Z";
|
|
84599
|
+
var LATEST_PR = 3302;
|
|
84577
84600
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
84578
84601
|
|
|
84579
84602
|
// gateway/boot-version.ts
|
|
@@ -87157,8 +87180,9 @@ async function deliverAnswer(args) {
|
|
|
87157
87180
|
}
|
|
87158
87181
|
};
|
|
87159
87182
|
let liveThreadId = args.threadId;
|
|
87160
|
-
const sendChunk = async (
|
|
87183
|
+
const sendChunk = async (chunkIndex, text5) => {
|
|
87161
87184
|
const chunkIds = [];
|
|
87185
|
+
const anchor = chunkIndex === 0 && args.replyToMessageId != null ? { reply_parameters: { message_id: args.replyToMessageId, allow_sending_without_reply: true } } : {};
|
|
87162
87186
|
const res = await sendReplyChunks(deps, {
|
|
87163
87187
|
chatId,
|
|
87164
87188
|
chunks: [text5],
|
|
@@ -87168,6 +87192,7 @@ async function deliverAnswer(args) {
|
|
|
87168
87192
|
previewMessageId: null,
|
|
87169
87193
|
sentIds: chunkIds,
|
|
87170
87194
|
buildSendOpts: (_i, _isLast, tid) => ({
|
|
87195
|
+
...anchor,
|
|
87171
87196
|
...tid != null ? { message_thread_id: tid } : {},
|
|
87172
87197
|
link_preview_options: { is_disabled: true }
|
|
87173
87198
|
}),
|
|
@@ -94408,6 +94433,7 @@ function handleSessionEvent(ev) {
|
|
|
94408
94433
|
const turn = currentTurn;
|
|
94409
94434
|
if (turn == null)
|
|
94410
94435
|
return;
|
|
94436
|
+
resetAnswerReadyFlushTimeout();
|
|
94411
94437
|
const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId));
|
|
94412
94438
|
if (ctrl)
|
|
94413
94439
|
ctrl.setThinking();
|
|
@@ -94812,15 +94838,21 @@ function handleSessionEvent(ev) {
|
|
|
94812
94838
|
await new Promise((resolve11) => setTimeout(resolve11, 500));
|
|
94813
94839
|
if (HISTORY_ENABLED) {
|
|
94814
94840
|
try {
|
|
94815
|
-
const {
|
|
94816
|
-
const
|
|
94817
|
-
|
|
94818
|
-
|
|
94841
|
+
const { hasOutboundDeliveredSince: hasOutboundDeliveredSince2 } = await Promise.resolve().then(() => (init_history(), exports_history));
|
|
94842
|
+
const { shouldSuppressTurnFlush: shouldSuppressTurnFlush2 } = await Promise.resolve().then(() => (init_turn_flush_suppression(), exports_turn_flush_suppression));
|
|
94843
|
+
const suppress = shouldSuppressTurnFlush2({ hasSubstantiveOutbound: hasOutboundDeliveredSince2 }, {
|
|
94844
|
+
chatId: backstopChatId,
|
|
94845
|
+
threadId: backstopThreadId ?? null,
|
|
94846
|
+
answerLength: capturedText.length,
|
|
94847
|
+
nowMs: Date.now()
|
|
94848
|
+
});
|
|
94849
|
+
if (suppress) {
|
|
94850
|
+
process.stderr.write(`telegram gateway: turn-flush suppressed \u2014 a substantive same-thread outbound landed within 2s
|
|
94819
94851
|
`);
|
|
94820
94852
|
if (backstopTurnEndedAt != null) {
|
|
94821
94853
|
turn.deliveryOutcome = "suppressed";
|
|
94822
94854
|
if (OBLIGATION_LEDGER_ENABLED)
|
|
94823
|
-
obligationLedger.
|
|
94855
|
+
obligationLedger.noteTurnEnded(turn.turnId, Date.now());
|
|
94824
94856
|
emitTurnRecord(turn, backstopTurnEndedAt);
|
|
94825
94857
|
}
|
|
94826
94858
|
return;
|
|
@@ -94843,7 +94875,8 @@ function handleSessionEvent(ev) {
|
|
|
94843
94875
|
threadId: backstopThreadId,
|
|
94844
94876
|
text: capturedText,
|
|
94845
94877
|
turnId: turn.turnId,
|
|
94846
|
-
cardMessageId: backstopCardMessageId
|
|
94878
|
+
cardMessageId: backstopCardMessageId,
|
|
94879
|
+
replyToMessageId: turn.sourceMessageId
|
|
94847
94880
|
});
|
|
94848
94881
|
sentIds = delivery.sentIds;
|
|
94849
94882
|
chunkCount = delivery.chunkCount;
|
|
@@ -2399,6 +2399,13 @@ async function deliverAnswer(args: {
|
|
|
2399
2399
|
text: string
|
|
2400
2400
|
turnId: string
|
|
2401
2401
|
cardMessageId: number | null
|
|
2402
|
+
/** S4 (fable red-team 2026-07-17) — the inbound message this turn answers
|
|
2403
|
+
* (`turn.sourceMessageId`). When present, the FIRST chunk is sent as a
|
|
2404
|
+
* native quote-reply so the flushed answer anchors to the user's question,
|
|
2405
|
+
* matching `executeReply`'s default. `allow_sending_without_reply` keeps
|
|
2406
|
+
* the send alive if the user deleted their message. Null for synthesized
|
|
2407
|
+
* turns (cron/handback) — those send bare, as before. */
|
|
2408
|
+
replyToMessageId: number | null
|
|
2402
2409
|
}): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
|
|
2403
2410
|
const { chatId, turnId } = args
|
|
2404
2411
|
// Inject visible blank-line spacers into `\n\n` gaps, then split — exactly as
|
|
@@ -2447,8 +2454,17 @@ async function deliverAnswer(args: {
|
|
|
2447
2454
|
// landed message id(s) (a length-resplit chunk may land >1); throws on an
|
|
2448
2455
|
// unrecoverable send failure so the retry orchestrator can resume.
|
|
2449
2456
|
let liveThreadId = args.threadId
|
|
2450
|
-
const sendChunk = async (
|
|
2457
|
+
const sendChunk = async (chunkIndex: number, text: string): Promise<number[]> => {
|
|
2451
2458
|
const chunkIds: number[] = []
|
|
2459
|
+
// S4 — quote-anchor the FIRST chunk only (chunkIndex is the OVERALL chunk
|
|
2460
|
+
// index across the retry orchestrator; each sendReplyChunks call here
|
|
2461
|
+
// carries exactly one chunk, so the inner index is always 0). Mirrors
|
|
2462
|
+
// executeReply's first-chunk-only default. The ledger resumes retries at
|
|
2463
|
+
// the first UNSENT chunk, so chunk 0 keeps its anchor across retries.
|
|
2464
|
+
const anchor =
|
|
2465
|
+
chunkIndex === 0 && args.replyToMessageId != null
|
|
2466
|
+
? { reply_parameters: { message_id: args.replyToMessageId, allow_sending_without_reply: true } }
|
|
2467
|
+
: {}
|
|
2452
2468
|
const res = await sendReplyChunks(deps, {
|
|
2453
2469
|
chatId,
|
|
2454
2470
|
chunks: [text],
|
|
@@ -2458,6 +2474,7 @@ async function deliverAnswer(args: {
|
|
|
2458
2474
|
previewMessageId: null,
|
|
2459
2475
|
sentIds: chunkIds,
|
|
2460
2476
|
buildSendOpts: (_i, _isLast, tid) => ({
|
|
2477
|
+
...anchor,
|
|
2461
2478
|
...(tid != null ? { message_thread_id: tid } : {}),
|
|
2462
2479
|
link_preview_options: { is_disabled: true },
|
|
2463
2480
|
}),
|
|
@@ -18097,6 +18114,16 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
18097
18114
|
// — read `turn` once, don't re-read currentTurn after any await.
|
|
18098
18115
|
const turn = currentTurn
|
|
18099
18116
|
if (turn == null) return
|
|
18117
|
+
// S2 fix (fable red-team 2026-07-17) — a thinking block means the model
|
|
18118
|
+
// is still working, not quiescent. Without this, "prose → >1s thinking
|
|
18119
|
+
// pause → trailing NO_REPLY" let the answer-ready quiescence timer fire
|
|
18120
|
+
// mid-pause and deliver a turn the model was about to mark silent
|
|
18121
|
+
// (#2053 in miniature). Re-arm (not just clear): `reset()` re-verifies
|
|
18122
|
+
// via `decideTurnFlush` and pushes the debounce out by a fresh window,
|
|
18123
|
+
// so the trailing sentinel gets to land before any fire; if no further
|
|
18124
|
+
// text arrives, the flush still fires one window after the LAST
|
|
18125
|
+
// thinking event — the fast path is deferred, never lost.
|
|
18126
|
+
resetAnswerReadyFlushTimeout()
|
|
18100
18127
|
const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
|
|
18101
18128
|
if (ctrl) ctrl.setThinking()
|
|
18102
18129
|
return
|
|
@@ -19208,10 +19235,28 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
19208
19235
|
await new Promise<void>(resolve => setTimeout(resolve, 500))
|
|
19209
19236
|
if (HISTORY_ENABLED) {
|
|
19210
19237
|
try {
|
|
19211
|
-
|
|
19212
|
-
|
|
19213
|
-
|
|
19214
|
-
|
|
19238
|
+
// S1 fix (fable red-team 2026-07-17) — the old predicate here,
|
|
19239
|
+
// `getRecentOutboundCount(chatId, 2) > 0`, counted ANY assistant
|
|
19240
|
+
// row in the WHOLE chat: a worker `progress_update`, a command
|
|
19241
|
+
// ack / restart notice, or a reply in a DIFFERENT forum topic all
|
|
19242
|
+
// suppressed the flush and (worse) CLOSED the obligation below —
|
|
19243
|
+
// silently dropping the user's real answer. The scoped predicate
|
|
19244
|
+
// (same thread, length ≥ min(answerLength, 200)) lives in
|
|
19245
|
+
// `turn-flush-suppression.ts`; `hasOutboundDeliveredSince` is the
|
|
19246
|
+
// durable oracle whose thread/length semantics history tests pin.
|
|
19247
|
+
const { hasOutboundDeliveredSince } = await import('../history.js')
|
|
19248
|
+
const { shouldSuppressTurnFlush } = await import('./turn-flush-suppression.js')
|
|
19249
|
+
const suppress = shouldSuppressTurnFlush(
|
|
19250
|
+
{ hasSubstantiveOutbound: hasOutboundDeliveredSince },
|
|
19251
|
+
{
|
|
19252
|
+
chatId: backstopChatId,
|
|
19253
|
+
threadId: backstopThreadId ?? null,
|
|
19254
|
+
answerLength: capturedText.length,
|
|
19255
|
+
nowMs: Date.now(),
|
|
19256
|
+
},
|
|
19257
|
+
)
|
|
19258
|
+
if (suppress) {
|
|
19259
|
+
process.stderr.write(`telegram gateway: turn-flush suppressed — a substantive same-thread outbound landed within 2s\n`)
|
|
19215
19260
|
// Do NOT finalize the status reaction here. As of #1713
|
|
19216
19261
|
// the reaction is only finalized by the `turn_end` IPC
|
|
19217
19262
|
// handler — mid-turn delivery proofs (local history,
|
|
@@ -19223,19 +19268,23 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
19223
19268
|
// here re-fired on an already-cleared key WITHOUT `endingTurn`,
|
|
19224
19269
|
// emitting an inconsistent shadow trace. Removed.
|
|
19225
19270
|
//
|
|
19226
|
-
// PR B —
|
|
19227
|
-
// the flush was legitimately suppressed. Emit
|
|
19228
|
-
// as 'suppressed'
|
|
19229
|
-
// finalAnswerDelivered). Not a failure.
|
|
19271
|
+
// PR B — a substantive same-thread outbound just delivered this
|
|
19272
|
+
// turn's answer, so the flush was legitimately suppressed. Emit
|
|
19273
|
+
// the deferred record as 'suppressed'. Not a failure.
|
|
19230
19274
|
if (backstopTurnEndedAt != null) {
|
|
19231
19275
|
turn.deliveryOutcome = 'suppressed'
|
|
19232
|
-
//
|
|
19233
|
-
//
|
|
19234
|
-
//
|
|
19235
|
-
//
|
|
19236
|
-
//
|
|
19237
|
-
//
|
|
19238
|
-
|
|
19276
|
+
// S1 fix — do NOT close the obligation here. When the recent
|
|
19277
|
+
// outbound is genuinely this turn's answer (a raced reply /
|
|
19278
|
+
// stream materialization), the reply path closes its own
|
|
19279
|
+
// obligation idempotently; when the suppression is a false
|
|
19280
|
+
// positive (a long same-thread non-answer inside the 2s
|
|
19281
|
+
// window — the residual the scoped predicate can't
|
|
19282
|
+
// discriminate), closing here made the drop PERMANENT.
|
|
19283
|
+
// Leaving it open lets the obligation sweep arbitrate: it
|
|
19284
|
+
// stands down silently if a substantive outbound answered
|
|
19285
|
+
// the user, and re-presents otherwise. `noteTurnEnded` arms
|
|
19286
|
+
// the liveness floor exactly as the send-failed path does.
|
|
19287
|
+
if (OBLIGATION_LEDGER_ENABLED) obligationLedger.noteTurnEnded(turn.turnId, Date.now())
|
|
19239
19288
|
emitTurnRecord(turn, backstopTurnEndedAt)
|
|
19240
19289
|
}
|
|
19241
19290
|
return
|
|
@@ -19283,6 +19332,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
19283
19332
|
text: capturedText,
|
|
19284
19333
|
turnId: turn.turnId,
|
|
19285
19334
|
cardMessageId: backstopCardMessageId,
|
|
19335
|
+
// S4 — anchor the flushed answer to the inbound it answers
|
|
19336
|
+
// (null for synthesized turns, which send bare as before).
|
|
19337
|
+
replyToMessageId: turn.sourceMessageId,
|
|
19286
19338
|
})
|
|
19287
19339
|
sentIds = delivery.sentIds
|
|
19288
19340
|
chunkCount = delivery.chunkCount
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-flush pre-delivery suppression decision (S1 fix, fable red-team
|
|
3
|
+
* 2026-07-17 — `~klanker/work/fable-redteam-delivery-20260717/REDTEAM.md`).
|
|
4
|
+
*
|
|
5
|
+
* Before the turn-flush backstop posts the captured terminal answer, it asks:
|
|
6
|
+
* "did a reply already land for this turn in the last ~2s?" (a race guard for
|
|
7
|
+
* a reply whose IPC signal hadn't registered when `decideTurnFlush` ran, and
|
|
8
|
+
* for answer-stream materializations that already delivered the answer text).
|
|
9
|
+
*
|
|
10
|
+
* The OLD predicate — `getRecentOutboundCount(chatId, 2) > 0` — counted ANY
|
|
11
|
+
* assistant row in the WHOLE chat: a background worker's `progress_update`, a
|
|
12
|
+
* command ack / restart notice, or a reply in a DIFFERENT forum topic all
|
|
13
|
+
* suppressed the flush, and the branch then CLOSED the delivery obligation —
|
|
14
|
+
* the user's real answer was dropped with no re-present. This module scopes
|
|
15
|
+
* the predicate to a SUBSTANTIVE (≥ `FINAL_ANSWER_MIN_CHARS`) outbound in the
|
|
16
|
+
* SAME thread, via the injected `hasSubstantiveOutbound` (the gateway wires
|
|
17
|
+
* `hasOutboundDeliveredSince`, whose thread/length semantics are pinned by
|
|
18
|
+
* `history.test.ts`).
|
|
19
|
+
*
|
|
20
|
+
* Residual (documented, accepted): a ≥200-char same-thread non-answer outbound
|
|
21
|
+
* (e.g. an unusually long progress update) inside the 2s window still
|
|
22
|
+
* suppresses — the history schema has no message-kind column to discriminate
|
|
23
|
+
* further. That residual is why the CALLER must NOT close the obligation on
|
|
24
|
+
* suppression: a genuine reply closes its own obligation idempotently, while a
|
|
25
|
+
* false-positive suppression leaves it open for the liveness floor / sweep.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
|
|
29
|
+
|
|
30
|
+
/** How far back a just-landed reply can be and still suppress the flush. */
|
|
31
|
+
export const FLUSH_SUPPRESSION_WINDOW_MS = 2000
|
|
32
|
+
|
|
33
|
+
export interface FlushSuppressionDeps {
|
|
34
|
+
/** Durable substantive-outbound oracle — the gateway passes
|
|
35
|
+
* `hasOutboundDeliveredSince` (thread-scoped, length-floored). */
|
|
36
|
+
hasSubstantiveOutbound(
|
|
37
|
+
chatId: string,
|
|
38
|
+
sinceMs: number,
|
|
39
|
+
threadId: number | null,
|
|
40
|
+
minChars: number,
|
|
41
|
+
): boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FlushSuppressionArgs {
|
|
45
|
+
chatId: string
|
|
46
|
+
/** The turn's origin thread. `null` = chat root / DM (matches the
|
|
47
|
+
* `hasOutboundDeliveredSince` explicit-null semantics — never pass
|
|
48
|
+
* `undefined`, which would match ANY thread and re-open the
|
|
49
|
+
* cross-topic false positive this module exists to close). */
|
|
50
|
+
threadId: number | null
|
|
51
|
+
/** Length of the captured answer the flush is about to deliver. The
|
|
52
|
+
* length floor is `min(FINAL_ANSWER_MIN_CHARS, answerLength)`: a row
|
|
53
|
+
* can only suppress the flush if it is at least as long as the answer
|
|
54
|
+
* itself (up to the 200-char substantive cap). A TERSE captured answer
|
|
55
|
+
* ("Yes — done.") is therefore still suppressible by its own short
|
|
56
|
+
* answer-stream materialization (avoiding a duplicate bubble), while a
|
|
57
|
+
* short progress ping can never suppress a LONG composed answer. */
|
|
58
|
+
answerLength: number
|
|
59
|
+
nowMs: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* True iff the flush should be suppressed: a substantive same-thread outbound
|
|
64
|
+
* landed within the suppression window. Fails open (no suppression) on oracle
|
|
65
|
+
* errors — delivering a possible duplicate beats dropping the only copy.
|
|
66
|
+
*/
|
|
67
|
+
export function shouldSuppressTurnFlush(
|
|
68
|
+
deps: FlushSuppressionDeps,
|
|
69
|
+
args: FlushSuppressionArgs,
|
|
70
|
+
): boolean {
|
|
71
|
+
const minChars = Math.max(1, Math.min(FINAL_ANSWER_MIN_CHARS, args.answerLength))
|
|
72
|
+
try {
|
|
73
|
+
return deps.hasSubstantiveOutbound(
|
|
74
|
+
args.chatId,
|
|
75
|
+
args.nowMs - FLUSH_SUPPRESSION_WINDOW_MS,
|
|
76
|
+
args.threadId,
|
|
77
|
+
minChars,
|
|
78
|
+
)
|
|
79
|
+
} catch {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S1 fix (fable red-team 2026-07-17) — turn-flush pre-delivery suppression.
|
|
3
|
+
*
|
|
4
|
+
* The old gateway predicate (`getRecentOutboundCount(chatId, 2) > 0`) counted
|
|
5
|
+
* ANY assistant row in the WHOLE chat, so a worker progress ping, a command
|
|
6
|
+
* ack, or a reply in a different forum topic suppressed the flush and closed
|
|
7
|
+
* the obligation — silently dropping the user's real answer. These tests pin
|
|
8
|
+
* the scoped replacement: the oracle must be asked for a SAME-THREAD row of
|
|
9
|
+
* substantive length within the 2s window, and oracle failures fail OPEN
|
|
10
|
+
* (deliver, don't drop). The oracle's own thread/length SQL semantics are
|
|
11
|
+
* pinned separately in `history.test.ts` (`hasOutboundDeliveredSince`).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
shouldSuppressTurnFlush,
|
|
16
|
+
FLUSH_SUPPRESSION_WINDOW_MS,
|
|
17
|
+
} from '../gateway/turn-flush-suppression.js'
|
|
18
|
+
import { FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
|
|
19
|
+
|
|
20
|
+
type OracleCall = { chatId: string; sinceMs: number; threadId: number | null; minChars: number }
|
|
21
|
+
|
|
22
|
+
function capture(result: boolean) {
|
|
23
|
+
const calls: OracleCall[] = []
|
|
24
|
+
const deps = {
|
|
25
|
+
hasSubstantiveOutbound: (chatId: string, sinceMs: number, threadId: number | null, minChars: number) => {
|
|
26
|
+
calls.push({ chatId, sinceMs, threadId, minChars })
|
|
27
|
+
return result
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
return { calls, deps }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('shouldSuppressTurnFlush', () => {
|
|
34
|
+
it('suppresses iff the oracle finds a substantive same-thread outbound', () => {
|
|
35
|
+
const yes = capture(true)
|
|
36
|
+
expect(
|
|
37
|
+
shouldSuppressTurnFlush(yes.deps, { chatId: '-100', threadId: 7, answerLength: 500, nowMs: 10_000 }),
|
|
38
|
+
).toBe(true)
|
|
39
|
+
const no = capture(false)
|
|
40
|
+
expect(
|
|
41
|
+
shouldSuppressTurnFlush(no.deps, { chatId: '-100', threadId: 7, answerLength: 500, nowMs: 10_000 }),
|
|
42
|
+
).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('scopes the oracle query to the turn thread and the 2s window', () => {
|
|
46
|
+
const { calls, deps } = capture(true)
|
|
47
|
+
shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: 7, answerLength: 500, nowMs: 10_000 })
|
|
48
|
+
expect(calls).toHaveLength(1)
|
|
49
|
+
expect(calls[0]!.chatId).toBe('-100')
|
|
50
|
+
// Explicit thread id — never `undefined` (undefined would match rows in
|
|
51
|
+
// ANY topic and re-open the cross-topic false positive).
|
|
52
|
+
expect(calls[0]!.threadId).toBe(7)
|
|
53
|
+
expect(calls[0]!.sinceMs).toBe(10_000 - FLUSH_SUPPRESSION_WINDOW_MS)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('passes an explicit null thread for DM / chat-root turns', () => {
|
|
57
|
+
const { calls, deps } = capture(false)
|
|
58
|
+
shouldSuppressTurnFlush(deps, { chatId: '55', threadId: null, answerLength: 300, nowMs: 5_000 })
|
|
59
|
+
expect(calls[0]!.threadId).toBeNull()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('caps the length floor at FINAL_ANSWER_MIN_CHARS for long answers (a short ping can never suppress a long answer)', () => {
|
|
63
|
+
const { calls, deps } = capture(false)
|
|
64
|
+
shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 5_000, nowMs: 10_000 })
|
|
65
|
+
expect(calls[0]!.minChars).toBe(FINAL_ANSWER_MIN_CHARS)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('lowers the floor to the answer length for terse answers (their own stream materialization still suppresses a duplicate)', () => {
|
|
69
|
+
const { calls, deps } = capture(true)
|
|
70
|
+
shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 12, nowMs: 10_000 })
|
|
71
|
+
expect(calls[0]!.minChars).toBe(12)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('clamps the floor to >= 1 for a degenerate empty answer', () => {
|
|
75
|
+
const { calls, deps } = capture(false)
|
|
76
|
+
shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 0, nowMs: 10_000 })
|
|
77
|
+
expect(calls[0]!.minChars).toBe(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('fails open (no suppression) when the oracle throws — deliver beats drop', () => {
|
|
81
|
+
const deps = {
|
|
82
|
+
hasSubstantiveOutbound: () => {
|
|
83
|
+
throw new Error('sqlite unavailable')
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
expect(
|
|
87
|
+
shouldSuppressTurnFlush(deps, { chatId: '-100', threadId: null, answerLength: 300, nowMs: 10_000 }),
|
|
88
|
+
).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
})
|