@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,76 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { execFileSync } from 'node:child_process'
3
+ import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ test('ordinary packing retains editable source, registry dependencies and a standalone runtime', async () => {
9
+ const root = fileURLToPath(new URL('../', import.meta.url))
10
+ const temporary = await mkdtemp(join(tmpdir(), 'agent-acp-pack-test-'))
11
+ const originalManifest = await readFile(join(root, 'package.json'), 'utf8')
12
+ try {
13
+ const first = join(temporary, 'first')
14
+ const extracted = join(temporary, 'extracted')
15
+ const repacked = join(temporary, 'repacked')
16
+ await mkdir(extracted)
17
+ await mkdir(first)
18
+ await mkdir(repacked)
19
+ const pack = (cwd: string, destination: string) =>
20
+ execFileSync(
21
+ process.execPath,
22
+ ['pm', 'pack', '--ignore-scripts', '--destination', destination],
23
+ {
24
+ cwd,
25
+ stdio: 'pipe',
26
+ },
27
+ )
28
+ pack(root, first)
29
+ const archives = (await readdir(first)).filter((name) => name.endsWith('.tgz'))
30
+ expect(archives).toHaveLength(1)
31
+ const archive = join(first, archives[0]!)
32
+ const entries = execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }).trim().split('\n')
33
+ expect(entries).toContain('package/src/flow.ts')
34
+ expect(entries).toContain('package/FLOW.contract.json')
35
+ expect(entries.some((name) => name.includes('/tooling/') || name.endsWith('.tgz'))).toBe(false)
36
+ expect(entries.some((name) => name.includes('/node_modules/'))).toBe(false)
37
+ execFileSync('tar', ['-xzf', archive, '--strip-components=1', '-C', extracted])
38
+ const manifest = JSON.parse(await readFile(join(extracted, 'package.json'), 'utf8'))
39
+ for (const name of ['@jigging/flow', '@jigging/agent-method']) {
40
+ const version = manifest.devDependencies[name]
41
+ expect(version).toMatch(/^\d+\.\d+\.\d+/)
42
+ expect(version).not.toMatch(/workspace:|file:/)
43
+ }
44
+ expect(Object.keys(manifest.dependencies ?? {})).toHaveLength(0)
45
+ // Public built exports and the complete Flow bundle work without development dependencies.
46
+ execFileSync(
47
+ process.execPath,
48
+ [
49
+ '-e',
50
+ `await import('./dist/flow.js');
51
+ const transport = await import('@jigging/agent-acp/transport');
52
+ new transport.FiniteAcpFrames('requests').finish();
53
+ const ready = transport.readFiniteAcpReady({ kind: 'ready', protocolVersion: 1, cwd: '/work', configuration: [], maxTurns: 1, restoreSessionId: 'owned-session' });
54
+ if (ready.restoreSessionId !== 'owned-session') throw new Error('missing owned restore identity')`,
55
+ ],
56
+ { cwd: extracted, stdio: 'pipe' },
57
+ )
58
+ pack(extracted, repacked)
59
+ const next = (await readdir(repacked)).filter((name) => name.endsWith('.tgz'))
60
+ expect(next).toHaveLength(1)
61
+ const nextManifest = JSON.parse(
62
+ execFileSync('tar', ['-xOf', join(repacked, next[0]!), 'package/package.json'], {
63
+ encoding: 'utf8',
64
+ }),
65
+ )
66
+ expect(nextManifest.devDependencies).toEqual(manifest.devDependencies)
67
+ expect(
68
+ execFileSync('tar', ['-xOf', join(repacked, next[0]!), 'package/src/flow.ts'], {
69
+ encoding: 'utf8',
70
+ }),
71
+ ).toBe(await readFile(join(extracted, 'src/flow.ts'), 'utf8'))
72
+ expect(await readFile(join(root, 'package.json'), 'utf8')).toBe(originalManifest)
73
+ } finally {
74
+ await rm(temporary, { recursive: true, force: true })
75
+ }
76
+ }, 30_000)
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ FINITE_ACP_LIMITS,
4
+ FiniteAcpFrames,
5
+ fragmentFiniteAcpFrame,
6
+ readFiniteAcpReady,
7
+ } from '../src/transport.js'
8
+
9
+ describe('finite ACP framing', () => {
10
+ test('round-trips large text and supplementary scalars without exceeding channel items', () => {
11
+ const text = JSON.stringify({ text: '\u0001\\"😀é'.repeat(90_000) })
12
+ const fragments = [...fragmentFiniteAcpFrame(text)]
13
+ expect(fragments.length).toBeGreaterThan(1)
14
+ const frames = new FiniteAcpFrames('requests')
15
+ const results: string[] = []
16
+ for (const fragment of fragments) {
17
+ expect(Buffer.byteLength(fragment.text)).toBeLessThanOrEqual(8_192)
18
+ expect(Buffer.byteLength(JSON.stringify(fragment))).toBeLessThanOrEqual(65_536)
19
+ expect(fragment.text.isWellFormed()).toBe(true)
20
+ const result = frames.accept(fragment)
21
+ if (result !== undefined) results.push(result)
22
+ }
23
+ frames.finish()
24
+ expect(results).toEqual([text])
25
+ expect(() => frames.accept(fragments[0])).toThrow('not open')
26
+ })
27
+
28
+ test('rejects invalid fragments and stays poisoned after the caller catches failure', () => {
29
+ for (const value of [
30
+ null,
31
+ [],
32
+ {},
33
+ { kind: 'data', text: '', end: true },
34
+ { kind: 'data', text: 'x', end: 0 },
35
+ { kind: 'data', text: 'x', end: true, id: 'replay' },
36
+ { kind: 'data', text: '\ud800', end: true },
37
+ { kind: 'data', text: 'x'.repeat(8_193), end: true },
38
+ ]) {
39
+ const frames = new FiniteAcpFrames('responses')
40
+ expect(() => frames.accept(value)).toThrow()
41
+ expect(() => frames.accept({ kind: 'data', text: '{}', end: true })).toThrow('not open')
42
+ expect(() => frames.finish()).toThrow('not open')
43
+ }
44
+ })
45
+
46
+ test('EOF inside a frame fails; a complete empty stream does not assert protocol settlement', () => {
47
+ const partial = new FiniteAcpFrames('requests')
48
+ partial.accept({ kind: 'data', text: '{', end: false })
49
+ expect(() => partial.finish()).toThrow('inside a frame')
50
+ new FiniteAcpFrames('requests').finish()
51
+ expect(() => [...fragmentFiniteAcpFrame('')]).toThrow()
52
+ expect(() => [...fragmentFiniteAcpFrame('\udc00')]).toThrow()
53
+ })
54
+
55
+ test('bounds aggregate request bytes even when complete frames are individually valid', () => {
56
+ const reader = new FiniteAcpFrames('requests')
57
+ for (let count = 0; count < FINITE_ACP_LIMITS.requestBytes / 8_192; count++)
58
+ reader.accept({ kind: 'data', text: 'x'.repeat(8_192), end: false })
59
+ expect(() => reader.accept({ kind: 'data', text: 'x', end: true })).toThrow('bounds')
60
+ })
61
+
62
+ test('bounds complete request count and one response frame', () => {
63
+ const requests = new FiniteAcpFrames('requests')
64
+ for (let count = 0; count < FINITE_ACP_LIMITS.requestFrames; count++)
65
+ requests.accept({ kind: 'data', text: '{}', end: true })
66
+ expect(() => requests.accept({ kind: 'data', text: '{}', end: true })).toThrow(
67
+ 'too many frames',
68
+ )
69
+ const responses = new FiniteAcpFrames('responses')
70
+ for (let count = 0; count < FINITE_ACP_LIMITS.frameBytes / 8_192; count++)
71
+ responses.accept({ kind: 'data', text: 'x'.repeat(8_192), end: false })
72
+ expect(() => responses.accept({ kind: 'data', text: 'x', end: true })).toThrow('bounds')
73
+ })
74
+ })
75
+
76
+ describe('reviewed ready record', () => {
77
+ test('snapshots configuration without allowing later model replacement', () => {
78
+ const input = {
79
+ kind: 'ready',
80
+ protocolVersion: 1,
81
+ cwd: '/work',
82
+ maxTurns: 1,
83
+ configuration: [{ configId: 'model', value: 'reviewed' }],
84
+ modeId: 'read-only',
85
+ }
86
+ const ready = readFiniteAcpReady(input)
87
+ input.configuration[0]!.value = 'other'
88
+ expect(ready.configuration[0]!.value).toBe('reviewed')
89
+ expect(Object.isFrozen(ready.configuration)).toBe(true)
90
+ expect(Object.isFrozen(ready.configuration[0])).toBe(true)
91
+ expect(
92
+ readFiniteAcpReady({ ...input, restoreSessionId: 'owned-session' }).restoreSessionId,
93
+ ).toBe('owned-session')
94
+ expect(
95
+ readFiniteAcpReady({
96
+ ...input,
97
+ configuration: [{ configId: 'compaction', type: 'boolean', value: false }],
98
+ }).configuration,
99
+ ).toEqual([{ configId: 'compaction', type: 'boolean', value: false }])
100
+ })
101
+
102
+ test('rejects extra authority, malformed or duplicate configuration, and accessors', () => {
103
+ const base = { kind: 'ready', protocolVersion: 1, cwd: '/work', maxTurns: 1, configuration: [] }
104
+ for (const value of [
105
+ { ...base, cwd: '/' },
106
+ { ...base, protocolVersion: 2 },
107
+ { ...base, token: 'secret' },
108
+ { ...base, modeId: null },
109
+ ...[null, '', 'x\0y', 'x'.repeat(1_025)].map((restoreSessionId) => ({
110
+ ...base,
111
+ restoreSessionId,
112
+ })),
113
+ { ...base, configuration: [{ configId: 'model', value: 'x', type: 'string' }] },
114
+ {
115
+ ...base,
116
+ configuration: [
117
+ { configId: 'x', value: '1' },
118
+ { configId: 'x', value: '2' },
119
+ ],
120
+ },
121
+ { ...base, configuration: new Array(17).fill({ configId: 'x', value: 'y' }) },
122
+ ])
123
+ expect(() => readFiniteAcpReady(value)).toThrow()
124
+ let invoked = false
125
+ const accessor = {
126
+ ...base,
127
+ get modeId() {
128
+ invoked = true
129
+ return 'bad'
130
+ },
131
+ }
132
+ expect(() => readFiniteAcpReady(accessor)).toThrow('data properties')
133
+ expect(invoked).toBe(false)
134
+ })
135
+ })
@@ -0,0 +1,94 @@
1
+ import { describe, expect, spyOn, test } from 'bun:test'
2
+ import type { ChannelCloseOptions, ChannelSender, JsonValue } from '@jigging/flow'
3
+ import { OptionalUpdates } from '../src/updates.js'
4
+
5
+ describe('optional ordinary presentation relay', () => {
6
+ test('healthy delivery preserves order and closes cleanly', async () => {
7
+ const sent: JsonValue[] = []
8
+ const ends: (ChannelCloseOptions | undefined)[] = []
9
+ const sender: ChannelSender = {
10
+ direction: 'send',
11
+ delivery: 'direct',
12
+ async send(value) {
13
+ sent.push(value)
14
+ },
15
+ async close(options) {
16
+ ends.push(options)
17
+ },
18
+ }
19
+ const updates = new OptionalUpdates(sender, new AbortController().signal)
20
+ updates.offer({ text: 'first' })
21
+ updates.offer({ text: 'second' })
22
+ await updates.finish()
23
+ expect(sent).toEqual([{ text: 'first' }, { text: 'second' }])
24
+ expect(ends).toEqual([undefined])
25
+ })
26
+
27
+ test('overflow aborts the blocked send, drops the suffix and declares LAGGED', async () => {
28
+ const diagnostic = spyOn(console, 'error').mockImplementation(() => {})
29
+ const ends: (ChannelCloseOptions | undefined)[] = []
30
+ let sends = 0
31
+ let aborted = false
32
+ const sender: ChannelSender = {
33
+ direction: 'send',
34
+ delivery: 'direct',
35
+ async send(_value, options) {
36
+ sends++
37
+ await new Promise<void>((_resolve, reject) =>
38
+ options!.signal!.addEventListener(
39
+ 'abort',
40
+ () => {
41
+ aborted = true
42
+ reject(new Error('aborted'))
43
+ },
44
+ { once: true },
45
+ ),
46
+ )
47
+ },
48
+ async close(options) {
49
+ ends.push(options)
50
+ },
51
+ }
52
+ try {
53
+ const updates = new OptionalUpdates(sender, new AbortController().signal)
54
+ for (let index = 0; index < 20; index++) updates.offer({ index })
55
+ await updates.finish()
56
+ updates.offer({ later: 'must never resume' })
57
+ expect(aborted).toBe(true)
58
+ expect(sends).toBe(1)
59
+ expect(ends).toEqual([{ error: 'LAGGED' }])
60
+ expect(diagnostic).toHaveBeenCalledTimes(1)
61
+ } finally {
62
+ diagnostic.mockRestore()
63
+ }
64
+ })
65
+
66
+ test('a blocked send has a finite wait without cancelling the Agent', async () => {
67
+ const diagnostic = spyOn(console, 'error').mockImplementation(() => {})
68
+ const owner = new AbortController()
69
+ let end: ChannelCloseOptions | undefined
70
+ const sender: ChannelSender = {
71
+ direction: 'send',
72
+ delivery: 'direct',
73
+ async send(_value, options) {
74
+ await new Promise<void>((_resolve, reject) =>
75
+ options!.signal!.addEventListener('abort', () => reject(new Error('blocked')), {
76
+ once: true,
77
+ }),
78
+ )
79
+ },
80
+ async close(options) {
81
+ end = options
82
+ },
83
+ }
84
+ try {
85
+ const updates = new OptionalUpdates(sender, owner.signal)
86
+ updates.offer({ text: 'bounded wait' })
87
+ await updates.finish()
88
+ expect(end).toEqual({ error: 'LAGGED' })
89
+ expect(owner.signal.aborted).toBe(false)
90
+ } finally {
91
+ diagnostic.mockRestore()
92
+ }
93
+ })
94
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "exactOptionalPropertyTypes": true,
11
+ "declaration": true,
12
+ "outDir": "dist",
13
+ "rootDir": "src"
14
+ },
15
+ "include": ["src/**/*.ts"],
16
+ "exclude": ["dist", "test"]
17
+ }