refactor(agent)!: focus tools on SCADA

This commit is contained in:
2026-07-15 18:30:58 +08:00
parent 8a21908785
commit 155513fdbc
47 changed files with 503 additions and 2910 deletions
-43
View File
@@ -44,47 +44,4 @@ describe("FrontendActionCoordinator", () => {
coordinator.registerSink("s", () => undefined);
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "unknown", params: {} })).toThrow("unknown frontend action");
});
it("requires a valid time range when opening history data", async () => {
const coordinator = new FrontendActionCoordinator();
let emitted: FrontendActionRequest | undefined;
coordinator.registerSink("s", (request) => { emitted = request; });
expect(() => coordinator.request({
sessionId: "s",
toolCallId: "c",
name: "view_history",
params: { feature_infos: [["node-1", "junction"]], data_type: "realtime" },
})).toThrow("invalid frontend action params");
expect(() => coordinator.request({
sessionId: "s",
toolCallId: "c",
name: "view_history",
params: {
feature_infos: [["node-1", "junction"]],
data_type: "realtime",
start_time: "2026-07-14T00:00:00Z",
end_time: "2026-07-14T00:00:00Z",
},
})).toThrow("invalid frontend action params");
const pending = coordinator.request({
sessionId: "s",
toolCallId: "c",
name: "view_history",
params: {
feature_infos: [["node-1", "junction"]],
data_type: "realtime",
start_time: "2026-07-14T00:00:00Z",
end_time: "2026-07-14T01:00:00Z",
},
});
if (!emitted) throw new Error("request was not emitted");
coordinator.submitResult("s", {
version: "frontend-action-result@1",
actionId: emitted.actionId,
status: "succeeded",
output: { component: "HistoryPanel", rendered: true, itemCount: 1 },
completedAt: Date.now(),
});
await expect(pending).resolves.toMatchObject({ status: "succeeded" });
});
});
+2 -2
View File
@@ -24,11 +24,11 @@ describe("MemoryStore", () => {
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.",
content: "SCADA level anomaly checks should compare level and temperature metrics first.",
source: "tool",
});
const second = await store.upsert("workspace", "project-1", {
content: "DMA-2 nightly leakage analysis should compare against adjacent zones first.",
content: "SCADA level anomaly checks should compare level and temperature metrics first.",
source: "tool",
});
@@ -0,0 +1,19 @@
import { describe, expect, it } from "bun:test";
import { readFileSync } from "node:fs";
describe("SCADA opencode tools", () => {
it("documents asset listing and reading query parameters", () => {
const listTool = readFileSync(".opencode/tools/list_scada_assets.ts", "utf8");
const queryTool = readFileSync(".opencode/tools/query_scada_readings.ts", "utf8");
expect(listTool).toContain("无请求参数");
expect(listTool).toContain("assets[].scada_id");
expect(queryTool).toContain("asset_ids");
expect(queryTool).toContain("observed_from");
expect(queryTool).toContain("observed_to");
expect(queryTool).toContain("granularity");
expect(queryTool).toContain("metrics");
expect(queryTool).toContain("半开区间");
expect(queryTool).toContain("不要使用 GeoServer feature id/fid、external_id 或 site_external_id");
});
});
+9 -9
View File
@@ -26,8 +26,8 @@ describe("skill_manager tool", () => {
const skillDocument = (body: string) =>
[
"---",
"name: pressure-review",
"description: Pressure review workflow.",
"name: scada-level-review",
"description: SCADA level review workflow.",
"---",
"",
body,
@@ -64,32 +64,32 @@ describe("skill_manager tool", () => {
await tool.execute(
{
action: "write_skill",
content: skillDocument("# Pressure Review"),
content: skillDocument("# SCADA Level Review"),
reason: "verified reusable workflow",
skill_path: "workflow/pressure-review",
skill_path: "workflow/scada-level-review",
},
toolContext,
) as string,
);
expect(writeResult.decision).toBe("accepted");
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
"# Pressure Review\n",
"# SCADA Level Review\n",
);
const updateResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: skillDocument("# Updated Pressure Review"),
content: skillDocument("# Updated SCADA Level Review"),
reason: "verified reusable workflow overwrite",
skill_path: "workflow/pressure-review",
skill_path: "workflow/scada-level-review",
},
toolContext,
) as string,
);
expect(updateResult.decision).toBe("accepted");
await expect(readFile(updateResult.target, "utf8")).resolves.toContain(
"# Updated Pressure Review\n",
"# Updated SCADA Level Review\n",
);
const removeResult = JSON.parse(
@@ -97,7 +97,7 @@ describe("skill_manager tool", () => {
{
action: "remove_skill",
reason: "workflow is obsolete",
skill_path: "workflow/pressure-review",
skill_path: "workflow/scada-level-review",
},
toolContext,
) as string,
+16 -94
View File
@@ -26,28 +26,26 @@ describe("ResultReferenceResolver", () => {
await rm(tempDir, { force: true, recursive: true });
});
it("stores metadata for render refs and resolves them", async () => {
it("stores metadata for server API result 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",
},
data: [{ asset_id: "asset-1", value: 1.23 }],
meta: { rows: 1 },
},
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
kind: RESULT_REFERENCE_KIND.serverApiPayload,
projectId: "project-1",
projectKey: "project-key-1",
schemaVersion: 1,
sessionId: "session-1",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
source: RESULT_REFERENCE_SOURCE.serverApi,
traceId: "trace-1",
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
expect(record.kind).toBe(RESULT_REFERENCE_KIND.serverApiPayload);
expect(record.schemaVersion).toBe(1);
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
expect(record.source).toBe(RESULT_REFERENCE_SOURCE.serverApi);
const result = await resolver.getFullAuthorized(
record.resultRef,
@@ -58,14 +56,12 @@ describe("ResultReferenceResolver", () => {
);
expect(result).not.toBeNull();
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
expect(result?.kind).toBe(RESULT_REFERENCE_KIND.serverApiPayload);
expect(result?.schema_version).toBe(1);
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated);
expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.serverApi);
expect(result?.data).toEqual({
node_area_map: {
J1: "DMA-1",
J2: "DMA-2",
},
data: [{ asset_id: "asset-1", value: 1.23 }],
meta: { rows: 1 },
});
});
@@ -102,97 +98,23 @@ describe("ResultReferenceResolver", () => {
});
expect(malformed).toBeNull();
const renderRecord = await resolver.register({
const serverRecord = await resolver.register({
actorKey: "actor-2",
clientSessionId: "client-2",
data: {
node_area_map: {
J1: "DMA-1",
},
},
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
data: { data: [{ asset_id: "asset-2" }] },
kind: RESULT_REFERENCE_KIND.serverApiPayload,
projectId: "project-2",
projectKey: "project-key-2",
schemaVersion: 1,
sessionId: "session-2",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
source: RESULT_REFERENCE_SOURCE.serverApi,
traceId: "trace-2",
});
const wrongActor = await resolver.getFullAuthorized(renderRecord.resultRef, {
const wrongActor = await resolver.getFullAuthorized(serverRecord.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",
},
});
});
});
+29 -29
View File
@@ -22,26 +22,26 @@ describe("resolveRequestedStreamSessionId", () => {
expect(
resolveRequestedStreamSessionId({
sessionId: " existing-session ",
promptText: "检查压力异常",
promptText: "检查 SCADA 液位异常",
createClientSessionId,
}),
).toBe("existing-session");
expect(
resolveRequestedStreamSessionId({
promptText: "检查压力异常",
promptText: "检查 SCADA 液位异常",
createClientSessionId,
}),
).toBeUndefined();
});
it("keeps the pressure trend demo on its generated client session id", () => {
it("does not special-case removed SCADA trend demo prompts", () => {
expect(
resolveRequestedStreamSessionId({
promptText: "展示最近压力趋势",
promptText: "展示最近液位趋势",
createClientSessionId: () => "demo-session",
}),
).toBe("demo-session");
).toBeUndefined();
});
});
@@ -92,7 +92,7 @@ describe("generateSessionTitle", () => {
messages: async () => [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "标题:泵站压力异常排查。" }],
parts: [{ type: "text", text: "标题:SCADA 液位异常排查。" }],
},
],
abortSession: async () => undefined,
@@ -100,14 +100,14 @@ describe("generateSessionTitle", () => {
const title = await generateSessionTitle(runtime, {
sessionId: "chat-session",
latestUserMessage: "检查一下三号泵站最近压力波动的原因",
latestAssistantMessage: "三号泵站压力波动主要与夜间阀门开度变化有关。",
latestUserMessage: "检查一下 82c7d04c 资产最近液位波动的原因",
latestAssistantMessage: "该 SCADA 资产液位波动主要与夜间水位快速变化时段重合。",
fallbackTitle: "新对话",
});
expect(title).toBe("泵站压力异常排查");
expect(titlePrompt).toContain("用户:检查一下三号泵站最近压力波动的原因");
expect(titlePrompt).toContain("助手:三号泵站压力波动主要与夜间阀门开度变化有关。");
expect(title).toBe("SCADA 液位异常排查");
expect(titlePrompt).toContain("用户:检查一下 82c7d04c 资产最近液位波动的原因");
expect(titlePrompt).toContain("助手:该 SCADA 资产液位波动主要与夜间水位快速变化时段重合。");
});
});
@@ -124,32 +124,32 @@ describe("buildPromptWithLearningContext", () => {
{
recentTurns: [],
persistedMessages: [
{ role: "user", content: "先分析 3 号泵站夜间压力波动" },
{ role: "user", content: "先分析 82c7d04c 资产夜间液位波动" },
{
role: "assistant",
content: "已定位到夜间阀门开度变化与压力波动时间段重合,下一步准备对比相邻支路。",
content: "已定位到夜间液位变化与读数异常时段重合,下一步准备对比温度和液位曲线。",
isError: true,
},
{ role: "assistant", content: "⚠️ **请求已中断**", isError: true },
],
message: "继续刚才的分析,并补充相邻支路影响",
message: "继续刚才的分析,并补充温度指标对照",
},
);
expect(prompt).toContain("用户:先分析 3 号泵站夜间压力波动");
expect(prompt).toContain("用户:先分析 82c7d04c 资产夜间液位波动");
expect(prompt).toContain(
"助手:已定位到夜间阀门开度变化与压力波动时间段重合,下一步准备对比相邻支路。",
"助手:已定位到夜间液位变化与读数异常时段重合,下一步准备对比温度和液位曲线。",
);
expect(prompt).not.toContain("⚠️ **请求已中断**");
expect(prompt).toContain("[Current user request]\n继续刚才的分析,并补充相邻支路影响");
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 出现持续最小夜流抬升。",
userMessage: "检查 82c7d04c 资产夜间液位异常",
assistantMessage: "82c7d04c 在 02:00-04:00 出现持续液位抬升。",
timestamp: new Date().toISOString(),
toolCallCount: 1,
},
@@ -161,12 +161,12 @@ describe("buildPromptWithLearningContext", () => {
"project-1",
{
recentTurns,
message: "继续给出查建议",
message: "继续给出数据核查建议",
},
);
expect(prompt).toContain("用户:检查 DMA-2 夜间漏损异常");
expect(prompt).toContain("助手:DMA-2 在 02:00-04:00 出现持续最小夜流抬升。");
expect(prompt).toContain("用户:检查 82c7d04c 资产夜间液位异常");
expect(prompt).toContain("助手:82c7d04c 在 02:00-04:00 出现持续液位抬升。");
});
it("skips restored conversation injection when reusing an existing opencode session", async () => {
@@ -216,20 +216,20 @@ describe("buildPromptWithLearningContext", () => {
describe("extractLatestFrontendTurn", () => {
it("extracts the latest valid frontend user and assistant turn", () => {
const turn = extractLatestFrontendTurn([
{ role: "user", content: "检查 DMA-2 漏损" },
{ role: "user", content: "检查 82c7d04c 液位异常" },
{
role: "assistant",
content: "DMA-2 夜间最小流量持续抬升。",
content: "82c7d04c 夜间液位持续抬升。",
progress: [{ id: "tool-dma", phase: "tool" }],
},
{ role: "user", content: "继续分析相邻分区" },
{ role: "user", content: "继续分析同类资产" },
{ role: "assistant", content: "⚠️ **请求已中断**", isError: true },
]);
expect(turn).toEqual({
assistantMessage: "DMA-2 夜间最小流量持续抬升。",
assistantMessage: "82c7d04c 夜间液位持续抬升。",
toolCallCount: 1,
userMessage: "检查 DMA-2 漏损",
userMessage: "检查 82c7d04c 液位异常",
});
});
});
@@ -239,7 +239,7 @@ describe("buildForkedSessionUiState", () => {
const forked = buildForkedSessionUiState(
{
messages: [
{ role: "user", content: "画压力曲线" },
{ role: "user", content: "画液位曲线" },
{
role: "assistant",
content: "已生成图表",
@@ -265,7 +265,7 @@ describe("buildForkedSessionUiState", () => {
sessionId: "forked-session",
isTitleManuallyEdited: false,
messages: [
{ role: "user", content: "画压力曲线" },
{ role: "user", content: "画液位曲线" },
{
role: "assistant",
content: "已生成图表",
+23 -23
View File
@@ -20,12 +20,12 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("streamPromptResponse", () => {
it("does not split short Chinese text only because it has punctuation", () => {
expect(detectTokenSmoothingChunk("管网压力异常,需要继续分析", "zh")).toBeNull();
expect(detectTokenSmoothingChunk("管网压力异常需要继续分析。", "zh")).toBeNull();
expect(detectTokenSmoothingChunk("SCADA 液位异常,需要继续分析", "zh")).toBeNull();
expect(detectTokenSmoothingChunk("SCADA 液位异常需要继续分析。", "zh")).toBeNull();
});
it("does not immediately release a single Chinese character", () => {
expect(detectTokenSmoothingChunk("", "zh")).toBeNull();
expect(detectTokenSmoothingChunk("", "zh")).toBeNull();
});
it("keeps non-Chinese text on the word path even with zh locale", () => {
@@ -33,7 +33,7 @@ describe("streamPromptResponse", () => {
});
it("falls back to bounded Chinese chunks when punctuation is absent", () => {
const content = "管网压力异常需要继续分析东部主干供水走廊和相关阀门状态变化";
const content = "SCADA 液位异常需要继续分析水质温度和液位读数变化";
const chunk = detectTokenSmoothingChunk(content, "zh");
expect(chunk).not.toBeNull();
@@ -72,11 +72,11 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
smoother.writeToken("");
smoother.writeToken("");
await sleep(8);
expect(events).toHaveLength(0);
const content = "网压力异常需要继续分析东部主干供水走廊和相关阀门状态";
const content = "异常需要继续分析水质温度和液位读数持续变化趋势";
for (const char of Array.from(content)) {
smoother.writeToken(char);
}
@@ -84,7 +84,7 @@ describe("streamPromptResponse", () => {
expect(events.length).toBeGreaterThan(0);
await smoother.flush();
expect(events.map((item) => item.data.content).join("")).toBe(`${content}`);
expect(events.map((item) => item.data.content).join("")).toBe(`${content}`);
});
it("smooths long Chinese text without waiting for paragraph-sized buffers", async () => {
@@ -97,7 +97,7 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
const content = "管网压力异常需要继续分析东部主干供水走廊和相关阀门状态变化";
const content = "SCADA 液位异常需要继续分析水质温度和液位读数变化";
for (const char of Array.from(content)) {
smoother.writeToken(char);
}
@@ -133,7 +133,7 @@ describe("streamPromptResponse", () => {
sessionID: "runtime-session-1",
partID: "text-part-1",
field: "text",
delta: "管网压力异常需要继续分析",
delta: "SCADA 液位异常需要继续分析",
},
},
{
@@ -142,7 +142,7 @@ describe("streamPromptResponse", () => {
sessionID: "runtime-session-1",
partID: "text-part-1",
field: "text",
delta: "东部主干供水走廊和相关阀门状态。",
delta: "水质温度和液位读数。",
},
},
{
@@ -172,7 +172,7 @@ describe("streamPromptResponse", () => {
expect(tokenEvents.length).toBeGreaterThan(0);
expect(tokenEvents.every((item) => events.indexOf(item) < doneIndex)).toBe(true);
expect(tokenEvents.map((item) => item.data.content).join("")).toBe(
"管网压力异常需要继续分析东部主干供水走廊和相关阀门状态。",
"SCADA 液位异常需要继续分析水质温度和液位读数。",
);
});
@@ -535,7 +535,7 @@ describe("streamPromptResponse", () => {
});
});
it("maps visual tool calls to UIEnvelope SSE payloads", async () => {
it("maps zoom_to_map tool calls to UIEnvelope SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
@@ -544,20 +544,19 @@ describe("streamPromptResponse", () => {
properties: {
sessionID: "runtime-session-1",
part: {
id: "tool-part-chart",
id: "tool-part-map",
sessionID: "runtime-session-1",
messageID: "message-1",
type: "tool",
callID: "call-chart",
tool: "show_chart",
callID: "call-map",
tool: "zoom_to_map",
state: {
status: "running",
input: {
reason: "展示压力趋势",
title: "压力趋势",
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-1", data: [0.4, 0.42] }],
reason: "定位 SCADA 资产附近位置",
x: 121.47,
y: 31.23,
source_crs: "EPSG:4326",
},
time: { start: Date.now() },
},
@@ -587,14 +586,15 @@ describe("streamPromptResponse", () => {
expect(events.find((item) => item.event === "tool_call")?.data).toMatchObject({
session_id: "client-session-1",
tool: "show_chart",
tool: "zoom_to_map",
});
expect(events.find((item) => item.event === "ui_envelope")?.data).toMatchObject({
session_id: "client-session-1",
envelope: {
kind: "chart",
kind: "map_action",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
action: "zoom_to_map",
surface: "map_overlay",
},
});
});
+8 -8
View File
@@ -8,13 +8,13 @@ import {
} from "../../src/routes/chatUiState.js";
describe("appendBackendToolArtifact", () => {
it("persists show_chart tool calls as chart artifacts", () => {
it("persists removed visual tool calls as generic tool artifacts", () => {
const artifacts = appendBackendToolArtifact([], {
session_id: "session-1",
tool: "show_chart",
reason: "测试折线图渲染",
params: {
title: "压力曲线",
title: "液位曲线",
chart_type: "line",
x_data: ["00:00", "01:00"],
series: [{ name: "P-101", data: [0.42, 0.41] }],
@@ -24,8 +24,8 @@ describe("appendBackendToolArtifact", () => {
expect(artifacts).toHaveLength(1);
expect(artifacts[0]).toMatchObject({
tool: "show_chart",
kind: "chart",
title: "压力曲线",
kind: "tool",
title: "液位曲线",
description: "测试折线图渲染",
params: {
chart_type: "line",
@@ -50,9 +50,9 @@ describe("appendBackendUiEnvelope", () => {
envelope: {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: "locate_features",
action: "zoom_to_map",
surface: "map_overlay",
params: { ids: ["J-1"], feature_type: "junction" },
params: { x: 121.47, y: 31.23, source_crs: "EPSG:4326" },
},
});
@@ -63,9 +63,9 @@ describe("appendBackendUiEnvelope", () => {
envelope: {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: "locate_features",
action: "zoom_to_map",
surface: "map_overlay",
params: { ids: ["J-1"], feature_type: "junction" },
params: { x: 121.47, y: 31.23, source_crs: "EPSG:4326" },
},
renderStatus: "pending",
},
-38
View File
@@ -1,38 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
createPressureTrendDemoEnvelopePayload,
createPressureTrendDemoToolCall,
isPressureTrendDemoPrompt,
} from "../../src/routes/pressureTrendDemo.js";
describe("pressure trend demo", () => {
it("matches the controlled demo prompt", () => {
expect(isPressureTrendDemoPrompt("展示最近压力趋势")).toBe(true);
expect(isPressureTrendDemoPrompt("请 展示 最近 压力 趋势")).toBe(true);
expect(isPressureTrendDemoPrompt("展示最近流量趋势")).toBe(false);
});
it("creates a show_chart tool call that maps to a chart UIEnvelope", () => {
const toolCall = createPressureTrendDemoToolCall();
const payload = createPressureTrendDemoEnvelopePayload("client-session-1", toolCall);
expect(toolCall.tool).toBe("show_chart");
expect(payload).toMatchObject({
session_id: "client-session-1",
envelope: {
kind: "chart",
schemaVersion: "agent-ui@1",
grammar: "echarts-safe-subset",
surface: "chat_inline",
spec: {
title: "最近压力趋势",
chart_type: "line",
},
data: {
x_data: ["10:00", "11:00", "12:00", "13:00", "14:00", "15:00"],
},
},
});
});
});
+14 -14
View File
@@ -16,38 +16,38 @@ describe("ServerApiGateway project context", () => {
let requestedPath = "";
const fetchImpl = (async (input: URL | RequestInfo) => {
requestedPath = new URL(input.toString()).pathname;
return Response.json({
project_id: context.projectId,
code: "lingang",
name: "临港排水",
description: null,
status: "active",
project_role: "owner",
gs_workspace: "lingang",
map_extent: { xmin: 120.0, ymin: 30.0, xmax: 122.0, ymax: 32.0 },
});
return Response.json([{
project_id: context.projectId,
code: "lingang",
name: "SCADA 物联网监测",
description: null,
status: "active",
project_role: "owner",
gs_workspace: "lingang",
map_extent: { xmin: 120.0, ymin: 30.0, xmax: 122.0, ymax: 32.0 },
}]);
}) as typeof fetch;
const gateway = new ServerApiGateway({} as never, fetchImpl);
const result = await gateway.execute("get_project_context", {}, context);
expect(requestedPath).toBe("/api/v1/meta/project");
expect(requestedPath).toBe("/api/v1/projects");
expect(result).toMatchObject({
ok: true,
data: { project_id: context.projectId, status: "active", project_role: "owner" },
});
});
test("rejects the removed id response shape", async () => {
test("does not select the removed id response shape", async () => {
const fetchImpl = (async () =>
Response.json({ id: context.projectId, code: "lingang", name: "临港排水" })) as unknown as typeof fetch;
Response.json([{ id: context.projectId, code: "lingang", name: "SCADA 物联网监测" }])) as unknown as typeof fetch;
const gateway = new ServerApiGateway({} as never, fetchImpl);
const result = await gateway.execute("get_project_context", {}, context);
expect(result).toMatchObject({
ok: false,
error: { code: "UPSTREAM_UNAVAILABLE" },
error: { code: "NOT_FOUND" },
});
});
});
+8 -15
View File
@@ -1,24 +1,17 @@
import { describe, expect, test } from "bun:test";
import { domainToolSchemas } from "../../src/serverApi/schemas.js";
import { createIdempotencyKey } from "../../src/serverApi/gateway.js";
const context = {
actorKey: "user", clientSessionId: "session", projectId: "1c60fd8c-89fb-47ca-84f1-66ec10f5bf73",
projectKey: "project", sessionId: "runtime", traceId: "trace",
};
describe("domain tool validation", () => {
test("rejects unknown metrics and reversed time", () => {
const base = { device_ids: ["940468c1-87cc-478f-b9f5-537ffbdb9025"], observed_from: "2026-07-14T02:00:00Z", observed_to: "2026-07-14T01:00:00Z", granularity: "5m" };
expect(domainToolSchemas.query_monitoring_readings.safeParse({ ...base, metrics: ["pressure"] }).success).toBeFalse();
expect(domainToolSchemas.query_monitoring_readings.safeParse({ ...base, metrics: ["flow_mean"] }).success).toBeFalse();
const base = { asset_ids: ["940468c1-87cc-478f-b9f5-537ffbdb9025"], observed_from: "2026-07-14T02:00:00Z", observed_to: "2026-07-14T01:00:00Z", granularity: "5m" };
expect(domainToolSchemas.query_scada_readings.safeParse({ ...base, metrics: ["ph_mean"] }).success).toBeFalse();
expect(domainToolSchemas.query_scada_readings.safeParse({ ...base, metrics: ["flow_mean"] }).success).toBeFalse();
});
test("rejects invalid UUID before dispatch", () => {
expect(domainToolSchemas.start_simulation.safeParse({ model_version_id: "bad", parameters: {} }).success).toBeFalse();
});
test("idempotency key ignores object key order", () => {
expect(createIdempotencyKey(context, "start_simulation", { b: 2, a: 1 })).toBe(createIdempotencyKey(context, "start_simulation", { a: 1, b: 2 }));
test("accepts four current metrics and rejects removed metrics", () => {
const base = { asset_ids: ["940468c1-87cc-478f-b9f5-537ffbdb9025"], observed_from: "2026-07-14T01:00:00Z", observed_to: "2026-07-14T02:00:00Z", granularity: "5m" };
expect(domainToolSchemas.query_scada_readings.safeParse({ ...base, metrics: ["conductivity_mean", "level_mean", "flow_mean", "temperature_mean"] }).success).toBeTrue();
expect(domainToolSchemas.query_scada_readings.safeParse({ ...base, metrics: ["flow_total"] }).success).toBeFalse();
expect(domainToolSchemas.query_scada_readings.safeParse({ ...base, metrics: ["velocity_mean"] }).success).toBeFalse();
});
});
+4 -4
View File
@@ -88,9 +88,9 @@ describe("SessionTranscriptStore", () => {
sessionId: "thread-3",
},
{
assistantMessage: "已完成压力波动分析。",
assistantMessage: "已完成液位波动分析。",
toolCallCount: 1,
userMessage: "分析压力波动。",
userMessage: "分析液位波动。",
},
);
@@ -102,9 +102,9 @@ describe("SessionTranscriptStore", () => {
sessionId: "thread-3",
},
{
assistantMessage: "已完成压力波动分析。",
assistantMessage: "已完成液位波动分析。",
toolCallCount: 2,
userMessage: "分析压力波动。",
userMessage: "分析液位波动。",
},
);
+8 -8
View File
@@ -42,7 +42,7 @@ describe("SkillStore", () => {
process.chdir(alternateCwd);
const result = await store.writeScript(
"workflow/hydraulic-bottleneck-analysis",
"workflow/scada-level-review",
"scripts/analyze.py",
"print('ok')\n",
);
@@ -53,7 +53,7 @@ describe("SkillStore", () => {
target: join(
skillsRoot,
"workflow",
"hydraulic-bottleneck-analysis",
"scada-level-review",
"scripts",
"analyze.py",
),
@@ -63,7 +63,7 @@ describe("SkillStore", () => {
it("rejects script paths outside scripts/*.py", async () => {
const result = await store.writeScript(
"workflow/hydraulic-bottleneck-analysis",
"workflow/scada-level-review",
"analyze.ts",
"console.log('ok')\n",
);
@@ -76,21 +76,21 @@ describe("SkillStore", () => {
});
it("writes and overwrites the main skill file", async () => {
const skillPath = "workflow/pressure-review";
const skillPath = "workflow/scada-level-review";
const writeResult = await store.writeSkill(
skillPath,
skillDocument("pressure-review", "# Pressure Review"),
skillDocument("scada-level-review", "# SCADA Level Review"),
);
expect(writeResult).toEqual({
changed: true,
detail: "skill written",
target: join(skillsRoot, "workflow", "pressure-review", "SKILL.md"),
target: join(skillsRoot, "workflow", "scada-level-review", "SKILL.md"),
});
const overwriteResult = await store.writeSkill(
skillPath,
skillDocument("pressure-review", "# Updated Pressure Review"),
skillDocument("scada-level-review", "# Updated SCADA Level Review"),
);
expect(overwriteResult).toEqual({
@@ -99,7 +99,7 @@ describe("SkillStore", () => {
target: writeResult.target,
});
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
"# Updated Pressure Review\n",
"# Updated SCADA Level Review\n",
);
});
+12 -39
View File
@@ -3,51 +3,24 @@ import { describe, expect, it } from "bun:test";
import { toUiEnvelopeFromToolCall } from "../../src/uiEnvelope/fromToolCall.js";
describe("toUiEnvelopeFromToolCall", () => {
it("maps show_chart calls to chart envelopes", () => {
it("maps zoom_to_map calls to map action 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] }],
},
tool: "zoom_to_map",
reason: "定位 SCADA 资产附近位置",
params: { x: 121.47, y: 31.23, source_crs: "EPSG:4326" },
});
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] }],
},
kind: "map_action",
action: "zoom_to_map",
surface: "map_overlay",
params: { x: 121.47, y: 31.23, source_crs: "EPSG:4326" },
fallbackText: "定位 SCADA 资产附近位置",
});
});
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" },
});
it("does not map removed visual tools", () => {
expect(toUiEnvelopeFromToolCall({ tool: "show_chart", params: {} })).toBeNull();
expect(toUiEnvelopeFromToolCall({ tool: "view_scada", params: { asset_ids: ["A-1"] } })).toBeNull();
});
});