switchroom 0.19.6 → 0.19.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +8 -2
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +4 -4
- package/skills/telegram-formatting/SKILL.md +147 -0
- package/telegram-plugin/dist/gateway/gateway.js +124 -15
- package/telegram-plugin/gateway/gateway.ts +10 -5
- package/telegram-plugin/gateway/outbound-send-path.ts +55 -16
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +21 -8
- package/telegram-plugin/gateway/subagent-handback-marker.ts +198 -19
- package/telegram-plugin/render/ir.ts +34 -26
- package/telegram-plugin/render/render.ts +12 -3
- package/telegram-plugin/rich-send.ts +16 -10
- package/telegram-plugin/shared/bot-runtime.ts +57 -0
- package/telegram-plugin/tests/format-guard-pins.test.ts +93 -0
- package/telegram-plugin/tests/render/underline-wire-outcome.test.ts +32 -0
- package/telegram-plugin/tests/rich-markdown-guard-transformer.test.ts +121 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -7
- package/telegram-plugin/tests/stream-render-golden.test.ts +140 -4
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +143 -14
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level outcome tests for the universal accidental-formatting guard
|
|
3
|
+
* (`installRichMarkdownGuard`, #3252/#3463 follow-up).
|
|
4
|
+
*
|
|
5
|
+
* These MUST go through a REAL grammy `Bot` with a stubbed transport. The
|
|
6
|
+
* existing unit/golden harnesses (`tests/bot-api.harness.ts` etc.) mock the
|
|
7
|
+
* `api` object ABOVE the grammy transformer layer, so `api.config.use`
|
|
8
|
+
* transformers never run there — a test written through them is a FALSE guard
|
|
9
|
+
* (it stays green with the transformer absent or mis-gated). By stubbing
|
|
10
|
+
* `client.fetch` we capture the exact serialized wire body AFTER the
|
|
11
|
+
* transformer has mutated the raw payload, which is the only place the guard's
|
|
12
|
+
* effect is observable.
|
|
13
|
+
*
|
|
14
|
+
* Red-team F-R1: the markdown lives at `payload.rich_message.markdown`, NOT
|
|
15
|
+
* `payload.markdown`. A guard gating on the latter matches nothing and every
|
|
16
|
+
* one of these assertions would still fail — so these tests pin the correct
|
|
17
|
+
* field shape too.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect } from 'vitest'
|
|
20
|
+
import { Bot } from 'grammy'
|
|
21
|
+
import { installTgPostLogger, installRichMarkdownGuard } from '../shared/bot-runtime.js'
|
|
22
|
+
|
|
23
|
+
interface CapturedCall {
|
|
24
|
+
method: string
|
|
25
|
+
body: Record<string, unknown>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a real grammy Bot whose transport is stubbed: every API call is
|
|
30
|
+
* captured (method + parsed JSON body) and answered with a minimal ok
|
|
31
|
+
* response. `botInfo` is supplied so `bot.init()` never hits the network.
|
|
32
|
+
*/
|
|
33
|
+
function makeCapturingBot(): { bot: Bot; calls: CapturedCall[] } {
|
|
34
|
+
const calls: CapturedCall[] = []
|
|
35
|
+
const fakeFetch = (async (url: unknown, init?: { body?: unknown }) => {
|
|
36
|
+
const method = String(url).split('/').pop() ?? ''
|
|
37
|
+
let body: Record<string, unknown> = {}
|
|
38
|
+
if (typeof init?.body === 'string') {
|
|
39
|
+
body = JSON.parse(init.body) as Record<string, unknown>
|
|
40
|
+
}
|
|
41
|
+
calls.push({ method, body })
|
|
42
|
+
// Minimal Telegram ok envelope. `result` shape doesn't matter for these
|
|
43
|
+
// send/edit calls — grammy only reads `ok`/`result`.
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
status: 200,
|
|
47
|
+
json: async () => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: 'private' } } }),
|
|
48
|
+
} as unknown as Response
|
|
49
|
+
}) as unknown as typeof fetch
|
|
50
|
+
|
|
51
|
+
const bot = new Bot('123456:TEST_TOKEN', {
|
|
52
|
+
botInfo: {
|
|
53
|
+
id: 123456,
|
|
54
|
+
is_bot: true,
|
|
55
|
+
first_name: 'Test',
|
|
56
|
+
username: 'test_bot',
|
|
57
|
+
can_join_groups: false,
|
|
58
|
+
can_read_all_group_messages: false,
|
|
59
|
+
supports_inline_queries: false,
|
|
60
|
+
can_connect_to_business: false,
|
|
61
|
+
has_main_web_app: false,
|
|
62
|
+
},
|
|
63
|
+
client: { fetch: fakeFetch },
|
|
64
|
+
})
|
|
65
|
+
// Install exactly the production transformer stack ordering: logger first,
|
|
66
|
+
// then guard (guard composes outermost — grammy runs last-installed first).
|
|
67
|
+
installTgPostLogger(bot)
|
|
68
|
+
installRichMarkdownGuard(bot)
|
|
69
|
+
return { bot, calls }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lastRichMarkdown(calls: CapturedCall[]): unknown {
|
|
73
|
+
const c = calls[calls.length - 1]
|
|
74
|
+
return (c.body.rich_message as { markdown?: unknown } | undefined)?.markdown
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('installRichMarkdownGuard — universal wire seam', () => {
|
|
78
|
+
it('(a) escapes a raw { markdown } sendRichMessage on the wire', async () => {
|
|
79
|
+
const { bot, calls } = makeCapturingBot()
|
|
80
|
+
await bot.api.sendRichMessage(1, { markdown: '#3460 done' })
|
|
81
|
+
expect(calls[calls.length - 1].method).toBe('sendRichMessage')
|
|
82
|
+
expect(lastRichMarkdown(calls)).toBe('\\#3460 done')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('(b) escapes an editMessageText object-arg { markdown } on the wire', async () => {
|
|
86
|
+
const { bot, calls } = makeCapturingBot()
|
|
87
|
+
await bot.api.editMessageText(1, 42, { markdown: '#3460 done' })
|
|
88
|
+
expect(calls[calls.length - 1].method).toBe('editMessageText')
|
|
89
|
+
expect(lastRichMarkdown(calls)).toBe('\\#3460 done')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('(c) leaves a literalText / string-arg edit UNTOUCHED (carries text, not rich_message)', async () => {
|
|
93
|
+
const { bot, calls } = makeCapturingBot()
|
|
94
|
+
await bot.api.editMessageText(1, 42, '#3460 done')
|
|
95
|
+
const c = calls[calls.length - 1]
|
|
96
|
+
expect(c.method).toBe('editMessageText')
|
|
97
|
+
expect(c.body.text).toBe('#3460 done')
|
|
98
|
+
expect(c.body.rich_message).toBeUndefined()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('(d) leaves a genuine heading byte-identical', async () => {
|
|
102
|
+
const { bot, calls } = makeCapturingBot()
|
|
103
|
+
await bot.api.sendRichMessage(1, { markdown: '# Title\n## Sub' })
|
|
104
|
+
expect(lastRichMarkdown(calls)).toBe('# Title\n## Sub')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('(e) is idempotent on an already-guarded (richMessage-wrapped) body', async () => {
|
|
108
|
+
const { bot, calls } = makeCapturingBot()
|
|
109
|
+
// Simulate a body that already went through richMessage()'s internal guard.
|
|
110
|
+
await bot.api.sendRichMessage(1, { markdown: '\\#3460 done' })
|
|
111
|
+
expect(lastRichMarkdown(calls)).toBe('\\#3460 done')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('does not mutate the caller\'s shared input object', async () => {
|
|
115
|
+
const { bot } = makeCapturingBot()
|
|
116
|
+
const input = { markdown: '#3460 done' }
|
|
117
|
+
await bot.api.sendRichMessage(1, input)
|
|
118
|
+
// Caller's object is unchanged; only the cloned wire payload is escaped.
|
|
119
|
+
expect(input.markdown).toBe('#3460 done')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
} from '../gateway/outbound-send-path.js'
|
|
47
47
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
48
48
|
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
49
|
-
import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
|
|
49
|
+
import { SubagentHandbackMarker, stampsHandbackMarker } from '../gateway/subagent-handback-marker.js'
|
|
50
50
|
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
|
|
51
51
|
import { redact } from '../secret-detect/redact.js'
|
|
52
52
|
import type { CurrentTurn, Access } from '../gateway/gateway.js'
|
|
@@ -887,7 +887,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
|
|
|
887
887
|
const marker = new SubagentHandbackMarker()
|
|
888
888
|
const buffer = createPendingInboundBuffer({
|
|
889
889
|
log: () => {},
|
|
890
|
-
onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
|
|
890
|
+
onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
|
|
891
891
|
})
|
|
892
892
|
|
|
893
893
|
// Simulate the boot-replay re-push of an un-acked spooled handback envelope.
|
|
@@ -905,14 +905,14 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
|
|
|
905
905
|
meta: { source: 'subagent_handback' },
|
|
906
906
|
})
|
|
907
907
|
// The chokepoint stamped the marker from the replay push (pre-fix: empty).
|
|
908
|
-
expect(marker.lastAt(CHAT)).toBe(handbackTs)
|
|
908
|
+
expect(marker.lastAt(CHAT, undefined)).toBe(handbackTs)
|
|
909
909
|
|
|
910
910
|
const h = makeHarness()
|
|
911
911
|
const owner = makeFlushDeliveredEndedTurn()
|
|
912
912
|
seedRecord(h, owner)
|
|
913
913
|
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
|
|
914
|
-
// The gateway reads the marker the boot-replay stamped.
|
|
915
|
-
h.deps.getLastSubagentHandbackAt = (chatId) => marker.
|
|
914
|
+
// The gateway reads the marker the boot-replay stamped (chat-wide gate).
|
|
915
|
+
h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
|
|
916
916
|
|
|
917
917
|
const res = await sendReply(h.deps, req(HANDBACK))
|
|
918
918
|
|
|
@@ -929,12 +929,226 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
|
|
|
929
929
|
const marker = new SubagentHandbackMarker()
|
|
930
930
|
const buffer = createPendingInboundBuffer({
|
|
931
931
|
log: () => {},
|
|
932
|
-
onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
|
|
932
|
+
onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
|
|
933
933
|
})
|
|
934
934
|
buffer.push('marko', {
|
|
935
935
|
type: 'inbound', chatId: CHAT, messageId: 5, user: 'ken', userId: 1,
|
|
936
936
|
ts: Date.now(), text: 'hi', meta: {},
|
|
937
937
|
})
|
|
938
|
-
expect(marker.lastAt(CHAT)).toBe(null)
|
|
938
|
+
expect(marker.lastAt(CHAT, undefined)).toBe(null)
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
// ─── MUST-FIX 2 (dup-audit / Fable): the gate is un-steerable by a model
|
|
942
|
+
// thread arg — a cross-topic handback still keeps the content gate ───
|
|
943
|
+
//
|
|
944
|
+
// Fable's PROVEN regression: F2's thread-keyed gate read let a foreign-content
|
|
945
|
+
// reply carrying `message_thread_id=<other topic>` dodge a handback marker
|
|
946
|
+
// stamped on the real topic → silent edit-over (the #3429 double-loss). Because
|
|
947
|
+
// `findLatestEndedTurnForChat` resolves owners CHAT-WIDE, a topic-A handback can
|
|
948
|
+
// resolve — and supersede — topic B's ended turn. The gate read is therefore
|
|
949
|
+
// chat-wide (`lastAtInChat`): any in-window handback in the chat keeps the gate,
|
|
950
|
+
// so the reply's own thread arg cannot bypass it. Drives the REAL buffer
|
|
951
|
+
// chokepoint + REAL send path.
|
|
952
|
+
it('MUST-FIX 2: a handback stamped in topic A keeps the content gate for a ' +
|
|
953
|
+
'foreign reply steered to topic B (message_thread_id) — FRESH send, no silent edit-over', async () => {
|
|
954
|
+
const TOPIC_A = 111
|
|
955
|
+
const TOPIC_B = 222
|
|
956
|
+
const marker = new SubagentHandbackMarker()
|
|
957
|
+
const buffer = createPendingInboundBuffer({
|
|
958
|
+
log: () => {},
|
|
959
|
+
onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
|
|
960
|
+
})
|
|
961
|
+
// A background handback lands in TOPIC A (envelope threadId=A) via the real
|
|
962
|
+
// chokepoint — after topic B's turn ended (now-30s), within the 60s TTL.
|
|
963
|
+
const handbackTs = Date.now() - 15_000
|
|
964
|
+
buffer.push('marko', {
|
|
965
|
+
type: 'inbound', chatId: CHAT, threadId: TOPIC_A, messageId: handbackTs,
|
|
966
|
+
user: 'subagent-watcher', userId: 0, ts: handbackTs, text: 'handback',
|
|
967
|
+
meta: { source: 'subagent_handback' },
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
// Topic B has its OWN flush-delivered ended turn with a real answer.
|
|
971
|
+
const h = makeHarness()
|
|
972
|
+
const owner = { ...makeFlushDeliveredEndedTurn(), sessionThreadId: TOPIC_B } as unknown as CurrentTurn
|
|
973
|
+
h.deps.flushedTurnSupersede.record(
|
|
974
|
+
CHAT, TOPIC_B,
|
|
975
|
+
{ turnId: (owner as CurrentTurn).turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
|
|
976
|
+
Date.now(),
|
|
977
|
+
)
|
|
978
|
+
// The topic-A handback's reply is STEERED to topic B (message_thread_id=222)
|
|
979
|
+
// and resolves topic B's ended turn chat-wide (latest-ended), carrying FOREIGN
|
|
980
|
+
// content. The gate read is chat-wide, so the topic-A stamp is seen.
|
|
981
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner as CurrentTurn, tier: 'latest-ended' })
|
|
982
|
+
h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
|
|
983
|
+
|
|
984
|
+
const res = await sendReply(
|
|
985
|
+
h.deps,
|
|
986
|
+
req(HANDBACK, { message_thread_id: TOPIC_B }),
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
// Gate kept → FRESH notifying send; topic B's flushed answer is NEITHER
|
|
990
|
+
// edited nor deleted. (Under the F2 thread-keyed gate this was editMessageText
|
|
991
|
+
// ×1 over msg 4242 — the silent-loss regression this closes.)
|
|
992
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
993
|
+
expect(fresh).toHaveLength(1)
|
|
994
|
+
expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
995
|
+
expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
996
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
997
|
+
// Topic B's flush record NOT consumed — the delivered answer stands.
|
|
998
|
+
expect(
|
|
999
|
+
h.deps.flushedTurnSupersede.peek(CHAT, TOPIC_B, {
|
|
1000
|
+
liveTurnId: (owner as CurrentTurn).turnId,
|
|
1001
|
+
replyText: CANONICAL_REPLY,
|
|
1002
|
+
now: Date.now(),
|
|
1003
|
+
}).supersede,
|
|
1004
|
+
).toBe(true)
|
|
1005
|
+
})
|
|
1006
|
+
|
|
1007
|
+
// Thread-keyed COLLAPSE still routes per-topic: a topic reply supersedes its
|
|
1008
|
+
// OWN topic's flush record (via the framework-resolved owner thread, not the
|
|
1009
|
+
// raw arg), so a genuine own-reply in a forum topic collapses to one message.
|
|
1010
|
+
it('F2 positive: a topic own-reply (no handback) supersedes its own topic\'s ' +
|
|
1011
|
+
'flush record — one message, on the framework-resolved thread lane', async () => {
|
|
1012
|
+
const TOPIC = 222
|
|
1013
|
+
const h = makeHarness()
|
|
1014
|
+
const owner = { ...makeFlushDeliveredEndedTurn(), sessionThreadId: TOPIC } as unknown as CurrentTurn
|
|
1015
|
+
// Flush recorded on the owner turn's OWN topic lane (what the flush does).
|
|
1016
|
+
h.deps.flushedTurnSupersede.record(
|
|
1017
|
+
CHAT, TOPIC,
|
|
1018
|
+
{ turnId: (owner as CurrentTurn).turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
|
|
1019
|
+
Date.now(),
|
|
1020
|
+
)
|
|
1021
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner as CurrentTurn, tier: 'latest-ended' })
|
|
1022
|
+
h.deps.getLastSubagentHandbackAt = () => null // no handback anywhere
|
|
1023
|
+
|
|
1024
|
+
const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER, { message_thread_id: TOPIC }))
|
|
1025
|
+
|
|
1026
|
+
// Collapses to ONE message: the topic's flushed message edited in place.
|
|
1027
|
+
const edits = h.calls.filter((c) => c.method === 'editMessageText')
|
|
1028
|
+
expect(edits).toHaveLength(1)
|
|
1029
|
+
expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
|
|
1030
|
+
expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
1031
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
// ─── F1 (dup-audit): negative outcome guard — no silent edit-over ───
|
|
1035
|
+
//
|
|
1036
|
+
// The content-gate bypass must NEVER silently edit over a flush-delivered
|
|
1037
|
+
// answer with FOREIGN content on a non-live tier. A decoupled completion (the
|
|
1038
|
+
// handback class the marker covers) landing with genuinely different content,
|
|
1039
|
+
// resolving an ended flushed turn via the latest-ended tier, must SEND FRESH —
|
|
1040
|
+
// the flushed answer left untouched (Telegram edits do not re-notify, so an
|
|
1041
|
+
// edit-over is a silent double-loss: the handback never surfaces AND the answer
|
|
1042
|
+
// is destroyed — the #3429 incident). This pins the invariant's OUTCOME: the
|
|
1043
|
+
// stamp (driven by the single chokepoint predicate) keeps the gate, and the
|
|
1044
|
+
// foreign reply never touches the delivered answer.
|
|
1045
|
+
it('F1: a decoupled foreign-content reply on a non-live tier resolving a ' +
|
|
1046
|
+
'DIFFERENT ended turn sends FRESH — the flushed answer is never edited/deleted', async () => {
|
|
1047
|
+
const h = makeHarness()
|
|
1048
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
1049
|
+
seedRecord(h, owner)
|
|
1050
|
+
// The reply resolves the flush-delivered ENDED turn via the ambiguous
|
|
1051
|
+
// latest-ended tier (no live turn, no positive attribution) and carries
|
|
1052
|
+
// FOREIGN content (a worker report, not this turn's answer nor a rewording).
|
|
1053
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
|
|
1054
|
+
// A decoupled completion IS in the window (the marker stamped it) — so this
|
|
1055
|
+
// late reply might BE it. The invariant keeps the content gate.
|
|
1056
|
+
h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
|
|
1057
|
+
|
|
1058
|
+
const res = await sendReply(h.deps, req(HANDBACK))
|
|
1059
|
+
|
|
1060
|
+
// FRESH notifying send; the flushed answer is NEITHER edited nor deleted.
|
|
1061
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
1062
|
+
expect(fresh).toHaveLength(1)
|
|
1063
|
+
expect(fresh[0]!.text).toContain('migration audit')
|
|
1064
|
+
expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
|
|
1065
|
+
expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
1066
|
+
expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
1067
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
1068
|
+
// The flush record is NOT consumed — the flushed answer stands, and the
|
|
1069
|
+
// turn's OWN canonical replay can still correct it.
|
|
1070
|
+
expect(
|
|
1071
|
+
h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
|
|
1072
|
+
liveTurnId: OWNER_TURN_ID,
|
|
1073
|
+
replyText: CANONICAL_REPLY,
|
|
1074
|
+
now: Date.now(),
|
|
1075
|
+
}).supersede,
|
|
1076
|
+
).toBe(true)
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
// Structural: the stamp membership lives in exactly ONE predicate, consulted
|
|
1080
|
+
// at the ONE buffer chokepoint. If a future refactor inlines a bare
|
|
1081
|
+
// `=== 'subagent_handback'` check at the chokepoint (bypassing the predicate),
|
|
1082
|
+
// the invariant's single-extension-point guarantee is lost — fail loudly.
|
|
1083
|
+
it('F1: the buffer chokepoint stamps via the stampsHandbackMarker predicate, ' +
|
|
1084
|
+
'not an inlined source literal', () => {
|
|
1085
|
+
const bufferSrc = readFileSync(new URL('../gateway/pending-inbound-buffer.ts', import.meta.url), 'utf8')
|
|
1086
|
+
expect(bufferSrc).toContain('stampsHandbackMarker(msg.meta?.source)')
|
|
1087
|
+
// The predicate itself is the single membership definition.
|
|
1088
|
+
expect(stampsHandbackMarker('subagent_handback')).toBe(true)
|
|
1089
|
+
expect(stampsHandbackMarker('cron')).toBe(false)
|
|
1090
|
+
expect(stampsHandbackMarker('resume_interrupted')).toBe(false)
|
|
1091
|
+
expect(stampsHandbackMarker(undefined)).toBe(false)
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
// ─── MUST-FIX 1 (dup-audit / Fable): model-steerable tiers NEVER bypass ───
|
|
1095
|
+
//
|
|
1096
|
+
// The `quoted`/`origin` tiers resolve from MODEL-supplied args (reply_to /
|
|
1097
|
+
// origin_turn_id), so a reply can steer ITSELF onto a different ended turn's
|
|
1098
|
+
// record. Marker-absence proves "own answer" only for the framework-derived
|
|
1099
|
+
// latest-ended tier — a quoted/origin resolution with no marker is NOT
|
|
1100
|
+
// ownership evidence. Fable executed this exact path (quoted tier, no marker,
|
|
1101
|
+
// foreign content → editMessageText ×1 over the flushed answer). With the tier
|
|
1102
|
+
// restriction, quoted/origin ALWAYS traverse the content gate → foreign content
|
|
1103
|
+
// sends FRESH, the flushed answer untouched. RED on pre-fix code
|
|
1104
|
+
// (replyIsOwnAnswer = tier==='live' || !handbackCouldOwnReply → quoted bypassed).
|
|
1105
|
+
it('MUST-FIX 1: a quoted-tier reply with NO marker and FOREIGN content does NOT ' +
|
|
1106
|
+
'bypass the content gate — FRESH send, flushed answer never edited/deleted', async () => {
|
|
1107
|
+
const h = makeHarness()
|
|
1108
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
1109
|
+
seedRecord(h, owner)
|
|
1110
|
+
// Model-steered `quoted` attribution to a DIFFERENT ended flushed turn, with
|
|
1111
|
+
// NO decoupled completion anywhere in the chat (the residual F1 vector).
|
|
1112
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
|
|
1113
|
+
h.deps.getLastSubagentHandbackAt = () => null
|
|
1114
|
+
|
|
1115
|
+
const res = await sendReply(h.deps, req(HANDBACK))
|
|
1116
|
+
|
|
1117
|
+
// FRESH notifying send; the flushed answer is NEITHER edited nor deleted.
|
|
1118
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
1119
|
+
expect(fresh).toHaveLength(1)
|
|
1120
|
+
expect(fresh[0]!.text).toContain('migration audit')
|
|
1121
|
+
expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
|
|
1122
|
+
expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
1123
|
+
expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
1124
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
1125
|
+
// Flush record NOT consumed — the delivered answer stands.
|
|
1126
|
+
expect(
|
|
1127
|
+
h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
|
|
1128
|
+
liveTurnId: OWNER_TURN_ID,
|
|
1129
|
+
replyText: CANONICAL_REPLY,
|
|
1130
|
+
now: Date.now(),
|
|
1131
|
+
}).supersede,
|
|
1132
|
+
).toBe(true)
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
// Sibling positive: a quoted-tier reply carrying the turn's OWN answer (same
|
|
1136
|
+
// content, contained in the flushed blob) still collapses through the content
|
|
1137
|
+
// gate — the tier restriction only blocks FOREIGN content, not genuine replays.
|
|
1138
|
+
it('MUST-FIX 1 sibling: a quoted-tier reply with the SAME answer still collapses ' +
|
|
1139
|
+
'(content gate passes) — one message', async () => {
|
|
1140
|
+
const h = makeHarness()
|
|
1141
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
1142
|
+
seedRecord(h, owner)
|
|
1143
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
|
|
1144
|
+
h.deps.getLastSubagentHandbackAt = () => null
|
|
1145
|
+
|
|
1146
|
+
const res = await sendReply(h.deps, req(CANONICAL_REPLY))
|
|
1147
|
+
|
|
1148
|
+
const edits = h.calls.filter((c) => c.method === 'editMessageText')
|
|
1149
|
+
expect(edits).toHaveLength(1)
|
|
1150
|
+
expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
|
|
1151
|
+
expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
1152
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
939
1153
|
})
|
|
940
1154
|
})
|
|
@@ -56,7 +56,12 @@ function makeFakeBot() {
|
|
|
56
56
|
const api = {
|
|
57
57
|
sendRichMessage: async (c: string, b: { markdown: string }, o: Record<string, unknown> = {}) => rec('sendRichMessage', c, b.markdown, o),
|
|
58
58
|
sendMessage: async (c: string, t: string, o: Record<string, unknown> = {}) => rec('sendMessage', c, t, o),
|
|
59
|
-
editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => {
|
|
59
|
+
editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => {
|
|
60
|
+
// Preserve the REAL edit target id (m) so tests can assert an edit-in-place
|
|
61
|
+
// hit the flushed message, not a synthetic fresh id.
|
|
62
|
+
calls.push({ method: 'editMessageText', chat_id: c, text: typeof b === 'string' ? b : (b as { markdown: string }).markdown, opts: o, reply_markup: o.reply_markup ?? null, message_id: m })
|
|
63
|
+
return {}
|
|
64
|
+
},
|
|
60
65
|
deleteMessage: async (c: string, m: number) => { rec('deleteMessage', c, null); return true },
|
|
61
66
|
}
|
|
62
67
|
return { api, calls }
|
|
@@ -100,6 +105,7 @@ function makeStreamDeps(opts?: {
|
|
|
100
105
|
dedup?: OutboundDedupCache
|
|
101
106
|
turn?: CurrentTurn | null
|
|
102
107
|
deliverResult?: { sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }
|
|
108
|
+
flushedTurnSupersede?: FlushedTurnSupersedeRegistry
|
|
103
109
|
}): StreamHarness {
|
|
104
110
|
const { api, calls } = makeFakeBot()
|
|
105
111
|
const dedup = opts?.dedup ?? new OutboundDedupCache()
|
|
@@ -135,7 +141,7 @@ function makeStreamDeps(opts?: {
|
|
|
135
141
|
activeTurnStartedAt: new Map(),
|
|
136
142
|
backstopDeliveryLedger: ledger,
|
|
137
143
|
bot: { api },
|
|
138
|
-
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
144
|
+
flushedTurnSupersede: opts?.flushedTurnSupersede ?? new FlushedTurnSupersedeRegistry(),
|
|
139
145
|
idleTracker: { noteEvent: noop },
|
|
140
146
|
lastPtyPreviewByChat: new Map(),
|
|
141
147
|
obligationLedger: { close: noop, noteTurnEnded: noop },
|
|
@@ -206,12 +212,12 @@ function makeStreamDeps(opts?: {
|
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
// ── the P2 sendReply harness (same content, sharing the ONE cache) ─────────
|
|
209
|
-
function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
215
|
+
function makeSendReplyDeps(dedup: OutboundDedupCache, sharedSupersede?: FlushedTurnSupersedeRegistry) {
|
|
210
216
|
const { api, calls } = makeFakeBot()
|
|
211
217
|
const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
|
|
212
218
|
const deps = {
|
|
213
219
|
outboundDedup: dedup,
|
|
214
|
-
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
220
|
+
flushedTurnSupersede: sharedSupersede ?? new FlushedTurnSupersedeRegistry(),
|
|
215
221
|
firstTextReplyLogged: new Set<string>(),
|
|
216
222
|
suppressPtyPreview: new Set<string>(),
|
|
217
223
|
activeDraftStreams: new Map(),
|
|
@@ -386,6 +392,136 @@ describe('cross-surface dedup — ONE OutboundDedupCache across P4 stream + P2 r
|
|
|
386
392
|
})
|
|
387
393
|
})
|
|
388
394
|
|
|
395
|
+
// ── F3 (dup-audit 2026-07-21): the flush RECORD wiring is load-bearing ──────
|
|
396
|
+
//
|
|
397
|
+
// The entire flush→reply dedup depends on stream-render.ts recording the
|
|
398
|
+
// flush's delivered ids into the shared FlushedTurnSupersedeRegistry
|
|
399
|
+
// (`flushedTurnSupersede.record(...)`). Every OTHER outcome test seeds that
|
|
400
|
+
// record by hand (send-reply-golden's seedRecord/seedFlushRecord), so DELETING
|
|
401
|
+
// the record call would reintroduce the dominant flush→reply duplicate with all
|
|
402
|
+
// those tests still green. This drives the REAL flush record() end-to-end (no
|
|
403
|
+
// pre-seed) across ONE shared registry and asserts the reworded same-turn reply
|
|
404
|
+
// collapses to EXACTLY ONE message — so it goes RED if the record() call is
|
|
405
|
+
// removed. This is the guard the audit flagged as missing.
|
|
406
|
+
describe('F3 — flush record() → same-turn reworded reply collapse (end-to-end, no pre-seed)', () => {
|
|
407
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
408
|
+
const FLUSHED_ANSWER =
|
|
409
|
+
'All twelve agents are healthy right now. The gateway, the vault broker and the ' +
|
|
410
|
+
'approval kernel all report green health checks, and no container has restarted in ' +
|
|
411
|
+
'the last twenty four hours, so there is nothing that needs your attention at the moment.'
|
|
412
|
+
const REWORDED =
|
|
413
|
+
'Good news on the fleet. Every one of the twelve agents is running fine at the moment. ' +
|
|
414
|
+
'Gateway, vault broker and approval kernel are all green, and nothing has restarted in ' +
|
|
415
|
+
'the past day, so you do not need to do anything right now.'
|
|
416
|
+
|
|
417
|
+
it('flush → record → late reworded reply collapses to ONE message ' +
|
|
418
|
+
'(RED if stream-render flushedTurnSupersede.record is removed)', async () => {
|
|
419
|
+
// ONE shared supersede registry across BOTH surfaces — exactly the gateway
|
|
420
|
+
// singleton wiring. The flush RECORDS (the real stream-render.ts:1828 call);
|
|
421
|
+
// the reply CONSUMES. Nothing is pre-seeded.
|
|
422
|
+
const supersede = new FlushedTurnSupersedeRegistry()
|
|
423
|
+
const turn = makeTurn({ capturedText: [FLUSHED_ANSWER], capturedBlockMeta: [true] })
|
|
424
|
+
const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
|
|
425
|
+
|
|
426
|
+
// Drive the REAL turn-flush path: it delivers message A (deliverAnswer → id
|
|
427
|
+
// 4242) AND records {turnId, [4242]} into the shared registry.
|
|
428
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
429
|
+
await settleFlush()
|
|
430
|
+
expect(sh.delivered).toContain(FLUSHED_ANSWER)
|
|
431
|
+
// The record actually landed (this is what a removal breaks first).
|
|
432
|
+
expect(
|
|
433
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() }).reason,
|
|
434
|
+
).toBe('supersede')
|
|
435
|
+
|
|
436
|
+
// The model's REAL reply lands late with a REWORDED version of the same
|
|
437
|
+
// answer: no live turn, latest-ended tier, NO handback in flight (CASE A).
|
|
438
|
+
const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
|
|
439
|
+
s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
|
|
440
|
+
// (getLastSubagentHandbackAt returns null in the base deps → own answer.)
|
|
441
|
+
|
|
442
|
+
const res = await sendReply(s.deps, req(REWORDED))
|
|
443
|
+
|
|
444
|
+
// EXACTLY ONE client-visible message: the flushed message (4242) edited in
|
|
445
|
+
// place into the reworded reply — NO fresh second bubble. Without the
|
|
446
|
+
// record(), the reply finds no record, falls to the latch branch, sees
|
|
447
|
+
// reworded ≠ flushed content, does NOT suppress, and ships a DUPLICATE
|
|
448
|
+
// sendRichMessage (edits=0, sends=1) → these assertions fail.
|
|
449
|
+
const edits = s.calls.filter((c) => c.method === 'editMessageText')
|
|
450
|
+
expect(edits).toHaveLength(1)
|
|
451
|
+
expect(edits[0]!.message_id).toBe(4242)
|
|
452
|
+
expect(s.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
453
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
// ── F5 (dup-audit): true take()-before-record() interleaving ────────────────
|
|
458
|
+
//
|
|
459
|
+
// The residual race the supersede registry cannot reach: a reply whose
|
|
460
|
+
// supersede take() runs in the window AFTER the flush FIRED but BEFORE it
|
|
461
|
+
// recorded its message ids. The flush arms `turn.answerDelivered='flush'` (+
|
|
462
|
+
// flushedAnswerText) SYNCHRONOUSLY at fire time — before its async deliver and
|
|
463
|
+
// before record — so a same-answer reply landing in that window is suppressed by
|
|
464
|
+
// the latch, not shipped as a duplicate. This drives a genuine two-emitter
|
|
465
|
+
// interleave (inverted ordering: reply take() strictly before flush record())
|
|
466
|
+
// and asserts exactly one delivered message.
|
|
467
|
+
describe('F5 — take()-before-record() interleaving delivers exactly one message', () => {
|
|
468
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
469
|
+
// ≥200 chars so the late reply is a substantive final answer (the floor the
|
|
470
|
+
// flush latch is scoped to) — else it would never trip the suppression.
|
|
471
|
+
const ANSWER =
|
|
472
|
+
'Yes, that is all done and confirmed. The migration ran cleanly against the staging ' +
|
|
473
|
+
'database, every integration check passed on the first attempt, the rollback plan is ' +
|
|
474
|
+
'staged in case it is ever needed, and I have written the full run log to the shared ' +
|
|
475
|
+
'drive so the team can review exactly what changed and when it happened.'
|
|
476
|
+
|
|
477
|
+
it('a reply whose take() runs BEFORE the flush record() is suppressed by the ' +
|
|
478
|
+
'flush-armed latch — one delivered message, not two', async () => {
|
|
479
|
+
const supersede = new FlushedTurnSupersedeRegistry()
|
|
480
|
+
const turn = makeTurn({ capturedText: [ANSWER], capturedBlockMeta: [true] })
|
|
481
|
+
const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
|
|
482
|
+
|
|
483
|
+
// Fire the flush. Its latch is set SYNCHRONOUSLY here; deliverAnswer + record
|
|
484
|
+
// run in the async IIFE that has NOT completed — the pre-record window.
|
|
485
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
486
|
+
// INVERTED ORDERING: the reply's take() runs now, before the flush record().
|
|
487
|
+
expect(
|
|
488
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() }).reason,
|
|
489
|
+
).toBe('no-record') // record genuinely not written yet
|
|
490
|
+
|
|
491
|
+
const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
|
|
492
|
+
s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
|
|
493
|
+
// The same answer landing again in the race window → latch backstop suppresses.
|
|
494
|
+
const res = await sendReply(s.deps, req(ANSWER))
|
|
495
|
+
|
|
496
|
+
expect(s.calls).toHaveLength(0) // the reply shipped nothing
|
|
497
|
+
expect(res.content[0]!.text).toContain('deduped')
|
|
498
|
+
|
|
499
|
+
await settleFlush() // let the flush's async deliver + record complete
|
|
500
|
+
// Exactly one message reached the user: the flush's message A.
|
|
501
|
+
expect(sh.delivered).toHaveLength(1)
|
|
502
|
+
expect(sh.delivered[0]).toContain('done and confirmed')
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// ── Double-flush idempotency at the delivery primitive (audit §3.5) ─────────
|
|
507
|
+
//
|
|
508
|
+
// The E2-vs-E3 double flush (answer-ready quiescence THEN turn-end backstop for
|
|
509
|
+
// the SAME turn) is guarded by backstopDeliveryLedger.claim. The ledger is
|
|
510
|
+
// unit-tested, but the audit wanted it pinned at the SEND-COUNT level in the
|
|
511
|
+
// flush integration. Two turn_end dispatches for one turn must deliver once.
|
|
512
|
+
describe('double-flush idempotency — deliverAnswer fires once (send-count level)', () => {
|
|
513
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
514
|
+
it('two turn_end dispatches for the same turn deliver the answer EXACTLY once', async () => {
|
|
515
|
+
const turn = makeTurn()
|
|
516
|
+
const answer = turn.capturedText.join('')
|
|
517
|
+
const sh = makeStreamDeps({ turn })
|
|
518
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 }) // claims the latch
|
|
519
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 }) // claim fails → no-op
|
|
520
|
+
await settleFlush()
|
|
521
|
+
expect(sh.delivered).toEqual([answer])
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
|
|
389
525
|
describe('structural — the singleton lives once in gateway, never in the modules (Amendment 1)', () => {
|
|
390
526
|
const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
391
527
|
const streamSrc = readFileSync(new URL('../gateway/stream-render.ts', import.meta.url), 'utf8')
|