fix: preserve interaction replies across chat recovery

This commit is contained in:
2026-10-29 17:53:33 +08:00
parent fbe273e90a
commit 52681a8451
38 changed files with 1623 additions and 314 deletions
+69 -281
View File
@@ -5,14 +5,12 @@ import { z } from "zod";
import { getLocalAgentContext, getTrustedProductCredential } from "../context/localContext.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
type ActiveRun,
toPermissionStatus,
updateLastAssistantPermission,
updateLastAssistantQuestion,
} from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({
@@ -37,32 +35,7 @@ type RegisterInteractionRoutesOptions = {
sessionUiStateStore: SessionUiStateStore;
};
type ResolvedPendingQuestion = {
requestId: string;
question: NonNullable<ReturnType<ActiveRun["pendingQuestions"]["get"]>>;
};
const toSessionUiStateContext = (sessionId: string) => ({
sessionId,
});
const resolvePendingQuestion = (
run: ActiveRun,
requestId: string,
): ResolvedPendingQuestion | null => {
const exactQuestion = run.pendingQuestions.get(requestId);
if (exactQuestion) {
return { requestId, question: exactQuestion };
}
for (const [pendingRequestId, question] of run.pendingQuestions.entries()) {
if (question.tool?.callID === requestId) {
return { requestId: pendingRequestId, question };
}
}
return null;
};
const toSessionUiStateContext = (sessionId: string) => ({ sessionId });
export const registerChatInteractionRoutes = (
chatRouter: Router,
@@ -213,265 +186,80 @@ export const registerChatInteractionRoutes = (
}
});
chatRouter.post("/question/:request_id/reply", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = questionReplyPayloadSchema.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;
}
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 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 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 run = activeRuns.get(sessionRecord.sessionId);
if (!run) {
res.status(409).json({ message: "session is not waiting for questions" });
return;
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";
}
}
const pendingQuestion = resolvePendingQuestion(run, requestId);
if (!pendingQuestion) {
res.status(404).json({ message: "question request not found" });
return;
}
const persistQuestionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
// A host-confirmed reply remains authoritative after native forms are pruned.
const nativeStatus = confirmedStatus ? undefined : await runtime.getQuestionStatus({ sessionId: session.sessionId, requestId: canonicalId });
const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!);
const persist = async (nextStatus: string, answers?: string[][]) => {
const update = (messages: unknown[]) => messages.map((message) => {
if (!isRecord(message)) return message;
const questions = Array.isArray(message.questions) ? message.questions.map((q: unknown) => isRecord(q) && q.requestId === canonicalId ? { ...q, status: nextStatus, ...(answers ? { answers } : {}), error: undefined } : q) : undefined;
const parts = Array.isArray(message.parts) ? [...message.parts] : undefined;
if (parts && nextStatus !== "pending" && parts.some((part: unknown) => isRecord(part) && part.type === "data-question_request" && isRecord(part.data) && part.data.request_id === canonicalId)) {
const response = { type: "data-question_response", data: { session_id: session.sessionId, request_id: canonicalId, status: nextStatus, rejected: nextStatus === "rejected" || nextStatus === "invalidated", ...(answers ? { answers } : {}) } };
const index = parts.findIndex((part: unknown) => isRecord(part) && part.type === "data-question_response" && isRecord(part.data) && part.data.request_id === canonicalId);
if (index >= 0) parts[index] = { ...parts[index], data: { ...(isRecord(parts[index]) && isRecord(parts[index].data) ? parts[index].data : {}), ...response.data } }; else parts.push(response);
}
return { ...message, ...(questions ? { questions } : {}), ...(parts ? { parts } : {}) };
});
// Update the live projection before entering the same atomic persistence queue.
if (run) { run.messages = update(run.messages); if (nextStatus !== "pending") run.pendingQuestions.delete(canonicalId); }
await sessionUiStateStore.update(context, (latest) => ({ ...latest, messages: update(latest.messages) }));
};
try {
await runtime.replyQuestion({
requestId: pendingQuestion.requestId,
sessionId: sessionRecord.sessionId,
answers: parsed.data.answers,
});
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
pendingQuestion.requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reply question",
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: "question reply failed",
detail: error instanceof Error ? error.message : String(error),
});
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;
}
run.pendingQuestions.delete(pendingQuestion.requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
pendingQuestion.requestId,
(question) => ({
...question,
status: "answered",
answers: parsed.data.answers,
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reply state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.question.session_id,
request_id: pendingQuestion.requestId,
answers: parsed.data.answers,
});
}
if (
run.status !== "running" &&
run.pendingPermissions.size === 0 &&
run.pendingQuestions.size === 0
) {
activeRuns.delete(sessionRecord.sessionId);
}
res.status(202).json({
session_id: pendingQuestion.question.session_id,
request_id: pendingQuestion.requestId,
answers: parsed.data.answers,
});
const answers = action === "reply" ? questionReplyPayloadSchema.parse(req.body).answers : undefined;
if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [] });
else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId });
await persist(action === "reply" ? "answered" : "rejected", answers);
for (const subscriber of run?.subscribers ?? []) subscriber.write("question_response", { session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) });
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) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reply route failed");
res.status(500).json({
message: "question reply route failed",
detail,
});
// 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.post("/question/:request_id/reject", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = questionRejectPayloadSchema.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) {
res.status(409).json({ message: "session is not waiting for questions" });
return;
}
const pendingQuestion = resolvePendingQuestion(run, requestId);
if (!pendingQuestion) {
res.status(404).json({ message: "question request not found" });
return;
}
const persistQuestionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
await runtime.rejectQuestion({
requestId: pendingQuestion.requestId,
sessionId: sessionRecord.sessionId,
});
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
pendingQuestion.requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reject question",
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: "question reject failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingQuestions.delete(pendingQuestion.requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
pendingQuestion.requestId,
(question) => ({
...question,
status: "rejected",
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reject state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.question.session_id,
request_id: pendingQuestion.requestId,
rejected: true,
});
}
if (
run.status !== "running" &&
run.pendingPermissions.size === 0 &&
run.pendingQuestions.size === 0
) {
activeRuns.delete(sessionRecord.sessionId);
}
res.status(202).json({
session_id: pendingQuestion.question.session_id,
request_id: pendingQuestion.requestId,
rejected: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reject route failed");
res.status(500).json({
message: "question reject route failed",
detail,
});
}
});
};
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"));
};