fabro/apps/fabro-web/app/lib/ask-fabro-runtime.ts
Bryan Helmkamp 34996d630f
Give Ask Fabro sessions their own event log
Step 4 of the legacy executor deletion, second commit. Ask Fabro's
sessions were the last writer of `run_events`: a session's creation, its
turns and their messages, tool calls and endings went into the run's
legacy event log, keyed by the run's sequence. They now have a log of
their own.

- `run_session_events` (migration `2026091802`): one row per session
  event, numbered per session from 1, with the owning run, the turn, the
  event name and its properties. `RunSessionEventStore` appends under the
  write lock, lists a session from a sequence, names a session's owner
  from its creation event, deletes a run's sessions with the run, and
  publishes each committed event to its subscribers.
- `fabro_types::SessionEvent`: `seq`, `session_id`, `run_id`, `ts` and a
  flattened body (`event` naming the kind, `properties` its fields), with
  the same event names and property shapes the legacy events carried,
  so the web app and the CLI read the same JSON. The property structs
  move to `session_event`; `run_event::session` re-exports them under
  their old names until the legacy event log goes.
- The API: `GET /sessions/{id}/events` pages `PaginatedSessionEventList`
  by the session's own sequence, `GET /sessions/{id}/attach` replays and
  streams `SessionEvent` frames (subscribed before the replay, so no
  event falls between the two), the turn stream carries the same frames,
  and an interrupt answers with the recorded event. The session
  projection folds `SessionEvent`s; the legacy `find_session_owner` over
  `run_events` is gone.
- The CLI's `run ask` and the web app's session stream read
  `SessionEvent`; the web runtime no longer accepts the nested legacy
  envelope shape.

The two session resume tests in the server keep failing for a reason
this commit does not touch: Ask Fabro reconnects to the run's sandbox
from the projection's sandbox instance, which the Petri projection does
not carry yet (`VIEWS.md`, the `scope.acquired` gap).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:47:43 -04:00

311 lines
9.4 KiB
TypeScript

