@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/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
+ }