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

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
temperature: 0.2
---
是 TJWater 供水管网分析 Agent,运用水力专业知识,使用简体中文,回复简洁准确。
是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
## 工作流生命周期
+2 -2
View File
@@ -34,12 +34,12 @@ SSE 事件:
{
"reason": "查询当前项目数据库健康状态",
"command": "project db-health",
"timeout": 60
"timeout": 120
}
```
- `command`tjwater-cli 子命令(不含二进制路径和 `--auth-context`
- `timeout`:可选超时秒数,默认 60,大结果集建议 300+
- `timeout`:可选超时秒数,默认 120,大结果集建议 300+
- 认证上下文(token、server、project)由内部桥接自动注入
## 4) 工具参数约定(前端工具)
+15 -7
View File
@@ -17,7 +17,7 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输
{
"reason": "说明调用原因",
"command": "project list",
"timeout": 60
"timeout": 120
}
```
@@ -25,7 +25,7 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输
|------|------|------|------|
| `reason` | string | 是 | 调用原因 |
| `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context` |
| `timeout` | number | 否 | 超时秒数,默认 60,大结果集建议 300+ |
| `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ |
认证上下文(token、server、project、network)由内部桥接自动注入,无需手动传参。
@@ -117,6 +117,7 @@ tjwater-cli help COMMAND → 子命令与参数详情
4. **管道串联** — workflow 脚本中用 shell pipe 串联多个 CLI 命令,减少 `subprocess.run` 次数
5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试
6. **大结果集** — 优先过滤/采样,不要一次性拉取全部数据
7. **模拟时长控制** — 模拟(`simulation`)或方案模拟的 `--duration` 不宜过长,建议每次仿真时间跨度控制在一小时以内,避免计算耗时过长或结果数据量过大
## 示例
@@ -147,18 +148,25 @@ tjwater-cli help COMMAND → 子命令与参数详情
### 触发仿真并获取结果
`simulation run` 仅接受 `--start-time`RFC3339,必填)和 `--duration`(整数分钟,必填)。结果需从 `data timeseries` 获取
通常系统会自动跑仿真,建议**先尝试获取结果**,若无数据再触发仿真
```json
// step 1: 触发仿真 (duration 为分钟数)
// step 1: 先尝试获取仿真结果
{
"reason": "触发24小时水力仿真",
"command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 1440"
"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"
}
// 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 时刻的压力",
"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 { 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:
@@ -25,11 +22,6 @@ 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: {
@@ -39,7 +31,7 @@ export default tool({
body: JSON.stringify({
max_results: args.max_results,
query: args.query,
sessionScopeKey: sessionContext.sessionScopeKey,
session_id: context.sessionID,
}),
});
const text = await response.text();
+1 -9
View File
@@ -1,10 +1,7 @@
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:
@@ -20,11 +17,6 @@ export default tool({
),
},
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: {
@@ -32,7 +24,7 @@ export default tool({
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
sessionScopeKey: sessionContext.sessionScopeKey,
session_id: context.sessionID,
file_path: args.file_path,
}),
});
+2 -10
View File
@@ -1,10 +1,7 @@
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,14 +18,9 @@ export default tool({
timeout: tool.schema
.number()
.optional()
.describe("超时秒数,默认 60。大结果集建议设 120。"),
.describe("超时秒数,默认 120。大结果集建议设 300+。"),
},
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`, {
method: "POST",
headers: {
@@ -36,7 +28,7 @@ export default tool({
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
sessionScopeKey: sessionContext.sessionScopeKey,
session_id: context.sessionID,
reason: args.reason,
command: args.command,
timeout: args.timeout,
+51 -66
View File
@@ -2,10 +2,7 @@ import { randomUUID } from "node:crypto";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import {
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../session/toolContextStore.js";
import { ToolSessionContextStore } from "../session/toolContextStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
export type SessionBinding = {
@@ -28,9 +25,6 @@ export type ChatRequestContext = SessionContext & {
};
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 toolContextStore = new ToolSessionContextStore();
@@ -38,6 +32,7 @@ export class ChatSessionBridge {
async resolve(context: {
clientSessionId?: string;
sessionId?: string;
accessToken?: string;
projectId?: string;
traceId?: string;
@@ -48,70 +43,63 @@ export class ChatSessionBridge {
created: boolean;
}> {
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 = {
clientSessionId: requestContext.clientSessionId,
sessionId: session.id,
sessionId,
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({
accessToken: requestContext.accessToken,
actorKey: requestContext.actorKey,
allowLearningWrite: true,
clientSessionId: requestContext.clientSessionId,
learningMode: "interactive",
projectId: requestContext.projectId,
projectKey: requestContext.projectKey,
sessionId: session.id,
sessionScopeKey,
sessionId,
traceId: requestContext.traceId,
});
return { binding, requestContext, created: true };
return { binding, requestContext, created };
}
count(): number {
return this.activeRuntimeSessions.size;
return this.abortControllers.size;
}
createClientSessionId() {
return `agent-${randomUUID().slice(0, 12)}`;
}
getActiveSensitiveContext(sessionScopeKey: string) {
return this.activeSensitiveContexts.get(sessionScopeKey) ?? null;
}
registerAbortController(clientSessionId: string, controller: AbortController) {
this.abortControllers.set(clientSessionId, controller);
}
deleteAbortController(clientSessionId: string) {
finalizeRequest(clientSessionId: string) {
this.abortControllers.delete(clientSessionId);
}
async abort(context: {
clientSessionId?: string;
sessionId?: string;
}): Promise<SessionBinding | null> {
const clientSessionId = context.clientSessionId?.trim();
if (!clientSessionId) {
const sessionId = context.sessionId?.trim();
if (!clientSessionId || !sessionId) {
return null;
}
const sessionId = this.activeRuntimeSessions.get(clientSessionId);
if (!sessionId) {
return null;
}
await this.abortActiveRuntime(clientSessionId);
await this.abortActiveRuntime(clientSessionId, sessionId);
return {
clientSessionId,
sessionId,
@@ -119,18 +107,32 @@ export class ChatSessionBridge {
};
}
async releaseRuntimeSession(clientSessionId: string, sessionId: string) {
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
if (activeSessionId === sessionId) {
this.activeRuntimeSessions.delete(clientSessionId);
async deleteConversationSession(context: {
clientSessionId: string;
sessionId: string;
}) {
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) => {
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: {
@@ -151,44 +153,27 @@ export class ChatSessionBridge {
};
}
private async abortActiveRuntime(clientSessionId: string) {
const activeSessionId = this.activeRuntimeSessions.get(clientSessionId);
this.activeRuntimeSessions.delete(clientSessionId);
this.activeSensitiveContexts.delete(findScopeKey(this.activeSensitiveContexts, clientSessionId));
private async abortActiveRuntime(clientSessionId: string, sessionId?: string) {
const controller = this.abortControllers.get(clientSessionId);
if (controller) {
this.abortControllers.delete(clientSessionId);
controller.abort();
}
if (!activeSessionId) {
if (!sessionId) {
return;
}
await this.toolContextStore.remove(activeSessionId).catch(() => undefined);
await this.runtime.abortSession(activeSessionId).catch((error) => {
await this.runtime.abortSession(sessionId).catch((error) => {
logger.warn(
{ clientSessionId, sessionId: activeSessionId, err: error },
"failed to abort previous active runtime session",
{ clientSessionId, sessionId, err: error },
"failed to abort active runtime session",
);
});
await this.runtime.waitForSessionIdle(activeSessionId).catch((error) => {
await this.runtime.waitForSessionIdle(sessionId).catch((error) => {
logger.warn(
{ clientSessionId, sessionId: activeSessionId, err: error },
"failed while waiting for previous runtime session to become idle",
{ clientSessionId, sessionId, err: error },
"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,
readJsonFile,
removeFileIfExists,
toConversationScopeKey,
} from "../utils/fileStore.js";
export type ConversationStateRecord = {
@@ -15,6 +16,12 @@ export type ConversationStateRecord = {
branchGroups: unknown[];
};
type ConversationStateContext = {
actorKey: string;
projectKey: string;
sessionId: string;
};
export class ConversationStateStore {
constructor(private readonly baseDir = config.CONVERSATION_STATE_STORAGE_DIR) {}
@@ -22,20 +29,27 @@ export class ConversationStateStore {
await ensureDirectory(this.baseDir);
}
async read(sessionScopeKey: string) {
return await readJsonFile<ConversationStateRecord>(this.filePath(sessionScopeKey));
async read(context: ConversationStateContext) {
return await readJsonFile<ConversationStateRecord>(this.filePath(context));
}
async write(sessionScopeKey: string, state: ConversationStateRecord) {
await atomicWriteJson(this.filePath(sessionScopeKey), state);
async write(context: ConversationStateContext, state: ConversationStateRecord) {
await atomicWriteJson(this.filePath(context), state);
return state;
}
async remove(sessionScopeKey: string) {
await removeFileIfExists(this.filePath(sessionScopeKey));
async remove(context: ConversationStateContext) {
await removeFileIfExists(this.filePath(context));
}
private filePath(sessionScopeKey: string) {
return join(this.baseDir, `${sessionScopeKey}.json`);
private filePath(context: ConversationStateContext) {
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 = {
sessionId: string;
sessionScopeKey: string;
actorKey: string;
ownerUserId?: string;
projectId?: string;
projectKey: string;
opencodeSessionId?: string;
parentSessionId?: string;
createdAt: string;
updatedAt: string;
@@ -48,12 +48,9 @@ export class ConversationStore {
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(input.actorKey, input.projectKey, sessionId),
);
const existing = await readJsonFile<ConversationRecord>(this.filePath(sessionScopeKey));
if (existing) {
return { created: false, record: existing };
}
@@ -61,7 +58,6 @@ export class ConversationStore {
const now = new Date().toISOString();
const record: ConversationRecord = {
sessionId,
sessionScopeKey,
actorKey: input.actorKey,
ownerUserId: input.userId?.trim(),
projectId: input.projectId,
@@ -71,7 +67,10 @@ export class ConversationStore {
updatedAt: now,
status: "active",
};
await atomicWriteJson(this.filePath(sessionScopeKey), record);
await atomicWriteJson(
this.filePath(record.actorKey, record.projectKey, record.sessionId),
record,
);
return { created: true, record };
}
@@ -81,22 +80,23 @@ export class ConversationStore {
return null;
}
return await readJsonFile<ConversationRecord>(
this.filePath(
toConversationScopeKey(context.actorKey, context.projectKey, normalizedSessionId),
),
this.filePath(context.actorKey, context.projectKey, normalizedSessionId),
);
}
async touch(
record: ConversationRecord,
updates: Partial<Pick<ConversationRecord, "title" | "status">> = {},
updates: Partial<Pick<ConversationRecord, "title" | "status" | "opencodeSessionId">> = {},
) {
const next: ConversationRecord = {
...record,
...normalizeConversationUpdates(updates),
updatedAt: new Date().toISOString(),
};
await atomicWriteJson(this.filePath(record.sessionScopeKey), next);
await atomicWriteJson(
this.filePath(record.actorKey, record.projectKey, record.sessionId),
next,
);
return next;
}
@@ -116,11 +116,16 @@ export class ConversationStore {
}
async remove(record: ConversationRecord) {
await removeFileIfExists(this.filePath(record.sessionScopeKey));
await removeFileIfExists(
this.filePath(record.actorKey, record.projectKey, record.sessionId),
);
}
private filePath(sessionScopeKey: string) {
return join(this.baseDir, `${sessionScopeKey}.json`);
private filePath(actorKey: string, projectKey: string, sessionId: string) {
return join(
this.baseDir,
`${toConversationScopeKey(actorKey, projectKey, sessionId)}.json`,
);
}
}
@@ -132,9 +137,11 @@ const normalizeSessionId = (value?: string) => {
};
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") {
normalized.status = updates.status;
}
@@ -144,5 +151,11 @@ const normalizeConversationUpdates = (
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;
};
+1 -14
View File
@@ -9,10 +9,7 @@ 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 {
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../session/toolContextStore.js";
import { ToolSessionContextStore } from "../session/toolContextStore.js";
import {
sanitizePersistentDocument,
sanitizePersistentLine,
@@ -153,11 +150,6 @@ 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(
@@ -247,11 +239,6 @@ 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 {
+61 -16
View File
@@ -11,6 +11,7 @@ import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { type ConversationRecord } from "../conversations/store.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
buildPromptWithLearningContext,
@@ -51,6 +52,12 @@ const conversationStateSchema = z.object({
branch_groups: z.array(z.unknown()).default([]),
});
const toConversationStateContext = (conversation: ConversationRecord) => ({
actorKey: conversation.actorKey,
projectKey: conversation.projectKey,
sessionId: conversation.sessionId,
});
export const buildChatRouter = (
sessionBridge: ChatSessionBridge,
runtime: OpencodeRuntimeAdapter,
@@ -145,7 +152,9 @@ export const buildChatRouter = (
return;
}
const state = await conversationStateStore.read(conversation.sessionScopeKey);
const state = await conversationStateStore.read(
toConversationStateContext(conversation),
);
res.json({
id: conversation.sessionId,
title: conversation.title ?? "新对话",
@@ -190,7 +199,7 @@ export const buildChatRouter = (
const nextRecord = await conversationStore.touch(record, {
...(parsed.data.title ? { title: parsed.data.title } : {}),
});
await conversationStateStore.write(nextRecord.sessionScopeKey, {
await conversationStateStore.write(toConversationStateContext(nextRecord), {
sessionId: nextRecord.sessionId,
isTitleManuallyEdited: parsed.data.is_title_manually_edited,
messages: parsed.data.messages,
@@ -231,13 +240,18 @@ export const buildChatRouter = (
return;
}
const nextConversation = await conversationStore.touch(conversation, { title });
const state = await conversationStateStore.read(nextConversation.sessionScopeKey);
const state = await conversationStateStore.read(
toConversationStateContext(nextConversation),
);
if (state) {
await conversationStateStore.write(nextConversation.sessionScopeKey, {
await conversationStateStore.write(
toConversationStateContext(nextConversation),
{
...state,
isTitleManuallyEdited:
isTitleManuallyEdited ?? state.isTitleManuallyEdited,
});
},
);
}
res.json({
id: nextConversation.sessionId,
@@ -264,7 +278,13 @@ export const buildChatRouter = (
res.status(204).end();
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);
res.status(204).end();
});
@@ -323,9 +343,20 @@ export const buildChatRouter = (
}
try {
const binding = await sessionBridge.abort({
clientSessionId: parsed.data.session_id,
});
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 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) {
res.status(204).end();
@@ -467,14 +498,22 @@ export const buildChatRouter = (
userId,
});
const activeConversation = await conversationStore.touch(conversation);
const hadExistingRuntimeSession = Boolean(activeConversation.opencodeSessionId);
const { binding, requestContext, created } = await sessionBridge.resolve({
clientSessionId: activeConversation.sessionId,
sessionId: activeConversation.opencodeSessionId,
accessToken,
projectId,
traceId,
userId,
});
const conversationWithRuntime =
created && binding.sessionId !== activeConversation.opencodeSessionId
? await conversationStore.touch(activeConversation, {
opencodeSessionId: binding.sessionId,
})
: activeConversation;
const historyContext = {
actorKey: requestContext.actorKey,
clientSessionId: requestContext.clientSessionId,
@@ -482,6 +521,9 @@ export const buildChatRouter = (
sessionId: requestContext.clientSessionId,
};
const recentTurns = await sessionHistoryStore.getRecentTurns(historyContext, 8);
const initialConversationState = await conversationStateStore.read(
toConversationStateContext(conversationWithRuntime),
);
logger.info(
{
@@ -521,8 +563,12 @@ export const buildChatRouter = (
memoryStore,
requestContext.actorKey,
requestContext.projectKey,
recentTurns,
parsed.data.message,
{
recentTurns,
persistedMessages: initialConversationState?.messages,
message: parsed.data.message,
restoreConversation: !hadExistingRuntimeSession,
},
);
const streamResult = await streamPromptResponse({
runtime,
@@ -550,10 +596,10 @@ export const buildChatRouter = (
const latestConversation =
(await conversationStore.get(
{ actorKey, projectId, projectKey, userId },
activeConversation.sessionId,
)) ?? activeConversation;
conversationWithRuntime.sessionId,
)) ?? conversationWithRuntime;
const latestConversationState = await conversationStateStore.read(
latestConversation.sessionScopeKey,
toConversationStateContext(latestConversation),
);
const existingSessionTitle = latestConversation.title;
let sessionTitle = existingSessionTitle;
@@ -606,8 +652,7 @@ export const buildChatRouter = (
}
}
} finally {
await sessionBridge.releaseRuntimeSession(clientSessionId, binding.sessionId);
sessionBridge.deleteAbortController(clientSessionId);
sessionBridge.finalizeRequest(clientSessionId);
streamClosed = true;
req.off("close", handleClientClose);
res.off("close", handleClientClose);
+65 -5
View File
@@ -192,15 +192,22 @@ export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore,
actorKey: string,
projectKey: string,
recentTurns: SessionTurnRecord[],
message: string,
options: {
recentTurns: SessionTurnRecord[];
persistedMessages?: unknown[];
message: string;
restoreConversation?: boolean;
},
) => {
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) {
return message;
return options.message;
}
return [snapshot, restoredConversation, `[Current user request]\n${message}`]
return [snapshot, restoredConversation, `[Current user request]\n${options.message}`]
.filter(Boolean)
.join("\n\n");
};
@@ -239,4 +246,57 @@ const compactMessage = (value: string) => {
return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
: 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;
}
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;
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? await toolContextStore.read(sessionId) : null;
if (!context) {
res.status(404).json({
message: "runtime or session context not found",
detail: sessionScopeKey,
message: "session context not found",
detail: sessionId,
});
return;
}
@@ -96,7 +87,7 @@ app.post("/internal/tools/dynamic-http-call", async (req, res) => {
arguments: req.body?.arguments,
},
{
accessToken: runtimeContext?.accessToken,
accessToken: context.accessToken,
actorKey: context.actorKey,
clientSessionId: context.clientSessionId,
projectId: context.projectId,
@@ -121,22 +112,13 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
return;
}
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;
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? await toolContextStore.read(sessionId) : null;
if (!context) {
res.status(404).json({
message: "runtime or session context not found",
detail: sessionScopeKey,
message: "session context not found",
detail: sessionId,
});
return;
}
@@ -148,11 +130,11 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
}
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({
server: config.TJWATER_API_BASE_URL,
access_token: runtimeContext?.accessToken,
access_token: context.accessToken,
project_id: context.projectId,
network:"tjwater",
});
@@ -233,14 +215,14 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => {
return;
}
const sessionScopeKey =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
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) {
res.status(404).json({
message: "session context not found",
detail: sessionScopeKey,
detail: sessionId,
});
return;
}
@@ -276,14 +258,14 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
return;
}
const sessionScopeKey =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const sessionId =
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 context = await toolContextStore.read(sessionScopeKey);
const context = sessionId ? await toolContextStore.read(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionScopeKey,
detail: sessionId,
});
return;
}
@@ -326,14 +308,14 @@ app.post("/internal/tools/session-search", async (req, res) => {
return;
}
const sessionScopeKey =
typeof req.body?.sessionScopeKey === "string" ? req.body.sessionScopeKey : "";
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
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) {
res.status(404).json({
message: "session context not found",
detail: sessionScopeKey,
detail: sessionId,
});
return;
}
+1 -11
View File
@@ -7,9 +7,9 @@ import {
readJsonFile,
removeFileIfExists,
} from "../utils/fileStore.js";
import { toConversationScopeKey } from "../utils/fileStore.js";
export type ToolSessionContext = {
accessToken?: string;
actorKey: string;
allowLearningWrite?: boolean;
clientSessionId: string;
@@ -17,7 +17,6 @@ export type ToolSessionContext = {
projectId?: string;
projectKey: string;
sessionId: string;
sessionScopeKey: string;
traceId: string;
};
@@ -30,9 +29,6 @@ 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) {
@@ -47,9 +43,3 @@ export class ToolSessionContextStore {
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, {
title: "新标题",
opencodeSessionId: "opencode-session-1",
});
expect(touched.title).toBe("新标题");
expect(touched.opencodeSessionId).toBe("opencode-session-1");
expect(touched.updatedAt >= record.updatedAt).toBe(true);
const fetched = await store.get(
@@ -58,7 +60,7 @@ describe("ConversationStore", () => {
},
"existing-session",
);
expect(fetched?.sessionScopeKey).toBe(record.sessionScopeKey);
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 {
buildPromptWithLearningContext,
generateSessionTitle,
shouldGenerateSessionTitle,
} 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";
describe("shouldGenerateSessionTitle", () => {
@@ -71,3 +74,90 @@ describe("generateSessionTitle", () => {
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 { join } from "node:path";
import {
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../../src/session/toolContextStore.js";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
describe("ToolSessionContextStore", () => {
let tempDir: string;
@@ -22,14 +19,9 @@ describe("ToolSessionContextStore", () => {
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",
);
it("writes and reads runtime session context by opencode session id", async () => {
await store.write({
accessToken: "token-1",
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "chat-session-1",
@@ -37,15 +29,13 @@ describe("ToolSessionContextStore", () => {
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?.accessToken).toBe("token-1");
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
expect(scopedContext?.sessionScopeKey).toBe(sessionScopeKey);
expect(scopedContext?.sessionId).toBe("runtime-session-1");
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
});
});