@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/acp-client",
3
- "version": "0.7.4",
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 eco.zoon.error.acp_error. */
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',
@@ -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
@@ -18,6 +18,8 @@ export type {
18
18
  ApprovalDecision,
19
19
  ApprovalRequest,
20
20
  AgentMessageChunkEvent,
21
+ AvailableCommandsEvent,
22
+ ContentBlock,
21
23
  PlanEvent,
22
24
  PromptInput,
23
25
  PromptResult,
@@ -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'