diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index 95e457a..b4d4625 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -8,10 +8,13 @@ model: deepseek/deepseek-v4-flash ## 回复要求 - 工具执行期间不输出过程说明,全部完成后只回复最终结果 +- 最终回答必须通过 `final_answer` 提交;调用前必须完成全部业务动作和其他工具,调用后禁止继续调用工具或输出额外文本 - 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步 -- 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划,并在每个里程碑更新状态;简单问答不创建计划 +- 多步骤或预计超过 30 秒的任务,开始时使用 `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` 3. 大结果集禁止完整读取,优先采样/截断/按字段读取 4. 避免直接用 `Read` 或 `cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout diff --git a/.opencode/skills/tjwater-cli/SKILL.md b/.opencode/skills/tjwater-cli/SKILL.md index ce1d963..0fa3e98 100644 --- a/.opencode/skills/tjwater-cli/SKILL.md +++ b/.opencode/skills/tjwater-cli/SKILL.md @@ -15,7 +15,6 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输 ```json { - "reason": "说明调用原因", "command": "project list", "timeout": 120, "store_result": false @@ -24,7 +23,6 @@ description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输 | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| -| `reason` | string | 是 | 调用原因 | | `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context`) | | `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ | | `store_result` | boolean | 否 | 强制保存到当前对话目录并返回 `data_file.file_path`;分析脚本需要文件输入时设为 true | @@ -130,7 +128,7 @@ tjwater-cli help COMMAND → 子命令与参数详情 ## 最佳实践 1. **禁止猜测命令** — 执行任何命令前必须先 `tjwater_cli(command="help ...")` 确认命令存在及参数签名,参数均已写在 help 中,禁止凭经验拼写 -2. **reason 必填** — 每次调用必须说明具体理由 +2. **阶段分组** — 调用 CLI 前确认当前业务阶段已通过 `activity_update` 建立,同一阶段的多个查询无需重复说明理由 3. **按运行 ID 取结果** — 分析完成后先用 `analysis runs list/get/results` 获取 `run_id` 和非时序结果;元素时序再用 `data timeseries analysis` 查询 4. **文件分析** — workflow 脚本需要文件时使用 `store_result=true`,不得从 Bash 直接联网调用 CLI 5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试 @@ -142,7 +140,6 @@ tjwater-cli help COMMAND → 子命令与参数详情 ### 查询所有实时节点数据 ```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" } ``` @@ -151,7 +148,6 @@ tjwater-cli help COMMAND → 子命令与参数详情 ### 按节点查询分析运行时序字段 ```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" } ``` @@ -159,7 +155,6 @@ tjwater-cli help COMMAND → 子命令与参数详情 ### 查询 SCADA 时序数据 ```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" } ``` @@ -171,17 +166,14 @@ tjwater-cli help COMMAND → 子命令与参数详情 ```json // 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" } // step 2: 若 step 1 无数据(ok: false 或 data 为空),触发仿真 { - "reason": "无已有仿真结果,触发1小时水力仿真", "command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 60" } // 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" } ``` diff --git a/.opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md index 186d251..3acdb0c 100644 --- a/.opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md +++ b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md @@ -150,4 +150,5 @@ if length < 0.01 and headloss > 0.5 → "短管高水损,检查是否存在模 - 脚本读取全量 JSON 入内存,峰值内存约 200-300MB,需确保执行环境有足够内存。 ## Learned Patterns +- [5cbdaa6bcf4e01c22eb2e544] [2026-08 复验] **schema 适配已固化进脚本**:bottleneck_analysis.py 现已直接使用 link_id/node_id 主键与 UTC target-time,无需再手工改码。本次 91,052 管段管网识别出 56 条瓶颈(0.06%),典型特征:100-110mm 小管径串联瓶颈(多条水损值近等差递减、逐段累计,如 7 段链 478128→460635→479635→508436→506699→484919→406224),宜按整链统一扩径;另有 1.4m 短管水损 67m 的模型异常信号(399832/399820),需核查局部阻塞或模型设置。分析后若用户需要改造建议落地,可按"极危... - [5ba58cd24c9cea84b6ab5861] **数据 schema 实测适配(2026-04 验证)**:`data timeseries realtime links` 返回记录的管道主键为 `link_id`(不是 `id`),`data timeseries realtime nodes` 返回记录的节点主键为 `node_id`(不是 `id`),且 `time` 字段为 UTC 格式(如 `2026-04-01T00:00:00+00:00`)。运行 `bottleneck_analysis.py` 前需:① 脚本内将 `r['id']` 改为 `r['link_id']`、`n['id']` 改为 `n['node_id']`;② `--target-tim... diff --git a/.opencode/skills/workflow/hydraulic-bottleneck-analysis/scripts/bottleneck_analysis.py b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/scripts/bottleneck_analysis.py index 97fb352..571a02a 100644 --- a/.opencode/skills/workflow/hydraulic-bottleneck-analysis/scripts/bottleneck_analysis.py +++ b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/scripts/bottleneck_analysis.py @@ -3,6 +3,8 @@ 水力瓶颈管道综合分析 数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议 注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。 +schema 适配(2026-08 实测):realtime links 主键为 link_id,realtime nodes 主键为 node_id, +time 为 UTC 格式,--target-time 需传 UTC 时刻(如北京时间 08:00 对应 00:00+00:00)。 """ import json, sys, math, argparse from collections import defaultdict @@ -38,16 +40,16 @@ def main(): np = load_json(args.node_pressures).get('data', []) 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) # 2. Merge print("[2/5] Merging...", file=sys.stderr) merged = [] for r in rt: - pid = r['id']; prop = pipe_map.get(pid) + pid = r['link_id']; prop = pipe_map.get(pid) 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) # 3. Score diff --git a/.opencode/tools/activity_update.ts b/.opencode/tools/activity_update.ts new file mode 100644 index 0000000..6ea2ff5 --- /dev/null +++ b/.opencode/tools/activity_update.ts @@ -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 "活动阶段已更新。"; + }, +}); diff --git a/.opencode/tools/apply_layer_style.ts b/.opencode/tools/apply_layer_style.ts index 95e183b..e390e9c 100644 --- a/.opencode/tools/apply_layer_style.ts +++ b/.opencode/tools/apply_layer_style.ts @@ -4,11 +4,6 @@ export default tool({ description: "在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。", args: { - reason: tool.schema - .string() - .describe( - "Why this style action is needed for the current user request.", - ), layer_id: tool.schema .enum(["junctions", "pipes"]) .describe("Target layer id. Must be exactly 'junctions' or 'pipes'."), diff --git a/.opencode/tools/final_answer.ts b/.opencode/tools/final_answer.ts new file mode 100644 index 0000000..89226dc --- /dev/null +++ b/.opencode/tools/final_answer.ts @@ -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 "最终回答已提交。"; + }, +}); diff --git a/.opencode/tools/geocode.ts b/.opencode/tools/geocode.ts index d113ad5..065a28a 100644 --- a/.opencode/tools/geocode.ts +++ b/.opencode/tools/geocode.ts @@ -8,9 +8,6 @@ export default tool({ description: "调用 TJWater 后端的天地图地理编码服务,将中国境内结构化地址或地点名称转换为经纬度。若需缩放地图,把返回的 location.lon/location.lat 传给 zoom_to_map,并设置 source_crs='EPSG:4326'。", args: { - reason: tool.schema - .string() - .describe("Why geocoding is required for the current user request."), keyword: tool.schema .string() .describe("Address or place name to geocode, such as 北京市人民政府."), diff --git a/.opencode/tools/locate_features.ts b/.opencode/tools/locate_features.ts index 1ff3b49..b33e08d 100644 --- a/.opencode/tools/locate_features.ts +++ b/.opencode/tools/locate_features.ts @@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin"; export default tool({ description: "在前端地图上定位并高亮指定的管网要素。", args: { - reason: tool.schema - .string() - .describe( - "Why this map positioning action is needed for the user request.", - ), ids: tool.schema .array(tool.schema.string()) .describe("Feature ids to locate."), diff --git a/.opencode/tools/memory_manager.ts b/.opencode/tools/memory_manager.ts index bbceda5..2fbac0c 100644 --- a/.opencode/tools/memory_manager.ts +++ b/.opencode/tools/memory_manager.ts @@ -11,9 +11,6 @@ export default tool({ action: tool.schema .enum(["add", "list", "replace", "remove"]) .describe("Memory operation to perform."), - reason: tool.schema - .string() - .describe("Why this memory should be persisted for future requests."), scope: tool.schema .string() .describe( diff --git a/.opencode/tools/render_junctions.ts b/.opencode/tools/render_junctions.ts index b5a5d9e..1ed3e60 100644 --- a/.opencode/tools/render_junctions.ts +++ b/.opencode/tools/render_junctions.ts @@ -4,11 +4,6 @@ export default tool({ description: "在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIR,location.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。", args: { - reason: tool.schema - .string() - .describe( - "Why this junction rendering action is needed for the user request.", - ), render_ref: tool.schema .string() .describe( diff --git a/.opencode/tools/session_search.ts b/.opencode/tools/session_search.ts index 50dcea9..fec6c2b 100644 --- a/.opencode/tools/session_search.ts +++ b/.opencode/tools/session_search.ts @@ -8,9 +8,6 @@ export default tool({ description: "搜索当前用户和项目范围内的历史会话 transcript。适合回忆过去讨论过的案例、约束和结论,避免把一次性案例写入 memory。", args: { - reason: tool.schema - .string() - .describe("Why prior session history is needed for the current request."), query: tool.schema .string() .describe("What to search for in prior session history."), diff --git a/.opencode/tools/show_chart.ts b/.opencode/tools/show_chart.ts index e133305..306c8e1 100644 --- a/.opencode/tools/show_chart.ts +++ b/.opencode/tools/show_chart.ts @@ -4,9 +4,6 @@ export default tool({ description: "在前端对话界面中渲染图表。折线图/柱状图必须使用 x_data 作为横轴标签,series[].data 作为同长度的一维数值数组,不要把折线数据写成 ECharts 的 [x, y] 二维点数组。", args: { - reason: tool.schema - .string() - .describe("Why this chart should be rendered for the user request."), title: tool.schema.string().optional().describe("Chart title."), chart_type: tool.schema .enum(["line", "bar", "pie"]) diff --git a/.opencode/tools/skill_manager.ts b/.opencode/tools/skill_manager.ts index bb21b2b..012301e 100644 --- a/.opencode/tools/skill_manager.ts +++ b/.opencode/tools/skill_manager.ts @@ -21,9 +21,6 @@ export default tool({ "remove_script", ]) .describe("Skill maintenance operation."), - reason: tool.schema - .string() - .describe("Why this skill maintenance action is justified for future reuse."), skill_path: tool.schema .string() .describe( diff --git a/.opencode/tools/store_render_ref.ts b/.opencode/tools/store_render_ref.ts index 794bc72..827a31b 100644 --- a/.opencode/tools/store_render_ref.ts +++ b/.opencode/tools/store_render_ref.ts @@ -23,11 +23,6 @@ export default tool({ description: "导入当前对话工作目录下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } },location.file_path 必须与传入的绝对路径完全一致。只接受当前对话工作目录内的真实文件,不接受其他对话目录、目录外路径或指向目录外的符号链接。", args: { - reason: tool.schema - .string() - .describe( - "为何需要将此本地渲染数据持久化为 render_ref,以便后续通过 render_junctions 渲染到前端。", - ), file_path: tool.schema .string() .optional() diff --git a/.opencode/tools/tjwater_cli.ts b/.opencode/tools/tjwater_cli.ts index e05a8b8..1e7fca7 100644 --- a/.opencode/tools/tjwater_cli.ts +++ b/.opencode/tools/tjwater_cli.ts @@ -6,15 +6,12 @@ const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; export default tool({ description: - "通过本地 Agent 桥接调用 tjwater-cli 命令访问 TJWater 后端服务。提供 CLI 子命令和参数。", + "通过本地 Agent 桥接调用 tjwater-cli。命令路径和参数不是可自由拼接的语法;若当前会话或已加载工作流没有经过验证的完整命令,必须先调用 help 或 help <命令族>,再从返回的 command、usage 和 options 中选择。", args: { - reason: tool.schema - .string() - .describe("Why this tool call is required for the current user request."), command: tool.schema .string() .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 .number() @@ -38,7 +35,6 @@ export default tool({ }, body: JSON.stringify({ session_id: context.sessionID, - reason: args.reason, command: args.command, store_result: args.store_result, timeout: args.timeout, diff --git a/.opencode/tools/view_history.ts b/.opencode/tools/view_history.ts index 29c68a9..f3db730 100644 --- a/.opencode/tools/view_history.ts +++ b/.opencode/tools/view_history.ts @@ -3,11 +3,6 @@ import { tool } from "@opencode-ai/plugin"; export default tool({ description: "为选定的管网要素打开前端的历史记录或计算结果面板。", args: { - reason: tool.schema - .string() - .describe( - "Why this history panel should be opened for the current task.", - ), feature_infos: tool.schema .array(tool.schema.tuple([tool.schema.string(), tool.schema.string()])) .describe("List of [id, type] pairs."), diff --git a/.opencode/tools/view_scada.ts b/.opencode/tools/view_scada.ts index ad05355..d0063cc 100644 --- a/.opencode/tools/view_scada.ts +++ b/.opencode/tools/view_scada.ts @@ -3,9 +3,6 @@ import { tool } from "@opencode-ai/plugin"; export default tool({ description: "打开前端的 SCADA 监测数据历史面板。", args: { - reason: tool.schema - .string() - .describe("Why SCADA panel interaction is required for this request."), device_ids: tool.schema .array(tool.schema.string()) .optional() diff --git a/.opencode/tools/web_search.ts b/.opencode/tools/web_search.ts index fd64c2a..443f1f0 100644 --- a/.opencode/tools/web_search.ts +++ b/.opencode/tools/web_search.ts @@ -8,9 +8,6 @@ export default tool({ description: "调用 TJWater 后端的实时网页搜索服务。适合查询新闻、政策、规范、产品资料、公开网页事实等可能变化的信息。", args: { - reason: tool.schema - .string() - .describe("Why web search is required for the current user request."), query: tool.schema.string().describe("Search query text."), freshness: tool.schema .enum(["no_limit", "one_day", "one_week", "one_month", "one_year"]) diff --git a/.opencode/tools/zoom_to_map.ts b/.opencode/tools/zoom_to_map.ts index 986533b..9bdd105 100644 --- a/.opencode/tools/zoom_to_map.ts +++ b/.opencode/tools/zoom_to_map.ts @@ -4,9 +4,6 @@ export default tool({ description: "在前端地图上缩放定位到坐标。默认坐标为 EPSG:3857;如果来自天地图 geocode 的 lon/lat,传 source_crs='EPSG:4326',前端会转换为 EPSG:3857 后缩放。", args: { - reason: tool.schema - .string() - .describe("Why this map zoom action is needed for the current request."), x: tool.schema .number() .describe("X coordinate. For EPSG:4326 this is longitude; for EPSG:3857 this is meters."), diff --git a/opencode.json b/opencode.json index 80e4996..83b9f89 100644 --- a/opencode.json +++ b/opencode.json @@ -60,6 +60,8 @@ "*.env*": "deny" }, "question": "allow", + "activity_update": "allow", + "final_answer": "allow", "task": "deny", "todo": "allow", "todoread": "allow", diff --git a/src/audit/llmRequestAudit.ts b/src/audit/llmRequestAudit.ts index be9307a..bbc5916 100644 --- a/src/audit/llmRequestAudit.ts +++ b/src/audit/llmRequestAudit.ts @@ -4,14 +4,15 @@ import { dirname } from "node:path"; import { config } from "../config.js"; export type LlmRequestAuditEntry = { - kind: "tool" | "skill"; + kind: "activity" | "tool" | "skill"; sessionId: string; clientSessionId: string; traceId?: string; projectId?: string; target: string; - reason: string; - reasonProvided: boolean; + activityId?: string; + activityTitle?: string; + activityReason?: string; payload?: Record; }; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 7cdc993..6610254 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -35,6 +35,7 @@ import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js"; import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js"; import { collectTextContent, + type ActivityUpdatePayload, type PermissionRequestPayload, type QuestionRequestPayload, streamPromptResponse, @@ -46,7 +47,9 @@ import { type StreamSubscriber, appendBackendToolArtifact, cancelBackendTodos, + completeBackendActivities, completeBackendProgress, + completeBackendTodos, createInitialStreamingMessages, isObjectRecord, toFrontendPermission, @@ -54,6 +57,7 @@ import { updateLastAssistantMessage, updateLastAssistantPermission, updateLastAssistantQuestion, + upsertBackendActivity, upsertBackendProgress, upsertBackendQuestion, upsertBackendTodoUpdate, @@ -687,6 +691,27 @@ export const buildChatRouter = ( content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`, 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") { activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ ...message, @@ -702,6 +727,8 @@ export const buildChatRouter = ( ? message.content : "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。", progress: completeBackendProgress(message.progress), + activities: completeBackendActivities(message.activities), + todos: completeBackendTodos(message.todos), })); } else if (event === "error") { activeRun.status = activeRun.status === "aborted" ? "aborted" : "error"; @@ -714,6 +741,7 @@ export const buildChatRouter = ( : `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`, isError: true, progress: completeBackendProgress(message.progress), + activities: completeBackendActivities(message.activities, "error"), todos: cancelBackendTodos(message.todos), })); } else if (event === "auth_required") { @@ -727,6 +755,7 @@ export const buildChatRouter = ( : "⚠️ **登录态已过期,请刷新登录后重试**", isError: true, progress: completeBackendProgress(message.progress), + activities: completeBackendActivities(message.activities, "error"), todos: cancelBackendTodos(message.todos), })); } else if (event === "permission_request") { @@ -968,6 +997,7 @@ export const buildChatRouter = ( : "⚠️ **请求已中断**", isError: true, progress: completeBackendProgress(message.progress), + activities: completeBackendActivities(message.activities, "cancelled"), todos: cancelBackendTodos(message.todos), })); void queueSessionUiStatePersist().catch((error) => { diff --git a/src/routes/chatActivityTracker.ts b/src/routes/chatActivityTracker.ts new file mode 100644 index 0000000..bf726ac --- /dev/null +++ b/src/routes/chatActivityTracker.ts @@ -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; +type ActivityContext = Pick; + +const getActionTarget = (params: Record) => { + 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) => void; +}) => { + const activities = new Map(); + const actionActivityIds = new Map(); + 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) => { + 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) => { + 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, + }; +}; diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index b9a1871..70c8458 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -2,24 +2,16 @@ import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2"; import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js"; import { type SupportedModel } from "../chat/models.js"; -import { config } from "../config.js"; import { logger } from "../logger.js"; import { type PermissionReply, type OpencodeRuntimeAdapter, } from "../runtime/opencode.js"; import { - buildPermissionDetail, - buildPermissionV2Detail, - buildReasoningProgressDetail, - buildSessionStatusDetail, - buildToolProgressDetail, collectTextContent, - extractRequestReason, extractSkillAuditInfo, getErrorMessage, getAssistantMessagePhase, - getToolProgressTitle, getUnknownErrorMessage, hasToolParams, isPermissionAskedEvent, @@ -35,6 +27,7 @@ import { isQuestionV2RepliedEvent, isSessionEvent, isSkillEvent, + initialActivity, logDevelopmentDebug, normalizeQuestionAnswers, normalizeQuestionPayload, @@ -42,13 +35,15 @@ import { normalizeTodoPriority, normalizeTodoStatus, normalizeToolParams, - normalizeToolStatus, + type ActivityPayload, + type ActivityUpdatePayload, type PermissionRequestPayload, type QuestionRequestPayload, type AssistantMessagePhase, type TodoItemPayload, type TodoUpdatePayload, } from "./chatStreamEvents.js"; +import { createActivityTracker } from "./chatActivityTracker.js"; import { resolvePermissionApproval, type ApprovalMode, @@ -56,10 +51,13 @@ import { export { collectTextContent, + initialActivity, type PermissionRequestPayload, type QuestionRequestPayload, type TodoItemPayload, type TodoUpdatePayload, + type ActivityPayload, + type ActivityUpdatePayload, } from "./chatStreamEvents.js"; export type { ApprovalMode } from "./chatPermissionPolicy.js"; @@ -78,16 +76,6 @@ type StreamPromptOptions = { write: (event: string, data: Record) => void; }; -type ProgressStatus = "running" | "completed" | "error"; - -type ProgressPayload = { - id: string; - phase: string; - status: ProgressStatus; - title: string; - detail?: string; -}; - const getPermissionTarget = (metadata: unknown) => { if (!isObjectRecord(metadata)) { return undefined; @@ -115,38 +103,20 @@ const toRuntimeModel = (model?: SupportedModel) => { }; }; -const STRUCTURED_OUTPUT_TOOL_NAME = "StructuredOutput"; -const STRUCTURED_FINAL_ANSWER_FORMAT = { - type: "json_schema" as const, - schema: { - type: "object", - properties: { - answer: { - type: "string", - description: "直接展示给用户的完整最终回答,使用简体中文和 Markdown。", - }, - }, - required: ["answer"], - additionalProperties: false, - }, -}; +const FINAL_ANSWER_TOOL_NAME = "final_answer"; +const ACTIVITY_UPDATE_TOOL_NAME = "activity_update"; -const requiresStructuredFinalAnswer = (model?: SupportedModel) => - (model ?? config.OPENCODE_MODEL).split("/", 1)[0] === "deepseek"; - -const extractStructuredAnswer = (value: unknown) => +const extractFinalAnswer = (value: unknown) => isObjectRecord(value) && typeof value.answer === "string" ? value.answer.trim() : ""; -const emitFinalMessage = async ( +const resolveFinalMessage = async ( runtime: OpencodeRuntimeAdapter, sessionId: string, - clientSessionId: string, currentAssistantMessageIds: Set, assistantTextParts: Map>, assistantTextPartPhases: Map, - write: (event: string, data: Record) => void, ) => { let text = [...currentAssistantMessageIds] .reverse() @@ -180,30 +150,53 @@ const emitFinalMessage = async ( (currentAssistantMessageIds.size === 0 || currentAssistantMessageIds.has(message.info.id)), ); - const structured = - assistantMessage && "structured" in assistantMessage.info - ? assistantMessage.info.structured - : undefined; - const structuredAnswer = extractStructuredAnswer(structured); - text = - structuredAnswer || - collectTextContent( - (assistantMessage?.parts ?? []).filter( + const assistantParts = assistantMessage?.parts ?? []; + text = collectTextContent( + assistantParts.filter( + (part) => + part.type !== "text" || + getAssistantMessagePhase(part.metadata) !== "commentary", + ), + ); + if (!text) { + const finalAnswerPart = [...assistantParts] + .reverse() + .find( (part) => - part.type !== "text" || - getAssistantMessagePhase(part.metadata) !== "commentary", - ), - ); + part.type === "tool" && part.tool === FINAL_ANSWER_TOOL_NAME, + ); + if (finalAnswerPart?.type === "tool") { + text = extractFinalAnswer(finalAnswerPart.state.input); + } + } } - if (text) { - write("token", { - session_id: clientSessionId, - content: text, - }); - return true; - } - return false; + return text.trim(); +}; + +const normalizeActivityTodos = (value: unknown): TodoItemPayload[] | undefined => { + if (!Array.isArray(value)) return undefined; + const now = Date.now(); + return value + .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 ({ @@ -227,15 +220,13 @@ export const streamPromptResponse = async ({ const iterator = eventStream[Symbol.asyncIterator](); const requestStartedAt = Date.now(); const promptStartedAt = Date.now(); - const progressStartedAtMap = new Map(); - const finalizedProgressIds = new Set(); const emittedToolParts = new Set(); + const emittedActivityParts = new Set(); const emittedQuestionToolParts = new Set(); const emittedQuestionRequestIds = new Set(); const currentAssistantMessageIds = new Set(); const assistantTextParts = new Map>(); const assistantTextPartPhases = new Map(); - const emittedFinalTextLengths = new Map(); const partTypes = new Map(); const pendingTextDeltas = new Map(); const reasoningStatuses = new Map(); @@ -246,13 +237,12 @@ export const streamPromptResponse = async ({ let lastSessionStatus: string | null = null; let lastSessionStatusMessage: string | null = null; let sawResponseActivity = false; - let emittedText = false; + let finalAnswerText = ""; let toolCallCount = 0; let done = false; let promptSettled = false; let aborted = signal?.aborted ?? false; let failed = false; - let structuredFinalAnswerEmitted = false; const debugContext = { sessionId, clientSessionId, @@ -278,94 +268,24 @@ export const streamPromptResponse = async ({ }) : null; - const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => { - if (status === "running" && finalizedProgressIds.has(id)) { - return; - } + const activityTracker = createActivityTracker({ clientSessionId, write }); - const now = Date.now(); - const startedAt = progressStartedAtMap.get(id) ?? now; - if (!progressStartedAtMap.has(id)) { - 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 captureFinalText = (text: string) => { + const answer = text.trim(); + if (answer) finalAnswerText = answer; }; - const emitFinalText = (partId: string, text: string) => { - const emittedLength = emittedFinalTextLengths.get(partId) ?? 0; - if (text.length <= emittedLength) { - return; - } - 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: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。", - }); + activityTracker.start( + initialActivity.id, + initialActivity.title, + initialActivity.reason, + ); const promptPromise = runtime .prompt( sessionId, message, toRuntimeModel(model), - requiresStructuredFinalAnswer(model) - ? { format: STRUCTURED_FINAL_ANSWER_FORMAT } - : undefined, ) .then(() => { promptSettled = true; @@ -388,6 +308,8 @@ export const streamPromptResponse = async ({ ...debugContext, }); + let pendingIteratorNext: ReturnType | undefined; + try { while (!done) { if (signal?.aborted) { @@ -399,8 +321,8 @@ export const streamPromptResponse = async ({ break; } - const nextEvent = iterator - .next() + pendingIteratorNext ??= iterator.next(); + const nextEvent = pendingIteratorNext .then((result) => ({ type: "event" as const, result })); const nextPrompt = promptSettled ? null @@ -426,6 +348,7 @@ export const streamPromptResponse = async ({ if (next.type === "prompt") { continue; } + pendingIteratorNext = undefined; if (next.result.done) { break; } @@ -465,18 +388,6 @@ export const streamPromptResponse = async ({ 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; } @@ -508,17 +419,6 @@ export const streamPromptResponse = async ({ patterns: event.properties.patterns, 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) { const reply = permissionApproval.autoReject ? "reject" : "once"; await runtime.replyPermission({ @@ -534,12 +434,15 @@ export const streamPromptResponse = async ({ }); continue; } + const activity = activityTracker.getCurrentContext(); write("permission_request", { session_id: clientSessionId, request_id: event.properties.id, permission: event.properties.permission, patterns: event.properties.patterns, target: getPermissionTarget(event.properties.metadata), + activity_id: activity?.id, + reason: activity?.reason, always: event.properties.always, tool: event.properties.tool, created_at: Date.now(), @@ -565,17 +468,6 @@ export const streamPromptResponse = async ({ resources: event.properties.resources, 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) { const reply = permissionApproval.autoReject ? "reject" : "once"; await runtime.replyPermission({ @@ -591,12 +483,15 @@ export const streamPromptResponse = async ({ }); continue; } + const activity = activityTracker.getCurrentContext(); write("permission_request", { session_id: clientSessionId, request_id: event.properties.id, permission: event.properties.action, patterns: event.properties.resources, target: getPermissionTarget(event.properties.metadata), + activity_id: activity?.id, + reason: activity?.reason, always: event.properties.save ?? [], tool: undefined, created_at: Date.now(), @@ -612,21 +507,6 @@ export const streamPromptResponse = async ({ reply: event.properties.reply, 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", { session_id: clientSessionId, request_id: event.properties.requestID, @@ -643,21 +523,6 @@ export const streamPromptResponse = async ({ reply: event.properties.reply, 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", { session_id: clientSessionId, request_id: event.properties.requestID, @@ -674,15 +539,6 @@ export const streamPromptResponse = async ({ questionCount: event.properties.questions.length, 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); emittedQuestionRequestIds.add(payload.request_id); write("question_request", payload); @@ -696,16 +552,6 @@ export const streamPromptResponse = async ({ requestId: event.properties.requestID, 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", { session_id: clientSessionId, request_id: event.properties.requestID, @@ -721,13 +567,6 @@ export const streamPromptResponse = async ({ requestId: event.properties.requestID, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); - emitProgress({ - id: `question-${event.properties.requestID}`, - phase: "question", - status: "completed", - title: "已跳过补充信息", - detail: "用户选择跳过本次补充信息。", - }); write("question_response", { session_id: clientSessionId, request_id: event.properties.requestID, @@ -738,11 +577,11 @@ export const streamPromptResponse = async ({ if (isSkillEvent(event)) { sawResponseActivity = true; - const { name, reason, payload } = extractSkillAuditInfo(event); + const { name, payload } = extractSkillAuditInfo(event); + const activity = activityTracker.getCurrentContext(); logDevelopmentDebug("skill event received", { ...debugContext, skill: name, - reason: reason || null, payloadKeys: Object.keys(payload).slice(0, 8), elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); @@ -753,8 +592,9 @@ export const streamPromptResponse = async ({ traceId, projectId, target: name, - reason, - reasonProvided: Boolean(reason), + activityId: activity?.id, + activityTitle: activity?.title, + activityReason: activity?.reason, payload, }).catch((error) => { logger.warn({ err: error }, "failed to write skill audit log"); @@ -780,9 +620,7 @@ export const streamPromptResponse = async ({ assistantTextParts.set(event.properties.messageID, messageParts); const phase = assistantTextPartPhases.get(event.properties.partID) ?? "unknown"; if (phase === "final_answer") { - emitFinalText(event.properties.partID, text); - } else if (phase === "commentary") { - emitCommentaryProgress(event.properties.partID, text, false); + captureFinalText(text); } } else if (!partType) { const pending = pendingTextDeltas.get(event.properties.partID) ?? []; @@ -809,9 +647,7 @@ export const streamPromptResponse = async ({ messageParts.set(part.id, text); assistantTextParts.set(part.messageID, messageParts); if (phase === "final_answer") { - emitFinalText(part.id, text); - } else if (phase === "commentary") { - emitCommentaryProgress(part.id, text, Boolean(part.time?.end)); + captureFinalText(text); } } else { pendingTextDeltas.delete(part.id); @@ -827,14 +663,6 @@ export const streamPromptResponse = async ({ 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 (!firstToolEventLogged) { @@ -849,7 +677,6 @@ export const streamPromptResponse = async ({ }); } const toolParams = normalizeToolParams(part.state.input); - const reason = extractRequestReason(toolParams); const isToolFinalState = part.state.status === "completed" || part.state.status === "error"; const nextToolStatus = String(part.state.status); @@ -861,7 +688,6 @@ export const streamPromptResponse = async ({ partId: part.id, tool: part.tool, status: nextToolStatus, - reason: reason || null, inputKeys: Object.keys(toolParams).slice(0, 8), error: 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") { logger.warn( { @@ -878,38 +704,71 @@ export const streamPromptResponse = async ({ partId: part.id, error: part.state.error, }, - "structured final answer tool failed", + "final answer tool failed", ); continue; } if ( (part.state.status !== "running" && - part.state.status !== "completed") || - structuredFinalAnswerEmitted + part.state.status !== "completed") ) { continue; } - const answer = extractStructuredAnswer(toolParams); + const answer = extractFinalAnswer(toolParams); if (!answer) { logger.warn( { sessionId, clientSessionId, partId: part.id }, - "structured final answer tool received without an answer", + "final answer tool received without an answer", ); continue; } - structuredFinalAnswerEmitted = true; - emitFinalText(part.id, answer); - logDevelopmentDebug("final answer submitted through StructuredOutput", { + captureFinalText(answer); + logDevelopmentDebug("final answer submitted through tool", { ...debugContext, partId: part.id, + tool: part.tool, answerChars: answer.length, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); continue; } + if (part.tool === ACTIVITY_UPDATE_TOOL_NAME) { + const title = typeof toolParams.title === "string" + ? toolParams.title.trim() + : ""; + const reason = typeof toolParams.reason === "string" + ? toolParams.reason.trim() + : ""; + const todos = normalizeActivityTodos(toolParams.todos); + if ( + title && + reason && + !emittedActivityParts.has(part.id) && + (hasToolParams(toolParams) || isToolFinalState) + ) { + emittedActivityParts.add(part.id); + const activity = activityTracker.start(part.id, title, reason, todos); + void writeLlmRequestAuditLog({ + kind: "activity", + sessionId, + clientSessionId, + traceId, + projectId, + target: ACTIVITY_UPDATE_TOOL_NAME, + activityId: activity.id, + activityTitle: activity.title, + activityReason: activity.reason, + payload: toolParams, + }).catch((error) => { + logger.warn({ err: error }, "failed to write activity audit log"); + }); + } + continue; + } + const questionToolPayload = normalizeQuestionToolPayload( part, toolParams, @@ -926,49 +785,23 @@ export const streamPromptResponse = async ({ questionCount: questionToolPayload.questions.length, 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); } continue; } - emitProgress({ - id: part.id, - phase: "tool", - status: normalizeToolStatus(part.state.status), - title: getToolProgressTitle(part.tool, part.state.status), - detail: buildToolProgressDetail( - part.tool, - part.state.status, - toolParams, - reason, - part.state.status === "error" ? part.state.error : undefined, - ), - }); + if (part.tool === "todowrite" || part.tool === "todo") { + continue; + } + + activityTracker.upsertAction(part, toolParams); if ( !emittedToolParts.has(part.id) && (hasToolParams(toolParams) || isToolFinalState) ) { emittedToolParts.add(part.id); toolCallCount += 1; - if (!reason) { - logger.warn( - { - tool: part.tool, - sessionId: sessionId, - clientSessionId, - }, - "llm tool request missing reason", - ); - } + const activity = activityTracker.getActionContext(part.id); void writeLlmRequestAuditLog({ kind: "tool", sessionId: sessionId, @@ -976,8 +809,9 @@ export const streamPromptResponse = async ({ traceId, projectId, target: part.tool, - reason, - reasonProvided: Boolean(reason), + activityId: activity?.id, + activityTitle: activity?.title, + activityReason: activity?.reason, payload: toolParams, }).catch((error) => { logger.warn({ err: error }, "failed to write tool audit log"); @@ -986,7 +820,6 @@ export const streamPromptResponse = async ({ session_id: clientSessionId, tool: part.tool, params: toolParams, - reason, }); } } @@ -1007,18 +840,6 @@ export const streamPromptResponse = async ({ priority: normalizeTodoPriority(todo.priority), 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", { session_id: clientSessionId, todos: normalizedTodos, @@ -1036,6 +857,7 @@ export const streamPromptResponse = async ({ ? getErrorMessage(event.properties.error) : "opencode session error", }); + activityTracker.finalize("error"); write("error", { session_id: clientSessionId, message: event.properties.error @@ -1059,17 +881,10 @@ export const streamPromptResponse = async ({ } logDevelopmentDebug("session idle received", { ...debugContext, - emittedText, + hasFinalAnswer: Boolean(finalAnswerText), toolCallCount, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); - emitProgress({ - id: "session-status", - phase: "session", - status: "completed", - title: "Agent 已完成处理", - detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。", - }); done = true; } } @@ -1079,6 +894,7 @@ export const streamPromptResponse = async ({ ...debugContext, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); + activityTracker.finalize("cancelled"); await runtime.abortSession(sessionId).catch((error) => { logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session"); }); @@ -1096,40 +912,34 @@ export const streamPromptResponse = async ({ } await promptPromise; - if (!emittedText) { - emittedText = await emitFinalMessage( + if (!finalAnswerText) { + finalAnswerText = await resolveFinalMessage( runtime, sessionId, - clientSessionId, currentAssistantMessageIds, assistantTextParts, assistantTextPartPhases, - write, ); } - emitProgress({ - id: "request-received", - phase: "start", - status: "completed", - title: "请求处理完成", - detail: "本次请求的分析、工具执行和结果整理流程已经完成。", - }); - emitProgress({ - id: "request-completed", - phase: "complete", - status: "completed", - title: "分析完成", - detail: emittedText - ? "最终回答已生成并推送到前端。" - : "已完成分析,并通过兜底消息补发最终回答内容。", - }); + activityTracker.finalize("completed"); + if (finalAnswerText) { + // Keep one compatibility cycle for deployed frontends that only consume token. + write("token", { + session_id: clientSessionId, + content: finalAnswerText, + }); + write("final_answer", { + session_id: clientSessionId, + content: finalAnswerText, + }); + } write("done", { session_id: clientSessionId, total_duration_ms: Math.max(0, Date.now() - requestStartedAt), }); logDevelopmentDebug("chat stream completed", { ...debugContext, - emittedText, + hasFinalAnswer: Boolean(finalAnswerText), toolCallCount, totalDurationMs: Math.max(0, Date.now() - requestStartedAt), }); diff --git a/src/routes/chatStreamEvents.ts b/src/routes/chatStreamEvents.ts index 67cf8de..4d1e649 100644 --- a/src/routes/chatStreamEvents.ts +++ b/src/routes/chatStreamEvents.ts @@ -9,6 +9,8 @@ export type PermissionRequestPayload = { permission: string; patterns: string[]; target?: string; + activity_id?: string; + reason?: string; always: string[]; tool?: { messageID: string; @@ -17,6 +19,46 @@ export type PermissionRequestPayload = { 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 = { label: string; description: string; @@ -65,6 +107,9 @@ export type AssistantMessagePhase = const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; const toolLabels: Record = { + tjwater_cli: "查询后端数据", + bash: "运行本地分析", + store_render_ref: "保存渲染结果", memory_manager: "记忆写入", geocode: "地理编码", session_search: "历史会话检索", @@ -76,6 +121,7 @@ const toolLabels: Record = { view_scada: "SCADA 面板", show_chart: "图表渲染", render_junctions: "节点渲染", + apply_layer_style: "图层样式调整", }; export const logDevelopmentDebug = ( @@ -143,20 +189,6 @@ export const normalizeToolParams = (value: unknown): Record => return {}; }; -export const extractRequestReason = (params: Record) => { - 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) => event.type.toLowerCase().includes("skill"); @@ -172,10 +204,8 @@ export const extractSkillAuditInfo = (event: OpencodeEvent) => { : typeof payload.name === "string" ? payload.name : event.type; - const reason = extractRequestReason(payload); return { name: candidateName, - reason, payload, }; }; @@ -240,24 +270,6 @@ export const isQuestionV2RejectedEvent = ( ): event is Extract => event.type === "question.v2.rejected"; -export const buildPermissionDetail = ( - event: Extract, -) => { - const patterns = event.properties.patterns.length - ? event.properties.patterns.join(", ") - : event.properties.permission; - return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`; -}; - -export const buildPermissionV2Detail = ( - event: Extract, -) => { - const resources = event.properties.resources.length - ? event.properties.resources.join(", ") - : event.properties.action; - return `需要用户确认权限:${event.properties.action};资源:${resources}`; -}; - export const normalizeQuestionPayload = ( event: Extract, clientSessionId: string, @@ -374,88 +386,4 @@ export const normalizeToolStatus = (status: string) => { return "running"; }; -const formatProgressValue = (value: unknown): string => { - 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) => { - 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, - 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}`; -}; +export const getToolLabel = (tool: string) => toolLabels[tool] ?? tool; diff --git a/src/routes/chatUiState.ts b/src/routes/chatUiState.ts index 30c9135..16d0eef 100644 --- a/src/routes/chatUiState.ts +++ b/src/routes/chatUiState.ts @@ -1,5 +1,7 @@ import { type PermissionReply } from "../runtime/opencode.js"; import { + type ActivityPayload, + initialActivity, type PermissionRequestPayload, type QuestionRequestPayload, type TodoUpdatePayload, @@ -28,6 +30,7 @@ type ToolCallPayload = { session_id?: string; tool?: string; params?: unknown; + /** Legacy payload field. New tool calls inherit purpose from their Activity. */ reason?: string; }; @@ -56,13 +59,13 @@ export const createInitialStreamingMessages = ( id: createFrontendMessageId(), role: "assistant", content: "", - progress: [ + activities: [ { - id: "request-received", - phase: "start", + id: initialActivity.id, status: "running", - title: "已收到请求,正在启动 Agent 分析", - detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。", + title: initialActivity.title, + reason: initialActivity.reason, + actions: [], startedAt: Date.now(), elapsedMs: 0, 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 = ( progress: unknown, payload: Record, @@ -152,6 +239,31 @@ export const cancelBackendTodos = (todos: unknown) => }) : 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 = ( messages: unknown[], updater: (message: Record) => Record, @@ -249,10 +361,6 @@ export const appendBackendToolArtifact = ( tool, kind: getToolArtifactKind(tool), title: getToolArtifactTitle(tool, params), - description: - typeof payload.reason === "string" && payload.reason.trim() - ? payload.reason.trim() - : undefined, params, }); return next; @@ -267,6 +375,8 @@ export const toFrontendPermission = ( permission: payload.permission, patterns: payload.patterns, target: payload.target, + activityId: payload.activity_id, + reason: payload.reason, always: payload.always, tool: payload.tool, createdAt: payload.created_at, diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index 7b39981..99c40f2 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -1,7 +1,6 @@ import { createOpencode, type OpencodeClient, - type OutputFormat, } from "@opencode-ai/sdk/v2"; import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; @@ -39,10 +38,6 @@ type RuntimeModelOverride = { modelID: string; }; -type RuntimePromptOptions = { - format?: OutputFormat; -}; - export type PermissionReply = "once" | "always" | "reject"; export type QuestionAnswers = string[][]; @@ -189,7 +184,6 @@ export class OpencodeRuntimeAdapter { sessionId: string, text: string, model?: RuntimeModelOverride, - options?: RuntimePromptOptions, ) { const client = await this.ensureClient(); const startedAt = Date.now(); @@ -204,7 +198,6 @@ export class OpencodeRuntimeAdapter { await client.session.prompt({ sessionID: sessionId, model, - ...(options?.format ? { format: options.format } : {}), parts: [{ type: "text", text }], }); logDevelopmentDebug( diff --git a/tests/routes/chatActivityTracker.test.ts b/tests/routes/chatActivityTracker.test.ts new file mode 100644 index 0000000..b0c4248 --- /dev/null +++ b/tests/routes/chatActivityTracker.test.ts @@ -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 }> = []; + 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", + }), + ], + }, + }, + }); + }); +}); diff --git a/tests/routes/chatStream.test.ts b/tests/routes/chatStream.test.ts index 511d3ad..e054876 100644 --- a/tests/routes/chatStream.test.ts +++ b/tests/routes/chatStream.test.ts @@ -117,15 +117,27 @@ describe("streamPromptResponse", () => { }); expect(subscribedDirectory).toBe("/tmp/conversation-workspace-1"); - expect( - events - .filter((item) => item.event === "token") - .map((item) => item.data.content) - .join(""), - ).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。"); + expect(events.filter((item) => item.event === "token")).toEqual([ + { + event: "token", + data: { + session_id: "client-session-1", + content: "共识别 56 条瓶颈管段,建议优先改造 Top 5。", + }, + }, + ]); + expect(events.filter((item) => item.event === "final_answer")).toEqual([ + { + event: "final_answer", + data: { + session_id: "client-session-1", + content: "共识别 56 条瓶颈管段,建议优先改造 Top 5。", + }, + }, + ]); }); - it("streams final_answer deltas while routing commentary to progress", async () => { + it("buffers final_answer deltas and emits one complete answer", async () => { let messagesCalls = 0; const runtime = { subscribeEvents: async () => @@ -241,20 +253,20 @@ describe("streamPromptResponse", () => { write: (event, data) => events.push({ event, data }), }); - expect( - events.filter((item) => item.event === "token").map((item) => item.data.content), - ).toEqual(["分析完成,", "结果正常。"]); + expect(events.find((item) => item.event === "token")?.data.content).toBe( + "分析完成,结果正常。", + ); + expect(events.filter((item) => item.event === "final_answer")).toEqual([ + { + event: "final_answer", + data: { + session_id: "client-session-1", + content: "分析完成,结果正常。", + }, + }, + ]); expect(messagesCalls).toBe(0); - expect( - events.find( - (item) => - item.event === "progress" && item.data.id === "commentary-commentary-part", - )?.data, - ).toMatchObject({ - phase: "commentary", - status: "running", - detail: "我先检查相关数据。", - }); + expect(events.some((item) => item.event === "progress")).toBe(false); expect( events.some( (item) => item.event === "token" && item.data.content === "我先检查相关数据。", @@ -302,18 +314,56 @@ describe("streamPromptResponse", () => { }); expect(result.failed).toBe(false); - expect( - events - .filter((item) => item.event === "token") - .map((item) => item.data.content) - .join(""), - ).toBe("最终分析结果。"); + expect(events.find((item) => item.event === "token")?.data.content).toBe( + "最终分析结果。", + ); + expect(events.find((item) => item.event === "final_answer")?.data.content).toBe( + "最终分析结果。", + ); }); - it("keeps reasoning generic while preserving tool execution details", async () => { + it("groups concrete tool execution under the current activity", async () => { const runtime = { subscribeEvents: async () => 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", properties: { @@ -354,7 +404,6 @@ describe("streamPromptResponse", () => { status: "error", input: { command: "network get-all-pipes-properties --limit 5000", - reason: "尝试突破分页限制", }, error: "HTTP_422 raw backend payload with trace_id=secret-trace", time: { start: 1, end: 2 }, @@ -381,16 +430,37 @@ describe("streamPromptResponse", () => { write: (event, data) => events.push({ event, data }), }); - const reasoningProgress = events.find( - (item) => item.event === "progress" && item.data.id === "reasoning-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", + const activityUpdates = events.filter( + (item) => item.event === "activity_update" && + (item.data.activity as { id?: string } | undefined)?.id === "activity-part-1", ); + 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 () => { @@ -435,6 +505,8 @@ describe("streamPromptResponse", () => { permission: "bash", patterns: ["rm *"], target: "rm tmp.txt", + activity_id: "activity-startup", + reason: "正在理解请求并确定本次分析需要完成的业务步骤。", always: ["rm *"], } satisfies Partial); }); @@ -829,10 +901,35 @@ describe("streamPromptResponse", () => { ).toBe(false); }); - it("forwards todo updates as structured SSE payloads and progress", async () => { + it("forwards todo updates independently from activity progress", async () => { const runtime = { subscribeEvents: async () => 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", properties: { @@ -863,15 +960,7 @@ describe("streamPromptResponse", () => { write: (event, data) => events.push({ event, data }), }); - expect( - events.find( - (item) => item.event === "progress" && item.data.id === "todo-progress", - )?.data, - ).toMatchObject({ - id: "todo-progress", - phase: "planning", - title: "计划进度 1/2", - }); + expect(events.some((item) => item.event === "progress")).toBe(false); expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({ session_id: "client-session-1", 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 events: Array<{ event: string; data: Record }> = []; const runtime = { - subscribeEvents: async () => - createEventStream([ - { + subscribeEvents: async () => ({ + async *[Symbol.asyncIterator]() { + yield { type: "message.part.updated", properties: { sessionID: "runtime-session-1", @@ -904,7 +1008,32 @@ describe("streamPromptResponse", () => { messageID: "assistant-final", type: "tool", 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: { status: "running", 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", properties: { sessionID: "runtime-session-1", @@ -923,31 +1058,29 @@ describe("streamPromptResponse", () => { messageID: "assistant-final", type: "tool", callID: "final-answer-call", - tool: "StructuredOutput", + tool: "final_answer", state: { status: "completed", input: { answer: "供水服务分区分析已完成。" }, output: "最终回答已提交。", - title: "StructuredOutput", + title: "final_answer", metadata: {}, time: { start: 1, end: 2 }, }, }, }, - }, - { + }; + yield { type: "session.idle", properties: { sessionID: "runtime-session-1" }, - }, - ]), + }; + }, + }), prompt: async (...args: unknown[]) => { promptCalls.push(args); }, - messages: async () => { - throw new Error("StructuredOutput should avoid the message fallback"); - }, + messages: async () => [], } as unknown as OpencodeRuntimeAdapter; - const events: Array<{ event: string; data: Record }> = []; const result = await streamPromptResponse({ runtime, @@ -958,37 +1091,87 @@ describe("streamPromptResponse", () => { write: (event, data) => events.push({ event, data }), }); - expect(promptCalls[0]?.[3]).toMatchObject({ - format: { - type: "json_schema", - schema: { - required: ["answer"], + expect(promptCalls[0]?.[3]).toBeUndefined(); + expect(result).toEqual({ aborted: false, failed: false, toolCallCount: 0 }); + expect(events.find((item) => item.event === "token")?.data.content).toBe( + "供水服务分区分析已完成。", + ); + expect(events.filter((item) => item.event === "final_answer")).toEqual([ + { + event: "final_answer", + data: { + session_id: "client-session-1", + content: "供水服务分区分析已完成。", }, }, - }); - expect(result).toEqual({ aborted: false, failed: false, toolCallCount: 0 }); - expect( - events - .filter((item) => item.event === "token") - .map((item) => item.data.content) - .join(""), - ).toBe("供水服务分区分析已完成。"); + ]); expect(events.some((item) => item.event === "tool_call")).toBe(false); 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 }> = []; + 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 }> = []; const runtime = { subscribeEvents: async () => createEventStream([ { type: "message.updated", properties: { - info: { - id: "assistant-final", - sessionID: "runtime-session-1", - role: "assistant", - }, + sessionID: "runtime-session-1", + info: { id: "assistant-final", role: "assistant" }, }, }, { @@ -999,33 +1182,40 @@ describe("streamPromptResponse", () => { prompt: async () => undefined, messages: async () => [ { - info: { - id: "assistant-final", - sessionID: "runtime-session-1", - role: "assistant", - structured: { answer: "已从结构化结果恢复最终回答。" }, - }, - parts: [], + info: { id: "assistant-final", role: "assistant" }, + parts: [ + { + id: "final-answer-part", + sessionID: "runtime-session-1", + messageID: "assistant-final", + type: "tool", + callID: "final-answer-call", + tool: "final_answer", + state: { + status: "completed", + input: { answer: "已从持久化工具结果恢复最终答案。" }, + output: "最终回答已提交。", + title: "final_answer", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }, + ], }, ], } as unknown as OpencodeRuntimeAdapter; - const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", - message: "恢复最终回答", - model: "deepseek/deepseek-v4-flash", + message: "分析管网", write: (event, data) => events.push({ event, data }), }); - expect( - events - .filter((item) => item.event === "token") - .map((item) => item.data.content) - .join(""), - ).toBe("已从结构化结果恢复最终回答。"); + expect(events.find((item) => item.event === "final_answer")?.data.content).toBe( + "已从持久化工具结果恢复最终答案。", + ); }); }); diff --git a/tests/routes/chatUiState.test.ts b/tests/routes/chatUiState.test.ts index 81b444c..5261b13 100644 --- a/tests/routes/chatUiState.test.ts +++ b/tests/routes/chatUiState.test.ts @@ -3,15 +3,40 @@ import { describe, expect, it } from "bun:test"; import { appendBackendToolArtifact, cancelBackendTodos, + completeBackendActivities, + completeBackendTodos, upsertBackendQuestion, } 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>; + + expect(activities[0]).toMatchObject({ + status: "error", + actions: [expect.objectContaining({ status: "error" })], + }); + }); +}); + describe("appendBackendToolArtifact", () => { it("persists show_chart tool calls as chart artifacts", () => { const artifacts = appendBackendToolArtifact([], { session_id: "session-1", tool: "show_chart", - reason: "测试折线图渲染", params: { title: "压力曲线", chart_type: "line", @@ -25,7 +50,6 @@ describe("appendBackendToolArtifact", () => { tool: "show_chart", kind: "chart", title: "压力曲线", - description: "测试折线图渲染", params: { chart_type: "line", 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" }), + ], + }), + ]); + }); +}); diff --git a/tests/runtime/opencode.test.ts b/tests/runtime/opencode.test.ts index 26a6553..7fec7cd 100644 --- a/tests/runtime/opencode.test.ts +++ b/tests/runtime/opencode.test.ts @@ -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", () => { it("replies to permissions in the conversation workspace directory", async () => { const calls: unknown[] = []; diff --git a/tests/runtime/opencodeConfig.test.ts b/tests/runtime/opencodeConfig.test.ts index a076f55..bdf8fa1 100644 --- a/tests/runtime/opencodeConfig.test.ts +++ b/tests/runtime/opencodeConfig.test.ts @@ -1,5 +1,5 @@ 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 tjwaterCli from "../../.opencode/tools/tjwater_cli.js"; import storeRenderRef, { @@ -37,6 +37,8 @@ describe("internal OpenCode permissions", () => { expect(permission.external_directory).toBe("deny"); expect(permission.task).toBe("deny"); expect(permission.question).toBe("allow"); + expect(permission.activity_update).toBe("allow"); + expect(permission.final_answer).toBe("allow"); expect(permission.todowrite).toBe("allow"); expect(read?.["*"]).toBe("allow"); expect(read?.["data/**"]).toBe("deny"); @@ -56,6 +58,22 @@ describe("internal OpenCode permissions", () => { expect(bash?.["*data/*"]).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", () => { @@ -154,6 +172,20 @@ describe("sandbox bash tool", () => { }); 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 () => { const originalFetch = globalThis.fetch; let requestBody: unknown; @@ -168,7 +200,6 @@ describe("tjwater_cli storage request", () => { await definition.execute( { command: "network get-all-reservoirs-properties", - reason: "prepare workflow input", store_result: true, }, { sessionID: "session-test" } as never,