switchroom 0.18.25 → 0.18.27

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.
Files changed (44) hide show
  1. package/README.md +6 -2
  2. package/dist/cli/ms-365-write-pretool.mjs +4953 -14
  3. package/dist/cli/switchroom.js +1 -1
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +2 -2
  6. package/profiles/_base/start.sh.hbs +16 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +832 -38
  8. package/telegram-plugin/flushed-turn-supersede.ts +58 -0
  9. package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +305 -41
  11. package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
  12. package/telegram-plugin/gateway/model-command.ts +68 -0
  13. package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
  14. package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
  15. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
  16. package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
  17. package/telegram-plugin/render/code-segments.ts +210 -0
  18. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  19. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  20. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  21. package/telegram-plugin/render/line-start-guard.ts +167 -0
  22. package/telegram-plugin/render/rich-render.ts +7 -0
  23. package/telegram-plugin/rich-send.ts +48 -2
  24. package/telegram-plugin/send-gate.test.ts +138 -0
  25. package/telegram-plugin/send-gate.ts +104 -1
  26. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
  27. package/telegram-plugin/tests/effort-command.test.ts +47 -0
  28. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
  29. package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
  30. package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
  31. package/telegram-plugin/tests/model-command.test.ts +112 -0
  32. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
  33. package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
  34. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
  35. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  36. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  37. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  38. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  39. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  40. package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
  41. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
  42. package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
  43. package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
  44. package/telegram-plugin/worker-activity-feed.ts +91 -1
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { guardAccidentalInlinePairs } from "../../render/inline-pairs-guard.js";
3
+
4
+ // Strip defusing backslashes so we can assert the reader-visible text is
5
+ // byte-identical to the original prose (the backslash is consumed by Telegram's
6
+ // parser; copy-paste yields the clean glyph).
7
+ function copyText(s: string): string {
8
+ return s.replace(/\\([~=|])/g, "$1");
9
+ }
10
+
11
+ describe("guardAccidentalInlinePairs — NO false positives on INTENDED formatting", () => {
12
+ // These are the hard constraint: the agent uses these DELIBERATELY. They must
13
+ // pass through byte-for-byte untouched.
14
+ it("leaves intended `~~strike~~` (word content) untouched", () => {
15
+ const s = "the ~~deprecated~~ flag is gone";
16
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
17
+ });
18
+
19
+ it("leaves intended single-tilde `~struck~` (word content) untouched", () => {
20
+ const s = "mark it ~struck~ for now";
21
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
22
+ });
23
+
24
+ it("leaves intended `==mark==` highlight untouched", () => {
25
+ const s = "please ==highlight this== part";
26
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
27
+ });
28
+
29
+ it("leaves two intended `==mark==` highlights untouched", () => {
30
+ const s = "==first== and also ==second== matter";
31
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
32
+ });
33
+
34
+ it("leaves intended `||spoiler||` untouched", () => {
35
+ const s = "the twist is ||he was dead|| all along";
36
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
37
+ });
38
+
39
+ it("leaves two intended `||spoiler||` spans untouched", () => {
40
+ const s = "reveal ||one|| then ||two|| slowly";
41
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
42
+ });
43
+
44
+ it("leaves intended `code` spans untouched (even with operators inside)", () => {
45
+ const s = "run `x==y` and `a||b` and `~10` in the shell";
46
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
47
+ });
48
+
49
+ it("leaves a single intended strike of a number `~~2024~~` untouched (below threshold)", () => {
50
+ const s = "the ~~2024~~ figure was revised";
51
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
52
+ });
53
+
54
+ it("leaves a lone comparison `x==y` untouched (cannot form a pair)", () => {
55
+ const s = "the check is x==y in the loop";
56
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
57
+ });
58
+
59
+ it("leaves a lone approximation `~10` untouched (cannot form a pair)", () => {
60
+ const s = "roughly ~10 items remain";
61
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
62
+ });
63
+
64
+ it("leaves space-flanked `a == b == c` untouched (cannot open a span)", () => {
65
+ const s = "assert a == b == c holds";
66
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
67
+ });
68
+
69
+ it("is a strict no-op for text with none of the trigger chars", () => {
70
+ const s = "a perfectly ordinary sentence with no specials";
71
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
72
+ });
73
+ });
74
+
75
+ describe("guardAccidentalInlinePairs — neutralises ACCIDENTAL pairs", () => {
76
+ it("breaks an accidental strikethrough from two approximation tildes", () => {
77
+ const s = "trims to ~10 units, down from ~20 last week";
78
+ const out = guardAccidentalInlinePairs(s);
79
+ // No unescaped digit-adjacent tilde survives to open a `~…~` span.
80
+ expect(out).not.toMatch(/(?<!\\)~(?=\$?\.?\d)/);
81
+ expect(out).toContain("\\~10");
82
+ expect(out).toContain("\\~20");
83
+ // Reader-visible prose unchanged once the defuser is stripped.
84
+ expect(copyText(out)).toBe(s);
85
+ });
86
+
87
+ it("breaks an accidental strike from currency-approx tildes (`~$5M` / `~$9M`)", () => {
88
+ const s = "budget is ~$5M now, was ~$9M before";
89
+ const out = guardAccidentalInlinePairs(s);
90
+ expect(out).not.toMatch(/(?<!\\)~(?=\$?\.?\d)/);
91
+ expect(copyText(out)).toBe(s);
92
+ });
93
+
94
+ it("breaks the inner tilde of a `~~10 ... ~~20` double-open accidental pair", () => {
95
+ const s = "about ~~10 to ~~20 percent";
96
+ const out = guardAccidentalInlinePairs(s);
97
+ expect(out).not.toMatch(/(?<!\\)~(?=\$?\.?\d)/);
98
+ expect(copyText(out)).toBe(s);
99
+ });
100
+
101
+ it("breaks an accidental highlight from two `==` comparison operators", () => {
102
+ const s = "check x==y and then p==q in the guard";
103
+ const out = guardAccidentalInlinePairs(s);
104
+ expect(out).not.toMatch(/(?<=\w)==(?=\w)/);
105
+ expect(out).toContain("x\\=\\=y");
106
+ expect(out).toContain("p\\=\\=q");
107
+ expect(copyText(out)).toBe(s);
108
+ });
109
+
110
+ it("breaks an accidental spoiler from two `||` logical-or operators", () => {
111
+ const s = "when a||b and c||d both hold";
112
+ const out = guardAccidentalInlinePairs(s);
113
+ expect(out).not.toMatch(/(?<=\w)\|\|(?=\w)/);
114
+ expect(out).toContain("a\\|\\|b");
115
+ expect(out).toContain("c\\|\\|d");
116
+ expect(copyText(out)).toBe(s);
117
+ });
118
+
119
+ it("never escapes operators INSIDE a code span while fixing prose ones", () => {
120
+ const s = "prose x==y and p==q but code `m==n` stays";
121
+ const out = guardAccidentalInlinePairs(s);
122
+ // Prose operators neutralised…
123
+ expect(out).toContain("x\\=\\=y");
124
+ expect(out).toContain("p\\=\\=q");
125
+ // …code span verbatim.
126
+ expect(out).toContain("`m==n`");
127
+ });
128
+ });
129
+
130
+ describe("guardAccidentalInlinePairs — link / autolink awareness (finding 1)", () => {
131
+ it("leaves `==` inside a markdown link destination UNMODIFIED", () => {
132
+ const s = "see [a](https://x.io?p==1) and [b](https://y.io?q==2)";
133
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
134
+ });
135
+
136
+ it("leaves `~` (digit-adjacent) inside link destinations UNMODIFIED", () => {
137
+ const s = "see [a](https://x.io/~5x) and [b](https://y.io/~10y)";
138
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
139
+ });
140
+
141
+ it("leaves `==` inside a bare autolinked URL UNMODIFIED", () => {
142
+ const s = "open https://x.io/path?a==1&b==2 in a browser";
143
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
144
+ });
145
+ });
146
+
147
+ describe("guardAccidentalInlinePairs — table awareness (finding 3)", () => {
148
+ it("leaves a real table's empty cells (`|a||b|`) UNMODIFIED", () => {
149
+ const s = ["| A | B | C |", "| --- | --- | --- |", "|a||b||c|"].join("\n");
150
+ expect(guardAccidentalInlinePairs(s)).toBe(s);
151
+ });
152
+
153
+ it("STILL guards a genuine accidental `a||b` in prose (no delimiter row)", () => {
154
+ const out = guardAccidentalInlinePairs("eval a||b and c||d in code");
155
+ expect(out).toContain("a\\|\\|b");
156
+ expect(out).toContain("c\\|\\|d");
157
+ });
158
+ });
159
+
160
+ describe("guardAccidentalInlinePairs — idempotency", () => {
161
+ it("guarding already-guarded tilde text is a strict no-op", () => {
162
+ const once = guardAccidentalInlinePairs("trims to ~10 units, down from ~20 last week");
163
+ expect(guardAccidentalInlinePairs(once)).toBe(once);
164
+ expect(once).not.toContain("\\\\~"); // never a doubled backslash
165
+ });
166
+
167
+ it("guarding already-guarded `==`/`||` text is a strict no-op", () => {
168
+ const once = guardAccidentalInlinePairs("check x==y and p==q; also a||b and c||d");
169
+ expect(guardAccidentalInlinePairs(once)).toBe(once);
170
+ });
171
+ });
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { guardAccidentalBlockConstructs } from "../../render/line-start-guard.js";
3
+
4
+ /** Strip the defusing backslashes so we can assert the reader-visible text is
5
+ * byte-identical to the original prose (Telegram consumes the `\`). */
6
+ function copyText(s: string): string {
7
+ return s.replace(/\\([>.)])/g, "$1");
8
+ }
9
+
10
+ describe("guardAccidentalBlockConstructs (#3252) — INTENDED formatting is never touched", () => {
11
+ // ── Headings: deferred entirely, must pass through untouched ──
12
+ it("leaves an intended `# ` heading untouched (deferred family)", () => {
13
+ const s = "# Project status\nAll green.";
14
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
15
+ });
16
+
17
+ it("leaves `## `/`### ` sub-headings untouched", () => {
18
+ const s = "## Section\n### Subsection\nbody";
19
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
20
+ });
21
+
22
+ it("leaves `#1` (no space, not a GFM heading) untouched — Telegram renders it literally", () => {
23
+ const s = "#1 priority is latency";
24
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
25
+ });
26
+
27
+ it("leaves `# of items` untouched — deferred (indistinguishable from a heading)", () => {
28
+ const s = "# of items in the cart";
29
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
30
+ });
31
+
32
+ // ── Intended blockquotes: `> ` with a space, never touched ──
33
+ it("leaves an intended `> quoted` blockquote untouched", () => {
34
+ const s = "> This is a real quote\n> spanning two lines";
35
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
36
+ });
37
+
38
+ it("leaves `> 50% of users` untouched — spaced form is a valid/intended blockquote", () => {
39
+ const s = "> 50% of users prefer dark mode";
40
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
41
+ });
42
+
43
+ // ── Bullet lists: deferred entirely, never touched ──
44
+ it("leaves `- ` bullets untouched (deferred family)", () => {
45
+ const s = "- first item\n- second item\n- 5 apples";
46
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
47
+ });
48
+
49
+ it("leaves `* ` and `+ ` bullets untouched", () => {
50
+ const s = "* star bullet\n+ plus bullet";
51
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
52
+ });
53
+
54
+ it("leaves `- 5 degrees` untouched — ambiguous with a bullet, deferred", () => {
55
+ const s = "- 5 degrees below freezing";
56
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
57
+ });
58
+
59
+ // ── Ordered lists: small numbers are real lists, never touched ──
60
+ it("leaves a real `1.`/`2.`/`3.` numbered list untouched", () => {
61
+ const s = "1. First we plan\n2. Then we build\n3. Finally we ship";
62
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
63
+ });
64
+
65
+ it("leaves a 2–3 digit list marker (`42.`, `100.`) untouched — deferred", () => {
66
+ const s = "42. answer\n100. century";
67
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
68
+ });
69
+
70
+ it("leaves a decimal `3.14` untouched (no space after dot => not a list)", () => {
71
+ const s = "3.14 is pi and 2.71 is e";
72
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
73
+ });
74
+
75
+ // ── Non-triggering prose ──
76
+ it("is a strict no-op for ordinary prose with no line-start triggers", () => {
77
+ const s = "just a normal sentence about the weather today";
78
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
79
+ });
80
+ });
81
+
82
+ describe("guardAccidentalBlockConstructs (#3252) — accidental constructs ARE escaped", () => {
83
+ it("escapes a line-start `>2x` comparison (accidental blockquote)", () => {
84
+ const out = guardAccidentalBlockConstructs(">2x faster than before");
85
+ expect(out).toBe("\\>2x faster than before");
86
+ expect(copyText(out)).toBe(">2x faster than before");
87
+ });
88
+
89
+ it("escapes a line-start `>50%` comparison", () => {
90
+ const out = guardAccidentalBlockConstructs(">50% of the budget");
91
+ expect(out).toBe("\\>50% of the budget");
92
+ });
93
+
94
+ it("escapes a line-start `>=3` comparison", () => {
95
+ const out = guardAccidentalBlockConstructs(">=3 retries required");
96
+ expect(out).toBe("\\>=3 retries required");
97
+ });
98
+
99
+ it("escapes an accidental blockquote only on the offending line, mid-paragraph", () => {
100
+ const s = "Latency improved.\n>2x faster now\nShipping today.";
101
+ const out = guardAccidentalBlockConstructs(s);
102
+ expect(out).toBe("Latency improved.\n\\>2x faster now\nShipping today.");
103
+ });
104
+
105
+ it("escapes a 4+ digit year promoted to an ordered-list item (`2026. was`)", () => {
106
+ const out = guardAccidentalBlockConstructs("2026. was a great year for the team");
107
+ expect(out).toBe("2026\\. was a great year for the team");
108
+ expect(copyText(out)).toBe("2026. was a great year for the team");
109
+ });
110
+
111
+ it("escapes a `1999) ` year with the paren delimiter", () => {
112
+ const out = guardAccidentalBlockConstructs("1999) marked the dot-com peak");
113
+ expect(out).toBe("1999\\) marked the dot-com peak");
114
+ });
115
+
116
+ it("escapes the accidental ordered-list line only, inside a paragraph", () => {
117
+ const s = "Consider the timeline.\n2026. was pivotal\nThat is all.";
118
+ const out = guardAccidentalBlockConstructs(s);
119
+ expect(out).toBe("Consider the timeline.\n2026\\. was pivotal\nThat is all.");
120
+ });
121
+ });
122
+
123
+ describe("guardAccidentalBlockConstructs (#3252) — code / indent safety", () => {
124
+ it("never touches a `>` at the start of a FENCED code block line", () => {
125
+ const s = "```\n>2x in shell\n2026. code year\n```";
126
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
127
+ });
128
+
129
+ it("never touches a `>` inside an inline code span", () => {
130
+ const s = "use `>2x` in the expression";
131
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
132
+ });
133
+
134
+ it("does not treat a prose `>` that begins mid-line (after a code span) as a line start", () => {
135
+ // The `>5` is NOT at a real line start (an inline code span precedes it on
136
+ // the same line), so it must be left alone.
137
+ const s = "the value `x` >5 always";
138
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
139
+ });
140
+
141
+ it("leaves a 4-space indented (code) line untouched", () => {
142
+ const s = " >2x indented code\n 2026. also code";
143
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
144
+ });
145
+
146
+ it("still escapes a construct indented 1–3 spaces (valid block context)", () => {
147
+ const out = guardAccidentalBlockConstructs(" >2x slightly indented");
148
+ expect(out).toBe(" \\>2x slightly indented");
149
+ });
150
+ });
151
+
152
+ describe("guardAccidentalBlockConstructs (#3252) — idempotency & no-op signal", () => {
153
+ it("is idempotent: guarding already-guarded text is a strict no-op", () => {
154
+ const once = guardAccidentalBlockConstructs(">2x and\n2026. year");
155
+ expect(guardAccidentalBlockConstructs(once)).toBe(once);
156
+ expect(once).not.toContain("\\\\"); // never a doubled backslash
157
+ });
158
+
159
+ it("short-circuits to a strict no-op when there is no `>` and no 4+ digit marker", () => {
160
+ const s = "1. a\n2. b\n- c\n# d\n> e";
161
+ // All intended constructs; contains `>` so it runs, but produces no change.
162
+ expect(guardAccidentalBlockConstructs(s)).toBe(s);
163
+ });
164
+ });
@@ -277,3 +277,93 @@ describe('F2 — recency-bound the destructive latest-ended supersede tier', ()
277
277
  ).toBe('turn-T')
