Compare commits
3
Commits
v2026.08.25.3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ebf4d6c1 | ||
|
|
a438433068 | ||
|
|
4ec010455b |
@@ -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
|
||||||
|
|||||||
@@ -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...
|
||||||
|
|||||||
+5
-3
@@ -3,6 +3,8 @@
|
|||||||
水力瓶颈管道综合分析
|
水力瓶颈管道综合分析
|
||||||
数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议
|
数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议
|
||||||
注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。
|
注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。
|
||||||
|
schema 适配(2026-08 实测):realtime links 主键为 link_id,realtime 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:00,target-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)
|
||||||
|
|||||||
@@ -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 "活动阶段已更新。";
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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'."),
|
||||||
|
|||||||
@@ -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 "最终回答已提交。";
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 北京市人民政府."),
|
||||||
|
|||||||
@@ -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."),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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_DIR,location.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。",
|
"在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIR,location.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(
|
||||||
|
|||||||
@@ -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."),
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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` 不触发续期。
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
+239
-272
@@ -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) {
|
||||||
runtime,
|
finalAnswerText = await resolveFinalMessage(
|
||||||
sessionId,
|
runtime,
|
||||||
clientSessionId,
|
sessionId,
|
||||||
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({
|
});
|
||||||
id: "request-completed",
|
write("final_answer", {
|
||||||
phase: "complete",
|
session_id: clientSessionId,
|
||||||
status: "completed",
|
content: finalAnswerText,
|
||||||
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
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
"已从持久化工具结果恢复最终答案。",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: [["继续"]],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user