switchroom 0.14.40 → 0.14.41

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.
@@ -49462,8 +49462,8 @@ var {
49462
49462
  } = import__.default;
49463
49463
 
49464
49464
  // src/build-info.ts
49465
- var VERSION = "0.14.40";
49466
- var COMMIT_SHA = "d2d69140";
49465
+ var VERSION = "0.14.41";
49466
+ var COMMIT_SHA = "747ab2f1";
49467
49467
 
49468
49468
  // src/cli/agent.ts
49469
49469
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.40",
3
+ "version": "0.14.41",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47263,11 +47263,17 @@ function decideInboundDelivery(input) {
47263
47263
  function createDeliveryQueue() {
47264
47264
  return { pending: new Map };
47265
47265
  }
47266
- function trackDelivery(q, key, inbound, now) {
47267
- q.pending.set(key, { key, inbound, lastAttemptAt: now });
47266
+ function trackDelivery(q, key, inbound, now, messageId = null) {
47267
+ q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now });
47268
47268
  }
47269
- function ackDelivery(q, key) {
47270
- return q.pending.delete(key);
47269
+ function ackDelivery(q, key, enqueueMessageId = null) {
47270
+ const entry = q.pending.get(key);
47271
+ if (!entry)
47272
+ return false;
47273
+ if (entry.messageId != null && entry.messageId !== enqueueMessageId)
47274
+ return false;
47275
+ q.pending.delete(key);
47276
+ return true;
47271
47277
  }
47272
47278
  function sweep(q, now, timeoutMs) {
47273
47279
  const redeliver = [];
@@ -47282,6 +47288,15 @@ function sweep(q, now, timeoutMs) {
47282
47288
  function forgetDelivery(q, key) {
47283
47289
  q.pending.delete(key);
47284
47290
  }
47291
+ function shouldTrackDelivery(input) {
47292
+ if (input.isSteering || input.isInterrupt)
47293
+ return false;
47294
+ if (input.hasSource)
47295
+ return false;
47296
+ if (input.effectiveText !== undefined && input.effectiveText.trim().length === 0)
47297
+ return false;
47298
+ return true;
47299
+ }
47285
47300
 
47286
47301
  // gateway/pending-permission-decisions.ts
47287
47302
  var DEFAULT_PENDING_PERMISSION_CAP = 32;
@@ -51850,10 +51865,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51850
51865
  }
51851
51866
 
51852
51867
  // ../src/build-info.ts
51853
- var VERSION = "0.14.40";
51854
- var COMMIT_SHA = "d2d69140";
51855
- var COMMIT_DATE = "2026-06-02T08:59:46Z";
51856
- var LATEST_PR = 2090;
51868
+ var VERSION = "0.14.41";
51869
+ var COMMIT_SHA = "747ab2f1";
51870
+ var COMMIT_DATE = "2026-06-02T11:03:39Z";
51871
+ var LATEST_PR = 2095;
51857
51872
  var COMMITS_AHEAD_OF_TAG = 0;
51858
51873
 
51859
51874
  // gateway/boot-version.ts
@@ -53028,7 +53043,9 @@ function markClaudeBusyForInbound(m) {
53028
53043
  return key;
53029
53044
  }
53030
53045
  var DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM !== "0";
53031
- var DELIVERY_CONFIRM_TIMEOUT_MS = Number(process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS) || 15000;
53046
+ var _deliveryTimeoutRaw = process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS;
53047
+ var _deliveryTimeoutParsed = _deliveryTimeoutRaw != null && _deliveryTimeoutRaw !== "" ? Number(_deliveryTimeoutRaw) : 15000;
53048
+ var DELIVERY_CONFIRM_TIMEOUT_MS = Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15000;
53032
53049
  var DELIVERY_CONFIRM_SWEEP_MS = 5000;
53033
53050
  var deliveryQueue = createDeliveryQueue();
53034
53051
  function turnInFlightForGate() {
@@ -54160,7 +54177,12 @@ async function redeliverStrandedInbound(p) {
54160
54177
  clearAgentComposer2({ agentName: selfAgent });
54161
54178
  } catch {}
54162
54179
  const ok = ipcServer.sendToAgent(selfAgent, p.inbound);
54163
- if (!ok) {
54180
+ if (ok) {
54181
+ markClaudeBusyForInbound(p.inbound);
54182
+ if (!deliveryQueue.pending.has(p.key)) {
54183
+ trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId);
54184
+ }
54185
+ } else {
54164
54186
  pendingInboundBuffer.push(selfAgent, p.inbound);
54165
54187
  forgetDelivery(deliveryQueue, p.key);
54166
54188
  }
@@ -54168,6 +54190,10 @@ async function redeliverStrandedInbound(p) {
54168
54190
  var _deliveryConfirmSweep = setInterval(() => {
54169
54191
  if (!DELIVERY_CONFIRM_ENABLED)
54170
54192
  return;
54193
+ if (currentTurn != null)
54194
+ return;
54195
+ if (pendingPermissions.size > 0 || pendingAskUser.size > 0)
54196
+ return;
54171
54197
  for (const p of sweep(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
54172
54198
  redeliverStrandedInbound(p);
54173
54199
  }
@@ -56564,7 +56590,7 @@ function handleSessionEvent(ev) {
56564
56590
  };
56565
56591
  currentTurn = next;
56566
56592
  if (DELIVERY_CONFIRM_ENABLED) {
56567
- ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null));
56593
+ ackDelivery(deliveryQueue, chatKey2(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null), ev.messageId);
56568
56594
  }
56569
56595
  shadowEmit({
56570
56596
  kind: "turnStart",
@@ -58000,12 +58026,24 @@ ${preBlock(write.output)}`;
58000
58026
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg);
58001
58027
  if (delivered) {
58002
58028
  const busyKey = markClaudeBusyForInbound(inboundMsg);
58003
- if (DELIVERY_CONFIRM_ENABLED) {
58004
- trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now());
58029
+ if (DELIVERY_CONFIRM_ENABLED && shouldTrackDelivery({
58030
+ isSteering,
58031
+ isInterrupt: interrupt.isInterrupt,
58032
+ hasSource: inboundMsg.meta?.source != null,
58033
+ effectiveText
58034
+ })) {
58035
+ trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId));
58005
58036
  }
58006
58037
  }
58007
58038
  if (!delivered) {
58008
- pendingInboundBuffer.push(selfAgent, inboundMsg);
58039
+ if (shouldTrackDelivery({
58040
+ isSteering,
58041
+ isInterrupt: interrupt.isInterrupt,
58042
+ hasSource: inboundMsg.meta?.source != null,
58043
+ effectiveText
58044
+ })) {
58045
+ pendingInboundBuffer.push(selfAgent, inboundMsg);
58046
+ }
58009
58047
  const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {};
58010
58048
  swallowingApiCall(() => bot.api.sendMessage(chat_id, "\u23F3 Agent is restarting \u2014 your message is queued and will be processed when it reconnects.", { ...threadOpts }), {
58011
58049
  chat_id,
@@ -286,6 +286,7 @@ import {
286
286
  ackDelivery,
287
287
  sweep as sweepDeliveryQueue,
288
288
  forgetDelivery,
289
+ shouldTrackDelivery,
289
290
  type PendingDelivery,
290
291
  } from './inbound-delivery-confirm.js'
291
292
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
@@ -1342,7 +1343,15 @@ const DELIVERY_CONFIRM_ENABLED = process.env.SWITCHROOM_INBOUND_DELIVERY_CONFIRM
1342
1343
  // a clean delivery, so 15s won't false-positive on a healthy turn. Tunable
1343
1344
  // (env) for tests/forensics; a too-low value re-delivers healthy slow turns
1344
1345
  // (duplicate turn), which is why the default is comfortably above ack latency.
1345
- const DELIVERY_CONFIRM_TIMEOUT_MS = Number(process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS) || 15_000
1346
+ const _deliveryTimeoutRaw = process.env.SWITCHROOM_INBOUND_DELIVERY_TIMEOUT_MS
1347
+ const _deliveryTimeoutParsed =
1348
+ _deliveryTimeoutRaw != null && _deliveryTimeoutRaw !== '' ? Number(_deliveryTimeoutRaw) : 15_000
1349
+ // Clamp to a positive, finite value: a negative / zero / NaN env override would
1350
+ // make the sweep treat every tracked entry as stranded and re-deliver every
1351
+ // cycle forever (a self-inflicted re-delivery loop). To disable the feature,
1352
+ // use SWITCHROOM_INBOUND_DELIVERY_CONFIRM=0, not a degenerate timeout.
1353
+ const DELIVERY_CONFIRM_TIMEOUT_MS =
1354
+ Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15_000
1346
1355
  const DELIVERY_CONFIRM_SWEEP_MS = 5_000
1347
1356
  const deliveryQueue = createDeliveryQueue<InboundMessage>()
1348
1357
 
@@ -4106,7 +4115,16 @@ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Pro
4106
4115
  if (selfAgent) clearAgentComposer({ agentName: selfAgent })
4107
4116
  } catch { /* best-effort; re-deliver regardless */ }
4108
4117
  const ok = ipcServer.sendToAgent(selfAgent, p.inbound)
4109
- if (!ok) {
4118
+ if (ok) {
4119
+ // Keep the #1556 gate coherent with the re-sent delivery, and survive an
4120
+ // ack that raced the `await import` above: only `enqueue` clears tracking,
4121
+ // so if a concurrent ack removed the entry, re-affirm it — never drop.
4122
+ // Both ops are idempotent.
4123
+ markClaudeBusyForInbound(p.inbound)
4124
+ if (!deliveryQueue.pending.has(p.key)) {
4125
+ trackDelivery(deliveryQueue, p.key, p.inbound, Date.now(), p.messageId)
4126
+ }
4127
+ } else {
4110
4128
  // Bridge offline between attempts — hand off to the offline buffer
4111
4129
  // (bridgeUp drains it) and stop tracking here; the spool owns it now.
4112
4130
  pendingInboundBuffer.push(selfAgent, p.inbound)
@@ -4115,6 +4133,16 @@ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Pro
4115
4133
  }
4116
4134
  const _deliveryConfirmSweep = setInterval(() => {
4117
4135
  if (!DELIVERY_CONFIRM_ENABLED) return
4136
+ // Re-deliver ONLY when claude is genuinely idle. `currentTurn` is set solely
4137
+ // by the enqueue session-event and nulled at turn-end, so `currentTurn != null`
4138
+ // means a real turn is in flight — re-clearing the composer + re-sending now
4139
+ // would clobber it (the exact mid-turn wedge this queue exists to prevent). A
4140
+ // pending permission / ask_user prompt is likewise a live interaction. Defer:
4141
+ // leave the entry pending (it isn't acked) so the next idle sweep retries.
4142
+ // NB: claudeBusyKeys (turnInFlightForGate) is set EAGERLY at delivery and
4143
+ // stays set through a strand, so it is NOT a usable "idle" signal here.
4144
+ if (currentTurn != null) return
4145
+ if (pendingPermissions.size > 0 || pendingAskUser.size > 0) return
4118
4146
  for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
4119
4147
  void redeliverStrandedInbound(p)
4120
4148
  }
@@ -7945,9 +7973,14 @@ function handleSessionEvent(ev: SessionEvent): void {
7945
7973
  // re-delivery. `enqueue` carries the same chat/thread the inbound was
7946
7974
  // keyed on, so the key matches.
7947
7975
  if (DELIVERY_CONFIRM_ENABLED) {
7976
+ // Match on the source message id: `enqueue` fires for EVERY turn
7977
+ // start (cron / subagent-handback / vault-resume / restart-marker
7978
+ // too — see comment below), so a key-only ack would let a synthetic
7979
+ // turn clear a real user message still waiting under the same key.
7948
7980
  ackDelivery(
7949
7981
  deliveryQueue,
7950
7982
  chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
7983
+ ev.messageId,
7951
7984
  )
7952
7985
  }
7953
7986
  // PR3b-cutover: feed the authoritative turn-start to the delivery
@@ -10670,13 +10703,40 @@ async function handleInbound(
10670
10703
  const busyKey = markClaudeBusyForInbound(inboundMsg)
10671
10704
  // Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
10672
10705
  // lands, the message stranded in the composer and the sweep re-delivers
10673
- // it. See inbound-delivery-confirm.ts.
10674
- if (DELIVERY_CONFIRM_ENABLED) {
10675
- trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now())
10706
+ // it. Track ONLY messages that produce an `enqueue` to ack against —
10707
+ // shouldTrackDelivery excludes steering / `!` interrupt (amend the running
10708
+ // turn), synthetic (meta.source) inbounds, and empty bodies, all of which
10709
+ // never enqueue and would re-deliver forever. The tracked messageId lets
10710
+ // the ack match only THIS message's enqueue (not a synthetic turn sharing
10711
+ // the key). See shouldTrackDelivery / ackDelivery (inbound-delivery-confirm.ts).
10712
+ if (
10713
+ DELIVERY_CONFIRM_ENABLED &&
10714
+ shouldTrackDelivery({
10715
+ isSteering,
10716
+ isInterrupt: interrupt.isInterrupt,
10717
+ hasSource: inboundMsg.meta?.source != null,
10718
+ effectiveText,
10719
+ })
10720
+ ) {
10721
+ trackDelivery(deliveryQueue, busyKey, inboundMsg, Date.now(), String(inboundMsg.messageId))
10676
10722
  }
10677
10723
  }
10678
10724
  if (!delivered) {
10679
- pendingInboundBuffer.push(selfAgent, inboundMsg)
10725
+ // Only persist fresh user turns to the durable spool. Steering / `!`
10726
+ // interrupt / empty bodies are mid-turn amendments or no-ops that would
10727
+ // arrive orphaned if replayed as a fresh turn after a restart — drop them
10728
+ // (the restart notice below tells the user to re-send). Mirrors the
10729
+ // tracking + #1556-gate carve-outs.
10730
+ if (
10731
+ shouldTrackDelivery({
10732
+ isSteering,
10733
+ isInterrupt: interrupt.isInterrupt,
10734
+ hasSource: inboundMsg.meta?.source != null,
10735
+ effectiveText,
10736
+ })
10737
+ ) {
10738
+ pendingInboundBuffer.push(selfAgent, inboundMsg)
10739
+ }
10680
10740
  const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
10681
10741
  // #1075: thread-id-bearing — swallow via robustApiCall so a deleted
10682
10742
  // topic doesn't crash the gateway. Fire-and-forget; the inbound is
@@ -35,6 +35,14 @@ export interface PendingDelivery<M> {
35
35
  readonly key: string
36
36
  /** The exact inbound to re-send until claude acks it. */
37
37
  readonly inbound: M
38
+ /**
39
+ * Source message id of the tracked inbound (stringified Telegram
40
+ * `message_id`), or null if unknown. The `enqueue` ack matches on THIS so a
41
+ * synthetic-source turn (cron / resume / vault / reaction) that shares the
42
+ * chatKey can't false-ack — and silently drop — a real user message still
43
+ * waiting to land. See ackDelivery.
44
+ */
45
+ readonly messageId: string | null
38
46
  /** When the latest delivery attempt was made (unix-ms). */
39
47
  lastAttemptAt: number
40
48
  }
@@ -51,22 +59,44 @@ export function createDeliveryQueue<M>(): DeliveryQueue<M> {
51
59
  * Track a freshly-delivered inbound, awaiting claude's `enqueue` ack.
52
60
  * Overwrites any prior pending for the key — the #1556 gate serialises per
53
61
  * key, so a later inbound supersedes an earlier un-acked one for that key.
62
+ * `messageId` (stringified Telegram message_id) lets the ack match only the
63
+ * enqueue that belongs to THIS message; pass null when unknown.
54
64
  */
55
65
  export function trackDelivery<M>(
56
66
  q: DeliveryQueue<M>,
57
67
  key: string,
58
68
  inbound: M,
59
69
  now: number,
70
+ messageId: string | null = null,
60
71
  ): void {
61
- q.pending.set(key, { key, inbound, lastAttemptAt: now })
72
+ q.pending.set(key, { key, inbound, messageId, lastAttemptAt: now })
62
73
  }
63
74
 
64
75
  /**
65
- * Ack a delivery — call from the `enqueue` session-event (claude started the
66
- * turn, so the message landed). Returns true if a pending entry was cleared.
76
+ * Ack a delivery — call from the `enqueue` session-event (claude started a
77
+ * turn). `enqueue` fires for EVERY turn start regardless of source (user
78
+ * inbound, cron, subagent-handback, vault-resume, restart-marker), so acking
79
+ * purely by chatKey would let a synthetic-source turn clear — and thus
80
+ * silently drop — a real user message still waiting under the same key. So
81
+ * ack ONLY when the enqueue's source message id matches the tracked one.
82
+ *
83
+ * Matching rule: if we recorded a messageId for the pending entry, require the
84
+ * enqueue's `enqueueMessageId` to equal it. If we never recorded one (legacy /
85
+ * defensive null), fall back to key-only ack. Returns true if an entry was
86
+ * cleared.
67
87
  */
68
- export function ackDelivery<M>(q: DeliveryQueue<M>, key: string): boolean {
69
- return q.pending.delete(key)
88
+ export function ackDelivery<M>(
89
+ q: DeliveryQueue<M>,
90
+ key: string,
91
+ enqueueMessageId: string | null = null,
92
+ ): boolean {
93
+ const entry = q.pending.get(key)
94
+ if (!entry) return false
95
+ // A different message started this turn — don't ack ours (it may still be
96
+ // waiting to land; the sweep will re-deliver it if it stranded).
97
+ if (entry.messageId != null && entry.messageId !== enqueueMessageId) return false
98
+ q.pending.delete(key)
99
+ return true
70
100
  }
71
101
 
72
102
  /**
@@ -94,3 +124,37 @@ export function sweep<M>(
94
124
  export function forgetDelivery<M>(q: DeliveryQueue<M>, key: string): void {
95
125
  q.pending.delete(key)
96
126
  }
127
+
128
+ /**
129
+ * Should this delivered inbound be tracked for ack/re-delivery?
130
+ *
131
+ * Track a delivery iff it is a fresh user turn that will produce exactly one
132
+ * `enqueue` to ack against. Everything that does NOT enqueue must be excluded,
133
+ * or the sweep re-delivers it forever (re-clearing the composer every cycle):
134
+ *
135
+ * - `isSteering` / `isInterrupt` — the #1556 gate's carve-outs: delivered
136
+ * mid-turn to AMEND the running turn, so they never start a fresh turn and
137
+ * never emit `enqueue`.
138
+ * - `hasSource` — synthetic inbounds (cron / vault-resume / subagent-handback
139
+ * / reaction) carry a `meta.source`; they enqueue under their own semantics
140
+ * and must never be tracked as if they were a queued user turn.
141
+ * - empty `effectiveText` — an empty body (e.g. `/queue` with no text) is
142
+ * silently dropped by claude's auto-submit and never enqueues, so tracking
143
+ * it is a pure re-delivery loop (a self-inflicted DoS on the queue).
144
+ *
145
+ * Mirror the gate's carve-outs here so tracking is exactly the set of messages
146
+ * that produce an `enqueue`.
147
+ */
148
+ export function shouldTrackDelivery(input: {
149
+ isSteering: boolean
150
+ isInterrupt: boolean
151
+ hasSource?: boolean
152
+ effectiveText?: string
153
+ }): boolean {
154
+ if (input.isSteering || input.isInterrupt) return false
155
+ if (input.hasSource) return false
156
+ // Gate on empty text only when the caller actually provided it (undefined =
157
+ // "not supplied", left untracked-gated so existing callers keep their behaviour).
158
+ if (input.effectiveText !== undefined && input.effectiveText.trim().length === 0) return false
159
+ return true
160
+ }
@@ -4,6 +4,7 @@ import {
4
4
  ackDelivery,
5
5
  createDeliveryQueue,
6
6
  forgetDelivery,
7
+ shouldTrackDelivery,
7
8
  sweep,
8
9
  trackDelivery,
9
10
  type DeliveryQueue,
@@ -107,3 +108,73 @@ describe('inbound-delivery-confirm (reliable deliver-until-acked queue)', () =>
107
108
  expect(sweep(q, 999_999, TIMEOUT)).toHaveLength(0)
108
109
  })
109
110
  })
111
+
112
+ // Regression for the cross-source ACK collision (silent drop): `enqueue` fires
113
+ // for EVERY turn start regardless of source. A synthetic-source turn (cron /
114
+ // resume / vault / reaction) that shares the chatKey of a real user message
115
+ // still waiting to land would, with a key-only ack, clear — and silently drop
116
+ // — that user message. The ack must match on the tracked message id.
117
+ describe('ackDelivery — message-id-matched (cross-source false-ack guard)', () => {
118
+ it('acks when the enqueue message id matches the tracked one', () => {
119
+ const q = fresh()
120
+ trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
121
+ expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
122
+ expect(q.pending.size).toBe(0)
123
+ })
124
+
125
+ it('does NOT ack when a different (synthetic-source) turn enqueues under the same key', () => {
126
+ const q = fresh()
127
+ trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
128
+ // a cron / resume turn for the same chat enqueues first, with its own id
129
+ expect(ackDelivery(q, 'chat:_', '1716123456789')).toBe(false)
130
+ // the user message is still tracked — it strands → gets re-delivered, not dropped
131
+ expect(q.pending.size).toBe(1)
132
+ expect(sweep(q, 15_000, TIMEOUT)).toHaveLength(1)
133
+ // and its own enqueue (matching id) later acks it cleanly
134
+ expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
135
+ expect(q.pending.size).toBe(0)
136
+ })
137
+
138
+ it('does NOT ack when the enqueue carries no message id but the tracked one has one', () => {
139
+ const q = fresh()
140
+ trackDelivery(q, 'chat:_', { text: 'real user msg' }, 0, '5001')
141
+ expect(ackDelivery(q, 'chat:_', null)).toBe(false)
142
+ expect(q.pending.size).toBe(1)
143
+ })
144
+
145
+ it('falls back to key-only ack when no message id was recorded (legacy/defensive)', () => {
146
+ const q = fresh()
147
+ trackDelivery(q, 'chat:_', { text: 'x' }, 0) // no messageId
148
+ expect(ackDelivery(q, 'chat:_', '5001')).toBe(true)
149
+ expect(q.pending.size).toBe(0)
150
+ })
151
+ })
152
+
153
+ // Regression for the steer/interrupt re-delivery loop: steering and `!`
154
+ // interrupt inbounds amend the running turn and never emit `enqueue`, so they
155
+ // must NOT be tracked (else the sweep re-delivers them forever). Only
156
+ // fresh-turn messages — which DO enqueue — are tracked.
157
+ describe('shouldTrackDelivery — only fresh-turn messages are tracked', () => {
158
+ it('tracks a normal (non-steering, non-interrupt) message', () => {
159
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false })).toBe(true)
160
+ })
161
+ it('does NOT track a /steer message (amends the turn — never acks)', () => {
162
+ expect(shouldTrackDelivery({ isSteering: true, isInterrupt: false })).toBe(false)
163
+ })
164
+ it('does NOT track a ! interrupt message (amends the turn — never acks)', () => {
165
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: true })).toBe(false)
166
+ })
167
+ it('does NOT track when both flags set (defensive)', () => {
168
+ expect(shouldTrackDelivery({ isSteering: true, isInterrupt: true })).toBe(false)
169
+ })
170
+ it('does NOT track a synthetic (meta.source) inbound — cron/resume/vault/reaction enqueue under their own semantics', () => {
171
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, hasSource: true })).toBe(false)
172
+ })
173
+ it('does NOT track an empty-body message (e.g. `/queue` with no text) — never enqueues, would re-deliver forever', () => {
174
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: '' })).toBe(false)
175
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: ' ' })).toBe(false)
176
+ })
177
+ it('tracks a normal message when effectiveText is provided and non-empty', () => {
178
+ expect(shouldTrackDelivery({ isSteering: false, isInterrupt: false, effectiveText: 'draft the email' })).toBe(true)
179
+ })
180
+ })
@@ -55,8 +55,15 @@ describe("uat: rapid follow-ups — steering vs queued classification", () => {
55
55
  (m) => {
56
56
  const txt = m.text;
57
57
  const mentionsMd5 = /\bmd5\b/i.test(txt);
58
+ // Steer narration: the agent acknowledges amending the in-flight
59
+ // task. Accept the phrasings the model actually uses — including
60
+ // "Switched to MD5 per your update/follow-up" (the 2026-06-02
61
+ // canary reply that the old regex wrongly rejected). Anchored on
62
+ // "per your <qualifier>" / continuation language so it stays
63
+ // distinct from the QUEUED path (a fresh answer with no such
64
+ // course-correction narration).
58
65
  const narratesSteer =
59
- /↪️|\bsteer(ing)?\b|continuing the (prior|original|in-flight) task|amendment|course[- ]correct/i.test(
66
+ /↪️|\bsteer(ing)?\b|switch(?:ed|ing)? to \w+ per your (?:update|follow-?up|guidance|request|steer)|continuing the (prior|original|in-flight) task|amendment|course[- ]correct/i.test(
60
67
  txt,
61
68
  );
62
69
  return mentionsMd5 && narratesSteer;