refactor: consolidate chat state and task bridges

Centralize session scope, chat projections and typed task bridges.
Keep historical interaction replies authoritative and publish terminal state to subscribers. Validate Server contracts before replacing generated artifacts.
This commit is contained in:
2026-11-09 17:55:00 +08:00
parent addc81e106
commit 6279a4b555
31 changed files with 849 additions and 522 deletions
+22 -51
View File
@@ -1,3 +1,5 @@
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";
@@ -8,9 +10,7 @@ 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,
toPermissionStatus,
updateLastAssistantPermission,
type ActiveRun, applyInteractionResponse,
} from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({
@@ -35,7 +35,6 @@ type RegisterInteractionRoutesOptions = {
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionId: string) => ({ sessionId });
export const registerChatInteractionRoutes = (
chatRouter: Router,
@@ -104,12 +103,13 @@ export const registerChatInteractionRoutes = (
}
const persistPermissionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
toSessionScope(sessionRecord),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
await sessionUiStateStore.write(toSessionScope(sessionRecord), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
...currentState,
messages: run.uiMessages?.() ?? run.messages,
});
};
@@ -121,18 +121,10 @@ export const registerChatInteractionRoutes = (
message: parsed.data.message,
});
} catch (error) {
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reply permission",
}),
);
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 },
@@ -146,30 +138,15 @@ export const registerChatInteractionRoutes = (
return;
}
run.pendingPermissions.delete(requestId);
const status = toPermissionStatus(parsed.data.reply);
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status,
repliedAt: Date.now(),
}),
);
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",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("permission_response", {
session_id: sessionRecord.sessionId,
request_id: requestId,
reply: parsed.data.reply,
});
}
res.status(202).json({
session_id: sessionRecord.sessionId,
@@ -222,20 +199,15 @@ export const registerChatInteractionRoutes = (
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 } : {}) };
});
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) { run.messages = update(run.messages); if (nextStatus !== "pending") run.pendingQuestions.delete(canonicalId); }
await sessionUiStateStore.update(context, (latest) => ({ ...latest, messages: update(latest.messages) }));
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);
@@ -246,7 +218,6 @@ export const registerChatInteractionRoutes = (
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) {