@zooid/acp-client 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.
- package/LICENSE +21 -0
- package/package.json +40 -0
- package/src/acp-client.cancel.test.ts +44 -0
- package/src/acp-client.context.test.ts +93 -0
- package/src/acp-client.preset.test.ts +120 -0
- package/src/acp-client.runtime.test.ts +51 -0
- package/src/acp-client.session-load.test.ts +184 -0
- package/src/acp-client.tap.test.ts +36 -0
- package/src/acp-client.ts +361 -0
- package/src/agent-process.test.ts +103 -0
- package/src/agent-process.ts +49 -0
- package/src/event-mapping.test.ts +105 -0
- package/src/event-mapping.ts +69 -0
- package/src/index.ts +25 -0
- package/src/presets.test.ts +102 -0
- package/src/presets.ts +64 -0
- package/src/session-map.test.ts +54 -0
- package/src/session-map.ts +47 -0
- package/src/session-store.test.ts +115 -0
- package/src/session-store.ts +110 -0
- package/src/turn-tracker.test.ts +70 -0
- package/src/turn-tracker.ts +96 -0
- package/src/types.ts +103 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { AcpClient } from './acp-client.js'
|
|
2
|
+
export { AgentProcess } from './agent-process.js'
|
|
3
|
+
export { SessionMap } from './session-map.js'
|
|
4
|
+
export { PRESETS, resolvePreset, isPreset } from './presets.js'
|
|
5
|
+
export type { PresetName, PresetSpec } from './presets.js'
|
|
6
|
+
export {
|
|
7
|
+
acpUpdateToAgentEvent,
|
|
8
|
+
approvalDecisionToPermissionResponse,
|
|
9
|
+
} from './event-mapping.js'
|
|
10
|
+
export type {
|
|
11
|
+
AgentConfig,
|
|
12
|
+
AgentEvent,
|
|
13
|
+
ApprovalDecision,
|
|
14
|
+
ApprovalRequest,
|
|
15
|
+
AgentMessageChunkEvent,
|
|
16
|
+
PlanEvent,
|
|
17
|
+
PromptInput,
|
|
18
|
+
PromptResult,
|
|
19
|
+
ToolCallEvent,
|
|
20
|
+
ToolCallUpdateEvent,
|
|
21
|
+
} from './types.js'
|
|
22
|
+
export type { SessionKey, SessionRecord } from './session-map.js'
|
|
23
|
+
export type { AcpClientOptions } from './acp-client.js'
|
|
24
|
+
export { TurnTracker } from './turn-tracker.js'
|
|
25
|
+
export type { TapEvent, SessionUpdate, TurnTrackerOpts } from './turn-tracker.js'
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { PRESETS, resolvePreset, isPreset } from './presets.js'
|
|
3
|
+
|
|
4
|
+
describe('PRESETS registry', () => {
|
|
5
|
+
it('includes the ACP-native harnesses from SPEC.md', () => {
|
|
6
|
+
expect(PRESETS.opencode).toEqual({ command: 'opencode', args: ['acp'] })
|
|
7
|
+
expect(PRESETS.cline).toEqual({ command: 'cline', args: ['--acp'] })
|
|
8
|
+
expect(PRESETS.kiro).toEqual({ command: 'kiro', args: ['--acp'] })
|
|
9
|
+
expect(PRESETS.gemini).toEqual({ command: 'gemini', args: ['--acp'] })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('includes the vendored-shim harnesses', () => {
|
|
13
|
+
expect(PRESETS.claude).toEqual({
|
|
14
|
+
command: 'npx',
|
|
15
|
+
args: ['-y', '@agentclientprotocol/claude-agent-acp'],
|
|
16
|
+
})
|
|
17
|
+
expect(PRESETS.codex).toEqual({
|
|
18
|
+
command: 'npx',
|
|
19
|
+
args: ['-y', '@zed-industries/codex-acp'],
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('resolvePreset', () => {
|
|
25
|
+
it('returns the command/args tuple for a known preset', () => {
|
|
26
|
+
expect(resolvePreset('claude')).toEqual({
|
|
27
|
+
command: 'npx',
|
|
28
|
+
args: ['-y', '@agentclientprotocol/claude-agent-acp'],
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('throws a clear error for an unknown preset', () => {
|
|
33
|
+
expect(() => resolvePreset('made-up')).toThrow(/unknown ACP preset/i)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('error message lists the available presets', () => {
|
|
37
|
+
try {
|
|
38
|
+
resolvePreset('made-up')
|
|
39
|
+
} catch (e) {
|
|
40
|
+
const msg = (e as Error).message
|
|
41
|
+
expect(msg).toContain('claude')
|
|
42
|
+
expect(msg).toContain('opencode')
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns a fresh args array each call (no shared mutable state)', () => {
|
|
47
|
+
const a = resolvePreset('claude')
|
|
48
|
+
const b = resolvePreset('claude')
|
|
49
|
+
expect(a).not.toBe(b)
|
|
50
|
+
expect(a.args).not.toBe(b.args)
|
|
51
|
+
a.args.push('mutated')
|
|
52
|
+
expect(b.args).toEqual(['-y', '@agentclientprotocol/claude-agent-acp'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('appends --model <id> to claude when opts.model is set', () => {
|
|
56
|
+
const spec = resolvePreset('claude', { model: 'claude-sonnet-4-6' })
|
|
57
|
+
expect(spec.args).toEqual([
|
|
58
|
+
'-y',
|
|
59
|
+
'@agentclientprotocol/claude-agent-acp',
|
|
60
|
+
'--model',
|
|
61
|
+
'claude-sonnet-4-6',
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('appends --model <id> to codex when opts.model is set', () => {
|
|
66
|
+
const spec = resolvePreset('codex', { model: 'gpt-5.5' })
|
|
67
|
+
expect(spec.args).toEqual([
|
|
68
|
+
'-y',
|
|
69
|
+
'@zed-industries/codex-acp',
|
|
70
|
+
'--model',
|
|
71
|
+
'gpt-5.5',
|
|
72
|
+
])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('ignores opts.model for opencode (model lives in opencode.json)', () => {
|
|
76
|
+
const spec = resolvePreset('opencode', { model: 'opencode-go/kimi-k2.6' })
|
|
77
|
+
expect(spec.args).toEqual(['acp'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('returns the bare preset spec when no opts are given', () => {
|
|
81
|
+
const spec = resolvePreset('claude')
|
|
82
|
+
expect(spec.command).toBe('npx')
|
|
83
|
+
expect(spec.args).toEqual(['-y', '@agentclientprotocol/claude-agent-acp'])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('isPreset', () => {
|
|
88
|
+
it('returns true for known names', () => {
|
|
89
|
+
expect(isPreset('claude')).toBe(true)
|
|
90
|
+
expect(isPreset('opencode')).toBe(true)
|
|
91
|
+
})
|
|
92
|
+
it('returns false for unknown names', () => {
|
|
93
|
+
expect(isPreset('made-up')).toBe(false)
|
|
94
|
+
})
|
|
95
|
+
it('narrows the type so the value can index PRESETS', () => {
|
|
96
|
+
const name: string = 'claude'
|
|
97
|
+
if (isPreset(name)) {
|
|
98
|
+
const _entry = PRESETS[name]
|
|
99
|
+
expect(_entry.command).toBe('npx')
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
})
|
package/src/presets.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Registry of ACP agent presets. Each preset maps a short name (e.g. "claude")
|
|
2
|
+
// to the command + args needed to spawn that harness as an ACP agent.
|
|
3
|
+
//
|
|
4
|
+
// Categories:
|
|
5
|
+
// - ACP-native: invoked with a flag (opencode, cline, kiro, gemini)
|
|
6
|
+
// - Vendored shim: invoked via npx (claude, codex)
|
|
7
|
+
//
|
|
8
|
+
// See epics/002-ZOD019-acp-runtime/SPEC.md §"Agent compatibility matrix".
|
|
9
|
+
|
|
10
|
+
export interface PresetSpec {
|
|
11
|
+
command: string
|
|
12
|
+
args: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PRESETS_INTERNAL = {
|
|
16
|
+
opencode: { command: 'opencode', args: ['acp'] },
|
|
17
|
+
cline: { command: 'cline', args: ['--acp'] },
|
|
18
|
+
kiro: { command: 'kiro', args: ['--acp'] },
|
|
19
|
+
gemini: { command: 'gemini', args: ['--acp'] },
|
|
20
|
+
claude: { command: 'npx', args: ['-y', '@agentclientprotocol/claude-agent-acp'] },
|
|
21
|
+
codex: { command: 'npx', args: ['-y', '@zed-industries/codex-acp'] },
|
|
22
|
+
} as const satisfies Record<string, PresetSpec>
|
|
23
|
+
|
|
24
|
+
export type PresetName = keyof typeof PRESETS_INTERNAL
|
|
25
|
+
|
|
26
|
+
export const PRESETS: Record<PresetName, PresetSpec> = Object.freeze(
|
|
27
|
+
Object.fromEntries(
|
|
28
|
+
(Object.keys(PRESETS_INTERNAL) as PresetName[]).map((k) => [
|
|
29
|
+
k,
|
|
30
|
+
{ command: PRESETS_INTERNAL[k].command, args: [...PRESETS_INTERNAL[k].args] },
|
|
31
|
+
]),
|
|
32
|
+
),
|
|
33
|
+
) as Record<PresetName, PresetSpec>
|
|
34
|
+
|
|
35
|
+
export function isPreset(name: string): name is PresetName {
|
|
36
|
+
return Object.prototype.hasOwnProperty.call(PRESETS_INTERNAL, name)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ResolvePresetOpts {
|
|
40
|
+
/** Optional model string. Forwarded to the underlying shim as a `--model`
|
|
41
|
+
* flag where supported. Ignored for `opencode` (model lives in opencode.json). */
|
|
42
|
+
model?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// null = preset has its own model channel (opencode reads opencode.json); undefined = not implemented yet.
|
|
46
|
+
const MODEL_FLAG_PER_PRESET: Partial<Record<PresetName, string | null>> = {
|
|
47
|
+
claude: '--model',
|
|
48
|
+
codex: '--model',
|
|
49
|
+
opencode: null,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolvePreset(name: string, opts: ResolvePresetOpts = {}): PresetSpec {
|
|
53
|
+
if (!isPreset(name)) {
|
|
54
|
+
const known = Object.keys(PRESETS_INTERNAL).sort().join(', ')
|
|
55
|
+
throw new Error(`unknown ACP preset "${name}". Known presets: ${known}`)
|
|
56
|
+
}
|
|
57
|
+
const entry = PRESETS_INTERNAL[name]
|
|
58
|
+
const args: string[] = [...entry.args]
|
|
59
|
+
if (opts.model !== undefined) {
|
|
60
|
+
const flag = MODEL_FLAG_PER_PRESET[name]
|
|
61
|
+
if (flag) args.push(flag, opts.model)
|
|
62
|
+
}
|
|
63
|
+
return { command: entry.command, args }
|
|
64
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { SessionMap, type SessionKey } from './session-map.js'
|
|
3
|
+
|
|
4
|
+
describe('SessionMap', () => {
|
|
5
|
+
let map: SessionMap
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
map = new SessionMap()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const key = (threadId: string, agentId: string): SessionKey => ({ threadId, agentId })
|
|
12
|
+
|
|
13
|
+
it('returns undefined for missing keys', () => {
|
|
14
|
+
expect(map.get(key('t1', 'a1'))).toBeUndefined()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('stores and retrieves sessions by (thread, agent) tuple', () => {
|
|
18
|
+
const session = { sessionId: 's-1', startedAt: Date.now() }
|
|
19
|
+
map.set(key('t1', 'a1'), session)
|
|
20
|
+
expect(map.get(key('t1', 'a1'))).toEqual(session)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('keeps sessions for different agents in the same thread separate', () => {
|
|
24
|
+
map.set(key('t1', 'a1'), { sessionId: 's-1', startedAt: 0 })
|
|
25
|
+
map.set(key('t1', 'a2'), { sessionId: 's-2', startedAt: 0 })
|
|
26
|
+
expect(map.get(key('t1', 'a1'))?.sessionId).toBe('s-1')
|
|
27
|
+
expect(map.get(key('t1', 'a2'))?.sessionId).toBe('s-2')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('keeps sessions for the same agent in different threads separate', () => {
|
|
31
|
+
map.set(key('t1', 'a1'), { sessionId: 's-1', startedAt: 0 })
|
|
32
|
+
map.set(key('t2', 'a1'), { sessionId: 's-2', startedAt: 0 })
|
|
33
|
+
expect(map.get(key('t1', 'a1'))?.sessionId).toBe('s-1')
|
|
34
|
+
expect(map.get(key('t2', 'a1'))?.sessionId).toBe('s-2')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('removes a session by key', () => {
|
|
38
|
+
const k = key('t1', 'a1')
|
|
39
|
+
map.set(k, { sessionId: 's-1', startedAt: 0 })
|
|
40
|
+
map.delete(k)
|
|
41
|
+
expect(map.get(k)).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('lists all sessions for a given agent across threads', () => {
|
|
45
|
+
map.set(key('t1', 'a1'), { sessionId: 's-1', startedAt: 0 })
|
|
46
|
+
map.set(key('t2', 'a1'), { sessionId: 's-2', startedAt: 0 })
|
|
47
|
+
map.set(key('t1', 'a2'), { sessionId: 's-3', startedAt: 0 })
|
|
48
|
+
const ids = map
|
|
49
|
+
.listForAgent('a1')
|
|
50
|
+
.map((s) => s.sessionId)
|
|
51
|
+
.sort()
|
|
52
|
+
expect(ids).toEqual(['s-1', 's-2'])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface SessionKey {
|
|
2
|
+
threadId: string
|
|
3
|
+
agentId: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SessionRecord {
|
|
7
|
+
sessionId: string
|
|
8
|
+
startedAt: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const keyOf = (k: SessionKey) => `${k.threadId}\x00${k.agentId}`
|
|
12
|
+
|
|
13
|
+
interface InternalRecord extends SessionRecord {
|
|
14
|
+
agentId: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SessionMap {
|
|
18
|
+
private readonly inner = new Map<string, InternalRecord>()
|
|
19
|
+
|
|
20
|
+
get(k: SessionKey): SessionRecord | undefined {
|
|
21
|
+
const v = this.inner.get(keyOf(k))
|
|
22
|
+
if (!v) return undefined
|
|
23
|
+
return { sessionId: v.sessionId, startedAt: v.startedAt }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
set(k: SessionKey, value: SessionRecord): void {
|
|
27
|
+
this.inner.set(keyOf(k), {
|
|
28
|
+
sessionId: value.sessionId,
|
|
29
|
+
startedAt: value.startedAt,
|
|
30
|
+
agentId: k.agentId,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
delete(k: SessionKey): void {
|
|
35
|
+
this.inner.delete(keyOf(k))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
listForAgent(agentId: string): SessionRecord[] {
|
|
39
|
+
const out: SessionRecord[] = []
|
|
40
|
+
for (const v of this.inner.values()) {
|
|
41
|
+
if (v.agentId === agentId) {
|
|
42
|
+
out.push({ sessionId: v.sessionId, startedAt: v.startedAt })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { mkdtemp, readFile, writeFile, mkdir, rm } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { JsonFileSessionStore } from './session-store.js'
|
|
6
|
+
|
|
7
|
+
describe('JsonFileSessionStore', () => {
|
|
8
|
+
let dir: string
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
dir = await mkdtemp(join(tmpdir(), 'zooid-session-store-'))
|
|
11
|
+
})
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await rm(dir, { recursive: true, force: true })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns undefined for an unseen threadId on a fresh store', async () => {
|
|
17
|
+
const store = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
18
|
+
await store.load()
|
|
19
|
+
expect(store.get('$thread1')).toBeUndefined()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('persists a write and is readable from a new instance', async () => {
|
|
23
|
+
const a = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
24
|
+
await a.load()
|
|
25
|
+
await a.set('$thread1', 'sess_aaa')
|
|
26
|
+
|
|
27
|
+
const b = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
28
|
+
await b.load()
|
|
29
|
+
expect(b.get('$thread1')).toBe('sess_aaa')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('overwrites an existing threadId on set()', async () => {
|
|
33
|
+
const store = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
34
|
+
await store.load()
|
|
35
|
+
await store.set('$thread1', 'sess_aaa')
|
|
36
|
+
await store.set('$thread1', 'sess_bbb')
|
|
37
|
+
expect(store.get('$thread1')).toBe('sess_bbb')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('delete() removes a threadId, persists, survives reload', async () => {
|
|
41
|
+
const a = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
42
|
+
await a.load()
|
|
43
|
+
await a.set('$thread1', 'sess_aaa')
|
|
44
|
+
await a.delete('$thread1')
|
|
45
|
+
|
|
46
|
+
const b = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
47
|
+
await b.load()
|
|
48
|
+
expect(b.get('$thread1')).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('creates the dir on first write if it does not yet exist', async () => {
|
|
52
|
+
const nested = join(dir, 'agents', 'docs')
|
|
53
|
+
const store = new JsonFileSessionStore({ agentId: 'docs', dir: nested })
|
|
54
|
+
await store.load()
|
|
55
|
+
await store.set('$t', 'sess_x')
|
|
56
|
+
const raw = await readFile(join(nested, 'sessions.json'), 'utf8')
|
|
57
|
+
expect(JSON.parse(raw).agent_id).toBe('docs')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('treats a missing file as empty (no throw)', async () => {
|
|
61
|
+
const store = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
62
|
+
await expect(store.load()).resolves.toBeUndefined()
|
|
63
|
+
expect(store.get('anything')).toBeUndefined()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('treats a corrupted JSON file as empty (logs, no throw)', async () => {
|
|
67
|
+
await mkdir(dir, { recursive: true })
|
|
68
|
+
await writeFile(join(dir, 'sessions.json'), 'this is not json{', 'utf8')
|
|
69
|
+
const store = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
70
|
+
await expect(store.load()).resolves.toBeUndefined()
|
|
71
|
+
expect(store.get('anything')).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('treats a file with mismatched agent_id as empty', async () => {
|
|
75
|
+
await mkdir(dir, { recursive: true })
|
|
76
|
+
await writeFile(
|
|
77
|
+
join(dir, 'sessions.json'),
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
version: 1,
|
|
80
|
+
agent_id: 'someone-else',
|
|
81
|
+
sessions: [{ thread_id: '$t', session_id: 'sess_x', updated_at: '2026-05-07T00:00:00Z' }],
|
|
82
|
+
}),
|
|
83
|
+
'utf8',
|
|
84
|
+
)
|
|
85
|
+
const store = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
86
|
+
await store.load()
|
|
87
|
+
expect(store.get('$t')).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('writes atomically (temp file then rename) and produces valid JSON', async () => {
|
|
91
|
+
const store = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
92
|
+
await store.load()
|
|
93
|
+
await store.set('$t', 'sess_x')
|
|
94
|
+
const raw = await readFile(join(dir, 'sessions.json'), 'utf8')
|
|
95
|
+
const parsed = JSON.parse(raw) as { version: number; agent_id: string; sessions: unknown[] }
|
|
96
|
+
expect(parsed.version).toBe(1)
|
|
97
|
+
expect(parsed.agent_id).toBe('docs')
|
|
98
|
+
expect(parsed.sessions).toHaveLength(1)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('serialises concurrent set() calls (last write wins, file stays valid)', async () => {
|
|
102
|
+
const store = new JsonFileSessionStore({ agentId: 'docs', dir })
|
|
103
|
+
await store.load()
|
|
104
|
+
await Promise.all([
|
|
105
|
+
store.set('$t1', 'sess_1'),
|
|
106
|
+
store.set('$t2', 'sess_2'),
|
|
107
|
+
store.set('$t3', 'sess_3'),
|
|
108
|
+
])
|
|
109
|
+
const raw = await readFile(join(dir, 'sessions.json'), 'utf8')
|
|
110
|
+
expect(() => JSON.parse(raw)).not.toThrow()
|
|
111
|
+
expect(store.get('$t1')).toBe('sess_1')
|
|
112
|
+
expect(store.get('$t2')).toBe('sess_2')
|
|
113
|
+
expect(store.get('$t3')).toBe('sess_3')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface SessionStoreOptions {
|
|
5
|
+
/** Stable agent id; written into the file as a sanity-check on load. */
|
|
6
|
+
agentId: string
|
|
7
|
+
/** Directory holding `sessions.json`. Typically `<dataRoot>/agents/<agentId>/`. */
|
|
8
|
+
dir: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface StoredFile {
|
|
12
|
+
version: 1
|
|
13
|
+
agent_id: string
|
|
14
|
+
sessions: Array<{
|
|
15
|
+
thread_id: string
|
|
16
|
+
session_id: string
|
|
17
|
+
updated_at: string
|
|
18
|
+
}>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Per-agent JSON-file-backed map of `threadId → sessionId`. In-memory map is
|
|
23
|
+
* the source of truth at runtime; writes are flushed atomically (write to a
|
|
24
|
+
* temp file then rename) and serialised so concurrent set/delete calls never
|
|
25
|
+
* produce a partial file.
|
|
26
|
+
*
|
|
27
|
+
* Scale ceiling: every set rewrites the whole file. ZOD042 will swap this
|
|
28
|
+
* impl for a SQLite-backed one with the same surface beyond ~10k threads.
|
|
29
|
+
*/
|
|
30
|
+
export class JsonFileSessionStore {
|
|
31
|
+
private readonly mem = new Map<string, string>()
|
|
32
|
+
private readonly path: string
|
|
33
|
+
private writeChain: Promise<void> = Promise.resolve()
|
|
34
|
+
|
|
35
|
+
constructor(private readonly opts: SessionStoreOptions) {
|
|
36
|
+
this.path = join(opts.dir, 'sessions.json')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async load(): Promise<void> {
|
|
40
|
+
let raw: string
|
|
41
|
+
try {
|
|
42
|
+
raw = await readFile(this.path, 'utf8')
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return
|
|
45
|
+
console.warn(`[acp-client:${this.opts.agentId}] sessions.json read failed:`, err)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
let parsed: StoredFile
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(raw) as StoredFile
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.warn(`[acp-client:${this.opts.agentId}] sessions.json corrupt; treating as empty:`, err)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
if (parsed.version !== 1) {
|
|
56
|
+
console.warn(
|
|
57
|
+
`[acp-client:${this.opts.agentId}] sessions.json version=${parsed.version} unknown; treating as empty`,
|
|
58
|
+
)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (parsed.agent_id !== this.opts.agentId) {
|
|
62
|
+
console.warn(
|
|
63
|
+
`[acp-client:${this.opts.agentId}] sessions.json agent_id mismatch ` +
|
|
64
|
+
`(file=${parsed.agent_id}); treating as empty`,
|
|
65
|
+
)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
for (const row of parsed.sessions ?? []) {
|
|
69
|
+
if (typeof row.thread_id === 'string' && typeof row.session_id === 'string') {
|
|
70
|
+
this.mem.set(row.thread_id, row.session_id)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get(threadId: string): string | undefined {
|
|
76
|
+
return this.mem.get(threadId)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async set(threadId: string, sessionId: string): Promise<void> {
|
|
80
|
+
this.mem.set(threadId, sessionId)
|
|
81
|
+
await this.flush()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async delete(threadId: string): Promise<void> {
|
|
85
|
+
if (!this.mem.has(threadId)) return
|
|
86
|
+
this.mem.delete(threadId)
|
|
87
|
+
await this.flush()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async flush(): Promise<void> {
|
|
91
|
+
const next = (this.writeChain = this.writeChain.then(() => this.writeNow()))
|
|
92
|
+
return next
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async writeNow(): Promise<void> {
|
|
96
|
+
await mkdir(this.opts.dir, { recursive: true })
|
|
97
|
+
const file: StoredFile = {
|
|
98
|
+
version: 1,
|
|
99
|
+
agent_id: this.opts.agentId,
|
|
100
|
+
sessions: Array.from(this.mem.entries()).map(([thread_id, session_id]) => ({
|
|
101
|
+
thread_id,
|
|
102
|
+
session_id,
|
|
103
|
+
updated_at: new Date().toISOString(),
|
|
104
|
+
})),
|
|
105
|
+
}
|
|
106
|
+
const tmp = `${this.path}.tmp-${process.pid}-${Date.now()}`
|
|
107
|
+
await writeFile(tmp, JSON.stringify(file, null, 2), 'utf8')
|
|
108
|
+
await rename(tmp, this.path)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { TurnTracker, type TapEvent } from './turn-tracker.js'
|
|
3
|
+
|
|
4
|
+
describe('TurnTracker', () => {
|
|
5
|
+
it('emits turn_started → session_update* → turn_completed in order', () => {
|
|
6
|
+
const seen: TapEvent[] = []
|
|
7
|
+
const t = new TurnTracker({
|
|
8
|
+
agentId: 'docs',
|
|
9
|
+
onTap: (e) => seen.push(e),
|
|
10
|
+
newTurnId: () => 'turn_001',
|
|
11
|
+
})
|
|
12
|
+
const turnId = t.startTurn({ sessionId: 'sess_a', promptText: 'hi' })
|
|
13
|
+
expect(turnId).toBe('turn_001')
|
|
14
|
+
t.observeUpdate('sess_a', {
|
|
15
|
+
sessionUpdate: 'agent_message_chunk',
|
|
16
|
+
content: { type: 'text', text: 'hello' },
|
|
17
|
+
} as never)
|
|
18
|
+
t.endTurn({ sessionId: 'sess_a', stopReason: 'end_turn' })
|
|
19
|
+
expect(seen.map((e) => e.kind)).toEqual([
|
|
20
|
+
'turn_started',
|
|
21
|
+
'session_update',
|
|
22
|
+
'turn_completed',
|
|
23
|
+
])
|
|
24
|
+
expect(seen[1].turnId).toBe('turn_001')
|
|
25
|
+
expect(seen[2].kind === 'turn_completed' && seen[2].stopReason).toBe('end_turn')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('tags concurrent sessions with their own turn ids', () => {
|
|
29
|
+
const seen: TapEvent[] = []
|
|
30
|
+
let n = 0
|
|
31
|
+
const t = new TurnTracker({
|
|
32
|
+
agentId: 'x',
|
|
33
|
+
onTap: (e) => seen.push(e),
|
|
34
|
+
newTurnId: () => `turn_${++n}`,
|
|
35
|
+
})
|
|
36
|
+
t.startTurn({ sessionId: 'a', promptText: 'p1' })
|
|
37
|
+
t.startTurn({ sessionId: 'b', promptText: 'p2' })
|
|
38
|
+
t.observeUpdate('a', { sessionUpdate: 'tool_call' } as never)
|
|
39
|
+
t.observeUpdate('b', { sessionUpdate: 'tool_call' } as never)
|
|
40
|
+
t.endTurn({ sessionId: 'a', stopReason: 'end_turn' })
|
|
41
|
+
t.endTurn({ sessionId: 'b', stopReason: 'cancelled' })
|
|
42
|
+
const updates = seen.filter((e) => e.kind === 'session_update')
|
|
43
|
+
expect(updates).toHaveLength(2)
|
|
44
|
+
expect(updates[0]).toMatchObject({ sessionId: 'a', turnId: 'turn_1' })
|
|
45
|
+
expect(updates[1]).toMatchObject({ sessionId: 'b', turnId: 'turn_2' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('emits update with turnId=null if no turn is active for that session', () => {
|
|
49
|
+
const seen: TapEvent[] = []
|
|
50
|
+
const t = new TurnTracker({
|
|
51
|
+
agentId: 'x',
|
|
52
|
+
onTap: (e) => seen.push(e),
|
|
53
|
+
newTurnId: () => 't',
|
|
54
|
+
})
|
|
55
|
+
t.observeUpdate('orphan', { sessionUpdate: 'agent_message_chunk' } as never)
|
|
56
|
+
expect(seen).toHaveLength(1)
|
|
57
|
+
expect(seen[0]).toMatchObject({
|
|
58
|
+
kind: 'session_update',
|
|
59
|
+
sessionId: 'orphan',
|
|
60
|
+
turnId: null,
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('endTurn for an unknown session is a silent no-op (defensive)', () => {
|
|
65
|
+
const onTap = vi.fn()
|
|
66
|
+
const t = new TurnTracker({ agentId: 'x', onTap, newTurnId: () => 't' })
|
|
67
|
+
expect(() => t.endTurn({ sessionId: 'nope', stopReason: 'end_turn' })).not.toThrow()
|
|
68
|
+
expect(onTap).not.toHaveBeenCalled()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
|
2
|
+
|
|
3
|
+
export type SessionUpdate = SessionNotification['update']
|
|
4
|
+
|
|
5
|
+
export type TapEvent =
|
|
6
|
+
| {
|
|
7
|
+
kind: 'turn_started'
|
|
8
|
+
agentId: string
|
|
9
|
+
sessionId: string
|
|
10
|
+
turnId: string
|
|
11
|
+
promptText: string
|
|
12
|
+
}
|
|
13
|
+
| {
|
|
14
|
+
kind: 'session_update'
|
|
15
|
+
agentId: string
|
|
16
|
+
sessionId: string
|
|
17
|
+
turnId: string | null
|
|
18
|
+
update: SessionUpdate
|
|
19
|
+
}
|
|
20
|
+
| {
|
|
21
|
+
kind: 'turn_completed'
|
|
22
|
+
agentId: string
|
|
23
|
+
sessionId: string
|
|
24
|
+
turnId: string
|
|
25
|
+
stopReason: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TurnTrackerOpts {
|
|
29
|
+
agentId: string
|
|
30
|
+
onTap: (e: TapEvent) => void
|
|
31
|
+
/** Override for tests; defaults to crypto.randomUUID(). */
|
|
32
|
+
newTurnId?: () => string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class TurnTracker {
|
|
36
|
+
private readonly active = new Map<string, string>()
|
|
37
|
+
private readonly newTurnId: () => string
|
|
38
|
+
private readonly agentId: string
|
|
39
|
+
private readonly onTap: (e: TapEvent) => void
|
|
40
|
+
|
|
41
|
+
constructor(opts: TurnTrackerOpts) {
|
|
42
|
+
this.agentId = opts.agentId
|
|
43
|
+
this.onTap = opts.onTap
|
|
44
|
+
this.newTurnId =
|
|
45
|
+
opts.newTurnId ?? (() => `turn_${crypto.randomUUID().slice(0, 8)}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
startTurn({
|
|
49
|
+
sessionId,
|
|
50
|
+
promptText,
|
|
51
|
+
}: {
|
|
52
|
+
sessionId: string
|
|
53
|
+
promptText: string
|
|
54
|
+
}): string {
|
|
55
|
+
const turnId = this.newTurnId()
|
|
56
|
+
this.active.set(sessionId, turnId)
|
|
57
|
+
this.onTap({
|
|
58
|
+
kind: 'turn_started',
|
|
59
|
+
agentId: this.agentId,
|
|
60
|
+
sessionId,
|
|
61
|
+
turnId,
|
|
62
|
+
promptText,
|
|
63
|
+
})
|
|
64
|
+
return turnId
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
observeUpdate(sessionId: string, update: SessionUpdate): void {
|
|
68
|
+
const turnId = this.active.get(sessionId) ?? null
|
|
69
|
+
this.onTap({
|
|
70
|
+
kind: 'session_update',
|
|
71
|
+
agentId: this.agentId,
|
|
72
|
+
sessionId,
|
|
73
|
+
turnId,
|
|
74
|
+
update,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
endTurn({
|
|
79
|
+
sessionId,
|
|
80
|
+
stopReason,
|
|
81
|
+
}: {
|
|
82
|
+
sessionId: string
|
|
83
|
+
stopReason: string
|
|
84
|
+
}): void {
|
|
85
|
+
const turnId = this.active.get(sessionId)
|
|
86
|
+
if (!turnId) return
|
|
87
|
+
this.active.delete(sessionId)
|
|
88
|
+
this.onTap({
|
|
89
|
+
kind: 'turn_completed',
|
|
90
|
+
agentId: this.agentId,
|
|
91
|
+
sessionId,
|
|
92
|
+
turnId,
|
|
93
|
+
stopReason,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|