Files
next-tjwater-agent/src/routes/interaction-request-routes.ts
T

61 lines
4.0 KiB
TypeScript

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<OpencodeRuntimeAdapter, "agentMessageState" | "readInteractionResult">;
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));
}