@zooid/core 0.7.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.
@@ -0,0 +1,143 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { randomUUID } from 'node:crypto'
3
+ import type {
4
+ ApprovalDecision,
5
+ ApprovalRequest,
6
+ } from '@zooid/acp-client'
7
+
8
+ export interface RegisteredApproval {
9
+ approvalId: string
10
+ agentName: string
11
+ sessionId: string
12
+ toolCallId: string
13
+ toolKind?: string
14
+ toolTitle?: string
15
+ toolInput?: unknown
16
+ options: ApprovalRequest['options']
17
+ decisionPromise: Promise<ApprovalDecision>
18
+ }
19
+
20
+ export interface RegisterOptions {
21
+ /** Wall-clock timeout in ms. 0 = no timeout. */
22
+ timeoutMs?: number
23
+ }
24
+
25
+ interface PendingEntry extends RegisteredApproval {
26
+ resolve(decision: ApprovalDecision): void
27
+ timer?: ReturnType<typeof setTimeout>
28
+ }
29
+
30
+ /**
31
+ * Correlates ACP approval requests (mid-prompt, originating in `AcpClient`)
32
+ * with HTTP/transport-side decisions. The transport listens to:
33
+ *
34
+ * - `'registered'` — fires after `register()` so the transport can emit
35
+ * `approval.request` on the right SSE stream.
36
+ * - `'timeout'` — fires when an entry is auto-cancelled by the timer
37
+ * so the transport can emit `approval.timeout`.
38
+ */
39
+ export class ApprovalCorrelator extends EventEmitter {
40
+ private readonly pending = new Map<string, PendingEntry>()
41
+ private readonly bySession = new Map<string, Set<string>>()
42
+
43
+ register(
44
+ agentName: string,
45
+ sessionId: string,
46
+ req: ApprovalRequest,
47
+ opts: RegisterOptions = {},
48
+ ): RegisteredApproval {
49
+ const approvalId = randomUUID()
50
+ let resolve!: (d: ApprovalDecision) => void
51
+ const decisionPromise = new Promise<ApprovalDecision>((r) => {
52
+ resolve = r
53
+ })
54
+ const entry: PendingEntry = {
55
+ approvalId,
56
+ agentName,
57
+ sessionId,
58
+ toolCallId: req.toolCallId,
59
+ toolKind: req.toolKind,
60
+ toolTitle: req.toolTitle,
61
+ toolInput: req.toolInput,
62
+ options: req.options,
63
+ decisionPromise,
64
+ resolve,
65
+ }
66
+ if (opts.timeoutMs && opts.timeoutMs > 0) {
67
+ entry.timer = setTimeout(() => {
68
+ if (this.pending.get(approvalId) !== entry) return
69
+ entry.resolve({ decision: 'cancel' })
70
+ this.pending.delete(approvalId)
71
+ this.bySession.get(sessionId)?.delete(approvalId)
72
+ this.emit('timeout', { approvalId, sessionId, agentName })
73
+ }, opts.timeoutMs)
74
+ entry.timer.unref?.()
75
+ }
76
+ this.pending.set(approvalId, entry)
77
+ let set = this.bySession.get(sessionId)
78
+ if (!set) {
79
+ set = new Set()
80
+ this.bySession.set(sessionId, set)
81
+ }
82
+ set.add(approvalId)
83
+ this.emit('registered', this.toPublic(entry))
84
+ return this.toPublic(entry)
85
+ }
86
+
87
+ resolve(
88
+ sessionId: string,
89
+ approvalId: string,
90
+ decision: ApprovalDecision,
91
+ ): boolean {
92
+ const entry = this.pending.get(approvalId)
93
+ if (!entry || entry.sessionId !== sessionId) return false
94
+ if (entry.timer) clearTimeout(entry.timer)
95
+ entry.resolve(decision)
96
+ this.pending.delete(approvalId)
97
+ this.bySession.get(sessionId)?.delete(approvalId)
98
+ return true
99
+ }
100
+
101
+ cancelSession(sessionId: string): void {
102
+ const ids = this.bySession.get(sessionId)
103
+ if (!ids) return
104
+ for (const id of [...ids]) {
105
+ const entry = this.pending.get(id)
106
+ if (entry) {
107
+ if (entry.timer) clearTimeout(entry.timer)
108
+ entry.resolve({ decision: 'cancel' })
109
+ this.pending.delete(id)
110
+ }
111
+ }
112
+ this.bySession.delete(sessionId)
113
+ }
114
+
115
+ listPending(sessionId: string): RegisteredApproval[] {
116
+ const ids = this.bySession.get(sessionId)
117
+ if (!ids) return []
118
+ const out: RegisteredApproval[] = []
119
+ for (const id of ids) {
120
+ const entry = this.pending.get(id)
121
+ if (entry) out.push(this.toPublic(entry))
122
+ }
123
+ return out
124
+ }
125
+
126
+ size(): number {
127
+ return this.pending.size
128
+ }
129
+
130
+ private toPublic(entry: PendingEntry): RegisteredApproval {
131
+ return {
132
+ approvalId: entry.approvalId,
133
+ agentName: entry.agentName,
134
+ sessionId: entry.sessionId,
135
+ toolCallId: entry.toolCallId,
136
+ toolKind: entry.toolKind,
137
+ toolTitle: entry.toolTitle,
138
+ toolInput: entry.toolInput,
139
+ options: entry.options,
140
+ decisionPromise: entry.decisionPromise,
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,35 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { findConfigFile } from './config.js'
6
+
7
+ describe('findConfigFile — zooid.yaml migration', () => {
8
+ let dir: string
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(join(tmpdir(), 'zooid-rename-'))
11
+ })
12
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
13
+
14
+ it('finds zooid.yaml when present', () => {
15
+ writeFileSync(join(dir, 'zooid.yaml'), 'runtime: local\n')
16
+ const found = findConfigFile(dir)
17
+ expect(found?.path).toBe(join(dir, 'zooid.yaml'))
18
+ })
19
+
20
+ it('throws a migration error when only workforce.yaml is present', () => {
21
+ writeFileSync(join(dir, 'workforce.yaml'), 'runtime: local\n')
22
+ expect(() => findConfigFile(dir)).toThrow(/workforce\.yaml.*no longer supported.*zooid\.yaml.*ZOD045/i)
23
+ })
24
+
25
+ it('returns null when neither file exists', () => {
26
+ expect(findConfigFile(dir)).toBeNull()
27
+ })
28
+
29
+ it('prefers zooid.yaml when both exist (and does not throw)', () => {
30
+ writeFileSync(join(dir, 'zooid.yaml'), 'runtime: local\n')
31
+ writeFileSync(join(dir, 'workforce.yaml'), 'runtime: local\n')
32
+ const found = findConfigFile(dir)
33
+ expect(found?.path).toBe(join(dir, 'zooid.yaml'))
34
+ })
35
+ })