Lands the lean-SDK direction (boocode_code_review_v2 §1 #9) behind a flag. Adds @anthropic-ai/claude-agent-sdk@0.3.159 (Commercial Terms, runtime dep). - PostgresSessionStore: clean-room impl of the SDK's real SessionStore type over a new claude_session_entries table. Typechecks against the SDK type; 8 DB-integration tests. - ClaudeSdkBackend (implements AgentBackend): one warm query() per (chat,claude) in streaming-input mode via a pushable async-iterable pump, sessionStore + resume continuity, pure mapSdkMessage->AgentEvent, session_id from init, usage/cost onto agent_sessions (backend CHECK gains 'claude_sdk'). - Routing env-gated by CLAUDE_SDK_BACKEND (default off) -> PTY path UNCHANGED. - Built against real SDK 0.3.159 types (install paid off: partial=stream_event needing includePartialMessages, MessageParam, result error arm). - Fix latent test-infra deadlock: serialize DB suites (fileParallelism:false). Coder 269 passing default / 290 with DB; tsc clean vs SDK types; builds clean. LIVE pump + resume + actual claude turn need a host smoke (CLAUDE_SDK_BACKEND=1 + claude binary + auth). zod peer-dep wants ^4 (workspace 3.25). Builds on v2.7.4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5.0 KiB
5.0 KiB
Claude Agent SDK backend + clean-room PostgresSessionStore (#9)
Status: in progress (started 2026-06-01)
Source: boocode_code_review_v2.md §1 #9, §5h/§5i (happy + SDK .d.ts). Decision §6.2: lean SDK.
SDK: @anthropic-ai/claude-agent-sdk@0.3.159 (installed, Commercial Terms — runtime dep OK, code
reference-only; the store is clean-room from the real interface, not vendored).
Replace BooCoder's one-shot PTY claude dispatch with a warm, resumable Claude-SDK backend. Two parts:
the clean-room session store (fully testable here) and the backend + wiring (live pump needs a host
smoke against real claude).
Ground-truth SDK API (from the installed sdk.d.ts)
query({ prompt: string | AsyncIterable<SDKUserMessage>, options?: Options }): QuerywhereQuery extends AsyncGenerator<SDKMessage, void>.Options:sessionStore?: SessionStore,resume?: string,model?,cwd?,pathToClaudeCodeExecutable?,canUseTool?,permissionMode?,env?,allowedTools?.SessionStore = { append(key, entries): Promise<void>; load(key): Promise<SessionStoreEntry[]|null>; listSessions?(projectKey): Promise<{sessionId,mtime}[]>; delete?(key): Promise<void>; listSubkeys?({projectKey,sessionId}): Promise<string[]> }.SessionKey = { projectKey: string; sessionId: string; subpath?: string }(undefined subpath = main transcript; empty string invalid — store maps undefined→'' internally).SessionStoreEntry = { type: string; uuid?: string; timestamp?: string; [k]: unknown }(opaque JSONL).- Messages:
SDKSystemMessage{subtype:'init'}carriessession_id(+ model/tools);SDKResultMessage(success/error) ends a turn withresult,usage,total_cost_usd;SDKPartialAssistantMessage/SDKAssistantMessagecarry text/thinking/tool blocks.
Part 1 — Clean-room PostgresSessionStore (testable now)
- Schema (
apps/coder/src/schema.sql): a generic append-only entry tableclaude_session_entries(id BIGSERIAL PK, project_key TEXT, session_id TEXT, subpath TEXT DEFAULT '', entry JSONB, created_at TIMESTAMPTZ DEFAULT clock_timestamp())+ index(project_key, session_id, subpath, id). (The store is generic per the SDK's key; the chat↔session ownership lives inagent_sessions, not here.) apps/coder/src/services/backends/claude-session-store.ts:PostgresSessionStoreimplementing the realSessionStoretype overSql.append= ordered multi-INSERT (id = order);load= SELECT ORDER BY id → array or null;listSessions= group main-transcript rows, mtime = max(created_at) ms;delete= scoped delete (subpath given → that subpath; omitted → whole session);listSubkeys= DISTINCT non-'' subpaths. Pure SQL, no SDK import needed beyond theSessionStoretype.- Tests
__tests__/claude-session-store.test.ts(DB-opt-in, mirrorcheckpoints.test.ts): append→load round-trip + order, null on unseen key, subpath isolation (main vs subagent), listSessions mtime, delete scoping, listSubkeys.
Part 2 — ClaudeSdkBackend + wiring (live pump needs host smoke)
agent_sessions.backendCHECK adds'claude_sdk'.apps/coder/src/services/backends/claude-sdk.ts: aClaudeSdkBackendimplementingAgentBackend(mirrorwarm-acp.ts/opencode-server.ts).ensureSessionresolves the resume id fromagent_sessions(chat_id,'claude').agent_session_id;promptdrives one persistentquery()in streaming-input mode (a pushableAsyncIterable<SDKUserMessage>fed per turn) with{ sessionStore, resume, model, cwd: worktreePath, pathToClaudeCodeExecutable: installPath }, reads theAsyncGenerator<SDKMessage>untilresult, capturessession_idfrom theinitmessage and persists it toagent_sessions. A puremapSdkMessage(msg): AgentEvent[](unit-tested) maps partial/assistant/tool/thinking → the existingAgentEventunion;result.usage/total_cost_usdaccumulate ontoagent_sessions(like opencode U.6).isBusy/closeSession/crash mirror the ACP backend.- Routing: add
claudeto the warm path (warm-acp-routing.tsor a siblingshouldUseClaudeSdk), with the existing PTYrunExternalAgentkept as the fallback (session-less creators + if the SDK backend fails to start). Provider registry: claude stays selectable; transport reflects the SDK path. - Frames + persistence identical to the warm-ACP path (
persistExternalAgentTurn, broker frames).
Verify
- Part 1:
pnpm -C apps/coder test+ DB-opt-in store tests against dev postgres; build clean. - Part 2:
pnpm -C apps/coder build+npx tsc -p apps/coder/tsconfig.json --noEmit(typechecks against the REAL SDK types) + pure-mapper unit tests. Live pump + resume across turns: host smoke against realclaude(auth required) — cannot run from the dev container.
Open flags
- SDK peer-deps want
zod@^4; workspace iszod@3.25.76(installed with a warning) — watch at runtime. pathToClaudeCodeExecutablefromavailable_agents.install_path; the SDK spawns the sameclaudebinary the PTY path uses. ANTHROPIC auth/env must reach the child (host concern).