import type {
ChatModelAdapter,
ChatModelRunResult,
ThreadAssistantMessagePart,
} from "@assistant-ui/react";
import {
streamSessionTurn,
type SessionStreamEvent,
} from "./session-stream";
import { ApiError, sessionsApi } from "./api-client";
const SESSION_STORAGE_PREFIX = "fabro:ask-fabro-session:";
function sessionStorageKey(runId: string): string {
return `${SESSION_STORAGE_PREFIX}${runId}`;
}
interface PersistedSessionState {
read(runId: string): string | null;
write(runId: string, sessionId: string): void;
clear(runId: string): void;
}
const defaultPersistedSessionState: PersistedSessionState = {
read(runId) {
if (typeof sessionStorage === "undefined") return null;
try {
return sessionStorage.getItem(sessionStorageKey(runId));
} catch {
return null;
}
},
write(runId, sessionId) {
if (typeof sessionStorage === "undefined") return;
try {
sessionStorage.setItem(sessionStorageKey(runId), sessionId);
} catch {
// ignore quota or privacy-mode failures; session will be recreated next time
}
},
clear(runId) {
if (typeof sessionStorage === "undefined") return;
try {
sessionStorage.removeItem(sessionStorageKey(runId));
} catch {
// best effort
}
},
};
export interface AskFabroAdapterOptions {
/** Run ID this Ask Fabro session is scoped to. */
runId: string;
/** Catalog model id used when creating a fresh session. */
defaultModel?: string | null;
/** Override session persistence; defaults to `sessionStorage` keyed by run. */
persistedSession?: PersistedSessionState;
/** Override stream impl for tests. */
streamSessionTurnImpl?: typeof streamSessionTurn;
/** Override session API for tests. */
createSession?: (
runId: string,
body: { title?: string; model?: string },
) => Promise<{ id: string }>;
}
/**
* State accumulated as `run.session.*` events arrive during a single turn,
* mapped to assistant-ui's `ThreadAssistantMessagePart[]` view model. The
* assistant-ui runtime is given a snapshot after every event so users see
* streaming text and tool-call cards in real time.
*/
interface TurnAccumulator {
/** Active text part index, if the last delta added/extended text. */
activeTextIndex: number | null;
parts: ThreadAssistantMessagePart[];
/** Maps `tool_call_id` → index in `parts` for completing pairs. */
toolCallIndex: Map<string, number>;
}
function emptyAccumulator(): TurnAccumulator {
return {
activeTextIndex: null,
parts: [],
toolCallIndex: new Map(),
};
}
function snapshot(acc: TurnAccumulator): ChatModelRunResult {
return { content: acc.parts.slice() };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Apply a single `SessionEvent` to the accumulator. Returns true if the
* accumulator changed and a fresh `ChatModelRunResult` should be yielded.
*/
export function applyTurnEvent(
acc: TurnAccumulator,
event: SessionStreamEvent,
): boolean {
const eventName = event.event;
const props: Record<string, unknown> = isRecord(event.properties)
? event.properties
: {};
if (eventName === "run.session.assistant_delta") {
const delta = typeof props.delta === "string" ? props.delta : "";
if (!delta) return false;
if (acc.activeTextIndex == null) {
acc.parts.push({ type: "text", text: delta });
acc.activeTextIndex = acc.parts.length - 1;
} else {
const part = acc.parts[acc.activeTextIndex];
if (part && part.type === "text") {
acc.parts[acc.activeTextIndex] = { ...part, text: part.text + delta };
}
}
return true;
}
if (eventName === "run.session.assistant_message") {
// The full text was already streamed via deltas; the message event marks
// the end of an assistant text segment. Reset the active-text pointer so
// any following tool calls become separate parts, and any later text part
// starts fresh (matches the durable transcript projection).
if (acc.activeTextIndex != null) {
acc.activeTextIndex = null;
return true;
}
const text = typeof props.text === "string" ? props.text : "";
if (text) {
acc.parts.push({ type: "text", text });
return true;
}
return false;
}
if (eventName === "run.session.tool_call.started") {
const toolCallId = typeof props.tool_call_id === "string"
? props.tool_call_id
: "";
const toolName = typeof props.tool_name === "string" ? props.tool_name : "";
if (!toolCallId || !toolName) return false;
const argsValue = props.arguments;
const args =
argsValue && typeof argsValue === "object" ? (argsValue as object) : {};
acc.parts.push({
type: "tool-call",
toolCallId,
toolName,
// Assistant-ui expects a JSON-shaped value here; the property's actual
// shape is whatever the tool's argument schema produces.
args: args as never,
argsText: JSON.stringify(args),
});
acc.toolCallIndex.set(toolCallId, acc.parts.length - 1);
acc.activeTextIndex = null;
return true;
}
if (eventName === "run.session.tool_call.completed") {
const toolCallId = typeof props.tool_call_id === "string"
? props.tool_call_id
: "";
if (!toolCallId) return false;
const index = acc.toolCallIndex.get(toolCallId);
if (index == null) return false;
const part = acc.parts[index];
if (!part || part.type !== "tool-call") return false;
acc.parts[index] = { ...part, result: props.output };
return true;
}
return false;
}
type CreateSession = NonNullable<AskFabroAdapterOptions["createSession"]>;
function defaultCreateSession(
runId: string,
body: { title?: string; model?: string },
): Promise<{ id: string }> {
return sessionsApi
.createRunSession(runId, body)
.then((response) => ({ id: response.data.id }));
}
interface UserContentPart {
type?: unknown;
text?: unknown;
}
function lastUserText(
messages: ReadonlyArray<{
role: string;
content: ReadonlyArray<UserContentPart>;
}>,
): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!message || message.role !== "user") continue;
const segments: string[] = [];
for (const part of message.content) {
if (part.type === "text" && typeof part.text === "string") {
segments.push(part.text);
}
}
if (segments.length > 0) return segments.join("\n");
}
return "";
}
/**
* Build an assistant-ui `ChatModelAdapter` that talks to the Fabro Sessions
* API. The adapter is parameterized by a `runId`; the session is created
* lazily on the first turn (reusing a `sessionStorage`-cached id on reopen)
* and turns are submitted via streamed SSE.
*/
export function createAskFabroAdapter(
options: AskFabroAdapterOptions,
): ChatModelAdapter {
const persisted = options.persistedSession ?? defaultPersistedSessionState;
const streamImpl = options.streamSessionTurnImpl ?? streamSessionTurn;
const createSession: CreateSession =
options.createSession ?? defaultCreateSession;
let sessionId: string | null = persisted.read(options.runId);
async function ensureSession(): Promise<string> {
if (sessionId) return sessionId;
const body: { title?: string; model?: string } = { title: "Ask Fabro" };
if (options.defaultModel) body.model = options.defaultModel;
const created = await createSession(options.runId, body);
sessionId = created.id;
persisted.write(options.runId, sessionId);
return sessionId;
}
return {
async *run({ messages, abortSignal }) {
const id = await ensureSession();
const input = lastUserText(messages as never);
const acc = emptyAccumulator();
const queue: SessionStreamEvent[] = [];
let resolveWaiter: (() => void) | null = null;
let streamDone = false;
function wakeWaiter() {
if (!resolveWaiter) return;
const r = resolveWaiter;
resolveWaiter = null;
r();
}
const streamPromise = (async () => {
try {
await streamImpl({
sessionId: id,
input,
signal: abortSignal,
onEvent: (event) => {
queue.push(event);
wakeWaiter();
},
});
} finally {
streamDone = true;
wakeWaiter();
}
})();
let yielded = false;
while (true) {
if (queue.length === 0) {
if (streamDone) break;
// react-doctor-disable-next-line react-doctor/async-await-in-loop -- This loop waits for the next streamed event; iterations are not independent work.
await new Promise<void>((resolve) => {
resolveWaiter = resolve;
});
continue;
}
const event = queue.shift();
if (!event) continue;
if (applyTurnEvent(acc, event)) {
yield snapshot(acc);
yielded = true;
}
}
// Propagate any error from the stream task. If the cached session was
// pruned server-side, clear it so the next turn creates a fresh session.
try {
await streamPromise;
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
persisted.clear(options.runId);
sessionId = null;
}
throw error;
}
// Guarantee assistant-ui sees at least one result for an empty turn.
if (!yielded) yield snapshot(acc);
},
};
}