fix(chat): handle question and todo state

This commit is contained in:
2026-06-08 18:10:28 +08:00
parent f20847399a
commit 15c3263369
10 changed files with 2173 additions and 724 deletions
+122 -447
View File
@@ -8,9 +8,7 @@ import { MemoryStore } from "../memory/store.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js";
import {
type PermissionReply,
type OpencodeRuntimeAdapter,
} from "../runtime/opencode.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
@@ -22,13 +20,36 @@ import {
generateSessionTitle,
shouldGenerateSessionTitle,
} from "./chatSession.js";
import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
import {
collectTextContent,
type PermissionRequestPayload,
type QuestionRequestPayload,
streamPromptResponse,
supportedModels,
type SupportedModel,
type TodoUpdatePayload,
} from "./chatStream.js";
import {
type ActiveRun,
type RunStatus,
type StreamSubscriber,
cancelBackendTodos,
completeBackendProgress,
countFrontendUserMessages,
createInitialStreamingMessages,
isObjectRecord,
pruneBranchGroupsForMessageIndex,
toFrontendPermission,
toPermissionStatus,
updateLastAssistantMessage,
updateLastAssistantPermission,
updateLastAssistantQuestion,
upsertBackendProgress,
upsertBackendQuestion,
upsertBackendTodoUpdate,
} from "./chatUiState.js";
const payloadSchema = z.object({
message: z.string().min(1).max(10000),
@@ -38,16 +59,6 @@ const payloadSchema = z.object({
regenerate_from_message_index: z.coerce.number().int().min(0).optional(),
});
const abortPayloadSchema = z.object({
session_id: z.string().max(128),
});
const permissionReplyPayloadSchema = z.object({
session_id: z.string().max(128),
reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(),
});
const createSessionPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
@@ -65,22 +76,6 @@ const sessionStateSchema = z.object({
branch_groups: z.array(z.unknown()).default([]),
});
type RunStatus = "running" | "completed" | "error" | "aborted";
type StreamSubscriber = {
write: (event: string, data: Record<string, unknown>) => void;
close: () => void;
};
type ActiveRun = {
clientSessionId: string;
controller: AbortController;
messages: unknown[];
pendingPermissions: Map<string, PermissionRequestPayload>;
status: RunStatus;
subscribers: Set<StreamSubscriber>;
};
const activeRuns = new Map<string, ActiveRun>();
const lastRunStatuses = new Map<string, RunStatus>();
@@ -91,174 +86,6 @@ const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
const getSessionRunStatus = (sessionId: string) =>
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const createFrontendMessageId = () =>
`msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const createInitialStreamingMessages = (existingMessages: unknown[], userContent: string) => {
const userMessage = {
id: createFrontendMessageId(),
role: "user",
content: userContent,
};
return [
...existingMessages,
{
...userMessage,
branchRootId: userMessage.id,
},
{
id: createFrontendMessageId(),
role: "assistant",
content: "",
progress: [
{
id: "request-received",
phase: "start",
status: "running",
title: "已收到请求,正在启动 Agent 分析",
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
startedAt: Date.now(),
elapsedMs: 0,
elapsedSnapshotAt: Date.now(),
},
],
},
];
};
const countFrontendUserMessages = (messages: unknown[]) =>
messages.filter(
(message) => isObjectRecord(message) && message.role === "user",
).length;
const pruneBranchGroupsForMessageIndex = (
branchGroups: unknown[],
messageIndex: number | undefined,
) => {
if (messageIndex === undefined) {
return branchGroups;
}
return branchGroups.filter(
(group) =>
!isObjectRecord(group) ||
typeof group.parentCount !== "number" ||
group.parentCount < messageIndex,
);
};
const upsertBackendProgress = (
progress: unknown,
payload: Record<string, unknown>,
) => {
const next = Array.isArray(progress) ? [...progress] : [];
const id = typeof payload.id === "string" ? payload.id : `progress-${Date.now()}`;
const index = next.findIndex((item) => isObjectRecord(item) && item.id === id);
const nextItem = {
id,
phase: typeof payload.phase === "string" ? payload.phase : "progress",
status:
payload.status === "completed" || payload.status === "error"
? payload.status
: "running",
title: typeof payload.title === "string" ? payload.title : "正在处理",
detail: typeof payload.detail === "string" ? payload.detail : undefined,
startedAt: typeof payload.started_at === "number" ? payload.started_at : undefined,
endedAt: typeof payload.ended_at === "number" ? payload.ended_at : undefined,
elapsedMs: typeof payload.elapsed_ms === "number" ? payload.elapsed_ms : undefined,
elapsedSnapshotAt:
typeof payload.elapsed_ms === "number" ? Date.now() : undefined,
durationMs: typeof payload.duration_ms === "number" ? payload.duration_ms : undefined,
};
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
};
const completeBackendProgress = (progress: unknown) =>
Array.isArray(progress)
? progress.map((item) => {
if (!isObjectRecord(item) || item.status !== "running") {
return item;
}
const endedAt = Date.now();
const startedAt = typeof item.startedAt === "number" ? item.startedAt : undefined;
return {
...item,
status: "completed",
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
typeof item.durationMs === "number"
? item.durationMs
: startedAt !== undefined
? Math.max(0, endedAt - startedAt)
: item.elapsedMs,
};
})
: progress;
const updateLastAssistantMessage = (
messages: unknown[],
updater: (message: Record<string, unknown>) => Record<string, unknown>,
) => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (isObjectRecord(message) && message.role === "assistant") {
const next = [...messages];
next[index] = updater(message);
return next;
}
}
return messages;
};
const updateLastAssistantPermission = (
messages: unknown[],
requestId: string,
updater: (permission: Record<string, unknown>) => Record<string, unknown>,
) =>
updateLastAssistantMessage(messages, (message) => {
const permissions = Array.isArray(message.permissions)
? message.permissions
: [];
return {
...message,
permissions: permissions.map((permission) =>
isObjectRecord(permission) && permission.requestId === requestId
? updater(permission)
: permission,
),
};
});
const toFrontendPermission = (
payload: PermissionRequestPayload,
status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending",
) => ({
requestId: payload.request_id,
sessionId: payload.session_id,
permission: payload.permission,
patterns: payload.patterns,
metadata: payload.metadata,
always: payload.always,
tool: payload.tool,
createdAt: payload.created_at,
status,
});
const toPermissionStatus = (reply: PermissionReply) => {
if (reply === "always") return "approved_always";
if (reply === "once") return "approved_once";
return "rejected";
};
export const buildChatRouter = (
sessionBridge: ChatSessionBridge,
runtime: OpencodeRuntimeAdapter,
@@ -580,258 +407,20 @@ export const buildChatRouter = (
res.status(204).end();
});
chatRouter.get("/render-ref/:renderRef", async (req, res) => {
const renderRef = req.params.renderRef?.trim();
const userId = req.header("x-user-id")?.trim();
const projectId = req.header("x-project-id") ?? undefined;
const clientSessionId =
typeof req.query.session_id === "string"
? req.query.session_id.trim()
: undefined;
if (!userId) {
res.status(400).json({
message: "x-user-id is required",
});
return;
}
if (!renderRef) {
res.status(400).json({
message: "render_ref is required",
});
return;
}
const result = await resultReferenceResolver.getFullAuthorized(
renderRef,
{
actorKey: toActorKey(userId),
clientSessionId,
projectId,
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
if (!result) {
res.status(404).json({ message: "render_ref not found" });
return;
}
res.json(result);
registerChatAuxiliaryRoutes(chatRouter, {
activeRuns,
lastRunStatuses,
resultReferenceResolver,
sessionBridge,
sessionMetadataStore,
sessionUiStateStore,
});
chatRouter.post("/abort", async (req, res) => {
const parsed = abortPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
const binding = sessionRecord
? await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
})
: null;
const run = activeRuns.get(parsed.data.session_id);
if (run && run.status === "running") {
run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted");
run.controller.abort();
run.messages = updateLastAssistantMessage(run.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
}));
if (sessionRecord) {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
branchGroups: currentState?.branchGroups ?? [],
});
}
for (const subscriber of run.subscribers) {
subscriber.write("error", {
session_id: parsed.data.session_id,
message: "请求已中断",
});
subscriber.close();
}
run.subscribers.clear();
}
if (!binding && !run) {
res.status(204).end();
return;
}
logger.info(
{
clientSessionId: parsed.data.session_id,
sessionId: binding?.sessionId ?? parsed.data.session_id,
},
"aborted chat session by client request",
);
res.status(202).json({
session_id: parsed.data.session_id,
aborted: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat abort failed");
res.status(500).json({
message: "chat abort failed",
detail,
});
}
});
chatRouter.post("/permission/:requestId/reply", async (req, res) => {
const requestId = req.params.requestId?.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 projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
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(
toSessionUiStateContext(sessionRecord),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
branchGroups: currentState?.branchGroups ?? [],
});
};
try {
await runtime.replyPermission({
requestId,
sessionId: sessionRecord.sessionId,
reply: parsed.data.reply,
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",
}),
);
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;
}
run.pendingPermissions.delete(requestId);
const status = toPermissionStatus(parsed.data.reply);
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status,
repliedAt: Date.now(),
}),
);
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,
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,
});
}
registerChatInteractionRoutes(chatRouter, {
activeRuns,
runtime,
sessionMetadataStore,
sessionUiStateStore,
});
chatRouter.post("/fork", async (req, res) => {
@@ -1045,6 +634,7 @@ export const buildChatRouter = (
controller: abortController,
messages: initialMessages,
pendingPermissions: new Map(),
pendingQuestions: new Map(),
status: "running",
subscribers: new Set(),
};
@@ -1129,6 +719,7 @@ export const buildChatRouter = (
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") {
const payload = data as PermissionRequestPayload;
@@ -1159,6 +750,60 @@ export const buildChatRouter = (
}),
);
}
} else if (event === "question_request") {
const payload = data as QuestionRequestPayload;
let shouldTrackQuestion = true;
if (payload.tool?.callID) {
if (payload.request_id !== payload.tool.callID) {
activeRun.pendingQuestions.delete(payload.tool.callID);
} else {
const hasActionableQuestion = [...activeRun.pendingQuestions.values()].some(
(question) =>
question.tool?.callID === payload.tool?.callID &&
question.request_id !== payload.tool?.callID,
);
if (hasActionableQuestion) {
activeRun.messages = updateLastAssistantMessage(
activeRun.messages,
(message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}),
);
shouldTrackQuestion = false;
}
}
}
if (shouldTrackQuestion) {
activeRun.pendingQuestions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}));
}
} else if (event === "question_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
if (requestId) {
activeRun.pendingQuestions.delete(requestId);
activeRun.messages = updateLastAssistantQuestion(
activeRun.messages,
requestId,
(question) => ({
...question,
status: data.rejected === true ? "rejected" : "answered",
repliedAt: Date.now(),
answers: Array.isArray(data.answers) ? data.answers : question.answers,
error: undefined,
}),
);
}
} else if (event === "todo_update") {
const payload = data as TodoUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
todos: upsertBackendTodoUpdate(message.todos, payload),
}));
}
for (const subscriber of activeRun.subscribers) {
@@ -1257,6 +902,21 @@ export const buildChatRouter = (
}
}
} finally {
if (abortController.signal.aborted) {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state");
});
}
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
@@ -1273,7 +933,12 @@ export const buildChatRouter = (
subscriber.close();
}
activeRun.subscribers.clear();
activeRuns.delete(clientSessionId);
if (
activeRun.pendingPermissions.size === 0 &&
activeRun.pendingQuestions.size === 0
) {
activeRuns.delete(clientSessionId);
}
streamClosed = true;
req.off("close", handleClientClose);
res.off("close", handleClientClose);
@@ -1285,6 +950,16 @@ export const buildChatRouter = (
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat stream failed");
if (res.headersSent) {
if (!res.writableEnded && !res.destroyed) {
res.write(toSse("error", {
message: "chat stream failed",
detail,
}));
res.end();
}
return;
}
res.status(500).json({
message: "chat stream failed",
detail,