diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index 74bf75b..a715de8 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -15,9 +15,7 @@ Agent 负责: 1. 理解用户意图。 2. 调用受控工具获取有限上下文或表达展示意图。 3. 输出文字摘要、结论、追问和建议。 -4. 在没有可用前端工具时,仅输出文字摘要、结论和建议。 - -当前不向 Agent 暴露前端动作工具。 +4. 在分析结论完成后,可将 SCADA 等级结果作为受控地图动作发送到浏览器。 ## 工具选择 @@ -26,12 +24,17 @@ Agent 负责: | 历史会话检索 | `session_search` | | 用户偏好和项目事实 | `memory_manager` | | 可复用流程沉淀 | `skill_manager` | +| 渲染 SCADA 分析结果 | `render_scada_analysis` | +| 清除 SCADA 分析结果 | `clear_scada_analysis` | ## UI 约束 -1. 不生成或调用前端动作。 +1. `render_scada_analysis` 只能在分析结论已经完成后调用,不得让前端计算、猜测或修改高中低等级。 2. 每次普通工具调用必须填写具体 `reason`。 3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。 +4. SCADA 地图结果只允许使用可信 `sensor_id` 和 `high | medium | low | unrated` 等级,不得传入自定义标签、颜色或 HTML。 +5. 同一批结果不得包含重复 `sensor_id`,每次最多 100 个点位。 +6. 只有 SCADA 地图工具返回浏览器的成功结果后,才能声称已渲染或已清除;工具报错、超时或无活跃浏览器连接时,必须明确告知用户动作失败,不得伪造已渲染的点位、编号或等级。 ## 执行约束 diff --git a/.opencode/plugins/frontend-action-call-id.ts b/.opencode/plugins/frontend-action-call-id.ts new file mode 100644 index 0000000..040f000 --- /dev/null +++ b/.opencode/plugins/frontend-action-call-id.ts @@ -0,0 +1,13 @@ +import type { Plugin } from "@opencode-ai/plugin"; + +const FRONTEND_ACTION_TOOLS = new Set([ + "render_scada_analysis", + "clear_scada_analysis", +]); + +export const frontendActionCallIdPlugin: Plugin = async () => ({ + "tool.execute.before": async (input, output) => { + if (!FRONTEND_ACTION_TOOLS.has(input.tool)) return; + output.args.__frontend_action_call_id = input.callID; + }, +}); diff --git a/.opencode/tools/clear_scada_analysis.ts b/.opencode/tools/clear_scada_analysis.ts new file mode 100644 index 0000000..fd11dcb --- /dev/null +++ b/.opencode/tools/clear_scada_analysis.ts @@ -0,0 +1,17 @@ +import { tool } from "@opencode-ai/plugin"; + +import { executeFrontendAction } from "./frontend_action.js"; + +export default tool({ + description: "清除当前浏览器地图上的 Agent SCADA 分析结果覆盖层。", + args: { + reason: tool.schema + .string() + .trim() + .min(1) + .describe("Why the current SCADA analysis overlay should be cleared."), + }, + async execute(args, context) { + return executeFrontendAction("clear_scada_analysis", args, context); + }, +}); diff --git a/.opencode/tools/frontend_action.ts b/.opencode/tools/frontend_action.ts new file mode 100644 index 0000000..b7bd5b0 --- /dev/null +++ b/.opencode/tools/frontend_action.ts @@ -0,0 +1,76 @@ +type FrontendActionArgs = Record & { + __frontend_action_call_id?: string; + reason?: string; +}; + +type FrontendActionBridgeResult = { + version: "frontend-action-result@1"; + status: "succeeded" | "failed" | "rejected" | "cancelled" | "expired"; + output?: unknown; + error?: { code?: string; message?: 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: "render_scada_analysis" | "clear_scada_analysis", + 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 unwrapFrontendActionResult(text); +}; + +export const unwrapFrontendActionResult = (text: string) => { + let result: FrontendActionBridgeResult; + try { + result = JSON.parse(text) as FrontendActionBridgeResult; + } catch { + throw new Error("frontend action bridge returned invalid JSON"); + } + if (result.version !== "frontend-action-result@1" || typeof result.status !== "string") { + throw new Error("frontend action bridge returned an invalid result"); + } + if (result.status !== "succeeded") { + const code = result.error?.code?.trim() || `ACTION_${result.status.toUpperCase()}`; + const message = result.error?.message?.trim() || `browser action ${result.status}`; + throw new Error(`${code}: ${message}`); + } + if (result.output === undefined) { + throw new Error("frontend action bridge returned no output"); + } + return JSON.stringify(result.output); +}; diff --git a/.opencode/tools/render_scada_analysis.ts b/.opencode/tools/render_scada_analysis.ts new file mode 100644 index 0000000..6165040 --- /dev/null +++ b/.opencode/tools/render_scada_analysis.ts @@ -0,0 +1,36 @@ +import { tool } from "@opencode-ai/plugin"; + +import { executeFrontendAction } from "./frontend_action.js"; + +const sensorId = tool.schema.string().trim().min(1).max(128); + +export default tool({ + description: + "将已经完成分析并确定等级的 SCADA 点位结果高亮、编号并定位到前端地图。等级只能来自已完成的分析结论。", + args: { + reason: tool.schema + .string() + .trim() + .min(1) + .describe("Why the completed SCADA analysis should be rendered on the map."), + items: tool.schema + .array( + tool.schema.object({ + sensor_id: sensorId.describe("Trusted SCADA sensor identifier."), + level: tool.schema + .enum(["high", "medium", "low", "unrated"]) + .describe("Completed analysis level; use unrated only when no level was concluded."), + }), + ) + .min(1) + .max(100) + .refine( + (items) => new Set(items.map((item) => item.sensor_id)).size === items.length, + "sensor_id values must be unique", + ) + .describe("One to one hundred unique SCADA analysis results."), + }, + async execute(args, context) { + return executeFrontendAction("render_scada_analysis", args, context); + }, +}); diff --git a/src/frontendAction/coordinator.ts b/src/frontendAction/coordinator.ts index 27866e2..6861b6b 100644 --- a/src/frontendAction/coordinator.ts +++ b/src/frontendAction/coordinator.ts @@ -48,7 +48,11 @@ export class FrontendActionCoordinator { this.sinks.set(sessionId, sink); const now = Date.now(); for (const pending of this.pending.values()) { - if (pending.request.sessionId === sessionId && pending.request.expiresAt > now) sink(pending.request); + if ( + pending.request.sessionId === sessionId && + pending.request.replayPolicy === "explicit" && + pending.request.expiresAt > now + ) sink(pending.request); } return () => { if (this.sinks.get(sessionId) === sink) this.sinks.delete(sessionId); diff --git a/src/frontendAction/registry.ts b/src/frontendAction/registry.ts index 92949de..535e689 100644 --- a/src/frontendAction/registry.ts +++ b/src/frontendAction/registry.ts @@ -2,7 +2,58 @@ import { z } from "zod"; import type { FrontendActionManifest } from "./types.js"; type Definition = { manifest: FrontendActionManifest; inputSchema: z.ZodTypeAny; outputSchema: z.ZodTypeAny }; -const definitions: Definition[] = []; +const sensorId = z.string().trim().min(1).max(128); +const scadaLevel = z.enum(["high", "medium", "low", "unrated"]); +const levelCounts = z.object({ + high: z.number().int().nonnegative(), + medium: z.number().int().nonnegative(), + low: z.number().int().nonnegative(), + unrated: z.number().int().nonnegative(), +}).strict(); +const renderScadaAnalysisInput = z.object({ + items: z.array(z.object({ sensor_id: sensorId, level: scadaLevel }).strict()).min(1).max(100), +}).strict().superRefine((value, context) => { + const seen = new Set(); + value.items.forEach((item, index) => { + if (seen.has(item.sensor_id)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "sensor_id values must be unique", + path: ["items", index, "sensor_id"], + }); + } + seen.add(item.sensor_id); + }); +}); +const definitions: Definition[] = [ + { + manifest: { + id: "render_scada_analysis", + version: "frontend-action@1", + risk: "view", + timeoutMs: 15_000, + supportedSurfaces: ["map_overlay"], + }, + inputSchema: renderScadaAnalysisInput, + outputSchema: z.object({ + rendered_ids: z.array(sensorId), + missing_ids: z.array(sensorId), + level_counts: levelCounts, + fitted: z.literal(true), + }).strict(), + }, + { + manifest: { + id: "clear_scada_analysis", + version: "frontend-action@1", + risk: "view", + timeoutMs: 15_000, + supportedSurfaces: ["map_overlay"], + }, + inputSchema: z.object({}).strict(), + outputSchema: z.object({ cleared: z.literal(true) }).strict(), + }, +]; export const frontendActionRegistry = definitions.map((item) => item.manifest); export const getFrontendActionDefinition = (name: string) => definitions.find((item) => item.manifest.id === name); diff --git a/src/routes/chatStreamEvents.ts b/src/routes/chatStreamEvents.ts index f470366..d366091 100644 --- a/src/routes/chatStreamEvents.ts +++ b/src/routes/chatStreamEvents.ts @@ -60,7 +60,9 @@ export type TodoUpdatePayload = { const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; const toolLabels: Record = { + clear_scada_analysis: "清除 SCADA 地图分析", memory_manager: "记忆写入", + render_scada_analysis: "渲染 SCADA 地图分析", session_search: "历史会话检索", skill_manager: "流程沉淀", }; diff --git a/src/routes/chatUiState.ts b/src/routes/chatUiState.ts index b1d95cd..bbf702a 100644 --- a/src/routes/chatUiState.ts +++ b/src/routes/chatUiState.ts @@ -207,6 +207,9 @@ export const updateLastAssistantQuestion = ( }); const getToolArtifactKind = (tool: string): ToolArtifactKind => { + if (tool === "render_scada_analysis" || tool === "clear_scada_analysis") { + return "map"; + } return "tool"; }; diff --git a/src/server.ts b/src/server.ts index fb08dfc..0bb0e09 100644 --- a/src/server.ts +++ b/src/server.ts @@ -52,13 +52,7 @@ app.post("/internal/frontend-actions/request", async (req, res) => { return; } if (!context.capabilities?.includes("frontend-action@1")) { - // Legacy clients still execute the existing UIEnvelope emitted from the tool call. - res.json({ - version: "frontend-action-result@1", - status: "succeeded", - output: { legacy: true }, - detail: "legacy client executes the action from its UIEnvelope", - }); + res.status(409).json({ message: "frontend-action@1 browser capability is required" }); return; } try { diff --git a/tests/frontendAction/callIdPlugin.test.ts b/tests/frontendAction/callIdPlugin.test.ts new file mode 100644 index 0000000..61ba4c1 --- /dev/null +++ b/tests/frontendAction/callIdPlugin.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; + +import { frontendActionCallIdPlugin } from "../../.opencode/plugins/frontend-action-call-id.js"; + +describe("frontend action call ID plugin", () => { + it("injects call IDs only for SCADA frontend actions", async () => { + const hooks = await frontendActionCallIdPlugin({} as never); + const before = hooks["tool.execute.before"]; + if (!before) throw new Error("before hook missing"); + const actionArgs: Record = { items: [] }; + const actionOutput: { args: Record } = { args: actionArgs }; + await before( + { tool: "render_scada_analysis", callID: "call-1", sessionID: "s" }, + actionOutput, + ); + expect(actionOutput.args).toBe(actionArgs); + expect(actionArgs).toEqual({ + items: [], + __frontend_action_call_id: "call-1", + }); + const regularArgs = { query: "SCADA" }; + const regularOutput = { args: regularArgs }; + await before( + { tool: "session_search", callID: "call-2", sessionID: "s" }, + regularOutput, + ); + expect(regularOutput.args).toBe(regularArgs); + expect(regularArgs).toEqual({ query: "SCADA" }); + }); + + it("exports only plugin functions so OpenCode can load the module", async () => { + const pluginModule = await import("../../.opencode/plugins/frontend-action-call-id.js"); + expect(Object.keys(pluginModule)).toEqual(["frontendActionCallIdPlugin"]); + expect(Object.values(pluginModule).every((value) => typeof value === "function")).toBeTrue(); + }); +}); diff --git a/tests/frontendAction/coordinator.test.ts b/tests/frontendAction/coordinator.test.ts index 7642f36..cb61808 100644 --- a/tests/frontendAction/coordinator.test.ts +++ b/tests/frontendAction/coordinator.test.ts @@ -1,12 +1,143 @@ import { describe, expect, it } from "bun:test"; -import { FrontendActionCoordinator, FrontendActionError } from "../../src/frontendAction/coordinator.js"; +import { + FrontendActionCoordinator, + FrontendActionError, +} from "../../src/frontendAction/coordinator.js"; +import { frontendActionRegistry } from "../../src/frontendAction/registry.js"; +import type { FrontendActionRequest } from "../../src/frontendAction/types.js"; + +const items = [ + { sensor_id: "MP01", level: "high" }, + { sensor_id: "MP02", level: "medium" }, +] as const; + +const start = ( + coordinator: FrontendActionCoordinator, + name = "render_scada_analysis", + params: unknown = { items }, +) => { + let emitted: FrontendActionRequest | undefined; + coordinator.registerSink("session-1", (request) => { + emitted = request; + }); + const pending = coordinator.request({ + sessionId: "session-1", + toolCallId: "call-1", + name, + params, + }); + if (!emitted) throw new Error("request was not emitted"); + return { emitted, pending }; +}; describe("FrontendActionCoordinator", () => { - it("rejects frontend actions when the registry is empty", () => { + it("registers only the two view-risk SCADA actions with 15 second expiry", () => { + expect(frontendActionRegistry).toEqual([ + expect.objectContaining({ id: "render_scada_analysis", risk: "view", timeoutMs: 15_000 }), + expect.objectContaining({ id: "clear_scada_analysis", risk: "view", timeoutMs: 15_000 }), + ]); + }); + + it("emits once and resolves with validated browser output", async () => { + const coordinator = new FrontendActionCoordinator(); + const { emitted, pending } = start(coordinator); + expect(emitted).toMatchObject({ + name: "render_scada_analysis", + risk: "view", + replayPolicy: "never", + params: { items }, + }); + expect(emitted.expiresAt - emitted.issuedAt).toBe(15_000); + const result = { + version: "frontend-action-result@1" as const, + actionId: emitted.actionId, + status: "succeeded" as const, + output: { + rendered_ids: ["MP01", "MP02"], + missing_ids: [], + level_counts: { high: 1, medium: 1, low: 0, unrated: 0 }, + fitted: true as const, + }, + completedAt: Date.now(), + }; + expect(coordinator.submitResult("session-1", result).duplicate).toBe(false); + expect((await pending).output).toEqual(result.output); + expect(coordinator.submitResult("session-1", result).duplicate).toBe(true); + }); + + it("does not replay never-policy actions when the browser sink reconnects", async () => { + const coordinator = new FrontendActionCoordinator(); + const emitted: FrontendActionRequest[] = []; + const unregister = coordinator.registerSink("session-1", (request) => emitted.push(request)); + const pending = coordinator.request({ + sessionId: "session-1", + toolCallId: "call-no-replay", + name: "clear_scada_analysis", + params: {}, + }); + unregister(); + coordinator.registerSink("session-1", (request) => emitted.push(request)); + expect(emitted).toHaveLength(1); + coordinator.submitResult("session-1", { + version: "frontend-action-result@1", + actionId: emitted[0].actionId, + status: "succeeded", + output: { cleared: true }, + completedAt: Date.now(), + }); + await pending; + }); + + it("accepts clear output and rejects invalid successful output", async () => { + const coordinator = new FrontendActionCoordinator(); + const { emitted, pending } = start(coordinator, "clear_scada_analysis", {}); + expect(() => coordinator.submitResult("session-1", { + version: "frontend-action-result@1", + actionId: emitted.actionId, + status: "succeeded", + output: { cleared: false }, + completedAt: Date.now(), + })).toThrow("invalid frontend action output"); + coordinator.submitResult("session-1", { + version: "frontend-action-result@1", + actionId: emitted.actionId, + status: "succeeded", + output: { cleared: true }, + completedAt: Date.now(), + }); + expect((await pending).output).toEqual({ cleared: true }); + }); + + it("validates item count, unique IDs, ID length, and levels", () => { const coordinator = new FrontendActionCoordinator(); coordinator.registerSink("s", () => undefined); - 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"); + const request = (value: unknown) => coordinator.request({ + sessionId: "s", + toolCallId: "call-validation", + name: "render_scada_analysis", + params: value, + }); + expect(() => request({ items: [] })).toThrow(FrontendActionError); + expect(() => request({ items: Array.from({ length: 101 }, (_, index) => ({ sensor_id: `MP${index}`, level: "low" })) })).toThrow("invalid frontend action params"); + expect(() => request({ items: [{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }] })).toThrow("invalid frontend action params"); + expect(() => request({ items: [{ sensor_id: "x".repeat(129), level: "low" }] })).toThrow("invalid frontend action params"); + expect(() => request({ items: [{ sensor_id: "MP01", level: "critical" }] })).toThrow("invalid frontend action params"); + }); + + it("keeps terminal results idempotent and rejects conflicts", async () => { + const coordinator = new FrontendActionCoordinator(); + const { emitted, pending } = start(coordinator, "clear_scada_analysis", {}); + const result = { + version: "frontend-action-result@1" as const, + actionId: emitted.actionId, + status: "succeeded" as const, + output: { cleared: true as const }, + 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"); }); }); diff --git a/tests/frontendAction/toolBridge.test.ts b/tests/frontendAction/toolBridge.test.ts new file mode 100644 index 0000000..0b34cd1 --- /dev/null +++ b/tests/frontendAction/toolBridge.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test"; + +import { unwrapFrontendActionResult } from "../../.opencode/tools/frontend_action.js"; + +describe("frontend action tool bridge", () => { + it("returns only validated browser output for successful actions", () => { + expect(unwrapFrontendActionResult(JSON.stringify({ + version: "frontend-action-result@1", + actionId: "action-1", + status: "succeeded", + output: { cleared: true }, + completedAt: Date.now(), + }))).toBe('{"cleared":true}'); + }); + + it("throws the browser error for failed and expired actions", () => { + expect(() => unwrapFrontendActionResult(JSON.stringify({ + version: "frontend-action-result@1", + actionId: "action-1", + status: "failed", + error: { code: "WFS_UNAVAILABLE", message: "SCADA geometry query failed" }, + completedAt: Date.now(), + }))).toThrow("WFS_UNAVAILABLE: SCADA geometry query failed"); + expect(() => unwrapFrontendActionResult(JSON.stringify({ + version: "frontend-action-result@1", + actionId: "action-2", + status: "expired", + error: { code: "ACTION_TIMEOUT", message: "browser action timed out" }, + completedAt: Date.now(), + }))).toThrow("ACTION_TIMEOUT: browser action timed out"); + }); +}); diff --git a/tests/opencode/disabledTools.test.ts b/tests/opencode/disabledTools.test.ts index 27f3897..e790e45 100644 --- a/tests/opencode/disabledTools.test.ts +++ b/tests/opencode/disabledTools.test.ts @@ -10,13 +10,15 @@ const disabledServerTools = [ ]; describe("disabled opencode tools", () => { - it("does not expose Server-dependent or frontend-action tools", () => { + it("exposes only the two SCADA 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(); + expect(existsSync(".opencode/tools/frontend_action.ts")).toBeTrue(); + expect(existsSync(".opencode/tools/render_scada_analysis.ts")).toBeTrue(); + expect(existsSync(".opencode/tools/clear_scada_analysis.ts")).toBeTrue(); + expect(existsSync(".opencode/plugins/frontend-action-call-id.ts")).toBeTrue(); }); }); diff --git a/tests/routes/chatUiState.test.ts b/tests/routes/chatUiState.test.ts index 8ef72bc..c774033 100644 --- a/tests/routes/chatUiState.test.ts +++ b/tests/routes/chatUiState.test.ts @@ -8,6 +8,20 @@ import { } from "../../src/routes/chatUiState.js"; describe("appendBackendToolArtifact", () => { + it("marks SCADA frontend actions as map artifacts", () => { + const artifacts = appendBackendToolArtifact([], { + tool: "render_scada_analysis", + reason: "显示已完成的分级结论", + params: { items: [{ sensor_id: "MP01", level: "high" }] }, + }) as Array>; + + expect(artifacts[0]).toMatchObject({ + tool: "render_scada_analysis", + kind: "map", + description: "显示已完成的分级结论", + }); + }); + it("persists removed visual tool calls as generic tool artifacts", () => { const artifacts = appendBackendToolArtifact([], { session_id: "session-1", diff --git a/tests/uiEnvelope/fromToolCall.test.ts b/tests/uiEnvelope/fromToolCall.test.ts index f0aff4c..e1739d7 100644 --- a/tests/uiEnvelope/fromToolCall.test.ts +++ b/tests/uiEnvelope/fromToolCall.test.ts @@ -8,4 +8,15 @@ describe("toUiEnvelopeFromToolCall", () => { expect(toUiEnvelopeFromToolCall({ tool: "show_chart", params: {} })).toBeNull(); expect(toUiEnvelopeFromToolCall({ tool: "view_scada", params: { asset_ids: ["A-1"] } })).toBeNull(); }); + + it("does not generate duplicate UIEnvelopes for SCADA frontend actions", () => { + expect(toUiEnvelopeFromToolCall({ + tool: "render_scada_analysis", + params: { items: [{ sensor_id: "MP01", level: "high" }] }, + })).toBeNull(); + expect(toUiEnvelopeFromToolCall({ + tool: "clear_scada_analysis", + params: {}, + })).toBeNull(); + }); });