@jigging/agent-acp 0.0.0 → 0.1.0-alpha.3

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.
@@ -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
+ })