switchroom 0.18.26 → 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.
- package/README.md +6 -2
- package/dist/cli/ms-365-write-pretool.mjs +4953 -14
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +16 -0
- package/telegram-plugin/dist/gateway/gateway.js +494 -36
- package/telegram-plugin/flushed-turn-supersede.ts +58 -0
- package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +305 -41
- package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
- package/telegram-plugin/gateway/model-command.ts +68 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
- package/telegram-plugin/send-gate.test.ts +138 -0
- package/telegram-plugin/send-gate.ts +104 -1
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
- package/telegram-plugin/tests/effort-command.test.ts +47 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
- package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
- package/telegram-plugin/tests/model-command.test.ts +112 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
- package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
- package/telegram-plugin/worker-activity-feed.ts +91 -1
|
@@ -17,6 +17,7 @@ import { describe, it, expect, beforeAll } from "vitest";
|
|
|
17
17
|
import {
|
|
18
18
|
parseModelCommand,
|
|
19
19
|
planModelCommand,
|
|
20
|
+
resolveStaleAwareBusy,
|
|
20
21
|
isModelCommandBusy,
|
|
21
22
|
modelCommandReceiptLine,
|
|
22
23
|
handleModelCommand,
|
|
@@ -1622,6 +1623,117 @@ describe("#3177 typed /model never silently swallowed mid-turn", () => {
|
|
|
1622
1623
|
});
|
|
1623
1624
|
});
|
|
1624
1625
|
|
|
1626
|
+
describe("resolveStaleAwareBusy — phantom 'active turn' fix (#3262)", () => {
|
|
1627
|
+
const HARD_TTL = 10 * 60_000;
|
|
1628
|
+
|
|
1629
|
+
it("APPLIES a set when the turn atom is DANGLING (older than the hard TTL)", () => {
|
|
1630
|
+
// A currentTurn atom whose turn_end never fired: non-null but its
|
|
1631
|
+
// liveness marker is far older than the ceiling. Discounted to idle,
|
|
1632
|
+
// and flagged for the gateway to clear.
|
|
1633
|
+
const r = resolveStaleAwareBusy({
|
|
1634
|
+
currentTurnActive: true,
|
|
1635
|
+
turnAgeMs: HARD_TTL + 1,
|
|
1636
|
+
machineInTurn: false,
|
|
1637
|
+
oldestPendingApprovalAgeMs: null,
|
|
1638
|
+
hardTtlMs: HARD_TTL,
|
|
1639
|
+
});
|
|
1640
|
+
expect(r.currentTurnActive).toBe(false);
|
|
1641
|
+
expect(r.turnInFlight).toBe(false);
|
|
1642
|
+
expect(r.clearStaleTurn).toBe(true);
|
|
1643
|
+
// Routing sees "idle" → applies, not queues.
|
|
1644
|
+
const d = planModelCommand(
|
|
1645
|
+
{ kind: "set", model: "opus" },
|
|
1646
|
+
{ currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
|
|
1647
|
+
);
|
|
1648
|
+
expect(d).toEqual({ kind: "apply", parsed: { kind: "set", model: "opus" } });
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
it("QUEUES a set when the turn is FRESH (marker within the TTL) — no regression", () => {
|
|
1652
|
+
const r = resolveStaleAwareBusy({
|
|
1653
|
+
currentTurnActive: true,
|
|
1654
|
+
turnAgeMs: 5_000,
|
|
1655
|
+
machineInTurn: false,
|
|
1656
|
+
oldestPendingApprovalAgeMs: null,
|
|
1657
|
+
hardTtlMs: HARD_TTL,
|
|
1658
|
+
});
|
|
1659
|
+
expect(r.currentTurnActive).toBe(true);
|
|
1660
|
+
expect(r.clearStaleTurn).toBe(false);
|
|
1661
|
+
const d = planModelCommand(
|
|
1662
|
+
{ kind: "set", model: "opus" },
|
|
1663
|
+
{ currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
|
|
1664
|
+
);
|
|
1665
|
+
expect(d).toEqual({ kind: "queue", target: "opus" });
|
|
1666
|
+
});
|
|
1667
|
+
|
|
1668
|
+
it("APPLIES when only a WEDGED approval (older than the TTL) holds the gate on an idle session", () => {
|
|
1669
|
+
const r = resolveStaleAwareBusy({
|
|
1670
|
+
currentTurnActive: false,
|
|
1671
|
+
turnAgeMs: null,
|
|
1672
|
+
machineInTurn: false,
|
|
1673
|
+
oldestPendingApprovalAgeMs: HARD_TTL + 1,
|
|
1674
|
+
hardTtlMs: HARD_TTL,
|
|
1675
|
+
});
|
|
1676
|
+
expect(r.turnInFlight).toBe(false);
|
|
1677
|
+
expect(r.currentTurnActive).toBe(false);
|
|
1678
|
+
const d = planModelCommand(
|
|
1679
|
+
{ kind: "set", model: "opus" },
|
|
1680
|
+
{ currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
|
|
1681
|
+
);
|
|
1682
|
+
expect(d).toEqual({ kind: "apply", parsed: { kind: "set", model: "opus" } });
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
it("QUEUES when a RECENT pending approval holds the gate — a real block is preserved", () => {
|
|
1686
|
+
const r = resolveStaleAwareBusy({
|
|
1687
|
+
currentTurnActive: false,
|
|
1688
|
+
turnAgeMs: null,
|
|
1689
|
+
machineInTurn: false,
|
|
1690
|
+
oldestPendingApprovalAgeMs: 3_000,
|
|
1691
|
+
hardTtlMs: HARD_TTL,
|
|
1692
|
+
});
|
|
1693
|
+
expect(r.turnInFlight).toBe(true);
|
|
1694
|
+
const d = planModelCommand(
|
|
1695
|
+
{ kind: "set", model: "opus" },
|
|
1696
|
+
{ currentTurnActive: r.currentTurnActive, turnInFlight: r.turnInFlight, menuEnabled: true },
|
|
1697
|
+
);
|
|
1698
|
+
expect(d).toEqual({ kind: "queue", target: "opus" });
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
it("QUEUES when the machine is genuinely in-turn regardless of atom age", () => {
|
|
1702
|
+
const r = resolveStaleAwareBusy({
|
|
1703
|
+
currentTurnActive: false,
|
|
1704
|
+
turnAgeMs: null,
|
|
1705
|
+
machineInTurn: true,
|
|
1706
|
+
oldestPendingApprovalAgeMs: null,
|
|
1707
|
+
hardTtlMs: HARD_TTL,
|
|
1708
|
+
});
|
|
1709
|
+
expect(r.turnInFlight).toBe(true);
|
|
1710
|
+
expect(r.clearStaleTurn).toBe(false);
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
it("does NOT clear or discount when there is no turn atom", () => {
|
|
1714
|
+
const r = resolveStaleAwareBusy({
|
|
1715
|
+
currentTurnActive: false,
|
|
1716
|
+
turnAgeMs: null,
|
|
1717
|
+
machineInTurn: false,
|
|
1718
|
+
oldestPendingApprovalAgeMs: null,
|
|
1719
|
+
hardTtlMs: HARD_TTL,
|
|
1720
|
+
});
|
|
1721
|
+
expect(r).toEqual({ currentTurnActive: false, turnInFlight: false, clearStaleTurn: false });
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
it("keeps a non-null atom busy when its age is exactly at the ceiling (strict >)", () => {
|
|
1725
|
+
const r = resolveStaleAwareBusy({
|
|
1726
|
+
currentTurnActive: true,
|
|
1727
|
+
turnAgeMs: HARD_TTL,
|
|
1728
|
+
machineInTurn: false,
|
|
1729
|
+
oldestPendingApprovalAgeMs: null,
|
|
1730
|
+
hardTtlMs: HARD_TTL,
|
|
1731
|
+
});
|
|
1732
|
+
expect(r.currentTurnActive).toBe(true);
|
|
1733
|
+
expect(r.clearStaleTurn).toBe(false);
|
|
1734
|
+
});
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1625
1737
|
describe("modelCommandReceiptLine — the durable, greppable entry trace", () => {
|
|
1626
1738
|
it("stamps agent, kind, arg, and busy for a typed set", () => {
|
|
1627
1739
|
const line = modelCommandReceiptLine("finn", { kind: "set", model: "opus" }, true);
|
|
@@ -20,6 +20,13 @@ const bridgeSrc = readFileSync(
|
|
|
20
20
|
resolve(__dirname, '..', 'bridge', 'bridge.ts'),
|
|
21
21
|
'utf-8',
|
|
22
22
|
)
|
|
23
|
+
// #3268 — deriveTurnId was extracted from the gateway monolith into its own
|
|
24
|
+
// importable module so the enqueue seam and the handback round-trip test share
|
|
25
|
+
// ONE function. The body-shape assertion below now reads it from there.
|
|
26
|
+
const deriveTurnIdSrc = readFileSync(
|
|
27
|
+
resolve(__dirname, '..', 'gateway', 'derive-turn-id.ts'),
|
|
28
|
+
'utf-8',
|
|
29
|
+
)
|
|
23
30
|
|
|
24
31
|
describe('component 3 — turn-origin reply routing', () => {
|
|
25
32
|
it('CurrentTurn carries a turnId, and the enqueue handler initialises it', () => {
|
|
@@ -35,10 +42,15 @@ describe('component 3 — turn-origin reply routing', () => {
|
|
|
35
42
|
|
|
36
43
|
it('deriveTurnId is stable across inbound-build and enqueue (message-id based)', () => {
|
|
37
44
|
// The id must be derivable identically at both sites — keyed on
|
|
38
|
-
// chat/thread/messageId, NOT the not-yet-known startedAt.
|
|
39
|
-
|
|
45
|
+
// chat/thread/messageId, NOT the not-yet-known startedAt. Lives in the
|
|
46
|
+
// extracted derive-turn-id.ts module (#3268); the gateway imports it under
|
|
47
|
+
// the same name (asserted below), so every enqueue callsite is unchanged.
|
|
48
|
+
const fn = deriveTurnIdSrc.split('export function deriveTurnId')[1]?.split('\nexport function ')[0] ?? ''
|
|
40
49
|
expect(fn).toMatch(/chatKey\(chatId, threadId \?\? null\)/)
|
|
41
50
|
expect(fn).toMatch(/messageId/)
|
|
51
|
+
// The gateway must import the shared function, not redefine it — so the
|
|
52
|
+
// enqueue seam and the round-trip test provably use the same identity.
|
|
53
|
+
expect(gatewaySrc).toMatch(/import \{ deriveTurnId \} from '\.\/derive-turn-id\.js'/)
|
|
42
54
|
})
|
|
43
55
|
|
|
44
56
|
it('executeReply resolves the answer thread via the origin turn, not the live currentTurn', () => {
|
|
@@ -302,3 +302,60 @@ describe('sendReplyChunks — preview edit-in-place', () => {
|
|
|
302
302
|
expect(calls.filter((c) => c.kind === 'sendRich')).toHaveLength(0)
|
|
303
303
|
})
|
|
304
304
|
})
|
|
305
|
+
|
|
306
|
+
// Reply-flicker fix: the flushed-turn supersede edits the flushed message A in
|
|
307
|
+
// place instead of delete+resend, by re-pointing `previewMessageId` at A's id.
|
|
308
|
+
// These drive that exact execution lane through the fake bot to assert the
|
|
309
|
+
// user-visible OUTCOME (one message survives, no flicker) rather than the path.
|
|
310
|
+
describe('sendReplyChunks — flushed-turn supersede edit-in-place', () => {
|
|
311
|
+
it('a single-message text reply superseding a flushed message EDITS A in place — no delete', async () => {
|
|
312
|
+
// Gateway sets previewMessageId to the flushed message id when
|
|
313
|
+
// decideSupersedeCorrection returns edit-in-place (single chunk, no files/preview).
|
|
314
|
+
const { deps, calls, deleted } = makeFake()
|
|
315
|
+
const flushedId = 700
|
|
316
|
+
const state = baseState({ chunks: ['the real answer'], previewMessageId: flushedId })
|
|
317
|
+
const res = await sendReplyChunks(deps, state)
|
|
318
|
+
// A is edited in place with the canonical reply body — NOT deleted.
|
|
319
|
+
expect(calls).toHaveLength(1)
|
|
320
|
+
expect(calls[0].kind).toBe('editPreview')
|
|
321
|
+
expect(calls[0].messageId).toBe(flushedId)
|
|
322
|
+
expect(calls[0].body).toEqual({ rich: 'the real answer' })
|
|
323
|
+
expect(deleted).toEqual([])
|
|
324
|
+
expect(calls.some((c) => c.kind === 'sendRich')).toBe(false)
|
|
325
|
+
// Exactly ONE message survives, and it is the (edited) flushed message.
|
|
326
|
+
expect(state.sentIds).toEqual([flushedId])
|
|
327
|
+
expect(res.previewMessageId).toBeNull()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('anti-duplicate: an edit-in-place supersede never leaves BOTH the flush and a fresh reply visible', async () => {
|
|
331
|
+
const { deps, calls, deleted } = makeFake()
|
|
332
|
+
const state = baseState({ chunks: ['answer'], previewMessageId: 710 })
|
|
333
|
+
await sendReplyChunks(deps, state)
|
|
334
|
+
// No fresh send happened alongside the edit, and nothing was deleted — the
|
|
335
|
+
// single surviving message is the flushed one, now carrying the reply.
|
|
336
|
+
const fresh = calls.filter((c) => c.kind === 'sendRich' || c.kind === 'sendLiteral')
|
|
337
|
+
expect(fresh).toHaveLength(0)
|
|
338
|
+
expect(deleted).toEqual([])
|
|
339
|
+
expect(state.sentIds).toEqual([710])
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('edit returns a 400 → falls back to delete+resend, exactly one clean message survives', async () => {
|
|
343
|
+
// Message too old / uneditable / gone: editPreview throws, the lane deletes
|
|
344
|
+
// A and sends the canonical reply fresh — never both, never neither.
|
|
345
|
+
const { deps, calls, deleted } = makeFake({
|
|
346
|
+
failNext: { editPreview: [grammy400("message can't be edited", 'editMessageText')] },
|
|
347
|
+
})
|
|
348
|
+
const flushedId = 720
|
|
349
|
+
const state = baseState({ chunks: ['the real answer'], previewMessageId: flushedId })
|
|
350
|
+
const res = await sendReplyChunks(deps, state)
|
|
351
|
+
// Fallback: stale flush deleted, canonical reply sent fresh.
|
|
352
|
+
expect(deleted).toEqual([flushedId])
|
|
353
|
+
const fresh = calls.filter((c) => c.kind === 'sendRich')
|
|
354
|
+
expect(fresh).toHaveLength(1)
|
|
355
|
+
expect(fresh[0].body).toEqual({ rich: 'the real answer' })
|
|
356
|
+
// Exactly one surviving message (the fresh send), and it is NOT the flushed id.
|
|
357
|
+
expect(state.sentIds).toHaveLength(1)
|
|
358
|
+
expect(state.sentIds[0]).not.toBe(flushedId)
|
|
359
|
+
expect(res.previewMessageId).toBeNull()
|
|
360
|
+
})
|
|
361
|
+
})
|
|
@@ -90,20 +90,27 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
|
|
|
90
90
|
// even after releaseTurnBufferGate has cleared claudeBusyKeys/machine on the
|
|
91
91
|
// first interim reply. Without this, a new inbound delivered in that window
|
|
92
92
|
// displaces the approval context and orphans the pending MCP call.
|
|
93
|
-
it('turnInFlightForGate
|
|
94
|
-
//
|
|
95
|
-
// the
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
93
|
+
it('turnInFlightForGate STILL composes the pending-approval hold (#2841 contract)', () => {
|
|
94
|
+
// #3262 factored the machine-in-turn leg into turnInFlightMachineOnly(), but
|
|
95
|
+
// the shared inbound gate must remain UNCONDITIONALLY busy while an approval
|
|
96
|
+
// card is outstanding. That contract now lives in the composition here.
|
|
97
|
+
// span 2800: the function carries a ~1100-char #2841/#3084 note before the
|
|
98
|
+
// composition line.
|
|
99
|
+
const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2800)
|
|
100
|
+
expect(fn).toMatch(/return\s+turnInFlightMachineOnly\(\)\s*\|\|\s*hasPendingApproval/)
|
|
101
|
+
expect(fn).toMatch(/hasPendingApproval\s*=\s*pendingPermissions\.size\s*>\s*0/)
|
|
99
102
|
})
|
|
100
103
|
|
|
101
|
-
it('
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
104
|
+
it('turnInFlightMachineOnly carries BOTH busy paths (legacy claudeBusyKeys + machine)', () => {
|
|
105
|
+
// #3262: the machine-in-turn signal moved here (read by the /model & /effort
|
|
106
|
+
// resolver without the approval hold). Both the legacy claudeBusyKeys path
|
|
107
|
+
// and the machine-authoritative probeGateParity path must be present — a
|
|
108
|
+
// dropped branch would silently change the busy read.
|
|
109
|
+
const fn = slice(GATEWAY, 'function turnInFlightMachineOnly()', 1400)
|
|
110
|
+
expect(fn).toMatch(/claudeBusyKeys\.size\s*>\s*0/)
|
|
105
111
|
expect(fn).toContain('probeGateParity(')
|
|
106
|
-
|
|
112
|
+
// NOT gated by the approval hold — that stays in turnInFlightForGate.
|
|
113
|
+
expect(fn).not.toContain('hasPendingApproval')
|
|
107
114
|
})
|
|
108
115
|
|
|
109
116
|
it('hasPendingApproval reads pendingPermissions.size', () => {
|
|
@@ -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
|
+
})
|