Compare commits

...

3 Commits

Author SHA1 Message Date
jiang 7427d08d6c 更新渲染描述,移除对临时文件路径的限制
Agent CI/CD / docker-image (push) Successful in 12s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
2026-05-21 17:48:36 +08:00
jiang f7122d1260 Persist agent chat sessions and protect manual titles
Agent CI/CD / docker-image (push) Successful in 28s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-21 17:33:48 +08:00
jiang 5d80961930 重构会话管理功能,由后端 opencode 发放 sessionId,后端做 scope 2026-05-21 15:41:46 +08:00
23 changed files with 1130 additions and 392 deletions
+9 -1
View File
@@ -1,7 +1,10 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({ export default tool({
description: description:
@@ -21,6 +24,11 @@ export default tool({
.describe("Query arguments object."), .describe("Query arguments object."),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
// 工具本身不直接持有用户 token;通过 sessionID 回调 Agent 服务,由服务侧补齐用户上下文。 // 工具本身不直接持有用户 token;通过 sessionID 回调 Agent 服务,由服务侧补齐用户上下文。
const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, { const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, {
method: "POST", method: "POST",
@@ -29,7 +37,7 @@ export default tool({
"x-agent-internal-token": internalToken, "x-agent-internal-token": internalToken,
}, },
body: JSON.stringify({ body: JSON.stringify({
sessionId: context.sessionID, sessionScopeKey: sessionContext.sessionScopeKey,
reason: args.reason, reason: args.reason,
path: args.path, path: args.path,
method: args.method, method: args.method,
+9 -1
View File
@@ -1,7 +1,10 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({ export default tool({
description: description:
@@ -19,6 +22,11 @@ export default tool({
.describe("Optional maximum number of top-level items or fields to return."), .describe("Optional maximum number of top-level items or fields to return."),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const response = await fetch(`${internalBaseUrl}/internal/tools/fetch-result-ref`, { const response = await fetch(`${internalBaseUrl}/internal/tools/fetch-result-ref`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -26,7 +34,7 @@ export default tool({
"x-agent-internal-token": internalToken, "x-agent-internal-token": internalToken,
}, },
body: JSON.stringify({ body: JSON.stringify({
sessionId: context.sessionID, sessionScopeKey: sessionContext.sessionScopeKey,
result_ref: args.result_ref, result_ref: args.result_ref,
max_items: args.max_items, max_items: args.max_items,
}), }),
+2 -2
View File
@@ -80,7 +80,7 @@ export default tool({
if (args.action === "add") { if (args.action === "add") {
const result = await memoryStore.upsert(scope, scopeKey, { const result = await memoryStore.upsert(scope, scopeKey, {
content: args.content ?? "", content: args.content ?? "",
sessionId: context.sessionID, sessionId: sessionContext.clientSessionId,
source: "tool", source: "tool",
traceId: sessionContext.traceId, traceId: sessionContext.traceId,
}); });
@@ -105,7 +105,7 @@ export default tool({
if (args.action === "replace") { if (args.action === "replace") {
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", { const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
content: args.content ?? "", content: args.content ?? "",
sessionId: context.sessionID, sessionId: sessionContext.clientSessionId,
source: "tool", source: "tool",
traceId: sessionContext.traceId, traceId: sessionContext.traceId,
}); });
+2 -2
View File
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: description:
"在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用,格式应为 res-...),不要传 /tmp/*.json 之类的临时文件路径,也不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若当前只有本地 JSON 文件,请先调用 store_render_ref 把它迁移为受控 render_ref。供 render_ref 引用的 JSON 结构必须为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node idvalue 是 area id。", "在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用,格式应为 res-...),也不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若当前只有本地 JSON 文件,请先调用 store_render_ref 把它迁移为受控 render_ref。供 render_ref 引用的 JSON 结构必须为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node idvalue 是 area id。",
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
@@ -10,7 +10,7 @@ export default tool({
render_ref: tool.schema render_ref: tool.schema
.string() .string()
.describe( .describe(
"渲染引用 ID。必须是持久化结果引用(res-...),不要传 /tmp/*.json 或其他本地路径。前端会按该引用读取完整 payload.data 并渲染,不需要先用 fetch_result_ref 提取完整数据。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } }node_area_map 必填,area_ids / area_colors 可选。", "渲染引用 ID。必须是持久化结果引用(res-...)。前端会按该引用读取完整 payload.data 并渲染,不需要先用 fetch_result_ref 提取完整数据。render_ref 对应的数据结构必须是 { node_area_map: { [junctionId]: areaId }, area_ids?: string[], area_colors?: { [areaId]: color } }node_area_map 必填,area_ids / area_colors 可选。",
), ),
}, },
async execute() { async execute() {
+9 -1
View File
@@ -1,8 +1,11 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({ export default tool({
description: description:
@@ -22,6 +25,11 @@ export default tool({
.describe("Optional maximum number of hits to return."), .describe("Optional maximum number of hits to return."),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, { const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -31,7 +39,7 @@ export default tool({
body: JSON.stringify({ body: JSON.stringify({
max_results: args.max_results, max_results: args.max_results,
query: args.query, query: args.query,
sessionId: context.sessionID, sessionScopeKey: sessionContext.sessionScopeKey,
}), }),
}); });
const text = await response.text(); const text = await response.text();
+9 -1
View File
@@ -1,7 +1,10 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({ export default tool({
description: description:
@@ -15,6 +18,11 @@ export default tool({
.describe("Absolute path to a local JSON file containing the render payload or a wrapper object with data."), .describe("Absolute path to a local JSON file containing the render payload or a wrapper object with data."),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, { const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -22,7 +30,7 @@ export default tool({
"x-agent-internal-token": internalToken, "x-agent-internal-token": internalToken,
}, },
body: JSON.stringify({ body: JSON.stringify({
sessionId: context.sessionID, sessionScopeKey: sessionContext.sessionScopeKey,
file_path: args.file_path, file_path: args.file_path,
}), }),
}); });
+102 -220
View File
@@ -2,10 +2,25 @@ import { randomUUID } from "node:crypto";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type SessionBinding, type SessionContext, SessionRegistry } from "../session/registry.js"; import {
import { ToolSessionContextStore } from "../session/toolContextStore.js"; buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../session/toolContextStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
export type SessionBinding = {
clientSessionId: string;
sessionId: string;
startedAt: number;
};
export type SessionContext = {
clientSessionId: string;
accessToken?: string;
projectId?: string;
userId?: string;
};
export type ChatRequestContext = SessionContext & { export type ChatRequestContext = SessionContext & {
actorKey: string; actorKey: string;
projectKey: string; projectKey: string;
@@ -13,15 +28,12 @@ export type ChatRequestContext = SessionContext & {
}; };
export class ChatSessionBridge { export class ChatSessionBridge {
// 这里额外保存 session -> 用户上下文,供工具桥在服务端代发真实后端请求时复用 // runtime session 仅在单次请求生命周期内有效;线程连续性由 clientSessionId 对应的持久状态承担
private readonly sessionContexts = new Map<string, ChatRequestContext>(); private readonly activeRuntimeSessions = new Map<string, string>();
private readonly sessionTitles = new Map<string, string>(); private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>();
private readonly toolContextStore = new ToolSessionContextStore(); private readonly toolContextStore = new ToolSessionContextStore();
constructor( constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
private readonly registry: SessionRegistry,
private readonly runtime: OpencodeRuntimeAdapter,
) {}
async resolve(context: { async resolve(context: {
clientSessionId?: string; clientSessionId?: string;
@@ -34,61 +46,22 @@ export class ChatSessionBridge {
requestContext: ChatRequestContext; requestContext: ChatRequestContext;
created: boolean; created: boolean;
}> { }> {
const requestContext: ChatRequestContext = { const requestContext = this.buildRequestContext(context);
clientSessionId: await this.abortActiveRuntime(requestContext.clientSessionId);
context.clientSessionId?.trim() || `agent-${randomUUID().slice(0, 12)}`,
accessToken: context.accessToken,
actorKey: toActorKey(context.userId),
projectId: context.projectId,
projectKey: toProjectKey(context.projectId),
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(),
};
this.cleanupExpired();
const current = this.registry.get(requestContext);
if (current) {
this.sessionContexts.set(current.sessionId, requestContext);
await this.toolContextStore.write({
actorKey: requestContext.actorKey,
allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId,
learningMode: "interactive",
projectId: requestContext.projectId,
projectKey: requestContext.projectKey,
sessionId: current.sessionId,
traceId: requestContext.traceId,
});
try {
// 只有 opencode 侧 session 仍存在时,才复用本地映射。
await this.runtime.getSession(current.sessionId);
await this.runtime.waitForSessionIdle(current.sessionId).catch((error) => {
logger.warn(
{
clientSessionId: requestContext.clientSessionId,
sessionId: current.sessionId,
err: error,
},
"failed while waiting for reused opencode session to become idle",
);
});
return { binding: current, requestContext, created: false };
} catch (error) {
logger.warn(
{
clientSessionId: requestContext.clientSessionId,
sessionId: current.sessionId,
err: error,
},
"existing opencode session lookup failed, creating a new session",
);
}
}
const session = await this.runtime.createSession(requestContext.clientSessionId); const session = await this.runtime.createSession(requestContext.clientSessionId);
const binding = this.registry.upsert(requestContext, session.id); const binding: SessionBinding = {
this.sessionContexts.set(binding.sessionId, requestContext); clientSessionId: requestContext.clientSessionId,
sessionId: session.id,
startedAt: Date.now(),
};
const sessionScopeKey = buildToolSessionScopeKey(
requestContext.actorKey,
requestContext.projectKey,
requestContext.clientSessionId,
);
this.activeRuntimeSessions.set(requestContext.clientSessionId, session.id);
this.activeSensitiveContexts.set(sessionScopeKey, requestContext);
await this.toolContextStore.write({ await this.toolContextStore.write({
actorKey: requestContext.actorKey, actorKey: requestContext.actorKey,
allowLearningWrite: true, allowLearningWrite: true,
@@ -96,105 +69,70 @@ export class ChatSessionBridge {
learningMode: "interactive", learningMode: "interactive",
projectId: requestContext.projectId, projectId: requestContext.projectId,
projectKey: requestContext.projectKey, projectKey: requestContext.projectKey,
sessionId: binding.sessionId, sessionId: session.id,
sessionScopeKey,
traceId: requestContext.traceId, traceId: requestContext.traceId,
}); });
return { binding, requestContext, created: true }; return { binding, requestContext, created: true };
} }
count(): number { count(): number {
return this.registry.count(); return this.activeRuntimeSessions.size;
} }
getSessionContext(sessionId: string) { createClientSessionId() {
return this.sessionContexts.get(sessionId) ?? null; return `agent-${randomUUID().slice(0, 12)}`;
} }
getSessionTitle(sessionId: string) { getActiveSensitiveContext(sessionScopeKey: string) {
return this.sessionTitles.get(sessionId); return this.activeSensitiveContexts.get(sessionScopeKey) ?? null;
}
setSessionTitle(sessionId: string, title: string) {
const normalized = title.trim();
if (!normalized) {
return;
}
this.sessionTitles.set(sessionId, normalized);
}
cloneSessionTitle(sourceSessionId: string, targetSessionId: string) {
const existingTitle = this.sessionTitles.get(sourceSessionId);
if (!existingTitle) {
return;
}
this.sessionTitles.set(targetSessionId, existingTitle);
} }
async abort(context: { async abort(context: {
clientSessionId?: string; clientSessionId?: string;
accessToken?: string;
projectId?: string;
traceId?: string;
userId?: string;
}): Promise<SessionBinding | null> { }): Promise<SessionBinding | null> {
const clientSessionId = context.clientSessionId?.trim(); const clientSessionId = context.clientSessionId?.trim();
if (!clientSessionId) { if (!clientSessionId) {
return null; return null;
} }
const requestContext: ChatRequestContext = { const sessionId = this.activeRuntimeSessions.get(clientSessionId);
clientSessionId, if (!sessionId) {
accessToken: context.accessToken,
actorKey: toActorKey(context.userId),
projectId: context.projectId,
projectKey: toProjectKey(context.projectId),
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(),
};
this.cleanupExpired();
const binding = this.registry.get(requestContext);
if (!binding) {
return null; return null;
} }
this.sessionContexts.set(binding.sessionId, requestContext); await this.abortActiveRuntime(clientSessionId);
await this.toolContextStore.write({ return {
actorKey: requestContext.actorKey, clientSessionId,
allowLearningWrite: true, sessionId,
clientSessionId: requestContext.clientSessionId, startedAt: Date.now(),
learningMode: "interactive", };
projectId: requestContext.projectId,
projectKey: requestContext.projectKey,
sessionId: binding.sessionId,
traceId: requestContext.traceId,
});
await this.runtime.abortSession(binding.sessionId);
await this.runtime.waitForSessionIdle(binding.sessionId).catch((error) => {
logger.warn(
{ clientSessionId, sessionId: binding.sessionId, err: error },
"failed while waiting for aborted opencode session to become idle",
);
});
return binding;
} }
async fork(context: { async releaseRuntimeSession(clientSessionId: string, sessionId: string) {
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
if (activeSessionId === sessionId) {
this.activeRuntimeSessions.delete(clientSessionId);
}
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
await this.toolContextStore.remove(sessionId).catch((error) => {
logger.debug({ sessionId, err: error }, "failed to cleanup runtime tool context");
});
await this.runtime.abortSession(sessionId).catch((error) => {
logger.debug({ sessionId, err: error }, "failed to cleanup runtime session");
});
}
private buildRequestContext(context: {
clientSessionId?: string; clientSessionId?: string;
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
keepMessageCount: number;
userId?: string; userId?: string;
}): Promise<{ }): ChatRequestContext {
binding: SessionBinding; return {
requestContext: ChatRequestContext; clientSessionId: context.clientSessionId?.trim() || this.createClientSessionId(),
created: boolean;
}> {
const currentClientSessionId = context.clientSessionId?.trim();
const nextRequestContext: ChatRequestContext = {
clientSessionId: `agent-${randomUUID().slice(0, 12)}`,
accessToken: context.accessToken, accessToken: context.accessToken,
actorKey: toActorKey(context.userId), actorKey: toActorKey(context.userId),
projectId: context.projectId, projectId: context.projectId,
@@ -202,95 +140,39 @@ export class ChatSessionBridge {
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`, traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
userId: context.userId?.trim(), userId: context.userId?.trim(),
}; };
this.cleanupExpired();
if (!currentClientSessionId || context.keepMessageCount <= 0) {
const session = await this.runtime.createSession(nextRequestContext.clientSessionId);
const binding = this.registry.upsert(nextRequestContext, session.id);
this.sessionContexts.set(binding.sessionId, nextRequestContext);
await this.toolContextStore.write({
actorKey: nextRequestContext.actorKey,
allowLearningWrite: true,
clientSessionId: nextRequestContext.clientSessionId,
learningMode: "interactive",
projectId: nextRequestContext.projectId,
projectKey: nextRequestContext.projectKey,
sessionId: binding.sessionId,
traceId: nextRequestContext.traceId,
});
return { binding, requestContext: nextRequestContext, created: true };
} }
const currentContext: ChatRequestContext = { private async abortActiveRuntime(clientSessionId: string) {
clientSessionId: currentClientSessionId, const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
accessToken: context.accessToken, if (!activeSessionId) {
actorKey: toActorKey(context.userId), return;
projectId: context.projectId, }
projectKey: toProjectKey(context.projectId), this.activeRuntimeSessions.delete(clientSessionId);
traceId: nextRequestContext.traceId, this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
userId: context.userId?.trim(), await this.toolContextStore.remove(activeSessionId).catch(() => undefined);
await this.runtime.abortSession(activeSessionId).catch((error) => {
logger.warn(
{ clientSessionId, sessionId: activeSessionId, err: error },
"failed to abort previous active runtime session",
);
});
await this.runtime.waitForSessionIdle(activeSessionId).catch((error) => {
logger.warn(
{ clientSessionId, sessionId: activeSessionId, err: error },
"failed while waiting for previous runtime session to become idle",
);
});
}
}
const findScopeKey = (
contexts: Map<string, ChatRequestContext>,
clientSessionId: string,
) => {
for (const [scopeKey, context] of contexts.entries()) {
if (context.clientSessionId === clientSessionId) {
return scopeKey;
}
}
return clientSessionId;
}; };
const current = this.registry.get(currentContext);
if (!current) {
const session = await this.runtime.createSession(nextRequestContext.clientSessionId);
const binding = this.registry.upsert(nextRequestContext, session.id);
this.sessionContexts.set(binding.sessionId, nextRequestContext);
await this.toolContextStore.write({
actorKey: nextRequestContext.actorKey,
allowLearningWrite: true,
clientSessionId: nextRequestContext.clientSessionId,
learningMode: "interactive",
projectId: nextRequestContext.projectId,
projectKey: nextRequestContext.projectKey,
sessionId: binding.sessionId,
traceId: nextRequestContext.traceId,
});
return { binding, requestContext: nextRequestContext, created: true };
}
await this.runtime.getSession(current.sessionId);
const messages = await this.runtime.messages(
current.sessionId,
Math.max(100, context.keepMessageCount + 20),
);
const chatMessages = messages.filter(
(message) => message.info.role === "user" || message.info.role === "assistant",
);
const keepMessage = chatMessages[context.keepMessageCount - 1];
if (!keepMessage) {
throw new Error(`fork keep point not found for message count ${context.keepMessageCount}`);
}
const session = await this.runtime.forkSession(current.sessionId, keepMessage.info.id);
const binding = this.registry.upsert(nextRequestContext, session.id);
this.sessionContexts.set(binding.sessionId, nextRequestContext);
await this.toolContextStore.write({
actorKey: nextRequestContext.actorKey,
allowLearningWrite: true,
clientSessionId: nextRequestContext.clientSessionId,
learningMode: "interactive",
projectId: nextRequestContext.projectId,
projectKey: nextRequestContext.projectKey,
sessionId: binding.sessionId,
traceId: nextRequestContext.traceId,
});
this.cloneSessionTitle(current.sessionId, binding.sessionId);
return { binding, requestContext: nextRequestContext, created: true };
}
cleanupExpired(): void {
const expiredSessionIds = this.registry.evictExpired();
for (const sessionId of expiredSessionIds) {
this.sessionContexts.delete(sessionId);
this.sessionTitles.delete(sessionId);
void this.toolContextStore.remove(sessionId);
// 这里用 abort 做轻量清理;即使失败,也不阻断本地过期回收。
void this.runtime.abortSession(sessionId).catch((error) => {
logger.debug({ sessionId, err: error }, "ignoring failed abort for expired session");
});
}
}
}
+4 -2
View File
@@ -45,8 +45,6 @@ const envSchema = z
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"), OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
// client 模式下,目标 opencode server 的基础地址。 // client 模式下,目标 opencode server 的基础地址。
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(), OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// chat session 在本地注册表中的保活时长(秒)。
SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
// 提供给本地 opencode tools 读取的会话上下文目录。 // 提供给本地 opencode tools 读取的会话上下文目录。
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"), SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
// TJWater 后端 API 的基础地址。 // TJWater 后端 API 的基础地址。
@@ -65,6 +63,10 @@ const envSchema = z
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800), MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
// session transcript 持久化目录。 // session transcript 持久化目录。
SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"), SESSION_HISTORY_STORAGE_DIR: z.string().default("./data/session-history"),
// conversation metadata 持久化目录。
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()
+41
View File
@@ -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`);
}
}
+148
View File
@@ -0,0 +1,148 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { config } from "../config.js";
import {
atomicWriteJson,
ensureDirectory,
listJsonFiles,
readJsonFile,
removeFileIfExists,
} from "../utils/fileStore.js";
import { toConversationScopeKey } from "../utils/fileStore.js";
export type ConversationStatus = "active" | "archived";
export type ConversationRecord = {
sessionId: string;
sessionScopeKey: string;
actorKey: string;
ownerUserId?: string;
projectId?: string;
projectKey: string;
parentSessionId?: string;
createdAt: string;
updatedAt: string;
status: ConversationStatus;
title?: string;
};
type ConversationContext = {
actorKey: string;
userId?: string;
projectId?: string;
projectKey: string;
};
type EnsureConversationInput = ConversationContext & {
sessionId?: string;
parentSessionId?: string;
};
export class ConversationStore {
constructor(private readonly baseDir = config.CONVERSATION_STORAGE_DIR) {}
async initialize() {
await ensureDirectory(this.baseDir);
}
async ensure(input: EnsureConversationInput) {
const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId();
const sessionScopeKey = toConversationScopeKey(
input.actorKey,
input.projectKey,
sessionId,
);
const existing = await readJsonFile<ConversationRecord>(this.filePath(sessionScopeKey));
if (existing) {
return { created: false, record: existing };
}
const now = new Date().toISOString();
const record: ConversationRecord = {
sessionId,
sessionScopeKey,
actorKey: input.actorKey,
ownerUserId: input.userId?.trim(),
projectId: input.projectId,
projectKey: input.projectKey,
parentSessionId: normalizeSessionId(input.parentSessionId),
createdAt: now,
updatedAt: now,
status: "active",
};
await atomicWriteJson(this.filePath(sessionScopeKey), record);
return { created: true, record };
}
async get(context: ConversationContext, sessionId: string) {
const normalizedSessionId = normalizeSessionId(sessionId);
if (!normalizedSessionId) {
return null;
}
return await readJsonFile<ConversationRecord>(
this.filePath(
toConversationScopeKey(context.actorKey, context.projectKey, normalizedSessionId),
),
);
}
async touch(
record: ConversationRecord,
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {},
) {
const next: ConversationRecord = {
...record,
...normalizeConversationUpdates(updates),
updatedAt: new Date().toISOString(),
};
await atomicWriteJson(this.filePath(record.sessionScopeKey), 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) {
return join(this.baseDir, `${sessionScopeKey}.json`);
}
}
export const createConversationSessionId = () => `chat-${randomUUID().slice(0, 16)}`;
const normalizeSessionId = (value?: string) => {
const normalized = value?.trim();
return normalized ? normalized.slice(0, 128) : undefined;
};
const normalizeConversationUpdates = (
updates: Partial<Pick<ConversationRecord, "title" | "status">>,
) => {
const normalized: Partial<Pick<ConversationRecord, "title" | "status">> = {};
if (updates.status === "active" || updates.status === "archived") {
normalized.status = updates.status;
}
if (typeof updates.title === "string") {
const trimmed = updates.title.trim();
if (trimmed) {
normalized.title = trimmed.slice(0, 120);
}
}
return normalized;
};
+68 -1
View File
@@ -85,6 +85,7 @@ export class SessionHistoryStore {
userMessage, userMessage,
}; };
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId; transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
transcript.sessionId = context.sessionId;
transcript.turns.push(record); transcript.turns.push(record);
if (transcript.turns.length > config.SESSION_HISTORY_MAX_TURNS_PER_SESSION) { if (transcript.turns.length > config.SESSION_HISTORY_MAX_TURNS_PER_SESSION) {
transcript.turns = transcript.turns.slice( transcript.turns = transcript.turns.slice(
@@ -108,6 +109,25 @@ export class SessionHistoryStore {
return transcript.turns.slice(-Math.max(1, limit)); return transcript.turns.slice(-Math.max(1, limit));
} }
async cloneThread(
sourceContext: SessionHistoryContext,
targetContext: SessionHistoryContext,
keepMessageCount: number,
) {
const sourceTranscript = await this.readTranscript(sourceContext);
const timestamp = new Date().toISOString();
const nextTranscript: SessionTranscriptRecord = {
actorKey: targetContext.actorKey,
clientSessionId: targetContext.clientSessionId,
projectKey: targetContext.projectKey,
sessionId: targetContext.sessionId,
turns: projectTurnsForFork(sourceTranscript?.turns ?? [], keepMessageCount),
updatedAt: timestamp,
};
await atomicWriteJson(this.filePath(targetContext), nextTranscript);
return nextTranscript;
}
async search( async search(
context: Pick<SessionHistoryContext, "actorKey" | "projectKey">, context: Pick<SessionHistoryContext, "actorKey" | "projectKey">,
query: string, query: string,
@@ -156,7 +176,38 @@ export class SessionHistoryStore {
} }
private async readTranscript(context: SessionHistoryContext) { private async readTranscript(context: SessionHistoryContext) {
return await readJsonFile<SessionTranscriptRecord>(this.filePath(context)); const direct = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
if (direct) {
return direct;
}
const clientSessionId = context.clientSessionId?.trim();
if (!clientSessionId) {
return null;
}
const files = await listJsonFiles(this.baseDir);
const matches: SessionTranscriptRecord[] = [];
for (const file of files) {
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
if (!transcript) {
continue;
}
if (
transcript.actorKey !== context.actorKey ||
transcript.projectKey !== context.projectKey ||
transcript.clientSessionId !== clientSessionId
) {
continue;
}
matches.push(transcript);
}
if (matches.length === 0) {
return null;
}
return matches.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
} }
private filePath(context: SessionHistoryContext) { private filePath(context: SessionHistoryContext) {
@@ -211,3 +262,19 @@ const buildSnippet = (text: string, query: string) => {
const suffix = end < compact.length ? "..." : ""; const suffix = end < compact.length ? "..." : "";
return `${prefix}${snippet}${suffix}`; return `${prefix}${snippet}${suffix}`;
}; };
const projectTurnsForFork = (
turns: SessionTurnRecord[],
keepMessageCount: number,
): SessionTurnRecord[] => {
if (keepMessageCount <= 0) {
return [];
}
const keepTurnCount = Math.floor(keepMessageCount / 2);
if (keepTurnCount <= 0) {
return [];
}
return turns.slice(0, keepTurnCount);
};
+14 -1
View File
@@ -9,7 +9,10 @@ import { LearningStateStore } from "./stateStore.js";
import { MemoryStore, type MemoryScope } from "../memory/store.js"; import { MemoryStore, type MemoryScope } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SkillStore } from "../skills/store.js"; import { SkillStore } from "../skills/store.js";
import { ToolSessionContextStore } from "../session/toolContextStore.js"; import {
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../session/toolContextStore.js";
import { import {
sanitizePersistentDocument, sanitizePersistentDocument,
sanitizePersistentLine, sanitizePersistentLine,
@@ -150,6 +153,11 @@ export class LearningOrchestrator {
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: gateSession.id, sessionId: gateSession.id,
sessionScopeKey: buildToolSessionScopeKey(
input.requestContext.actorKey,
input.requestContext.projectKey,
input.requestContext.clientSessionId,
),
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
await this.runtime.prompt( await this.runtime.prompt(
@@ -239,6 +247,11 @@ export class LearningOrchestrator {
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: reviewSession.id, sessionId: reviewSession.id,
sessionScopeKey: buildToolSessionScopeKey(
input.requestContext.actorKey,
input.requestContext.projectKey,
input.requestContext.clientSessionId,
),
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
try { try {
+315 -43
View File
@@ -2,17 +2,20 @@ import { Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { type LearningOrchestrator } from "../learning/orchestrator.js"; import { type LearningOrchestrator } from "../learning/orchestrator.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 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";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { toActorKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { import {
buildPromptWithLearningContext, buildPromptWithLearningContext,
generateSessionTitle, generateSessionTitle,
getConversationTurnStats, shouldGenerateSessionTitle,
} from "./chatSession.js"; } from "./chatSession.js";
import { import {
collectTextContent, collectTextContent,
@@ -31,20 +34,241 @@ const abortPayloadSchema = z.object({
session_id: z.string().max(128), session_id: z.string().max(128),
}); });
const createSessionPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
});
const forkPayloadSchema = z.object({ const forkPayloadSchema = z.object({
session_id: z.string().max(128).optional(), session_id: z.string().max(128).optional(),
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,
conversationStateStore: ConversationStateStore,
memoryStore: MemoryStore, memoryStore: MemoryStore,
sessionHistoryStore: SessionHistoryStore,
learningOrchestrator: LearningOrchestrator, learningOrchestrator: LearningOrchestrator,
resultReferenceResolver: ResultReferenceResolver, resultReferenceResolver: ResultReferenceResolver,
) => { ) => {
const chatRouter = Router(); const chatRouter = Router();
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 projectId = req.header("x-project-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const { record, created } = await conversationStore.ensure({
actorKey,
parentSessionId: parsed.data.parent_session_id,
projectId,
projectKey,
sessionId: parsed.data.session_id,
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,
});
});
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();
@@ -99,20 +323,8 @@ export const buildChatRouter = (
} }
try { try {
const authHeader = req.header("authorization");
const accessToken = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: authHeader;
const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined;
const binding = await sessionBridge.abort({ const binding = await sessionBridge.abort({
clientSessionId: parsed.data.session_id, clientSessionId: parsed.data.session_id,
accessToken,
projectId,
traceId,
userId,
}); });
if (!binding) { if (!binding) {
@@ -124,8 +336,6 @@ export const buildChatRouter = (
{ {
clientSessionId: parsed.data.session_id, clientSessionId: parsed.data.session_id,
sessionId: binding.sessionId, sessionId: binding.sessionId,
traceId,
projectId,
}, },
"aborted chat session by client request", "aborted chat session by client request",
); );
@@ -154,37 +364,69 @@ export const buildChatRouter = (
} }
try { try {
const authHeader = req.header("authorization");
const accessToken = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: authHeader;
const projectId = req.header("x-project-id") ?? undefined; const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined; const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined; const userId = req.header("x-user-id") ?? undefined;
const { binding, requestContext } = await sessionBridge.fork({ const actorKey = toActorKey(userId);
clientSessionId: parsed.data.session_id, const projectKey = toProjectKey(projectId);
accessToken, const sourceClientSessionId = parsed.data.session_id?.trim();
const sourceConversation = sourceClientSessionId
? await conversationStore.get(
{
actorKey,
projectId, projectId,
traceId, projectKey,
keepMessageCount: parsed.data.keep_message_count, userId,
},
sourceClientSessionId,
)
: null;
const { record: targetConversation } = await conversationStore.ensure({
actorKey,
parentSessionId: sourceClientSessionId,
projectId,
projectKey,
userId, userId,
}); });
const nextClientSessionId = targetConversation.sessionId;
if (sourceClientSessionId && parsed.data.keep_message_count > 0) {
await sessionHistoryStore.cloneThread(
{
actorKey,
clientSessionId: sourceClientSessionId,
projectKey,
sessionId: sourceClientSessionId,
},
{
actorKey,
clientSessionId: nextClientSessionId,
projectKey,
sessionId: nextClientSessionId,
},
parsed.data.keep_message_count,
);
if (sourceConversation?.title) {
await conversationStore.touch(targetConversation, {
title: sourceConversation.title,
});
}
}
logger.info( logger.info(
{ {
sourceClientSessionId: parsed.data.session_id, sourceClientSessionId: parsed.data.session_id,
clientSessionId: requestContext.clientSessionId, clientSessionId: nextClientSessionId,
sessionId: binding.sessionId, traceId,
traceId: requestContext.traceId, projectId,
projectId: requestContext.projectId,
keepMessageCount: parsed.data.keep_message_count, keepMessageCount: parsed.data.keep_message_count,
}, },
"forked chat session", "forked chat session",
); );
res.status(200).json({ res.status(200).json({
session_id: requestContext.clientSessionId, session_id: nextClientSessionId,
}); });
} catch (error) { } catch (error) {
const detail = error instanceof Error ? error.message : String(error); const detail = error instanceof Error ? error.message : String(error);
@@ -214,20 +456,38 @@ export const buildChatRouter = (
const projectId = req.header("x-project-id") ?? undefined; const projectId = req.header("x-project-id") ?? undefined;
const traceId = req.header("x-trace-id") ?? undefined; const traceId = req.header("x-trace-id") ?? undefined;
const userId = req.header("x-user-id") ?? undefined; 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 { binding, requestContext, created } = await sessionBridge.resolve({ const { binding, requestContext, created } = await sessionBridge.resolve({
clientSessionId: parsed.data.session_id, clientSessionId: activeConversation.sessionId,
accessToken, accessToken,
projectId, projectId,
traceId, traceId,
userId, userId,
}); });
const historyContext = {
actorKey: requestContext.actorKey,
clientSessionId: requestContext.clientSessionId,
projectKey: requestContext.projectKey,
sessionId: requestContext.clientSessionId,
};
const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8);
logger.info( logger.info(
{ {
clientSessionId: requestContext.clientSessionId, clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId, sessionId: binding.sessionId,
created, created: created || conversationCreated,
model: parsed.data.model, model: parsed.data.model,
traceId: requestContext.traceId, traceId: requestContext.traceId,
projectId: requestContext.projectId, projectId: requestContext.projectId,
@@ -260,6 +520,7 @@ export const buildChatRouter = (
memoryStore, memoryStore,
requestContext.actorKey, requestContext.actorKey,
requestContext.projectKey, requestContext.projectKey,
recentTurns,
parsed.data.message, parsed.data.message,
); );
const streamResult = await streamPromptResponse({ const streamResult = await streamPromptResponse({
@@ -285,23 +546,33 @@ 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 = sessionBridge.getSessionTitle(binding.sessionId); 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 { userMessageCount, assistantMessageCount } = const shouldGenerateTitle = shouldGenerateSessionTitle({
await getConversationTurnStats(runtime, binding.sessionId); recentTurnCount: recentTurns.length,
const shouldGenerateTitle = isTitleManuallyEdited:
userMessageCount <= 3 && latestConversationState?.isTitleManuallyEdited ?? false,
assistantMessageCount >= 1; });
if (shouldGenerateTitle) { if (shouldGenerateTitle) {
sessionTitle = await generateSessionTitle(runtime, { sessionTitle = await generateSessionTitle(runtime, {
sessionId: binding.sessionId, sessionId: binding.sessionId,
latestUserMessage: parsed.data.message, latestUserMessage: parsed.data.message,
fallbackTitle: existingSessionTitle, fallbackTitle: existingSessionTitle,
}); });
if (sessionTitle !== existingSessionTitle) {
sessionBridge.setSessionTitle(binding.sessionId, sessionTitle);
}
} }
const nextConversation = await conversationStore.touch(latestConversation, {
...(sessionTitle && sessionTitle !== existingSessionTitle
? { title: sessionTitle }
: {}),
});
if (!streamClosed && !res.writableEnded && !res.destroyed) { if (!streamClosed && !res.writableEnded && !res.destroyed) {
if ( if (
shouldGenerateTitle && shouldGenerateTitle &&
@@ -321,18 +592,19 @@ export const buildChatRouter = (
assistantMessage: assistantText, assistantMessage: assistantText,
model: parsed.data.model, model: parsed.data.model,
requestContext, requestContext,
sessionId: binding.sessionId, sessionId: clientSessionId,
toolCallCount: streamResult.toolCallCount, toolCallCount: streamResult.toolCallCount,
userMessage: parsed.data.message, userMessage: parsed.data.message,
}).catch((error) => { }).catch((error) => {
logger.warn( logger.warn(
{ err: error, sessionId: binding.sessionId }, { err: error, sessionId: clientSessionId },
"post-turn learning failed", "post-turn learning failed",
); );
}); });
} }
} }
} finally { } finally {
await sessionBridge.releaseRuntimeSession(clientSessionId, binding.sessionId);
streamClosed = true; streamClosed = true;
req.off("close", handleClientClose); req.off("close", handleClientClose);
res.off("close", handleClientClose); res.off("close", handleClientClose);
+51 -2
View File
@@ -1,4 +1,5 @@
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { type SessionTurnRecord } from "../history/store.js";
import { MemoryStore } from "../memory/store.js"; import { MemoryStore } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
@@ -8,6 +9,9 @@ const TITLE_PROMPT_TIMEOUT_MS = 5000;
const TITLE_CONTEXT_MESSAGE_LIMIT = 40; const TITLE_CONTEXT_MESSAGE_LIMIT = 40;
const TITLE_CONTEXT_CHAR_LIMIT = 2400; const TITLE_CONTEXT_CHAR_LIMIT = 2400;
const TITLE_CONTEXT_MESSAGE_CHAR_LIMIT = 240; const TITLE_CONTEXT_MESSAGE_CHAR_LIMIT = 240;
const RESTORE_TURN_LIMIT = 8;
const RESTORE_MESSAGE_CHAR_LIMIT = 480;
const RESTORE_CONTEXT_CHAR_LIMIT = 3200;
const buildSessionTitle = (message: string) => { const buildSessionTitle = (message: string) => {
const normalized = message.replace(/\s+/g, " ").trim(); const normalized = message.replace(/\s+/g, " ").trim();
@@ -71,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: {
@@ -155,11 +164,51 @@ export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore, memoryStore: MemoryStore,
actorKey: string, actorKey: string,
projectKey: string, projectKey: string,
recentTurns: SessionTurnRecord[],
message: string, message: string,
) => { ) => {
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey }); const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
if (!snapshot) { const restoredConversation = buildRestoredConversationContext(recentTurns);
if (!snapshot && !restoredConversation) {
return message; return message;
} }
return `${snapshot}\n\n[Current user request]\n${message}`; return [snapshot, restoredConversation, `[Current user request]\n${message}`]
.filter(Boolean)
.join("\n\n");
};
const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => {
const formattedTurns = recentTurns
.slice(-RESTORE_TURN_LIMIT)
.flatMap((turn) => [
`用户:${compactMessage(turn.userMessage)}`,
`助手:${compactMessage(turn.assistantMessage)}`,
])
.filter((entry) => entry.length > 0);
if (formattedTurns.length === 0) {
return "";
}
const conversation = formattedTurns.join("\n");
const trimmedConversation =
conversation.length > RESTORE_CONTEXT_CHAR_LIMIT
? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...`
: conversation;
return [
"[Previous conversation context]",
"以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。",
trimmedConversation,
].join("\n");
};
const compactMessage = (value: string) => {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) {
return "";
}
return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
: normalized;
}; };
-17
View File
@@ -54,14 +54,6 @@ export class OpencodeRuntimeAdapter {
return requireData(response.data, "session.create"); return requireData(response.data, "session.create");
} }
async getSession(id: string) {
const client = await this.ensureClient();
const response = await client.session.get({
sessionID: id,
});
return requireData(response.data, "session.get");
}
async sendPrompt(sessionId: string, text: string) { async sendPrompt(sessionId: string, text: string) {
await this.prompt(sessionId, text); await this.prompt(sessionId, text);
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts // 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts
@@ -103,15 +95,6 @@ export class OpencodeRuntimeAdapter {
return requireData(messages.data, "session.messages"); return requireData(messages.data, "session.messages");
} }
async forkSession(sessionId: string, messageId?: string) {
const client = await this.ensureClient();
const response = await client.session.fork({
sessionID: sessionId,
messageID: messageId,
});
return requireData(response.data, "session.fork");
}
async abortSession(sessionId: string) { async abortSession(sessionId: string) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const response = await client.session.abort({ const response = await client.session.abort({
+41 -20
View File
@@ -5,6 +5,8 @@ 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 { logger } from "./logger.js"; import { logger } from "./logger.js";
import { LearningOrchestrator } from "./learning/orchestrator.js"; import { LearningOrchestrator } from "./learning/orchestrator.js";
import { MemoryStore } from "./memory/store.js"; import { MemoryStore } from "./memory/store.js";
@@ -12,13 +14,13 @@ import { ResultReferenceResolver } from "./results/resolver.js";
import { ResultReferenceStore } from "./results/store.js"; import { ResultReferenceStore } from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js"; import { buildChatRouter } from "./routes/chat.js";
import { opencodeRuntime } from "./runtime/opencode.js"; import { opencodeRuntime } from "./runtime/opencode.js";
import { SessionRegistry } from "./session/registry.js";
import { ToolSessionContextStore } from "./session/toolContextStore.js"; import { ToolSessionContextStore } from "./session/toolContextStore.js";
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js"; import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
const app = express(); const app = express();
const registry = new SessionRegistry(config.SESSION_TTL_SECONDS); const sessionBridge = new ChatSessionBridge(opencodeRuntime);
const sessionBridge = new ChatSessionBridge(registry, opencodeRuntime); 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();
@@ -63,12 +65,22 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
return; return;
} }
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : ""; const sessionScopeKey =
const context = sessionBridge.getSessionContext(sessionId); typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const threadContext = await toolContextStore.read(sessionScopeKey);
const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey);
if (!threadContext && !runtimeContext) {
res.status(404).json({
message: "runtime or session context not found",
detail: sessionScopeKey,
});
return;
}
const context = runtimeContext ?? threadContext;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "runtime or session context not found",
detail: sessionId, detail: sessionScopeKey,
}); });
return; return;
} }
@@ -83,12 +95,12 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
arguments: req.body?.arguments, arguments: req.body?.arguments,
}, },
{ {
accessToken: context.accessToken, accessToken: runtimeContext?.accessToken,
actorKey: context.actorKey, actorKey: context.actorKey,
clientSessionId: context.clientSessionId, clientSessionId: context.clientSessionId,
projectId: context.projectId, projectId: context.projectId,
projectKey: context.projectKey, projectKey: context.projectKey,
sessionId, sessionId: context.clientSessionId,
traceId: context.traceId, traceId: context.traceId,
}, },
); );
@@ -108,13 +120,14 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
return; return;
} }
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : ""; const sessionScopeKey =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : ""; const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
const context = sessionBridge.getSessionContext(sessionId); const context = await toolContextStore.read(sessionScopeKey);
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "session context not found",
detail: sessionId, detail: sessionScopeKey,
}); });
return; return;
} }
@@ -127,6 +140,7 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
resultRef, resultRef,
{ {
actorKey: context.actorKey, actorKey: context.actorKey,
clientSessionId: context.clientSessionId,
projectId: context.projectId, projectId: context.projectId,
}, },
{ {
@@ -149,13 +163,14 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
return; return;
} }
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : ""; const sessionScopeKey =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : ""; const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : "";
const context = sessionBridge.getSessionContext(sessionId); const context = await toolContextStore.read(sessionScopeKey);
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "session context not found",
detail: sessionId, detail: sessionScopeKey,
}); });
return; return;
} }
@@ -170,7 +185,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
clientSessionId: context.clientSessionId, clientSessionId: context.clientSessionId,
projectId: context.projectId, projectId: context.projectId,
projectKey: context.projectKey, projectKey: context.projectKey,
sessionId, sessionId: context.clientSessionId,
source: "migration", source: "migration",
traceId: context.traceId, traceId: context.traceId,
}); });
@@ -198,13 +213,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
return; return;
} }
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : ""; const sessionScopeKey =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const query = typeof req.body?.query === "string" ? req.body.query : ""; const query = typeof req.body?.query === "string" ? req.body.query : "";
const context = await toolContextStore.read(sessionId); const context = await toolContextStore.read(sessionScopeKey);
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "tool session context not found", message: "session context not found",
detail: sessionId, detail: sessionScopeKey,
}); });
return; return;
} }
@@ -231,7 +247,10 @@ app.use(
buildChatRouter( buildChatRouter(
sessionBridge, sessionBridge,
opencodeRuntime, opencodeRuntime,
conversationStore,
conversationStateStore,
memoryStore, memoryStore,
sessionHistoryStore,
learningOrchestrator, learningOrchestrator,
resultReferenceResolver, resultReferenceResolver,
), ),
@@ -239,6 +258,8 @@ app.use(
const bootstrap = async () => { const bootstrap = async () => {
await Promise.all([ await Promise.all([
conversationStore.initialize(),
conversationStateStore.initialize(),
learningOrchestrator.initialize(), learningOrchestrator.initialize(),
memoryStore.initialize(), memoryStore.initialize(),
resultReferenceStore.initialize(), resultReferenceStore.initialize(),
-80
View File
@@ -1,80 +0,0 @@
import crypto from "node:crypto";
export type SessionBinding = {
clientSessionId: string;
sessionId: string;
lastUsedAt: number;
};
export type SessionContext = {
clientSessionId: string;
accessToken?: string;
projectId?: string;
userId?: string;
};
export class SessionRegistry {
private readonly ttlMs: number;
private readonly bindings = new Map<string, SessionBinding>();
constructor(ttlSeconds: number) {
this.ttlMs = ttlSeconds * 1000;
}
upsert(context: SessionContext, sessionId: string): SessionBinding {
const binding: SessionBinding = {
clientSessionId: context.clientSessionId,
sessionId,
lastUsedAt: Date.now(),
};
this.bindings.set(this.makeKey(context), binding);
return binding;
}
get(context: SessionContext): SessionBinding | null {
const key = this.makeKey(context);
const binding = this.bindings.get(key);
if (!binding) {
return null;
}
if (Date.now() - binding.lastUsedAt > this.ttlMs) {
this.bindings.delete(key);
return null;
}
binding.lastUsedAt = Date.now();
return binding;
}
count(): number {
this.evictExpired();
return this.bindings.size;
}
evictExpired(): string[] {
const expired: string[] = [];
const now = Date.now();
for (const [key, binding] of this.bindings.entries()) {
if (now - binding.lastUsedAt > this.ttlMs) {
expired.push(binding.sessionId);
this.bindings.delete(key);
}
}
return expired;
}
private makeKey(context: SessionContext): string {
// 会话隔离不能只看前端 session_id;同一浏览器会话切换用户或项目时必须映射到不同 opencode session。
const digest = crypto
.createHash("sha256")
.update(
[
context.clientSessionId,
context.userId?.trim() ?? "",
context.projectId ?? "",
].join("|"),
)
.digest("hex");
return digest;
}
}
+11
View File
@@ -7,6 +7,7 @@ import {
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
import { toConversationScopeKey } from "../utils/fileStore.js";
export type ToolSessionContext = { export type ToolSessionContext = {
actorKey: string; actorKey: string;
@@ -16,6 +17,7 @@ export type ToolSessionContext = {
projectId?: string; projectId?: string;
projectKey: string; projectKey: string;
sessionId: string; sessionId: string;
sessionScopeKey: string;
traceId: string; traceId: string;
}; };
@@ -28,6 +30,9 @@ export class ToolSessionContextStore {
async write(context: ToolSessionContext) { async write(context: ToolSessionContext) {
await atomicWriteJson(this.filePath(context.sessionId), context); await atomicWriteJson(this.filePath(context.sessionId), context);
if (context.learningMode === "interactive" && context.sessionScopeKey) {
await atomicWriteJson(this.filePath(context.sessionScopeKey), context);
}
} }
async read(sessionId: string) { async read(sessionId: string) {
@@ -42,3 +47,9 @@ export class ToolSessionContextStore {
return join(this.baseDir, `${sessionId}.json`); return join(this.baseDir, `${sessionId}.json`);
} }
} }
export const buildToolSessionScopeKey = (
actorKey: string,
projectKey: string,
clientSessionId: string,
) => toConversationScopeKey(actorKey, projectKey, clientSessionId);
+6
View File
@@ -149,6 +149,12 @@ export const toProjectKey = (projectId?: string) => toScopedKey("project", proje
export const toStableId = (...parts: string[]) => export const toStableId = (...parts: string[]) =>
createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24); createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 24);
export const toConversationScopeKey = (
actorKey: string,
projectKey: string,
sessionId: string,
) => `conversation-${toStableId(actorKey, projectKey, sessionId)}`;
export const slugify = (value: string) => export const slugify = (value: string) =>
value value
.toLowerCase() .toLowerCase()
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ConversationStore } from "../../src/conversations/store.js";
describe("ConversationStore", () => {
let tempDir: string;
let store: ConversationStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-conversation-"));
store = new ConversationStore(tempDir);
await store.initialize();
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("issues backend-managed session ids when absent", async () => {
const { record, created } = await store.ensure({
actorKey: "actor-1",
projectId: "project-1",
projectKey: "project-key-1",
userId: "user-1",
});
expect(created).toBe(true);
expect(record.sessionId).toStartWith("chat-");
expect(record.ownerUserId).toBe("user-1");
expect(record.status).toBe("active");
});
it("touches metadata and preserves scoped ownership", async () => {
const { record } = await store.ensure({
actorKey: "actor-2",
projectId: "project-2",
projectKey: "project-key-2",
sessionId: "existing-session",
userId: "user-2",
});
const touched = await store.touch(record, {
title: "新标题",
});
expect(touched.title).toBe("新标题");
expect(touched.updatedAt >= record.updatedAt).toBe(true);
const fetched = await store.get(
{
actorKey: "actor-2",
projectId: "project-2",
projectKey: "project-key-2",
userId: "user-2",
},
"existing-session",
);
expect(fetched?.sessionScopeKey).toBe(record.sessionScopeKey);
expect(fetched?.title).toBe("新标题");
});
});
+138
View File
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionHistoryStore } from "../../src/history/store.js";
describe("SessionHistoryStore", () => {
let tempDir: string;
let store: SessionHistoryStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-history-"));
store = new SessionHistoryStore(tempDir);
await store.initialize();
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("falls back to legacy runtime-session transcripts by client session id and migrates on append", async () => {
await writeFile(
join(tempDir, "actor-1__project-1__runtime-session-1.json"),
JSON.stringify(
{
actorKey: "actor-1",
clientSessionId: "thread-1",
projectKey: "project-1",
sessionId: "runtime-session-1",
turns: [
{
id: "turn-1",
assistantMessage: "先检查泵站流量。",
timestamp: "2026-05-21T00:00:00.000Z",
toolCallCount: 1,
userMessage: "帮我看一下当前异常。",
},
],
updatedAt: "2026-05-21T00:00:00.000Z",
},
null,
2,
),
"utf8",
);
const recentTurns = await store.getRecentTurns(
{
actorKey: "actor-1",
clientSessionId: "thread-1",
projectKey: "project-1",
sessionId: "thread-1",
},
5,
);
expect(recentTurns).toHaveLength(1);
expect(recentTurns[0]?.userMessage).toBe("帮我看一下当前异常。");
const transcript = await store.appendTurn(
{
actorKey: "actor-1",
clientSessionId: "thread-1",
projectKey: "project-1",
sessionId: "thread-1",
},
{
assistantMessage: "已经定位到 3 条疑似异常支路。",
toolCallCount: 2,
userMessage: "继续分析这些支路。",
},
);
expect(transcript.sessionId).toBe("thread-1");
expect(transcript.turns).toHaveLength(2);
});
it("clones only the kept prefix when forking a thread", async () => {
await store.appendTurn(
{
actorKey: "actor-2",
clientSessionId: "thread-source",
projectKey: "project-2",
sessionId: "thread-source",
},
{
assistantMessage: "第一轮回复",
toolCallCount: 0,
userMessage: "第一轮提问",
},
);
await store.appendTurn(
{
actorKey: "actor-2",
clientSessionId: "thread-source",
projectKey: "project-2",
sessionId: "thread-source",
},
{
assistantMessage: "第二轮回复",
toolCallCount: 0,
userMessage: "第二轮提问",
},
);
const cloned = await store.cloneThread(
{
actorKey: "actor-2",
clientSessionId: "thread-source",
projectKey: "project-2",
sessionId: "thread-source",
},
{
actorKey: "actor-2",
clientSessionId: "thread-fork",
projectKey: "project-2",
sessionId: "thread-fork",
},
2,
);
expect(cloned.turns).toHaveLength(1);
expect(cloned.turns[0]?.userMessage).toBe("第一轮提问");
const forkRecentTurns = await store.getRecentTurns(
{
actorKey: "actor-2",
clientSessionId: "thread-fork",
projectKey: "project-2",
sessionId: "thread-fork",
},
5,
);
expect(forkRecentTurns).toHaveLength(1);
expect(forkRecentTurns[0]?.assistantMessage).toBe("第一轮回复");
});
});
+38
View File
@@ -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);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../../src/session/toolContextStore.js";
describe("ToolSessionContextStore", () => {
let tempDir: string;
let store: ToolSessionContextStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-tool-context-"));
store = new ToolSessionContextStore(tempDir);
await store.initialize();
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("writes interactive aliases under scoped session keys", async () => {
const sessionScopeKey = buildToolSessionScopeKey(
"actor-1",
"project-1",
"chat-session-1",
);
await store.write({
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "chat-session-1",
learningMode: "interactive",
projectId: "project-id-1",
projectKey: "project-1",
sessionId: "runtime-session-1",
sessionScopeKey,
traceId: "trace-1",
});
const runtimeContext = await store.read("runtime-session-1");
const scopedContext = await store.read(sessionScopeKey);
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
expect(scopedContext?.sessionScopeKey).toBe(sessionScopeKey);
expect(scopedContext?.sessionId).toBe("runtime-session-1");
});
});