feat(agent): 完善分析编排与结果传输

This commit is contained in:
2026-08-26 18:04:51 +08:00
parent a438433068
commit 7948e3a02a
33 changed files with 1027 additions and 705 deletions
+7 -2
View File
@@ -8,10 +8,13 @@ model: deepseek/deepseek-v4-flash
## 回复要求 ## 回复要求
- 工具执行期间不输出过程说明,全部完成后只回复最终结果 - 工具执行期间不输出过程说明,全部完成后只回复最终结果
- 最终回答必须通过 `final_answer` 提交;调用前必须完成全部业务动作和其他工具,调用后禁止继续调用工具或输出额外文本
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步 - 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
- 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划,并在每个里程碑更新状态;简单问答不创建计划 - 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划;简单问答不创建计划
- `todowrite` 是面向用户的业务任务摘要:每项只描述目标或可验证结果,不出现函数名、脚本/文件名、命令、工具名、参数、内部目录或具体修复实现;这些技术细节仅保留在工具过程信息中 - `todowrite` 是面向用户的业务任务摘要:每项只描述目标或可验证结果,不出现函数名、脚本/文件名、命令、工具名、参数、内部目录或具体修复实现;这些技术细节仅保留在工具过程信息中
- 任务标题使用简洁的业务语言,例如“准备供水分区所需数据”“计算供水服务范围”“生成并展示分析结果”“整理可复用分析经验” - 任务标题使用简洁的业务语言,例如“准备供水分区所需数据”“计算供水服务范围”“生成并展示分析结果”“整理可复用分析经验”
- 开始工作及每次进入新的业务阶段时调用一次 `activity_update`,用 `title` 概括当前阶段、用 `reason` 说明该阶段为何必要;已有计划时必须通过 `todos` 提交完整计划状态快照,使阶段与任务状态同时更新,不再单独调用 `todowrite` 更新里程碑
- `activity_update` 是过程分组,不是任务清单:活动描述当前正在做的一组动作,`todowrite` 描述整个任务的业务目标与完成状态
## 工作流生命周期 ## 工作流生命周期
@@ -44,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
+1 -9
View File
@@ -15,7 +15,6 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输
```json ```json
{ {
"reason": "说明调用原因",
"command": "project list", "command": "project list",
"timeout": 120, "timeout": 120,
"store_result": false "store_result": false
@@ -24,7 +23,6 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| `reason` | string | 是 | 调用原因 |
| `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context` | | `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context` |
| `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ | | `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ |
| `store_result` | boolean | 否 | 强制保存到当前对话目录并返回 `data_file.file_path`;分析脚本需要文件输入时设为 true | | `store_result` | boolean | 否 | 强制保存到当前对话目录并返回 `data_file.file_path`;分析脚本需要文件输入时设为 true |
@@ -130,7 +128,7 @@ tjwater-cli help COMMAND → 子命令与参数详情
## 最佳实践 ## 最佳实践
1. **禁止猜测命令** — 执行任何命令前必须先 `tjwater_cli(command="help ...")` 确认命令存在及参数签名,参数均已写在 help 中,禁止凭经验拼写 1. **禁止猜测命令** — 执行任何命令前必须先 `tjwater_cli(command="help ...")` 确认命令存在及参数签名,参数均已写在 help 中,禁止凭经验拼写
2. **reason 必填** — 每次调用必须说明具体理由 2. **阶段分组** — 调用 CLI 前确认当前业务阶段已通过 `activity_update` 建立,同一阶段的多个查询无需重复说明理由
3. **按运行 ID 取结果** — 分析完成后先用 `analysis runs list/get/results` 获取 `run_id` 和非时序结果;元素时序再用 `data timeseries analysis` 查询 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` 字段,失败时先处理错误码再重试
@@ -142,7 +140,6 @@ tjwater-cli help COMMAND → 子命令与参数详情
### 查询所有实时节点数据 ### 查询所有实时节点数据
```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"
} }
``` ```
@@ -151,7 +148,6 @@ tjwater-cli help COMMAND → 子命令与参数详情
### 按节点查询分析运行时序字段 ### 按节点查询分析运行时序字段
```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 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"
} }
``` ```
@@ -159,7 +155,6 @@ tjwater-cli help COMMAND → 子命令与参数详情
### 查询 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"
} }
``` ```
@@ -171,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"
} }
``` ```
@@ -150,4 +150,5 @@ if length < 0.01 and headloss > 0.5 → "短管高水损,检查是否存在模
- 脚本读取全量 JSON 入内存,峰值内存约 200-300MB,需确保执行环境有足够内存。 - 脚本读取全量 JSON 入内存,峰值内存约 200-300MB,需确保执行环境有足够内存。
## Learned Patterns ## 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... - [5ba58cd24c9cea84b6ab5861] **数据 schema 实测适配(2026-04 验证)**`data timeseries realtime links` 返回记录的管道主键为 `link_id`(不是 `id`),`data timeseries realtime nodes` 返回记录的节点主键为 `node_id`(不是 `id`),且 `time` 字段为 UTC 格式(如 `2026-04-01T00:00:00+00:00`)。运行 `bottleneck_analysis.py` 前需:① 脚本内将 `r['id']` 改为 `r['link_id']``n['id']` 改为 `n['node_id']`;② `--target-tim...
@@ -3,6 +3,8 @@
水力瓶颈管道综合分析 水力瓶颈管道综合分析
数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议 数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议
注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。 注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。
schema 适配(2026-08 实测):realtime links 主键为 link_idrealtime nodes 主键为 node_id
time 为 UTC 格式,--target-time 需传 UTC 时刻(如北京时间 08:00 对应 00:00+00:00)。
""" """
import json, sys, math, argparse import json, sys, math, argparse
from collections import defaultdict from collections import defaultdict
@@ -38,16 +40,16 @@ def main():
np = load_json(args.node_pressures).get('data', []) np = load_json(args.node_pressures).get('data', [])
np = [n for n in np if n.get('time') == TT] np = [n for n in np if n.get('time') == TT]
node_pressure = {n['id']: n.get('pressure', n.get('value',0)) for n in np} node_pressure = {n['node_id']: n.get('pressure', n.get('value',0)) for n in np}
print(f" Pipes: {len(props)}, Realtime: {len(rt)}, Node pressures: {len(node_pressure)}", file=sys.stderr) print(f" Pipes: {len(props)}, Realtime: {len(rt)}, Node pressures: {len(node_pressure)}", file=sys.stderr)
# 2. Merge # 2. Merge
print("[2/5] Merging...", file=sys.stderr) print("[2/5] Merging...", file=sys.stderr)
merged = [] merged = []
for r in rt: for r in rt:
pid = r['id']; prop = pipe_map.get(pid) pid = r['link_id']; prop = pipe_map.get(pid)
if prop: if prop:
merged.append({**prop, **r, '_prop_id': prop['id'], '_rt_id': r['id']}) merged.append({**prop, **r, '_prop_id': prop['id'], '_rt_id': r['link_id']})
print(f" Merged: {len(merged)}", file=sys.stderr) print(f" Merged: {len(merged)}", file=sys.stderr)
# 3. Score # 3. Score
+37
View File
@@ -0,0 +1,37 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"开始一个新的业务活动阶段。仅在语义阶段发生变化时调用一次,用 title 概括阶段,用 reason 说明本阶段为何必要;同一阶段内的多个工具动作不要重复调用。该工具只更新用户可见的过程信息,不执行外部操作。",
args: {
title: tool.schema
.string()
.min(1)
.describe("面向用户的简短业务阶段标题,不包含工具名、函数名、文件名或命令。"),
reason: tool.schema
.string()
.min(1)
.describe("本阶段对完成用户目标的必要性,使用一句简洁的自然语言。"),
todos: tool.schema
.array(
tool.schema.object({
id: tool.schema.string().optional(),
content: tool.schema.string().min(1),
status: tool.schema.enum([
"pending",
"in_progress",
"completed",
"cancelled",
]),
priority: tool.schema.enum(["low", "medium", "high"]).optional(),
}),
)
.optional()
.describe(
"已有任务计划时提交完整状态快照,使本阶段与任务状态在同一次更新中生效。",
),
},
async execute() {
return "活动阶段已更新。";
},
});
-5
View File
@@ -4,11 +4,6 @@ export default tool({
description: description:
"在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。", "在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。",
args: { args: {
reason: tool.schema
.string()
.describe(
"Why this style action is needed for the current user request.",
),
layer_id: tool.schema layer_id: tool.schema
.enum(["junctions", "pipes"]) .enum(["junctions", "pipes"])
.describe("Target layer id. Must be exactly 'junctions' or 'pipes'."), .describe("Target layer id. Must be exactly 'junctions' or 'pipes'."),
+15
View File
@@ -0,0 +1,15 @@
import { tool } from "@opencode-ai/plugin";
export default tool({
description:
"提交直接展示给用户的最终回答。只能在全部业务动作和其他工具调用完成后调用一次;调用后不得继续调用任何工具或输出额外文本。",
args: {
answer: tool.schema
.string()
.min(1)
.describe("直接展示给用户的完整最终回答,使用简体中文和 Markdown。"),
},
async execute() {
return "最终回答已提交。";
},
});
-3
View File
@@ -8,9 +8,6 @@ export default tool({
description: description:
"调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。", "调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。",
args: { args: {
reason: tool.schema
.string()
.describe("Why geocoding is required for the current user request."),
keyword: tool.schema keyword: tool.schema
.string() .string()
.describe("Address or place name to geocode, such as 北京市人民政府."), .describe("Address or place name to geocode, such as 北京市人民政府."),
-5
View File
@@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: "在前端地图上定位并高亮指定的管网要素。", description: "在前端地图上定位并高亮指定的管网要素。",
args: { args: {
reason: tool.schema
.string()
.describe(
"Why this map positioning action is needed for the user request.",
),
ids: tool.schema ids: tool.schema
.array(tool.schema.string()) .array(tool.schema.string())
.describe("Feature ids to locate."), .describe("Feature ids to locate."),
-3
View File
@@ -11,9 +11,6 @@ export default tool({
action: tool.schema action: tool.schema
.enum(["add", "list", "replace", "remove"]) .enum(["add", "list", "replace", "remove"])
.describe("Memory operation to perform."), .describe("Memory operation to perform."),
reason: tool.schema
.string()
.describe("Why this memory should be persisted for future requests."),
scope: tool.schema scope: tool.schema
.string() .string()
.describe( .describe(
-5
View File
@@ -4,11 +4,6 @@ export default tool({
description: description:
"在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIRlocation.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。", "在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIRlocation.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。",
args: { args: {
reason: tool.schema
.string()
.describe(
"Why this junction rendering action is needed for the user request.",
),
render_ref: tool.schema render_ref: tool.schema
.string() .string()
.describe( .describe(
-3
View File
@@ -8,9 +8,6 @@ export default tool({
description: description:
"搜索当前用户和项目范围内的历史会话 transcript。适合回忆过去讨论过的案例、约束和结论,避免把一次性案例写入 memory。", "搜索当前用户和项目范围内的历史会话 transcript。适合回忆过去讨论过的案例、约束和结论,避免把一次性案例写入 memory。",
args: { args: {
reason: tool.schema
.string()
.describe("Why prior session history is needed for the current request."),
query: tool.schema query: tool.schema
.string() .string()
.describe("What to search for in prior session history."), .describe("What to search for in prior session history."),
-3
View File
@@ -4,9 +4,6 @@ export default tool({
description: description:
"在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。", "在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。",
args: { args: {
reason: tool.schema
.string()
.describe("Why this chart should be rendered for the user request."),
title: tool.schema.string().optional().describe("Chart title."), title: tool.schema.string().optional().describe("Chart title."),
chart_type: tool.schema chart_type: tool.schema
.enum(["line", "bar", "pie"]) .enum(["line", "bar", "pie"])
-3
View File
@@ -21,9 +21,6 @@ export default tool({
"remove_script", "remove_script",
]) ])
.describe("Skill maintenance operation."), .describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe("Why this skill maintenance action is justified for future reuse."),
skill_path: tool.schema skill_path: tool.schema
.string() .string()
.describe( .describe(
-5
View File
@@ -23,11 +23,6 @@ export default tool({
description: description:
"导入当前对话工作目录下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } }location.file_path 必须与传入的绝对路径完全一致。只接受当前对话工作目录内的真实文件,不接受其他对话目录、目录外路径或指向目录外的符号链接。", "导入当前对话工作目录下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } }location.file_path 必须与传入的绝对路径完全一致。只接受当前对话工作目录内的真实文件,不接受其他对话目录、目录外路径或指向目录外的符号链接。",
args: { args: {
reason: tool.schema
.string()
.describe(
"为何需要将此本地渲染数据持久化为 render_ref,以便后续通过 render_junctions 渲染到前端。",
),
file_path: tool.schema file_path: tool.schema
.string() .string()
.optional() .optional()
+2 -6
View File
@@ -6,15 +6,12 @@ const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({ export default tool({
description: description:
"通过本地 Agent 桥接调用 tjwater-cli 命令访问 TJWater 后端服务。提供 CLI 子命令和参数。", "通过本地 Agent 桥接调用 tjwater-cli命令路径和参数不是可自由拼接的语法;若当前会话或已加载工作流没有经过验证的完整命令,必须先调用 help 或 help <命令族>,再从返回的 command、usage 和 options 中选择。",
args: { args: {
reason: tool.schema
.string()
.describe("Why this tool call is required for the current user request."),
command: tool.schema command: tool.schema
.string() .string()
.describe( .describe(
"tjwater-cli 子命令,不含二进制路径。示例:'analysis runs list'、'data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00'", "不含二进制路径。只可使用 help 响应或已验证工作流中出现的完整命令路径和参数,禁止类推不同命令族的层级,例如 analysis runs list 存在不代表 simulation runs list 存在。无法确认时调用 'help'、'help simulation' 或相应前缀的 help。",
), ),
timeout: tool.schema timeout: tool.schema
.number() .number()
@@ -38,7 +35,6 @@ export default tool({
}, },
body: JSON.stringify({ body: JSON.stringify({
session_id: context.sessionID, session_id: context.sessionID,
reason: args.reason,
command: args.command, command: args.command,
store_result: args.store_result, store_result: args.store_result,
timeout: args.timeout, timeout: args.timeout,
-5
View File
@@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: "为选定的管网要素打开前端的历史记录或计算结果面板。", description: "为选定的管网要素打开前端的历史记录或计算结果面板。",
args: { args: {
reason: tool.schema
.string()
.describe(
"Why this history panel should be opened for the current task.",
),
feature_infos: tool.schema feature_infos: tool.schema
.array(tool.schema.tuple([tool.schema.string(), tool.schema.string()])) .array(tool.schema.tuple([tool.schema.string(), tool.schema.string()]))
.describe("List of [id, type] pairs."), .describe("List of [id, type] pairs."),
-3
View File
@@ -3,9 +3,6 @@ import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: "打开前端的 SCADA 监测数据历史面板。", description: "打开前端的 SCADA 监测数据历史面板。",
args: { args: {
reason: tool.schema
.string()
.describe("Why SCADA panel interaction is required for this request."),
device_ids: tool.schema device_ids: tool.schema
.array(tool.schema.string()) .array(tool.schema.string())
.optional() .optional()
-3
View File
@@ -8,9 +8,6 @@ export default tool({
description: description:
"调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。", "调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。",
args: { args: {
reason: tool.schema
.string()
.describe("Why web search is required for the current user request."),
query: tool.schema.string().describe("Search query text."), query: tool.schema.string().describe("Search query text."),
freshness: tool.schema freshness: tool.schema
.enum(["no_limit", "one_day", "one_week", "one_month", "one_year"]) .enum(["no_limit", "one_day", "one_week", "one_month", "one_year"])
-3
View File
@@ -4,9 +4,6 @@ export default tool({
description: description:
"在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。", "在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。",
args: { args: {
reason: tool.schema
.string()
.describe("Why this map zoom action is needed for the current request."),
x: tool.schema x: tool.schema
.number() .number()
.describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."), .describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."),
+2
View File
@@ -60,6 +60,8 @@
"*.env*": "deny" "*.env*": "deny"
}, },
"question": "allow", "question": "allow",
"activity_update": "allow",
"final_answer": "allow",
"task": "deny", "task": "deny",
"todo": "allow", "todo": "allow",
"todoread": "allow", "todoread": "allow",
+4 -3
View File
@@ -4,14 +4,15 @@ import { dirname } from "node:path";
import { config } from "../config.js"; import { config } from "../config.js";
export type LlmRequestAuditEntry = { export type LlmRequestAuditEntry = {
kind: "tool" | "skill"; kind: "activity" | "tool" | "skill";
sessionId: string; sessionId: string;
clientSessionId: string; clientSessionId: string;
traceId?: string; traceId?: string;
projectId?: string; projectId?: string;
target: string; target: string;
reason: string; activityId?: string;
reasonProvided: boolean; activityTitle?: string;
activityReason?: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
}; };
+30
View File
@@ -35,6 +35,7 @@ import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js"; import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
import { import {
collectTextContent, collectTextContent,
type ActivityUpdatePayload,
type PermissionRequestPayload, type PermissionRequestPayload,
type QuestionRequestPayload, type QuestionRequestPayload,
streamPromptResponse, streamPromptResponse,
@@ -46,7 +47,9 @@ import {
type StreamSubscriber, type StreamSubscriber,
appendBackendToolArtifact, appendBackendToolArtifact,
cancelBackendTodos, cancelBackendTodos,
completeBackendActivities,
completeBackendProgress, completeBackendProgress,
completeBackendTodos,
createInitialStreamingMessages, createInitialStreamingMessages,
isObjectRecord, isObjectRecord,
toFrontendPermission, toFrontendPermission,
@@ -54,6 +57,7 @@ import {
updateLastAssistantMessage, updateLastAssistantMessage,
updateLastAssistantPermission, updateLastAssistantPermission,
updateLastAssistantQuestion, updateLastAssistantQuestion,
upsertBackendActivity,
upsertBackendProgress, upsertBackendProgress,
upsertBackendQuestion, upsertBackendQuestion,
upsertBackendTodoUpdate, upsertBackendTodoUpdate,
@@ -687,6 +691,27 @@ export const buildChatRouter = (
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`, content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false, isError: false,
})); }));
} else if (event === "final_answer") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: typeof data.content === "string" ? data.content : "",
isError: false,
}));
} else if (event === "activity_update") {
const payload = data as ActivityUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
activities: upsertBackendActivity(message.activities, payload.activity),
...(payload.todos
? {
todos: upsertBackendTodoUpdate(message.todos, {
session_id: payload.session_id,
todos: payload.todos,
created_at: payload.todos_created_at ?? Date.now(),
}),
}
: {}),
}));
} else if (event === "progress") { } else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, ...message,
@@ -702,6 +727,8 @@ export const buildChatRouter = (
? message.content ? message.content
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。", : "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities),
todos: completeBackendTodos(message.todos),
})); }));
} else if (event === "error") { } else if (event === "error") {
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error"; activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
@@ -714,6 +741,7 @@ export const buildChatRouter = (
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`, : `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true, isError: true,
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "error"),
todos: cancelBackendTodos(message.todos), todos: cancelBackendTodos(message.todos),
})); }));
} else if (event === "auth_required") { } else if (event === "auth_required") {
@@ -727,6 +755,7 @@ export const buildChatRouter = (
: "⚠️ **登录态已过期,请刷新登录后重试**", : "⚠️ **登录态已过期,请刷新登录后重试**",
isError: true, isError: true,
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "error"),
todos: cancelBackendTodos(message.todos), todos: cancelBackendTodos(message.todos),
})); }));
} else if (event === "permission_request") { } else if (event === "permission_request") {
@@ -968,6 +997,7 @@ export const buildChatRouter = (
: "⚠️ **请求已中断**", : "⚠️ **请求已中断**",
isError: true, isError: true,
progress: completeBackendProgress(message.progress), progress: completeBackendProgress(message.progress),
activities: completeBackendActivities(message.activities, "cancelled"),
todos: cancelBackendTodos(message.todos), todos: cancelBackendTodos(message.todos),
})); }));
void queueSessionUiStatePersist().catch((error) => { void queueSessionUiStatePersist().catch((error) => {
+180
View File
@@ -0,0 +1,180 @@
import type { Part } from "@opencode-ai/sdk/v2";
import {
getToolLabel,
normalizeToolStatus,
type ActivityActionPayload,
type ActivityPayload,
type ActivityStatus,
type ActivityUpdatePayload,
type TodoItemPayload,
} from "./chatStreamEvents.js";
type ToolPart = Extract<Part, { type: "tool" }>;
type ActivityContext = Pick<ActivityPayload, "id" | "title" | "reason">;
const getActionTarget = (params: Record<string, unknown>) => {
for (const key of ["command", "file_path", "filePath", "path", "query", "keyword"]) {
const value = params[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
};
export const createActivityTracker = ({
clientSessionId,
write,
}: {
clientSessionId: string;
write: (event: string, data: Record<string, unknown>) => void;
}) => {
const activities = new Map<string, ActivityPayload>();
const actionActivityIds = new Map<string, string>();
let currentActivityId: string | null = null;
const emit = (activity: ActivityPayload, todos?: TodoItemPayload[]) => {
const now = Date.now();
const snapshot = activity.status === "running"
? {
...activity,
elapsed_ms: Math.max(0, now - activity.started_at),
actions: activity.actions.map((action) =>
action.status === "running"
? { ...action, elapsed_ms: Math.max(0, now - action.started_at) }
: action,
),
}
: activity;
write("activity_update", {
session_id: clientSessionId,
activity: snapshot,
...(todos
? {
todos,
todos_created_at: now,
}
: {}),
} satisfies ActivityUpdatePayload);
};
const current = (): ActivityPayload | undefined =>
currentActivityId ? activities.get(currentActivityId) : undefined;
const finalize = (status: Exclude<ActivityStatus, "running">) => {
const activity = current();
if (!activity || activity.status !== "running") return;
const endedAt = Date.now();
const nextActivity: ActivityPayload = {
...activity,
status,
ended_at: endedAt,
elapsed_ms: undefined,
duration_ms: Math.max(0, endedAt - activity.started_at),
actions: activity.actions.map((action) => {
if (action.status !== "running") return action;
return {
...action,
status: status === "error" ? "error" : "completed",
ended_at: endedAt,
elapsed_ms: undefined,
duration_ms: Math.max(0, endedAt - action.started_at),
...(status === "error" ? { error: action.error ?? "活动执行失败" } : {}),
};
}),
};
activities.set(activity.id, nextActivity);
emit(nextActivity);
};
const start = (
id: string,
title: string,
reason: string,
todos?: TodoItemPayload[],
) => {
finalize("completed");
const activity: ActivityPayload = {
id,
title,
reason,
status: "running",
actions: [],
started_at: Date.now(),
elapsed_ms: 0,
};
activities.set(id, activity);
currentActivityId = id;
emit(activity, todos);
return activity;
};
const ensure = (tool: string) => {
const activity = current();
if (activity?.status === "running") return activity;
return start(
`activity-fallback-${Date.now().toString(36)}`,
"执行分析操作",
`为完成当前请求,需要使用${getToolLabel(tool)}处理相关信息。`,
);
};
const upsertAction = (part: ToolPart, params: Record<string, unknown>) => {
const associatedActivityId = actionActivityIds.get(part.id);
const activity = associatedActivityId
? activities.get(associatedActivityId)
: ensure(part.tool);
if (!activity) return;
if (!associatedActivityId) actionActivityIds.set(part.id, activity.id);
const now = Date.now();
const actionIndex = activity.actions.findIndex((action) => action.id === part.id);
const previous = actionIndex >= 0 ? activity.actions[actionIndex] : undefined;
const status = normalizeToolStatus(part.state.status);
const startedAt = previous?.started_at ?? now;
const endedAt = status === "running" ? undefined : now;
const action: ActivityActionPayload = {
id: part.id,
tool: part.tool,
title: getToolLabel(part.tool),
status,
target: getActionTarget(params),
error: part.state.status === "error" ? part.state.error : undefined,
started_at: startedAt,
ended_at: endedAt,
elapsed_ms: status === "running" ? Math.max(0, now - startedAt) : undefined,
duration_ms: endedAt ? Math.max(0, endedAt - startedAt) : undefined,
};
const actions = [...activity.actions];
if (actionIndex >= 0) actions[actionIndex] = action;
else actions.push(action);
const nextActivity = { ...activity, actions };
activities.set(activity.id, nextActivity);
emit(nextActivity);
};
const getActionContext = (actionId: string): ActivityContext | undefined => {
const activityId = actionActivityIds.get(actionId);
const activity = activityId ? activities.get(activityId) : undefined;
return activity
? { id: activity.id, title: activity.title, reason: activity.reason }
: undefined;
};
const getCurrentContext = (): ActivityContext | undefined => {
const activity = current();
return activity
? { id: activity.id, title: activity.title, reason: activity.reason }
: undefined;
};
return {
finalize,
getActionContext,
getCurrentContext,
start,
upsertAction,
};
};
+147 -337
View File
@@ -2,24 +2,16 @@ import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js"; import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
import { type SupportedModel } from "../chat/models.js"; import { type SupportedModel } from "../chat/models.js";
import { config } from "../config.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { import {
type PermissionReply, type PermissionReply,
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,
getAssistantMessagePhase, getAssistantMessagePhase,
getToolProgressTitle,
getUnknownErrorMessage, getUnknownErrorMessage,
hasToolParams, hasToolParams,
isPermissionAskedEvent, isPermissionAskedEvent,
@@ -35,6 +27,7 @@ import {
isQuestionV2RepliedEvent, isQuestionV2RepliedEvent,
isSessionEvent, isSessionEvent,
isSkillEvent, isSkillEvent,
initialActivity,
logDevelopmentDebug, logDevelopmentDebug,
normalizeQuestionAnswers, normalizeQuestionAnswers,
normalizeQuestionPayload, normalizeQuestionPayload,
@@ -42,13 +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 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,
@@ -56,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";
@@ -78,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;
@@ -115,38 +103,20 @@ const toRuntimeModel = (model?: SupportedModel) => {
}; };
}; };
const STRUCTURED_OUTPUT_TOOL_NAME = "StructuredOutput"; const FINAL_ANSWER_TOOL_NAME = "final_answer";
const STRUCTURED_FINAL_ANSWER_FORMAT = { const ACTIVITY_UPDATE_TOOL_NAME = "activity_update";
type: "json_schema" as const,
schema: {
type: "object",
properties: {
answer: {
type: "string",
description: "直接展示给用户的完整最终回答,使用简体中文和 Markdown。",
},
},
required: ["answer"],
additionalProperties: false,
},
};
const requiresStructuredFinalAnswer = (model?: SupportedModel) => const extractFinalAnswer = (value: unknown) =>
(model ?? config.OPENCODE_MODEL).split("/", 1)[0] === "deepseek";
const extractStructuredAnswer = (value: unknown) =>
isObjectRecord(value) && typeof value.answer === "string" isObjectRecord(value) && typeof value.answer === "string"
? value.answer.trim() ? value.answer.trim()
: ""; : "";
const emitFinalMessage = async ( 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>>,
assistantTextPartPhases: Map<string, AssistantMessagePhase>, assistantTextPartPhases: Map<string, AssistantMessagePhase>,
write: (event: string, data: Record<string, unknown>) => void,
) => { ) => {
let text = [...currentAssistantMessageIds] let text = [...currentAssistantMessageIds]
.reverse() .reverse()
@@ -180,30 +150,53 @@ const emitFinalMessage = async (
(currentAssistantMessageIds.size === 0 || (currentAssistantMessageIds.size === 0 ||
currentAssistantMessageIds.has(message.info.id)), currentAssistantMessageIds.has(message.info.id)),
); );
const structured = const assistantParts = assistantMessage?.parts ?? [];
assistantMessage && "structured" in assistantMessage.info text = collectTextContent(
? assistantMessage.info.structured assistantParts.filter(
: undefined;
const structuredAnswer = extractStructuredAnswer(structured);
text =
structuredAnswer ||
collectTextContent(
(assistantMessage?.parts ?? []).filter(
(part) => (part) =>
part.type !== "text" || part.type !== "text" ||
getAssistantMessagePhase(part.metadata) !== "commentary", 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 ({
@@ -227,15 +220,13 @@ export const streamPromptResponse = async ({
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 assistantTextPartPhases = new Map<string, AssistantMessagePhase>();
const emittedFinalTextLengths = new Map<string, number>();
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">();
@@ -246,13 +237,12 @@ 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;
let aborted = signal?.aborted ?? false; let aborted = signal?.aborted ?? false;
let failed = false; let failed = false;
let structuredFinalAnswerEmitted = false;
const debugContext = { const debugContext = {
sessionId, sessionId,
clientSessionId, clientSessionId,
@@ -278,94 +268,24 @@ 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,
});
}; };
const emitFinalText = (partId: string, text: string) => { activityTracker.start(
const emittedLength = emittedFinalTextLengths.get(partId) ?? 0; initialActivity.id,
if (text.length <= emittedLength) { initialActivity.title,
return; initialActivity.reason,
} );
const content = text.slice(emittedLength);
emittedFinalTextLengths.set(partId, text.length);
emittedText = true;
write("token", {
session_id: clientSessionId,
content,
});
};
const emitCommentaryProgress = (
partId: string,
text: string,
completed: boolean,
) => {
if (!text.trim()) {
return;
}
emitProgress({
id: `commentary-${partId}`,
phase: "commentary",
status: completed ? "completed" : "running",
title: completed ? "Agent 过程已更新" : "Agent 正在处理",
detail: text,
});
};
emitProgress({
id: "request-received",
phase: "start",
status: "running",
title: "已收到请求,正在启动 Agent 分析",
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
});
const promptPromise = runtime const promptPromise = runtime
.prompt( .prompt(
sessionId, sessionId,
message, message,
toRuntimeModel(model), toRuntimeModel(model),
requiresStructuredFinalAnswer(model)
? { format: STRUCTURED_FINAL_ANSWER_FORMAT }
: undefined,
) )
.then(() => { .then(() => {
promptSettled = true; promptSettled = true;
@@ -388,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) {
@@ -399,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
@@ -426,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;
} }
@@ -465,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;
} }
@@ -508,17 +419,6 @@ 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({
@@ -534,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(),
@@ -565,17 +468,6 @@ 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({
@@ -591,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(),
@@ -612,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,
@@ -643,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,
@@ -674,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);
@@ -696,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,
@@ -721,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,
@@ -738,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),
}); });
@@ -753,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");
@@ -780,9 +620,7 @@ export const streamPromptResponse = async ({
assistantTextParts.set(event.properties.messageID, messageParts); assistantTextParts.set(event.properties.messageID, messageParts);
const phase = assistantTextPartPhases.get(event.properties.partID) ?? "unknown"; const phase = assistantTextPartPhases.get(event.properties.partID) ?? "unknown";
if (phase === "final_answer") { if (phase === "final_answer") {
emitFinalText(event.properties.partID, text); captureFinalText(text);
} else if (phase === "commentary") {
emitCommentaryProgress(event.properties.partID, text, false);
} }
} else if (!partType) { } else if (!partType) {
const pending = pendingTextDeltas.get(event.properties.partID) ?? []; const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
@@ -809,9 +647,7 @@ export const streamPromptResponse = async ({
messageParts.set(part.id, text); messageParts.set(part.id, text);
assistantTextParts.set(part.messageID, messageParts); assistantTextParts.set(part.messageID, messageParts);
if (phase === "final_answer") { if (phase === "final_answer") {
emitFinalText(part.id, text); captureFinalText(text);
} else if (phase === "commentary") {
emitCommentaryProgress(part.id, text, Boolean(part.time?.end));
} }
} else { } else {
pendingTextDeltas.delete(part.id); pendingTextDeltas.delete(part.id);
@@ -827,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) {
@@ -849,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);
@@ -861,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,
@@ -869,7 +695,7 @@ export const streamPromptResponse = async ({
}); });
} }
if (part.tool === STRUCTURED_OUTPUT_TOOL_NAME) { if (part.tool === FINAL_ANSWER_TOOL_NAME) {
if (part.state.status === "error") { if (part.state.status === "error") {
logger.warn( logger.warn(
{ {
@@ -878,38 +704,71 @@ export const streamPromptResponse = async ({
partId: part.id, partId: part.id,
error: part.state.error, error: part.state.error,
}, },
"structured final answer tool failed", "final answer tool failed",
); );
continue; continue;
} }
if ( if (
(part.state.status !== "running" && (part.state.status !== "running" &&
part.state.status !== "completed") || part.state.status !== "completed")
structuredFinalAnswerEmitted
) { ) {
continue; continue;
} }
const answer = extractStructuredAnswer(toolParams); const answer = extractFinalAnswer(toolParams);
if (!answer) { if (!answer) {
logger.warn( logger.warn(
{ sessionId, clientSessionId, partId: part.id }, { sessionId, clientSessionId, partId: part.id },
"structured final answer tool received without an answer", "final answer tool received without an answer",
); );
continue; continue;
} }
structuredFinalAnswerEmitted = true; captureFinalText(answer);
emitFinalText(part.id, answer); logDevelopmentDebug("final answer submitted through tool", {
logDevelopmentDebug("final answer submitted through StructuredOutput", {
...debugContext, ...debugContext,
partId: part.id, partId: part.id,
tool: part.tool,
answerChars: answer.length, answerChars: answer.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
continue; 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,
@@ -926,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,
@@ -976,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");
@@ -986,7 +820,6 @@ export const streamPromptResponse = async ({
session_id: clientSessionId, session_id: clientSessionId,
tool: part.tool, tool: part.tool,
params: toolParams, params: toolParams,
reason,
}); });
} }
} }
@@ -1007,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,
@@ -1036,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
@@ -1059,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;
} }
} }
@@ -1079,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");
}); });
@@ -1096,40 +912,34 @@ export const streamPromptResponse = async ({
} }
await promptPromise; await promptPromise;
if (!emittedText) { if (!finalAnswerText) {
emittedText = await emitFinalMessage( finalAnswerText = await resolveFinalMessage(
runtime, runtime,
sessionId, sessionId,
clientSessionId,
currentAssistantMessageIds, currentAssistantMessageIds,
assistantTextParts, assistantTextParts,
assistantTextPartPhases, assistantTextPartPhases,
write,
); );
} }
emitProgress({ activityTracker.finalize("completed");
id: "request-received", if (finalAnswerText) {
phase: "start", // Keep one compatibility cycle for deployed frontends that only consume token.
status: "completed", write("token", {
title: "请求处理完成", session_id: clientSessionId,
detail: "本次请求的分析、工具执行和结果整理流程已经完成。", content: finalAnswerText,
}); });
emitProgress({ write("final_answer", {
id: "request-completed", session_id: clientSessionId,
phase: "complete", content: finalAnswerText,
status: "completed",
title: "分析完成",
detail: emittedText
? "最终回答已生成并推送到前端。"
: "已完成分析,并通过兜底消息补发最终回答内容。",
}); });
}
write("done", { write("done", {
session_id: clientSessionId, session_id: clientSessionId,
total_duration_ms: Math.max(0, Date.now() - requestStartedAt), total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
}); });
logDevelopmentDebug("chat stream completed", { logDevelopmentDebug("chat stream completed", {
...debugContext, ...debugContext,
emittedText, hasFinalAnswer: Boolean(finalAnswerText),
toolCallCount, toolCallCount,
totalDurationMs: Math.max(0, Date.now() - requestStartedAt), totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
}); });
+47 -119
View File
@@ -9,6 +9,8 @@ export type PermissionRequestPayload = {
permission: string; permission: string;
patterns: string[]; patterns: string[];
target?: string; target?: string;
activity_id?: string;
reason?: string;
always: string[]; always: string[];
tool?: { tool?: {
messageID: string; messageID: string;
@@ -17,6 +19,46 @@ export type PermissionRequestPayload = {
created_at: number; created_at: number;
}; };
export type ActivityStatus = "running" | "completed" | "error" | "cancelled";
export type ActivityActionPayload = {
id: string;
tool: string;
title: string;
status: "running" | "completed" | "error";
target?: string;
error?: string;
started_at: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
};
export type ActivityPayload = {
id: string;
title: string;
reason: string;
status: ActivityStatus;
actions: ActivityActionPayload[];
started_at: number;
ended_at?: number;
elapsed_ms?: number;
duration_ms?: number;
};
export type ActivityUpdatePayload = {
session_id: string;
activity: ActivityPayload;
todos?: TodoItemPayload[];
todos_created_at?: number;
};
export const initialActivity = {
id: "activity-startup",
title: "正在准备分析",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
} as const;
type QuestionOptionPayload = { type QuestionOptionPayload = {
label: string; label: string;
description: string; description: string;
@@ -65,6 +107,9 @@ export type AssistantMessagePhase =
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: "历史会话检索",
@@ -76,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 = (
@@ -143,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");
@@ -172,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,
}; };
}; };
@@ -240,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,
@@ -374,88 +386,4 @@ export const normalizeToolStatus = (status: string) => {
return "running"; return "running";
}; };
const formatProgressValue = (value: unknown): string => { export const getToolLabel = (tool: string) => toolLabels[tool] ?? tool;
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
value === null ||
value === undefined
) {
return String(value);
}
try {
const serialized = JSON.stringify(value);
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
} catch {
return "[unserializable]";
}
};
const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
const summary = Object.entries(params)
.filter(([key]) => !ignoredKeys.has(key))
.slice(0, 4)
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
.join(", ");
return summary || "无附加参数";
};
export const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
if (status.type === "retry") {
return status.message
? `模型请求需要重试,原因:${status.message}`
: "模型请求正在重试,等待下一次响应。";
}
if (status.type === "busy") {
return status.message
? `Agent 正在处理中:${status.message}`
: "Agent 正在执行推理、工具调用或结果整理。";
}
if (status.type === "idle") {
return status.message
? `Agent 已空闲:${status.message}`
: "当前会话暂时没有待处理任务。";
}
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
};
export const buildReasoningProgressDetail = (
ended?: string | number | Date | null,
) => ended ? "分析步骤已整理完成。" : "Agent 正在分析问题。";
export const buildToolProgressDetail = (
tool: string,
status: string,
params: Record<string, unknown>,
reason: string,
error?: string,
) => {
const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") {
const errorText = error ? `;错误:${error}` : "";
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
}
if (status === "completed") {
return `${toolName} 已执行完成${reasonText}${paramsText}`;
}
if (status === "pending") {
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
}
return `${toolName} 正在执行${reasonText}${paramsText}`;
};
export const getToolProgressTitle = (tool: string, status: string) => {
const toolName = toolLabels[tool] ?? tool;
if (status === "completed") return `${toolName} 已完成`;
if (status === "error") return `${toolName} 执行失败`;
if (status === "pending") return `准备调用 ${toolName}`;
return `正在调用 ${toolName}`;
};
+119 -9
View File
@@ -1,5 +1,7 @@
import { type PermissionReply } from "../runtime/opencode.js"; import { type PermissionReply } from "../runtime/opencode.js";
import { import {
type ActivityPayload,
initialActivity,
type PermissionRequestPayload, type PermissionRequestPayload,
type QuestionRequestPayload, type QuestionRequestPayload,
type TodoUpdatePayload, type TodoUpdatePayload,
@@ -28,6 +30,7 @@ type ToolCallPayload = {
session_id?: string; session_id?: string;
tool?: string; tool?: string;
params?: unknown; params?: unknown;
/** Legacy payload field. New tool calls inherit purpose from their Activity. */
reason?: string; reason?: string;
}; };
@@ -56,13 +59,13 @@ export const createInitialStreamingMessages = (
id: createFrontendMessageId(), id: createFrontendMessageId(),
role: "assistant", role: "assistant",
content: "", content: "",
progress: [ activities: [
{ {
id: "request-received", id: initialActivity.id,
phase: "start",
status: "running", status: "running",
title: "已收到请求,正在启动 Agent 分析", title: initialActivity.title,
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。", reason: initialActivity.reason,
actions: [],
startedAt: Date.now(), startedAt: Date.now(),
elapsedMs: 0, elapsedMs: 0,
elapsedSnapshotAt: Date.now(), elapsedSnapshotAt: Date.now(),
@@ -72,6 +75,90 @@ export const createInitialStreamingMessages = (
]; ];
}; };
const toFrontendActivityAction = (
action: ActivityPayload["actions"][number],
) => ({
id: action.id,
tool: action.tool,
title: action.title,
status: action.status,
target: action.target,
error: action.error,
startedAt: action.started_at,
endedAt: action.ended_at,
elapsedMs: action.elapsed_ms,
elapsedSnapshotAt: action.elapsed_ms === undefined ? undefined : Date.now(),
durationMs: action.duration_ms,
});
const toFrontendActivity = (activity: ActivityPayload) => ({
id: activity.id,
title: activity.title,
reason: activity.reason,
status: activity.status,
actions: activity.actions.map(toFrontendActivityAction),
startedAt: activity.started_at,
endedAt: activity.ended_at,
elapsedMs: activity.elapsed_ms,
elapsedSnapshotAt: activity.elapsed_ms === undefined ? undefined : Date.now(),
durationMs: activity.duration_ms,
});
export const upsertBackendActivity = (
activities: unknown,
activity: ActivityPayload,
) => {
const next = Array.isArray(activities) ? [...activities] : [];
const index = next.findIndex(
(item) => isObjectRecord(item) && item.id === activity.id,
);
const nextItem = toFrontendActivity(activity);
if (index >= 0) next[index] = nextItem;
else next.push(nextItem);
return next;
};
export const completeBackendActivities = (
activities: unknown,
status: "completed" | "error" | "cancelled" = "completed",
) => Array.isArray(activities)
? activities.map((activity) => {
if (!isObjectRecord(activity) || activity.status !== "running") return activity;
const endedAt = Date.now();
const startedAt = typeof activity.startedAt === "number"
? activity.startedAt
: endedAt;
const actions = Array.isArray(activity.actions)
? activity.actions.map((action) => {
if (!isObjectRecord(action) || action.status !== "running") return action;
const actionStartedAt = typeof action.startedAt === "number"
? action.startedAt
: endedAt;
return {
...action,
status: status === "error" ? "error" : "completed",
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - actionStartedAt),
...(status === "error"
? { error: action.error ?? "活动执行失败" }
: {}),
};
})
: activity.actions;
return {
...activity,
status,
actions,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs: Math.max(0, endedAt - startedAt),
};
})
: activities;
export const upsertBackendProgress = ( export const upsertBackendProgress = (
progress: unknown, progress: unknown,
payload: Record<string, unknown>, payload: Record<string, unknown>,
@@ -152,6 +239,31 @@ export const cancelBackendTodos = (todos: unknown) =>
}) })
: todos; : todos;
export const completeBackendTodos = (todos: unknown) =>
Array.isArray(todos)
? todos.map((todoUpdate) => {
if (!isObjectRecord(todoUpdate) || !Array.isArray(todoUpdate.todos)) {
return todoUpdate;
}
return {
...todoUpdate,
todos: todoUpdate.todos.map((todo) => {
if (!isObjectRecord(todo)) {
return todo;
}
if (todo.status !== "pending" && todo.status !== "in_progress") {
return todo;
}
return {
...todo,
status: "completed",
updatedAt: Date.now(),
};
}),
};
})
: todos;
export const updateLastAssistantMessage = ( export const updateLastAssistantMessage = (
messages: unknown[], messages: unknown[],
updater: (message: Record<string, unknown>) => Record<string, unknown>, updater: (message: Record<string, unknown>) => Record<string, unknown>,
@@ -249,10 +361,6 @@ export const appendBackendToolArtifact = (
tool, tool,
kind: getToolArtifactKind(tool), kind: getToolArtifactKind(tool),
title: getToolArtifactTitle(tool, params), title: getToolArtifactTitle(tool, params),
description:
typeof payload.reason === "string" && payload.reason.trim()
? payload.reason.trim()
: undefined,
params, params,
}); });
return next; return next;
@@ -267,6 +375,8 @@ export const toFrontendPermission = (
permission: payload.permission, permission: payload.permission,
patterns: payload.patterns, patterns: payload.patterns,
target: payload.target, target: payload.target,
activityId: payload.activity_id,
reason: payload.reason,
always: payload.always, always: payload.always,
tool: payload.tool, tool: payload.tool,
createdAt: payload.created_at, createdAt: payload.created_at,
-7
View File
@@ -1,7 +1,6 @@
import { import {
createOpencode, createOpencode,
type OpencodeClient, type OpencodeClient,
type OutputFormat,
} from "@opencode-ai/sdk/v2"; } from "@opencode-ai/sdk/v2";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
@@ -39,10 +38,6 @@ type RuntimeModelOverride = {
modelID: string; modelID: string;
}; };
type RuntimePromptOptions = {
format?: OutputFormat;
};
export type PermissionReply = "once" | "always" | "reject"; export type PermissionReply = "once" | "always" | "reject";
export type QuestionAnswers = string[][]; export type QuestionAnswers = string[][];
@@ -189,7 +184,6 @@ export class OpencodeRuntimeAdapter {
sessionId: string, sessionId: string,
text: string, text: string,
model?: RuntimeModelOverride, model?: RuntimeModelOverride,
options?: RuntimePromptOptions,
) { ) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const startedAt = Date.now(); const startedAt = Date.now();
@@ -204,7 +198,6 @@ export class OpencodeRuntimeAdapter {
await client.session.prompt({ await client.session.prompt({
sessionID: sessionId, sessionID: sessionId,
model, model,
...(options?.format ? { format: options.format } : {}),
parts: [{ type: "text", text }], parts: [{ type: "text", text }],
}); });
logDevelopmentDebug( logDevelopmentDebug(
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "bun:test";
import { createActivityTracker } from "../../src/routes/chatActivityTracker.js";
describe("createActivityTracker", () => {
it("groups actions and closes running children with their activity", () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const tracker = createActivityTracker({
clientSessionId: "client-session-1",
write: (event, data) => events.push({ event, data }),
});
tracker.start("activity-1", "准备分析数据", "需要先获取分析输入。");
tracker.upsertAction(
{
id: "tool-1",
tool: "tjwater_cli",
state: {
status: "running",
input: { command: "data list" },
},
} as never,
{ command: "data list" },
);
tracker.finalize("cancelled");
expect(tracker.getCurrentContext()).toEqual({
id: "activity-1",
title: "准备分析数据",
reason: "需要先获取分析输入。",
});
expect(events.at(-1)).toMatchObject({
event: "activity_update",
data: {
session_id: "client-session-1",
activity: {
id: "activity-1",
status: "cancelled",
actions: [
expect.objectContaining({
id: "tool-1",
status: "completed",
target: "data list",
}),
],
},
},
});
});
});
+286 -96
View File
@@ -117,15 +117,27 @@ describe("streamPromptResponse", () => {
}); });
expect(subscribedDirectory).toBe("/tmp/conversation-workspace-1"); expect(subscribedDirectory).toBe("/tmp/conversation-workspace-1");
expect( expect(events.filter((item) => item.event === "token")).toEqual([
events {
.filter((item) => item.event === "token") event: "token",
.map((item) => item.data.content) data: {
.join(""), session_id: "client-session-1",
).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。"); 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("streams final_answer deltas while routing commentary to progress", async () => { it("buffers final_answer deltas and emits one complete answer", async () => {
let messagesCalls = 0; let messagesCalls = 0;
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () =>
@@ -241,20 +253,20 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
expect( expect(events.find((item) => item.event === "token")?.data.content).toBe(
events.filter((item) => item.event === "token").map((item) => item.data.content), "分析完成,结果正常。",
).toEqual(["分析完成,", "结果正常。"]); );
expect(events.filter((item) => item.event === "final_answer")).toEqual([
{
event: "final_answer",
data: {
session_id: "client-session-1",
content: "分析完成,结果正常。",
},
},
]);
expect(messagesCalls).toBe(0); expect(messagesCalls).toBe(0);
expect( expect(events.some((item) => item.event === "progress")).toBe(false);
events.find(
(item) =>
item.event === "progress" && item.data.id === "commentary-commentary-part",
)?.data,
).toMatchObject({
phase: "commentary",
status: "running",
detail: "我先检查相关数据。",
});
expect( expect(
events.some( events.some(
(item) => item.event === "token" && item.data.content === "我先检查相关数据。", (item) => item.event === "token" && item.data.content === "我先检查相关数据。",
@@ -302,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: {
@@ -354,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 },
@@ -381,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 () => {
@@ -435,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>);
}); });
@@ -829,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: {
@@ -863,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: [
@@ -887,14 +976,29 @@ 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("uses StructuredOutput as the terminal action for models without reliable phases", async () => { it("buffers the voluntary DeepSeek final answer tool without forcing tool choice", async () => {
const promptCalls: unknown[][] = []; const promptCalls: unknown[][] = [];
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () => ({
createEventStream([ async *[Symbol.asyncIterator]() {
{ yield {
type: "message.part.updated", type: "message.part.updated",
properties: { properties: {
sessionID: "runtime-session-1", sessionID: "runtime-session-1",
@@ -904,7 +1008,32 @@ describe("streamPromptResponse", () => {
messageID: "assistant-final", messageID: "assistant-final",
type: "tool", type: "tool",
callID: "final-answer-call", callID: "final-answer-call",
tool: "StructuredOutput", 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: { state: {
status: "running", status: "running",
input: { answer: "供水服务分区分析已完成。" }, input: { answer: "供水服务分区分析已完成。" },
@@ -912,8 +1041,14 @@ describe("streamPromptResponse", () => {
}, },
}, },
}, },
}, };
{ expect(
events
.filter((item) => item.event === "final_answer")
.map((item) => item.data.content)
.join(""),
).toBe("");
yield {
type: "message.part.updated", type: "message.part.updated",
properties: { properties: {
sessionID: "runtime-session-1", sessionID: "runtime-session-1",
@@ -923,31 +1058,29 @@ describe("streamPromptResponse", () => {
messageID: "assistant-final", messageID: "assistant-final",
type: "tool", type: "tool",
callID: "final-answer-call", callID: "final-answer-call",
tool: "StructuredOutput", tool: "final_answer",
state: { state: {
status: "completed", status: "completed",
input: { answer: "供水服务分区分析已完成。" }, input: { answer: "供水服务分区分析已完成。" },
output: "最终回答已提交。", output: "最终回答已提交。",
title: "StructuredOutput", title: "final_answer",
metadata: {}, metadata: {},
time: { start: 1, end: 2 }, time: { start: 1, end: 2 },
}, },
}, },
}, },
}, };
{ yield {
type: "session.idle", type: "session.idle",
properties: { sessionID: "runtime-session-1" }, properties: { sessionID: "runtime-session-1" },
};
}, },
]), }),
prompt: async (...args: unknown[]) => { prompt: async (...args: unknown[]) => {
promptCalls.push(args); promptCalls.push(args);
}, },
messages: async () => { messages: async () => [],
throw new Error("StructuredOutput should avoid the message fallback");
},
} as unknown as OpencodeRuntimeAdapter; } as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const result = await streamPromptResponse({ const result = await streamPromptResponse({
runtime, runtime,
@@ -958,37 +1091,87 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
expect(promptCalls[0]?.[3]).toMatchObject({ expect(promptCalls[0]?.[3]).toBeUndefined();
format: {
type: "json_schema",
schema: {
required: ["answer"],
},
},
});
expect(result).toEqual({ aborted: false, failed: false, toolCallCount: 0 }); expect(result).toEqual({ aborted: false, failed: false, toolCallCount: 0 });
expect( expect(events.find((item) => item.event === "token")?.data.content).toBe(
events "供水服务分区分析已完成。",
.filter((item) => item.event === "token") );
.map((item) => item.data.content) expect(events.filter((item) => item.event === "final_answer")).toEqual([
.join(""), {
).toBe("供水服务分区分析已完成。"); 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 === "tool_call")).toBe(false);
expect(events.some((item) => item.event === "done")).toBe(true); expect(events.some((item) => item.event === "done")).toBe(true);
}); });
it("recovers the final answer from structured message data when tool events are missed", async () => { 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 = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () =>
createEventStream([ createEventStream([
{ {
type: "message.updated", type: "message.updated",
properties: { properties: {
info: {
id: "assistant-final",
sessionID: "runtime-session-1", sessionID: "runtime-session-1",
role: "assistant", info: { id: "assistant-final", role: "assistant" },
},
}, },
}, },
{ {
@@ -999,33 +1182,40 @@ describe("streamPromptResponse", () => {
prompt: async () => undefined, prompt: async () => undefined,
messages: async () => [ messages: async () => [
{ {
info: { info: { id: "assistant-final", role: "assistant" },
id: "assistant-final", parts: [
{
id: "final-answer-part",
sessionID: "runtime-session-1", sessionID: "runtime-session-1",
role: "assistant", messageID: "assistant-final",
structured: { answer: "已从结构化结果恢复最终回答。" }, type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "completed",
input: { answer: "已从持久化工具结果恢复最终答案。" },
output: "最终回答已提交。",
title: "final_answer",
metadata: {},
time: { start: 1, end: 2 },
}, },
parts: [], },
],
}, },
], ],
} as unknown as OpencodeRuntimeAdapter; } as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({ await streamPromptResponse({
runtime, runtime,
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
clientSessionId: "client-session-1", clientSessionId: "client-session-1",
message: "恢复最终回答", message: "分析管网",
model: "deepseek/deepseek-v4-flash",
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
expect( expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
events "已从持久化工具结果恢复最终答案。",
.filter((item) => item.event === "token") );
.map((item) => item.data.content)
.join(""),
).toBe("已从结构化结果恢复最终回答。");
}); });
}); });
+52 -2
View File
@@ -3,15 +3,40 @@ import { describe, expect, it } from "bun:test";
import { import {
appendBackendToolArtifact, appendBackendToolArtifact,
cancelBackendTodos, cancelBackendTodos,
completeBackendActivities,
completeBackendTodos,
upsertBackendQuestion, upsertBackendQuestion,
} from "../../src/routes/chatUiState.js"; } from "../../src/routes/chatUiState.js";
describe("completeBackendActivities", () => {
it("closes running child actions when the stream terminates", () => {
const activities = completeBackendActivities([
{
id: "activity-1",
status: "running",
startedAt: Date.now() - 100,
actions: [
{
id: "action-1",
status: "running",
startedAt: Date.now() - 50,
},
],
},
], "error") as Array<Record<string, unknown>>;
expect(activities[0]).toMatchObject({
status: "error",
actions: [expect.objectContaining({ status: "error" })],
});
});
});
describe("appendBackendToolArtifact", () => { describe("appendBackendToolArtifact", () => {
it("persists show_chart tool calls as chart artifacts", () => { it("persists show_chart tool calls as chart artifacts", () => {
const artifacts = appendBackendToolArtifact([], { const artifacts = appendBackendToolArtifact([], {
session_id: "session-1", session_id: "session-1",
tool: "show_chart", tool: "show_chart",
reason: "测试折线图渲染",
params: { params: {
title: "压力曲线", title: "压力曲线",
chart_type: "line", chart_type: "line",
@@ -25,7 +50,6 @@ describe("appendBackendToolArtifact", () => {
tool: "show_chart", tool: "show_chart",
kind: "chart", kind: "chart",
title: "压力曲线", title: "压力曲线",
description: "测试折线图渲染",
params: { params: {
chart_type: "line", chart_type: "line",
x_data: ["00:00", "01:00"], x_data: ["00:00", "01:00"],
@@ -160,3 +184,29 @@ describe("cancelBackendTodos", () => {
]); ]);
}); });
}); });
describe("completeBackendTodos", () => {
it("marks pending and in-progress todos as completed after a successful run", () => {
const completed = completeBackendTodos([
{
sessionId: "session-1",
todos: [
{ id: "todo-1", content: "分析水位", status: "in_progress" },
{ id: "todo-2", content: "生成建议", status: "pending" },
{ id: "todo-3", content: "完成报告", status: "completed" },
],
createdAt: 123,
},
]);
expect(completed).toEqual([
expect.objectContaining({
todos: [
expect.objectContaining({ id: "todo-1", status: "completed" }),
expect.objectContaining({ id: "todo-2", status: "completed" }),
expect.objectContaining({ id: "todo-3", status: "completed" }),
],
}),
]);
});
});
-52
View File
@@ -117,58 +117,6 @@ describe("OpencodeRuntimeAdapter.subscribeEvents", () => {
}); });
}); });
describe("OpencodeRuntimeAdapter.prompt", () => {
it("forwards the final answer schema", async () => {
const calls: unknown[] = [];
const client = {
session: {
prompt: async (input: unknown) => {
calls.push(input);
return { data: {} };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await runtime.prompt(
"session-1",
"分析供水分区",
{ providerID: "deepseek", modelID: "deepseek-v4-flash" },
{
format: {
type: "json_schema",
schema: {
type: "object",
properties: { answer: { type: "string" } },
required: ["answer"],
},
},
},
);
expect(calls).toEqual([
{
sessionID: "session-1",
model: {
providerID: "deepseek",
modelID: "deepseek-v4-flash",
},
format: {
type: "json_schema",
schema: {
type: "object",
properties: { answer: { type: "string" } },
required: ["answer"],
},
},
parts: [{ type: "text", text: "分析供水分区" }],
},
]);
});
});
describe("OpencodeRuntimeAdapter interaction replies", () => { describe("OpencodeRuntimeAdapter interaction replies", () => {
it("replies to permissions in the conversation workspace directory", async () => { it("replies to permissions in the conversation workspace directory", async () => {
const calls: unknown[] = []; const calls: unknown[] = [];
+33 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { readFile } from "node:fs/promises"; import { readFile, readdir } from "node:fs/promises";
import sandboxBash from "../../.opencode/tools/bash.js"; import sandboxBash from "../../.opencode/tools/bash.js";
import tjwaterCli from "../../.opencode/tools/tjwater_cli.js"; import tjwaterCli from "../../.opencode/tools/tjwater_cli.js";
import storeRenderRef, { import storeRenderRef, {
@@ -37,6 +37,8 @@ describe("internal OpenCode permissions", () => {
expect(permission.external_directory).toBe("deny"); expect(permission.external_directory).toBe("deny");
expect(permission.task).toBe("deny"); expect(permission.task).toBe("deny");
expect(permission.question).toBe("allow"); expect(permission.question).toBe("allow");
expect(permission.activity_update).toBe("allow");
expect(permission.final_answer).toBe("allow");
expect(permission.todowrite).toBe("allow"); expect(permission.todowrite).toBe("allow");
expect(read?.["*"]).toBe("allow"); expect(read?.["*"]).toBe("allow");
expect(read?.["data/**"]).toBe("deny"); expect(read?.["data/**"]).toBe("deny");
@@ -56,6 +58,22 @@ describe("internal OpenCode permissions", () => {
expect(bash?.["*data/*"]).toBeUndefined(); expect(bash?.["*data/*"]).toBeUndefined();
expect(bash?.["*logs/*"]).toBeUndefined(); expect(bash?.["*logs/*"]).toBeUndefined();
}); });
it("keeps reason only on the activity grouping tool", async () => {
const toolFiles = (await readdir(".opencode/tools"))
.filter((file) => file.endsWith(".ts"));
const sources = await Promise.all(
toolFiles.map(async (file) => ({
file,
source: await readFile(`.opencode/tools/${file}`, "utf8"),
})),
);
const reasonSchemaFiles = sources
.filter(({ source }) => /reason:\s*tool\.schema/u.test(source))
.map(({ file }) => file);
expect(reasonSchemaFiles).toEqual(["activity_update.ts"]);
});
}); });
describe("store_render_ref arguments", () => { describe("store_render_ref arguments", () => {
@@ -154,6 +172,20 @@ describe("sandbox bash tool", () => {
}); });
describe("tjwater_cli storage request", () => { describe("tjwater_cli storage request", () => {
it("keeps command discovery rules in the always-visible tool contract", () => {
const definition = tjwaterCli as unknown as {
description: string;
args: { command: { description?: string } };
};
expect(definition.description).toContain("help");
expect(definition.args.command.description).toContain("禁止类推");
expect(definition.args.command.description).toContain("simulation runs list");
expect(definition.args.command.description).not.toContain(
"示例:'analysis runs list'",
);
});
it("forwards store_result so small workflow inputs can be staged", async () => { it("forwards store_result so small workflow inputs can be staged", async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
let requestBody: unknown; let requestBody: unknown;
@@ -168,7 +200,6 @@ describe("tjwater_cli storage request", () => {
await definition.execute( await definition.execute(
{ {
command: "network get-all-reservoirs-properties", command: "network get-all-reservoirs-properties",
reason: "prepare workflow input",
store_result: true, store_result: true,
}, },
{ sessionID: "session-test" } as never, { sessionID: "session-test" } as never,