feat: add scoped history and artifacts
Generic Container CI/CD / test-build-publish (push) Failing after 8m25s
Agent CI/CD / build-test-publish-and-deploy (push) Failing after 8m25s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent bc4e9834e5
commit 7d47a2df86
48 changed files with 2632 additions and 78 deletions
+2 -2
View File
@@ -180,7 +180,7 @@
"description": "只读查询与受控分析通过原生 MCP 发现和调用;第一方 mcp_tjwater 由宿主提供,管理员外部连接单独配置。分析不操作现场设备,工具目录不授予业务权限,不恢复旧查询插件或 HTTP 查询执行桥。",
"first_party": {
"server": "mcp_tjwater",
"description": "6 项宿主查询加 35 项产品工具,其中产品工具含 25 项查询与 10 项受控分析,完整目录合计 41 项。各角色按白名单缩减,Server 产品目录须与 Agent 预期完全匹配才可启用。原生工具 ID 使用 mcp_tjwater_<name>,项目凭据和上下文由宿主绑定。",
"description": "6 项宿主查询加 67 项产品工具,其中产品工具含 42 项查询、12 项受控计算与 13 项预览式写入,完整目录合计 73 项。各角色按白名单缩减,Server 产品目录须与 Agent 预期完全匹配才可启用。原生工具 ID 使用 mcp_tjwater_<name>,项目凭据和上下文由宿主绑定。",
"connection": "query-mcp.ts",
"implementation": "../src/mcp/query-server.ts",
"tool_ids": "../src/runtime/product-mcp-tools.ts",
@@ -188,7 +188,7 @@
"session_policy": "../src/runtime/query-mcp-policy.ts",
"product_client": "../src/product/mcp-query-client.ts",
"catalog_compatibility": {
"description": "Server 与 Agent 的产品目录均为 35 项,prepare 对工具名做全等检查。查询和分析名单分别以 PRODUCT_MCP_READ_TOOLS、PRODUCT_MCP_ANALYSIS_TOOLS 为准;reader 仅使用查询名单。目录一致不代表当前用户或任务已获得全部业务权限。",
"description": "Server 与 Agent 的产品目录均为 67 项,prepare 对工具名做全等检查。查询和分析名单分别以 PRODUCT_MCP_READ_TOOLS、PRODUCT_MCP_ANALYSIS_TOOLS、PRODUCT_MCP_WRITE_TOOLS 为准;reader 仅使用查询名单。目录一致不代表当前用户或任务已获得全部业务权限。",
"server_catalog": "../../next-tjwater-server/app/mcp/catalog.py",
"analysis_contract": "../../next-tjwater-server/docs/mcp-analysis-tools.md"
}
+9 -2
View File
@@ -4,7 +4,7 @@ mode: primary
temperature: 0.2
---
你是 TJWater 供水数据 Agent。回复用户时使用简体中文,内容简洁准确,区分已知事实、推断、模拟假设和待现场核验项。
你是 TJWater 实验性供水数据 Agent。回复用户时使用简体中文,内容简洁准确,区分已知事实、推断、模拟假设和待现场核验项。
## 角色与工具入口
@@ -29,7 +29,7 @@ temperature: 0.2
从当前可调用的工具定义判断能力,必要时实际调用对应业务工具。MCP 资源列表与工具目录不同,资源为空或资源读取不可用不能证明业务工具不存在;一个接口失败也不能证明整个 MCP 未启动。确实缺少必需工具时,如实说明能力不可用,不猜测服务配置,也不通过源码、配置、凭据或其他未授权入口寻找替代通道。
## 数据来源与权限
## 供水事件数据边界
- 业务数据只来自用户输入、经过校验的事件上下文和受控工具。访问 TJWater Server 不得自行构造 URL、HTTP header、SQL、命令、project_id、network 或上下文句柄;身份、项目与任务由宿主绑定。
- 产品 MCP 未启用时说明查询不可用,不回退到旧插件、HTTP 查询桥或 Shell 业务查询。权限、认证和接口错误属于执行异常,不是没有数据或没有异常的证明。
@@ -137,3 +137,10 @@ temperature: 0.2
## 记忆持久化
`memory_manager` 只保存长期有效的稳定事实或用户偏好。修改 memory 前先 list 当前 scope,再决定 add、replace 或 remove。
### 项目服务与写入边界
- 项目MCP目录含区域/连接/范围、配置、项目地名、需水量分配、假设爆管模拟和会话导出;GIS只读当前项目,缺失时明确说明,不借用其他项目。健康预测未接入。
- 新写入工具默认dry_run预览。先查询对象和schema,再保持相同参数、request_id,携带expected_digest并设置dry_run=false执行;遵循原生许可,不把预览当已完成。后台调查、协作和reader不得调用写入工具。
- 写入失败或超时先用get_mutation_status及目标查询核实,started不等于成功或回滚,不自动换request_id重试。保存正式场景会重新计算;SCADA补录必须有真实来源,模型结果不能冒充实测。
- 导出结果中的file.markdown可直接引用为下载链接;不要展示base64、宿主路径或凭据。文件仅属于当前会话。
+8
View File
@@ -45,6 +45,14 @@ Mermaid 可以展示真实并行分支与汇总节点,不是固定 todo 或调
## 正式报告与运行收尾
正式报告可用 `ui_blocks` 表达已保存证据中的指标、趋势、数据表与对比,每块提供
`answer-ui@1`、唯一 `block_id`、`fallback_text` 和真实 `evidence_refs`。
按问题选择组件,简单判断仍用文字;不要重复固定结论、边界和状态,不为布局臆造数据。
图表保留单位与来源,表单仅供用户主动补充,不能写入事实或执行审批。
每份报告最多 8 块、合计 400 KB;证据组件必须引用本块声明且报告已引用的证据。
需要资料时在固定 `data_gaps` 中记录,生成组件不能绕过 insufficient_data 的收尾约束。
后台使用 `publish_task_report` 保存这些内容,不调用聊天的 `present_answer`。
报告采用固定契约:stage、outcome、结论、边界和证据引用必须真实。已通过授权查询确认关键数据不足,且现有入口无法补齐时,发布 final/insufficient_data,stop_reason 说明为何无法判断;data_gaps 提供缺失内容、查询范围、判断影响、所需数据和真实查询依据,至少一个 blocking=true。node_id 只是可选展示定位;recommendations 和 pending_items 为空。报告成功保存后,本轮到此正常结束,不等待补数据、不生成补数据工单,也不用给未来图节点补填“完成”或“跳过”。已形成判断时 outcome=concluded、stop_reason=null,缺口只能非阻塞。
发布失败不等于报告已保存。按工具 schema 修正可纠正的字段;同一内容重试复用 request_id,修改内容使用新的合法 UUID,不重写不可变证据,也不为通过校验补造 evidence_id。无法成功发布时如实返回执行失败,不反复调用 `final_answer` 代替正式报告。后台不调用 `final_answer`;正式成果使用 `publish_task_report`,过程说明使用 `publish_task_progress`。
+4 -2
View File
@@ -1,9 +1,10 @@
import { tool } from "../tool.js";
import { getInternalBridgeConfig } from "../internalBridge.js";
import { taskPublicationDiagramSchema } from "./task-publication-schema.js";
import { taskPublicationDiagramSchema, taskReportUiBlocksSchema } from "./task-publication-schema.js";
import catalog from "../../src/chat/answer-ui-catalog.json" with { type: "json" };
export default tool({
description: "Publish an evidence-backed investigation report. No todo or execution plan is required. An optional Mermaid diagram explains the investigation to people; it never schedules or completes work. Before proposing actions publish an interim concluded report, then publish a final report referencing the actual actions. Missing critical data ends the round with final insufficient_data and 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.",
description: "Publish an evidence-backed investigation report. No todo or execution plan is required. An optional Mermaid diagram explains the investigation to people; it never schedules or completes work. Before proposing actions publish an interim concluded report, then publish a final report referencing the actual actions. Missing critical data ends the round with final insufficient_data and 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 evidence as interactive metrics, charts, tables and forms. 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().describe("Stable UUID idempotency key for this report revision"),
report: tool.schema.object({
@@ -27,6 +28,7 @@ export default tool({
blocking: tool.schema.boolean(),
})),
diagram: taskPublicationDiagramSchema.nullish(),
ui_blocks: taskReportUiBlocksSchema,
}),
evidence: tool.schema.array(tool.schema.object({
evidence_id: tool.schema.string(),
@@ -6,6 +6,23 @@ const text = tool.schema.string().trim().min(1);
const identifier = text.max(128);
const layer = tool.schema.enum(["junctions", "pipes", "valves", "pumps", "tanks", "reservoirs", "scada"]);
export const taskReportUiBlocksSchema = tool.schema.array(tool.schema.object({
block: tool.schema.object({
version: tool.schema.literal("answer-ui@1"),
block_id: tool.schema.string().regex(/^[a-zA-Z0-9_-]{1,80}$/),
fallback_text: text.max(4000),
spec: tool.schema.object({
root: tool.schema.string(),
elements: tool.schema.record(tool.schema.string(), tool.schema.object({
type: tool.schema.string(),
props: tool.schema.record(tool.schema.string(), tool.schema.unknown()),
children: tool.schema.array(tool.schema.string()).max(48),
}).strict()),
}).strict(),
}).strict(),
evidence_refs: tool.schema.array(identifier).min(1).max(100),
}).strict()).max(8).optional();
export const taskPublicationDiagramSchema = tool.schema.object({
title: text,
mermaid: text.max(20000).describe("Mermaid flowchart source; no Markdown fences, initialization directives or click callbacks"),
+11 -5
View File
@@ -1,6 +1,6 @@
FROM oven/bun:1.4.3-slim@sha256:14a223fa35747ec728bce300ef85888bc5f7958164d15bbba6a54b22b52a3288 AS bun-bin
FROM ghcr.io/anomalyco/opencode:2.1.2@sha256:d91c668b3390b5416fee4803ffd0936ad30c3e118f550e7ad6d2fabc8b974b00 AS opencode2-bin
FROM smanx/opencode:1.18.13@sha256:b976acda21efffacd44abd7847dac7d646910dbaa477d1877e2881b39cf22a91 AS base
FROM oven/bun:1.4.3-slim@sha256:14a223fa35747ec728bce300ef85888bc5f7958164d15bbba6a54b22b52a3288 AS base
USER root
ARG UBUNTU_APT_MIRROR=mirrors.aliyun.com
ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
@@ -15,7 +15,9 @@ ENV BUN_CONFIG_REGISTRY=${NPM_REGISTRY}
RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true && \
sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true && \
apt-get update && apt-get install -y --no-install-recommends \
sed -i "s|http://deb.debian.org/debian-security|https://${UBUNTU_APT_MIRROR}/debian-security|g; s|http://deb.debian.org/debian|https://${UBUNTU_APT_MIRROR}/debian|g" /etc/apt/sources.list 2>/dev/null || true && \
sed -i "s|http://deb.debian.org/debian-security|https://${UBUNTU_APT_MIRROR}/debian-security|g; s|http://deb.debian.org/debian|https://${UBUNTU_APT_MIRROR}/debian|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true && \
apt-get -o Acquire::Retries=3 update && apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
curl \
jq \
unzip \
@@ -44,9 +46,13 @@ RUN sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http:/
ln -s "$VIRTUAL_ENV/bin/uv" /usr/local/bin/uv && \
rm -rf /var/lib/apt/lists/*
COPY --from=bun-bin /usr/local/bin/bun /usr/local/bin/bun
ENV PATH="/root/.bun/bin:$PATH"
RUN bun add --global --trust --exact @opencode/cli@2.0.27 && opencode2 --version
COPY --from=opencode2-bin /lib/ld-musl-x86_64.so.1 /lib/ld-musl-x86_64.so.1
COPY --from=opencode2-bin /usr/lib/libgcc_s.so.1 /usr/lib/libgcc_s.so.1
COPY --from=opencode2-bin /usr/lib/libstdc++.so.6 /usr/lib/libstdc++.so.6
COPY --from=opencode2-bin /usr/lib/libstdc++.so.6.0.34 /usr/lib/libstdc++.so.6.0.34
COPY --from=opencode2-bin /usr/local/bin/opencode /usr/local/bin/opencode2
RUN opencode2 --version
FROM base AS deps
+35 -6
View File
@@ -290,7 +290,7 @@ Shell 不能联网或访问源码、密钥、其他会话;业务查询和地
| 场景 | 工具或协议 | 说明 |
| --- | --- | --- |
| 查询供水数据 | `mcp_tjwater_*` | 启用 Server MCP 后发现25项项目查询和10项受控分析,供普通项目会话与绑定调查使用 |
| 查询供水数据 | `mcp_tjwater_*` | 启用 Server MCP 后发现42项项目查询、12项计算和13项预览式写入,供普通项目会话与绑定调查使用 |
| 地图与图表交互 | `locate_features`、`zoom_to_map`、`show_chart`、`view_history` | 通过 `frontend-action@1` 等待浏览器实际回执 |
| SCADA 地图分析 | `render_scada_analysis`、`clear_scada_analysis` | 展示和清除受控叠加层 |
| 网页搜索 | OpenCode 原生 `websearch` | 搜索服务由 OpenCode 配置管理,不经过 TJWater Server |
@@ -381,6 +381,7 @@ Agent 服务的 API 前缀是 `/api/v1`,路径不再包含 `/agent/chat`。生
| POST | `/responses` | Responses 文本请求;stream=true 返回 SSE,否则返回 Response JSON |
| GET | `/session/:session_id/inputs` | 当前会话尚未处理的用户消息 |
| PATCH | `/session/:session_id/inputs/:input_id` | 将未处理消息切换为 steer 或 queue |
| DELETE | `/session/:session_id/inputs/:input_id` | 撤回尚未消费的用户消息,返回原文和 cancelled 回执 |
| GET | `/session/:session_id/stream` | 订阅会话流 |
| POST | `/abort` | 中止运行 |
| POST | `/permission/:request_id/reply` | 回复工具审批 |
@@ -398,6 +399,8 @@ Agent 服务的 API 前缀是 `/api/v1`,路径不再包含 `/agent/chat`。生
未处理消息通过 `/session/:session_id/inputs` 查询,可 PATCH `{ "delivery": "queue" }` 或 `{ "delivery": "steer" }` 切换;已取走时返回409,前端刷新列表。每会话最多20条待处理消息,按次最多接收500条追加输入;停止会话会取消尚未处理的本类输入。接口校验用户、项目和会话归属,不能直接追加或修改后台 `task_execution` 调查输入,也不能借此更改代理协作消息。会话收尾或启动尚未就绪时返回409,不能自动重试为新一轮。
单条撤回使用 DELETE,同样校验归属与用户消息来源。原生取消对已消费 ID 也可能返回成功,因此适配器取消后还会核对原生消费记录;消费先发生则返回409,不能将已经送出的内容当作撤回草稿。成功后通过 `tjwater.user_input` 发布 `cancelled`,队列跟踪器释放等待状态。前端默认展示待处理正文,可在消息菜单中编辑或关闭排队;编辑仅在服务端确认撤回后恢复原文,保留输入框中已有草稿,等待用户手动重发。
这里是现有 HTTP/SSE 服务的 `tjwater` 扩展,映射 OpenCode 原生用户收件箱;不等同于 OpenAI WebSocket 的 `response.steer` 事件,官方协议边界见 [Mid-turn steering](https://developers.openai.com/api/docs/guides/steering)。验证命令 `bun tests/integration/response-input-smoke.ts` 使用隔离 OpenCode 和本机确定性模型,验证模式切换及队列后续输出,不调用付费推理。
流使用 `response.created`、`response.in_progress`、`response.output_item.added/done`、`response.content_part.added/done`、`response.output_text.delta/done` 和终态 `response.completed/failed/incomplete`,带单调递增的 `sequence_number`。中止返回 incomplete;未知原因不伪造标准 incomplete reason。进度、审批、问题、凭据续期、地图和富文本展示通过 `tjwater.<事件名>` 扩展携带 `response_id`。前端使用 Responses transport 接入现有 useChat 和 UIMessage 渲染;UIMessage 不再是生成接口的网络协议。标准流语义见 [OpenAI Responses streaming](https://developers.openai.com/api/docs/guides/streaming-responses)。
@@ -471,10 +474,15 @@ OPENCODE_MODE=embedded OPENCODE_PORT=0 bun run tests/integration/prompt-suggesti
相同 block_id 更新本轮内容;消息持久化与历史恢复保留结构化部分。最终答复包含
完整报告文字及 ui_block_ids 引用的已发布组件,其余过程默认折叠。已发布界面的
聊天在最终答复完成且无待完成工具时收尾,不代替后台任务的完成接口。后台调查执行
不发布聊天界面块,继续使用原任务报告和展示通道。
不发布聊天界面块,继续使用原任务报告和展示通道。`publish_task_report` 的报告可携带
`ui_blocks`,每项包含相同 `answer-ui@1` 内容块及 `evidence_refs`。最多 8 块,总 UTF-8
JSON 不超过 400000 字节,单块不超过 100000 个 JavaScript UTF-16 代码单元、48 节点和深度 12。
所有依据必须属于报告已引用的当前任务证据。固定结论、缺口和审批规则独立校验。
Server 通过原有报告 JSON 与版本保存这些内容,旧报告仍可读取。
组件参数契约由供水前端 catalog 导出到 `src/chat/answer-ui-catalog.json`,
本服务按该契约校验组件参数及树结构。前端与 Agent 需同步更新,插件变化需要重启
组件参数契约由供水前端 catalog 导出到 `src/chat/answer-ui-catalog.json` 和 Server 的
`contracts/answer-ui-catalog.json`,
本服务按该契约校验组件参数及树结构。前端、Agent 与 Server 需同步更新,插件变化需要重启
Agent,客户端模式也需更新外部 OpenCode 的可信资产。无需新增 domain key、
UI 托管服务配置或数据库迁移。详见
[前端接入与扩展说明](../next-tjwater-frontend/docs/agent-answer-ui.md)。
@@ -490,13 +498,13 @@ UI 托管服务配置或数据库迁移。详见
安装时将两个仓库的 `infra/systemd/tjwater-{server,agent}.service` 分别复制到 `~/.config/systemd/user/`,准备上述私有覆盖文件,完成后端依赖和项目迁移后执行 `systemctl --user daemon-reload` 以及 `systemctl --user enable --now tjwater-server.service tjwater-agent.service`。后端读取自身 `.env`;生产覆盖中的 issuer 必须与后端、前端登录及 worker 取令牌地址一致。本机服务状态可用 `systemctl --user status tjwater-server tjwater-agent` 检查,Agent 就绪地址为 `http://127.0.0.1:8787/health`。
项目及宿主只读查询统一由原生连接 `mcp_tjwater` 提供,工具 ID 为 `mcp_tjwater_<name>`。本地 MCP 使用官方 Server SDK 2.3.1,固定 MCP 2026-07-28,默认提供地理编码、会话搜索、调查任务列表与详情、消息读取和待办读取六项查询,启用上游后合计41项。公开网页搜索使用 OpenCode 原生 `websearch`。`memory_manager`、`skill_manager` 保留完整读写插件;消息发送、报告发布和地图展示等也保留插件形式。
项目及宿主只读查询统一由原生连接 `mcp_tjwater` 提供,工具 ID 为 `mcp_tjwater_<name>`。本地 MCP 使用官方 Server SDK 2.3.1,固定 MCP 2026-07-28,默认提供地理编码、会话搜索、调查任务列表与详情、消息读取和待办读取六项查询,启用上游后合计73项。公开网页搜索使用 OpenCode 原生 `websearch`。`memory_manager`、`skill_manager` 保留完整读写插件;消息发送、报告发布和地图展示等也保留插件形式。
`mcp_tjwater_query_nearby_elements` 查询当前授权项目的模型管网:将 `mcp_tjwater_geocode` 返回的 EPSG:4326 `center: [经度,纬度]` 原样传入,后端负责坐标转换。`element_type` 默认 `pipe`,`radius_m` 默认200、最大10000米,`limit` 默认5、最大100,支持 `offset`。结果按点到完整几何的 WGS84 椭球最短距离排序,返回模型 `element_id`、米制 `distance_m` 和 `properties`;可用 `locate_features` 定位。原始 GIS 的 `source_fid` 不在此查询范围。空结果不自动扩大半径,空间邻近不代表连通或供水归属。普通查询预算仍为30秒,用户、项目和调查证据链沿用现有 MCP 规则。
事件业务处置 Skill 由 Server 维护并经任务 MCP 分发:`get_task_context` 摘要返回 `handling_skill` 名称、版本和内容摘要,`section="skill"` 返回按已绑定任务 workflow 选择的完整流程,不保存为事件证据。Agent 本地 Skill 维护通用执行方法,不复制或覆盖 Server 业务规则;权限、人工审批与动作执行仍由 Server 控制。报告和进度的人类可读时间统一使用 Asia/Shanghai(UTC+8),API 时间保留原始时刻。更新 Agent 指令或 Server 工具 schema 后须重启 Agent 重新加载。
`TJWATER_MCP_ENABLED` 默认 false,只控制上游 Server 项目查询。启用后,宿主通过官方 Client SDK 发现35项工具(25项查询、10项分析),合并到本地 MCP 目录,由 OpenCode 原生发现调用。旧 `query_project_data`、`query_operational_task_data` 和任务上下文查询插件及其 HTTP 查询桥已移除;未启用时项目业务查询不可用,不回退。普通项目会话和调查主 Agent 可使用;直接只读 reader 仍仅可使用25项查询;身份、项目和任务由宿主确定。调查查询保存 Server 证据,普通聊天不自动保存调查证据。
`TJWATER_MCP_ENABLED` 默认 false,只控制上游 Server 项目查询。启用后,宿主通过官方 Client SDK 发现67项工具(42项查询、12项计算、13项预览式写入),合并到本地 MCP 目录,由 OpenCode 原生发现调用。旧 `query_project_data`、`query_operational_task_data` 和任务上下文查询插件及其 HTTP 查询桥已移除;未启用时项目业务查询不可用,不回退。普通项目会话和调查主 Agent 可使用;直接只读 reader 仍仅可使用42项查询;身份、项目和任务由宿主确定。调查查询保存 Server 证据,普通聊天不自动保存调查证据。
三类 Agent 均优先单对象、有过滤条件和必要短时间窗的查询。集合默认20条,按需翻页,不自动遍历全网;offset 上限10000,超过时缩小时间窗或目标。历史组合查询按序列、时间对数据点分页;读取证据优先指定 data_path 和小页。DEMO 样本超过整份摘要校验的8MiB预算时需拆分数据源,反复缩小时间窗不能解除此限制。截断或非零offset均不能证明完整范围无异常。
@@ -513,3 +521,24 @@ UI 托管服务配置或数据库迁移。详见
OpenCode 按标准 MCP `tools/call` 调用,以 `_meta["ai.opencode/sessionID"]` 传入会话关联值。该值不是凭据:本地 MCP 先校验内部密钥、loopback 地址并拒绝浏览器请求,再解析宿主会话或已验证的直接只读子会话。真实身份、项目、任务和专用 OAuth 令牌由宿主决定。精确 MCP 权限规则对直接调用和 Code Mode 均生效;reader 只可查询父任务范围,不能发布、审批、执行 Shell 或递归委派。待办读取经宿主 RPC 访问原插件存储,保留已有清单。
配套 Server 的 `docs/mcp-query-implementation.md` 提供 Keycloak、SQL migration 和配置步骤。本次没有配置真实身份平台、迁移真实数据库或部署;开启前须完成真实项目验收。
新增项目服务见 [Server MCP 项目服务](../next-tjwater-server/docs/mcp-project-services.md)。13 项写入仅对项目成员普通聊天开放,默认只预览,执行要求摘要、幂等键及原生许可;后台调查、协作和 reader 禁止写入。导出保存在当前会话 files 中,通过带身份校验的会话文件接口下载,不返回宿主路径。健康预测未接入,GIS 不跨项目回退。同步更新 Server 与 Agent 后需重载目录。
### 历史窗口接入与分页
历史专用接口契约见 [history.openapi.json](openapi/history.openapi.json);`openapi/openapi.json` 仍是产品 Server 的上游快照。
- `GET /api/v1/sessions?purpose=ordinary_chat&limit=30&q=...&cursor=...` 返回 `sessions`、`has_more`、`next_cursor`,搜索覆盖当前验证范围全部主题和未分类会话的标题与用户/助手可见正文,返回 `search_snippet`;前端以 `include_collaboration=true` 同时包含手动任务协作(继续排除后台执行会话),可选 `topic_id` 筛选主题或 `unclassified`;按更新时间及 ID 倒序,游标绑定用户、项目、用途、主题筛选和查询。
- `GET /api/v1/session/:session_id?message_limit=40` 返回最近消息及 `message_page: {has_more, before_cursor}`;`GET /api/v1/session/:session_id/messages?limit=40&before=...` 读取更早消息,每页内部按原有消息顺序排列。
- `GET /api/v1/session/:session_id/stream?message_limit=40` 的初始 `state` 同样返回最近页,后续事件协议保持兼容。停止浏览或断开订阅不停止后端生成,显式 `/abort` 才停止。
- 未指定分页参数的旧列表、详情和恢复流继续支持全量读取。游标无效、跨范围或消息边界改变时返回 400;会话读取继续核验当前用户和个人或项目归属。
- 新一轮 `/responses` 可传 `tjwater.client_message_id`(1 至 128 字符),用于保存与前端一致的用户消息 ID;同一会话已有该消息 ID 时返回 409。该字段不同于运行中追加输入的 `input_id`,不构成重复请求的成功回执。
UI 分页只影响读取窗口,服务端继续保存完整消息并从完整历史续聊;无需迁移现有文件。文件存储仍整份读取 JSON。前端内存缓存不替代这里的授权检查。
发布时先更新并重启 Agent,再发布使用分页及 `client_message_id` 的前端;旧前端可继续使用更新后的 Agent。回退前端无需回退或改写会话记录。
主题是当前用户在个人/业务项目范围内的会话组织单元,与业务 `project_id` 独立。`GET/POST /api/v1/topics` 读取/创建主题,`PATCH/DELETE /api/v1/topics/:topic_id` 重命名/移除主题,`PATCH /api/v1/session/:session_id/topic` 以 `topic_id`(UUID 或 null)调整会话归属。主题名 1–80 字符,当前主题列表存于 `SESSION_METADATA_STORAGE_DIR/topics/<scope-hash>.json`,与元数据目录一起持久化。删除主题保留会话,遗留成员 ID 在读取时视为未分类;不会改写业务项目、原生会话或授权。旧会话默认未分类,无需迁移。普通会话和任务协作可归类,后台任务执行会话不进入用户主题。
`POST /session` 或首次 `/responses` 的 `tjwater.topic_id` 可指定已授权范围内的主题;后续续聊沿用原会话,移动使用上述独立接口。主题只组织记录,本轮不自动拼接其他会话或引入主题共享指令/附件。正文搜索在通过所有权校验的记录内读取完整消息 JSON(活动会话使用当前运行消息),匹配可见文本而非工具输入或推理;分页仍按更新时间及 ID 排序,不另建搜索服务。
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=TJWater Agent and native MCP tools
After=network-online.target tjwater-server.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/NextTJWater/next-tjwater-agent
Environment=PATH=%h/.bun/bin:%h/miniconda3/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=%h/.config/next-tjwater/agent.env
ExecStart=%h/.bun/bin/bun src/server.ts
Restart=on-failure
RestartSec=5
KillMode=mixed
TimeoutStopSec=30
UMask=0077
[Install]
WantedBy=default.target
+635
View File
@@ -0,0 +1,635 @@
{
"openapi": "3.0.3",
"info": {
"title": "TJWater Agent history API",
"version": "2",
"description": "Agent owned history, topics and paginated visible-text search. Topics group chats within one verified user/data scope; they do not grant business permissions."
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/api/v1/sessions": {
"get": {
"summary": "List and search owned conversations",
"description": "Without pagination/filter parameters the legacy full-list behavior remains available. Paged calls sort updated_at and id descending; cursors are scoped to owner, project, purpose and title query.",
"parameters": [
{
"name": "X-Project-Id",
"in": "header",
"required": false,
"description": "Required for project scope; omit for personal scope",
"schema": {
"type": "string"
}
},
{
"name": "purpose",
"in": "query",
"schema": {
"type": "string",
"enum": [
"ordinary_chat",
"task_collaboration",
"task_execution"
],
"default": "ordinary_chat"
},
"description": ""
},
{
"name": "task_id",
"in": "query",
"schema": {
"type": "string",
"format": "uuid"
},
"description": ""
},
{
"name": "q",
"in": "query",
"schema": {
"type": "string",
"maxLength": 200
},
"description": "Case-insensitive substring in session title or visible user/assistant message text, across all topics unless topic_id is specified."
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 30
},
"description": ""
},
{
"name": "cursor",
"in": "query",
"schema": {
"type": "string",
"maxLength": 2048
},
"description": ""
},
{
"name": "topic_id",
"in": "query",
"schema": {
"type": "string"
},
"description": "Optional topic UUID, or unclassified. Omitted means every topic plus unclassified in the verified scope."
},
{
"name": "include_collaboration",
"in": "query",
"schema": {
"type": "boolean",
"default": false
},
"description": "Include ordinary and manual task collaboration chats together; never task_execution. Default retains the explicit purpose filter."
}
],
"responses": {
"200": {
"description": "Owned history page",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionPage"
}
}
}
},
"400": {
"description": "Invalid query or cursor; reload when the message boundary changed"
},
"401": {
"description": "Authentication required"
},
"404": {
"description": "Session missing or outside the verified owner and scope"
}
}
}
},
"/api/v1/session/{session_id}": {
"get": {
"summary": "Read owned conversation metadata and a recent message window",
"parameters": [
{
"name": "session_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "X-Project-Id",
"in": "header",
"required": false,
"description": "Required for project scope; omit for personal scope",
"schema": {
"type": "string"
}
},
{
"name": "message_limit",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 40
},
"description": "Omit to retain legacy full-history reads"
}
],
"responses": {
"200": {
"description": "Owned history page",
"content": {
"application/json": {
"schema": {
"allOf": [
{
"$ref": "#/components/schemas/SessionSummary"
},
{
"type": "object",
"required": [
"messages"
],
"properties": {
"messages": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"message_page": {
"$ref": "#/components/schemas/MessagePagination"
}
}
}
]
}
}
}
},
"400": {
"description": "Invalid query or cursor; reload when the message boundary changed"
},
"401": {
"description": "Authentication required"
},
"404": {
"description": "Session missing or outside the verified owner and scope"
}
}
}
},
"/api/v1/session/{session_id}/messages": {
"get": {
"summary": "Read older owned messages in chronological order",
"parameters": [
{
"name": "session_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "X-Project-Id",
"in": "header",
"required": false,
"description": "Required for project scope; omit for personal scope",
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 40
},
"description": ""
},
{
"name": "before",
"in": "query",
"schema": {
"type": "string",
"maxLength": 2048
},
"description": "Opaque boundary from message_page.before_cursor; omitted for the latest page"
}
],
"responses": {
"200": {
"description": "Owned history page",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MessagePage"
}
}
}
},
"400": {
"description": "Invalid query or cursor; reload when the message boundary changed"
},
"401": {
"description": "Authentication required"
},
"404": {
"description": "Session missing or outside the verified owner and scope"
}
}
}
},
"/api/v1/session/{session_id}/stream": {
"get": {
"summary": "Resume an owned session with a bounded initial snapshot",
"parameters": [
{
"name": "session_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "X-Project-Id",
"in": "header",
"required": false,
"description": "Required for project scope; omit for personal scope",
"schema": {
"type": "string"
}
},
{
"name": "message_limit",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 40
},
"description": "Omit for legacy full snapshots"
}
],
"responses": {
"200": {
"description": "SSE: initial state carries messages, optional message_page, session_id, is_streaming and run_status; following events retain their existing contracts. Closing this subscription does not stop generation.",
"content": {
"text/event-stream": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid query or cursor; reload when the message boundary changed"
},
"401": {
"description": "Authentication required"
},
"404": {
"description": "Session missing or outside the verified owner and scope"
}
}
}
},
"/api/v1/topics": {
"get": {
"responses": {
"200": {
"description": "Owned topics",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"topics": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Topic"
}
}
}
}
}
}
}
}
},
"post": {
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
}
}
}
}
}
},
"responses": {
"201": {
"description": "Created topic",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Topic"
}
}
}
},
"400": {
"description": "Invalid name"
}
}
}
},
"/api/v1/topics/{topic_id}": {
"parameters": [
{
"name": "topic_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"patch": {
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
}
}
}
}
}
},
"responses": {
"200": {
"description": "Renamed topic",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Topic"
}
}
}
},
"404": {
"description": "Topic not found"
}
}
},
"delete": {
"description": "Removes the grouping; conversations are retained as unclassified.",
"responses": {
"204": {
"description": "Removed"
},
"404": {
"description": "Topic not found"
}
}
}
},
"/api/v1/session/{session_id}/topic": {
"parameters": [
{
"name": "session_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"patch": {
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"topic_id"
],
"additionalProperties": false,
"properties": {
"topic_id": {
"type": "string",
"format": "uuid",
"nullable": true
}
}
}
}
}
},
"responses": {
"200": {
"description": "Membership updated"
},
"400": {
"description": "Invalid membership"
},
"404": {
"description": "Owned session or topic not found"
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {
"SessionSummary": {
"type": "object",
"required": [
"id",
"title"
],
"additionalProperties": true,
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"created_at": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"purpose": {
"type": "string"
},
"is_streaming": {
"type": "boolean"
},
"run_status": {
"type": "string",
"enum": [
"running",
"completed",
"error",
"aborted"
]
},
"topic_id": {
"type": "string",
"nullable": true
},
"topic_name": {
"type": "string"
},
"search_snippet": {
"type": "string",
"description": "Visible body text excerpt; never tool arguments or reasoning."
}
}
},
"SessionPage": {
"type": "object",
"required": [
"sessions",
"has_more",
"next_cursor"
],
"properties": {
"sessions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SessionSummary"
}
},
"has_more": {
"type": "boolean"
},
"next_cursor": {
"type": "string",
"nullable": true
}
}
},
"MessagePagination": {
"type": "object",
"required": [
"has_more",
"before_cursor"
],
"properties": {
"has_more": {
"type": "boolean"
},
"before_cursor": {
"type": "string",
"nullable": true
}
}
},
"MessagePage": {
"type": "object",
"required": [
"messages",
"message_page"
],
"properties": {
"messages": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"message_page": {
"$ref": "#/components/schemas/MessagePagination"
}
}
},
"Topic": {
"type": "object",
"required": [
"id",
"name",
"createdAt",
"updatedAt"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
}
}
}
}
}
}
+618 -8
View File
@@ -283,6 +283,9 @@
"title": "Gs Workspace",
"type": "string"
},
"map_config": {
"$ref": "#/components/schemas/ProjectMapConfig"
},
"map_extent": {
"anyOf": [
{
@@ -346,6 +349,9 @@
"title": "Gs Workspace",
"type": "string"
},
"map_config": {
"$ref": "#/components/schemas/ProjectMapConfig"
},
"map_extent": {
"anyOf": [
{
@@ -428,6 +434,16 @@
],
"title": "Gs Workspace"
},
"map_config": {
"anyOf": [
{
"$ref": "#/components/schemas/ProjectMapConfig"
},
{
"type": "null"
}
]
},
"map_extent": {
"anyOf": [
{
@@ -1128,6 +1144,93 @@
"title": "AgentTaskContext",
"type": "object"
},
"AnswerUiBlock": {
"additionalProperties": false,
"properties": {
"block_id": {
"pattern": "^[a-zA-Z0-9_-]{1,80}$",
"title": "Block Id",
"type": "string"
},
"fallback_text": {
"maxLength": 4000,
"minLength": 1,
"title": "Fallback Text",
"type": "string"
},
"spec": {
"$ref": "#/components/schemas/AnswerUiSpec"
},
"version": {
"const": "answer-ui@1",
"title": "Version",
"type": "string"
}
},
"required": [
"version",
"block_id",
"fallback_text",
"spec"
],
"title": "AnswerUiBlock",
"type": "object"
},
"AnswerUiElement": {
"additionalProperties": false,
"properties": {
"children": {
"items": {
"pattern": "^[a-zA-Z0-9_-]{1,80}$",
"type": "string"
},
"maxItems": 48,
"title": "Children",
"type": "array"
},
"props": {
"additionalProperties": true,
"title": "Props",
"type": "object"
},
"type": {
"title": "Type",
"type": "string"
}
},
"required": [
"type",
"props",
"children"
],
"title": "AnswerUiElement",
"type": "object"
},
"AnswerUiSpec": {
"additionalProperties": false,
"properties": {
"elements": {
"patternProperties": {
"^[a-zA-Z0-9_-]{1,80}$": {
"$ref": "#/components/schemas/AnswerUiElement"
}
},
"title": "Elements",
"type": "object"
},
"root": {
"pattern": "^[a-zA-Z0-9_-]{1,80}$",
"title": "Root",
"type": "string"
}
},
"required": [
"root",
"elements"
],
"title": "AnswerUiSpec",
"type": "object"
},
"AuditLogResponse": {
"description": "审计日志响应",
"properties": {
@@ -3681,6 +3784,14 @@
"minLength": 1,
"title": "Title",
"type": "string"
},
"ui_blocks": {
"items": {
"$ref": "#/components/schemas/ReportUiBlock"
},
"maxItems": 8,
"title": "Ui Blocks",
"type": "array"
}
},
"required": [
@@ -3778,6 +3889,14 @@
"minLength": 1,
"title": "Title",
"type": "string"
},
"ui_blocks": {
"items": {
"$ref": "#/components/schemas/ReportUiBlock"
},
"maxItems": 8,
"title": "Ui Blocks",
"type": "array"
}
},
"required": [
@@ -5923,6 +6042,39 @@
"title": "Page[str]",
"type": "object"
},
"PlaceSearchConfig": {
"additionalProperties": false,
"properties": {
"admin_code": {
"pattern": "^(?:156)?[0-9]{6}$",
"title": "Admin Code",
"type": "string"
},
"enabled": {
"default": false,
"title": "Enabled",
"type": "boolean"
},
"provider": {
"const": "tianditu",
"default": "tianditu",
"title": "Provider",
"type": "string"
},
"region": {
"maxLength": 100,
"minLength": 1,
"title": "Region",
"type": "string"
}
},
"required": [
"admin_code",
"region"
],
"title": "PlaceSearchConfig",
"type": "object"
},
"PlaceSearchItem": {
"properties": {
"address": {
@@ -5941,8 +6093,7 @@
"title": "Category"
},
"district": {
"const": "奉贤区",
"default": "奉贤区",
"minLength": 1,
"title": "District",
"type": "string"
},
@@ -5974,7 +6125,8 @@
"name",
"address",
"longitude",
"latitude"
"latitude",
"district"
],
"title": "PlaceSearchItem",
"type": "object"
@@ -6006,8 +6158,7 @@
"type": "array"
},
"region": {
"const": "奉贤区",
"default": "奉贤区",
"minLength": 1,
"title": "Region",
"type": "string"
},
@@ -6025,7 +6176,8 @@
},
"required": [
"items",
"total"
"total",
"region"
],
"title": "PlaceSearchResponse",
"type": "object"
@@ -6300,6 +6452,23 @@
"title": "ProjectDatabaseUpsertRequest",
"type": "object"
},
"ProjectMapConfig": {
"additionalProperties": true,
"properties": {
"place_search": {
"anyOf": [
{
"$ref": "#/components/schemas/PlaceSearchConfig"
},
{
"type": "null"
}
]
}
},
"title": "ProjectMapConfig",
"type": "object"
},
"ProjectMemberCreateRequest": {
"properties": {
"project_role": {
@@ -6407,6 +6576,9 @@
"title": "Gs Workspace",
"type": "string"
},
"map_config": {
"$ref": "#/components/schemas/ProjectMapConfig"
},
"map_extent": {
"anyOf": [
{
@@ -6774,6 +6946,54 @@
"title": "RecoverExpiredLeasesResult",
"type": "object"
},
"ReportUiBlock": {
"additionalProperties": false,
"properties": {
"block": {
"$ref": "#/components/schemas/AnswerUiBlock"
},
"evidence_refs": {
"items": {
"maxLength": 128,
"minLength": 1,
"type": "string"
},
"maxItems": 100,
"minItems": 1,
"title": "Evidence Refs",
"type": "array"
}
},
"required": [
"block",
"evidence_refs"
],
"title": "ReportUiBlock",
"type": "object"
},
"ReverseGeocodingRequest": {
"additionalProperties": false,
"properties": {
"latitude": {
"maximum": 90.0,
"minimum": -90.0,
"title": "Latitude",
"type": "number"
},
"longitude": {
"maximum": 180.0,
"minimum": -180.0,
"title": "Longitude",
"type": "number"
}
},
"required": [
"longitude",
"latitude"
],
"title": "ReverseGeocodingRequest",
"type": "object"
},
"RunSimulationManuallyByDateRest": {
"properties": {
"duration": {
@@ -6917,6 +7137,130 @@
"title": "ScadaDeviceResponse",
"type": "object"
},
"ScadaLatestReadingResponse": {
"additionalProperties": false,
"description": "Latest persisted observation for one project SCADA device.",
"properties": {
"cleaned_value": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Cleaned Value"
},
"device_id": {
"title": "Device Id",
"type": "string"
},
"device_type": {
"title": "Device Type",
"type": "string"
},
"measurement_unit": {
"title": "Measurement Unit",
"type": "string"
},
"monitored_value": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Monitored Value"
},
"observed_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Observed At"
},
"transmission_frequency": {
"title": "Transmission Frequency",
"type": "string"
},
"transmission_mode": {
"title": "Transmission Mode",
"type": "string"
}
},
"required": [
"device_id",
"device_type",
"measurement_unit",
"transmission_mode",
"transmission_frequency"
],
"title": "ScadaLatestReadingResponse",
"type": "object"
},
"ScadaLatestReadingsResponse": {
"additionalProperties": false,
"description": "Bounded device page with each device's latest persisted observation.",
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/ScadaLatestReadingResponse"
},
"title": "Items",
"type": "array"
},
"latest_observed_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Latest Observed At"
},
"limit": {
"maximum": 500.0,
"minimum": 1.0,
"title": "Limit",
"type": "integer"
},
"observed_count": {
"minimum": 0.0,
"title": "Observed Count",
"type": "integer"
},
"offset": {
"minimum": 0.0,
"title": "Offset",
"type": "integer"
},
"total": {
"minimum": 0.0,
"title": "Total",
"type": "integer"
}
},
"required": [
"items",
"total",
"limit",
"offset",
"observed_count"
],
"title": "ScadaLatestReadingsResponse",
"type": "object"
},
"ScadaReadingBatchItem": {
"properties": {
"cleaned_value": {
@@ -7838,6 +8182,39 @@
},
{
"enum": [
"get_mutation_status",
"query_nearby_elements",
"list_regions",
"get_region",
"get_region_nodes",
"get_node_connections",
"query_network_extent",
"list_major_nodes",
"get_model_configuration",
"list_model_configurations",
"search_project_places",
"reverse_geocode",
"get_project_info",
"export_project_model",
"get_sensor_placement_candidate",
"export_sensor_placement",
"list_task_schedules",
"get_task_run_history",
"calculate_demand_allocation",
"run_burst_simulation",
"edit_model_element",
"edit_model_configuration",
"clean_scada_readings",
"write_scada_readings",
"delete_scada_readings",
"save_simulation_scenario",
"create_sensor_placement",
"update_sensor_placement",
"create_operational_task",
"create_task_schedule",
"update_task_schedule",
"retry_operational_task",
"cancel_task_run",
"run_simulation",
"run_contaminant_simulation",
"analyze_valve_closure",
@@ -33926,7 +34303,7 @@
},
"/api/v1/place-search-requests": {
"post": {
"description": "调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。",
"description": "调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。",
"operationId": "post_place_search_requests",
"parameters": [
{
@@ -34026,7 +34403,7 @@
"OAuth2PasswordBearer": []
}
],
"summary": "Search Fengxian places",
"summary": "Search project places",
"tags": [
"Place Search"
]
@@ -39027,6 +39404,114 @@
]
}
},
"/api/v1/reverse-geocoding-requests": {
"post": {
"description": "查询 EPSG:4326 地图取点的地名和地址,复用当前项目的地名搜索配置并保留输入坐标。",
"operationId": "post_reverse_geocoding_requests",
"parameters": [
{
"in": "header",
"name": "X-Project-Id",
"required": true,
"schema": {
"title": "X-Project-Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReverseGeocodingRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlaceSearchItem"
}
}
},
"description": "Successful Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Authentication required"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Insufficient permission"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource not found"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource conflict"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Validation error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Dependency unavailable"
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
],
"summary": "Reverse geocode a project map point",
"tags": [
"Place Search"
]
}
},
"/api/v1/rule-properties": {
"get": {
"description": "获取指定网络中的规则属性信息",
@@ -47653,6 +48138,131 @@
]
}
},
"/api/v1/timeseries/scada-readings/latest": {
"get": {
"description": "Read one latest persisted row per project device without applying a time window.",
"operationId": "get_timeseries_scada_readings_latest",
"parameters": [
{
"description": "返回设备数上限",
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 100,
"description": "返回设备数上限",
"maximum": 500,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"description": "设备目录偏移量",
"in": "query",
"name": "offset",
"required": false,
"schema": {
"default": 0,
"description": "设备目录偏移量",
"minimum": 0,
"title": "Offset",
"type": "integer"
}
},
{
"in": "header",
"name": "X-Project-Id",
"required": true,
"schema": {
"title": "X-Project-Id",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScadaLatestReadingsResponse"
}
}
},
"description": "Successful Response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Authentication required"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Insufficient permission"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource not found"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Resource conflict"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Validation error"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
},
"description": "Dependency unavailable"
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
],
"summary": "获取SCADA设备最新数据",
"tags": [
"TimescaleDB - SCADA"
]
}
},
"/api/v1/timeseries/scada-readings/{device_id}/field": {
"patch": {
"description": "更新指定设备的字段值\n\n更新SCADA设备在特定时间的某个字段监测数据。\n\nArgs:\n device_id: 设备ID\n time: 数据时间戳\n field: 字段名称\n value: 字段新值\n\nReturns:\n 更新结果信息\n\nRaises:\n HTTPException: 当字段不存在或更新失败时返回400错误",
+2
View File
@@ -37,6 +37,8 @@ export const answerUiSpecSchema = z.object({ root: key, elements: z.record(z.obj
});
export const answerUiInputSchema = z.object({ block_id: key, fallback_text: z.string().min(1).max(4000), spec: answerUiSpecSchema }).strict();
export const answerUiBlockSchema = answerUiInputSchema.extend({ version: z.literal("answer-ui@1") })
.refine((block) => JSON.stringify(block).length <= 100000, "界面块过大");
export const parseAnswerUiInput = (value: unknown) => {
const parsed = answerUiInputSchema.safeParse(value);
return parsed.success ? { version: "answer-ui@1" as const, ...parsed.data } : null;
+4 -3
View File
@@ -1,5 +1,5 @@
export type InputDelivery = "steer" | "queue";
export type ResponseInput = { id: string; text: string; delivery: InputDelivery; status: "pending" | "delivered" };
export type ResponseInput = { id: string; text: string; delivery: InputDelivery; status: "pending" | "delivered" | "cancelled" };
type InboxRuntime = {
enqueueUserInput: (sessionId: string, input: ResponseInput) => Promise<void>;
listPendingUserInputs: (sessionId: string) => Promise<ResponseInput[]>;
@@ -19,7 +19,7 @@ export class ResponseInputTracker {
markReady() { this.releaseReady(); }
close() { this.accepting = false; this.releaseReady(); }
get hasAdditionalInput() { return this.items.size > 0; }
get hasAdditionalInput() { return [...this.items.values()].some((input) => input.status !== "cancelled"); }
async restore() {
for (const input of await this.runtime.listPendingUserInputs(this.sessionId)) this.items.set(input.id, input);
@@ -53,8 +53,9 @@ export class ResponseInputTracker {
observe(type: string, data: { inboxID?: string; delivery?: InputDelivery }) {
const input = data.inboxID ? this.items.get(data.inboxID) : undefined;
if (!input) return;
if (!input || input.status !== "pending") return;
if (type === "session.inbox.delivered") input.status = "delivered";
else if (type === "session.inbox.cancelled") input.status = "cancelled";
else if (type === "session.inbox.delivery.changed" && data.delivery) input.delivery = data.delivery;
else return;
this.changed({ ...input });
+2
View File
@@ -18,6 +18,8 @@ export const responseRequestSchema = z.object({
conversation: z.union([z.string().min(1).max(128), z.object({ id: z.string().min(1).max(128) }).strict()]).optional(),
metadata: z.record(z.string().max(64), z.string().max(512)).refine((value) => Object.keys(value).length <= 16).optional(),
tjwater: z.object({
topic_id: z.string().uuid().optional(),
client_message_id: z.string().min(1).max(128).optional(),
delivery: z.enum(["steer", "queue"]).optional(),
input_id: z.string().regex(/^msg_[a-f0-9]{32}$/).optional(),
approval_mode: z.enum(["request", "always"]).default("request"),
+326 -15
View File
@@ -3318,8 +3318,8 @@ export interface paths {
get?: never;
put?: never;
/**
* Search Fengxian places
* @description 调用天地图地名搜索,固定上海市奉贤区范围,只返回前 10 个可定位候选,不提供分页。
* Search project places
* @description 调用天地图地名搜索,按当前项目 map_config.place_search 的行政区范围,只返回前 10 个可定位候选,不提供分页。
*/
post: operations["post_place_search_requests"];
delete?: never;
@@ -3918,6 +3918,26 @@ export interface paths {
patch: operations["patch_reservoirs_y"];
trace?: never;
};
"/api/v1/reverse-geocoding-requests": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Reverse geocode a project map point
* @description 查询 EPSG:4326 地图取点的地名和地址,复用当前项目的地名搜索配置并保留输入坐标。
*/
post: operations["post_reverse_geocoding_requests"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/rule-properties": {
parameters: {
query?: never;
@@ -5056,6 +5076,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/timeseries/scada-readings/latest": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* 获取SCADA设备最新数据
* @description Read one latest persisted row per project device without applying a time window.
*/
get: operations["get_timeseries_scada_readings_latest"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/timeseries/scada-readings/{device_id}/field": {
parameters: {
query?: never;
@@ -5687,6 +5727,7 @@ export interface components {
description?: string | null;
/** Gs Workspace */
gs_workspace: string;
map_config?: components["schemas"]["ProjectMapConfig"];
/** Map Extent */
map_extent?: {
[key: string]: unknown;
@@ -5713,6 +5754,7 @@ export interface components {
description?: string | null;
/** Gs Workspace */
gs_workspace: string;
map_config?: components["schemas"]["ProjectMapConfig"];
/** Map Extent */
map_extent?: {
[key: string]: unknown;
@@ -5740,6 +5782,7 @@ export interface components {
description?: string | null;
/** Gs Workspace */
gs_workspace?: string | null;
map_config?: components["schemas"]["ProjectMapConfig"] | null;
/** Map Extent */
map_extent?: {
[key: string]: unknown;
@@ -5992,6 +6035,39 @@ export interface components {
selected_report?: components["schemas"]["InvestigationReportRevision"] | null;
task: components["schemas"]["OperationalTaskDetail"];
};
/** AnswerUiBlock */
AnswerUiBlock: {
/** Block Id */
block_id: string;
/** Fallback Text */
fallback_text: string;
spec: components["schemas"]["AnswerUiSpec"];
/**
* Version
* @constant
*/
version: "answer-ui@1";
};
/** AnswerUiElement */
AnswerUiElement: {
/** Children */
children: string[];
/** Props */
props: {
[key: string]: unknown;
};
/** Type */
type: string;
};
/** AnswerUiSpec */
AnswerUiSpec: {
/** Elements */
elements: {
[key: string]: components["schemas"]["AnswerUiElement"];
};
/** Root */
root: string;
};
/**
* AuditLogResponse
* @description 审计日志响应
@@ -6914,6 +6990,8 @@ export interface components {
stop_reason?: string | null;
/** Title */
title: string;
/** Ui Blocks */
ui_blocks?: components["schemas"]["ReportUiBlock"][];
};
/**
* InvestigationReportInput
@@ -6947,6 +7025,8 @@ export interface components {
stop_reason?: string | null;
/** Title */
title: string;
/** Ui Blocks */
ui_blocks?: components["schemas"]["ReportUiBlock"][];
};
/** InvestigationReportPage */
InvestigationReportPage: {
@@ -7759,18 +7839,32 @@ export interface components {
/** Total */
total: number;
};
/** PlaceSearchConfig */
PlaceSearchConfig: {
/** Admin Code */
admin_code: string;
/**
* Enabled
* @default false
*/
enabled: boolean;
/**
* Provider
* @default tianditu
* @constant
*/
provider: "tianditu";
/** Region */
region: string;
};
/** PlaceSearchItem */
PlaceSearchItem: {
/** Address */
address: string;
/** Category */
category?: string | null;
/**
* District
* @default 奉贤区
* @constant
*/
district: "奉贤区";
/** District */
district: string;
/** Id */
id: string;
/** Latitude */
@@ -7789,12 +7883,8 @@ export interface components {
PlaceSearchResponse: {
/** Items */
items: components["schemas"]["PlaceSearchItem"][];
/**
* Region
* @default 奉贤区
* @constant
*/
region: "奉贤区";
/** Region */
region: string;
/** Total */
total: number;
/**
@@ -7938,6 +8028,12 @@ export interface components {
*/
pool_min_size: number;
};
/** ProjectMapConfig */
ProjectMapConfig: {
place_search?: components["schemas"]["PlaceSearchConfig"] | null;
} & {
[key: string]: unknown;
};
/** ProjectMemberCreateRequest */
ProjectMemberCreateRequest: {
/**
@@ -7994,6 +8090,7 @@ export interface components {
description?: string | null;
/** Gs Workspace */
gs_workspace: string;
map_config?: components["schemas"]["ProjectMapConfig"];
/** Map Extent */
map_extent?: {
[key: string]: unknown;
@@ -8110,6 +8207,19 @@ export interface components {
/** Task Ids */
task_ids: string[];
};
/** ReportUiBlock */
ReportUiBlock: {
block: components["schemas"]["AnswerUiBlock"];
/** Evidence Refs */
evidence_refs: string[];
};
/** ReverseGeocodingRequest */
ReverseGeocodingRequest: {
/** Latitude */
latitude: number;
/** Longitude */
longitude: number;
};
/** RunSimulationManuallyByDateRest */
RunSimulationManuallyByDateRest: {
/**
@@ -8155,6 +8265,46 @@ export interface components {
/** Y */
y?: number | null;
};
/**
* ScadaLatestReadingResponse
* @description Latest persisted observation for one project SCADA device.
*/
ScadaLatestReadingResponse: {
/** Cleaned Value */
cleaned_value?: number | null;
/** Device Id */
device_id: string;
/** Device Type */
device_type: string;
/** Measurement Unit */
measurement_unit: string;
/** Monitored Value */
monitored_value?: number | null;
/** Observed At */
observed_at?: string | null;
/** Transmission Frequency */
transmission_frequency: string;
/** Transmission Mode */
transmission_mode: string;
};
/**
* ScadaLatestReadingsResponse
* @description Bounded device page with each device's latest persisted observation.
*/
ScadaLatestReadingsResponse: {
/** Items */
items: components["schemas"]["ScadaLatestReadingResponse"][];
/** Latest Observed At */
latest_observed_at?: string | null;
/** Limit */
limit: number;
/** Observed Count */
observed_count: number;
/** Offset */
offset: number;
/** Total */
total: number;
};
/** ScadaReadingBatchItem */
ScadaReadingBatchItem: {
/** Cleaned Value */
@@ -8494,7 +8644,7 @@ export interface components {
*/
queried_at: string;
/** Source */
source: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data") | ("run_simulation" | "run_contaminant_simulation" | "analyze_valve_closure" | "analyze_flushing" | "analyze_pressure_regulation" | "optimize_sensor_placement" | "run_burst_detection" | "run_burst_location" | "analyze_valve_isolation" | "run_leakage_identification" | "get_element_properties" | "list_elements" | "get_network_options" | "get_network_schema" | "list_scada_devices" | "get_scada_device" | "query_scada_readings" | "query_model_baseline_series" | "get_model_baseline_at_time" | "query_model_baseline_field" | "query_analysis_series" | "query_analysis_values" | "list_analysis_runs" | "get_analysis_run" | "list_analysis_results" | "list_sensor_placement_runs" | "get_sensor_placement_run" | "query_element_history" | "get_element_gis_properties" | "get_source_gis_properties" | "get_workbench_runtime" | "get_task_context" | "query_peripheral_data" | "get_task_evidence");
source: ("workbench_runtime" | "scada_devices" | "scada_readings" | "peripheral_points" | "peripheral_work_orders" | "peripheral_observations" | "peripheral_test_data") | ("get_mutation_status" | "query_nearby_elements" | "list_regions" | "get_region" | "get_region_nodes" | "get_node_connections" | "query_network_extent" | "list_major_nodes" | "get_model_configuration" | "list_model_configurations" | "search_project_places" | "reverse_geocode" | "get_project_info" | "export_project_model" | "get_sensor_placement_candidate" | "export_sensor_placement" | "list_task_schedules" | "get_task_run_history" | "calculate_demand_allocation" | "run_burst_simulation" | "edit_model_element" | "edit_model_configuration" | "clean_scada_readings" | "write_scada_readings" | "delete_scada_readings" | "save_simulation_scenario" | "create_sensor_placement" | "update_sensor_placement" | "create_operational_task" | "create_task_schedule" | "update_task_schedule" | "retry_operational_task" | "cancel_task_run" | "run_simulation" | "run_contaminant_simulation" | "analyze_valve_closure" | "analyze_flushing" | "analyze_pressure_regulation" | "optimize_sensor_placement" | "run_burst_detection" | "run_burst_location" | "analyze_valve_isolation" | "run_leakage_identification" | "get_element_properties" | "list_elements" | "get_network_options" | "get_network_schema" | "list_scada_devices" | "get_scada_device" | "query_scada_readings" | "query_model_baseline_series" | "get_model_baseline_at_time" | "query_model_baseline_field" | "query_analysis_series" | "query_analysis_values" | "list_analysis_runs" | "get_analysis_run" | "list_analysis_results" | "list_sensor_placement_runs" | "get_sensor_placement_run" | "query_element_history" | "get_element_gis_properties" | "get_source_gis_properties" | "get_workbench_runtime" | "get_task_context" | "query_peripheral_data" | "get_task_evidence");
/** Source Ref */
source_ref: string;
/** Truncated */
@@ -29690,6 +29840,86 @@ export interface operations {
};
};
};
post_reverse_geocoding_requests: {
parameters: {
query?: never;
header: {
"X-Project-Id": string;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ReverseGeocodingRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PlaceSearchItem"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
get_rule_properties: {
parameters: {
query?: never;
@@ -35600,6 +35830,87 @@ export interface operations {
};
};
};
get_timeseries_scada_readings_latest: {
parameters: {
query?: {
/** @description 返回设备数上限 */
limit?: number;
/** @description 设备目录偏移量 */
offset?: number;
};
header: {
"X-Project-Id": string;
};
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ScadaLatestReadingsResponse"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Insufficient permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Resource conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Validation error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
/** @description Dependency unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProblemDetails"];
};
};
};
};
patch_timeseries_scada_readings_device_id_field: {
parameters: {
query: {
+50
View File
@@ -0,0 +1,50 @@
import { constants } from "node:fs";
import { open } from "node:fs/promises";
import { createHash, randomUUID } from "node:crypto";
import type { CallToolResult } from "@modelcontextprotocol/client";
import { config } from "../config.js";
import { isManagedConversationWorkspace, resolveConversationWorkspace, SANDBOX_UID, SANDBOX_GID } from "../runtime/conversationWorkspace.js";
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
const formats = new Map([
["application/zip", ".zip"],
["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"],
]);
export const artifactFilename = /^mcp-[a-f0-9-]{36}\.(zip|xlsx)$/;
export async function openArtifactDirectory(workspace: string, sessionId: string) {
const canonical = await resolveConversationWorkspace(workspace, config.CONVERSATION_WORKSPACE_ROOT);
if (!isManagedConversationWorkspace(canonical, config.CONVERSATION_WORKSPACE_ROOT, sessionId)) throw new Error("Export workspace owner mismatch");
const root = await open(canonical, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
try { return await open(`/proc/self/fd/${root.fd}/files`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); }
finally { await root.close(); }
}
export async function saveMcpArtifact(result: CallToolResult, context: RuntimeSessionContext, workspace: string): Promise<CallToolResult> {
if (result.isError) return result;
const output = result.structuredContent as Record<string, unknown> | undefined;
const data = output?.data as Record<string, unknown> | undefined;
const file = data?.file as Record<string, unknown> | undefined;
if (!file) throw new Error("Missing export file");
const extension = formats.get(String(file.mime_type));
if (!extension || file.encoding !== "base64" || typeof file.data !== "string" || file.data.length > 7 * 1024 * 1024) throw new Error("Invalid export format");
const bytes = Buffer.from(file.data, "base64");
if (!bytes.length || bytes.length > 5 * 1024 * 1024 || bytes.length !== file.size || bytes.toString("base64") !== file.data || createHash("sha256").update(bytes).digest("hex") !== file.sha256) throw new Error("Export integrity check failed");
const filename = `mcp-${randomUUID()}${extension}`;
const directory = await openArtifactDirectory(workspace, context.sessionId);
try {
const path = `/proc/self/fd/${directory.fd}/${filename}`;
const destination = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try {
await destination.writeFile(bytes);
if (typeof process.getuid === "function" && process.getuid() === 0) await destination.chown(SANDBOX_UID, SANDBOX_GID);
}
finally { await destination.close(); }
} finally { await directory.close(); }
const url = `/api/v1/session/${encodeURIComponent(context.clientSessionId)}/files/${filename}`;
const next = { ...output, data: { file: { name: file.name, mime_type: file.mime_type, size: bytes.length,
sha256: file.sha256, relative_path: `files/${filename}`, download_url: url,
markdown: `[下载 ${extension === ".zip" ? "模型文件" : "监测点清单"}](${url})`,
} } };
return { ...result, structuredContent: next, content: [{ type: "text", text: JSON.stringify(next) }] };
}
+8 -1
View File
@@ -8,6 +8,7 @@ import { localQueryDescriptions, localQueryInputs } from "./local-query-tools.js
import { allowedQueryTools } from "../runtime/query-mcp-policy.js";
import { localEvidenceResult } from "./evidence-result.js";
import { nativeQueryTimeout, queryTimeouts } from "./query-timeouts.js";
import { saveMcpArtifact } from "./artifact-files.js";
export type LocalQueryName = typeof LOCAL_QUERY_TOOLS[number];
const productNames = new Set<string>(PRODUCT_MCP_TOOLS);
@@ -16,6 +17,7 @@ export function createQueryMcpHandler(options: {
internalToken: string; productClient: Pick<McpQueryClient, "query"> | null; productTools: Tool[];
resolveReader: (id: string) => Promise<QueryOwner | null>;
executeLocal: (name: LocalQueryName, args: Record<string, unknown>, context: RuntimeSessionContext, signal: AbortSignal) => Promise<unknown>;
artifactWorkspace?: (context: RuntimeSessionContext) => Promise<string>;
}) {
const definitions: Tool[] = [...options.productTools, ...LOCAL_QUERY_TOOLS.map((name) => ({ name, description: localQueryDescriptions[name],
inputSchema: z.toJSONSchema(localQueryInputs[name], { io: "input" }) as Tool["inputSchema"], annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: name === "geocode" } }))];
@@ -38,7 +40,12 @@ export function createQueryMcpHandler(options: {
// JSON-RPC IDs may restart on a native MCP reconnect. Use a host
// request key, stable across this client's auth/transport retries,
// so a later query cannot collide with a saved evidence receipt.
return localEvidenceResult(await options.productClient.query(id, owner, definition.name, query, randomUUID(), signal));
const result = await options.productClient.query(id, owner, definition.name, query, randomUUID(), signal);
if (["export_project_model", "export_sensor_placement"].includes(definition.name) && !result.isError) {
if (!options.artifactWorkspace) return rejected("Export workspace unavailable");
return await saveMcpArtifact(result, owner.context, await options.artifactWorkspace(owner.context));
}
return localEvidenceResult(result);
}
const value = await options.executeLocal(definition.name as LocalQueryName, args, owner.context, signal);
signal.throwIfAborted();
+15
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { supplyLayerSchema } from "./task-presentation.js";
import { answerUiBlockSchema } from "../chat/answer-ui.js";
const text = z.string().trim().min(1);
export const investigationDiagramSchema = z.object({
@@ -54,8 +55,22 @@ export const taskReportSchema = z.object({
stop_reason: text.nullable().default(null),
data_gaps: z.array(dataGapSchema),
diagram: investigationDiagramSchema.nullish(),
ui_blocks: z.array(z.object({
block: answerUiBlockSchema,
evidence_refs: z.array(text.max(128)).min(1).max(100),
}).strict()).max(8).optional(),
}).strict().superRefine((report, ctx) => {
const invalid = (message: string) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
const blocks = report.ui_blocks ?? [];
if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 400000) invalid("报告交互内容超过 400 KB");
if (new Set(blocks.map((item) => item.block.block_id)).size !== blocks.length) invalid("报告界面块编号重复");
for (const item of blocks) {
if (item.evidence_refs.some((ref) => !report.evidence_refs.includes(ref))) invalid("界面依据必须属于报告依据");
for (const node of Object.values(item.block.spec.elements)) {
if (node.type === "Evidence" && !item.evidence_refs.includes(String(node.props.evidence_id ?? "")))
invalid("证据组件必须引用界面块的已有依据");
}
}
if (new Set(report.data_gaps.map((gap) => gap.gap_id)).size !== report.data_gaps.length) invalid("数据缺口编号重复");
const blocking = report.data_gaps.some((gap) => gap.blocking);
if (report.outcome === "insufficient_data") {
+35
View File
@@ -0,0 +1,35 @@
import { constants } from "node:fs";
import { open } from "node:fs/promises";
import type { Router } from "express";
import { getLocalAgentContext } from "../context/localContext.js";
import { artifactFilename, openArtifactDirectory } from "../mcp/artifact-files.js";
import type { SessionMetadataStore } from "../sessions/metadataStore.js";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
export function registerArtifactRoutes(router: Router, options: {
sessionMetadataStore: SessionMetadataStore;
runtime: Pick<OpencodeRuntimeAdapter, "ensureSessionWorkspace">;
}) {
router.get("/session/:session_id/files/:filename", async (req, res) => {
const filename = req.params.filename;
if (!artifactFilename.test(filename)) { res.status(404).end(); return; }
try {
const record = await options.sessionMetadataStore.get(getLocalAgentContext(req), req.params.session_id);
if (!record) { res.status(404).end(); return; }
const workspace = await options.runtime.ensureSessionWorkspace(record.sessionId, record);
const directory = await openArtifactDirectory(workspace, record.sessionId);
try {
const file = await open(`/proc/self/fd/${directory.fd}/${filename}`, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stat = await file.stat();
if (!stat.isFile() || stat.size > 5 * 1024 * 1024) { res.status(404).end(); return; }
res.setHeader("Content-Type", filename.endsWith(".zip") ? "application/zip" : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Cache-Control", "private, no-store");
res.setHeader("X-Content-Type-Options", "nosniff");
res.send(await file.readFile());
} finally { await file.close(); }
} finally { await directory.close(); }
} catch { res.status(404).end(); }
});
}
+43
View File
@@ -0,0 +1,43 @@
import type { Router } from "express";
import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js";
import { readPurpose, type SessionMetadataStore } from "../sessions/metadataStore.js";
const nameSchema = z.object({ name: z.string().trim().min(1).max(80) }).strict();
const membershipSchema = z.object({ topic_id: z.string().uuid().nullable() }).strict();
export function registerChatTopicRoutes(router: Router, sessions: SessionMetadataStore) {
router.get("/topics", async (req, res) => {
res.json({ topics: await sessions.topics.list(getLocalAgentContext(req)) });
});
router.post("/topics", async (req, res) => {
const parsed = nameSchema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "主题名称须为 1 至 80 个字符" }); return; }
res.status(201).json(await sessions.topics.create(getLocalAgentContext(req), parsed.data.name));
});
router.patch("/topics/:topic_id", async (req, res) => {
const parsed = nameSchema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "主题名称须为 1 至 80 个字符" }); return; }
const topic = await sessions.topics.rename(getLocalAgentContext(req), req.params.topic_id, parsed.data.name);
if (!topic) { res.status(404).json({ message: "主题不存在" }); return; }
res.json(topic);
});
router.delete("/topics/:topic_id", async (req, res) => {
// Removing a grouping never deletes conversations. Old IDs resolve as unclassified.
const removed = await sessions.topics.remove(getLocalAgentContext(req), req.params.topic_id);
if (!removed) { res.status(404).json({ message: "主题不存在" }); return; }
res.status(204).end();
});
router.patch("/session/:session_id/topic", async (req, res) => {
const parsed = membershipSchema.safeParse(req.body);
if (!parsed.success) { res.status(400).json({ message: "invalid topic membership" }); return; }
const context = getLocalAgentContext(req);
const record = await sessions.get(context, req.params.session_id);
if (!record || readPurpose(record) === "task_execution") { res.status(404).json({ message: "session not found" }); return; }
if (parsed.data.topic_id && !await sessions.topics.get(context, parsed.data.topic_id)) {
res.status(404).json({ message: "主题不存在" }); return;
}
await sessions.touch(record, { topicId: parsed.data.topic_id });
res.json({ session_id: record.sessionId, topic_id: parsed.data.topic_id });
});
}
+2
View File
@@ -27,6 +27,7 @@ import { registerResponseInputRoutes } from "./response-input-routes.js";
import { registerMcpStatusRoutes } from "./mcpRoutes.js";
import { registerChatSuggestionRoutes } from "./chatSuggestionRoutes.js";
import { registerAgentMessageRoutes } from "./agent-message-routes.js";
import { registerArtifactRoutes } from "./artifact-routes.js";
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
export type BuildChatRouterOptions = {
@@ -76,6 +77,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
models: agentModelOptions,
});
});
registerArtifactRoutes(chatRouter, { sessionMetadataStore, runtime });
chatRouter.get("/ui-registry", (_req, res) => {
res.json(uiRegistryResponse);
+95 -7
View File
@@ -1,3 +1,5 @@
import { registerChatTopicRoutes } from "./chat-topic-routes.js";
import { matchHistoryText } from "../sessions/history-search.js";
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express";
import { z } from "zod";
@@ -21,6 +23,7 @@ import {
} from "../sessions/metadataStore.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { HistoryCursorError, historyScope, sessionHistoryPage, sessionMessagePage } from "../sessions/history-page.js";
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { type ChatRunState } from "./chatRunState.js";
@@ -38,6 +41,7 @@ const createSessionPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
trigger_context: agentTriggerContextSchema.optional(),
topic_id: z.string().uuid().optional(),
});
const taskExecutionPayloadSchema = z.object({
@@ -50,6 +54,19 @@ const taskExecutionPayloadSchema = z.object({
const sessionListQuerySchema = z.object({
purpose: z.enum(["ordinary_chat", "task_execution", "task_collaboration"]).optional(),
task_id: z.string().uuid().optional(),
include_collaboration: z.enum(["true", "false"]).optional(),
q: z.string().trim().max(200).optional(),
topic_id: z.union([z.string().uuid(), z.literal("unclassified")]).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
cursor: z.string().max(2048).optional(),
});
const messageQuerySchema = z.object({
message_limit: z.coerce.number().int().min(1).max(100).optional(),
});
const olderMessagesQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(40),
before: z.string().max(2048).optional(),
});
const forkPayloadSchema = z.object({
@@ -91,6 +108,7 @@ export const registerChatSessionRoutes = (
sessionTranscriptStore,
sessionUiStateStore,
} = options;
registerChatTopicRoutes(chatRouter, sessionMetadataStore);
const { activeRuns, lastRunStatuses } = runState;
const getSessionRunStatus = runState.getStatus;
@@ -191,6 +209,9 @@ export const registerChatSessionRoutes = (
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
if (parsed.data.topic_id && !await sessionMetadataStore.topics.get(authContext, parsed.data.topic_id)) {
res.status(404).json({ message: "主题不存在" }); return;
}
const trigger = parsed.data.trigger_context;
if (trigger && !projectId) {
res.status(403).json({ code: "PROJECT_CONTEXT_REQUIRED", message: "任务会话必须关联项目" });
@@ -244,6 +265,7 @@ export const registerChatSessionRoutes = (
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: parsed.data.parent_session_id,
topicId: parsed.data.topic_id,
projectId,
projectKey,
sessionId,
@@ -267,6 +289,7 @@ export const registerChatSessionRoutes = (
status: record.status,
title: record.title,
parent_session_id: record.parentSessionId,
topic_id: record.topicId ?? null,
created,
event_duplicate: false,
trigger_id: record.triggerId,
@@ -460,19 +483,52 @@ export const registerChatSessionRoutes = (
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const records = await sessionMetadataStore.list({
const includeCollaboration = query.data.include_collaboration === "true";
let records = await sessionMetadataStore.list({
actorKey,
projectId,
projectKey,
userId,
}, {
purpose: query.data.purpose ?? "ordinary_chat",
purpose: includeCollaboration ? undefined : query.data.purpose ?? "ordinary_chat",
taskId: query.data.task_id,
});
if (includeCollaboration) records = records.filter((record) => ["ordinary_chat", "task_collaboration"].includes(readPurpose(record)));
const topics = new Map((await sessionMetadataStore.topics.list(authContext)).map((topic) => [topic.id, topic]));
const topicId = query.data.topic_id;
if (topicId && topicId !== "unclassified" && !topics.has(topicId)) {
res.status(404).json({ message: "主题不存在" }); return;
}
records = records.filter((record) => !topicId || (topicId === "unclassified"
? !record.topicId || !topics.has(record.topicId) : record.topicId === topicId));
const searchable: Array<SessionRecord & { searchSnippet?: string }> = [];
for (const record of records) {
const titleMatches = (record.title ?? "新对话").toLocaleLowerCase().includes((query.data.q ?? "").toLocaleLowerCase());
const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionUiStateContext(record)) : null;
const messages = activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
searchable.push({ ...record, searchSnippet: query.data.q && !titleMatches ? matchHistoryText(messages, query.data.q) : undefined });
}
let page;
try {
page = sessionHistoryPage(searchable, {
scope: historyScope([actorKey, projectKey, userId, query.data.purpose ?? "ordinary_chat", query.data.task_id, topicId, includeCollaboration]),
q: query.data.q, limit: query.data.limit ?? (query.data.q || query.data.cursor ? 30 : Math.max(records.length, 1)),
cursor: query.data.cursor,
});
} catch (error) {
if (!(error instanceof HistoryCursorError)) throw error;
res.status(400).json({ message: error.message });
return;
}
res.json({
sessions: records.map((record) => ({
has_more: page.has_more,
next_cursor: page.next_cursor,
sessions: page.sessions.map((record) => ({
id: record.sessionId,
title: record.title ?? "新对话",
topic_id: record.topicId && topics.has(record.topicId) ? record.topicId : null,
topic_name: record.topicId ? topics.get(record.topicId)?.name : undefined,
search_snippet: record.searchSnippet,
created_at: record.createdAt,
updated_at: record.updatedAt,
status: record.status,
@@ -490,7 +546,27 @@ export const registerChatSessionRoutes = (
});
});
chatRouter.get("/session/:session_id/messages", async (req, res) => {
const query = olderMessagesQuerySchema.safeParse(req.query);
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
const context = getLocalAgentContext(req);
const record = await sessionMetadataStore.get(context, req.params.session_id);
if (!record) { res.status(404).json({ message: "session not found" }); return; }
const state = await sessionUiStateStore.read(toSessionUiStateContext(record));
const messages = readPurpose(record) === "task_collaboration" ? state?.messages ?? []
: activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
try {
res.json(sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]),
...query.data }));
} catch (error) {
if (!(error instanceof HistoryCursorError)) throw error;
res.status(400).json({ message: error.message });
}
});
chatRouter.get("/session/:session_id", async (req, res) => {
const query = messageQuerySchema.safeParse(req.query);
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
@@ -519,15 +595,21 @@ export const registerChatSessionRoutes = (
const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
: activeRuns.get(sessionRecord.sessionId)?.messages ?? state?.messages ?? [];
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
}) : { messages };
res.json({
id: sessionRecord.sessionId,
title: sessionRecord.title ?? "新对话",
topic_id: sessionRecord.topicId && await sessionMetadataStore.topics.get(authContext, sessionRecord.topicId) ? sessionRecord.topicId : null,
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
created_at: sessionRecord.createdAt,
updated_at: sessionRecord.updatedAt,
status: sessionRecord.status,
session_id: sessionRecord.sessionId,
messages: state?.messages ?? [],
...messagePayload,
execution_plans: state?.executionPlans ?? [],
parent_session_id: sessionRecord.parentSessionId,
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
@@ -538,6 +620,8 @@ export const registerChatSessionRoutes = (
});
chatRouter.get("/session/:session_id/stream", async (req, res) => {
const query = messageQuerySchema.safeParse(req.query);
if (!query.success) { res.status(400).json({ message: "invalid message pagination" }); return; }
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
@@ -567,12 +651,15 @@ export const registerChatSessionRoutes = (
const run = activeRuns.get(sessionRecord.sessionId);
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
: run?.messages ?? state?.messages ?? [];
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
}) : { messages };
res.write(
toSse("state", {
session_id: sessionRecord.sessionId,
messages: readPurpose(sessionRecord) === "task_collaboration"
? state?.messages ?? []
: run?.messages ?? state?.messages ?? [],
...messagePayload,
execution_plans: run ? undefined : state?.executionPlans,
is_streaming: run?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
@@ -740,6 +827,7 @@ export const registerChatSessionRoutes = (
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
topicId: sourceSessionRecord?.topicId ?? undefined,
projectId,
projectKey,
sessionId: forkSession.id,
+11 -3
View File
@@ -181,8 +181,8 @@ const toLegacyMessages = (messages: UIMessage[]) =>
];
});
const createFallbackUserUiMessage = (content: string): UIMessage => ({
id: `user-${Date.now().toString(36)}`,
const createFallbackUserUiMessage = (content: string, id?: string): UIMessage => ({
id: id ?? `user-${randomUUID()}`,
role: "user",
parts: [{ type: "text", text: content }],
});
@@ -326,6 +326,9 @@ export const registerChatStreamRoute = (
});
return;
}
if (input.tjwater.topic_id && !await sessionMetadataStore.topics.get(authContext, input.tjwater.topic_id)) {
res.status(404).json({ message: "主题不存在" }); return;
}
const requestedSessionId = resolveRequestedStreamSessionId({
sessionId: parsed.data.session_id,
promptText,
@@ -403,6 +406,7 @@ export const registerChatStreamRoute = (
projectId,
projectKey,
sessionId: binding.sessionId,
topicId: existingSessionRecord?.topicId ?? input.tjwater.topic_id,
userId,
});
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
@@ -454,6 +458,10 @@ export const registerChatStreamRoute = (
toSessionUiStateContext(activeSessionRecord),
);
const persistedUiMessages = toUiMessages(initialSessionState?.messages);
if (input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) {
res.status(409).json({ message: "client_message_id already exists in this conversation" });
return;
}
let executionPlanHistory = initialSessionState?.executionPlans ?? readExecutionPlans(initialSessionState?.messages ?? []);
const visiblePersistedMessages = sessionPurpose === "task_collaboration"
? toCollaborationMessages(persistedUiMessages)
@@ -466,7 +474,7 @@ export const registerChatStreamRoute = (
? visibleRequestMessages
: [
...visiblePersistedMessages,
createFallbackUserUiMessage(promptText),
createFallbackUserUiMessage(promptText, input.tjwater.client_message_id),
];
let latestUiMessages = requestMessages;
const baseMessages = toLegacyMessages(requestMessages);
+3 -2
View File
@@ -6,6 +6,7 @@ import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { type McpRuntimeReport, type McpSessionPolicy } from "../runtime/mcp-config.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { allowedQueryTools } from "../runtime/query-mcp-policy.js";
import { PRODUCT_MCP_WRITE_TOOL_IDS } from "../runtime/product-mcp-tools.js";
import type { QueryOwner } from "../product/mcp-query-client.js";
export function registerMcpPolicyBridge(app: Express, options: {
@@ -24,8 +25,8 @@ export function registerMcpPolicyBridge(app: Express, options: {
const grants = new Set(readonlyTools);
const policy: McpSessionPolicy = {
mode: context.learningMode === "review" ? "disabled" : !direct || context.purpose === "task_execution" ? "readonly" : "interactive",
readonly_tools: readonlyTools,
query_tools: allowedQueryTools(context, !direct).filter((name) => grants.has(name)),
readonly_tools: readonlyTools.filter((name) => !PRODUCT_MCP_WRITE_TOOL_IDS.includes(name)),
query_tools: allowedQueryTools(context, !direct).filter((name) => grants.has(name) || PRODUCT_MCP_WRITE_TOOL_IDS.includes(name)),
};
res.json(policy);
});
+7 -1
View File
@@ -11,7 +11,7 @@ const update = z.object({ delivery: z.enum(["steer", "queue"]) }).strict();
export function registerResponseInputRoutes(router: Router, options: {
runtime: OpencodeRuntimeAdapter; sessionMetadataStore: SessionMetadataStore; runState: ChatRunState;
}) {
for (const method of ["get", "patch"] as const) {
for (const method of ["get", "patch", "delete"] as const) {
router[method](method === "get" ? "/session/:session_id/inputs" : "/session/:session_id/inputs/:input_id", async (req, res) => {
const sessionId = String(req.params.session_id);
try {
@@ -22,6 +22,12 @@ export function registerResponseInputRoutes(router: Router, options: {
return;
}
const inputId = id.parse((req.params as Record<string, string>).input_id);
if (method === "delete") {
const input = await options.runtime.cancelPendingUserInput(sessionId, inputId);
options.runState.activeRuns.get(sessionId)?.inputs?.observe("session.inbox.cancelled", { inboxID: inputId });
res.json(input);
return;
}
const { delivery } = update.parse(req.body);
const input = await options.runtime.updatePendingUserInput(sessionId, inputId, delivery);
options.runState.activeRuns.get(sessionId)?.inputs?.observe("session.inbox.delivery.changed", { inboxID: inputId, delivery });
+19
View File
@@ -397,6 +397,25 @@ export class OpencodeRuntimeAdapter {
}
}
async cancelPendingUserInput(sessionId: string, id: string): Promise<ResponseInput> {
const item = (await this.listPendingUserInputs(sessionId)).find((input) => input.id === id);
if (!item) throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
const client = await this.ensureClient();
try { await client.session.inbox.cancel({ sessionID: sessionId, inboxID: id }); }
catch (error) {
if (!(await this.listPendingUserInputs(sessionId)).some((input) => input.id === id)) {
throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
}
throw error;
}
// Native cancel also succeeds for an already consumed ID. Check the native
// message record so a delivery racing cancellation never restores a sent draft.
if (await this.agentMessageState(sessionId, id) !== "missing") {
throw Object.assign(new Error("Message has already been consumed or is unavailable"), { status: 409 });
}
return { ...item, status: "cancelled" };
}
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
const client = await this.ensureClient();
const startedAt = Date.now();
+2 -2
View File
@@ -1,4 +1,4 @@
import { PRODUCT_MCP_TOOL_IDS, QUERY_MCP_TOOL_IDS, queryMcpToolId } from "./product-mcp-tools.js";
import { PRODUCT_MCP_TOOL_IDS, QUERY_MCP_AUTOALLOW_TOOL_IDS, PRODUCT_MCP_WRITE_TOOL_IDS, queryMcpToolId } from "./product-mcp-tools.js";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { normalizeMcpConfig } from "./mcp-config.js";
@@ -54,7 +54,7 @@ export function serviceOpencodeConfig(input: Record<string, unknown>, models: Ag
return {
...input,
...compileOpencodeModels(models),
permissions: sessionWorkspacePermissions([...(input.permissions ?? []) as PermissionRule[], ...QUERY_MCP_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "allow" }))], workspaceRoot),
permissions: sessionWorkspacePermissions([...(input.permissions ?? []) as PermissionRule[], ...QUERY_MCP_AUTOALLOW_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "allow" })), ...PRODUCT_MCP_WRITE_TOOL_IDS.filter((name) => !deniedProductQuery(name, (input.permissions ?? []) as PermissionRule[])).map((action): PermissionRule => ({ action, resource: "*", effect: "ask" }))], workspaceRoot),
snapshots: false,
mcp: normalizeMcpConfig(input.mcp),
plugins: ["*", ...((input.plugins ?? []) as unknown[]), resolve(".opencode/tjwater-plugin"),
+49 -2
View File
@@ -1,36 +1,83 @@
export const PRODUCT_MCP_READ_TOOLS = [
"get_mutation_status",
"export_project_model",
"export_sensor_placement",
"get_analysis_run",
"get_element_gis_properties",
"get_element_properties",
"get_model_baseline_at_time",
"get_model_configuration",
"get_network_options",
"get_network_schema",
"get_node_connections",
"get_project_info",
"get_region",
"get_region_nodes",
"get_scada_device",
"get_sensor_placement_candidate",
"get_sensor_placement_run",
"get_source_gis_properties",
"get_task_context",
"get_task_evidence",
"get_task_run_history",
"get_workbench_runtime",
"list_analysis_results",
"list_analysis_runs",
"list_elements",
"list_major_nodes",
"list_model_configurations",
"list_regions",
"list_scada_devices",
"list_sensor_placement_runs",
"list_task_schedules",
"query_analysis_series",
"query_analysis_values",
"query_element_history",
"query_model_baseline_field",
"query_model_baseline_series",
"query_nearby_elements",
"query_network_extent",
"query_peripheral_data",
"query_scada_readings",
"reverse_geocode",
"search_project_places",
] as const;
export const PRODUCT_MCP_ANALYSIS_TOOLS = ["run_simulation", "run_burst_detection", "run_burst_location", "analyze_valve_isolation", "run_leakage_identification", "run_contaminant_simulation", "analyze_valve_closure", "analyze_flushing", "analyze_pressure_regulation", "optimize_sensor_placement"] as const;
export const PRODUCT_MCP_TOOLS = [...PRODUCT_MCP_READ_TOOLS, ...PRODUCT_MCP_ANALYSIS_TOOLS] as const;
export const PRODUCT_MCP_ANALYSIS_TOOLS = [
"analyze_flushing",
"analyze_pressure_regulation",
"analyze_valve_closure",
"analyze_valve_isolation",
"calculate_demand_allocation",
"optimize_sensor_placement",
"run_burst_detection",
"run_burst_location",
"run_burst_simulation",
"run_contaminant_simulation",
"run_leakage_identification",
"run_simulation",
] as const;
export const PRODUCT_MCP_WRITE_TOOLS = [
"cancel_task_run",
"clean_scada_readings",
"create_operational_task",
"create_sensor_placement",
"create_task_schedule",
"delete_scada_readings",
"edit_model_configuration",
"edit_model_element",
"retry_operational_task",
"save_simulation_scenario",
"update_sensor_placement",
"update_task_schedule",
"write_scada_readings",
] as const;
export const PRODUCT_MCP_TOOLS = [...PRODUCT_MCP_READ_TOOLS, ...PRODUCT_MCP_ANALYSIS_TOOLS, ...PRODUCT_MCP_WRITE_TOOLS] as const;
export const QUERY_MCP_SERVER = "mcp_tjwater";
export const queryMcpToolId = (name: string) => `${QUERY_MCP_SERVER}_${name}`;
export const PRODUCT_MCP_TOOL_IDS = PRODUCT_MCP_TOOLS.map(queryMcpToolId);
export const PRODUCT_MCP_READ_TOOL_IDS = PRODUCT_MCP_READ_TOOLS.map(queryMcpToolId);
export const PRODUCT_MCP_WRITE_TOOL_IDS = PRODUCT_MCP_WRITE_TOOLS.map(queryMcpToolId);
export const LOCAL_QUERY_TOOLS = ["geocode", "session_search", "list_investigation_tasks", "get_investigation_task", "read_agent_messages", "todoread"] as const;
export const LOCAL_QUERY_TOOL_IDS = LOCAL_QUERY_TOOLS.map(queryMcpToolId);
export const QUERY_MCP_TOOL_IDS = [...PRODUCT_MCP_TOOL_IDS, ...LOCAL_QUERY_TOOL_IDS];
export const QUERY_MCP_AUTOALLOW_TOOL_IDS = [...PRODUCT_MCP_READ_TOOL_IDS, ...PRODUCT_MCP_ANALYSIS_TOOLS.map(queryMcpToolId), ...LOCAL_QUERY_TOOL_IDS];
+2 -2
View File
@@ -1,11 +1,11 @@
import type { RuntimeSessionContext } from "./sessionContext.js";
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_TOOLS, PRODUCT_MCP_READ_TOOLS, queryMcpToolId } from "./product-mcp-tools.js";
import { LOCAL_QUERY_TOOLS, PRODUCT_MCP_TOOLS, PRODUCT_MCP_READ_TOOLS, PRODUCT_MCP_WRITE_TOOLS, queryMcpToolId } from "./product-mcp-tools.js";
const projectLocal = new Set(["geocode", "list_investigation_tasks", "get_investigation_task", "read_agent_messages"]);
export function allowedQueryTools(context: RuntimeSessionContext, reader = false): string[] {
if (context.learningMode === "review") return [];
const project = !!context.projectId && !context.projectKey.startsWith("personal-") && ["ordinary_chat", "task_execution", "task_collaboration"].includes(context.purpose ?? "");
if (reader) return project ? PRODUCT_MCP_READ_TOOLS.map(queryMcpToolId) : [];
return [...(project ? PRODUCT_MCP_TOOLS : []), ...LOCAL_QUERY_TOOLS.filter((name) =>
return [...(project ? PRODUCT_MCP_TOOLS.filter((name) => !(PRODUCT_MCP_WRITE_TOOLS as readonly string[]).includes(name) || context.purpose === "ordinary_chat") : []), ...LOCAL_QUERY_TOOLS.filter((name) =>
(!projectLocal.has(name) || project) && !(context.purpose === "task_execution" && ["list_investigation_tasks", "get_investigation_task"].includes(name)))].map(queryMcpToolId);
}
+1
View File
@@ -279,6 +279,7 @@ const warmupOpencodeRuntime = async () => {
const sandboxProbe = await probeLandlockSandbox();
const queryMcp = startQueryMcpServer({
artifactWorkspace: (context) => opencodeRuntime.ensureSessionWorkspace(context.sessionId, context),
internalToken, productClient: productMcpClient,
productTools: productMcpClient ? await productMcpClient.prepare() : [],
resolveReader: resolveTaskReader,
+63
View File
@@ -0,0 +1,63 @@
import { createHash } from "node:crypto";
export class HistoryCursorError extends Error {}
type Cursor = { scope: string; kind: string; position?: number; anchor?: string; updatedAt?: string; id?: string };
export function historyScope(value: unknown) {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function encode(value: Cursor) {
return Buffer.from(JSON.stringify(value)).toString("base64url");
}
function decode(value: string, scope: string, kind: string): Cursor {
try {
const cursor = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Cursor;
if (cursor.scope !== scope || cursor.kind !== kind) throw new Error();
return cursor;
} catch {
throw new HistoryCursorError("invalid history cursor");
}
}
export function sessionHistoryPage<T extends { sessionId: string; updatedAt: string; title?: string; searchSnippet?: string }>(
records: T[], options: { scope: string; q?: string; limit: number; cursor?: string },
) {
const query = (options.q ?? "").trim().toLocaleLowerCase();
const scope = historyScope([options.scope, query]);
const boundary = options.cursor ? decode(options.cursor, scope, "sessions") : null;
if (boundary && (typeof boundary.updatedAt !== "string" || typeof boundary.id !== "string")) {
throw new HistoryCursorError("invalid history cursor");
}
const ordered = records.filter((record) => (record.title ?? "新对话").toLocaleLowerCase().includes(query) || Boolean(query && record.searchSnippet))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.sessionId.localeCompare(a.sessionId));
const remaining = boundary ? ordered.filter((record) => record.updatedAt < boundary.updatedAt!
|| (record.updatedAt === boundary.updatedAt && record.sessionId.localeCompare(boundary.id!) < 0)) : ordered;
const sessions = remaining.slice(0, options.limit);
const has_more = remaining.length > sessions.length;
const last = sessions.at(-1);
return { sessions, has_more, next_cursor: has_more && last
? encode({ scope, kind: "sessions", updatedAt: last.updatedAt, id: last.sessionId }) : null };
}
function messageAnchor(message: unknown) {
if (message && typeof message === "object" && "id" in message && typeof message.id === "string") return message.id;
return historyScope(message);
}
export function sessionMessagePage(messages: unknown[], options: { scope: string; limit: number; before?: string }) {
const boundary = options.before ? decode(options.before, options.scope, "messages") : null;
const end = boundary?.position ?? messages.length;
if (!Number.isInteger(end) || end < 0 || end > messages.length || (boundary &&
(end >= messages.length || messageAnchor(messages[end]) !== boundary.anchor))) {
throw new HistoryCursorError("message history changed; reload this conversation");
}
const start = Math.max(0, end - options.limit);
return {
messages: messages.slice(start, end),
message_page: { has_more: start > 0, before_cursor: start > 0
? encode({ scope: options.scope, kind: "messages", position: start, anchor: messageAnchor(messages[start]) }) : null },
};
}
+21
View File
@@ -0,0 +1,21 @@
/** Search only visible user/assistant text, never tool arguments or hidden reasoning. */
export function matchHistoryText(messages: unknown[], query: string): string | undefined {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return;
for (const value of messages) {
if (!value || typeof value !== "object") continue;
const message = value as Record<string, unknown>;
if (message.role !== "user" && message.role !== "assistant") continue;
const parts = Array.isArray(message.parts) ? message.parts : [message];
for (const value of parts) {
if (!value || typeof value !== "object") continue;
const part = value as Record<string, unknown>;
if (part.type !== "text" || typeof part.text !== "string") continue;
const index = part.text.toLocaleLowerCase().indexOf(needle);
if (index < 0) continue;
const start = Math.max(0, index - 36);
const end = Math.min(part.text.length, index + needle.length + 80);
return `${start ? "…" : ""}${part.text.slice(start, end).replace(/\s+/g, " ")}${end < part.text.length ? "…" : ""}`;
}
}
}
+10 -3
View File
@@ -1,5 +1,6 @@
import { join } from "node:path";
import { SessionTopicStore } from "./topic-store.js";
import { config } from "../config.js";
import { type SessionWorkspaceStore } from "./workspace-store.js";
import {
@@ -30,6 +31,7 @@ export type SessionRecord = {
requestId?: string;
modelId?: string;
title?: string;
topicId?: string | null;
triggerId?: string;
triggerSource?: string;
triggerSubjectType?: string;
@@ -63,14 +65,17 @@ type EnsureSessionMetadataInput = SessionMetadataContext & {
runId?: string;
requestId?: string;
modelId?: string;
topicId?: string;
};
export class SessionMetadataStore {
readonly topics: SessionTopicStore;
private readonly ensureQueues = new Map<string, Promise<void>>();
private index?: Map<string, Map<string, SessionRecord>>;
private indexLoading?: Promise<void>;
private indexVersion = 0;
constructor(private readonly baseDir = config.SESSION_METADATA_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {
this.topics = new SessionTopicStore(join(baseDir, "topics"));
workspaces?.setDeletionCheck(async (context) => Boolean((await this.get(context, context.sessionId, { includeDeleted: true }))?.deletedAt));
}
@@ -133,6 +138,7 @@ export class SessionMetadataStore {
runId: input.runId?.trim(),
requestId: input.requestId?.trim(),
modelId: input.modelId?.trim(),
topicId: input.topicId,
...(input.trigger ? {
triggerId: input.trigger.triggerId,
triggerSource: input.trigger.source,
@@ -175,7 +181,7 @@ export class SessionMetadataStore {
async touch(
record: SessionRecord,
updates: Partial<Pick<SessionRecord, "title" | "status">> = {},
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {},
) {
return this.serialize(record.sessionId, async () => {
const current = await this.get({ ...record, userId: record.ownerUserId }, record.sessionId);
@@ -314,12 +320,13 @@ const matchesContext = (record: SessionRecord, context: SessionMetadataContext)
(context.projectId === undefined || record.projectId === context.projectId);
const normalizeSessionUpdates = (
updates: Partial<Pick<SessionRecord, "title" | "status">>,
updates: Partial<Pick<SessionRecord, "title" | "status" | "topicId">>,
) => {
const normalized: Partial<Pick<SessionRecord, "title" | "status">> = {};
const normalized: Partial<Pick<SessionRecord, "title" | "status" | "topicId">> = {};
if (updates.status === "active" || updates.status === "archived") {
normalized.status = updates.status;
}
if (updates.topicId === null || typeof updates.topicId === "string") normalized.topicId = updates.topicId;
if (typeof updates.title === "string") {
const trimmed = updates.title.trim();
if (trimmed) {
+66
View File
@@ -0,0 +1,66 @@
import { createHash, randomUUID } from "node:crypto";
import { join } from "node:path";
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
export type TopicScope = { actorKey: string; projectKey: string; userId?: string };
export type SessionTopic = { id: string; name: string; createdAt: string; updatedAt: string };
/** Topics organize one user's chats within an authorized data scope. */
export class SessionTopicStore {
private queues = new Map<string, Promise<void>>();
constructor(private readonly directory: string) {}
async list(scope: TopicScope): Promise<SessionTopic[]> {
return (await readJsonFile<{ topics: SessionTopic[] }>(this.path(scope)))?.topics ?? [];
}
async get(scope: TopicScope, id: string) {
return (await this.list(scope)).find((topic) => topic.id === id);
}
create(scope: TopicScope, name: string) {
return this.update(scope, (topics) => {
const now = new Date().toISOString();
const topic = { id: randomUUID(), name, createdAt: now, updatedAt: now };
topics.push(topic);
return topic;
});
}
rename(scope: TopicScope, id: string, name: string) {
return this.update(scope, (topics) => {
const topic = topics.find((entry) => entry.id === id);
if (topic) { topic.name = name; topic.updatedAt = new Date().toISOString(); }
return topic;
});
}
remove(scope: TopicScope, id: string) {
return this.update(scope, (topics) => {
const index = topics.findIndex((topic) => topic.id === id);
if (index < 0) return false;
topics.splice(index, 1);
return true;
});
}
private async update<T>(scope: TopicScope, change: (topics: SessionTopic[]) => T): Promise<T> {
const path = this.path(scope);
const previous = this.queues.get(path) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(async () => {
const topics = await this.list(scope);
const value = change(topics);
await atomicWriteJson(path, { topics }, 0o600);
return value;
});
const pending = result.then(() => undefined, () => undefined);
this.queues.set(path, pending);
try { return await result; }
finally { if (this.queues.get(path) === pending) this.queues.delete(path); }
}
private path(scope: TopicScope) {
const hash = createHash("sha256").update(JSON.stringify([scope.actorKey, scope.projectKey, scope.userId ?? null])).digest("hex");
return join(this.directory, `${hash}.json`);
}
}
+17
View File
@@ -48,3 +48,20 @@ it("bounds pending input and refuses admission after an explicit stop", async ()
tracker.close();
await expect(tracker.submit(input(21))).rejects.toMatchObject({ status: 409 });
});
it("allows close after withdrawal and keeps cancelled retries from enqueueing again", async () => {
const runtime = { listPendingUserInputs: async () => [], enqueueUserInput: mock(async () => {}) };
const changed = mock((_input: ResponseInput) => {});
const tracker = new ResponseInputTracker(runtime, "owned", changed);
tracker.markReady();
await tracker.submit(input(1));
tracker.observe("session.inbox.cancelled", { inboxID: input(1).id });
expect(changed.mock.calls.at(-1)?.[0].status).toBe("cancelled");
expect(tracker.hasAdditionalInput).toBe(false);
expect((await tracker.submit(input(1))).status).toBe("cancelled");
expect(runtime.enqueueUserInput).toHaveBeenCalledTimes(1);
tracker.observe("session.inbox.delivery.changed", { inboxID: input(1).id, delivery: "queue" });
tracker.observe("session.inbox.delivered", { inboxID: input(1).id });
expect(changed).toHaveBeenCalledTimes(2);
expect(await tracker.canClose()).toBe(true);
});
+17 -2
View File
@@ -1,6 +1,17 @@
import { describe, expect, it } from "bun:test";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import plugin from "../../.opencode/plugins/tjwater-tools.js";
const originalInternalToken = process.env.AGENT_INTERNAL_TOKEN;
beforeAll(() => {
process.env.AGENT_INTERNAL_TOKEN = "test-internal-token";
});
afterAll(() => {
if (originalInternalToken === undefined) delete process.env.AGENT_INTERNAL_TOKEN;
else process.env.AGENT_INTERNAL_TOKEN = originalInternalToken;
});
describe("OpenCode 2 business tool registration", () => {
it("registers business tools for direct dispatch, forwards the native call ID and validates inputs", async () => {
const tools = new Map<string, any>();
@@ -9,7 +20,11 @@ describe("OpenCode 2 business tool registration", () => {
let readTodos: ((input: { session_id: string }) => Promise<unknown>) | undefined;
await plugin.setup({
tool: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => Array.from(tools, ([id, tool]) => ({ ...tool, id })), add: (tool: any) => tools.set(tool.name, tool), remove: (name: string) => tools.delete(name), update: () => {} }), list: async () => Array.from(tools, ([id, tool]) => ({ ...tool, id })) },
mcp: { transform: async (fn: (editor: unknown) => void) => fn({ list: () => [] }), list: async () => ({ data: [] }) },
mcp: {
transform: async (fn: (editor: unknown) => void) =>
fn({ list: () => [], set: () => {} }),
list: async () => ({ data: [] }),
},
shell: { hook: async () => {} },
permission: { hook: async () => {} },
session: { hook: async () => {} },
+2 -1
View File
@@ -25,8 +25,9 @@ try {
const response = await native.client.rpc.call({ rpcID: "tjwater.tools", method: "list", input: {}, location: { directory } });
const result = response.output as Array<{ id: string; direct: boolean }>;
assert.ok(result.some((tool) => tool.id === "present_answer" && tool.direct), "present_answer must be registered as a direct native tool");
assert.ok(result.some((tool) => tool.id === "publish_task_report" && tool.direct), "publish_task_report must remain registered with rich report support");
assert.equal((await native.client.permission.create({ sessionID, action: "present_answer", resources: ["*"], save: [] })).effect, "allow");
console.log("Native present_answer discovery and permission passed. No inference requests.");
console.log("Native answer and report tool discovery, plus answer permission, passed. No inference requests.");
} finally {
if (native && sessionID) await native.client.session.remove({ sessionID }).catch(() => {});
native?.close();
+12 -3
View File
@@ -5,6 +5,7 @@ import { bindSessionCredential, releaseSessionCredential } from "../../src/produ
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { startQueryMcpServer } from "../../src/mcp/query-server.js";
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { PRODUCT_MCP_TOOLS, QUERY_MCP_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
const python = process.env.MCP_TEST_PYTHON;
const root = process.env.MCP_TEST_SERVER_ROOT;
@@ -44,23 +45,31 @@ try {
};
const client = new McpQueryClient({ issuer: "http://localhost/realms/test", resource, audience: "tjwater-mcp", clientId: "tjwater-agent-mcp", clientSecret: "fixture" }, http);
const catalog = await client.prepare();
if (catalog.length !== 35) throw new Error("Query catalog discovery failed");
if (catalog.length !== PRODUCT_MCP_TOOLS.length) throw new Error("Query catalog discovery failed");
if (!catalog.some((tool) => tool.name === "run_simulation" && tool.inputSchema.properties?.start_time && tool.inputSchema.properties?.output_node_ids)) throw new Error("Standard hydraulic simulation schema missing");
bindSessionCredential(session, { authorization: `Bearer ${fixtureJwt("source")}`, projectId: "d44a9241-de48-4e60-9348-867dc466a031" });
setRuntimeSessionContext({ sessionId: session, clientSessionId: session, actorKey: "fixture", traceId: "fixture", projectId: "d44a9241-de48-4e60-9348-867dc466a031", projectKey: "fixture-project", purpose: "ordinary_chat" });
gateway = startQueryMcpServer({ internalToken: "MCP_TEST_ONLY", productClient: client, productTools: catalog, resolveReader: async () => null, executeLocal: async () => { throw new Error("unused fixture query"); } });
nativeClient = new Client({ name: "fixture-native", version: "1" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
await nativeClient.connect(new StreamableHTTPClientTransport(new URL(gateway.url), { requestInit: { headers: { "x-agent-internal-token": "MCP_TEST_ONLY" } } }));
if ((await nativeClient.listTools()).tools.length !== 41) throw new Error("Merged MCP query catalog discovery failed");
if ((await nativeClient.listTools()).tools.length !== QUERY_MCP_TOOL_IDS.length) throw new Error("Merged MCP query catalog discovery failed");
const result = await nativeClient.callTool({ name: "list_elements", arguments: { element_type: "pipe", limit: 1 }, _meta: { "ai.opencode/sessionID": session } });
if (result.isError || JSON.stringify((result.structuredContent as { data?: unknown } | undefined)?.data) !== '[{"id":"P-1"}]') throw new Error("Typed query execution failed");
const nearbyTool = catalog.find((tool) => tool.name === "query_nearby_elements");
if (!nearbyTool?.inputSchema.properties?.center) throw new Error("Nearby model tool schema missing");
const nearby = await nativeClient.callTool({ name: "query_nearby_elements", arguments: { center: [121.5, 30.9], radius_m: 500, limit: 5 }, _meta: { "ai.opencode/sessionID": session } });
if (nearby.isError) throw new Error("Nearby model query failed through merged MCP");
for (const [name, input] of [
["get_project_info", {}],
["get_node_connections", { node_id: "J1", limit: 1 }],
["query_network_extent", { bounds: [121, 31, 121.1, 31.1], limit: 1 }],
] as const) {
const response = await nativeClient.callTool({ name, arguments: input, _meta: { "ai.opencode/sessionID": session } });
if (response.isError) throw new Error(`Extended tool failed through merged MCP: ${name}`);
}
if (!requests.includes("server/discover") || !requests.includes("tools/call") || requests.includes("initialize") || requests.includes("GET")) throw new Error("Transport used unexpected protocol requests");
if (grants.join(",") !== "client_credentials,urn:ietf:params:oauth:grant-type:token-exchange") throw new Error("OAuth grants were not validated");
console.log("PASS: local MCP 41-tool discovery, real Server schemas, OAuth discovery, resource-bound grants, host binding, query and revocation over MCP HTTP");
console.log(`PASS: local MCP ${QUERY_MCP_TOOL_IDS.length}-tool discovery, real Server schemas, OAuth discovery, resource-bound grants, host binding, extended query and revocation over MCP HTTP`);
} finally {
await nativeClient?.close();
await gateway?.close();
+9 -1
View File
@@ -61,6 +61,9 @@ try {
await client.session.inbox.update({ sessionID: session.id, inboxID: item.id, delivery: "steer" });
assert.equal((await client.session.inbox.list({ sessionID: session.id })).find((entry) => entry.id === item.id)?.delivery, "steer");
await client.session.inbox.update({ sessionID: session.id, inboxID: item.id, delivery: "queue" });
const withdrawn = await tracker.submit({ id: `msg_${"2".repeat(32)}`, text: "Withdraw this before delivery", delivery: "queue", status: "pending" });
assert.equal((await runtime.cancelPendingUserInput(session.id, withdrawn.id)).status, "cancelled");
assert.ok(!(await client.session.inbox.list({ sessionID: session.id })).some((entry) => entry.id === withdrawn.id));
release();
await wait("queued continuation", () => prompts.length >= 2);
await Bun.sleep(100);
@@ -77,8 +80,13 @@ try {
assert.ok(tokens.join("").split("Local completed.").length >= 3, "managed stream closed before queued answer");
assert.ok(JSON.stringify(prompts[1]).includes("Additional local user input"));
assert.ok(!(await client.session.inbox.list({ sessionID: session.id })).some((entry) => entry.id === item.id));
assert.ok(!JSON.stringify(prompts).includes(withdrawn.text));
await assert.rejects(runtime.cancelPendingUserInput(session.id, item.id), { status: 409 });
// Simulate delivery winning the race after the adapter's pending-list read.
const staleRuntime = Object.assign(Object.create(runtime), { listPendingUserInputs: async () => [item] }) as OpencodeRuntimeAdapter;
await assert.rejects(staleRuntime.cancelPendingUserInput(session.id, item.id), { status: 409 });
await client.session.remove({ sessionID: session.id });
console.log("Native queue delivery, pending steer/queue changes and automatic continuation passed; local fixture only.");
console.log("Native queue delivery, pending changes, withdrawal and delivery-race rejection passed; local fixture only.");
} finally {
release();
releaseSecond();
+67
View File
@@ -0,0 +1,67 @@
import { afterEach, expect, it } from "bun:test";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, rm, symlink } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { config } from "../../src/config.js";
import { saveMcpArtifact, openArtifactDirectory } from "../../src/mcp/artifact-files.js";
import { PRODUCT_MCP_WRITE_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
import { allowedQueryTools } from "../../src/runtime/query-mcp-policy.js";
import type { RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
const roots: string[] = [];
const originalRoot = config.CONVERSATION_WORKSPACE_ROOT;
afterEach(async () => {
config.CONVERSATION_WORKSPACE_ROOT = originalRoot;
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
const owner: RuntimeSessionContext = { sessionId: "export-native", clientSessionId: "export-client", actorKey: "actor-a", projectKey: "project-a", projectId: "p", purpose: "ordinary_chat", traceId: "test" };
async function workspace() {
const root = await mkdtemp(join(tmpdir(), "artifact-test-")); roots.push(root);
config.CONVERSATION_WORKSPACE_ROOT = root;
const directory = join(root, "actor-a", "project-a", `session-${createHash("sha256").update(owner.sessionId).digest("hex")}`, "workspace");
await mkdir(join(directory, "files"), { recursive: true });
return directory;
}
function result() {
const bytes = Buffer.from("export fixture");
return { content: [{ type: "text" as const, text: "binary" }], structuredContent: { source: "export_project_model", evidence_id: "E1", data: { file: {
name: "model.zip", mime_type: "application/zip", encoding: "base64", data: bytes.toString("base64"), size: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex"),
} } } };
}
it("writes verified exports only to the owning workspace and returns no binary or host path", async () => {
const directory = await workspace();
const saved = await saveMcpArtifact(result(), owner, directory);
const data = saved.structuredContent as { data: { file: { relative_path: string; download_url: string } } };
expect(await readFile(join(directory, data.data.file.relative_path), "utf8")).toBe("export fixture");
expect(data.data.file.download_url).toStartWith("/api/v1/session/export-client/files/mcp-");
expect(JSON.stringify(saved)).not.toContain(directory);
expect(JSON.stringify(saved)).not.toContain(Buffer.from("export fixture").toString("base64"));
expect(saved.structuredContent).toHaveProperty("evidence_id", "E1");
});
it("rejects tampered file contents, cross-session workspaces and symlink directories", async () => {
const directory = await workspace();
const tampered = result(); tampered.structuredContent.data.file.sha256 = "0".repeat(64);
await expect(saveMcpArtifact(tampered, owner, directory)).rejects.toThrow("integrity");
await expect(openArtifactDirectory(directory, "other-session")).rejects.toThrow("owner");
await rm(join(directory, "files"), { recursive: true });
await symlink(tmpdir(), join(directory, "files"));
await expect(saveMcpArtifact(result(), owner, directory)).rejects.toThrow();
});
it("keeps all product writes behind native ask permissions and out of task, reader and review policies", () => {
const configuration = serviceOpencodeConfig({});
for (const tool of PRODUCT_MCP_WRITE_TOOL_IDS) {
expect(configuration.permissions.filter((rule) => rule.action === tool).at(-1)?.effect).toBe("ask");
expect(allowedQueryTools(owner)).toContain(tool);
expect(allowedQueryTools({ ...owner, purpose: "task_execution" })).not.toContain(tool);
expect(allowedQueryTools({ ...owner, purpose: "task_collaboration" })).not.toContain(tool);
expect(allowedQueryTools(owner, true)).not.toContain(tool);
expect(allowedQueryTools({ ...owner, learningMode: "review" })).not.toContain(tool);
}
});
+43
View File
@@ -66,3 +66,46 @@ describe("new investigation report writes", () => {
expect(updated.diagram?.node_refs.map((node) => node.status)).toEqual(["completed", "running", "pending"]);
});
});
const uiBlock = {
version: "answer-ui@1" as const, block_id: "pressure", fallback_text: "演练数据,等待核验。",
spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: {
title: "演练压力", chart_type: "line", x_data: ["08:00", "09:00"],
series: [{ name: "压力", data: [0.3, 0.28] }], source: "query-1",
} } } },
};
describe("interactive report publication", () => {
it("preserves rich blocks and legacy reports without changing fixed facts", () => {
expect(taskReportSchema.parse(report).ui_blocks).toBeUndefined();
const parsed = taskReportSchema.parse({ ...report, ui_blocks: [{ block: uiBlock, evidence_refs: ["query-1"] }] });
expect(parsed.ui_blocks?.[0].block).toEqual(uiBlock);
expect(parsed.outcome).toBe("insufficient_data");
expect(parsed.data_gaps).toEqual(report.data_gaps);
});
it("rejects foreign evidence, duplicate ids, executable props and malformed charts", () => {
const item = { block: uiBlock, evidence_refs: ["query-1"] };
for (const blocks of [
[item, item], [{ ...item, evidence_refs: ["other-task"] }],
Array.from({ length: 9 }, (_, index) => ({ ...item, block: { ...uiBlock, block_id: `b${index}` } })),
...[
{ type: "iframe" },
{ props: { ...uiBlock.spec.elements.chart.props, onclick: "execute()" } },
{ props: { ...uiBlock.spec.elements.chart.props, series: [{ name: "压力", data: [1] }] } },
{ props: { ...uiBlock.spec.elements.chart.props, series: [{ name: "压力", data: [NaN, 1] }] } },
].map((change) => [{ ...item, block: { ...uiBlock, spec: { ...uiBlock.spec,
elements: { chart: { ...uiBlock.spec.elements.chart, ...change } } } } }]),
]) expect(taskReportSchema.safeParse({ ...report, ui_blocks: blocks }).success).toBe(false);
});
it("bounds the total UTF-8 payload even when every individual block is valid", () => {
const elements = Object.fromEntries(Array.from({ length: 20 }, (_, index) => [
`n${index}`, { type: "Text", props: { text: "资".repeat(3900), tone: "neutral" }, children: [] },
]));
const block = { ...uiBlock, spec: { root: "root", elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal" }, children: Object.keys(elements) }, ...elements,
} } };
const blocks = ["first", "second"].map((block_id) => ({ block: { ...block, block_id }, evidence_refs: ["query-1"] }));
expect(taskReportSchema.safeParse({ ...report, ui_blocks: [blocks[0]] }).success).toBe(true);
expect(taskReportSchema.safeParse({ ...report, ui_blocks: blocks }).success).toBe(false);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { expect, it } from "bun:test";
import express, { type Request } from "express";
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, writeFile, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { config } from "../../src/config.js";
import { attachTrustedAgentContext, getLocalAgentContext, ProductContextError } from "../../src/context/localContext.js";
import { registerArtifactRoutes } from "../../src/routes/artifact-routes.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
it("requires verified actor, project and session ownership for exports and rejects symlinks", async () => {
const originalFetch = globalThis.fetch;
const originalEnv = config.NODE_ENV;
const originalRoot = config.CONVERSATION_WORKSPACE_ROOT;
const root = await mkdtemp(join(tmpdir(), "artifact-route-"));
const store = new SessionMetadataStore(join(root, "metadata"));
const app = express();
let calls = 0;
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
if (String(input).endsWith("/api/v1/agent-auth-context")) {
const headers = new Headers(init?.headers);
return Response.json({ user_id: headers.get("Authorization") === "Bearer OWNER" ? "owner" : "other", project_id: headers.get("X-Project-Id"), network: "supply" });
}
return originalFetch(input, init);
}) as typeof fetch;
config.NODE_ENV = "production";
config.CONVERSATION_WORKSPACE_ROOT = root;
const owner = { header: (name: string) => ({ authorization: "Bearer OWNER", "x-project-id": "project" })[name.toLowerCase() as "authorization" | "x-project-id"] } as Request;
await attachTrustedAgentContext(owner);
const context = getLocalAgentContext(owner);
const session = "export-session";
await store.ensure({ ...context, sessionId: session });
const directory = join(root, context.actorKey, context.projectKey, `session-${createHash("sha256").update(session).digest("hex")}`, "workspace");
await mkdir(join(directory, "files"), { recursive: true });
const filename = "mcp-11111111-1111-1111-1111-111111111111.zip";
await writeFile(join(directory, "files", filename), "fixture");
const router = express.Router();
router.use(async (req, res, next) => {
try { await attachTrustedAgentContext(req); next(); }
catch (error) { res.status(error instanceof ProductContextError ? error.status : 503).end(); }
});
registerArtifactRoutes(router, { sessionMetadataStore: store, runtime: {
ensureSessionWorkspace: async () => { calls++; return directory; },
} });
app.use("/api/v1", router);
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/api/v1/session/${session}/files/${filename}`;
const headers = { Authorization: "Bearer OWNER", "X-Project-Id": "project" };
try {
expect((await originalFetch(url)).status).toBe(401);
expect((await originalFetch(url, { headers: { ...headers, Authorization: "Bearer OTHER" } })).status).toBe(404);
expect((await originalFetch(url, { headers: { ...headers, "X-Project-Id": "other" } })).status).toBe(404);
expect(calls).toBe(0);
const response = await originalFetch(url, { headers });
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(response.headers.get("Content-Disposition")).toContain("attachment");
expect(await response.text()).toBe("fixture");
await rm(join(directory, "files", filename));
await symlink(join(root, "metadata"), join(directory, "files", filename));
expect((await originalFetch(url, { headers })).status).toBe(404);
} finally {
globalThis.fetch = originalFetch; config.NODE_ENV = originalEnv; config.CONVERSATION_WORKSPACE_ROOT = originalRoot;
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
});
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, expect, it } from "bun:test";
import express from "express";
import { mkdtemp, rm } from "node:fs/promises";
import type { Server } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildChatRouter } from "../../src/routes/chat.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
const http = globalThis.fetch;
let root: string;
let server: Server;
afterEach(async () => { globalThis.fetch = http; if (server) { server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); } if (root) await rm(root, { recursive: true, force: true }); });
it("persists owned topics, searches old text across themes and preserves chats when a theme is deleted", async () => {
root = await mkdtemp(join(tmpdir(), "tjwater-topics-"));
const project = "10000000-0000-4000-8000-000000000001";
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner" };
globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("authorization") === "Bearer owner" ? "owner" : "other", project_id: new Headers(init?.headers).get("x-project-id"), network: "supply" })) as typeof fetch;
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(join(root, "ui"), workspaces);
for (const id of ["a", "b", "unclassified"]) {
await sessions.ensure({ ...context, sessionId: id });
await states.write({ ...context, sessionId: id }, { sessionId: id, messages: [
{ id: "old", role: "user", parts: [{ type: "text", text: `历史最早消息的共同关键词:南桥阀门 ${id}` }] },
...Array.from({ length: 60 }, (_, index) => ({ id: `message-${index}`, role: "assistant", parts: [{ type: "text", text: "正在复核监测记录" }] })),
{ role: "assistant", parts: [{ type: "reasoning", text: "隐藏推理密码" }, { type: "tool-input", text: "工具秘密参数" }] },
] });
}
await sessions.ensure({ ...context, sessionId: "collaboration", purpose: "task_collaboration" });
await states.write({ ...context, sessionId: "collaboration" }, { sessionId: "collaboration", messages: [{ role: "user", parts: [{ type: "text", text: "协作讨论:南桥阀门" }] }] });
await sessions.ensure({ ...context, sessionId: "execution", purpose: "task_execution" });
const app = express(); app.use(express.json());
app.use("/api/v1", buildChatRouter({ sessionMetadataStore: sessions, sessionUiStateStore: states, runtime: { createSession: async () => ({ id: "new-topic-chat" }) } as never,
sessionBridge: {} as never, sessionTranscriptStore: {} as never, memoryStore: {} as never, learningOrchestrator: {} as never, frontendActionCoordinator: {} as never }));
server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address(); if (!address || typeof address === "string") throw new Error("missing address");
const request = async (path: string, method = "GET", body?: unknown, user = "owner", projectId = project) => {
const response = await http(`http://127.0.0.1:${address.port}/api/v1${path}`, { method,
headers: { Authorization: `Bearer ${user}`, "X-Project-Id": projectId, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
return { status: response.status, body: JSON.parse(await response.text() || "null") };
};
const first = (await request("/topics", "POST", { name: "压力异常事件" })).body;
const second = (await request("/topics", "POST", { name: "阀门检修问题" })).body;
expect((await request("/topics", "POST", { name: " " })).status).toBe(400);
expect((await request("/topics", "GET", undefined, "other")).body.topics).toEqual([]);
expect((await request(`/topics/${first.id}`, "PATCH", { name: "越权" }, "other")).status).toBe(404);
expect((await request(`/session/a/topic`, "PATCH", { topic_id: first.id })).status).toBe(200);
expect((await request(`/session/b/topic`, "PATCH", { topic_id: second.id })).status).toBe(200);
expect((await request(`/session/a/topic`, "PATCH", { topic_id: first.id }, "other")).status).toBe(404);
expect((await request(`/session/a/topic`, "PATCH", { topic_id: first.id }, "owner", "10000000-0000-4000-8000-000000000002")).status).toBe(404);
const hits = (await request(`/sessions?q=${encodeURIComponent("南桥阀门")}&limit=1`)).body;
const next = (await request(`/sessions?q=${encodeURIComponent("南桥阀门")}&limit=10&cursor=${encodeURIComponent(hits.next_cursor)}`)).body;
expect(hits.sessions.length + next.sessions.length).toBe(3);
expect([...hits.sessions, ...next.sessions].every((item) => item.search_snippet.includes("南桥阀门"))).toBe(true);
expect((await request(`/sessions?topic_id=${first.id}&q=${encodeURIComponent("南桥阀门")}`)).body.sessions.map((item: { id: string }) => item.id)).toEqual(["a"]);
expect((await request(`/sessions?topic_id=${first.id}&q=${encodeURIComponent("南桥阀门")}&cursor=${encodeURIComponent(hits.next_cursor)}`)).status).toBe(400);
expect((await request(`/sessions?q=${encodeURIComponent("隐藏推理密码")}`)).body.sessions).toEqual([]);
expect((await request(`/sessions?q=${encodeURIComponent("工具秘密参数")}`)).body.sessions).toEqual([]);
await request("/session/collaboration/topic", "PATCH", { topic_id: first.id });
expect((await request(`/sessions?include_collaboration=true&topic_id=${first.id}&q=${encodeURIComponent("南桥阀门")}`)).body.sessions.map((item: { id: string }) => item.id).sort()).toEqual(["a", "collaboration"]);
expect((await request("/sessions?include_collaboration=true")).body.sessions.some((item: { id: string }) => item.id === "execution")).toBe(false);
await request(`/topics/${first.id}`, "PATCH", { name: "压力异常复盘" });
expect((await new SessionMetadataStore(join(root, "metadata"), workspaces).topics.list(context))[0]?.name).toBe("压力异常复盘");
const created = await request("/session", "POST", { topic_id: first.id });
expect(created.body.topic_id).toBe(first.id);
expect((await request(`/topics/${first.id}`, "DELETE")).status).toBe(204);
expect((await request("/session/a")).body.topic_id).toBe(null);
expect((await request("/session/a")).body.messages).toHaveLength(62);
expect((await request("/sessions?topic_id=unclassified")).body.sessions.map((item: { id: string }) => item.id).sort()).toEqual(["a", "new-topic-chat", "unclassified"]);
expect((await request(`/session/a/topic`, "PATCH", { topic_id: first.id })).status).toBe(404);
});
+52 -1
View File
@@ -24,6 +24,7 @@ let aborts: number;
let gate: Promise<void> | undefined;
let release: () => void = () => {};
let bridge: ChatSessionBridge;
let states: SessionUiStateStore;
const project = "10000000-0000-4000-8000-000000000001";
beforeEach(async () => {
fail = false; prompted = []; pendingInputs = []; aborts = 0; gate = undefined;
@@ -34,7 +35,7 @@ beforeEach(async () => {
})) as typeof fetch;
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(join(root, "ui"), workspaces);
states = new SessionUiStateStore(join(root, "ui"), workspaces);
await sessions.initialize();
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
await sessions.ensure(context);
@@ -44,6 +45,12 @@ beforeEach(async () => {
messages: async () => [], abortSession: async () => { aborts++; }, waitForSessionIdle: async () => {},
listPendingUserInputs: async () => pendingInputs.map((input) => ({ ...input })),
cancelPendingUserInputs: async () => { pendingInputs = []; },
cancelPendingUserInput: async (_sessionId: string, id: string) => {
const item = pendingInputs.find((input) => input.id === id);
if (!item) throw Object.assign(new Error("Consumed"), { status: 409 });
pendingInputs = pendingInputs.filter((input) => input.id !== id);
return { ...item, status: "cancelled" };
},
enqueueUserInput: async (_sessionId: string, input: ResponseInput) => { pendingInputs.push({ ...input }); },
updatePendingUserInput: async (_sessionId: string, id: string, delivery: "steer" | "queue") => {
const item = pendingInputs.find((input) => input.id === id);
@@ -144,6 +151,31 @@ it("rejects cross-user conversations, legacy requests and retired URLs before ge
expect((await call({ input: "核查压力" }, "owner", "/api/v1/agent/chat/stream")).status).toBe(404);
expect(prompted).toEqual([]);
});
it("withdraws only owned pending input without stopping generation or persisting a sent message", async () => {
gate = new Promise<void>((resolve) => { release = resolve; });
const initial = await call({ input: "原始问题", conversation: "owned", stream: true });
const id = `msg_${"3".repeat(32)}`;
await call({ input: "撤回后重新编辑", conversation: "owned", tjwater: { delivery: "queue", input_id: id } });
const headers = { Authorization: "Bearer owner", "X-Project-Id": project };
const url = `${base}/api/v1/session/owned/inputs/${id}`;
const baselineAborts = aborts;
expect((await http(url, { method: "DELETE", headers: { ...headers, Authorization: "Bearer other" } })).status).toBe(404);
expect(pendingInputs).toHaveLength(1);
const withdrawn = await http(url, { method: "DELETE", headers });
expect(withdrawn.status).toBe(200);
expect(await withdrawn.json()).toMatchObject({ id, text: "撤回后重新编辑", delivery: "queue", status: "cancelled" });
expect(pendingInputs).toHaveLength(0);
expect((await http(url, { method: "DELETE", headers })).status).toBe(409);
expect(aborts).toBe(baselineAborts);
release();
const output = await initial.text();
expect(output).toContain('"status":"cancelled"');
expect(output).toContain("event: response.completed");
const history = await http(`${base}/api/v1/session/owned`, { headers });
const messages = (await history.json()).messages as Array<{ role: string }>;
expect(JSON.stringify(messages.filter((message) => message.role === "user"))).not.toContain("撤回后重新编辑");
});
it("returns response.failed when the runtime fails", async () => {
fail = true;
const response = await call({ input: "核查压力", conversation: "owned", stream: true });
@@ -183,3 +215,22 @@ it("marks an explicitly aborted response incomplete and preserves scoped history
expect(text).toContain("event: response.incomplete");
expect(text).not.toContain("event: response.completed");
});
it("continues a paged conversation from the complete saved history and preserves client message IDs", async () => {
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), sessionId: "owned" };
const history = Array.from({ length: 105 }, (_, index) => ({ id: `old-${index}`, role: index % 2 ? "assistant" : "user",
parts: [{ type: "text", text: `历史证据 ${index}` }] }));
await states.write(context, { sessionId: "owned", isTitleManuallyEdited: true, messages: history });
const headers = { Authorization: "Bearer owner", "X-Project-Id": project };
const first = await http(`${base}/api/v1/session/owned?message_limit=40`, { headers });
expect((await first.json()).messages).toHaveLength(40);
const response = await call({ input: "继续核查", conversation: "owned", tjwater: { client_message_id: "client-turn-a" } });
expect(response.status).toBe(200);
await response.json();
const saved = (await states.read(context))!.messages as Array<{ id: string }>;
expect(saved.slice(0, 105)).toEqual(history);
expect(saved).toHaveLength(107);
expect(saved[105]?.id).toBe("client-turn-a");
expect((await call({ input: "重复提交", conversation: "owned", tjwater: { client_message_id: "client-turn-a" } })).status).toBe(409);
expect(prompted).toHaveLength(1);
});
+22
View File
@@ -110,6 +110,28 @@ describe("chat session HTTP isolation", () => {
expect(otherUser.body.sessions).toEqual([]);
expect(otherProject.body.sessions).toEqual([]);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a", ownerContext.projectId)).body.messages).toEqual([{ type: "text", text: "owner-only answer" }]);
const pagedMessages = Array.from({ length: 105 }, (_, index) => ({ id: `message-${index}`, role: "user", parts: [{ type: "text", text: `record ${index}` }] }));
await sessionUiStateStore.write({ ...ownerContext, sessionId: "ordinary-session" }, { sessionId: "ordinary-session", messages: pagedMessages });
const latestPage = await getJson(address.port, "/api/v1/session/ordinary-session?message_limit=40", "token-a", ownerContext.projectId);
expect(latestPage.body.messages).toEqual(pagedMessages.slice(65));
const cursor = encodeURIComponent(latestPage.body.message_page.before_cursor);
const olderPage = await getJson(address.port, `/api/v1/session/ordinary-session/messages?limit=40&before=${cursor}`, "token-a", ownerContext.projectId);
expect(olderPage.body.messages).toEqual(pagedMessages.slice(25, 65));
expect((await getJson(address.port, `/api/v1/session/ordinary-session/messages?before=${cursor}`, "token-b", ownerContext.projectId)).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session/ordinary-session/messages?limit=0", "token-a", ownerContext.projectId)).status).toBe(400);
expect((await getJson(address.port, "/api/v1/session/ordinary-session?message_limit=101", "token-a", ownerContext.projectId)).status).toBe(400);
expect((await getJson(address.port, "/api/v1/sessions?q=not-found&limit=30", "token-a", ownerContext.projectId)).body.sessions).toEqual([]);
expect((await getJson(address.port, "/api/v1/sessions?limit=1", "token-a", ownerContext.projectId)).body.sessions).toHaveLength(1);
const restored = await originalFetch(`http://127.0.0.1:${address.port}/api/v1/session/ordinary-session/stream?message_limit=40`, {
headers: { Authorization: "Bearer token-a", "X-Project-Id": ownerContext.projectId },
});
const snapshot = JSON.parse((await restored.text()).split("data: ")[1]!.trim());
expect(snapshot.messages).toEqual(pagedMessages.slice(65));
expect(snapshot.message_page.has_more).toBe(true);
expect((await sessionUiStateStore.read({ ...ownerContext, sessionId: "ordinary-session" }))?.messages).toHaveLength(105);
await sessionUiStateStore.write({ ...ownerContext, sessionId: "ordinary-session" }, {
sessionId: "ordinary-session", messages: [{ type: "text", text: "owner-only answer" }],
});
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-b", ownerContext.projectId)).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a", "10000000-0000-4000-8000-000000000002")).status).toBe(404);
expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a")).status).toBe(404);
@@ -63,6 +63,17 @@ describe("task publication bridge", () => {
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);
const interactive = { ...report, evidence_refs: ["e1"], ui_blocks: [{ evidence_refs: ["e1"], block: {
version: "answer-ui@1", block_id: "summary", fallback_text: "已保存的演练记录。", spec: {
root: "text", elements: { text: { type: "Text", children: [], props: { text: "已保存的演练记录。", tone: "neutral" } } },
},
} }] };
expect((await post("artifacts", { session_id: contexts[0], request_id: requestId, report: interactive })).status).toBe(200);
expect(published.at(-1)?.args[2]).toMatchObject({ report: { ui_blocks: interactive.ui_blocks }, sourceSessionId: "client-0" });
const count = published.length;
expect((await post("artifacts", { session_id: contexts[0], request_id: requestId,
report: { ...interactive, evidence_refs: [] } })).status).toBe(400);
expect(published).toHaveLength(count);
} finally {
contexts.forEach(removeRuntimeSessionContext);
await new Promise<void>((resolve) => server.close(() => resolve()));
+5 -2
View File
@@ -1,4 +1,7 @@
import { LOCAL_QUERY_TOOL_IDS } from "../../src/runtime/product-mcp-tools.js";
import {
LOCAL_QUERY_TOOL_IDS,
PRODUCT_MCP_TOOL_IDS,
} from "../../src/runtime/product-mcp-tools.js";
import { describe, expect, it } from "bun:test";
import { type OpenCodeClient as OpencodeClient } from "@opencode/client";
import { createServer } from "node:net";
@@ -222,7 +225,7 @@ describe("resolveEmbeddedOpencodePort", () => {
});
describe("OpencodeRuntimeAdapter.warmup", () => {
const toolIds = [...LOCAL_QUERY_TOOL_IDS, "websearch", "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
const toolIds = [...PRODUCT_MCP_TOOL_IDS, ...LOCAL_QUERY_TOOL_IDS, "websearch", "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
async function createWarmupRuntime(missing: string[] = [], invalidModel?: string, delayedMcp = false,
search: { providers?: readonly string[]; selection?: false | { provider: string }; unavailable?: boolean; extraTools?: readonly string[] } = {}) {
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
+2 -2
View File
@@ -21,8 +21,8 @@ describe("task agent isolation", () => {
expect(config.agents[TASK_READER_AGENT].permissions.map((rule) => rule.action)).toEqual([
"*", ...PRODUCT_MCP_READ_TOOL_IDS,
]);
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(25);
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(35);
expect(PRODUCT_MCP_READ_TOOL_IDS).toHaveLength(42);
expect(PRODUCT_MCP_TOOL_IDS).toHaveLength(67);
expect(PRODUCT_MCP_TOOL_IDS.some((name) => /publish|approve|execute|shell/.test(name))).toBeFalse();
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "bun:test";
import { HistoryCursorError, sessionHistoryPage, sessionMessagePage } from "../../src/sessions/history-page.js";
describe("scoped history pagination", () => {
const records = Array.from({ length: 65 }, (_, i) => ({ sessionId: `session-${String(i).padStart(3, "0")}`,
updatedAt: "2026-10-22T01:00:00Z", title: i === 0 ? "城南压力复核" : `对话 ${i}` }));
it("continues equal timestamps deterministically and searches beyond the first page", () => {
const first = sessionHistoryPage(records, { scope: "owner-project", limit: 30 });
const second = sessionHistoryPage(records, { scope: "owner-project", limit: 30, cursor: first.next_cursor! });
const last = sessionHistoryPage(records, { scope: "owner-project", limit: 30, cursor: second.next_cursor! });
expect(first.sessions).toHaveLength(30);
expect(last.has_more).toBe(false);
expect(new Set([...first.sessions, ...second.sessions, ...last.sessions].map((item) => item.sessionId)).size).toBe(65);
expect(sessionHistoryPage(records, { scope: "owner-project", limit: 30, q: "压力" }).sessions[0]?.sessionId).toBe("session-000");
expect(() => sessionHistoryPage(records, { scope: "another-project", limit: 30, cursor: first.next_cursor! })).toThrow(HistoryCursorError);
expect(() => sessionHistoryPage(records, { scope: "owner-project", q: "压力", limit: 30, cursor: first.next_cursor! })).toThrow(HistoryCursorError);
});
it("pages older messages without truncating the store or moving when messages arrive", () => {
const messages = Array.from({ length: 105 }, (_, i) => ({ id: `message-${i}` }));
const latest = sessionMessagePage(messages, { scope: "owner-session", limit: 40 });
expect(latest.messages).toEqual(messages.slice(65));
messages.push({ id: "message-105" });
const older = sessionMessagePage(messages, { scope: "owner-session", limit: 40, before: latest.message_page.before_cursor! });
expect(older.messages).toEqual(messages.slice(25, 65));
const oldest = sessionMessagePage(messages, { scope: "owner-session", limit: 40, before: older.message_page.before_cursor! });
expect(oldest.messages).toEqual(messages.slice(0, 25));
expect(oldest.message_page.has_more).toBe(false);
expect(messages).toHaveLength(106);
expect(() => sessionMessagePage(messages, { scope: "another-session", limit: 40, before: latest.message_page.before_cursor! })).toThrow(HistoryCursorError);
messages.splice(0, 1);
expect(() => sessionMessagePage(messages, { scope: "owner-session", limit: 40, before: latest.message_page.before_cursor! })).toThrow("history changed");
});
});