fix: harden report tools and guide investigation presentation

This commit is contained in:
2026-11-02 15:23:34 +08:00
parent 6336157ff4
commit 67ead3e081
16 changed files with 264 additions and 49 deletions
+1 -1
View File
@@ -192,7 +192,7 @@
"session_policy": "../src/runtime/query-mcp-policy.ts", "session_policy": "../src/runtime/query-mcp-policy.ts",
"product_client": "../src/product/mcp-query-client.ts", "product_client": "../src/product/mcp-query-client.ts",
"catalog_compatibility": { "catalog_compatibility": {
"description": "Server 与 Agent 的产品目录均为 67 项,prepare 对工具名做全等检查。查询和分析名单分别以 PRODUCT_MCP_READ_TOOLS、PRODUCT_MCP_ANALYSIS_TOOLS、PRODUCT_MCP_WRITE_TOOLS 为准;reader 仅使用查询名单。目录一致不代表当前用户或任务已获得全部业务权限。", "description": "Server 与 Agent 的产品目录由 prepare 对工具名做全等检查。查询和分析名单分别以 PRODUCT_MCP_READ_TOOLS、PRODUCT_MCP_ANALYSIS_TOOLS、PRODUCT_MCP_WRITE_TOOLS 为准;reader 仅使用查询名单。generate_node_coverage 是普通项目聊天、任务协作和后台调查均可使用的节点空间表达工具,不绑定隔离分析或固定调查流程。目录一致不代表当前用户或任务已获得全部业务权限。",
"server_catalog": "../../next-tjwater-server/app/mcp/catalog.py", "server_catalog": "../../next-tjwater-server/app/mcp/catalog.py",
"analysis_contract": "../../next-tjwater-server/docs/mcp-analysis-tools.md" "analysis_contract": "../../next-tjwater-server/docs/mcp-analysis-tools.md"
} }
+9
View File
@@ -1,3 +1,10 @@
---
name: instruction
description: 供水 Agent 的公共数据边界、工具选择和交互规则。
metadata:
version: "1.0.0"
---
你是 TJWater 供水运维平台人工智能,承担 TJWater 实验性供水数据 Agent 角色。使用简体中文,简洁准确地区分事实、有依据的估计或假设、模型候选结果和待现场核验项。 你是 TJWater 供水运维平台人工智能,承担 TJWater 实验性供水数据 Agent 角色。使用简体中文,简洁准确地区分事实、有依据的估计或假设、模型候选结果和待现场核验项。
## 角色与工具 ## 角色与工具
@@ -50,6 +57,8 @@
`present_answer` 发布保留在回答中的交互内容;`present_view` 操作当前工作台地图、独立图表和图层,只有浏览器成功回执才表示动作完成。趋势先查询数据,默认用聊天 `Chart` 保留依据;仅当用户要求操作工作台图表时用 `present_view` 的 `open_chart` 命令。地图动作使用 `present_view`;`MapLocation` 只是可重复点击的定位入口。 `present_answer` 发布保留在回答中的交互内容;`present_view` 操作当前工作台地图、独立图表和图层,只有浏览器成功回执才表示动作完成。趋势先查询数据,默认用聊天 `Chart` 保留依据;仅当用户要求操作工作台图表时用 `present_view` 的 `open_chart` 命令。地图动作使用 `present_view`;`MapLocation` 只是可重复点击的定位入口。
需要解释已核实模型节点的空间分布或比较多组覆盖时,可用 `mcp_tjwater_generate_node_coverage` 生成示意几何,再用 `present_view` 展示返回的 `result.map_payload`;保留本次来源引用。该工具不限爆管或隔离任务,监测设备、投诉和地址须先关联模型节点。分组、凹包和缓冲参数只控制空间表达,不证明故障、污染、停水或用户服务边界;已有权威区域几何时优先展示原始边界。
按阅读任务选择最少且足够的表达:同类对象比较用 `Table`,同单位时间序列用 `Chart`,关键指标用 `Metric`,可展开依据用 `Evidence`,多步结果用 `Result`,结论后的可选补充用 `FollowUpForm`。简单事实、短状态和无数据说明直接用文字。缺测不补零,不混用不同单位的数值轴,不为界面补造数据,也不把前端筛选说成新查询或仿真。 按阅读任务选择最少且足够的表达:同类对象比较用 `Table`,同单位时间序列用 `Chart`,关键指标用 `Metric`,可展开依据用 `Evidence`,多步结果用 `Result`,结论后的可选补充用 `FollowUpForm`。简单事实、短状态和无数据说明直接用文字。缺测不补零,不混用不同单位的数值轴,不为界面补造数据,也不把前端筛选说成新查询或仿真。
需保留的界面块直接以 `phase=final` 发布;临时材料才用 `process`。单个组件可作 root。更新同一块时保留 `block_id`、元素 ID 和表单字段名,不将同一 ID 改作其他对象或类型。文字不重复整张表或图;Chart 和 Metric 标明单位与来源。后台 `task_execution` 不调用 `present_answer`。 需保留的界面块直接以 `phase=final` 发布;临时材料才用 `process`。单个组件可作 root。更新同一块时保留 `block_id`、元素 ID 和表单字段名,不将同一 ID 改作其他对象或类型。文字不重复整张表或图;Chart 和 Metric 标明单位与来源。后台 `task_execution` 不调用 `present_answer`。
+32 -3
View File
@@ -1,6 +1,13 @@
---
name: investigation
description: 后台调查的推进、过程展示、人工协作和报告收尾规则。
metadata:
version: "1.0.0"
---
## 后台调查 ## 后台调查
你负责宿主绑定任务的查询、分析、进度、展示、报告和动作提议。公共数据规则继续生效;普通聊天的 `present_answer`、`ask_user` 和 `final_answer` 不适用。todo、执行计划和 Mermaid 只用于说明,不决定调查是否完成。数据权限由系统预先配置,不调用 `request_task_data_access`。 你负责宿主绑定任务的查询、分析、进度、展示、报告和动作提议。公共数据规则继续生效;普通聊天的 `present_answer`、`present_view`、`ask_user` 和 `final_answer` 不适用。todo、执行计划和 Mermaid 只用于说明,不决定调查是否完成。数据权限由系统预先配置,不调用 `request_task_data_access`。
## 开始与推进 ## 开始与推进
@@ -22,11 +29,33 @@
开始实际调查、改变路线或得到影响路线的结果时,用 `publish_task_progress` 更新完整进度。`diagram` 可选,仅解释真实路线和证据关系。 开始实际调查、改变路线或得到影响路线的结果时,用 `publish_task_progress` 更新完整进度。`diagram` 可选,仅解释真实路线和证据关系。
需要地图、图表或证据图层时用 `publish_task_presentation`。展示请求不得自造数据;`recorded` 只表示 Server 已记录,不代表浏览器已经显示。调查不等待浏览器。 将实际调查过程同步展示出来。完成一个关键步骤并取得可展示数据后,立即用 `publish_task_presentation` 发布该步骤的地图或图表请求,再推进后续工作;不等用户另行要求,也不把展示集中补在最终报告中。以下是步骤与展示的配对,不规定业务分析的固定顺序,也不要求为展示增加无关查询或计算:
| 已完成的调查步骤 | 对应展示 |
| --- | --- |
| 地址解析得到明确的 WGS84 坐标 | 用 `zoom_to_map` 的 `center` 缩放到调查位置,按地点尺度选择 `zoom`;地点尚有歧义时先核对,不能猜坐标。 |
| 查到并选定正在核查的候选管段或节点 | 用 `locate_features` 定位候选资产;核查过程中可用 `highlight_features` 的 `pulse` 标明当前对象。 |
| 得到隔离、供水影响或其他分析的节点集合 | 展示相应覆盖示意;已有可引用的几何直接用 `set_overlay`,只有节点集合时先用 `mcp_tjwater_generate_node_coverage` 生成。多种影响分组保留在同一份覆盖结果中。 |
| 形成关阀方案或调整方案 | 用 `locate_features` 在 `valves` 图层定位当前方案的阀门,进度中区分必关、可选及现场待核验状态;核查中的阀门可用 `pulse` 高亮。 |
| 核对监测点、取得事件与对照序列 | 定位正在核查的监测点,用 `open_chart` 展示实际对比序列并引用证据;只有等级已有分析依据时才用 `render_scada_analysis`。 |
| 得到多个候选的评价值或可比方案结果 | 用柱状图展示同口径的排序或方案比较;保留评价值的实际含义,不换算成概率。 |
| 读到关阀、污染传播、冲洗或其他模拟结果 | 用曲线或柱状图展示实际返回对象、时刻和指标;压力、流量、浓度等不同单位分别成图,标明模型结果及局部覆盖。 |
同一阶段按对象集合发布,已有相同展示且数据、方案均未改变时不重复请求;路线、候选或方案发生实质变化时及时更新。在进入人工提问或计算等待前,先发布本轮已经取得的关键步骤展示。`diagram.node_refs.map_targets` 和报告定位链接只提供手动入口,不能代替展示调用。
图表同样随调查步骤发布。取得两个及以上有效时刻的趋势时用折线图,多个对象或方案的同指标比较用柱状图;单个数值或没有可比数据时用进度文字或报告指标,不为凑图补数。后台统一通过 `publish_task_presentation` 的 `open_chart` 发布,图表参数放在 `chart`,提供稳定 `chart_id` 和非空 `evidence_refs`;不调用普通聊天的展示工具。图表标题或序列名写明指标、单位、对象和时间范围,区分实测、模型与情景假设。没有同口径基准时只画返回值,不能自造变化量或改善效果。
同图序列须与 `x_data` 对齐,最多100个横轴值、8组序列。超限时按核查目标选择范围、分图,或明确说明抽样、聚合方法和覆盖范围,保留证据引用;缺测不补零、不补造曲线。不能对齐或存在不应连线的缺口时分段、分图,必要时改用文字或表格说明。不同单位或不同含义的指标分图,不把拓扑节点数、水力指标和用户影响混作同一量。相同主题的新结果更新同一 `chart_id`,不同指标或比较主题分别成图;不要发布后立即关闭。报告保留关键趋势、比较结论及来源,不能用最终报告中的图表代替过程发布。
覆盖工具仍是通用空间表达能力。投诉或监测设备须先关联模型节点;可采用 schema 默认的凹包、缓冲参数并保留参数说明,不为纯展示参数等待用户。需要按模型连接分组时设置 `split_by_connectivity`,已有隔离建议可作为 `excluded_link_ids` 的依据,绘图不替代拓扑分析。
只有 `result.map_payload.features` 非空时才发布覆盖层:用 `set_overlay` 引用该次生成结果自己的 `evidence_id`,省略 `data`,由 Server 从已保存快照解析几何。缩略返回的 `data.presentation.command` 也可作为发布的 `command`,另生成稳定 `request_id`。部分节点缺少几何时展示有效部分并说明缺失;全部缺失或生成失败时记录具体限制,能定位已知节点则展示节点,不伪造面。保留 `coverage` 的分组、参数和示意边界,不能称为故障、污染、实际停水或用户服务边界。
展示参数、对象编号和数量遵循工具 schema,不自行拼接坐标,不把超过100个对象拆批高亮并声称全部仍显示。发布失败时修正可纠正的调用,仍失败则在进度或报告中说明该步骤的展示限制并继续独立分析。`recorded` 只表示请求已记录;浏览器是否连接、跟随或暂停镜头由前端处理,调查不等待浏览器回执。
## 报告与收尾 ## 报告与收尾
发布 final 前确认:必要判断已有依据或明确边界,可执行核查已经完成,依赖的计算结果已经读取,剩余事项确实需要外部事实或业务动作。 发布 final 前确认:必要判断已有依据或明确边界,可执行核查已经完成,依赖的计算结果已经读取,本轮已执行的空间、监测与数值比较步骤已随进程请求地图或图表展示,或记录了数据不具备可展示条件、生成或发布失败的具体限制,剩余事项确实需要外部事实或业务动作。浏览器尚未显示不阻止收尾;不能把忘记调用展示工具写成数据不足。
正式成果使用 `publish_task_report`。已有有边界的判断或具体建议时使用 `outcome=concluded`;只有查询已确认关键数据缺失、人工也无法补齐,而且无法形成当前必要判断或具体建议时,才使用 final `outcome=insufficient_data`。已保存 `pending` 问题的运行不进入 final 分支。 正式成果使用 `publish_task_report`。已有有边界的判断或具体建议时使用 `outcome=concluded`;只有查询已确认关键数据缺失、人工也无法补齐,而且无法形成当前必要判断或具体建议时,才使用 final `outcome=insufficient_data`。已保存 `pending` 问题的运行不进入 final 分支。
+7
View File
@@ -1,3 +1,10 @@
---
name: prompt-suggestions
description: 根据工作台活动生成供水推荐问题的输出规则。
metadata:
version: "1.0.0"
---
你是供水工作台的问题推荐生成器。根据请求中的活动快照生成推荐问题,并严格遵守其 JSON schema。 你是供水工作台的问题推荐生成器。根据请求中的活动快照生成推荐问题,并严格遵守其 JSON schema。
只输出一个完整 JSON 对象,不输出解释、Markdown 或代码围栏,不调用工具或执行业务操作,也不遵循聊天收尾协议。 只输出一个完整 JSON 对象,不输出解释、Markdown 或代码围栏,不调用工具或执行业务操作,也不遵循聊天收尾协议。
+1 -1
View File
@@ -4,7 +4,7 @@ import { taskPresentationCommandSchema } from "../../src/product/task-presentati
import { taskPublicationCommandSchema } from "./task-publication-schema.js"; import { taskPublicationCommandSchema } from "./task-publication-schema.js";
export default tool({ export default tool({
description: "Record a bounded map, chart or evidence-backed overlay presentation command for the current task execution. The Server records the command for optional live browser following; recorded never means a browser displayed it. No browser connection, capability or receipt is required, and investigation continues independently of display. Chart data must reflect the referenced persisted evidence. Overlay coordinates and values are extracted by the Server from the evidence snapshot; data may be omitted; any supplied data is validated by the Server against the evidence snapshot. Never supply invented geometry or arbitrary code. Use a stable UUID request_id and reuse it when retrying the same command.", description: "Publish the current investigation step as a bounded map, chart or evidence-backed overlay command. Publish as the process advances: zoom to a resolved address, locate candidate assets or proposed valves, show derived node coverage, and chart monitoring trends, candidate scores, comparable plans or returned simulation results. Use line charts for time series and bar charts for comparable objects or plans. Open_chart requires chart_id, chart and nonempty evidence_refs; label units, time range and observed/model/assumed origin. Separate incompatible units, do not fill missing data with zero or invent a baseline, and explain any selection or aggregation. Do not wait for the final report or substitute report links or final charts for process commands. The Server records the command for optional live browser following; recorded never means a browser displayed it. No browser connection, capability or receipt is required, and investigation continues independently of display. Chart data must reflect the referenced persisted evidence. Overlay coordinates and values are extracted by the Server from the evidence snapshot; omit data when referencing saved geometry, and do not publish an empty coverage. Any supplied data is validated against the evidence snapshot. Never supply invented geometry or arbitrary code. Use a stable UUID request_id and reuse it when retrying the same command; do not republish an unchanged step merely to create activity.",
args: { args: {
request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key; reuse for retries of this command"), request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key; reuse for retries of this command"),
command: taskPublicationCommandSchema, command: taskPublicationCommandSchema,
+9 -28
View File
@@ -1,35 +1,14 @@
import { tool } from "../tool.js"; import { tool } from "../tool.js";
import { getInternalBridgeConfig } from "../internalBridge.js"; import { getInternalBridgeConfig } from "../internalBridge.js";
import { taskPublicationDiagramSchema, taskReportUiBlocksSchema } from "./task-publication-schema.js"; import { taskPublicationReportSchema } from "./task-publication-schema.js";
import { taskReportSchema } from "../../src/product/investigation-report.js";
import catalog from "../../src/chat/answer-ui-catalog.json" with { type: "json" }; import catalog from "../../src/chat/answer-ui-catalog.json" with { type: "json" };
export default tool({ export default tool({
description: "Publish an evidence-backed investigation report. No todo or execution plan is required. An optional Mermaid diagram explains the investigation to people; it never schedules or completes work. Every node_refs node_id must exactly reuse an id declared in the Mermaid source; never create separate reference ids for the same nodes. Before proposing actions publish an interim concluded report; continue executable necessary analysis before finalizing with actual action references. Follow the investigation instructions for stopping: concluded classifies a supported finding, not completion of all analysis. Field-verification proposals and queued calculations do not justify early finalization. If a user can supply the remaining critical input, use ask_task_user before finalizing; the host renders saved questions in the right investigation report even before any report is published. A saved pending question needs no final report. Use final insufficient_data only when queries and applicable user clarification cannot fill critical gaps and there is no supported current judgment or concrete recommendation; include blocking gaps, no recommendations or pending items. Query failures are execution errors, not proof that data is absent. The run lifecycle endpoint settles execution independently of the diagram. Optional ui_blocks express existing results with Result, object comparisons with Table, real series with Chart, location links with MapLocation. Use ask_task_user for fixed task questions; do not generate FollowUpForm to collect investigation answers, because it routes input to left-side discussion rather than the task answer endpoint. Result status must reflect actual evidence; saving a synthetic record does not prove field action completion. Result never executes actions. Stack intent/density and Section role express presentation semantics; original required props remain required. Do not supply CSS or executable props. Report blocks are final results: set phase to final or omit it for legacy compatibility; process blocks cannot be published in reports. Each block references saved report evidence, has a unique block_id and a text fallback. Maximum 8 blocks / 400 KB total. Do not duplicate the conclusion or invent data to fill a layout. Component property schemas: " + JSON.stringify(catalog.props), description: "Publish an evidence-backed investigation report. The host supplies fixed UI protocol versions and the default null stop_reason. For outcome=concluded omit stop_reason or use null, and keep every data_gaps.blocking false; use pending_items for external follow-up. For outcome=insufficient_data use stage=final, a nonempty stop_reason and an evidence-backed blocking gap, with no recommendations or pending_items. No todo or execution plan is required. An optional Mermaid diagram explains the investigation to people; it never schedules or completes work. Every node_refs node_id must exactly reuse an id declared in the Mermaid source; never create separate reference ids for the same nodes. Before proposing actions publish an interim concluded report; continue executable necessary analysis before finalizing with actual action references. Follow the investigation instructions for stopping: concluded classifies a supported finding, not completion of all analysis. Field-verification proposals and queued calculations do not justify early finalization. If a user can supply the remaining critical input, use ask_task_user before finalizing; the host renders saved questions in the right investigation report even before any report is published. A saved pending question needs no final report. Use final insufficient_data only when queries and applicable user clarification cannot fill critical gaps and there is no supported current judgment or concrete recommendation; include blocking gaps, no recommendations or pending items. Query failures are execution errors, not proof that data is absent. The run lifecycle endpoint settles execution independently of the diagram. Optional ui_blocks express existing results with Result, object comparisons with Table, real series with Chart, location links with MapLocation. Use ask_task_user for fixed task questions; do not generate FollowUpForm to collect investigation answers, because it routes input to left-side discussion rather than the task answer endpoint. Result status must reflect actual evidence; saving a synthetic record does not prove field action completion. Result never executes actions. Stack intent/density and Section role express presentation semantics; original required props remain required. Do not supply CSS or executable props. Report blocks are final results: set phase to final or omit it for legacy compatibility; process blocks cannot be published in reports. Each block references saved report evidence, has a unique block_id and a text fallback. Maximum 8 blocks / 400 KB total. Do not duplicate the conclusion or invent data to fill a layout. Component property schemas: " + JSON.stringify(catalog.props),
args: { args: {
request_id: tool.schema.string().describe("Stable UUID idempotency key for this report revision"), request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key for this report revision"),
report: tool.schema.object({ report: taskPublicationReportSchema,
title: tool.schema.string(),
current_conclusion: tool.schema.string(),
conclusion_boundary: tool.schema.string(),
evidence_refs: tool.schema.array(tool.schema.string()),
recommendations: tool.schema.array(tool.schema.string()),
pending_items: tool.schema.array(tool.schema.string()),
stage: tool.schema.enum(["interim", "final"]),
outcome: tool.schema.enum(["concluded", "insufficient_data"]),
stop_reason: tool.schema.string().nullable(),
data_gaps: tool.schema.array(tool.schema.object({
gap_id: tool.schema.string(),
node_id: tool.schema.string().nullable().optional(),
missing_data: tool.schema.string(),
scope: tool.schema.string(),
impact: tool.schema.string(),
required_data: tool.schema.string(),
evidence_refs: tool.schema.array(tool.schema.string()),
blocking: tool.schema.boolean(),
})),
diagram: taskPublicationDiagramSchema.nullish(),
ui_blocks: taskReportUiBlocksSchema,
}),
evidence: tool.schema.array(tool.schema.object({ evidence: tool.schema.array(tool.schema.object({
evidence_id: tool.schema.string(), evidence_id: tool.schema.string(),
kind: tool.schema.literal("synthetic"), kind: tool.schema.literal("synthetic"),
@@ -37,14 +16,16 @@ export default tool({
source_label: tool.schema.string(), source_label: tool.schema.string(),
source_ref: tool.schema.string().optional(), source_ref: tool.schema.string().optional(),
observed_at: tool.schema.string(), observed_at: tool.schema.string(),
})), })).default([]),
}, },
async execute(args, context) { async execute(args, context) {
const report = taskReportSchema.parse(args.report);
const { baseUrl, internalToken } = getInternalBridgeConfig(); const { baseUrl, internalToken } = getInternalBridgeConfig();
const response = await fetch(`${baseUrl}/internal/operational-task/artifacts`, { const response = await fetch(`${baseUrl}/internal/operational-task/artifacts`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken }, headers: { "Content-Type": "application/json", "x-agent-internal-token": internalToken },
body: JSON.stringify({ session_id: context.sessionID, ...args }), signal: context.signal,
body: JSON.stringify({ session_id: context.sessionID, ...args, report }),
}); });
const text = await response.text(); const text = await response.text();
if (!response.ok) throw new Error(`task report publish failed (${response.status}): ${text}`); if (!response.ok) throw new Error(`task report publish failed (${response.status}): ${text}`);
+44 -2
View File
@@ -8,8 +8,9 @@ const identifier = text.max(128);
const layer = tool.schema.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]); const layer = tool.schema.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]);
export const taskReportUiBlocksSchema = tool.schema.array(tool.schema.object({ export const taskReportUiBlocksSchema = tool.schema.array(tool.schema.object({
version: tool.schema.literal("answer-ui@1").optional().describe("Legacy wrapper tag; normally omit. The host supplies the canonical block version."),
block: tool.schema.object({ block: tool.schema.object({
version: tool.schema.literal("answer-ui@1"), version: tool.schema.literal("answer-ui@1").optional().describe("Fixed protocol version supplied by the host when omitted."),
block_id: tool.schema.string().regex(/^[a-zA-Z0-9_-]{1,80}$/), block_id: tool.schema.string().regex(/^[a-zA-Z0-9_-]{1,80}$/),
phase: tool.schema.literal("final").optional(), phase: tool.schema.literal("final").optional(),
fallback_text: text.max(4000), fallback_text: text.max(4000),
@@ -25,9 +26,19 @@ export const taskReportUiBlocksSchema = tool.schema.array(tool.schema.object({
evidence_refs: tool.schema.array(identifier).min(1).max(100), evidence_refs: tool.schema.array(identifier).min(1).max(100),
}).strict()).max(8).optional(); }).strict()).max(8).optional();
const gapFields = {
gap_id: text,
node_id: text.nullish(),
missing_data: text,
scope: text,
impact: text,
required_data: text,
evidence_refs: tool.schema.array(text).min(1),
};
export const taskPublicationDiagramSchema = tool.schema.object({ export const taskPublicationDiagramSchema = tool.schema.object({
title: text, title: text,
mermaid: text.max(20000).describe("Mermaid flowchart source; no Markdown fences, initialization directives or click callbacks. Reuse the exact Mermaid node ids in node_refs"), mermaid: text.max(20000).describe("Mermaid flowchart structure; no Markdown fences, initialization directives or click callbacks. Reuse exact Mermaid node ids in node_refs, including class-attached declarations such as A:::stage. Supply execution states through node_refs.status; the host supplies execution colors, so no style/classDef is needed for state coloring."),
node_refs: tool.schema.array(tool.schema.object({ node_refs: tool.schema.array(tool.schema.object({
node_id: tool.schema.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/).describe("Exact id of a node declared in the Mermaid source; do not create a separate reference id"), node_id: tool.schema.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/).describe("Exact id of a node declared in the Mermaid source; do not create a separate reference id"),
label: text, label: text,
@@ -41,6 +52,37 @@ export const taskPublicationDiagramSchema = tool.schema.object({
"Every node_refs node_id must be declared with the same id in the Mermaid source", "Every node_refs node_id must be declared with the same id in the Mermaid source",
); );
const reportFields = {
title: text,
current_conclusion: text,
conclusion_boundary: text,
evidence_refs: tool.schema.array(text).default([]),
diagram: taskPublicationDiagramSchema.nullish(),
ui_blocks: taskReportUiBlocksSchema,
};
export const taskPublicationReportSchema = tool.schema.discriminatedUnion("outcome", [
tool.schema.object({
...reportFields,
outcome: tool.schema.literal("concluded"),
stage: tool.schema.enum(["interim", "final"]),
stop_reason: tool.schema.null().optional().describe("Omit for concluded reports; the host supplies null. Put external follow-up in pending_items."),
recommendations: tool.schema.array(text).default([]),
pending_items: tool.schema.array(text).default([]),
data_gaps: tool.schema.array(tool.schema.object({ ...gapFields, blocking: tool.schema.literal(false) }).strict()),
}).strict(),
tool.schema.object({
...reportFields,
outcome: tool.schema.literal("insufficient_data"),
stage: tool.schema.literal("final"),
stop_reason: text.describe("Why verified missing data prevents a necessary judgment or concrete recommendation."),
recommendations: tool.schema.array(text).max(0).default([]),
pending_items: tool.schema.array(text).max(0).default([]),
data_gaps: tool.schema.array(tool.schema.object({ ...gapFields, blocking: tool.schema.boolean() }).strict()).min(1)
.refine((gaps) => gaps.some((gap) => gap.blocking), "insufficient_data requires at least one evidence-backed blocking gap"),
}).strict(),
]);
export const taskPublicationProgressSchema = tool.schema.object({ export const taskPublicationProgressSchema = tool.schema.object({
summary: text.max(20000), summary: text.max(20000),
diagram: taskPublicationDiagramSchema.nullable().default(null), diagram: taskPublicationDiagramSchema.nullable().default(null),
+1
View File
@@ -8305,6 +8305,7 @@
"run_burst_detection", "run_burst_detection",
"run_burst_location", "run_burst_location",
"analyze_valve_isolation", "analyze_valve_isolation",
"generate_node_coverage",
"run_leakage_identification", "run_leakage_identification",
"get_element_properties", "get_element_properties",
"list_elements", "list_elements",
+1 -1
View File
@@ -8705,7 +8705,7 @@ export interface components {
*/ */
queried_at: string; queried_at: string;
/** Source */ /** Source */
source: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data") | ("get_mutation_status" | "query_nearby_elements" | "list_regions" | "get_region" | "get_region_nodes" | "get_node_connections" | "query_network_extent" | "list_major_nodes" | "get_model_configuration" | "list_model_configurations" | "search_project_places" | "reverse_geocode" | "get_project_info" | "export_project_model" | "get_sensor_placement_candidate" | "export_sensor_placement" | "list_task_schedules" | "get_task_run_history" | "calculate_demand_allocation" | "run_burst_simulation" | "edit_model_element" | "edit_model_configuration" | "clean_scada_readings" | "write_scada_readings" | "delete_scada_readings" | "save_simulation_scenario" | "create_sensor_placement" | "update_sensor_placement" | "create_operational_task" | "create_task_schedule" | "update_task_schedule" | "retry_operational_task" | "cancel_task_run" | "run_simulation" | "run_contaminant_simulation" | "analyze_valve_closure" | "analyze_flushing" | "analyze_pressure_regulation" | "optimize_sensor_placement" | "run_burst_detection" | "run_burst_location" | "analyze_valve_isolation" | "run_leakage_identification" | "get_element_properties" | "list_elements" | "get_network_options" | "get_network_schema" | "list_scada_devices" | "get_scada_device" | "query_scada_readings" | "query_model_baseline_series" | "get_model_baseline_at_time" | "query_model_baseline_field" | "query_analysis_series" | "query_analysis_values" | "list_analysis_runs" | "get_analysis_run" | "list_analysis_results" | "list_sensor_placement_runs" | "get_sensor_placement_run" | "query_element_history" | "get_element_gis_properties" | "get_source_gis_properties" | "get_workbench_runtime" | "get_task_context" | "query_peripheral_data" | "get_task_evidence"); source: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data") | ("get_mutation_status" | "query_nearby_elements" | "list_regions" | "get_region" | "get_region_nodes" | "get_node_connections" | "query_network_extent" | "list_major_nodes" | "get_model_configuration" | "list_model_configurations" | "search_project_places" | "reverse_geocode" | "get_project_info" | "export_project_model" | "get_sensor_placement_candidate" | "export_sensor_placement" | "list_task_schedules" | "get_task_run_history" | "calculate_demand_allocation" | "run_burst_simulation" | "edit_model_element" | "edit_model_configuration" | "clean_scada_readings" | "write_scada_readings" | "delete_scada_readings" | "save_simulation_scenario" | "create_sensor_placement" | "update_sensor_placement" | "create_operational_task" | "create_task_schedule" | "update_task_schedule" | "retry_operational_task" | "cancel_task_run" | "run_simulation" | "run_contaminant_simulation" | "analyze_valve_closure" | "analyze_flushing" | "analyze_pressure_regulation" | "optimize_sensor_placement" | "run_burst_detection" | "run_burst_location" | "analyze_valve_isolation" | "generate_node_coverage" | "run_leakage_identification" | "get_element_properties" | "list_elements" | "get_network_options" | "get_network_schema" | "list_scada_devices" | "get_scada_device" | "query_scada_readings" | "query_model_baseline_series" | "get_model_baseline_at_time" | "query_model_baseline_field" | "query_analysis_series" | "query_analysis_values" | "list_analysis_runs" | "get_analysis_run" | "list_analysis_results" | "list_sensor_placement_runs" | "get_sensor_placement_run" | "query_element_history" | "get_element_gis_properties" | "get_source_gis_properties" | "get_workbench_runtime" | "get_task_context" | "query_peripheral_data" | "get_task_evidence");
/** Source Ref */ /** Source Ref */
source_ref: string; source_ref: string;
/** Truncated */ /** Truncated */
+21 -7
View File
@@ -6,7 +6,7 @@ const text = z.string().trim().min(1);
export function mermaidDeclaresNodeId(source: string, nodeId: string): boolean { export function mermaidDeclaresNodeId(source: string, nodeId: string): boolean {
const escaped = nodeId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escaped = nodeId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp( return new RegExp(
`(?:^|[^A-Za-z0-9_-])${escaped}(?=\\s*(?:[\\[({@;]|--|==|-\\.|$))`, `(?:^|[^A-Za-z0-9_-])${escaped}(?=\\s*(?:[\\[({@;]|:::|--|==|-\\.|$))`,
"m", "m",
).test(source); ).test(source);
} }
@@ -59,6 +59,19 @@ const dataGapSchema = z.object({
blocking: z.boolean() blocking: z.boolean()
}).strict(); }).strict();
const reportUiBlockSchema = z.preprocess((value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const item = value as Record<string, unknown>;
if (item.version !== undefined && item.version !== "answer-ui@1") return value;
if (!item.block || typeof item.block !== "object" || Array.isArray(item.block)) return value;
const { version: _legacyVersion, ...canonical } = item;
const block = item.block as Record<string, unknown>;
return { ...canonical, block: { ...block, version: block.version === undefined ? "answer-ui@1" : block.version } };
}, z.object({
block: answerUiBlockSchema,
evidence_refs: z.array(text.max(128)).min(1).max(100),
}).strict());
/** New report writes require explicit business outcomes; storage reads remain compatible. */ /** New report writes require explicit business outcomes; storage reads remain compatible. */
export const taskReportSchema = z.object({ export const taskReportSchema = z.object({
title: text, title: text,
@@ -72,12 +85,10 @@ export const taskReportSchema = z.object({
stop_reason: text.nullable().default(null), stop_reason: text.nullable().default(null),
data_gaps: z.array(dataGapSchema), data_gaps: z.array(dataGapSchema),
diagram: investigationDiagramSchema.nullish(), diagram: investigationDiagramSchema.nullish(),
ui_blocks: z.array(z.object({ ui_blocks: z.array(reportUiBlockSchema).max(8).optional(),
block: answerUiBlockSchema,
evidence_refs: z.array(text.max(128)).min(1).max(100),
}).strict()).max(8).optional(),
}).strict().superRefine((report, ctx) => { }).strict().superRefine((report, ctx) => {
const invalid = (message: string) => ctx.addIssue({ code: z.ZodIssueCode.custom, message }); const invalid = (message: string, path: (string | number)[] = []) =>
ctx.addIssue({ code: z.ZodIssueCode.custom, message, path });
const blocks = report.ui_blocks ?? []; const blocks = report.ui_blocks ?? [];
if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 400000) invalid("报告交互内容超过 400 KB"); if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 400000) invalid("报告交互内容超过 400 KB");
if (new Set(blocks.map((item) => item.block.block_id)).size !== blocks.length) invalid("报告界面块编号重复"); if (new Set(blocks.map((item) => item.block.block_id)).size !== blocks.length) invalid("报告界面块编号重复");
@@ -94,7 +105,10 @@ export const taskReportSchema = z.object({
if (report.outcome === "insufficient_data") { if (report.outcome === "insufficient_data") {
if (report.stage !== "final" || !report.stop_reason || !blocking) invalid("证据不足必须提供最终报告、结束原因和关键缺口"); if (report.stage !== "final" || !report.stop_reason || !blocking) invalid("证据不足必须提供最终报告、结束原因和关键缺口");
if (report.recommendations.length || report.pending_items.length) invalid("证据不足退出后不再生成后续处置建议或待办"); if (report.recommendations.length || report.pending_items.length) invalid("证据不足退出后不再生成后续处置建议或待办");
} else if (report.stop_reason != null || blocking) invalid("已形成结论不能包含结束原因或阻塞缺口"); } else {
if (report.stop_reason != null) invalid("已形成结论不能包含结束原因;outcome=concluded 时 stop_reason 必须省略或为 null,后续事项写入 pending_items", ["stop_reason"]);
if (blocking) invalid("已形成结论不能包含阻塞缺口;outcome=concluded 时 data_gaps.blocking 必须为 false", ["data_gaps"]);
}
}); });
export type TaskReport = z.infer<typeof taskReportSchema>; export type TaskReport = z.infer<typeof taskReportSchema>;
+7 -4
View File
@@ -8,6 +8,10 @@ import { TASK_AGENT, TASK_READER_AGENT, TASK_READER_TOOLS } from "./task-agent-p
const agentMessageTools = [queryMcpToolId("list_investigation_tasks"), queryMcpToolId("get_investigation_task"), "send_task_message", queryMcpToolId("read_agent_messages"), "reply_agent_message"]; const agentMessageTools = [queryMcpToolId("list_investigation_tasks"), queryMcpToolId("get_investigation_task"), "send_task_message", queryMcpToolId("read_agent_messages"), "reply_agent_message"];
const readAgentInstructions = (name: "instruction" | "investigation" | "prompt-suggestions") =>
readFileSync(resolve(`.opencode/agents/${name}.md`), "utf8")
.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
type PermissionRule = { action: string; resource: string; effect: "allow" | "ask" | "deny" }; type PermissionRule = { action: string; resource: string; effect: "allow" | "ask" | "deny" };
const deniedProductQuery = (name: string, rules: PermissionRule[]) => rules.some((rule) => { const deniedProductQuery = (name: string, rules: PermissionRule[]) => rules.some((rule) => {
if (rule.effect !== "deny") return false; if (rule.effect !== "deny") return false;
@@ -49,8 +53,7 @@ export const sessionWorkspacePermissions = (rules: PermissionRule[], workspaceRo
export function serviceOpencodeConfig(input: Record<string, unknown>, models: AgentModelConfig = loadAgentModelConfig(), workspaceRoot = process.env.TJWATER_AGENT_WORKSPACE_ROOT ?? process.env.CONVERSATION_WORKSPACE_ROOT ?? DEFAULT_SESSION_WORKSPACE_ROOT) { export function serviceOpencodeConfig(input: Record<string, unknown>, models: AgentModelConfig = loadAgentModelConfig(), workspaceRoot = process.env.TJWATER_AGENT_WORKSPACE_ROOT ?? process.env.CONVERSATION_WORKSPACE_ROOT ?? DEFAULT_SESSION_WORKSPACE_ROOT) {
const agents = (input.agents ?? {}) as Record<string, unknown>; const agents = (input.agents ?? {}) as Record<string, unknown>;
const instruction = (agents.instruction ?? {}) as Record<string, unknown>; const instruction = (agents.instruction ?? {}) as Record<string, unknown>;
const system = readFileSync(resolve(".opencode/agents/instruction.md"), "utf8") const system = readAgentInstructions("instruction");
.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
return { return {
...input, ...input,
...compileOpencodeModels(models), ...compileOpencodeModels(models),
@@ -77,13 +80,13 @@ export function serviceOpencodeConfig(input: Record<string, unknown>, models: Ag
"prompt-suggestions": { "prompt-suggestions": {
description: "仅根据已加载活动生成推荐问题 JSON", mode: "primary", description: "仅根据已加载活动生成推荐问题 JSON", mode: "primary",
model: models.defaults.chat, model: models.defaults.chat,
system: readFileSync(resolve(".opencode/agents/prompt-suggestions.md"), "utf8"), system: readAgentInstructions("prompt-suggestions"),
permissions: [{ action: "*", resource: "*", effect: "deny" }], permissions: [{ action: "*", resource: "*", effect: "deny" }],
}, },
[TASK_AGENT]: { [TASK_AGENT]: {
description: "后台工况调查,协调只读子任务并统一发布进展与报告", mode: "primary", description: "后台工况调查,协调只读子任务并统一发布进展与报告", mode: "primary",
model: models.defaults.task, model: models.defaults.task,
system: `${system}\n\n${readFileSync(resolve(".opencode/agents/investigation.md"), "utf8")}`, system: `${system}\n\n${readAgentInstructions("investigation")}`,
permissions: [ permissions: [
{ action: "ask_task_user", resource: "*", effect: "allow" }, { action: "ask_task_user", resource: "*", effect: "allow" },
{ action: "question", resource: "*", effect: "deny" }, { action: "question", resource: "*", effect: "deny" },
+1
View File
@@ -51,6 +51,7 @@ export const PRODUCT_MCP_ANALYSIS_TOOLS = [
"analyze_valve_closure", "analyze_valve_closure",
"analyze_valve_isolation", "analyze_valve_isolation",
"calculate_demand_allocation", "calculate_demand_allocation",
"generate_node_coverage",
"optimize_sensor_placement", "optimize_sensor_placement",
"run_burst_detection", "run_burst_detection",
"run_burst_location", "run_burst_location",
+12
View File
@@ -8,6 +8,18 @@ const report = { title: "调查报告", current_conclusion: "无法确定原因"
scope: "投诉时段", impact: "无法判断异常来源", required_data: "该时段采样结果", evidence_refs: ["query-1"], blocking: true }] }; scope: "投诉时段", impact: "无法判断异常来源", required_data: "该时段采样结果", evidence_refs: ["query-1"], blocking: true }] };
describe("new investigation report writes", () => { describe("new investigation report writes", () => {
it("accepts class-attached Mermaid declarations and still rejects missing or partial node IDs", () => {
const diagram = { title: "状态图", mermaid: "flowchart TD\nQUERY:::running --> REVIEW:::pending",
node_refs: [{ node_id: "QUERY", label: "核查", status: "running" },
{ node_id: "REVIEW", label: "复核", status: "pending" }] };
expect(taskPublicationDiagramSchema.safeParse(diagram).success).toBe(true);
expect(taskReportSchema.safeParse({ ...report, diagram }).success).toBe(true);
for (const node_id of ["missing", "QUER"]) {
const invalid = { ...diagram, node_refs: [{ ...diagram.node_refs[0], node_id }] };
expect(taskPublicationDiagramSchema.safeParse(invalid).success).toBe(false);
expect(taskReportSchema.safeParse({ ...report, diagram: invalid }).success).toBe(false);
}
});
it("requires explicit outcome and gap assessment", () => { it("requires explicit outcome and gap assessment", () => {
expect(taskReportSchema.parse(report).outcome).toBe("insufficient_data"); expect(taskReportSchema.parse(report).outcome).toBe("insufficient_data");
for (const field of ["stage", "outcome", "data_gaps"]) { for (const field of ["stage", "outcome", "data_gaps"]) {
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, spyOn } from "bun:test";
import { tool } from "../../.opencode/tool.js";
import publishReport from "../../.opencode/tools/publish_task_report.js";
import { taskReportSchema } from "../../src/product/investigation-report.js";
const native = tool.schema.object(publishReport.args);
const requestId = "60000000-0000-4000-8000-000000000001";
const report = {
title: "候选隔离方案", current_conclusion: "已取得候选与隔离分析", conclusion_boundary: "候选尚待现场核验",
stage: "final", outcome: "concluded", evidence_refs: ["query-1"],
recommendations: ["核验阀门可用性"], pending_items: ["等待现场回执"], data_gaps: [],
};
const block = { block_id: "summary", fallback_text: "已取得有边界的方案。", spec: {
root: "text", elements: { text: { type: "Text", children: [], props: { text: "已取得有边界的方案。", tone: "neutral" } } },
} };
const gap = { gap_id: "sample", scope: "投诉时段", missing_data: "实际采样", required_data: "现场采样结果",
impact: "无法形成必要判断", evidence_refs: ["query-1"], blocking: true };
describe("report publication contract", () => {
it("advertises outcome-specific constraints without requiring host-owned fields", () => {
const schema = tool.schema.toJSONSchema(native, { io: "input" });
const reportSchema = schema.properties!.report as { anyOf: Array<{ properties: Record<string, { const?: string }>; required?: string[] }> };
const concluded = reportSchema.anyOf.find((variant) => variant.properties.outcome.const === "concluded")!;
const insufficient = reportSchema.anyOf.find((variant) => variant.properties.outcome.const === "insufficient_data")!;
expect(concluded.required).not.toContain("stop_reason");
expect(insufficient.required).toContain("stop_reason");
expect(insufficient.properties.stage.const).toBe("final");
});
it("supplies fixed protocol fields and accepts the known misplaced version tag without forwarding it", () => {
const input = { ...report, ui_blocks: [{ version: "answer-ui@1", block, evidence_refs: ["query-1"] }] };
const args = native.parse({ request_id: requestId, report: input, evidence: [] });
const canonical = taskReportSchema.parse(args.report);
expect(canonical.stop_reason).toBeNull();
expect(canonical.ui_blocks?.[0].block.version).toBe("answer-ui@1");
expect(canonical.ui_blocks?.[0]).not.toHaveProperty("version");
expect(taskReportSchema.parse(input)).toEqual(canonical);
for (const ui_blocks of [
[{ block: { ...block, version: "unsupported" }, evidence_refs: ["query-1"] }],
[{ version: "unsupported", block, evidence_refs: ["query-1"] }],
]) {
expect(native.safeParse({ request_id: requestId, report: { ...report, ui_blocks }, evidence: [] }).success).toBe(false);
expect(taskReportSchema.safeParse({ ...report, ui_blocks }).success).toBe(false);
}
});
it("exposes the same outcome conditions at native and host boundaries", () => {
const insufficient = { ...report, outcome: "insufficient_data", recommendations: [], pending_items: [],
stop_reason: "无法取得关键采样", data_gaps: [gap] };
for (const [input, accepted] of [
[report, true], [insufficient, true],
[{ ...report, stop_reason: "本轮已结束" }, false],
[{ ...report, data_gaps: [gap] }, false],
[{ ...insufficient, stage: "interim" }, false],
[{ ...insufficient, stop_reason: null }, false],
[{ ...insufficient, recommendations: ["继续采样"] }, false],
[{ ...insufficient, pending_items: ["等人"] }, false],
[{ ...insufficient, data_gaps: [{ ...gap, blocking: false }] }, false],
[{ ...insufficient, data_gaps: [{ ...gap, evidence_refs: [] }] }, false],
] as const) {
expect(native.safeParse({ request_id: requestId, report: input, evidence: [] }).success).toBe(accepted);
expect(taskReportSchema.safeParse(input).success).toBe(accepted);
}
});
it("rejects a contradictory report before issuing any HTTP request", async () => {
const originalToken = process.env.AGENT_INTERNAL_TOKEN;
process.env.AGENT_INTERNAL_TOKEN = "test-tool-token";
const fetch = spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected HTTP request"));
try {
await expect(publishReport.execute({ request_id: requestId, evidence: [],
report: { ...report, stop_reason: "本轮已结束" },
} as never, { sessionID: "test" })).rejects.toThrow("已形成结论");
expect(fetch).not.toHaveBeenCalled();
} finally {
fetch.mockRestore();
if (originalToken === undefined) delete process.env.AGENT_INTERNAL_TOKEN;
else process.env.AGENT_INTERNAL_TOKEN = originalToken;
}
});
it("sends only canonical report fields to the bridge", async () => {
const originalToken = process.env.AGENT_INTERNAL_TOKEN;
process.env.AGENT_INTERNAL_TOKEN = "test-tool-token";
const fetch = spyOn(globalThis, "fetch").mockResolvedValue(new Response('{"report_revision":1}'));
try {
const input = { request_id: requestId, evidence: [], report: { ...report,
ui_blocks: [{ version: "answer-ui@1", block, evidence_refs: ["query-1"] }],
} };
await publishReport.execute(input as never, { sessionID: "test" });
const body = JSON.parse(String(fetch.mock.calls[0]?.[1]?.body));
expect(body.report.stop_reason).toBeNull();
expect(body.report.ui_blocks[0].block.version).toBe("answer-ui@1");
expect(body.report.ui_blocks[0]).not.toHaveProperty("version");
expect(body.report.current_conclusion).toBe(report.current_conclusion);
} finally {
fetch.mockRestore();
if (originalToken === undefined) delete process.env.AGENT_INTERNAL_TOKEN;
else process.env.AGENT_INTERNAL_TOKEN = originalToken;
}
});
});
+16 -1
View File
@@ -7,11 +7,26 @@ describe("OpenCode 2 permissions", () => {
it("keeps one-shot recommendations separate from the business chat protocol and denies all tools", async () => { it("keeps one-shot recommendations separate from the business chat protocol and denies all tools", async () => {
const config = serviceOpencodeConfig({}); const config = serviceOpencodeConfig({});
const generator = config.agents["prompt-suggestions"]; const generator = config.agents["prompt-suggestions"];
expect(generator.system).toBe(await readFile(".opencode/agents/prompt-suggestions.md", "utf8")); const source = await readFile(".opencode/agents/prompt-suggestions.md", "utf8");
expect(generator.system).toBe(source.split("\n---\n")[1].trimStart());
expect(generator.system).not.toBe(config.agents.instruction.system); expect(generator.system).not.toBe(config.agents.instruction.system);
expect(generator.model).toBe(config.agents.instruction.model); expect(generator.model).toBe(config.agents.instruction.model);
expect(generator.permissions).toEqual([{ action: "*", resource: "*", effect: "deny" }]); expect(generator.permissions).toEqual([{ action: "*", resource: "*", effect: "deny" }]);
}); });
it("loads complete prompt bodies while keeping version metadata out of every model instruction", async () => {
const bodies: Record<string, string> = {};
for (const name of ["instruction", "investigation", "prompt-suggestions"]) {
const source = await readFile(`.opencode/agents/${name}.md`, "utf8");
const [header, body] = source.split("\n---\n");
expect(header).toContain(`name: ${name}`);
expect(header).toMatch(/metadata:\n version: "\d+\.\d+\.\d+"/);
bodies[name] = body.trimStart();
}
const config = serviceOpencodeConfig({});
expect(config.agents.instruction.system).toBe(bodies.instruction);
expect(config.agents.investigation.system).toBe(`${bodies.instruction}\n\n${bodies.investigation}`);
expect(config.agents["prompt-suggestions"].system).toBe(bodies["prompt-suggestions"]);
});
it("keeps protected paths denied, shell/edit gated and subagents disabled", async () => { it("keeps protected paths denied, shell/edit gated and subagents disabled", async () => {
const config = JSON.parse(await readFile("opencode.json", "utf8")); const config = JSON.parse(await readFile("opencode.json", "utf8"));
const rules = config.permissions as { action: string; resource: string; effect: string }[]; const rules = config.permissions as { action: string; resource: string; effect: string }[];
+1 -1
View File
@@ -22,7 +22,7 @@ describe("task agent isolation", () => {
"*", ...PRODUCT_MCP_READ_TOOL_IDS, "*", ...PRODUCT_MCP_READ_TOOL_IDS,
]); ]);
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(45); expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(45);
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(71); expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(72);
expect(PRODUCT_MCP_TOOL_IDS.some((name) => /publish|approve|execute|shell/.test(name))).toBeFalse(); expect(PRODUCT_MCP_TOOL_IDS.some((name) => /publish|approve|execute|shell/.test(name))).toBeFalse();
}); });