refactor: unify agent session persistence
This commit is contained in:
+106
-106
@@ -2,16 +2,16 @@ import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type LearningOrchestrator } from "../learning/orchestrator.js";
|
||||
import { type SessionHistoryStore } from "../history/store.js";
|
||||
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { MemoryStore } from "../memory/store.js";
|
||||
import { type ConversationStateStore } from "../conversations/stateStore.js";
|
||||
import { type ConversationStore } from "../conversations/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 OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
import { type ConversationRecord } from "../conversations/store.js";
|
||||
import { type SessionRecord } from "../sessions/metadataStore.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
import {
|
||||
buildPromptWithLearningContext,
|
||||
@@ -45,26 +45,24 @@ const forkPayloadSchema = z.object({
|
||||
keep_message_count: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
const conversationStateSchema = z.object({
|
||||
const sessionStateSchema = z.object({
|
||||
title: z.string().max(120).optional(),
|
||||
is_title_manually_edited: z.boolean().optional(),
|
||||
messages: z.array(z.unknown()).default([]),
|
||||
branch_groups: z.array(z.unknown()).default([]),
|
||||
});
|
||||
|
||||
const toConversationStateContext = (conversation: ConversationRecord) => ({
|
||||
actorKey: conversation.actorKey,
|
||||
projectKey: conversation.projectKey,
|
||||
sessionId: conversation.sessionId,
|
||||
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
|
||||
export const buildChatRouter = (
|
||||
sessionBridge: ChatSessionBridge,
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
conversationStore: ConversationStore,
|
||||
conversationStateStore: ConversationStateStore,
|
||||
sessionMetadataStore: SessionMetadataStore,
|
||||
sessionUiStateStore: SessionUiStateStore,
|
||||
memoryStore: MemoryStore,
|
||||
sessionHistoryStore: SessionHistoryStore,
|
||||
sessionTranscriptStore: SessionTranscriptStore,
|
||||
learningOrchestrator: LearningOrchestrator,
|
||||
resultReferenceResolver: ResultReferenceResolver,
|
||||
) => {
|
||||
@@ -84,13 +82,15 @@ export const buildChatRouter = (
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const requestedSessionId = parsed.data.session_id?.trim();
|
||||
const sessionId = requestedSessionId || (await runtime.createSession()).id;
|
||||
|
||||
const { record, created } = await conversationStore.ensure({
|
||||
const { record, created } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
parentSessionId: parsed.data.parent_session_id,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId: parsed.data.session_id,
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ export const buildChatRouter = (
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const records = await conversationStore.list({
|
||||
const records = await sessionMetadataStore.list({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
@@ -138,7 +138,7 @@ export const buildChatRouter = (
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = await conversationStore.get(
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{
|
||||
actorKey,
|
||||
projectId,
|
||||
@@ -147,31 +147,31 @@ export const buildChatRouter = (
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
if (!conversation) {
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await conversationStateStore.read(
|
||||
toConversationStateContext(conversation),
|
||||
const state = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord),
|
||||
);
|
||||
res.json({
|
||||
id: conversation.sessionId,
|
||||
title: conversation.title ?? "新对话",
|
||||
id: sessionRecord.sessionId,
|
||||
title: sessionRecord.title ?? "新对话",
|
||||
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
|
||||
created_at: conversation.createdAt,
|
||||
updated_at: conversation.updatedAt,
|
||||
status: conversation.status,
|
||||
session_id: conversation.sessionId,
|
||||
created_at: sessionRecord.createdAt,
|
||||
updated_at: sessionRecord.updatedAt,
|
||||
status: sessionRecord.status,
|
||||
session_id: sessionRecord.sessionId,
|
||||
messages: state?.messages ?? [],
|
||||
branch_groups: state?.branchGroups ?? [],
|
||||
parent_session_id: conversation.parentSessionId,
|
||||
parent_session_id: sessionRecord.parentSessionId,
|
||||
});
|
||||
});
|
||||
|
||||
chatRouter.put("/session/:sessionId", async (req, res) => {
|
||||
const sessionId = req.params.sessionId?.trim();
|
||||
const parsed = conversationStateSchema.safeParse(req.body ?? {});
|
||||
const parsed = sessionStateSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
message: "invalid request payload",
|
||||
@@ -189,17 +189,17 @@ export const buildChatRouter = (
|
||||
return;
|
||||
}
|
||||
|
||||
const { record } = await conversationStore.ensure({
|
||||
const { record } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
const nextRecord = await conversationStore.touch(record, {
|
||||
const nextRecord = await sessionMetadataStore.touch(record, {
|
||||
...(parsed.data.title ? { title: parsed.data.title } : {}),
|
||||
});
|
||||
await conversationStateStore.write(toConversationStateContext(nextRecord), {
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(nextRecord), {
|
||||
sessionId: nextRecord.sessionId,
|
||||
isTitleManuallyEdited: parsed.data.is_title_manually_edited,
|
||||
messages: parsed.data.messages,
|
||||
@@ -231,21 +231,21 @@ export const buildChatRouter = (
|
||||
res.status(400).json({ message: "session_id and title are required" });
|
||||
return;
|
||||
}
|
||||
const conversation = await conversationStore.get(
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
sessionId,
|
||||
);
|
||||
if (!conversation) {
|
||||
if (!sessionRecord) {
|
||||
res.status(404).json({ message: "session not found" });
|
||||
return;
|
||||
}
|
||||
const nextConversation = await conversationStore.touch(conversation, { title });
|
||||
const state = await conversationStateStore.read(
|
||||
toConversationStateContext(nextConversation),
|
||||
const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, { title });
|
||||
const state = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(nextSessionRecord),
|
||||
);
|
||||
if (state) {
|
||||
await conversationStateStore.write(
|
||||
toConversationStateContext(nextConversation),
|
||||
await sessionUiStateStore.write(
|
||||
toSessionUiStateContext(nextSessionRecord),
|
||||
{
|
||||
...state,
|
||||
isTitleManuallyEdited:
|
||||
@@ -254,9 +254,9 @@ export const buildChatRouter = (
|
||||
);
|
||||
}
|
||||
res.json({
|
||||
id: nextConversation.sessionId,
|
||||
title: nextConversation.title,
|
||||
updated_at: nextConversation.updatedAt,
|
||||
id: nextSessionRecord.sessionId,
|
||||
title: nextSessionRecord.title,
|
||||
updated_at: nextSessionRecord.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,22 +270,20 @@ export const buildChatRouter = (
|
||||
res.status(400).json({ message: "session_id is required" });
|
||||
return;
|
||||
}
|
||||
const conversation = await conversationStore.get(
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
sessionId,
|
||||
);
|
||||
if (!conversation) {
|
||||
if (!sessionRecord) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
await conversationStateStore.remove(toConversationStateContext(conversation));
|
||||
if (conversation.opencodeSessionId) {
|
||||
await sessionBridge.deleteConversationSession({
|
||||
clientSessionId: conversation.sessionId,
|
||||
sessionId: conversation.opencodeSessionId,
|
||||
});
|
||||
}
|
||||
await conversationStore.remove(conversation);
|
||||
await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord));
|
||||
await sessionBridge.deleteSession({
|
||||
clientSessionId: sessionRecord.sessionId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
});
|
||||
await sessionMetadataStore.remove(sessionRecord);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
@@ -347,14 +345,14 @@ export const buildChatRouter = (
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const conversation = await conversationStore.get(
|
||||
const sessionRecord = await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
parsed.data.session_id,
|
||||
);
|
||||
const binding = conversation?.opencodeSessionId
|
||||
const binding = sessionRecord
|
||||
? await sessionBridge.abort({
|
||||
clientSessionId: conversation.sessionId,
|
||||
sessionId: conversation.opencodeSessionId,
|
||||
clientSessionId: sessionRecord.sessionId,
|
||||
sessionId: sessionRecord.sessionId,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -401,54 +399,56 @@ export const buildChatRouter = (
|
||||
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sourceClientSessionId = parsed.data.session_id?.trim();
|
||||
const sourceConversation = sourceClientSessionId
|
||||
? await conversationStore.get(
|
||||
const sourceSessionId = parsed.data.session_id?.trim();
|
||||
const sourceSessionRecord = sourceSessionId
|
||||
? await sessionMetadataStore.get(
|
||||
{
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
userId,
|
||||
},
|
||||
sourceClientSessionId,
|
||||
sourceSessionId,
|
||||
)
|
||||
: null;
|
||||
const { record: targetConversation } = await conversationStore.ensure({
|
||||
const forkSession = await runtime.createSession();
|
||||
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
parentSessionId: sourceClientSessionId,
|
||||
parentSessionId: sourceSessionId,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId: forkSession.id,
|
||||
userId,
|
||||
});
|
||||
const nextClientSessionId = targetConversation.sessionId;
|
||||
const nextSessionId = targetSessionRecord.sessionId;
|
||||
|
||||
if (sourceClientSessionId && parsed.data.keep_message_count > 0) {
|
||||
await sessionHistoryStore.cloneThread(
|
||||
if (sourceSessionId && parsed.data.keep_message_count > 0) {
|
||||
await sessionTranscriptStore.cloneThread(
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: sourceClientSessionId,
|
||||
clientSessionId: sourceSessionId,
|
||||
projectKey,
|
||||
sessionId: sourceClientSessionId,
|
||||
sessionId: sourceSessionId,
|
||||
},
|
||||
{
|
||||
actorKey,
|
||||
clientSessionId: nextClientSessionId,
|
||||
clientSessionId: nextSessionId,
|
||||
projectKey,
|
||||
sessionId: nextClientSessionId,
|
||||
sessionId: nextSessionId,
|
||||
},
|
||||
parsed.data.keep_message_count,
|
||||
);
|
||||
if (sourceConversation?.title) {
|
||||
await conversationStore.touch(targetConversation, {
|
||||
title: sourceConversation.title,
|
||||
if (sourceSessionRecord?.title) {
|
||||
await sessionMetadataStore.touch(targetSessionRecord, {
|
||||
title: sourceSessionRecord.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
sourceClientSessionId: parsed.data.session_id,
|
||||
clientSessionId: nextClientSessionId,
|
||||
sourceSessionId: parsed.data.session_id,
|
||||
sessionId: nextSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
keepMessageCount: parsed.data.keep_message_count,
|
||||
@@ -457,7 +457,7 @@ export const buildChatRouter = (
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
session_id: nextClientSessionId,
|
||||
session_id: nextSessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
@@ -489,47 +489,47 @@ export const buildChatRouter = (
|
||||
const userId = req.header("x-user-id") ?? undefined;
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const { record: conversation, created: conversationCreated } =
|
||||
await conversationStore.ensure({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId: parsed.data.session_id,
|
||||
userId,
|
||||
});
|
||||
const activeConversation = await conversationStore.touch(conversation);
|
||||
const hadExistingRuntimeSession = Boolean(activeConversation.opencodeSessionId);
|
||||
const requestedSessionId = parsed.data.session_id?.trim();
|
||||
const existingSessionRecord = requestedSessionId
|
||||
? await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
requestedSessionId,
|
||||
)
|
||||
: null;
|
||||
const hadExistingRuntimeSession = Boolean(existingSessionRecord);
|
||||
|
||||
const { binding, requestContext, created } = await sessionBridge.resolve({
|
||||
clientSessionId: activeConversation.sessionId,
|
||||
sessionId: activeConversation.opencodeSessionId,
|
||||
sessionId: requestedSessionId,
|
||||
accessToken,
|
||||
projectId,
|
||||
traceId,
|
||||
userId,
|
||||
});
|
||||
const conversationWithRuntime =
|
||||
created && binding.sessionId !== activeConversation.opencodeSessionId
|
||||
? await conversationStore.touch(activeConversation, {
|
||||
opencodeSessionId: binding.sessionId,
|
||||
})
|
||||
: activeConversation;
|
||||
const { record: ensuredSessionRecord, created: sessionCreated } =
|
||||
await sessionMetadataStore.ensure({
|
||||
actorKey,
|
||||
projectId,
|
||||
projectKey,
|
||||
sessionId: binding.sessionId,
|
||||
userId,
|
||||
});
|
||||
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
|
||||
const historyContext = {
|
||||
actorKey: requestContext.actorKey,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
projectKey: requestContext.projectKey,
|
||||
sessionId: requestContext.clientSessionId,
|
||||
};
|
||||
const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8);
|
||||
const initialConversationState = await conversationStateStore.read(
|
||||
toConversationStateContext(conversationWithRuntime),
|
||||
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
|
||||
const initialSessionState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(activeSessionRecord),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
sessionId: binding.sessionId,
|
||||
created: created || conversationCreated,
|
||||
created: created || sessionCreated,
|
||||
model: parsed.data.model,
|
||||
traceId: requestContext.traceId,
|
||||
projectId: requestContext.projectId,
|
||||
@@ -565,14 +565,14 @@ export const buildChatRouter = (
|
||||
requestContext.projectKey,
|
||||
{
|
||||
recentTurns,
|
||||
persistedMessages: initialConversationState?.messages,
|
||||
persistedMessages: initialSessionState?.messages,
|
||||
message: parsed.data.message,
|
||||
restoreConversation: !hadExistingRuntimeSession,
|
||||
},
|
||||
);
|
||||
const streamResult = await streamPromptResponse({
|
||||
runtime,
|
||||
opencodeSessionId: binding.sessionId,
|
||||
sessionId: binding.sessionId,
|
||||
clientSessionId,
|
||||
message: preparedMessage,
|
||||
model: parsed.data.model,
|
||||
@@ -593,20 +593,20 @@ export const buildChatRouter = (
|
||||
.reverse()
|
||||
.find((message) => message.info.role === "assistant");
|
||||
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
|
||||
const latestConversation =
|
||||
(await conversationStore.get(
|
||||
const latestSessionRecord =
|
||||
(await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
conversationWithRuntime.sessionId,
|
||||
)) ?? conversationWithRuntime;
|
||||
const latestConversationState = await conversationStateStore.read(
|
||||
toConversationStateContext(latestConversation),
|
||||
activeSessionRecord.sessionId,
|
||||
)) ?? activeSessionRecord;
|
||||
const latestSessionState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(latestSessionRecord),
|
||||
);
|
||||
const existingSessionTitle = latestConversation.title;
|
||||
const existingSessionTitle = latestSessionRecord.title;
|
||||
let sessionTitle = existingSessionTitle;
|
||||
const shouldGenerateTitle = shouldGenerateSessionTitle({
|
||||
recentTurnCount: recentTurns.length,
|
||||
isTitleManuallyEdited:
|
||||
latestConversationState?.isTitleManuallyEdited ?? false,
|
||||
latestSessionState?.isTitleManuallyEdited ?? false,
|
||||
});
|
||||
if (shouldGenerateTitle) {
|
||||
sessionTitle = await generateSessionTitle(runtime, {
|
||||
@@ -616,7 +616,7 @@ export const buildChatRouter = (
|
||||
fallbackTitle: existingSessionTitle,
|
||||
});
|
||||
}
|
||||
const nextConversation = await conversationStore.touch(latestConversation, {
|
||||
const nextSessionRecord = await sessionMetadataStore.touch(latestSessionRecord, {
|
||||
...(sessionTitle && sessionTitle !== existingSessionTitle
|
||||
? { title: sessionTitle }
|
||||
: {}),
|
||||
|
||||
Reference in New Issue
Block a user