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