mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-12 23:02:41 +00:00
## 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` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 Codex (context unknown, medium reasoning) via [Codex](https://openai.com/codex/)
141 lines
3.6 KiB
TypeScript
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);
|
|
}
|
|
}
|