feat(investigation): publish incremental reference graphs

Publish graph deltas using the Server-owned cumulative graph contract. Validate historical comparison chart publications and consolidate Agent documentation.
This commit is contained in:
2026-11-11 19:10:41 +08:00
parent 5ee93329a4
commit db87323b69
28 changed files with 2329 additions and 90 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ cancel_calculation_job 按审批执行。只读子代理读取已授权结果;
按内容选择足够的表达:同类对象用 Table,同单位时序用 Chart,关键值用 Metric,展开依据用 Evidence,多步结果用 Result,结论后的可选补充用 FollowUpForm。简短事实、状态和缺数说明直接用文字。缺测填 null,各单位分图,筛选和仿真分别说明。
需保留的组件用 phase=final,临时材料用 process;更新同一对象保留 block_id、元素ID与表单字段名,新增对象使用独立ID。图表标明单位与来源,正文保留关键解释。
需保留的组件用 phase=final,临时材料用 process;更新同一对象保留 block_id、元素ID与表单字段名,新增对象使用独立ID。图表标明单位与来源,正文保留关键解释。工作台同指标同期对照按对象分别发布稳定chart_id,标题用“对象ID 共同标题”,各序列名以“对象ID ”开头;同组轴名、槽位、当前序列去掉对象前缀后的名称和sample_times一致,前端聚合为右侧对象切换、下方图例的子图表。普通趋势总览和柱状图保持独立,由总图表窗口统一管理,各子图仍可手动弹窗放大。只有运行中任务的新实时指令可请求总窗口自动弹出;任务打开、切换、刷新、完成态载入和历史恢复不弹窗,不能重复发布图表强制打开窗口。
确需用户选择时,先完成独立工具,再用 ask_user 一次提出最多三个问题,说明影响并保留稳定ID与自由回答入口。问题保存后结束本轮,待回答后继续;旧客户端使用原生 question。权限审批走宿主流程,FollowUpForm 用于可选补充。
+1 -1
View File
@@ -31,7 +31,7 @@ synthetic、回放等来源标记本身不构成停止条件。核对工具返
报告、进度、表格和图表统一按Asia/Shanghai(UTC+8)呈现时间,标题、横轴、系列名与采样时刻采用同一阅读时区。sample_times保留真实时间点与日期,使用+08:00偏移;数据源中的Z表示UTC,换算后按UTC+8命名历史日期与窗口。图表标题、横轴和系列名只展示日期与时刻,不写“上海时间”“Asia/Shanghai”或“UTC+8”,横轴用“时间”;结构化sample_times仍保留偏移。
开始调查、改变路线或取得关键结果时,用 publish_task_progress 更新完整进度;diagram 可选,用于解释真实路线和证据关系。
调查脉络图只作流程参考,按需整理:路线有实质调整、取得重要结果或失败尝试需要留痕、归纳报告时,可用 publish_task_progress 更新 summary 和 graph_update;不要求每步开始前或每次工具调用后更新,也不需要与实际任务及时同步。需要接续旧图时读取 get_task_context 的 section="progress",仅提交新增或变化的节点及明确连线,由 Server 保留各轮路径并生成 Mermaid。标记可省略,或用 reference(参考步骤)、recorded(已有结果)、needs_input(待补充条件)、failed(尝试失败)、not_taken(未采用路线)。recorded 不表示实际任务、计算或现场动作已完成,needs_input 也不控制等待。失败路径和实际原因保留,重试或替代方案使用新 node_id;不把原失败节点改成成功。旧图中的 pending/running 仅作参考,completed 表示已有结果,waiting/waiting_human 表示待补充条件,skipped/cancelled 表示未采用;不据此推断当前执行状态。summary 更新不清图,报告沿用已有图;图中标记不参与执行、等待、完成或审批判断。
取得关键步骤数据后,用 publish_task_presentation 随进程发布相应地图或图表,再继续分析;展示按实际完成步骤选择:
+2 -2
View File
@@ -4,9 +4,9 @@ import { createViewSchemas } from "../../src/frontendAction/command.js";
import { executeFrontendAction } from "./frontend_action.js";
export default tool({
description: "操作当前工作台的地图、独立图表窗口、证据图层或已完成的 SCADA 分析展示,等待浏览器回执。分析趋势并保留在聊天中时优先用 present_answer 的 Chart;用户要求打开工作台图表或操作已有窗口时才用 open_chart/close_chart,先查询数据,不隐式查询,也不默认重复展示同一趋势。地图定位、高亮和图层操作使用本工具;聊天中的可重复定位入口使用 MapLocation。不得编造坐标或分析等级。",
description: "操作当前工作台的地图、独立图表窗口、证据图层或已完成的 SCADA 分析展示,等待浏览器回执。分析趋势并保留在聊天中时优先用 present_answer 的 Chart;用户要求打开工作台图表或操作已有窗口时才用 open_chart/close_chart,先查询数据,不隐式查询,也不默认重复展示同一趋势。地图定位、高亮和图层操作使用本工具;聊天中的可重复定位入口使用 MapLocation。同指标同期复合图按对象分别提供稳定 chart_id,标题为“对象ID 共同标题”,每条序列名以“对象ID ”开头,同组轴名、槽位与当前窗口一致,前端在右侧提供对象切换、下方显示图例。time_comparison 必须保留真实 sample_times 并按共享时刻槽对齐;普通折线和柱状保持独立。不得编造坐标或分析等级。",
args: {
command: createViewSchemas(tool.schema as unknown as Parameters<typeof createViewSchemas>[0]).command as unknown as ReturnType<typeof tool.schema.object>,
command: createViewSchemas(tool.schema as unknown as Parameters<typeof createViewSchemas>[0], false, true).command as unknown as ReturnType<typeof tool.schema.object>,
reason: optionalToolReason,
},
async execute(args, context) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { getInternalBridgeConfig } from "../internalBridge.js";
import { newExecutionPlanSchema } from "../../src/routes/executionPlan.js";
export default tool({
description: "Publish an optional legacy investigation description to TJWater Server. This display snapshot never schedules the Agent or controls event-investigation completion. Prefer the optional Mermaid diagram in publish_task_report for a human-facing investigation narrative. Existing plan validation still requires stable node IDs, factual evidence and explicit edges. Do not invent dependencies from list order or infer human completion. Reports and action proposals do not require this plan. Follow the investigation instructions for report outcomes and stopping conditions; missing fields or planned field work do not themselves end the investigation. This tool only records explanatory information, it does not execute actions.",
description: "Publish an optional legacy investigation description to TJWater Server. This display snapshot never schedules the Agent or controls event-investigation completion. Prefer incremental graph_update in publish_task_progress for the human-facing investigation narrative; the Server generates Mermaid and archives it in reports. Existing plan validation still requires stable node IDs, factual evidence and explicit edges. Do not invent dependencies from list order or infer human completion. Reports and action proposals do not require this plan. Follow the investigation instructions for report outcomes and stopping conditions; missing fields or planned field work do not themselves end the investigation. This tool only records explanatory information, it does not execute actions.",
args: {
request_id: tool.schema.string().describe("Stable UUID idempotency key for this plan revision"),
schema: tool.schema.enum(["execution-plan@2"]),
+1 -1
View File
@@ -4,7 +4,7 @@ import { taskPresentationCommandSchema } from "../../src/product/task-presentati
import { taskPublicationCommandSchema } from "./task-publication-schema.js";
export default tool({
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. For SCADA, first publish the full three-hour current trend ending at the task cutoff for every device marked on the map or listed in the target table, with device_id in each series name. Publish pressure and flow separately. Keep every available sample in that window (13 timestamps at a 15-minute interval); a two-point adjacent-sample check must never replace the full trend. Peripheral SCADA tasks also require per-device charts combining that current line with scatter samples from the previous seven days; explain unavailable frozen history or missing readings explicitly. Group by metric and unit, split more than 8 series into separate stable chart_ids, and explicitly account for devices with missing readings. Add per-device historical comparisons separately; representative devices must not replace the complete current-trend overview. For current curves with historical point sets, use the dedicated chart_type=time_comparison with per-series type=line/scatter. Ordinary charts use line or bar. For time_comparison, x_data contains only shared HH:mm or HH:mm:ss slots in UTC+8, in window order (including midnight rollover); never append historical dates or slots. Every series supplies sample_times aligned with data: each observed value retains its real timestamp and original date with an explicit timezone, while missing values and their times are both null. Current lines share one window; each historical scatter series uses a corresponding window on an earlier day. Place each historical value at the matching time-of-day slot. Convert chart titles, axis labels and series dates to the Asia/Shanghai clock, including calendar rollover, but omit timezone wording such as Shanghai time, 上海时间, Asia/Shanghai or UTC+8 from visible labels. Name the time axis 时间. Keep explicit offsets in sample_times. 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.",
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. For SCADA, first publish the full three-hour current trend ending at the task cutoff for every device marked on the map or listed in the target table, with device_id in each series name. Publish pressure and flow separately. Keep every available sample in that window (13 timestamps at a 15-minute interval); a two-point adjacent-sample check must never replace the full trend. Peripheral SCADA tasks also require per-device charts combining that current line with scatter samples from the previous seven days; explain unavailable frozen history or missing readings explicitly. Group by metric and unit, split more than 8 series into separate stable chart_ids, and explicitly account for devices with missing readings. Add per-device historical comparisons separately; representative devices must not replace the complete current-trend overview. For object switching within one metric/unit/window, keep one stable chart_id per device, title each chart as <device_id> <shared comparison title>, and start every series name with <device_id> followed by a space. Use identical x/y axis names, x_data and current sample_times across the group, with matching current-series names after removing the device prefix. The browser groups these object datasets into one child chart, with object buttons on the right and series legends below the plot; ordinary overview and bar charts stay separate. The browser manages all charts in one parent window and preserves manual child enlargement. Only new live commands may open the parent; task loading, switching, refresh, completed results and history restoration never replay popup intent. Do not republish charts to force windows open. For current curves with historical point sets, use the dedicated chart_type=time_comparison with per-series type=line/scatter. Ordinary charts use line or bar. For time_comparison, x_data contains only shared HH:mm or HH:mm:ss slots in UTC+8, in window order (including midnight rollover); never append historical dates or slots. Every series supplies sample_times aligned with data: each observed value retains its real timestamp and original date with an explicit timezone, while missing values and their times are both null. Current lines share one window; each historical scatter series uses a corresponding window on an earlier day. Place each historical value at the matching time-of-day slot. Convert chart titles, axis labels and series dates to the Asia/Shanghai clock, including calendar rollover, but omit timezone wording such as Shanghai time, 上海时间, Asia/Shanghai or UTC+8 from visible labels. Name the time axis 时间. Keep explicit offsets in sample_times. 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: {
request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key; reuse for retries of this command"),
command: taskPublicationCommandSchema,
+4 -4
View File
@@ -1,16 +1,16 @@
import { tool } from "../tool.js";
import { getInternalBridgeConfig } from "../internalBridge.js";
import { taskProgressSchema } from "../../src/product/investigation-report.js";
import { taskPublicationProgressSchema } from "./task-publication-schema.js";
import { taskPublicationGraphProgressSchema } from "./task-publication-schema.js";
export default tool({
description: "Publish the complete current investigation progress from a task execution session. Publish before starting a real investigation step and when changing direction. The summary and optional Mermaid diagram describe actual activity for people; they never schedule the Agent or derive status from todo. Every node_refs node_id must exactly reuse an id declared in the Mermaid source; do not use A/B nodes in Mermaid and separate semantic ids in node_refs. The entire diagram may be redrawn, and only explicitly running nodes indicate current activity. Map targets reference known supply features. Use a stable UUID request_id and reuse it on retries. Publishing progress does not complete the run or confirm any human work.",
description: "Save an optional investigation process reference with summary and optional graph_update. Update when the route materially changes, an important finding or failed attempt should be recorded, or a report is being organized. No per-step publication, frequent refresh, or live synchronization is required. The Server retains prior nodes and paths and generates Mermaid. Use stable node_id values; only submit changes. Before extending a saved graph, read get_task_context section=progress when needed. Optional status annotations are reference, recorded, needs_input, failed, and not_taken. They describe the saved explanation, not execution lifecycle: recorded means a finding exists, not that a task, calculation, work order, or field action completed. Retain failed attempts with the actual reason in detail; append a new node and edge for a retry or alternative. Text-only updates preserve the graph. Graphs never schedule execution, settle runs, or infer human completion. Use known supply feature ids and saved evidence/action references. Reuse request_id on retries.",
args: {
request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key; reuse for retries of this progress publication"),
progress: taskPublicationProgressSchema,
progress: taskPublicationGraphProgressSchema,
},
async execute(args, context) {
const progress = taskProgressSchema.parse(args.progress);
const progress = taskProgressSchema.parse(taskPublicationGraphProgressSchema.parse(args.progress));
const { baseUrl, internalToken } = getInternalBridgeConfig();
const response = await fetch(`${baseUrl}/internal/operational-task/artifacts`, {
method: "POST",
+1 -1
View File
@@ -5,7 +5,7 @@ import { taskReportSchema } from "../../src/product/investigation-report.js";
import catalog from "../../src/chat/answer-ui-catalog.json" with { type: "json" };
export default tool({
description: "Publish an evidence-backed investigation report. Write all reader-facing times in Asia/Shanghai (UTC+8), including prose, tables, chart labels and historical series dates. Tool display timestamps with +08:00 already use this zone; keep the same instant and offset. Format UTC source timestamps into UTC+8 once, including date rollover. 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 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. Write all reader-facing times in Asia/Shanghai (UTC+8), including prose, tables, chart labels and historical series dates. Tool display timestamps with +08:00 already use this zone; keep the same instant and offset. Format UTC source timestamps into UTC+8 once, including date rollover. 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. The Server archives the accumulated investigation graph from publish_task_progress; do not regenerate or supply Mermaid in a report. Update graph nodes and failure states through publish_task_progress before publishing. Graphs explain activity and never schedule or complete work. 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 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: {
request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key for this report revision"),
report: taskPublicationReportSchema,
+19 -20
View File
@@ -1,5 +1,5 @@
import { tool } from "../tool.js";
import { mermaidDeclaresNodeId } from "../../src/product/investigation-report.js";
import { investigationNodeAnnotations } from "../../src/product/investigation-node-state.js";
// OpenCode owns a separate Zod version. Native parameters are described here;
// product schemas revalidate progress and presentation at execution boundaries.
@@ -36,28 +36,11 @@ const gapFields = {
evidence_refs: tool.schema.array(text).min(1),
};
export const taskPublicationDiagramSchema = tool.schema.object({
title: text,
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_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,
evidence_refs: tool.schema.array(text).default([]),
action_refs: tool.schema.array(tool.schema.string().uuid()).default([]),
status: tool.schema.enum(["pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"]).nullish().describe("Evidence-confirmed node state; waiting means prerequisites/external/system result, waiting_human requires manual authorization/approval/feedback. Unknown stays omitted/null; host controls colors."),
map_targets: tool.schema.array(tool.schema.object({ source_id: layer, feature_id: identifier }).strict()).max(100).default([]),
}).strict()).max(100).default([]),
}).strict().refine(
(diagram) => diagram.node_refs.every((node) => mermaidDeclaresNodeId(diagram.mermaid, node.node_id)),
"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,
};
@@ -83,9 +66,25 @@ export const taskPublicationReportSchema = tool.schema.discriminatedUnion("outco
}).strict(),
]);
export const taskPublicationProgressSchema = tool.schema.object({
export const taskPublicationGraphProgressSchema = tool.schema.object({
summary: text.max(20000),
diagram: taskPublicationDiagramSchema.nullable().default(null),
graph_update: tool.schema.object({
title: text.max(200).nullish(),
nodes: tool.schema.array(tool.schema.object({
node_id: tool.schema.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/).describe("Stable task-wide id. Keep this id when updating a step; use a new id for a retry of a failed attempt."),
label: text,
detail: text.max(2000).nullish().describe("Actual result or failure reason; preserve the failed attempt."),
status: tool.schema.enum(investigationNodeAnnotations).nullish().describe("Optional saved reference annotation. recorded means a finding exists, not business completion; reference is neutral. No running state or live task synchronization."),
evidence_refs: tool.schema.array(text).default([]),
action_refs: tool.schema.array(tool.schema.string().uuid()).default([]),
map_targets: tool.schema.array(tool.schema.object({ source_id: layer, feature_id: identifier }).strict()).max(100).default([]),
}).strict()).max(100).default([]),
edges: tool.schema.array(tool.schema.object({
source: tool.schema.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
target: tool.schema.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text.max(200).nullish(),
}).strict()).max(200).default([]),
}).strict().optional().describe("Incremental nodes and explicit relationships. The Server retains prior paths and generates Mermaid; omit for a text-only update."),
}).strict();
import { createViewSchemas } from "../../src/frontendAction/command.js";
+2
View File
@@ -1,5 +1,7 @@
# TJWater 供水 Agent
架构、协议、用户隔离与历史资料入口见 [文档目录](docs/README.md)。
这个服务把供水 WebGIS 中的对话、数据查询、地图操作和工况任务接到 OpenCode 2。浏览器负责展示和交互,Agent 编排工具,TJWater Server 校验身份、项目权限并保存业务状态。
本仓库仍是实验服务,与历史 `TJWaterAgent` 仓库独立。排水业务由排水 Agent 仓库维护。
+17
View File
@@ -0,0 +1,17 @@
# 供水 Agent 文档目录
运行、模型、MCP 配置、会话目录和部署见 [仓库 README](../README.md),开发与安全边界见 [AGENTS.md](../AGENTS.md)。整理日期:2026-11-10。
| 主题 | 维护入口 |
| --- | --- |
| 宿主、OpenCode、工具、权限、持久化与生命周期 | [Agent 架构](architecture.md) |
| 回答内容、展示命令、回执、聊天与任务生命周期 | [Agent UI 协议](agent-ui-protocol.md) |
| 调查运行、报告、协作与旧计划兼容 | [执行与调查报告](execution-plan.md) |
| 用户身份、会话文件和历史数据隔离 | [用户隔离](user-isolation.md) |
| 原生 Agent、插件、Skill 和 MCP 配置来源 | [可信资产管理](../.opencode/README.md) |
| Server 查询、认证与队列 | [Server 文档目录](../../next-tjwater-server/docs/README.md) |
| 前端控件与宿主提问状态 | [交互状态](../../next-tjwater-frontend/docs/agent-interaction-state-design.md) |
原 `HARNESS_INTRODUCTION.md` 与 `AGENT_HARNESS_REPORT.md` 的当前职责已合并到架构文档;原文保存在 [历史 Harness 资料](history/legacy-harness-design.md)。历史 CLI、render_ref、旧模型默认值及旧数据目录不再作为实现或待办依据。
配置表只在仓库 README 与实际配置源维护,协议字段只在拥有该契约的代码及专门文档维护。实现和测试记录不证明现有进程已经重载或部署。
+10 -2
View File
@@ -6,7 +6,7 @@
Map, chart, overlay and SCADA display use a single flat command object: `{action, ...typed fields}`. Chat calls `present_view({command, reason?})` with an optional concise purpose; tasks call `publish_task_presentation({request_id, command})`; Dev JSONL wraps the same command in `{record_type:"presentation", command}`. No executable JS, JSX, HTML, CSS, URL or arbitrary request is accepted. The former chat name/params action shape and frontend-action@1 are rejected; no migration adapter is provided.
The ten commands are locate_features, zoom_to_map, highlight_features, clear_highlights, open_chart, close_chart, set_overlay, clear_overlay, render_scada_analysis and clear_scada_analysis. Historical data must first be queried through an authorized MCP tool and then supplied to open_chart. Command definitions, including bounded GeoJSON, charts and target lists, are generated from the supply frontend's `src/features/agent/presentation/command-schema.ts`; the exported `src/frontendAction/command.ts` is also used with OpenCode's native Zod instance. Regenerate using `node scripts/export-view-command-contract.mjs` in the supply frontend; add `--check` to verify copies.
The ten commands are locate_features, zoom_to_map, highlight_features, clear_highlights, open_chart, close_chart, set_overlay, clear_overlay, render_scada_analysis and clear_scada_analysis. Historical data must first be queried through an authorized MCP tool and then supplied to open_chart. Command definitions, including bounded GeoJSON, charts and target lists, are generated from the supply frontend's `src/features/agent/presentation/command-schema.ts`; the exported `src/frontendAction/command.ts` is also used with OpenCode's native Zod instance. Regenerate using `node scripts/export-view-command-contract.mjs` in the supply frontend; add `--check` to verify copies. The same exporter also synchronizes `tests/fixtures/unified-presentation.jsonl` and `chart-publication-cases.json` to Agent and Server. JSON Schema covers structural limits; runtime refinement and the shared conformance tests cover timestamp semantics.
## Chat transport and receipts
@@ -34,9 +34,11 @@ Session subscriptions carry the same data in a `frontend_action` event. The brow
Task display is asynchronous: Server verifies the task/run/generation, active lease, source session, evidence ownership and idempotency key before recording `operational.presentation.requested`. A recorded result does not mean a browser displayed it; tasks progress without any connected browser. New subscriptions take a fresh event baseline and do not replay historical display commands.
Task chart windows are managed by the browser in one parent window containing process chart groups and report Chart components. Only new live open_chart commands for the followed running investigation can automatically open that parent once per task-view session; updates preserve dismissal and never automatically open individual child windows. Opening, switching or refreshing a task, loading completed tasks, reconnecting history, and navigating saved snapshots restore data without opening windows. Each child still supports manual independent enlargement. Chat and Dev previews use manual opening. Window intent is transient browser state, not a field in the command or saved event; displayed means chart data was loaded, not that a popup opened.
Task open_chart requires persisted evidence references. Task set_overlay can omit data; Server resolves immutable evidence into bounded GeoJSON. Supplied data must match that resolution and never overrides the stored snapshot. Chat and Dev supply resolved data for structural validation before rendering.
Same-time-of-day current/history comparisons use `chart_type=time_comparison`; ordinary charts retain `line` or `bar`. The comparison axis contains shared UTC+8 `HH:mm` or `HH:mm:ss` slots, with current `line` and historical `scatter` series and aligned real `sample_times`. The Server automatically validates new task presentation and report chart publications, rejecting date-appended axes, missing timestamps, misplaced points and nonhistorical reference dates with `TIME_COMPARISON_INVALID`. Ordinary charts skip this semantic check. Historical stored chart read contracts remain unchanged.
Same-time-of-day current/history comparisons use `chart_type=time_comparison`; ordinary charts retain `line` or `bar`. The comparison axis contains shared UTC+8 `HH:mm` or `HH:mm:ss` slots, with current `line` and historical `scatter` series and aligned real `sample_times`. The Server automatically validates new task presentation and report chart publications, rejecting date-appended axes, missing timestamps, misplaced points and nonhistorical reference dates with `TIME_COMPARISON_INVALID`. Chat open_chart is checked by both the native tool and host coordinator using the same generated chart refinement as task publication; Dev commands and ui_block.Chart use it too. Ordinary line/bar charts skip comparison semantics, but a line/scatter comparison cannot be labelled line. Historical stored chart read contracts remain unchanged.
Chat, task following and Dev previews share the frontend command dispatcher but own independent map sources, SCADA layers and chart state. Clearing or switching one owner does not clear another. Dev JSONL is an explicit local preview/import/export container, not an alternative online business protocol. Plain data records and ui_block records do not execute embedded actions. The earlier agent-ui@1 registry has no active tool mappings and is not an action entry point.
@@ -77,3 +79,9 @@ Canonical parts are the saved history; reconnect compatibility fields are projec
`data-run_status` retains completed, waiting_input, error or aborted state for reloads. Host-confirmed
interaction updates may update stored parts after a response has finished without reopening its wire stream.
UI, redacted transcript and native execution history retain their separate purposes and retention rules.
## Grouped object chart examples
Keep one stable chart_id per object. A comparison title is `<object_id> <shared title>` and every series name starts with `<object_id> `; IDs start with a letter or digit and contain only letters, digits, underscores, periods or hyphens. Comparisons group only when their shared title, chart_type, axis names, x_data, current series names without the object prefix and current sample_times match. Different metrics, units or windows stay separate. The browser places single-selection object buttons on the right and series legends under the plot; narrow windows place objects below. Closing a grouped card removes its objects, while close_chart still addresses a single original chart_id. Ordinary overview/bar commands and standalone answer Chart components stay independent. No objects field or new command is introduced.
The complex Dev examples publish eight pressure and four pipe-flow object datasets, each with 96 slots, one current line and seven sparse historical scatter series, plus an independent plan bar chart. The canonical JSONL and shared positive/negative chart cases are copied from the frontend fixtures by the contract exporter; do not edit generated fixture copies locally. Publication checks reject inconsistent dates, misplaced slots, missing observed timestamps and invalid null pairings before execution. Browser tests use synthetic readings and do not establish live Agent or business acceptance.
+68
View File
@@ -0,0 +1,68 @@
# 供水 Agent 架构与维护边界
整理日期:2026-11-10。本文合并原 Harness 简介与架构报告,按当前源码说明宿主职责;启动、模型、MCP 和部署参数统一见 [README](../README.md),不在此维护第二份配置表。旧架构原文见 [历史资料](history/legacy-harness-design.md)。
## 总体链路
```mermaid
flowchart LR
Frontend[前端聊天与工作台] --> Host[Agent 宿主]
Worker[Server 调查 Worker] --> Host
Host --> Identity[Server 验证身份与任务归属]
Host --> Runtime[OpenCode 运行时]
Runtime --> MCP[宿主管理的产品 MCP]
MCP --> Server[TJWater Server 与项目数据]
Runtime --> Tools[受控发布与展示插件]
Tools --> Host
Host --> Persistence[会话文件、状态与审计]
Host --> Frontend
```
模型负责推理与工具选择。Agent 宿主负责身份、运行生命周期、可信工具桥、流式事件、会话文件和取消;Server 负责业务事实、项目权限、任务与报告权威状态。图表展示、报告发布、模型轮次结束与任务执行完成分别处理。
## 代码职责
| 层 | 当前入口 | 职责 |
| --- | --- | --- |
| 启动与装配 | [src/server.ts](../src/server.ts) | 组装身份、存储、运行时、内部桥和 HTTP 服务;就绪检查失败不开始正常服务 |
| 聊天与输入 | [src/routes/chat.ts](../src/routes/chat.ts) · [chat-run-accumulator.ts](../src/routes/chat-run-accumulator.ts) | HTTP/SSE、运行事件累加、权限与问题投影、追加输入和中断 |
| 原生运行时 | [src/runtime/opencode.ts](../src/runtime/opencode.ts) · [opencode2-config.ts](../src/runtime/opencode2-config.ts) | embedded/client 接入、可信原生配置、会话绑定、事件与取消 |
| 业务 MCP | [mcp-query-client.ts](../src/product/mcp-query-client.ts) · [product-mcp-tools.ts](../src/runtime/product-mcp-tools.ts) | 授权上下文、专用 OAuth、工具目录和访问策略;实际目录由代码维护 |
| 发布与任务 | [task-publication-bridge.ts](../src/routes/task-publication-bridge.ts) · [task-command-bridge.ts](../src/routes/task-command-bridge.ts) | 类型化任务命令、进度、报告和展示发布,校验会话用途与运行归属 |
| 前端动作 | [src/frontendAction/](../src/frontendAction/) · [UI 协议](agent-ui-protocol.md) | 受控展示 command 与回执,内容渲染不重放动作 |
| 存储与隔离 | [workspace-store.ts](../src/sessions/workspace-store.ts) · [session-scope.ts](../src/sessions/session-scope.ts) | 从已验证会话取得 actor、scope 与所有权,管理 host-owned chat 和模型可写 workspace |
| 记忆与技能 | [src/learning/orchestrator.ts](../src/learning/orchestrator.ts) · [src/memory/](../src/memory/) | 按既有规则组织学习、记忆和技能;Skill 存储不宣称按用户或项目隔离 |
| Shell 沙箱 | [landlockSandbox.ts](../src/sandbox/landlockSandbox.ts) | 受许可的本地 Shell 及系统级访问限制,不作为自由业务查询入口 |
## 身份、数据与工具
生产聊天必须通过 Server 验证 Bearer。个人会话使用个人范围,不能获得项目业务凭据;项目会话还需核验 `X-Project-Id` 和成员权限。调查执行绑定 task/run/execution_request 和实际 Agent 实例,客户端或模型不能自行指定身份、产品 URL、凭据或数据范围。
业务查询通过原生 `mcp_tjwater` 连接,由宿主持有 OAuth 凭据。业务写入和计算按目录元数据、会话用途、原生许可及 Server 权限处理,不将 `readOnlyHint` 当作授权。普通聊天与学习不启动子代理;后台调查的受控只读 reader 边界见 [README](../README.md#后台调查子代理)。
发布、前端动作、消息、记忆和技能沿用可信插件与类型化宿主桥。旧 `tjwater_cli`、自由业务命令桥、独立结果引用存储和 render-ref 回读 API 已退役;不要据历史 Harness 图重新建立这些入口。文件结果保存到会话 `files/`,通过会话身份及相对路径下载。
## 会话与持久化
会话根目录由配置管理,通常位于 `data/session-workspaces/`。host-owned `chat/` 保存 UI 状态、transcript 和学习记录;模型仅绑定 `workspace/`,通过其中的 `files/`、`tmp/` 和 `.cache/` 工作。OpenCode 原生历史、UI state 和脱敏 transcript 各有用途,不能相互替代。
只共享不可变资料或配置,不将用户凭据、其他会话和仓库源码复制到模型工作目录。会话目录属于持久用户数据;部署需保留实际 data root 与 OpenCode 数据库。删除、旧目录迁移和失败重试的规则见 [用户隔离](user-isolation.md) 与仓库 `AGENTS.md`,不能作为构建缓存清理。
## 生命周期与可观察性
- 原生事件进入宿主累加器,再持久化并推送浏览器;断开浏览器订阅不等于中止后台运行。
- `ask_user` 的请求、答案和投递记录独立保存,答案已保存与推理已继续分别反馈;见 [前端交互状态](../../next-tjwater-frontend/docs/agent-interaction-state-design.md)。
- 后台任务以 Server 持久化状态及 claim、heartbeat、complete/fail 为准,不能以 SSE done 或最终文字替代;见 [任务执行边界](execution-plan.md)。
- 启动检查、业务审计、原生 call ID、取消、超时和故障清理均保留各自证据。收到 queued/running 或一次 HTTP 超时不能据此宣布任务完成或业务失败。
## 验证入口
从 Agent 仓库运行:
```bash
bun run check
bun run check:openapi
bun test tests/routes tests/sessions tests/product tests/frontendAction
```
模型配置检查、隔离集成和部署检查按 [README](../README.md) 选择,不在文档整理时执行付费推理、修改生产身份平台或重启用户服务。本地静态检查、模拟测试、原生能力探针、真实项目验收和实际发布分别记录。
+1 -1
View File
@@ -23,7 +23,7 @@ Mermaid、todo 和可选的旧执行计划供人理解,不约束 Agent 调度
## 任务、协作与恢复
后台调查通过 `publish_task_progress` 在真实步骤开始及路线变化时发布完整 summary 与可空 diagram,复用 Server `agent-artifacts` 写入。节点可带显式 status 和供水图层要素 map_targets;只有 running 代表当前活动。进度图可以替换或重绘,不受旧计划保留节点规则约束,也不从 todo 推算。最终报告 diagram 使用相同节点引用字段;两者均不调度 Agent。
后台调查通过 `publish_task_progress` 按需整理 summary 与可选 graph_update,复用 Server `agent-artifacts` 写入。路线实质调整、重要结果或失败尝试需要留痕、归纳报告时更新即可,不要求逐步发布或实时同步。graph_update 只提交变化的 nodes 和显式 edges(source/target/label);Server 累计保留路径并生成 Mermaid。node_id 稳定,可用 detail、证据及动作引用解释依据。可选 status 只接受 reference(参考步骤)、recorded(已有结果)、needs_input(待补充条件)、failed(尝试失败)、not_taken(未采用路线);无标记时保持中性。recorded 不证明实际任务或现场动作完成,needs_input 不控制任务等待。失败路径保留,重试另建节点。需要接续旧图时读取 get_task_context(section="progress");旧 pending/running 仅作参考,completed 表示已有结果,waiting/waiting_human 表示待补充条件,skipped/cancelled 表示未采用。summary 更新不清图,报告归档已有累计图;旧图原源码保留,未保存的旧进度无法补回。图示与实际任务、计算和工具执行状态独立,不调度 Agent。上面的旧执行计划字段只用于历史兼容,也不驱动 Mermaid 标记。
`publish_task_presentation` 仅允许 task_execution,通过 Server `presentation-events` 记录有限地图、图表及证据图层命令。run_id、任务和会话身份来自可信运行上下文;request_id 为模型提供的 UUID,相同操作重试复用。命令无需浏览器 capability 或实际显示回执,recorded 不代表已显示。图表引用真实证据,overlay 仅提供 evidence_id,由 Server 提取数据。前端 Dev 选择当前任务后实时跟随未来展示事件,关闭、切换或重连不回放旧展示,不启动新调查。
+622
View File
@@ -0,0 +1,622 @@
# 早期 Harness 架构资料
> 历史归档:由旧 `HARNESS_INTRODUCTION.md` 与 `AGENT_HARNESS_REPORT.md` 合并保存。文中 CLI、render_ref、独立结果引用存储、旧模型默认值和旧会话目录均不能作为当前实现或维护依据。当前架构见 [Agent 架构](../architecture.md),配置见 [仓库 README](../../README.md)。原文中的覆盖声明与外部参考未在本次文档整理中重新验证。
## 原文件:HARNESS_INTRODUCTION.md
# TJWaterAgent Harness 框架设计
> 如果把 AI 大模型比作"大脑",Harness 就是让大脑能**安全、可靠、有条理地**在真实水务业务中工作的整套 **"神经 + 手脚 + 记忆"系统**。
---
## 一张图看懂
```mermaid
sequenceDiagram
autonumber
actor 用户 as 👤 用户
participant 前端 as 🖥️ 前端界面<br/>(聊天 / 地图 / 图表)
participant 接口 as 🚪 接口层<br/>接收问题、返回结果
participant 会话 as 📋 会话层<br/>“你是谁?哪个项目?”
participant 上下文 as 📦 上下文层<br/>整理背景资料<br/>历史对话 · 偏好 · 技能
participant 适配 as 🔌 适配层<br/>接通AI大脑<br/>(嵌入/远程)
participant 工具 as 🔧 工具层<br/>AI的“手和脚”<br/>调CLI · 存渲染引用 · 画图
participant 水务 as 🏭 水务系统<br/>管网 / SCADA / 仿真
participant 治理 as 🛡️ 治理观测层<br/>日志 · 审计 · 超时 · 容错
用户->>前端: 💬 “帮我查A片区今天用水量”
前端->>接口: 📤 POST 请求
接口->>会话: 🔍 验证身份、绑定会话
会话->>上下文: 📋 拼装:历史对话+记忆+技能+身份
接口->>前端: 📡 建立实时通道 SSE(“电话接通”)
接口->>适配: 📤 问题和背景一起发给AI
适配->>适配: 🧠 AI思考:推理 → 规划 → 决定调工具
适配->>工具: 🔧 “调用 tjwater_cli 查用水数据”
工具->>会话: 🔐 回读身份令牌(令牌留在服务端)
工具->>水务: 🏭 带上身份去查真实数据
水务-->>工具: 📊 返回数据
opt 需要渲染大型 junction 分区
工具->>工具: 💾 store_render_ref 存 render_ref
前端->>接口: 🔎 按 render_ref 回读完整渲染 payload
end
工具-->>适配: 📦 给AI结果
适配-->>前端: 📡 实时推送:文字逐字显示+图表弹窗
前端-->>用户: 📲 看到回答和数据
适配->>治理: 📝 记录:谁调了什么、为什么、耗时多久
会话->>会话: 🧠 同步复盘:对话中实时沉淀记忆/技能
```
---
## 主流 Harness 能力覆盖
AI Agent 领域主流的 Harness 是指大模型外层的工程基础设施。对照这些标准能力项,当前设计全覆盖:
| 主流 Harness 能力 | 本项目落点 | 覆盖 |
|---|---|---|
| 运行时编排 | `OpencodeRuntimeAdapter` + `ChatSessionBridge` | ✅ |
| 上下文装配 | `buildPromptWithLearningContext` + `runtime context store` | ✅ |
| 工具执行与权限隔离 | `.opencode/tools` + `/internal/tools/*` + `x-agent-internal-token`,令牌不出工具 | ✅ |
| 状态持久化 | `session` / `UI state` / `transcript` / `runtime context` | ✅ |
| 记忆与技能沉淀 | `MemoryStore` + `SkillStore` + `LearningOrchestrator`(对话中同步复盘) | ✅ |
| 观测与审计 | `Pino` + `LLM request audit` + `learning audit` + `traceId` | ✅ |
| 可靠性 | `abort` / `timeout` / `fallback` / `render_ref TTL` / `cleanup` | ✅ |
| 前端动作桥接 | SSE `tool_call` + `UI tools`(地图 / 图表 / 面板) | ✅ |
---
**Harness = 大脑负责思考,外壳负责让思考能落地执行。**
## 原文件:AGENT_HARNESS_REPORT.md
# TJWaterAgent 后端 Harness Engineering 设计整理
## 1. 先明确概念:什么是 Agent Harness
在 Agent 系统里,LLM 只是推理核心。Harness engineering 关注的是模型外层的工程系统:它把用户请求、上下文、工具、状态、权限、观测、持久化和恢复机制组织起来,让模型能在真实业务环境里安全地执行多步任务。
结合近期资料,可以把 harness 概括为:
- **运行时外壳**:承接请求,驱动模型,多轮执行,判断何时继续、停止或失败。
- **上下文装配层**:把用户身份、项目、历史、记忆、技能、业务约束注入到模型可用的上下文中。
- **工具执行层**:把模型意图转成受控的 CLI、渲染引用迁移和 UI 动作,并把结果反馈给模型。
- **权限与治理层**:隔离用户 token、项目权限、内部工具调用权限和渲染引用回读权限。
- **状态与记忆层**:保存 session、transcript、UI state、memory、skills、render refs。
- **观测与审计层**:记录工具调用、技能调用、学习决策、trace 和错误。
- **可靠性层**:处理 abort、timeout、fallback、cleanup 和渲染引用 TTL。
因此,TJWaterAgent 的 harness 不是某一个单独文件,而是 `src/` 服务代码、`.opencode/` Agent 资产、工具桥、持久化 store 和运行时适配器共同组成的 Agent 后端工程体系。
参考资料:
- TechTarget: [Harness engineering: Agent harnesses as critical infrastructure](https://www.techtarget.com/searchapparchitecture/tip/Harness-engineering-Agent-harnesses-as-critical-infrastructure)
- O'Reilly Radar: [Agent Harness Engineering](https://www.oreilly.com/radar/agent-harness-engineering/)
- MongoDB: [The Agent Harness: Why the LLM Is the Smallest Part of Your Agent System](https://www.mongodb.com/company/blog/technical/agent-harness-why-llm-is-smallest-part-of-your-agent-system)
- LLM Reference: [Harness](https://www.llmreference.com/concept/harness)
## 2. 项目总体框架
`TJWaterAgent` 是面向 TJWater 前端和水务后端能力的 Agent 服务层。它对外暴露 HTTP/SSE API,对内启动或连接 opencode runtime,并通过 `.opencode` 下的 agent、tools、skills 资产完成业务推理和工具执行。
```text
TJWaterAgent/
src/ 服务宿主、路由、会话、runtime、工具桥、持久化
.opencode/ opencode agent 资产
agents/instruction.md Agent 角色、行为规则、工具策略
tools/*.ts opencode 可调用工具
skills/ 可复用 workflow、CLI 使用说明、案例
data/ session、memory、render_ref 等运行时数据
logs/ LLM/tool/learning 审计日志
cli/tjwater-cli 业务 CLI 能力入口
opencode.json opencode provider、model、server、default_agent
```
核心技术栈:
| 类别 | 技术 |
| --- | --- |
| 服务运行 | Bun、Node.js、TypeScript ESM |
| HTTP 层 | Express、CORS、SSE |
| 参数校验 | Zod |
| Agent runtime | `@opencode-ai/sdk/v2`、opencode embedded/client 两种模式 |
| LLM provider | DeepSeek,默认 `deepseek/deepseek-v4-pro` |
| 工具扩展 | `@opencode-ai/plugin`、`.opencode/tools/*.ts` |
| 持久化 | JSON file store、atomic write、TTL cleanup |
| 观测 | Pino、LLM request audit、learning audit |
| 后端能力 | `tjwater-cli --auth-stdin` |
## 3. Harness Engineering 一图总览
下面这张图把现有后端设计收束成一张 Harness Engineering 视角的总图。重点不是“LLM 调用链”,而是模型外层的工程外壳如何完成请求接入、上下文装配、runtime 编排、工具治理、状态持久化、结果裁剪、审计和恢复。
```mermaid
flowchart LR
subgraph Client["Client / Frontend"]
Chat["Agent Chat UI"]
UIAction["Map / Chart / Panel Actions"]
end
subgraph Harness["TJWaterAgent Backend Harness Engineering"]
direction TB
subgraph Entry["1. 接入与协议层"]
API["Express REST API<br/>/api/v1/agent/chat/*"]
SSE["SSE Contract<br/>progress / token / tool_call / done / error"]
end
subgraph Control["2. 会话与上下文控制面"]
Session["ChatSessionBridge<br/>clientSessionId -> opencode sessionId"]
ActiveRun["activeRuns + AbortController<br/>并发控制 / 中断 / 重连"]
Context["SessionRuntimeContextStore<br/>actor / project / trace / token"]
PromptCtx["Learning Context Assembly<br/>recent turns / memory / skills"]
end
subgraph RuntimeLayer["3. Runtime 编排层"]
Adapter["OpencodeRuntimeAdapter"]
Mode["embedded / client mode"]
EventAdapter["Raw Event Adapter<br/>opencode events -> frontend SSE"]
end
subgraph ToolPlane["4. 工具执行与权限边界"]
ToolBridge["Internal Tool Bridge<br/>/internal/tools/*"]
InternalAuth["x-agent-internal-token"]
TokenInject["Server-side Auth Injection<br/>token / project / trace"]
RenderRefPolicy["Render Ref Policy<br/>store_render_ref + render_ref"]
end
subgraph State["5. 状态、记忆、沉淀"]
SessionStore["Session / UI State / Transcript Store"]
ResultStore["ResultReferenceStore<br/>TTL / actor-project-session auth"]
MemoryStore["MemoryStore"]
SkillStore["SkillStore"]
Learning["LearningOrchestrator<br/>gate / review / persistence policy"]
end
subgraph Governance["6. 治理、观测、可靠性"]
Audit["LLM Tool Audit / Learning Audit"]
Logs["Pino Logs + traceId"]
Reliability["timeout / fallback / cleanup / health"]
end
end
subgraph Runtime["opencode Runtime Assets"]
Agent[".opencode/agents/instruction.md"]
OCTools[".opencode/tools/*.ts"]
OCSkills[".opencode/skills/*"]
LLM["DeepSeek Model"]
end
subgraph Business["TJWater Business Capability"]
CLI["tjwater-cli --auth-stdin"]
Data["管网 / SCADA / 仿真 / 分析数据"]
end
Chat -->|"POST /stream"| API
API --> Session
API --> ActiveRun
Session --> Context
Session --> SessionStore
Session --> PromptCtx
MemoryStore --> PromptCtx
SkillStore --> PromptCtx
PromptCtx --> Adapter
Adapter --> Mode
Mode --> LLM
LLM --> Agent
LLM --> OCTools
LLM --> OCSkills
Adapter --> EventAdapter
EventAdapter --> SSE
SSE -->|"stream events"| Chat
SSE -->|"tool_call"| UIAction
OCTools -->|"sessionID + args + reason"| ToolBridge
ToolBridge --> InternalAuth
InternalAuth --> Context
Context --> TokenInject
TokenInject --> CLI
CLI --> Data
OCTools --> RenderRefPolicy
RenderRefPolicy --> ResultStore
SessionStore --> Learning
Learning --> MemoryStore
Learning --> SkillStore
EventAdapter --> Audit
ToolBridge --> Audit
Learning --> Audit
Audit --> Logs
ActiveRun --> Reliability
ResultStore --> Reliability
Adapter --> Reliability
```
图中可以看出,本项目 Harness Engineering 的核心设计是:
| 设计域 | 关键职责 | 项目落点 |
| --- | --- | --- |
| 接入协议 | 把前端请求和流式响应稳定化 | Express routes、SSE event contract |
| 会话控制 | 绑定前端会话和 runtime 会话,支持 abort、reconnect、active run 管理 | `ChatSessionBridge`、`activeRuns` |
| 上下文装配 | 把用户、项目、trace、token、历史、记忆、技能装配到一次运行中 | `SessionRuntimeContextStore`、learning context |
| Runtime 编排 | 屏蔽 opencode embedded/client 差异,统一 prompt、event、messages、abort | `OpencodeRuntimeAdapter` |
| 事件适配 | 把 opencode raw events 转成前端稳定协议 | `streamPromptResponse()` |
| 工具治理 | 通过内部桥执行真实业务能力,隔离模型和用户 token | `.opencode/tools`、`/internal/tools/*` |
| 渲染引用管理 | 大型 junction 渲染 payload 引用化,避免模型读取完整 `node_area_map` | `ResultReferenceStore`、`render_ref` |
| 记忆沉淀 | 从对话中异步提取 memory 和 reusable skill | `LearningOrchestrator`、`MemoryStore`、`SkillStore` |
| 治理可靠性 | 观测、审计、超时、中断、fallback、TTL cleanup | Pino、audit logs、health、cleanup |
## 4. Harness 总体架构图
```mermaid
flowchart LR
subgraph FE["TJWater 前端"]
ChatUI["聊天 UI / AgentWorkspace"]
MapUI["地图 / 图表 / 面板"]
end
subgraph Harness["TJWaterAgent Backend Harness"]
Server["Express Server"]
ChatRouter["Chat Router<br/>/api/v1/agent/chat/*"]
SessionBridge["ChatSessionBridge"]
RuntimeAdapter["OpencodeRuntimeAdapter"]
StreamAdapter["SSE Stream Adapter"]
InternalTools["Internal Tool Bridge<br/>/internal/tools/*"]
Stores["Persistent Stores<br/>session / memory / transcript / render_ref"]
Learning["LearningOrchestrator"]
Audit["Audit Logs"]
end
subgraph Runtime["opencode Runtime"]
OCServer["opencode server"]
AgentPrompt["agents/instruction.md"]
Tools["tools/*.ts"]
Skills["skills/workflow/*"]
end
subgraph Business["TJWater 业务能力"]
CLI["tjwater-cli"]
Data["管网 / SCADA / 仿真 / 分析数据"]
end
ChatUI -->|"POST /stream 或 GET /session/:id/stream"| Server
Server --> ChatRouter
ChatRouter --> SessionBridge
ChatRouter --> Stores
SessionBridge --> RuntimeAdapter
RuntimeAdapter --> OCServer
OCServer --> AgentPrompt
OCServer --> Tools
OCServer --> Skills
Tools -->|"internal callback + sessionID"| InternalTools
InternalTools --> Stores
InternalTools --> CLI
CLI --> Data
OCServer -->|"raw events"| StreamAdapter
StreamAdapter -->|"progress / token / tool_call / done / error"| ChatUI
StreamAdapter --> Audit
ChatRouter --> Learning
Learning --> Stores
ChatUI -->|"tool_call action"| MapUI
```
## 5. Harness 工程分层
```mermaid
flowchart TB
L1["1. 接口层<br/>Express + REST + SSE"]
L2["2. 会话层<br/>ChatSessionBridge + activeRuns + abort"]
L3["3. 上下文层<br/>user / project / trace / token 绑定"]
L4["4. Runtime 适配层<br/>opencode embedded/client"]
L5["5. 事件适配层<br/>opencode raw events -> 前端 SSE 协议"]
L6["6. 工具执行层<br/>tjwater-cli / store_render_ref / UI tools"]
L7["7. 渲染引用层<br/>render_ref / TTL"]
L8["8. 记忆学习层<br/>memory / transcript / skill / learning gate"]
L9["9. 治理观测层<br/>auth / audit / logs / timeout / cleanup"]
L1 --> L2 --> L3 --> L4 --> L5
L3 --> L6 --> L7
L2 --> L8
L6 --> L9
L8 --> L9
```
### 5.1 接口层
主要入口在 `src/server.ts` 和 `src/routes/chat.ts`:
- `GET /health`:检查服务和 opencode runtime 健康状态。
- `POST /api/v1/agent/chat/session`:创建或确保 session metadata。
- `GET /api/v1/agent/chat/sessions`:列出用户/项目维度的会话。
- `GET /api/v1/agent/chat/session/:sessionId`:读取会话 UI state。
- `POST /api/v1/agent/chat/stream`:核心对话入口,返回 SSE。
- `GET /api/v1/agent/chat/session/:sessionId/stream`:重新订阅正在运行的 stream。
- `POST /api/v1/agent/chat/abort`:中断当前会话运行。
- `POST /api/v1/agent/chat/fork`:基于历史 session 分叉。
- `GET /api/v1/agent/chat/render-ref/:renderRef`:授权读取渲染结果引用。
接口层的 harness 价值是给前端一个稳定协议,把复杂的 Agent runtime 行为收敛成会话、消息、状态、流式事件。
### 5.2 会话与上下文层
`ChatSessionBridge` 负责把前端 session 和 opencode session 绑定起来,并维护请求级上下文:
- `clientSessionId`:前端可见的会话 ID。
- `sessionId`:opencode runtime session ID。
- `actorKey`:由 `x-user-id` 归一化得到的用户隔离键。
- `projectKey`:由 `x-project-id` 归一化得到的项目隔离键。
- `traceId`:链路追踪 ID。
- `accessToken`:来自 `Authorization`,只存入服务端 runtime context,不暴露给模型工具代码。
每次 `POST /stream` 会:
1. 解析 headers 和 payload。
2. 查询或创建 session metadata。
3. 调用 `sessionBridge.resolve()` 创建或复用 runtime session。
4. 写入 `SessionRuntimeContextStore`,供内部工具桥回读。
5. 为该 session 注册 `AbortController`,避免同一会话并发运行。
### 5.3 Runtime 适配层
`OpencodeRuntimeAdapter` 屏蔽 opencode SDK 细节,提供统一方法:
- `ensureClient()`:懒加载 opencode client。
- `health()`:runtime 健康检查。
- `createSession()`:创建 runtime session。
- `prompt()` / `sendPrompt()`:发送用户消息。
- `messages()`:回读消息。
- `abortSession()` / `waitForSessionIdle()`:中断和等待空闲。
- `subscribeEvents()`:订阅 opencode 事件流。
- `dispose()`:关闭 embedded runtime。
支持两种部署模式:
```mermaid
flowchart LR
Mode["OPENCODE_MODE"]
Embedded["embedded"]
Client["client"]
SDKCreate["createOpencode()"]
SDKClient["createOpencodeClient()"]
Local["本地 opencode CLI 子进程"]
Remote["外部 opencode server"]
Mode --> Embedded --> SDKCreate --> Local
Mode --> Client --> SDKClient --> Remote
```
- `embedded`:服务启动本地 opencode server,同时注入 `TJWATER_AGENT_INTERNAL_BASE_URL` 和 `TJWATER_AGENT_INTERNAL_TOKEN` 给 `.opencode/tools`。
- `client`:服务连接外部 opencode server,适合 runtime 与 API 服务分离。
### 5.4 事件适配层
`streamPromptResponse()` 是前端体验的关键 harness。它订阅 opencode raw events,把内部事件转成稳定 SSE:
| opencode 事件 | SSE 事件 | 用途 |
| --- | --- | --- |
| `session.status` | `progress` | 展示 busy/retry/idle 等状态 |
| `message.part.delta` + text | `token` | 流式输出最终回答 |
| `message.part.updated` + reasoning | `progress` | 展示规划和推理状态 |
| `message.part.updated` + tool | `progress` / `tool_call` | 展示工具状态,并通知前端执行 UI 动作 |
| `todo.updated` | `progress` | 展示计划进度 |
| `session.error` | `error` | 返回错误 |
| `session.idle` | `done` | 结束本轮 |
```mermaid
flowchart TD
Raw["opencode raw event stream"]
Status["session.status"]
Reasoning["reasoning part"]
TextDelta["text delta"]
ToolPart["tool part"]
Todo["todo.updated"]
Error["session.error"]
Idle["session.idle"]
Progress["SSE progress"]
Token["SSE token"]
ToolCall["SSE tool_call"]
SSEError["SSE error"]
Done["SSE done"]
Raw --> Status --> Progress
Raw --> Reasoning --> Progress
Raw --> TextDelta --> Token
Raw --> ToolPart --> Progress
ToolPart --> ToolCall
Raw --> Todo --> Progress
Raw --> Error --> SSEError
Raw --> Idle --> Done
```
适配层还提供:
- 缺失流式文本时,通过 `runtime.messages()` 兜底补发最终回答。
- 工具调用必须提取 `reason`,并写入 LLM request audit。
- 对 running/completed/error 做统一归一化,前端不依赖 opencode 内部状态结构。
### 5.5 工具执行层
`.opencode/tools/*.ts` 是模型能调用的工具,分为三类:
| 类别 | 工具 | 作用 |
| --- | --- | --- |
| 业务数据工具 | `tjwater_cli` | 查询或计算真实后端数据 |
| 上下文工具 | `memory_manager`、`session_search`、`skill_manager` | 管理记忆、历史、技能 |
| 前端动作工具 | `locate_features`、`view_scada`、`view_history`、`show_chart`、`render_junctions`、`apply_layer_style` | 触发地图、图表、面板展示 |
工具桥设计重点:
```mermaid
sequenceDiagram
autonumber
participant M as Model / opencode Tool
participant Tool as .opencode/tools/*.ts
participant Bridge as /internal/tools/*
participant Ctx as SessionRuntimeContextStore
participant API as TJWater API
participant CLI as tjwater-cli
participant Store as ResultReferenceStore
M->>Tool: tool call(args, context.sessionID)
Tool->>Bridge: POST internal endpoint + x-agent-internal-token
Bridge->>Bridge: 校验内部 token
Bridge->>Ctx: 用 session_id 回读用户/项目/trace/token
Bridge->>CLI: --auth-stdin 写入 server/token/project
CLI-->>Bridge: JSON 或 raw stdout
opt 大型 junction 渲染 payload
Bridge->>Store: store_render_ref 写入 render_ref
Bridge-->>Tool: render_ref + preview
end
Tool-->>M: tool result
```
这个设计避免了两个常见问题:
- 模型工具代码不直接持有用户 token。
- 大型 junction 渲染 payload 不直接塞进模型上下文,而是存为 `render_ref`,由前端授权回读。
### 5.6 结果引用层
渲染引用会估算结果大小:
- 节点渲染 payload 存入 `ResultReferenceStore`,返回 `render_ref` 和 `preview`。
- 前端渲染工具通过 `render_ref` 回读完整 junctions 映射。
`ResultReferenceStore` 的权限校验维度:
- `actorKey`
- `projectId`
- 可选 `clientSessionId`
并提供:
- `preview`:列表、对象或标量的摘要。
- TTL 清理:`RESULT_REF_TTL_HOURS` 和 `RESULT_REF_CLEANUP_INTERVAL_MS`。
- legacy render payload 兼容。
### 5.7 记忆、历史与技能沉淀
这部分体现了 harness engineering 的“外部化记忆”和“可复用工作流”:
- `SessionTranscriptStore`:保存最近对话 turn,支持 `session_search`。
- `MemoryStore`:保存用户偏好和 workspace 事实。
- `SkillStore`:保存 workflow skill 的模式和参考资料。
- `LearningOrchestrator`:在 turn 完成后异步运行 gate/review,判断是否沉淀 memory 或 skill。
学习链路:
```mermaid
flowchart LR
Turn["对话 turn 完成"]
Transcript["append transcript"]
Cooldown["检查 cooldown"]
Gate["learning gate<br/>是否值得复盘"]
Review["learning review<br/>生成 memory/skill proposal"]
Policy["persistence policy<br/>脱敏与置信度阈值"]
Memory["MemoryStore"]
Skill["SkillStore"]
Audit["Learning Audit"]
Turn --> Transcript --> Cooldown --> Gate
Gate -->|"skip"| Audit
Gate -->|"accept"| Review --> Policy
Policy --> Memory
Policy --> Skill
Policy --> Audit
```
Agent 指令中明确区分:
- `memory_manager`:长期有效的用户偏好或项目事实。
- `skill_manager`:可复用操作流程。
- `session_search`:历史案例检索,只读。
- `tjwater_cli`:真实业务数据源和分析能力。
## 6. 一次 `/stream` 请求的完整时序
```mermaid
sequenceDiagram
autonumber
participant U as User
participant FE as Frontend
participant API as Express Chat Router
participant SB as ChatSessionBridge
participant Store as Session Stores
participant RT as OpencodeRuntimeAdapter
participant OC as opencode Runtime
participant SSE as Stream Adapter
participant Tool as opencode Tool
participant Bridge as Internal Tool Bridge
participant BE as TJWater API / CLI
U->>FE: 输入问题
FE->>API: POST /api/v1/agent/chat/stream
API->>API: Zod 校验 message/session/model
API->>Store: 读取或创建 session metadata/ui state/transcript
API->>SB: resolve(session, token, user, project, trace)
SB->>RT: createSession 或复用 session
SB->>Store: 写入 runtime context
API->>FE: 建立 SSE
API->>Store: 写入初始 user/assistant streaming messages
API->>Store: 读取 recent turns + memory
API->>SSE: streamPromptResponse(preparedMessage)
SSE->>RT: subscribeEvents()
SSE->>RT: prompt(sessionId, message, model)
RT->>OC: session.prompt
OC-->>SSE: status/reasoning/todo events
SSE-->>FE: progress
OC->>Tool: tool call
Tool->>Bridge: internal callback(sessionID, args, reason)
Bridge->>Store: 回读 runtime context
Bridge->>BE: 注入鉴权上下文后执行 CLI 或存储 render_ref
BE-->>Bridge: CLI 结果或 render_ref
Bridge-->>Tool: CLI 结果或 render_ref
Tool-->>OC: tool result
OC-->>SSE: tool/text/session events
SSE-->>FE: tool_call/token/done
API->>Store: 持久化 UI state、title、transcript
API->>Store: 触发异步 learning gate/review
```
## 7. 与 Harness Engineering 能力项的映射
| Harness 能力项 | 本项目落点 | 说明 |
| --- | --- | --- |
| Runtime orchestration | `OpencodeRuntimeAdapter`、`ChatSessionBridge` | 启动/连接 opencode,创建 session,发送 prompt,等待 idle,abort |
| Context assembly | `buildPromptWithLearningContext()`、runtime context store | 拼接历史、记忆、用户/项目/trace/token |
| Tool dispatch | `.opencode/tools/*.ts`、`/internal/tools/*` | 模型工具经内部桥执行真实能力 |
| Permission boundary | `x-agent-internal-token`、actor/project/session 授权 | token 不进工具代码,render_ref 按用户和项目授权 |
| State persistence | metadata、UI state、transcript、runtime context | 支持会话列表、恢复、重连、分叉和历史检索 |
| Memory | `MemoryStore`、`memory_manager`、learning review | 用户偏好、项目事实、prompt 注入 |
| Skill externalization | `.opencode/skills/workflow`、`skill_manager` | 把验证过的流程沉淀成可复用 workflow |
| Observability | Pino、LLM request audit、learning audit、traceId | 记录工具/技能调用理由、耗时、结果和学习决策 |
| Reliability | abort、timeout、fallback messages、render_ref TTL | 管控长任务、中断、超时、渲染引用和清理 |
| Frontend action bridge | SSE `tool_call` + UI tools | 把 Agent 决策映射为地图、图表、面板动作 |
## 8. 当前设计亮点
1. **模型与权限解耦**:用户 token 由服务端 session context 管理,工具只拿 `sessionID` 回调内部接口。
2. **前端协议稳定**:opencode 原始事件被统一成 `progress/token/tool_call/done/error`。
3. **支持运行时部署弹性**:embedded 和 client 模式兼容本地一体化和外部 runtime。
4. **大型渲染上下文友好**:使用 `render_ref`,避免模型读取完整 `node_area_map`。
5. **可持续沉淀工作流**:Agent 指令、`skill_manager`、`LearningOrchestrator` 共同支持 workflow 生长。
6. **恢复与重连能力**:active run、session UI state、`GET /session/:id/stream` 支持前端重新订阅。
7. **审计意识明确**:工具调用 reason、skill event、learning proposal 均有日志记录。
## 9. 可继续加强的点
| 方向 | 建议 |
| --- | --- |
| 工具治理 | 给工具增加风险等级、只读/写入分类、危险动作二次确认 |
| 幂等与重试 | 对写操作 API/CLI 增加 idempotency key,区分可重试和不可重试错误 |
| Eval harness | 为典型水务任务建立 golden traces,回归验证工具链和 prompt 改动 |
| 结构化事件 | 对 `tool_call` 参数增加 schema/version,便于前端兼容升级 |
| 结果生命周期 | render_ref 可增加按 session 删除、按项目统计和手动清理接口 |
| 安全审计 | 对内部工具桥增加 IP/来源限制、日志脱敏覆盖率检查 |
| Runtime 隔离 | client 模式下明确多租户 runtime session 隔离策略 |
## 10. 一句话结论
TJWaterAgent 的后端 harness 是一套围绕 opencode runtime 构建的 Agent 工程外壳:它把前端交互、LLM 推理、业务工具、用户权限、会话状态、记忆学习和审计观测组织成可运行、可恢复、可治理的生产级 Agent 服务。
+230 -9
View File
@@ -530,7 +530,7 @@
"progress": {
"anyOf": [
{
"$ref": "#/components/schemas/InvestigationProgress"
"$ref": "#/components/schemas/InvestigationProgressInput"
},
{
"type": "null"
@@ -3540,6 +3540,21 @@
"InvestigationDiagram": {
"additionalProperties": false,
"properties": {
"edges": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/InvestigationGraphEdge"
},
"maxItems": 200,
"type": "array"
},
{
"type": "null"
}
],
"title": "Edges"
},
"mermaid": {
"maxLength": 20000,
"minLength": 1,
@@ -3554,6 +3569,19 @@
"title": "Node Refs",
"type": "array"
},
"source_mermaid": {
"anyOf": [
{
"maxLength": 20000,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Mermaid"
},
"title": {
"minLength": 1,
"title": "Title",
@@ -3578,6 +3606,19 @@
"title": "Action Refs",
"type": "array"
},
"detail": {
"anyOf": [
{
"maxLength": 2000,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Detail"
},
"evidence_refs": {
"items": {
"minLength": 1,
@@ -3608,14 +3649,11 @@
"anyOf": [
{
"enum": [
"pending",
"running",
"completed",
"waiting",
"waiting_human",
"reference",
"recorded",
"needs_input",
"failed",
"skipped",
"cancelled"
"not_taken"
],
"type": "string"
},
@@ -3623,7 +3661,7 @@
"type": "null"
}
],
"description": "Evidence-confirmed node state; waiting means prerequisites/external/system result, waiting_human requires manual authorization/approval/feedback. Unknown stays omitted/null; host controls colors.",
"description": "A saved process-reference annotation, not live execution state. recorded means a finding exists, not that a task or field action completed.",
"title": "Status"
}
},
@@ -3634,6 +3672,153 @@
"title": "InvestigationDiagramNodeRef",
"type": "object"
},
"InvestigationGraphEdge": {
"additionalProperties": false,
"properties": {
"label": {
"anyOf": [
{
"maxLength": 200,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Label"
},
"source": {
"pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,79}$",
"title": "Source",
"type": "string"
},
"target": {
"pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,79}$",
"title": "Target",
"type": "string"
}
},
"required": [
"source",
"target"
],
"title": "InvestigationGraphEdge",
"type": "object"
},
"InvestigationGraphNodeUpdate": {
"additionalProperties": false,
"properties": {
"action_refs": {
"items": {
"format": "uuid",
"type": "string"
},
"title": "Action Refs",
"type": "array"
},
"detail": {
"anyOf": [
{
"maxLength": 2000,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Detail"
},
"evidence_refs": {
"items": {
"minLength": 1,
"type": "string"
},
"title": "Evidence Refs",
"type": "array"
},
"label": {
"minLength": 1,
"title": "Label",
"type": "string"
},
"map_targets": {
"items": {
"$ref": "#/components/schemas/InvestigationMapTarget"
},
"maxItems": 100,
"title": "Map Targets",
"type": "array"
},
"node_id": {
"pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,79}$",
"title": "Node Id",
"type": "string"
},
"status": {
"anyOf": [
{
"enum": [
"reference",
"recorded",
"needs_input",
"failed",
"not_taken"
],
"type": "string"
},
{
"type": "null"
}
],
"description": "Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. No live node/task binding or per-step synchronization is required.",
"title": "Status"
}
},
"required": [
"node_id",
"label"
],
"title": "InvestigationGraphNodeUpdate",
"type": "object"
},
"InvestigationGraphUpdate": {
"additionalProperties": false,
"properties": {
"edges": {
"items": {
"$ref": "#/components/schemas/InvestigationGraphEdge"
},
"maxItems": 200,
"title": "Edges",
"type": "array"
},
"nodes": {
"items": {
"$ref": "#/components/schemas/InvestigationGraphNodeUpdate"
},
"maxItems": 100,
"title": "Nodes",
"type": "array"
},
"title": {
"anyOf": [
{
"maxLength": 200,
"minLength": 1,
"type": "string"
},
{
"type": "null"
}
],
"title": "Title"
}
},
"title": "InvestigationGraphUpdate",
"type": "object"
},
"InvestigationMapTarget": {
"additionalProperties": false,
"properties": {
@@ -3691,6 +3876,42 @@
"title": "InvestigationProgress",
"type": "object"
},
"InvestigationProgressInput": {
"additionalProperties": false,
"properties": {
"diagram": {
"anyOf": [
{
"$ref": "#/components/schemas/InvestigationDiagram"
},
{
"type": "null"
}
]
},
"graph_update": {
"anyOf": [
{
"$ref": "#/components/schemas/InvestigationGraphUpdate"
},
{
"type": "null"
}
]
},
"summary": {
"maxLength": 20000,
"minLength": 1,
"title": "Summary",
"type": "string"
}
},
"required": [
"summary"
],
"title": "InvestigationProgressInput",
"type": "object"
},
"InvestigationProgressSnapshot": {
"additionalProperties": false,
"properties": {
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "426b8d9170f253aa7132e9376031a7da8c04ac6f6e693948890d6f343420af1b"
"sha256": "7ed3f6485909c3ef7fac8d2083d6931765e4d197e690c3b9914b905a8aa743b2"
}
}
}
+64 -4
View File
@@ -8,8 +8,63 @@ export function createChartSampleTimeSchema(zod: typeof z = z) {
);
}
type PublicationChart = {
chart_type: string;
x_data: string[];
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
};
/** Mirrors Server time_comparison_chart.py for new publications, never historical reads. */
export function chartPublicationError(chart: PublicationChart): string | null {
const kinds = new Set(chart.series.map((series) => series.type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)));
if (chart.chart_type !== "time_comparison") return kinds.has("line") && kinds.has("scatter")
? "曲线与历史散点复合图须使用 chart_type=time_comparison" : null;
if (kinds.size !== 2 || !kinds.has("line") || !kinds.has("scatter"))
return "time_comparison 必须包含当前 line 和历史 scatter,不能混入 bar";
const labels = chart.x_data;
if (!labels.every((label) => /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/.test(label)) || new Set(labels.map((label) => label.length)).size !== 1)
return "time_comparison.x_data 必须使用一致精度的 HH:mm 或 HH:mm:ss 共享时刻槽";
const seconds = labels.map((label) => label.split(":").reduce((value, part, index) => value + Number(part) * [3600, 60, 1][index], 0));
const offsets = [0];
for (let index = 1; index < seconds.length; index++) {
const step = (seconds[index] - seconds[index - 1] + 86400) % 86400;
if (!step) return "x_data 不能重复时刻,历史点应复用当前槽位";
offsets.push(offsets[index - 1] + step);
}
if (offsets.at(-1)! >= 86400) return "x_data 必须按时间递增且小于一天";
const current = new Set<number>();
const historical: number[] = [];
for (const series of chart.series) {
const times = series.sample_times;
if (!times || times.length !== labels.length) return "sample_times 必须提供并与 x_data 等长";
const bases = new Set<number>();
for (let index = 0; index < times.length; index++) {
const at = times[index];
if ((series.data[index] === null) !== (at === null)) return "有效值必须有真实采样时间,缺测值和时间均填 null";
if (at === null) continue;
const local = new Date(Date.parse(at) + 8 * 3600_000);
const base = local.getTime() - offsets[index] * 1000;
const year = local.getUTCFullYear(), baseYear = new Date(base).getUTCFullYear();
if (!Number.isFinite(base) || year < 1 || year > 9999 || baseYear < 1 || baseYear > 9999)
return "sample_times 超出同期对比可表达的日期范围";
const microseconds = /\.(\d+)/.exec(at)?.[1].slice(0, 6) ?? "0";
if (local.toISOString().slice(11, labels[index].length === 8 ? 19 : 16) !== labels[index] ||
Number(microseconds) !== 0 || labels[index].length === 5 && local.getUTCSeconds() !== 0)
return "sample_times 换算为 UTC+8 后须与 x_data 对应时刻对齐";
bases.add(base);
}
if (bases.size > 1) return "同一序列的 sample_times 必须按同一日期窗口对齐";
if ((series.type ?? "line") === "line") for (const base of bases) current.add(base);
else historical.push(...bases);
}
if (current.size !== 1) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
const start = [...current][0];
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
}
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
export function createViewSchemas(zod: typeof z = z, publication = false) {
export function createViewSchemas(zod: typeof z = z, publication = false, validateCharts = publication) {
const z = zod;
const presentationIdentifierSchema = z.string().trim().min(1).max(128);
@@ -40,7 +95,11 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
(!series.sample_times || series.sample_times.length === value.x_data.length &&
series.sample_times.every((time, index) => time === null || series.data[index] !== null))),
"series must align with x_data"
);
).superRefine((value, ctx) => {
if (!validateCharts) return;
const message = chartPublicationError(value);
if (message) ctx.addIssue({ code: "custom", message });
});
const position = z
.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)])
.rest(z.number().finite())
@@ -214,8 +273,9 @@ export const {
overlayDataSchema,
variants: viewCommandVariants
} = createViewSchemas();
export const createViewCommandSchema = (options: { publication: boolean }) =>
createViewSchemas(z, options.publication).command;
export const safeChartPublicationInput = createViewSchemas(z, true).safeChartInput;
export const createViewCommandSchema = (options: { publication: boolean; validateCharts?: boolean }) =>
createViewSchemas(z, options.publication, options.validateCharts ?? options.publication).command;
type Command = z.infer<ReturnType<typeof createViewCommandSchema>>;
export type ViewCommand =
| Exclude<Command, { action: "set_overlay" }>
+8 -2
View File
@@ -1,11 +1,17 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { viewCommandSchema } from "./command.js";
import { chartPublicationError, viewCommandSchema } from "./command.js";
import { logger } from "../logger.js";
import { getFrontendActionDefinition } from "./registry.js";
import type { FrontendActionRequest, FrontendActionResult } from "./types.js";
const publicationCommandSchema = viewCommandSchema.superRefine((command, context) => {
if (command.action !== "open_chart") return;
const message = chartPublicationError(command.chart);
if (message) context.addIssue({ code: "custom", message, path: ["chart"] });
});
export const frontendActionResultSchema = z.object({
version: z.literal("frontend-action-result@2"),
actionId: z.string().trim().min(1).max(128),
@@ -68,7 +74,7 @@ export class FrontendActionCoordinator {
signal?: AbortSignal;
}): Promise<FrontendActionResult> {
if (!options.toolCallId.trim()) throw new FrontendActionError(400, "frontend action call ID is required");
const parsedParams = viewCommandSchema.safeParse(options.command);
const parsedParams = publicationCommandSchema.safeParse(options.command);
if (!parsedParams.success) throw new FrontendActionError(400, "invalid view command");
const definition = getFrontendActionDefinition(parsedParams.data.action);
if (!definition) throw new FrontendActionError(400, "unknown frontend action");
+54 -3
View File
@@ -5972,7 +5972,7 @@ export interface components {
/** Evidence */
evidence?: components["schemas"]["EvidenceInput"][];
plan?: components["schemas"]["ExecutionPlanInput"] | null;
progress?: components["schemas"]["InvestigationProgress"] | null;
progress?: components["schemas"]["InvestigationProgressInput"] | null;
report?: components["schemas"]["InvestigationReportInput"] | null;
/**
* Request Id
@@ -7084,10 +7084,14 @@ export interface components {
};
/** InvestigationDiagram */
InvestigationDiagram: {
/** Edges */
edges?: components["schemas"]["InvestigationGraphEdge"][] | null;
/** Mermaid */
mermaid: string;
/** Node Refs */
node_refs?: components["schemas"]["InvestigationDiagramNodeRef"][];
/** Source Mermaid */
source_mermaid?: string | null;
/** Title */
title: string;
};
@@ -7095,6 +7099,8 @@ export interface components {
InvestigationDiagramNodeRef: {
/** Action Refs */
action_refs?: string[];
/** Detail */
detail?: string | null;
/** Evidence Refs */
evidence_refs?: string[];
/** Label */
@@ -7105,9 +7111,47 @@ export interface components {
node_id: string;
/**
* Status
* @description Evidence-confirmed node state; waiting means prerequisites/external/system result, waiting_human requires manual authorization/approval/feedback. Unknown stays omitted/null; host controls colors.
* @description A saved process-reference annotation, not live execution state. recorded means a finding exists, not that a task or field action completed.
*/
status?: ("pending" | "running" | "completed" | "waiting" | "waiting_human" | "failed" | "skipped" | "cancelled") | null;
status?: ("reference" | "recorded" | "needs_input" | "failed" | "not_taken") | null;
};
/** InvestigationGraphEdge */
InvestigationGraphEdge: {
/** Label */
label?: string | null;
/** Source */
source: string;
/** Target */
target: string;
};
/** InvestigationGraphNodeUpdate */
InvestigationGraphNodeUpdate: {
/** Action Refs */
action_refs?: string[];
/** Detail */
detail?: string | null;
/** Evidence Refs */
evidence_refs?: string[];
/** Label */
label: string;
/** Map Targets */
map_targets?: components["schemas"]["InvestigationMapTarget"][];
/** Node Id */
node_id: string;
/**
* Status
* @description Optional reference annotation: reference, recorded finding, needs_input, failed attempt, or not_taken. No live node/task binding or per-step synchronization is required.
*/
status?: ("reference" | "recorded" | "needs_input" | "failed" | "not_taken") | null;
};
/** InvestigationGraphUpdate */
InvestigationGraphUpdate: {
/** Edges */
edges?: components["schemas"]["InvestigationGraphEdge"][];
/** Nodes */
nodes?: components["schemas"]["InvestigationGraphNodeUpdate"][];
/** Title */
title?: string | null;
};
/** InvestigationMapTarget */
InvestigationMapTarget: {
@@ -7128,6 +7172,13 @@ export interface components {
/** Summary */
summary: string;
};
/** InvestigationProgressInput */
InvestigationProgressInput: {
diagram?: components["schemas"]["InvestigationDiagram"] | null;
graph_update?: components["schemas"]["InvestigationGraphUpdate"] | null;
/** Summary */
summary: string;
};
/** InvestigationProgressSnapshot */
InvestigationProgressSnapshot: {
/** Execution Generation */
+13
View File
@@ -0,0 +1,13 @@
export const investigationNodeAnnotations = ["reference", "recorded", "needs_input", "failed", "not_taken"] as const;
export type InvestigationNodeAnnotation = (typeof investigationNodeAnnotations)[number];
export const legacyInvestigationNodeAnnotations = ["pending", "running", "completed", "waiting", "waiting_human", "skipped", "cancelled"] as const;
const legacyAnnotations = {
pending: "reference", running: "reference", completed: "recorded",
waiting: "needs_input", waiting_human: "needs_input", skipped: "not_taken", cancelled: "not_taken",
} as const satisfies Record<string, InvestigationNodeAnnotation>;
export function readInvestigationNodeAnnotation(status: string | null | undefined): InvestigationNodeAnnotation | null | undefined {
if (status == null) return status;
if (status in legacyAnnotations) return legacyAnnotations[status as keyof typeof legacyAnnotations];
return status as InvestigationNodeAnnotation;
}
+33 -12
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { supplyLayerSchema } from "./task-presentation.js";
import { answerUiBlockSchema } from "../chat/answer-ui.js";
import { investigationNodeAnnotations, legacyInvestigationNodeAnnotations, readInvestigationNodeAnnotation } from "./investigation-node-state.js";
const text = z.string().trim().min(1);
export function mermaidDeclaresNodeId(source: string, nodeId: string): boolean {
@@ -11,23 +12,41 @@ export function mermaidDeclaresNodeId(source: string, nodeId: string): boolean {
).test(source);
}
export const investigationGraphNodeSchema = z.object({
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text,
detail: text.max(2000).nullish(),
evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([]),
status: z.enum(investigationNodeAnnotations).nullish(),
map_targets: z.array(z.object({
source_id: supplyLayerSchema,
feature_id: text.max(128),
}).strict()).max(100).default([]),
}).strict();
const graphEdgeSchema = z.object({
source: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
target: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text.max(200).nullish(),
}).strict();
export const investigationGraphUpdateSchema = z.object({
title: text.max(200).nullish(),
nodes: z.array(investigationGraphNodeSchema).max(100).default([]),
edges: z.array(graphEdgeSchema).max(200).default([]),
}).strict();
export const investigationDiagramSchema = z.object({
title: text,
mermaid: text.max(20000).refine((source) =>
/^\s*(?:flowchart|graph)\s+(?:TD|TB|BT|LR|RL)\b/.test(source)
&& !source.includes("```") && !source.includes("%%{") && !/(?:^|[;\n])\s*click\b/.test(source),
"调查图只接受不含指令或回调的 Mermaid 流程描述"),
node_refs: z.array(z.object({
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
label: text,
evidence_refs: z.array(text).default([]),
action_refs: z.array(z.string().uuid()).default([]),
status: z.enum(["pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"]).nullish().describe("Evidence-confirmed node state; waiting means prerequisites/external/system result, waiting_human requires manual authorization/approval/feedback. Unknown stays omitted/null; host controls colors."),
map_targets: z.array(z.object({
source_id: supplyLayerSchema,
feature_id: text.max(128),
}).strict()).max(100).default([]),
}).strict()).max(100).default([]),
node_refs: z.array(investigationGraphNodeSchema.extend({
status: z.enum([...investigationNodeAnnotations, ...legacyInvestigationNodeAnnotations]).nullish()
.transform(readInvestigationNodeAnnotation),
})).max(100).default([]),
edges: z.array(graphEdgeSchema).max(200).nullish(),
source_mermaid: text.max(20000).nullish(),
}).strict().superRefine((diagram, ctx) => {
if (new Set(diagram.node_refs.map((node) => node.node_id)).size !== diagram.node_refs.length) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "调查图节点引用重复" });
@@ -45,7 +64,9 @@ export const investigationDiagramSchema = z.object({
export const taskProgressSchema = z.object({
summary: text.max(20000),
diagram: investigationDiagramSchema.nullable().default(null),
}).strict();
graph_update: investigationGraphUpdateSchema.nullish(),
}).strict().refine((progress) => !(progress.diagram && progress.graph_update),
"Use graph_update without a generated Mermaid diagram");
export type TaskProgress = z.infer<typeof taskProgressSchema>;
const dataGapSchema = z.object({
+992
View File
@@ -0,0 +1,992 @@
[
{
"name": "zero and sparse historical data",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "old mixed charts are read only",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "line",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "missing history timestamps",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
]
}
]
}
},
{
"name": "missing observed timestamp",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
null,
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "timestamp on a missing value",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
"2026-07-09T08:00:00+08:00",
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "wrong projected slot",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:30:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "mixed historical dates",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-10T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "same day history",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
}
]
}
},
{
"name": "future history",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-17T08:15:00+08:00",
"2026-07-17T08:30:00+08:00"
]
}
]
}
},
{
"name": "duplicate slots",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:00",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "date appended axis",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"07-16 08:00",
"07-16 08:15",
"07-16 08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "inconsistent slot precision",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "bars mixed with comparison",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "bar",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "no historical series",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
}
]
}
},
{
"name": "no current observations",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
null,
null,
null
],
"sample_times": [
null,
null,
null
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "missing samples with missing timestamps",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
null,
28,
30
],
"sample_times": [
null,
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "UTC spelling preserves projected instant",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T00:15:00Z",
"2026-07-09T00:30:00Z"
]
}
]
}
},
{
"name": "ordinary line",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "line",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "合成读数",
"data": [
0,
null,
30
]
}
]
}
},
{
"name": "ordinary bar",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "bar",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "合成读数",
"data": [
0,
null,
30
]
}
]
}
},
{
"name": "midnight rollover keeps one window",
"accepted": true,
"chart": {
"title": "跨午夜同期对比",
"chart_type": "time_comparison",
"x_data": [
"23:45",
"00:00"
],
"series": [
{
"name": "当前",
"type": "line",
"data": [
0,
25
],
"sample_times": [
"2026-07-16T23:45:00+08:00",
"2026-07-17T00:00:00+08:00"
]
},
{
"name": "历史",
"type": "scatter",
"data": [
26,
27
],
"sample_times": [
"2026-07-15T23:45:00+08:00",
"2026-07-16T00:00:00+08:00"
]
}
]
}
},
{
"name": "microseconds are not aligned",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00.000001+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "fraction beyond Python microsecond precision",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00.0000001+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "calendar overflow",
"accepted": false,
"chart": {
"title": "calendar overflow",
"chart_type": "time_comparison",
"x_data": [
"07:45"
],
"series": [
{
"name": "当前",
"type": "line",
"data": [
1
],
"sample_times": [
"9999-12-31T23:45:00Z"
]
},
{
"name": "历史缺测",
"type": "scatter",
"data": [
null
],
"sample_times": [
null
]
}
]
}
},
{
"name": "calendar underflow",
"accepted": false,
"chart": {
"title": "calendar underflow",
"chart_type": "time_comparison",
"x_data": [
"08:00"
],
"series": [
{
"name": "当前",
"type": "line",
"data": [
1
],
"sample_times": [
"0001-01-01T00:00:00+23:59"
]
},
{
"name": "历史缺测",
"type": "scatter",
"data": [
null
],
"sample_times": [
null
]
}
]
}
}
]
+14
View File
@@ -0,0 +1,14 @@
{"record_type":"presentation","command":{"action":"locate_features","layer":"scada","feature_ids":["P1"]}}
{"record_type":"presentation","command":{"action":"zoom_to_map","center":[121.48,31.1],"zoom":14}}
{"record_type":"presentation","command":{"action":"highlight_features","layer":"scada","feature_ids":["P1"],"effect":"pulse"}}
{"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure-P1","evidence_refs":["dev:synthetic"],"chart":{"title":"P1 合成压力七日对照(m)","chart_type":"time_comparison","x_axis_name":"时间","y_axis_name":"压力(m)","x_data":["08:00","08:15","08:30"],"series":[{"name":"P1 当前 2026-07-16","type":"line","data":[32,28,30],"sample_times":["2026-07-16T08:00:00+08:00","2026-07-16T08:15:00+08:00","2026-07-16T08:30:00+08:00"]},{"name":"P1 2026-07-09","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-09T08:15:00+08:00","2026-07-09T08:30:00+08:00"]},{"name":"P1 2026-07-10","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-10T08:15:00+08:00","2026-07-10T08:30:00+08:00"]},{"name":"P1 2026-07-11","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-11T08:15:00+08:00","2026-07-11T08:30:00+08:00"]},{"name":"P1 2026-07-12","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-12T08:15:00+08:00","2026-07-12T08:30:00+08:00"]},{"name":"P1 2026-07-13","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-13T08:15:00+08:00","2026-07-13T08:30:00+08:00"]},{"name":"P1 2026-07-14","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-14T08:15:00+08:00","2026-07-14T08:30:00+08:00"]},{"name":"P1 2026-07-15","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-15T08:15:00+08:00","2026-07-15T08:30:00+08:00"]}]}}}
{"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure-P2","evidence_refs":["dev:synthetic"],"chart":{"title":"P2 合成压力七日对照(m)","chart_type":"time_comparison","x_axis_name":"时间","y_axis_name":"压力(m)","x_data":["08:00","08:15","08:30"],"series":[{"name":"P2 当前 2026-07-16","type":"line","data":[31,27,29],"sample_times":["2026-07-16T08:00:00+08:00","2026-07-16T08:15:00+08:00","2026-07-16T08:30:00+08:00"]},{"name":"P2 2026-07-09","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-09T08:15:00+08:00","2026-07-09T08:30:00+08:00"]},{"name":"P2 2026-07-10","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-10T08:15:00+08:00","2026-07-10T08:30:00+08:00"]},{"name":"P2 2026-07-11","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-11T08:15:00+08:00","2026-07-11T08:30:00+08:00"]},{"name":"P2 2026-07-12","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-12T08:15:00+08:00","2026-07-12T08:30:00+08:00"]},{"name":"P2 2026-07-13","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-13T08:15:00+08:00","2026-07-13T08:30:00+08:00"]},{"name":"P2 2026-07-14","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-14T08:15:00+08:00","2026-07-14T08:30:00+08:00"]},{"name":"P2 2026-07-15","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-15T08:15:00+08:00","2026-07-15T08:30:00+08:00"]}]}}}
{"record_type":"presentation","command":{"action":"set_overlay","overlay_id":"fixture-area","title":"合成空间样例","mode":"geometry","evidence_id":"dev:synthetic","data":{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"origin":"synthetic"},"geometry":{"type":"Point","coordinates":[121.48,31.1]}}]}}}
{"record_type":"presentation","command":{"action":"render_scada_analysis","items":[{"device_id":"P1","level":"high"}]}}
{"record_type":"ui_block","evidence_refs":[],"block":{"version":"answer-ui@1","block_id":"fixture-summary","phase":"final","fallback_text":"合成候选对比,仅供展示测试。","spec":{"root":"table","elements":{"table":{"type":"Table","props":{"title":"合成候选对比","columns":["候选","说明"],"rows":[["DEMO-PIPE-01","仅供展示测试"],["DEMO-PIPE-02","未执行定位分析"]]},"children":[]}}}}}
{"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","rank":1,"status":"example_only"}
{"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure-P1"}}
{"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure-P2"}}
{"record_type":"presentation","command":{"action":"clear_overlay","overlay_id":"fixture-area"}}
{"record_type":"presentation","command":{"action":"clear_highlights"}}
{"record_type":"presentation","command":{"action":"clear_scada_analysis"}}
@@ -0,0 +1,31 @@
import { expect, it } from "bun:test";
import { readFileSync } from "node:fs";
import cases from "../fixtures/chart-publication-cases.json" with { type: "json" };
import presentView from "../../.opencode/tools/present_view.js";
import { taskPublicationCommandSchema } from "../../.opencode/tools/task-publication-schema.js";
import { taskPresentationCommandSchema } from "../../src/product/task-presentation.js";
import { createViewCommandSchema, safeChartInput } from "../../src/frontendAction/command.js";
import { FrontendActionCoordinator } from "../../src/frontendAction/coordinator.js";
const schemas = [presentView.args.command, taskPublicationCommandSchema, taskPresentationCommandSchema,
createViewCommandSchema({ publication: false, validateCharts: true })];
it.each(cases)("matches Frontend and Server chart publication: $name", ({ chart, accepted }) => {
const command = { action: "open_chart", chart_id: "comparison", evidence_refs: ["dev:synthetic"], chart };
for (const schema of schemas)
expect(schema.safeParse(command).success).toBe(accepted);
expect(() => new FrontendActionCoordinator().request({ sessionId: "synthetic", toolCallId: "case", command }))
.toThrow(accepted ? "no active browser sink" : "invalid view command");
});
it("keeps historical mixed charts structurally readable", () => {
expect(safeChartInput.safeParse(cases.find((item) => item.name === "old mixed charts are read only")!.chart).success).toBe(true);
});
it("accepts all exported commands in both host and native publication validators", () => {
const examples = readFileSync(new URL("../fixtures/unified-presentation.jsonl", import.meta.url), "utf8");
for (const record of examples.trim().split("\n").map((line) => JSON.parse(line))) {
if (record.record_type !== "presentation") continue;
for (const schema of schemas) expect(schema.safeParse(record.command).success).toBe(true);
}
});
@@ -0,0 +1,87 @@
import { describe, expect, it } from "bun:test";
import express from "express";
import { tool } from "../../.opencode/tool.js";
import publishProgress from "../../.opencode/tools/publish_task_progress.js";
import type { OperationalTaskClient } from "../../src/product/operational-task-client.js";
import { investigationNodeAnnotations } from "../../src/product/investigation-node-state.js";
import { registerTaskPublicationBridge } from "../../src/routes/task-publication-bridge.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
const native = tool.schema.object(publishProgress.args);
describe("investigation reference contract", () => {
it("advertises five optional annotations to the model without execution identities or Mermaid", () => {
const schema = tool.schema.toJSONSchema(native, { io: "input" });
const progress = schema.properties!.progress as {
properties: Record<string, {
properties: { nodes: { items: { properties: { status: { anyOf: Array<{ enum?: unknown[] }> } }; required?: string[] } } };
}>;
};
const node = progress.properties.graph_update.properties.nodes.items;
const statuses = node.properties.status.anyOf.flatMap((variant) => variant.enum ?? []);
expect(statuses).toEqual([...investigationNodeAnnotations]);
expect(node.required).not.toContain("status");
expect(Object.keys(schema.properties!)).toEqual(["request_id", "progress"]);
expect(Object.keys(progress.properties)).toEqual(["summary", "graph_update"]);
});
it("transports a native graph update and text-only update through the trusted task bridge", async () => {
const runtimeSessionId = "reference-contract-native-session";
const taskId = "50000000-0000-4000-8000-000000000031";
const token = "reference-contract-test-token";
const published: unknown[][] = [];
setRuntimeSessionContext({
sessionId: runtimeSessionId, clientSessionId: "reference-contract-client", actorKey: "test-actor",
projectKey: "test-project", traceId: "test", taskId, purpose: "task_execution",
});
const app = express();
app.use(express.json());
registerTaskPublicationBridge(app, { internalToken: token, client: {
publishArtifacts: async (...args: unknown[]) => {
published.push(args);
return { progress_revision: published.length };
},
} as unknown as Pick<OperationalTaskClient, "publishArtifacts" | "publishPresentation"> });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
const oldToken = process.env.AGENT_INTERNAL_TOKEN;
const oldBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL;
process.env.AGENT_INTERNAL_TOKEN = token;
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = baseUrl;
try {
const requestId = "60000000-0000-4000-8000-000000000031";
const progress = { summary: "整理已有路线与结果", graph_update: {
nodes: investigationNodeAnnotations.map((status, index) => ({
node_id: `step_${index}`, label: `参考步骤 ${index}`, status,
...(status === "failed" ? { detail: "此前查询超时,保留失败路径" } : {}),
})), edges: [{ source: "step_3", target: "step_0", label: "替代路线" }],
} };
const args = native.parse({ request_id: requestId, progress });
expect(JSON.parse(await publishProgress.execute(args, { sessionID: runtimeSessionId }))).toEqual({ progress_revision: 1 });
expect(published[0]?.slice(0, 2)).toEqual([runtimeSessionId, taskId]);
expect(published[0]?.[2]).toMatchObject({ requestId, sourceSessionId: "reference-contract-client",
progress: { ...progress, diagram: null },
});
await publishProgress.execute(native.parse({ request_id: "60000000-0000-4000-8000-000000000032", progress: { summary: "补充本轮说明" } }),
{ sessionID: runtimeSessionId });
expect(published[1]?.[2]).toMatchObject({ progress: { summary: "补充本轮说明", diagram: null } });
const invalid = await fetch(`${baseUrl}/internal/operational-task/artifacts`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": token },
body: JSON.stringify({ session_id: runtimeSessionId, request_id: requestId, progress: {
summary: "旧执行状态不进入新图", graph_update: { nodes: [{ node_id: "old", label: "核查", status: "running" }] },
} }),
});
expect(invalid.status).toBe(400);
expect(published).toHaveLength(2);
} finally {
if (oldToken === undefined) delete process.env.AGENT_INTERNAL_TOKEN;
else process.env.AGENT_INTERNAL_TOKEN = oldToken;
if (oldBaseUrl === undefined) delete process.env.TJWATER_AGENT_INTERNAL_BASE_URL;
else process.env.TJWATER_AGENT_INTERNAL_BASE_URL = oldBaseUrl;
removeRuntimeSessionContext(runtimeSessionId);
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
+8 -13
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { taskReportSchema, taskProgressSchema } from "../../src/product/investigation-report.js";
import { taskPublicationDiagramSchema, taskPublicationProgressSchema, taskReportUiBlocksSchema } from "../../.opencode/tools/task-publication-schema.js";
import { taskReportUiBlocksSchema } from "../../.opencode/tools/task-publication-schema.js";
const report = { title: "调查报告", current_conclusion: "无法确定原因", conclusion_boundary: "仅有上游数据",
evidence_refs: ["query-1"], recommendations: [], pending_items: [], stage: "final", outcome: "insufficient_data",
@@ -12,11 +12,9 @@ describe("new investigation report writes", () => {
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);
}
});
@@ -41,7 +39,6 @@ describe("new investigation report writes", () => {
}
const mismatched = { ...diagram, node_refs: [{ ...diagram.node_refs[0], node_id: "query" }] };
expect(() => taskReportSchema.parse({ ...report, diagram: mismatched })).toThrow("未在 Mermaid 源码中声明");
expect(taskPublicationDiagramSchema.safeParse(mismatched).success).toBe(false);
});
it("rejects downstream work or unsupported claims on insufficient-data exit", () => {
for (const changes of [{ recommendations: ["派采样工单"] }, { pending_items: ["等待人工"] },
@@ -50,13 +47,13 @@ describe("new investigation report writes", () => {
expect(() => taskReportSchema.parse({ ...report, ...changes })).toThrow();
}
});
it("shares explicit node activity and supply feature targets with progress", () => {
it("shares saved reference annotations and supply feature targets with progress", () => {
const diagram = { title: "当前调查", mermaid: "flowchart LR\nQUERY --> HUMAN",
node_refs: [{ node_id: "QUERY", label: "核对监测", status: "running", evidence_refs: ["query-1"],
map_targets: [{ source_id: "scada", feature_id: "MP01" }] },
{ node_id: "HUMAN", label: "现场核验", status: "waiting_human" }] };
const progress = taskProgressSchema.parse({ summary: "正在核对投诉时段的实际监测记录", diagram });
expect(progress.diagram?.node_refs[0]).toMatchObject({ status: "running", map_targets: [{ source_id: "scada", feature_id: "MP01" }] });
expect(progress.diagram?.node_refs[0]).toMatchObject({ status: "reference", map_targets: [{ source_id: "scada", feature_id: "MP01" }] });
expect(taskReportSchema.parse({ ...report, diagram }).diagram).toEqual(progress.diagram);
expect(taskProgressSchema.parse({ summary: "调整调查方向", diagram: null }).diagram).toBeNull();
for (const change of [{ status: "blocked" }, { map_targets: [{ source_id: "drainage", feature_id: "X" }] },
@@ -66,7 +63,7 @@ describe("new investigation report writes", () => {
} })).toThrow();
}
});
it("preserves waiting, unknown and evidence-confirmed states at every diagram boundary", () => {
it("normalizes legacy states into saved reference annotations at every read boundary", () => {
const diagram = { title: "等待系统结果", mermaid: "flowchart LR\nDONE --> WAIT --> UNKNOWN --> UNSET", node_refs: [
{ node_id: "DONE", label: "查询完成", status: "completed", evidence_refs: ["query-1"] },
{ node_id: "WAIT", label: "等待系统结果", status: "waiting" },
@@ -74,12 +71,10 @@ describe("new investigation report writes", () => {
{ node_id: "UNSET", label: "未确认" },
] };
const nodes = taskProgressSchema.parse({ summary: "等待系统结果", diagram }).diagram!.node_refs;
expect(nodes?.map((node) => node.status)).toEqual(["completed", "waiting", null, undefined]);
expect(nodes?.map((node) => node.status)).toEqual(["recorded", "needs_input", null, undefined]);
expect(taskReportSchema.parse({ ...report, diagram }).diagram?.node_refs).toEqual(nodes);
expect(taskPublicationDiagramSchema.parse(diagram).node_refs).toEqual(nodes);
expect(taskPublicationProgressSchema.parse({ summary: "等待系统结果", diagram }).diagram?.node_refs).toEqual(nodes);
});
it("preserves parallel branches with independent activity and a pending join", () => {
it("preserves parallel branches and recorded findings without live activity assertions", () => {
const mermaid = 'flowchart TD\nSTART[受理投诉] --> SPLIT{并行核查}\nSPLIT --> TICKETS[工单核查]\nSPLIT --> SCADA[监测核查]\nTICKETS --> JOIN[汇总证据]\nSCADA --> JOIN';
const diagram = { title: "并行调查", mermaid, node_refs: [
{ node_id: "TICKETS", label: "工单核查", status: "running" },
@@ -88,11 +83,11 @@ describe("new investigation report writes", () => {
] };
const progress = taskProgressSchema.parse({ summary: "两个独立核查正在执行", diagram });
expect(progress.diagram?.mermaid).toBe(mermaid);
expect(progress.diagram?.node_refs.map((node) => node.status)).toEqual(["running", "running", "pending"]);
expect(progress.diagram?.node_refs.map((node) => node.status)).toEqual(["reference", "reference", "reference"]);
const updated = taskProgressSchema.parse({ summary: "工单核查已完成,监测核查仍在执行", diagram: {
...diagram, node_refs: [{ ...diagram.node_refs[0], status: "completed", evidence_refs: ["query-1"] }, ...diagram.node_refs.slice(1)],
} });
expect(updated.diagram?.node_refs.map((node) => node.status)).toEqual(["completed", "running", "pending"]);
expect(updated.diagram?.node_refs.map((node) => node.status)).toEqual(["recorded", "reference", "reference"]);
});
});
+38 -12
View File
@@ -2,20 +2,11 @@ import { describe, expect, it } from "bun:test";
import publishPresentation from "../../.opencode/tools/publish_task_presentation.js";
import publishProgress from "../../.opencode/tools/publish_task_progress.js";
import { taskPublicationCommandSchema, taskPublicationProgressSchema } from "../../.opencode/tools/task-publication-schema.js";
import { taskPublicationCommandSchema, taskPublicationGraphProgressSchema } from "../../.opencode/tools/task-publication-schema.js";
import { taskProgressSchema } from "../../src/product/investigation-report.js";
import { taskPresentationCommandSchema } from "../../src/product/task-presentation.js";
describe("task presentation input boundaries", () => {
it("aligns native and product progress schemas for legacy and current diagrams", () => {
const progress = { summary: "实际读取监测资料", diagram: { title: "当前路线", mermaid: "flowchart TD\nQUERY --> REVIEW", node_refs: [
{ node_id: "QUERY", label: "查询", status: "running", map_targets: [{ source_id: "pipes", feature_id: "P-1" }] },
{ node_id: "REVIEW", label: "复核", status: null },
] } };
expect(taskProgressSchema.parse(taskPublicationProgressSchema.parse(progress))).toEqual(taskProgressSchema.parse(progress));
expect(taskProgressSchema.parse(taskPublicationProgressSchema.parse({ summary: "当前无图" }))).toEqual({ summary: "当前无图", diagram: null });
});
it("accepts finite display commands and rejects unsupported data, layers and chart evidence", () => {
const chart = { title: "压力", chart_type: "line", x_data: ["10:00"], series: [{ name: "MP01", data: [0.4] }] };
const commands = [
@@ -38,6 +29,41 @@ describe("task presentation input boundaries", () => {
]) expect(() => taskPresentationCommandSchema.parse(command)).toThrow();
});
it("exposes incremental graph updates instead of generated Mermaid to the Agent", () => {
const update = { summary: "首次定位超时,调整窗口重试", graph_update: {
nodes: [{ node_id: "locate_1", label: "首次定位", status: "failed", detail: "计算超时" },
{ node_id: "locate_2", label: "调整窗口重试", status: "reference" }],
edges: [{ source: "locate_1", target: "locate_2", label: "重试" }],
} };
expect(taskProgressSchema.parse(taskPublicationGraphProgressSchema.parse(update)))
.toEqual(taskProgressSchema.parse(update));
expect(taskPublicationGraphProgressSchema.safeParse({ summary: "重新生成", diagram: {
title: "替换图", mermaid: "flowchart TD\nA[新图]",
} }).success).toBe(false);
expect(taskProgressSchema.safeParse({ ...update, diagram: {
title: "替换图", mermaid: "flowchart TD\nA[新图]",
} }).success).toBe(false);
expect(taskProgressSchema.parse(taskPublicationGraphProgressSchema.parse({ summary: "继续等待" })))
.toEqual({ summary: "继续等待", diagram: null });
});
it("accepts optional reference annotations and rejects live execution states in new graph updates", () => {
for (const status of [undefined, "reference", "recorded", "needs_input", "failed", "not_taken"]) {
const progress = { summary: "整理流程参考", graph_update: {
nodes: [{ node_id: "check", label: "核查路线", ...(status ? { status } : {}) }], edges: [],
} };
expect(taskPublicationGraphProgressSchema.safeParse(progress).success).toBe(true);
expect(taskProgressSchema.safeParse(progress).success).toBe(true);
}
for (const status of ["running", "completed", "pending", "waiting", "waiting_human", "skipped", "cancelled"]) {
const progress = { summary: "整理流程参考", graph_update: {
nodes: [{ node_id: "check", label: "核查路线", status }], edges: [],
} };
expect(taskPublicationGraphProgressSchema.safeParse(progress).success).toBe(false);
expect(taskProgressSchema.safeParse(progress).success).toBe(false);
}
});
it("revalidates semantic chart and Mermaid inputs before sending a tool request", async () => {
const request_id = "60000000-0000-4000-8000-000000000001";
await expect(publishPresentation.execute({ request_id, command: {
@@ -46,11 +72,11 @@ describe("task presentation input boundaries", () => {
} }, { sessionID: "test" })).rejects.toThrow("series must align");
await expect(publishProgress.execute({ request_id, progress: {
summary: "查询", diagram: { title: "路线", mermaid: "flowchart TD; A-->B; click A callback", node_refs: [] },
} }, { sessionID: "test" })).rejects.toThrow("Mermaid");
} } as never, { sessionID: "test" })).rejects.toThrow();
await expect(publishProgress.execute({ request_id, progress: {
summary: "查询", diagram: { title: "路线", mermaid: "flowchart TD\nA-->B",
node_refs: [{ node_id: "query", label: "查询", status: "running",
evidence_refs: [], action_refs: [], map_targets: [] }] },
} }, { sessionID: "test" })).rejects.toThrow("未在 Mermaid 源码中声明");
} } as never, { sessionID: "test" })).rejects.toThrow();
});
});
@@ -60,6 +60,12 @@ describe("task publication bridge", () => {
expect(published[1]?.args).toEqual([contexts[0], taskId, {
requestId, sourceSessionId: "client-0", evidence: undefined, executionPlan: undefined, report: undefined, progress,
}]);
const incremental = { summary: "失败后沿新路径继续", graph_update: {
nodes: [{ node_id: "attempt_1", label: "查询", status: "failed", detail: "接口超时" }],
edges: [],
} };
expect((await post("artifacts", { session_id: contexts[0], request_id: requestId, progress: incremental })).status).toBe(200);
expect(published.at(-1)?.args[2]).toMatchObject({ progress: incremental });
const report = { title: "报告", current_conclusion: "核验结束", conclusion_boundary: "授权数据范围",
stage: "final", outcome: "concluded", stop_reason: null, data_gaps: [] };
expect((await post("artifacts", { session_id: contexts[1], request_id: requestId, report })).status).toBe(200);