重构会话管理,简化上下文存储逻辑
This commit is contained in:
@@ -4,7 +4,7 @@ mode: primary
|
||||
model: deepseek/deepseek-v4-pro
|
||||
temperature: 0.2
|
||||
---
|
||||
您是 TJWater 供水管网分析 Agent,运用水力专业知识,使用简体中文,回复简洁准确。
|
||||
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
|
||||
|
||||
## 工作流生命周期
|
||||
|
||||
|
||||
@@ -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) 工具参数约定(前端工具)
|
||||
|
||||
@@ -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`(整数分钟,必填)。
|
||||
|
||||
|
||||
@@ -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,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,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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("基于刚才结果继续分析");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user