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:
2026-06-01 13:37:57 +00:00
parent 7e0ecde83d
commit f3a0197d6a
18 changed files with 1776 additions and 2 deletions

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { createPushable } from '../pushable-iterable.js';
/**
* The pushable async-iterable that feeds the Claude SDK's streaming-input query()
* one message per turn while staying open across turns. Tests cover the ordering
* contract (push/close/async-iterate) without any SDK shape.
*/
describe('createPushable — push/iterate ordering', () => {
it('yields buffered values in FIFO order then parks', async () => {
const p = createPushable<number>();
const it = p.iterable[Symbol.asyncIterator]();
p.push(1);
p.push(2);
expect(await it.next()).toEqual({ value: 1, done: false });
expect(await it.next()).toEqual({ value: 2, done: false });
// No more buffered → next() parks; resolve it by pushing.
const parked = it.next();
p.push(3);
expect(await parked).toEqual({ value: 3, done: false });
});
it('hands a value directly to a parked consumer (push after await)', async () => {
const p = createPushable<string>();
const it = p.iterable[Symbol.asyncIterator]();
const pending = it.next(); // parks immediately (empty buffer)
p.push('hello');
expect(await pending).toEqual({ value: 'hello', done: false });
});
it('close() resolves a parked consumer as done and reports done thereafter', async () => {
const p = createPushable<number>();
const it = p.iterable[Symbol.asyncIterator]();
const pending = it.next();
p.close();
expect(await pending).toEqual({ value: undefined, done: true });
expect(await it.next()).toEqual({ value: undefined, done: true });
expect(p.closed).toBe(true);
});
it('still drains values buffered BEFORE close', async () => {
const p = createPushable<number>();
const it = p.iterable[Symbol.asyncIterator]();
p.push(10);
p.push(20);
p.close();
expect(await it.next()).toEqual({ value: 10, done: false });
expect(await it.next()).toEqual({ value: 20, done: false });
expect(await it.next()).toEqual({ value: undefined, done: true });
});
it('drops values pushed after close', async () => {
const p = createPushable<number>();
const it = p.iterable[Symbol.asyncIterator]();
p.close();
p.push(99); // no-op
expect(await it.next()).toEqual({ value: undefined, done: true });
});
it('close() is idempotent', () => {
const p = createPushable<number>();
p.close();
expect(() => p.close()).not.toThrow();
expect(p.closed).toBe(true);
});
it('works with a for-await loop driven by interleaved pushes', async () => {
const p = createPushable<number>();
const seen: number[] = [];
const consumer = (async () => {
for await (const v of p.iterable) seen.push(v);
})();
p.push(1);
await Promise.resolve();
p.push(2);
await Promise.resolve();
p.close();
await consumer;
expect(seen).toEqual([1, 2]);
});
it('return() on the iterator closes the queue (for-await break)', async () => {
const p = createPushable<number>();
const it = p.iterable[Symbol.asyncIterator]();
p.push(1);
expect(await it.next()).toEqual({ value: 1, done: false });
// Simulate a `break` in for-await: the runtime calls return().
expect(await it.return!()).toEqual({ value: undefined, done: true });
expect(p.closed).toBe(true);
p.push(2); // dropped — queue is closed
expect(await it.next()).toEqual({ value: undefined, done: true });
});
});