Files
next-tjwater-agent/src/routes/chatSessionRoutes.ts
T
jiang 6a49d2209e feat(agent): unify MCP tools and adopt Responses delivery
Move project and host queries into native MCP with host-owned OAuth/context,
while retaining memory, skills, actions and publication plugins. Add nearby-model
queries and explicit chat/task/learning model defaults.

Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped
session ownership, pending input edits and investigation communication.

Disable local-provider discovery: Server /health allowed native vLLM to probe
/v1/models on port 8000. A native before/after regression prevents recurrence.

Validation: 170 focused Bun tests; service/plugin typechecks; native discovery,
provider-discovery and input-inbox smoke checks; no paid model inference.
2026-10-19 18:46:58 +08:00

811 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express";
import { z } from "zod";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { agentModelOptions } from "../chat/models.js";
import { config } from "../config.js";
import {
getLocalAgentContext,
getTrustedProductCredential,
} from "../context/localContext.js";
import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import {
type SessionMetadataStore,
type SessionRecord,
type SessionPurpose,
SessionContextMismatchError,
readPurpose,
} from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { type ChatRunState } from "./chatRunState.js";
import { buildForkedSessionUiState } from "./chatSession.js";
import { type StreamSubscriber } from "./chatUiState.js";
import { agentTriggerContextSchema } from "./agentTrigger.js";
import {
bindOperationalTaskCredential,
clearOperationalTaskLease,
operationalTaskClient,
releaseOperationalTaskCredential,
} from "../product/operational-task-client.js";
const createSessionPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
trigger_context: agentTriggerContextSchema.optional(),
});
const taskExecutionPayloadSchema = z.object({
task_id: z.string().uuid(),
run_id: z.string().uuid(),
execution_request_id: z.string().uuid(),
workflow: z.string().trim().min(1).max(120).optional(),
});
const sessionListQuerySchema = z.object({
purpose: z.enum(["ordinary_chat", "task_execution", "task_collaboration"]).optional(),
task_id: z.string().uuid().optional(),
});
const forkPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
keep_message_count: z.coerce.number().int().min(0),
});
type RegisterChatSessionRoutesOptions = {
learningOrchestrator: LearningOrchestrator;
frontendActionCoordinator: FrontendActionCoordinator;
runState: ChatRunState;
runtime: OpencodeRuntimeAdapter;
sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore;
sessionTranscriptStore: SessionTranscriptStore;
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
const toSse = (event: string, data: Record<string, unknown>) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
export const registerChatSessionRoutes = (
chatRouter: Router,
options: RegisterChatSessionRoutesOptions,
) => {
const {
frontendActionCoordinator,
learningOrchestrator,
runState,
runtime,
sessionBridge,
sessionMetadataStore,
sessionTranscriptStore,
sessionUiStateStore,
} = options;
const { activeRuns, lastRunStatuses } = runState;
const getSessionRunStatus = runState.getStatus;
const startTaskExecution = (
record: SessionRecord,
credential: NonNullable<ReturnType<typeof getTrustedProductCredential>>,
model: string,
workflow?: string,
) => {
queueMicrotask(() => {
const baseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? `http://127.0.0.1:${config.PORT}`;
void fetch(new URL("/api/v1/responses", baseUrl), {
method: "POST",
headers: {
Authorization: credential.authorization,
"Content-Type": "application/json",
"X-Project-Id": credential.projectId,
},
body: JSON.stringify({
conversation: record.sessionId,
model,
stream: true,
input: `执行工况任务${workflow ? `:${workflow}` : ""}。先读取后端任务上下文,自主查询并保存证据和调查报告;每次开始真实步骤或改变路线时发布完整 task progress,可用 Mermaid 调查图向人解释方向、分支和依据,图及 todo 均不约束执行。实际查询资料可通过 task presentation 发布,recorded 仅表示已记录,不等待浏览器展示。数据权限由系统配置,关键数据不足时发布最终报告并正常结束本轮,人工审核仅针对具体工单等业务动作。`,
}),
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
while (!(await reader.read()).done) {
// Drain the stream without retaining the model response in memory.
}
}
if (!response.ok) {
clearOperationalTaskLease(record.sessionId);
logger.error({ sessionId: record.sessionId, status: response.status }, "background task execution failed");
}
}).catch((error) => {
clearOperationalTaskLease(record.sessionId);
logger.error({ err: error, sessionId: record.sessionId }, "background task execution request failed");
});
});
};
const prepareTaskExecution = async (
record: SessionRecord,
credential: NonNullable<ReturnType<typeof getTrustedProductCredential>>,
input: z.infer<typeof taskExecutionPayloadSchema>,
) => {
bindOperationalTaskCredential(record.sessionId, credential);
try {
const taskContext = await operationalTaskClient.getAgentContext(record.sessionId, record.taskId!);
const task = taskContext.task && typeof taskContext.task === "object"
? taskContext.task as Record<string, unknown>
: null;
const activeRun = task?.active_run && typeof task.active_run === "object"
? task.active_run as Record<string, unknown>
: null;
if (
!activeRun
|| activeRun.run_id !== input.run_id
|| activeRun.execution_request_id !== input.execution_request_id
|| typeof activeRun.revision !== "number"
|| typeof activeRun.status !== "string"
) {
throw new Error("task context did not include the requested active run");
}
if (activeRun.status === "queued") {
await operationalTaskClient.claimTask(record.sessionId, record.taskId!, input.run_id, {
requestId: input.execution_request_id,
expectedRevision: activeRun.revision,
sourceSessionId: record.sessionId,
});
return "running";
}
if (
["claimed", "running"].includes(activeRun.status)
&& activeRun.agent_session_id === record.sessionId
) {
return "running";
}
throw new Error(`task run is not executable from this session (${activeRun.status})`);
} finally {
releaseOperationalTaskCredential(record.sessionId);
}
};
chatRouter.post("/session", async (req, res) => {
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const trigger = parsed.data.trigger_context;
if (trigger && !projectId) {
res.status(403).json({ code: "PROJECT_CONTEXT_REQUIRED", message: "任务会话必须关联项目" });
return;
}
const parentSessionId = parsed.data.parent_session_id?.trim();
if (parentSessionId && !await sessionMetadataStore.get(authContext, parentSessionId)) {
res.status(404).json({ message: "session not found" });
return;
}
const purpose: SessionPurpose = trigger ? "task_execution" : "ordinary_chat";
const taskId = trigger?.subject_id;
if (trigger) {
const existingTriggeredSession = await sessionMetadataStore.findByTrigger(
{ actorKey, projectId, projectKey, userId },
trigger.trigger_id,
);
if (existingTriggeredSession) {
res.status(200).json({
session_id: existingTriggeredSession.sessionId,
created_at: existingTriggeredSession.createdAt,
updated_at: existingTriggeredSession.updatedAt,
status: existingTriggeredSession.status,
title: existingTriggeredSession.title,
parent_session_id: existingTriggeredSession.parentSessionId,
created: false,
event_duplicate: true,
trigger_id: trigger.trigger_id,
});
return;
}
}
const requestedSessionId = parsed.data.session_id?.trim();
if (requestedSessionId && !await sessionMetadataStore.get(authContext, requestedSessionId)) {
res.status(404).json({ message: "session not found" });
return;
}
let sessionId = requestedSessionId;
if (!sessionId) {
try {
sessionId = (await runtime.createSession(undefined, { actorKey, projectKey })).id;
} catch (error) {
sessionId = sessionBridge.createClientSessionId();
logger.warn(
{ err: error, sessionId },
"runtime unavailable while creating chat session; using local degraded session",
);
}
}
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: parsed.data.parent_session_id,
projectId,
projectKey,
sessionId,
purpose,
taskId,
trigger: trigger ? {
triggerId: trigger.trigger_id,
source: trigger.source,
subjectType: trigger.subject_type,
subjectId: trigger.subject_id,
workflow: trigger.workflow,
occurredAt: trigger.occurred_at,
} : undefined,
userId,
});
res.status(created ? 201 : 200).json({
session_id: record.sessionId,
created_at: record.createdAt,
updated_at: record.updatedAt,
status: record.status,
title: record.title,
parent_session_id: record.parentSessionId,
created,
event_duplicate: false,
trigger_id: record.triggerId,
purpose: readPurpose(record),
task_id: record.taskId,
});
});
chatRouter.post("/task/:task_id/collaboration/session", async (req, res) => {
const taskId = z.string().uuid().safeParse(req.params.task_id);
if (!taskId.success) {
res.status(400).json({ message: "valid task_id is required" });
return;
}
const authContext = getLocalAgentContext(req);
const credential = getTrustedProductCredential(req);
if (!credential) {
res.status(401).json({ message: "task collaboration requires product credentials" });
return;
}
const metadataContext = {
actorKey: authContext.actorKey,
projectId: authContext.projectId,
projectKey: authContext.projectKey,
userId: authContext.userId,
};
const existing = await sessionMetadataStore.findByTask(
metadataContext,
taskId.data,
"task_collaboration",
);
const validationSessionId = existing?.sessionId ?? `task-access-${crypto.randomUUID()}`;
bindOperationalTaskCredential(validationSessionId, credential);
try {
await operationalTaskClient.getAgentContext(validationSessionId, taskId.data);
} catch (error) {
res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502).json({
message: error instanceof Error ? error.message : "task access validation failed",
});
return;
} finally {
releaseOperationalTaskCredential(validationSessionId);
}
if (existing) {
res.json({
session_id: existing.sessionId,
purpose: readPurpose(existing),
task_id: existing.taskId,
created: false,
status: existing.status,
});
return;
}
const runtimeSession = await runtime.createSession(undefined, metadataContext);
const { record } = await sessionMetadataStore.ensure({
...metadataContext,
sessionId: runtimeSession.id,
purpose: "task_collaboration",
taskId: taskId.data,
});
const titled = await sessionMetadataStore.touch(record, { title: "任务协作" });
res.status(201).json({
session_id: titled.sessionId,
purpose: readPurpose(titled),
task_id: titled.taskId,
created: true,
status: titled.status,
});
});
chatRouter.post("/task-executions", async (req, res) => {
const parsed = taskExecutionPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ message: "invalid request payload", detail: parsed.error.flatten() });
return;
}
const credential = getTrustedProductCredential(req);
if (!credential) {
res.status(401).json({ message: "task execution requires product credentials" });
return;
}
const authContext = getLocalAgentContext(req);
const metadataContext = {
actorKey: authContext.actorKey,
projectId: authContext.projectId,
projectKey: authContext.projectKey,
userId: authContext.userId,
};
const existing = await sessionMetadataStore.findByRequest(metadataContext, parsed.data.execution_request_id);
if (existing) {
if (
existing.taskId !== parsed.data.task_id
|| existing.runId !== parsed.data.run_id
|| readPurpose(existing) !== "task_execution"
) {
res.status(409).json({ message: "request_id is already bound to another task" });
return;
}
if (activeRuns.get(existing.sessionId)?.status === "running") {
res.status(202).json({
session_id: existing.sessionId,
task_id: existing.taskId,
request_id: existing.requestId,
run_id: existing.runId,
purpose: readPurpose(existing),
created: false,
status: "running",
model: existing.modelId,
});
return;
}
const taskModel = existing.modelId;
if (!taskModel) {
res.status(409).json({ message: "task execution session has no model binding" });
return;
}
try {
await prepareTaskExecution(existing, credential, parsed.data);
} catch (error) {
res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 409).json({
message: error instanceof Error ? error.message : "task execution recovery failed",
});
return;
}
res.status(202).json({
session_id: existing.sessionId,
task_id: existing.taskId,
request_id: existing.requestId,
run_id: existing.runId,
purpose: readPurpose(existing),
created: false,
status: "running",
model: taskModel,
});
startTaskExecution(existing, credential, taskModel, parsed.data.workflow);
return;
}
let taskModel: string;
try {
taskModel = await runtime.resolveAvailableModel([
config.OPERATIONAL_TASK_MODEL ?? config.OPENCODE_MODEL,
...agentModelOptions.map((option) => option.id),
]);
} catch (error) {
res.status(503).json({
message: error instanceof Error ? error.message : "no operational task model is available",
});
return;
}
const runtimeSession = await runtime.createSession(undefined, metadataContext);
const { record } = await sessionMetadataStore.ensure({
...metadataContext,
sessionId: runtimeSession.id,
purpose: "task_execution",
taskId: parsed.data.task_id,
runId: parsed.data.run_id,
requestId: parsed.data.execution_request_id,
modelId: taskModel,
});
try {
await prepareTaskExecution(record, credential, parsed.data);
} catch (error) {
await sessionMetadataStore.remove(record);
clearOperationalTaskLease(record.sessionId);
res.status(error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502).json({
message: error instanceof Error ? error.message : "task claim failed",
});
return;
}
res.status(202).json({
session_id: record.sessionId,
task_id: record.taskId,
request_id: record.requestId,
run_id: record.runId,
purpose: readPurpose(record),
created: true,
status: "running",
model: taskModel,
});
startTaskExecution(record, credential, taskModel, parsed.data.workflow);
});
chatRouter.get("/sessions", async (req, res) => {
const query = sessionListQuerySchema.safeParse(req.query);
if (!query.success) {
res.status(400).json({ message: "invalid session filter", detail: query.error.flatten() });
return;
}
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const records = await sessionMetadataStore.list({
actorKey,
projectId,
projectKey,
userId,
}, {
purpose: query.data.purpose ?? "ordinary_chat",
taskId: query.data.task_id,
});
res.json({
sessions: records.map((record) => ({
id: record.sessionId,
title: record.title ?? "新对话",
created_at: record.createdAt,
updated_at: record.updatedAt,
status: record.status,
parent_session_id: record.parentSessionId,
is_streaming: activeRuns.get(record.sessionId)?.status === "running",
run_status: getSessionRunStatus(record.sessionId),
purpose: readPurpose(record),
task_id: record.taskId,
trigger: record.triggerWorkflow === "water_quality_complaint" ? {
subject_id: record.triggerSubjectId,
workflow: record.triggerWorkflow,
occurred_at: record.triggerOccurredAt,
} : undefined,
})),
});
});
chatRouter.get("/session/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{
actorKey,
projectId,
projectKey,
userId,
},
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
res.json({
id: sessionRecord.sessionId,
title: sessionRecord.title ?? "新对话",
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
created_at: sessionRecord.createdAt,
updated_at: sessionRecord.updatedAt,
status: sessionRecord.status,
session_id: sessionRecord.sessionId,
messages: state?.messages ?? [],
execution_plans: state?.executionPlans ?? [],
parent_session_id: sessionRecord.parentSessionId,
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
purpose: readPurpose(sessionRecord),
task_id: sessionRecord.taskId,
});
});
chatRouter.get("/session/:session_id/stream", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
const run = activeRuns.get(sessionRecord.sessionId);
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
res.write(
toSse("state", {
session_id: sessionRecord.sessionId,
messages: readPurpose(sessionRecord) === "task_collaboration"
? state?.messages ?? []
: run?.messages ?? state?.messages ?? [],
execution_plans: run ? undefined : state?.executionPlans,
is_streaming: run?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
}),
);
if (!run || run.status !== "running") {
res.end();
return;
}
const subscriber: StreamSubscriber = {
write: (event, data) => {
if (!res.writableEnded && !res.destroyed) {
res.write(toSse(event, data));
}
},
close: () => {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
},
};
run.subscribers.add(subscriber);
const pendingRefresh = credentialRefreshCoordinator.getPendingEvent(sessionRecord.sessionId);
if (pendingRefresh) subscriber.write(pendingRefresh.type, {
...pendingRefresh, session_id: sessionRecord.sessionId, request_id: pendingRefresh.requestId,
});
const cleanup = () => {
run.subscribers.delete(subscriber);
};
req.on("close", cleanup);
res.on("close", cleanup);
});
chatRouter.patch("/session/:session_id/title", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const title =
typeof req.body?.title === "string" ? req.body.title.trim() : "";
const isTitleManuallyEdited =
typeof req.body?.is_title_manually_edited === "boolean"
? req.body.is_title_manually_edited
: undefined;
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId || !title) {
res.status(400).json({ message: "session_id and title are required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, {
title,
});
const state = await sessionUiStateStore.read(
toSessionUiStateContext(nextSessionRecord),
);
if (state) {
await sessionUiStateStore.write(
toSessionUiStateContext(nextSessionRecord),
{
...state,
isTitleManuallyEdited:
isTitleManuallyEdited ?? state.isTitleManuallyEdited,
},
);
}
res.json({
id: nextSessionRecord.sessionId,
title: nextSessionRecord.title,
updated_at: nextSessionRecord.updatedAt,
});
});
chatRouter.delete("/session/:session_id", async (req, res) => {
try {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
{ includeDeleted: true },
);
if (!sessionRecord) {
res.status(204).end();
return;
}
await sessionMetadataStore.remove(sessionRecord);
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
await learningOrchestrator.cancelSession(sessionRecord.sessionId);
await sessionBridge.deleteSession({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
await sessionMetadataStore.purge(sessionRecord);
activeRuns.delete(sessionRecord.sessionId);
lastRunStatuses.delete(sessionRecord.sessionId);
res.status(204).end();
} catch (error) {
if (error instanceof SessionContextMismatchError) { res.status(404).json({ message: "session not found" }); return; }
logger.warn({ err: error, sessionId: req.params.session_id }, "session cleanup incomplete");
res.status(503).json({ code: "SESSION_CLEANUP_PENDING", message: "Session cleanup could not finish. Retry deleting this session." });
}
});
chatRouter.post("/fork", async (req, res) => {
const parsed = forkPayloadSchema.safeParse(req.body);
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 traceId = req.header("x-trace-id") ?? undefined;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const sourceSessionId = parsed.data.session_id?.trim();
const sourceSessionRecord = sourceSessionId
? await sessionMetadataStore.get(
{
actorKey,
projectId,
projectKey,
userId,
},
sourceSessionId,
)
: null;
if (sourceSessionId && !sourceSessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
if (sourceSessionRecord && readPurpose(sourceSessionRecord) !== "ordinary_chat") {
res.status(409).json({ message: "task sessions cannot be forked into ordinary chat history" });
return;
}
const forkSession = await runtime.createSession(undefined, { actorKey, projectKey });
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
projectId,
projectKey,
sessionId: forkSession.id,
userId,
});
const nextSessionId = targetSessionRecord.sessionId;
if (sourceSessionId) {
await sessionTranscriptStore.cloneThread(
{
actorKey,
clientSessionId: sourceSessionId,
projectKey,
sessionId: sourceSessionId,
},
{
actorKey,
clientSessionId: nextSessionId,
projectKey,
sessionId: nextSessionId,
},
parsed.data.keep_message_count,
);
}
const sourceState = sourceSessionRecord
? await sessionUiStateStore.read(
toSessionUiStateContext(sourceSessionRecord),
)
: null;
const forkTitle = sourceSessionRecord?.title
? `${sourceSessionRecord.title} 副本`
: "新对话副本";
const titledTargetSessionRecord = await sessionMetadataStore.touch(
targetSessionRecord,
{ title: forkTitle },
);
await sessionUiStateStore.write(
toSessionUiStateContext(titledTargetSessionRecord),
buildForkedSessionUiState(sourceState, {
keepMessageCount: parsed.data.keep_message_count,
targetSessionId: nextSessionId,
}),
);
logger.info(
{
sourceSessionId: parsed.data.session_id,
sessionId: nextSessionId,
traceId,
projectId,
keepMessageCount: parsed.data.keep_message_count,
},
"forked chat session",
);
res.status(200).json({
session_id: nextSessionId,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat fork failed");
res.status(500).json({
message: "chat fork failed",
detail,
});
}
});
};