@zooid/acp-client 0.7.0 → 0.7.1
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/package.json +1 -1
- package/src/acp-client.tap.test.ts +65 -0
- package/src/acp-client.ts +23 -5
- package/src/errors.test.ts +101 -0
- package/src/errors.ts +63 -0
- package/src/index.ts +8 -1
- package/src/presets.mounts.test.ts +82 -0
- package/src/presets.test.ts +12 -10
- package/src/presets.ts +102 -13
- package/src/turn-tracker.test.ts +31 -0
- package/src/turn-tracker.ts +12 -0
- package/src/types.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/acp-client",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Zooid's ACP client: spawn agent subprocesses, drive them via Agent Client Protocol, route events and permission requests through callbacks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
2
2
|
import { EventEmitter } from 'node:events'
|
|
3
3
|
import { Readable, Writable } from 'node:stream'
|
|
4
4
|
import type { ChildProcess } from 'node:child_process'
|
|
5
|
+
import { RequestError } from '@agentclientprotocol/sdk'
|
|
5
6
|
import { AcpClient } from './acp-client.js'
|
|
7
|
+
import type { TapEvent } from './turn-tracker.js'
|
|
6
8
|
|
|
7
9
|
class FakeChild extends EventEmitter {
|
|
8
10
|
stdout = new Readable({ read() {} })
|
|
@@ -34,3 +36,66 @@ describe('AcpClient onTap', () => {
|
|
|
34
36
|
}).not.toThrow()
|
|
35
37
|
})
|
|
36
38
|
})
|
|
39
|
+
|
|
40
|
+
describe('AcpClient — error TapEvent emission', () => {
|
|
41
|
+
function makeClient(onTap: (e: TapEvent) => void) {
|
|
42
|
+
const child = new FakeChild() as unknown as ChildProcess
|
|
43
|
+
const rt = { spawn: vi.fn().mockReturnValue(child) }
|
|
44
|
+
const client = new AcpClient({
|
|
45
|
+
agent: { id: 'alice', command: 'foo', args: [] },
|
|
46
|
+
onEvent: () => {},
|
|
47
|
+
onApprovalRequest: async () => ({ decision: 'cancel' }),
|
|
48
|
+
runtime: rt,
|
|
49
|
+
onTap,
|
|
50
|
+
})
|
|
51
|
+
return client
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('emits {kind:"error",...} when prompt throws a RequestError', async () => {
|
|
55
|
+
const seen: TapEvent[] = []
|
|
56
|
+
const client = makeClient((e) => seen.push(e))
|
|
57
|
+
|
|
58
|
+
const fakeConn = {
|
|
59
|
+
newSession: vi.fn().mockResolvedValue({ sessionId: 'sess-1' }),
|
|
60
|
+
prompt: vi.fn().mockRejectedValue(
|
|
61
|
+
new RequestError(-32000, 'Authentication required', undefined),
|
|
62
|
+
),
|
|
63
|
+
loadSession: vi.fn(),
|
|
64
|
+
}
|
|
65
|
+
;(client as unknown as Record<string, unknown>).connection = fakeConn
|
|
66
|
+
;(client as unknown as Record<string, unknown>).initialized = true
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
client.prompt({ threadId: 'thread-1', content: [{ type: 'text', text: 'hi' }] }),
|
|
70
|
+
).rejects.toThrow()
|
|
71
|
+
|
|
72
|
+
const errEvt = seen.find((e) => e.kind === 'error')
|
|
73
|
+
expect(errEvt).toBeDefined()
|
|
74
|
+
expect(errEvt!.code).toBe('auth_missing')
|
|
75
|
+
expect(errEvt!.acp_error).toMatchObject({ code: -32000, message: 'Authentication required' })
|
|
76
|
+
expect(errEvt!.sessionId).toBe('sess-1')
|
|
77
|
+
expect(errEvt!.turnId).not.toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('emits with sessionId=null when ensureSession throws (newSession failure)', async () => {
|
|
81
|
+
const seen: TapEvent[] = []
|
|
82
|
+
const client = makeClient((e) => seen.push(e))
|
|
83
|
+
|
|
84
|
+
const fakeConn = {
|
|
85
|
+
newSession: vi.fn().mockRejectedValue(new Error('ACP connection closed')),
|
|
86
|
+
prompt: vi.fn(),
|
|
87
|
+
loadSession: vi.fn(),
|
|
88
|
+
}
|
|
89
|
+
;(client as unknown as Record<string, unknown>).connection = fakeConn
|
|
90
|
+
;(client as unknown as Record<string, unknown>).initialized = true
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
client.prompt({ threadId: 'thread-1', content: [{ type: 'text', text: 'hi' }] }),
|
|
94
|
+
).rejects.toThrow()
|
|
95
|
+
|
|
96
|
+
const errEvt = seen.find((e) => e.kind === 'error')
|
|
97
|
+
expect(errEvt).toBeDefined()
|
|
98
|
+
expect(errEvt!.sessionId).toBeNull()
|
|
99
|
+
expect(errEvt!.code).toBe('container_exit')
|
|
100
|
+
})
|
|
101
|
+
})
|
package/src/acp-client.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
approvalDecisionToPermissionResponse,
|
|
17
17
|
} from './event-mapping.js'
|
|
18
18
|
import { TurnTracker, type TapEvent } from './turn-tracker.js'
|
|
19
|
+
import { classify } from './errors.js'
|
|
19
20
|
import type {
|
|
20
21
|
AgentConfig,
|
|
21
22
|
AgentEvent,
|
|
@@ -37,6 +38,8 @@ export interface SpawnRuntime {
|
|
|
37
38
|
cwd?: string
|
|
38
39
|
/** Container image. Honoured by container runtimes; ignored by local spawners. */
|
|
39
40
|
image?: string
|
|
41
|
+
/** Bind mounts. Honoured by container runtimes; ignored by local spawners. */
|
|
42
|
+
mounts?: Array<{ path: string; target: string; mode: 'ro' | 'rw' }>
|
|
40
43
|
}): ChildProcess
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -108,6 +111,7 @@ export class AcpClient {
|
|
|
108
111
|
env: this.options.agent.env,
|
|
109
112
|
cwd: this.options.agent.cwd,
|
|
110
113
|
image: this.options.agent.image,
|
|
114
|
+
mounts: this.options.agent.mounts,
|
|
111
115
|
})
|
|
112
116
|
this.runtimeChild = child
|
|
113
117
|
if (!child.stdout || !child.stdin) {
|
|
@@ -253,11 +257,13 @@ export class AcpClient {
|
|
|
253
257
|
}
|
|
254
258
|
|
|
255
259
|
async prompt(input: PromptInput): Promise<PromptResult> {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
this.turns?.startTurn({ sessionId, promptText })
|
|
259
|
-
debugLog(this.options.agent.id, 'prompt →', { sessionId, content: input.content })
|
|
260
|
+
let sessionId: string | null = null
|
|
261
|
+
let turnId: string | null = null
|
|
260
262
|
try {
|
|
263
|
+
sessionId = await this.ensureSession(input.threadId, input.channelId)
|
|
264
|
+
const promptText = stringifyPromptForLog(input.content)
|
|
265
|
+
turnId = this.turns?.startTurn({ sessionId, promptText }) ?? null
|
|
266
|
+
debugLog(this.options.agent.id, 'prompt →', { sessionId, content: input.content })
|
|
261
267
|
const result = await this.connection!.prompt({
|
|
262
268
|
sessionId,
|
|
263
269
|
prompt: input.content,
|
|
@@ -266,7 +272,19 @@ export class AcpClient {
|
|
|
266
272
|
debugLog(this.options.agent.id, 'prompt ←', { sessionId, stopReason: result.stopReason })
|
|
267
273
|
return { stopReason: result.stopReason }
|
|
268
274
|
} catch (err) {
|
|
269
|
-
|
|
275
|
+
const c = classify(err)
|
|
276
|
+
this.options.onTap?.({
|
|
277
|
+
kind: 'error',
|
|
278
|
+
agentId: this.options.agent.id,
|
|
279
|
+
sessionId,
|
|
280
|
+
turnId,
|
|
281
|
+
code: c.code,
|
|
282
|
+
message: err instanceof Error ? err.message : String(err),
|
|
283
|
+
detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
|
|
284
|
+
transient: c.transient,
|
|
285
|
+
acp_error: c.acp_error,
|
|
286
|
+
})
|
|
287
|
+
if (sessionId) this.turns?.endTurn({ sessionId, stopReason: 'error' })
|
|
270
288
|
throw err
|
|
271
289
|
}
|
|
272
290
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { RequestError } from '@agentclientprotocol/sdk'
|
|
3
|
+
import { classify } from './errors.js'
|
|
4
|
+
|
|
5
|
+
describe('classify — RequestError', () => {
|
|
6
|
+
it('claude-agent-acp -32000 "Authentication required" → auth_missing', () => {
|
|
7
|
+
const err = new RequestError(-32000, 'Authentication required', undefined)
|
|
8
|
+
const r = classify(err)
|
|
9
|
+
expect(r.code).toBe('auth_missing')
|
|
10
|
+
expect(r.transient).toBe(false)
|
|
11
|
+
expect(r.acp_error).toEqual({
|
|
12
|
+
code: -32000,
|
|
13
|
+
message: 'Authentication required',
|
|
14
|
+
data: undefined,
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('-32002 (spec-compliant authRequired) → auth_missing', () => {
|
|
19
|
+
const err = RequestError.authRequired()
|
|
20
|
+
expect(classify(err).code).toBe('auth_missing')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('invalid/expired/revoked credential → auth_invalid', () => {
|
|
24
|
+
expect(classify(new RequestError(-32000, 'API key invalid')).code).toBe('auth_invalid')
|
|
25
|
+
expect(classify(new RequestError(-32000, 'token expired')).code).toBe('auth_invalid')
|
|
26
|
+
expect(classify(new RequestError(-32000, 'credential revoked')).code).toBe('auth_invalid')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('rate limit → model_rate_limit (transient)', () => {
|
|
30
|
+
const r = classify(new RequestError(-32000, 'rate limit exceeded'))
|
|
31
|
+
expect(r.code).toBe('model_rate_limit')
|
|
32
|
+
expect(r.transient).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('5xx / overloaded / unknown model → model_unavailable (transient)', () => {
|
|
36
|
+
expect(classify(new RequestError(-32000, 'upstream 503')).code).toBe('model_unavailable')
|
|
37
|
+
expect(classify(new RequestError(-32000, 'service overloaded')).code).toBe('model_unavailable')
|
|
38
|
+
expect(classify(new RequestError(-32000, 'unknown model claude-5')).code).toBe('model_unavailable')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('JSON-RPC standard codes → acp_protocol', () => {
|
|
42
|
+
for (const code of [-32700, -32600, -32601, -32602]) {
|
|
43
|
+
expect(classify(new RequestError(code, 'malformed')).code).toBe('acp_protocol')
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('-32603 (internal JSON-RPC error) → internal', () => {
|
|
48
|
+
expect(classify(new RequestError(-32603, 'internal')).code).toBe('internal')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('preserves acp_error verbatim including data', () => {
|
|
52
|
+
const err = new RequestError(-32000, 'something', { trace: 'abc' })
|
|
53
|
+
expect(classify(err).acp_error).toEqual({
|
|
54
|
+
code: -32000,
|
|
55
|
+
message: 'something',
|
|
56
|
+
data: { trace: 'abc' },
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('unmatched -32000…-32099 message → internal (not auth-mis-fire)', () => {
|
|
61
|
+
expect(classify(new RequestError(-32000, 'gobbledygook')).code).toBe('internal')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('classify — out-of-band errors', () => {
|
|
66
|
+
it('mount-failed engine error → mount_failed (non-transient)', () => {
|
|
67
|
+
const err = new Error(
|
|
68
|
+
"docker: Error response from daemon: error while creating mount source path '/home/zooid/.codex': mkdir /home/zooid/.codex: permission denied",
|
|
69
|
+
)
|
|
70
|
+
const r = classify(err)
|
|
71
|
+
expect(r.code).toBe('mount_failed')
|
|
72
|
+
expect(r.transient).toBe(false)
|
|
73
|
+
expect(r.acp_error).toBeUndefined()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('image pull failure → image_pull_failed (transient)', () => {
|
|
77
|
+
const err = new Error(
|
|
78
|
+
'image prepull failed for ghcr.io/zooid-ai/agent-codex:latest:\nmanifest unknown',
|
|
79
|
+
)
|
|
80
|
+
expect(classify(err).code).toBe('image_pull_failed')
|
|
81
|
+
expect(classify(err).transient).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('ACP stream closed → container_exit (transient)', () => {
|
|
85
|
+
const err = new Error('ACP connection closed')
|
|
86
|
+
expect(classify(err).code).toBe('container_exit')
|
|
87
|
+
expect(classify(err).transient).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('unmatched error → internal with String(err) preserved', () => {
|
|
91
|
+
const r = classify(new Error('weird thing happened'))
|
|
92
|
+
expect(r.code).toBe('internal')
|
|
93
|
+
expect(r.transient).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('non-Error thrown value → internal', () => {
|
|
97
|
+
expect(classify('a bare string').code).toBe('internal')
|
|
98
|
+
expect(classify(null).code).toBe('internal')
|
|
99
|
+
expect(classify(undefined).code).toBe('internal')
|
|
100
|
+
})
|
|
101
|
+
})
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { RequestError } from '@agentclientprotocol/sdk'
|
|
2
|
+
|
|
3
|
+
export type ErrorCode =
|
|
4
|
+
| 'auth_missing'
|
|
5
|
+
| 'auth_invalid'
|
|
6
|
+
| 'model_rate_limit'
|
|
7
|
+
| 'model_unavailable'
|
|
8
|
+
| 'image_pull_failed'
|
|
9
|
+
| 'mount_failed'
|
|
10
|
+
| 'container_exit'
|
|
11
|
+
| 'acp_protocol'
|
|
12
|
+
| 'permission_denied'
|
|
13
|
+
| 'internal'
|
|
14
|
+
|
|
15
|
+
export interface Classified {
|
|
16
|
+
code: ErrorCode
|
|
17
|
+
transient: boolean
|
|
18
|
+
/** Verbatim ACP RequestError triple — forwarded into eco.zoon.error.acp_error. */
|
|
19
|
+
acp_error?: { code: number; message: string; data?: unknown }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PROTOCOL_CODES = new Set([-32700, -32600, -32601, -32602])
|
|
23
|
+
|
|
24
|
+
export function classify(err: unknown): Classified {
|
|
25
|
+
if (err instanceof RequestError) {
|
|
26
|
+
const acp_error = { code: err.code, message: err.message, data: err.data }
|
|
27
|
+
if (PROTOCOL_CODES.has(err.code)) return { code: 'acp_protocol', transient: false, acp_error }
|
|
28
|
+
if (err.code === -32603) return { code: 'internal', transient: false, acp_error }
|
|
29
|
+
// -32002 (spec authRequired) → auth_missing
|
|
30
|
+
if (err.code === -32002) return { code: 'auth_missing', transient: false, acp_error }
|
|
31
|
+
// -32000…-32099 generic: classify by message
|
|
32
|
+
const m = err.message
|
|
33
|
+
if (/^auth(entication)? required$/i.test(m)) {
|
|
34
|
+
return { code: 'auth_missing', transient: false, acp_error }
|
|
35
|
+
}
|
|
36
|
+
if (/\b(token|api ?key|credential)\b/i.test(m) && /\b(invalid|expired|revoked)\b/i.test(m)) {
|
|
37
|
+
return { code: 'auth_invalid', transient: false, acp_error }
|
|
38
|
+
}
|
|
39
|
+
if (/\brate ?limit\b|\b429\b/i.test(m)) {
|
|
40
|
+
return { code: 'model_rate_limit', transient: true, acp_error }
|
|
41
|
+
}
|
|
42
|
+
if (/\b5\d\d\b|\boverloaded\b|\bunavailable\b|\bunknown model\b/i.test(m)) {
|
|
43
|
+
return { code: 'model_unavailable', transient: true, acp_error }
|
|
44
|
+
}
|
|
45
|
+
return { code: 'internal', transient: false, acp_error }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Out-of-band errors (no RequestError → no acp_error).
|
|
49
|
+
if (err instanceof Error) {
|
|
50
|
+
const m = err.message
|
|
51
|
+
if (/error while creating mount source path|mkdir .* permission denied/i.test(m)) {
|
|
52
|
+
return { code: 'mount_failed', transient: false }
|
|
53
|
+
}
|
|
54
|
+
if (/^image prepull failed/i.test(m)) {
|
|
55
|
+
return { code: 'image_pull_failed', transient: true }
|
|
56
|
+
}
|
|
57
|
+
if (/ACP connection closed/i.test(m)) {
|
|
58
|
+
return { code: 'container_exit', transient: true }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { code: 'internal', transient: false }
|
|
63
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,12 @@ export { AcpClient } from './acp-client.js'
|
|
|
2
2
|
export { AgentProcess } from './agent-process.js'
|
|
3
3
|
export { SessionMap } from './session-map.js'
|
|
4
4
|
export { PRESETS, resolvePreset, isPreset } from './presets.js'
|
|
5
|
-
export type {
|
|
5
|
+
export type {
|
|
6
|
+
PresetName,
|
|
7
|
+
PresetSpec,
|
|
8
|
+
PresetMount,
|
|
9
|
+
PresetMountContext,
|
|
10
|
+
} from './presets.js'
|
|
6
11
|
export {
|
|
7
12
|
acpUpdateToAgentEvent,
|
|
8
13
|
approvalDecisionToPermissionResponse,
|
|
@@ -23,3 +28,5 @@ export type { SessionKey, SessionRecord } from './session-map.js'
|
|
|
23
28
|
export type { AcpClientOptions } from './acp-client.js'
|
|
24
29
|
export { TurnTracker } from './turn-tracker.js'
|
|
25
30
|
export type { TapEvent, SessionUpdate, TurnTrackerOpts } from './turn-tracker.js'
|
|
31
|
+
export { classify } from './errors.js'
|
|
32
|
+
export type { ErrorCode, Classified } from './errors.js'
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { PRESETS } from './presets.js'
|
|
3
|
+
|
|
4
|
+
const ctx = {
|
|
5
|
+
agentName: 'alice',
|
|
6
|
+
agentDataDir: '/data/agents/alice',
|
|
7
|
+
containerWorkdir: '/workspace',
|
|
8
|
+
daemonHome: '/home/zooid',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('preset mount declarations — home / data / config', () => {
|
|
12
|
+
it('claude declares a single `home` mount at ${daemonHome}/.claude', () => {
|
|
13
|
+
const mounts = PRESETS.claude.mounts!(ctx)
|
|
14
|
+
expect(mounts).toHaveLength(1)
|
|
15
|
+
expect(mounts[0]).toMatchObject({
|
|
16
|
+
id: 'home',
|
|
17
|
+
host: '/home/zooid/.claude',
|
|
18
|
+
target: '/root/.claude',
|
|
19
|
+
mode: 'rw',
|
|
20
|
+
create: false,
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('codex declares a single `home` mount at ${daemonHome}/.codex', () => {
|
|
25
|
+
const mounts = PRESETS.codex.mounts!(ctx)
|
|
26
|
+
expect(mounts).toHaveLength(1)
|
|
27
|
+
expect(mounts[0]).toMatchObject({
|
|
28
|
+
id: 'home',
|
|
29
|
+
host: '/home/zooid/.codex',
|
|
30
|
+
target: '/root/.codex',
|
|
31
|
+
mode: 'rw',
|
|
32
|
+
create: false,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('opencode declares `data` + `config` from XDG dirs', () => {
|
|
37
|
+
const mounts = PRESETS.opencode.mounts!(ctx)
|
|
38
|
+
const byId = Object.fromEntries(mounts.map((m) => [m.id, m]))
|
|
39
|
+
expect(Object.keys(byId).sort()).toEqual(['config', 'data'])
|
|
40
|
+
expect(byId.data).toMatchObject({
|
|
41
|
+
host: '/home/zooid/.local/share/opencode',
|
|
42
|
+
target: '/root/.local/share/opencode',
|
|
43
|
+
mode: 'rw',
|
|
44
|
+
create: false,
|
|
45
|
+
})
|
|
46
|
+
expect(byId.config).toMatchObject({
|
|
47
|
+
host: '/home/zooid/.config/opencode',
|
|
48
|
+
target: '/root/.config/opencode',
|
|
49
|
+
mode: 'rw',
|
|
50
|
+
create: false,
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('preset mounts do not reference `ctx.agentDataDir` (per-agent isolation is opt-in via user mounts)', () => {
|
|
55
|
+
const claudeMounts = PRESETS.claude.mounts!(ctx)
|
|
56
|
+
const codexMounts = PRESETS.codex.mounts!(ctx)
|
|
57
|
+
const opencodeMounts = PRESETS.opencode.mounts!(ctx)
|
|
58
|
+
for (const m of [...claudeMounts, ...codexMounts, ...opencodeMounts]) {
|
|
59
|
+
expect(m.host).not.toContain('/data/agents/alice')
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('cline / kiro / gemini have no preset-declared mounts', () => {
|
|
64
|
+
expect(PRESETS.cline.mounts?.(ctx) ?? []).toEqual([])
|
|
65
|
+
expect(PRESETS.kiro.mounts?.(ctx) ?? []).toEqual([])
|
|
66
|
+
expect(PRESETS.gemini.mounts?.(ctx) ?? []).toEqual([])
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('preset default images (unchanged from cycle 1)', () => {
|
|
71
|
+
it('claude, codex, opencode declare a default ghcr image', () => {
|
|
72
|
+
expect(PRESETS.claude.image).toBe('ghcr.io/zooid-ai/agent-claude-code:latest')
|
|
73
|
+
expect(PRESETS.codex.image).toBe('ghcr.io/zooid-ai/agent-codex:latest')
|
|
74
|
+
expect(PRESETS.opencode.image).toBe('ghcr.io/zooid-ai/agent-opencode:latest')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('cline / kiro / gemini declare no default image', () => {
|
|
78
|
+
expect(PRESETS.cline.image).toBeUndefined()
|
|
79
|
+
expect(PRESETS.kiro.image).toBeUndefined()
|
|
80
|
+
expect(PRESETS.gemini.image).toBeUndefined()
|
|
81
|
+
})
|
|
82
|
+
})
|
package/src/presets.test.ts
CHANGED
|
@@ -3,20 +3,20 @@ import { PRESETS, resolvePreset, isPreset } from './presets.js'
|
|
|
3
3
|
|
|
4
4
|
describe('PRESETS registry', () => {
|
|
5
5
|
it('includes the ACP-native harnesses from SPEC.md', () => {
|
|
6
|
-
expect(PRESETS.opencode).
|
|
7
|
-
expect(PRESETS.cline).
|
|
8
|
-
expect(PRESETS.kiro).
|
|
9
|
-
expect(PRESETS.gemini).
|
|
6
|
+
expect(PRESETS.opencode).toMatchObject({ command: 'opencode', args: ['acp'] })
|
|
7
|
+
expect(PRESETS.cline).toMatchObject({ command: 'cline', args: ['--acp'] })
|
|
8
|
+
expect(PRESETS.kiro).toMatchObject({ command: 'kiro', args: ['--acp'] })
|
|
9
|
+
expect(PRESETS.gemini).toMatchObject({ command: 'gemini', args: ['--acp'] })
|
|
10
10
|
})
|
|
11
11
|
|
|
12
12
|
it('includes the vendored-shim harnesses', () => {
|
|
13
|
-
expect(PRESETS.claude).
|
|
13
|
+
expect(PRESETS.claude).toMatchObject({
|
|
14
14
|
command: 'npx',
|
|
15
15
|
args: ['-y', '@agentclientprotocol/claude-agent-acp'],
|
|
16
16
|
})
|
|
17
|
-
expect(PRESETS.codex).
|
|
17
|
+
expect(PRESETS.codex).toMatchObject({
|
|
18
18
|
command: 'npx',
|
|
19
|
-
args: ['-y', '@zed-industries/codex-acp'],
|
|
19
|
+
args: ['-y', '@zed-industries/codex-acp', '-c', 'web_search="live"'],
|
|
20
20
|
})
|
|
21
21
|
})
|
|
22
22
|
})
|
|
@@ -62,13 +62,15 @@ describe('resolvePreset', () => {
|
|
|
62
62
|
])
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
it('
|
|
65
|
+
it('passes model to codex via `-c model="..."` (codex-acp uses TOML config overrides)', () => {
|
|
66
66
|
const spec = resolvePreset('codex', { model: 'gpt-5.5' })
|
|
67
67
|
expect(spec.args).toEqual([
|
|
68
68
|
'-y',
|
|
69
69
|
'@zed-industries/codex-acp',
|
|
70
|
-
'
|
|
71
|
-
'
|
|
70
|
+
'-c',
|
|
71
|
+
'web_search="live"',
|
|
72
|
+
'-c',
|
|
73
|
+
'model="gpt-5.5"',
|
|
72
74
|
])
|
|
73
75
|
})
|
|
74
76
|
|
package/src/presets.ts
CHANGED
|
@@ -7,28 +7,116 @@
|
|
|
7
7
|
//
|
|
8
8
|
// See epics/002-ZOD019-acp-runtime/SPEC.md §"Agent compatibility matrix".
|
|
9
9
|
|
|
10
|
+
export interface PresetMountContext {
|
|
11
|
+
agentName: string
|
|
12
|
+
/** Per-agent host state root (`<dataDir>/agents/<agentName>`). Kept on the
|
|
13
|
+
* context for forward compatibility — v1 default declarations don't use
|
|
14
|
+
* it, but presets that opt into per-agent isolation can. */
|
|
15
|
+
agentDataDir: string
|
|
16
|
+
/** Resolved container working directory, e.g. `/workspace`. */
|
|
17
|
+
containerWorkdir: string
|
|
18
|
+
/** Daemon user's `$HOME`. Source of `~/.<preset>` for the v1 `home` /
|
|
19
|
+
* `data` / `config` defaults. Threaded from `buildAcpRegistry` so tests
|
|
20
|
+
* can inject without mutating `process.env.HOME`. */
|
|
21
|
+
daemonHome: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PresetMount {
|
|
25
|
+
id: string
|
|
26
|
+
host: string
|
|
27
|
+
target: string
|
|
28
|
+
mode: 'ro' | 'rw'
|
|
29
|
+
create?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
export interface PresetSpec {
|
|
11
33
|
command: string
|
|
12
34
|
args: string[]
|
|
35
|
+
/**
|
|
36
|
+
* Default container image for this preset. Last fallback in the
|
|
37
|
+
* `buildAcpRegistry` image-resolution chain (agent > workforce > preset).
|
|
38
|
+
* Omit when no first-party image is published yet.
|
|
39
|
+
*/
|
|
40
|
+
image?: string
|
|
41
|
+
/**
|
|
42
|
+
* Canonical-id mounts this preset wants set up. Called once per agent
|
|
43
|
+
* during registry construction with `{ agentName, agentDataDir, containerWorkdir }`.
|
|
44
|
+
*/
|
|
45
|
+
mounts?: (ctx: PresetMountContext) => PresetMount[]
|
|
13
46
|
}
|
|
14
47
|
|
|
48
|
+
type PresetInternal = PresetSpec
|
|
49
|
+
|
|
15
50
|
const PRESETS_INTERNAL = {
|
|
16
|
-
opencode: {
|
|
51
|
+
opencode: {
|
|
52
|
+
command: 'opencode',
|
|
53
|
+
args: ['acp'],
|
|
54
|
+
image: 'ghcr.io/zooid-ai/agent-opencode:latest',
|
|
55
|
+
mounts: (ctx: PresetMountContext): PresetMount[] => [
|
|
56
|
+
{
|
|
57
|
+
id: 'data',
|
|
58
|
+
host: `${ctx.daemonHome}/.local/share/opencode`,
|
|
59
|
+
target: '/root/.local/share/opencode',
|
|
60
|
+
mode: 'rw',
|
|
61
|
+
create: false,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'config',
|
|
65
|
+
host: `${ctx.daemonHome}/.config/opencode`,
|
|
66
|
+
target: '/root/.config/opencode',
|
|
67
|
+
mode: 'rw',
|
|
68
|
+
create: false,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
17
72
|
cline: { command: 'cline', args: ['--acp'] },
|
|
18
73
|
kiro: { command: 'kiro', args: ['--acp'] },
|
|
19
74
|
gemini: { command: 'gemini', args: ['--acp'] },
|
|
20
|
-
claude: {
|
|
21
|
-
|
|
22
|
-
|
|
75
|
+
claude: {
|
|
76
|
+
command: 'npx',
|
|
77
|
+
args: ['-y', '@agentclientprotocol/claude-agent-acp'],
|
|
78
|
+
image: 'ghcr.io/zooid-ai/agent-claude-code:latest',
|
|
79
|
+
mounts: (ctx: PresetMountContext): PresetMount[] => [
|
|
80
|
+
{
|
|
81
|
+
id: 'home',
|
|
82
|
+
host: `${ctx.daemonHome}/.claude`,
|
|
83
|
+
target: '/root/.claude',
|
|
84
|
+
mode: 'rw',
|
|
85
|
+
create: false,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
codex: {
|
|
90
|
+
command: 'npx',
|
|
91
|
+
// web_search="live" forces live web fetches instead of codex's cached snippet
|
|
92
|
+
// index, which doesn't know about recently-launched sites (e.g. zooid.dev).
|
|
93
|
+
args: ['-y', '@zed-industries/codex-acp', '-c', 'web_search="live"'],
|
|
94
|
+
image: 'ghcr.io/zooid-ai/agent-codex:latest',
|
|
95
|
+
mounts: (ctx: PresetMountContext): PresetMount[] => [
|
|
96
|
+
{
|
|
97
|
+
id: 'home',
|
|
98
|
+
host: `${ctx.daemonHome}/.codex`,
|
|
99
|
+
target: '/root/.codex',
|
|
100
|
+
mode: 'rw',
|
|
101
|
+
create: false,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
} as const satisfies Record<string, PresetInternal>
|
|
23
106
|
|
|
24
107
|
export type PresetName = keyof typeof PRESETS_INTERNAL
|
|
25
108
|
|
|
26
109
|
export const PRESETS: Record<PresetName, PresetSpec> = Object.freeze(
|
|
27
110
|
Object.fromEntries(
|
|
28
|
-
(Object.keys(PRESETS_INTERNAL) as PresetName[]).map((k) =>
|
|
29
|
-
k
|
|
30
|
-
{ command:
|
|
31
|
-
|
|
111
|
+
(Object.keys(PRESETS_INTERNAL) as PresetName[]).map((k) => {
|
|
112
|
+
const e = PRESETS_INTERNAL[k]
|
|
113
|
+
const copy: PresetSpec = { command: e.command, args: [...e.args] }
|
|
114
|
+
if ('image' in e && e.image) copy.image = e.image
|
|
115
|
+
if ('mounts' in e && typeof e.mounts === 'function') {
|
|
116
|
+
copy.mounts = e.mounts as PresetSpec['mounts']
|
|
117
|
+
}
|
|
118
|
+
return [k, copy]
|
|
119
|
+
}),
|
|
32
120
|
),
|
|
33
121
|
) as Record<PresetName, PresetSpec>
|
|
34
122
|
|
|
@@ -43,9 +131,10 @@ export interface ResolvePresetOpts {
|
|
|
43
131
|
}
|
|
44
132
|
|
|
45
133
|
// null = preset has its own model channel (opencode reads opencode.json); undefined = not implemented yet.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
134
|
+
// codex-acp doesn't accept --model; it takes config overrides via `-c key=value` (TOML).
|
|
135
|
+
const MODEL_ARGS_PER_PRESET: Partial<Record<PresetName, ((model: string) => string[]) | null>> = {
|
|
136
|
+
claude: (m) => ['--model', m],
|
|
137
|
+
codex: (m) => ['-c', `model="${m.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`],
|
|
49
138
|
opencode: null,
|
|
50
139
|
}
|
|
51
140
|
|
|
@@ -57,8 +146,8 @@ export function resolvePreset(name: string, opts: ResolvePresetOpts = {}): Prese
|
|
|
57
146
|
const entry = PRESETS_INTERNAL[name]
|
|
58
147
|
const args: string[] = [...entry.args]
|
|
59
148
|
if (opts.model !== undefined) {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
149
|
+
const builder = MODEL_ARGS_PER_PRESET[name]
|
|
150
|
+
if (builder) args.push(...builder(opts.model))
|
|
62
151
|
}
|
|
63
152
|
return { command: entry.command, args }
|
|
64
153
|
}
|
package/src/turn-tracker.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { TurnTracker, type TapEvent } from './turn-tracker.js'
|
|
3
|
+
import type { ErrorCode } from './errors.js'
|
|
3
4
|
|
|
4
5
|
describe('TurnTracker', () => {
|
|
5
6
|
it('emits turn_started → session_update* → turn_completed in order', () => {
|
|
@@ -68,3 +69,33 @@ describe('TurnTracker', () => {
|
|
|
68
69
|
expect(onTap).not.toHaveBeenCalled()
|
|
69
70
|
})
|
|
70
71
|
})
|
|
72
|
+
|
|
73
|
+
describe('TapEvent error variant (ZOD055)', () => {
|
|
74
|
+
it('is a discriminated union member with the expected fields', () => {
|
|
75
|
+
const e: TapEvent = {
|
|
76
|
+
kind: 'error',
|
|
77
|
+
agentId: 'alice',
|
|
78
|
+
sessionId: 'sess-1',
|
|
79
|
+
turnId: 'turn-1',
|
|
80
|
+
code: 'auth_missing' as ErrorCode,
|
|
81
|
+
message: 'Authentication required',
|
|
82
|
+
detail: 'classified from RequestError',
|
|
83
|
+
transient: false,
|
|
84
|
+
acp_error: { code: -32000, message: 'Authentication required', data: undefined },
|
|
85
|
+
}
|
|
86
|
+
expect(e.kind).toBe('error')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('sessionId and turnId may be null (failure preceded session/new)', () => {
|
|
90
|
+
const e: TapEvent = {
|
|
91
|
+
kind: 'error',
|
|
92
|
+
agentId: 'alice',
|
|
93
|
+
sessionId: null,
|
|
94
|
+
turnId: null,
|
|
95
|
+
code: 'container_exit' as ErrorCode,
|
|
96
|
+
message: 'Agent container exited',
|
|
97
|
+
transient: true,
|
|
98
|
+
}
|
|
99
|
+
expect(e.sessionId).toBeNull()
|
|
100
|
+
})
|
|
101
|
+
})
|
package/src/turn-tracker.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
|
2
|
+
import type { ErrorCode } from './errors.js'
|
|
2
3
|
|
|
3
4
|
export type SessionUpdate = SessionNotification['update']
|
|
4
5
|
|
|
@@ -24,6 +25,17 @@ export type TapEvent =
|
|
|
24
25
|
turnId: string
|
|
25
26
|
stopReason: string
|
|
26
27
|
}
|
|
28
|
+
| {
|
|
29
|
+
kind: 'error'
|
|
30
|
+
agentId: string
|
|
31
|
+
sessionId: string | null
|
|
32
|
+
turnId: string | null
|
|
33
|
+
code: ErrorCode
|
|
34
|
+
message: string
|
|
35
|
+
detail?: string
|
|
36
|
+
transient: boolean
|
|
37
|
+
acp_error?: { code: number; message: string; data?: unknown }
|
|
38
|
+
}
|
|
27
39
|
|
|
28
40
|
export interface TurnTrackerOpts {
|
|
29
41
|
agentId: string
|
package/src/types.ts
CHANGED
|
@@ -8,6 +8,17 @@ import type {
|
|
|
8
8
|
} from '@agentclientprotocol/sdk'
|
|
9
9
|
import type { PresetName } from './presets.js'
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Structural copy of `@zooid/core`'s `AcpMount` shape. Kept here to avoid a
|
|
13
|
+
* back-edge to core; both files describe the same `{ path, target, mode }`
|
|
14
|
+
* tuple the docker runtime consumes.
|
|
15
|
+
*/
|
|
16
|
+
export interface AcpMountSpec {
|
|
17
|
+
path: string
|
|
18
|
+
target: string
|
|
19
|
+
mode: 'ro' | 'rw'
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
export interface AgentConfig {
|
|
12
23
|
id: string
|
|
13
24
|
/** Short-hand for a known ACP harness. Resolves to command/args via the preset registry. */
|
|
@@ -22,6 +33,8 @@ export interface AgentConfig {
|
|
|
22
33
|
cwd?: string
|
|
23
34
|
/** Container image. Forwarded to the spawn runtime; ignored by host-spawn paths. */
|
|
24
35
|
image?: string
|
|
36
|
+
/** Bind mounts. Forwarded to the spawn runtime; ignored by host-spawn paths. */
|
|
37
|
+
mounts?: AcpMountSpec[]
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
export interface PromptInput {
|