import { Effect } from "effect"; import { safeJson } from "@/features/agent/safe-json"; import { parseAgentTurnCommandResult, type AgentTurnCommandResult, } from "@/features/agent/messages"; import type { AgentImageInput } from "@/features/agent/contracts"; import type { BrowserBackend } from "@/features/agent/tools/types"; import type { ComposerPromptTemplateRef, ComposerSkillRef, } from "@/features/agent/composer-context"; import { decodeRuntimeEventPayload, decodeRuntimeSessions, decodeRuntimeStatusResponse, type RuntimeContextUsage, type RuntimeEventPayload, type RuntimeSessionSummary, type RuntimeStatus, } from "@/features/agent/runtime/runtime-schema"; export type { RuntimeContextUsage, RuntimeEventPayload, RuntimeSessionSummary, RuntimeStatus }; export function runtimeContextUsage( status: RuntimeStatus | null | undefined, fallback: RuntimeContextUsage | null | undefined, ): RuntimeContextUsage | null { if (status) return status.contextUsage ?? null; return fallback ?? null; } const fetchEffect = ( input: RequestInfo | URL, init?: RequestInit, ): Effect.Effect => Effect.tryPromise({ try: () => fetch(input, init), catch: (error) => error, }); const safeJsonEffect = (response: Response): Effect.Effect => Effect.tryPromise({ try: () => safeJson(response), catch: (error) => error, }); export function listRuntimeSessions(): Promise { return Effect.runPromise( Effect.gen(function* () { const response = yield* fetchEffect("/api/agent/runtime/sessions", { cache: "no-store" }); const payload = yield* safeJsonEffect(response); return decodeRuntimeSessions(payload); }).pipe(Effect.catch(() => Effect.succeed([]))), ); } export function loadRuntimeStatus( sessionId: string, piSessionId?: string | null, ): Promise { return Effect.runPromise( Effect.gen(function* () { const params = new URLSearchParams({ sessionId }); if (piSessionId) params.set("piSessionId", piSessionId); const response = yield* fetchEffect(`/api/agent/runtime/status?${params.toString()}`, { cache: "no-store", }); const payload = yield* safeJsonEffect(response); const decoded = decodeRuntimeStatusResponse(payload); if (!decoded) return null; return { ...decoded.status, events: decoded.events ?? [] }; }).pipe(Effect.catch(() => Effect.succeed(null))), ); } export function abortSession(sessionId: string): Promise { return Effect.runPromise( fetchEffect("/api/agent/abort", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId }), }).pipe( Effect.map(() => undefined), Effect.catch(() => Effect.succeed(undefined)), ), ); } export type CanonicalSessionMeta = { title: string | null; modelId: string | null; startedAt: string | null; piSessionId: string | null; }; export type CanonicalSessionResult = { events: Record[]; // Byte-offset cursor to pass as `before` to load the previous (older) page, // or null when this page already reaches the start of the session log. cursor: number | null; // Session metadata from a head-scan; present on an initial tail load only. meta: CanonicalSessionMeta | null; }; // Default page size for the initial tail load — enough to fill a long scrollback // while keeping a giant log from being read/parsed whole. export const DEFAULT_SESSION_TAIL = 500; export type LoadCanonicalSessionOptions = { tail?: number; before?: number }; export function loadCanonicalSession( piSessionId: string, cwd: string, options: LoadCanonicalSessionOptions = {}, ): Promise { return Effect.runPromise( Effect.gen(function* () { const params = new URLSearchParams({ cwd }); const tail = options.before === undefined ? (options.tail ?? DEFAULT_SESSION_TAIL) : undefined; if (tail !== undefined) params.set("tail", String(tail)); if (options.before !== undefined) params.set("before", String(options.before)); const response = yield* fetchEffect( `/api/agent/sessions/${encodeURIComponent(piSessionId)}?${params.toString()}`, { cache: "no-store" }, ); const payload = yield* safeJsonEffect<{ events?: Record[]; cursor?: number | null; meta?: CanonicalSessionMeta | null; error?: string; }>(response); if (!response.ok) return yield* Effect.fail(new Error(payload.error || "Failed to load session")); return { events: payload.events ?? [], cursor: payload.cursor ?? null, meta: payload.meta ?? null, }; }), ); } export type CompactSessionArgs = { sessionId: string; modelId: string; cwd?: string; piSessionId?: string | null; browserToolEnabled: boolean; browserSessionId?: string; browserBackend?: BrowserBackend; canvasEnabled?: boolean; skills: ComposerSkillRef[]; promptTemplates?: ComposerPromptTemplateRef[]; }; export type CompactSessionResult = { status?: RuntimeStatus; }; export function compactSession(args: CompactSessionArgs): Promise { return Effect.runPromise( Effect.gen(function* () { const response = yield* fetchEffect("/api/agent/compact", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(args), }); const payload = yield* safeJsonEffect<{ error?: string; status?: RuntimeStatus; }>(response); if (!response.ok) return yield* Effect.fail(new Error(payload.error || "Compaction failed")); return payload; }), ); } export type SubmitTurnArgs = { sessionId: string; modelId: string; message: string; images?: AgentImageInput[]; cwd?: string; piSessionId?: string | null; /** Control mode for steer/follow-up; omitted for a normal prompt. */ mode?: "steer" | "follow_up"; browserToolEnabled: boolean; browserSessionId?: string; browserBackend?: BrowserBackend; canvasEnabled?: boolean; skills: ComposerSkillRef[]; promptTemplates?: ComposerPromptTemplateRef[]; }; export function submitTurnCommand(args: SubmitTurnArgs): Promise { return Effect.runPromise( Effect.gen(function* () { const response = yield* fetchEffect("/api/agent/turn", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(args), }); const payload = yield* safeJsonEffect<{ error?: string } & Partial>( response, ); const parsed = parseAgentTurnCommandResult(payload); if (!response.ok || !parsed) { return yield* Effect.fail( new Error(payload.error || `Agent request failed: ${response.status}`), ); } if (parsed.outcome === "rejected") { return yield* Effect.fail(new Error(parsed.error || "Agent request was rejected")); } return parsed; }), ); } export type RuntimeEventSubscription = { close: () => void }; export function subscribeRuntimeEvents( sessionId: string, after: number, piSessionId: string | null | undefined, handlers: { onPayload: (payload: RuntimeEventPayload) => void; onError: () => void; }, ): RuntimeEventSubscription { const params = new URLSearchParams({ sessionId, after: String(after) }); if (piSessionId) params.set("piSessionId", piSessionId); const source = new EventSource(`/api/agent/runtime/events?${params.toString()}`); source.onmessage = (event) => { let parsed: unknown; try { parsed = JSON.parse(event.data); } catch { return; } const payload = decodeRuntimeEventPayload(parsed); if (!payload) return; handlers.onPayload(payload); }; source.onerror = handlers.onError; return { close: () => { source.close(); }, }; }