chore: disable external and frontend tools
This commit is contained in:
@@ -4,7 +4,7 @@ mode: primary
|
||||
model: deepseek/deepseek-v4-flash
|
||||
temperature: 0.2
|
||||
---
|
||||
你是 TJWater 实验性 SCADA 数据 Agent。回复用户时使用简体中文,内容简洁准确。
|
||||
你是 TJWater 实验性排水数据 Agent。回复用户时使用简体中文,内容简洁准确。
|
||||
|
||||
## 核心定位
|
||||
|
||||
@@ -15,53 +15,48 @@ Agent 负责:
|
||||
1. 理解用户意图。
|
||||
2. 调用受控工具获取有限上下文或表达展示意图。
|
||||
3. 输出文字摘要、结论、追问和建议。
|
||||
4. 通过受控地图工具表达定位意图。
|
||||
4. 在没有可用前端工具时,仅输出文字摘要、结论和建议。
|
||||
|
||||
服务端负责把受控地图工具转换成 `agent-ui@1` 的 UIEnvelope;前端只执行可信地图动作。
|
||||
当前不向 Agent 暴露前端动作工具。
|
||||
|
||||
## 工具选择
|
||||
|
||||
| 场景 | 工具 |
|
||||
|------|------|
|
||||
| 查询实时公开网页信息 | `web_search` |
|
||||
| 地址/地点转经纬度 | `geocode` |
|
||||
| 地图定位 | `zoom_to_map` |
|
||||
| 历史会话检索 | `session_search` |
|
||||
| 用户偏好和项目事实 | `memory_manager` |
|
||||
| 可复用流程沉淀 | `skill_manager` |
|
||||
| 项目背景 | `get_project_context` |
|
||||
| SCADA 资产 | `list_scada_assets` |
|
||||
| SCADA 时序读数 | `query_scada_readings`(必须给出 `observed_from`、`observed_to`) |
|
||||
|
||||
## UI 约束
|
||||
|
||||
1. 前端地图工具仅表达定位意图,不返回业务数据。
|
||||
2. 每次工具调用必须填写具体 `reason`。
|
||||
1. 不生成或调用前端动作。
|
||||
2. 每次普通工具调用必须填写具体 `reason`。
|
||||
3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。
|
||||
|
||||
## 执行约束
|
||||
|
||||
1. 无可用数据时不得编造结论。
|
||||
2. 不要调用 shell、Python 或 CLI 来拼装业务数据流程。
|
||||
2. 不要自由拼接 shell、Python 或 CLI 业务数据流程。仅可对 `source/` 数据执行已验证 Skill 内明确指定的只读分析脚本。
|
||||
3. 不要读取或写入 token、password、secret、API key、system prompt。
|
||||
4. 尽量不用子代理,保持过程可观测。
|
||||
|
||||
## 工作流沉淀
|
||||
|
||||
只有在当前对话流程已经验证有效、可被复用且用户明确需要保存时,才使用 `skill_manager` 沉淀 workflow。实验阶段 workflow 不应记录 CLI、shell pipe 或认证上下文。
|
||||
只有在当前对话流程已经验证有效、可被复用且用户明确需要保存时,才使用 `skill_manager` 沉淀 workflow。实验阶段 workflow 不应记录自由拼接的 CLI、shell pipe 或认证上下文;可引用 Skill 内已验证的固定只读分析脚本。
|
||||
|
||||
## 记忆持久化
|
||||
|
||||
`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`,再用文字摘要说明结果。
|
||||
先查询数据再作判断。回答必须说明查询时间范围、粒度和指标。
|
||||
遇到空数据、拒绝、权限错误、超时或服务不可用时,明确说明事实,不得补全或猜测结果。
|
||||
工具返回 `result_ref` 时,说明完整结果已安全存储,并使用 preview 中的样本和统计作答。
|
||||
1. 先读取 `source/catalog.json` 选择数据源,再读取对应数据包的 `manifest.json` 和所需 JSONL。
|
||||
2. 用户明确指定编号或场景时,只使用对应数据包。例如只分析污染源接入时选择 `scenario_code=07`。
|
||||
3. 用户要求雨污混接、雨水混接、地下水入渗或外来水稀释分析,且未指定其他数据源时,默认选择 `scenario_code=01`。
|
||||
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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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) });
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -13,8 +13,8 @@ real auth provider through `src/context/`.
|
||||
- No required `Authorization` or `X-Project-Id` headers.
|
||||
- No `cli/`, no `tjwater_cli` opencode tool, no free-form CLI command bridge.
|
||||
- Agent visual output is expressed through `agent-ui@1` UIEnvelope events.
|
||||
- Backend business data is available through typed, allowlisted domain tools.
|
||||
Arbitrary URLs, SQL, shell commands, and filesystem paths remain unavailable.
|
||||
- Tools that depend on the TJWater Server are disabled. Arbitrary URLs, SQL,
|
||||
shell commands, and filesystem paths remain unavailable.
|
||||
|
||||
## Main API
|
||||
|
||||
@@ -51,22 +51,14 @@ Defaults:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
Local domain tools are enabled by default. Set `TJWATER_API_BASE_URL` to the
|
||||
Server address and `AGENT_LOCAL_PROJECT_ID` to an enabled project UUID. Setting
|
||||
`TJWATER_API_ENABLED=false` disables all Server calls. Production startup rejects
|
||||
enabled Server tools while `TJWATER_AUTH_MODE=disabled`.
|
||||
|
||||
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.
|
||||
Monitoring time-series data is read from the agent project's `source/`
|
||||
directory and analyzed through validated local skills. `get_project_context`,
|
||||
`list_scada_assets`, `query_scada_readings`, `web_search`, and `geocode` are not
|
||||
exposed because they depend on the TJWater Server.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Visual opencode tools are mapped by the service:
|
||||
|
||||
- `show_chart` -> `chart`
|
||||
- `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.
|
||||
No visual or frontend-action tools are currently exposed to the Agent. The
|
||||
registry endpoints remain available for protocol compatibility and return no
|
||||
registered charts, components, or actions.
|
||||
|
||||
See [docs/agent-ui-protocol.md](docs/agent-ui-protocol.md).
|
||||
|
||||
|
||||
@@ -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
|
||||
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`
|
||||
- `zoom_to_map` -> `map_action`
|
||||
- `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.
|
||||
Historical monitoring values for analysis come from the project `source/`
|
||||
directory.
|
||||
|
||||
## State Persistence
|
||||
|
||||
|
||||
@@ -29,8 +29,5 @@
|
||||
"edit": "ask",
|
||||
"task": "deny"
|
||||
},
|
||||
"plugin": [
|
||||
"./.opencode/plugins/frontend-action-call-id.ts"
|
||||
],
|
||||
"default_agent": "instruction"
|
||||
}
|
||||
|
||||
@@ -82,13 +82,6 @@ const envSchema = z
|
||||
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
|
||||
// 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。
|
||||
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),
|
||||
// 生成结果 preview 时最多抽样的条目数。
|
||||
@@ -141,13 +134,6 @@ const envSchema = z
|
||||
.default(3600000),
|
||||
})
|
||||
.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) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
|
||||
@@ -2,10 +2,7 @@ import { z } from "zod";
|
||||
import type { FrontendActionManifest } from "./types.js";
|
||||
|
||||
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[] = [
|
||||
{ manifest: { id: "zoom_to_map", version: "frontend-action@1", risk: "view", timeoutMs: 10_000, supportedSurfaces: ["map_overlay"] }, inputSchema: zoomInput, outputSchema: z.object({}).passthrough() },
|
||||
];
|
||||
const definitions: Definition[] = [];
|
||||
|
||||
export const frontendActionRegistry = definitions.map((item) => item.manifest);
|
||||
export const getFrontendActionDefinition = (name: string) => definitions.find((item) => item.manifest.id === name);
|
||||
|
||||
@@ -830,7 +830,7 @@ export const streamPromptResponse = async ({
|
||||
params: toolParams,
|
||||
reason,
|
||||
});
|
||||
if (envelope && !(suppressLegacyFrontendActions && part.tool === "zoom_to_map")) {
|
||||
if (envelope) {
|
||||
write("ui_envelope", {
|
||||
session_id: clientSessionId,
|
||||
envelope_id: createEnvelopeId(part.tool),
|
||||
|
||||
@@ -61,11 +61,8 @@ const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
memory_manager: "记忆写入",
|
||||
geocode: "地理编码",
|
||||
session_search: "历史会话检索",
|
||||
skill_manager: "流程沉淀",
|
||||
web_search: "网页搜索",
|
||||
zoom_to_map: "地图缩放",
|
||||
};
|
||||
|
||||
export const logDevelopmentDebug = (
|
||||
|
||||
@@ -207,9 +207,6 @@ export const updateLastAssistantQuestion = (
|
||||
});
|
||||
|
||||
const getToolArtifactKind = (tool: string): ToolArtifactKind => {
|
||||
if (tool === "zoom_to_map") {
|
||||
return "map";
|
||||
}
|
||||
return "tool";
|
||||
};
|
||||
|
||||
@@ -217,7 +214,6 @@ const getToolArtifactTitle = (tool: string, params: Record<string, unknown>) =>
|
||||
if (typeof params.title === "string" && params.title.trim()) {
|
||||
return params.title.trim();
|
||||
}
|
||||
if (tool === "zoom_to_map") return "缩放到地图坐标";
|
||||
return tool || "工具调用";
|
||||
};
|
||||
|
||||
|
||||
+1
-174
@@ -15,12 +15,7 @@ import { ResultReferenceStore } from "./results/store.js";
|
||||
import { buildChatRouter } from "./routes/chat.js";
|
||||
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
|
||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||
import { ServerApiGateway } from "./serverApi/gateway.js";
|
||||
import { isDomainToolName } from "./serverApi/schemas.js";
|
||||
import {
|
||||
getRuntimeSessionContext,
|
||||
type RuntimeSessionContext,
|
||||
} from "./runtime/sessionContext.js";
|
||||
import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -37,7 +32,6 @@ const learningOrchestrator = new LearningOrchestrator(
|
||||
);
|
||||
const resultReferenceStore = new ResultReferenceStore();
|
||||
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore);
|
||||
const serverApiGateway = new ServerApiGateway(resultReferenceResolver);
|
||||
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
|
||||
const frontendActionCoordinator = new FrontendActionCoordinator();
|
||||
|
||||
@@ -46,25 +40,6 @@ process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
|
||||
app.use(cors());
|
||||
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) => {
|
||||
if (req.header("x-agent-internal-token") !== internalToken) {
|
||||
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(
|
||||
"/api/v1/agent/chat",
|
||||
buildChatRouter(
|
||||
|
||||
@@ -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 } : {}) } });
|
||||
@@ -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;
|
||||
@@ -7,20 +7,4 @@ type ToolCallInput = {
|
||||
};
|
||||
|
||||
export const toUiEnvelopeFromToolCall = ({
|
||||
tool,
|
||||
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;
|
||||
};
|
||||
}: ToolCallInput): UIEnvelope | null => null;
|
||||
|
||||
@@ -4,14 +4,7 @@ export const chartGrammar = "echarts-safe-subset" as const;
|
||||
|
||||
export const componentRegistry: ComponentManifest[] = [];
|
||||
|
||||
export const actionRegistry: ActionManifest[] = [
|
||||
{
|
||||
id: "zoom_to_map",
|
||||
version: "1.0.0",
|
||||
description: "Zoom the map to a coordinate.",
|
||||
supportedSurfaces: ["map_overlay"],
|
||||
},
|
||||
];
|
||||
export const actionRegistry: ActionManifest[] = [];
|
||||
|
||||
export const uiRegistryResponse = {
|
||||
schema_version: "agent-ui-registry@1",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +1,12 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
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", () => {
|
||||
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 { 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);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -3,23 +3,8 @@ import { describe, expect, it } from "bun:test";
|
||||
import { toUiEnvelopeFromToolCall } from "../../src/uiEnvelope/fromToolCall.js";
|
||||
|
||||
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", () => {
|
||||
expect(toUiEnvelopeFromToolCall({ tool: "zoom_to_map", params: {} })).toBeNull();
|
||||
expect(toUiEnvelopeFromToolCall({ tool: "show_chart", params: {} })).toBeNull();
|
||||
expect(toUiEnvelopeFromToolCall({ tool: "view_scada", params: { asset_ids: ["A-1"] } })).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user