import type { Request, Response, Router } from "express"; import { z } from "zod"; import { getLocalAgentContext } from "../context/localContext.js"; import { InteractionRequestError, type InteractionRequestStore } from "../sessions/interaction-request-store.js"; import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js"; import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { reconcileInteractionDelivery } from "../sessions/interaction-delivery-reconciliation.js"; const identifier = z.string().min(1).max(128); const listQuerySchema = z.object({ limit: z.string().regex(/^[1-9]\d?$/).transform(Number).pipe(z.number().max(50)).optional(), cursor: identifier.optional(), }).strict(); type Options = { sessions: SessionMetadataStore; interactions: InteractionRequestStore; runtime?: Pick; isRunning?: (sessionId: string) => boolean }; /** These routes only persist user intent. Authorized /responses owns model continuation. */ export function registerInteractionRequestRoutes(router: Router, options: Options) { const handle = (operation: "list" | "get" | "draft" | "reply" | "skip" | "cancel" | "resume") => async (req: Request, res: Response) => { res.setHeader("Cache-Control", "private, no-store"); try { const sessionId = identifier.parse(req.params.session_id); const session = await options.sessions.get(getLocalAgentContext(req), sessionId); if (!session) { res.status(404).json({ code: "SESSION_NOT_FOUND", detail: "session not found" }); return; } const context = { sessionId: session.sessionId, actorKey: session.actorKey, projectKey: session.projectKey }; if (operation === "list") { const page = await options.interactions.list(context, listQuerySchema.parse(req.query)); const items = await Promise.all(page.items.map((record) => reconcileInteractionDelivery(context, record, options.interactions, options.runtime, options.isRunning?.(sessionId)))); res.json({ ...page, items }); return; } const interactionId = identifier.parse(req.params.interaction_id); if (operation === "get") { if (Object.keys(req.query).length) throw new InteractionRequestError("invalid_query", 400); const record = await options.interactions.get(context, interactionId); if (!record) { res.status(404).json({ code: "interaction_not_found", detail: "interaction not found" }); return; } res.json(await reconcileInteractionDelivery(context, record, options.interactions, options.runtime, options.isRunning?.(sessionId))); return; } if (Object.keys(req.query).length) throw new InteractionRequestError("invalid_query", 400); const record = operation === "draft" ? await options.interactions.updateDraft(context, interactionId, req.body) : await options.interactions[operation](context, interactionId, req.body); // A queued or blocked delivery is a successful saved answer, never an inference error. res.json(record); } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ code: "INTERACTION_INVALID", detail: "invalid interaction request" }); return; } if (error instanceof InteractionRequestError) { res.status(error.status).json({ code: error.code, detail: error.message, ...(error.current ? { current: error.current } : {}) }); return; } if (error instanceof SessionContextMismatchError) { res.status(404).json({ code: "SESSION_NOT_FOUND", detail: "session not found" }); return; } res.status(503).json({ code: "INTERACTION_UNAVAILABLE", detail: "interaction unavailable" }); } }; const base = "/session/:session_id/interactions"; router.get(base, handle("list")); router.get(`${base}/:interaction_id`, handle("get")); router.put(`${base}/:interaction_id/draft`, handle("draft")); for (const operation of ["reply", "skip", "cancel", "resume"] as const) router.post(`${base}/:interaction_id/${operation}`, handle(operation)); }