switchroom 0.19.4 → 0.19.6
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/auth-broker/index.js +7 -3
- package/dist/cli/autoaccept-poll.js +8 -2
- package/dist/cli/switchroom.js +20 -5
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +67 -4
- package/telegram-plugin/dist/gateway/gateway.js +585 -302
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +128 -259
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
- package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
- package/telegram-plugin/render/line-start-guard.ts +76 -4
- package/telegram-plugin/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
- package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
- package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
- package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
- package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
- package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
- package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
- package/telegram-plugin/tests/silent-end.test.ts +60 -5
- package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
- package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
|
@@ -736,10 +736,15 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
|
|
|
736
736
|
expect(decision.reason).toBe('no-state')
|
|
737
737
|
})
|
|
738
738
|
|
|
739
|
-
it('a
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
739
|
+
it('a short single-block answer → hook persists pendingText but default-floor decide falls through', () => {
|
|
740
|
+
// Single short trailing block under the 200-char substance floor. Post
|
|
741
|
+
// duplicate-message fix the hook persists it as pendingText (so the
|
|
742
|
+
// capture-divergence corner can deliver it with a lowered floor when the
|
|
743
|
+
// gateway captured nothing), but under the DEFAULT floor the gateway's
|
|
744
|
+
// decide returns no-substantive-prose — the normal recordUndeliveredTurnEnd
|
|
745
|
+
// / represent path is unchanged. (No gateway heartbeat file exists in this
|
|
746
|
+
// test's state dir, so the single-writer election also BLOCKS on the
|
|
747
|
+
// liveness gate rather than allowing the stop.)
|
|
743
748
|
const transcript = writeTranscript([
|
|
744
749
|
ENQUEUE,
|
|
745
750
|
{ type: 'assistant', message: { content: [{ type: 'text', text: 'ok done' }] } },
|
|
@@ -747,10 +752,60 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
|
|
|
747
752
|
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
748
753
|
expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
|
|
749
754
|
const state = readSilentEndState()!
|
|
750
|
-
expect(state.pendingText).
|
|
755
|
+
expect(state.pendingText).toBe('ok done')
|
|
756
|
+
// Default floor (200) → short prose is not delivered on the normal path.
|
|
751
757
|
const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
|
|
752
758
|
expect(decision.deliver).toBe(false)
|
|
753
759
|
expect(decision.reason).toBe('no-substantive-prose')
|
|
760
|
+
// Lowered floor (capture-divergence corner) → the short answer IS delivered.
|
|
761
|
+
const lowered = decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 })
|
|
762
|
+
expect(lowered.deliver).toBe(true)
|
|
763
|
+
expect(lowered.text).toBe('ok done')
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it('review item 3: multi sub-200 REAL blocks + capture-empty corner → bridge delivers JOINED prose (no drop)', () => {
|
|
767
|
+
// A real answer split as two ~150-char paragraphs, each under the 200-char
|
|
768
|
+
// floor. Pre-fix: scan set NO pendingText → in the capture-divergence-empty
|
|
769
|
+
// corner (gateway captured nothing → flush skips 'empty-text') the bridge
|
|
770
|
+
// had nothing → hook already allowed the stop → DROPPED ANSWER. Post-fix
|
|
771
|
+
// the hook persists the JOINED prose so the lowered-floor bridge delivers
|
|
772
|
+
// exactly one message.
|
|
773
|
+
// Each block short enough that the JOINED prose is still under the 200-char
|
|
774
|
+
// default floor — so this also exercises the lowered-floor plumbing.
|
|
775
|
+
const PARA_1 = 'The root cause is a stale cache entry surviving reload.'
|
|
776
|
+
const PARA_2 = 'The fix keys invalidation off the canonical id; verified.'
|
|
777
|
+
const joined = `${PARA_1}\n\n${PARA_2}`
|
|
778
|
+
expect(joined.length).toBeLessThan(200)
|
|
779
|
+
const transcript = writeTranscript([
|
|
780
|
+
ENQUEUE,
|
|
781
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: PARA_1 }] } },
|
|
782
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: PARA_2 }] } },
|
|
783
|
+
])
|
|
784
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
785
|
+
expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
|
|
786
|
+
const state = readSilentEndState()!
|
|
787
|
+
expect(state.pendingText).toBe(joined)
|
|
788
|
+
// Default floor → not delivered on the normal path (no duplicate risk).
|
|
789
|
+
expect(decideCapturedProseDelivery({ turnKey: 'c:_' }).deliver).toBe(false)
|
|
790
|
+
// Capture-divergence corner (gateway captured empty → lowered floor) →
|
|
791
|
+
// exactly one delivery of the joined answer, instead of a silent drop.
|
|
792
|
+
const lowered = decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 })
|
|
793
|
+
expect(lowered.deliver).toBe(true)
|
|
794
|
+
expect(lowered.text).toBe(joined)
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it('review item 3: NARRATION-only multi-block → still NO pendingText (no masquerade, #3228 Finding 2)', () => {
|
|
798
|
+
const transcript = writeTranscript([
|
|
799
|
+
ENQUEUE,
|
|
800
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Let me check the first source now, scanning the rows…' }] } },
|
|
801
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: "Now let me query the second source; hang tight…" }] } },
|
|
802
|
+
])
|
|
803
|
+
const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
|
|
804
|
+
expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
|
|
805
|
+
const state = readSilentEndState()!
|
|
806
|
+
expect(state.pendingText).toBeUndefined()
|
|
807
|
+
// Even at the lowered floor there is nothing to deliver.
|
|
808
|
+
expect(decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 }).deliver).toBe(false)
|
|
754
809
|
})
|
|
755
810
|
|
|
756
811
|
it('a stale pendingText for a DIFFERENT turn is never delivered (turnKey guard)', () => {
|
|
@@ -238,7 +238,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
|
238
238
|
assertSendable: () => {},
|
|
239
239
|
statusKey: key,
|
|
240
240
|
streamKey: key,
|
|
241
|
-
resolveReplyOwnerTurn: () => null,
|
|
241
|
+
resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
|
|
242
|
+
getLastSubagentHandbackAt: () => null,
|
|
242
243
|
findTurnByOriginId: () => null,
|
|
243
244
|
findTurnByQuotedMessageId: () => null,
|
|
244
245
|
resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for the per-chat subagent-handback marker
|
|
3
|
+
* (fix/backstop-duplicate-reply). The marker is the deterministic signal the
|
|
4
|
+
* supersede path uses to tell a flushed turn's OWN reworded late reply (no
|
|
5
|
+
* handback in flight → supersede) from a background handback attributed to that
|
|
6
|
+
* ended turn (handback in flight → keep the #3429 content gate).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
|
|
11
|
+
|
|
12
|
+
describe('SubagentHandbackMarker', () => {
|
|
13
|
+
it('returns null for a chat with no recorded handback', () => {
|
|
14
|
+
const m = new SubagentHandbackMarker()
|
|
15
|
+
expect(m.lastAt('chatA')).toBe(null)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('returns the recorded enqueue ts for the chat', () => {
|
|
19
|
+
const m = new SubagentHandbackMarker()
|
|
20
|
+
m.record('chatA', 1_000_000)
|
|
21
|
+
expect(m.lastAt('chatA')).toBe(1_000_000)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('is per-chat — one chat never leaks into another', () => {
|
|
25
|
+
const m = new SubagentHandbackMarker()
|
|
26
|
+
m.record('chatA', 1_000_000)
|
|
27
|
+
expect(m.lastAt('chatB')).toBe(null)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keeps only the MOST RECENT enqueue (overwrites)', () => {
|
|
31
|
+
const m = new SubagentHandbackMarker()
|
|
32
|
+
m.record('chatA', 1_000_000)
|
|
33
|
+
m.record('chatA', 1_050_000)
|
|
34
|
+
expect(m.lastAt('chatA')).toBe(1_050_000)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-feed origin-race defer (DM-misrouted worker card).
|
|
3
|
+
*
|
|
4
|
+
* Root cause: the gateway picks a worker card's destination on the FIRST
|
|
5
|
+
* progress tick of a new sub-agent. That tick can beat the async
|
|
6
|
+
* `jsonl_agent_id` backfill that links the sub-agent's registry row to its
|
|
7
|
+
* origin turn (retried ~every 3s). Until linked, origin resolution returns
|
|
8
|
+
* null and the destination hard-falls back to the owner DM — CREATING the card
|
|
9
|
+
* there. The origin (supergroup + forum topic) resolves ~3s later, but the DM
|
|
10
|
+
* card already exists and stays the visible one.
|
|
11
|
+
*
|
|
12
|
+
* Fix (approach #1 — defer): when the agent is not yet linked AND no card
|
|
13
|
+
* exists yet, SKIP card creation for that tick. The watcher re-fires within
|
|
14
|
+
* seconds; once the backfill links the row the card is created in the correct
|
|
15
|
+
* chat+topic.
|
|
16
|
+
*
|
|
17
|
+
* These tests exercise the REAL exported decision function
|
|
18
|
+
* `decideWorkerFeedDestination` (worker-feed-dispatch.ts) — the SAME code the
|
|
19
|
+
* gateway's `onProgress` block calls (issue #3460) — plus, in the integration
|
|
20
|
+
* case, the REAL `createWorkerActivityFeed` with a mock Bot API that records
|
|
21
|
+
* every send. A regression in the gateway's defer/route decision now fails a
|
|
22
|
+
* test, because the test binds to the shared function the gateway runs, not to
|
|
23
|
+
* a hand-rolled replica.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, it, expect } from 'vitest'
|
|
26
|
+
import { decideWorkerFeedDestination } from '../gateway/worker-feed-dispatch.js'
|
|
27
|
+
import {
|
|
28
|
+
createWorkerActivityFeed,
|
|
29
|
+
type BotApiForWorkerFeed,
|
|
30
|
+
type WorkerActivityView,
|
|
31
|
+
} from '../worker-activity-feed.js'
|
|
32
|
+
|
|
33
|
+
const MAX = 10
|
|
34
|
+
const OWNER_DM = '111111'
|
|
35
|
+
const ORIGIN_CHAT = '-1002000000000' // supergroup
|
|
36
|
+
const ORIGIN_THREAD = 42 // forum topic
|
|
37
|
+
|
|
38
|
+
// The gateway resolves the owner DM from access config; the extracted pure
|
|
39
|
+
// function takes it as an explicit input. All calls in this suite pass the
|
|
40
|
+
// same owner DM so a fall-through to it is observable.
|
|
41
|
+
function decide(overrides: Partial<Parameters<typeof decideWorkerFeedDestination>[0]>) {
|
|
42
|
+
return decideWorkerFeedDestination({
|
|
43
|
+
origin: null,
|
|
44
|
+
cardExists: false,
|
|
45
|
+
priorDeferrals: 0,
|
|
46
|
+
maxDeferrals: MAX,
|
|
47
|
+
fleetChatId: '',
|
|
48
|
+
stampChatId: undefined,
|
|
49
|
+
stampThreadId: undefined,
|
|
50
|
+
ownerDm: OWNER_DM,
|
|
51
|
+
...overrides,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('decideWorkerFeedDestination (origin-race defer + routing decision)', () => {
|
|
56
|
+
it('(a) unlinked + no card → DEFER (no paint)', () => {
|
|
57
|
+
const out = decide({ origin: null, cardExists: false, priorDeferrals: 0 })
|
|
58
|
+
expect(out.action).toBe('defer')
|
|
59
|
+
if (out.action === 'defer') expect(out.deferrals).toBe(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('(b) linked → PAINT to the origin chat + forum thread', () => {
|
|
63
|
+
const out = decide({
|
|
64
|
+
origin: { chatId: ORIGIN_CHAT, threadId: ORIGIN_THREAD },
|
|
65
|
+
cardExists: false,
|
|
66
|
+
priorDeferrals: 3,
|
|
67
|
+
})
|
|
68
|
+
expect(out.action).toBe('paint')
|
|
69
|
+
if (out.action === 'paint') {
|
|
70
|
+
expect(out.chatId).toBe(ORIGIN_CHAT)
|
|
71
|
+
expect(out.threadId).toBe(ORIGIN_THREAD)
|
|
72
|
+
expect(out.deferrals).toBe(0)
|
|
73
|
+
expect(out.exhausted).toBe(false)
|
|
74
|
+
expect(out.ownerDmFallback).toBe(false)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('(c) exhausted defer (never-backfill, forum) → PAINT to the stamp chat + stamp FORUM THREAD, not owner DM', () => {
|
|
79
|
+
// priorDeferrals = MAX-1 → this tick is the MAX-th: paint anyway. No fleet
|
|
80
|
+
// chat is configured, so the destination is the live turn's chat AND its
|
|
81
|
+
// forum topic (stamp-turn fallback, #3458) — NOT General, NOT the owner DM.
|
|
82
|
+
const out = decide({
|
|
83
|
+
origin: null,
|
|
84
|
+
cardExists: false,
|
|
85
|
+
priorDeferrals: MAX - 1,
|
|
86
|
+
fleetChatId: '',
|
|
87
|
+
stampChatId: ORIGIN_CHAT,
|
|
88
|
+
stampThreadId: ORIGIN_THREAD,
|
|
89
|
+
})
|
|
90
|
+
expect(out.action).toBe('paint')
|
|
91
|
+
if (out.action === 'paint') {
|
|
92
|
+
expect(out.chatId).toBe(ORIGIN_CHAT)
|
|
93
|
+
expect(out.threadId).toBe(ORIGIN_THREAD) // forum topic carried, not General
|
|
94
|
+
expect(out.chatId).not.toBe(OWNER_DM)
|
|
95
|
+
expect(out.exhausted).toBe(true)
|
|
96
|
+
expect(out.deferrals).toBe(MAX)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('exhausted defer with NO stamp chat → owner DM (durable floor), flagged as fallback', () => {
|
|
101
|
+
const out = decide({
|
|
102
|
+
origin: null,
|
|
103
|
+
cardExists: false,
|
|
104
|
+
priorDeferrals: MAX - 1,
|
|
105
|
+
fleetChatId: '',
|
|
106
|
+
stampChatId: undefined,
|
|
107
|
+
})
|
|
108
|
+
expect(out.action).toBe('paint')
|
|
109
|
+
if (out.action === 'paint') {
|
|
110
|
+
expect(out.chatId).toBe(OWNER_DM)
|
|
111
|
+
expect(out.ownerDmFallback).toBe(true)
|
|
112
|
+
expect(out.exhausted).toBe(true)
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('(d) existing card always PAINTS (updates proceed) even while unlinked', () => {
|
|
117
|
+
const out = decide({
|
|
118
|
+
origin: null,
|
|
119
|
+
cardExists: true,
|
|
120
|
+
priorDeferrals: 0,
|
|
121
|
+
fleetChatId: ORIGIN_CHAT,
|
|
122
|
+
})
|
|
123
|
+
expect(out.action).toBe('paint')
|
|
124
|
+
if (out.action === 'paint') {
|
|
125
|
+
expect(out.chatId).toBe(ORIGIN_CHAT)
|
|
126
|
+
expect(out.deferrals).toBe(0)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('fleet chat wins over the stamp fallback when configured', () => {
|
|
131
|
+
const out = decide({
|
|
132
|
+
origin: null,
|
|
133
|
+
cardExists: true, // paint (not defer) so we observe the route
|
|
134
|
+
fleetChatId: ORIGIN_CHAT,
|
|
135
|
+
stampChatId: '999999',
|
|
136
|
+
stampThreadId: 7,
|
|
137
|
+
})
|
|
138
|
+
expect(out.action).toBe('paint')
|
|
139
|
+
if (out.action === 'paint') {
|
|
140
|
+
expect(out.chatId).toBe(ORIGIN_CHAT)
|
|
141
|
+
// Fleet-chat route carries no stamp thread (usingStampFallback is false).
|
|
142
|
+
expect(out.threadId).toBeUndefined()
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ─── Integration: the real feed, driven by the real decision ──────────────────
|
|
148
|
+
|
|
149
|
+
interface SentRecord {
|
|
150
|
+
chatId: string
|
|
151
|
+
threadId: number | undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function makeRecordingBot(sent: SentRecord[]): BotApiForWorkerFeed {
|
|
155
|
+
let nextId = 1000
|
|
156
|
+
return {
|
|
157
|
+
async sendMessage(chatId, _text, opts) {
|
|
158
|
+
sent.push({
|
|
159
|
+
chatId,
|
|
160
|
+
threadId: (opts?.message_thread_id as number | undefined) ?? undefined,
|
|
161
|
+
})
|
|
162
|
+
return { message_id: nextId++ }
|
|
163
|
+
},
|
|
164
|
+
async editMessageText() {
|
|
165
|
+
return {}
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Faithful model of the gateway `onProgress` worker-feed block: run the REAL
|
|
172
|
+
* `decideWorkerFeedDestination` and — only when it says paint — call the real
|
|
173
|
+
* feed with the resolved chat/thread. `resolveOrigin` mimics
|
|
174
|
+
* `resolveSubagentOriginChat` (null until the backfill links the row).
|
|
175
|
+
*/
|
|
176
|
+
function workerFeedTick(
|
|
177
|
+
feed: ReturnType<typeof createWorkerActivityFeed>,
|
|
178
|
+
deferrals: Map<string, number>,
|
|
179
|
+
agentId: string,
|
|
180
|
+
resolveOrigin: () => { chatId: string; threadId?: number } | null,
|
|
181
|
+
view: WorkerActivityView,
|
|
182
|
+
// The stamp-turn fallback used when no fleet chat is configured. Mirrors the
|
|
183
|
+
// gateway's exhausted-defer paint source (stampTurn.sessionChatId / thread).
|
|
184
|
+
stamp: { chatId?: string; threadId?: number } = { chatId: OWNER_DM, threadId: undefined },
|
|
185
|
+
): void {
|
|
186
|
+
const dest = decideWorkerFeedDestination({
|
|
187
|
+
origin: resolveOrigin(),
|
|
188
|
+
cardExists: feed.has(agentId),
|
|
189
|
+
priorDeferrals: deferrals.get(agentId) ?? 0,
|
|
190
|
+
maxDeferrals: MAX,
|
|
191
|
+
fleetChatId: '',
|
|
192
|
+
stampChatId: stamp.chatId,
|
|
193
|
+
stampThreadId: stamp.threadId,
|
|
194
|
+
ownerDm: OWNER_DM,
|
|
195
|
+
})
|
|
196
|
+
if (dest.action === 'defer') {
|
|
197
|
+
deferrals.set(agentId, dest.deferrals)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
deferrals.delete(agentId)
|
|
201
|
+
void feed.update(agentId, dest.chatId, view, dest.threadId)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
describe('worker-feed origin race — outcome: no owner-DM card', () => {
|
|
205
|
+
const runningView = (elapsedMs: number): WorkerActivityView => ({
|
|
206
|
+
description: 'racing worker',
|
|
207
|
+
lastTool: 'Read',
|
|
208
|
+
toolCount: 1,
|
|
209
|
+
latestSummary: 'reading files',
|
|
210
|
+
elapsedMs,
|
|
211
|
+
state: 'running',
|
|
212
|
+
model: 'sonnet',
|
|
213
|
+
totalTokens: 10,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('defers the racing first tick, then paints the card in the ORIGIN topic — never the DM', async () => {
|
|
217
|
+
let now = 0
|
|
218
|
+
const sent: SentRecord[] = []
|
|
219
|
+
const feed = createWorkerActivityFeed({
|
|
220
|
+
bot: makeRecordingBot(sent),
|
|
221
|
+
now: () => now,
|
|
222
|
+
firstPaintMinMs: 0, // paint immediately once we do update
|
|
223
|
+
minEditIntervalMs: 0,
|
|
224
|
+
})
|
|
225
|
+
const deferrals = new Map<string, number>()
|
|
226
|
+
const agentId = 'a37ad7639ae61476c'
|
|
227
|
+
|
|
228
|
+
// The backfill has NOT linked the row yet: origin resolves to null.
|
|
229
|
+
let linked = false
|
|
230
|
+
const resolveOrigin = () =>
|
|
231
|
+
linked ? { chatId: ORIGIN_CHAT, threadId: ORIGIN_THREAD } : null
|
|
232
|
+
|
|
233
|
+
// First progress tick — races the backfill (origin unresolved).
|
|
234
|
+
workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(1000))
|
|
235
|
+
await Promise.resolve()
|
|
236
|
+
|
|
237
|
+
// OUTCOME: nothing sent. In particular, no card in the owner DM.
|
|
238
|
+
expect(sent).toHaveLength(0)
|
|
239
|
+
expect(deferrals.get(agentId)).toBe(1)
|
|
240
|
+
expect(feed.has(agentId)).toBe(false)
|
|
241
|
+
|
|
242
|
+
// ~3s later the backfill completes and links the row → origin resolves.
|
|
243
|
+
linked = true
|
|
244
|
+
now = 4000
|
|
245
|
+
workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(4000))
|
|
246
|
+
await Promise.resolve()
|
|
247
|
+
await Promise.resolve()
|
|
248
|
+
|
|
249
|
+
// OUTCOME: the card is created in the ORIGIN supergroup + forum topic,
|
|
250
|
+
// and NEVER in the owner DM.
|
|
251
|
+
expect(sent.length).toBeGreaterThanOrEqual(1)
|
|
252
|
+
for (const s of sent) {
|
|
253
|
+
expect(s.chatId).not.toBe(OWNER_DM)
|
|
254
|
+
}
|
|
255
|
+
expect(sent.some((s) => s.chatId === ORIGIN_CHAT && s.threadId === ORIGIN_THREAD)).toBe(true)
|
|
256
|
+
// Defer state cleared once linked.
|
|
257
|
+
expect(deferrals.has(agentId)).toBe(false)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('bounded fallback: paints anyway if the backfill never links (no infinite defer)', async () => {
|
|
261
|
+
let now = 0
|
|
262
|
+
const sent: SentRecord[] = []
|
|
263
|
+
const feed = createWorkerActivityFeed({
|
|
264
|
+
bot: makeRecordingBot(sent),
|
|
265
|
+
now: () => now,
|
|
266
|
+
firstPaintMinMs: 0,
|
|
267
|
+
minEditIntervalMs: 0,
|
|
268
|
+
})
|
|
269
|
+
const deferrals = new Map<string, number>()
|
|
270
|
+
const agentId = 'neverlinks00000'
|
|
271
|
+
const resolveOrigin = () => null // backfill permanently broken
|
|
272
|
+
|
|
273
|
+
for (let i = 0; i < MAX; i++) {
|
|
274
|
+
now = i * 1000
|
|
275
|
+
workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(now))
|
|
276
|
+
await Promise.resolve()
|
|
277
|
+
}
|
|
278
|
+
await Promise.resolve()
|
|
279
|
+
|
|
280
|
+
// The first MAX-1 ticks deferred; the MAX-th painted. The universal-liveness
|
|
281
|
+
// contract wins over infinite silence, so a card IS eventually created —
|
|
282
|
+
// here to the owner DM (the only resort when origin never resolves and no
|
|
283
|
+
// stamp chat is set).
|
|
284
|
+
expect(sent.length).toBeGreaterThanOrEqual(1)
|
|
285
|
+
expect(sent.every((s) => s.chatId === OWNER_DM)).toBe(true)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('exhausted-defer (never-backfill) fallback lands in the origin FORUM THREAD, not General (#3458)', async () => {
|
|
289
|
+
// The backfill never links, so origin stays null through the bounded cap.
|
|
290
|
+
// In a forum supergroup the gateway paints using the live turn's chat AND
|
|
291
|
+
// its forum-topic id (stampTurn.sessionChatId / .sessionThreadId). Before
|
|
292
|
+
// the fix the thread was dropped and the card landed in General (thread
|
|
293
|
+
// undefined). Assert the thread id is carried to the painted card.
|
|
294
|
+
let now = 0
|
|
295
|
+
const sent: SentRecord[] = []
|
|
296
|
+
const feed = createWorkerActivityFeed({
|
|
297
|
+
bot: makeRecordingBot(sent),
|
|
298
|
+
now: () => now,
|
|
299
|
+
firstPaintMinMs: 0,
|
|
300
|
+
minEditIntervalMs: 0,
|
|
301
|
+
})
|
|
302
|
+
const deferrals = new Map<string, number>()
|
|
303
|
+
const agentId = 'neverlinks-forum'
|
|
304
|
+
const resolveOrigin = () => null // backfill permanently broken
|
|
305
|
+
// Live turn's chat + forum topic — the exhausted-defer fallback source.
|
|
306
|
+
const liveFallback = { chatId: ORIGIN_CHAT, threadId: ORIGIN_THREAD }
|
|
307
|
+
|
|
308
|
+
for (let i = 0; i < MAX; i++) {
|
|
309
|
+
now = i * 1000
|
|
310
|
+
workerFeedTick(feed, deferrals, agentId, resolveOrigin, runningView(now), liveFallback)
|
|
311
|
+
await Promise.resolve()
|
|
312
|
+
}
|
|
313
|
+
await Promise.resolve()
|
|
314
|
+
|
|
315
|
+
// OUTCOME: a card painted, in the origin supergroup AND its forum topic —
|
|
316
|
+
// the thread id survived the never-backfill fallback (not General).
|
|
317
|
+
expect(sent.length).toBeGreaterThanOrEqual(1)
|
|
318
|
+
expect(sent.every((s) => s.chatId === ORIGIN_CHAT)).toBe(true)
|
|
319
|
+
expect(sent.every((s) => s.threadId === ORIGIN_THREAD)).toBe(true)
|
|
320
|
+
})
|
|
321
|
+
})
|