Persist agent chat sessions and protect manual titles
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -65,6 +65,8 @@ const envSchema = z
|
|||||||
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
|
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
|
||||||
// conversation metadata 持久化目录。
|
// conversation metadata 持久化目录。
|
||||||
CONVERSATION_STORAGE_DIR: z.string().default("./data/conversations"),
|
CONVERSATION_STORAGE_DIR: z.string().default("./data/conversations"),
|
||||||
|
// conversation UI state 持久化目录。
|
||||||
|
CONVERSATION_STATE_STORAGE_DIR: z.string().default("./data/conversation-states"),
|
||||||
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
|
// 每个会话最多保留多少轮 transcript,超过后裁剪旧记录。
|
||||||
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
|
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import {
|
||||||
|
atomicWriteJson,
|
||||||
|
ensureDirectory,
|
||||||
|
readJsonFile,
|
||||||
|
removeFileIfExists,
|
||||||
|
} from "../utils/fileStore.js";
|
||||||
|
|
||||||
|
export type ConversationStateRecord = {
|
||||||
|
sessionId: string;
|
||||||
|
isTitleManuallyEdited?: boolean;
|
||||||
|
messages: unknown[];
|
||||||
|
branchGroups: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ConversationStateStore {
|
||||||
|
constructor(private readonly baseDir = config.CONVERSATION_STATE_STORAGE_DIR) {}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
await ensureDirectory(this.baseDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(sessionScopeKey: string) {
|
||||||
|
return await readJsonFile<ConversationStateRecord>(this.filePath(sessionScopeKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(sessionScopeKey: string, state: ConversationStateRecord) {
|
||||||
|
await atomicWriteJson(this.filePath(sessionScopeKey), state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(sessionScopeKey: string) {
|
||||||
|
await removeFileIfExists(this.filePath(sessionScopeKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
private filePath(sessionScopeKey: string) {
|
||||||
|
return join(this.baseDir, `${sessionScopeKey}.json`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,13 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { atomicWriteJson, ensureDirectory, readJsonFile } from "../utils/fileStore.js";
|
import {
|
||||||
|
atomicWriteJson,
|
||||||
|
ensureDirectory,
|
||||||
|
listJsonFiles,
|
||||||
|
readJsonFile,
|
||||||
|
removeFileIfExists,
|
||||||
|
} from "../utils/fileStore.js";
|
||||||
import { toConversationScopeKey } from "../utils/fileStore.js";
|
import { toConversationScopeKey } from "../utils/fileStore.js";
|
||||||
|
|
||||||
export type ConversationStatus = "active" | "archived";
|
export type ConversationStatus = "active" | "archived";
|
||||||
@@ -81,7 +87,10 @@ export class ConversationStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async touch(record: ConversationRecord, updates: Partial<Pick<ConversationRecord, "title" | "status">> = {}) {
|
async touch(
|
||||||
|
record: ConversationRecord,
|
||||||
|
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {},
|
||||||
|
) {
|
||||||
const next: ConversationRecord = {
|
const next: ConversationRecord = {
|
||||||
...record,
|
...record,
|
||||||
...normalizeConversationUpdates(updates),
|
...normalizeConversationUpdates(updates),
|
||||||
@@ -91,6 +100,25 @@ export class ConversationStore {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async list(context: ConversationContext) {
|
||||||
|
const files = await listJsonFiles(this.baseDir);
|
||||||
|
const records = await Promise.all(
|
||||||
|
files.map((file) => readJsonFile<ConversationRecord>(file)),
|
||||||
|
);
|
||||||
|
return records
|
||||||
|
.filter((record): record is ConversationRecord => Boolean(record))
|
||||||
|
.filter(
|
||||||
|
(record) =>
|
||||||
|
record.actorKey === context.actorKey &&
|
||||||
|
record.projectKey === context.projectKey,
|
||||||
|
)
|
||||||
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(record: ConversationRecord) {
|
||||||
|
await removeFileIfExists(this.filePath(record.sessionScopeKey));
|
||||||
|
}
|
||||||
|
|
||||||
private filePath(sessionScopeKey: string) {
|
private filePath(sessionScopeKey: string) {
|
||||||
return join(this.baseDir, `${sessionScopeKey}.json`);
|
return join(this.baseDir, `${sessionScopeKey}.json`);
|
||||||
}
|
}
|
||||||
@@ -113,7 +141,7 @@ const normalizeConversationUpdates = (
|
|||||||
if (typeof updates.title === "string") {
|
if (typeof updates.title === "string") {
|
||||||
const trimmed = updates.title.trim();
|
const trimmed = updates.title.trim();
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
normalized.title = trimmed;
|
normalized.title = trimmed.slice(0, 120);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|||||||
+197
-3
@@ -5,6 +5,7 @@ import { type LearningOrchestrator } from "../learning/orchestrator.js";
|
|||||||
import { type SessionHistoryStore } from "../history/store.js";
|
import { type SessionHistoryStore } from "../history/store.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { MemoryStore } from "../memory/store.js";
|
import { MemoryStore } from "../memory/store.js";
|
||||||
|
import { type ConversationStateStore } from "../conversations/stateStore.js";
|
||||||
import { type ConversationStore } from "../conversations/store.js";
|
import { type ConversationStore } from "../conversations/store.js";
|
||||||
import { type ResultReferenceResolver } from "../results/resolver.js";
|
import { type ResultReferenceResolver } from "../results/resolver.js";
|
||||||
import { RESULT_REFERENCE_KIND } from "../results/store.js";
|
import { RESULT_REFERENCE_KIND } from "../results/store.js";
|
||||||
@@ -14,6 +15,7 @@ import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
|||||||
import {
|
import {
|
||||||
buildPromptWithLearningContext,
|
buildPromptWithLearningContext,
|
||||||
generateSessionTitle,
|
generateSessionTitle,
|
||||||
|
shouldGenerateSessionTitle,
|
||||||
} from "./chatSession.js";
|
} from "./chatSession.js";
|
||||||
import {
|
import {
|
||||||
collectTextContent,
|
collectTextContent,
|
||||||
@@ -42,10 +44,18 @@ const forkPayloadSchema = z.object({
|
|||||||
keep_message_count: z.coerce.number().int().min(0),
|
keep_message_count: z.coerce.number().int().min(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const conversationStateSchema = 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([]),
|
||||||
|
});
|
||||||
|
|
||||||
export const buildChatRouter = (
|
export const buildChatRouter = (
|
||||||
sessionBridge: ChatSessionBridge,
|
sessionBridge: ChatSessionBridge,
|
||||||
runtime: OpencodeRuntimeAdapter,
|
runtime: OpencodeRuntimeAdapter,
|
||||||
conversationStore: ConversationStore,
|
conversationStore: ConversationStore,
|
||||||
|
conversationStateStore: ConversationStateStore,
|
||||||
memoryStore: MemoryStore,
|
memoryStore: MemoryStore,
|
||||||
sessionHistoryStore: SessionHistoryStore,
|
sessionHistoryStore: SessionHistoryStore,
|
||||||
learningOrchestrator: LearningOrchestrator,
|
learningOrchestrator: LearningOrchestrator,
|
||||||
@@ -87,6 +97,178 @@ export const buildChatRouter = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
chatRouter.get("/sessions", async (req, res) => {
|
||||||
|
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 records = await conversationStore.list({
|
||||||
|
actorKey,
|
||||||
|
projectId,
|
||||||
|
projectKey,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.get("/session/:sessionId", async (req, res) => {
|
||||||
|
const sessionId = req.params.sessionId?.trim();
|
||||||
|
const projectId = req.header("x-project-id") ?? undefined;
|
||||||
|
const userId = req.header("x-user-id") ?? undefined;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
if (!sessionId) {
|
||||||
|
res.status(400).json({ message: "session_id is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = await conversationStore.get(
|
||||||
|
{
|
||||||
|
actorKey,
|
||||||
|
projectId,
|
||||||
|
projectKey,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
if (!conversation) {
|
||||||
|
res.status(404).json({ message: "session not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await conversationStateStore.read(conversation.sessionScopeKey);
|
||||||
|
res.json({
|
||||||
|
id: conversation.sessionId,
|
||||||
|
title: conversation.title ?? "新对话",
|
||||||
|
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
|
||||||
|
created_at: conversation.createdAt,
|
||||||
|
updated_at: conversation.updatedAt,
|
||||||
|
status: conversation.status,
|
||||||
|
session_id: conversation.sessionId,
|
||||||
|
messages: state?.messages ?? [],
|
||||||
|
branch_groups: state?.branchGroups ?? [],
|
||||||
|
parent_session_id: conversation.parentSessionId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.put("/session/:sessionId", async (req, res) => {
|
||||||
|
const sessionId = req.params.sessionId?.trim();
|
||||||
|
const parsed = conversationStateSchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
message: "invalid request payload",
|
||||||
|
detail: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = req.header("x-project-id") ?? undefined;
|
||||||
|
const userId = req.header("x-user-id") ?? undefined;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
if (!sessionId) {
|
||||||
|
res.status(400).json({ message: "session_id is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { record } = await conversationStore.ensure({
|
||||||
|
actorKey,
|
||||||
|
projectId,
|
||||||
|
projectKey,
|
||||||
|
sessionId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
const nextRecord = await conversationStore.touch(record, {
|
||||||
|
...(parsed.data.title ? { title: parsed.data.title } : {}),
|
||||||
|
});
|
||||||
|
await conversationStateStore.write(nextRecord.sessionScopeKey, {
|
||||||
|
sessionId: nextRecord.sessionId,
|
||||||
|
isTitleManuallyEdited: parsed.data.is_title_manually_edited,
|
||||||
|
messages: parsed.data.messages,
|
||||||
|
branchGroups: parsed.data.branch_groups,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
id: nextRecord.sessionId,
|
||||||
|
title: nextRecord.title ?? "新对话",
|
||||||
|
created_at: nextRecord.createdAt,
|
||||||
|
updated_at: nextRecord.updatedAt,
|
||||||
|
status: nextRecord.status,
|
||||||
|
session_id: nextRecord.sessionId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.patch("/session/:sessionId/title", async (req, res) => {
|
||||||
|
const sessionId = req.params.sessionId?.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 projectId = req.header("x-project-id") ?? undefined;
|
||||||
|
const userId = req.header("x-user-id") ?? undefined;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
if (!sessionId || !title) {
|
||||||
|
res.status(400).json({ message: "session_id and title are required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const conversation = await conversationStore.get(
|
||||||
|
{ actorKey, projectId, projectKey, userId },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
if (!conversation) {
|
||||||
|
res.status(404).json({ message: "session not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextConversation = await conversationStore.touch(conversation, { title });
|
||||||
|
const state = await conversationStateStore.read(nextConversation.sessionScopeKey);
|
||||||
|
if (state) {
|
||||||
|
await conversationStateStore.write(nextConversation.sessionScopeKey, {
|
||||||
|
...state,
|
||||||
|
isTitleManuallyEdited:
|
||||||
|
isTitleManuallyEdited ?? state.isTitleManuallyEdited,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
id: nextConversation.sessionId,
|
||||||
|
title: nextConversation.title,
|
||||||
|
updated_at: nextConversation.updatedAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chatRouter.delete("/session/:sessionId", async (req, res) => {
|
||||||
|
const sessionId = req.params.sessionId?.trim();
|
||||||
|
const projectId = req.header("x-project-id") ?? undefined;
|
||||||
|
const userId = req.header("x-user-id") ?? undefined;
|
||||||
|
const actorKey = toActorKey(userId);
|
||||||
|
const projectKey = toProjectKey(projectId);
|
||||||
|
if (!sessionId) {
|
||||||
|
res.status(400).json({ message: "session_id is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const conversation = await conversationStore.get(
|
||||||
|
{ actorKey, projectId, projectKey, userId },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
if (!conversation) {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await conversationStateStore.remove(conversation.sessionScopeKey);
|
||||||
|
await conversationStore.remove(conversation);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
chatRouter.get("/render-ref/:renderRef", async (req, res) => {
|
chatRouter.get("/render-ref/:renderRef", async (req, res) => {
|
||||||
const renderRef = req.params.renderRef?.trim();
|
const renderRef = req.params.renderRef?.trim();
|
||||||
const userId = req.header("x-user-id")?.trim();
|
const userId = req.header("x-user-id")?.trim();
|
||||||
@@ -364,9 +546,21 @@ export const buildChatRouter = (
|
|||||||
.reverse()
|
.reverse()
|
||||||
.find((message) => message.info.role === "assistant");
|
.find((message) => message.info.role === "assistant");
|
||||||
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
|
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
|
||||||
const existingSessionTitle = activeConversation.title;
|
const latestConversation =
|
||||||
|
(await conversationStore.get(
|
||||||
|
{ actorKey, projectId, projectKey, userId },
|
||||||
|
activeConversation.sessionId,
|
||||||
|
)) ?? activeConversation;
|
||||||
|
const latestConversationState = await conversationStateStore.read(
|
||||||
|
latestConversation.sessionScopeKey,
|
||||||
|
);
|
||||||
|
const existingSessionTitle = latestConversation.title;
|
||||||
let sessionTitle = existingSessionTitle;
|
let sessionTitle = existingSessionTitle;
|
||||||
const shouldGenerateTitle = recentTurns.length <= 1;
|
const shouldGenerateTitle = shouldGenerateSessionTitle({
|
||||||
|
recentTurnCount: recentTurns.length,
|
||||||
|
isTitleManuallyEdited:
|
||||||
|
latestConversationState?.isTitleManuallyEdited ?? false,
|
||||||
|
});
|
||||||
if (shouldGenerateTitle) {
|
if (shouldGenerateTitle) {
|
||||||
sessionTitle = await generateSessionTitle(runtime, {
|
sessionTitle = await generateSessionTitle(runtime, {
|
||||||
sessionId: binding.sessionId,
|
sessionId: binding.sessionId,
|
||||||
@@ -374,7 +568,7 @@ export const buildChatRouter = (
|
|||||||
fallbackTitle: existingSessionTitle,
|
fallbackTitle: existingSessionTitle,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const nextConversation = await conversationStore.touch(activeConversation, {
|
const nextConversation = await conversationStore.touch(latestConversation, {
|
||||||
...(sessionTitle && sessionTitle !== existingSessionTitle
|
...(sessionTitle && sessionTitle !== existingSessionTitle
|
||||||
? { title: sessionTitle }
|
? { title: sessionTitle }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => {
|
|||||||
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
|
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const shouldGenerateSessionTitle = (options: {
|
||||||
|
recentTurnCount: number;
|
||||||
|
isTitleManuallyEdited: boolean;
|
||||||
|
}) => options.recentTurnCount <= 1 && !options.isTitleManuallyEdited;
|
||||||
|
|
||||||
export const generateSessionTitle = async (
|
export const generateSessionTitle = async (
|
||||||
runtime: OpencodeRuntimeAdapter,
|
runtime: OpencodeRuntimeAdapter,
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import express from "express";
|
|||||||
import { SessionHistoryStore } from "./history/store.js";
|
import { SessionHistoryStore } from "./history/store.js";
|
||||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
|
import { ConversationStateStore } from "./conversations/stateStore.js";
|
||||||
import { ConversationStore } from "./conversations/store.js";
|
import { ConversationStore } from "./conversations/store.js";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
||||||
@@ -19,6 +20,7 @@ import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
|
|||||||
const app = express();
|
const app = express();
|
||||||
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
||||||
const conversationStore = new ConversationStore();
|
const conversationStore = new ConversationStore();
|
||||||
|
const conversationStateStore = new ConversationStateStore();
|
||||||
const memoryStore = new MemoryStore();
|
const memoryStore = new MemoryStore();
|
||||||
const sessionHistoryStore = new SessionHistoryStore();
|
const sessionHistoryStore = new SessionHistoryStore();
|
||||||
const toolContextStore = new ToolSessionContextStore();
|
const toolContextStore = new ToolSessionContextStore();
|
||||||
@@ -246,6 +248,7 @@ app.use(
|
|||||||
sessionBridge,
|
sessionBridge,
|
||||||
opencodeRuntime,
|
opencodeRuntime,
|
||||||
conversationStore,
|
conversationStore,
|
||||||
|
conversationStateStore,
|
||||||
memoryStore,
|
memoryStore,
|
||||||
sessionHistoryStore,
|
sessionHistoryStore,
|
||||||
learningOrchestrator,
|
learningOrchestrator,
|
||||||
@@ -256,6 +259,7 @@ app.use(
|
|||||||
const bootstrap = async () => {
|
const bootstrap = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
conversationStore.initialize(),
|
conversationStore.initialize(),
|
||||||
|
conversationStateStore.initialize(),
|
||||||
learningOrchestrator.initialize(),
|
learningOrchestrator.initialize(),
|
||||||
memoryStore.initialize(),
|
memoryStore.initialize(),
|
||||||
resultReferenceStore.initialize(),
|
resultReferenceStore.initialize(),
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
import { shouldGenerateSessionTitle } from "../../src/routes/chatSession.js";
|
||||||
|
|
||||||
|
describe("shouldGenerateSessionTitle", () => {
|
||||||
|
it("allows auto-title generation for the first turn when the title was not edited", () => {
|
||||||
|
expect(
|
||||||
|
shouldGenerateSessionTitle({
|
||||||
|
recentTurnCount: 0,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks auto-title generation after the user edits the title manually", () => {
|
||||||
|
expect(
|
||||||
|
shouldGenerateSessionTitle({
|
||||||
|
recentTurnCount: 0,
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only allows auto-title generation during the first two turns", () => {
|
||||||
|
expect(
|
||||||
|
shouldGenerateSessionTitle({
|
||||||
|
recentTurnCount: 1,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldGenerateSessionTitle({
|
||||||
|
recentTurnCount: 2,
|
||||||
|
isTitleManuallyEdited: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user