import { publishRunEvent } from "./chat-run-accumulator.js"; import { toSessionScope } from "../sessions/session-scope.js"; import { refreshSessionCredential } from "../product/credential-refresh.js"; import { type Router } from "express"; import { z } from "zod"; import { getLocalAgentContext, getTrustedProductCredential } from "../context/localContext.js"; import { logger } from "../logger.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type ActiveRun, applyInteractionResponse, } from "./chatUiState.js"; const permissionReplyPayloadSchema = z.object({ session_id: z.string().max(128), reply: z.enum(["once", "always", "reject"]), message: z.string().max(1000).optional(), }); const questionReplyPayloadSchema = z.object({ session_id: z.string().max(128), answers: z.array(z.array(z.string().max(2000))).default([]), }); const questionRejectPayloadSchema = z.object({ session_id: z.string().max(128), }); type RegisterInteractionRoutesOptions = { activeRuns: Map; runtime: OpencodeRuntimeAdapter; sessionMetadataStore: SessionMetadataStore; sessionUiStateStore: SessionUiStateStore; }; export const registerChatInteractionRoutes = ( chatRouter: Router, { activeRuns, runtime, sessionMetadataStore, sessionUiStateStore, }: RegisterInteractionRoutesOptions, ) => { chatRouter.post("/session/:session_id/credentials", async (req, res) => { const record = await sessionMetadataStore.get(getLocalAgentContext(req), req.params.session_id); const credential = getTrustedProductCredential(req); if (!record || !credential) { res.status(404).json({ message: "session not found" }); return; } const run = activeRuns.get(record.sessionId); const requestId = typeof req.body?.request_id === "string" ? req.body.request_id : undefined; if (!run || run.status !== "running" || (!requestId && record.purpose !== "task_execution")) { res.status(409).json({ message: "credential refresh is not pending" }); return; } if (!refreshSessionCredential(record.sessionId, credential, requestId)) { res.status(409).json({ message: "credential refresh request does not match" }); return; } res.status(202).json({ accepted: true }); }); chatRouter.post("/permission/:request_id/reply", async (req, res) => { const requestId = req.params.request_id?.trim(); const parsed = permissionReplyPayloadSchema.safeParse(req.body); if (!requestId) { res.status(400).json({ message: "request_id is required" }); return; } if (!parsed.success) { res.status(400).json({ message: "invalid request payload", detail: parsed.error.flatten(), }); return; } try { const authContext = getLocalAgentContext(req); const projectId = authContext.projectId; const userId = authContext.userId; const actorKey = authContext.actorKey; const projectKey = authContext.projectKey; const sessionRecord = await sessionMetadataStore.get( { actorKey, projectId, projectKey, userId }, parsed.data.session_id, ); if (!sessionRecord) { res.status(404).json({ message: "session not found" }); return; } const run = activeRuns.get(sessionRecord.sessionId); if (!run || run.status !== "running") { res.status(409).json({ message: "session is not waiting for permissions" }); return; } const pendingPermission = run.pendingPermissions.get(requestId); if (!pendingPermission) { res.status(404).json({ message: "permission request not found" }); return; } const persistPermissionState = async () => { const currentState = await sessionUiStateStore.read( toSessionScope(sessionRecord), ); await sessionUiStateStore.write(toSessionScope(sessionRecord), { sessionId: sessionRecord.sessionId, isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false, ...currentState, messages: run.uiMessages?.() ?? run.messages, }); }; try { await runtime.replyPermission({ requestId, sessionId: sessionRecord.sessionId, reply: parsed.data.reply, message: parsed.data.message, }); } catch (error) { publishRunEvent(run, "permission_response", { session_id: sessionRecord.sessionId, request_id: requestId, status: "error", error: error instanceof Error ? error.message : "failed to reply permission", }); await persistPermissionState().catch((persistError) => { logger.warn( { err: persistError, sessionId: sessionRecord.sessionId }, "failed to persist permission error state", ); }); res.status(502).json({ message: "permission reply failed", detail: error instanceof Error ? error.message : String(error), }); return; } publishRunEvent(run, "permission_response", { session_id: sessionRecord.sessionId, request_id: requestId, reply: parsed.data.reply, }); await persistPermissionState().catch((persistError) => { logger.warn( { err: persistError, sessionId: sessionRecord.sessionId }, "failed to persist permission reply state", ); }); res.status(202).json({ session_id: sessionRecord.sessionId, request_id: requestId, reply: parsed.data.reply, }); } catch (error) { const detail = error instanceof Error ? error.message : String(error); logger.error({ err: error }, "permission reply route failed"); res.status(500).json({ message: "permission reply route failed", detail, }); } }); const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object" && !Array.isArray(value)); const questionHandler = (action: "status" | "reply" | "reject") => async (req: import("express").Request, res: import("express").Response) => { res.setHeader("Cache-Control", "private, no-store"); const requestId = typeof req.params.request_id === "string" ? req.params.request_id.trim() : undefined; const parsed = action === "status" ? questionRejectPayloadSchema.strict().safeParse({ session_id: req.params.session_id }) : (action === "reply" ? questionReplyPayloadSchema.strict() : questionRejectPayloadSchema.strict()).safeParse(req.body); if (!requestId || requestId.length > 128 || !parsed.success) { res.status(400).json({ code: "QUESTION_INVALID", message: "invalid question request" }); return; } try { const session = await sessionMetadataStore.get(getLocalAgentContext(req), parsed.data.session_id); if (!session) { res.status(404).json({ code: "SESSION_NOT_FOUND", message: "session not found" }); return; } const context = { sessionId: session.sessionId, actorKey: session.actorKey, projectKey: session.projectKey }; const saved = await sessionUiStateStore.read(context); const run = activeRuns.get(session.sessionId); // Only host-persisted questions grant access to the native form API. const candidates: Record[] = []; for (const message of saved?.messages ?? []) { if (!isRecord(message)) continue; const historical = Array.isArray(message.questions) ? message.questions : []; const canonical = Array.isArray(message.parts) ? message.parts.flatMap((part: unknown) => isRecord(part) && part.type === "data-question_request" && isRecord(part.data) ? [{ ...part.data, requestId: part.data.request_id }] : []) : []; for (const q of [...historical, ...canonical]) if (isRecord(q) && (q.requestId === requestId || (isRecord(q.tool) && q.tool.callID === requestId))) candidates.push(q); } const stored = candidates.find((q) => !isRecord(q.tool) || q.requestId !== q.tool.callID) ?? candidates[0]; if (!stored || typeof stored.requestId !== "string") { res.status(404).json({ code: "QUESTION_NOT_FOUND", message: "question request not found" }); return; } const canonicalId = stored.requestId; let confirmedStatus = ["answered", "rejected", "invalidated"].includes(String(stored.status)) ? String(stored.status) : undefined; for (const message of saved?.messages ?? []) { if (!isRecord(message) || !Array.isArray(message.parts)) continue; for (const part of message.parts) { if (isRecord(part) && part.type === "data-question_response" && isRecord(part.data) && part.data.request_id === canonicalId) confirmedStatus = typeof part.data.status === "string" ? part.data.status : part.data.rejected === true ? "rejected" : "answered"; } } // A host-confirmed reply remains authoritative after native forms are pruned. const form = confirmedStatus ? undefined : await runtime.getQuestion({ sessionId: session.sessionId, requestId: canonicalId }); const nativeStatus = form?.state.status ?? "missing"; const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!); const persist = async (nextStatus: string, answers?: string[][]) => { const response = { session_id: session.sessionId, request_id: canonicalId, status: nextStatus, rejected: nextStatus === "rejected" || nextStatus === "invalidated", ...(answers ? { answers } : {}), }; // Update the live projection before entering the same atomic persistence queue. if (run && nextStatus !== "pending") publishRunEvent(run, "question_response", response); await sessionUiStateStore.update(context, (latest) => ({ ...latest, messages: applyInteractionResponse(latest.messages, "question_response", response), })); }; if (action === "status" || status !== "pending") { await persist(status); res.status(action === "status" ? 200 : 409).json({ session_id: session.sessionId, request_id: canonicalId, status, ...(action !== "status" ? { code: "QUESTION_RESOLVED" } : {}) }); return; } const answers = action === "reply" ? questionReplyPayloadSchema.parse(req.body).answers : undefined; if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [], form: form! }); else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId }); await persist(action === "reply" ? "answered" : "rejected", answers); if (run && run.status !== "running" && !run.pendingPermissions.size && !run.pendingQuestions.size) activeRuns.delete(session.sessionId); res.status(202).json({ session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) }); } catch (error) { // Native connectivity failure is not proof that a historical question expired. if (error instanceof SessionContextMismatchError) { res.status(404).json({ code: "SESSION_NOT_FOUND", message: "session not found" }); return; } const tag = isRecord(error) ? error._tag : undefined; if (tag === "FormInvalidAnswerError") { res.status(400).json({ code: "QUESTION_INVALID_ANSWER", message: "invalid question answer" }); return; } if (tag === "FormAlreadySettledError" || tag === "FormNotFoundError") { res.status(409).json({ code: "QUESTION_RESOLVED", message: "question is no longer pending" }); return; } logger.warn({ err: error }, "legacy question reconciliation unavailable"); res.status(503).json({ code: "QUESTION_UNAVAILABLE", message: "question state temporarily unavailable" }); } }; chatRouter.get("/session/:session_id/questions/:request_id", questionHandler("status")); chatRouter.post("/question/:request_id/reply", questionHandler("reply")); chatRouter.post("/question/:request_id/reject", questionHandler("reject")); };