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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.18.30", COMMIT_SHA = "7729c674";
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 ?? SWITCHROOM_DEFAULT_MAIN_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.30";
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.30",
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
- _EFFECTIVE_MODEL={{{modelQ}}}
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.30";
84574
- var COMMIT_SHA = "7729c674";
84575
- var COMMIT_DATE = "2026-07-17T10:52:43+10:00";
84576
- var LATEST_PR = 3293;
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 (_chunkIndex, text5) => {
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 { getRecentOutboundCount: getRecentOutboundCount2 } = await Promise.resolve().then(() => (init_history(), exports_history));
94816
- const recentCount = getRecentOutboundCount2(backstopChatId, 2);
94817
- if (recentCount > 0) {
94818
- process.stderr.write(`telegram gateway: turn-flush suppressed \u2014 reply tool sent ${recentCount} message(s) within 2s
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.close(turn.turnId);
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 (_chunkIndex: number, text: string): Promise<number[]> => {
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
- const { getRecentOutboundCount } = await import('../history.js')
19212
- const recentCount = getRecentOutboundCount(backstopChatId, 2)
19213
- if (recentCount > 0) {
19214
- process.stderr.write(`telegram gateway: turn-flush suppressed reply tool sent ${recentCount} message(s) within 2s\n`)
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 — the reply tool already delivered this turn's answer, so
19227
- // the flush was legitimately suppressed. Emit the deferred record
19228
- // as 'suppressed' (→ `complete`, since the reply path set
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
- // #3276 finding 1 obligation close was DEFERRED out of
19233
- // endCurrentTurnAtomic. The reply tool already delivered this
19234
- // turn's answer (recentCount>0), so resolve it as satisfied
19235
- // here (idempotent with the reply path's own close). Without
19236
- // this the deferred obligation would linger OPEN and spuriously
19237
- // re-present a turn that WAS answered.
19238
- if (OBLIGATION_LEDGER_ENABLED) obligationLedger.close(turn.turnId)
19276
+ // S1 fixdo 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
+ })