@jigging/agent-acp 0.0.0 → 0.1.0-alpha.4
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/AGENTS.md +88 -0
- package/FLOW.contract.json +241 -0
- package/FLOW.meta.json +6 -0
- package/FLOW.ts +3 -0
- package/LICENSE +374 -0
- package/README.md +156 -0
- package/THIRD_PARTY_NOTICES +14 -0
- package/contracts/acp-public-updates.json +75 -0
- package/contracts/agent-commands.json +73 -0
- package/contracts/agent-replies.json +156 -0
- package/contracts/finite-acp/contract.json +104 -0
- package/contracts/finite-acp/requests.json +24 -0
- package/contracts/finite-acp/responses.json +102 -0
- package/dist/conversation.d.ts +8 -0
- package/dist/conversation.js +155 -0
- package/dist/flow.d.ts +4 -0
- package/dist/flow.js +3393 -0
- package/dist/transport.d.ts +56 -0
- package/dist/transport.js +209 -0
- package/dist/updates.d.ts +16 -0
- package/dist/updates.js +77 -0
- package/justfile +32 -0
- package/licenses/agent-method.LICENSE +373 -0
- package/licenses/flow.LICENSE +202 -0
- package/package.json +35 -5
- package/src/conversation.ts +192 -0
- package/src/flow.ts +495 -0
- package/src/transport.ts +242 -0
- package/src/updates.ts +76 -0
- package/test/flow.test.ts +982 -0
- package/test/pack.test.ts +76 -0
- package/test/transport.test.ts +135 -0
- package/test/updates.test.ts +94 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
OperationError,
|
|
4
|
+
type ChannelPair,
|
|
5
|
+
type ChannelReceiver,
|
|
6
|
+
type ChannelSender,
|
|
7
|
+
type FlowCall,
|
|
8
|
+
type JsonObject,
|
|
9
|
+
type JsonValue,
|
|
10
|
+
type RunContext,
|
|
11
|
+
type RunResult,
|
|
12
|
+
} from '@jigging/flow'
|
|
13
|
+
import { agentAcpFlow } from '../src/flow.js'
|
|
14
|
+
import { FiniteAcpFrames, fragmentFiniteAcpFrame } from '../src/transport.js'
|
|
15
|
+
|
|
16
|
+
const completed: RunResult = {
|
|
17
|
+
outcome: 'done',
|
|
18
|
+
output: { exitCode: 0, signal: null, cleanup: 'complete', stopReason: 'exited' },
|
|
19
|
+
}
|
|
20
|
+
const ready = {
|
|
21
|
+
kind: 'ready',
|
|
22
|
+
protocolVersion: 1,
|
|
23
|
+
cwd: '/work',
|
|
24
|
+
maxTurns: 3,
|
|
25
|
+
configuration: [
|
|
26
|
+
{ configId: 'model', value: 'reviewed-model' },
|
|
27
|
+
{ configId: 'autoCompact', type: 'boolean', value: false },
|
|
28
|
+
],
|
|
29
|
+
modeId: 'read-only',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Public endpoint test double, not an SDK/host/containment implementation. */
|
|
33
|
+
function pair(): ChannelPair & { fail(error: OperationError): void } {
|
|
34
|
+
const items: JsonValue[] = []
|
|
35
|
+
let ended = false
|
|
36
|
+
let failure: OperationError | undefined
|
|
37
|
+
let waiting:
|
|
38
|
+
| {
|
|
39
|
+
resolve(value: IteratorResult<JsonValue>): void
|
|
40
|
+
reject(error: unknown): void
|
|
41
|
+
remove(): void
|
|
42
|
+
}
|
|
43
|
+
| undefined
|
|
44
|
+
const close = async () => {
|
|
45
|
+
ended = true
|
|
46
|
+
if (waiting) {
|
|
47
|
+
const saved = waiting
|
|
48
|
+
waiting = undefined
|
|
49
|
+
saved.remove()
|
|
50
|
+
saved.resolve({ done: true, value: undefined })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const receive: ChannelReceiver = {
|
|
54
|
+
direction: 'receive',
|
|
55
|
+
delivery: 'direct',
|
|
56
|
+
startSequence: 0,
|
|
57
|
+
async next(options) {
|
|
58
|
+
options?.signal?.throwIfAborted()
|
|
59
|
+
if (failure) throw failure
|
|
60
|
+
if (items.length) return { done: false, value: items.shift()! }
|
|
61
|
+
if (ended) return { done: true, value: undefined }
|
|
62
|
+
if (waiting) throw Error('concurrent test read')
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const abort = () => {
|
|
65
|
+
waiting = undefined
|
|
66
|
+
reject(options?.signal?.reason)
|
|
67
|
+
}
|
|
68
|
+
options?.signal?.addEventListener('abort', abort, { once: true })
|
|
69
|
+
waiting = {
|
|
70
|
+
resolve,
|
|
71
|
+
reject,
|
|
72
|
+
remove: () => options?.signal?.removeEventListener('abort', abort),
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
},
|
|
76
|
+
close,
|
|
77
|
+
[Symbol.asyncIterator]() {
|
|
78
|
+
return this
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
const send: ChannelSender = {
|
|
82
|
+
direction: 'send',
|
|
83
|
+
delivery: 'direct',
|
|
84
|
+
async send(value, options) {
|
|
85
|
+
options?.signal?.throwIfAborted()
|
|
86
|
+
if (ended) throw new OperationError('CHANNEL_LOST', 'test peer closed')
|
|
87
|
+
if (waiting) {
|
|
88
|
+
const saved = waiting
|
|
89
|
+
waiting = undefined
|
|
90
|
+
saved.remove()
|
|
91
|
+
saved.resolve({ done: false, value })
|
|
92
|
+
} else items.push(value)
|
|
93
|
+
},
|
|
94
|
+
close,
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
send,
|
|
98
|
+
receive,
|
|
99
|
+
fail(error) {
|
|
100
|
+
failure = error
|
|
101
|
+
items.length = 0
|
|
102
|
+
ended = true
|
|
103
|
+
if (waiting) {
|
|
104
|
+
const saved = waiting
|
|
105
|
+
waiting = undefined
|
|
106
|
+
saved.remove()
|
|
107
|
+
saved.reject(error)
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type Frame = JsonObject
|
|
114
|
+
interface Options {
|
|
115
|
+
readonly ready?: JsonObject
|
|
116
|
+
readonly output?: string
|
|
117
|
+
readonly stop?: string
|
|
118
|
+
readonly result?: RunResult
|
|
119
|
+
readonly input?: JsonValue
|
|
120
|
+
readonly settings?: JsonObject
|
|
121
|
+
readonly events?: ChannelSender
|
|
122
|
+
readonly commands?: ChannelReceiver
|
|
123
|
+
readonly replies?: ChannelSender
|
|
124
|
+
readonly signal?: AbortSignal
|
|
125
|
+
readonly emit?: (frame: Frame, send: ChannelSender) => Promise<boolean>
|
|
126
|
+
readonly disposeFailure?: Error
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function fixture(options: Options = {}) {
|
|
130
|
+
const frames: Frame[] = []
|
|
131
|
+
let calls = 0
|
|
132
|
+
let cancelled = false
|
|
133
|
+
let settled = false
|
|
134
|
+
const channels: ReturnType<typeof pair>[] = []
|
|
135
|
+
const frameSend = async (send: ChannelSender, value: JsonValue) => {
|
|
136
|
+
for (const fragment of fragmentFiniteAcpFrame(JSON.stringify(value)))
|
|
137
|
+
await send.send({ ...fragment })
|
|
138
|
+
}
|
|
139
|
+
const run = {
|
|
140
|
+
input: options.input ?? {
|
|
141
|
+
instructions: 'Answer.',
|
|
142
|
+
guidance: [{ label: 'facts', text: 'Supplied guidance.' }],
|
|
143
|
+
skills: [{ name: 'review', files: [{ path: 'SKILL.md', text: 'Selected Skill.' }] }],
|
|
144
|
+
},
|
|
145
|
+
settings: options.settings ?? {},
|
|
146
|
+
attachments: {},
|
|
147
|
+
channels: {
|
|
148
|
+
...(options.events ? { events: options.events } : {}),
|
|
149
|
+
...(options.commands ? { commands: options.commands } : {}),
|
|
150
|
+
...(options.replies ? { replies: options.replies } : {}),
|
|
151
|
+
},
|
|
152
|
+
scratch: '/scratch',
|
|
153
|
+
deadlineUnixMs: Date.now() + 60_000,
|
|
154
|
+
signal: options.signal ?? new AbortController().signal,
|
|
155
|
+
async channel(settings: { contract: string }) {
|
|
156
|
+
expect(settings.contract).toBe(
|
|
157
|
+
channels.length === 0
|
|
158
|
+
? './contracts/finite-acp/requests.json'
|
|
159
|
+
: './contracts/finite-acp/responses.json',
|
|
160
|
+
)
|
|
161
|
+
const value = pair()
|
|
162
|
+
channels.push(value)
|
|
163
|
+
if (options.disposeFailure && channels.length === 2)
|
|
164
|
+
value.receive.close = async () => {
|
|
165
|
+
throw options.disposeFailure
|
|
166
|
+
}
|
|
167
|
+
return value
|
|
168
|
+
},
|
|
169
|
+
async call(call: FlowCall, settings: { signal: AbortSignal }) {
|
|
170
|
+
calls++
|
|
171
|
+
const session = (run.input as JsonObject).session
|
|
172
|
+
expect(call).toMatchObject({
|
|
173
|
+
operationId: 'native',
|
|
174
|
+
slot: 'native',
|
|
175
|
+
input: session === undefined ? null : { session },
|
|
176
|
+
})
|
|
177
|
+
expect(call.channels?.requests).toBe(channels[0]!.receive)
|
|
178
|
+
expect(call.channels?.responses).toBe(channels[1]!.send)
|
|
179
|
+
const receive = call.channels!.requests as ChannelReceiver
|
|
180
|
+
const send = call.channels!.responses as ChannelSender
|
|
181
|
+
const fragments = new FiniteAcpFrames('requests')
|
|
182
|
+
settings.signal.addEventListener(
|
|
183
|
+
'abort',
|
|
184
|
+
() => {
|
|
185
|
+
cancelled = true
|
|
186
|
+
},
|
|
187
|
+
{ once: true },
|
|
188
|
+
)
|
|
189
|
+
try {
|
|
190
|
+
await send.send(options.ready ?? ready)
|
|
191
|
+
for (;;) {
|
|
192
|
+
const item = await receive.next({ signal: settings.signal })
|
|
193
|
+
if (item.done) {
|
|
194
|
+
fragments.finish()
|
|
195
|
+
break
|
|
196
|
+
}
|
|
197
|
+
const data = fragments.accept(item.value)
|
|
198
|
+
if (data === undefined) continue
|
|
199
|
+
const frame = JSON.parse(data) as Frame
|
|
200
|
+
frames.push(frame)
|
|
201
|
+
if (await options.emit?.(frame, send)) continue
|
|
202
|
+
let result: JsonValue = {}
|
|
203
|
+
switch (frame.method) {
|
|
204
|
+
case 'initialize':
|
|
205
|
+
result = {
|
|
206
|
+
protocolVersion: 1,
|
|
207
|
+
agentCapabilities: { sessionCapabilities: { close: {}, resume: {} } },
|
|
208
|
+
}
|
|
209
|
+
break
|
|
210
|
+
case 'session/new':
|
|
211
|
+
result = { sessionId: 'owned-session' }
|
|
212
|
+
break
|
|
213
|
+
case 'session/set_config_option': {
|
|
214
|
+
const params = frame.params as JsonObject
|
|
215
|
+
result = { configOptions: [{ id: params.configId!, currentValue: params.value! }] }
|
|
216
|
+
break
|
|
217
|
+
}
|
|
218
|
+
case 'session/prompt':
|
|
219
|
+
await frameSend(send, {
|
|
220
|
+
jsonrpc: '2.0',
|
|
221
|
+
method: 'session/update',
|
|
222
|
+
params: {
|
|
223
|
+
sessionId: (frame.params as JsonObject).sessionId!,
|
|
224
|
+
update: {
|
|
225
|
+
sessionUpdate: 'agent_message_chunk',
|
|
226
|
+
content: { type: 'text', text: options.output ?? 'The answer.' },
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
result = { stopReason: options.stop ?? 'end_turn' }
|
|
231
|
+
break
|
|
232
|
+
}
|
|
233
|
+
await frameSend(send, { jsonrpc: '2.0', id: frame.id!, result })
|
|
234
|
+
}
|
|
235
|
+
return options.result ?? completed
|
|
236
|
+
} finally {
|
|
237
|
+
settled = true
|
|
238
|
+
await send.close()
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
} as unknown as RunContext
|
|
242
|
+
return {
|
|
243
|
+
run,
|
|
244
|
+
frames,
|
|
245
|
+
stats: () => ({ calls, cancelled, settled }),
|
|
246
|
+
frameSend,
|
|
247
|
+
failResponses: (error: OperationError) => channels[1]!.fail(error),
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
describe('ordinary session retention and restoration', () => {
|
|
252
|
+
test.each(['active', 'settled'])(
|
|
253
|
+
'notice display during %s does not enter answers or selected public events',
|
|
254
|
+
async (phase) => {
|
|
255
|
+
const warning = spyOn(console, 'warn').mockImplementation(() => {})
|
|
256
|
+
const updates: JsonValue[] = []
|
|
257
|
+
const f = fixture({
|
|
258
|
+
events: {
|
|
259
|
+
direction: 'send',
|
|
260
|
+
delivery: 'direct',
|
|
261
|
+
send: async (value) => {
|
|
262
|
+
updates.push(value)
|
|
263
|
+
},
|
|
264
|
+
close: async () => {},
|
|
265
|
+
},
|
|
266
|
+
async emit(frame, send) {
|
|
267
|
+
if (frame.method === 'session/prompt') {
|
|
268
|
+
if (phase === 'settled') {
|
|
269
|
+
await f.frameSend(send, {
|
|
270
|
+
jsonrpc: '2.0',
|
|
271
|
+
method: 'session/update',
|
|
272
|
+
params: {
|
|
273
|
+
sessionId: 'owned-session',
|
|
274
|
+
update: {
|
|
275
|
+
sessionUpdate: 'agent_message_chunk',
|
|
276
|
+
content: { type: 'text', text: 'The answer.' },
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
await f.frameSend(send, {
|
|
281
|
+
jsonrpc: '2.0',
|
|
282
|
+
id: frame.id!,
|
|
283
|
+
result: { stopReason: 'end_turn' },
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
await f.frameSend(send, {
|
|
287
|
+
jsonrpc: '2.0',
|
|
288
|
+
method: 'session/update',
|
|
289
|
+
params: {
|
|
290
|
+
sessionId: 'owned-session',
|
|
291
|
+
update: {
|
|
292
|
+
sessionUpdate: 'session_info_update',
|
|
293
|
+
_meta: { notice: { code: 'NATIVE_WARNING' } },
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
return phase === 'settled'
|
|
298
|
+
}
|
|
299
|
+
return false
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
try {
|
|
303
|
+
expect(await agentAcpFlow(f.run)).toEqual({
|
|
304
|
+
outcome: 'done',
|
|
305
|
+
output: { text: 'The answer.' },
|
|
306
|
+
})
|
|
307
|
+
expect(warning).toHaveBeenCalledTimes(1)
|
|
308
|
+
expect(JSON.stringify(updates)).not.toContain('NATIVE_WARNING')
|
|
309
|
+
expect(f.stats().settled).toBe(true)
|
|
310
|
+
} finally {
|
|
311
|
+
warning.mockRestore()
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
const reference = '013579ab-cdef-4567-89ab-0123456789ab'
|
|
316
|
+
const retained = { status: 'retained', reference }
|
|
317
|
+
const result = (session: JsonValue, stopReason = 'exited'): RunResult => ({
|
|
318
|
+
outcome: 'done',
|
|
319
|
+
output: { ...(completed.output as JsonObject), stopReason, session },
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('relays requested retention after resource settlement without changing the answer', async () => {
|
|
323
|
+
for (const [session, stop] of [
|
|
324
|
+
[retained, 'exited'],
|
|
325
|
+
[{ status: 'unavailable', reason: 'not-cleanly-closed' }, 'closed'],
|
|
326
|
+
] as const) {
|
|
327
|
+
const f = fixture({
|
|
328
|
+
input: { instructions: 'Answer.', session: { retain: true } },
|
|
329
|
+
result: result(session, stop),
|
|
330
|
+
})
|
|
331
|
+
expect(await agentAcpFlow(f.run)).toEqual({
|
|
332
|
+
outcome: 'done',
|
|
333
|
+
output: { text: 'The answer.', session },
|
|
334
|
+
})
|
|
335
|
+
expect(f.stats().settled).toBe(true)
|
|
336
|
+
expect(f.frames.map((frame) => frame.method)).not.toContain('session/resume')
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('resumes only the host-owned identity and reapplies every reviewed configuration before prompting', async () => {
|
|
341
|
+
const f = fixture({
|
|
342
|
+
input: { instructions: 'Continue.', session: { restore: reference } },
|
|
343
|
+
ready: { ...ready, restoreSessionId: 'restored-native-session' },
|
|
344
|
+
result: result(retained),
|
|
345
|
+
})
|
|
346
|
+
expect(await agentAcpFlow(f.run)).toEqual({
|
|
347
|
+
outcome: 'done',
|
|
348
|
+
output: { text: 'The answer.', session: retained },
|
|
349
|
+
})
|
|
350
|
+
expect(f.frames.map((frame) => frame.method)).toEqual([
|
|
351
|
+
'initialize',
|
|
352
|
+
'session/resume',
|
|
353
|
+
'session/set_config_option',
|
|
354
|
+
'session/set_config_option',
|
|
355
|
+
'session/set_mode',
|
|
356
|
+
'session/prompt',
|
|
357
|
+
])
|
|
358
|
+
expect(f.frames[1]!.params).toEqual({
|
|
359
|
+
sessionId: 'restored-native-session',
|
|
360
|
+
cwd: '/work',
|
|
361
|
+
mcpServers: [],
|
|
362
|
+
})
|
|
363
|
+
for (const frame of f.frames.slice(2))
|
|
364
|
+
expect(frame.params).toMatchObject({ sessionId: 'restored-native-session' })
|
|
365
|
+
expect(JSON.stringify(f.frames)).not.toContain(reference)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
test('rejects mismatched ready metadata and missing resume capability without a fresh-session fallback', async () => {
|
|
369
|
+
for (const mode of ['missing-id', 'unexpected-id', 'unsupported', 'new-id']) {
|
|
370
|
+
const f = fixture({
|
|
371
|
+
input: {
|
|
372
|
+
instructions: 'Answer.',
|
|
373
|
+
...(mode === 'unexpected-id' ? {} : { session: { restore: reference } }),
|
|
374
|
+
},
|
|
375
|
+
ready: mode === 'missing-id' ? ready : { ...ready, restoreSessionId: 'owned-session' },
|
|
376
|
+
async emit(frame, send) {
|
|
377
|
+
if (mode === 'unsupported' && frame.method === 'initialize') {
|
|
378
|
+
await f.frameSend(send, {
|
|
379
|
+
jsonrpc: '2.0',
|
|
380
|
+
id: frame.id!,
|
|
381
|
+
result: { protocolVersion: 1, agentCapabilities: { sessionCapabilities: {} } },
|
|
382
|
+
})
|
|
383
|
+
return true
|
|
384
|
+
}
|
|
385
|
+
if (mode === 'new-id' && frame.method === 'session/resume') {
|
|
386
|
+
await f.frameSend(send, {
|
|
387
|
+
jsonrpc: '2.0',
|
|
388
|
+
id: frame.id!,
|
|
389
|
+
result: { sessionId: 'replacement' },
|
|
390
|
+
})
|
|
391
|
+
return true
|
|
392
|
+
}
|
|
393
|
+
return false
|
|
394
|
+
},
|
|
395
|
+
})
|
|
396
|
+
await expect(agentAcpFlow(f.run)).rejects.toMatchObject({ code: 'INVALID_RESULT' })
|
|
397
|
+
expect(
|
|
398
|
+
f.frames.some((frame) =>
|
|
399
|
+
['session/new', 'session/prompt'].includes(frame.method as string),
|
|
400
|
+
),
|
|
401
|
+
).toBe(false)
|
|
402
|
+
expect(f.stats().settled).toBe(true)
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
test('rejects missing, unsolicited, malformed or unclean retained receipts', async () => {
|
|
407
|
+
for (const native of [
|
|
408
|
+
completed,
|
|
409
|
+
result(null),
|
|
410
|
+
result({ status: 'retained' }),
|
|
411
|
+
result({ status: 'retained', reference: 'private/path' }),
|
|
412
|
+
result({ status: 'retained', reference: `${reference}\n` }),
|
|
413
|
+
result({ status: 'unavailable', reference }),
|
|
414
|
+
result({ status: 'unavailable' }),
|
|
415
|
+
result({ status: 'unavailable', reason: 'unknown' }),
|
|
416
|
+
result(retained, 'closed'),
|
|
417
|
+
]) {
|
|
418
|
+
const f = fixture({
|
|
419
|
+
input: { instructions: 'Answer.', session: { retain: true } },
|
|
420
|
+
result: native,
|
|
421
|
+
})
|
|
422
|
+
await expect(agentAcpFlow(f.run)).rejects.toMatchObject({ code: 'INVALID_RESULT' })
|
|
423
|
+
expect(f.stats().settled).toBe(true)
|
|
424
|
+
}
|
|
425
|
+
const unsolicited = fixture({ result: result(retained) })
|
|
426
|
+
await expect(agentAcpFlow(unsolicited.run)).rejects.toMatchObject({ code: 'INVALID_RESULT' })
|
|
427
|
+
const malformed = fixture({ input: { instructions: 'Answer.', session: { retain: false } } })
|
|
428
|
+
await expect(agentAcpFlow(malformed.run)).rejects.toMatchObject({ code: 'INVALID_INPUT' })
|
|
429
|
+
expect(malformed.stats().calls).toBe(0)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('returns the receipt only after conversation close and rejects mid-conversation session intent', async () => {
|
|
433
|
+
const commands = pair(),
|
|
434
|
+
replies = pair()
|
|
435
|
+
const f = fixture({
|
|
436
|
+
input: { instructions: 'Answer.', conversation: true, session: { retain: true } },
|
|
437
|
+
commands: commands.receive,
|
|
438
|
+
replies: replies.send,
|
|
439
|
+
result: result(retained),
|
|
440
|
+
})
|
|
441
|
+
const work = agentAcpFlow(f.run)
|
|
442
|
+
expect((await replies.receive.next()).value).toEqual({
|
|
443
|
+
type: 'result',
|
|
444
|
+
turn: 0,
|
|
445
|
+
result: { outcome: 'done', output: { text: 'The answer.' } },
|
|
446
|
+
})
|
|
447
|
+
await commands.send.send({
|
|
448
|
+
type: 'prompt',
|
|
449
|
+
turn: 1,
|
|
450
|
+
input: { instructions: 'Continue.', session: { retain: true } },
|
|
451
|
+
})
|
|
452
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
453
|
+
type: 'rejected',
|
|
454
|
+
command: 'prompt',
|
|
455
|
+
code: 'INVALID_INPUT',
|
|
456
|
+
})
|
|
457
|
+
await commands.send.send({ type: 'close', turn: 0 })
|
|
458
|
+
await commands.send.close()
|
|
459
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
460
|
+
type: 'accepted',
|
|
461
|
+
command: 'close',
|
|
462
|
+
})
|
|
463
|
+
expect(await work).toEqual({ outcome: 'done', output: { turns: 1, session: retained } })
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
describe('ordinary continuing Agent', () => {
|
|
468
|
+
test('continues, rejects stale control, interrupts and settles before another turn', async () => {
|
|
469
|
+
const commands = pair(),
|
|
470
|
+
replies = pair(),
|
|
471
|
+
events = pair()
|
|
472
|
+
let prompts = 0
|
|
473
|
+
let held: JsonValue = null
|
|
474
|
+
const f = fixture({
|
|
475
|
+
input: { instructions: 'Initial context.', conversation: true },
|
|
476
|
+
commands: commands.receive,
|
|
477
|
+
replies: replies.send,
|
|
478
|
+
events: events.send,
|
|
479
|
+
async emit(frame, send) {
|
|
480
|
+
if (frame.method === 'session/prompt' && ++prompts === 2) {
|
|
481
|
+
held = frame.id!
|
|
482
|
+
return true
|
|
483
|
+
}
|
|
484
|
+
if (frame.method === 'session/cancel') {
|
|
485
|
+
await f.frameSend(send, { jsonrpc: '2.0', id: held, result: { stopReason: 'cancelled' } })
|
|
486
|
+
return true
|
|
487
|
+
}
|
|
488
|
+
return false
|
|
489
|
+
},
|
|
490
|
+
})
|
|
491
|
+
const work = agentAcpFlow(f.run)
|
|
492
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
493
|
+
type: 'result',
|
|
494
|
+
turn: 0,
|
|
495
|
+
result: { outcome: 'done' },
|
|
496
|
+
})
|
|
497
|
+
expect((await events.receive.next()).value).toMatchObject({ turn: 0 })
|
|
498
|
+
await commands.send.send({ type: 'prompt', turn: 1, input: { instructions: 'Continue.' } })
|
|
499
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
500
|
+
type: 'accepted',
|
|
501
|
+
command: 'prompt',
|
|
502
|
+
turn: 1,
|
|
503
|
+
})
|
|
504
|
+
await commands.send.send({ type: 'interrupt', turn: 0 })
|
|
505
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
506
|
+
type: 'rejected',
|
|
507
|
+
code: 'STALE_TURN',
|
|
508
|
+
})
|
|
509
|
+
await commands.send.send({ type: 'prompt', turn: 2, input: { instructions: 'Too early.' } })
|
|
510
|
+
expect((await replies.receive.next()).value).toMatchObject({ type: 'rejected', code: 'BUSY' })
|
|
511
|
+
await commands.send.send({ type: 'interrupt', turn: 1 })
|
|
512
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
513
|
+
type: 'accepted',
|
|
514
|
+
command: 'interrupt',
|
|
515
|
+
})
|
|
516
|
+
expect((await replies.receive.next()).value).toEqual({ type: 'cancelled', turn: 1 })
|
|
517
|
+
await commands.send.send({ type: 'prompt', turn: 2, input: { instructions: 'Now continue.' } })
|
|
518
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
519
|
+
type: 'accepted',
|
|
520
|
+
command: 'prompt',
|
|
521
|
+
})
|
|
522
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
523
|
+
type: 'result',
|
|
524
|
+
turn: 2,
|
|
525
|
+
result: { output: { text: 'The answer.' } },
|
|
526
|
+
})
|
|
527
|
+
await commands.send.send({
|
|
528
|
+
type: 'prompt',
|
|
529
|
+
turn: 3,
|
|
530
|
+
input: { instructions: 'Beyond allowance.' },
|
|
531
|
+
})
|
|
532
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
533
|
+
type: 'rejected',
|
|
534
|
+
code: 'TURN_LIMIT',
|
|
535
|
+
})
|
|
536
|
+
await commands.send.send({ type: 'close', turn: 2 })
|
|
537
|
+
await commands.send.close()
|
|
538
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
539
|
+
type: 'accepted',
|
|
540
|
+
command: 'close',
|
|
541
|
+
})
|
|
542
|
+
expect(await work).toEqual({ outcome: 'done', output: { turns: 3 } })
|
|
543
|
+
expect(f.frames.filter((frame) => frame.method === 'session/new')).toHaveLength(1)
|
|
544
|
+
expect(f.frames.filter((frame) => frame.method === 'session/prompt')).toHaveLength(3)
|
|
545
|
+
expect(f.stats().settled).toBe(true)
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
test('losing control is not a clean conversation end', async () => {
|
|
549
|
+
const commands = pair(),
|
|
550
|
+
replies = pair()
|
|
551
|
+
const f = fixture({
|
|
552
|
+
input: { instructions: 'Answer.', conversation: true },
|
|
553
|
+
commands: commands.receive,
|
|
554
|
+
replies: replies.send,
|
|
555
|
+
})
|
|
556
|
+
const work = agentAcpFlow(f.run).then(
|
|
557
|
+
() => undefined,
|
|
558
|
+
(error) => error,
|
|
559
|
+
)
|
|
560
|
+
await replies.receive.next()
|
|
561
|
+
await commands.send.close()
|
|
562
|
+
expect(await work).toMatchObject({ code: 'DISCONNECTED' })
|
|
563
|
+
expect(f.stats()).toMatchObject({ cancelled: true, settled: true })
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test('rejects incomplete conversational wiring before native dispatch', async () => {
|
|
567
|
+
const f = fixture({
|
|
568
|
+
input: { instructions: 'Answer.', conversation: true },
|
|
569
|
+
commands: pair().receive,
|
|
570
|
+
})
|
|
571
|
+
await expect(agentAcpFlow(f.run)).rejects.toMatchObject({ code: 'INVALID_INPUT' })
|
|
572
|
+
expect(f.stats().calls).toBe(0)
|
|
573
|
+
})
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
/** Match the SDK's immediate caller-wait cancellation, not resource cleanup. */
|
|
577
|
+
function cancellableWait(run: RunContext): RunContext {
|
|
578
|
+
return {
|
|
579
|
+
...run,
|
|
580
|
+
call(call, options) {
|
|
581
|
+
return new Promise((resolve, reject) => {
|
|
582
|
+
const cancel = () => reject(new OperationError('CANCELLED', 'caller wait cancelled'))
|
|
583
|
+
options?.signal?.addEventListener('abort', cancel, { once: true })
|
|
584
|
+
void run
|
|
585
|
+
.call(call, options)
|
|
586
|
+
.then(resolve, reject)
|
|
587
|
+
.finally(() => {
|
|
588
|
+
options?.signal?.removeEventListener('abort', cancel)
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
},
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
describe('ordinary finite ACP Agent Flow', () => {
|
|
596
|
+
test('retains a full 8 MiB answer through bounded text fragments', async () => {
|
|
597
|
+
const output = '😀'.repeat(2_097_152)
|
|
598
|
+
const consumer = fixture({ output })
|
|
599
|
+
expect((await agentAcpFlow(consumer.run)).output).toEqual({ text: output })
|
|
600
|
+
expect(consumer.stats()).toEqual({ calls: 1, cancelled: false, settled: true })
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
test('a wrong reply identity or private update fails essential transport without replay', async () => {
|
|
604
|
+
for (const variant of ['id', 'session', 'private', 'partial']) {
|
|
605
|
+
const consumer = fixture({
|
|
606
|
+
emit: async (frame, send) => {
|
|
607
|
+
if (frame.method !== 'session/prompt') return false
|
|
608
|
+
if (variant === 'partial') {
|
|
609
|
+
await send.send({ kind: 'data', text: '{', end: false })
|
|
610
|
+
await send.close()
|
|
611
|
+
} else {
|
|
612
|
+
const value =
|
|
613
|
+
variant === 'id'
|
|
614
|
+
? { jsonrpc: '2.0', id: 999, result: { stopReason: 'end_turn' } }
|
|
615
|
+
: {
|
|
616
|
+
jsonrpc: '2.0',
|
|
617
|
+
method: 'session/update',
|
|
618
|
+
params: {
|
|
619
|
+
sessionId: variant === 'session' ? 'foreign' : 'owned-session',
|
|
620
|
+
update: {
|
|
621
|
+
sessionUpdate:
|
|
622
|
+
variant === 'private' ? 'agent_thought_chunk' : 'agent_message_chunk',
|
|
623
|
+
content: { type: 'text', text: 'not eligible' },
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
}
|
|
627
|
+
for (const fragment of fragmentFiniteAcpFrame(JSON.stringify(value)))
|
|
628
|
+
await send.send({ ...fragment })
|
|
629
|
+
}
|
|
630
|
+
return true
|
|
631
|
+
},
|
|
632
|
+
})
|
|
633
|
+
await expect(agentAcpFlow(consumer.run)).rejects.toThrow()
|
|
634
|
+
expect(consumer.stats()).toEqual({ calls: 1, cancelled: true, settled: true })
|
|
635
|
+
}
|
|
636
|
+
})
|
|
637
|
+
test('prepares selected context, uses reviewed configuration, and independently checks a structured result', async () => {
|
|
638
|
+
const responseSchema = {
|
|
639
|
+
$schema: 'https://flow.jig.md/schemas/schema-0.json',
|
|
640
|
+
type: 'object',
|
|
641
|
+
properties: { answer: { type: 'string' } },
|
|
642
|
+
required: ['answer'],
|
|
643
|
+
additionalProperties: false,
|
|
644
|
+
}
|
|
645
|
+
const observed: JsonValue[] = []
|
|
646
|
+
const events: ChannelSender = {
|
|
647
|
+
direction: 'send',
|
|
648
|
+
delivery: 'direct',
|
|
649
|
+
async send(value) {
|
|
650
|
+
observed.push(value)
|
|
651
|
+
},
|
|
652
|
+
async close() {},
|
|
653
|
+
}
|
|
654
|
+
const consumer = fixture({
|
|
655
|
+
output: '{"answer":"checked"}',
|
|
656
|
+
input: {
|
|
657
|
+
instructions: 'Answer.',
|
|
658
|
+
responseSchema,
|
|
659
|
+
skills: [{ name: 'review', files: [{ path: 'SKILL.md', text: 'Selected Skill.' }] }],
|
|
660
|
+
},
|
|
661
|
+
events,
|
|
662
|
+
})
|
|
663
|
+
expect(await agentAcpFlow(consumer.run)).toEqual({
|
|
664
|
+
outcome: 'done',
|
|
665
|
+
output: { text: '{"answer":"checked"}', structured: { answer: 'checked' } },
|
|
666
|
+
})
|
|
667
|
+
expect(consumer.frames.map((f) => f.method)).toEqual([
|
|
668
|
+
'initialize',
|
|
669
|
+
'session/new',
|
|
670
|
+
'session/set_config_option',
|
|
671
|
+
'session/set_config_option',
|
|
672
|
+
'session/set_mode',
|
|
673
|
+
'session/prompt',
|
|
674
|
+
])
|
|
675
|
+
const prompt = JSON.stringify(consumer.frames.find((f) => f.method === 'session/prompt'))
|
|
676
|
+
expect(prompt).toContain('Selected Skill.')
|
|
677
|
+
expect(prompt).toContain('matching this canonical FLOW Schema/0')
|
|
678
|
+
expect(consumer.frames[2]!.params).toEqual({
|
|
679
|
+
sessionId: 'owned-session',
|
|
680
|
+
configId: 'model',
|
|
681
|
+
value: 'reviewed-model',
|
|
682
|
+
})
|
|
683
|
+
expect(consumer.frames[3]!.params).toEqual({
|
|
684
|
+
sessionId: 'owned-session',
|
|
685
|
+
configId: 'autoCompact',
|
|
686
|
+
type: 'boolean',
|
|
687
|
+
value: false,
|
|
688
|
+
})
|
|
689
|
+
expect(observed).toHaveLength(1)
|
|
690
|
+
expect(consumer.stats()).toEqual({ calls: 1, cancelled: false, settled: true })
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
test('accepts host-collected controlled closure without manufacturing exit zero', async () => {
|
|
694
|
+
const consumer = fixture({
|
|
695
|
+
result: {
|
|
696
|
+
outcome: 'done',
|
|
697
|
+
output: { exitCode: null, signal: 'SIGTERM', cleanup: 'complete', stopReason: 'closed' },
|
|
698
|
+
},
|
|
699
|
+
})
|
|
700
|
+
expect((await agentAcpFlow(consumer.run)).outcome).toBe('done')
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
test('refusal and limits stay honest method outcomes; cancellation is not a method limit', async () => {
|
|
704
|
+
for (const [stop, outcome] of [
|
|
705
|
+
['refusal', 'blocked'],
|
|
706
|
+
['max_tokens', 'limit'],
|
|
707
|
+
['max_turn_requests', 'limit'],
|
|
708
|
+
]) {
|
|
709
|
+
const consumer = fixture({ stop })
|
|
710
|
+
expect((await agentAcpFlow(consumer.run)).outcome).toBe(outcome)
|
|
711
|
+
}
|
|
712
|
+
const consumer = fixture({ stop: 'cancelled' })
|
|
713
|
+
await expect(agentAcpFlow(consumer.run)).rejects.toThrow()
|
|
714
|
+
expect(consumer.stats()).toEqual({ calls: 1, cancelled: true, settled: true })
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
test('an ACP stop and EOF cannot replace successful process evidence', async () => {
|
|
718
|
+
for (const result of [
|
|
719
|
+
{
|
|
720
|
+
outcome: 'done',
|
|
721
|
+
output: { exitCode: 2, signal: null, cleanup: 'complete', stopReason: 'exited' },
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
outcome: 'done',
|
|
725
|
+
output: { exitCode: 0, signal: null, cleanup: 'unknown', stopReason: 'exited' },
|
|
726
|
+
},
|
|
727
|
+
{ outcome: 'blocked', output: {} },
|
|
728
|
+
]) {
|
|
729
|
+
const consumer = fixture({ result })
|
|
730
|
+
await expect(agentAcpFlow(consumer.run)).rejects.toThrow()
|
|
731
|
+
expect(consumer.stats().settled).toBe(true)
|
|
732
|
+
}
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
test('rejects settings and malformed instructions before resource dispatch', async () => {
|
|
736
|
+
for (const options of [
|
|
737
|
+
{ settings: { model: 'unreviewed' } },
|
|
738
|
+
{ input: { instructions: 'Answer.', extra: 'unexpected' } },
|
|
739
|
+
{ input: { instructions: 42 } },
|
|
740
|
+
]) {
|
|
741
|
+
const consumer = fixture(options)
|
|
742
|
+
await expect(agentAcpFlow(consumer.run)).rejects.toThrow()
|
|
743
|
+
expect(consumer.stats().calls).toBe(0)
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
test('essential malformed framing cancels and waits for the resource, with no retry', async () => {
|
|
748
|
+
const consumer = fixture({
|
|
749
|
+
emit: async (frame, send) => {
|
|
750
|
+
if (frame.method !== 'session/prompt') return false
|
|
751
|
+
await send.send({ kind: 'data', text: '{', end: true })
|
|
752
|
+
return true
|
|
753
|
+
},
|
|
754
|
+
})
|
|
755
|
+
await expect(agentAcpFlow(cancellableWait(consumer.run))).rejects.toMatchObject({
|
|
756
|
+
code: 'INVALID_RESULT',
|
|
757
|
+
})
|
|
758
|
+
expect(consumer.stats()).toEqual({ calls: 1, cancelled: true, settled: true })
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
test('terminal channel loss waits for the delayed resource failure without cancelling its result', async () => {
|
|
762
|
+
for (const code of ['DISCONNECTED', 'LAGGED'] as const) {
|
|
763
|
+
const failed = Promise.withResolvers<void>()
|
|
764
|
+
const release = Promise.withResolvers<void>()
|
|
765
|
+
const uncertain = new OperationError('UNCERTAIN', 'native dispatch did not settle normally')
|
|
766
|
+
const consumer = fixture({
|
|
767
|
+
emit: async (frame) => {
|
|
768
|
+
if (frame.method !== 'session/prompt') return false
|
|
769
|
+
consumer.failResponses(new OperationError(code))
|
|
770
|
+
failed.resolve()
|
|
771
|
+
await release.promise
|
|
772
|
+
throw uncertain
|
|
773
|
+
},
|
|
774
|
+
})
|
|
775
|
+
let returned = false
|
|
776
|
+
const outcome = agentAcpFlow(cancellableWait(consumer.run))
|
|
777
|
+
.then(
|
|
778
|
+
(result) => ({ result }),
|
|
779
|
+
(error: unknown) => ({ error }),
|
|
780
|
+
)
|
|
781
|
+
.finally(() => {
|
|
782
|
+
returned = true
|
|
783
|
+
})
|
|
784
|
+
await failed.promise
|
|
785
|
+
// Let the consumer handle the already-delivered channel failure while
|
|
786
|
+
// independently owned cleanup is deliberately held at its final barrier.
|
|
787
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
788
|
+
const before = { ...consumer.stats(), returned }
|
|
789
|
+
release.resolve()
|
|
790
|
+
expect(await outcome).toEqual({ error: uncertain })
|
|
791
|
+
expect(before).toEqual({ calls: 1, cancelled: false, settled: false, returned: false })
|
|
792
|
+
expect(consumer.stats().settled).toBe(true)
|
|
793
|
+
}
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
test('root cancellation still interrupts a failed-channel settlement wait', async () => {
|
|
797
|
+
const controller = new AbortController()
|
|
798
|
+
const failed = Promise.withResolvers<void>()
|
|
799
|
+
const release = Promise.withResolvers<void>()
|
|
800
|
+
const consumer = fixture({
|
|
801
|
+
signal: controller.signal,
|
|
802
|
+
emit: async (frame) => {
|
|
803
|
+
if (frame.method !== 'session/prompt') return false
|
|
804
|
+
consumer.failResponses(new OperationError('DISCONNECTED'))
|
|
805
|
+
failed.resolve()
|
|
806
|
+
await release.promise
|
|
807
|
+
throw new OperationError('UNCERTAIN')
|
|
808
|
+
},
|
|
809
|
+
})
|
|
810
|
+
const cancellation = new OperationError('CANCELLED', 'operator cancelled during settlement')
|
|
811
|
+
const outcome = agentAcpFlow(cancellableWait(consumer.run)).then(
|
|
812
|
+
(result) => ({ result }),
|
|
813
|
+
(error: unknown) => ({ error }),
|
|
814
|
+
)
|
|
815
|
+
await failed.promise
|
|
816
|
+
controller.abort(cancellation)
|
|
817
|
+
release.resolve()
|
|
818
|
+
expect(await outcome).toEqual({ error: cancellation })
|
|
819
|
+
expect(consumer.stats()).toEqual({ calls: 1, cancelled: true, settled: true })
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
test('resource cancellation settles while an Agent reply is pending', async () => {
|
|
823
|
+
const controller = new AbortController()
|
|
824
|
+
const consumer = fixture({
|
|
825
|
+
signal: controller.signal,
|
|
826
|
+
emit: async (frame) => {
|
|
827
|
+
if (frame.method !== 'session/prompt') return false
|
|
828
|
+
controller.abort(new OperationError('CANCELLED', 'operator cancelled'))
|
|
829
|
+
return true
|
|
830
|
+
},
|
|
831
|
+
})
|
|
832
|
+
await expect(agentAcpFlow(consumer.run)).rejects.toThrow('operator cancelled')
|
|
833
|
+
expect(consumer.stats().settled).toBe(true)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
test('a late disposal failure remains visible after otherwise successful execution', async () => {
|
|
837
|
+
const error = new OperationError('LAGGED', 'late essential loss')
|
|
838
|
+
const consumer = fixture({ disposeFailure: error })
|
|
839
|
+
await expect(agentAcpFlow(consumer.run)).rejects.toBe(error)
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
test('a settled invalid structured answer permits an explicit follow-up', async () => {
|
|
843
|
+
const commands = pair(),
|
|
844
|
+
replies = pair()
|
|
845
|
+
const consumer = fixture({
|
|
846
|
+
input: {
|
|
847
|
+
instructions: 'Answer.',
|
|
848
|
+
conversation: true,
|
|
849
|
+
responseSchema: {
|
|
850
|
+
$schema: 'https://flow.jig.md/schemas/schema-0.json',
|
|
851
|
+
type: 'object',
|
|
852
|
+
properties: { accepted: { type: 'string' } },
|
|
853
|
+
required: ['accepted'],
|
|
854
|
+
additionalProperties: false,
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
commands: commands.receive,
|
|
858
|
+
replies: replies.send,
|
|
859
|
+
})
|
|
860
|
+
const work = agentAcpFlow(consumer.run)
|
|
861
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
862
|
+
type: 'error',
|
|
863
|
+
turn: 0,
|
|
864
|
+
code: 'INVALID_RESULT',
|
|
865
|
+
})
|
|
866
|
+
await commands.send.send({
|
|
867
|
+
type: 'prompt',
|
|
868
|
+
turn: 1,
|
|
869
|
+
input: { instructions: 'Answer in plain text.' },
|
|
870
|
+
})
|
|
871
|
+
expect((await replies.receive.next()).value).toEqual({
|
|
872
|
+
type: 'accepted',
|
|
873
|
+
command: 'prompt',
|
|
874
|
+
turn: 1,
|
|
875
|
+
})
|
|
876
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
877
|
+
type: 'result',
|
|
878
|
+
turn: 1,
|
|
879
|
+
result: { outcome: 'done' },
|
|
880
|
+
})
|
|
881
|
+
await commands.send.send({ type: 'close', turn: 1 })
|
|
882
|
+
expect((await replies.receive.next()).value).toEqual({
|
|
883
|
+
type: 'accepted',
|
|
884
|
+
command: 'close',
|
|
885
|
+
turn: 1,
|
|
886
|
+
})
|
|
887
|
+
await commands.send.close()
|
|
888
|
+
expect(await work).toEqual({ outcome: 'done', output: { turns: 2 } })
|
|
889
|
+
expect(consumer.stats()).toEqual({ calls: 1, cancelled: false, settled: true })
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
test('an oversized essential reply fails rather than truncating a successful answer', async () => {
|
|
893
|
+
const commands = pair(),
|
|
894
|
+
replies = pair()
|
|
895
|
+
const consumer = fixture({
|
|
896
|
+
input: { instructions: 'Answer.', conversation: true },
|
|
897
|
+
output: 'x'.repeat(65_536),
|
|
898
|
+
commands: commands.receive,
|
|
899
|
+
replies: replies.send,
|
|
900
|
+
})
|
|
901
|
+
await expect(agentAcpFlow(consumer.run)).rejects.toMatchObject({ code: 'RESOURCE_EXHAUSTED' })
|
|
902
|
+
expect(consumer.stats()).toEqual({ calls: 1, cancelled: true, settled: true })
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
test('accepted close requires bounded command-stream settlement', async () => {
|
|
906
|
+
const commands = pair(),
|
|
907
|
+
replies = pair()
|
|
908
|
+
const consumer = fixture({
|
|
909
|
+
input: { instructions: 'Answer.', conversation: true },
|
|
910
|
+
commands: commands.receive,
|
|
911
|
+
replies: replies.send,
|
|
912
|
+
})
|
|
913
|
+
const work = agentAcpFlow(consumer.run).catch((error) => error)
|
|
914
|
+
expect((await replies.receive.next()).value).toMatchObject({ type: 'result', turn: 0 })
|
|
915
|
+
await commands.send.send({ type: 'close', turn: 0 })
|
|
916
|
+
expect((await replies.receive.next()).value).toMatchObject({
|
|
917
|
+
type: 'accepted',
|
|
918
|
+
command: 'close',
|
|
919
|
+
})
|
|
920
|
+
expect(await work).toMatchObject({ code: 'DEADLINE_EXCEEDED' })
|
|
921
|
+
expect(consumer.stats()).toEqual({ calls: 1, cancelled: true, settled: true })
|
|
922
|
+
}, 10_000)
|
|
923
|
+
|
|
924
|
+
test('root cancellation settles a conversation blocked delivering an essential reply', async () => {
|
|
925
|
+
const controller = new AbortController(),
|
|
926
|
+
commands = pair()
|
|
927
|
+
const entered = Promise.withResolvers<void>()
|
|
928
|
+
const replies: ChannelSender = {
|
|
929
|
+
direction: 'send',
|
|
930
|
+
delivery: 'direct',
|
|
931
|
+
async close() {},
|
|
932
|
+
async send(_value, options) {
|
|
933
|
+
entered.resolve()
|
|
934
|
+
await new Promise<void>((_resolve, reject) => {
|
|
935
|
+
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason), {
|
|
936
|
+
once: true,
|
|
937
|
+
})
|
|
938
|
+
})
|
|
939
|
+
},
|
|
940
|
+
}
|
|
941
|
+
const consumer = fixture({
|
|
942
|
+
input: { instructions: 'Answer.', conversation: true },
|
|
943
|
+
commands: commands.receive,
|
|
944
|
+
replies,
|
|
945
|
+
signal: controller.signal,
|
|
946
|
+
})
|
|
947
|
+
const result = agentAcpFlow(consumer.run).catch((error) => error)
|
|
948
|
+
await entered.promise
|
|
949
|
+
const cancellation = new OperationError('CANCELLED', 'conversation cancelled')
|
|
950
|
+
controller.abort(cancellation)
|
|
951
|
+
expect(await result).toBe(cancellation)
|
|
952
|
+
expect(consumer.stats()).toEqual({ calls: 1, cancelled: true, settled: true })
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
test('optional update failure drops its suffix but preserves complete execution text', async () => {
|
|
956
|
+
const diagnostic = spyOn(console, 'error').mockImplementation(() => {})
|
|
957
|
+
let sends = 0
|
|
958
|
+
const events: ChannelSender = {
|
|
959
|
+
direction: 'send',
|
|
960
|
+
delivery: 'direct',
|
|
961
|
+
async send() {
|
|
962
|
+
sends++
|
|
963
|
+
throw new OperationError('LAGGED')
|
|
964
|
+
},
|
|
965
|
+
async close() {},
|
|
966
|
+
}
|
|
967
|
+
try {
|
|
968
|
+
const consumer = fixture({ events })
|
|
969
|
+
expect(await agentAcpFlow(consumer.run)).toEqual({
|
|
970
|
+
outcome: 'done',
|
|
971
|
+
output: { text: 'The answer.' },
|
|
972
|
+
})
|
|
973
|
+
expect(sends).toBe(1)
|
|
974
|
+
expect(diagnostic).toHaveBeenCalledWith(
|
|
975
|
+
'Agent progress is incomplete; the execution result remains separate.',
|
|
976
|
+
)
|
|
977
|
+
expect(consumer.stats().cancelled).toBe(false)
|
|
978
|
+
} finally {
|
|
979
|
+
diagnostic.mockRestore()
|
|
980
|
+
}
|
|
981
|
+
})
|
|
982
|
+
})
|