feat(agent): 完善分析编排与结果传输
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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...
|
||||
|
||||
+5
-3
@@ -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
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"开始一个新的业务活动阶段。仅在语义阶段发生变化时调用一次,用 title 概括阶段,用 reason 说明本阶段为何必要;同一阶段内的多个工具动作不要重复调用。该工具只更新用户可见的过程信息,不执行外部操作。",
|
||||
args: {
|
||||
title: tool.schema
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("面向用户的简短业务阶段标题,不包含工具名、函数名、文件名或命令。"),
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("本阶段对完成用户目标的必要性,使用一句简洁的自然语言。"),
|
||||
todos: tool.schema
|
||||
.array(
|
||||
tool.schema.object({
|
||||
id: tool.schema.string().optional(),
|
||||
content: tool.schema.string().min(1),
|
||||
status: tool.schema.enum([
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]),
|
||||
priority: tool.schema.enum(["low", "medium", "high"]).optional(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe(
|
||||
"已有任务计划时提交完整状态快照,使本阶段与任务状态在同一次更新中生效。",
|
||||
),
|
||||
},
|
||||
async execute() {
|
||||
return "活动阶段已更新。";
|
||||
},
|
||||
});
|
||||
@@ -4,11 +4,6 @@ export default tool({
|
||||
description:
|
||||
"在前端地图上对节点或管道图层应用样式,或重置为默认样式。样式参数应尽量与前端样式编辑器字段保持一致。",
|
||||
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'."),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"提交直接展示给用户的最终回答。只能在全部业务动作和其他工具调用完成后调用一次;调用后不得继续调用任何工具或输出额外文本。",
|
||||
args: {
|
||||
answer: tool.schema
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("直接展示给用户的完整最终回答,使用简体中文和 Markdown。"),
|
||||
},
|
||||
async execute() {
|
||||
return "最终回答已提交。";
|
||||
},
|
||||
});
|
||||
@@ -8,9 +8,6 @@ export default tool({
|
||||
description:
|
||||
"调用 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 北京市人民政府."),
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
"*.env*": "deny"
|
||||
},
|
||||
"question": "allow",
|
||||
"activity_update": "allow",
|
||||
"final_answer": "allow",
|
||||
"task": "deny",
|
||||
"todo": "allow",
|
||||
"todoread": "allow",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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<string, unknown>) => 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<string>,
|
||||
assistantTextParts: Map<string, Map<string, string>>,
|
||||
assistantTextPartPhases: Map<string, AssistantMessagePhase>,
|
||||
write: (event: string, data: Record<string, unknown>) => 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 === "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<string, number>();
|
||||
const finalizedProgressIds = new Set<string>();
|
||||
const emittedToolParts = new Set<string>();
|
||||
const emittedActivityParts = new Set<string>();
|
||||
const emittedQuestionToolParts = new Set<string>();
|
||||
const emittedQuestionRequestIds = new Set<string>();
|
||||
const currentAssistantMessageIds = new Set<string>();
|
||||
const assistantTextParts = new Map<string, Map<string, string>>();
|
||||
const assistantTextPartPhases = new Map<string, AssistantMessagePhase>();
|
||||
const emittedFinalTextLengths = new Map<string, number>();
|
||||
const partTypes = new Map<string, Part["type"]>();
|
||||
const pendingTextDeltas = new Map<string, string[]>();
|
||||
const reasoningStatuses = new Map<string, "running" | "completed">();
|
||||
@@ -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<typeof iterator.next> | 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: "本次请求的分析、工具执行和结果整理流程已经完成。",
|
||||
activityTracker.finalize("completed");
|
||||
if (finalAnswerText) {
|
||||
// Keep one compatibility cycle for deployed frontends that only consume token.
|
||||
write("token", {
|
||||
session_id: clientSessionId,
|
||||
content: finalAnswerText,
|
||||
});
|
||||
emitProgress({
|
||||
id: "request-completed",
|
||||
phase: "complete",
|
||||
status: "completed",
|
||||
title: "分析完成",
|
||||
detail: emittedText
|
||||
? "最终回答已生成并推送到前端。"
|
||||
: "已完成分析,并通过兜底消息补发最终回答内容。",
|
||||
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),
|
||||
});
|
||||
|
||||
+47
-119
@@ -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<string, string> = {
|
||||
tjwater_cli: "查询后端数据",
|
||||
bash: "运行本地分析",
|
||||
store_render_ref: "保存渲染结果",
|
||||
memory_manager: "记忆写入",
|
||||
geocode: "地理编码",
|
||||
session_search: "历史会话检索",
|
||||
@@ -76,6 +121,7 @@ const toolLabels: Record<string, string> = {
|
||||
view_scada: "SCADA 面板",
|
||||
show_chart: "图表渲染",
|
||||
render_junctions: "节点渲染",
|
||||
apply_layer_style: "图层样式调整",
|
||||
};
|
||||
|
||||
export const logDevelopmentDebug = (
|
||||
@@ -143,20 +189,6 @@ export const normalizeToolParams = (value: unknown): Record<string, unknown> =>
|
||||
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) =>
|
||||
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<OpencodeEvent, { 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 = (
|
||||
event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>,
|
||||
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<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}`;
|
||||
};
|
||||
export const getToolLabel = (tool: string) => toolLabels[tool] ?? tool;
|
||||
|
||||
+119
-9
@@ -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<string, unknown>,
|
||||
@@ -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<string, unknown>) => Record<string, unknown>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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<PermissionRequestPayload>);
|
||||
});
|
||||
@@ -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<string, unknown> }> = [];
|
||||
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<string, unknown> }> = [];
|
||||
|
||||
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
|
||||
.filter((item) => item.event === "token")
|
||||
.map((item) => item.data.content)
|
||||
.join(""),
|
||||
).toBe("供水服务分区分析已完成。");
|
||||
expect(events.find((item) => item.event === "token")?.data.content).toBe(
|
||||
"供水服务分区分析已完成。",
|
||||
);
|
||||
expect(events.filter((item) => item.event === "final_answer")).toEqual([
|
||||
{
|
||||
event: "final_answer",
|
||||
data: {
|
||||
session_id: "client-session-1",
|
||||
content: "供水服务分区分析已完成。",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(events.some((item) => item.event === "tool_call")).toBe(false);
|
||||
expect(events.some((item) => item.event === "done")).toBe(true);
|
||||
});
|
||||
|
||||
it("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 = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: {
|
||||
id: "assistant-final",
|
||||
sessionID: "runtime-session-1",
|
||||
role: "assistant",
|
||||
},
|
||||
info: { id: "assistant-final", role: "assistant" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -999,33 +1182,40 @@ describe("streamPromptResponse", () => {
|
||||
prompt: async () => undefined,
|
||||
messages: async () => [
|
||||
{
|
||||
info: {
|
||||
id: "assistant-final",
|
||||
info: { id: "assistant-final", role: "assistant" },
|
||||
parts: [
|
||||
{
|
||||
id: "final-answer-part",
|
||||
sessionID: "runtime-session-1",
|
||||
role: "assistant",
|
||||
structured: { answer: "已从结构化结果恢复最终回答。" },
|
||||
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 },
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
|
||||
await streamPromptResponse({
|
||||
runtime,
|
||||
sessionId: "runtime-session-1",
|
||||
clientSessionId: "client-session-1",
|
||||
message: "恢复最终回答",
|
||||
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(
|
||||
"已从持久化工具结果恢复最终答案。",
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
|
||||
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" }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user