Files
next-tjwater-agent/src/routes/chatInteractionRoutes.ts
T
jiang 5ee93329a4
Generic Container CI/CD / test-build-publish (push) Successful in 2m4s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 2m4s
refactor: streamline query requests and session storage
2026-11-09 18:49:32 +08:00

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"));
};