feat: Claude Agent SDK backend + clean-room PostgresSessionStore (v2.7.5)
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>
This commit is contained in:
68
openspec/changes/claude-sdk-sessionstore/proposal.md
Normal file
68
openspec/changes/claude-sdk-sessionstore/proposal.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 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 }): Query` where
|
||||
`Query 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'}` carries `session_id` (+ model/tools); `SDKResultMessage`
|
||||
(success/error) ends a turn with `result`, `usage`, `total_cost_usd`; `SDKPartialAssistantMessage` /
|
||||
`SDKAssistantMessage` carry text/thinking/tool blocks.
|
||||
|
||||
## Part 1 — Clean-room PostgresSessionStore (testable now)
|
||||
- Schema (`apps/coder/src/schema.sql`): a generic append-only entry table
|
||||
`claude_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 in
|
||||
`agent_sessions`, not here.)
|
||||
- `apps/coder/src/services/backends/claude-session-store.ts`: `PostgresSessionStore` implementing the
|
||||
real `SessionStore` type over `Sql`. `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 the `SessionStore` type.
|
||||
- Tests `__tests__/claude-session-store.test.ts` (DB-opt-in, mirror `checkpoints.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.backend` CHECK adds `'claude_sdk'`.
|
||||
- `apps/coder/src/services/backends/claude-sdk.ts`: a `ClaudeSdkBackend` implementing `AgentBackend`
|
||||
(mirror `warm-acp.ts`/`opencode-server.ts`). `ensureSession` resolves the resume id from
|
||||
`agent_sessions(chat_id,'claude').agent_session_id`; `prompt` drives one persistent `query()` in
|
||||
streaming-input mode (a pushable `AsyncIterable<SDKUserMessage>` fed per turn) with
|
||||
`{ sessionStore, resume, model, cwd: worktreePath, pathToClaudeCodeExecutable: installPath }`,
|
||||
reads the `AsyncGenerator<SDKMessage>` until `result`, captures `session_id` from the `init` message
|
||||
and persists it to `agent_sessions`. A pure `mapSdkMessage(msg): AgentEvent[]` (unit-tested) maps
|
||||
partial/assistant/tool/thinking → the existing `AgentEvent` union; `result.usage`/`total_cost_usd`
|
||||
accumulate onto `agent_sessions` (like opencode U.6). `isBusy`/`closeSession`/crash mirror the ACP
|
||||
backend.
|
||||
- Routing: add `claude` to the warm path (`warm-acp-routing.ts` or a sibling `shouldUseClaudeSdk`),
|
||||
with the existing PTY `runExternalAgent` kept 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 real `claude` (auth required) — cannot run from the dev container.**
|
||||
|
||||
## Open flags
|
||||
- SDK peer-deps want `zod@^4`; workspace is `zod@3.25.76` (installed with a warning) — watch at runtime.
|
||||
- `pathToClaudeCodeExecutable` from `available_agents.install_path`; the SDK spawns the same `claude`
|
||||
binary the PTY path uses. ANTHROPIC auth/env must reach the child (host concern).
|
||||
Reference in New Issue
Block a user