重构会话管理功能,由后端 opencode 发放 sessionId,后端做 scope

This commit is contained in:
2026-05-21 15:41:46 +08:00
parent 7e63d38cf5
commit 5d80961930
20 changed files with 816 additions and 390 deletions
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ConversationStore } from "../../src/conversations/store.js";
describe("ConversationStore", () => {
let tempDir: string;
let store: ConversationStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-conversation-"));
store = new ConversationStore(tempDir);
await store.initialize();
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("issues backend-managed session ids when absent", async () => {
const { record, created } = await store.ensure({
actorKey: "actor-1",
projectId: "project-1",
projectKey: "project-key-1",
userId: "user-1",
});
expect(created).toBe(true);
expect(record.sessionId).toStartWith("chat-");
expect(record.ownerUserId).toBe("user-1");
expect(record.status).toBe("active");
});
it("touches metadata and preserves scoped ownership", async () => {
const { record } = await store.ensure({
actorKey: "actor-2",
projectId: "project-2",
projectKey: "project-key-2",
sessionId: "existing-session",
userId: "user-2",
});
const touched = await store.touch(record, {
title: "新标题",
});
expect(touched.title).toBe("新标题");
expect(touched.updatedAt >= record.updatedAt).toBe(true);
const fetched = await store.get(
{
actorKey: "actor-2",
projectId: "project-2",
projectKey: "project-key-2",
userId: "user-2",
},
"existing-session",
);
expect(fetched?.sessionScopeKey).toBe(record.sessionScopeKey);
expect(fetched?.title).toBe("新标题");
});
});
+138
View File
@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionHistoryStore } from "../../src/history/store.js";
describe("SessionHistoryStore", () => {
let tempDir: string;
let store: SessionHistoryStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-history-"));
store = new SessionHistoryStore(tempDir);
await store.initialize();
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("falls back to legacy runtime-session transcripts by client session id and migrates on append", async () => {
await writeFile(
join(tempDir, "actor-1__project-1__runtime-session-1.json"),
JSON.stringify(
{
actorKey: "actor-1",
clientSessionId: "thread-1",
projectKey: "project-1",
sessionId: "runtime-session-1",
turns: [
{
id: "turn-1",
assistantMessage: "先检查泵站流量。",
timestamp: "2026-05-21T00:00:00.000Z",
toolCallCount: 1,
userMessage: "帮我看一下当前异常。",
},
],
updatedAt: "2026-05-21T00:00:00.000Z",
},
null,
2,
),
"utf8",
);
const recentTurns = await store.getRecentTurns(
{
actorKey: "actor-1",
clientSessionId: "thread-1",
projectKey: "project-1",
sessionId: "thread-1",
},
5,
);
expect(recentTurns).toHaveLength(1);
expect(recentTurns[0]?.userMessage).toBe("帮我看一下当前异常。");
const transcript = await store.appendTurn(
{
actorKey: "actor-1",
clientSessionId: "thread-1",
projectKey: "project-1",
sessionId: "thread-1",
},
{
assistantMessage: "已经定位到 3 条疑似异常支路。",
toolCallCount: 2,
userMessage: "继续分析这些支路。",
},
);
expect(transcript.sessionId).toBe("thread-1");
expect(transcript.turns).toHaveLength(2);
});
it("clones only the kept prefix when forking a thread", async () => {
await store.appendTurn(
{
actorKey: "actor-2",
clientSessionId: "thread-source",
projectKey: "project-2",
sessionId: "thread-source",
},
{
assistantMessage: "第一轮回复",
toolCallCount: 0,
userMessage: "第一轮提问",
},
);
await store.appendTurn(
{
actorKey: "actor-2",
clientSessionId: "thread-source",
projectKey: "project-2",
sessionId: "thread-source",
},
{
assistantMessage: "第二轮回复",
toolCallCount: 0,
userMessage: "第二轮提问",
},
);
const cloned = await store.cloneThread(
{
actorKey: "actor-2",
clientSessionId: "thread-source",
projectKey: "project-2",
sessionId: "thread-source",
},
{
actorKey: "actor-2",
clientSessionId: "thread-fork",
projectKey: "project-2",
sessionId: "thread-fork",
},
2,
);
expect(cloned.turns).toHaveLength(1);
expect(cloned.turns[0]?.userMessage).toBe("第一轮提问");
const forkRecentTurns = await store.getRecentTurns(
{
actorKey: "actor-2",
clientSessionId: "thread-fork",
projectKey: "project-2",
sessionId: "thread-fork",
},
5,
);
expect(forkRecentTurns).toHaveLength(1);
expect(forkRecentTurns[0]?.assistantMessage).toBe("第一轮回复");
});
});
+51
View File
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
buildToolSessionScopeKey,
ToolSessionContextStore,
} from "../../src/session/toolContextStore.js";
describe("ToolSessionContextStore", () => {
let tempDir: string;
let store: ToolSessionContextStore;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-tool-context-"));
store = new ToolSessionContextStore(tempDir);
await store.initialize();
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("writes interactive aliases under scoped session keys", async () => {
const sessionScopeKey = buildToolSessionScopeKey(
"actor-1",
"project-1",
"chat-session-1",
);
await store.write({
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "chat-session-1",
learningMode: "interactive",
projectId: "project-id-1",
projectKey: "project-1",
sessionId: "runtime-session-1",
sessionScopeKey,
traceId: "trace-1",
});
const runtimeContext = await store.read("runtime-session-1");
const scopedContext = await store.read(sessionScopeKey);
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
expect(scopedContext?.sessionScopeKey).toBe(sessionScopeKey);
expect(scopedContext?.sessionId).toBe("runtime-session-1");
});
});