feat: initialize experimental agent repo
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
agentModelOptions,
|
||||
isSupportedModel,
|
||||
resolveDefaultModel,
|
||||
supportedModels,
|
||||
} from "../../src/chat/models.js";
|
||||
import { parseAgentModelOptions } from "../../src/chat/modelConfig.js";
|
||||
|
||||
describe("agent model config", () => {
|
||||
it("keeps every exposed option in the supported model list", () => {
|
||||
expect(agentModelOptions.map((model) => model.id)).toEqual(supportedModels);
|
||||
expect(
|
||||
agentModelOptions.every(
|
||||
(model) => model.label && model.description && model.icon,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("validates supported model ids", () => {
|
||||
expect(isSupportedModel("deepseek/deepseek-v4-flash")).toBe(true);
|
||||
expect(isSupportedModel("unknown/model")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsupported default models", () => {
|
||||
expect(resolveDefaultModel("deepseek/deepseek-v4-pro")).toBe(
|
||||
"deepseek/deepseek-v4-pro",
|
||||
);
|
||||
expect(() => resolveDefaultModel("unknown/model")).toThrow(
|
||||
"unsupported default agent model",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses full model option config from JSON", () => {
|
||||
expect(
|
||||
parseAgentModelOptions(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "provider/model",
|
||||
label: "自定义",
|
||||
description: "自定义模型",
|
||||
icon: "bolt",
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: "provider/model",
|
||||
label: "自定义",
|
||||
description: "自定义模型",
|
||||
icon: "bolt",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects invalid model option config", () => {
|
||||
expect(() => parseAgentModelOptions("not-json")).toThrow(
|
||||
"OPENCODE_MODEL_OPTIONS must be valid JSON",
|
||||
);
|
||||
expect(() =>
|
||||
parseAgentModelOptions(
|
||||
JSON.stringify([
|
||||
{ id: "provider/model", label: "模型" },
|
||||
{ id: "provider/model", label: "重复模型" },
|
||||
]),
|
||||
),
|
||||
).toThrow("duplicate OPENCODE_MODEL_OPTIONS id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
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 { MemoryStore } from "../../src/memory/store.js";
|
||||
|
||||
describe("MemoryStore", () => {
|
||||
let tempDir: string;
|
||||
let backupDir: string;
|
||||
let store: MemoryStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-memory-"));
|
||||
backupDir = await mkdtemp(join(tmpdir(), "tjwater-memory-backup-"));
|
||||
store = new MemoryStore(tempDir, backupDir);
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
await rm(backupDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("dedupes exact duplicate memories", async () => {
|
||||
const first = await store.upsert("workspace", "project-1", {
|
||||
content: "DMA-2 nightly leakage analysis should compare against adjacent zones first.",
|
||||
source: "tool",
|
||||
});
|
||||
const second = await store.upsert("workspace", "project-1", {
|
||||
content: "DMA-2 nightly leakage analysis should compare against adjacent zones first.",
|
||||
source: "tool",
|
||||
});
|
||||
|
||||
expect(first.changed).toBe(true);
|
||||
expect(second.changed).toBe(false);
|
||||
expect(second.detail).toBe("memory already existed");
|
||||
});
|
||||
|
||||
it("allows rewritten memories when the content is not exactly the same", async () => {
|
||||
await store.upsert("workspace", "project-1", {
|
||||
content: "保存记忆前先查看当前 workspace memory,避免重复写入相同约束。",
|
||||
source: "tool",
|
||||
});
|
||||
|
||||
const result = await store.upsert("workspace", "project-1", {
|
||||
content: "写入前先看一遍当前 workspace 记忆,避免把同样的约束重复保存进去。",
|
||||
source: "tool",
|
||||
});
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.detail).toBe("memory stored");
|
||||
expect(result.entry?.content).toBe("写入前先看一遍当前 workspace 记忆,避免把同样的约束重复保存进去。");
|
||||
});
|
||||
|
||||
it("rejects replace when the new content would become an exact duplicate", async () => {
|
||||
const first = await store.upsert("user", "actor-1", {
|
||||
content: "回答时默认使用中文,并保持结论先行。",
|
||||
source: "tool",
|
||||
});
|
||||
const second = await store.upsert("user", "actor-1", {
|
||||
content: "回答要包含必要的文件路径引用。",
|
||||
source: "tool",
|
||||
});
|
||||
|
||||
const result = await store.replace("user", "actor-1", second.entry?.id ?? "", {
|
||||
content: "回答时默认使用中文,并保持结论先行。",
|
||||
source: "tool",
|
||||
});
|
||||
|
||||
expect(first.changed).toBe(true);
|
||||
expect(second.changed).toBe(true);
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.detail).toBe("replacement would duplicate an existing memory");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js";
|
||||
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
import { SkillStore } from "../../src/skills/store.js";
|
||||
|
||||
describe("skill_manager tool", () => {
|
||||
let tempDir: string;
|
||||
let skillStore: SkillStore;
|
||||
let context: RuntimeSessionContext;
|
||||
|
||||
const toolContext = {
|
||||
abort: new AbortController().signal,
|
||||
agent: "test",
|
||||
ask: (() => undefined) as never,
|
||||
directory: "",
|
||||
messageID: "message-1",
|
||||
metadata: () => undefined,
|
||||
sessionID: "session-1",
|
||||
worktree: "",
|
||||
};
|
||||
|
||||
const skillDocument = (body: string) =>
|
||||
[
|
||||
"---",
|
||||
"name: pressure-review",
|
||||
"description: Pressure review workflow.",
|
||||
"---",
|
||||
"",
|
||||
body,
|
||||
].join("\n");
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-skill-tool-"));
|
||||
skillStore = new SkillStore(
|
||||
join(tempDir, "skills"),
|
||||
join(tempDir, "backup", "skills"),
|
||||
);
|
||||
context = {
|
||||
actorKey: "actor-1",
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: "client-session-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "session-1",
|
||||
traceId: "trace-1",
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("dispatches skill-level write, overwrite, and remove actions", async () => {
|
||||
const tool = createSkillManagerTool(
|
||||
skillStore,
|
||||
{ read: () => context },
|
||||
Promise.resolve(),
|
||||
);
|
||||
|
||||
const writeResult = JSON.parse(
|
||||
await tool.execute(
|
||||
{
|
||||
action: "write_skill",
|
||||
content: skillDocument("# Pressure Review"),
|
||||
reason: "verified reusable workflow",
|
||||
skill_path: "workflow/pressure-review",
|
||||
},
|
||||
toolContext,
|
||||
) as string,
|
||||
);
|
||||
expect(writeResult.decision).toBe("accepted");
|
||||
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
|
||||
"# Pressure Review\n",
|
||||
);
|
||||
|
||||
const updateResult = JSON.parse(
|
||||
await tool.execute(
|
||||
{
|
||||
action: "write_skill",
|
||||
content: skillDocument("# Updated Pressure Review"),
|
||||
reason: "verified reusable workflow overwrite",
|
||||
skill_path: "workflow/pressure-review",
|
||||
},
|
||||
toolContext,
|
||||
) as string,
|
||||
);
|
||||
expect(updateResult.decision).toBe("accepted");
|
||||
await expect(readFile(updateResult.target, "utf8")).resolves.toContain(
|
||||
"# Updated Pressure Review\n",
|
||||
);
|
||||
|
||||
const removeResult = JSON.parse(
|
||||
await tool.execute(
|
||||
{
|
||||
action: "remove_skill",
|
||||
reason: "workflow is obsolete",
|
||||
skill_path: "workflow/pressure-review",
|
||||
},
|
||||
toolContext,
|
||||
) as string,
|
||||
);
|
||||
expect(removeResult.decision).toBe("accepted");
|
||||
await expect(readFile(removeResult.target, "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("writes the root skills index through the reserved alias", async () => {
|
||||
const tool = createSkillManagerTool(
|
||||
skillStore,
|
||||
{ read: () => context },
|
||||
Promise.resolve(),
|
||||
);
|
||||
|
||||
const writeResult = JSON.parse(
|
||||
await tool.execute(
|
||||
{
|
||||
action: "write_skill",
|
||||
content: [
|
||||
"---",
|
||||
"name: skills",
|
||||
"description: TJWater Skills root index.",
|
||||
"---",
|
||||
"",
|
||||
"# TJWater Skills",
|
||||
].join("\n"),
|
||||
reason: "refresh root skills index",
|
||||
skill_path: "__root__",
|
||||
},
|
||||
toolContext,
|
||||
) as string,
|
||||
);
|
||||
|
||||
expect(writeResult.decision).toBe("accepted");
|
||||
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
|
||||
"# TJWater Skills\n",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
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 { ResultReferenceResolver } from "../../src/results/resolver.js";
|
||||
import {
|
||||
RESULT_REFERENCE_KIND,
|
||||
RESULT_REFERENCE_SOURCE,
|
||||
ResultReferenceStore,
|
||||
} from "../../src/results/store.js";
|
||||
|
||||
describe("ResultReferenceResolver", () => {
|
||||
let tempDir: string;
|
||||
let store: ResultReferenceStore;
|
||||
let resolver: ResultReferenceResolver;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
|
||||
store = new ResultReferenceStore(tempDir, 60_000);
|
||||
resolver = new ResultReferenceResolver(store);
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("stores metadata for render refs and resolves them", async () => {
|
||||
const record = await resolver.register({
|
||||
actorKey: "actor-1",
|
||||
clientSessionId: "client-1",
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
J2: "DMA-2",
|
||||
},
|
||||
},
|
||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
projectId: "project-1",
|
||||
projectKey: "project-key-1",
|
||||
schemaVersion: 1,
|
||||
sessionId: "session-1",
|
||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||
traceId: "trace-1",
|
||||
});
|
||||
|
||||
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||
expect(record.schemaVersion).toBe(1);
|
||||
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
|
||||
|
||||
const result = await resolver.getFullAuthorized(
|
||||
record.resultRef,
|
||||
{
|
||||
actorKey: "actor-1",
|
||||
projectId: "project-1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||
expect(result?.schema_version).toBe(1);
|
||||
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
|
||||
expect(result?.data).toEqual({
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
J2: "DMA-2",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed refs and auth mismatches", async () => {
|
||||
const malformedRef = "res-bbbbbbbbbbbbbbbb";
|
||||
await writeFile(
|
||||
join(tempDir, `${malformedRef}.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
resultRef: malformedRef,
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
data: { value: 1 },
|
||||
preview: {
|
||||
count: 1,
|
||||
fields: ["value"],
|
||||
sample: { value: 1 },
|
||||
summary: "object<1 fields>",
|
||||
},
|
||||
projectId: "project-1",
|
||||
projectKey: "project-key-1",
|
||||
sessionId: "session-1",
|
||||
sizeBytes: 10,
|
||||
traceId: "trace-1",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const malformed = await store.getAuthorizedRecord(malformedRef, {
|
||||
actorKey: "actor-1",
|
||||
projectId: "project-1",
|
||||
});
|
||||
expect(malformed).toBeNull();
|
||||
|
||||
const renderRecord = await resolver.register({
|
||||
actorKey: "actor-2",
|
||||
clientSessionId: "client-2",
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
},
|
||||
},
|
||||
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
projectId: "project-2",
|
||||
projectKey: "project-key-2",
|
||||
schemaVersion: 1,
|
||||
sessionId: "session-2",
|
||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||
traceId: "trace-2",
|
||||
});
|
||||
|
||||
const wrongActor = await resolver.getFullAuthorized(renderRecord.resultRef, {
|
||||
actorKey: "actor-other",
|
||||
projectId: "project-2",
|
||||
});
|
||||
expect(wrongActor).toBeNull();
|
||||
});
|
||||
|
||||
it("registers render refs from local wrapper files and normalizes payloads", async () => {
|
||||
const filePath = join(tempDir, "render-wrapper.json");
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
metadata: {
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
projectId: "project-3",
|
||||
},
|
||||
location: {
|
||||
file_path: filePath,
|
||||
},
|
||||
data: {
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
J2: 2,
|
||||
},
|
||||
area_ids: ["DMA-1", " DMA-2 "],
|
||||
area_colors: {
|
||||
"DMA-1": "#ff0000",
|
||||
"DMA-2": "#00ff00",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-05-21T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const record = await resolver.registerRenderPayloadFile(filePath, {
|
||||
actorKey: "actor-3",
|
||||
clientSessionId: "client-3",
|
||||
projectId: "project-3",
|
||||
projectKey: "project-key-3",
|
||||
sessionId: "session-3",
|
||||
source: RESULT_REFERENCE_SOURCE.agentGenerated,
|
||||
traceId: "trace-3",
|
||||
});
|
||||
|
||||
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
|
||||
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
|
||||
|
||||
const result = await resolver.getFullAuthorized(
|
||||
record.resultRef,
|
||||
{
|
||||
actorKey: "actor-3",
|
||||
projectId: "project-3",
|
||||
},
|
||||
{
|
||||
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.data).toEqual({
|
||||
node_area_map: {
|
||||
J1: "DMA-1",
|
||||
J2: "2",
|
||||
},
|
||||
area_ids: ["DMA-1", "DMA-2"],
|
||||
area_colors: {
|
||||
"DMA-1": "#ff0000",
|
||||
"DMA-2": "#00ff00",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
buildForkedSessionUiState,
|
||||
} from "../../src/routes/chat.js";
|
||||
import {
|
||||
buildPromptWithLearningContext,
|
||||
extractLatestFrontendTurn,
|
||||
generateSessionTitle,
|
||||
shouldRestoreConversationForRuntime,
|
||||
shouldGenerateSessionTitle,
|
||||
} from "../../src/routes/chatSession.js";
|
||||
import { type SessionTurnRecord } from "../../src/sessions/transcriptStore.js";
|
||||
import { type MemoryStore } from "../../src/memory/store.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
|
||||
describe("shouldGenerateSessionTitle", () => {
|
||||
it("allows auto-title generation for the first turn when the title was not edited", () => {
|
||||
expect(
|
||||
shouldGenerateSessionTitle({
|
||||
recentTurnCount: 0,
|
||||
isTitleManuallyEdited: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks auto-title generation after the user edits the title manually", () => {
|
||||
expect(
|
||||
shouldGenerateSessionTitle({
|
||||
recentTurnCount: 0,
|
||||
isTitleManuallyEdited: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("only allows auto-title generation during the first two turns", () => {
|
||||
expect(
|
||||
shouldGenerateSessionTitle({
|
||||
recentTurnCount: 1,
|
||||
isTitleManuallyEdited: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldGenerateSessionTitle({
|
||||
recentTurnCount: 2,
|
||||
isTitleManuallyEdited: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSessionTitle", () => {
|
||||
it("uses the current user and assistant turn instead of reading wrapped runtime context", async () => {
|
||||
let titlePrompt = "";
|
||||
const runtime = {
|
||||
createSession: async () => ({ id: "title-session" }),
|
||||
prompt: async (_sessionId: string, prompt: string) => {
|
||||
titlePrompt = prompt;
|
||||
},
|
||||
waitForSessionIdle: async () => undefined,
|
||||
messages: async () => [
|
||||
{
|
||||
info: { role: "assistant" },
|
||||
parts: [{ type: "text", text: "标题:泵站压力异常排查。" }],
|
||||
},
|
||||
],
|
||||
abortSession: async () => undefined,
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
|
||||
const title = await generateSessionTitle(runtime, {
|
||||
sessionId: "chat-session",
|
||||
latestUserMessage: "检查一下三号泵站最近压力波动的原因",
|
||||
latestAssistantMessage: "三号泵站压力波动主要与夜间阀门开度变化有关。",
|
||||
fallbackTitle: "新对话",
|
||||
});
|
||||
|
||||
expect(title).toBe("泵站压力异常排查");
|
||||
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("基于刚才结果继续分析");
|
||||
});
|
||||
|
||||
it("restores copied fork context when metadata exists but runtime has no conversation", () => {
|
||||
expect(
|
||||
shouldRestoreConversationForRuntime({
|
||||
hadExistingSessionRecord: true,
|
||||
runtimeHasConversation: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRestoreConversationForRuntime({
|
||||
hadExistingSessionRecord: true,
|
||||
runtimeHasConversation: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractLatestFrontendTurn", () => {
|
||||
it("extracts the latest valid frontend user and assistant turn", () => {
|
||||
const turn = extractLatestFrontendTurn([
|
||||
{ role: "user", content: "检查 DMA-2 漏损" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "DMA-2 夜间最小流量持续抬升。",
|
||||
progress: [{ id: "tool-dma", phase: "tool" }],
|
||||
},
|
||||
{ role: "user", content: "继续分析相邻分区" },
|
||||
{ role: "assistant", content: "⚠️ **请求已中断**", isError: true },
|
||||
]);
|
||||
|
||||
expect(turn).toEqual({
|
||||
assistantMessage: "DMA-2 夜间最小流量持续抬升。",
|
||||
toolCallCount: 1,
|
||||
userMessage: "检查 DMA-2 漏损",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForkedSessionUiState", () => {
|
||||
it("copies truncated source messages and preserves tool artifacts", () => {
|
||||
const forked = buildForkedSessionUiState(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "画压力曲线" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "已生成图表",
|
||||
artifacts: [
|
||||
{
|
||||
id: "chart-1",
|
||||
tool: "show_chart",
|
||||
kind: "chart",
|
||||
params: { chart_type: "line" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "继续分析" },
|
||||
],
|
||||
},
|
||||
{
|
||||
keepMessageCount: 2,
|
||||
targetSessionId: "forked-session",
|
||||
},
|
||||
);
|
||||
|
||||
expect(forked).toEqual({
|
||||
sessionId: "forked-session",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [
|
||||
{ role: "user", content: "画压力曲线" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "已生成图表",
|
||||
artifacts: [
|
||||
{
|
||||
id: "chart-1",
|
||||
tool: "show_chart",
|
||||
kind: "chart",
|
||||
params: { chart_type: "line" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an empty branch state when source UI state is missing or keep count is zero", () => {
|
||||
expect(
|
||||
buildForkedSessionUiState(null, {
|
||||
keepMessageCount: 3,
|
||||
targetSessionId: "forked-without-source",
|
||||
}),
|
||||
).toEqual({
|
||||
sessionId: "forked-without-source",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildForkedSessionUiState(
|
||||
{ messages: [{ role: "user", content: "不保留" }] },
|
||||
{
|
||||
keepMessageCount: 0,
|
||||
targetSessionId: "forked-empty",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
sessionId: "forked-empty",
|
||||
isTitleManuallyEdited: false,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
streamPromptResponse,
|
||||
type PermissionRequestPayload,
|
||||
} from "../../src/routes/chatStream.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
|
||||
const createEventStream = (events: unknown[]) => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
describe("streamPromptResponse", () => {
|
||||
it("forwards opencode permission requests as SSE payloads", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "runtime-session-1",
|
||||
permission: "bash",
|
||||
patterns: ["rm *"],
|
||||
metadata: { command: "rm tmp.txt" },
|
||||
always: ["rm *"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
messages: async () => [],
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
await streamPromptResponse({
|
||||
runtime,
|
||||
sessionId: "runtime-session-1",
|
||||
clientSessionId: "client-session-1",
|
||||
message: "delete temp",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
const permissionEvent = events.find((item) => item.event === "permission_request");
|
||||
expect(permissionEvent?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
request_id: "perm-1",
|
||||
permission: "bash",
|
||||
patterns: ["rm *"],
|
||||
target: "rm tmp.txt",
|
||||
always: ["rm *"],
|
||||
} satisfies Partial<PermissionRequestPayload>);
|
||||
});
|
||||
|
||||
it("auto replies always when approval mode is always", async () => {
|
||||
const replies: Array<Record<string, unknown>> = [];
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "runtime-session-1",
|
||||
permission: "bash",
|
||||
patterns: ["npm test"],
|
||||
metadata: { command: "npm test" },
|
||||
always: ["npm test"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
messages: async () => [],
|
||||
replyPermission: async (options: Record<string, unknown>) => {
|
||||
replies.push(options);
|
||||
},
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
await streamPromptResponse({
|
||||
runtime,
|
||||
sessionId: "runtime-session-1",
|
||||
clientSessionId: "client-session-1",
|
||||
message: "run tests",
|
||||
approvalMode: "always",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
expect(replies).toEqual([
|
||||
{
|
||||
requestId: "perm-1",
|
||||
sessionId: "runtime-session-1",
|
||||
reply: "always",
|
||||
},
|
||||
]);
|
||||
expect(events.some((item) => item.event === "permission_request")).toBe(false);
|
||||
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
|
||||
session_id: "client-session-1",
|
||||
request_id: "perm-1",
|
||||
reply: "always",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards opencode v2 permission requests as SSE payloads", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "permission.v2.asked",
|
||||
properties: {
|
||||
id: "perm-v2-1",
|
||||
sessionID: "runtime-session-1",
|
||||
action: "external_directory",
|
||||
resources: ["/tmp"],
|
||||
save: ["/tmp"],
|
||||
metadata: { path: "/tmp" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
messages: async () => [],
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
await streamPromptResponse({
|
||||
runtime,
|
||||
sessionId: "runtime-session-1",
|
||||
clientSessionId: "client-session-1",
|
||||
message: "read /tmp",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
const permissionEvent = events.find((item) => item.event === "permission_request");
|
||||
expect(permissionEvent?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
request_id: "perm-v2-1",
|
||||
permission: "external_directory",
|
||||
patterns: ["/tmp"],
|
||||
target: "/tmp",
|
||||
always: ["/tmp"],
|
||||
} satisfies Partial<PermissionRequestPayload>);
|
||||
});
|
||||
|
||||
it("forwards opencode question requests and replies as SSE payloads", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-1",
|
||||
sessionID: "runtime-session-1",
|
||||
questions: [
|
||||
{
|
||||
header: "范围",
|
||||
question: "选择分析范围",
|
||||
options: [{ label: "城区", description: "中心城区" }],
|
||||
multiple: false,
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "question.replied",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
requestID: "question-1",
|
||||
answers: [["城区", "补充说明"]],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
messages: async () => [],
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
await streamPromptResponse({
|
||||
runtime,
|
||||
sessionId: "runtime-session-1",
|
||||
clientSessionId: "client-session-1",
|
||||
message: "ask",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
request_id: "question-1",
|
||||
questions: [
|
||||
{
|
||||
header: "范围",
|
||||
question: "选择分析范围",
|
||||
options: [{ label: "城区", description: "中心城区" }],
|
||||
multiple: false,
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(events.find((item) => item.event === "question_response")?.data).toEqual({
|
||||
session_id: "client-session-1",
|
||||
request_id: "question-1",
|
||||
answers: [["城区", "补充说明"]],
|
||||
});
|
||||
});
|
||||
|
||||
it("converts question tool parts into question request SSE payloads", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
part: {
|
||||
id: "tool-part-1",
|
||||
sessionID: "runtime-session-1",
|
||||
messageID: "message-1",
|
||||
type: "tool",
|
||||
callID: "call-1",
|
||||
tool: "question",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
header: "测试问题",
|
||||
options: [
|
||||
{
|
||||
label: "非常好用",
|
||||
description: "交互清晰,选项方便",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
},
|
||||
time: Date.now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
messages: async () => [],
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
await streamPromptResponse({
|
||||
runtime,
|
||||
sessionId: "runtime-session-1",
|
||||
clientSessionId: "client-session-1",
|
||||
message: "ask",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
request_id: "call-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [
|
||||
{
|
||||
label: "非常好用",
|
||||
description: "交互清晰,选项方便",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
events.some(
|
||||
(item) => item.event === "tool_call" && item.data.tool === "question",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards todo updates as structured SSE payloads and progress", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "todo.updated",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
todos: [
|
||||
{ content: "分析水位", status: "completed", priority: "high" },
|
||||
{ content: "生成建议", status: "in_progress", priority: "medium" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
messages: async () => [],
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
await streamPromptResponse({
|
||||
runtime,
|
||||
sessionId: "runtime-session-1",
|
||||
clientSessionId: "client-session-1",
|
||||
message: "plan",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
expect(
|
||||
events.find(
|
||||
(item) => item.event === "progress" && item.data.id === "todo-progress",
|
||||
)?.data,
|
||||
).toMatchObject({
|
||||
id: "todo-progress",
|
||||
phase: "planning",
|
||||
title: "计划进度 1/2",
|
||||
});
|
||||
expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
todos: [
|
||||
expect.objectContaining({
|
||||
content: "分析水位",
|
||||
status: "completed",
|
||||
priority: "high",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
content: "生成建议",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps visual tool calls to UIEnvelope SSE payloads", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
part: {
|
||||
id: "tool-part-chart",
|
||||
sessionID: "runtime-session-1",
|
||||
messageID: "message-1",
|
||||
type: "tool",
|
||||
callID: "call-chart",
|
||||
tool: "show_chart",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
reason: "展示压力趋势",
|
||||
title: "压力趋势",
|
||||
chart_type: "line",
|
||||
x_data: ["00:00", "01:00"],
|
||||
series: [{ name: "P-1", data: [0.4, 0.42] }],
|
||||
},
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
},
|
||||
time: Date.now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
messages: async () => [],
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
await streamPromptResponse({
|
||||
runtime,
|
||||
sessionId: "runtime-session-1",
|
||||
clientSessionId: "client-session-1",
|
||||
message: "chart",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
expect(events.find((item) => item.event === "tool_call")?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
tool: "show_chart",
|
||||
});
|
||||
expect(events.find((item) => item.event === "ui_envelope")?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
envelope: {
|
||||
kind: "chart",
|
||||
schemaVersion: "agent-ui@1",
|
||||
grammar: "echarts-safe-subset",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
appendBackendUiEnvelope,
|
||||
appendBackendToolArtifact,
|
||||
cancelBackendTodos,
|
||||
upsertBackendQuestion,
|
||||
} from "../../src/routes/chatUiState.js";
|
||||
|
||||
describe("appendBackendToolArtifact", () => {
|
||||
it("persists show_chart tool calls as chart artifacts", () => {
|
||||
const artifacts = appendBackendToolArtifact([], {
|
||||
session_id: "session-1",
|
||||
tool: "show_chart",
|
||||
reason: "测试折线图渲染",
|
||||
params: {
|
||||
title: "压力曲线",
|
||||
chart_type: "line",
|
||||
x_data: ["00:00", "01:00"],
|
||||
series: [{ name: "P-101", data: [0.42, 0.41] }],
|
||||
},
|
||||
}) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(artifacts).toHaveLength(1);
|
||||
expect(artifacts[0]).toMatchObject({
|
||||
tool: "show_chart",
|
||||
kind: "chart",
|
||||
title: "压力曲线",
|
||||
description: "测试折线图渲染",
|
||||
params: {
|
||||
chart_type: "line",
|
||||
x_data: ["00:00", "01:00"],
|
||||
series: [{ name: "P-101", data: [0.42, 0.41] }],
|
||||
},
|
||||
});
|
||||
expect(artifacts[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^show_chart-/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendBackendUiEnvelope", () => {
|
||||
it("persists UIEnvelope payloads on assistant messages", () => {
|
||||
const uiEnvelopes = appendBackendUiEnvelope([], {
|
||||
session_id: "session-1",
|
||||
envelope_id: "env-1",
|
||||
created_at: 123,
|
||||
envelope: {
|
||||
kind: "map_action",
|
||||
schemaVersion: "agent-ui@1",
|
||||
action: "locate_features",
|
||||
surface: "map_overlay",
|
||||
params: { ids: ["J-1"], feature_type: "junction" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(uiEnvelopes).toEqual([
|
||||
{
|
||||
envelopeId: "env-1",
|
||||
createdAt: 123,
|
||||
envelope: {
|
||||
kind: "map_action",
|
||||
schemaVersion: "agent-ui@1",
|
||||
action: "locate_features",
|
||||
surface: "map_overlay",
|
||||
params: { ids: ["J-1"], feature_type: "junction" },
|
||||
},
|
||||
renderStatus: "pending",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertBackendQuestion", () => {
|
||||
it("replaces a tool-call placeholder with the actionable question request", () => {
|
||||
const questions = upsertBackendQuestion(
|
||||
[
|
||||
{
|
||||
requestId: "call-1",
|
||||
sessionId: "session-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
createdAt: 123,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
{
|
||||
session_id: "session-1",
|
||||
request_id: "question-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
created_at: 456,
|
||||
},
|
||||
);
|
||||
|
||||
expect(questions).toHaveLength(1);
|
||||
expect(questions[0]).toMatchObject({
|
||||
requestId: "question-1",
|
||||
tool: { callID: "call-1" },
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replace an actionable question request with a later tool-call placeholder", () => {
|
||||
const questions = upsertBackendQuestion(
|
||||
[
|
||||
{
|
||||
requestId: "question-1",
|
||||
sessionId: "session-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
createdAt: 123,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
{
|
||||
session_id: "session-1",
|
||||
request_id: "call-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
created_at: 456,
|
||||
},
|
||||
);
|
||||
|
||||
expect(questions).toHaveLength(1);
|
||||
expect(questions[0]).toMatchObject({
|
||||
requestId: "question-1",
|
||||
tool: { callID: "call-1" },
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelBackendTodos", () => {
|
||||
it("marks pending and in-progress todos as cancelled", () => {
|
||||
const cancelled = cancelBackendTodos([
|
||||
{
|
||||
sessionId: "session-1",
|
||||
todos: [
|
||||
{ id: "todo-1", content: "分析水位", status: "in_progress" },
|
||||
{ id: "todo-2", content: "生成建议", status: "pending" },
|
||||
{ id: "todo-3", content: "完成报告", status: "completed" },
|
||||
],
|
||||
createdAt: 123,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(cancelled).toEqual([
|
||||
expect.objectContaining({
|
||||
todos: [
|
||||
expect.objectContaining({
|
||||
id: "todo-1",
|
||||
status: "cancelled",
|
||||
updatedAt: expect.any(Number),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "todo-2",
|
||||
status: "cancelled",
|
||||
updatedAt: expect.any(Number),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "todo-3",
|
||||
status: "completed",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
|
||||
|
||||
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
|
||||
const createRuntimeAdapter = (
|
||||
messages: unknown[],
|
||||
calls: {
|
||||
reverted: string[];
|
||||
removed: string[];
|
||||
} = { reverted: [], removed: [] },
|
||||
) =>
|
||||
Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
messages: async () => messages,
|
||||
revertMessage: async (_sessionId: string, messageId: string) => {
|
||||
calls.reverted.push(messageId);
|
||||
},
|
||||
removeMessage: async (_sessionId: string, messageId: string) => {
|
||||
calls.removed.push(messageId);
|
||||
},
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
describe("OpencodeRuntimeAdapter.revertToUserMessage", () => {
|
||||
it("skips reverting the first user message when the runtime session is empty", async () => {
|
||||
const calls = { reverted: [] as string[], removed: [] as string[] };
|
||||
const runtime = createRuntimeAdapter([], calls);
|
||||
|
||||
await runtime.revertToUserMessage("session-1", { userOrdinal: 1 });
|
||||
|
||||
expect(calls).toEqual({ reverted: [], removed: [] });
|
||||
});
|
||||
|
||||
it("keeps ordinal mismatches visible when runtime messages exist", async () => {
|
||||
const runtime = createRuntimeAdapter([
|
||||
{ info: { id: "user-1", role: "user" } },
|
||||
{ info: { id: "assistant-1", role: "assistant" } },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
runtime.revertToUserMessage("session-1", { userOrdinal: 2 }),
|
||||
).rejects.toThrow("target user message not found to revert");
|
||||
});
|
||||
|
||||
it("reverts and removes messages from the target user message onward", async () => {
|
||||
const calls = { reverted: [] as string[], removed: [] as string[] };
|
||||
const runtime = createRuntimeAdapter(
|
||||
[
|
||||
{ info: { id: "user-1", role: "user" } },
|
||||
{ info: { id: "assistant-1", role: "assistant" } },
|
||||
{ info: { id: "user-2", role: "user" } },
|
||||
{ info: { id: "assistant-2", role: "assistant" } },
|
||||
],
|
||||
calls,
|
||||
);
|
||||
|
||||
await runtime.revertToUserMessage("session-1", { userOrdinal: 2 });
|
||||
|
||||
expect(calls).toEqual({
|
||||
reverted: ["user-2"],
|
||||
removed: ["assistant-2", "user-2"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
||||
it("retries bootstrap after a failed startup attempt", async () => {
|
||||
let attempts = 0;
|
||||
const client = {
|
||||
global: { health: async () => ({ data: { healthy: true } }) },
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
clientPromise: null,
|
||||
closeServer: null,
|
||||
bootstrapClient: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("startup failed");
|
||||
}
|
||||
return client;
|
||||
},
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
await expect(runtime.ensureClient()).rejects.toThrow("startup failed");
|
||||
await expect(runtime.ensureClient()).resolves.toBe(client);
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
removeRuntimeSessionContext,
|
||||
setRuntimeSessionContext,
|
||||
} from "../../src/runtime/sessionContext.js";
|
||||
|
||||
describe("runtime session context", () => {
|
||||
it("stores local context in process memory", () => {
|
||||
setRuntimeSessionContext({
|
||||
actorKey: "actor-1",
|
||||
allowLearningWrite: true,
|
||||
clientSessionId: "chat-session-1",
|
||||
learningMode: "interactive",
|
||||
network: "fengyang",
|
||||
projectId: "project-id-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "runtime-session-1",
|
||||
traceId: "trace-1",
|
||||
});
|
||||
|
||||
const runtimeContext = getRuntimeSessionContext("runtime-session-1");
|
||||
|
||||
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
|
||||
expect(runtimeContext?.network).toBe("fengyang");
|
||||
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
|
||||
|
||||
removeRuntimeSessionContext("runtime-session-1");
|
||||
expect(getRuntimeSessionContext("runtime-session-1")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
|
||||
describe("SessionMetadataStore", () => {
|
||||
let tempDir: string;
|
||||
let store: SessionMetadataStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-session-"));
|
||||
store = new SessionMetadataStore(tempDir);
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("persists the provided opencode session id", async () => {
|
||||
const { record, created } = await store.ensure({
|
||||
actorKey: "actor-1",
|
||||
projectId: "project-1",
|
||||
projectKey: "project-key-1",
|
||||
sessionId: "opencode-session-1",
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
expect(created).toBe(true);
|
||||
expect(record.sessionId).toBe("opencode-session-1");
|
||||
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?.title).toBe("新标题");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
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 { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js";
|
||||
|
||||
describe("SessionTranscriptStore", () => {
|
||||
let tempDir: string;
|
||||
let store: SessionTranscriptStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-transcript-"));
|
||||
store = new SessionTranscriptStore(tempDir);
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
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("第一轮回复");
|
||||
});
|
||||
|
||||
it("does not duplicate the latest turn when the frontend state is saved again", async () => {
|
||||
await store.appendTurn(
|
||||
{
|
||||
actorKey: "actor-3",
|
||||
clientSessionId: "thread-3",
|
||||
projectKey: "project-3",
|
||||
sessionId: "thread-3",
|
||||
},
|
||||
{
|
||||
assistantMessage: "已完成压力波动分析。",
|
||||
toolCallCount: 1,
|
||||
userMessage: "分析压力波动。",
|
||||
},
|
||||
);
|
||||
|
||||
const transcript = await store.appendTurn(
|
||||
{
|
||||
actorKey: "actor-3",
|
||||
clientSessionId: "thread-3",
|
||||
projectKey: "project-3",
|
||||
sessionId: "thread-3",
|
||||
},
|
||||
{
|
||||
assistantMessage: "已完成压力波动分析。",
|
||||
toolCallCount: 2,
|
||||
userMessage: "分析压力波动。",
|
||||
},
|
||||
);
|
||||
|
||||
expect(transcript.turns).toHaveLength(1);
|
||||
expect(transcript.turns[0]?.toolCallCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { SkillStore } from "../../src/skills/store.js";
|
||||
|
||||
describe("SkillStore", () => {
|
||||
let originalCwd: string;
|
||||
let tempDir: string;
|
||||
let alternateCwd: string;
|
||||
let skillsRoot: string;
|
||||
let backupRoot: string;
|
||||
let store: SkillStore;
|
||||
|
||||
const skillDocument = (name: string, body: string) =>
|
||||
[
|
||||
"---",
|
||||
`name: ${name}`,
|
||||
`description: ${name} workflow.`,
|
||||
"---",
|
||||
"",
|
||||
body,
|
||||
].join("\n");
|
||||
|
||||
beforeEach(async () => {
|
||||
originalCwd = process.cwd();
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-skills-"));
|
||||
alternateCwd = join(tempDir, "runtime-cwd");
|
||||
skillsRoot = join(tempDir, "project", ".opencode", "skills");
|
||||
backupRoot = join(tempDir, "backup", "skills");
|
||||
store = new SkillStore(skillsRoot, backupRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(originalCwd);
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("writes scripts under the configured skills root regardless of process cwd", async () => {
|
||||
await mkdir(alternateCwd, { recursive: true });
|
||||
process.chdir(alternateCwd);
|
||||
|
||||
const result = await store.writeScript(
|
||||
"workflow/hydraulic-bottleneck-analysis",
|
||||
"scripts/analyze.py",
|
||||
"print('ok')\n",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
changed: true,
|
||||
detail: "script written",
|
||||
target: join(
|
||||
skillsRoot,
|
||||
"workflow",
|
||||
"hydraulic-bottleneck-analysis",
|
||||
"scripts",
|
||||
"analyze.py",
|
||||
),
|
||||
});
|
||||
await expect(readFile(result.target, "utf8")).resolves.toBe("print('ok')\n");
|
||||
});
|
||||
|
||||
it("rejects script paths outside scripts/*.py", async () => {
|
||||
const result = await store.writeScript(
|
||||
"workflow/hydraulic-bottleneck-analysis",
|
||||
"analyze.ts",
|
||||
"console.log('ok')\n",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
changed: false,
|
||||
detail: "invalid script file_path",
|
||||
target: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes and overwrites the main skill file", async () => {
|
||||
const skillPath = "workflow/pressure-review";
|
||||
const writeResult = await store.writeSkill(
|
||||
skillPath,
|
||||
skillDocument("pressure-review", "# Pressure Review"),
|
||||
);
|
||||
|
||||
expect(writeResult).toEqual({
|
||||
changed: true,
|
||||
detail: "skill written",
|
||||
target: join(skillsRoot, "workflow", "pressure-review", "SKILL.md"),
|
||||
});
|
||||
|
||||
const overwriteResult = await store.writeSkill(
|
||||
skillPath,
|
||||
skillDocument("pressure-review", "# Updated Pressure Review"),
|
||||
);
|
||||
|
||||
expect(overwriteResult).toEqual({
|
||||
changed: true,
|
||||
detail: "skill written",
|
||||
target: writeResult.target,
|
||||
});
|
||||
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
|
||||
"# Updated Pressure Review\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes the root skills index via the reserved alias", async () => {
|
||||
const result = await store.writeSkill(
|
||||
"__root__",
|
||||
[
|
||||
"---",
|
||||
"name: skills",
|
||||
"description: TJWater Skills root index.",
|
||||
"---",
|
||||
"",
|
||||
"# TJWater Skills",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
changed: true,
|
||||
detail: "skill written",
|
||||
target: join(skillsRoot, "SKILL.md"),
|
||||
});
|
||||
await expect(readFile(result.target, "utf8")).resolves.toContain(
|
||||
"# TJWater Skills\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the main skill file", async () => {
|
||||
const writeResult = await store.writeSkill(
|
||||
"workflow/remove-me",
|
||||
skillDocument("remove-me", "# Remove Me"),
|
||||
);
|
||||
const removeResult = await store.removeSkill("workflow/remove-me");
|
||||
|
||||
expect(removeResult).toEqual({
|
||||
changed: true,
|
||||
detail: "skill removed",
|
||||
target: writeResult.target,
|
||||
});
|
||||
await expect(readFile(writeResult.target, "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects sensitive skill content", async () => {
|
||||
const result = await store.writeSkill(
|
||||
"workflow/unsafe",
|
||||
"access_token=secret-value",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
changed: false,
|
||||
detail: "skill content rejected by persistence policy",
|
||||
target: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects skill content without required frontmatter", async () => {
|
||||
const result = await store.writeSkill("workflow/incomplete", "# Incomplete");
|
||||
|
||||
expect(result).toEqual({
|
||||
changed: false,
|
||||
detail: "skill content rejected: expected SKILL.md frontmatter with name and description",
|
||||
target: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { toUiEnvelopeFromToolCall } from "../../src/uiEnvelope/fromToolCall.js";
|
||||
|
||||
describe("toUiEnvelopeFromToolCall", () => {
|
||||
it("maps show_chart calls to chart envelopes", () => {
|
||||
const envelope = toUiEnvelopeFromToolCall({
|
||||
tool: "show_chart",
|
||||
reason: "展示压力趋势",
|
||||
params: {
|
||||
title: "压力趋势",
|
||||
chart_type: "line",
|
||||
x_data: ["00:00", "01:00"],
|
||||
series: [{ name: "P-1", data: [0.41, 0.43] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope).toMatchObject({
|
||||
kind: "chart",
|
||||
schemaVersion: "agent-ui@1",
|
||||
grammar: "echarts-safe-subset",
|
||||
surface: "chat_inline",
|
||||
fallbackText: "展示压力趋势",
|
||||
data: {
|
||||
x_data: ["00:00", "01:00"],
|
||||
series: [{ name: "P-1", data: [0.41, 0.43] }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects render_junctions without a valid render_ref", () => {
|
||||
expect(
|
||||
toUiEnvelopeFromToolCall({
|
||||
tool: "render_junctions",
|
||||
params: { render_ref: "/tmp/payload.json" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("maps view_scada calls to registered component envelopes", () => {
|
||||
const envelope = toUiEnvelopeFromToolCall({
|
||||
tool: "view_scada",
|
||||
params: { device_id: "D-1" },
|
||||
});
|
||||
|
||||
expect(envelope).toMatchObject({
|
||||
kind: "registered_component",
|
||||
component: "ScadaPanel",
|
||||
surface: "side_panel",
|
||||
props: { device_id: "D-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { atomicWriteFile, readTextFile } from "../../src/utils/fileStore.js";
|
||||
|
||||
describe("fileStore", () => {
|
||||
const originalDateNow = Date.now;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-file-store-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
Date.now = originalDateNow;
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("uses unique temp paths for concurrent writes in the same millisecond", async () => {
|
||||
Date.now = () => 1_801_578_600_000;
|
||||
const path = join(tempDir, "state.json");
|
||||
const values = Array.from({ length: 24 }, (_, index) => `value-${index}`);
|
||||
|
||||
await Promise.all(values.map((value) => atomicWriteFile(path, value)));
|
||||
|
||||
const written = await readTextFile(path);
|
||||
expect(written).not.toBeNull();
|
||||
expect(values).toContain(written as string);
|
||||
expect((await readdir(tempDir)).filter((name) => name.endsWith(".tmp"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user