重构会话管理,简化上下文存储逻辑

This commit is contained in:
2026-06-03 17:14:55 +08:00
parent 76d4b510f4
commit 04ded0ceb0
19 changed files with 420 additions and 235 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ mode: primary
model: deepseek/deepseek-v4-pro model: deepseek/deepseek-v4-pro
temperature: 0.2 temperature: 0.2
--- ---
是 TJWater 供水管网分析 Agent,运用水力专业知识,使用简体中文,回复简洁准确。 是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
## 工作流生命周期 ## 工作流生命周期
+2 -2
View File
@@ -34,12 +34,12 @@ SSE 事件:
{ {
"reason": "查询当前项目数据库健康状态", "reason": "查询当前项目数据库健康状态",
"command": "project db-health", "command": "project db-health",
"timeout": 60 "timeout": 120
} }
``` ```
- `command`tjwater-cli 子命令(不含二进制路径和 `--auth-context` - `command`tjwater-cli 子命令(不含二进制路径和 `--auth-context`
- `timeout`:可选超时秒数,默认 60,大结果集建议 300+ - `timeout`:可选超时秒数,默认 120,大结果集建议 300+
- 认证上下文(token、server、project)由内部桥接自动注入 - 认证上下文(token、server、project)由内部桥接自动注入
## 4) 工具参数约定(前端工具) ## 4) 工具参数约定(前端工具)
+15 -7
View File
@@ -17,7 +17,7 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输
{ {
"reason": "说明调用原因", "reason": "说明调用原因",
"command": "project list", "command": "project list",
"timeout": 60 "timeout": 120
} }
``` ```
@@ -25,7 +25,7 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输
|------|------|------|------| |------|------|------|------|
| `reason` | string | 是 | 调用原因 | | `reason` | string | 是 | 调用原因 |
| `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context` | | `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context` |
| `timeout` | number | 否 | 超时秒数,默认 60,大结果集建议 300+ | | `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ |
认证上下文(token、server、project、network)由内部桥接自动注入,无需手动传参。 认证上下文(token、server、project、network)由内部桥接自动注入,无需手动传参。
@@ -117,6 +117,7 @@ tjwater-cli help COMMAND → 子命令与参数详情
4. **管道串联** — workflow 脚本中用 shell pipe 串联多个 CLI 命令,减少 `subprocess.run` 次数 4. **管道串联** — workflow 脚本中用 shell pipe 串联多个 CLI 命令,减少 `subprocess.run` 次数
5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试 5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试
6. **大结果集** — 优先过滤/采样,不要一次性拉取全部数据 6. **大结果集** — 优先过滤/采样,不要一次性拉取全部数据
7. **模拟时长控制** — 模拟(`simulation`)或方案模拟的 `--duration` 不宜过长,建议每次仿真时间跨度控制在一小时以内,避免计算耗时过长或结果数据量过大
## 示例 ## 示例
@@ -147,18 +148,25 @@ tjwater-cli help COMMAND → 子命令与参数详情
### 触发仿真并获取结果 ### 触发仿真并获取结果
`simulation run` 仅接受 `--start-time`RFC3339,必填)和 `--duration`(整数分钟,必填)。结果需从 `data timeseries` 获取 通常系统会自动跑仿真,建议**先尝试获取结果**,若无数据再触发仿真
```json ```json
// step 1: 触发仿真 (duration 为分钟数) // step 1: 先尝试获取仿真结果
{ {
"reason": "触发24小时水力仿真", "reason": "尝试获取节点 J-001 09:00 时刻的仿真压力",
"command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 1440" "command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00"
} }
// step 2: 按节点和时间获取仿真结果 // step 2: 若 step 1 无数据(ok: false 或 data 为空),触发仿真
{
"reason": "无已有仿真结果,触发1小时水力仿真",
"command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 60"
}
// step 3: 仿真完成后,再次获取结果(同 step 1)
{ {
"reason": "获取仿真结果中节点 J-001 09:00 时刻的压力", "reason": "获取仿真结果中节点 J-001 09:00 时刻的压力",
"command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00" "command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00"
} }
``` ```
`simulation run` 仅接受 `--start-time`RFC3339,必填)和 `--duration`(整数分钟,必填)。
+29
View File
@@ -0,0 +1,29 @@
---
name: workflow
description: 供水管网分析工作流目录,描述可复用的分析流程与操作序列。
---
# workflow 工作流
## 简介
本 skill 为工作流目录入口,汇总可复用的多步骤分析流程。每个工作流对应一个子目录,内含该流程的完整操作步骤、所需数据来源与判定策略。
## 可用工作流
| 工作流 | 子目录 | 用途 |
|--------|--------|------|
| 水力瓶颈分析 | `hydraulic-bottleneck-analysis` | 复合评分法识别管网水力瓶颈,区分管径不足与阀门节流问题 |
## 使用方式
1. 根据分析需求匹配对应工作流
2. 按子目录 `SKILL.md` 中的步骤依次执行
3. 严格遵循判定策略与阈值,避免凭经验跳过步骤
## 工作流规范
- 每个工作流子目录包含独立的 `SKILL.md`,描述目的、步骤、参数与判定阈值
- 所有数据获取均通过 `tjwater-cli` 命令族(见 `tjwater-cli` skill
- 流程中若涉及仿真,遵循"先查结果后触发"原则
- 新工作流由 `skill_manager` 在线追加,若子目录不存在则无对应工作流
@@ -0,0 +1,14 @@
---
name: hydraulic-bottleneck-analysis
description: 由 skill_manager 在线追加的高置信度可复用 workflow。
version: 1.0.0
---
# learned skill
## 简介
记录由 `skill_manager` 在线追加的高置信度 workflow 模式。
## Learned Patterns
- [350873f4366ebc50601f694a] 水力瓶颈复合评分法:流速分级(>3.0m/s=极危, >2.0m/s=严重, >1.5m/s=偏高) × 水头损失绝对值(>P90=严重, >P80=中度),双侧超标为复合瓶颈;辅以节点压力(<20m低压, 20-25m偏低)验证。管道setting字段<100=阀门节流(优先调整阀门), >100=疑似水泵增压
+1 -9
View File
@@ -1,11 +1,8 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({ export default tool({
description: description:
@@ -25,11 +22,6 @@ export default tool({
.describe("Optional maximum number of hits to return."), .describe("Optional maximum number of hits to return."),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, { const response = await fetch(`${internalBaseUrl}/internal/tools/session-search`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -39,7 +31,7 @@ export default tool({
body: JSON.stringify({ body: JSON.stringify({
max_results: args.max_results, max_results: args.max_results,
query: args.query, query: args.query,
sessionScopeKey: sessionContext.sessionScopeKey, session_id: context.sessionID,
}), }),
}); });
const text = await response.text(); const text = await response.text();
+1 -9
View File
@@ -1,10 +1,7 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({ export default tool({
description: description:
@@ -20,11 +17,6 @@ export default tool({
), ),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, { const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -32,7 +24,7 @@ export default tool({
"x-agent-internal-token": internalToken, "x-agent-internal-token": internalToken,
}, },
body: JSON.stringify({ body: JSON.stringify({
sessionScopeKey: sessionContext.sessionScopeKey, session_id: context.sessionID,
file_path: args.file_path, file_path: args.file_path,
}), }),
}); });
+2 -10
View File
@@ -1,10 +1,7 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
export default tool({ export default tool({
description: description:
@@ -21,14 +18,9 @@ export default tool({
timeout: tool.schema timeout: tool.schema
.number() .number()
.optional() .optional()
.describe("超时秒数,默认 60。大结果集建议设 120。"), .describe("超时秒数,默认 120。大结果集建议设 300+。"),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise;
const sessionContext = await toolContextStore.read(context.sessionID);
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const response = await fetch(`${internalBaseUrl}/internal/tools/tjwater-cli-call`, { const response = await fetch(`${internalBaseUrl}/internal/tools/tjwater-cli-call`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -36,7 +28,7 @@ export default tool({
"x-agent-internal-token": internalToken, "x-agent-internal-token": internalToken,
}, },
body: JSON.stringify({ body: JSON.stringify({
sessionScopeKey: sessionContext.sessionScopeKey, session_id: context.sessionID,
reason: args.reason, reason: args.reason,
command: args.command, command: args.command,
timeout: args.timeout, timeout: args.timeout,
+51 -66
View File
@@ -2,10 +2,7 @@ import { randomUUID } from "node:crypto";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { import { ToolSessionContextStore } from "../session/toolContextStore.js";
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../session/toolContextStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
export type SessionBinding = { export type SessionBinding = {
@@ -28,9 +25,6 @@ export type ChatRequestContext = SessionContext & {
}; };
export class ChatSessionBridge { export class ChatSessionBridge {
// runtime session 仅在单次请求生命周期内有效;线程连续性由 clientSessionId 对应的持久状态承担。
private readonly activeRuntimeSessions = new Map<string, string>();
private readonly activeSensitiveContexts = new Map<string, ChatRequestContext>();
private readonly abortControllers = new Map<string, AbortController>(); private readonly abortControllers = new Map<string, AbortController>();
private readonly toolContextStore = new ToolSessionContextStore(); private readonly toolContextStore = new ToolSessionContextStore();
@@ -38,6 +32,7 @@ export class ChatSessionBridge {
async resolve(context: { async resolve(context: {
clientSessionId?: string; clientSessionId?: string;
sessionId?: string;
accessToken?: string; accessToken?: string;
projectId?: string; projectId?: string;
traceId?: string; traceId?: string;
@@ -48,70 +43,63 @@ export class ChatSessionBridge {
created: boolean; created: boolean;
}> { }> {
const requestContext = this.buildRequestContext(context); const requestContext = this.buildRequestContext(context);
await this.abortActiveRuntime(requestContext.clientSessionId); const existingSessionId = context.sessionId?.trim();
await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
const session = await this.runtime.createSession(requestContext.clientSessionId); let sessionId = existingSessionId;
let created = false;
if (!sessionId) {
const session = await this.runtime.createSession(requestContext.clientSessionId);
sessionId = session.id;
created = true;
}
const binding: SessionBinding = { const binding: SessionBinding = {
clientSessionId: requestContext.clientSessionId, clientSessionId: requestContext.clientSessionId,
sessionId: session.id, sessionId,
startedAt: Date.now(), startedAt: Date.now(),
}; };
const sessionScopeKey = buildToolSessionScopeKey(
requestContext.actorKey,
requestContext.projectKey,
requestContext.clientSessionId,
);
this.activeRuntimeSessions.set(requestContext.clientSessionId, session.id);
this.activeSensitiveContexts.set(sessionScopeKey, requestContext);
await this.toolContextStore.write({ await this.toolContextStore.write({
accessToken: requestContext.accessToken,
actorKey: requestContext.actorKey, actorKey: requestContext.actorKey,
allowLearningWrite: true, allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId, clientSessionId: requestContext.clientSessionId,
learningMode: "interactive", learningMode: "interactive",
projectId: requestContext.projectId, projectId: requestContext.projectId,
projectKey: requestContext.projectKey, projectKey: requestContext.projectKey,
sessionId: session.id, sessionId,
sessionScopeKey,
traceId: requestContext.traceId, traceId: requestContext.traceId,
}); });
return { binding, requestContext, created: true }; return { binding, requestContext, created };
} }
count(): number { count(): number {
return this.activeRuntimeSessions.size; return this.abortControllers.size;
} }
createClientSessionId() { createClientSessionId() {
return `agent-${randomUUID().slice(0, 12)}`; return `agent-${randomUUID().slice(0, 12)}`;
} }
getActiveSensitiveContext(sessionScopeKey: string) {
return this.activeSensitiveContexts.get(sessionScopeKey) ?? null;
}
registerAbortController(clientSessionId: string, controller: AbortController) { registerAbortController(clientSessionId: string, controller: AbortController) {
this.abortControllers.set(clientSessionId, controller); this.abortControllers.set(clientSessionId, controller);
} }
deleteAbortController(clientSessionId: string) { finalizeRequest(clientSessionId: string) {
this.abortControllers.delete(clientSessionId); this.abortControllers.delete(clientSessionId);
} }
async abort(context: { async abort(context: {
clientSessionId?: string; clientSessionId?: string;
sessionId?: string;
}): Promise<SessionBinding | null> { }): Promise<SessionBinding | null> {
const clientSessionId = context.clientSessionId?.trim(); const clientSessionId = context.clientSessionId?.trim();
if (!clientSessionId) { const sessionId = context.sessionId?.trim();
if (!clientSessionId || !sessionId) {
return null; return null;
} }
const sessionId = this.activeRuntimeSessions.get(clientSessionId); await this.abortActiveRuntime(clientSessionId, sessionId);
if (!sessionId) {
return null;
}
await this.abortActiveRuntime(clientSessionId);
return { return {
clientSessionId, clientSessionId,
sessionId, sessionId,
@@ -119,18 +107,32 @@ export class ChatSessionBridge {
}; };
} }
async releaseRuntimeSession(clientSessionId: string, sessionId: string) { async deleteConversationSession(context: {
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId); clientSessionId: string;
if (activeSessionId === sessionId) { sessionId: string;
this.activeRuntimeSessions.delete(clientSessionId); }) {
const clientSessionId = context.clientSessionId.trim();
const sessionId = context.sessionId.trim();
const controller = this.abortControllers.get(clientSessionId);
if (controller) {
this.abortControllers.delete(clientSessionId);
controller.abort();
} }
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId)); await this.runtime.abortSession(sessionId).catch((error) => {
logger.warn(
{ clientSessionId, sessionId, err: error },
"failed to abort conversation runtime session",
);
});
await this.runtime.waitForSessionIdle(sessionId).catch((error) => {
logger.warn(
{ clientSessionId, sessionId, err: error },
"failed while waiting for conversation runtime session to become idle",
);
});
await this.toolContextStore.remove(sessionId).catch((error) => { await this.toolContextStore.remove(sessionId).catch((error) => {
logger.debug({ sessionId, err: error }, "failed to cleanup runtime tool context"); 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: { private buildRequestContext(context: {
@@ -151,44 +153,27 @@ export class ChatSessionBridge {
}; };
} }
private async abortActiveRuntime(clientSessionId: string) { private async abortActiveRuntime(clientSessionId: string, sessionId?: string) {
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
this.activeRuntimeSessions.delete(clientSessionId);
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
const controller = this.abortControllers.get(clientSessionId); const controller = this.abortControllers.get(clientSessionId);
if (controller) { if (controller) {
this.abortControllers.delete(clientSessionId); this.abortControllers.delete(clientSessionId);
controller.abort(); controller.abort();
} }
if (!activeSessionId) { if (!sessionId) {
return; return;
} }
await this.toolContextStore.remove(activeSessionId).catch(() => undefined); await this.runtime.abortSession(sessionId).catch((error) => {
await this.runtime.abortSession(activeSessionId).catch((error) => {
logger.warn( logger.warn(
{ clientSessionId, sessionId: activeSessionId, err: error }, { clientSessionId, sessionId, err: error },
"failed to abort previous active runtime session", "failed to abort active runtime session",
); );
}); });
await this.runtime.waitForSessionIdle(activeSessionId).catch((error) => { await this.runtime.waitForSessionIdle(sessionId).catch((error) => {
logger.warn( logger.warn(
{ clientSessionId, sessionId: activeSessionId, err: error }, { clientSessionId, sessionId, err: error },
"failed while waiting for previous runtime session to become idle", "failed while waiting for active runtime session to become idle",
); );
}); });
} }
} }
const findScopeKey = (
contexts: Map<string, ChatRequestContext>,
clientSessionId: string,
) => {
for (const [scopeKey, context] of contexts.entries()) {
if (context.clientSessionId === clientSessionId) {
return scopeKey;
}
}
return clientSessionId;
};
+22 -8
View File
@@ -6,6 +6,7 @@ import {
ensureDirectory, ensureDirectory,
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
toConversationScopeKey,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
export type ConversationStateRecord = { export type ConversationStateRecord = {
@@ -15,6 +16,12 @@ export type ConversationStateRecord = {
branchGroups: unknown[]; branchGroups: unknown[];
}; };
type ConversationStateContext = {
actorKey: string;
projectKey: string;
sessionId: string;
};
export class ConversationStateStore { export class ConversationStateStore {
constructor(private readonly baseDir = config.CONVERSATION_STATE_STORAGE_DIR) {} constructor(private readonly baseDir = config.CONVERSATION_STATE_STORAGE_DIR) {}
@@ -22,20 +29,27 @@ export class ConversationStateStore {
await ensureDirectory(this.baseDir); await ensureDirectory(this.baseDir);
} }
async read(sessionScopeKey: string) { async read(context: ConversationStateContext) {
return await readJsonFile<ConversationStateRecord>(this.filePath(sessionScopeKey)); return await readJsonFile<ConversationStateRecord>(this.filePath(context));
} }
async write(sessionScopeKey: string, state: ConversationStateRecord) { async write(context: ConversationStateContext, state: ConversationStateRecord) {
await atomicWriteJson(this.filePath(sessionScopeKey), state); await atomicWriteJson(this.filePath(context), state);
return state; return state;
} }
async remove(sessionScopeKey: string) { async remove(context: ConversationStateContext) {
await removeFileIfExists(this.filePath(sessionScopeKey)); await removeFileIfExists(this.filePath(context));
} }
private filePath(sessionScopeKey: string) { private filePath(context: ConversationStateContext) {
return join(this.baseDir, `${sessionScopeKey}.json`); return join(
this.baseDir,
`${toConversationScopeKey(
context.actorKey,
context.projectKey,
context.sessionId,
)}.json`,
);
} }
} }
+31 -18
View File
@@ -15,11 +15,11 @@ export type ConversationStatus = "active" | "archived";
export type ConversationRecord = { export type ConversationRecord = {
sessionId: string; sessionId: string;
sessionScopeKey: string;
actorKey: string; actorKey: string;
ownerUserId?: string; ownerUserId?: string;
projectId?: string; projectId?: string;
projectKey: string; projectKey: string;
opencodeSessionId?: string;
parentSessionId?: string; parentSessionId?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -48,12 +48,9 @@ export class ConversationStore {
async ensure(input: EnsureConversationInput) { async ensure(input: EnsureConversationInput) {
const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId(); const sessionId = normalizeSessionId(input.sessionId) ?? createConversationSessionId();
const sessionScopeKey = toConversationScopeKey( const existing = await readJsonFile<ConversationRecord>(
input.actorKey, this.filePath(input.actorKey, input.projectKey, sessionId),
input.projectKey,
sessionId,
); );
const existing = await readJsonFile<ConversationRecord>(this.filePath(sessionScopeKey));
if (existing) { if (existing) {
return { created: false, record: existing }; return { created: false, record: existing };
} }
@@ -61,7 +58,6 @@ export class ConversationStore {
const now = new Date().toISOString(); const now = new Date().toISOString();
const record: ConversationRecord = { const record: ConversationRecord = {
sessionId, sessionId,
sessionScopeKey,
actorKey: input.actorKey, actorKey: input.actorKey,
ownerUserId: input.userId?.trim(), ownerUserId: input.userId?.trim(),
projectId: input.projectId, projectId: input.projectId,
@@ -71,7 +67,10 @@ export class ConversationStore {
updatedAt: now, updatedAt: now,
status: "active", status: "active",
}; };
await atomicWriteJson(this.filePath(sessionScopeKey), record); await atomicWriteJson(
this.filePath(record.actorKey, record.projectKey, record.sessionId),
record,
);
return { created: true, record }; return { created: true, record };
} }
@@ -81,22 +80,23 @@ export class ConversationStore {
return null; return null;
} }
return await readJsonFile<ConversationRecord>( return await readJsonFile<ConversationRecord>(
this.filePath( this.filePath(context.actorKey, context.projectKey, normalizedSessionId),
toConversationScopeKey(context.actorKey, context.projectKey, normalizedSessionId),
),
); );
} }
async touch( async touch(
record: ConversationRecord, record: ConversationRecord,
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {}, updates: Partial<Pick<ConversationRecord, "title" | "status" | "opencodeSessionId">> = {},
) { ) {
const next: ConversationRecord = { const next: ConversationRecord = {
...record, ...record,
...normalizeConversationUpdates(updates), ...normalizeConversationUpdates(updates),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
await atomicWriteJson(this.filePath(record.sessionScopeKey), next); await atomicWriteJson(
this.filePath(record.actorKey, record.projectKey, record.sessionId),
next,
);
return next; return next;
} }
@@ -116,11 +116,16 @@ export class ConversationStore {
} }
async remove(record: ConversationRecord) { async remove(record: ConversationRecord) {
await removeFileIfExists(this.filePath(record.sessionScopeKey)); await removeFileIfExists(
this.filePath(record.actorKey, record.projectKey, record.sessionId),
);
} }
private filePath(sessionScopeKey: string) { private filePath(actorKey: string, projectKey: string, sessionId: string) {
return join(this.baseDir, `${sessionScopeKey}.json`); return join(
this.baseDir,
`${toConversationScopeKey(actorKey, projectKey, sessionId)}.json`,
);
} }
} }
@@ -132,9 +137,11 @@ const normalizeSessionId = (value?: string) => {
}; };
const normalizeConversationUpdates = ( const normalizeConversationUpdates = (
updates: Partial<Pick<ConversationRecord, "title" | "status">>, updates: Partial<Pick<ConversationRecord, "title" | "status" | "opencodeSessionId">>,
) => { ) => {
const normalized: Partial<Pick<ConversationRecord, "title" | "status">> = {}; const normalized: Partial<
Pick<ConversationRecord, "title" | "status" | "opencodeSessionId">
> = {};
if (updates.status === "active" || updates.status === "archived") { if (updates.status === "active" || updates.status === "archived") {
normalized.status = updates.status; normalized.status = updates.status;
} }
@@ -144,5 +151,11 @@ const normalizeConversationUpdates = (
normalized.title = trimmed.slice(0, 120); normalized.title = trimmed.slice(0, 120);
} }
} }
if (typeof updates.opencodeSessionId === "string") {
const trimmed = updates.opencodeSessionId.trim();
if (trimmed) {
normalized.opencodeSessionId = trimmed.slice(0, 256);
}
}
return normalized; return normalized;
}; };
+1 -14
View File
@@ -9,10 +9,7 @@ import { LearningStateStore } from "./stateStore.js";
import { MemoryStore, type MemoryScope } from "../memory/store.js"; import { MemoryStore, type MemoryScope } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SkillStore } from "../skills/store.js"; import { SkillStore } from "../skills/store.js";
import { import { ToolSessionContextStore } from "../session/toolContextStore.js";
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../session/toolContextStore.js";
import { import {
sanitizePersistentDocument, sanitizePersistentDocument,
sanitizePersistentLine, sanitizePersistentLine,
@@ -153,11 +150,6 @@ export class LearningOrchestrator {
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: gateSession.id, sessionId: gateSession.id,
sessionScopeKey: buildToolSessionScopeKey(
input.requestContext.actorKey,
input.requestContext.projectKey,
input.requestContext.clientSessionId,
),
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
await this.runtime.prompt( await this.runtime.prompt(
@@ -247,11 +239,6 @@ export class LearningOrchestrator {
projectId: input.requestContext.projectId, projectId: input.requestContext.projectId,
projectKey: input.requestContext.projectKey, projectKey: input.requestContext.projectKey,
sessionId: reviewSession.id, sessionId: reviewSession.id,
sessionScopeKey: buildToolSessionScopeKey(
input.requestContext.actorKey,
input.requestContext.projectKey,
input.requestContext.clientSessionId,
),
traceId: input.requestContext.traceId, traceId: input.requestContext.traceId,
}); });
try { try {
+61 -16
View File
@@ -11,6 +11,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js"; import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { type ConversationRecord } from "../conversations/store.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { import {
buildPromptWithLearningContext, buildPromptWithLearningContext,
@@ -51,6 +52,12 @@ const conversationStateSchema = z.object({
branch_groups: z.array(z.unknown()).default([]), branch_groups: z.array(z.unknown()).default([]),
}); });
const toConversationStateContext = (conversation: ConversationRecord) => ({
actorKey: conversation.actorKey,
projectKey: conversation.projectKey,
sessionId: conversation.sessionId,
});
export const buildChatRouter = ( export const buildChatRouter = (
sessionBridge: ChatSessionBridge, sessionBridge: ChatSessionBridge,
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
@@ -145,7 +152,9 @@ export const buildChatRouter = (
return; return;
} }
const state = await conversationStateStore.read(conversation.sessionScopeKey); const state = await conversationStateStore.read(
toConversationStateContext(conversation),
);
res.json({ res.json({
id: conversation.sessionId, id: conversation.sessionId,
title: conversation.title ?? "新对话", title: conversation.title ?? "新对话",
@@ -190,7 +199,7 @@ export const buildChatRouter = (
const nextRecord = await conversationStore.touch(record, { const nextRecord = await conversationStore.touch(record, {
...(parsed.data.title ? { title: parsed.data.title } : {}), ...(parsed.data.title ? { title: parsed.data.title } : {}),
}); });
await conversationStateStore.write(nextRecord.sessionScopeKey, { await conversationStateStore.write(toConversationStateContext(nextRecord), {
sessionId: nextRecord.sessionId, sessionId: nextRecord.sessionId,
isTitleManuallyEdited: parsed.data.is_title_manually_edited, isTitleManuallyEdited: parsed.data.is_title_manually_edited,
messages: parsed.data.messages, messages: parsed.data.messages,
@@ -231,13 +240,18 @@ export const buildChatRouter = (
return; return;
} }
const nextConversation = await conversationStore.touch(conversation, { title }); const nextConversation = await conversationStore.touch(conversation, { title });
const state = await conversationStateStore.read(nextConversation.sessionScopeKey); const state = await conversationStateStore.read(
toConversationStateContext(nextConversation),
);
if (state) { if (state) {
await conversationStateStore.write(nextConversation.sessionScopeKey, { await conversationStateStore.write(
toConversationStateContext(nextConversation),
{
...state, ...state,
isTitleManuallyEdited: isTitleManuallyEdited:
isTitleManuallyEdited ?? state.isTitleManuallyEdited, isTitleManuallyEdited ?? state.isTitleManuallyEdited,
}); },
);
} }
res.json({ res.json({
id: nextConversation.sessionId, id: nextConversation.sessionId,
@@ -264,7 +278,13 @@ export const buildChatRouter = (
res.status(204).end(); res.status(204).end();
return; return;
} }
await conversationStateStore.remove(conversation.sessionScopeKey); await conversationStateStore.remove(toConversationStateContext(conversation));
if (conversation.opencodeSessionId) {
await sessionBridge.deleteConversationSession({
clientSessionId: conversation.sessionId,
sessionId: conversation.opencodeSessionId,
});
}
await conversationStore.remove(conversation); await conversationStore.remove(conversation);
res.status(204).end(); res.status(204).end();
}); });
@@ -323,9 +343,20 @@ export const buildChatRouter = (
} }
try { try {
const binding = await sessionBridge.abort({ const projectId = req.header("x-project-id") ?? undefined;
clientSessionId: parsed.data.session_id, const userId = req.header("x-user-id") ?? undefined;
}); const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const conversation = await conversationStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
const binding = conversation?.opencodeSessionId
? await sessionBridge.abort({
clientSessionId: conversation.sessionId,
sessionId: conversation.opencodeSessionId,
})
: null;
if (!binding) { if (!binding) {
res.status(204).end(); res.status(204).end();
@@ -467,14 +498,22 @@ export const buildChatRouter = (
userId, userId,
}); });
const activeConversation = await conversationStore.touch(conversation); const activeConversation = await conversationStore.touch(conversation);
const hadExistingRuntimeSession = Boolean(activeConversation.opencodeSessionId);
const { binding, requestContext, created } = await sessionBridge.resolve({ const { binding, requestContext, created } = await sessionBridge.resolve({
clientSessionId: activeConversation.sessionId, clientSessionId: activeConversation.sessionId,
sessionId: activeConversation.opencodeSessionId,
accessToken, accessToken,
projectId, projectId,
traceId, traceId,
userId, userId,
}); });
const conversationWithRuntime =
created && binding.sessionId !== activeConversation.opencodeSessionId
? await conversationStore.touch(activeConversation, {
opencodeSessionId: binding.sessionId,
})
: activeConversation;
const historyContext = { const historyContext = {
actorKey: requestContext.actorKey, actorKey: requestContext.actorKey,
clientSessionId: requestContext.clientSessionId, clientSessionId: requestContext.clientSessionId,
@@ -482,6 +521,9 @@ export const buildChatRouter = (
sessionId: requestContext.clientSessionId, sessionId: requestContext.clientSessionId,
}; };
const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8); const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8);
const initialConversationState = await conversationStateStore.read(
toConversationStateContext(conversationWithRuntime),
);
logger.info( logger.info(
{ {
@@ -521,8 +563,12 @@ export const buildChatRouter = (
memoryStore, memoryStore,
requestContext.actorKey, requestContext.actorKey,
requestContext.projectKey, requestContext.projectKey,
recentTurns, {
parsed.data.message, recentTurns,
persistedMessages: initialConversationState?.messages,
message: parsed.data.message,
restoreConversation: !hadExistingRuntimeSession,
},
); );
const streamResult = await streamPromptResponse({ const streamResult = await streamPromptResponse({
runtime, runtime,
@@ -550,10 +596,10 @@ export const buildChatRouter = (
const latestConversation = const latestConversation =
(await conversationStore.get( (await conversationStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
activeConversation.sessionId, conversationWithRuntime.sessionId,
)) ?? activeConversation; )) ?? conversationWithRuntime;
const latestConversationState = await conversationStateStore.read( const latestConversationState = await conversationStateStore.read(
latestConversation.sessionScopeKey, toConversationStateContext(latestConversation),
); );
const existingSessionTitle = latestConversation.title; const existingSessionTitle = latestConversation.title;
let sessionTitle = existingSessionTitle; let sessionTitle = existingSessionTitle;
@@ -606,8 +652,7 @@ export const buildChatRouter = (
} }
} }
} finally { } finally {
await sessionBridge.releaseRuntimeSession(clientSessionId, binding.sessionId); sessionBridge.finalizeRequest(clientSessionId);
sessionBridge.deleteAbortController(clientSessionId);
streamClosed = true; streamClosed = true;
req.off("close", handleClientClose); req.off("close", handleClientClose);
res.off("close", handleClientClose); res.off("close", handleClientClose);
+65 -5
View File
@@ -192,15 +192,22 @@ export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore, memoryStore: MemoryStore,
actorKey: string, actorKey: string,
projectKey: string, projectKey: string,
recentTurns: SessionTurnRecord[], options: {
message: string, recentTurns: SessionTurnRecord[];
persistedMessages?: unknown[];
message: string;
restoreConversation?: boolean;
},
) => { ) => {
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey }); const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
const restoredConversation = buildRestoredConversationContext(recentTurns); const restoredConversation = options.restoreConversation === false
? ""
: buildRestoredConversationFromMessages(options.persistedMessages) ||
buildRestoredConversationContext(options.recentTurns);
if (!snapshot && !restoredConversation) { if (!snapshot && !restoredConversation) {
return message; return options.message;
} }
return [snapshot, restoredConversation, `[Current user request]\n${message}`] return [snapshot, restoredConversation, `[Current user request]\n${options.message}`]
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
}; };
@@ -239,4 +246,57 @@ const compactMessage = (value: string) => {
return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...` ? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
: normalized; : normalized;
};
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isSyntheticAssistantError = (content: string) =>
/^⚠️\s*\*\*(请求已中断|错误[:]?)/.test(content);
const buildRestoredConversationFromMessages = (messages: unknown[] | undefined) => {
if (!Array.isArray(messages) || messages.length === 0) {
return "";
}
const formattedMessages = messages
.slice(-(RESTORE_TURN_LIMIT * 2 + 2))
.flatMap((message) => {
if (!isObjectRecord(message)) {
return [];
}
const role = message.role;
const content = message.content;
if ((role !== "user" && role !== "assistant") || typeof content !== "string") {
return [];
}
const normalizedContent = compactMessage(content);
if (!normalizedContent) {
return [];
}
if (role === "assistant" && isSyntheticAssistantError(normalizedContent)) {
return [];
}
return [`${role === "user" ? "用户" : "助手"}${normalizedContent}`];
});
if (formattedMessages.length === 0) {
return "";
}
const conversation = formattedMessages.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");
}; };
+25 -43
View File
@@ -66,22 +66,13 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
return; return;
} }
const sessionScopeKey = const sessionId =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const threadContext = await toolContextStore.read(sessionScopeKey); const context = sessionId ? await toolContextStore.read(sessionId) : null;
const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey);
if (!threadContext && !runtimeContext) {
res.status(404).json({
message: "runtime or session context not found",
detail: sessionScopeKey,
});
return;
}
const context = runtimeContext ?? threadContext;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "runtime or session context not found", message: "session context not found",
detail: sessionScopeKey, detail: sessionId,
}); });
return; return;
} }
@@ -96,7 +87,7 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
arguments: req.body?.arguments, arguments: req.body?.arguments,
}, },
{ {
accessToken: runtimeContext?.accessToken, accessToken: context.accessToken,
actorKey: context.actorKey, actorKey: context.actorKey,
clientSessionId: context.clientSessionId, clientSessionId: context.clientSessionId,
projectId: context.projectId, projectId: context.projectId,
@@ -121,22 +112,13 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
return; return;
} }
const sessionScopeKey = const sessionId =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const threadContext = await toolContextStore.read(sessionScopeKey); const context = sessionId ? await toolContextStore.read(sessionId) : null;
const runtimeContext = sessionBridge.getActiveSensitiveContext(sessionScopeKey);
if (!threadContext && !runtimeContext) {
res.status(404).json({
message: "runtime or session context not found",
detail: sessionScopeKey,
});
return;
}
const context = runtimeContext ?? threadContext;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "runtime or session context not found", message: "session context not found",
detail: sessionScopeKey, detail: sessionId,
}); });
return; return;
} }
@@ -148,11 +130,11 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
} }
const timeoutSec = const timeoutSec =
typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 60; typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120;
const authJson = JSON.stringify({ const authJson = JSON.stringify({
server: config.TJWATER_API_BASE_URL, server: config.TJWATER_API_BASE_URL,
access_token: runtimeContext?.accessToken, access_token: context.accessToken,
project_id: context.projectId, project_id: context.projectId,
network:"tjwater", network:"tjwater",
}); });
@@ -233,14 +215,14 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
return; return;
} }
const sessionScopeKey = const sessionId =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : ""; const resultRef = typeof req.body?.result_ref === "string" ? req.body.result_ref : "";
const context = await toolContextStore.read(sessionScopeKey); const context = sessionId ? await toolContextStore.read(sessionId) : null;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "session context not found",
detail: sessionScopeKey, detail: sessionId,
}); });
return; return;
} }
@@ -276,14 +258,14 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
return; return;
} }
const sessionScopeKey = const sessionId =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : ""; const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : "";
const context = await toolContextStore.read(sessionScopeKey); const context = sessionId ? await toolContextStore.read(sessionId) : null;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "session context not found",
detail: sessionScopeKey, detail: sessionId,
}); });
return; return;
} }
@@ -326,14 +308,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
return; return;
} }
const sessionScopeKey = const sessionId =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : ""; typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const query = typeof req.body?.query === "string" ? req.body.query : ""; const query = typeof req.body?.query === "string" ? req.body.query : "";
const context = await toolContextStore.read(sessionScopeKey); const context = sessionId ? await toolContextStore.read(sessionId) : null;
if (!context) { if (!context) {
res.status(404).json({ res.status(404).json({
message: "session context not found", message: "session context not found",
detail: sessionScopeKey, detail: sessionId,
}); });
return; return;
} }
+1 -11
View File
@@ -7,9 +7,9 @@ import {
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
import { toConversationScopeKey } from "../utils/fileStore.js";
export type ToolSessionContext = { export type ToolSessionContext = {
accessToken?: string;
actorKey: string; actorKey: string;
allowLearningWrite?: boolean; allowLearningWrite?: boolean;
clientSessionId: string; clientSessionId: string;
@@ -17,7 +17,6 @@ export type ToolSessionContext = {
projectId?: string; projectId?: string;
projectKey: string; projectKey: string;
sessionId: string; sessionId: string;
sessionScopeKey: string;
traceId: string; traceId: string;
}; };
@@ -30,9 +29,6 @@ export class ToolSessionContextStore {
async write(context: ToolSessionContext) { async write(context: ToolSessionContext) {
await atomicWriteJson(this.filePath(context.sessionId), context); await atomicWriteJson(this.filePath(context.sessionId), context);
if (context.learningMode === "interactive" && context.sessionScopeKey) {
await atomicWriteJson(this.filePath(context.sessionScopeKey), context);
}
} }
async read(sessionId: string) { async read(sessionId: string) {
@@ -47,9 +43,3 @@ export class ToolSessionContextStore {
return join(this.baseDir, `${sessionId}.json`); return join(this.baseDir, `${sessionId}.json`);
} }
} }
export const buildToolSessionScopeKey = (
actorKey: string,
projectKey: string,
clientSessionId: string,
) => toConversationScopeKey(actorKey, projectKey, clientSessionId);
+3 -1
View File
@@ -44,9 +44,11 @@ describe("ConversationStore", () => {
const touched = await store.touch(record, { const touched = await store.touch(record, {
title: "新标题", title: "新标题",
opencodeSessionId: "opencode-session-1",
}); });
expect(touched.title).toBe("新标题"); expect(touched.title).toBe("新标题");
expect(touched.opencodeSessionId).toBe("opencode-session-1");
expect(touched.updatedAt >= record.updatedAt).toBe(true); expect(touched.updatedAt >= record.updatedAt).toBe(true);
const fetched = await store.get( const fetched = await store.get(
@@ -58,7 +60,7 @@ describe("ConversationStore", () => {
}, },
"existing-session", "existing-session",
); );
expect(fetched?.sessionScopeKey).toBe(record.sessionScopeKey);
expect(fetched?.title).toBe("新标题"); expect(fetched?.title).toBe("新标题");
expect(fetched?.opencodeSessionId).toBe("opencode-session-1");
}); });
}); });
+90
View File
@@ -1,9 +1,12 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { import {
buildPromptWithLearningContext,
generateSessionTitle, generateSessionTitle,
shouldGenerateSessionTitle, shouldGenerateSessionTitle,
} from "../../src/routes/chatSession.js"; } from "../../src/routes/chatSession.js";
import { type SessionTurnRecord } from "../../src/history/store.js";
import { type MemoryStore } from "../../src/memory/store.js";
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
describe("shouldGenerateSessionTitle", () => { describe("shouldGenerateSessionTitle", () => {
@@ -71,3 +74,90 @@ describe("generateSessionTitle", () => {
expect(titlePrompt).toContain("助手:三号泵站压力波动主要与夜间阀门开度变化有关。"); expect(titlePrompt).toContain("助手:三号泵站压力波动主要与夜间阀门开度变化有关。");
}); });
}); });
describe("buildPromptWithLearningContext", () => {
const memoryStore = {
buildPromptSnapshot: async () => "",
} as unknown as MemoryStore;
it("prefers persisted frontend messages so aborted turns remain in restored context", async () => {
const prompt = await buildPromptWithLearningContext(
memoryStore,
"actor-1",
"project-1",
{
recentTurns: [],
persistedMessages: [
{ role: "user", content: "先分析 3 号泵站夜间压力波动" },
{
role: "assistant",
content: "已定位到夜间阀门开度变化与压力波动时间段重合,下一步准备对比相邻支路。",
isError: true,
},
{ role: "assistant", content: "⚠️ **请求已中断**", isError: true },
],
message: "继续刚才的分析,并补充相邻支路影响",
},
);
expect(prompt).toContain("用户:先分析 3 号泵站夜间压力波动");
expect(prompt).toContain(
"助手:已定位到夜间阀门开度变化与压力波动时间段重合,下一步准备对比相邻支路。",
);
expect(prompt).not.toContain("⚠️ **请求已中断**");
expect(prompt).toContain("[Current user request]\n继续刚才的分析,并补充相邻支路影响");
});
it("falls back to history turns when frontend state is unavailable", async () => {
const recentTurns: SessionTurnRecord[] = [
{
id: "turn-1",
userMessage: "检查 DMA-2 夜间漏损异常",
assistantMessage: "DMA-2 在 02:00-04:00 出现持续最小夜流抬升。",
timestamp: new Date().toISOString(),
toolCallCount: 1,
},
];
const prompt = await buildPromptWithLearningContext(
memoryStore,
"actor-1",
"project-1",
{
recentTurns,
message: "继续给出排查建议",
},
);
expect(prompt).toContain("用户:检查 DMA-2 夜间漏损异常");
expect(prompt).toContain("助手:DMA-2 在 02:00-04:00 出现持续最小夜流抬升。");
});
it("skips restored conversation injection when reusing an existing opencode session", async () => {
const prompt = await buildPromptWithLearningContext(
memoryStore,
"actor-1",
"project-1",
{
recentTurns: [
{
id: "turn-1",
userMessage: "上一轮问题",
assistantMessage: "上一轮回答",
timestamp: new Date().toISOString(),
toolCallCount: 0,
},
],
persistedMessages: [
{ role: "user", content: "旧问题" },
{ role: "assistant", content: "旧回答" },
],
message: "基于刚才结果继续分析",
restoreConversation: false,
},
);
expect(prompt).not.toContain("[Previous conversation context]");
expect(prompt).toBe("基于刚才结果继续分析");
});
});
+5 -15
View File
@@ -3,10 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../../src/session/toolContextStore.js";
describe("ToolSessionContextStore", () => { describe("ToolSessionContextStore", () => {
let tempDir: string; let tempDir: string;
@@ -22,14 +19,9 @@ describe("ToolSessionContextStore", () => {
await rm(tempDir, { force: true, recursive: true }); await rm(tempDir, { force: true, recursive: true });
}); });
it("writes interactive aliases under scoped session keys", async () => { it("writes and reads runtime session context by opencode session id", async () => {
const sessionScopeKey = buildToolSessionScopeKey(
"actor-1",
"project-1",
"chat-session-1",
);
await store.write({ await store.write({
accessToken: "token-1",
actorKey: "actor-1", actorKey: "actor-1",
allowLearningWrite: true, allowLearningWrite: true,
clientSessionId: "chat-session-1", clientSessionId: "chat-session-1",
@@ -37,15 +29,13 @@ describe("ToolSessionContextStore", () => {
projectId: "project-id-1", projectId: "project-id-1",
projectKey: "project-1", projectKey: "project-1",
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
sessionScopeKey,
traceId: "trace-1", traceId: "trace-1",
}); });
const runtimeContext = await store.read("runtime-session-1"); const runtimeContext = await store.read("runtime-session-1");
const scopedContext = await store.read(sessionScopeKey);
expect(runtimeContext?.accessToken).toBe("token-1");
expect(runtimeContext?.clientSessionId).toBe("chat-session-1"); expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
expect(scopedContext?.sessionScopeKey).toBe(sessionScopeKey); expect(runtimeContext?.sessionId).toBe("runtime-session-1");
expect(scopedContext?.sessionId).toBe("runtime-session-1");
}); });
}); });