238 lines
12 KiB
TypeScript
238 lines
12 KiB
TypeScript
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<string, ActiveRun>;
|
|
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<string, unknown> => 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<string, unknown>[] = [];
|
|
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"));
|
|
};
|