278
278
  })
279
279
  })
280
+
281
+ /**
282
+ * Reply-flicker edit-in-place fix (PR #3266) — the deferred-correction
283
+ * resurrection window (adversarial-review L1).
284
+ *
285
+ * The fix defers the flushed-message correction (delete-resend vs edit-in-place)
286
+ * from the supersede site to the send site so a single-message reply can EDIT
287
+ * the flushed message A in place instead of delete+resend. But `take()` consumes
288
+ * the supersede record at the supersede site, and TWO arg-validation `throw`s
289
+ * (file too-large, invalid inline_keyboard) sit BETWEEN consumption and the
290
+ * correction. A late reply that supersedes a flush AND carries an oversized file
291
+ * / invalid keyboard throws before the correction runs: message A is neither
292
+ * deleted nor edited, the model retries `reply`, and — the record already
293
+ * consumed — the retry takes the no-record branch. Without a latch the retry
294
+ * would ship a fresh B alongside the stale narration A (both visible).
295
+ *
296
+ * The fix latches `ownerTurn.answerDelivered = true` at record consumption, so
297
+ * the retry (resolving the SAME ended owner turn) is caught by
298
+ * `decideAnswerLatchSuppression` and suppressed — exactly one message survives.
299
+ */
300
+ describe('reply-flicker edit-in-place — mid-path throw + retry never resurrects the duplicate (L1)', () => {
301
+ const CHAT = '636363'
302
+
303
+ /** Minimal model of the ended owner turn atom the gateway mutates + reads. */
304
+ interface OwnerTurn { turnId: string; answerDelivered: boolean }
305
+
306
+ /**
307
+ * Drive the two gateway `reply` calls the incident produces, threading the
308
+ * SAME registry + owner-turn atom. `setLatchOnSupersede` toggles the fix on
309
+ * (true = PR behaviour) vs off (false = pre-fix red-on-main contrast).
310
+ * Returns which messages are visible after the sequence.
311
+ */
312
+ function runSupersedeThenThrowRetry(setLatchOnSupersede: boolean): {
313
+ visible: string[]
314
+ firstSuperseded: boolean
315
+ retrySuppressed: boolean
316
+ } {
317
+ const reg = new FlushedTurnSupersedeRegistry()
318
+ const now = 2_000_000
319
+ const owner: OwnerTurn = { turnId: 'turn-A', answerDelivered: false }
320
+ // The flush posted message A for turn-A and recorded it.
321
+ reg.record(CHAT, undefined, { turnId: owner.turnId, messageIds: [8001], text: 'narration\n\nthe answer' }, now)
322
+ const visible = ['A(flush-narration)']
323
+
324
+ // --- Call 1: the model's real `reply` lands and SUPERSEDES the flush. ---
325
+ const d1 = reg.take(CHAT, undefined, { liveTurnId: owner.turnId, now: now + 10 })
326
+ const firstSuperseded = d1.supersede
327
+ if (d1.supersede && setLatchOnSupersede) {
328
+ // The fix: latch at consumption, BEFORE the arg-validation throw.
329
+ owner.answerDelivered = true
330
+ }
331
+ // Arg-validation throw fires here (oversized file / invalid keyboard):
332
+ // the correction never runs → A is neither deleted nor edited, B not sent.
333
+ // (Modelled by simply NOT applying the correction and NOT pushing B.)
334
+
335
+ // --- Call 2: the model retries `reply` after seeing the MCP error. ---
336
+ // The record was consumed by Call 1's take() → no-record now.
337
+ const d2 = reg.take(CHAT, undefined, { liveTurnId: owner.turnId, now: now + 20 })
338
+ let retrySuppressed = false
339
+ if (!d2.supersede) {
340
+ // else/no-record branch: the retry is a late substantive reply.
341
+ retrySuppressed = decideAnswerLatchSuppression({
342
+ superseded: false,
343
+ replySubstantive: true,
344
+ isLateReply: true,
345
+ ownerAnswerDelivered: owner.answerDelivered,
346
+ })
347
+ }
348
+ if (!retrySuppressed) visible.push('B(retry-reply)')
349
+ return { visible, firstSuperseded, retrySuppressed }
350
+ }
351
+
352
+ it('WITH the fix: the retry is latch-suppressed → exactly ONE surviving message (never A+B)', () => {
353
+ const r = runSupersedeThenThrowRetry(true)
354
+ expect(r.firstSuperseded).toBe(true)
355
+ expect(r.retrySuppressed).toBe(true)
356
+ expect(r.visible).toHaveLength(1)
357
+ // The single surviving message is the flushed A (the correction throw left it
358
+ // in place); the retry B was suppressed — no stale-narration + reply duplicate.
359
+ expect(r.visible).toEqual(['A(flush-narration)'])
360
+ })
361
+
362
+ it('WITHOUT the fix (contrast): the retry is NOT suppressed → BOTH A and B ship (the resurrected duplicate)', () => {
363
+ const r = runSupersedeThenThrowRetry(false)
364
+ expect(r.firstSuperseded).toBe(true)
365
+ expect(r.retrySuppressed).toBe(false)
366
+ // The exact regression: stale narration A AND the retry reply B are both visible.
367
+ expect(r.visible).toEqual(['A(flush-narration)', 'B(retry-reply)'])
368
+ })
369
+ })
@@ -37,6 +37,11 @@ describe('buildSubagentHandbackInbound', () => {
37
37
  // The wake-up contract: bridge renders <channel source="subagent_handback">.
38
38
  expect(inbound.meta.source).toBe('subagent_handback')
39
39
  expect(inbound.meta.outcome).toBe('completed')
40
+ // #3268 — the fabricated ts rounds-trips through meta.message_id (the only
41
+ // channel-rendered id) so enqueue's deriveTurnId matches the pre-turn seam's
42
+ // adopt id. Must equal the top-level messageId as a string.
43
+ expect(inbound.meta.message_id).toBe(String(FIXED_NOW))
44
+ expect(inbound.meta.message_id).toBe(String(inbound.messageId))
40
45
  // Text carries the task, the result, and the beat-4 steer.
41
46
  expect(inbound.text).toContain('Refactor the auth module')
42
47
  expect(inbound.text).toContain('4 tests added, all green')
@@ -15,6 +15,9 @@ import {
15
15
  removeTurnActiveMarker,
16
16
  sweepStaleTurnActiveMarker,
17
17
  readTurnActiveMarkerAgeMs,
18
+ effectiveTurnAgeMs,
19
+ TURN_ACTIVE_HARD_TTL_MS,
20
+ TURN_ACTIVE_IDLE_SWEEP_MS,
18
21
  } from '../gateway/turn-active-marker.js'
19
22
 
20
23
  describe('turn-active-marker (#412)', () => {
@@ -28,6 +31,32 @@ describe('turn-active-marker (#412)', () => {
28
31
  rmSync(tmp, { recursive: true, force: true })
29
32
  })
30
33
 
34
+ it('exports the sweep TTL bounds as shared constants (#3262 single source of truth)', () => {
35
+ // The /model & /effort phantom-turn cross-check reuses these SAME bounds —
36
+ // guarding against a drift where the atom ceiling and the marker ceiling
37
+ // diverge into two different magic numbers.
38
+ expect(TURN_ACTIVE_HARD_TTL_MS).toBe(10 * 60_000)
39
+ expect(TURN_ACTIVE_IDLE_SWEEP_MS).toBe(60_000)
40
+ expect(TURN_ACTIVE_HARD_TTL_MS).toBeGreaterThan(TURN_ACTIVE_IDLE_SWEEP_MS)
41
+ })
42
+
43
+ it('effectiveTurnAgeMs prefers the marker age when present (#3262)', () => {
44
+ // Marker present (touched on tool_use) → its age wins over the fixed
45
+ // turn.startedAt, so a legitimately LONG turn that keeps touching the
46
+ // marker reads fresh and is never mis-swept as a phantom.
47
+ const now = 1_000_000
48
+ expect(effectiveTurnAgeMs(5_000, now - 9_999_999, now)).toBe(5_000)
49
+ })
50
+
51
+ it('effectiveTurnAgeMs falls back to now - startedAt when the marker is absent (#3262)', () => {
52
+ // Marker already swept (null age) → fall back to the turn-start timestamp,
53
+ // so a dangling atom whose marker was reaped still reports a large age and
54
+ // is recognised as stale by the /model & /effort cross-check.
55
+ const now = 1_000_000
56
+ const startedAt = now - 42_000
57
+ expect(effectiveTurnAgeMs(null, startedAt, now)).toBe(42_000)
58
+ })
59
+
31
60
  it('writeTurnActiveMarker creates a JSON file with the expected payload', () => {
32
61
  writeTurnActiveMarker(tmp, {
33
62
  turnKey: 'chat:1:1700000000000',
@@ -659,6 +659,10 @@ describe('createWorkerActivityFeed — heartbeat', () => {
659
659
  bot,
660
660
  now: () => clock,
661
661
  minEditIntervalMs: 2500,
662
+ // Suffix-mechanics test: pin the elapsed-refresh cadence to the edit floor
663
+ // so this exercises the classic 6s heartbeat suffix, not the coarse
664
+ // flood-pacing cadence (covered by its own tests below).
665
+ elapsedRefreshMs: 2500,
662
666
  heartbeatTickMs: 6000,
663
667
  // No real timer: drive ticks manually.
664
668
  setInterval: () => 1,
@@ -862,6 +866,10 @@ describe('createWorkerActivityFeed — heartbeat', () => {
862
866
  firstPaintMinMs: 8000,
863
867
  heartbeatTickMs: 6000,
864
868
  minEditIntervalMs: 2500,
869
+ // Heartbeat-liveness test: pin the elapsed-refresh cadence to the edit
870
+ // floor so a later heartbeat repaints the climbing clock under the classic
871
+ // 6s cadence (coarse flood-pacing is covered by its own tests below).
872
+ elapsedRefreshMs: 2500,
865
873
  setInterval: () => 1,
866
874
  clearInterval: () => {},
867
875
  })
@@ -1551,3 +1559,116 @@ describe('worker-feed send-gate shed contract', () => {
1551
1559
  expect(feed.has('w1')).toBe(false)
1552
1560
  })
1553
1561
  })
1562
+
1563
+ // ─── elapsed-only edit pacing (flood-ban defense, Part 1) ────────────────────
1564
+
1565
+ describe('createWorkerActivityFeed — elapsed-only edit pacing', () => {
1566
+ const drain = () => new Promise((r) => setTimeout(r, 0))
1567
+
1568
+ function mkFeed(bot: FakeBot, nowRef: { t: number }) {
1569
+ return createWorkerActivityFeed({
1570
+ bot,
1571
+ now: () => nowRef.t,
1572
+ minEditIntervalMs: 2500,
1573
+ elapsedRefreshMs: 15_000,
1574
+ firstPaintMinMs: 8000,
1575
+ heartbeatTickMs: 6000,
1576
+ setInterval: () => 1,
1577
+ clearInterval: () => {},
1578
+ })
1579
+ }
1580
+
1581
+ it('suppresses clock-only churn: elapsed advancing within elapsedRefreshMs emits NO edit', async () => {
1582
+ const bot = makeFakeBot()
1583
+ const ref = { t: 10_000 }
1584
+ const feed = mkFeed(bot, ref)
1585
+
1586
+ // First paint.
1587
+ ref.t = 20_000
1588
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'scanning', toolCount: 3 }))
1589
+ expect(bot.sent).toHaveLength(1)
1590
+
1591
+ // A stream of ticks where ONLY the elapsed clock advances (same step, same
1592
+ // toolCount). Every one is past the 2.5s edit floor but under the 15s
1593
+ // elapsed-refresh cadence measured from the last edit (paint @20_000) — so
1594
+ // the send-gate-evading clock-only edit that earns a flood ban never fires.
1595
+ for (const t of [23_000, 26_000, 30_000, 34_000]) {
1596
+ ref.t = t
1597
+ await feed.update('w1', 'chat', view({ elapsedMs: t - 11_000, latestSummary: 'scanning', toolCount: 3 }))
1598
+ }
1599
+ await drain()
1600
+ expect(bot.edits).toHaveLength(0)
1601
+ })
1602
+
1603
+ it('renders a substantive step change PROMPTLY (before the elapsed-refresh cadence)', async () => {
1604
+ const bot = makeFakeBot()
1605
+ const ref = { t: 10_000 }
1606
+ const feed = mkFeed(bot, ref)
1607
+
1608
+ ref.t = 20_000
1609
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'scanning', toolCount: 3 }))
1610
+ expect(bot.sent).toHaveLength(1)
1611
+
1612
+ // 6s later: still under the 15s elapsed-refresh cadence, but the STEP and
1613
+ // toolCount changed — a real update. It renders immediately (only the 2.5s
1614
+ // floor applies), not held for the coarse clock cadence.
1615
+ ref.t = 26_000
1616
+ await feed.update('w1', 'chat', view({ elapsedMs: 15_000, latestSummary: 'writing report', toolCount: 4 }))
1617
+ await drain()
1618
+ expect(bot.edits.length).toBeGreaterThanOrEqual(1)
1619
+ expect(bot.edits[bot.edits.length - 1].text).toContain('writing report')
1620
+ })
1621
+
1622
+ it('forces the terminal edit through and renders the honest final elapsed', async () => {
1623
+ const bot = makeFakeBot()
1624
+ const ref = { t: 10_000 }
1625
+ const feed = mkFeed(bot, ref)
1626
+
1627
+ ref.t = 20_000
1628
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'scanning', toolCount: 3 }))
1629
+ expect(bot.sent).toHaveLength(1)
1630
+
1631
+ // Completion within the elapsed-refresh window still finalizes immediately
1632
+ // (terminal edits bypass the pacing) and shows the correct final elapsed.
1633
+ ref.t = 28_000
1634
+ await feed.finish('w1', view({ state: 'done', toolCount: 6, elapsedMs: 31_000, latestSummary: 'all done' }))
1635
+ await drain()
1636
+ expect(bot.edits.length).toBeGreaterThanOrEqual(1)
1637
+ const last = bot.edits[bot.edits.length - 1].text
1638
+ expect(last).toContain('done · 6 tools')
1639
+ expect(last).toContain('31s')
1640
+ })
1641
+
1642
+ it('does not over-suppress a substantive change that would field-boundary-collide under naive concatenation', async () => {
1643
+ const bot = makeFakeBot()
1644
+ const ref = { t: 10_000 }
1645
+ const feed = mkFeed(bot, ref)
1646
+
1647
+ // Paint: toolCount 1, totalTokens 23. Under a delimiter-less substance key
1648
+ // the fields `1` and `23` concatenate to `123`.
1649
+ ref.t = 20_000
1650
+ await feed.update(
1651
+ 'w1',
1652
+ 'chat',
1653
+ view({ elapsedMs: 9000, latestSummary: 'scanning', toolCount: 1, totalTokens: 23 }),
1654
+ )
1655
+ expect(bot.sent).toHaveLength(1)
1656
+
1657
+ // A REAL substantive change (toolCount 1→12, totalTokens 23→3) that collides
1658
+ // to the SAME `123` under naive concatenation. It lands within the 15s
1659
+ // elapsed-refresh window (past the 2.5s floor), so ONLY a substance-key
1660
+ // difference can drive the edit. A colliding key would wrongly treat this as
1661
+ // elapsed-only churn and suppress it; the NUL/RS-delimited key keeps them
1662
+ // distinct, so the edit fires promptly.
1663
+ ref.t = 22_500
1664
+ await feed.update(
1665
+ 'w1',
1666
+ 'chat',
1667
+ view({ elapsedMs: 11_500, latestSummary: 'scanning', toolCount: 12, totalTokens: 3 }),
1668
+ )
1669
+ await drain()
1670
+ expect(bot.edits.length).toBeGreaterThanOrEqual(1)
1671
+ // The rendered card reflects the new tool count, proving the change landed.
1672
+ expect(bot.edits[bot.edits.length - 1].text).toContain('12 tools')
1673
+ })
1674
+ })