chore: disable external and frontend tools

This commit is contained in:
2026-07-20 17:27:46 +08:00
parent c6231f7b00
commit 72019ac1f2
31 changed files with 63 additions and 899 deletions
+17 -22
View File
@@ -4,7 +4,7 @@ mode: primary
model: deepseek/deepseek-v4-flash model: deepseek/deepseek-v4-flash
temperature: 0.2 temperature: 0.2
--- ---
你是 TJWater 实验性 SCADA 数据 Agent。回复用户时使用简体中文,内容简洁准确。 你是 TJWater 实验性排水数据 Agent。回复用户时使用简体中文,内容简洁准确。
## 核心定位 ## 核心定位
@@ -15,53 +15,48 @@ Agent 负责:
1. 理解用户意图。 1. 理解用户意图。
2. 调用受控工具获取有限上下文或表达展示意图。 2. 调用受控工具获取有限上下文或表达展示意图。
3. 输出文字摘要、结论、追问和建议。 3. 输出文字摘要、结论、追问和建议。
4. 通过受控地图工具表达定位意图 4. 在没有可用前端工具时,仅输出文字摘要、结论和建议
服务端负责把受控地图工具转换成 `agent-ui@1` 的 UIEnvelope;前端只执行可信地图动作 当前不向 Agent 暴露前端动作工具
## 工具选择 ## 工具选择
| 场景 | 工具 | | 场景 | 工具 |
|------|------| |------|------|
| 查询实时公开网页信息 | `web_search` |
| 地址/地点转经纬度 | `geocode` |
| 地图定位 | `zoom_to_map` |
| 历史会话检索 | `session_search` | | 历史会话检索 | `session_search` |
| 用户偏好和项目事实 | `memory_manager` | | 用户偏好和项目事实 | `memory_manager` |
| 可复用流程沉淀 | `skill_manager` | | 可复用流程沉淀 | `skill_manager` |
| 项目背景 | `get_project_context` |
| SCADA 资产 | `list_scada_assets` |
| SCADA 时序读数 | `query_scada_readings`(必须给出 `observed_from``observed_to` |
## UI 约束 ## UI 约束
1. 前端地图工具仅表达定位意图,不返回业务数据 1. 不生成或调用前端动作
2. 每次工具调用必须填写具体 `reason` 2. 每次普通工具调用必须填写具体 `reason`
3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。 3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。
## 执行约束 ## 执行约束
1. 无可用数据时不得编造结论。 1. 无可用数据时不得编造结论。
2. 不要调用 shell、Python 或 CLI 来拼装业务数据流程。 2. 不要自由拼接 shell、Python 或 CLI 业务数据流程。仅可对 `source/` 数据执行已验证 Skill 内明确指定的只读分析脚本。
3. 不要读取或写入 token、password、secret、API key、system prompt。 3. 不要读取或写入 token、password、secret、API key、system prompt。
4. 尽量不用子代理,保持过程可观测。 4. 尽量不用子代理,保持过程可观测。
## 工作流沉淀 ## 工作流沉淀
只有在当前对话流程已经验证有效、可被复用且用户明确需要保存时,才使用 `skill_manager` 沉淀 workflow。实验阶段 workflow 不应记录 CLI、shell pipe 或认证上下文。 只有在当前对话流程已经验证有效、可被复用且用户明确需要保存时,才使用 `skill_manager` 沉淀 workflow。实验阶段 workflow 不应记录自由拼接的 CLI、shell pipe 或认证上下文;可引用 Skill 内已验证的固定只读分析脚本
## 记忆持久化 ## 记忆持久化
`memory_manager` 只保存长期有效的稳定事实或用户偏好。修改 memory 前先 list 当前 scope,再决定 add、replace 或 remove。 `memory_manager` 只保存长期有效的稳定事实或用户偏好。修改 memory 前先 list 当前 scope,再决定 add、replace 或 remove。
## TJWater Server 受控领域工具 ## TJWater Server
仅通过领域工具访问 Server;不得构造 URL、HTTP header、SQL、命令或项目 ID 当前不提供依赖 TJWater Server 的工具。不得自行构造 URL、HTTP header、SQL、命令或项目 ID 访问 Server。监测数据统一从项目 `source/` 目录读取
- 项目背景:`get_project_context` ## 本地数据分析
- SCADA 资产:`list_scada_assets`
- SCADA 时序读数:`query_scada_readings`
查询历史 SCADA 数据时,必须先明确时间区间;不清楚区间时先追问,不得省略或自行扩大范围。需要数值结论时先调用 `query_scada_readings`,传入 `observed_from``observed_to``granularity``metrics``asset_ids`,再用文字摘要说明结果 1. 先读取 `source/catalog.json` 选择数据源,再读取对应数据包的 `manifest.json` 和所需 JSONL
先查询数据再作判断。回答必须说明查询时间范围、粒度和指标 2. 用户明确指定编号或场景时,只使用对应数据包。例如只分析污染源接入时选择 `scenario_code=07`
遇到空数据、拒绝、权限错误、超时或服务不可用时,明确说明事实,不得补全或猜测结果 3. 用户要求雨污混接、雨水混接、地下水入渗或外来水稀释分析,且未指定其他数据源时,默认选择 `scenario_code=01`
工具返回 `result_ref` 时,说明完整结果已安全存储,并使用 preview 中的样本和统计作答 4. 用户未明确场景时,先用 `scenario_code=00` 总览判断匹配场景;除非用户要求综合对比,不得默认混合多个场景数据
5. 使用与任务匹配的 Skill 执行数据准入、计算和结论约束。
6. 只读取与分析数据;不覆盖、删除或回写 `source/` 原始数据。
7. 数值结论必须说明数据集、时间范围、粒度、指标和计算口径。
@@ -1,14 +0,0 @@
import type { Plugin } from "@opencode-ai/plugin";
export const FRONTEND_ACTION_TOOLS = new Set(["zoom_to_map"]);
export const frontendActionCallIdPlugin: Plugin = async () => ({
"tool.execute.before": async (input, output) => {
if (!FRONTEND_ACTION_TOOLS.has(input.tool)) return;
output.args = { ...(output.args ?? {}), __frontend_action_call_id: input.callID };
},
});
export const server = frontendActionCallIdPlugin;
export default frontendActionCallIdPlugin;
-27
View File
@@ -1,27 +0,0 @@
type FrontendActionArgs = Record<string, unknown> & { __frontend_action_call_id?: string; reason?: string };
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export const executeFrontendAction = async (
name: string,
args: FrontendActionArgs,
context: { sessionID: string },
) => {
const callId = args.__frontend_action_call_id;
if (!callId) throw new Error("frontend action bridge did not inject the OpenCode call ID");
const { __frontend_action_call_id: _callId, reason, ...params } = args;
let response: Response;
try {
response = await fetch(`${internalBaseUrl}/internal/frontend-actions/request`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken },
body: JSON.stringify({ session_id: context.sessionID, call_id: callId, name, params, fallback_text: reason }),
});
} catch (error) {
throw new Error(`frontend action bridge unavailable: ${error instanceof Error ? error.message : String(error)}`);
}
const text = await response.text();
if (!response.ok) throw new Error(`frontend action bridge rejected request (${response.status}): ${text}`);
return text;
};
-37
View File
@@ -1,37 +0,0 @@
import { tool } from "@opencode-ai/plugin";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。",
args: {
reason: tool.schema
.string()
.describe("Why geocoding is required for the current user request."),
keyword: tool.schema
.string()
.describe("Address or place name to geocode, such as 北京市人民政府."),
},
async execute(args, context) {
const response = await fetch(`${internalBaseUrl}/internal/tools/geocode`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
keyword: args.keyword,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
-3
View File
@@ -1,3 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { callServerApiTool } from "./server_api_shared.js";
export default tool({ description: "读取当前受控项目的基本信息。", args: {}, execute: (_args, context) => callServerApiTool("get_project_context", {}, context.sessionID) });
-10
View File
@@ -1,10 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { callServerApiTool } from "./server_api_shared.js";
export default tool({
description:
"列出当前项目可查询的 SCADA 资产。无请求参数;返回 assets 数组,包含 assets[].scada_id、external_id、site_external_id、name、kind、active。后续查询读数时必须使用 assets[].scada_id 作为 query_scada_readings.asset_ids;不要使用 GeoServer feature id/fid。",
args: {},
execute: (args, context) =>
callServerApiTool("list_scada_assets", args, context.sessionID),
});
-50
View File
@@ -1,50 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { callServerApiTool } from "./server_api_shared.js";
export default tool({
description:
"查询一个或多个 SCADA 资产在指定时间范围内的时序读数。调用前通常先用 list_scada_assets 获取资产 id;必须给出明确的 observed_from 和 observed_to,不要省略或自行扩大时间范围。",
args: {
asset_ids: tool.schema
.array(tool.schema.string().uuid())
.min(1)
.max(100)
.describe(
"SCADA asset UUID 数组,1 到 100 个。必须使用 list_scada_assets 返回的 assets[].scada_id,不要使用 GeoServer feature id/fid、external_id 或 site_external_id。",
),
observed_from: tool.schema
.string()
.datetime()
.describe(
"查询开始时间,ISO8601 datetime 字符串,必须包含时区,例如 2026-04-10T07:15:00Z 或 2026-04-10T15:15:00+08:00。包含该时刻。",
),
observed_to: tool.schema
.string()
.datetime()
.describe(
"查询结束时间,ISO8601 datetime 字符串,必须包含时区,且必须晚于 observed_from。服务端按半开区间处理:observed_at >= observed_from 且 observed_at < observed_to。",
),
granularity: tool.schema
.enum(["5m", "1h", "1d"])
.default("5m")
.describe(
"读数粒度:5m 为原始 5 分钟读数,1h 为小时聚合,1d 为日聚合。未指定时默认 5m。",
),
metrics: tool.schema
.array(
tool.schema.enum([
"conductivity_mean",
"level_mean",
"flow_mean",
"temperature_mean",
]),
)
.min(1)
.max(4)
.describe(
"要返回的指标数组,1 到 4 个。允许值:conductivity_mean(电导率均值)、level_mean(液位均值)、flow_mean(流量均值)、temperature_mean(温度均值)。",
),
},
execute: (args, context) =>
callServerApiTool("query_scada_readings", args, context.sessionID),
});
-13
View File
@@ -1,13 +0,0 @@
const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export const callServerApiTool = async (name: string, args: unknown, sessionID: string) => {
const response = await fetch(`${internalBaseUrl}/internal/tools/server-api/${name}`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken },
body: JSON.stringify({ session_id: sessionID, args }),
});
const text = await response.text();
if (!response.ok) throw new Error("TJWater domain tool bridge rejected the request");
return text;
};
-62
View File
@@ -1,62 +0,0 @@
import { tool } from "@opencode-ai/plugin";
const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({
description:
"调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。",
args: {
reason: tool.schema
.string()
.describe("Why web search is required for the current user request."),
query: tool.schema.string().describe("Search query text."),
freshness: tool.schema
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
.optional()
.describe("Optional freshness filter. Defaults to no_limit."),
summary: tool.schema
.boolean()
.optional()
.describe("Whether the backend should include page summaries."),
count: tool.schema
.number()
.int()
.positive()
.optional()
.describe("Optional result count, backend accepts 1 to 50."),
include: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional domains to include."),
exclude: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional domains to exclude."),
},
async execute(args, context) {
const response = await fetch(`${internalBaseUrl}/internal/tools/web-search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-agent-internal-token": internalToken,
},
body: JSON.stringify({
session_id: context.sessionID,
query: args.query,
freshness: args.freshness,
summary: args.summary,
count: args.count,
include: args.include,
exclude: args.exclude,
}),
});
const text = await response.text();
if (!response.ok) {
throw new Error(text);
}
return text;
},
});
-33
View File
@@ -1,33 +0,0 @@
import { tool } from "@opencode-ai/plugin";
import { executeFrontendAction } from "./frontend_action.js";
export default tool({
description:
"在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。",
args: {
reason: tool.schema
.string()
.describe("Why this map zoom action is needed for the current request."),
x: tool.schema
.number()
.describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."),
y: tool.schema
.number()
.describe("Y coordinate. For EPSG:4326 this is latitude; for EPSG:3857 this is meters."),
source_crs: tool.schema
.enum(["EPSG:3857", "EPSG:4326"])
.optional()
.describe("Input coordinate CRS. Defaults to EPSG:3857."),
zoom: tool.schema
.number()
.optional()
.describe("Optional OpenLayers zoom level. Defaults to 18."),
duration_ms: tool.schema
.number()
.optional()
.describe("Optional animation duration in milliseconds. Defaults to 1000."),
},
async execute(args, context) {
return executeFrontendAction("zoom_to_map", args, context);
},
});
+10 -24
View File
@@ -13,8 +13,8 @@ real auth provider through `src/context/`.
- No required `Authorization` or `X-Project-Id` headers. - No required `Authorization` or `X-Project-Id` headers.
- No `cli/`, no `tjwater_cli` opencode tool, no free-form CLI command bridge. - No `cli/`, no `tjwater_cli` opencode tool, no free-form CLI command bridge.
- Agent visual output is expressed through `agent-ui@1` UIEnvelope events. - Agent visual output is expressed through `agent-ui@1` UIEnvelope events.
- Backend business data is available through typed, allowlisted domain tools. - Tools that depend on the TJWater Server are disabled. Arbitrary URLs, SQL,
Arbitrary URLs, SQL, shell commands, and filesystem paths remain unavailable. shell commands, and filesystem paths remain unavailable.
## Main API ## Main API
@@ -51,22 +51,14 @@ Defaults:
```text ```text
AGENT_LOCAL_USER_ID=local-user AGENT_LOCAL_USER_ID=local-user
AGENT_LOCAL_PROJECT_ID=<UUID returned as project_id by /api/v1/projects> AGENT_LOCAL_PROJECT_ID=local-project
AGENT_LOCAL_NETWORK=local-network AGENT_LOCAL_NETWORK=local-network
``` ```
Local domain tools are enabled by default. Set `TJWATER_API_BASE_URL` to the Monitoring time-series data is read from the agent project's `source/`
Server address and `AGENT_LOCAL_PROJECT_ID` to an enabled project UUID. Setting directory and analyzed through validated local skills. `get_project_context`,
`TJWATER_API_ENABLED=false` disables all Server calls. Production startup rejects `list_scada_assets`, `query_scada_readings`, `web_search`, and `geocode` are not
enabled Server tools while `TJWATER_AUTH_MODE=disabled`. exposed because they depend on the TJWater Server.
Available domain tools:
- `get_project_context`
- `list_scada_assets`
- `query_scada_readings` with required `observed_from` and `observed_to`
ISO8601 timestamps. The server rejects reversed or unbounded historical
queries.
Optional request headers can override the local context during experiments: Optional request headers can override the local context during experiments:
@@ -85,15 +77,9 @@ The registry is exposed at:
GET /api/v1/agent/chat/ui-registry GET /api/v1/agent/chat/ui-registry
``` ```
Visual opencode tools are mapped by the service: No visual or frontend-action tools are currently exposed to the Agent. The
registry endpoints remain available for protocol compatibility and return no
- `show_chart` -> `chart` registered charts, components, or actions.
- `zoom_to_map` -> `map_action`
- `view_scada` -> `registered_component`
Use `query_scada_readings` first when the answer needs actual SCADA values.
Use `view_scada` only to ask the frontend to display the selected SCADA asset
panel.
See [docs/agent-ui-protocol.md](docs/agent-ui-protocol.md). See [docs/agent-ui-protocol.md](docs/agent-ui-protocol.md).
+5 -8
View File
@@ -77,15 +77,12 @@ is valid only on `map_overlay`; every action and parameter set must be validated
by the frontend before it can affect MapLibre state. Invalid map actions should by the frontend before it can affect MapLibre state. Invalid map actions should
fall back to `fallbackText`. fall back to `fallbackText`.
Initial mappings: There are currently no Agent tool mappings for charts, components, or map
actions. The protocol types remain documented for compatibility and future
allowlisted actions.
- `show_chart` -> `chart` Historical monitoring values for analysis come from the project `source/`
- `zoom_to_map` -> `map_action` directory.
- `view_scada` -> `registered_component: ScadaPanel`
When the Agent needs historical SCADA values for a chart or explanation, it
should call `query_scada_readings` first, then use `view_scada` only to open the
frontend panel.
## State Persistence ## State Persistence
-3
View File
@@ -29,8 +29,5 @@
"edit": "ask", "edit": "ask",
"task": "deny" "task": "deny"
}, },
"plugin": [
"./.opencode/plugins/frontend-action-call-id.ts"
],
"default_agent": "instruction" "default_agent": "instruction"
} }
-14
View File
@@ -82,13 +82,6 @@ const envSchema = z
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(), OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。 // 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。
OPENCODE_BASE_URL: z.string().url().optional(), OPENCODE_BASE_URL: z.string().url().optional(),
// TJWater 后端 API 的基础地址。
TJWATER_API_BASE_URL: z.string().default("http://127.0.0.1:8000"),
// 本地实验环境默认开放受控领域工具;生产环境仍由下方鉴权校验保护。
TJWATER_API_ENABLED: optionalBoolean(true),
TJWATER_AUTH_MODE: z.enum(["disabled"]).default("disabled"),
// 代理调用 TJWater 后端 API 的超时时间(毫秒)。
TJWATER_API_TIMEOUT_MS: z.coerce.number().int().positive().default(30000),
// 后端结果在直接内联返回给模型前允许的最大字节数。 // 后端结果在直接内联返回给模型前允许的最大字节数。
MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000), MAX_INLINE_RESULT_BYTES: z.coerce.number().int().positive().default(12000),
// 生成结果 preview 时最多抽样的条目数。 // 生成结果 preview 时最多抽样的条目数。
@@ -141,13 +134,6 @@ const envSchema = z
.default(3600000), .default(3600000),
}) })
.superRefine((env, ctx) => { .superRefine((env, ctx) => {
if (env.NODE_ENV === "production" && env.TJWATER_API_ENABLED && env.TJWATER_AUTH_MODE === "disabled") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["TJWATER_AUTH_MODE"],
message: "TJWATER API tools cannot run without authentication in production",
});
}
if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) { if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
+1 -4
View File
@@ -2,10 +2,7 @@ import { z } from "zod";
import type { FrontendActionManifest } from "./types.js"; import type { FrontendActionManifest } from "./types.js";
type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny }; type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny };
const zoomInput = z.object({ x: z.number().finite(), y: z.number().finite(), source_crs: z.enum(["EPSG:3857", "EPSG:4326"]).optional(), zoom: z.number().finite().optional(), duration_ms: z.number().finite().nonnegative().optional() }).strict(); const definitions: Definition[] = [];
const definitions: Definition[] = [
{ manifest: { id: "zoom_to_map", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["map_overlay"] }, inputSchema: zoomInput, outputSchema: z.object({}).passthrough() },
];
export const frontendActionRegistry = definitions.map((item) => item.manifest); export const frontendActionRegistry = definitions.map((item) => item.manifest);
export const getFrontendActionDefinition = (name: string) => definitions.find((item) => item.manifest.id === name); export const getFrontendActionDefinition = (name: string) => definitions.find((item) => item.manifest.id === name);
+1 -1
View File
@@ -830,7 +830,7 @@ export const streamPromptResponse = async ({
params: toolParams, params: toolParams,
reason, reason,
}); });
if (envelope && !(suppressLegacyFrontendActions && part.tool === "zoom_to_map")) { if (envelope) {
write("ui_envelope", { write("ui_envelope", {
session_id: clientSessionId, session_id: clientSessionId,
envelope_id: createEnvelopeId(part.tool), envelope_id: createEnvelopeId(part.tool),
-3
View File
@@ -61,11 +61,8 @@ const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = { const toolLabels: Record<string, string> = {
memory_manager: "记忆写入", memory_manager: "记忆写入",
geocode: "地理编码",
session_search: "历史会话检索", session_search: "历史会话检索",
skill_manager: "流程沉淀", skill_manager: "流程沉淀",
web_search: "网页搜索",
zoom_to_map: "地图缩放",
}; };
export const logDevelopmentDebug = ( export const logDevelopmentDebug = (
-4
View File
@@ -207,9 +207,6 @@ export const updateLastAssistantQuestion = (
}); });
const getToolArtifactKind = (tool: string): ToolArtifactKind => { const getToolArtifactKind = (tool: string): ToolArtifactKind => {
if (tool === "zoom_to_map") {
return "map";
}
return "tool"; return "tool";
}; };
@@ -217,7 +214,6 @@ const getToolArtifactTitle = (tool: string, params: Record<string, unknown>) =>
if (typeof params.title === "string" && params.title.trim()) { if (typeof params.title === "string" && params.title.trim()) {
return params.title.trim(); return params.title.trim();
} }
if (tool === "zoom_to_map") return "缩放到地图坐标";
return tool || "工具调用"; return tool || "工具调用";
}; };
+1 -174
View File
@@ -15,12 +15,7 @@ import { ResultReferenceStore } from "./results/store.js";
import { buildChatRouter } from "./routes/chat.js"; import { buildChatRouter } from "./routes/chat.js";
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js"; import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
import { opencodeRuntime } from "./runtime/opencode.js"; import { opencodeRuntime } from "./runtime/opencode.js";
import { ServerApiGateway } from "./serverApi/gateway.js"; import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
import { isDomainToolName } from "./serverApi/schemas.js";
import {
getRuntimeSessionContext,
type RuntimeSessionContext,
} from "./runtime/sessionContext.js";
const app = express(); const app = express();
@@ -37,7 +32,6 @@ const learningOrchestrator = new LearningOrchestrator(
); );
const resultReferenceStore = new ResultReferenceStore(); const resultReferenceStore = new ResultReferenceStore();
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
const serverApiGateway = new ServerApiGateway(resultReferenceResolver);
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
const frontendActionCoordinator = new FrontendActionCoordinator(); const frontendActionCoordinator = new FrontendActionCoordinator();
@@ -46,25 +40,6 @@ process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors()); app.use(cors());
app.use(express.json({ limit: "1mb" })); app.use(express.json({ limit: "1mb" }));
app.post("/internal/tools/server-api/:tool", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const name = req.params.tool;
if (!isDomainToolName(name)) {
res.status(404).json({ message: "unknown domain tool" });
return;
}
const sessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({ message: "session context not found" });
return;
}
res.json(await serverApiGateway.execute(name, req.body?.args ?? {}, context));
});
app.post("/internal/frontend-actions/request", async (req, res) => { app.post("/internal/frontend-actions/request", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) { if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" }); res.status(403).json({ message: "forbidden" });
@@ -155,154 +130,6 @@ app.post("/internal/tools/session-search", async (req, res) => {
}); });
}); });
const callBackendJson = async (
path: string,
_context: RuntimeSessionContext,
payload: unknown,
) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
const text = await response.text();
return {
ok: response.ok,
status: response.status,
text,
};
} finally {
clearTimeout(timer);
}
};
const parseStringArray = (value: unknown) =>
Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: undefined;
const webSearchFreshnessMap: Record<string, string> = {
no_limit: "noLimit",
one_day: "oneDay",
one_week: "oneWeek",
one_month: "oneMonth",
one_year: "oneYear",
};
const normalizeWebSearchFreshness = (value: unknown) => {
if (typeof value !== "string") {
return undefined;
}
return webSearchFreshnessMap[value] ?? value;
};
app.post("/internal/tools/web-search", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionId,
});
return;
}
const query = typeof req.body?.query === "string" ? req.body.query.trim() : "";
if (!query) {
res.status(400).json({ message: "query is required" });
return;
}
const count =
typeof req.body?.count === "number" && Number.isFinite(req.body.count)
? Math.trunc(req.body.count)
: undefined;
const payload = {
query,
freshness: normalizeWebSearchFreshness(req.body?.freshness),
summary:
typeof req.body?.summary === "boolean" ? req.body.summary : undefined,
count,
include: parseStringArray(req.body?.include),
exclude: parseStringArray(req.body?.exclude),
};
try {
const response = await callBackendJson(
"/api/v1/web-search",
context,
payload,
);
res
.status(response.ok ? 200 : response.status)
.type("application/json")
.send(response.text);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(503).json({
message: "web search service unavailable",
detail,
});
}
});
app.post("/internal/tools/geocode", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionId,
});
return;
}
const keyword =
typeof req.body?.keyword === "string" ? req.body.keyword.trim() : "";
if (!keyword) {
res.status(400).json({ message: "keyword is required" });
return;
}
try {
const response = await callBackendJson(
"/api/v1/geocode",
context,
{ keyword },
);
res
.status(response.ok ? 200 : response.status)
.type("application/json")
.send(response.text);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
res.status(503).json({
message: "geocoding service unavailable",
detail,
});
}
});
app.use( app.use(
"/api/v1/agent/chat", "/api/v1/agent/chat",
buildChatRouter( buildChatRouter(
-96
View File
@@ -1,96 +0,0 @@
import { z } from "zod";
import { config } from "../config.js";
import { ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js";
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
import { domainToolSchemas, type DomainToolName } from "./schemas.js";
export type GatewayErrorCode = "VALIDATION_FAILED" | "NOT_FOUND" | "UPSTREAM_UNAVAILABLE" | "TIMEOUT" | "PROJECT_CONTEXT_INVALID";
type Fetch = typeof fetch;
const responseSchemas: Record<DomainToolName, z.ZodTypeAny> = {
get_project_context: z.object({
project_id: z.string().uuid(),
code: z.string(),
name: z.string(),
description: z.string().nullable(),
status: z.enum(["active", "inactive"]),
project_role: z.string(),
gs_workspace: z.string(),
map_extent: z.object({ xmin: z.number(), ymin: z.number(), xmax: z.number(), ymax: z.number() }).nullable(),
}),
list_scada_assets: z.object({ assets: z.array(z.unknown()) }),
query_scada_readings: z.object({ data: z.array(z.unknown()), meta: z.record(z.unknown()) }),
};
export class ServerApiGateway {
constructor(private readonly resolver: ResultReferenceResolver, private readonly fetchImpl: Fetch = fetch) {}
async execute(name: DomainToolName, rawArgs: unknown, context: RuntimeSessionContext) {
if (!config.TJWATER_API_ENABLED) return failure("UPSTREAM_UNAVAILABLE", "TJWater API tools are disabled");
if (!context.projectId || !z.string().uuid().safeParse(context.projectId).success) {
return failure("PROJECT_CONTEXT_INVALID", "project context is unavailable or invalid");
}
const parsed = domainToolSchemas[name].safeParse(rawArgs);
if (!parsed.success) return failure("VALIDATION_FAILED", "tool parameters are invalid", parsed.error.flatten());
try {
const data = await this.dispatch(name, parsed.data, context);
const validated = responseSchemas[name].safeParse(data);
if (!validated.success) return failure("UPSTREAM_UNAVAILABLE", "upstream returned an invalid response");
const bytes = Buffer.byteLength(JSON.stringify(validated.data));
if (bytes <= config.MAX_INLINE_RESULT_BYTES) return { ok: true, data: validated.data, truncated: false };
const record = await this.resolver.register({
actorKey: context.actorKey, clientSessionId: context.clientSessionId, data: validated.data,
kind: RESULT_REFERENCE_KIND.serverApiPayload, projectId: context.projectId,
projectKey: context.projectKey, schemaVersion: 1, sessionId: context.clientSessionId,
source: RESULT_REFERENCE_SOURCE.serverApi, traceId: context.traceId,
});
return { ok: true, result_ref: record.resultRef, preview: record.preview, result_size_bytes: bytes, truncated: true };
} catch (error) {
if (error instanceof GatewayHttpError) {
if (error.status === 404) return failure("NOT_FOUND", "requested resource was not found");
return failure("UPSTREAM_UNAVAILABLE", `upstream request failed (${error.status})`);
}
if (error instanceof DOMException && error.name === "AbortError") return failure("TIMEOUT", "upstream request timed out");
return failure("UPSTREAM_UNAVAILABLE", "upstream service is unavailable");
}
}
private async dispatch(name: DomainToolName, args: any, context: RuntimeSessionContext): Promise<unknown> {
if (name === "get_project_context") {
const projects = await this.request("GET", "/api/v1/projects", undefined, context);
if (!Array.isArray(projects)) throw new GatewayHttpError(502);
const project = projects.find((item) => (
typeof item === "object"
&& item !== null
&& "project_id" in item
&& item.project_id === context.projectId
));
if (!project) throw new GatewayHttpError(404);
return project;
}
if (name === "list_scada_assets") {
return this.request("GET", "/api/v1/scada/assets", undefined, context);
}
return this.request("POST", "/api/v1/scada/readings/query", args, context);
}
private async request(method: string, path: string, body: unknown, context: RuntimeSessionContext) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
try {
const response = await this.fetchImpl(new URL(path, config.TJWATER_API_BASE_URL), {
method, signal: controller.signal, headers: {
Accept: "application/json", "Content-Type": "application/json", "X-Project-Id": context.projectId!,
"X-Request-Id": context.traceId,
}, ...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) throw new GatewayHttpError(response.status);
return await response.json();
} finally { clearTimeout(timer); }
}
}
class GatewayHttpError extends Error { constructor(readonly status: number) { super("upstream request failed"); } }
const failure = (code: GatewayErrorCode, message: string, detail?: unknown) => ({ ok: false, error: { code, message, ...(detail ? { detail } : {}) } });
-24
View File
@@ -1,24 +0,0 @@
import { z } from "zod";
const uuid = z.string().uuid();
const dateTime = z.string().datetime({ offset: true });
const metrics = z.enum([
"conductivity_mean", "level_mean", "flow_mean", "temperature_mean",
]);
export const domainToolSchemas = {
get_project_context: z.object({}),
list_scada_assets: z.object({}),
query_scada_readings: z.object({
asset_ids: z.array(uuid).min(1).max(100),
observed_from: dateTime,
observed_to: dateTime,
granularity: z.enum(["5m", "1h", "1d"]).default("5m"),
metrics: z.array(metrics).min(1).max(4),
}).refine((value) => Date.parse(value.observed_from) < Date.parse(value.observed_to), {
message: "observed_from must precede observed_to",
}),
} as const;
export type DomainToolName = keyof typeof domainToolSchemas;
export const isDomainToolName = (value: string): value is DomainToolName => value in domainToolSchemas;
+1 -17
View File
@@ -7,20 +7,4 @@ type ToolCallInput = {
}; };
export const toUiEnvelopeFromToolCall = ({ export const toUiEnvelopeFromToolCall = ({
tool, }: ToolCallInput): UIEnvelope | null => null;
params,
reason,
}: ToolCallInput): UIEnvelope | null => {
if (tool === "zoom_to_map") {
return {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: tool,
surface: "map_overlay",
params,
fallbackText: reason || "已生成地图操作。",
};
}
return null;
};
+1 -8
View File
@@ -4,14 +4,7 @@ export const chartGrammar = "echarts-safe-subset" as const;
export const componentRegistry: ComponentManifest[] = []; export const componentRegistry: ComponentManifest[] = [];
export const actionRegistry: ActionManifest[] = [ export const actionRegistry: ActionManifest[] = [];
{
id: "zoom_to_map",
version: "1.0.0",
description: "Zoom the map to a coordinate.",
supportedSurfaces: ["map_overlay"],
},
];
export const uiRegistryResponse = { export const uiRegistryResponse = {
schema_version: "agent-ui-registry@1", schema_version: "agent-ui-registry@1",
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it } from "bun:test";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { FRONTEND_ACTION_TOOLS, frontendActionCallIdPlugin, server } from "../../.opencode/plugins/frontend-action-call-id.js";
import { frontendActionRegistry } from "../../src/frontendAction/registry.js";
const frontendActionToolNames = () => readdirSync(".opencode/tools")
.filter((file) => file.endsWith(".ts"))
.filter((file) => readFileSync(join(".opencode/tools", file), "utf8").includes("executeFrontendAction("))
.map((file) => file.replace(/\.ts$/, ""))
.sort();
describe("frontend action call ID plugin", () => {
it("is registered in the OpenCode project config", () => {
const config = JSON.parse(readFileSync("opencode.json", "utf8")) as { plugin?: unknown[] };
expect(config.plugin).toContain("./.opencode/plugins/frontend-action-call-id.ts");
});
it("exposes the server export required by OpenCode", () => {
expect(server).toBe(frontendActionCallIdPlugin);
});
it("overwrites forged IDs only for allowlisted tools", async () => {
const hooks = await frontendActionCallIdPlugin({} as never);
const before = hooks["tool.execute.before"];
if (!before) throw new Error("hook missing");
const actionOutput = { args: { x: 1, __frontend_action_call_id: "forged" } };
await before({ tool: "zoom_to_map", sessionID: "session", callID: "real" }, actionOutput);
expect(actionOutput.args.__frontend_action_call_id).toBe("real");
const ordinaryOutput = { args: { query: "water" } };
await before({ tool: "web_search", sessionID: "session", callID: "ignored" }, ordinaryOutput);
expect(ordinaryOutput.args).toEqual({ query: "water" });
});
it("covers every tool that uses the frontend action bridge", () => {
const bridgeTools = frontendActionToolNames();
expect([...FRONTEND_ACTION_TOOLS].sort()).toEqual(bridgeTools);
expect(frontendActionRegistry.map((action) => action.id).sort()).toEqual(bridgeTools);
});
});
+3 -38
View File
@@ -1,47 +1,12 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { FrontendActionCoordinator, FrontendActionError } from "../../src/frontendAction/coordinator.js"; import { FrontendActionCoordinator, FrontendActionError } from "../../src/frontendAction/coordinator.js";
import type { FrontendActionRequest } from "../../src/frontendAction/types.js";
const start = (coordinator: FrontendActionCoordinator, sessionId = "session-1") => {
let emitted: FrontendActionRequest | undefined;
coordinator.registerSink(sessionId, (request) => { emitted = request; });
const pending = coordinator.request({ sessionId, toolCallId: "call-1", name: "zoom_to_map", params: { x: 117.2, y: 39.1 } });
if (!emitted) throw new Error("request was not emitted");
return { emitted, pending };
};
describe("FrontendActionCoordinator", () => { describe("FrontendActionCoordinator", () => {
it("emits once and resolves with a validated browser result", async () => { it("rejects frontend actions when the registry is empty", () => {
const coordinator = new FrontendActionCoordinator(); const coordinator = new FrontendActionCoordinator();
const { emitted, pending } = start(coordinator);
const result = { version: "frontend-action-result@1" as const, actionId: emitted.actionId, status: "succeeded" as const, output: { zoom: 14 }, completedAt: Date.now() };
expect(coordinator.submitResult("session-1", result).duplicate).toBe(false);
expect((await pending).output).toEqual({ zoom: 14 });
expect(coordinator.submitResult("session-1", result).duplicate).toBe(true);
});
it("rejects conflicting terminal results and cross-session submissions", async () => {
const coordinator = new FrontendActionCoordinator();
const { emitted, pending } = start(coordinator);
const result = { version: "frontend-action-result@1" as const, actionId: emitted.actionId, status: "succeeded" as const, output: {}, completedAt: Date.now() };
expect(() => coordinator.submitResult("other", result)).toThrow("session mismatch");
coordinator.submitResult("session-1", result);
await pending;
expect(() => coordinator.submitResult("session-1", { ...result, completedAt: result.completedAt + 1 })).toThrow("conflicts");
});
it("cancels all pending actions for a session", async () => {
const coordinator = new FrontendActionCoordinator();
const { pending } = start(coordinator);
coordinator.cancelSession("session-1");
expect((await pending).status).toBe("cancelled");
});
it("validates params and requires an active browser sink", () => {
const coordinator = new FrontendActionCoordinator();
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "zoom_to_map", params: { x: "bad", y: 1 } })).toThrow(FrontendActionError);
coordinator.registerSink("s", () => undefined); coordinator.registerSink("s", () => undefined);
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "unknown", params: {} })).toThrow("unknown frontend action"); expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "zoom_to_map", params: {} })).toThrow(FrontendActionError);
expect(() => coordinator.request({ sessionId: "s", toolCallId: "c", name: "zoom_to_map", params: {} })).toThrow("unknown frontend action");
}); });
}); });
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "bun:test";
import { existsSync } from "node:fs";
const disabledServerTools = [
"geocode",
"get_project_context",
"list_scada_assets",
"query_scada_readings",
"web_search",
];
describe("disabled opencode tools", () => {
it("does not expose Server-dependent or frontend-action tools", () => {
for (const toolName of disabledServerTools) {
expect(existsSync(`.opencode/tools/${toolName}.ts`)).toBeFalse();
}
expect(existsSync(".opencode/tools/server_api_shared.ts")).toBeFalse();
expect(existsSync(".opencode/tools/zoom_to_map.ts")).toBeFalse();
expect(existsSync(".opencode/tools/frontend_action.ts")).toBeFalse();
expect(existsSync(".opencode/plugins/frontend-action-call-id.ts")).toBeFalse();
});
});
@@ -1,19 +0,0 @@
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");
});
});
-63
View File
@@ -535,67 +535,4 @@ describe("streamPromptResponse", () => {
}); });
}); });
it("maps zoom_to_map 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-map",
sessionID: "runtime-session-1",
messageID: "message-1",
type: "tool",
callID: "call-map",
tool: "zoom_to_map",
state: {
status: "running",
input: {
reason: "定位 SCADA 资产附近位置",
x: 121.47,
y: 31.23,
source_crs: "EPSG:4326",
},
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: "zoom_to_map",
});
expect(events.find((item) => item.event === "ui_envelope")?.data).toMatchObject({
session_id: "client-session-1",
envelope: {
kind: "map_action",
schemaVersion: "agent-ui@1",
action: "zoom_to_map",
surface: "map_overlay",
},
});
});
}); });
-53
View File
@@ -1,53 +0,0 @@
import { describe, expect, test } from "bun:test";
import { ServerApiGateway } from "../../src/serverApi/gateway.js";
const context = {
actorKey: "user",
clientSessionId: "session",
projectId: "1c60fd8c-89fb-47ca-84f1-66ec10f5bf73",
projectKey: "project",
sessionId: "runtime",
traceId: "trace",
};
describe("ServerApiGateway project context", () => {
test("uses the metadata path and accepts project_id responses", async () => {
let requestedPath = "";
const fetchImpl = (async (input: URL | RequestInfo) => {
requestedPath = new URL(input.toString()).pathname;
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/projects");
expect(result).toMatchObject({
ok: true,
data: { project_id: context.projectId, status: "active", project_role: "owner" },
});
});
test("does not select the removed id response shape", async () => {
const fetchImpl = (async () =>
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: "NOT_FOUND" },
});
});
});
-17
View File
@@ -1,17 +0,0 @@
import { describe, expect, test } from "bun:test";
import { domainToolSchemas } from "../../src/serverApi/schemas.js";
describe("domain tool validation", () => {
test("rejects unknown metrics and reversed time", () => {
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("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();
});
});
+1 -16
View File
@@ -3,23 +3,8 @@ import { describe, expect, it } from "bun:test";
import { toUiEnvelopeFromToolCall } from "../../src/uiEnvelope/fromToolCall.js"; import { toUiEnvelopeFromToolCall } from "../../src/uiEnvelope/fromToolCall.js";
describe("toUiEnvelopeFromToolCall", () => { describe("toUiEnvelopeFromToolCall", () => {
it("maps zoom_to_map calls to map action envelopes", () => {
const envelope = toUiEnvelopeFromToolCall({
tool: "zoom_to_map",
reason: "定位 SCADA 资产附近位置",
params: { x: 121.47, y: 31.23, source_crs: "EPSG:4326" },
});
expect(envelope).toMatchObject({
kind: "map_action",
action: "zoom_to_map",
surface: "map_overlay",
params: { x: 121.47, y: 31.23, source_crs: "EPSG:4326" },
fallbackText: "定位 SCADA 资产附近位置",
});
});
it("does not map removed visual tools", () => { it("does not map removed visual tools", () => {
expect(toUiEnvelopeFromToolCall({ tool: "zoom_to_map", params: {} })).toBeNull();
expect(toUiEnvelopeFromToolCall({ tool: "show_chart", params: {} })).toBeNull(); expect(toUiEnvelopeFromToolCall({ tool: "show_chart", params: {} })).toBeNull();
expect(toUiEnvelopeFromToolCall({ tool: "view_scada", params: { asset_ids: ["A-1"] } })).toBeNull(); expect(toUiEnvelopeFromToolCall({ tool: "view_scada", params: { asset_ids: ["A-1"] } })).toBeNull();
}); });