switchroom 0.14.19 → 0.14.21
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/agent-scheduler/index.js +6 -1
- package/dist/auth-broker/index.js +6 -1
- package/dist/cli/notion-write-pretool.mjs +6 -1
- package/dist/cli/switchroom.js +17 -3
- package/dist/host-control/main.js +6 -1
- package/dist/vault/approvals/kernel-server.js +6 -1
- package/dist/vault/broker/server.js +6 -1
- package/package.json +2 -2
- package/telegram-plugin/README.md +7 -3
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +368 -153
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/coalesce-attachments.ts +79 -0
- package/telegram-plugin/gateway/gateway.ts +257 -39
- package/telegram-plugin/gateway/interrupt-defer.ts +106 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +21 -4
- package/telegram-plugin/tests/coalesce-attachments.test.ts +170 -0
- package/telegram-plugin/tests/interrupt-defer.test.ts +160 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +36 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +86 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +127 -0
- package/telegram-plugin/uat/assertions.ts +53 -0
- package/telegram-plugin/uat/driver.ts +28 -0
- package/telegram-plugin/uat/feed-matcher.test.ts +80 -0
- package/telegram-plugin/uat/fixtures/album/blue.jpg +0 -0
- package/telegram-plugin/uat/fixtures/album/green.jpg +0 -0
- package/telegram-plugin/uat/fixtures/album/red.jpg +0 -0
- package/telegram-plugin/uat/scenarios/jtbd-album-coalescing-dm.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/jtbd-forwarded-burst-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +17 -2
- package/telegram-plugin/worker-activity-feed.ts +65 -9
|
@@ -184,10 +184,16 @@ export function planBufferedRedelivery(
|
|
|
184
184
|
return out
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/** Meta keys that describe an attachment — the primary (image_path,
|
|
188
|
+
* attachment_*) plus the A2 numbered siblings (image_path_2,
|
|
189
|
+
* attachment_file_id_2, …) and attachment_count. */
|
|
190
|
+
const ATTACHMENT_META_RE = /^(image_path|attachment_)/
|
|
191
|
+
|
|
187
192
|
/** Collapse a >1 run into a single turn. The newest message anchors the
|
|
188
193
|
* turn (its messageId/ts/user/meta); texts join in arrival order; the
|
|
189
|
-
*
|
|
190
|
-
*
|
|
194
|
+
* attachment(s) (if any) ride along from whichever message carried them.
|
|
195
|
+
* Caller guarantees the run is mergeable + has at most one media-bearing
|
|
196
|
+
* entry. */
|
|
191
197
|
function mergeRun(run: InboundMessage[]): InboundMessage {
|
|
192
198
|
const last = run[run.length - 1]!
|
|
193
199
|
const mediaEntry = run.find(inboundHasMedia)
|
|
@@ -195,10 +201,21 @@ function mergeRun(run: InboundMessage[]): InboundMessage {
|
|
|
195
201
|
...last,
|
|
196
202
|
text: run.map((m) => m.text).join('\n'),
|
|
197
203
|
}
|
|
198
|
-
// Re-seat the
|
|
199
|
-
//
|
|
204
|
+
// Re-seat the attachment/imagePath from the entry that owns it (which may
|
|
205
|
+
// not be `last`), or strip them if the run is text-only.
|
|
200
206
|
delete merged.imagePath
|
|
201
207
|
delete merged.attachment
|
|
208
|
+
if (mediaEntry != null && mediaEntry !== last) {
|
|
209
|
+
// The media-bearing entry isn't the anchor, so `last.meta` lacks the
|
|
210
|
+
// attachment fields the agent reads (image_path / attachment_* and the
|
|
211
|
+
// A2 numbered siblings). Splice the owning entry's attachment meta keys
|
|
212
|
+
// into the merged meta so the agent still sees every attachment.
|
|
213
|
+
const splicedMeta: Record<string, string> = { ...merged.meta }
|
|
214
|
+
for (const [k, v] of Object.entries(mediaEntry.meta)) {
|
|
215
|
+
if (ATTACHMENT_META_RE.test(k)) splicedMeta[k] = v
|
|
216
|
+
}
|
|
217
|
+
merged.meta = splicedMeta
|
|
218
|
+
}
|
|
202
219
|
if (mediaEntry?.imagePath != null) merged.imagePath = mediaEntry.imagePath
|
|
203
220
|
if (mediaEntry?.attachment != null) merged.attachment = mediaEntry.attachment
|
|
204
221
|
return merged
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the A2 multi-attachment helpers
|
|
3
|
+
* (telegram-plugin/gateway/coalesce-attachments.ts).
|
|
4
|
+
*
|
|
5
|
+
* These pin the pure pieces of the multi-attachment fold-in that live
|
|
6
|
+
* outside gateway.ts so they can be exercised without loadAccess()/IPC:
|
|
7
|
+
* 1. resolveCoalesceMaxAttachments — the runtime cap default (10).
|
|
8
|
+
* 2. splitCoalescedAttachments — primary + capped extras, arrival order.
|
|
9
|
+
* 3. buildExtraAttachmentMeta — numbered meta fields starting at _2.
|
|
10
|
+
*
|
|
11
|
+
* A cap of 1 reproduces the historical single-attachment shape: primary
|
|
12
|
+
* only, no extras, no numbered meta.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from 'vitest'
|
|
16
|
+
import {
|
|
17
|
+
splitCoalescedAttachments,
|
|
18
|
+
buildExtraAttachmentMeta,
|
|
19
|
+
resolveCoalesceMaxAttachments,
|
|
20
|
+
DEFAULT_MAX_ATTACHMENTS,
|
|
21
|
+
type ResolvedExtraAttachment,
|
|
22
|
+
} from '../gateway/coalesce-attachments.js'
|
|
23
|
+
|
|
24
|
+
describe('resolveCoalesceMaxAttachments (default 10 = full album)', () => {
|
|
25
|
+
it('defaults to 10 when unset', () => {
|
|
26
|
+
expect(resolveCoalesceMaxAttachments(undefined)).toBe(10)
|
|
27
|
+
expect(DEFAULT_MAX_ATTACHMENTS).toBe(10)
|
|
28
|
+
})
|
|
29
|
+
it('honours an explicit operator cap', () => {
|
|
30
|
+
expect(resolveCoalesceMaxAttachments(1)).toBe(1)
|
|
31
|
+
expect(resolveCoalesceMaxAttachments(25)).toBe(25)
|
|
32
|
+
})
|
|
33
|
+
it('floors a 0 / negative cap at 1 (never strips the only attachment)', () => {
|
|
34
|
+
expect(resolveCoalesceMaxAttachments(0)).toBe(1)
|
|
35
|
+
expect(resolveCoalesceMaxAttachments(-5)).toBe(1)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
interface Entry {
|
|
40
|
+
text: string
|
|
41
|
+
att?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const has = (e: Entry): boolean => e.att != null
|
|
45
|
+
|
|
46
|
+
describe('splitCoalescedAttachments', () => {
|
|
47
|
+
it('cap 1: keeps only the first attachment as primary, no extras', () => {
|
|
48
|
+
const entries: Entry[] = [
|
|
49
|
+
{ text: 'a', att: 'photo-1' },
|
|
50
|
+
{ text: 'b', att: 'photo-2' },
|
|
51
|
+
]
|
|
52
|
+
const { primary, extras } = splitCoalescedAttachments(entries, has, 1)
|
|
53
|
+
expect(primary).toEqual({ text: 'a', att: 'photo-1' })
|
|
54
|
+
expect(extras).toEqual([])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('picks the FIRST attachment-bearing entry as primary even when text-only entries precede it', () => {
|
|
58
|
+
const entries: Entry[] = [
|
|
59
|
+
{ text: 'look' },
|
|
60
|
+
{ text: 'at this', att: 'photo-1' },
|
|
61
|
+
{ text: 'and this', att: 'photo-2' },
|
|
62
|
+
]
|
|
63
|
+
const { primary, extras } = splitCoalescedAttachments(entries, has, 3)
|
|
64
|
+
expect(primary?.att).toBe('photo-1')
|
|
65
|
+
expect(extras.map((e) => e.att)).toEqual(['photo-2'])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('preserves arrival order of extras', () => {
|
|
69
|
+
const entries: Entry[] = [
|
|
70
|
+
{ text: '1', att: 'a' },
|
|
71
|
+
{ text: '2', att: 'b' },
|
|
72
|
+
{ text: '3', att: 'c' },
|
|
73
|
+
]
|
|
74
|
+
const { primary, extras } = splitCoalescedAttachments(entries, has, 5)
|
|
75
|
+
expect(primary?.att).toBe('a')
|
|
76
|
+
expect(extras.map((e) => e.att)).toEqual(['b', 'c'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('caps extras at maxAttachments (overflow dropped here; bypassed upstream)', () => {
|
|
80
|
+
const entries: Entry[] = [
|
|
81
|
+
{ text: '1', att: 'a' },
|
|
82
|
+
{ text: '2', att: 'b' },
|
|
83
|
+
{ text: '3', att: 'c' },
|
|
84
|
+
{ text: '4', att: 'd' },
|
|
85
|
+
]
|
|
86
|
+
const { primary, extras } = splitCoalescedAttachments(entries, has, 2)
|
|
87
|
+
expect(primary?.att).toBe('a')
|
|
88
|
+
expect(extras.map((e) => e.att)).toEqual(['b']) // total = cap of 2
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('returns undefined primary when no entry carries an attachment', () => {
|
|
92
|
+
const entries: Entry[] = [{ text: 'just' }, { text: 'text' }]
|
|
93
|
+
const { primary, extras } = splitCoalescedAttachments(entries, has, 3)
|
|
94
|
+
expect(primary).toBeUndefined()
|
|
95
|
+
expect(extras).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('floors a cap of 0 / negative at 1 so the only attachment is never stripped', () => {
|
|
99
|
+
const entries: Entry[] = [{ text: '1', att: 'a' }, { text: '2', att: 'b' }]
|
|
100
|
+
expect(splitCoalescedAttachments(entries, has, 0).primary?.att).toBe('a')
|
|
101
|
+
expect(splitCoalescedAttachments(entries, has, -5).primary?.att).toBe('a')
|
|
102
|
+
expect(splitCoalescedAttachments(entries, has, 0).extras).toEqual([])
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('buildExtraAttachmentMeta', () => {
|
|
107
|
+
it('returns an empty object for no extras (default single-attachment turn)', () => {
|
|
108
|
+
expect(buildExtraAttachmentMeta([])).toEqual({})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('numbers a single photo extra as _2', () => {
|
|
112
|
+
const resolved: ResolvedExtraAttachment[] = [{ imagePath: '/inbox/p2.jpg' }]
|
|
113
|
+
expect(buildExtraAttachmentMeta(resolved)).toEqual({ image_path_2: '/inbox/p2.jpg' })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('numbers multiple extras incrementally from _2', () => {
|
|
117
|
+
const resolved: ResolvedExtraAttachment[] = [
|
|
118
|
+
{ imagePath: '/inbox/p2.jpg' },
|
|
119
|
+
{ imagePath: '/inbox/p3.jpg' },
|
|
120
|
+
]
|
|
121
|
+
expect(buildExtraAttachmentMeta(resolved)).toEqual({
|
|
122
|
+
image_path_2: '/inbox/p2.jpg',
|
|
123
|
+
image_path_3: '/inbox/p3.jpg',
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('emits full attachment metadata fields for a document extra', () => {
|
|
128
|
+
const resolved: ResolvedExtraAttachment[] = [
|
|
129
|
+
{
|
|
130
|
+
attachment: {
|
|
131
|
+
kind: 'document',
|
|
132
|
+
file_id: 'FID2',
|
|
133
|
+
size: 1234,
|
|
134
|
+
mime: 'application/pdf',
|
|
135
|
+
name: 'spec.pdf',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
]
|
|
139
|
+
expect(buildExtraAttachmentMeta(resolved)).toEqual({
|
|
140
|
+
attachment_kind_2: 'document',
|
|
141
|
+
attachment_file_id_2: 'FID2',
|
|
142
|
+
attachment_size_2: '1234',
|
|
143
|
+
attachment_mime_2: 'application/pdf',
|
|
144
|
+
attachment_name_2: 'spec.pdf',
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('omits optional metadata fields that are absent', () => {
|
|
149
|
+
const resolved: ResolvedExtraAttachment[] = [
|
|
150
|
+
{ attachment: { kind: 'voice', file_id: 'FID2' } },
|
|
151
|
+
]
|
|
152
|
+
expect(buildExtraAttachmentMeta(resolved)).toEqual({
|
|
153
|
+
attachment_kind_2: 'voice',
|
|
154
|
+
attachment_file_id_2: 'FID2',
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('handles a mix of photo and document extras with correct numbering', () => {
|
|
159
|
+
const resolved: ResolvedExtraAttachment[] = [
|
|
160
|
+
{ imagePath: '/inbox/p2.jpg' },
|
|
161
|
+
{ attachment: { kind: 'document', file_id: 'FID3', mime: 'text/plain' } },
|
|
162
|
+
]
|
|
163
|
+
expect(buildExtraAttachmentMeta(resolved)).toEqual({
|
|
164
|
+
image_path_2: '/inbox/p2.jpg',
|
|
165
|
+
attachment_kind_3: 'document',
|
|
166
|
+
attachment_file_id_3: 'FID3',
|
|
167
|
+
attachment_mime_3: 'text/plain',
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the deferred safe-boundary interrupt core (Problem B).
|
|
3
|
+
*
|
|
4
|
+
* The gateway-side wiring (timer, SIGINT-via-tmux, sendToAgent resume,
|
|
5
|
+
* coalescing) is exercised by integration; these pin the pure decision:
|
|
6
|
+
* - ToolFlightTracker correctly tracks open tool calls by toolUseId and
|
|
7
|
+
* clears on turn_end / a fresh enqueue.
|
|
8
|
+
* - decideInterruptTiming returns fire-now unless the flag is on AND a tool
|
|
9
|
+
* is in flight.
|
|
10
|
+
* - resolveInterruptMaxWaitMs never yields a non-positive / forever wait.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
ToolFlightTracker,
|
|
16
|
+
decideInterruptTiming,
|
|
17
|
+
resolveInterruptMaxWaitMs,
|
|
18
|
+
resolveSafeBoundaryEnabled,
|
|
19
|
+
DEFAULT_INTERRUPT_MAX_WAIT_MS,
|
|
20
|
+
} from '../gateway/interrupt-defer.js'
|
|
21
|
+
|
|
22
|
+
describe('ToolFlightTracker', () => {
|
|
23
|
+
it('starts at a safe boundary (no tools in flight)', () => {
|
|
24
|
+
const t = new ToolFlightTracker()
|
|
25
|
+
expect(t.isMidToolCall()).toBe(false)
|
|
26
|
+
expect(t.inFlightCount()).toBe(0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('a tool_use opens an unsafe boundary; its tool_result closes it', () => {
|
|
30
|
+
const t = new ToolFlightTracker()
|
|
31
|
+
t.onEvent({ kind: 'tool_use', toolUseId: 'tu_1' })
|
|
32
|
+
expect(t.isMidToolCall()).toBe(true)
|
|
33
|
+
t.onEvent({ kind: 'tool_result', toolUseId: 'tu_1' })
|
|
34
|
+
expect(t.isMidToolCall()).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('stays unsafe while ANY of several parallel tools is open', () => {
|
|
38
|
+
const t = new ToolFlightTracker()
|
|
39
|
+
t.onEvent({ kind: 'tool_use', toolUseId: 'a' })
|
|
40
|
+
t.onEvent({ kind: 'tool_use', toolUseId: 'b' })
|
|
41
|
+
t.onEvent({ kind: 'tool_use', toolUseId: 'c' })
|
|
42
|
+
expect(t.inFlightCount()).toBe(3)
|
|
43
|
+
t.onEvent({ kind: 'tool_result', toolUseId: 'b' })
|
|
44
|
+
t.onEvent({ kind: 'tool_result', toolUseId: 'a' })
|
|
45
|
+
expect(t.isMidToolCall()).toBe(true) // c still open
|
|
46
|
+
t.onEvent({ kind: 'tool_result', toolUseId: 'c' })
|
|
47
|
+
expect(t.isMidToolCall()).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('turn_end clears any residual in-flight tools', () => {
|
|
51
|
+
const t = new ToolFlightTracker()
|
|
52
|
+
t.onEvent({ kind: 'tool_use', toolUseId: 'tu_1' })
|
|
53
|
+
t.onEvent({ kind: 'tool_use', toolUseId: 'tu_2' })
|
|
54
|
+
t.onEvent({ kind: 'turn_end' })
|
|
55
|
+
expect(t.isMidToolCall()).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('a fresh enqueue clears the slate (new turn starts clean)', () => {
|
|
59
|
+
const t = new ToolFlightTracker()
|
|
60
|
+
t.onEvent({ kind: 'tool_use', toolUseId: 'tu_1' })
|
|
61
|
+
t.onEvent({ kind: 'enqueue' })
|
|
62
|
+
expect(t.isMidToolCall()).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('ignores sub-agent and non-tool events', () => {
|
|
66
|
+
const t = new ToolFlightTracker()
|
|
67
|
+
t.onEvent({ kind: 'sub_agent_tool_use', toolUseId: 'sub_1' })
|
|
68
|
+
t.onEvent({ kind: 'thinking' })
|
|
69
|
+
t.onEvent({ kind: 'text' })
|
|
70
|
+
t.onEvent({ kind: 'tool_label', toolUseId: 'tu_x' })
|
|
71
|
+
expect(t.isMidToolCall()).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('ignores tool_use with a missing / empty toolUseId', () => {
|
|
75
|
+
const t = new ToolFlightTracker()
|
|
76
|
+
t.onEvent({ kind: 'tool_use' })
|
|
77
|
+
t.onEvent({ kind: 'tool_use', toolUseId: null })
|
|
78
|
+
t.onEvent({ kind: 'tool_use', toolUseId: '' })
|
|
79
|
+
expect(t.isMidToolCall()).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('tool_result for an unknown id is a harmless no-op', () => {
|
|
83
|
+
const t = new ToolFlightTracker()
|
|
84
|
+
t.onEvent({ kind: 'tool_use', toolUseId: 'real' })
|
|
85
|
+
t.onEvent({ kind: 'tool_result', toolUseId: 'never-opened' })
|
|
86
|
+
expect(t.isMidToolCall()).toBe(true) // 'real' still open
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('clear() resets the tracker', () => {
|
|
90
|
+
const t = new ToolFlightTracker()
|
|
91
|
+
t.onEvent({ kind: 'tool_use', toolUseId: 'tu_1' })
|
|
92
|
+
t.clear()
|
|
93
|
+
expect(t.isMidToolCall()).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('decideInterruptTiming', () => {
|
|
98
|
+
it('fires now when the flag is off, even mid-tool-call', () => {
|
|
99
|
+
expect(
|
|
100
|
+
decideInterruptTiming({ safeBoundaryEnabled: false, midToolCall: true }),
|
|
101
|
+
).toBe('fire-now')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('fires now when the flag is on but no tool is in flight', () => {
|
|
105
|
+
expect(
|
|
106
|
+
decideInterruptTiming({ safeBoundaryEnabled: true, midToolCall: false }),
|
|
107
|
+
).toBe('fire-now')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('defers only when the flag is on AND a tool is in flight', () => {
|
|
111
|
+
expect(
|
|
112
|
+
decideInterruptTiming({ safeBoundaryEnabled: true, midToolCall: true }),
|
|
113
|
+
).toBe('defer')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('fires now in the fully-off case', () => {
|
|
117
|
+
expect(
|
|
118
|
+
decideInterruptTiming({ safeBoundaryEnabled: false, midToolCall: false }),
|
|
119
|
+
).toBe('fire-now')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('resolveSafeBoundaryEnabled (default ON)', () => {
|
|
124
|
+
it('defaults to true when unset', () => {
|
|
125
|
+
expect(resolveSafeBoundaryEnabled(undefined)).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
it('stays true when explicitly true', () => {
|
|
128
|
+
expect(resolveSafeBoundaryEnabled(true)).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
it('only an explicit false opts out', () => {
|
|
131
|
+
expect(resolveSafeBoundaryEnabled(false)).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('resolveInterruptMaxWaitMs', () => {
|
|
136
|
+
it('uses the configured value when positive', () => {
|
|
137
|
+
expect(resolveInterruptMaxWaitMs(3000)).toBe(3000)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('falls back to the default when undefined', () => {
|
|
141
|
+
expect(resolveInterruptMaxWaitMs(undefined)).toBe(DEFAULT_INTERRUPT_MAX_WAIT_MS)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('never returns a non-positive wait (no forever-wait)', () => {
|
|
145
|
+
expect(resolveInterruptMaxWaitMs(0)).toBe(DEFAULT_INTERRUPT_MAX_WAIT_MS)
|
|
146
|
+
expect(resolveInterruptMaxWaitMs(-1)).toBe(DEFAULT_INTERRUPT_MAX_WAIT_MS)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('models the lifecycle: open tool → defer; tool_result → safe → fire', () => {
|
|
150
|
+
const t = new ToolFlightTracker()
|
|
151
|
+
t.onEvent({ kind: 'tool_use', toolUseId: 'w1' })
|
|
152
|
+
// `!` lands here, flag on:
|
|
153
|
+
expect(
|
|
154
|
+
decideInterruptTiming({ safeBoundaryEnabled: true, midToolCall: t.isMidToolCall() }),
|
|
155
|
+
).toBe('defer')
|
|
156
|
+
// tool completes:
|
|
157
|
+
t.onEvent({ kind: 'tool_result', toolUseId: 'w1' })
|
|
158
|
+
expect(t.isMidToolCall()).toBe(false) // gateway fires the parked interrupt here
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -450,6 +450,42 @@ describe('planBufferedRedelivery — merge-on-drain (forwarded-burst across a tu
|
|
|
450
450
|
expect(plan[0]!.merged.imagePath).toBe('/tmp/p.jpg')
|
|
451
451
|
})
|
|
452
452
|
|
|
453
|
+
it('splices attachment meta from the media entry when it is NOT the anchor (A2 numbered fields survive)', () => {
|
|
454
|
+
// A coalesced multi-attachment message buffered, then a text-only
|
|
455
|
+
// follow-up. mergeRun anchors on `last` (the text), whose meta has no
|
|
456
|
+
// attachment fields — so the owning entry's image_path + numbered
|
|
457
|
+
// siblings + attachment_count must be spliced into the merged meta or
|
|
458
|
+
// the agent would never see the photos.
|
|
459
|
+
const photo = userMsg({ text: 'look', ts: 1, imagePath: '/tmp/a.jpg' })
|
|
460
|
+
photo.meta = {
|
|
461
|
+
image_path: '/tmp/a.jpg',
|
|
462
|
+
image_path_2: '/tmp/b.jpg',
|
|
463
|
+
attachment_count: '2',
|
|
464
|
+
user: 'alice',
|
|
465
|
+
}
|
|
466
|
+
const txt = userMsg({ text: 'at these', ts: 2 })
|
|
467
|
+
txt.meta = { user: 'alice' }
|
|
468
|
+
const plan = planBufferedRedelivery([photo, txt])
|
|
469
|
+
expect(plan).toHaveLength(1)
|
|
470
|
+
const meta = plan[0]!.merged.meta
|
|
471
|
+
expect(meta.image_path).toBe('/tmp/a.jpg')
|
|
472
|
+
expect(meta.image_path_2).toBe('/tmp/b.jpg')
|
|
473
|
+
expect(meta.attachment_count).toBe('2')
|
|
474
|
+
// Top-level primary still re-seated for inboundHasMedia detection.
|
|
475
|
+
expect(plan[0]!.merged.imagePath).toBe('/tmp/a.jpg')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('does not need a meta splice when the media entry IS the anchor', () => {
|
|
479
|
+
const txt = userMsg({ text: 'intro', ts: 1 })
|
|
480
|
+
txt.meta = { user: 'alice' }
|
|
481
|
+
const photo = userMsg({ text: 'pic', ts: 2, imagePath: '/tmp/p.jpg' })
|
|
482
|
+
photo.meta = { image_path: '/tmp/p.jpg', user: 'alice' }
|
|
483
|
+
const plan = planBufferedRedelivery([txt, photo])
|
|
484
|
+
expect(plan).toHaveLength(1)
|
|
485
|
+
// Anchor is the photo, so its meta is inherited verbatim.
|
|
486
|
+
expect(plan[0]!.merged.meta.image_path).toBe('/tmp/p.jpg')
|
|
487
|
+
})
|
|
488
|
+
|
|
453
489
|
it('preserves the run total — sum of originals equals input length (lossless)', () => {
|
|
454
490
|
const msgs = [
|
|
455
491
|
userMsg({ text: 'a', ts: 1 }),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural pin for the permission-card resume beat.
|
|
3
|
+
*
|
|
4
|
+
* What broke (and the bug this guards against): when the operator
|
|
5
|
+
* answers a permission card, the suspended `claude` turn un-parks and
|
|
6
|
+
* resumes the SAME turn — the gateway must flip the awaiting glyph
|
|
7
|
+
* (🙏) back to a working glyph so the operator sees progress instead
|
|
8
|
+
* of a stuck card. That flip is `resumeReactionAfterVerdict()`.
|
|
9
|
+
*
|
|
10
|
+
* The verdict can arrive down several independent paths (button tap,
|
|
11
|
+
* always-allow, `/allow`·`/deny`, TTL auto-deny, free-text `y <id>`/
|
|
12
|
+
* `no <id>` reply, …). Every one of them calls
|
|
13
|
+
* `dispatchPermissionVerdict(...)` to un-park the turn — but the resume
|
|
14
|
+
* glyph flip is a *separate* call right next to it. The free-text-reply
|
|
15
|
+
* path shipped the dispatch WITHOUT the resume (fixed in v0.14.19), so
|
|
16
|
+
* answering via a text reply left the card frozen on 🙏 even though the
|
|
17
|
+
* turn was running. The controller-level behaviour is covered by
|
|
18
|
+
* `status-reactions.test.ts` ("setAwaiting" + watchdog re-arm); mtcute
|
|
19
|
+
* UAT cannot observe reactions at all, so this static pin is the only
|
|
20
|
+
* thing that catches a verdict path forgetting the resume.
|
|
21
|
+
*
|
|
22
|
+
* This guard fails loudly if any `dispatchPermissionVerdict(...)`
|
|
23
|
+
* callsite is not paired with a `resumeReactionAfterVerdict()` within a
|
|
24
|
+
* few lines — i.e. a new (or refactored) verdict path drops the resume.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest'
|
|
28
|
+
import { readFileSync } from 'node:fs'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
import { dirname, resolve } from 'node:path'
|
|
31
|
+
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
33
|
+
const GATEWAY_SRC = readFileSync(
|
|
34
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
35
|
+
'utf8',
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const LINES = GATEWAY_SRC.split('\n')
|
|
39
|
+
|
|
40
|
+
// A `dispatchPermissionVerdict(` occurrence is a CALLSITE unless it's the
|
|
41
|
+
// function definition itself.
|
|
42
|
+
const isDefinition = (line: string) =>
|
|
43
|
+
/\bfunction\s+dispatchPermissionVerdict\b/.test(line)
|
|
44
|
+
|
|
45
|
+
const dispatchCallsites = LINES.flatMap((line, i) =>
|
|
46
|
+
/\bdispatchPermissionVerdict\s*\(/.test(line) && !isDefinition(line)
|
|
47
|
+
? [i]
|
|
48
|
+
: [],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// How far below the dispatch the resume call is allowed to live. The
|
|
52
|
+
// widest real gap today is ~9 lines (the slash-command path); 15 gives
|
|
53
|
+
// refactor headroom without letting an unrelated resume "cover" a
|
|
54
|
+
// dispatch from a different block.
|
|
55
|
+
const RESUME_WINDOW = 15
|
|
56
|
+
|
|
57
|
+
describe('permission verdict → resume reaction wiring', () => {
|
|
58
|
+
it('there is at least one verdict-dispatch path to guard', () => {
|
|
59
|
+
expect(dispatchCallsites.length).toBeGreaterThan(0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('every dispatchPermissionVerdict() callsite flips the awaiting glyph back via resumeReactionAfterVerdict()', () => {
|
|
63
|
+
const unpaired: number[] = []
|
|
64
|
+
for (const idx of dispatchCallsites) {
|
|
65
|
+
const window = LINES.slice(idx, idx + RESUME_WINDOW + 1).join('\n')
|
|
66
|
+
if (!/\bresumeReactionAfterVerdict\s*\(\s*\)/.test(window)) {
|
|
67
|
+
// 1-based line number for a human-readable failure.
|
|
68
|
+
unpaired.push(idx + 1)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
expect(
|
|
72
|
+
unpaired,
|
|
73
|
+
`dispatchPermissionVerdict() at gateway.ts line(s) ` +
|
|
74
|
+
`${unpaired.join(', ')} has no resumeReactionAfterVerdict() within ` +
|
|
75
|
+
`${RESUME_WINDOW} lines — that verdict path leaves the permission ` +
|
|
76
|
+
`card stuck on 🙏 after the operator answers. Add the resume call ` +
|
|
77
|
+
`(see the sibling paths and v0.14.19 / the free-text-reply fix).`,
|
|
78
|
+
).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('the resume helper still exists (the pairing is meaningless if it was deleted)', () => {
|
|
82
|
+
expect(/function\s+resumeReactionAfterVerdict\s*\(/.test(GATEWAY_SRC)).toBe(
|
|
83
|
+
true,
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -2,10 +2,24 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
import {
|
|
3
3
|
renderWorkerActivity,
|
|
4
4
|
createWorkerActivityFeed,
|
|
5
|
+
isWorkerActivityFeedEnabled,
|
|
5
6
|
type WorkerActivityView,
|
|
6
7
|
type BotApiForWorkerFeed,
|
|
7
8
|
} from '../worker-activity-feed.js'
|
|
8
9
|
|
|
10
|
+
describe('isWorkerActivityFeedEnabled (default ON)', () => {
|
|
11
|
+
it('defaults to true when the env var is unset', () => {
|
|
12
|
+
expect(isWorkerActivityFeedEnabled(undefined)).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
it('stays on for any value other than "0"', () => {
|
|
15
|
+
expect(isWorkerActivityFeedEnabled('1')).toBe(true)
|
|
16
|
+
expect(isWorkerActivityFeedEnabled('')).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
it('only "0" disables it', () => {
|
|
19
|
+
expect(isWorkerActivityFeedEnabled('0')).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
9
23
|
function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
|
|
10
24
|
return {
|
|
11
25
|
description: 'research competitors',
|
|
@@ -92,6 +106,42 @@ describe('renderWorkerActivity', () => {
|
|
|
92
106
|
expect(out).toContain('⚠️ <b>Worker failed</b>')
|
|
93
107
|
})
|
|
94
108
|
|
|
109
|
+
it('grows a narrative block when narrativeLines is present', () => {
|
|
110
|
+
const out = renderWorkerActivity(
|
|
111
|
+
view({
|
|
112
|
+
latestSummary: 'newest only — should be ignored',
|
|
113
|
+
narrativeLines: ['read the brief', 'scanned vendor A', 'scanned vendor B'],
|
|
114
|
+
}),
|
|
115
|
+
)
|
|
116
|
+
expect(out).toContain('↳ <i>read the brief</i>')
|
|
117
|
+
expect(out).toContain('↳ <i>scanned vendor A</i>')
|
|
118
|
+
expect(out).toContain('↳ <i>scanned vendor B</i>')
|
|
119
|
+
// The single-line latestSummary fallback is NOT used when a block is present.
|
|
120
|
+
expect(out).not.toContain('newest only')
|
|
121
|
+
// Three narrative lines → three ↳ lines.
|
|
122
|
+
expect(out.match(/↳/g) ?? []).toHaveLength(3)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('falls back to latestSummary when narrativeLines is empty', () => {
|
|
126
|
+
const out = renderWorkerActivity(view({ narrativeLines: [], latestSummary: 'one line' }))
|
|
127
|
+
expect(out).toContain('↳ <i>one line</i>')
|
|
128
|
+
expect(out.match(/↳/g) ?? []).toHaveLength(1)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('drops blank narrative lines from the block', () => {
|
|
132
|
+
const out = renderWorkerActivity(
|
|
133
|
+
view({ narrativeLines: ['kept', ' ', 'also kept'] }),
|
|
134
|
+
)
|
|
135
|
+
expect(out).toContain('↳ <i>kept</i>')
|
|
136
|
+
expect(out).toContain('↳ <i>also kept</i>')
|
|
137
|
+
expect(out.match(/↳/g) ?? []).toHaveLength(2)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('escapes HTML inside narrative lines', () => {
|
|
141
|
+
const out = renderWorkerActivity(view({ narrativeLines: ['a <b>x</b> & y'] }))
|
|
142
|
+
expect(out).toContain('a <b>x</b> & y')
|
|
143
|
+
})
|
|
144
|
+
|
|
95
145
|
it('escapes HTML in description, tool, arg, and summary', () => {
|
|
96
146
|
const out = renderWorkerActivity(
|
|
97
147
|
view({
|
|
@@ -246,6 +296,83 @@ describe('createWorkerActivityFeed', () => {
|
|
|
246
296
|
expect(feed.size).toBe(0)
|
|
247
297
|
})
|
|
248
298
|
|
|
299
|
+
it('accumulates distinct narrative lines into a growing block across ticks', async () => {
|
|
300
|
+
const bot = makeFakeBot()
|
|
301
|
+
let clock = 10_000
|
|
302
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
303
|
+
|
|
304
|
+
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'read the brief' }))
|
|
305
|
+
expect(bot.sent).toHaveLength(1)
|
|
306
|
+
expect(bot.sent[0].text).toContain('↳ <i>read the brief</i>')
|
|
307
|
+
|
|
308
|
+
clock = 11_000
|
|
309
|
+
await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'scanned vendor A' }))
|
|
310
|
+
clock = 12_000
|
|
311
|
+
await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'scanned vendor B' }))
|
|
312
|
+
|
|
313
|
+
const last = bot.edits.at(-1)!
|
|
314
|
+
expect(last.text).toContain('↳ <i>read the brief</i>')
|
|
315
|
+
expect(last.text).toContain('↳ <i>scanned vendor A</i>')
|
|
316
|
+
expect(last.text).toContain('↳ <i>scanned vendor B</i>')
|
|
317
|
+
expect(last.text.match(/↳/g) ?? []).toHaveLength(3)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('dedups a repeated narrative line so the block does not duplicate', async () => {
|
|
321
|
+
const bot = makeFakeBot()
|
|
322
|
+
let clock = 10_000
|
|
323
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
324
|
+
|
|
325
|
+
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'same line' }))
|
|
326
|
+
// Repeated narrative but a changed tool count → body differs, edit fires,
|
|
327
|
+
// but the narrative block must not gain a duplicate line.
|
|
328
|
+
clock = 11_000
|
|
329
|
+
await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'same line' }))
|
|
330
|
+
|
|
331
|
+
const last = bot.edits.at(-1)!
|
|
332
|
+
expect(last.text.match(/↳/g) ?? []).toHaveLength(1)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('caps the narrative block to the last 6 lines', async () => {
|
|
336
|
+
const bot = makeFakeBot()
|
|
337
|
+
let clock = 10_000
|
|
338
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
339
|
+
|
|
340
|
+
for (let i = 1; i <= 9; i++) {
|
|
341
|
+
clock += 1000
|
|
342
|
+
await feed.update('w1', 'chat', view({ toolCount: i, latestSummary: `line ${i}` }))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const last = bot.edits.at(-1)!
|
|
346
|
+
expect(last.text.match(/↳/g) ?? []).toHaveLength(6)
|
|
347
|
+
// Oldest lines evicted; newest retained.
|
|
348
|
+
expect(last.text).not.toContain('line 1')
|
|
349
|
+
expect(last.text).not.toContain('line 3')
|
|
350
|
+
expect(last.text).toContain('line 4')
|
|
351
|
+
expect(last.text).toContain('line 9')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('grows the narrative even while throttled (line surfaces on next edit)', async () => {
|
|
355
|
+
const bot = makeFakeBot()
|
|
356
|
+
let clock = 10_000
|
|
357
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 2500 })
|
|
358
|
+
|
|
359
|
+
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'line A' }))
|
|
360
|
+
expect(bot.sent).toHaveLength(1)
|
|
361
|
+
|
|
362
|
+
// Throttled tick — no edit, but the line must still be accumulated.
|
|
363
|
+
clock = 11_000
|
|
364
|
+
await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'line B' }))
|
|
365
|
+
expect(bot.edits).toHaveLength(0)
|
|
366
|
+
|
|
367
|
+
// Past the throttle — the edit now carries BOTH lines.
|
|
368
|
+
clock = 13_000
|
|
369
|
+
await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'line C' }))
|
|
370
|
+
const last = bot.edits.at(-1)!
|
|
371
|
+
expect(last.text).toContain('↳ <i>line A</i>')
|
|
372
|
+
expect(last.text).toContain('↳ <i>line B</i>')
|
|
373
|
+
expect(last.text).toContain('↳ <i>line C</i>')
|
|
374
|
+
})
|
|
375
|
+
|
|
249
376
|
it('forwards threadId as message_thread_id on send', async () => {
|
|
250
377
|
const bot = makeFakeBot()
|
|
251
378
|
let clock = 10_000
|