Compare commits
5 Commits
7e63d38cf5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c47841483 | |||
| ab12d79d91 | |||
| 7427d08d6c | |||
| f7122d1260 | |||
| 5d80961930 |
@@ -1,7 +1,10 @@
|
||||
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 internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
const initializePromise = toolContextStore.initialize();
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
@@ -21,6 +24,11 @@ export default tool({
|
||||
.describe("Query arguments object."),
|
||||
},
|
||||
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 服务,由服务侧补齐用户上下文。
|
||||
const response = await fetch(`${internalBaseUrl}/internal/tools/dynamic-http-call`, {
|
||||
method: "POST",
|
||||
@@ -29,7 +37,7 @@ export default tool({
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: context.sessionID,
|
||||
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||
reason: args.reason,
|
||||
path: args.path,
|
||||
method: args.method,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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 internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
const initializePromise = toolContextStore.initialize();
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
@@ -19,6 +22,11 @@ export default tool({
|
||||
.describe("Optional maximum number of top-level items or fields to return."),
|
||||
},
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -26,7 +34,7 @@ export default tool({
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: context.sessionID,
|
||||
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||
result_ref: args.result_ref,
|
||||
max_items: args.max_items,
|
||||
}),
|
||||
|
||||
@@ -80,7 +80,7 @@ export default tool({
|
||||
if (args.action === "add") {
|
||||
const result = await memoryStore.upsert(scope, scopeKey, {
|
||||
content: args.content ?? "",
|
||||
sessionId: context.sessionID,
|
||||
sessionId: sessionContext.clientSessionId,
|
||||
source: "tool",
|
||||
traceId: sessionContext.traceId,
|
||||
});
|
||||
@@ -105,7 +105,7 @@ export default tool({
|
||||
if (args.action === "replace") {
|
||||
const result = await memoryStore.replace(scope, scopeKey, args.target_id ?? "", {
|
||||
content: args.content ?? "",
|
||||
sessionId: context.sessionID,
|
||||
sessionId: sessionContext.clientSessionId,
|
||||
source: "tool",
|
||||
traceId: sessionContext.traceId,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
export default tool({
|
||||
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 id,value 是 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 id,value 是 area id。",
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
@@ -10,7 +10,7 @@ export default tool({
|
||||
render_ref: tool.schema
|
||||
.string()
|
||||
.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() {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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 internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
const initializePromise = toolContextStore.initialize();
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
@@ -22,6 +25,11 @@ export default tool({
|
||||
.describe("Optional maximum number of hits to return."),
|
||||
},
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -31,7 +39,7 @@ export default tool({
|
||||
body: JSON.stringify({
|
||||
max_results: args.max_results,
|
||||
query: args.query,
|
||||
sessionId: context.sessionID,
|
||||
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||
}),
|
||||
});
|
||||
const text = await response.text();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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 internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
const initializePromise = toolContextStore.initialize();
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
@@ -12,9 +15,16 @@ export default tool({
|
||||
.describe("Why this local render payload should be persisted as a render_ref."),
|
||||
file_path: tool.schema
|
||||
.string()
|
||||
.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 raw render payload, or a wrapper object with data, metadata, and location. If wrapper metadata/location is missing or stale, the resolver will normalize and write it back before storing the render_ref.",
|
||||
),
|
||||
},
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -22,7 +32,7 @@ export default tool({
|
||||
"x-agent-internal-token": internalToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: context.sessionID,
|
||||
sessionScopeKey: sessionContext.sessionScopeKey,
|
||||
file_path: args.file_path,
|
||||
}),
|
||||
});
|
||||
|
||||
+97
-215
@@ -2,10 +2,25 @@ import { randomUUID } from "node:crypto";
|
||||
|
||||
import { logger } from "../logger.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { type SessionBinding, type SessionContext, SessionRegistry } from "../session/registry.js";
|
||||
import { ToolSessionContextStore } from "../session/toolContextStore.js";
|
||||
import {
|
||||
buildToolSessionScopeKey,
|
||||
ToolSessionContextStore,
|
||||
} from "../session/toolContextStore.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 & {
|
||||
actorKey: string;
|
||||
projectKey: string;
|
||||
@@ -13,15 +28,12 @@ export type ChatRequestContext = SessionContext & {
|
||||
};
|
||||
|
||||
export class ChatSessionBridge {
|
||||
// 这里额外保存 session -> 用户上下文,供工具桥在服务端代发真实后端请求时复用。
|
||||
private readonly sessionContexts = new Map<string, ChatRequestContext>();
|
||||
private readonly sessionTitles = new Map<string, string>();
|
||||
// runtime session 仅在单次请求生命周期内有效;线程连续性由 clientSessionId 对应的持久状态承担。
|
||||
private readonly activeRuntimeSessions = new Map<string, string>();
|
||||
private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>();
|
||||
private readonly toolContextStore = new ToolSessionContextStore();
|
||||
|
||||
constructor(
|
||||
private readonly registry: SessionRegistry,
|
||||
private readonly runtime: OpencodeRuntimeAdapter,
|
||||
) {}
|
||||
constructor(private readonly runtime: OpencodeRuntimeAdapter) {}
|
||||
|
||||
async resolve(context: {
|
||||
clientSessionId?: string;
|
||||
@@ -34,61 +46,22 @@ export class ChatSessionBridge {
|
||||
requestContext: ChatRequestContext;
|
||||
created: boolean;
|
||||
}> {
|
||||
const requestContext: ChatRequestContext = {
|
||||
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 requestContext = this.buildRequestContext(context);
|
||||
await this.abortActiveRuntime(requestContext.clientSessionId);
|
||||
|
||||
const session = await this.runtime.createSession(requestContext.clientSessionId);
|
||||
const binding = this.registry.upsert(requestContext, session.id);
|
||||
this.sessionContexts.set(binding.sessionId, requestContext);
|
||||
const binding: SessionBinding = {
|
||||
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({
|
||||
actorKey: requestContext.actorKey,
|
||||
allowLearningWrite: true,
|
||||
@@ -96,105 +69,70 @@ export class ChatSessionBridge {
|
||||
learningMode: "interactive",
|
||||
projectId: requestContext.projectId,
|
||||
projectKey: requestContext.projectKey,
|
||||
sessionId: binding.sessionId,
|
||||
sessionId: session.id,
|
||||
sessionScopeKey,
|
||||
traceId: requestContext.traceId,
|
||||
});
|
||||
|
||||
return { binding, requestContext, created: true };
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.registry.count();
|
||||
return this.activeRuntimeSessions.size;
|
||||
}
|
||||
|
||||
getSessionContext(sessionId: string) {
|
||||
return this.sessionContexts.get(sessionId) ?? null;
|
||||
createClientSessionId() {
|
||||
return `agent-${randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
|
||||
getSessionTitle(sessionId: string) {
|
||||
return this.sessionTitles.get(sessionId);
|
||||
}
|
||||
|
||||
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);
|
||||
getActiveSensitiveContext(sessionScopeKey: string) {
|
||||
return this.activeSensitiveContexts.get(sessionScopeKey) ?? null;
|
||||
}
|
||||
|
||||
async abort(context: {
|
||||
clientSessionId?: string;
|
||||
accessToken?: string;
|
||||
projectId?: string;
|
||||
traceId?: string;
|
||||
userId?: string;
|
||||
}): Promise<SessionBinding | null> {
|
||||
const clientSessionId = context.clientSessionId?.trim();
|
||||
if (!clientSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestContext: ChatRequestContext = {
|
||||
clientSessionId,
|
||||
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) {
|
||||
const sessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.sessionContexts.set(binding.sessionId, requestContext);
|
||||
await this.toolContextStore.write({
|
||||
actorKey: requestContext.actorKey,
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
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;
|
||||
await this.abortActiveRuntime(clientSessionId);
|
||||
return {
|
||||
clientSessionId,
|
||||
sessionId,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
accessToken?: string;
|
||||
projectId?: string;
|
||||
traceId?: string;
|
||||
keepMessageCount: number;
|
||||
userId?: string;
|
||||
}): Promise<{
|
||||
binding: SessionBinding;
|
||||
requestContext: ChatRequestContext;
|
||||
created: boolean;
|
||||
}> {
|
||||
const currentClientSessionId = context.clientSessionId?.trim();
|
||||
const nextRequestContext: ChatRequestContext = {
|
||||
clientSessionId: `agent-${randomUUID().slice(0, 12)}`,
|
||||
}): ChatRequestContext {
|
||||
return {
|
||||
clientSessionId: context.clientSessionId?.trim() || this.createClientSessionId(),
|
||||
accessToken: context.accessToken,
|
||||
actorKey: toActorKey(context.userId),
|
||||
projectId: context.projectId,
|
||||
@@ -202,95 +140,39 @@ export class ChatSessionBridge {
|
||||
traceId: context.traceId?.trim() || `trace-${randomUUID().slice(0, 12)}`,
|
||||
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 = {
|
||||
clientSessionId: currentClientSessionId,
|
||||
accessToken: context.accessToken,
|
||||
actorKey: toActorKey(context.userId),
|
||||
projectId: context.projectId,
|
||||
projectKey: toProjectKey(context.projectId),
|
||||
traceId: nextRequestContext.traceId,
|
||||
userId: context.userId?.trim(),
|
||||
};
|
||||
|
||||
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 };
|
||||
private async abortActiveRuntime(clientSessionId: string) {
|
||||
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
|
||||
if (!activeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runtime.getSession(current.sessionId);
|
||||
const messages = await this.runtime.messages(
|
||||
current.sessionId,
|
||||
Math.max(100, context.keepMessageCount + 20),
|
||||
this.activeRuntimeSessions.delete(clientSessionId);
|
||||
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
|
||||
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",
|
||||
);
|
||||
const chatMessages = messages.filter(
|
||||
(message) => message.info.role === "user" || message.info.role === "assistant",
|
||||
});
|
||||
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 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const findScopeKey = (
|
||||
contexts: Map<string, ChatRequestContext>,
|
||||
clientSessionId: string,
|
||||
) => {
|
||||
for (const [scopeKey, context] of contexts.entries()) {
|
||||
if (context.clientSessionId === clientSessionId) {
|
||||
return scopeKey;
|
||||
}
|
||||
}
|
||||
return clientSessionId;
|
||||
};
|
||||
|
||||
+4
-2
@@ -45,8 +45,6 @@ const envSchema = z
|
||||
OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-pro"),
|
||||
// client 模式下,目标 opencode server 的基础地址。
|
||||
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
||||
// chat session 在本地注册表中的保活时长(秒)。
|
||||
SESSION_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
|
||||
// 提供给本地 opencode tools 读取的会话上下文目录。
|
||||
SESSION_CONTEXT_STORAGE_DIR: z.string().default("./data/session-contexts"),
|
||||
// TJWater 后端 API 的基础地址。
|
||||
@@ -65,6 +63,10 @@ const envSchema = z
|
||||
MEMORY_MAX_PROMPT_CHARS: z.coerce.number().int().positive().default(1800),
|
||||
// session transcript 持久化目录。
|
||||
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,超过后裁剪旧记录。
|
||||
SESSION_HISTORY_MAX_TURNS_PER_SESSION: z.coerce
|
||||
.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`);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -85,6 +85,7 @@ export class SessionHistoryStore {
|
||||
userMessage,
|
||||
};
|
||||
transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId;
|
||||
transcript.sessionId = context.sessionId;
|
||||
transcript.turns.push(record);
|
||||
if (transcript.turns.length > config.SESSION_HISTORY_MAX_TURNS_PER_SESSION) {
|
||||
transcript.turns = transcript.turns.slice(
|
||||
@@ -108,6 +109,25 @@ export class SessionHistoryStore {
|
||||
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(
|
||||
context: Pick<SessionHistoryContext, "actorKey" | "projectKey">,
|
||||
query: string,
|
||||
@@ -156,7 +176,38 @@ export class SessionHistoryStore {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -211,3 +262,19 @@ const buildSnippet = (text: string, query: string) => {
|
||||
const suffix = end < compact.length ? "..." : "";
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,10 @@ import { LearningStateStore } from "./stateStore.js";
|
||||
import { MemoryStore, type MemoryScope } from "../memory/store.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { SkillStore } from "../skills/store.js";
|
||||
import { ToolSessionContextStore } from "../session/toolContextStore.js";
|
||||
import {
|
||||
buildToolSessionScopeKey,
|
||||
ToolSessionContextStore,
|
||||
} from "../session/toolContextStore.js";
|
||||
import {
|
||||
sanitizePersistentDocument,
|
||||
sanitizePersistentLine,
|
||||
@@ -150,6 +153,11 @@ export class LearningOrchestrator {
|
||||
projectId: input.requestContext.projectId,
|
||||
projectKey: input.requestContext.projectKey,
|
||||
sessionId: gateSession.id,
|
||||
sessionScopeKey: buildToolSessionScopeKey(
|
||||
input.requestContext.actorKey,
|
||||
input.requestContext.projectKey,
|
||||
input.requestContext.clientSessionId,
|
||||
),
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
await this.runtime.prompt(
|
||||
@@ -239,6 +247,11 @@ export class LearningOrchestrator {
|
||||
projectId: input.requestContext.projectId,
|
||||
projectKey: input.requestContext.projectKey,
|
||||
sessionId: reviewSession.id,
|
||||
sessionScopeKey: buildToolSessionScopeKey(
|
||||
input.requestContext.actorKey,
|
||||
input.requestContext.projectKey,
|
||||
input.requestContext.clientSessionId,
|
||||
),
|
||||
traceId: input.requestContext.traceId,
|
||||
});
|
||||
try {
|
||||
|
||||
+88
-2
@@ -1,5 +1,5 @@
|
||||
import { config } from "../config.js";
|
||||
import { readJsonFile } from "../utils/fileStore.js";
|
||||
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
|
||||
import {
|
||||
type ResultReferenceKind,
|
||||
type ResultReferenceRecord,
|
||||
@@ -68,7 +68,15 @@ export class ResultReferenceResolver {
|
||||
throw new Error(`render payload file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const payload = extractRenderJunctionPayload(raw);
|
||||
const payloadCandidate = normalizeRenderPayloadFile(raw, {
|
||||
filePath,
|
||||
projectId: input.projectId,
|
||||
});
|
||||
if (payloadCandidate.repaired) {
|
||||
await atomicWriteJson(filePath, payloadCandidate.file);
|
||||
}
|
||||
|
||||
const payload = extractRenderJunctionPayload(payloadCandidate.data);
|
||||
if (!payload) {
|
||||
throw new Error("render payload file does not contain a valid junction render payload");
|
||||
}
|
||||
@@ -192,6 +200,39 @@ const normalizeDataForKind = (
|
||||
return data;
|
||||
};
|
||||
|
||||
const normalizeRenderPayloadFile = (
|
||||
value: unknown,
|
||||
context: { filePath: string; projectId?: string },
|
||||
): { data: unknown; file: Record<string, unknown>; repaired: boolean } => {
|
||||
if (!isRecord(value) || !("data" in value)) {
|
||||
return {
|
||||
data: value,
|
||||
file: {
|
||||
metadata: buildWrapperMetadata({}, value, context.projectId),
|
||||
location: buildWrapperLocation(undefined, context.filePath),
|
||||
data: value,
|
||||
},
|
||||
repaired: false,
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = buildWrapperMetadata(value.metadata, value, context.projectId);
|
||||
const location = buildWrapperLocation(value.location, context.filePath);
|
||||
const next: Record<string, unknown> = {
|
||||
...value,
|
||||
metadata,
|
||||
location,
|
||||
};
|
||||
|
||||
return {
|
||||
data: next.data,
|
||||
file: next,
|
||||
repaired:
|
||||
JSON.stringify(metadata) !== JSON.stringify(value.metadata ?? null) ||
|
||||
JSON.stringify(location) !== JSON.stringify(value.location ?? null),
|
||||
};
|
||||
};
|
||||
|
||||
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
@@ -221,3 +262,48 @@ const projectData = (data: unknown, maxItems: number) => {
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const buildWrapperMetadata = (
|
||||
value: unknown,
|
||||
root: unknown,
|
||||
fallbackProjectId?: string,
|
||||
) => {
|
||||
const metadata = isRecord(value) ? { ...value } : {};
|
||||
const source = isRecord(root) ? root : {};
|
||||
|
||||
if (typeof metadata.createdAt !== "string" || metadata.createdAt.trim().length === 0) {
|
||||
const createdAt =
|
||||
typeof source.createdAt === "string" && source.createdAt.trim().length > 0
|
||||
? source.createdAt.trim()
|
||||
: new Date().toISOString();
|
||||
metadata.createdAt = createdAt;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof metadata.projectId !== "string" ||
|
||||
metadata.projectId.trim().length === 0
|
||||
) {
|
||||
const projectId =
|
||||
typeof source.projectId === "string" && source.projectId.trim().length > 0
|
||||
? source.projectId.trim()
|
||||
: fallbackProjectId;
|
||||
if (projectId) {
|
||||
metadata.projectId = projectId;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
const buildWrapperLocation = (value: unknown, filePath: string) => {
|
||||
if (isRecord(value)) {
|
||||
return {
|
||||
...value,
|
||||
file_path: filePath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
file_path: filePath,
|
||||
};
|
||||
};
|
||||
|
||||
+68
-4
@@ -10,9 +10,11 @@ import {
|
||||
listJsonFiles,
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
toProjectKey,
|
||||
} from "../utils/fileStore.js";
|
||||
|
||||
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{16}$/;
|
||||
export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/;
|
||||
const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/;
|
||||
|
||||
export const RESULT_REFERENCE_KIND = {
|
||||
dynamicHttpResult: "dynamic-http-result",
|
||||
@@ -138,12 +140,15 @@ export class ResultReferenceStore {
|
||||
}
|
||||
|
||||
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
||||
if (!RESULT_REF_PATTERN.test(resultRef)) {
|
||||
const normalizedResultRef = normalizeResultRef(resultRef);
|
||||
if (!normalizedResultRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawRecord = await readJsonFile<unknown>(this.filePath(resultRef));
|
||||
const record = normalizeResultReferenceRecord(rawRecord);
|
||||
const rawRecord = await readJsonFile<unknown>(this.filePath(normalizedResultRef));
|
||||
const record =
|
||||
normalizeResultReferenceRecord(rawRecord) ??
|
||||
normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
@@ -301,6 +306,65 @@ const normalizeResultReferenceSource = (
|
||||
const isValidResultRef = (value: unknown): value is string =>
|
||||
typeof value === "string" && RESULT_REF_PATTERN.test(value);
|
||||
|
||||
const normalizeResultRef = (value: string) => {
|
||||
const match = value.trim().match(RESULT_REF_FILE_PATTERN);
|
||||
return match?.[1] ?? null;
|
||||
};
|
||||
|
||||
const normalizeLegacyRenderReferenceRecord = (
|
||||
value: unknown,
|
||||
resultRef: string,
|
||||
context: RetrievalContext,
|
||||
): ResultReferenceRecord | null => {
|
||||
const data = extractLegacyRenderPayload(value);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = isRecord(value) ? value : {};
|
||||
const metadata = isRecord(root.metadata) ? root.metadata : {};
|
||||
const projectId = firstNonEmptyString(root.projectId, metadata.projectId);
|
||||
const createdAt =
|
||||
firstNonEmptyString(root.createdAt, metadata.createdAt) ?? new Date().toISOString();
|
||||
|
||||
return {
|
||||
resultRef,
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId ?? "",
|
||||
createdAt,
|
||||
data,
|
||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
preview: buildPreview(data),
|
||||
projectId,
|
||||
projectKey: toProjectKey(projectId),
|
||||
schemaVersion: 1,
|
||||
sessionId: context.clientSessionId ?? resultRef,
|
||||
sizeBytes: estimateBytes(data),
|
||||
source: RESULT_REFERENCE_SOURCE.legacy,
|
||||
traceId: "legacy-render-ref",
|
||||
};
|
||||
};
|
||||
|
||||
const extractLegacyRenderPayload = (value: unknown) => {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = isRecord(value.data) ? value.data : value;
|
||||
if (!isRecord(candidate.node_area_map)) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const firstNonEmptyString = (...values: unknown[]) => {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isResultPreview = (value: unknown): value is ResultPreview =>
|
||||
isRecord(value) &&
|
||||
typeof value.count === "number" &&
|
||||
|
||||
+316
-43
@@ -2,17 +2,20 @@ import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type LearningOrchestrator } from "../learning/orchestrator.js";
|
||||
import { type SessionHistoryStore } from "../history/store.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { MemoryStore } from "../memory/store.js";
|
||||
import { type ConversationStateStore } from "../conversations/stateStore.js";
|
||||
import { type ConversationStore } from "../conversations/store.js";
|
||||
import { type ResultReferenceResolver } from "../results/resolver.js";
|
||||
import { RESULT_REFERENCE_KIND } from "../results/store.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
|
||||
import { toActorKey } from "../utils/fileStore.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
import {
|
||||
buildPromptWithLearningContext,
|
||||
generateSessionTitle,
|
||||
getConversationTurnStats,
|
||||
shouldGenerateSessionTitle,
|
||||
} from "./chatSession.js";
|
||||
import {
|
||||
collectTextContent,
|
||||
@@ -31,20 +34,241 @@ const abortPayloadSchema = z.object({
|
||||
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({
|
||||
session_id: z.string().max(128).optional(),
|
||||
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 = (
|
||||
sessionBridge: ChatSessionBridge,
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
conversationStore: ConversationStore,
|
||||
conversationStateStore: ConversationStateStore,
|
||||
memoryStore: MemoryStore,
|
||||
sessionHistoryStore: SessionHistoryStore,
|
||||
learningOrchestrator: LearningOrchestrator,
|
||||
resultReferenceResolver: ResultReferenceResolver,
|
||||
) => {
|
||||
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) => {
|
||||
const renderRef = req.params.renderRef?.trim();
|
||||
const userId = req.header("x-user-id")?.trim();
|
||||
@@ -99,20 +323,8 @@ export const buildChatRouter = (
|
||||
}
|
||||
|
||||
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({
|
||||
clientSessionId: parsed.data.session_id,
|
||||
accessToken,
|
||||
projectId,
|
||||
traceId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!binding) {
|
||||
@@ -124,8 +336,6 @@ export const buildChatRouter = (
|
||||
{
|
||||
clientSessionId: parsed.data.session_id,
|
||||
sessionId: binding.sessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
},
|
||||
"aborted chat session by client request",
|
||||
);
|
||||
@@ -154,37 +364,69 @@ export const buildChatRouter = (
|
||||
}
|
||||
|
||||
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, requestContext } = await sessionBridge.fork({
|
||||
clientSessionId: parsed.data.session_id,
|
||||
accessToken,
|
||||
const actorKey = toActorKey(userId);
|
||||
const projectKey = toProjectKey(projectId);
|
||||
const sourceClientSessionId = parsed.data.session_id?.trim();
|
||||
const sourceConversation = sourceClientSessionId
|
||||
? await conversationStore.get(
|
||||
{
|
||||
actorKey,
|
||||
projectId,
|
||||
traceId,
|
||||
keepMessageCount: parsed.data.keep_message_count,
|
||||
projectKey,
|
||||
userId,
|
||||
},
|
||||
sourceClientSessionId,
|
||||
)
|
||||
: null;
|
||||
const { record: targetConversation } = await conversationStore.ensure({
|
||||
actorKey,
|
||||
parentSessionId: sourceClientSessionId,
|
||||
projectId,
|
||||
projectKey,
|
||||
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(
|
||||
{
|
||||
sourceClientSessionId: parsed.data.session_id,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
sessionId: binding.sessionId,
|
||||
traceId: requestContext.traceId,
|
||||
projectId: requestContext.projectId,
|
||||
clientSessionId: nextClientSessionId,
|
||||
traceId,
|
||||
projectId,
|
||||
keepMessageCount: parsed.data.keep_message_count,
|
||||
},
|
||||
"forked chat session",
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
session_id: requestContext.clientSessionId,
|
||||
session_id: nextClientSessionId,
|
||||
});
|
||||
} catch (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 traceId = req.header("x-trace-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({
|
||||
clientSessionId: parsed.data.session_id,
|
||||
clientSessionId: activeConversation.sessionId,
|
||||
accessToken,
|
||||
projectId,
|
||||
traceId,
|
||||
userId,
|
||||
});
|
||||
const historyContext = {
|
||||
actorKey: requestContext.actorKey,
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
projectKey: requestContext.projectKey,
|
||||
sessionId: requestContext.clientSessionId,
|
||||
};
|
||||
const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
clientSessionId: requestContext.clientSessionId,
|
||||
sessionId: binding.sessionId,
|
||||
created,
|
||||
created: created || conversationCreated,
|
||||
model: parsed.data.model,
|
||||
traceId: requestContext.traceId,
|
||||
projectId: requestContext.projectId,
|
||||
@@ -260,6 +520,7 @@ export const buildChatRouter = (
|
||||
memoryStore,
|
||||
requestContext.actorKey,
|
||||
requestContext.projectKey,
|
||||
recentTurns,
|
||||
parsed.data.message,
|
||||
);
|
||||
const streamResult = await streamPromptResponse({
|
||||
@@ -285,23 +546,34 @@ export const buildChatRouter = (
|
||||
.reverse()
|
||||
.find((message) => message.info.role === "assistant");
|
||||
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;
|
||||
const { userMessageCount, assistantMessageCount } =
|
||||
await getConversationTurnStats(runtime, binding.sessionId);
|
||||
const shouldGenerateTitle =
|
||||
userMessageCount <= 3 &&
|
||||
assistantMessageCount >= 1;
|
||||
const shouldGenerateTitle = shouldGenerateSessionTitle({
|
||||
recentTurnCount: recentTurns.length,
|
||||
isTitleManuallyEdited:
|
||||
latestConversationState?.isTitleManuallyEdited ?? false,
|
||||
});
|
||||
if (shouldGenerateTitle) {
|
||||
sessionTitle = await generateSessionTitle(runtime, {
|
||||
sessionId: binding.sessionId,
|
||||
latestAssistantMessage: assistantText,
|
||||
latestUserMessage: parsed.data.message,
|
||||
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 (
|
||||
shouldGenerateTitle &&
|
||||
@@ -321,18 +593,19 @@ export const buildChatRouter = (
|
||||
assistantMessage: assistantText,
|
||||
model: parsed.data.model,
|
||||
requestContext,
|
||||
sessionId: binding.sessionId,
|
||||
sessionId: clientSessionId,
|
||||
toolCallCount: streamResult.toolCallCount,
|
||||
userMessage: parsed.data.message,
|
||||
}).catch((error) => {
|
||||
logger.warn(
|
||||
{ err: error, sessionId: binding.sessionId },
|
||||
{ err: error, sessionId: clientSessionId },
|
||||
"post-turn learning failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await sessionBridge.releaseRuntimeSession(clientSessionId, binding.sessionId);
|
||||
streamClosed = true;
|
||||
req.off("close", handleClientClose);
|
||||
res.off("close", handleClientClose);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { logger } from "../logger.js";
|
||||
import { type SessionTurnRecord } from "../history/store.js";
|
||||
import { MemoryStore } from "../memory/store.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
|
||||
@@ -8,15 +9,32 @@ const TITLE_PROMPT_TIMEOUT_MS = 5000;
|
||||
const TITLE_CONTEXT_MESSAGE_LIMIT = 40;
|
||||
const TITLE_CONTEXT_CHAR_LIMIT = 2400;
|
||||
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 DEFAULT_SESSION_TITLE = "新对话";
|
||||
|
||||
const buildSessionTitle = (message: string) => {
|
||||
const normalized = message.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return "新对话";
|
||||
return DEFAULT_SESSION_TITLE;
|
||||
}
|
||||
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
|
||||
};
|
||||
|
||||
const appendTitleContextMessage = (
|
||||
lines: string[],
|
||||
role: "用户" | "助手",
|
||||
content: string | undefined,
|
||||
maxLength = TITLE_CONTEXT_MESSAGE_CHAR_LIMIT,
|
||||
) => {
|
||||
const normalized = content?.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
lines.push(`${role}:${normalized.slice(0, maxLength)}`);
|
||||
};
|
||||
|
||||
const buildTitleConversationContext = async (
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
sessionId: string,
|
||||
@@ -63,7 +81,9 @@ const buildTitleConversationContext = async (
|
||||
const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => {
|
||||
const normalized = rawTitle
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^标题[::]\s*/i, "")
|
||||
.replace(/["'“”‘’`]/g, "")
|
||||
.replace(/[。!?!?,,、;;::]+$/g, "")
|
||||
.trim();
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
@@ -71,18 +91,34 @@ const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => {
|
||||
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 (
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
options: {
|
||||
sessionId: string;
|
||||
latestUserMessage: string;
|
||||
latestAssistantMessage?: string;
|
||||
fallbackTitle?: string;
|
||||
},
|
||||
) => {
|
||||
const fallback = options.fallbackTitle?.trim() || buildSessionTitle(options.latestUserMessage);
|
||||
const fallbackTitle = options.fallbackTitle?.trim();
|
||||
const fallback =
|
||||
fallbackTitle && fallbackTitle !== DEFAULT_SESSION_TITLE
|
||||
? fallbackTitle
|
||||
: buildSessionTitle(options.latestUserMessage);
|
||||
let titleSessionId: string | undefined;
|
||||
try {
|
||||
const conversation = await buildTitleConversationContext(runtime, options.sessionId);
|
||||
const scopedContext: string[] = [];
|
||||
appendTitleContextMessage(scopedContext, "用户", options.latestUserMessage, 480);
|
||||
appendTitleContextMessage(scopedContext, "助手", options.latestAssistantMessage, 960);
|
||||
const conversation =
|
||||
scopedContext.length > 0
|
||||
? scopedContext.join("\n")
|
||||
: await buildTitleConversationContext(runtime, options.sessionId);
|
||||
if (!conversation) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -95,8 +131,9 @@ export const generateSessionTitle = async (
|
||||
[
|
||||
"你是会话标题生成器。",
|
||||
"请根据下面整段多轮对话生成一个 8-16 字中文标题。",
|
||||
"要求:简洁、可读、避免标点、不要引号、不要解释。",
|
||||
"先理解完整对话,再概括核心任务或结论。",
|
||||
"要求:简洁、具体、可读、避免标点、不要引号、不要解释。",
|
||||
"优先概括用户当前真实需求和助手最终结论。",
|
||||
"忽略系统提示、历史记忆、学习上下文、工具日志等元信息。",
|
||||
"不要直接照抄用户任一条消息原文。",
|
||||
"只输出标题本身。",
|
||||
"",
|
||||
@@ -155,11 +192,51 @@ export const buildPromptWithLearningContext = async (
|
||||
memoryStore: MemoryStore,
|
||||
actorKey: string,
|
||||
projectKey: string,
|
||||
recentTurns: SessionTurnRecord[],
|
||||
message: string,
|
||||
) => {
|
||||
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
|
||||
if (!snapshot) {
|
||||
const restoredConversation = buildRestoredConversationContext(recentTurns);
|
||||
if (!snapshot && !restoredConversation) {
|
||||
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;
|
||||
};
|
||||
@@ -54,14 +54,6 @@ export class OpencodeRuntimeAdapter {
|
||||
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) {
|
||||
await this.prompt(sessionId, text);
|
||||
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
|
||||
@@ -103,15 +95,6 @@ export class OpencodeRuntimeAdapter {
|
||||
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) {
|
||||
const client = await this.ensureClient();
|
||||
const response = await client.session.abort({
|
||||
|
||||
+41
-20
@@ -5,6 +5,8 @@ import express from "express";
|
||||
import { SessionHistoryStore } from "./history/store.js";
|
||||
import { ChatSessionBridge } from "./chat/sessionBridge.js";
|
||||
import { config } from "./config.js";
|
||||
import { ConversationStateStore } from "./conversations/stateStore.js";
|
||||
import { ConversationStore } from "./conversations/store.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { LearningOrchestrator } from "./learning/orchestrator.js";
|
||||
import { MemoryStore } from "./memory/store.js";
|
||||
@@ -12,13 +14,13 @@ import { ResultReferenceResolver } from "./results/resolver.js";
|
||||
import { ResultReferenceStore } from "./results/store.js";
|
||||
import { buildChatRouter } from "./routes/chat.js";
|
||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||
import { SessionRegistry } from "./session/registry.js";
|
||||
import { ToolSessionContextStore } from "./session/toolContextStore.js";
|
||||
import { DynamicHttpExecutor } from "./tools/dynamicHttpExecutor.js";
|
||||
|
||||
const app = express();
|
||||
const registry = new SessionRegistry(config.SESSION_TTL_SECONDS);
|
||||
const sessionBridge = new ChatSessionBridge(registry, opencodeRuntime);
|
||||
const sessionBridge = new ChatSessionBridge(opencodeRuntime);
|
||||
const conversationStore = new ConversationStore();
|
||||
const conversationStateStore = new ConversationStateStore();
|
||||
const memoryStore = new MemoryStore();
|
||||
const sessionHistoryStore = new SessionHistoryStore();
|
||||
const toolContextStore = new ToolSessionContextStore();
|
||||
@@ -63,12 +65,22 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : "";
|
||||
const context = sessionBridge.getSessionContext(sessionId);
|
||||
const sessionScopeKey =
|
||||
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) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionId,
|
||||
message: "runtime or session context not found",
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -83,12 +95,12 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
|
||||
arguments: req.body?.arguments,
|
||||
},
|
||||
{
|
||||
accessToken: context.accessToken,
|
||||
accessToken: runtimeContext?.accessToken,
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectId: context.projectId,
|
||||
projectKey: context.projectKey,
|
||||
sessionId,
|
||||
sessionId: context.clientSessionId,
|
||||
traceId: context.traceId,
|
||||
},
|
||||
);
|
||||
@@ -108,13 +120,14 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
||||
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 context = sessionBridge.getSessionContext(sessionId);
|
||||
const context = await toolContextStore.read(sessionScopeKey);
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionId,
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -127,6 +140,7 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
|
||||
resultRef,
|
||||
{
|
||||
actorKey: context.actorKey,
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectId: context.projectId,
|
||||
},
|
||||
{
|
||||
@@ -149,13 +163,14 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
|
||||
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 context = sessionBridge.getSessionContext(sessionId);
|
||||
const context = await toolContextStore.read(sessionScopeKey);
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "session context not found",
|
||||
detail: sessionId,
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -170,7 +185,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
|
||||
clientSessionId: context.clientSessionId,
|
||||
projectId: context.projectId,
|
||||
projectKey: context.projectKey,
|
||||
sessionId,
|
||||
sessionId: context.clientSessionId,
|
||||
source: "migration",
|
||||
traceId: context.traceId,
|
||||
});
|
||||
@@ -198,13 +213,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
|
||||
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 context = await toolContextStore.read(sessionId);
|
||||
const context = await toolContextStore.read(sessionScopeKey);
|
||||
if (!context) {
|
||||
res.status(404).json({
|
||||
message: "tool session context not found",
|
||||
detail: sessionId,
|
||||
message: "session context not found",
|
||||
detail: sessionScopeKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -231,7 +247,10 @@ app.use(
|
||||
buildChatRouter(
|
||||
sessionBridge,
|
||||
opencodeRuntime,
|
||||
conversationStore,
|
||||
conversationStateStore,
|
||||
memoryStore,
|
||||
sessionHistoryStore,
|
||||
learningOrchestrator,
|
||||
resultReferenceResolver,
|
||||
),
|
||||
@@ -239,6 +258,8 @@ app.use(
|
||||
|
||||
const bootstrap = async () => {
|
||||
await Promise.all([
|
||||
conversationStore.initialize(),
|
||||
conversationStateStore.initialize(),
|
||||
learningOrchestrator.initialize(),
|
||||
memoryStore.initialize(),
|
||||
resultReferenceStore.initialize(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
readJsonFile,
|
||||
removeFileIfExists,
|
||||
} from "../utils/fileStore.js";
|
||||
import { toConversationScopeKey } from "../utils/fileStore.js";
|
||||
|
||||
export type ToolSessionContext = {
|
||||
actorKey: string;
|
||||
@@ -16,6 +17,7 @@ export type ToolSessionContext = {
|
||||
projectId?: string;
|
||||
projectKey: string;
|
||||
sessionId: string;
|
||||
sessionScopeKey: string;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
@@ -28,6 +30,9 @@ export class ToolSessionContextStore {
|
||||
|
||||
async write(context: ToolSessionContext) {
|
||||
await atomicWriteJson(this.filePath(context.sessionId), context);
|
||||
if (context.learningMode === "interactive" && context.sessionScopeKey) {
|
||||
await atomicWriteJson(this.filePath(context.sessionScopeKey), context);
|
||||
}
|
||||
}
|
||||
|
||||
async read(sessionId: string) {
|
||||
@@ -42,3 +47,9 @@ export class ToolSessionContextStore {
|
||||
return join(this.baseDir, `${sessionId}.json`);
|
||||
}
|
||||
}
|
||||
|
||||
export const buildToolSessionScopeKey = (
|
||||
actorKey: string,
|
||||
projectKey: string,
|
||||
clientSessionId: string,
|
||||
) => toConversationScopeKey(actorKey, projectKey, clientSessionId);
|
||||
|
||||
@@ -149,6 +149,12 @@ export const toProjectKey = (projectId?: string) => toScopedKey("project", proje
|
||||
export const toStableId = (...parts: string[]) =>
|
||||
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) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
|
||||
@@ -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("新标题");
|
||||
});
|
||||
});
|
||||
@@ -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("第一轮回复");
|
||||
});
|
||||
});
|
||||
+183
-1
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -177,6 +177,13 @@ describe("ResultReferenceResolver", () => {
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
metadata: {
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
projectId: "project-3",
|
||||
},
|
||||
location: {
|
||||
file_path: filePath,
|
||||
},
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
@@ -232,4 +239,179 @@ describe("ResultReferenceResolver", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs wrapper files that omit metadata and location", async () => {
|
||||
const filePath = join(tempDir, "render-wrapper-missing-fields.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const record = await resolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: "actor-4",
|
||||
clientSessionId: "client-4",
|
||||
projectId: "project-4",
|
||||
projectKey: "project-key-4",
|
||||
sessionId: "session-4",
|
||||
source: RESULT_REFERENCE_SOURCE.migration,
|
||||
traceId: "trace-4",
|
||||
});
|
||||
|
||||
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||
|
||||
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
|
||||
metadata?: Record<string, unknown>;
|
||||
location?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(repaired.metadata).toEqual({
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
projectId: "project-4",
|
||||
});
|
||||
expect(repaired.location).toEqual({
|
||||
file_path: filePath,
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs wrapper files whose location points elsewhere", async () => {
|
||||
const filePath = join(tempDir, "render-wrapper-wrong-location.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
metadata: {
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
},
|
||||
location: {
|
||||
file_path: "/tmp/elsewhere.json",
|
||||
source: "legacy",
|
||||
},
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await resolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: "actor-4",
|
||||
clientSessionId: "client-4",
|
||||
projectId: "project-4",
|
||||
projectKey: "project-key-4",
|
||||
sessionId: "session-4",
|
||||
source: RESULT_REFERENCE_SOURCE.migration,
|
||||
traceId: "trace-4",
|
||||
});
|
||||
|
||||
const repaired = JSON.parse(await readFile(filePath, "utf8")) as {
|
||||
metadata?: Record<string, unknown>;
|
||||
location?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(repaired.metadata).toEqual({
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
projectId: "project-4",
|
||||
});
|
||||
expect(repaired.location).toEqual({
|
||||
file_path: filePath,
|
||||
source: "legacy",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves legacy render payload files when callers include the json suffix", async () => {
|
||||
const legacyRef = "res-c2fcee33-577e";
|
||||
await writeFile(
|
||||
join(tempDir, `${legacyRef}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
J2: 2,
|
||||
},
|
||||
area_ids: ["DMA-1"],
|
||||
},
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
projectId: "project-legacy-render",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await resolver.getFullAuthorized(
|
||||
`${legacyRef}.json`,
|
||||
{
|
||||
actorKey: "actor-legacy-render",
|
||||
clientSessionId: "chat-legacy-render",
|
||||
projectId: "project-legacy-render",
|
||||
},
|
||||
{
|
||||
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.result_ref).toBe(legacyRef);
|
||||
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.legacy);
|
||||
expect(result?.data).toEqual({
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
J2: "2",
|
||||
},
|
||||
area_ids: ["DMA-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy render payload files scoped to their project", async () => {
|
||||
const legacyRef = "res-dddddddddddddddd";
|
||||
await writeFile(
|
||||
join(tempDir, `${legacyRef}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
},
|
||||
},
|
||||
projectId: "project-allowed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await resolver.getFullAuthorized(
|
||||
legacyRef,
|
||||
{
|
||||
actorKey: "actor-legacy-render",
|
||||
clientSessionId: "chat-legacy-render",
|
||||
projectId: "project-denied",
|
||||
},
|
||||
{
|
||||
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
generateSessionTitle,
|
||||
shouldGenerateSessionTitle,
|
||||
} from "../../src/routes/chatSession.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSessionTitle", () => {
|
||||
it("uses the current user and assistant turn instead of reading wrapped runtime context", async () => {
|
||||
let titlePrompt = "";
|
||||
const runtime = {
|
||||
createSession: async () => ({ id: "title-session" }),
|
||||
prompt: async (_sessionId: string, prompt: string) => {
|
||||
titlePrompt = prompt;
|
||||
},
|
||||
waitForSessionIdle: async () => undefined,
|
||||
messages: async () => [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "标题:泵站压力异常排查。" }],
|
||||
},
|
||||
],
|
||||
abortSession: async () => undefined,
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
|
||||
const title = await generateSessionTitle(runtime, {
|
||||
sessionId: "chat-session",
|
||||
latestUserMessage: "检查一下三号泵站最近压力波动的原因",
|
||||
latestAssistantMessage: "三号泵站压力波动主要与夜间阀门开度变化有关。",
|
||||
fallbackTitle: "新对话",
|
||||
});
|
||||
|
||||
expect(title).toBe("泵站压力异常排查");
|
||||
expect(titlePrompt).toContain("用户:检查一下三号泵站最近压力波动的原因");
|
||||
expect(titlePrompt).toContain("助手:三号泵站压力波动主要与夜间阀门开度变化有关。");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user