@zooid/acp-client 0.7.4 → 0.9.0
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/command-discovery.integration.test.ts +66 -0
- package/src/errors.ts +2 -1
- package/src/event-mapping.test.ts +21 -0
- package/src/event-mapping.ts +11 -0
- package/src/index.ts +2 -0
- package/src/planning-tools.integration.test.ts +107 -0
- package/src/types.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/acp-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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",
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { AcpClient } from './acp-client.js'
|
|
3
|
+
import type { PresetName } from './index.js'
|
|
4
|
+
import type { TapEvent } from './turn-tracker.js'
|
|
5
|
+
|
|
6
|
+
const E2E = process.env.ZOOID_ACP_E2E === '1'
|
|
7
|
+
const TURN_TIMEOUT_MS = 120_000
|
|
8
|
+
|
|
9
|
+
interface Advert {
|
|
10
|
+
advertised: boolean
|
|
11
|
+
updateCount: number
|
|
12
|
+
totalCommands: number
|
|
13
|
+
sampleNames: string[]
|
|
14
|
+
firstSeenBeforeTurnEnd: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function probe(preset: PresetName): Promise<Advert> {
|
|
18
|
+
const taps: TapEvent[] = []
|
|
19
|
+
const client = new AcpClient({
|
|
20
|
+
agent: { id: `e2e-${preset}`, preset },
|
|
21
|
+
onEvent: () => {},
|
|
22
|
+
onApprovalRequest: async (req) => ({
|
|
23
|
+
decision: 'allow',
|
|
24
|
+
optionId: req.options[0]?.optionId ?? 'allow',
|
|
25
|
+
}),
|
|
26
|
+
onTap: (e) => taps.push(e),
|
|
27
|
+
})
|
|
28
|
+
await client.start()
|
|
29
|
+
try {
|
|
30
|
+
await client.prompt({ threadId: `e2e-cmd-${preset}`, content: [{ type: 'text', text: 'hi' }] })
|
|
31
|
+
} finally {
|
|
32
|
+
await client.stop()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const updates = taps.filter(
|
|
36
|
+
(t): t is Extract<TapEvent, { kind: 'session_update' }> =>
|
|
37
|
+
t.kind === 'session_update' && t.update.sessionUpdate === 'available_commands_update',
|
|
38
|
+
)
|
|
39
|
+
const names = new Set<string>()
|
|
40
|
+
for (const u of updates) {
|
|
41
|
+
for (const c of (u.update as { availableCommands?: Array<{ name?: string }> }).availableCommands ?? []) {
|
|
42
|
+
if (typeof c.name === 'string') names.add(c.name)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
advertised: updates.length > 0,
|
|
47
|
+
updateCount: updates.length,
|
|
48
|
+
totalCommands: names.size,
|
|
49
|
+
sampleNames: [...names].slice(0, 8),
|
|
50
|
+
firstSeenBeforeTurnEnd: updates.length > 0,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe.skipIf(!E2E)('cross-shim command advertisement (opt-in: ZOOID_ACP_E2E=1)', () => {
|
|
55
|
+
for (const preset of ['claude', 'codex', 'opencode'] as const) {
|
|
56
|
+
it(
|
|
57
|
+
`${preset}: record whether commands are advertised over ACP`,
|
|
58
|
+
async () => {
|
|
59
|
+
const a = await probe(preset)
|
|
60
|
+
console.log(`[commands] ${preset}:`, JSON.stringify(a))
|
|
61
|
+
if (a.advertised) expect(a.sampleNames.every((n) => n.length > 0)).toBe(true)
|
|
62
|
+
},
|
|
63
|
+
TURN_TIMEOUT_MS,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
})
|
package/src/errors.ts
CHANGED
|
@@ -10,12 +10,13 @@ export type ErrorCode =
|
|
|
10
10
|
| 'container_exit'
|
|
11
11
|
| 'acp_protocol'
|
|
12
12
|
| 'permission_denied'
|
|
13
|
+
| 'media_failed'
|
|
13
14
|
| 'internal'
|
|
14
15
|
|
|
15
16
|
export interface Classified {
|
|
16
17
|
code: ErrorCode
|
|
17
18
|
transient: boolean
|
|
18
|
-
/** Verbatim ACP RequestError triple — forwarded into
|
|
19
|
+
/** Verbatim ACP RequestError triple — forwarded into dev.zooid.error.acp_error. */
|
|
19
20
|
acp_error?: { code: number; message: string; data?: unknown }
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -97,6 +97,27 @@ describe('acpUpdateToAgentEvent', () => {
|
|
|
97
97
|
}
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
+
it('maps available_commands_update to an available_commands event', () => {
|
|
101
|
+
const event = acpUpdateToAgentEvent({
|
|
102
|
+
sessionId: 's-1',
|
|
103
|
+
update: {
|
|
104
|
+
sessionUpdate: 'available_commands_update',
|
|
105
|
+
availableCommands: [
|
|
106
|
+
{ name: 'plan', description: 'Switch to plan mode' },
|
|
107
|
+
{ name: 'compact', description: 'Compact the context', input: null },
|
|
108
|
+
],
|
|
109
|
+
} as never,
|
|
110
|
+
})
|
|
111
|
+
expect(event).toEqual<AgentEvent>({
|
|
112
|
+
type: 'available_commands',
|
|
113
|
+
sessionId: 's-1',
|
|
114
|
+
commands: [
|
|
115
|
+
{ name: 'plan', description: 'Switch to plan mode' },
|
|
116
|
+
{ name: 'compact', description: 'Compact the context' },
|
|
117
|
+
],
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
100
121
|
it('returns null for unknown update variants (forward-compat)', () => {
|
|
101
122
|
const event = acpUpdateToAgentEvent({
|
|
102
123
|
sessionId: 's-1',
|
package/src/event-mapping.ts
CHANGED
|
@@ -47,6 +47,17 @@ export function acpUpdateToAgentEvent(
|
|
|
47
47
|
}
|
|
48
48
|
case 'plan':
|
|
49
49
|
return { type: 'plan', sessionId, entries: update.entries }
|
|
50
|
+
case 'available_commands_update': {
|
|
51
|
+
const u = update as { availableCommands?: Array<{ name: string; description: string }> }
|
|
52
|
+
return {
|
|
53
|
+
type: 'available_commands',
|
|
54
|
+
sessionId,
|
|
55
|
+
commands: (u.availableCommands ?? []).map((c) => ({
|
|
56
|
+
name: c.name,
|
|
57
|
+
description: c.description,
|
|
58
|
+
})),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
50
61
|
default:
|
|
51
62
|
return null
|
|
52
63
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { AcpClient } from './acp-client.js'
|
|
3
|
+
import type { AgentEvent, PresetName } from './index.js'
|
|
4
|
+
|
|
5
|
+
const E2E = process.env.ZOOID_ACP_E2E === '1'
|
|
6
|
+
const PROMPT =
|
|
7
|
+
"let's test your planning tools. Using your tasks/planning tools, make a grocery " +
|
|
8
|
+
'list with bananas, bread, and milk, and then complete it.'
|
|
9
|
+
|
|
10
|
+
// Per-shim turn budget — real model calls are slow.
|
|
11
|
+
const TURN_TIMEOUT_MS = 180_000
|
|
12
|
+
|
|
13
|
+
interface PlanLike {
|
|
14
|
+
content: string
|
|
15
|
+
status: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Recognize a planning tool call by its rawInput shape (tool *kind* is an opaque
|
|
19
|
+
// ACP enum like "other"/"think", so we key on the payload, not the name).
|
|
20
|
+
function planFromToolInput(rawInput: unknown): PlanLike[] | null {
|
|
21
|
+
if (!rawInput || typeof rawInput !== 'object') return null
|
|
22
|
+
const r = rawInput as Record<string, unknown>
|
|
23
|
+
if (Array.isArray(r.todos)) {
|
|
24
|
+
return r.todos
|
|
25
|
+
.map((t) => t as Record<string, unknown>)
|
|
26
|
+
.filter((t) => typeof t.content === 'string' && typeof t.status === 'string')
|
|
27
|
+
.map((t) => ({ content: t.content as string, status: t.status as string }))
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(r.plan)) {
|
|
30
|
+
return r.plan
|
|
31
|
+
.map((p) => p as Record<string, unknown>)
|
|
32
|
+
.filter((p) => typeof p.step === 'string' && typeof p.status === 'string')
|
|
33
|
+
.map((p) => ({ content: p.step as string, status: p.status as string }))
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Classification {
|
|
39
|
+
viaPlanEvent: boolean
|
|
40
|
+
viaToolCall: boolean
|
|
41
|
+
sawInProgressOrCompleted: boolean
|
|
42
|
+
snapshots: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function classify(events: AgentEvent[]): Classification {
|
|
46
|
+
let viaPlanEvent = false
|
|
47
|
+
let viaToolCall = false
|
|
48
|
+
let sawProgress = false
|
|
49
|
+
let snapshots = 0
|
|
50
|
+
for (const ev of events) {
|
|
51
|
+
let entries: PlanLike[] | null = null
|
|
52
|
+
if (ev.type === 'plan') {
|
|
53
|
+
viaPlanEvent = true
|
|
54
|
+
entries = ev.entries.map((e) => ({ content: e.content, status: e.status }))
|
|
55
|
+
} else if (ev.type === 'tool_call' || ev.type === 'tool_call_update') {
|
|
56
|
+
entries = planFromToolInput(ev.rawInput)
|
|
57
|
+
if (entries) viaToolCall = true
|
|
58
|
+
}
|
|
59
|
+
if (entries) {
|
|
60
|
+
snapshots++
|
|
61
|
+
if (entries.some((e) => e.status === 'in_progress' || e.status === 'completed')) {
|
|
62
|
+
sawProgress = true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { viaPlanEvent, viaToolCall, sawInProgressOrCompleted: sawProgress, snapshots }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function runShim(preset: PresetName): Promise<Classification> {
|
|
70
|
+
const events: AgentEvent[] = []
|
|
71
|
+
const client = new AcpClient({
|
|
72
|
+
agent: { id: `e2e-${preset}`, preset },
|
|
73
|
+
onEvent: (e) => events.push(e),
|
|
74
|
+
// Auto-approve everything so file/exec tools don't block the turn.
|
|
75
|
+
onApprovalRequest: async (req) => ({
|
|
76
|
+
decision: 'allow',
|
|
77
|
+
optionId: req.options[0]?.optionId ?? 'allow',
|
|
78
|
+
}),
|
|
79
|
+
})
|
|
80
|
+
await client.start()
|
|
81
|
+
try {
|
|
82
|
+
await client.prompt({
|
|
83
|
+
threadId: `e2e-thread-${preset}`,
|
|
84
|
+
content: [{ type: 'text', text: PROMPT }],
|
|
85
|
+
})
|
|
86
|
+
} finally {
|
|
87
|
+
await client.stop()
|
|
88
|
+
}
|
|
89
|
+
return classify(events)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe.skipIf(!E2E)('cross-shim planning tools (opt-in: ZOOID_ACP_E2E=1)', () => {
|
|
93
|
+
for (const preset of ['claude', 'codex', 'opencode'] as const) {
|
|
94
|
+
it(
|
|
95
|
+
`${preset} surfaces a plan in some form, with a status transition`,
|
|
96
|
+
async () => {
|
|
97
|
+
const c = await runShim(preset)
|
|
98
|
+
// Record the finding (visible in test output) — this is the deliverable.
|
|
99
|
+
console.log(`[planning] ${preset}:`, JSON.stringify(c))
|
|
100
|
+
expect(c.viaPlanEvent || c.viaToolCall).toBe(true)
|
|
101
|
+
expect(c.snapshots).toBeGreaterThan(0)
|
|
102
|
+
expect(c.sawInProgressOrCompleted).toBe(true)
|
|
103
|
+
},
|
|
104
|
+
TURN_TIMEOUT_MS,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type {
|
|
|
6
6
|
ToolCallStatus,
|
|
7
7
|
ToolKind,
|
|
8
8
|
} from '@agentclientprotocol/sdk'
|
|
9
|
+
|
|
10
|
+
export type { ContentBlock }
|
|
9
11
|
import type { PresetName } from './presets.js'
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -58,6 +60,13 @@ export type AgentEvent =
|
|
|
58
60
|
| ToolCallEvent
|
|
59
61
|
| ToolCallUpdateEvent
|
|
60
62
|
| PlanEvent
|
|
63
|
+
| AvailableCommandsEvent
|
|
64
|
+
|
|
65
|
+
export interface AvailableCommandsEvent {
|
|
66
|
+
type: 'available_commands'
|
|
67
|
+
sessionId: string
|
|
68
|
+
commands: Array<{ name: string; description: string }>
|
|
69
|
+
}
|
|
61
70
|
|
|
62
71
|
export interface AgentMessageChunkEvent {
|
|
63
72
|
type: 'agent_message_chunk'
|