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>
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
/**
|
|
* claude-sdk-sessionstore #9 (Part 2) — a tiny PURE pushable async-iterable.
|
|
*
|
|
* The Claude Agent SDK's streaming-input mode wants `query({ prompt })` where
|
|
* `prompt` is an `AsyncIterable<SDKUserMessage>`. To keep ONE `query()` generator
|
|
* alive across many turns (the "warm" property), the backend feeds it ONE user
|
|
* message per `prompt()` turn through a queue that stays open between turns and is
|
|
* only closed at `closeSession`/`dispose`. This is that queue.
|
|
*
|
|
* Semantics (the bit worth unit-testing — push/close/iterate ordering):
|
|
* - `push(v)` enqueues a value. If a consumer is parked in `await next()`, it's
|
|
* handed the value immediately; otherwise the value buffers in FIFO order.
|
|
* - The async iterator yields buffered/pushed values in push order, and PARKS
|
|
* (never busy-loops) when the buffer is empty — so the SDK generator waits for
|
|
* the next turn's message instead of seeing end-of-input.
|
|
* - `close()` ends the iterable: any parked consumer resolves `{done:true}` and
|
|
* all future `next()`s return done. Values pushed after close are dropped.
|
|
* - It's single-consumer (one `query()` reads it); concurrent consumers are not a
|
|
* supported shape and not needed here.
|
|
*
|
|
* No SDK import — generic over the pushed value `T` — so the pure push/close/iterate
|
|
* ordering is testable without the `SDKUserMessage` shape or a live binary.
|
|
*/
|
|
export interface Pushable<T> {
|
|
/** Enqueue a value (or hand it to a parked consumer). No-op after close. */
|
|
push(value: T): void;
|
|
/** End the iterable. Idempotent; a parked consumer resolves done. */
|
|
close(): void;
|
|
/** True once `close()` has been called. */
|
|
readonly closed: boolean;
|
|
/** The async-iterable the consumer (the SDK `query`) drives. */
|
|
readonly iterable: AsyncIterable<T>;
|
|
}
|
|
|
|
export function createPushable<T>(): Pushable<T> {
|
|
const buffer: T[] = [];
|
|
// A waiting consumer's resolver (null when none is parked). Single-consumer.
|
|
let pendingResolve: ((res: IteratorResult<T>) => void) | null = null;
|
|
let closed = false;
|
|
|
|
function push(value: T): void {
|
|
if (closed) return;
|
|
if (pendingResolve) {
|
|
const resolve = pendingResolve;
|
|
pendingResolve = null;
|
|
resolve({ value, done: false });
|
|
return;
|
|
}
|
|
buffer.push(value);
|
|
}
|
|
|
|
function close(): void {
|
|
if (closed) return;
|
|
closed = true;
|
|
if (pendingResolve) {
|
|
const resolve = pendingResolve;
|
|
pendingResolve = null;
|
|
resolve({ value: undefined, done: true });
|
|
}
|
|
}
|
|
|
|
const iterator: AsyncIterator<T> = {
|
|
next(): Promise<IteratorResult<T>> {
|
|
// Drain the buffer first (FIFO), regardless of close — buffered values
|
|
// pushed before close are still delivered.
|
|
if (buffer.length > 0) {
|
|
return Promise.resolve({ value: buffer.shift() as T, done: false });
|
|
}
|
|
if (closed) {
|
|
return Promise.resolve({ value: undefined, done: true });
|
|
}
|
|
// Park until the next push/close. Single-consumer: only one waiter at a time.
|
|
return new Promise<IteratorResult<T>>((resolve) => {
|
|
pendingResolve = resolve;
|
|
});
|
|
},
|
|
return(): Promise<IteratorResult<T>> {
|
|
// Consumer abandoned the loop (e.g. `break`) → close so a later push no-ops.
|
|
close();
|
|
return Promise.resolve({ value: undefined, done: true });
|
|
},
|
|
};
|
|
|
|
return {
|
|
push,
|
|
close,
|
|
get closed() {
|
|
return closed;
|
|
},
|
|
iterable: {
|
|
[Symbol.asyncIterator]() {
|
|
return iterator;
|
|
},
|
|
},
|
|
};
|
|
}
|