fabro/apps/fabro-web/app/lib/session-stream.ts
Bryan Helmkamp 296fbddec9
feat(api): add ask fabro session endpoints (#342)
## Summary

Adds the run-backed API surface needed for a real Ask Fabro sidebar: run
readiness metadata, detailed session projections, session-scoped event
listing/attach streaming, and turn control that exposes durable turn IDs
and machine-readable failures.

## What Changed

- Extended the OpenAPI contract and regenerated Rust/TypeScript clients
for `Run.ask_fabro`, `SessionDetail`, `SessionTurn`, paginated run
sessions, session event APIs, and optional client-supplied `turn_id`
values.
- Updated `fabro-types` and `fabro-store` so durable `run.session.*`
events project active turn state, transcript messages, and the latest
owning run event sequence.
- Implemented server routing for session details, `/events`, `/attach`,
turn conflict headers, typed turn failure codes, and cheap run readiness
decoration across run responses.
- Added browser helpers for POST turn streaming and session attach SSE
parsing, plus an exported generated `sessionsApi`.

## Verification

- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D
warnings`
- `cargo build --workspace`
- `cargo test -p fabro-types
run_session_turn_failed_defaults_code_for_old_events`
- `cargo test -p fabro-store run_sessions::tests`
- `cargo test -p fabro-api`
- `cargo test -p fabro-server --features test-support --test it
api::sessions`
- `cargo test -p fabro-server --features test-support --test it
api::runs`
- `cd apps/fabro-web && bun test app/lib/session-stream.test.ts`
- `cd apps/fabro-web && bun run typecheck`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 Codex (context unknown, medium reasoning) via
[Codex](https://openai.com/codex/)
2026-05-21 21:26:15 -04:00

141 lines
3.6 KiB
TypeScript

import {
SessionsApiAxiosParamCreator,
type EventEnvelope,
type SubmitTurnRequest,
} from "@qltysh/fabro-api-client";
import {
apiErrorFromFetchResponse,
generatedApiConfiguration,
} from "./api-client";
export type SessionStreamEvent = EventEnvelope;
type FetchLike = (
input: string,
init?: RequestInit,
) => Promise<Response>;
interface SessionStreamOptions {
sessionId: string;
signal?: AbortSignal;
fetchImpl?: FetchLike;
onEvent: (event: SessionStreamEvent) => void;
}
export interface StreamSessionTurnOptions extends SessionStreamOptions {
input: string;
turnId?: string;
}
export interface StreamSessionTurnResult {
turnId: string | null;
}
export interface AttachSessionEventsOptions extends SessionStreamOptions {
sinceSeq?: number;
}
export async function streamSessionTurn({
sessionId,
input,
turnId,
signal,
fetchImpl = fetch,
onEvent,
}: StreamSessionTurnOptions): Promise<StreamSessionTurnResult> {
const body: SubmitTurnRequest = { input };
if (turnId) body.turn_id = turnId;
const request = await SessionsApiAxiosParamCreator(
generatedApiConfiguration,
).submitSessionTurn(sessionId, body, { signal });
const response = await fetchImpl(request.url, fetchInitFromAxiosRequest(request.options));
await throwIfApiError(response);
await readEventStream(response, onEvent);
return { turnId: response.headers.get("x-fabro-turn-id") };
}
export async function attachSessionEvents({
sessionId,
sinceSeq,
signal,
fetchImpl = fetch,
onEvent,
}: AttachSessionEventsOptions): Promise<void> {
const request = await SessionsApiAxiosParamCreator(
generatedApiConfiguration,
).attachSessionEvents(sessionId, sinceSeq, { signal });
const response = await fetchImpl(request.url, fetchInitFromAxiosRequest(request.options));
await throwIfApiError(response);
await readEventStream(response, onEvent);
}
async function throwIfApiError(response: Response): Promise<void> {
const error = await apiErrorFromFetchResponse(response);
if (error) throw error;
}
function fetchInitFromAxiosRequest(options: {
method?: string;
headers?: unknown;
data?: unknown;
signal?: unknown;
}): RequestInit {
const init: RequestInit = {
method: options.method,
credentials: "same-origin",
headers: options.headers as HeadersInit,
signal: options.signal as AbortSignal | undefined,
};
if (options.data !== undefined) {
init.body = typeof options.data === "string"
? options.data
: JSON.stringify(options.data);
}
return init;
}
async function readEventStream(
response: Response,
onEvent: (event: SessionStreamEvent) => void,
): Promise<void> {
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
buffer = drainSseBuffer(buffer, onEvent);
}
buffer += decoder.decode();
drainSseBuffer(`${buffer}\n\n`, onEvent);
}
function drainSseBuffer(
buffer: string,
onEvent: (event: SessionStreamEvent) => void,
): string {
let cursor = 0;
while (true) {
const match = /\r?\n\r?\n/g.exec(buffer.slice(cursor));
if (!match) return buffer.slice(cursor);
const next = cursor + match.index;
const frame = buffer.slice(cursor, next);
cursor = next + match[0].length;
const data = frame
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart())
.join("\n");
if (!data) continue;
onEvent(JSON.parse(data) as SessionStreamEvent);
}
}