diff --git a/.opencode/README.md b/.opencode/README.md index 5ea6f00..d6ac8d7 100644 --- a/.opencode/README.md +++ b/.opencode/README.md @@ -99,3 +99,10 @@ bun test tests/frontendAction/callIdPlugin.test.ts tests/runtime/opencode.test.t MCP 策略变更使用 MCP 相关测试,实际连接联调可选择根目录 README 列出的本地 smoke 脚本。模型配置修改使用 `bun run check:models`。这些命令从仓库根目录运行;仅修改描述或说明时检查路径、JSON 和 `git diff --check` 即可。 运行时确认以 Agent 的启动预热和 `/health` 为准。client 模式要同时更新外部 OpenCode 使用的资产。服务启动、工作区保留和容器挂载参见 [根目录 README](../README.md),代码修改约束见 [AGENTS.md](../AGENTS.md)。 + +## 本地交互式回答 + +`tools/present_answer.ts` 注册本地交互式回答工具。组件契约来自前端导出的 +`../src/chat/answer-ui-catalog.json`,由服务与前端同时校验;注册入口仍是 +`plugins/tjwater-tools.ts`。更新后需要重新加载 Agent/OpenCode 的可信插件资产。 +发现验证使用 `bun run tests/integration/answer-ui-smoke.ts`,从 Agent 仓库根目录运行。 diff --git a/.opencode/agent-plugin.json b/.opencode/agent-plugin.json index f95815c..da5bf22 100644 --- a/.opencode/agent-plugin.json +++ b/.opencode/agent-plugin.json @@ -20,7 +20,7 @@ "plugins": [ { "id": "tjwater.tools", - "description": "注册业务工具和待办工具,安装会话 Shell 沙箱与 MCP 权限策略,并提供模型校验和工具发现 RPC。", + "description": "注册业务工具、本地交互式回答 present_answer 和待办工具,安装会话 Shell 沙箱与 MCP 权限策略,并提供模型校验和工具发现 RPC。", "package": "tjwater-plugin", "entrypoint": "tjwater-plugin/index.ts", "implementation": "plugins/tjwater-tools.ts" diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index b7cac9e..67e7cc1 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -34,9 +34,21 @@ Agent 负责: | 发布精简调查报告和关键证据 | `publish_task_report` | | 提议需人工审批的工单或命令 | `propose_task_action` | | 记录用户明确提供的任务证据 | `add_task_human_evidence` | +| 发布真实调查进度与当前路线 | `publish_task_progress` | +| 发布任务地图、图表和证据图层展示请求 | `publish_task_presentation` | ## UI 约束 +回答可通过 `present_answer` 组合本地交互界面,与简短文字交错呈现。按问题选择组织方式:比较资料用 Stack 双列或 Table,真实监测数据用 Chart,证据依据用 Evidence,补充资料用 FollowUpForm;普通短问答可只用文字。组件参数以工具描述中的 schema 为准,不输出 HTML、JSX、脚本或 JSONL 到聊天正文。 + +一个界面块发布后可以继续查询或解释,再发布下一块;相同 block_id 更新本轮同一块。不要重复在文字中抄写整张表或图。每个 Chart 和 Metric 保留单位或来源;查询事实、用户事实、推断和演练数据分别标注。MapLocation 仅提供用户主动点击后的定位入口。FollowUpForm 只收集输入,用户提交后形成新的对话消息,不能在未提交时当作已确认事实。后台 task_execution 使用原任务进度与展示渠道,不调用 present_answer。 + +已发布本地交互界面的普通/协作聊天在 `final_answer` 完成后结束本轮,不能在最终答复之后继续调用 `present_answer`。需要补充界面应先更新完再提交最终答复。后台调查执行仍按原有任务运行和完成接口收尾。 + +`final_answer.answer` 是完整的最后报告,保留结论和必要边界;在 ui_block_ids 中明确引用应保留在报告内的已发布 block_id(按报告顺序)。其余流式文字、界面与轨迹在最终报告到达后统一折叠为处理过程,不能把报告必需的图表、证据或资料表单遗漏在折叠过程里。 + +`final_answer` 保留完整报告和必要边界,不抹掉已发布界面,也不把资料记录成功说成浏览器已展示、业务处置完成或现场核验完成。 + 1. `render_scada_analysis` 只能在分析结论已经完成后调用,不得让前端计算、猜测或修改高中低等级。 2. 每次普通工具调用必须填写具体 `reason`。 3. 不生成 JS、JSX、HTML、CSS 或可执行前端代码。 diff --git a/.opencode/plugins/tjwater-tools.ts b/.opencode/plugins/tjwater-tools.ts index f3a6d7f..8b57a60 100644 --- a/.opencode/plugins/tjwater-tools.ts +++ b/.opencode/plugins/tjwater-tools.ts @@ -2,6 +2,7 @@ import query_project_data from "../tools/query_project_data.js"; import web_search from "../tools/web_search.js"; import geocode from "../tools/geocode.js"; import final_answer from "../tools/final_answer.js"; +import present_answer from "../tools/present_answer.js"; import locate_features from "../tools/locate_features.js"; import zoom_to_map from "../tools/zoom_to_map.js"; import show_chart from "../tools/show_chart.js"; @@ -31,6 +32,7 @@ const businessTools = { geocode, query_project_data, final_answer, + present_answer, locate_features, zoom_to_map, show_chart, diff --git a/.opencode/tools/final_answer.ts b/.opencode/tools/final_answer.ts index 0391ea5..1653d07 100644 --- a/.opencode/tools/final_answer.ts +++ b/.opencode/tools/final_answer.ts @@ -1,7 +1,10 @@ import { tool } from "../tool.js"; export default tool({ - description: "提交本轮完整的最终答复。只在全部工具结果、浏览器回执和业务写回已核实后调用;阶段说明留在过程记录。此工具不停止执行,也不代表任务、工单或运行已完成。", - args: { answer: tool.schema.string().min(1).max(30000) }, - async execute({ answer }) { return JSON.stringify({ answer }); }, + description: "提交本轮完整的最终答复。全部工具结果与需要的业务写回已核实后调用;需要浏览器动作回执时先核实回执,本地回答界面发布不等待浏览器回执;阶段说明留在过程记录。answer 是最后报告的完整文字;ui_block_ids 只列已经用 present_answer 发布且应保留在最终报告中的 block_id,按报告顺序排列。收到最终报告后,其他过程文字、界面与轨迹自动折叠。已发布本地交互界面的聊天会在此工具完成后结束本轮,之后不能继续更新界面;需要补充内容应先更新再提交最终答复。后台任务仍以 Server 的完成/失败接口为准,此工具不代表任务、工单或运行已完成。", + args: { + answer: tool.schema.string().min(1).max(30000), + ui_block_ids: tool.schema.array(tool.schema.string().regex(/^[a-zA-Z0-9_-]{1,80}$/)).max(48).optional(), + }, + async execute({ answer, ui_block_ids }) { return JSON.stringify({ answer, ui_block_ids: ui_block_ids ?? [] }); }, }); diff --git a/.opencode/tools/present_answer.ts b/.opencode/tools/present_answer.ts new file mode 100644 index 0000000..e8c8a5b --- /dev/null +++ b/.opencode/tools/present_answer.ts @@ -0,0 +1,18 @@ +import { tool } from "../tool.js"; +import { answerUiInputSchema } from "../../src/chat/answer-ui.js"; +import catalog from "../../src/chat/answer-ui-catalog.json" with { type: "json" }; + +export default tool({ + description: "把回答的一段内容发布为本地交互界面。spec 是 root + elements 的扁平树,每个元素包括 type、props、children。只组织真实查询资料或用户事实;无脚本、URL 或任意代码。不执行地图或业务动作,不等待浏览器,不代表业务完成。相同 block_id 更新本轮同一内容块。组件参数的 JSON Schema:" + JSON.stringify(catalog.props), + args: { + block_id: tool.schema.string().regex(/^[a-zA-Z0-9_-]{1,80}$/), + fallback_text: tool.schema.string().min(1).max(4000), + spec: tool.schema.object({ root: tool.schema.string(), elements: tool.schema.record(tool.schema.string(), tool.schema.object({ + type: tool.schema.string(), props: tool.schema.record(tool.schema.string(), tool.schema.unknown()), children: tool.schema.array(tool.schema.string()).max(48), + }).strict()) }).strict(), + }, + async execute(args) { + const value = answerUiInputSchema.parse(args); + return JSON.stringify({ version: "answer-ui@1", ...value, recorded: true }); + }, +}); diff --git a/README.md b/README.md index 5b13958..950b897 100644 --- a/README.md +++ b/README.md @@ -341,3 +341,21 @@ volumes: [CI 工作流](.gitea/workflows/package.yml) 在推送 `v*` 标签或手动触发时调用共享容器流水线。当前镜像名为 `gitea.waternetwork.cn/orgtjwater/next-tjwater-agent`,部署服务名为 `next-agent`;仓库需设置 `REGISTRY_USERNAME`、`REGISTRY_PASSWORD`、`DEV_DEPLOY_SSH_KEY`。具体候选验证和发布步骤由共享模板定义。 代码修改约束见 [AGENTS.md](AGENTS.md),前端协议见 [Agent UI 协议](docs/agent-ui-protocol.md),任务计划规则见 [工况执行计划](docs/execution-plan.md)。 + +### 本地交互式聊天回答 + +原生插件通过 `present_answer` 发布 `answer-ui@1` 内容块,现有聊天消息流输出 +`data-answer_ui`,允许文字与本地指标、表格、图表、证据、地图定位和补充资料表单交替出现。 +相同 block_id 更新本轮内容;消息持久化与历史恢复保留结构化部分。最终答复包含 +完整报告文字及 ui_block_ids 引用的已发布组件,其余过程默认折叠。已发布界面的 +聊天在最终答复完成且无待完成工具时收尾,不代替后台任务的完成接口。后台调查执行 +不发布聊天界面块,继续使用原任务报告和展示通道。 + +组件参数契约由供水前端 catalog 导出到 `src/chat/answer-ui-catalog.json`, +本服务按该契约校验组件参数及树结构。前端与 Agent 需同步更新,插件变化需要重启 +Agent,客户端模式也需更新外部 OpenCode 的可信资产。无需新增 domain key、 +UI 托管服务配置或数据库迁移。详见 +[前端接入与扩展说明](../next-tjwater-frontend/docs/agent-answer-ui.md)。 + +原生工具发现验证:`bun run tests/integration/answer-ui-smoke.ts`,使用独立临时数据库, +不调用模型推理,不接管既有服务。 diff --git a/bun.lock b/bun.lock index 974e77c..56bb1c4 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@opencode/client": "2.0.24", "ai": "7.0.8", + "ajv": "^8.20.0", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.21.2", @@ -98,6 +99,8 @@ "ai": ["ai@7.0.8", "https://registry.npmmirror.com/ai/-/ai-7.0.8.tgz", { "dependencies": { "@ai-sdk/gateway": "4.0.6", "@ai-sdk/provider": "4.0.1", "@ai-sdk/provider-utils": "5.0.2" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vTEKl6fDBZ2IxBXTRaZOajf9W2Ev57Ju8iKtUvqlmDk8Z9BrEP4c22SWJsg1RcWHSFmJMSBa/s5dlUBHUq3YwA=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "ansi-colors": ["ansi-colors@4.1.3", "https://registry.npmmirror.com/ansi-colors/-/ansi-colors-4.1.3.tgz", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -180,6 +183,8 @@ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + "finalhandler": ["finalhandler@1.3.2", "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], "forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], diff --git a/opencode.json b/opencode.json index fa2b7f5..6ae34b7 100644 --- a/opencode.json +++ b/opencode.json @@ -267,6 +267,11 @@ "resource": "*", "effect": "allow" }, + { + "action": "present_answer", + "resource": "*", + "effect": "allow" + }, { "action": "locate_features", "resource": "*", diff --git a/package.json b/package.json index d8c0a2b..77ac31c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@opencode/client": "2.0.24", "ai": "7.0.8", + "ajv": "^8.20.0", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.21.2", diff --git a/src/chat/answer-stream-projection.ts b/src/chat/answer-stream-projection.ts new file mode 100644 index 0000000..73d13bd --- /dev/null +++ b/src/chat/answer-stream-projection.ts @@ -0,0 +1,57 @@ +import type { UIMessageChunk } from "ai"; + +/** Canonical parts are projected independently of an attached browser response. */ +export function createAnswerStreamProjection(options: { + messageId: string; + sessionId: string; + write: (chunk: UIMessageChunk) => void; + onChange: () => void; +}) { + const parts: Record[] = []; + let segment = 0; + let textId = "answer"; + let currentText: Record | undefined; + let finished = false; + const endText = () => { + if (!currentText) return; + currentText.state = "done"; + options.write({ type: "text-end", id: textId }); + currentText = undefined; + textId = `answer-${++segment}`; + }; + const appendText = (delta: string) => { + if (!delta || finished) return; + if (!currentText) { + currentText = { type: "text", text: "", state: "streaming" }; + parts.push(currentText); + options.write({ type: "text-start", id: textId }); + } + currentText.text = `${String(currentText.text)}${delta}`; + options.write({ type: "text-delta", id: textId, delta }); + options.write({ type: "data-stream_token", transient: true, data: { + session_id: options.sessionId, message_id: options.messageId, content: delta, + } } as UIMessageChunk); + options.onChange(); + }; + const appendData = (event: string, data: Record) => { + if (finished) return; + if (event === "answer_ui") endText(); + const type = `data-${event}`; + const id = [data.id, data.request_id, data.envelope_id, data.block_id].find((value): value is string => typeof value === "string"); + const existing = id === undefined ? -1 : parts.findIndex((part) => part.type === type && part.id === id); + const part = id ? { type, id, data } : { type, data }; + if (existing >= 0) parts[existing] = part; + else parts.push(part); + options.write(part as UIMessageChunk); + options.onChange(); + }; + const finish = (finishReason: "stop" | "error" = "stop") => { + if (finished) return; + if (!parts.length) appendText("Agent 已完成处理。"); + endText(); + finished = true; + options.onChange(); + options.write({ type: "finish", finishReason }); + }; + return { parts, appendText, appendData, finish, get finished() { return finished; } }; +} diff --git a/src/chat/answer-ui-catalog.json b/src/chat/answer-ui-catalog.json new file mode 100644 index 0000000..ab9bbc6 --- /dev/null +++ b/src/chat/answer-ui-catalog.json @@ -0,0 +1,454 @@ +{ + "version": "answer-ui@1", + "description": "Generated from supply frontend src/features/agent/answer-ui/catalog.ts. Regenerate after catalog changes.", + "schema": { + "type": "object", + "properties": { + "root": { + "type": "string" + }, + "elements": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "enum": [ + "Stack", + "Section", + "Text", + "Metric", + "Table", + "Chart", + "Evidence", + "MapLocation", + "FollowUpForm" + ] + }, + "props": { + "type": "object", + "additionalProperties": {} + }, + "children": { + "type": "array", + "items": { + "type": "string" + } + }, + "slots": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "visible": {}, + "repeat": {} + }, + "required": [ + "type", + "props", + "children" + ], + "additionalProperties": false + } + } + }, + "required": [ + "root", + "elements" + ], + "additionalProperties": false + }, + "components": [ + "Stack", + "Section", + "Text", + "Metric", + "Table", + "Chart", + "Evidence", + "MapLocation", + "FollowUpForm" + ], + "props": { + "Stack": { + "type": "object", + "properties": { + "columns": { + "type": "string", + "enum": [ + "one", + "two" + ] + }, + "gap": { + "type": "string", + "enum": [ + "compact", + "normal" + ] + } + }, + "required": [ + "columns", + "gap" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Section": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 4000 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "title", + "description" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Text": { + "type": "object", + "properties": { + "text": { + "type": "string", + "maxLength": 4000 + }, + "tone": { + "type": "string", + "enum": [ + "neutral", + "info", + "warning" + ] + } + }, + "required": [ + "text", + "tone" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Metric": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 200 + }, + "value": { + "type": "string", + "maxLength": 200 + }, + "unit": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "source": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "label", + "value", + "unit", + "source" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Table": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "columns": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200 + }, + "minItems": 1, + "maxItems": 8 + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string", + "maxLength": 1000 + }, + "maxItems": 8 + }, + "maxItems": 50 + } + }, + "required": [ + "title", + "columns", + "rows" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Chart": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "chart_type": { + "type": "string", + "enum": [ + "line", + "bar" + ] + }, + "x_data": { + "type": "array", + "items": { + "type": "string", + "maxLength": 200 + }, + "minItems": 1, + "maxItems": 100 + }, + "series": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "data": { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1, + "maxItems": 100 + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 8 + }, + "source": { + "type": "string", + "maxLength": 4000 + } + }, + "required": [ + "title", + "chart_type", + "x_data", + "series", + "source" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "Evidence": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "summary": { + "type": "string", + "maxLength": 4000 + }, + "source": { + "type": "string", + "maxLength": 200 + }, + "evidence_id": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "observed_at": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "observed", + "human_input", + "inference", + "demo" + ] + } + }, + "required": [ + "title", + "summary", + "source", + "evidence_id", + "observed_at", + "kind" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "MapLocation": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 200 + }, + "layer": { + "type": "string", + "enum": [ + "junctions", + "pipes", + "valves", + "pumps", + "tanks", + "reservoirs", + "scada" + ] + }, + "feature_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "minItems": 1, + "maxItems": 100 + }, + "source": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "label", + "layer", + "feature_ids", + "source" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "FollowUpForm": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 4000 + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,39}$" + }, + "label": { + "type": "string", + "maxLength": 200 + }, + "kind": { + "type": "string", + "enum": [ + "text", + "datetime", + "number" + ] + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "name", + "label", + "kind", + "required" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 12 + } + }, + "required": [ + "title", + "description", + "fields" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + } +} diff --git a/src/chat/answer-ui.ts b/src/chat/answer-ui.ts new file mode 100644 index 0000000..2d78ca9 --- /dev/null +++ b/src/chat/answer-ui.ts @@ -0,0 +1,43 @@ +import { Ajv } from "ajv"; +import { z } from "zod"; +import catalog from "./answer-ui-catalog.json" with { type: "json" }; + +const ajv = new Ajv({ allErrors: false, strict: true }); +const propertyValidators = Object.fromEntries(Object.entries(catalog.props).map(([name, schema]) => [name, ajv.compile(schema)])); +const key = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/); +export const answerUiSpecSchema = z.object({ root: key, elements: z.record(z.object({ + type: z.string(), props: z.record(z.unknown()), children: z.array(key).max(48), +}).strict()) }).strict().superRefine((spec, context) => { + const fail = () => context.addIssue({ code: z.ZodIssueCode.custom, message: "界面必须使用已注册组件、合法参数和至多 48 节点的无重复树结构" }); + const names = Object.keys(spec.elements); + if (!names.length || names.length > 48 || JSON.stringify(spec).length > 100000 || names.some((id) => !key.safeParse(id).success)) { fail(); return; } + for (const node of Object.values(spec.elements)) { + const validate = Object.hasOwn(propertyValidators, node.type) ? propertyValidators[node.type] : undefined; + if (!validate?.(node.props) || (!["Stack", "Section"].includes(node.type) && node.children.length)) { fail(); return; } + if (node.type === "Chart") { + const props = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> }; + if (props.series.some((series) => series.data.length !== props.x_data.length)) { fail(); return; } + } + if (node.type === "Table") { + const props = node.props as { columns: unknown[]; rows: unknown[][] }; + if (props.rows.some((row) => row.length !== props.columns.length)) { fail(); return; } + } + if (node.type === "FollowUpForm") { + const fields = node.props.fields as Array<{ name: string }>; + if (new Set(fields.map((field) => field.name)).size !== fields.length) { fail(); return; } + } + } + const visited = new Set(); + const visit = (id: string, depth: number): boolean => { + if (depth > 12 || visited.has(id) || !Object.hasOwn(spec.elements, id)) return false; + visited.add(id); + return spec.elements[id].children.every((child) => visit(child, depth + 1)); + }; + if (!visit(spec.root, 0) || visited.size !== names.length) fail(); +}); + +export const answerUiInputSchema = z.object({ block_id: key, fallback_text: z.string().min(1).max(4000), spec: answerUiSpecSchema }).strict(); +export const parseAnswerUiInput = (value: unknown) => { + const parsed = answerUiInputSchema.safeParse(value); + return parsed.success ? { version: "answer-ui@1" as const, ...parsed.data } : null; +}; diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index 8e990b4..7f80698 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -52,6 +52,7 @@ import { type TodoUpdatePayload, } from "./chatStreamEvents.js"; import { createTokenSmoother } from "./chatTokenSmoother.js"; +import { parseAnswerUiInput } from "../chat/answer-ui.js"; export { createTokenSmoother, @@ -79,6 +80,7 @@ type StreamPromptOptions = { projectId?: string; signal?: AbortSignal; suppressLegacyFrontendActions?: boolean; + suppressAnswerUi?: boolean; write: (event: string, data: Record) => void; }; @@ -142,6 +144,7 @@ export const streamPromptResponse = async ({ projectId, signal, suppressLegacyFrontendActions = false, + suppressAnswerUi = false, write, }: StreamPromptOptions): Promise<{ aborted: boolean; @@ -177,6 +180,8 @@ export const streamPromptResponse = async ({ let aborted = signal?.aborted ?? false; let failed = false; let finalAnswer: string | undefined; + const presentedAnswerIds = new Set(); + let finalAnswerUiBlockIds: string[] = []; const trackBusinessStage = createBusinessStageTracker((event, data) => write(event, { ...data, session_id: clientSessionId })); const debugContext = { sessionId, @@ -722,11 +727,36 @@ export const streamPromptResponse = async ({ }); } if (part.type === "tool") { + if (part.tool === "present_answer") { + if (suppressAnswerUi) continue; + if (part.state.status === "completed" && !emittedToolParts.has(part.id)) { + emittedToolParts.add(part.id); + const value = parseAnswerUiInput(part.state.input); + if (value) { + presentedAnswerIds.add(value.block_id); + await tokenSmoother.flush(); + write("answer_ui", { ...value, id: value.block_id, session_id: clientSessionId }); + } else { + write("progress", { id: part.id, phase: "visualization", status: "error", title: "回答界面未通过校验,保留文字说明" }); + } + } + continue; + } if (part.tool === "final_answer") { if (part.state.status === "completed") { const output = parseToolOutput(part.state.output); if (output && typeof output === "object" && typeof (output as Record).answer === "string") { finalAnswer = (output as { answer: string }).answer; + const refs = (output as { ui_block_ids?: unknown }).ui_block_ids; + finalAnswerUiBlockIds = Array.isArray(refs) ? [...new Set(refs.filter((id): id is string => typeof id === "string" && presentedAnswerIds.has(id)))].slice(0, 48) : []; + const hasPendingTool = [...toolStatuses.values()].some((status) => status !== "completed" && status !== "error"); + if (presentedAnswerIds.size > 0 && !suppressAnswerUi && !hasPendingTool) { + // A final rich-chat answer closes this turn; native interruption stops + // automatic revisions without marking a background task complete. + await runtime.abortSession(sessionId); + await runtime.waitForSessionIdle(sessionId); + done = true; + } } } continue; @@ -985,7 +1015,7 @@ export const streamPromptResponse = async ({ await emitFallbackMessage(runtime, sessionId, clientSessionId, tokenSmoother.writeToken); } await tokenSmoother.flush(); - if (finalAnswer) write("final_answer", { session_id: clientSessionId, content: finalAnswer }); + if (finalAnswer) write("final_answer", { session_id: clientSessionId, content: finalAnswer, ui_block_ids: finalAnswerUiBlockIds }); emitProgress({ id: "request-received", phase: "start", diff --git a/src/routes/chatStreamRoute.ts b/src/routes/chatStreamRoute.ts index 9c61bac..e59c128 100644 --- a/src/routes/chatStreamRoute.ts +++ b/src/routes/chatStreamRoute.ts @@ -1,3 +1,4 @@ +import { createAnswerStreamProjection } from "../chat/answer-stream-projection.js"; import { credentialRefreshCoordinator } from "../product/credential-refresh.js"; import { type Router } from "express"; import { randomUUID } from "node:crypto"; @@ -153,13 +154,16 @@ export const toCollaborationMessages = (messages: UIMessage[]) => if (message.role !== "user" && message.role !== "assistant") return []; const content = readUiMessageText(message); const reference = message.parts.filter((part) => part.type === "data-task_context"); - return content ? [{ ...message, parts: [{ type: "text" as const, text: content }, ...reference] } as UIMessage] : []; + const rich = message.parts.some((part) => part.type === "data-answer_ui" || part.type === "data-final_answer"); + const parts = rich ? message.parts.filter((part) => ["text", "data-answer_ui", "data-final_answer", "data-task_context"].includes(part.type)) : [{ type: "text" as const, text: content }, ...reference]; + return content || rich ? [{ ...message, parts } as UIMessage] : []; }); const toLegacyMessages = (messages: UIMessage[]) => messages.flatMap((message) => { const content = readUiMessageText(message); - if (!content || (message.role !== "user" && message.role !== "assistant")) { + const rich = message.parts.some((part) => part.type === "data-answer_ui" || part.type === "data-final_answer"); + if ((!content && !rich) || (message.role !== "user" && message.role !== "assistant")) { return []; } return [ @@ -167,6 +171,7 @@ const toLegacyMessages = (messages: UIMessage[]) => id: message.id, role: message.role, content, + ...(rich ? { parts: message.parts } : {}), }, ]; }); @@ -482,12 +487,6 @@ export const registerChatStreamRoute = ( lastRunStatuses.set(clientSessionId, "running"); const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord); const assistantUiMessageId = `assistant-${Date.now().toString(36)}`; - const textPartId = "answer"; - const assistantParts: Record[] = []; - let responseText = ""; - let textStarted = false; - let textEnded = false; - let uiFinished = false; const syncLatestUiMessages = () => { latestUiMessages = [ ...requestMessages, @@ -499,90 +498,16 @@ export const registerChatStreamRoute = ( ]; }; const writeUiChunk = (chunk: UIMessageChunk) => { - pipeUiChunk(res, chunk); - }; - const writeTextDelta = (delta: string) => { - if (!delta || uiFinished) { - return; - } - if (!textStarted) { - textStarted = true; - assistantParts.push({ - type: "text", - text: "", - state: "streaming", - }); - writeUiChunk({ type: "text-start", id: textPartId }); - } - responseText += delta; - const textPart = assistantParts.find( - (part) => part.type === "text", - ); - if (textPart) { - textPart.text = responseText; - textPart.state = "streaming"; - } - writeUiChunk({ type: "text-delta", id: textPartId, delta }); - writeUiChunk({ - type: "data-stream_token", - data: { - session_id: clientSessionId, - message_id: assistantUiMessageId, - content: delta, - }, - transient: true, - } as UIMessageChunk); - syncLatestUiMessages(); - }; - const writeDataPart = (event: string, data: Record) => { - if (uiFinished) { - return; - } - const type = `data-${event}`; - const id = - typeof data.id === "string" - ? data.id - : typeof data.request_id === "string" - ? data.request_id - : typeof data.envelope_id === "string" - ? data.envelope_id - : undefined; - const existingIndex = - id === undefined - ? -1 - : assistantParts.findIndex( - (part) => part.type === type && part.id === id, - ); - const part = id ? { type, id, data } : { type, data }; - if (existingIndex >= 0) { - assistantParts[existingIndex] = part; - } else { - assistantParts.push(part); - } - writeUiChunk(part as UIMessageChunk); - syncLatestUiMessages(); - }; - const finishUiMessage = (finishReason: "stop" | "error" = "stop") => { - if (uiFinished) { - return; - } - if (!textStarted && assistantParts.length === 0) { - writeTextDelta("Agent 已完成处理。"); - } - if (textStarted && !textEnded) { - textEnded = true; - const textPart = assistantParts.find( - (part) => part.type === "text", - ); - if (textPart) { - textPart.state = "done"; - } - writeUiChunk({ type: "text-end", id: textPartId }); - } - syncLatestUiMessages(); - writeUiChunk({ type: "finish", finishReason }); - uiFinished = true; + if (!streamClosed && !res.writableEnded && !res.destroyed) pipeUiChunk(res, chunk); }; + const projection = createAnswerStreamProjection({ + messageId: assistantUiMessageId, sessionId: clientSessionId, + write: writeUiChunk, onChange: syncLatestUiMessages, + }); + const assistantParts = projection.parts; + const writeTextDelta = projection.appendText; + const writeDataPart = projection.appendData; + const finishUiMessage = projection.finish; writeUiChunk({ type: "start", messageId: assistantUiMessageId }); let persistQueue = sessionUiStateStore.write(sessionUiStateContext, { executionPlans: executionPlanHistory, @@ -609,9 +534,6 @@ export const registerChatStreamRoute = ( }; const primarySubscriber: StreamSubscriber = { write: (event, data) => { - if (streamClosed || res.writableEnded || res.destroyed) { - return; - } if (event === "token") { writeTextDelta(typeof data.content === "string" ? data.content : ""); return; @@ -642,7 +564,8 @@ export const registerChatStreamRoute = ( activeRun.subscribers.add(primarySubscriber); const handleClientClose = () => { streamClosed = true; - activeRun.subscribers.delete(primarySubscriber); + // Keep projecting and persisting parts after the browser disconnects. + // writeUiChunk guards the closed response; replay subscribers remain independent. }; req.on("close", handleClientClose); @@ -837,6 +760,7 @@ export const registerChatStreamRoute = ( subscriber.write(event, subscriberData); } } + activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ ...message, id: assistantUiMessageId, parts: assistantParts.map((part) => ({ ...part })) })); void queueSessionUiStatePersist().catch((error) => { logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); }); @@ -939,6 +863,7 @@ export const registerChatStreamRoute = ( projectId: requestContext.projectId, signal: abortController.signal, suppressLegacyFrontendActions: parsed.data.capabilities.includes("frontend-action@1"), + suppressAnswerUi: sessionPurpose === "task_execution", write: (event, data) => { publish(event, data); }, @@ -1110,7 +1035,7 @@ export const registerChatStreamRoute = ( progress: completeBackendProgress(message.progress), todos: cancelBackendTodos(message.todos), })); - if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) { + if (!projection.finished) { writeUiChunk({ type: "error", errorText: "请求已中断", @@ -1121,10 +1046,10 @@ export const registerChatStreamRoute = ( logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state"); }); } - if (!uiFinished && !streamClosed && !res.writableEnded && !res.destroyed) { + if (!projection.finished) { finishUiMessage(activeRun.status === "error" ? "error" : "stop"); } - await persistQueue.catch((error) => { + await queueSessionUiStatePersist().catch((error) => { logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); }); sessionBridge.finalizeRequest(clientSessionId); diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index 816742f..4aafdcc 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -150,7 +150,7 @@ export class OpencodeRuntimeAdapter { location: { directory: session.location.directory }, }); const tools = response.output as { id: string; direct: boolean; sandboxed: boolean; workspaceRoot?: string }[] | undefined; - const required = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"]; + const required = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"]; const missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct)); if (missing.length) throw new Error(`OpenCode 2 business tools missing: ${missing.join(", ")}`); if (!tools?.some((tool) => tool.id === "shell" && tool.sandboxed)) throw new Error("OpenCode shell sandbox replacement is missing"); diff --git a/tests/chat/answer-stream-projection.test.ts b/tests/chat/answer-stream-projection.test.ts new file mode 100644 index 0000000..0bdce7b --- /dev/null +++ b/tests/chat/answer-stream-projection.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; +import type { UIMessageChunk } from "ai"; +import { createAnswerStreamProjection } from "../../src/chat/answer-stream-projection.js"; + +describe("canonical answer stream projection", () => { + it("splits text around UI blocks, updates them in place and finishes each segment exactly once", () => { + const chunks: UIMessageChunk[] = []; + const stream = createAnswerStreamProjection({ messageId: "a", sessionId: "s", write: (chunk) => chunks.push(chunk), onChange() {} }); + stream.appendText("查询资料。"); + stream.appendData("answer_ui", { block_id: "chart", fallback_text: "初始" }); + stream.appendData("answer_ui", { block_id: "chart", fallback_text: "更新" }); + stream.appendText("待现场核验。"); + stream.finish(); stream.finish(); stream.appendText("不应追加"); + expect(stream.parts.map((part) => part.type)).toEqual(["text", "data-answer_ui", "text"]); + expect(stream.parts[1].data).toEqual({ block_id: "chart", fallback_text: "更新" }); + expect(stream.parts[0].text).toBe("查询资料。"); + expect(stream.parts[2].text).toBe("待现场核验。"); + expect(chunks.filter((chunk) => chunk.type === "text-start").map((chunk) => chunk.id)).toEqual(["answer", "answer-1"]); + expect(chunks.filter((chunk) => chunk.type === "text-end").map((chunk) => chunk.id)).toEqual(["answer", "answer-1"]); + expect(chunks.filter((chunk) => chunk.type === "finish")).toHaveLength(1); + }); + it("continues canonical parts for persistence after the browser disconnects", () => { + let connected = true; + const written: UIMessageChunk[] = []; + const snapshots: unknown[] = []; + const stream = createAnswerStreamProjection({ messageId: "a", sessionId: "s", write: (chunk) => { if (connected) written.push(chunk); }, onChange: () => snapshots.push(structuredClone(stream.parts)) }); + stream.appendText("第一段"); + connected = false; + const count = written.length; + stream.appendData("answer_ui", { block_id: "evidence", fallback_text: "依据" }); + stream.appendText("第二段"); stream.finish(); + expect(written).toHaveLength(count); + expect(snapshots.at(-1)).toEqual([ + { type: "text", text: "第一段", state: "done" }, + { type: "data-answer_ui", id: "evidence", data: { block_id: "evidence", fallback_text: "依据" } }, + { type: "text", text: "第二段", state: "done" }, + ]); + }); +}); diff --git a/tests/chat/answer-ui.test.ts b/tests/chat/answer-ui.test.ts new file mode 100644 index 0000000..4f158ab --- /dev/null +++ b/tests/chat/answer-ui.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test"; +import { parseAnswerUiInput } from "../../src/chat/answer-ui.js"; +import { streamPromptResponse } from "../../src/routes/chatStream.js"; +import { toCollaborationMessages } from "../../src/routes/chatStreamRoute.js"; +import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; +import type { UIMessage } from "ai"; + +const input = { block_id: "pressure", fallback_text: "演练压力 0.3 MPa", spec: { root: "metric", elements: { metric: { type: "Metric", props: { label: "压力", value: "0.3", unit: "MPa", source: "演练数据" }, children: [] } } } }; +describe("locally rendered answers", () => { + it("validates catalog props instead of accepting arbitrary model components", () => { + expect(parseAnswerUiInput(input)?.version).toBe("answer-ui@1"); + for (const metric of [ + { ...input.spec.elements.metric, type: "Script" }, + { ...input.spec.elements.metric, props: { ...input.spec.elements.metric.props, execute: "alert(1)" } }, + { ...input.spec.elements.metric, children: ["metric"] }, + ]) expect(parseAnswerUiInput({ ...input, spec: { root: "metric", elements: { metric } } })).toBeNull(); + }); + it("rejects chart misalignment, nonfinite numbers and unreachable nodes", () => { + const node = { type: "Chart", props: { title: "演练", chart_type: "line", source: "演练数据", x_data: ["08:00"], series: [{ name: "压力", data: [0.3] }] }, children: [] }; + const withNode = (value: unknown) => ({ ...input, spec: { root: "chart", elements: { chart: value } } }); + expect(parseAnswerUiInput(withNode(node))).not.toBeNull(); + for (const data of [[0.3, 0.4], [Infinity], [NaN]]) expect(parseAnswerUiInput(withNode({ ...node, props: { ...node.props, series: [{ name: "压力", data }] } }))).toBeNull(); + expect(parseAnswerUiInput({ ...input, spec: { ...input.spec, elements: { ...input.spec.elements, unused: node } } })).toBeNull(); + }); + it("flushes prose before the interface, publishes once per tool call, and suppresses background UI", async () => { + const part = { id: "ui-call", type: "tool", tool: "present_answer", sessionID: "s", messageID: "m", state: { status: "completed", input, output: "{}", time: { start: 1, end: 2 } } }; + const events = [ + { type: "message.part.updated", properties: { sessionID: "s", part: { id: "text", type: "text", text: "先看演练资料。", sessionID: "s", messageID: "m" } } }, + ...[part, part].map((value) => ({ type: "message.part.updated", properties: { sessionID: "s", part: value } })), + { type: "session.idle", properties: { sessionID: "s" } }, + ]; + const runtime = { prompt: async () => undefined, messages: async () => [], subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) } as unknown as OpencodeRuntimeAdapter; + for (const suppressAnswerUi of [false, true]) { + const emitted: Array<{ event: string; data: Record }> = []; + await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", suppressAnswerUi, write: (event, data) => emitted.push({ event, data }) }); + expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(suppressAnswerUi ? 0 : 1); + if (!suppressAnswerUi) expect(emitted.findIndex((event) => event.event === "token")).toBeLessThan(emitted.findIndex((event) => event.event === "answer_ui")); + } + }); + it("preserves UI-only answers and ordered rich parts in task collaboration history", () => { + const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍需核验。" }, { type: "data-final_answer", data: { content: "结论待确认。" } }]; + const messages = [{ id: "a", role: "assistant", parts: [...parts, { type: "data-progress", data: { title: "查询" } }] }, { id: "b", role: "assistant", parts: [parts[1]] }] as unknown as UIMessage[]; + const restored = toCollaborationMessages(messages); + expect(restored[0].parts).toEqual(parts); + expect(restored[1].parts).toEqual([parts[1]]); + }); +}); + +it("finishes a rich chat turn at final_answer and prevents later automatic UI revisions", async () => { + const emitted: Array<{ event: string; data: Record }> = []; + const interrupted: string[] = []; + const toolPart = (id: string, tool: string, args: unknown, output = "{}") => ({ type: "message.part.updated", properties: { sessionID: "s", part: { id, type: "tool", tool, sessionID: "s", messageID: "m", state: { status: "completed", input: args, output, time: { start: 1, end: 2 } } } } }); + const events = [toolPart("ui", "present_answer", input), toolPart("final", "final_answer", { answer: "演练结论仍待核验。" }, JSON.stringify({ answer: "演练结论仍待核验。", ui_block_ids: ["pressure", "unknown", "pressure"] })), toolPart("late", "present_answer", { ...input, block_id: "late" }), { type: "session.idle", properties: { sessionID: "s" } }]; + const runtime = { prompt: async () => undefined, messages: async () => [], abortSession: async (id: string) => { interrupted.push(id); }, waitForSessionIdle: async () => undefined, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) } as unknown as OpencodeRuntimeAdapter; + const result = await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "演练", write: (event, data) => emitted.push({ event, data }) }); + expect(result).toMatchObject({ failed: false, aborted: false }); + expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(1); + expect(interrupted).toEqual(["s"]); + expect(emitted.find((event) => event.event === "final_answer")?.data.content).toBe("演练结论仍待核验。"); + expect(emitted.find((event) => event.event === "final_answer")?.data.ui_block_ids).toEqual(["pressure"]); + expect(emitted.filter((event) => event.event === "done")).toHaveLength(1); + interrupted.length = 0; + emitted.length = 0; + await streamPromptResponse({ runtime, sessionId: "s", clientSessionId: "client", message: "后台执行", suppressAnswerUi: true, write: (event, data) => emitted.push({ event, data }) }); + expect(interrupted).toEqual([]); + expect(emitted.filter((event) => event.event === "answer_ui")).toHaveLength(0); + expect(emitted.filter((event) => event.event === "done")).toHaveLength(1); +}); diff --git a/tests/integration/answer-ui-smoke.ts b/tests/integration/answer-ui-smoke.ts new file mode 100644 index 0000000..2a86554 --- /dev/null +++ b/tests/integration/answer-ui-smoke.ts @@ -0,0 +1,35 @@ +// Opt-in native plugin discovery in an isolated database. No inference requests. +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js"; +import { startOpencode2 } from "../../src/runtime/opencode2-server.js"; + +const root = await mkdtemp(join(tmpdir(), "tjwater-answer-ui-smoke-")); +const directory = join(root, "workspace"); +await mkdir(directory); +let native: Awaited> | undefined; +let sessionID: string | undefined; +try { + native = await startOpencode2({ + command: process.env.OPENCODE_COMMAND ?? "opencode2", hostname: "127.0.0.1", port: 0, timeout: 30_000, + config: serviceOpencodeConfig(JSON.parse(await readFile("opencode.json", "utf8")), undefined, root), + env: { AGENT_INTERNAL_TOKEN: randomBytes(32).toString("hex"), TJWATER_AGENT_WORKSPACE_ROOT: root, + WATERNETWORK_API_KEY: "DISCOVERY_TEST_ONLY", XDG_DATA_HOME: join(root, "native-data"), + OPENCODE_DB: join(root, "native-data", "opencode", "opencode.db"), XDG_CONFIG_HOME: join(root, "native-config"), + XDG_CACHE_HOME: join(root, "native-cache"), XDG_STATE_HOME: join(root, "native-state") }, + }); + sessionID = (await native.client.session.create({ agent: "instruction", location: { directory } })).id; + const response = await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } }); + const result = response.output as Array<{ id: string; direct: boolean }>; + assert.ok(result.some((tool) => tool.id === "present_answer" && tool.direct), "present_answer must be registered as a direct native tool"); + assert.equal((await native.client.permission.create({ sessionID, action: "present_answer", resources: ["*"], save: [] })).effect, "allow"); + console.log("Native present_answer discovery and permission passed. No inference requests."); +} finally { + if (native && sessionID) await native.client.session.remove({ sessionID }).catch(() => {}); + native?.close(); + if (native) await native.exited; + await rm(root, { recursive: true, force: true }); +} diff --git a/tests/runtime/opencode.test.ts b/tests/runtime/opencode.test.ts index c3963de..6787d7a 100644 --- a/tests/runtime/opencode.test.ts +++ b/tests/runtime/opencode.test.ts @@ -141,7 +141,7 @@ describe("resolveEmbeddedOpencodePort", () => { }); describe("OpencodeRuntimeAdapter.warmup", () => { - const toolIds = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"]; + const toolIds = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"]; async function createWarmupRuntime(missing: string[] = [], invalidModel?: string) { const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-")); const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;