3 Commits
Author SHA1 Message Date
jiang 72ebf4d6c1 feat(agent): 完善分析编排与结果传输
Generic Container CI/CD / test-build-publish (push) Failing after 1m20s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m20s
2026-08-26 18:04:51 +08:00
jiang a438433068 refactor(cli)!: align commands with analysis run APIs
Replace legacy scheme commands with run-id based analysis and timeseries queries. Update sensor placement and SCADA routes, and refresh CLI help, tests, and usage guidance.

BREAKING CHANGE: legacy scheme, risk, and valve-close CLI command paths are removed.
2026-08-26 17:28:41 +08:00
jiang 4ec010455b fix(agent): stabilize sandboxed streaming workflows 2026-08-26 12:26:29 +08:00
42 changed files with 1943 additions and 730 deletions
+9 -2
View File
@@ -8,8 +8,13 @@ model: deepseek/deepseek-v4-flash
## 回复要求 ## 回复要求
- 工具执行期间不输出过程说明,全部完成后只回复最终结果 - 工具执行期间不输出过程说明,全部完成后只回复最终结果
- 最终回答必须通过 `final_answer` 提交;调用前必须完成全部业务动作和其他工具,调用后禁止继续调用工具或输出额外文本
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步 - 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
- 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划,并在每个里程碑更新状态;简单问答不创建计划 - 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划;简单问答不创建计划
- `todowrite` 是面向用户的业务任务摘要:每项只描述目标或可验证结果,不出现函数名、脚本/文件名、命令、工具名、参数、内部目录或具体修复实现;这些技术细节仅保留在工具过程信息中
- 任务标题使用简洁的业务语言,例如“准备供水分区所需数据”“计算供水服务范围”“生成并展示分析结果”“整理可复用分析经验”
- 开始工作及每次进入新的业务阶段时调用一次 `activity_update`,用 `title` 概括当前阶段、用 `reason` 说明该阶段为何必要;已有计划时必须通过 `todos` 提交完整计划状态快照,使阶段与任务状态同时更新,不再单独调用 `todowrite` 更新里程碑
- `activity_update` 是过程分组,不是任务清单:活动描述当前正在做的一组动作,`todowrite` 描述整个任务的业务目标与完成状态
## 工作流生命周期 ## 工作流生命周期
@@ -42,9 +47,11 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
**前端工具仅做显示,不返回数据**,不要假设其返回内容。 **前端工具仅做显示,不返回数据**,不要假设其返回内容。
`tjwater_cli.command` 虽然是字符串,但命令空间不是可类推的层级语法。当前会话尚未验证某个完整命令路径和参数时,先调用 `help <命令族或前缀>`;已加载工作流中明确记录且已验证的固定命令可直接使用。禁止根据 `analysis runs` 等已有路径创造其他命令族的同名子路径。收到 `COMMAND_NOT_FOUND` 后只执行返回的 `next_commands` 做命令发现,不得继续猜测近似命令。
## 执行约束 ## 执行约束
1. 每次工具调用必须在 `reason` 字段填写具体理由 1. 普通工具不填写重复的调用理由,具体动作自动归入当前 `activity_update` 活动;切换业务阶段前先更新活动
2. `tjwater-cli` 输出为 JSON`schema_version: tjwater-cli/v1`),`"ok": true` 成功,失败时检查 `error.code` 2. `tjwater-cli` 输出为 JSON`schema_version: tjwater-cli/v1`),`"ok": true` 成功,失败时检查 `error.code`
3. 大结果集禁止完整读取,优先采样/截断/按字段读取 3. 大结果集禁止完整读取,优先采样/截断/按字段读取
4. 避免直接用 `Read``cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout 4. 避免直接用 `Read``cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
+18 -23
View File
@@ -15,7 +15,6 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输
```json ```json
{ {
"reason": "说明调用原因",
"command": "project list", "command": "project list",
"timeout": 120, "timeout": 120,
"store_result": false "store_result": false
@@ -24,7 +23,6 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| `reason` | string | 是 | 调用原因 |
| `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context` | | `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context` |
| `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ | | `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ |
| `store_result` | boolean | 否 | 强制保存到当前对话目录并返回 `data_file.file_path`;分析脚本需要文件输入时设为 true | | `store_result` | boolean | 否 | 强制保存到当前对话目录并返回 `data_file.file_path`;分析脚本需要文件输入时设为 true |
@@ -35,15 +33,18 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输
Agent 通过 `help` 动态发现可用命令,而非依赖硬编码清单。 Agent 通过 `help` 动态发现可用命令,而非依赖硬编码清单。
**重要:命令分为类——触发动作数据获取。** **重要:命令分为类——触发动作、运行/结果查询与时序数据获取。**
- **触发动作**`simulation``analysis`):向服务端发起计算请求,返回任务状态/ID,**不直接返回分析结果** - **触发动作**`simulation`各类 `analysis`):向服务端发起计算请求。
- **数据获取**`data timeseries`):所有计算结果(仿真压力、分析指标等)的唯一数据出口,需在触发动作完成后调用 - **运行/结果查询**`analysis runs`):按 `run_id` 查询运行元数据和非时序结果
- **时序数据获取**`data timeseries`):实时结果或按 `run_id` 查询节点、管道时序。
``` ```
simulation/analysis → 触发计算 → 返回状态/任务ID analysis → 触发计算 → analysis runs list/get/results
run_id
data timeseries → 获取计算结果 data timeseries analysis → 获取元素时序
simulation → 触发实时模拟 → data timeseries realtime → 获取实时结果
``` ```
通过 `help` 发现命令: 通过 `help` 发现命令:
@@ -63,11 +64,11 @@ tjwater-cli help COMMAND → 子命令与参数详情
| 命令族 | 典型子命令 | 用途 | | 命令族 | 典型子命令 | 用途 |
|------|-----------|------| |------|-----------|------|
| `project` | `list`, `db-health` | 项目管理、数据库健康检查 | | `network` | `get-pipe-properties`, `get-all-pipes-properties` | 管网元素查询 |
| `data` | `timeseries realtime links / nodes`, `timeseries scada query` | **时序数据查询**(实时/SCADA),所有分析结果的唯一获取渠道 | | `component` | `option get`, `option schema` | 模型选项和结构查询 |
| `data` | `timeseries realtime`, `timeseries analysis`, `timeseries scada`, `scada` | 实时、分析时序和 SCADA 查询 |
| `simulation` | 通过 `help simulation` 发现 | **触发水力仿真计算**(执行成功返回状态,实际结果需走 `data timeseries` 获取) | | `simulation` | 通过 `help simulation` 发现 | **触发水力仿真计算**(执行成功返回状态,实际结果需走 `data timeseries` 获取) |
| `analysis` | 通过 `help analysis` 发现 | **触发分析计算**(执行成功返回状态,实际结果需走 `data timeseries` 获取) | | `analysis` | `runs`, `sensor-placement` 及各类分析命令 | 触发分析,并按运行 ID 查询元数据与结果 |
| `net` | `list-pipes` | 管网拓扑查询 |
| `help` | (无子命令) | 命令发现入口 | | `help` | (无子命令) | 命令发现入口 |
> 完整命令清单始终以 `tjwater-cli help` 实时输出为准。 > 完整命令清单始终以 `tjwater-cli help` 实时输出为准。
@@ -127,36 +128,33 @@ tjwater-cli help COMMAND → 子命令与参数详情
## 最佳实践 ## 最佳实践
1. **禁止猜测命令** — 执行任何命令前必须先 `tjwater_cli(command="help ...")` 确认命令存在及参数签名,参数均已写在 help 中,禁止凭经验拼写 1. **禁止猜测命令** — 执行任何命令前必须先 `tjwater_cli(command="help ...")` 确认命令存在及参数签名,参数均已写在 help 中,禁止凭经验拼写
2. **reason 必填** — 每次调用必须说明具体理由 2. **阶段分组** — 调用 CLI 前确认当前业务阶段已通过 `activity_update` 建立,同一阶段的多个查询无需重复说明理由
3. **触发后取数据**`simulation`/`analysis` 仅触发计算,结果必须从 `data timeseries` 获取,勿将触发返回的状态信息当作分析结果 3. **按运行 ID 取结果** — 分析完成后先用 `analysis runs list/get/results` 获取 `run_id` 和非时序结果;元素时序再用 `data timeseries analysis` 查询
4. **文件分析** — workflow 脚本需要文件时使用 `store_result=true`,不得从 Bash 直接联网调用 CLI 4. **文件分析** — workflow 脚本需要文件时使用 `store_result=true`,不得从 Bash 直接联网调用 CLI
5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试 5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试
6. **大结果集** — 优先过滤/采样,不要一次性拉取全部数据 6. **大结果集** — 优先过滤/采样,不要一次性拉取全部数据
7. **模拟时长控制**模拟(`simulation`)或方案模拟`--duration` 不宜过长,建议每次仿真时间跨度控制在一小时以内,避免计算耗时过长或结果数据量过大 7. **模拟时长控制**实时模拟或分析运行`--duration` 不宜过长,建议每次仿真时间跨度控制在一小时以内,避免计算耗时过长或结果数据量过大
## 示例 ## 示例
### 查询所有实时节点数据 ### 查询所有实时节点数据
```json ```json
{ {
"reason": "获取最近1小时内全部节点的实时数据",
"command": "data timeseries realtime nodes --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00" "command": "data timeseries realtime nodes --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00"
} }
``` ```
> `data timeseries realtime nodes` 仅接受 `--start-time` / `--end-time`,返回全量节点数据。 > `data timeseries realtime nodes` 仅接受 `--start-time` / `--end-time`,返回全量节点数据。
### 按节点查询方案时序字段 ### 按节点查询分析运行时序字段
```json ```json
{ {
"reason": "查询节点 J-001 最近1小时的压力数据", "command": "data timeseries analysis node-field --run-id 00000000-0000-0000-0000-000000000001 --node J-001 --field pressure --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00"
"command": "data timeseries scheme node-field --node J-001 --field pressure --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00"
} }
``` ```
### 查询 SCADA 时序数据 ### 查询 SCADA 时序数据
```json ```json
{ {
"reason": "查询 SCADA 设备 170490 在指定时间范围的 monitored_value",
"command": "data timeseries scada query --device-id 170490 --field monitored_value --start-time 2026-06-02T00:00:00+08:00 --end-time 2026-06-03T00:00:00+08:00" "command": "data timeseries scada query --device-id 170490 --field monitored_value --start-time 2026-06-02T00:00:00+08:00 --end-time 2026-06-03T00:00:00+08:00"
} }
``` ```
@@ -168,17 +166,14 @@ tjwater-cli help COMMAND → 子命令与参数详情
```json ```json
// step 1: 先尝试获取仿真结果 // step 1: 先尝试获取仿真结果
{ {
"reason": "尝试获取节点 J-001 09:00 时刻的仿真压力",
"command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00" "command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00"
} }
// step 2: 若 step 1 无数据(ok: false 或 data 为空),触发仿真 // step 2: 若 step 1 无数据(ok: false 或 data 为空),触发仿真
{ {
"reason": "无已有仿真结果,触发1小时水力仿真",
"command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 60" "command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 60"
} }
// step 3: 仿真完成后,再次获取结果(同 step 1) // step 3: 仿真完成后,再次获取结果(同 step 1)
{ {
"reason": "获取仿真结果中节点 J-001 09:00 时刻的压力",
"command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00" "command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00"
} }
``` ```
@@ -148,3 +148,7 @@ if length < 0.01 and headloss > 0.5 → "短管高水损,检查是否存在模
- 水头损失百分位阈值(P80/P90)基于**全管网**统计,如果管网上游存在极端水损(如 400m+),会拉高整体 P 值,导致部分中高水损管段被漏判。极端场景下可考虑对水损做分位数裁剪(如排除 >P99.9 的离群值)后再计算 P80/P90。 - 水头损失百分位阈值(P80/P90)基于**全管网**统计,如果管网上游存在极端水损(如 400m+),会拉高整体 P 值,导致部分中高水损管段被漏判。极端场景下可考虑对水损做分位数裁剪(如排除 >P99.9 的离群值)后再计算 P80/P90。
- **setting 字段不可用**`data timeseries realtime links` 返回的 `setting` 值为无效数据,本工作流已移除所有基于 setting 的阀门节流 / 水泵出口判定。若需要此类判定,应通过 `network get-link-properties` 逐条获取属性中的 setting 作为替代。 - **setting 字段不可用**`data timeseries realtime links` 返回的 `setting` 值为无效数据,本工作流已移除所有基于 setting 的阀门节流 / 水泵出口判定。若需要此类判定,应通过 `network get-link-properties` 逐条获取属性中的 setting 作为替代。
- 脚本读取全量 JSON 入内存,峰值内存约 200-300MB,需确保执行环境有足够内存。 - 脚本读取全量 JSON 入内存,峰值内存约 200-300MB,需确保执行环境有足够内存。
## Learned Patterns
- [5cbdaa6bcf4e01c22eb2e544] [2026-08 复验] **schema 适配已固化进脚本**bottleneck_analysis.py 现已直接使用 link_id/node_id 主键与 UTC target-time,无需再手工改码。本次 91,052 管段管网识别出 56 条瓶颈(0.06%),典型特征:100-110mm 小管径串联瓶颈(多条水损值近等差递减、逐段累计,如 7 段链 478128→460635→479635→508436→506699→484919→406224),宜按整链统一扩径;另有 1.4m 短管水损 67m 的模型异常信号(399832/399820),需核查局部阻塞或模型设置。分析后若用户需要改造建议落地,可按"极危...
- [5ba58cd24c9cea84b6ab5861] **数据 schema 实测适配(2026-04 验证)**`data timeseries realtime links` 返回记录的管道主键为 `link_id`(不是 `id`),`data timeseries realtime nodes` 返回记录的节点主键为 `node_id`(不是 `id`),且 `time` 字段为 UTC 格式(如 `2026-04-01T00:00:00+00:00`)。运行 `bottleneck_analysis.py` 前需:① 脚本内将 `r['id']` 改为 `r['link_id']``n['id']` 改为 `n['node_id']`;② `--target-tim...
@@ -3,6 +3,8 @@
水力瓶颈管道综合分析 水力瓶颈管道综合分析
数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议 数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议
注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。 注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。
schema 适配(2026-08 实测):realtime links 主键为 link_idrealtime nodes 主键为 node_id
time 为 UTC 格式,--target-time 需传 UTC 时刻(如北京时间 08:00 对应 00:00+00:00)。
""" """
import json, sys, math, argparse import json, sys, math, argparse
from collections import defaultdict from collections import defaultdict
@@ -38,16 +40,16 @@ def main():
np = load_json(args.node_pressures).get('data', []) np = load_json(args.node_pressures).get('data', [])
np = [n for n in np if n.get('time') == TT] np = [n for n in np if n.get('time') == TT]
node_pressure = {n['id']: n.get('pressure', n.get('value',0)) for n in np} node_pressure = {n['node_id']: n.get('pressure', n.get('value',0)) for n in np}
print(f" Pipes: {len(props)}, Realtime: {len(rt)}, Node pressures: {len(node_pressure)}", file=sys.stderr) print(f" Pipes: {len(props)}, Realtime: {len(rt)}, Node pressures: {len(node_pressure)}", file=sys.stderr)
# 2. Merge # 2. Merge
print("[2/5] Merging...", file=sys.stderr) print("[2/5] Merging...", file=sys.stderr)
merged = [] merged = []
for r in rt: for r in rt:
pid = r['id']; prop = pipe_map.get(pid) pid = r['link_id']; prop = pipe_map.get(pid)
if prop: if prop:
merged.append({**prop, **r, '_prop_id': prop['id'], '_rt_id': r['id']}) merged.append({**prop, **r, '_prop_id': prop['id'], '_rt_id': r['link_id']})
print(f" Merged: {len(merged)}", file=sys.stderr) print(f" Merged: {len(merged)}", file=sys.stderr)
# 3. Score # 3. Score
@@ -144,3 +144,6 @@ show_chart(title="各水源分区节点数/压力对比", chart_type="bar", ...)
- **水库顺序敏感**:多源 BFS 中先遍历到的水库优先分配,不同水库启动顺序可能影响边界区域分配结果 - **水库顺序敏感**:多源 BFS 中先遍历到的水库优先分配,不同水库启动顺序可能影响边界区域分配结果
- **单时刻快照**:分区仅反映目标时刻的水力工况,不同时段的泵站启停、阀门切换可能导致分区边界变化 - **单时刻快照**:分区仅反映目标时刻的水力工况,不同时段的泵站启停、阀门切换可能导致分区边界变化
- **零流量阈值**`1e-6` 阈值过滤极低流量管段,若管网有长期小流量管段可能漏判方向 - **零流量阈值**`1e-6` 阈值过滤极低流量管段,若管网有长期小流量管段可能漏判方向
## Learned Patterns
- [6614a914a8f7dcc2fc34c1ba] 实时数据时间匹配必须用 norm_time 归一化为 UTC 再比较(数据 time 为 UTC 格式如 2026-06-03T00:00:00+00:00target-time 传 +08:00 会因字符串不等而筛出 0 条);links/nodes 记录主键字段为 link_id/node_id(不是 id)。执行分区前先确认目标时刻存在实时数据,可用 `data timeseries realtime simulation-by-id-time` 探测;数据可能只覆盖某几天(本模型覆盖 2026-06-03 附近,2026-04 与 2026-08 均无数据)。
@@ -29,6 +29,13 @@ COLORS = [
"rgba(204,153,204,0.7)", "rgba(153,153,153,0.7)" "rgba(204,153,204,0.7)", "rgba(153,153,153,0.7)"
] ]
def norm_time(t):
"""把 ISO8601 字符串归一化为 UTC 的 ISO 字符串,用于跨时区比较"""
from datetime import datetime
return datetime.fromisoformat(t.replace("Z", "+00:00")).astimezone(
__import__("datetime").timezone.utc).isoformat()
def load_json(path, label): def load_json(path, label):
print(f"Loading {label}...", file=sys.stderr) print(f"Loading {label}...", file=sys.stderr)
with open(path) as f: with open(path) as f:
@@ -63,11 +70,12 @@ def main():
# --- Step 3: Load link flow at target time --- # --- Step 3: Load link flow at target time ---
ldata = load_json(args.links, "link flows")["data"] ldata = load_json(args.links, "link flows")["data"]
target_links = [l for l in ldata if l["time"] == args.target_time] target_ts = norm_time(args.target_time)
target_links = [l for l in ldata if norm_time(l["time"]) == target_ts]
flow_direction = {} flow_direction = {}
pipe_flow = {} pipe_flow = {}
for l in target_links: for l in target_links:
lid = l["id"] lid = l.get("link_id") or l.get("id")
flow_val = l["flow"] flow_val = l["flow"]
pipe_flow[lid] = abs(flow_val) pipe_flow[lid] = abs(flow_val)
if lid in pipe_topology: if lid in pipe_topology:
@@ -81,11 +89,11 @@ def main():
# --- Step 4: Load node data at target time --- # --- Step 4: Load node data at target time ---
ndata = load_json(args.nodes, "node data")["data"] ndata = load_json(args.nodes, "node data")["data"]
target_nodes = [n for n in ndata if n["time"] == args.target_time] target_nodes = [n for n in ndata if norm_time(n["time"]) == target_ts]
node_pressure = {} node_pressure = {}
node_demand = {} node_demand = {}
for n in target_nodes: for n in target_nodes:
nid = n["id"] nid = n.get("node_id") or n.get("id")
node_pressure[nid] = n.get("pressure", 0) node_pressure[nid] = n.get("pressure", 0)
node_demand[nid] = n.get("actual_demand", 0) node_demand[nid] = n.get("actual_demand", 0)
print(f" {len(target_nodes)} nodes", file=sys.stderr) print(f" {len(target_nodes)} nodes", file=sys.stderr)
+37
View File
@@ -0,0 +1,37 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"开始一个新的业务活动阶段。仅在语义阶段发生变化时调用一次,用 title 概括阶段,用 reason 说明本阶段为何必要;同一阶段内的多个工具动作不要重复调用。该工具只更新用户可见的过程信息,不执行外部操作。",
args: {
title: tool.schema
.string()
.min(1)
.describe("面向用户的简短业务阶段标题,不包含工具名、函数名、文件名或命令。"),
reason: tool.schema
.string()
.min(1)
.describe("本阶段对完成用户目标的必要性,使用一句简洁的自然语言。"),
todos: tool.schema
.array(
tool.schema.object({
id: tool.schema.string().optional(),
content: tool.schema.string().min(1),
status: tool.schema.enum([
"pending",
"in_progress",
"completed",
"cancelled",
]),
priority: tool.schema.enum(["low", "medium", "high"]).optional(),
}),
)
.optional()
.describe(
"已有任务计划时提交完整状态快照,使本阶段与任务状态在同一次更新中生效。",
),
},
async execute() {
return "活动阶段已更新。";
},
});
-5
View File
@@ -4,11 +4,6 @@ export default tool({
description: description:
"在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。", "在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。",
args: { args: {
reason: tool.schema
.string()
.describe(
"Why this style action is needed for the current user request.",
),
layer_id: tool.schema layer_id: tool.schema
.enum(["junctions", "pipes"]) .enum(["junctions", "pipes"])
.describe("Target layer id. Must be exactly 'junctions' or 'pipes'."), .describe("Target layer id. Must be exactly 'junctions' or 'pipes'."),
+15
View File
@@ -0,0 +1,15 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"提交直接展示给用户的最终回答。只能在全部业务动作和其他工具调用完成后调用一次;调用后不得继续调用任何工具或输出额外文本。",
args: {
answer: tool.schema
.string()
.min(1)
.describe("直接展示给用户的完整最终回答,使用简体中文和 Markdown。"),
},
async execute() {
return "最终回答已提交。";
},
});
-3
View File
@@ -8,9 +8,6 @@ export default tool({
description: description:
"调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。", "调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。",
args: { args: {
reason: tool.schema
.string()
.describe("Why geocoding is required for the current user request."),
keyword: tool.schema keyword: tool.schema
.string() .string()
.describe("Address or place name to geocode, such as 北京市人民政府."), .describe("Address or place name to geocode, such as 北京市人民政府."),
-5
View File
@@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: "在前端地图上定位并高亮指定的管网要素。", description: "在前端地图上定位并高亮指定的管网要素。",
args: { args: {
reason: tool.schema
.string()
.describe(
"Why this map positioning action is needed for the user request.",
),
ids: tool.schema ids: tool.schema
.array(tool.schema.string()) .array(tool.schema.string())
.describe("Feature ids to locate."), .describe("Feature ids to locate."),
-3
View File
@@ -11,9 +11,6 @@ export default tool({
action: tool.schema action: tool.schema
.enum(["add", "list", "replace", "remove"]) .enum(["add", "list", "replace", "remove"])
.describe("Memory operation to perform."), .describe("Memory operation to perform."),
reason: tool.schema
.string()
.describe("Why this memory should be persisted for future requests."),
scope: tool.schema scope: tool.schema
.string() .string()
.describe( .describe(
-5
View File
@@ -4,11 +4,6 @@ export default tool({
description: description:
"在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIRlocation.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。", "在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIRlocation.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。",
args: { args: {
reason: tool.schema
.string()
.describe(
"Why this junction rendering action is needed for the user request.",
),
render_ref: tool.schema render_ref: tool.schema
.string() .string()
.describe( .describe(
-3
View File
@@ -8,9 +8,6 @@ export default tool({
description: description:
"搜索当前用户和项目范围内的历史会话 transcript。适合回忆过去讨论过的案例、约束和结论,避免把一次性案例写入 memory。", "搜索当前用户和项目范围内的历史会话 transcript。适合回忆过去讨论过的案例、约束和结论,避免把一次性案例写入 memory。",
args: { args: {
reason: tool.schema
.string()
.describe("Why prior session history is needed for the current request."),
query: tool.schema query: tool.schema
.string() .string()
.describe("What to search for in prior session history."), .describe("What to search for in prior session history."),
-3
View File
@@ -4,9 +4,6 @@ export default tool({
description: description:
"在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。", "在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。",
args: { args: {
reason: tool.schema
.string()
.describe("Why this chart should be rendered for the user request."),
title: tool.schema.string().optional().describe("Chart title."), title: tool.schema.string().optional().describe("Chart title."),
chart_type: tool.schema chart_type: tool.schema
.enum(["line", "bar", "pie"]) .enum(["line", "bar", "pie"])
-3
View File
@@ -21,9 +21,6 @@ export default tool({
"remove_script", "remove_script",
]) ])
.describe("Skill maintenance operation."), .describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe("Why this skill maintenance action is justified for future reuse."),
skill_path: tool.schema skill_path: tool.schema
.string() .string()
.describe( .describe(
-5
View File
@@ -23,11 +23,6 @@ export default tool({
description: description:
"导入当前对话工作目录下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } }location.file_path 必须与传入的绝对路径完全一致。只接受当前对话工作目录内的真实文件,不接受其他对话目录、目录外路径或指向目录外的符号链接。", "导入当前对话工作目录下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } }location.file_path 必须与传入的绝对路径完全一致。只接受当前对话工作目录内的真实文件,不接受其他对话目录、目录外路径或指向目录外的符号链接。",
args: { args: {
reason: tool.schema
.string()
.describe(
"为何需要将此本地渲染数据持久化为 render_ref,以便后续通过 render_junctions 渲染到前端。",
),
file_path: tool.schema file_path: tool.schema
.string() .string()
.optional() .optional()
+2 -6
View File
@@ -6,15 +6,12 @@ const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({ export default tool({
description: description:
"通过本地 Agent 桥接调用 tjwater-cli 命令访问 TJWater 后端服务。提供 CLI 子命令和参数。", "通过本地 Agent 桥接调用 tjwater-cli命令路径和参数不是可自由拼接的语法;若当前会话或已加载工作流没有经过验证的完整命令,必须先调用 help 或 help <命令族>,再从返回的 command、usage 和 options 中选择。",
args: { args: {
reason: tool.schema
.string()
.describe("Why this tool call is required for the current user request."),
command: tool.schema command: tool.schema
.string() .string()
.describe( .describe(
"tjwater-cli 子命令,不含二进制路径。示例:'data scheme list'、'data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00'", "不含二进制路径。只可使用 help 响应或已验证工作流中出现的完整命令路径和参数,禁止类推不同命令族的层级,例如 analysis runs list 存在不代表 simulation runs list 存在。无法确认时调用 'help'、'help simulation' 或相应前缀的 help。",
), ),
timeout: tool.schema timeout: tool.schema
.number() .number()
@@ -38,7 +35,6 @@ export default tool({
}, },
body: JSON.stringify({ body: JSON.stringify({
session_id: context.sessionID, session_id: context.sessionID,
reason: args.reason,
command: args.command, command: args.command,
store_result: args.store_result, store_result: args.store_result,
timeout: args.timeout, timeout: args.timeout,
-5
View File
@@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: "为选定的管网要素打开前端的历史记录或计算结果面板。", description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
args: { args: {
reason: tool.schema
.string()
.describe(
"Why this history panel should be opened for the current task.",
),
feature_infos: tool.schema feature_infos: tool.schema
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()])) .array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
.describe("List of [id, type] pairs."), .describe("List of [id, type] pairs."),
-3
View File
@@ -3,9 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: "打开前端的 SCADA 监测数据历史面板。", description: "打开前端的 SCADA 监测数据历史面板。",
args: { args: {
reason: tool.schema
.string()
.describe("Why SCADA panel interaction is required for this request."),
device_ids: tool.schema device_ids: tool.schema
.array(tool.schema.string()) .array(tool.schema.string())
.optional() .optional()
-3
View File
@@ -8,9 +8,6 @@ export default tool({
description: description:
"调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。", "调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。",
args: { args: {
reason: tool.schema
.string()
.describe("Why web search is required for the current user request."),
query: tool.schema.string().describe("Search query text."), query: tool.schema.string().describe("Search query text."),
freshness: tool.schema freshness: tool.schema
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"]) .enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
-3
View File
@@ -4,9 +4,6 @@ export default tool({
description: description:
"在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。", "在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。",
args: { args: {
reason: tool.schema
.string()
.describe("Why this map zoom action is needed for the current request."),
x: tool.schema x: tool.schema
.number() .number()
.describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."), .describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."),
+29
View File
@@ -48,6 +48,31 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
(getent passwd 10001 >/dev/null || useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin tjwater-sandbox) && \ (getent passwd 10001 >/dev/null || useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin tjwater-sandbox) && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
FROM base AS opencode-builder
ARG OPENCODE_SOURCE_COMMIT=a105350812f05f914c768e468559dbd6bd508d8e
ARG OPENCODE_PATCH_VERSION=1.18.13-tjwater.1
WORKDIR /tmp/opencode-src
RUN git init . && \
git remote add origin https://github.com/anomalyco/opencode.git && \
git fetch --depth 1 origin "$OPENCODE_SOURCE_COMMIT" && \
git checkout --detach FETCH_HEAD
COPY patches/opencode-1.18.13-message-phase.patch /tmp/opencode-message-phase.patch
RUN git apply --check /tmp/opencode-message-phase.patch && \
git apply /tmp/opencode-message-phase.patch && \
bun install --frozen-lockfile --ignore-scripts && \
bun test --cwd packages/llm test/provider/openai-responses.test.ts && \
OPENCODE_VERSION="$OPENCODE_PATCH_VERSION" bun run --cwd packages/opencode build --single --skip-install --skip-embed-web-ui && \
case "$(uname -m)" in \
x86_64) binary=packages/opencode/dist/opencode-linux-x64/bin/opencode ;; \
aarch64|arm64) binary=packages/opencode/dist/opencode-linux-arm64/bin/opencode ;; \
*) echo "unsupported OpenCode build architecture: $(uname -m)" >&2; exit 1 ;; \
esac && \
install -D -m 0755 "$binary" /out/opencode
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
@@ -70,6 +95,7 @@ COPY .opencode ./.opencode
RUN bun run check RUN bun run check
FROM build AS test FROM build AS test
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
RUN apt-get update && apt-get install -y --no-install-recommends nodejs && \ RUN apt-get update && apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY contracts ./contracts COPY contracts ./contracts
@@ -81,6 +107,9 @@ RUN bun run test:ci
FROM build AS runner FROM build AS runner
WORKDIR /app WORKDIR /app
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
RUN opencode --version
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=8787 ENV PORT=8787
+12
View File
@@ -76,6 +76,18 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000
当前仅支持 Embedded 模式,不支持连接外部 OpenCode server。 当前仅支持 Embedded 模式,不支持连接外部 OpenCode server。
生产镜像会从固定的 OpenCode `v1.18.13` 源码提交构建 CLI,并应用仓库内的
`patches/opencode-1.18.13-message-phase.patch`。该补丁只透传 OpenAI Responses
输出项已有的 `commentary` / `final_answer` phase,不改变模型行为:过程文本继续写入
可折叠的 Agent 过程卡,`final_answer` 到达后立即按增量写入正式回答。未提供 phase 的
DeepSeek 模型启用 OpenCode 1.18.13 内置的 JSON Schema 最终回答工具
`StructuredOutput`:模型必须先完成全部分析和工具调用,再把完整回答写入 `answer`
该工具成功后 OpenCode 会直接结束运行循环,不再进入下一轮模型或工具调用。Agent 将
`answer` 映射为正式文本推送;若模型未按协议调用该工具,仍保留会话 idle 后提取最终
文本的兼容兜底。
本地直接运行 `bun --watch src/server.ts` 时,`PATH` 中也需要放置应用了同一补丁的
`opencode` CLI,才能启用 phase 驱动的正式文本流式输出。
## 认证续期与学习工具 ## 认证续期与学习工具
后端工具调用遇到即将过期的 access token 或首次 `401` 时,Agent 会通过当前 SSE 流发送 `credential_refresh_required`。前端使用服务端保存的 Keycloak refresh token 强制换取新 access token,再调用 `POST /api/v1/agent/sessions/{session_id}/credential-refreshes` 唤醒原工具调用。等待上限为 30 秒,同一会话的并发请求合并为一次续期,原调用最多重试一次;`403` 不触发续期。 后端工具调用遇到即将过期的 access token 或首次 `401` 时,Agent 会通过当前 SSE 流发送 `credential_refresh_required`。前端使用服务端保存的 Keycloak refresh token 强制换取新 access token,再调用 `POST /api/v1/agent/sessions/{session_id}/credential-refreshes` 唤醒原工具调用。等待上限为 30 秒,同一会话的并发请求合并为一次续期,原调用最多重试一次;`403` 不触发续期。
+38 -55
View File
@@ -1,10 +1,9 @@
import { CliError } from "../core/errors.js"; import { CliError } from "../core/errors.js";
import { emitApi, requestJson } from "../core/http.js"; import { emitApi } from "../core/http.js";
import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js"; import { assignDatasetKeys, parseBurstFile, parseValveSettingFile } from "../core/files.js";
import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js"; import { optionalNumber, optionalString, optionalStringArray, parseOptions, requiredNumber, requiredString, validateChoice } from "../core/options.js";
import { resolveScheme } from "../core/runtime.js"; import { resolveScheme } from "../core/runtime.js";
import { parseTime } from "../core/time.js"; import { parseTime } from "../core/time.js";
import { success } from "../core/output.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js";
function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> { function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
@@ -22,30 +21,13 @@ function analysisBurst(ctx: RuntimeContext, argv: string[]): Promise<void> {
scheme_name: schemeName, scheme_name: schemeName,
}, },
requireProject: true, requireProject: true,
}, [`tjwater-cli data scheme get --name ${schemeName}`, "tjwater-cli data scheme list"]); }, ["tjwater-cli analysis runs list"]);
} }
function analysisValve(ctx: RuntimeContext, argv: string[]): Promise<void> { function analysisValveIsolation(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { valve: "repeat", element: "repeat", "disabled-valve": "repeat", duration: "integer" }); const { values } = parseOptions(argv, { element: "repeat", "disabled-valve": "repeat" });
const mode = validateChoice(requiredString(values, "mode"), ["close", "isolation"] as const, "--mode");
if (mode === "close") {
const valves = optionalStringArray(values, "valve");
const startTime = optionalString(values, "start-time");
if (!startTime || !valves) throw new CliError("CLI 参数错误", "INVALID_VALVE_CLOSE_ARGS", "close mode requires --start-time and at least one --valve", 2);
return emitApi(ctx, "阀门关闭分析执行成功", {
method: "POST",
path: "/valve-isolation-analyses",
params: {
start_time: parseTime(startTime, "--start-time"),
valves,
duration: optionalNumber(values, "duration") || 900,
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
},
requireProject: true,
});
}
const elements = optionalStringArray(values, "element"); const elements = optionalStringArray(values, "element");
if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "isolation mode requires at least one --element", 2); if (!elements) throw new CliError("CLI 参数错误", "INVALID_VALVE_ISOLATION_ARGS", "at least one --element is required", 2);
return emitApi(ctx, "阀门隔离分析执行成功", { return emitApi(ctx, "阀门隔离分析执行成功", {
method: "POST", method: "POST",
path: "/valve-isolation-analyses", path: "/valve-isolation-analyses",
@@ -97,14 +79,16 @@ function analysisContaminant(ctx: RuntimeContext, argv: string[]): Promise<void>
return emitApi(ctx, "污染物模拟执行成功", { method: "POST", path: "/contaminant-simulations", params, requireProject: true }); return emitApi(ctx, "污染物模拟执行成功", { method: "POST", path: "/contaminant-simulations", params, requireProject: true });
} }
function sensorKmeans(ctx: RuntimeContext, argv: string[]): Promise<void> { function sensorPlacementRun(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" }); const { values } = parseOptions(argv, { count: "integer", "min-diameter": "integer" });
return emitApi(ctx, "传感器选址执行成功", { return emitApi(ctx, "传感器选址执行成功", {
method: "POST", method: "POST",
path: "/pressure-sensor-placement-kmeans", path: "/sensor-placement-runs",
body: { body: {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), run_name: requiredString(values, "run-name"),
sensor_number: requiredNumber(values, "count"), sensor_type: "pressure",
method: validateChoice(requiredString(values, "method"), ["sensitivity", "kmeans"] as const, "--method"),
sensor_count: requiredNumber(values, "count"),
min_diameter: optionalNumber(values, "min-diameter") || 0, min_diameter: optionalNumber(values, "min-diameter") || 0,
}, },
requireProject: true, requireProject: true,
@@ -125,14 +109,25 @@ function schemeAnalysis(ctx: RuntimeContext, argv: string[], summary: string, pa
}); });
} }
function schemeList(ctx: RuntimeContext, summary: string, schemeType: string): Promise<void> { function analysisRunGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
return emitApi(ctx, summary, { method: "GET", path: "/schemes", params: { scheme_type: schemeType }, requireProject: true }); const { values } = parseOptions(argv);
const runId = requiredString(values, "run-id");
return emitApi(ctx, "读取分析运行成功", {
method: "GET",
path: `/analysis/runs/${encodeURIComponent(runId)}`,
requireProject: true,
});
} }
function schemeGet(ctx: RuntimeContext, argv: string[], summary: string, schemeType: string): Promise<void> { function analysisRunResults(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { positionals } = parseOptions(argv); const { values } = parseOptions(argv);
if (!positionals[0]) throw new CliError("CLI 参数错误", "MISSING_ARGUMENT", "Missing argument 'SCHEME_NAME'", 2); const runId = requiredString(values, "run-id");
return emitApi(ctx, summary, { method: "GET", path: `/schemes/${positionals[0]}`, params: { scheme_type: schemeType }, requireProject: true }); return emitApi(ctx, "读取分析结果成功", {
method: "GET",
path: `/analysis/runs/${encodeURIComponent(runId)}/results`,
params: { result_type: optionalString(values, "result-type") },
requireProject: true,
});
} }
function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> { function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
@@ -156,34 +151,22 @@ function burstLocation(ctx: RuntimeContext, argv: string[]): Promise<void> {
return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-locations", body, requireProject: true }); return emitApi(ctx, "爆管定位执行成功", { method: "POST", path: "/burst-locations", body, requireProject: true });
} }
function riskPipe(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
const { values } = parseOptions(argv);
return emitApi(ctx, summary, { method: "GET", path, params: { pipe_id: requiredString(values, "pipe") }, requireProject: true });
}
async function riskNetwork(ctx: RuntimeContext): Promise<void> {
const [probabilities, a] = await requestJson(ctx, { method: "GET", path: "/network-pipe-risk-probability-nows", requireProject: true });
const [geometries, b] = await requestJson(ctx, { method: "GET", path: "/pipes/risk-probability-geometries", requireProject: true });
success("读取全网风险成功", { probabilities, geometries }, ctx, a + b);
}
export const analysisHandlers: HandlerMap = { export const analysisHandlers: HandlerMap = {
"analysis burst": analysisBurst, "analysis burst": analysisBurst,
"analysis valve": analysisValve, "analysis valve isolation": analysisValveIsolation,
"analysis flushing": analysisFlushing, "analysis flushing": analysisFlushing,
"analysis age": analysisAge, "analysis age": analysisAge,
"analysis contaminant": analysisContaminant, "analysis contaminant": analysisContaminant,
"analysis sensor-placement kmeans": sensorKmeans, "analysis sensor-placement run": sensorPlacementRun,
"analysis sensor-placement list": (ctx) => emitApi(ctx, "读取传感器选址运行列表成功", { method: "GET", path: "/sensor-placement-runs", requireProject: true }),
"analysis sensor-placement get": (ctx, argv) => {
const { values } = parseOptions(argv);
return emitApi(ctx, "读取传感器选址运行成功", { method: "GET", path: `/sensor-placement-runs/${encodeURIComponent(requiredString(values, "run-id"))}`, requireProject: true });
},
"analysis runs list": (ctx) => emitApi(ctx, "读取分析运行列表成功", { method: "GET", path: "/analysis/runs", requireProject: true }),
"analysis runs get": analysisRunGet,
"analysis runs results": analysisRunResults,
"analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage-identifications", "scada_start", "scada_end"), "analysis leakage identify": (ctx, argv) => schemeAnalysis(ctx, argv, "漏损识别执行成功", "/leakage-identifications", "scada_start", "scada_end"),
"analysis leakage schemes list": (ctx) => schemeList(ctx, "读取漏损方案列表成功", "dma_leak_identification"),
"analysis leakage schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取漏损方案详情成功", "dma_leak_identification"),
"analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detections", "scada_start", "scada_end"), "analysis burst-detection detect": (ctx, argv) => schemeAnalysis(ctx, argv, "爆管检测执行成功", "/burst-detections", "scada_start", "scada_end"),
"analysis burst-detection schemes list": (ctx) => schemeList(ctx, "读取爆管检测方案列表成功", "burst_detection"),
"analysis burst-detection schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管检测方案详情成功", "burst_detection"),
"analysis burst-location locate": burstLocation, "analysis burst-location locate": burstLocation,
"analysis burst-location schemes list": (ctx) => schemeList(ctx, "读取爆管定位方案列表成功", "burst_location"),
"analysis burst-location schemes get": (ctx, argv) => schemeGet(ctx, argv, "读取爆管定位方案详情成功", "burst_location"),
"analysis risk pipe-now": (ctx, argv) => riskPipe(ctx, argv, "读取当前管道风险成功", "/pipes/risk-probability-now"),
"analysis risk pipe-history": (ctx, argv) => riskPipe(ctx, argv, "读取历史管道风险成功", "/pipes/risk-probability"),
"analysis risk network": riskNetwork,
}; };
+36 -57
View File
@@ -1,8 +1,8 @@
import { SCADA_FIELDS, type ElementType } from "../core/constants.js"; import { SCADA_FIELDS, type ElementType } from "../core/constants.js";
import { CliError } from "../core/errors.js"; import { CliError } from "../core/errors.js";
import { emitApi } from "../core/http.js"; import { emitApi, requestAllPages } from "../core/http.js";
import { fieldsFor, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js"; import { fieldsFor, optionalNumber, optionalString, parseOptions, requiredString, requiredStringArray, validateChoice } from "../core/options.js";
import { resolveScheme } from "../core/runtime.js"; import { success } from "../core/output.js";
import { parseTime } from "../core/time.js"; import { parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js";
@@ -42,14 +42,17 @@ function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<vo
}); });
} }
function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> { function analysisSeries(ctx: RuntimeContext, argv: string[], type: ElementType): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案管道数据成功", { const runId = requiredString(values, "run-id");
const idOption = type === "pipe" ? "link" : "node";
const elementId = requiredString(values, idOption);
const elementPath = type === "pipe" ? "links" : "nodes";
return emitApi(ctx, type === "pipe" ? "读取分析管道字段成功" : "读取分析节点字段成功", {
method: "GET", method: "GET",
path: "/timeseries/schemes/links", path: `/timeseries/analysis/runs/${encodeURIComponent(runId)}/${elementPath}/${encodeURIComponent(elementId)}`,
params: { params: {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), field: validateChoice(requiredString(values, "field"), fieldsFor(type), "--field"),
scheme_type: optionalString(values, "scheme-type") || "simulation",
start_time: parseTime(requiredString(values, "start-time"), "--start-time"), start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"), end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
}, },
@@ -57,40 +60,21 @@ function schemeLinks(ctx: RuntimeContext, argv: string[]): Promise<void> {
}); });
} }
function schemeNodeField(ctx: RuntimeContext, argv: string[]): Promise<void> { function analysisValues(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
return emitApi(ctx, "读取方案节点字段成功", { const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
return emitApi(ctx, "读取分析运行指定时刻结果成功", {
method: "GET", method: "GET",
path: `/timeseries/schemes/nodes/${requiredString(values, "node")}/field`, path: `/timeseries/analysis/runs/${encodeURIComponent(requiredString(values, "run-id"))}/values`,
params: { params: {
field: validateChoice(requiredString(values, "field"), fieldsFor("junction"), "--field"), result_time: parseTime(requiredString(values, "time"), "--time"),
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), element_type: backendElementType(type),
scheme_type: optionalString(values, "scheme-type") || "simulation", field: validateChoice(requiredString(values, "field"), fieldsFor(type), "--field"),
start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
}, },
requireProject: true, requireProject: true,
}); });
} }
function schemeSimulation(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv);
const query = validateChoice(requiredString(values, "query"), ["by-id-time", "by-scheme-time-property"] as const, "--query");
const type = validateChoice(optionalString(values, "type") || "pipe", ["pipe", "junction"] as const, "--type") as ElementType;
const params: Record<string, unknown> = {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation",
query_time: parseTime(requiredString(values, "time"), "--time"),
type: backendElementType(type),
};
if (query === "by-id-time") {
params.id = requiredString(values, "id");
return emitApi(ctx, "读取方案单点模拟数据成功", { method: "GET", path: "/timeseries/schemes/simulation-results", params, requireProject: true });
}
params.property = validateChoice(requiredString(values, "property"), fieldsFor(type), "--property");
return emitApi(ctx, "读取方案属性聚合数据成功", { method: "GET", path: "/timeseries/schemes/records", params, requireProject: true });
}
function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> { function scadaQuery(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv, { "device-id": "repeat" }); const { values } = parseOptions(argv, { "device-id": "repeat" });
const params: Record<string, unknown> = { const params: Record<string, unknown> = {
@@ -110,8 +94,8 @@ function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
start_time: parseTime(requiredString(values, "start-time"), "--start-time"), start_time: parseTime(requiredString(values, "start-time"), "--start-time"),
end_time: parseTime(requiredString(values, "end-time"), "--end-time"), end_time: parseTime(requiredString(values, "end-time"), "--end-time"),
}; };
const schemeName = resolveScheme(ctx, optionalString(values, "scheme")); const runId = optionalString(values, "run-id");
if (schemeName) Object.assign(params, { scheme_name: schemeName, scheme_type: optionalString(values, "scheme-type") || "simulation" }); if (runId && kind !== "element-scada") params.run_id = runId;
if (kind === "scada-simulation") params.device_ids = requiredStringArray(values, "feature").join(","); if (kind === "scada-simulation") params.device_ids = requiredStringArray(values, "feature").join(",");
else if (kind === "element-simulation") params.feature_infos = requiredStringArray(values, "feature").join(","); else if (kind === "element-simulation") params.feature_infos = requiredStringArray(values, "feature").join(",");
else { else {
@@ -130,31 +114,28 @@ function composite(ctx: RuntimeContext, argv: string[]): Promise<void> {
function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> { function pipelineHealth(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
requiredString(values, "pipe");
requiredString(values, "start-time");
return emitApi(ctx, "读取管道健康预测成功", { return emitApi(ctx, "读取管道健康预测成功", {
method: "GET", method: "GET",
path: "/pipeline-health-predictions", path: "/pipeline-health-predictions",
params: { query_time: parseTime(requiredString(values, "end-time"), "--end-time") }, params: { query_time: parseTime(requiredString(values, "time"), "--time") },
requireProject: true, requireProject: true,
}); });
} }
function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> { function dataScadaGet(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind"); return emitApi(ctx, "读取 SCADA 设备成功", { method: "GET", path: "/scada-devices/detail", params: { device_id: requiredString(values, "device-id") }, requireProject: true });
return emitApi(ctx, "读取 SCADA 数据成功", { method: "GET", path: "/scada-info/detail", params: { id: requiredString(values, "id") }, requireProject: true });
} }
function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> { async function dataScadaList(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv, { "page-size": "integer" });
validateChoice(requiredString(values, "kind"), ["info"] as const, "--kind"); const pageSize = Math.min(1000, Math.max(1, optionalNumber(values, "page-size") ?? 1000));
return emitApi(ctx, "读取 SCADA 列表成功", { method: "GET", path: "/scada-info", requireProject: true }); const [data, durationMs] = await requestAllPages(
} ctx,
{ method: "GET", path: "/scada-devices", requireProject: true },
function dataSchemeGet(ctx: RuntimeContext, argv: string[]): Promise<void> { pageSize,
const { values } = parseOptions(argv); );
return emitApi(ctx, "读取方案成功", { method: "GET", path: "/schemes/detail", params: { schema_name: requiredString(values, "name") }, requireProject: true }); success("读取 SCADA 设备列表成功", data, ctx, durationMs);
} }
export const dataHandlers: HandlerMap = { export const dataHandlers: HandlerMap = {
@@ -162,15 +143,13 @@ export const dataHandlers: HandlerMap = {
"data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/timeseries/realtime/nodes"), "data timeseries realtime nodes": (ctx, argv) => rangeGet(ctx, argv, "读取实时节点数据成功", "/timeseries/realtime/nodes"),
"data timeseries realtime simulation-by-id-time": realtimeByIdTime, "data timeseries realtime simulation-by-id-time": realtimeByIdTime,
"data timeseries realtime simulation-by-time-property": realtimeByTimeProperty, "data timeseries realtime simulation-by-time-property": realtimeByTimeProperty,
"data timeseries scheme links": schemeLinks, "data timeseries analysis link-field": (ctx, argv) => analysisSeries(ctx, argv, "pipe"),
"data timeseries scheme node-field": schemeNodeField, "data timeseries analysis node-field": (ctx, argv) => analysisSeries(ctx, argv, "junction"),
"data timeseries scheme simulation": schemeSimulation, "data timeseries analysis values": analysisValues,
"data timeseries scada query": scadaQuery, "data timeseries scada query": scadaQuery,
"data timeseries composite": composite, "data timeseries composite": composite,
"data timeseries composite pipeline-health": pipelineHealth, "data pipeline-health": pipelineHealth,
"data scada get": dataScadaGet, "data scada get": dataScadaGet,
"data scada list": dataScadaList, "data scada list": dataScadaList,
"data scheme schema": (ctx) => emitApi(ctx, "读取方案 schema 成功", { method: "GET", path: "/network-schemas/scheme", requireProject: true }), "data scada schema": (ctx) => emitApi(ctx, "读取 SCADA 设备 schema 成功", { method: "GET", path: "/network-schemas/scada-device", requireProject: true }),
"data scheme get": dataSchemeGet,
"data scheme list": (ctx) => emitApi(ctx, "读取方案列表成功", { method: "GET", path: "/schemes", requireProject: true }),
}; };
+21 -31
View File
@@ -7,25 +7,21 @@ export const GROUP_SUMMARIES: Record<string, string> = {
"component option": "组件选项查询命令。", "component option": "组件选项查询命令。",
simulation: "模拟运行与调度相关命令。", simulation: "模拟运行与调度相关命令。",
analysis: "分析计算与诊断相关命令。", analysis: "分析计算与诊断相关命令。",
"analysis runs": "分析运行及结果查询命令。",
"analysis leakage": "漏损分析相关命令。", "analysis leakage": "漏损分析相关命令。",
"analysis leakage schemes": "漏损方案查询命令。",
"analysis burst-detection": "爆管检测相关命令。", "analysis burst-detection": "爆管检测相关命令。",
"analysis burst-detection schemes": "爆管检测方案查询命令。",
"analysis burst-location": "爆管定位相关命令。", "analysis burst-location": "爆管定位相关命令。",
"analysis burst-location schemes": "爆管定位方案查询命令。",
"analysis risk": "风险分析相关命令。",
"analysis sensor-placement": "传感器选址相关命令。", "analysis sensor-placement": "传感器选址相关命令。",
data: "时序、SCADA 和方案数据查询命令。", data: "时序、SCADA 和分析数据查询命令。",
"data timeseries": "时序数据查询命令。", "data timeseries": "时序数据查询命令。",
"data timeseries realtime": "实时模拟时序查询命令。", "data timeseries realtime": "实时模拟时序查询命令。",
"data timeseries scheme": "方案时序查询命令。", "data timeseries analysis": "按分析运行 ID 查询历史时序命令。",
"data timeseries scada": "SCADA 时序查询命令。", "data timeseries scada": "SCADA 时序查询命令。",
"data timeseries composite": "复合时序查询命令。", "data timeseries composite": "复合时序查询命令。",
"data scada": "SCADA 元数据查询命令。", "data scada": "SCADA 元数据查询命令。",
"data scheme": "方案数据查询命令。",
}; };
export const HIDDEN_PATH_PREFIXES = ["analysis burst-location", "analysis risk"]; export const HIDDEN_PATH_PREFIXES: string[] = [];
type CommandSpec = readonly [path: string, summary: string, options: readonly string[], examples: readonly string[], nextCommands?: readonly string[]]; type CommandSpec = readonly [path: string, summary: string, options: readonly string[], examples: readonly string[], nextCommands?: readonly string[]];
@@ -44,39 +40,33 @@ const commandSpecs: readonly CommandSpec[] = [
["component option schema", "读取选项 schema", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option schema --kind time", "tjwater-cli component option schema --kind energy", "tjwater-cli component option schema --kind pump-energy --pump PUMP1", "tjwater-cli component option schema --kind network"]], ["component option schema", "读取选项 schema", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option schema --kind time", "tjwater-cli component option schema --kind energy", "tjwater-cli component option schema --kind pump-energy --pump PUMP1", "tjwater-cli component option schema --kind network"]],
["component option get", "读取选项属性", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option get --kind time", "tjwater-cli component option get --kind energy", "tjwater-cli component option get --kind pump-energy --pump PUMP1", "tjwater-cli component option get --kind network"]], ["component option get", "读取选项属性", ["--kind <KIND>", "[--pump <PUMP>]"], ["tjwater-cli component option get --kind time", "tjwater-cli component option get --kind energy", "tjwater-cli component option get --kind pump-energy --pump PUMP1", "tjwater-cli component option get --kind network"]],
["simulation run", "触发指定绝对时间的模拟运行", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"]], ["simulation run", "触发指定绝对时间的模拟运行", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli simulation run --start-time 2025-01-02T03:04:05+08:00 --duration 30"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00", "tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:04:05+08:00 --end-time 2025-01-02T03:34:05+08:00"]],
["analysis burst", "执行爆管分析", ["--start-time <START_TIME>", "--duration <DURATION>", "--burst-file <BURST_FILE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", "tjwater-cli data scheme get --name burst_case_01", "tjwater-cli data scheme list"]], ["analysis burst", "执行爆管分析", ["--start-time <START_TIME>", "--duration <DURATION>", "--burst-file <BURST_FILE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst --start-time 2025-01-02T03:04:05+08:00 --duration 900 --burst-file ./burst.json --scheme burst_case_01", "tjwater-cli analysis runs list"]],
["analysis valve", "阀门工况分析", ["--mode <MODE>", "[--start-time <START_TIME>]", "[--valve <VALVE>]", "[--element <ELEMENT>]", "[--disabled-valve <DISABLED_VALVE>]", "[--duration <DURATION>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis valve --mode close --start-time 2025-01-02T03:04:05+08:00 --valve V1 --valve V2 --duration 900 --scheme valve_case_01", "tjwater-cli analysis valve --mode isolation --element E1 --element E2", "tjwater-cli analysis valve --mode isolation --element E1 --disabled-valve V3"]], ["analysis valve isolation", "执行阀门隔离分析", ["--element <ELEMENT>", "[--disabled-valve <DISABLED_VALVE>]"], ["tjwater-cli analysis valve isolation --element E1 --element E2", "tjwater-cli analysis valve isolation --element E1 --disabled-valve V3"]],
["analysis flushing", "执行冲洗分析", ["--start-time <START_TIME>", "--valve-setting-file <VALVE_SETTING_FILE>", "--drainage-node <DRAINAGE_NODE>", "--flow <FLOW>", "[--duration <DURATION>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01"]], ["analysis flushing", "执行冲洗分析", ["--start-time <START_TIME>", "--valve-setting-file <VALVE_SETTING_FILE>", "--drainage-node <DRAINAGE_NODE>", "--flow <FLOW>", "[--duration <DURATION>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis flushing --start-time 2025-01-02T03:04:05+08:00 --valve-setting-file ./valve.json --drainage-node N1 --flow 100.0 --duration 900 --scheme flush_case_01"]],
["analysis age", "执行水龄分析", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900"]], ["analysis age", "执行水龄分析", ["--start-time <START_TIME>", "--duration <DURATION>"], ["tjwater-cli analysis age --start-time 2025-01-02T03:04:05+08:00 --duration 900"]],
["analysis contaminant", "执行污染物模拟", ["--start-time <START_TIME>", "--duration <DURATION>", "--source-node <SOURCE_NODE>", "--concentration <CONCENTRATION>", "[--pattern <PATTERN>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01"]], ["analysis contaminant", "执行污染物模拟", ["--start-time <START_TIME>", "--duration <DURATION>", "--source-node <SOURCE_NODE>", "--concentration <CONCENTRATION>", "[--pattern <PATTERN>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis contaminant --start-time 2025-01-02T03:04:05+08:00 --duration 900 --source-node N1 --concentration 10.0 --scheme contam_case_01"]],
["analysis sensor-placement kmeans", "执行 KMeans 传感器选址", ["--count <COUNT>", "[--min-diameter <MIN_DIAMETER>]", "[--scheme <SCHEME>]"], ["tjwater-cli analysis sensor-placement kmeans --count 5 --min-diameter 100 --scheme placement_case_01"]], ["analysis sensor-placement run", "执行传感器选址", ["--run-name <RUN_NAME>", "--method <METHOD>", "--count <COUNT>", "[--min-diameter <MIN_DIAMETER>]"], ["tjwater-cli analysis sensor-placement run --run-name placement_case_01 --method kmeans --count 5 --min-diameter 100", "tjwater-cli analysis sensor-placement run --run-name placement_case_02 --method sensitivity --count 5"]],
["analysis sensor-placement list", "列出传感器选址运行", [], ["tjwater-cli analysis sensor-placement list"]],
["analysis sensor-placement get", "读取传感器选址运行", ["--run-id <RUN_ID>"], ["tjwater-cli analysis sensor-placement get --run-id 00000000-0000-0000-0000-000000000001"]],
["analysis runs list", "列出分析运行", [], ["tjwater-cli analysis runs list"]],
["analysis runs get", "读取分析运行", ["--run-id <RUN_ID>"], ["tjwater-cli analysis runs get --run-id 00000000-0000-0000-0000-000000000001"]],
["analysis runs results", "读取分析运行结果", ["--run-id <RUN_ID>", "[--result-type <RESULT_TYPE>]"], ["tjwater-cli analysis runs results --run-id 00000000-0000-0000-0000-000000000001", "tjwater-cli analysis runs results --run-id 00000000-0000-0000-0000-000000000001 --result-type leakage_identification"]],
["analysis leakage identify", "执行漏损识别", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01"]], ["analysis leakage identify", "执行漏损识别", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis leakage identify --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme leak_case_01"]],
["analysis leakage schemes list", "列出漏损方案", [], ["tjwater-cli analysis leakage schemes list"]],
["analysis leakage schemes get", "读取漏损方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis leakage schemes get my_scheme"]],
["analysis burst-detection detect", "执行爆管检测", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01"]], ["analysis burst-detection detect", "执行爆管检测", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst-detection detect --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme detect_case_01"]],
["analysis burst-detection schemes list", "列出爆管检测方案", [], ["tjwater-cli analysis burst-detection schemes list"]],
["analysis burst-detection schemes get", "读取爆管检测方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis burst-detection schemes get my_scheme"]],
["analysis burst-location locate", "执行爆管定位", ["--start-time <START_TIME>", "--end-time <END_TIME>", "--burst-leakage <BURST_LEAKAGE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01"]], ["analysis burst-location locate", "执行爆管定位", ["--start-time <START_TIME>", "--end-time <END_TIME>", "--burst-leakage <BURST_LEAKAGE>", "[--scheme <SCHEME>]"], ["tjwater-cli analysis burst-location locate --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --burst-leakage 100.0 --scheme locate_case_01"]],
["analysis burst-location schemes list", "列出爆管定位方案", [], ["tjwater-cli analysis burst-location schemes list"]],
["analysis burst-location schemes get", "读取爆管定位方案详情", ["<SCHEME_NAME>"], ["tjwater-cli analysis burst-location schemes get my_scheme"]],
["analysis risk pipe-now", "读取单条管道当前风险", ["--pipe <PIPE>"], ["tjwater-cli analysis risk pipe-now --pipe P1"]],
["analysis risk pipe-history", "读取单条管道历史风险", ["--pipe <PIPE>"], ["tjwater-cli analysis risk pipe-history --pipe P1"]],
["analysis risk network", "读取全网风险", [], ["tjwater-cli analysis risk network"]],
["data timeseries realtime links", "查询实时管道时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]], ["data timeseries realtime links", "查询实时管道时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries realtime nodes", "查询实时节点时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]], ["data timeseries realtime nodes", "查询实时节点时序", ["--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries realtime nodes --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries realtime simulation-by-id-time", "按元素和时间查询实时模拟结果", ["--id <ID>", "--type <TYPE>", "--time <TIME>"], ["tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00", "tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00"]], ["data timeseries realtime simulation-by-id-time", "按元素和时间查询实时模拟结果", ["--id <ID>", "--type <TYPE>", "--time <TIME>"], ["tjwater-cli data timeseries realtime simulation-by-id-time --id J1 --type junction --time 2025-01-02T03:30:00+08:00", "tjwater-cli data timeseries realtime simulation-by-id-time --id P1 --type pipe --time 2025-01-02T03:30:00+08:00"]],
["data timeseries realtime simulation-by-time-property", "按时间和属性查询实时模拟结果", ["--type <TYPE>", "--time <TIME>", "--property <PROPERTY>"], ["tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow"]], ["data timeseries realtime simulation-by-time-property", "按时间和属性查询实时模拟结果", ["--type <TYPE>", "--time <TIME>", "--property <PROPERTY>"], ["tjwater-cli data timeseries realtime simulation-by-time-property --type pipe --time 2025-01-02T03:30:00+08:00 --property flow"]],
["data timeseries scheme links", "查询方案管道时序", ["--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]"], ["tjwater-cli data timeseries scheme links --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme"]], ["data timeseries analysis link-field", "查询分析运行的管道字段时序", ["--run-id <RUN_ID>", "--link <LINK>", "--field <FIELD>", "--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries analysis link-field --run-id 00000000-0000-0000-0000-000000000001 --link P1 --field flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries scheme node-field", "查询方案节点字段时序", ["--node <NODE>", "--field <FIELD>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]"], ["tjwater-cli data timeseries scheme node-field --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme"]], ["data timeseries analysis node-field", "查询分析运行的节点字段时序", ["--run-id <RUN_ID>", "--node <NODE>", "--field <FIELD>", "--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries analysis node-field --run-id 00000000-0000-0000-0000-000000000001 --node J1 --field pressure --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]],
["data timeseries scheme simulation", "查询方案模拟数据", ["--query <QUERY>", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]", "[--id <ID>]", "[--time <TIME>]", "[--type <TYPE>]", "[--property <PROPERTY>]"], ["tjwater-cli data timeseries scheme simulation --query by-id-time --id J1 --time 2025-01-02T03:30:00+08:00 --type junction --scheme my_scheme", "tjwater-cli data timeseries scheme simulation --query by-scheme-time-property --time 2025-01-02T03:30:00+08:00 --type pipe --property flow --scheme my_scheme"]], ["data timeseries analysis values", "查询分析运行指定时刻的全部元素字段", ["--run-id <RUN_ID>", "--type <TYPE>", "--time <TIME>", "--field <FIELD>"], ["tjwater-cli data timeseries analysis values --run-id 00000000-0000-0000-0000-000000000001 --type junction --time 2025-01-02T03:30:00+08:00 --field pressure"]],
["data timeseries scada query", "查询 SCADA 时序", ["--device-id <DEVICE_ID>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--field <FIELD>]"], ["tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00", "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value"]], ["data timeseries scada query", "查询 SCADA 时序", ["--device-id <DEVICE_ID>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--field <FIELD>]"], ["tjwater-cli data timeseries scada query --device-id D1 --device-id D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00", "tjwater-cli data timeseries scada query --device-id D1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --field monitored_value"]],
["data timeseries composite", "执行复合时序查询", ["[--kind <KIND>]", "[--feature <FEATURE>]", "[--start-time <START_TIME>]", "[--end-time <END_TIME>]", "[--pipe <PIPE>]", "[--scheme <SCHEME>]", "[--scheme-type <SCHEME_TYPE>]", "[--use-cleaned]"], ["tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", "tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --scheme my_scheme", "tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned"]], ["data timeseries composite", "执行复合时序查询", ["--kind <KIND>", "--feature <FEATURE>", "--start-time <START_TIME>", "--end-time <END_TIME>", "[--run-id <RUN_ID>]", "[--use-cleaned]"], ["tjwater-cli data timeseries composite --kind scada-simulation --feature D1 --feature D2 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --run-id 00000000-0000-0000-0000-000000000001", "tjwater-cli data timeseries composite --kind element-simulation --feature J1:pressure --feature P1:flow --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --run-id 00000000-0000-0000-0000-000000000001", "tjwater-cli data timeseries composite --kind element-scada --feature J1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00 --use-cleaned"]],
["data timeseries composite pipeline-health", "查询管道健康预测", ["--pipe <PIPE>", "--start-time <START_TIME>", "--end-time <END_TIME>"], ["tjwater-cli data timeseries composite pipeline-health --pipe P1 --start-time 2025-01-02T03:00:00+08:00 --end-time 2025-01-02T04:00:00+08:00"]], ["data pipeline-health", "查询指定时刻的管道健康预测", ["--time <TIME>"], ["tjwater-cli data pipeline-health --time 2025-01-02T04:00:00+08:00"]],
["data scada get", "读取单条 SCADA 元数据", ["--kind <KIND>", "--id <ID>"], ["tjwater-cli data scada get --kind info --id SCADA-001"]], ["data scada get", "读取单条 SCADA 设备", ["--device-id <DEVICE_ID>"], ["tjwater-cli data scada get --device-id SCADA-001"]],
["data scada list", "列出 SCADA 元数据", ["--kind <KIND>"], ["tjwater-cli data scada list --kind info"]], ["data scada list", "列出 SCADA 设备", ["[--page-size <PAGE_SIZE>]"], ["tjwater-cli data scada list"]],
["data scheme schema", "读取方案 schema", [], ["tjwater-cli data scheme schema"]], ["data scada schema", "读取 SCADA 设备 schema", [], ["tjwater-cli data scada schema"]],
["data scheme get", "读取单条方案", ["--name <NAME>"], ["tjwater-cli data scheme get --name my_scheme"]],
["data scheme list", "列出方案", [], ["tjwater-cli data scheme list"]],
]; ];
export const commandDocs = new Map<string, CommandDoc>( export const commandDocs = new Map<string, CommandDoc>(
@@ -107,7 +97,7 @@ function optionDoc(path: string, token: string): CommandOptionDoc {
const clean = token.replace(/^\[/, "").replace(/\]$/, ""); const clean = token.replace(/^\[/, "").replace(/\]$/, "");
const name = clean.slice(2).split(/\s+/)[0]!; const name = clean.slice(2).split(/\s+/)[0]!;
const repeatedOptions: Record<string, string[]> = { const repeatedOptions: Record<string, string[]> = {
"analysis valve": ["valve", "element", "disabled-valve"], "analysis valve isolation": ["element", "disabled-valve"],
"analysis burst-location locate": ["pressure-scada-id", "flow-scada-id"], "analysis burst-location locate": ["pressure-scada-id", "flow-scada-id"],
"data timeseries scada query": ["device-id"], "data timeseries scada query": ["device-id"],
"data timeseries composite": ["feature"], "data timeseries composite": ["feature"],
+102 -48
View File
@@ -13,32 +13,32 @@ const visibleCommandPaths = [
"analysis age", "analysis age",
"analysis burst", "analysis burst",
"analysis burst-detection detect", "analysis burst-detection detect",
"analysis burst-detection schemes get", "analysis burst-location locate",
"analysis burst-detection schemes list",
"analysis contaminant", "analysis contaminant",
"analysis flushing", "analysis flushing",
"analysis leakage identify", "analysis leakage identify",
"analysis leakage schemes get", "analysis runs get",
"analysis leakage schemes list", "analysis runs list",
"analysis sensor-placement kmeans", "analysis runs results",
"analysis valve", "analysis sensor-placement get",
"analysis sensor-placement list",
"analysis sensor-placement run",
"analysis valve isolation",
"component option get", "component option get",
"component option schema", "component option schema",
"data scada get", "data scada get",
"data scada list", "data scada list",
"data scheme get", "data scada schema",
"data scheme list", "data pipeline-health",
"data scheme schema", "data timeseries analysis link-field",
"data timeseries analysis node-field",
"data timeseries analysis values",
"data timeseries composite", "data timeseries composite",
"data timeseries composite pipeline-health",
"data timeseries realtime links", "data timeseries realtime links",
"data timeseries realtime nodes", "data timeseries realtime nodes",
"data timeseries realtime simulation-by-id-time", "data timeseries realtime simulation-by-id-time",
"data timeseries realtime simulation-by-time-property", "data timeseries realtime simulation-by-time-property",
"data timeseries scada query", "data timeseries scada query",
"data timeseries scheme links",
"data timeseries scheme node-field",
"data timeseries scheme simulation",
"network get-all-pipes-properties", "network get-all-pipes-properties",
"network get-all-pumps-properties", "network get-all-pumps-properties",
"network get-all-reservoirs-properties", "network get-all-reservoirs-properties",
@@ -53,14 +53,7 @@ const visibleCommandPaths = [
"simulation run", "simulation run",
]; ];
const hiddenCommandPaths = [ const hiddenCommandPaths = [];
"analysis burst-location locate",
"analysis burst-location schemes get",
"analysis burst-location schemes list",
"analysis risk network",
"analysis risk pipe-history",
"analysis risk pipe-now",
];
function runCommand(command, args, input, options = {}) { function runCommand(command, args, input, options = {}) {
return new Promise((resolveRun, reject) => { return new Promise((resolveRun, reject) => {
@@ -144,7 +137,7 @@ function normalizeSeenRequest(request) {
function defaultContractResponse(req) { function defaultContractResponse(req) {
const url = new URL(req.url, "http://127.0.0.1"); const url = new URL(req.url, "http://127.0.0.1");
if (["/api/v1/pipes", "/api/v1/reservoirs", "/api/v1/tanks", "/api/v1/pumps", "/api/v1/valves"].includes(url.pathname)) { if (["/api/v1/pipes", "/api/v1/reservoirs", "/api/v1/tanks", "/api/v1/pumps", "/api/v1/valves", "/api/v1/scada-devices"].includes(url.pathname)) {
return { return {
items: [], items: [],
limit: Number(url.searchParams.get("limit")), limit: Number(url.searchParams.get("limit")),
@@ -338,18 +331,86 @@ test("maps CLI pipe and junction types to backend link and node types", async ()
for (const args of [ for (const args of [
["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at], ["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at],
["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"], ["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"],
["data", "timeseries", "scheme", "simulation", "--query", "by-id-time", "--id", "P1", "--type", "pipe", "--time", at, "--scheme", "scheme_case"], ["data", "timeseries", "analysis", "values", "--run-id", "00000000-0000-0000-0000-000000000001", "--type", "pipe", "--time", at, "--field", "flow"],
]) { ]) {
const result = await runCli(["--auth-stdin", ...args], auth); const result = await runCli(["--auth-stdin", ...args], auth);
assert.equal(result.exitCode, 0, result.stderr); assert.equal(result.exitCode, 0, result.stderr);
} }
const queries = server.seen.map(normalizeSeenRequest).map((request) => request.query.type); const requests = server.seen.map(normalizeSeenRequest);
assert.deepEqual(queries, ["node", "link", "link"]); assert.deepEqual(
[requests[0].query.type, requests[1].query.type, requests[2].query.element_type],
["node", "link", "link"],
);
} finally { } finally {
await server.close(); await server.close();
} }
}); });
test("uses run-id based analysis, sensor placement, and SCADA contracts", async () => {
const server = await startJsonServer(defaultContractResponse);
const auth = { server: server.url, access_token: "token-4", project_id: "project-1" };
const runId = "00000000-0000-0000-0000-000000000001";
const start = "2025-01-02T03:00:00+08:00";
const end = "2025-01-02T04:00:00+08:00";
try {
for (const args of [
["analysis", "sensor-placement", "run", "--run-name", "placement-1", "--method", "kmeans", "--count", "5", "--min-diameter", "100"],
["analysis", "runs", "results", "--run-id", runId, "--result-type", "leakage_identification"],
["data", "timeseries", "analysis", "node-field", "--run-id", runId, "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end],
["data", "scada", "get", "--device-id", "SCADA-001"],
]) {
const result = await runCli(["--auth-stdin", ...args], auth);
assert.equal(result.exitCode, 0, result.stderr);
}
const requests = server.seen.map(normalizeSeenRequest);
assert.deepEqual(requests[0], {
body: {
run_name: "placement-1",
sensor_type: "pressure",
method: "kmeans",
sensor_count: 5,
min_diameter: 100,
},
headers: { authorization: "Bearer token-4", "x-project-id": "project-1" },
method: "POST",
path: "/api/v1/sensor-placement-runs",
query: {},
});
assert.equal(requests[1].path, `/api/v1/analysis/runs/${runId}/results`);
assert.deepEqual(requests[1].query, { result_type: "leakage_identification" });
assert.equal(requests[2].path, `/api/v1/timeseries/analysis/runs/${runId}/nodes/J1`);
assert.deepEqual(requests[2].query, {
end_time: end,
field: "pressure",
start_time: start,
});
assert.equal(requests[3].path, "/api/v1/scada-devices/detail");
assert.deepEqual(requests[3].query, { device_id: "SCADA-001" });
} finally {
await server.close();
}
});
test("does not expose removed scheme, risk, or valve-close commands", async () => {
for (const command of [
"analysis leakage schemes list",
"analysis risk network",
"data scheme list",
"data timeseries scheme simulation",
]) {
const result = await runCli(["help", ...command.split(" ")]);
assert.equal(result.exitCode, 0, result.stderr);
const payload = parseJsonResult(result);
assert.equal(payload.ok, false, command);
assert.equal(payload.error.code, "COMMAND_NOT_FOUND", command);
}
const valveClose = await runCli(["analysis", "valve", "--mode", "close"]);
assert.equal(valveClose.exitCode, 2, valveClose.stderr);
assert.equal(parseJsonResult(valveClose).error.code, "COMMAND_NOT_FOUND");
});
test("executes every command and key variant against the backend contract", async () => { test("executes every command and key variant against the backend contract", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-")); const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-"));
try { try {
@@ -372,6 +433,7 @@ test("executes every command and key variant against the backend contract", asyn
const start = "2025-01-02T03:00:00+08:00"; const start = "2025-01-02T03:00:00+08:00";
const end = "2025-01-02T04:00:00+08:00"; const end = "2025-01-02T04:00:00+08:00";
const at = "2025-01-02T03:30:00+08:00"; const at = "2025-01-02T03:30:00+08:00";
const runId = "00000000-0000-0000-0000-000000000001";
const cases = [ const cases = [
["network get-junction-properties", ["network", "get-junction-properties", "--junction", "J1"]], ["network get-junction-properties", ["network", "get-junction-properties", "--junction", "J1"]],
["network get-pipe-properties", ["network", "get-pipe-properties", "--pipe", "P1"]], ["network get-pipe-properties", ["network", "get-pipe-properties", "--pipe", "P1"]],
@@ -388,42 +450,34 @@ test("executes every command and key variant against the backend contract", asyn
["component option get", ["component", "option", "get", "--kind", "pump-energy", "--pump", "P1"]], ["component option get", ["component", "option", "get", "--kind", "pump-energy", "--pump", "P1"]],
["simulation run", ["simulation", "run", "--start-time", start, "--duration", "60"]], ["simulation run", ["simulation", "run", "--start-time", start, "--duration", "60"]],
["analysis burst", ["analysis", "burst", "--start-time", start, "--duration", "900", "--burst-file", burstFile, "--scheme", "burst_case"]], ["analysis burst", ["analysis", "burst", "--start-time", start, "--duration", "900", "--burst-file", burstFile, "--scheme", "burst_case"]],
["analysis valve close", ["analysis", "valve", "--mode", "close", "--start-time", start, "--valve", "V1", "--valve", "V2", "--duration", "900", "--scheme", "valve_case"]], ["analysis valve isolation", ["analysis", "valve", "isolation", "--element", "E1", "--disabled-valve", "V3"]],
["analysis valve isolation", ["analysis", "valve", "--mode", "isolation", "--element", "E1", "--disabled-valve", "V3"]],
["analysis flushing", ["analysis", "flushing", "--start-time", start, "--valve-setting-file", valveFile, "--drainage-node", "N1", "--flow", "100.5", "--duration", "900", "--scheme", "flush_case"]], ["analysis flushing", ["analysis", "flushing", "--start-time", start, "--valve-setting-file", valveFile, "--drainage-node", "N1", "--flow", "100.5", "--duration", "900", "--scheme", "flush_case"]],
["analysis age", ["analysis", "age", "--start-time", start, "--duration", "900"]], ["analysis age", ["analysis", "age", "--start-time", start, "--duration", "900"]],
["analysis contaminant", ["analysis", "contaminant", "--start-time", start, "--duration", "900", "--source-node", "N1", "--concentration", "10.5", "--pattern", "P1", "--scheme", "contam_case"]], ["analysis contaminant", ["analysis", "contaminant", "--start-time", start, "--duration", "900", "--source-node", "N1", "--concentration", "10.5", "--pattern", "P1", "--scheme", "contam_case"]],
["analysis sensor-placement kmeans", ["analysis", "sensor-placement", "kmeans", "--count", "5", "--min-diameter", "100", "--scheme", "place_case"]], ["analysis sensor-placement run", ["analysis", "sensor-placement", "run", "--run-name", "place_case", "--method", "kmeans", "--count", "5", "--min-diameter", "100"]],
["analysis sensor-placement list", ["analysis", "sensor-placement", "list"]],
["analysis sensor-placement get", ["analysis", "sensor-placement", "get", "--run-id", runId]],
["analysis runs list", ["analysis", "runs", "list"]],
["analysis runs get", ["analysis", "runs", "get", "--run-id", runId]],
["analysis runs results", ["analysis", "runs", "results", "--run-id", runId, "--result-type", "leakage_identification"]],
["analysis leakage identify", ["analysis", "leakage", "identify", "--start-time", start, "--end-time", end, "--scheme", "leak_case"]], ["analysis leakage identify", ["analysis", "leakage", "identify", "--start-time", start, "--end-time", end, "--scheme", "leak_case"]],
["analysis leakage schemes list", ["analysis", "leakage", "schemes", "list"]],
["analysis leakage schemes get", ["analysis", "leakage", "schemes", "get", "leak_case"]],
["analysis burst-detection detect", ["analysis", "burst-detection", "detect", "--start-time", start, "--end-time", end, "--scheme", "detect_case"]], ["analysis burst-detection detect", ["analysis", "burst-detection", "detect", "--start-time", start, "--end-time", end, "--scheme", "detect_case"]],
["analysis burst-detection schemes list", ["analysis", "burst-detection", "schemes", "list"]],
["analysis burst-detection schemes get", ["analysis", "burst-detection", "schemes", "get", "detect_case"]],
["analysis burst-location locate", ["analysis", "burst-location", "locate", "--start-time", start, "--end-time", end, "--burst-leakage", "50.5", "--scheme", "locate_case", "--data-source", "simulation", "--pressure-file", pressureFile, "--flow-file", flowFile, "--use-scada-flow"]], ["analysis burst-location locate", ["analysis", "burst-location", "locate", "--start-time", start, "--end-time", end, "--burst-leakage", "50.5", "--scheme", "locate_case", "--data-source", "simulation", "--pressure-file", pressureFile, "--flow-file", flowFile, "--use-scada-flow"]],
["analysis burst-location schemes list", ["analysis", "burst-location", "schemes", "list"]],
["analysis burst-location schemes get", ["analysis", "burst-location", "schemes", "get", "locate_case"]],
["analysis risk pipe-now", ["analysis", "risk", "pipe-now", "--pipe", "P1"]],
["analysis risk pipe-history", ["analysis", "risk", "pipe-history", "--pipe", "P1"]],
["analysis risk network", ["analysis", "risk", "network"]],
["data realtime links", ["data", "timeseries", "realtime", "links", "--start-time", start, "--end-time", end]], ["data realtime links", ["data", "timeseries", "realtime", "links", "--start-time", start, "--end-time", end]],
["data realtime nodes", ["data", "timeseries", "realtime", "nodes", "--start-time", start, "--end-time", end]], ["data realtime nodes", ["data", "timeseries", "realtime", "nodes", "--start-time", start, "--end-time", end]],
["data realtime simulation-by-id-time", ["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at]], ["data realtime simulation-by-id-time", ["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at]],
["data realtime simulation-by-time-property", ["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"]], ["data realtime simulation-by-time-property", ["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"]],
["data scheme links", ["data", "timeseries", "scheme", "links", "--start-time", start, "--end-time", end, "--scheme", "scheme_case", "--scheme-type", "simulation"]], ["data analysis link-field", ["data", "timeseries", "analysis", "link-field", "--run-id", runId, "--link", "P1", "--field", "flow", "--start-time", start, "--end-time", end]],
["data scheme node-field", ["data", "timeseries", "scheme", "node-field", "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end, "--scheme", "scheme_case"]], ["data analysis node-field", ["data", "timeseries", "analysis", "node-field", "--run-id", runId, "--node", "J1", "--field", "pressure", "--start-time", start, "--end-time", end]],
["data scheme simulation by-id", ["data", "timeseries", "scheme", "simulation", "--query", "by-id-time", "--id", "J1", "--time", at, "--type", "junction", "--scheme", "scheme_case"]], ["data analysis values", ["data", "timeseries", "analysis", "values", "--run-id", runId, "--type", "pipe", "--time", at, "--field", "flow"]],
["data scheme simulation by-property", ["data", "timeseries", "scheme", "simulation", "--query", "by-scheme-time-property", "--time", at, "--type", "pipe", "--property", "flow", "--scheme", "scheme_case"]],
["data scada query", ["data", "timeseries", "scada", "query", "--device-id", "D1", "--device-id", "D2", "--start-time", start, "--end-time", end, "--field", "monitored_value"]], ["data scada query", ["data", "timeseries", "scada", "query", "--device-id", "D1", "--device-id", "D2", "--start-time", start, "--end-time", end, "--field", "monitored_value"]],
["data composite scada-simulation", ["data", "timeseries", "composite", "--kind", "scada-simulation", "--feature", "D1", "--feature", "D2", "--start-time", start, "--end-time", end, "--scheme", "scheme_case"]], ["data composite scada-simulation", ["data", "timeseries", "composite", "--kind", "scada-simulation", "--feature", "D1", "--feature", "D2", "--start-time", start, "--end-time", end, "--run-id", runId]],
["data composite element-simulation", ["data", "timeseries", "composite", "--kind", "element-simulation", "--feature", "J1:pressure", "--start-time", start, "--end-time", end]], ["data composite element-simulation", ["data", "timeseries", "composite", "--kind", "element-simulation", "--feature", "J1:pressure", "--start-time", start, "--end-time", end]],
["data composite element-scada", ["data", "timeseries", "composite", "--kind", "element-scada", "--feature", "J1", "--start-time", start, "--end-time", end, "--use-cleaned"]], ["data composite element-scada", ["data", "timeseries", "composite", "--kind", "element-scada", "--feature", "J1", "--start-time", start, "--end-time", end, "--use-cleaned"]],
["data composite pipeline-health", ["data", "timeseries", "composite", "pipeline-health", "--pipe", "P1", "--start-time", start, "--end-time", end]], ["data pipeline-health", ["data", "pipeline-health", "--time", end]],
["data scada get", ["data", "scada", "get", "--kind", "info", "--id", "SCADA-001"]], ["data scada get", ["data", "scada", "get", "--device-id", "SCADA-001"]],
["data scada list", ["data", "scada", "list", "--kind", "info"]], ["data scada list", ["data", "scada", "list"]],
["data scheme schema", ["data", "scheme", "schema"]], ["data scada schema", ["data", "scada", "schema"]],
["data scheme get", ["data", "scheme", "get", "--name", "scheme_case"]],
["data scheme list", ["data", "scheme", "list"]],
]; ];
for (const [name, args] of cases) { for (const [name, args] of cases) {
+2
View File
@@ -60,6 +60,8 @@
"*.env*": "deny" "*.env*": "deny"
}, },
"question": "allow", "question": "allow",
"activity_update": "allow",
"final_answer": "allow",
"task": "deny", "task": "deny",
"todo": "allow", "todo": "allow",
"todoread": "allow", "todoread": "allow",
@@ -0,0 +1,186 @@
diff --git a/package.json b/package.json
index 15725c8..4378766 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
- "packageManager": "bun@1.3.14",
+ "packageManager": "bun@1.3.13",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev",
diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts
index 4936d31..53138af 100644
--- a/packages/llm/src/protocols/openai-responses.ts
+++ b/packages/llm/src/protocols/openai-responses.ts
@@ -177,6 +177,7 @@ type OpenAIResponsesUsage = Schema.Schema.Type<typeof OpenAIResponsesUsage>
const OpenAIResponsesStreamItem = Schema.Struct({
type: Schema.String,
id: Schema.optional(Schema.String),
+ phase: optionalNull(Schema.String),
call_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
@@ -238,6 +239,7 @@ interface ParserState {
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
+ readonly textMetadata: Readonly<Record<string, ProviderMetadata>>
readonly store: boolean | undefined
}
@@ -614,9 +616,19 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
+ const itemID = event.item_id ?? "text-0"
const events: LLMEvent[] = []
return [
- { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
+ {
+ ...state,
+ lifecycle: Lifecycle.textDelta(
+ state.lifecycle,
+ events,
+ itemID,
+ event.delta,
+ state.textMetadata[itemID],
+ ),
+ },
events,
]
}
@@ -655,6 +667,22 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const item = event.item
+ if (item?.type === "message" && item.id) {
+ const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined
+ return [
+ {
+ ...state,
+ textMetadata: {
+ ...state.textMetadata,
+ [item.id]: openaiMetadata({
+ itemId: item.id,
+ ...(phase ? { phase } : {}),
+ }),
+ },
+ },
+ NO_EVENTS,
+ ]
+ }
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
@@ -812,6 +840,20 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult
+ if (item.type === "message" && item.id) {
+ const events: LLMEvent[] = []
+ const lifecycle = Lifecycle.textEnd(state.lifecycle, events, item.id, state.textMetadata[item.id])
+ const { [item.id]: _removed, ...textMetadata } = state.textMetadata
+ return [
+ {
+ ...state,
+ lifecycle,
+ textMetadata,
+ },
+ events,
+ ] satisfies StepResult
+ }
+
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
@@ -969,6 +1011,7 @@ export const protocol = Protocol.make({
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
reasoningItems: {},
+ textMetadata: {},
store: OpenAIOptions.store(request),
}),
step,
diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts
index eb6c95d..64248be 100644
--- a/packages/llm/src/protocols/utils/lifecycle.ts
+++ b/packages/llm/src/protocols/utils/lifecycle.ts
@@ -14,13 +14,22 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true }
}
-export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
+export const textDelta = (
+ state: State,
+ events: LLMEvent[],
+ id: string,
+ text: string,
+ providerMetadata?: ProviderMetadata,
+): State => {
const stepped = stepStart(state, events)
if (stepped.text.has(id)) {
- events.push(LLMEvent.textDelta({ id, text }))
+ events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
return stepped
}
- events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
+ events.push(
+ LLMEvent.textStart({ id, providerMetadata }),
+ LLMEvent.textDelta({ id, text, providerMetadata }),
+ )
return { ...stepped, text: new Set([...stepped.text, id]) }
}
diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts
index cd8bad5..89ae576 100644
--- a/packages/llm/test/provider/openai-responses.test.ts
+++ b/packages/llm/test/provider/openai-responses.test.ts
@@ -754,6 +754,45 @@ describe("OpenAI Responses route", () => {
}),
)
+ it.effect("preserves assistant message phase in text provider metadata", () =>
+ Effect.gen(function* () {
+ const message = {
+ type: "message",
+ id: "msg_final",
+ phase: "final_answer",
+ }
+ const body = sseEvents(
+ { type: "response.output_item.added", item: message },
+ { type: "response.output_text.delta", item_id: "msg_final", delta: "Final" },
+ { type: "response.output_item.done", item: message },
+ { type: "response.completed", response: { id: "resp_1" } },
+ )
+
+ const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
+ const metadata = {
+ openai: {
+ itemId: "msg_final",
+ phase: "final_answer",
+ },
+ }
+
+ expect(response.text).toBe("Final")
+ expect(response.events).toMatchObject([
+ { type: "step-start", index: 0 },
+ { type: "text-start", id: "msg_final", providerMetadata: metadata },
+ {
+ type: "text-delta",
+ id: "msg_final",
+ text: "Final",
+ providerMetadata: metadata,
+ },
+ { type: "text-end", id: "msg_final", providerMetadata: metadata },
+ { type: "step-finish", index: 0, reason: "stop" },
+ { type: "finish", reason: "stop" },
+ ])
+ }),
+ )
+
it.effect("parses reasoning summary stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
+4 -3
View File
@@ -4,14 +4,15 @@ import { dirname } from "node:path";
import { config } from "../config.js"; import { config } from "../config.js";
export type LlmRequestAuditEntry = { export type LlmRequestAuditEntry = {
kind: "tool" | "skill"; kind: "activity" | "tool" | "skill";
sessionId: string; sessionId: string;
clientSessionId: string; clientSessionId: string;
traceId?: string; traceId?: string;
projectId?: string; projectId?: string;
target: string; target: string;
reason: string; activityId?: string;
reasonProvided: boolean; activityTitle?: string;
activityReason?: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
}; };
+30
View File
@@ -35,6 +35,7 @@ import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js"; import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
import { import {
collectTextContent, collectTextContent,
type ActivityUpdatePayload,
type PermissionRequestPayload, type PermissionRequestPayload,
type QuestionRequestPayload, type QuestionRequestPayload,
streamPromptResponse, streamPromptResponse,
@@ -46,7 +47,9 @@ import {
type StreamSubscriber, type StreamSubscriber,
appendBackendToolArtifact, appendBackendToolArtifact,
cancelBackendTodos, cancelBackendTodos,
completeBackendActivities,
completeBackendProgress, completeBackendProgress,
completeBackendTodos,
createInitialStreamingMessages, createInitialStreamingMessages,
isObjectRecord, isObjectRecord,
toFrontendPermission, toFrontendPermission,
@@ -54,6 +57,7 @@ import {
updateLastAssistantMessage, updateLastAssistantMessage,
updateLastAssistantPermission, updateLastAssistantPermission,
updateLastAssistantQuestion, updateLastAssistantQuestion,
upsertBackendActivity,
upsertBackendProgress, upsertBackendProgress,
upsertBackendQuestion, upsertBackendQuestion,
upsertBackendTodoUpdate, upsertBackendTodoUpdate,
@@ -687,6 +691,27 @@ export const buildChatRouter = (
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`, content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false, isError: false,
})); }));
} else if (event === "final_answer") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: typeof data.content === "string" ? data.content : "",
isError: false,
}));
} else if (event === "activity_update") {
const payload = data as ActivityUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
activities: upsertBackendActivity(message.activities, payload.activity),
...(payload.todos
? {
todos: upsertBackendTodoUpdate(message.todos, {
session_id: payload.session_id,
todos: payload.todos,
created_at: payload.todos_created_at ?? Date.now(),
}),
}
: {}),
}));
} else if (event === "progress") { } else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, ...message,
@@ -702,6 +727,8 @@ export const buildChatRouter = (
? message.content ? message.content
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。", : "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities),
todos: completeBackendTodos(message.todos),
})); }));
} else if (event === "error") { } else if (event === "error") {
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error"; activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
@@ -714,6 +741,7 @@ export const buildChatRouter = (
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`, : `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true, isError: true,
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "error"),
todos: cancelBackendTodos(message.todos), todos: cancelBackendTodos(message.todos),
})); }));
} else if (event === "auth_required") { } else if (event === "auth_required") {
@@ -727,6 +755,7 @@ export const buildChatRouter = (
: "⚠️ **登录态已过期,请刷新登录后重试**", : "⚠️ **登录态已过期,请刷新登录后重试**",
isError: true, isError: true,
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "error"),
todos: cancelBackendTodos(message.todos), todos: cancelBackendTodos(message.todos),
})); }));
} else if (event === "permission_request") { } else if (event === "permission_request") {
@@ -968,6 +997,7 @@ export const buildChatRouter = (
: "⚠️ **请求已中断**", : "⚠️ **请求已中断**",
isError: true, isError: true,
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "cancelled"),
todos: cancelBackendTodos(message.todos), todos: cancelBackendTodos(message.todos),
})); }));
void queueSessionUiStatePersist().catch((error) => { void queueSessionUiStatePersist().catch((error) => {
+180
View File
@@ -0,0 +1,180 @@
import type { Part } from "@opencode-ai/sdk/v2";
import {
getToolLabel,
normalizeToolStatus,
type ActivityActionPayload,
type ActivityPayload,
type ActivityStatus,
type ActivityUpdatePayload,
type TodoItemPayload,
} from "./chatStreamEvents.js";
type ToolPart = Extract<Part, { type: "tool" }>;
type ActivityContext = Pick<ActivityPayload, "id" | "title" | "reason">;
const getActionTarget = (params: Record<string, unknown>) => {
for (const key of ["command", "file_path", "filePath", "path", "query", "keyword"]) {
const value = params[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
};
export const createActivityTracker = ({
clientSessionId,
write,
}: {
clientSessionId: string;
write: (event: string, data: Record<string, unknown>) => void;
}) => {
const activities = new Map<string, ActivityPayload>();
const actionActivityIds = new Map<string, string>();
let currentActivityId: string | null = null;
const emit = (activity: ActivityPayload, todos?: TodoItemPayload[]) => {
const now = Date.now();
const snapshot = activity.status === "running"
? {
...activity,
elapsed_ms: Math.max(0, now - activity.started_at),
actions: activity.actions.map((action) =>
action.status === "running"
? { ...action, elapsed_ms: Math.max(0, now - action.started_at) }
: action,
),
}
: activity;
write("activity_update", {
session_id: clientSessionId,
activity: snapshot,
...(todos
? {
todos,
todos_created_at: now,
}
: {}),
} satisfies ActivityUpdatePayload);
};
const current = (): ActivityPayload | undefined =>
currentActivityId ? activities.get(currentActivityId) : undefined;
const finalize = (status: Exclude<ActivityStatus, "running">) => {
const activity = current();
if (!activity || activity.status !== "running") return;
const endedAt = Date.now();
const nextActivity: ActivityPayload = {
...activity,
status,
ended_at: endedAt,
elapsed_ms: undefined,
duration_ms: Math.max(0, endedAt - activity.started_at),
actions: activity.actions.map((action) => {
if (action.status !== "running") return action;
return {
...action,
status: status === "error" ? "error" : "completed",
ended_at: endedAt,
elapsed_ms: undefined,
duration_ms: Math.max(0, endedAt - action.started_at),
...(status === "error" ? { error: action.error ?? "活动执行失败" } : {}),
};
}),
};
activities.set(activity.id, nextActivity);
emit(nextActivity);
};
const start = (
id: string,
title: string,
reason: string,
todos?: TodoItemPayload[],
) => {
finalize("completed");
const activity: ActivityPayload = {
id,
title,
reason,
status: "running",
actions: [],
started_at: Date.now(),
elapsed_ms: 0,
};
activities.set(id, activity);
currentActivityId = id;
emit(activity, todos);
return activity;
};
const ensure = (tool: string) => {
const activity = current();
if (activity?.status === "running") return activity;
return start(
`activity-fallback-${Date.now().toString(36)}`,
"执行分析操作",
`为完成当前请求,需要使用${getToolLabel(tool)}处理相关信息。`,
);
};
const upsertAction = (part: ToolPart, params: Record<string, unknown>) => {
const associatedActivityId = actionActivityIds.get(part.id);
const activity = associatedActivityId
? activities.get(associatedActivityId)
: ensure(part.tool);
if (!activity) return;
if (!associatedActivityId) actionActivityIds.set(part.id, activity.id);
const now = Date.now();
const actionIndex = activity.actions.findIndex((action) => action.id === part.id);
const previous = actionIndex >= 0 ? activity.actions[actionIndex] : undefined;
const status = normalizeToolStatus(part.state.status);
const startedAt = previous?.started_at ?? now;
const endedAt = status === "running" ? undefined : now;
const action: ActivityActionPayload = {
id: part.id,
tool: part.tool,
title: getToolLabel(part.tool),
status,
target: getActionTarget(params),
error: part.state.status === "error" ? part.state.error : undefined,
started_at: startedAt,
ended_at: endedAt,
elapsed_ms: status === "running" ? Math.max(0, now - startedAt) : undefined,
duration_ms: endedAt ? Math.max(0, endedAt - startedAt) : undefined,
};
const actions = [...activity.actions];
if (actionIndex >= 0) actions[actionIndex] = action;
else actions.push(action);
const nextActivity = { ...activity, actions };
activities.set(activity.id, nextActivity);
emit(nextActivity);
};
const getActionContext = (actionId: string): ActivityContext | undefined => {
const activityId = actionActivityIds.get(actionId);
const activity = activityId ? activities.get(activityId) : undefined;
return activity
? { id: activity.id, title: activity.title, reason: activity.reason }
: undefined;
};
const getCurrentContext = (): ActivityContext | undefined => {
const activity = current();
return activity
? { id: activity.id, title: activity.title, reason: activity.reason }
: undefined;
};
return {
finalize,
getActionContext,
getCurrentContext,
start,
upsertAction,
};
};
+232 -265
View File
@@ -8,16 +8,10 @@ import {
type OpencodeRuntimeAdapter, type OpencodeRuntimeAdapter,
} from "../runtime/opencode.js"; } from "../runtime/opencode.js";
import { import {
buildPermissionDetail,
buildPermissionV2Detail,
buildReasoningProgressDetail,
buildSessionStatusDetail,
buildToolProgressDetail,
collectTextContent, collectTextContent,
extractRequestReason,
extractSkillAuditInfo, extractSkillAuditInfo,
getErrorMessage, getErrorMessage,
getToolProgressTitle, getAssistantMessagePhase,
getUnknownErrorMessage, getUnknownErrorMessage,
hasToolParams, hasToolParams,
isPermissionAskedEvent, isPermissionAskedEvent,
@@ -33,6 +27,7 @@ import {
isQuestionV2RepliedEvent, isQuestionV2RepliedEvent,
isSessionEvent, isSessionEvent,
isSkillEvent, isSkillEvent,
initialActivity,
logDevelopmentDebug, logDevelopmentDebug,
normalizeQuestionAnswers, normalizeQuestionAnswers,
normalizeQuestionPayload, normalizeQuestionPayload,
@@ -40,12 +35,15 @@ import {
normalizeTodoPriority, normalizeTodoPriority,
normalizeTodoStatus, normalizeTodoStatus,
normalizeToolParams, normalizeToolParams,
normalizeToolStatus, type ActivityPayload,
type ActivityUpdatePayload,
type PermissionRequestPayload, type PermissionRequestPayload,
type QuestionRequestPayload, type QuestionRequestPayload,
type AssistantMessagePhase,
type TodoItemPayload, type TodoItemPayload,
type TodoUpdatePayload, type TodoUpdatePayload,
} from "./chatStreamEvents.js"; } from "./chatStreamEvents.js";
import { createActivityTracker } from "./chatActivityTracker.js";
import { import {
resolvePermissionApproval, resolvePermissionApproval,
type ApprovalMode, type ApprovalMode,
@@ -53,10 +51,13 @@ import {
export { export {
collectTextContent, collectTextContent,
initialActivity,
type PermissionRequestPayload, type PermissionRequestPayload,
type QuestionRequestPayload, type QuestionRequestPayload,
type TodoItemPayload, type TodoItemPayload,
type TodoUpdatePayload, type TodoUpdatePayload,
type ActivityPayload,
type ActivityUpdatePayload,
} from "./chatStreamEvents.js"; } from "./chatStreamEvents.js";
export type { ApprovalMode } from "./chatPermissionPolicy.js"; export type { ApprovalMode } from "./chatPermissionPolicy.js";
@@ -75,16 +76,6 @@ type StreamPromptOptions = {
write: (event: string, data: Record<string, unknown>) => void; write: (event: string, data: Record<string, unknown>) => void;
}; };
type ProgressStatus = "running" | "completed" | "error";
type ProgressPayload = {
id: string;
phase: string;
status: ProgressStatus;
title: string;
detail?: string;
};
const getPermissionTarget = (metadata: unknown) => { const getPermissionTarget = (metadata: unknown) => {
if (!isObjectRecord(metadata)) { if (!isObjectRecord(metadata)) {
return undefined; return undefined;
@@ -112,17 +103,41 @@ const toRuntimeModel = (model?: SupportedModel) => {
}; };
}; };
const emitFinalMessage = async ( const FINAL_ANSWER_TOOL_NAME = "final_answer";
const ACTIVITY_UPDATE_TOOL_NAME = "activity_update";
const extractFinalAnswer = (value: unknown) =>
isObjectRecord(value) && typeof value.answer === "string"
? value.answer.trim()
: "";
const resolveFinalMessage = async (
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
sessionId: string, sessionId: string,
clientSessionId: string,
currentAssistantMessageIds: Set<string>, currentAssistantMessageIds: Set<string>,
assistantTextParts: Map<string, Map<string, string>>, assistantTextParts: Map<string, Map<string, string>>,
write: (event: string, data: Record<string, unknown>) => void, assistantTextPartPhases: Map<string, AssistantMessagePhase>,
) => { ) => {
let text = [...currentAssistantMessageIds] let text = [...currentAssistantMessageIds]
.reverse() .reverse()
.map((messageId) => [...(assistantTextParts.get(messageId)?.values() ?? [])].join("")) .map((messageId) => {
const parts = assistantTextParts.get(messageId);
if (!parts) {
return "";
}
const candidates = [...parts.entries()];
const finalText = candidates
.filter(([partId]) => assistantTextPartPhases.get(partId) === "final_answer")
.map(([, content]) => content)
.join("");
if (finalText) {
return finalText;
}
return candidates
.filter(([partId]) => assistantTextPartPhases.get(partId) !== "commentary")
.map(([, content]) => content)
.join("");
})
.find((content) => content.length > 0) ?? ""; .find((content) => content.length > 0) ?? "";
if (!text) { if (!text) {
@@ -135,17 +150,53 @@ const emitFinalMessage = async (
(currentAssistantMessageIds.size === 0 || (currentAssistantMessageIds.size === 0 ||
currentAssistantMessageIds.has(message.info.id)), currentAssistantMessageIds.has(message.info.id)),
); );
text = collectTextContent(assistantMessage?.parts ?? []); const assistantParts = assistantMessage?.parts ?? [];
text = collectTextContent(
assistantParts.filter(
(part) =>
part.type !== "text" ||
getAssistantMessagePhase(part.metadata) !== "commentary",
),
);
if (!text) {
const finalAnswerPart = [...assistantParts]
.reverse()
.find(
(part) =>
part.type === "tool" && part.tool === FINAL_ANSWER_TOOL_NAME,
);
if (finalAnswerPart?.type === "tool") {
text = extractFinalAnswer(finalAnswerPart.state.input);
}
}
} }
if (text) { return text.trim();
write("token", { };
session_id: clientSessionId,
content: text, const normalizeActivityTodos = (value: unknown): TodoItemPayload[] | undefined => {
}); if (!Array.isArray(value)) return undefined;
return true; const now = Date.now();
} return value
return false; .filter(isObjectRecord)
.map((todo, index) => {
const content = typeof todo.content === "string" ? todo.content.trim() : "";
return {
id:
typeof todo.id === "string" && todo.id.trim()
? todo.id.trim()
: `todo-${index}-${content.slice(0, 24)}`,
content,
status: normalizeTodoStatus(
typeof todo.status === "string" ? todo.status : "pending",
),
priority: normalizeTodoPriority(
typeof todo.priority === "string" ? todo.priority : "",
),
updated_at: now,
};
})
.filter((todo) => todo.content.length > 0);
}; };
export const streamPromptResponse = async ({ export const streamPromptResponse = async ({
@@ -165,17 +216,17 @@ export const streamPromptResponse = async ({
failed: boolean; failed: boolean;
toolCallCount: number; toolCallCount: number;
}> => { }> => {
const eventStream = await runtime.subscribeEvents(); const eventStream = await runtime.subscribeEvents(workspaceRoot);
const iterator = eventStream[Symbol.asyncIterator](); const iterator = eventStream[Symbol.asyncIterator]();
const requestStartedAt = Date.now(); const requestStartedAt = Date.now();
const promptStartedAt = Date.now(); const promptStartedAt = Date.now();
const progressStartedAtMap = new Map<string, number>();
const finalizedProgressIds = new Set<string>();
const emittedToolParts = new Set<string>(); const emittedToolParts = new Set<string>();
const emittedActivityParts = new Set<string>();
const emittedQuestionToolParts = new Set<string>(); const emittedQuestionToolParts = new Set<string>();
const emittedQuestionRequestIds = new Set<string>(); const emittedQuestionRequestIds = new Set<string>();
const currentAssistantMessageIds = new Set<string>(); const currentAssistantMessageIds = new Set<string>();
const assistantTextParts = new Map<string, Map<string, string>>(); const assistantTextParts = new Map<string, Map<string, string>>();
const assistantTextPartPhases = new Map<string, AssistantMessagePhase>();
const partTypes = new Map<string, Part["type"]>(); const partTypes = new Map<string, Part["type"]>();
const pendingTextDeltas = new Map<string, string[]>(); const pendingTextDeltas = new Map<string, string[]>();
const reasoningStatuses = new Map<string, "running" | "completed">(); const reasoningStatuses = new Map<string, "running" | "completed">();
@@ -186,7 +237,7 @@ export const streamPromptResponse = async ({
let lastSessionStatus: string | null = null; let lastSessionStatus: string | null = null;
let lastSessionStatusMessage: string | null = null; let lastSessionStatusMessage: string | null = null;
let sawResponseActivity = false; let sawResponseActivity = false;
let emittedText = false; let finalAnswerText = "";
let toolCallCount = 0; let toolCallCount = 0;
let done = false; let done = false;
let promptSettled = false; let promptSettled = false;
@@ -217,57 +268,25 @@ export const streamPromptResponse = async ({
}) })
: null; : null;
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => { const activityTracker = createActivityTracker({ clientSessionId, write });
if (status === "running" && finalizedProgressIds.has(id)) {
return;
}
const now = Date.now(); const captureFinalText = (text: string) => {
const startedAt = progressStartedAtMap.get(id) ?? now; const answer = text.trim();
if (!progressStartedAtMap.has(id)) { if (answer) finalAnswerText = answer;
progressStartedAtMap.set(id, startedAt);
}
if (status === "running") {
write("progress", {
session_id: clientSessionId,
id,
phase,
status,
title,
detail,
started_at: startedAt,
elapsed_ms: Math.max(0, now - startedAt),
});
return;
}
const durationMs = Math.max(0, now - startedAt);
finalizedProgressIds.add(id);
progressStartedAtMap.delete(id);
write("progress", {
session_id: clientSessionId,
id,
phase,
status,
title,
detail,
started_at: startedAt,
ended_at: now,
duration_ms: durationMs,
});
}; };
emitProgress({ activityTracker.start(
id: "request-received", initialActivity.id,
phase: "start", initialActivity.title,
status: "running", initialActivity.reason,
title: "已收到请求,正在启动 Agent 分析", );
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
});
const promptPromise = runtime const promptPromise = runtime
.prompt(sessionId, message, toRuntimeModel(model)) .prompt(
sessionId,
message,
toRuntimeModel(model),
)
.then(() => { .then(() => {
promptSettled = true; promptSettled = true;
logDevelopmentDebug("runtime.prompt resolved", { logDevelopmentDebug("runtime.prompt resolved", {
@@ -289,6 +308,8 @@ export const streamPromptResponse = async ({
...debugContext, ...debugContext,
}); });
let pendingIteratorNext: ReturnType<typeof iterator.next> | undefined;
try { try {
while (!done) { while (!done) {
if (signal?.aborted) { if (signal?.aborted) {
@@ -300,8 +321,8 @@ export const streamPromptResponse = async ({
break; break;
} }
const nextEvent = iterator pendingIteratorNext ??= iterator.next();
.next() const nextEvent = pendingIteratorNext
.then((result) => ({ type: "event" as const, result })); .then((result) => ({ type: "event" as const, result }));
const nextPrompt = promptSettled const nextPrompt = promptSettled
? null ? null
@@ -327,6 +348,7 @@ export const streamPromptResponse = async ({
if (next.type === "prompt") { if (next.type === "prompt") {
continue; continue;
} }
pendingIteratorNext = undefined;
if (next.result.done) { if (next.result.done) {
break; break;
} }
@@ -366,18 +388,6 @@ export const streamPromptResponse = async ({
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
} }
emitProgress({
id: "session-status",
phase: "session",
status: event.properties.status.type === "idle" ? "completed" : "running",
title:
event.properties.status.type === "retry"
? `模型请求重试中:${event.properties.status.message}`
: event.properties.status.type === "busy"
? "Agent 正在处理请求"
: "Agent 已空闲",
detail: buildSessionStatusDetail(event.properties.status),
});
continue; continue;
} }
@@ -409,22 +419,12 @@ export const streamPromptResponse = async ({
patterns: event.properties.patterns, patterns: event.properties.patterns,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: permissionApproval.autoReject
? "error"
: permissionApproval.autoApprove
? "completed"
: "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionDetail(event),
});
if (permissionApproval.autoApprove || permissionApproval.autoReject) { if (permissionApproval.autoApprove || permissionApproval.autoReject) {
const reply = permissionApproval.autoReject ? "reject" : "once"; const reply = permissionApproval.autoReject ? "reject" : "once";
await runtime.replyPermission({ await runtime.replyPermission({
requestId: event.properties.id, requestId: event.properties.id,
sessionId, sessionId,
directory: workspaceRoot,
reply, reply,
}); });
write("permission_response", { write("permission_response", {
@@ -434,12 +434,15 @@ export const streamPromptResponse = async ({
}); });
continue; continue;
} }
const activity = activityTracker.getCurrentContext();
write("permission_request", { write("permission_request", {
session_id: clientSessionId, session_id: clientSessionId,
request_id: event.properties.id, request_id: event.properties.id,
permission: event.properties.permission, permission: event.properties.permission,
patterns: event.properties.patterns, patterns: event.properties.patterns,
target: getPermissionTarget(event.properties.metadata), target: getPermissionTarget(event.properties.metadata),
activity_id: activity?.id,
reason: activity?.reason,
always: event.properties.always, always: event.properties.always,
tool: event.properties.tool, tool: event.properties.tool,
created_at: Date.now(), created_at: Date.now(),
@@ -465,22 +468,12 @@ export const streamPromptResponse = async ({
resources: event.properties.resources, resources: event.properties.resources,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: permissionApproval.autoReject
? "error"
: permissionApproval.autoApprove
? "completed"
: "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionV2Detail(event),
});
if (permissionApproval.autoApprove || permissionApproval.autoReject) { if (permissionApproval.autoApprove || permissionApproval.autoReject) {
const reply = permissionApproval.autoReject ? "reject" : "once"; const reply = permissionApproval.autoReject ? "reject" : "once";
await runtime.replyPermission({ await runtime.replyPermission({
requestId: event.properties.id, requestId: event.properties.id,
sessionId, sessionId,
directory: workspaceRoot,
reply, reply,
}); });
write("permission_response", { write("permission_response", {
@@ -490,12 +483,15 @@ export const streamPromptResponse = async ({
}); });
continue; continue;
} }
const activity = activityTracker.getCurrentContext();
write("permission_request", { write("permission_request", {
session_id: clientSessionId, session_id: clientSessionId,
request_id: event.properties.id, request_id: event.properties.id,
permission: event.properties.action, permission: event.properties.action,
patterns: event.properties.resources, patterns: event.properties.resources,
target: getPermissionTarget(event.properties.metadata), target: getPermissionTarget(event.properties.metadata),
activity_id: activity?.id,
reason: activity?.reason,
always: event.properties.save ?? [], always: event.properties.save ?? [],
tool: undefined, tool: undefined,
created_at: Date.now(), created_at: Date.now(),
@@ -511,21 +507,6 @@ export const streamPromptResponse = async ({
reply: event.properties.reply, reply: event.properties.reply,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
emitProgress({
id: `permission-${event.properties.requestID}`,
phase: "permission",
status: event.properties.reply === "reject" ? "error" : "completed",
title:
event.properties.reply === "reject"
? "权限请求已拒绝"
: "权限请求已允许",
detail:
event.properties.reply === "always"
? "已允许本次请求,并记住同类权限。"
: event.properties.reply === "once"
? "已允许本次请求。"
: "已拒绝本次请求。",
});
write("permission_response", { write("permission_response", {
session_id: clientSessionId, session_id: clientSessionId,
request_id: event.properties.requestID, request_id: event.properties.requestID,
@@ -542,21 +523,6 @@ export const streamPromptResponse = async ({
reply: event.properties.reply, reply: event.properties.reply,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
emitProgress({
id: `permission-${event.properties.requestID}`,
phase: "permission",
status: event.properties.reply === "reject" ? "error" : "completed",
title:
event.properties.reply === "reject"
? "权限请求已拒绝"
: "权限请求已允许",
detail:
event.properties.reply === "always"
? "已允许本次请求,并记住同类权限。"
: event.properties.reply === "once"
? "已允许本次请求。"
: "已拒绝本次请求。",
});
write("permission_response", { write("permission_response", {
session_id: clientSessionId, session_id: clientSessionId,
request_id: event.properties.requestID, request_id: event.properties.requestID,
@@ -573,15 +539,6 @@ export const streamPromptResponse = async ({
questionCount: event.properties.questions.length, questionCount: event.properties.questions.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
emitProgress({
id: `question-${event.properties.id}`,
phase: "question",
status: "running",
title: "等待用户补充信息",
detail: event.properties.questions
.map((question) => question.question)
.join("\n"),
});
const payload = normalizeQuestionPayload(event, clientSessionId); const payload = normalizeQuestionPayload(event, clientSessionId);
emittedQuestionRequestIds.add(payload.request_id); emittedQuestionRequestIds.add(payload.request_id);
write("question_request", payload); write("question_request", payload);
@@ -595,16 +552,6 @@ export const streamPromptResponse = async ({
requestId: event.properties.requestID, requestId: event.properties.requestID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
emitProgress({
id: `question-${event.properties.requestID}`,
phase: "question",
status: "completed",
title: "已收到补充信息",
detail: normalizeQuestionAnswers(event.properties.answers)
.map((answer) => answer.join("、"))
.filter(Boolean)
.join("\n"),
});
write("question_response", { write("question_response", {
session_id: clientSessionId, session_id: clientSessionId,
request_id: event.properties.requestID, request_id: event.properties.requestID,
@@ -620,13 +567,6 @@ export const streamPromptResponse = async ({
requestId: event.properties.requestID, requestId: event.properties.requestID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
emitProgress({
id: `question-${event.properties.requestID}`,
phase: "question",
status: "completed",
title: "已跳过补充信息",
detail: "用户选择跳过本次补充信息。",
});
write("question_response", { write("question_response", {
session_id: clientSessionId, session_id: clientSessionId,
request_id: event.properties.requestID, request_id: event.properties.requestID,
@@ -637,11 +577,11 @@ export const streamPromptResponse = async ({
if (isSkillEvent(event)) { if (isSkillEvent(event)) {
sawResponseActivity = true; sawResponseActivity = true;
const { name, reason, payload } = extractSkillAuditInfo(event); const { name, payload } = extractSkillAuditInfo(event);
const activity = activityTracker.getCurrentContext();
logDevelopmentDebug("skill event received", { logDevelopmentDebug("skill event received", {
...debugContext, ...debugContext,
skill: name, skill: name,
reason: reason || null,
payloadKeys: Object.keys(payload).slice(0, 8), payloadKeys: Object.keys(payload).slice(0, 8),
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
@@ -652,8 +592,9 @@ export const streamPromptResponse = async ({
traceId, traceId,
projectId, projectId,
target: name, target: name,
reason, activityId: activity?.id,
reasonProvided: Boolean(reason), activityTitle: activity?.title,
activityReason: activity?.reason,
payload, payload,
}).catch((error) => { }).catch((error) => {
logger.warn({ err: error }, "failed to write skill audit log"); logger.warn({ err: error }, "failed to write skill audit log");
@@ -674,11 +615,13 @@ export const streamPromptResponse = async ({
const partType = partTypes.get(event.properties.partID); const partType = partTypes.get(event.properties.partID);
if (partType === "text") { if (partType === "text") {
const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map(); const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map();
messageParts.set( const text = `${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`;
event.properties.partID, messageParts.set(event.properties.partID, text);
`${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`,
);
assistantTextParts.set(event.properties.messageID, messageParts); assistantTextParts.set(event.properties.messageID, messageParts);
const phase = assistantTextPartPhases.get(event.properties.partID) ?? "unknown";
if (phase === "final_answer") {
captureFinalText(text);
}
} else if (!partType) { } else if (!partType) {
const pending = pendingTextDeltas.get(event.properties.partID) ?? []; const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta); pending.push(event.properties.delta);
@@ -695,11 +638,17 @@ export const streamPromptResponse = async ({
currentAssistantMessageIds.add(part.messageID); currentAssistantMessageIds.add(part.messageID);
} }
if (part.type === "text") { if (part.type === "text") {
const phase = getAssistantMessagePhase(part.metadata);
assistantTextPartPhases.set(part.id, phase);
const pendingText = (pendingTextDeltas.get(part.id) ?? []).join(""); const pendingText = (pendingTextDeltas.get(part.id) ?? []).join("");
pendingTextDeltas.delete(part.id); pendingTextDeltas.delete(part.id);
const messageParts = assistantTextParts.get(part.messageID) ?? new Map(); const messageParts = assistantTextParts.get(part.messageID) ?? new Map();
messageParts.set(part.id, part.text || pendingText); const text = part.text || pendingText;
messageParts.set(part.id, text);
assistantTextParts.set(part.messageID, messageParts); assistantTextParts.set(part.messageID, messageParts);
if (phase === "final_answer") {
captureFinalText(text);
}
} else { } else {
pendingTextDeltas.delete(part.id); pendingTextDeltas.delete(part.id);
} }
@@ -714,14 +663,6 @@ export const streamPromptResponse = async ({
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
} }
const reasoningDetail = buildReasoningProgressDetail(part.time.end);
emitProgress({
id: part.id,
phase: "planning",
status: part.time.end ? "completed" : "running",
title: part.time.end ? "分析规划完成" : "正在规划分析步骤",
detail: reasoningDetail,
});
} }
if (part.type === "tool") { if (part.type === "tool") {
if (!firstToolEventLogged) { if (!firstToolEventLogged) {
@@ -736,7 +677,6 @@ export const streamPromptResponse = async ({
}); });
} }
const toolParams = normalizeToolParams(part.state.input); const toolParams = normalizeToolParams(part.state.input);
const reason = extractRequestReason(toolParams);
const isToolFinalState = const isToolFinalState =
part.state.status === "completed" || part.state.status === "error"; part.state.status === "completed" || part.state.status === "error";
const nextToolStatus = String(part.state.status); const nextToolStatus = String(part.state.status);
@@ -748,7 +688,6 @@ export const streamPromptResponse = async ({
partId: part.id, partId: part.id,
tool: part.tool, tool: part.tool,
status: nextToolStatus, status: nextToolStatus,
reason: reason || null,
inputKeys: Object.keys(toolParams).slice(0, 8), inputKeys: Object.keys(toolParams).slice(0, 8),
error: error:
part.state.status === "error" ? (part.state.error ?? "unknown") : null, part.state.status === "error" ? (part.state.error ?? "unknown") : null,
@@ -756,6 +695,80 @@ export const streamPromptResponse = async ({
}); });
} }
if (part.tool === FINAL_ANSWER_TOOL_NAME) {
if (part.state.status === "error") {
logger.warn(
{
sessionId,
clientSessionId,
partId: part.id,
error: part.state.error,
},
"final answer tool failed",
);
continue;
}
if (
(part.state.status !== "running" &&
part.state.status !== "completed")
) {
continue;
}
const answer = extractFinalAnswer(toolParams);
if (!answer) {
logger.warn(
{ sessionId, clientSessionId, partId: part.id },
"final answer tool received without an answer",
);
continue;
}
captureFinalText(answer);
logDevelopmentDebug("final answer submitted through tool", {
...debugContext,
partId: part.id,
tool: part.tool,
answerChars: answer.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
continue;
}
if (part.tool === ACTIVITY_UPDATE_TOOL_NAME) {
const title = typeof toolParams.title === "string"
? toolParams.title.trim()
: "";
const reason = typeof toolParams.reason === "string"
? toolParams.reason.trim()
: "";
const todos = normalizeActivityTodos(toolParams.todos);
if (
title &&
reason &&
!emittedActivityParts.has(part.id) &&
(hasToolParams(toolParams) || isToolFinalState)
) {
emittedActivityParts.add(part.id);
const activity = activityTracker.start(part.id, title, reason, todos);
void writeLlmRequestAuditLog({
kind: "activity",
sessionId,
clientSessionId,
traceId,
projectId,
target: ACTIVITY_UPDATE_TOOL_NAME,
activityId: activity.id,
activityTitle: activity.title,
activityReason: activity.reason,
payload: toolParams,
}).catch((error) => {
logger.warn({ err: error }, "failed to write activity audit log");
});
}
continue;
}
const questionToolPayload = normalizeQuestionToolPayload( const questionToolPayload = normalizeQuestionToolPayload(
part, part,
toolParams, toolParams,
@@ -772,49 +785,23 @@ export const streamPromptResponse = async ({
questionCount: questionToolPayload.questions.length, questionCount: questionToolPayload.questions.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
emitProgress({
id: `question-${questionToolPayload.request_id}`,
phase: "question",
status: "running",
title: "等待用户补充信息",
detail: questionToolPayload.questions
.map((question) => question.question)
.join("\n"),
});
write("question_request", questionToolPayload); write("question_request", questionToolPayload);
} }
continue; continue;
} }
emitProgress({ if (part.tool === "todowrite" || part.tool === "todo") {
id: part.id, continue;
phase: "tool", }
status: normalizeToolStatus(part.state.status),
title: getToolProgressTitle(part.tool, part.state.status), activityTracker.upsertAction(part, toolParams);
detail: buildToolProgressDetail(
part.tool,
part.state.status,
toolParams,
reason,
part.state.status === "error" ? part.state.error : undefined,
),
});
if ( if (
!emittedToolParts.has(part.id) && !emittedToolParts.has(part.id) &&
(hasToolParams(toolParams) || isToolFinalState) (hasToolParams(toolParams) || isToolFinalState)
) { ) {
emittedToolParts.add(part.id); emittedToolParts.add(part.id);
toolCallCount += 1; toolCallCount += 1;
if (!reason) { const activity = activityTracker.getActionContext(part.id);
logger.warn(
{
tool: part.tool,
sessionId: sessionId,
clientSessionId,
},
"llm tool request missing reason",
);
}
void writeLlmRequestAuditLog({ void writeLlmRequestAuditLog({
kind: "tool", kind: "tool",
sessionId: sessionId, sessionId: sessionId,
@@ -822,8 +809,9 @@ export const streamPromptResponse = async ({
traceId, traceId,
projectId, projectId,
target: part.tool, target: part.tool,
reason, activityId: activity?.id,
reasonProvided: Boolean(reason), activityTitle: activity?.title,
activityReason: activity?.reason,
payload: toolParams, payload: toolParams,
}).catch((error) => { }).catch((error) => {
logger.warn({ err: error }, "failed to write tool audit log"); logger.warn({ err: error }, "failed to write tool audit log");
@@ -832,7 +820,6 @@ export const streamPromptResponse = async ({
session_id: clientSessionId, session_id: clientSessionId,
tool: part.tool, tool: part.tool,
params: toolParams, params: toolParams,
reason,
}); });
} }
} }
@@ -853,18 +840,6 @@ export const streamPromptResponse = async ({
priority: normalizeTodoPriority(todo.priority), priority: normalizeTodoPriority(todo.priority),
updated_at: Date.now(), updated_at: Date.now(),
})); }));
const completed = todos.filter(
(todo) => todo.status === "completed",
).length;
emitProgress({
id: "todo-progress",
phase: "planning",
status: completed === todos.length ? "completed" : "running",
title: `计划进度 ${completed}/${todos.length}`,
detail: todos
.map((todo) => `${todo.status}: ${todo.content}`)
.join("\n"),
});
write("todo_update", { write("todo_update", {
session_id: clientSessionId, session_id: clientSessionId,
todos: normalizedTodos, todos: normalizedTodos,
@@ -882,6 +857,7 @@ export const streamPromptResponse = async ({
? getErrorMessage(event.properties.error) ? getErrorMessage(event.properties.error)
: "opencode session error", : "opencode session error",
}); });
activityTracker.finalize("error");
write("error", { write("error", {
session_id: clientSessionId, session_id: clientSessionId,
message: event.properties.error message: event.properties.error
@@ -905,17 +881,10 @@ export const streamPromptResponse = async ({
} }
logDevelopmentDebug("session idle received", { logDevelopmentDebug("session idle received", {
...debugContext, ...debugContext,
emittedText, hasFinalAnswer: Boolean(finalAnswerText),
toolCallCount, toolCallCount,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
emitProgress({
id: "session-status",
phase: "session",
status: "completed",
title: "Agent 已完成处理",
detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。",
});
done = true; done = true;
} }
} }
@@ -925,6 +894,7 @@ export const streamPromptResponse = async ({
...debugContext, ...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
activityTracker.finalize("cancelled");
await runtime.abortSession(sessionId).catch((error) => { await runtime.abortSession(sessionId).catch((error) => {
logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session"); logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session");
}); });
@@ -942,37 +912,34 @@ export const streamPromptResponse = async ({
} }
await promptPromise; await promptPromise;
emittedText = await emitFinalMessage( if (!finalAnswerText) {
finalAnswerText = await resolveFinalMessage(
runtime, runtime,
sessionId, sessionId,
clientSessionId,
currentAssistantMessageIds, currentAssistantMessageIds,
assistantTextParts, assistantTextParts,
write, assistantTextPartPhases,
); );
emitProgress({ }
id: "request-received", activityTracker.finalize("completed");
phase: "start", if (finalAnswerText) {
status: "completed", // Keep one compatibility cycle for deployed frontends that only consume token.
title: "请求处理完成", write("token", {
detail: "本次请求的分析、工具执行和结果整理流程已经完成。", session_id: clientSessionId,
content: finalAnswerText,
}); });
emitProgress({ write("final_answer", {
id: "request-completed", session_id: clientSessionId,
phase: "complete", content: finalAnswerText,
status: "completed",
title: "分析完成",
detail: emittedText
? "最终回答已生成并推送到前端。"
: "已完成分析,并通过兜底消息补发最终回答内容。",
}); });
}
write("done", { write("done", {
session_id: clientSessionId, session_id: clientSessionId,
total_duration_ms: Math.max(0, Date.now() - requestStartedAt), total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
}); });
logDevelopmentDebug("chat stream completed", { logDevelopmentDebug("chat stream completed", {
...debugContext, ...debugContext,
emittedText, hasFinalAnswer: Boolean(finalAnswerText),
toolCallCount, toolCallCount,
totalDurationMs: Math.max(0, Date.now() - requestStartedAt), totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
}); });
+65 -119
View File
@@ -9,6 +9,8 @@ export type PermissionRequestPayload = {
permission: string; permission: string;
patterns: string[]; patterns: string[];
target?: string; target?: string;
activity_id?: string;
reason?: string;
always: string[]; always: string[];
tool?: { tool?: {
messageID: string; messageID: string;
@@ -17,6 +19,46 @@ export type PermissionRequestPayload = {
created_at: number; created_at: number;
}; };
export type ActivityStatus = "running" | "completed" | "error" | "cancelled";
export type ActivityActionPayload = {
id: string;
tool: string;
title: string;
status: "running" | "completed" | "error";
target?: string;
error?: string;
started_at: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
};
export type ActivityPayload = {
id: string;
title: string;
reason: string;
status: ActivityStatus;
actions: ActivityActionPayload[];
started_at: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
};
export type ActivityUpdatePayload = {
session_id: string;
activity: ActivityPayload;
todos?: TodoItemPayload[];
todos_created_at?: number;
};
export const initialActivity = {
id: "activity-startup",
title: "正在准备分析",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
} as const;
type QuestionOptionPayload = { type QuestionOptionPayload = {
label: string; label: string;
description: string; description: string;
@@ -57,9 +99,17 @@ export type TodoUpdatePayload = {
created_at: number; created_at: number;
}; };
export type AssistantMessagePhase =
| "commentary"
| "final_answer"
| "unknown";
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = { const toolLabels: Record<string, string> = {
tjwater_cli: "查询后端数据",
bash: "运行本地分析",
store_render_ref: "保存渲染结果",
memory_manager: "记忆写入", memory_manager: "记忆写入",
geocode: "地理编码", geocode: "地理编码",
session_search: "历史会话检索", session_search: "历史会话检索",
@@ -71,6 +121,7 @@ const toolLabels: Record<string, string> = {
view_scada: "SCADA 面板", view_scada: "SCADA 面板",
show_chart: "图表渲染", show_chart: "图表渲染",
render_junctions: "节点渲染", render_junctions: "节点渲染",
apply_layer_style: "图层样式调整",
}; };
export const logDevelopmentDebug = ( export const logDevelopmentDebug = (
@@ -110,6 +161,19 @@ export const getUnknownErrorMessage = (error: unknown) => {
export const isObjectRecord = (value: unknown): value is Record<string, unknown> => export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value); typeof value === "object" && value !== null && !Array.isArray(value);
export const getAssistantMessagePhase = (
metadata: unknown,
): AssistantMessagePhase => {
if (!isObjectRecord(metadata)) {
return "unknown";
}
const openai = isObjectRecord(metadata.openai) ? metadata.openai : undefined;
const phase = openai?.phase ?? metadata.phase;
return phase === "commentary" || phase === "final_answer"
? phase
: "unknown";
};
export const normalizeToolParams = (value: unknown): Record<string, unknown> => { export const normalizeToolParams = (value: unknown): Record<string, unknown> => {
if (isObjectRecord(value)) { if (isObjectRecord(value)) {
return value; return value;
@@ -125,20 +189,6 @@ export const normalizeToolParams = (value: unknown): Record<string, unknown> =>
return {}; return {};
}; };
export const extractRequestReason = (params: Record<string, unknown>) => {
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
for (const key of candidates) {
const value = params[key];
if (typeof value === "string") {
const normalized = value.trim();
if (normalized) {
return normalized;
}
}
}
return "";
};
export const isSkillEvent = (event: OpencodeEvent) => export const isSkillEvent = (event: OpencodeEvent) =>
event.type.toLowerCase().includes("skill"); event.type.toLowerCase().includes("skill");
@@ -154,10 +204,8 @@ export const extractSkillAuditInfo = (event: OpencodeEvent) => {
: typeof payload.name === "string" : typeof payload.name === "string"
? payload.name ? payload.name
: event.type; : event.type;
const reason = extractRequestReason(payload);
return { return {
name: candidateName, name: candidateName,
reason,
payload, payload,
}; };
}; };
@@ -222,24 +270,6 @@ export const isQuestionV2RejectedEvent = (
): event is Extract<OpencodeEvent, { type: "question.v2.rejected" }> => ): event is Extract<OpencodeEvent, { type: "question.v2.rejected" }> =>
event.type === "question.v2.rejected"; event.type === "question.v2.rejected";
export const buildPermissionDetail = (
event: Extract<OpencodeEvent, { type: "permission.asked" }>,
) => {
const patterns = event.properties.patterns.length
? event.properties.patterns.join(", ")
: event.properties.permission;
return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`;
};
export const buildPermissionV2Detail = (
event: Extract<OpencodeEvent, { type: "permission.v2.asked" }>,
) => {
const resources = event.properties.resources.length
? event.properties.resources.join(", ")
: event.properties.action;
return `需要用户确认权限:${event.properties.action};资源:${resources}`;
};
export const normalizeQuestionPayload = ( export const normalizeQuestionPayload = (
event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>, event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>,
clientSessionId: string, clientSessionId: string,
@@ -356,88 +386,4 @@ export const normalizeToolStatus = (status: string) => {
return "running"; return "running";
}; };
const formatProgressValue = (value: unknown): string => { export const getToolLabel = (tool: string) => toolLabels[tool] ?? tool;
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
value === null ||
value === undefined
) {
return String(value);
}
try {
const serialized = JSON.stringify(value);
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
} catch {
return "[unserializable]";
}
};
const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
const summary = Object.entries(params)
.filter(([key]) => !ignoredKeys.has(key))
.slice(0, 4)
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
.join(", ");
return summary || "无附加参数";
};
export const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
if (status.type === "retry") {
return status.message
? `模型请求需要重试,原因:${status.message}`
: "模型请求正在重试,等待下一次响应。";
}
if (status.type === "busy") {
return status.message
? `Agent 正在处理中:${status.message}`
: "Agent 正在执行推理、工具调用或结果整理。";
}
if (status.type === "idle") {
return status.message
? `Agent 已空闲:${status.message}`
: "当前会话暂时没有待处理任务。";
}
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
};
export const buildReasoningProgressDetail = (
ended?: string | number | Date | null,
) => ended ? "分析步骤已整理完成。" : "Agent 正在分析问题。";
export const buildToolProgressDetail = (
tool: string,
status: string,
params: Record<string, unknown>,
reason: string,
error?: string,
) => {
const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") {
const errorText = error ? `;错误:${error}` : "";
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
}
if (status === "completed") {
return `${toolName} 已执行完成${reasonText}${paramsText}`;
}
if (status === "pending") {
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
}
return `${toolName} 正在执行${reasonText}${paramsText}`;
};
export const getToolProgressTitle = (tool: string, status: string) => {
const toolName = toolLabels[tool] ?? tool;
if (status === "completed") return `${toolName} 已完成`;
if (status === "error") return `${toolName} 执行失败`;
if (status === "pending") return `准备调用 ${toolName}`;
return `正在调用 ${toolName}`;
};
+119 -9
View File
@@ -1,5 +1,7 @@
import { type PermissionReply } from "../runtime/opencode.js"; import { type PermissionReply } from "../runtime/opencode.js";
import { import {
type ActivityPayload,
initialActivity,
type PermissionRequestPayload, type PermissionRequestPayload,
type QuestionRequestPayload, type QuestionRequestPayload,
type TodoUpdatePayload, type TodoUpdatePayload,
@@ -28,6 +30,7 @@ type ToolCallPayload = {
session_id?: string; session_id?: string;
tool?: string; tool?: string;
params?: unknown; params?: unknown;
/** Legacy payload field. New tool calls inherit purpose from their Activity. */
reason?: string; reason?: string;
}; };
@@ -56,13 +59,13 @@ export const createInitialStreamingMessages = (
id: createFrontendMessageId(), id: createFrontendMessageId(),
role: "assistant", role: "assistant",
content: "", content: "",
progress: [ activities: [
{ {
id: "request-received", id: initialActivity.id,
phase: "start",
status: "running", status: "running",
title: "已收到请求,正在启动 Agent 分析", title: initialActivity.title,
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。", reason: initialActivity.reason,
actions: [],
startedAt: Date.now(), startedAt: Date.now(),
elapsedMs: 0, elapsedMs: 0,
elapsedSnapshotAt: Date.now(), elapsedSnapshotAt: Date.now(),
@@ -72,6 +75,90 @@ export const createInitialStreamingMessages = (
]; ];
}; };
const toFrontendActivityAction = (
action: ActivityPayload["actions"][number],
) => ({
id: action.id,
tool: action.tool,
title: action.title,
status: action.status,
target: action.target,
error: action.error,
startedAt: action.started_at,
endedAt: action.ended_at,
elapsedMs: action.elapsed_ms,
elapsedSnapshotAt: action.elapsed_ms === undefined ? undefined : Date.now(),
durationMs: action.duration_ms,
});
const toFrontendActivity = (activity: ActivityPayload) => ({
id: activity.id,
title: activity.title,
reason: activity.reason,
status: activity.status,
actions: activity.actions.map(toFrontendActivityAction),
startedAt: activity.started_at,
endedAt: activity.ended_at,
elapsedMs: activity.elapsed_ms,
elapsedSnapshotAt: activity.elapsed_ms === undefined ? undefined : Date.now(),
durationMs: activity.duration_ms,
});
export const upsertBackendActivity = (
activities: unknown,
activity: ActivityPayload,
) => {
const next = Array.isArray(activities) ? [...activities] : [];
const index = next.findIndex(
(item) => isObjectRecord(item) && item.id === activity.id,
);
const nextItem = toFrontendActivity(activity);
if (index >= 0) next[index] = nextItem;
else next.push(nextItem);
return next;
};
export const completeBackendActivities = (
activities: unknown,
status: "completed" | "error" | "cancelled" = "completed",
) => Array.isArray(activities)
? activities.map((activity) => {
if (!isObjectRecord(activity) || activity.status !== "running") return activity;
const endedAt = Date.now();
const startedAt = typeof activity.startedAt === "number"
? activity.startedAt
: endedAt;
const actions = Array.isArray(activity.actions)
? activity.actions.map((action) => {
if (!isObjectRecord(action) || action.status !== "running") return action;
const actionStartedAt = typeof action.startedAt === "number"
? action.startedAt
: endedAt;
return {
...action,
status: status === "error" ? "error" : "completed",
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - actionStartedAt),
...(status === "error"
? { error: action.error ?? "活动执行失败" }
: {}),
};
})
: activity.actions;
return {
...activity,
status,
actions,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - startedAt),
};
})
: activities;
export const upsertBackendProgress = ( export const upsertBackendProgress = (
progress: unknown, progress: unknown,
payload: Record<string, unknown>, payload: Record<string, unknown>,
@@ -152,6 +239,31 @@ export const cancelBackendTodos = (todos: unknown) =>
}) })
: todos; : todos;
export const completeBackendTodos = (todos: unknown) =>
Array.isArray(todos)
? todos.map((todoUpdate) => {
if (!isObjectRecord(todoUpdate) || !Array.isArray(todoUpdate.todos)) {
return todoUpdate;
}
return {
...todoUpdate,
todos: todoUpdate.todos.map((todo) => {
if (!isObjectRecord(todo)) {
return todo;
}
if (todo.status !== "pending" && todo.status !== "in_progress") {
return todo;
}
return {
...todo,
status: "completed",
updatedAt: Date.now(),
};
}),
};
})
: todos;
export const updateLastAssistantMessage = ( export const updateLastAssistantMessage = (
messages: unknown[], messages: unknown[],
updater: (message: Record<string, unknown>) => Record<string, unknown>, updater: (message: Record<string, unknown>) => Record<string, unknown>,
@@ -249,10 +361,6 @@ export const appendBackendToolArtifact = (
tool, tool,
kind: getToolArtifactKind(tool), kind: getToolArtifactKind(tool),
title: getToolArtifactTitle(tool, params), title: getToolArtifactTitle(tool, params),
description:
typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: undefined,
params, params,
}); });
return next; return next;
@@ -267,6 +375,8 @@ export const toFrontendPermission = (
permission: payload.permission, permission: payload.permission,
patterns: payload.patterns, patterns: payload.patterns,
target: payload.target, target: payload.target,
activityId: payload.activity_id,
reason: payload.reason,
always: payload.always, always: payload.always,
tool: payload.tool, tool: payload.tool,
createdAt: payload.created_at, createdAt: payload.created_at,
+44 -9
View File
@@ -180,7 +180,11 @@ export class OpencodeRuntimeAdapter {
return this.messages(sessionId); return this.messages(sessionId);
} }
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) { async prompt(
sessionId: string,
text: string,
model?: RuntimeModelOverride,
) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const startedAt = Date.now(); const startedAt = Date.now();
logDevelopmentDebug( logDevelopmentDebug(
@@ -299,35 +303,41 @@ export class OpencodeRuntimeAdapter {
); );
} }
async subscribeEvents() { async subscribeEvents(directory?: string) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const response = await client.event.subscribe(); const response = await client.event.subscribe(
directory ? { directory } : undefined,
);
return response.stream; return response.stream;
} }
async replyPermission(options: { async replyPermission(options: {
requestId: string; requestId: string;
sessionId?: string; sessionId?: string;
directory?: string;
reply: PermissionReply; reply: PermissionReply;
message?: string; message?: string;
}) { }) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const directory = await this.resolveInteractionDirectory(options);
if ("permission" in client && client.permission?.reply) { if ("permission" in client && client.permission?.reply) {
const response = await client.permission.reply({ const response = await client.permission.reply({
requestID: options.requestId, requestID: options.requestId,
directory,
reply: options.reply, reply: options.reply,
message: options.message, message: options.message,
}); });
return response.data; return requireData(response.data, "permission.reply");
} }
if ("permission" in client && client.permission?.respond && options.sessionId) { if ("permission" in client && client.permission?.respond && options.sessionId) {
const response = await client.permission.respond({ const response = await client.permission.respond({
sessionID: options.sessionId, sessionID: options.sessionId,
permissionID: options.requestId, permissionID: options.requestId,
directory,
response: options.reply, response: options.reply,
}); });
return response.data; return requireData(response.data, "permission.respond");
} }
throw new Error("opencode permission reply API is unavailable"); throw new Error("opencode permission reply API is unavailable");
@@ -336,16 +346,19 @@ export class OpencodeRuntimeAdapter {
async replyQuestion(options: { async replyQuestion(options: {
requestId: string; requestId: string;
sessionId?: string; sessionId?: string;
directory?: string;
answers: QuestionAnswers; answers: QuestionAnswers;
}) { }) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const directory = await this.resolveInteractionDirectory(options);
if ("question" in client && client.question?.reply) { if ("question" in client && client.question?.reply) {
try { try {
const response = await client.question.reply({ const response = await client.question.reply({
requestID: options.requestId, requestID: options.requestId,
directory,
answers: options.answers, answers: options.answers,
}); });
return response.data; return requireData(response.data, "question.reply");
} catch (error) { } catch (error) {
if (!options.sessionId) { if (!options.sessionId) {
throw error; throw error;
@@ -360,6 +373,7 @@ export class OpencodeRuntimeAdapter {
reply?: (parameters: { reply?: (parameters: {
sessionID: string; sessionID: string;
requestID: string; requestID: string;
directory?: string;
questionV2Reply: { answers: QuestionAnswers }; questionV2Reply: { answers: QuestionAnswers };
}) => Promise<{ data: unknown }>; }) => Promise<{ data: unknown }>;
}; };
@@ -371,11 +385,12 @@ export class OpencodeRuntimeAdapter {
const response = await v2Question.reply({ const response = await v2Question.reply({
sessionID: options.sessionId, sessionID: options.sessionId,
requestID: options.requestId, requestID: options.requestId,
directory,
questionV2Reply: { questionV2Reply: {
answers: options.answers, answers: options.answers,
}, },
}); });
return response.data; return requireData(response.data, "question.v2.reply");
} }
throw new Error("opencode question reply API is unavailable"); throw new Error("opencode question reply API is unavailable");
@@ -384,14 +399,17 @@ export class OpencodeRuntimeAdapter {
async rejectQuestion(options: { async rejectQuestion(options: {
requestId: string; requestId: string;
sessionId?: string; sessionId?: string;
directory?: string;
}) { }) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const directory = await this.resolveInteractionDirectory(options);
if ("question" in client && client.question?.reject) { if ("question" in client && client.question?.reject) {
try { try {
const response = await client.question.reject({ const response = await client.question.reject({
requestID: options.requestId, requestID: options.requestId,
directory,
}); });
return response.data; return requireData(response.data, "question.reject");
} catch (error) { } catch (error) {
if (!options.sessionId) { if (!options.sessionId) {
throw error; throw error;
@@ -406,6 +424,7 @@ export class OpencodeRuntimeAdapter {
reject?: (parameters: { reject?: (parameters: {
sessionID: string; sessionID: string;
requestID: string; requestID: string;
directory?: string;
}) => Promise<{ data: unknown }>; }) => Promise<{ data: unknown }>;
}; };
}; };
@@ -416,13 +435,29 @@ export class OpencodeRuntimeAdapter {
const response = await v2Question.reject({ const response = await v2Question.reject({
sessionID: options.sessionId, sessionID: options.sessionId,
requestID: options.requestId, requestID: options.requestId,
directory,
}); });
return response.data; return requireData(response.data, "question.v2.reject");
} }
throw new Error("opencode question reject API is unavailable"); throw new Error("opencode question reject API is unavailable");
} }
private async resolveInteractionDirectory(options: {
sessionId?: string;
directory?: string;
}): Promise<string | undefined> {
const directory = options.directory?.trim();
if (directory) {
return directory;
}
if (!options.sessionId) {
return undefined;
}
const session = await this.getSession(options.sessionId);
return session.directory;
}
async dispose(): Promise<void> { async dispose(): Promise<void> {
if (this.toolOutputCleanupTimer) { if (this.toolOutputCleanupTimer) {
clearInterval(this.toolOutputCleanupTimer); clearInterval(this.toolOutputCleanupTimer);
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "bun:test";
import { createActivityTracker } from "../../src/routes/chatActivityTracker.js";
describe("createActivityTracker", () => {
it("groups actions and closes running children with their activity", () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const tracker = createActivityTracker({
clientSessionId: "client-session-1",
write: (event, data) => events.push({ event, data }),
});
tracker.start("activity-1", "准备分析数据", "需要先获取分析输入。");
tracker.upsertAction(
{
id: "tool-1",
tool: "tjwater_cli",
state: {
status: "running",
input: { command: "data list" },
},
} as never,
{ command: "data list" },
);
tracker.finalize("cancelled");
expect(tracker.getCurrentContext()).toEqual({
id: "activity-1",
title: "准备分析数据",
reason: "需要先获取分析输入。",
});
expect(events.at(-1)).toMatchObject({
event: "activity_update",
data: {
session_id: "client-session-1",
activity: {
id: "activity-1",
status: "cancelled",
actions: [
expect.objectContaining({
id: "tool-1",
status: "completed",
target: "data list",
}),
],
},
},
});
});
});
+512 -35
View File
@@ -16,9 +16,11 @@ const createEventStream = (events: unknown[]) => ({
describe("streamPromptResponse", () => { describe("streamPromptResponse", () => {
it("emits only the final assistant text after tool-driven intermediate messages", async () => { it("emits only the final assistant text after tool-driven intermediate messages", async () => {
let subscribedDirectory: string | undefined;
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async (directory?: string) => {
createEventStream([ subscribedDirectory = directory;
return createEventStream([
{ {
type: "message.part.delta", type: "message.part.delta",
properties: { properties: {
@@ -73,7 +75,8 @@ describe("streamPromptResponse", () => {
type: "session.idle", type: "session.idle",
properties: { sessionID: "runtime-session-1" }, properties: { sessionID: "runtime-session-1" },
}, },
]), ]);
},
prompt: async () => undefined, prompt: async () => undefined,
messages: async () => [ messages: async () => [
{ {
@@ -109,15 +112,166 @@ describe("streamPromptResponse", () => {
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
clientSessionId: "client-session-1", clientSessionId: "client-session-1",
message: "分析管网瓶颈", message: "分析管网瓶颈",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
expect(subscribedDirectory).toBe("/tmp/conversation-workspace-1");
expect(events.filter((item) => item.event === "token")).toEqual([
{
event: "token",
data: {
session_id: "client-session-1",
content: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
},
]);
expect(events.filter((item) => item.event === "final_answer")).toEqual([
{
event: "final_answer",
data: {
session_id: "client-session-1",
content: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
},
]);
});
it("buffers final_answer deltas and emits one complete answer", async () => {
let messagesCalls = 0;
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "commentary-part",
sessionID: "runtime-session-1",
messageID: "assistant-commentary",
type: "text",
text: "",
metadata: { openai: { phase: "commentary" } },
time: { start: 1 },
},
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-commentary",
partID: "commentary-part",
field: "text",
delta: "我先检查相关数据。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "commentary-part",
sessionID: "runtime-session-1",
messageID: "assistant-commentary",
type: "text",
text: "我先检查相关数据。",
metadata: { openai: { phase: "commentary" } },
time: { start: 1, end: 2 },
},
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "",
metadata: { openai: { phase: "final_answer" } },
time: { start: 3 },
},
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-final",
partID: "final-part",
field: "text",
delta: "分析完成,",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-final",
partID: "final-part",
field: "text",
delta: "结果正常。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "分析完成,结果正常。",
metadata: { openai: { phase: "final_answer" } },
time: { start: 3, end: 4 },
},
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => {
messagesCalls += 1;
return [];
},
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "token")?.data.content).toBe(
"分析完成,结果正常。",
);
expect(events.filter((item) => item.event === "final_answer")).toEqual([
{
event: "final_answer",
data: {
session_id: "client-session-1",
content: "分析完成,结果正常。",
},
},
]);
expect(messagesCalls).toBe(0);
expect(events.some((item) => item.event === "progress")).toBe(false);
expect( expect(
events events.some(
.filter((item) => item.event === "token") (item) => item.event === "token" && item.data.content === "我先检查相关数据。",
.map((item) => item.data.content) ),
.join(""), ).toBe(false);
).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。");
}); });
it("uses the final text event cache when the messages lookup fails", async () => { it("uses the final text event cache when the messages lookup fails", async () => {
@@ -160,18 +314,56 @@ describe("streamPromptResponse", () => {
}); });
expect(result.failed).toBe(false); expect(result.failed).toBe(false);
expect( expect(events.find((item) => item.event === "token")?.data.content).toBe(
events "最终分析结果。",
.filter((item) => item.event === "token") );
.map((item) => item.data.content) expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
.join(""), "最终分析结果。",
).toBe("最终分析结果。"); );
}); });
it("keeps reasoning generic while preserving tool execution details", async () => { it("groups concrete tool execution under the current activity", async () => {
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () =>
createEventStream([ createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "activity-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "tool",
callID: "activity-call-1",
tool: "activity_update",
state: {
status: "completed",
input: {
title: "检查管网数据",
reason: "需要确认输入数据是否满足瓶颈分析条件。",
todos: [
{
id: "prepare-data",
content: "准备管网数据",
status: "completed",
priority: "high",
},
{
id: "analyze-data",
content: "分析瓶颈管段",
status: "in_progress",
priority: "medium",
},
],
},
output: "活动阶段已更新。",
time: { start: 1, end: 2 },
},
},
time: 2,
},
},
{ {
type: "message.part.updated", type: "message.part.updated",
properties: { properties: {
@@ -212,7 +404,6 @@ describe("streamPromptResponse", () => {
status: "error", status: "error",
input: { input: {
command: "network get-all-pipes-properties --limit 5000", command: "network get-all-pipes-properties --limit 5000",
reason: "尝试突破分页限制",
}, },
error: "HTTP_422 raw backend payload with trace_id=secret-trace", error: "HTTP_422 raw backend payload with trace_id=secret-trace",
time: { start: 1, end: 2 }, time: { start: 1, end: 2 },
@@ -239,16 +430,37 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
const reasoningProgress = events.find( const activityUpdates = events.filter(
(item) => item.event === "progress" && item.data.id === "reasoning-part-1", (item) => item.event === "activity_update" &&
); (item.data.activity as { id?: string } | undefined)?.id === "activity-part-1",
const toolProgress = events.find(
(item) => item.event === "progress" && item.data.id === "tool-part-1",
);
expect(reasoningProgress?.data.detail).toBe("分析步骤已整理完成。");
expect(toolProgress?.data.detail).toBe(
"tjwater_cli 调用失败;调用原因:尝试突破分页限制;关键参数:command=network get-all-pipes-properties --limit 5000;错误:HTTP_422 raw backend payload with trace_id=secret-trace",
); );
expect(activityUpdates.at(-1)?.data.activity).toMatchObject({
title: "检查管网数据",
reason: "需要确认输入数据是否满足瓶颈分析条件。",
actions: [
expect.objectContaining({
id: "tool-part-1",
tool: "tjwater_cli",
status: "error",
target: "network get-all-pipes-properties --limit 5000",
}),
],
});
expect(activityUpdates[0]?.data.todos).toEqual([
expect.objectContaining({
id: "prepare-data",
content: "准备管网数据",
status: "completed",
priority: "high",
}),
expect.objectContaining({
id: "analyze-data",
content: "分析瓶颈管段",
status: "in_progress",
priority: "medium",
}),
]);
expect(events.some((item) => item.event === "progress")).toBe(false);
}); });
it("forwards opencode permission requests as SSE payloads", async () => { it("forwards opencode permission requests as SSE payloads", async () => {
@@ -293,6 +505,8 @@ describe("streamPromptResponse", () => {
permission: "bash", permission: "bash",
patterns: ["rm *"], patterns: ["rm *"],
target: "rm tmp.txt", target: "rm tmp.txt",
activity_id: "activity-startup",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
always: ["rm *"], always: ["rm *"],
} satisfies Partial<PermissionRequestPayload>); } satisfies Partial<PermissionRequestPayload>);
}); });
@@ -334,6 +548,7 @@ describe("streamPromptResponse", () => {
clientSessionId: "client-session-1", clientSessionId: "client-session-1",
message: "run tests", message: "run tests",
approvalMode: "auto", approvalMode: "auto",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
@@ -341,6 +556,7 @@ describe("streamPromptResponse", () => {
{ {
requestId: "perm-1", requestId: "perm-1",
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "once", reply: "once",
}, },
]); ]);
@@ -422,6 +638,7 @@ describe("streamPromptResponse", () => {
clientSessionId: "client-session-1", clientSessionId: "client-session-1",
message: "run tests", message: "run tests",
approvalMode: "always", approvalMode: "always",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
@@ -429,6 +646,7 @@ describe("streamPromptResponse", () => {
{ {
requestId: "perm-always-bash", requestId: "perm-always-bash",
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "once", reply: "once",
}, },
]); ]);
@@ -465,6 +683,7 @@ describe("streamPromptResponse", () => {
clientSessionId: "client-session-1", clientSessionId: "client-session-1",
message: "delete recursively", message: "delete recursively",
approvalMode: "always", approvalMode: "always",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
@@ -472,6 +691,7 @@ describe("streamPromptResponse", () => {
{ {
requestId: "perm-always-rm-rf", requestId: "perm-always-rm-rf",
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "reject", reply: "reject",
}, },
]); ]);
@@ -681,10 +901,35 @@ describe("streamPromptResponse", () => {
).toBe(false); ).toBe(false);
}); });
it("forwards todo updates as structured SSE payloads and progress", async () => { it("forwards todo updates independently from activity progress", async () => {
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () =>
createEventStream([ createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "todo-tool-part",
sessionID: "runtime-session-1",
messageID: "assistant-plan",
type: "tool",
callID: "todo-tool-call",
tool: "todowrite",
state: {
status: "completed",
input: {
todos: [
{ content: "分析水位", status: "completed", priority: "high" },
{ content: "生成建议", status: "in_progress", priority: "medium" },
],
},
output: "计划已更新",
time: { start: 1, end: 2 },
},
},
},
},
{ {
type: "todo.updated", type: "todo.updated",
properties: { properties: {
@@ -715,15 +960,7 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
expect( expect(events.some((item) => item.event === "progress")).toBe(false);
events.find(
(item) => item.event === "progress" && item.data.id === "todo-progress",
)?.data,
).toMatchObject({
id: "todo-progress",
phase: "planning",
title: "计划进度 1/2",
});
expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({ expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({
session_id: "client-session-1", session_id: "client-session-1",
todos: [ todos: [
@@ -739,6 +976,246 @@ describe("streamPromptResponse", () => {
}), }),
], ],
}); });
expect(
events.some(
(item) =>
item.event === "activity_update" &&
((item.data.activity as { actions?: Array<{ tool?: string }> } | undefined)
?.actions ?? [])
.some((action) => action.tool === "todowrite"),
),
).toBe(false);
expect(
events.some(
(item) => item.event === "tool_call" && item.data.tool === "todowrite",
),
).toBe(false);
});
it("buffers the voluntary DeepSeek final answer tool without forcing tool choice", async () => {
const promptCalls: unknown[][] = [];
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const runtime = {
subscribeEvents: async () => ({
async *[Symbol.asyncIterator]() {
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "running",
input: { answer: "供水服务分区" },
time: { start: 1 },
},
},
},
};
expect(
events
.filter((item) => item.event === "final_answer")
.map((item) => item.data.content)
.join(""),
).toBe("");
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "running",
input: { answer: "供水服务分区分析已完成。" },
time: { start: 1 },
},
},
},
};
expect(
events
.filter((item) => item.event === "final_answer")
.map((item) => item.data.content)
.join(""),
).toBe("");
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "completed",
input: { answer: "供水服务分区分析已完成。" },
output: "最终回答已提交。",
title: "final_answer",
metadata: {},
time: { start: 1, end: 2 },
},
},
},
};
yield {
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
};
},
}),
prompt: async (...args: unknown[]) => {
promptCalls.push(args);
},
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析供水服务分区",
model: "deepseek/deepseek-v4-flash",
write: (event, data) => events.push({ event, data }),
});
expect(promptCalls[0]?.[3]).toBeUndefined();
expect(result).toEqual({ aborted: false, failed: false, toolCallCount: 0 });
expect(events.find((item) => item.event === "token")?.data.content).toBe(
"供水服务分区分析已完成。",
);
expect(events.filter((item) => item.event === "final_answer")).toEqual([
{
event: "final_answer",
data: {
session_id: "client-session-1",
content: "供水服务分区分析已完成。",
},
},
]);
expect(events.some((item) => item.event === "tool_call")).toBe(false);
expect(events.some((item) => item.event === "done")).toBe(true);
});
it("keeps the pending event read when prompt resolves before final_answer", async () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const runtime = {
subscribeEvents: async () => ({
async *[Symbol.asyncIterator]() {
await new Promise((resolve) => setTimeout(resolve, 0));
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "completed",
input: { answer: "最终答案不会因事件竞争而丢失。" },
output: "最终回答已提交。",
title: "final_answer",
metadata: {},
time: { start: 1, end: 2 },
},
},
},
};
yield {
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
};
},
}),
prompt: async () => undefined,
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网",
write: (event, data) => events.push({ event, data }),
});
expect(result.failed).toBe(false);
expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
"最终答案不会因事件竞争而丢失。",
);
expect(events.at(-1)?.event).toBe("done");
});
it("recovers a persisted final_answer tool result from message history", async () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.updated",
properties: {
sessionID: "runtime-session-1",
info: { id: "assistant-final", role: "assistant" },
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => [
{
info: { id: "assistant-final", role: "assistant" },
parts: [
{
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "completed",
input: { answer: "已从持久化工具结果恢复最终答案。" },
output: "最终回答已提交。",
title: "final_answer",
metadata: {},
time: { start: 1, end: 2 },
},
},
],
},
],
} as unknown as OpencodeRuntimeAdapter;
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
"已从持久化工具结果恢复最终答案。",
);
}); });
}); });
+52 -2
View File
@@ -3,15 +3,40 @@ import { describe, expect, it } from "bun:test";
import { import {
appendBackendToolArtifact, appendBackendToolArtifact,
cancelBackendTodos, cancelBackendTodos,
completeBackendActivities,
completeBackendTodos,
upsertBackendQuestion, upsertBackendQuestion,
} from "../../src/routes/chatUiState.js"; } from "../../src/routes/chatUiState.js";
describe("completeBackendActivities", () => {
it("closes running child actions when the stream terminates", () => {
const activities = completeBackendActivities([
{
id: "activity-1",
status: "running",
startedAt: Date.now() - 100,
actions: [
{
id: "action-1",
status: "running",
startedAt: Date.now() - 50,
},
],
},
], "error") as Array<Record<string, unknown>>;
expect(activities[0]).toMatchObject({
status: "error",
actions: [expect.objectContaining({ status: "error" })],
});
});
});
describe("appendBackendToolArtifact", () => { describe("appendBackendToolArtifact", () => {
it("persists show_chart tool calls as chart artifacts", () => { it("persists show_chart tool calls as chart artifacts", () => {
const artifacts = appendBackendToolArtifact([], { const artifacts = appendBackendToolArtifact([], {
session_id: "session-1", session_id: "session-1",
tool: "show_chart", tool: "show_chart",
reason: "测试折线图渲染",
params: { params: {
title: "压力曲线", title: "压力曲线",
chart_type: "line", chart_type: "line",
@@ -25,7 +50,6 @@ describe("appendBackendToolArtifact", () => {
tool: "show_chart", tool: "show_chart",
kind: "chart", kind: "chart",
title: "压力曲线", title: "压力曲线",
description: "测试折线图渲染",
params: { params: {
chart_type: "line", chart_type: "line",
x_data: ["00:00", "01:00"], x_data: ["00:00", "01:00"],
@@ -160,3 +184,29 @@ describe("cancelBackendTodos", () => {
]); ]);
}); });
}); });
describe("completeBackendTodos", () => {
it("marks pending and in-progress todos as completed after a successful run", () => {
const completed = completeBackendTodos([
{
sessionId: "session-1",
todos: [
{ id: "todo-1", content: "分析水位", status: "in_progress" },
{ id: "todo-2", content: "生成建议", status: "pending" },
{ id: "todo-3", content: "完成报告", status: "completed" },
],
createdAt: 123,
},
]);
expect(completed).toEqual([
expect.objectContaining({
todos: [
expect.objectContaining({ id: "todo-1", status: "completed" }),
expect.objectContaining({ id: "todo-2", status: "completed" }),
expect.objectContaining({ id: "todo-3", status: "completed" }),
],
}),
]);
});
});
+84 -1
View File
@@ -90,6 +90,84 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
}); });
}); });
describe("OpencodeRuntimeAdapter.subscribeEvents", () => {
it("subscribes to the conversation workspace directory", async () => {
const calls: Array<Record<string, unknown> | undefined> = [];
const stream = (async function* () {
return;
})();
const client = {
event: {
subscribe: async (input?: Record<string, unknown>) => {
calls.push(input);
return { stream };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await expect(
runtime.subscribeEvents("/tmp/conversation-workspace-1"),
).resolves.toBe(stream);
expect(calls).toEqual([
{ directory: "/tmp/conversation-workspace-1" },
]);
});
});
describe("OpencodeRuntimeAdapter interaction replies", () => {
it("replies to permissions in the conversation workspace directory", async () => {
const calls: unknown[] = [];
const client = {
permission: {
reply: async (input: unknown) => {
calls.push(input);
return { data: true };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await runtime.replyPermission({
requestId: "permission-1",
sessionId: "session-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
});
expect(calls).toEqual([
{
requestID: "permission-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
},
]);
});
it("fails when OpenCode returns no permission reply data", async () => {
const client = {
permission: {
reply: async () => ({ data: undefined }),
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await expect(
runtime.replyPermission({
requestId: "permission-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
}),
).rejects.toThrow("permission.reply returned no data");
});
});
describe("OpencodeRuntimeAdapter.createSession", () => { describe("OpencodeRuntimeAdapter.createSession", () => {
it("creates a real chat session inside a dedicated conversation workspace", async () => { it("creates a real chat session inside a dedicated conversation workspace", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-")); const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-"));
@@ -225,11 +303,16 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
await runtime.replyQuestion({ await runtime.replyQuestion({
requestId: "question-1", requestId: "question-1",
sessionId: "session-1", sessionId: "session-1",
directory: "/tmp/conversation-workspace-1",
answers: [["继续"]], answers: [["继续"]],
}); });
expect(calls).toEqual([ expect(calls).toEqual([
{ requestID: "question-1", answers: [["继续"]] }, {
requestID: "question-1",
directory: "/tmp/conversation-workspace-1",
answers: [["继续"]],
},
]); ]);
}); });
}); });
+33 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { readFile } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import sandboxBash from "../../.opencode/tools/bash.js"; import sandboxBash from "../../.opencode/tools/bash.js";
import tjwaterCli from "../../.opencode/tools/tjwater_cli.js"; import tjwaterCli from "../../.opencode/tools/tjwater_cli.js";
import storeRenderRef, { import storeRenderRef, {
@@ -37,6 +37,8 @@ describe("internal OpenCode permissions", () => {
expect(permission.external_directory).toBe("deny"); expect(permission.external_directory).toBe("deny");
expect(permission.task).toBe("deny"); expect(permission.task).toBe("deny");
expect(permission.question).toBe("allow"); expect(permission.question).toBe("allow");
expect(permission.activity_update).toBe("allow");
expect(permission.final_answer).toBe("allow");
expect(permission.todowrite).toBe("allow"); expect(permission.todowrite).toBe("allow");
expect(read?.["*"]).toBe("allow"); expect(read?.["*"]).toBe("allow");
expect(read?.["data/**"]).toBe("deny"); expect(read?.["data/**"]).toBe("deny");
@@ -56,6 +58,22 @@ describe("internal OpenCode permissions", () => {
expect(bash?.["*data/*"]).toBeUndefined(); expect(bash?.["*data/*"]).toBeUndefined();
expect(bash?.["*logs/*"]).toBeUndefined(); expect(bash?.["*logs/*"]).toBeUndefined();
}); });
it("keeps reason only on the activity grouping tool", async () => {
const toolFiles = (await readdir(".opencode/tools"))
.filter((file) => file.endsWith(".ts"));
const sources = await Promise.all(
toolFiles.map(async (file) => ({
file,
source: await readFile(`.opencode/tools/${file}`, "utf8"),
})),
);
const reasonSchemaFiles = sources
.filter(({ source }) => /reason:\s*tool\.schema/u.test(source))
.map(({ file }) => file);
expect(reasonSchemaFiles).toEqual(["activity_update.ts"]);
});
}); });
describe("store_render_ref arguments", () => { describe("store_render_ref arguments", () => {
@@ -154,6 +172,20 @@ describe("sandbox bash tool", () => {
}); });
describe("tjwater_cli storage request", () => { describe("tjwater_cli storage request", () => {
it("keeps command discovery rules in the always-visible tool contract", () => {
const definition = tjwaterCli as unknown as {
description: string;
args: { command: { description?: string } };
};
expect(definition.description).toContain("help");
expect(definition.args.command.description).toContain("禁止类推");
expect(definition.args.command.description).toContain("simulation runs list");
expect(definition.args.command.description).not.toContain(
"示例:'analysis runs list'",
);
});
it("forwards store_result so small workflow inputs can be staged", async () => { it("forwards store_result so small workflow inputs can be staged", async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
let requestBody: unknown; let requestBody: unknown;
@@ -168,7 +200,6 @@ describe("tjwater_cli storage request", () => {
await definition.execute( await definition.execute(
{ {
command: "network get-all-reservoirs-properties", command: "network get-all-reservoirs-properties",
reason: "prepare workflow input",
store_result: true, store_result: true,
}, },
{ sessionID: "session-test" } as never, { sessionID: "session-test" } as never,