diff --git a/docs/dws-multi-im-error-optimization-plan.md b/docs/dws-multi-im-error-optimization-plan.md new file mode 100644 index 00000000..709b3d99 --- /dev/null +++ b/docs/dws-multi-im-error-optimization-plan.md @@ -0,0 +1,492 @@ +# DWS Multi IM 错误专项优化方案 + +> 日期:2026-08-03 +> 分支:`codex/multi-im-optimization` +> 当前提交:`30314311` +> 评测基线:`2a8c6c87`,155 条 accepted 轨迹、74 次失败调用 + +## 1. 结论 + +74 次失败已经能够完整归因,不应继续使用“API 业务错误”或“其他命令失败”这类粗分类: + +| 根因 | 次数 | 判断 | +|---|---:|---| +| 群名解析歧义 | 37 | Runtime 正确 fail-closed;评测夹具和 Agent 消歧流程问题 | +| 稳定 ID 进入群名参数 | 17 | Shortcut 目标参数合同不一致 | +| 群名进入稳定 ID 参数并打到后端 | 7 | Shortcut 目标参数合同不一致,且缺少调用前校验 | +| 不存在的命令或 flag | 11 | 选路/Help 漂移及缺少无歧义兼容别名 | +| `auth whoami` 不存在 | 1 | MCP 错误误导后的错误恢复分支 | +| MCP 内部依赖不可用 | 1 | 后端 `queryToolMeta` 内部连接拒绝;CLI 错分为业务错误 | +| **合计** | **74** | | + +其中真正带 `NETWORK_ERROR / UNAVAILABLE / Connection refused` 证据的只有 1 次。它不是用户侧公网故障,也不是 `+conversation-list` 参数拼装错误,而是 MCP 后端内部依赖不可用。 + +## 2. 本地复测 + +### 2.1 真实 MCP + +使用当前提交重新构建 CLI,执行: + +```bash +dws chat +conversation-list --page-all --format json --timeout 60 +``` + +6 轮已验证结果全部为: + +```text +exit=0 +complete=true +count=100 +failedCount=0 +``` + +此外,评测提交 `2a8c6c87` 重新构建后同一命令也成功;该提交到当前提交之间,`+conversation-list` 的 MCP 工具名、参数和分页代码没有变化。 + +### 2.2 原始失败确定性回放 + +本地 loopback MCP 返回评测中的等价内容: + +```json +{ + "success": false, + "server_error_code": "NETWORK_ERROR", + "technical_detail": "McpService.queryToolMeta ... StatusCode.UNAVAILABLE ... Connection refused", + "retryable": true +} +``` + +当前 CLI 稳定输出: + +```text +category=api +reason=business_error +message=business error: success=false +hint=Check required parameters and values +server_error_code=NETWORK_ERROR +retryable=true +``` + +这证明需要修的是中央错误分类:诊断字段已经保留,但没有参与 `reason/message/hint` 决策。 + +### 2.3 其他代表错误复测 + +当前 Runtime 可稳定复现: + +1. `+chat-members-list --group ` 在调用后端前失败,并提示“请使用 --group 或 --chat”,但当前命令的稳定 ID 参数实际是 `--conversation-id`;提示与命令合同矛盾。 +2. `+chat-messages --group <群名>` 把群名直接交给只接受 CID 的下层,后端返回 `openCid or cid is required`;应在 CLI 内先解析,不能打到后端。 +3. `+search-msg --text ...` 返回 `unknown_flag`;当前正式参数是 `--query`。 + +## 3. P0:修中央错误分类 + +### 3.1 分类顺序 + +修改 `internal/app/runner.go`,在通用 `business_error` 之前执行结构化分类: + +```text +ExtractServerDiagnostics +→ ClassifyServerFailure +→ 构造 typed Error +→ 无已知类别才回退 business_error +``` + +建议新增独立分类器,例如 `internal/app/server_failure_classifier.go`。输入为 MCP 内容和 `ServerDiagnostics`,输出: + +```go +type ServerFailureClass struct { + Reason string + Origin string + Stage string + ExecutionStarted *bool + Hint string + Actions []string +} +``` + +首批规则: + +| 条件 | reason | origin | stage | +|---|---|---|---| +| `NETWORK_ERROR` 且 detail 含 `queryToolMeta`、`UNAVAILABLE` | `backend_dependency_unavailable` | `mcp_gateway` | `tool_metadata_lookup` | +| `PARAM_ERROR`、缺 required 参数 | `invalid_request` | `dingtalk_api` 或 `mcp_gateway` | `tool_validation` | +| 权限/Scope | `permission_denied` | 实际返回方 | `authorization` | +| 资源不存在 | `resource_not_found` | `dingtalk_api` | `tool_execution` | +| 无结构化证据 | `business_error` | `unknown` | `unknown` | + +兼容期可以用 `technical_detail` 补充 stage,但 `server_error_code` 必须是主分类依据。原始 `trace_id`、`technical_detail` 和 `retryable` 原样保留。 + +### 3.2 重试边界 + +当前响应没有稳定的 `execution_started` 字段,因此第一阶段只修分类和恢复提示,不对所有工具自动重试。 + +- 后端应优先在 `queryToolMeta` 内部做短重试,因为它明确发生在业务执行前; +- CLI 只有在后端提供 `execution_started=false` 后,才可对读写命令统一安全重试; +- 过渡期最多只对最终 Schema 标记为只读的命令重试一次; +- 写命令执行状态未知时禁止自动重试。 + +## 4. P0:统一群目标参数合同 + +24 次失败来自同一个设计问题:不同 Shortcut 对 `--group` 的含义不同。 + +统一公开合同: + +| 参数 | 统一语义 | +|---|---| +| `--group` | 接受群名或 `openConversationId`;CLI 自动识别 | +| `--conversation-id` | 显式稳定 ID | +| `--chat-query` | 显式自然群名 | +| `--chat` / `--open-conversation-id` | 只作为无歧义兼容别名,不作为文档默认入口 | + +新增共享入口,例如: + +```go +ResolveChatTarget(rt, directValue, queryValue) +``` + +规则: + +1. directValue 形似 CID:直接返回,不进行群名搜索; +2. directValue 不是 CID:按自然群名走完整分页唯一解析; +3. queryValue 形似 CID:规范化为稳定 ID,而不是送入群名搜索; +4. 零命中或多命中继续 fail-closed; +5. 任何自然群名都不得直接发送到只接受 CID 的后端工具。 + +首批应用: + +- `internal/shortcut/smart/group_members.go` +- `internal/shortcut/smart/chat_messages.go` +- `internal/shortcut/smart/search_msg.go` +- 复核 `send_to_group.go`、`at_me.go` 和 `chat/chat_group.go`,消除同类局部实现。 + +同时修改 `ResolveChat` 的类型错误:不要输出与调用命令不一致的具体 flag,改为 typed `target_type_mismatch`,并提示读取当前 leaf Help。 + +## 5. P0:处理 11 次命令/flag 漂移 + +只增加语义唯一、不会制造第二入口的兼容项: + +| 观察到的错误 | 决策 | +|---|---| +| `+search-msg --text`(3 次) | 增加 `--text` → `--query` 隐藏兼容别名 | +| `+search-msg --text-query`(1 次) | 增加 `--text-query` → `--query` 隐藏兼容别名 | +| `+chat-messages --page-size`(1 次) | 增加 `--page-size` → `--limit` 隐藏兼容别名 | +| `+chat-messages --open-conversation-id`(1 次) | 增加稳定 ID 兼容别名 | +| `+chat-members-list --chat`(2 次) | 增加稳定 ID 兼容别名 | +| `+chat-members-list --open-conversation-id`(2 次) | 增加稳定 ID 兼容别名 | +| `+chat-rename`(1 次) | 作为 `+chat-update` 的隐藏兼容别名,`--group` 复用群名/CID 唯一解析;Skill 默认仍只推荐 reviewed `chat group rename --id --name` | + +所有新增 flag 必须进入真实 Cobra、Shortcut constraints、Schema 参数投影和示例校验;不能只改 Skill。 + +`auth whoami` 不建议补一个语义不清的新命令。MCP 内部依赖错误修正后,恢复动作应直接提示“稍后重试并保留 trace ID”,不得诱导 Agent 检查认证。认证确需检查时只推荐现有 `dws auth status` 和 `dws profile list`。 + +## 6. P1:同名群与评测口径 + +37 次 `resolution_ambiguous` 是安全机制正常工作,不能通过默认第一个候选来消除。 + +需要同时处理两层: + +1. 真实产品:继续返回结构化 candidates,Agent 展示候选并停止写入;可在下层真实提供时增加群主、群类型等可理解的区分字段。 +2. 固定评测:所有要求命中 `dws测试群01/02` 的 case 都必须在 accepted run 前保证目标名称唯一,而不是只处理 H15/H18/H20;夹具状态和恢复记录继续留在 Prompt 外。 + +评测报告应把错误维度分开: + +```text +agent_error +request_contract_error +expected_resolution_stop +business_error +infrastructure_error +recovered_infrastructure_error +``` + +若环境真实存在同名群,`resolution_ambiguous` 应计入安全停机事实,不应伪装成业务成功;若 case 设计要求唯一群,则应判夹具失败并重跑,而不是污染 Agent clean。 + +## 7. 测试与验收门禁 + +### 7.1 错误分类 + +- `success=false + NETWORK_ERROR + queryToolMeta/UNAVAILABLE` → `backend_dependency_unavailable`; +- `success=false + PARAM_ERROR` → `invalid_request`; +- 无诊断字段的 `success=false` 保持 `business_error`; +- JSON 输出中的 `reason/hint/retryable/server_error_code/trace_id` 与 typed error 一致; +- 不再对内部依赖错误提示“检查参数或认证”。 + +### 7.2 目标合同 + +对 `+chat-members-list`、`+chat-messages`、`+search-msg` 分别覆盖: + +- `--group ` 不搜索,且下层收到同一 CID; +- `--group ` 唯一解析后下层只收到 CID; +- `--conversation-id ` 不搜索; +- `--chat-query ` 唯一解析; +- `--chat-query ` 不再触发“ID 被当群名”; +- 多候选时下层业务调用为 0; +- 群名绝不进入 `openCid/cid/openConversationId` 参数。 + +### 7.3 兼容参数 + +- 每个兼容 alias 与 canonical flag 产生完全相同的 invocation; +- alias 与 canonical 同时出现时按 constraints 拒绝,禁止静默覆盖; +- Help 只突出 canonical flag,Schema 保留 Agent 可执行事实; +- Skill 和 reference 不把兼容 alias 写成新的默认路线。 + +### 7.4 最终门禁 + +```bash +gofmt -w +DWS_PACKAGE_VERSION=0.0.0-test go test ./internal/errors ./internal/transport ./internal/app ./internal/shortcut/targetresolver ./internal/shortcut/chat ./internal/shortcut/smart +go generate ./internal/cli +./scripts/policy/check-generated-drift.sh +./scripts/policy/check-schema-catalog.sh +./scripts/policy/check-skill-commands.sh +DWS_PACKAGE_VERSION=0.0.0-test go test ./... +``` + +真实环境至少再跑: + +```bash +dws chat +conversation-list --page-all --format json +dws chat +chat-members-list --group <真实群名或CID> --format json +dws chat +chat-messages --group <真实群名或CID> --limit 5 --format json +dws chat +search-msg --chat-query <真实群名> --query <关键词> --format json +``` + +验收不是简单要求 74 变 0,而是: + +- 1 次 MCP 内部故障被正确分类并进入独立可靠性指标; +- 24 次目标类型错误在 CLI 内确定性消除; +- 11 次无歧义 flag 漂移不再发生; +- 1 次错误认证分支不再由误导 hint 触发; +- 37 次同名群由正确夹具或显式用户消歧处理,Runtime 继续禁止猜第一个候选。 + +## 8. 优化实施拆分 + +优化按错误归属拆成四个可以独立验证的交付单元,避免 Runtime、Skill 和评测夹具混在同一个改动里。 + +### 8.1 交付 A:错误语义与恢复动作 + +范围: + +- `internal/app/runner.go` +- `internal/transport/diagnostics.go` +- `internal/errors/` +- 新增中央 server failure classifier 及测试 + +交付结果: + +1. `NETWORK_ERROR + queryToolMeta/UNAVAILABLE` 不再落入 `business_error`; +2. JSON 输出具备稳定的 `origin/stage/reason`; +3. 参数、权限、资源和基础设施错误给出不同恢复动作; +4. 未确认 `execution_started=false` 前不对写请求自动重试; +5. 错误提示不再诱导 Agent 执行无关认证命令。 + +该交付只改变错误解释和恢复合同,不改变 `+conversation-list` 的请求拼装。 + +### 8.2 交付 B:统一群目标 Runtime + +范围: + +- `internal/shortcut/targetresolver/` +- `internal/shortcut/smart/group_members.go` +- `internal/shortcut/smart/chat_messages.go` +- `internal/shortcut/smart/search_msg.go` +- 其他调用 `ResolveChat` 的 Chat Shortcut + +交付结果: + +1. 群名和 CID 在进入下层前完成类型规范化; +2. 稳定 CID 永远不进入 `search_groups`; +3. 自然群名永远不直接进入 `openCid/cid/openConversationId`; +4. 相同的参数名在高频 Shortcut 中表达相同语义; +5. 零命中、多候选和类型不匹配都使用 typed resolution error。 + +这是消除 24 次目标类型错误的主要 Runtime 改动。 + +### 8.3 交付 C:兼容参数、Schema 与 Skill 收敛 + +范围: + +- 对应 Shortcut 的真实 Cobra flags/constraints +- `internal/cli/schema_hints/metadata/chat.json` +- `internal/cli/schema_hints/selection/chat.json` +- `skills/multi/dingtalk-chat/SKILL.md` +- 精确 Chat references +- 生成的 Schema/Agent metadata/Catalog + +交付结果: + +1. 增加经过评审的无歧义兼容 flag,但 Help/Skill 只推荐 canonical flag; +2. 修改群名只推荐现有 reviewed `chat group rename --id --name`;已观测的 `+chat-rename` 仅作为隐藏兼容别名,不成为第二默认入口; +3. `unknown command/unknown flag` 后只查精确 leaf Help并最多重试一次; +4. 示例必须通过真实 Cobra 和 Schema constraints; +5. Skill 不保存固定测试群名、CID 或评测 case 文案。 + +### 8.4 交付 D:评测夹具与 judge 分层 + +范围:评测仓库或 runner,不进入 DWS Runtime 业务逻辑。 + +交付结果: + +1. 所有依赖指定测试群名的 case 在 accepted run 前保证目标唯一; +2. 夹具创建、隔离和恢复有稳定 ID 审计记录,但不注入 Prompt; +3. `expected_resolution_stop`、`request_contract_error` 和 `infrastructure_error` 分开统计; +4. infrastructure retry 即使恢复成功,也保留独立可靠性计数; +5. clean 不再把正确的安全停机和错误请求拼装混成一种失败。 + +## 9. 评测拟合方案 + +### 9.1 拟合对象 + +只拟合以下稳定模式: + +- 错误结构:`server_error_code`、MCP stage、retryability; +- 意图结构:群名、稳定 CID、搜索关键词、分页参数; +- 命令合同:canonical flag、无歧义兼容 alias、constraints; +- 安全语义:多候选禁止默认第一项、写操作禁止不安全重试; +- 结果合同:complete/partial/failures 和 typed error。 + +禁止拟合: + +- `H4/H8/...` 等 case 编号; +- `dws测试群01/02` 字面值; +- 评测中出现的固定 CID、用户 ID 或 trace ID; +- 针对某条 Prompt 的字符串分支; +- 为提高 clean 而把非零退出统一改为成功。 + +### 9.2 数据集拆分 + +采用三层数据集: + +| 数据集 | 用途 | 内容 | +|---|---|---| +| observed | 修复已观察问题 | 当前 155 条 accepted、74 次失败调用 | +| variant | 验证泛化 | 替换群名、CID、关键词、flag 顺序、是否显式指定 ID | +| safety holdout | 防止过拟合 | 同名群、零命中、跨 profile ID、写请求执行状态未知、分页中途失败 | + +Runtime 单元测试和本地 MCP E2E 使用合成稳定 ID,例如 `cid-test-a`,不得依赖真实评测资源。真实环境 smoke 只验证集成连通性和输出合同,不把真实业务数据写入测试快照。 + +### 9.3 变体矩阵 + +每个群目标型 Shortcut 至少覆盖: + +| 维度 | 变体 | +|---|---| +| 目标输入 | 唯一群名、CID、模糊群名、同名群、零命中 | +| 参数入口 | canonical flag、兼容 alias、canonical+alias 冲突 | +| 命令类型 | 只读、普通写、高风险写 | +| 后端错误 | 参数错误、权限错误、资源不存在、限流、内部依赖不可用 | +| 执行阶段 | 调用前、元数据查询、业务执行、分页后续页 | +| 结果状态 | complete、partial、fail-closed、retry recovered | + +兼容 alias 只需证明与 canonical invocation 等价,不为每个 alias 复制完整业务测试矩阵。 + +### 9.4 回归顺序 + +```text +typed unit tests +→ fake MCP CLI E2E +→ Schema/Skill policy +→ observed 31×5 回放 +→ variant 集 +→ safety holdout +→ 小规模真实环境 smoke +``` + +只有前四层通过后才看 clean 指标。若 clean 提升但 safety holdout 出现自动选第一个群、重复写或 partial 冒充 complete,则判优化失败。 + +### 9.5 指标 + +主指标: + +- 请求合同错误数; +- 首次 canonical 命令/flag 命中率; +- 自然目标到稳定 ID 的一次成功率; +- false success、错误写入和重复写入; +- 错误分类准确率; +- complete/partial 真实性。 + +分层可靠性指标: + +- MCP/网关基础设施失败率; +- 安全自动重试恢复率; +- 未恢复 infrastructure error 数; +- trace ID 可关联率。 + +次级指标: + +- clean run 比例; +- 每 case 业务 CLI 次数; +- unknown command/flag 后的纠错次数; +- Token 和执行时长。 + +clean 不能替代主指标,也不能通过过滤真实失败直接提升。 + +## 10. 预期收敛与发布判定 + +基于当前 74 次失败,实施后的预期归宿是: + +| 当前错误 | 目标状态 | +|---|---| +| MCP 内部依赖失败 1 次 | 正确分类;满足安全条件时恢复,否则进入 infrastructure 指标 | +| 错误认证分支 1 次 | 由精确恢复动作消除 | +| 目标类型错误 24 次 | Runtime 规范化后消除 | +| 命令/flag 漂移 11 次 | canonical 路由或兼容 alias 消除 | +| 同名群歧义 37 次 | 唯一夹具下正常执行;真实歧义下继续安全停机 | + +发布必须同时满足: + +1. observed 集中的确定性合同错误归零; +2. variant 集不依赖固定群名和 CID; +3. safety holdout 的误选、误写和重复写为 0; +4. MCP 基础设施错误分类准确,不再显示参数/认证误导; +5. 全仓、Schema、Skill 和真实只读 smoke 全部通过; +6. 评测报告同时给出 clean、业务正确性、安全停机和基础设施可靠性,不再只给一个混合分数。 + +## 11. 当前分支实施与验证记录 + +> 实施日期:2026-08-03 +> 实施分支:`codex/multi-im-optimization` + +### 11.1 已实施 + +1. 中央错误分类已在两条 HTTP MCP 失败路径生效: + - `NETWORK_ERROR` / `queryToolMeta` / `UNAVAILABLE` 分类为 `backend_dependency_unavailable`; + - 输出 `origin` / `stage` / `reason`,保留 trace、server code 和 retryable; + - 后端未显式返回时不猜测 `execution_started`; + - multi-profile 聚合保留错误恢复语义、resolution candidates 和诊断字段。 +2. 群目标合同已收敛到 `ResolveChatTarget`: + - CID 不进入 `search_groups`; + - 群名唯一解析后才传入业务工具; + - 零命中、多候选、不完整分页继续 fail-closed; + - 已应用到成员、会话消息、消息搜索、@ 我、群机器人/邀请、按群发送和群重命名。 +3. 11 次命令/flag 漂移的 Runtime 兼容已补齐: + - `--text` / `--text-query`、`--page-size`、`--open-conversation-id`、成员命令的 `--chat`; + - `+chat-rename` 作为 `+chat-update` 的隐藏兼容别名,群名依然在写前唯一解析; + - canonical 与 alias 同时出现时拒绝静默覆盖。 +4. Chat Skill、群/消息 reference、reviewed selection hints 与生成 Schema Catalog 已同步。 + +### 11.2 自动验证 + +- 本地可控 MCP 重放通过真实 `executeInvocation → HTTP MCP → success=false → typed Error` 路径; +- 聚焦错误、resolver、Chat Shortcut 和 app 回归通过; +- `go generate ./internal/cli` 通过; +- `check-generated-drift.sh` 通过; +- `check-schema-catalog.sh` 通过:26 products / 849 tools; +- `check-skill-commands.sh` 通过:1009 executable command paths; +- Skill `quick_validate.py` 通过; +- Agent 示例合同通过:1047 total / 1023 contract / 24 dry-run; +- `DWS_PACKAGE_VERSION=0.0.0-test go test ./...` 全仓通过; +- `go build -o /dws ./cmd` 真实构建通过。仓库文档中的裸 `go build ./cmd` 会因根目录已有 `cmd/` 目录而报输出同名冲突,因此验证时显式指定了临时 `-o`。 + +### 11.3 真实环境 smoke + +使用当前登录 profile 和本次构建产物执行,不保存真实群名或 CID 到仓库: + +- `+conversation-list --page-all`:完整返回; +- `+chat-members-list --group <真实群名>`:唯一解析成功,`complete=true`; +- `+chat-members-list --open-conversation-id <真实CID>`:兼容 alias 成功; +- `+chat-messages --group <真实群名>` 与 `--open-conversation-id/--page-size`:均成功,单页返回正确标记 `complete=false / hasMore=true / stopReason=single_page`; +- `+search-msg --chat-query <真实CID> --text-query <无命中词>`:不搜群名,搜索完整返回; +- `+chat-rename --group <真实CID> ... --dry-run`:返回 `executed=false`且 invocation 使用正确 CID,未修改真实群。 + +### 11.4 仓库外后续项 + +评测夹具唯一性与 judge 分层属于评测 runner,不在本 DWS CLI 仓库中。Runtime 修复后应重跑 31×5 observed、variant 和 safety holdout;真实同名群仍必须保持 `resolution_ambiguous` 安全停机,不以默认第一个候选换 clean。 diff --git a/internal/app/runner.go b/internal/app/runner.go index aa0eed40..885807bf 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -461,6 +461,33 @@ func multiProfileErrorPayload(err error) map[string]any { if typed.Operation != "" { payload["operation"] = typed.Operation } + if typed.Origin != "" { + payload["origin"] = typed.Origin + } + if typed.FailureStage != "" { + payload["stage"] = typed.FailureStage + } + if typed.ExecutionStarted != nil { + payload["execution_started"] = *typed.ExecutionStarted + } + if typed.RetryableSet { + payload["retryable"] = typed.Retryable + } + if typed.Hint != "" { + payload["hint"] = typed.Hint + } + if len(typed.Actions) > 0 { + payload["actions"] = append([]string(nil), typed.Actions...) + } + if len(typed.Details) > 0 { + payload["details"] = typed.Details + } + if typed.ServerDiag.TraceID != "" { + payload["trace_id"] = typed.ServerDiag.TraceID + } + if typed.ServerDiag.ServerErrorCode != "" { + payload["server_error_code"] = typed.ServerDiag.ServerErrorCode + } if code := typed.ExitCode(); code != 0 { payload["exitCode"] = code } @@ -720,7 +747,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, if callResult.IsError { diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content) - logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag) // ClassifyToolResult hook: let the overlay intercept known error // patterns (PAT permission, gateway-auth) before generic handling. @@ -737,14 +763,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, } } - mcpErr := apperrors.NewAPI( + mcpErr := newServerFailureAPIError( extractMCPErrorMessage(callResult), - apperrors.WithOperation("tools/call"), - apperrors.WithReason("mcp_tool_error"), - apperrors.WithServerKey(invocation.CanonicalProduct), - apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."), - apperrors.WithServerDiag(diag), + "mcp_tool_error", + "MCP tool returned a business error; check tool parameters and refer to skill documentation.", + invocation.CanonicalProduct, + diag, ) + logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag) // PAT scope error in business response: offer human-readable output and retry if isPatScopeError(mcpErr) { scopeErr := extractPatScopeError(mcpErr) @@ -762,14 +788,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, if bizErr := detectBusinessError(callResult.Content); bizErr != "" { diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content) - logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag) - return executor.Result{}, apperrors.NewAPI(bizErr, - apperrors.WithOperation("tools/call"), - apperrors.WithReason("business_error"), - apperrors.WithServerKey(invocation.CanonicalProduct), - apperrors.WithHint("The API returned a business-level error. Check required parameters and values."), - apperrors.WithServerDiag(diag), + classifiedErr := newServerFailureAPIError( + bizErr, + "business_error", + "The API returned a business-level error. Check required parameters and values.", + invocation.CanonicalProduct, + diag, ) + logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag) + return executor.Result{}, classifiedErr } invocation.Implemented = true diff --git a/internal/app/server_failure_classifier.go b/internal/app/server_failure_classifier.go new file mode 100644 index 00000000..6ab9d8ea --- /dev/null +++ b/internal/app/server_failure_classifier.go @@ -0,0 +1,105 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +type serverFailureClass struct { + message string + reason string + origin string + stage string + hint string + actions []string +} + +func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) { + code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode)) + detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail)) + text := strings.ToLower(strings.TrimSpace(message)) + + if code == "NETWORK_ERROR" || + strings.Contains(detail, "statuscode.unavailable") || + strings.Contains(detail, "connection refused") { + classified := serverFailureClass{ + message: "MCP 后端依赖暂时不可用", + reason: "backend_dependency_unavailable", + origin: "mcp_gateway", + stage: "backend_dependency", + hint: "请求参数无需修改;请使用相同参数稍后重试。持续失败时请提供 Trace ID 排查 MCP 服务。", + actions: []string{ + "使用相同参数重试一次", + "持续失败时保留 Trace ID 并排查 MCP 后端依赖", + }, + } + if strings.Contains(detail, "querytoolmeta") { + classified.message = "MCP 后端元数据服务暂时不可用" + classified.stage = "tool_metadata_lookup" + } + return classified, true + } + + if code == "PARAM_ERROR" || + strings.Contains(text, "opencid or cid is required") || + strings.Contains(text, "openconversationid") && strings.Contains(text, "required") { + return serverFailureClass{ + message: message, + reason: "invalid_request", + origin: "dingtalk_api", + stage: "tool_validation", + hint: "请求未通过后端参数校验;请核对当前 leaf Help/Schema 和稳定 ID 类型后重试。", + }, true + } + + return serverFailureClass{}, false +} + +func newServerFailureAPIError( + message string, + fallbackReason string, + fallbackHint string, + serverKey string, + diag apperrors.ServerDiagnostics, +) error { + opts := []apperrors.Option{ + apperrors.WithOperation("tools/call"), + apperrors.WithReason(fallbackReason), + apperrors.WithServerKey(serverKey), + apperrors.WithHint(fallbackHint), + apperrors.WithServerDiag(diag), + } + if classified, ok := classifyServerFailure(message, diag); ok { + message = classified.message + opts = append(opts, + apperrors.WithReason(classified.reason), + apperrors.WithOrigin(classified.origin), + apperrors.WithFailureStage(classified.stage), + apperrors.WithHint(classified.hint), + apperrors.WithActions(classified.actions...), + ) + } + return apperrors.NewAPI(message, opts...) +} + +func serverFailureReason(err error, fallback string) string { + typed, ok := err.(*apperrors.Error) + if ok && strings.TrimSpace(typed.Reason) != "" { + return typed.Reason + } + return fallback +} diff --git a/internal/app/server_failure_classifier_test.go b/internal/app/server_failure_classifier_test.go new file mode 100644 index 00000000..dbfd2632 --- /dev/null +++ b/internal/app/server_failure_classifier_test.go @@ -0,0 +1,223 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" +) + +func TestServerFailureClassifierBackendMetadataUnavailable(t *testing.T) { + retryable := true + err := newServerFailureAPIError( + "business error: success=false", + "business_error", + "check parameters", + "im", + apperrors.ServerDiagnostics{ + TraceID: "trace-local", + ServerErrorCode: "NETWORK_ERROR", + TechnicalDetail: "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)", + ServerRetryable: &retryable, + }, + ) + + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("error = %T, want *errors.Error", err) + } + if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" { + t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage) + } + if typed.ExecutionStarted != nil { + t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted) + } + if !typed.RetryableSet || !typed.Retryable { + t.Fatalf("retryability = (%v, %v), want explicit true", typed.RetryableSet, typed.Retryable) + } + if strings.Contains(strings.ToLower(typed.Hint), "parameter") || strings.Contains(typed.Hint, "认证") { + t.Fatalf("misleading hint = %q", typed.Hint) + } +} + +func TestServerFailureClassifierRequiredConversationID(t *testing.T) { + err := newServerFailureAPIError( + "openCid or cid is required", + "business_error", + "check parameters", + "chat", + apperrors.ServerDiagnostics{ServerErrorCode: "1001"}, + ) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("error = %T, want *errors.Error", err) + } + if typed.Reason != "invalid_request" || typed.FailureStage != "tool_validation" { + t.Fatalf("classification = reason %q stage %q", typed.Reason, typed.FailureStage) + } + if typed.ExecutionStarted != nil { + t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted) + } +} + +func TestServerFailureClassifierUnknownFallsBack(t *testing.T) { + err := newServerFailureAPIError( + "business error: success=false", + "business_error", + "check parameters", + "im", + apperrors.ServerDiagnostics{}, + ) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("error = %T, want *errors.Error", err) + } + if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil { + t.Fatalf("unexpected fallback classification: %#v", typed) + } +} + +func TestServerFailureReasonUsesTypedClassification(t *testing.T) { + err := newServerFailureAPIError( + "business error: success=false", + "business_error", + "check parameters", + "im", + apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"}, + ) + if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" { + t.Fatalf("reason = %q", got) + } + if got := serverFailureReason(errors.New("plain"), "fallback"); got != "fallback" { + t.Fatalf("fallback reason = %q", got) + } +} + +func TestMultiProfileErrorPayloadPreservesFailureSemantics(t *testing.T) { + retryable := true + err := newServerFailureAPIError( + "business error: success=false", + "business_error", + "check parameters", + "im", + apperrors.ServerDiagnostics{ + TraceID: "trace-multi", + ServerErrorCode: "NETWORK_ERROR", + TechnicalDetail: "McpService.queryToolMeta: StatusCode.UNAVAILABLE", + ServerRetryable: &retryable, + }, + ) + payload := multiProfileErrorPayload(err) + for key, want := range map[string]any{ + "reason": "backend_dependency_unavailable", + "origin": "mcp_gateway", + "stage": "tool_metadata_lookup", + "retryable": true, + "trace_id": "trace-multi", + "server_error_code": "NETWORK_ERROR", + } { + if got := payload[key]; got != want { + t.Errorf("payload[%q] = %#v, want %#v", key, got, want) + } + } + if _, ok := payload["execution_started"]; ok { + t.Fatalf("payload must not invent execution_started: %#v", payload) + } +} + +func TestMultiProfileErrorPayloadPreservesResolutionDetails(t *testing.T) { + err := apperrors.NewValidation( + "群目标不唯一", + apperrors.WithReason("resolution_ambiguous"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithHint("请选择候选"), + apperrors.WithActions("使用稳定 ID 重试"), + apperrors.WithDetails(map[string]any{ + "type": "resolution", + "candidates": []string{"cid-1", "cid-2"}, + }), + ) + payload := multiProfileErrorPayload(err) + details, ok := payload["details"].(map[string]any) + if !ok || details["type"] != "resolution" { + t.Fatalf("details = %#v", payload["details"]) + } + if payload["execution_started"] != false || payload["origin"] != "client" || payload["stage"] != "target_resolution" { + t.Fatalf("payload = %#v", payload) + } + if actions, ok := payload["actions"].([]string); !ok || len(actions) != 1 { + t.Fatalf("actions = %#v", payload["actions"]) + } +} + +func TestExecuteInvocationClassifiesObservedMCPMetadataFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID int `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": request.ID, + "result": map[string]any{ + "structuredContent": map[string]any{ + "success": false, + "code": "NETWORK_ERROR", + "trace_id": "trace-replay", + "technical_detail": "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)", + "retryable": true, + }, + }, + }) + })) + defer server.Close() + + client := transport.NewClient(server.Client()) + client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")} + runner := &runtimeRunner{ + transport: client, + globalFlags: &GlobalFlags{Token: "local-test-token"}, + } + _, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{ + CanonicalProduct: "im", + Tool: "list_conversations", + Params: map[string]any{"pageSize": 100}, + }) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err) + } + if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" { + t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage) + } + if typed.ServerDiag.TraceID != "trace-replay" || !typed.RetryableSet || !typed.Retryable { + t.Fatalf("diagnostics = %#v retryable=(%v,%v)", typed.ServerDiag, typed.RetryableSet, typed.Retryable) + } + if typed.ExecutionStarted != nil { + t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted) + } +} diff --git a/internal/cli/schema_agent_metadata/chat.json b/internal/cli/schema_agent_metadata/chat.json index df1932a8..c5a10010 100644 --- a/internal/cli/schema_agent_metadata/chat.json +++ b/internal/cli/schema_agent_metadata/chat.json @@ -4104,7 +4104,7 @@ }, "use_when": { "value": [ - "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" + "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", @@ -4113,7 +4113,7 @@ "candidates": [ { "value": [ - "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" + "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", @@ -4136,7 +4136,7 @@ "internal/cli/schema_hints/selection/chat.json" ], "use_when": [ - "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" + "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" ] }, "chat +chat-messages": { @@ -4365,7 +4365,7 @@ }, "use_when": { "value": [ - "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" + "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", @@ -4374,7 +4374,7 @@ "candidates": [ { "value": [ - "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" + "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", @@ -4398,7 +4398,7 @@ "skills/mono/references/intent-guide.md" ], "use_when": [ - "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" + "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" ] }, "chat +chat-mute": { @@ -12186,7 +12186,7 @@ ] }, "chat +send-to-group": { - "agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)", + "agent_summary": "按群名或 openConversationId 直接给群发消息", "agent_summary_source": "dws-agent-selection/chat", "availability": "available", "avoid_when": [ @@ -12200,14 +12200,14 @@ ], "field_provenance": { "agent_summary": { - "value": "按群名直接给群发消息(自动搜群解析 openConversationId)", + "value": "按群名或 openConversationId 直接给群发消息", "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "resolution": "highest_precedence", "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", "candidates": [ { - "value": "按群名直接给群发消息(自动搜群解析 openConversationId)", + "value": "按群名或 openConversationId 直接给群发消息", "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", "selected": true, @@ -12408,7 +12408,7 @@ }, "use_when": { "value": [ - "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。" + "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", @@ -12417,7 +12417,7 @@ "candidates": [ { "value": [ - "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。" + "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。" ], "source": "internal/cli/schema_hints/selection/chat.json", "precedence": "reviewed_explicit", @@ -12440,7 +12440,7 @@ "internal/cli/schema_hints/selection/chat.json" ], "use_when": [ - "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。" + "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。" ] }, "chat +thread-replies": { diff --git a/internal/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index 0cc7fe5a..db92ef0a 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:faa53064e9fff1f0869cd3f7cd522f9588512d2f416328a1b16524fd99b3c3e5", + "source_hash": "sha256:ad595e9ca84d09f061058027502c059c9df7108dc694860e346a60f6eb545071", "surface_hash": "sha256:4891325eea3c74dcdbc3590139e69f38e39cfabd9491c3de75a09aed01d39cae", "coverage": { "surface_products": 26, diff --git a/internal/cli/schema_agent_metadata_audit.json b/internal/cli/schema_agent_metadata_audit.json index 4de98eb8..1ea958b1 100644 --- a/internal/cli/schema_agent_metadata_audit.json +++ b/internal/cli/schema_agent_metadata_audit.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:faa53064e9fff1f0869cd3f7cd522f9588512d2f416328a1b16524fd99b3c3e5", + "source_hash": "sha256:ad595e9ca84d09f061058027502c059c9df7108dc694860e346a60f6eb545071", "surface_hash": "sha256:4891325eea3c74dcdbc3590139e69f38e39cfabd9491c3de75a09aed01d39cae", "source_files": 160, "hint_files": 54, diff --git a/internal/cli/schema_catalog/catalog.json b/internal/cli/schema_catalog/catalog.json index 294e7321..d01d42dc 100644 --- a/internal/cli/schema_catalog/catalog.json +++ b/internal/cli/schema_catalog/catalog.json @@ -1,12 +1,12 @@ { "version": 1, "surface_hash": "sha256:4891325eea3c74dcdbc3590139e69f38e39cfabd9491c3de75a09aed01d39cae", - "source_hash": "sha256:9f5b873d3996df76f7f584c7217adbba98beb9848db3c6557a9ab94c8fad802c", + "source_hash": "sha256:f89d4466e828bc53c69b0118d372f331d8ea84ce9398c976ebf53448fc0f9fea", "catalog": { "agent_metadata": { "products_with_metadata": 26, "source": "embedded-skill-metadata", - "source_hash": "sha256:faa53064e9fff1f0869cd3f7cd522f9588512d2f416328a1b16524fd99b3c3e5", + "source_hash": "sha256:ad595e9ca84d09f061058027502c059c9df7108dc694860e346a60f6eb545071", "surface_hash": "sha256:4891325eea3c74dcdbc3590139e69f38e39cfabd9491c3de75a09aed01d39cae", "surface_products": 26, "surface_tools": 849, @@ -10204,7 +10204,7 @@ "cli_name": "+chat-members-list", "cli_path": "chat +chat-members-list", "confirmation": "not_required", - "description": "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。\n\n参数约束:\n - --group、--conversation-id 必须且只能指定一个\n - --member-types 仅接受 user/bot\n - --page-limit 必须在 1-500 之间", + "description": "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。\n\n参数约束:\n - --group、--conversation-id、--chat-query、--chat、--open-conversation-id 必须且只能指定一个\n - --member-types 仅接受 user/bot\n - --page-limit 必须在 1-500 之间", "effect": "read", "idempotency": "idempotent", "interface_mode": "composite", @@ -10215,7 +10215,7 @@ "risk": "low", "title": "列出群成员并把用户与机器人分桶(支持群名语义解析)", "use_when": [ - "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" + "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" ] }, { @@ -10230,7 +10230,7 @@ "cli_name": "+chat-messages", "cli_path": "chat +chat-messages", "confirmation": "not_required", - "description": "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。\n\n参数约束:\n - --group、--conversation-id、--id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个\n - --page-limit 仅与 --page-all 一起使用且范围 1-500\n - --max-results 仅与 --page-all 一起使用且不能为负数\n - --output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", + "description": "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。\n\n参数约束:\n - --group、--conversation-id、--id、--open-conversation-id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个\n - --limit、--size、--page-size 互斥,最多指定一个\n - --page-limit 仅与 --page-all 一起使用且范围 1-500\n - --max-results 仅与 --page-all 一起使用且不能为负数\n - --output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", "effect": "read", "idempotency": "idempotent", "interface_mode": "composite", @@ -10241,7 +10241,7 @@ "risk": "low", "title": "按会话 ID、群名或姓名读取消息,支持有界全量分页与原子 JSON 导出", "use_when": [ - "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" + "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" ] }, { @@ -11015,7 +11015,7 @@ "cli_name": "+search-msg", "cli_path": "chat +search-msg", "confirmation": "not_required", - "description": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话可传稳定 ID 或 --chat-query,发送者可传稳定 ID 或 --sender-query,多个自然目标会先做全量解析,任一零命中/多命中都会在搜索前返回结构化失败。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", + "description": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话可传稳定 ID 或 --chat-query,发送者可传稳定 ID 或 --sender-query,多个自然目标会先做全量解析,任一零命中/多命中都会在搜索前返回结构化失败。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --query、--keyword、--text、--text-query 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", "effect": "read", "idempotency": "idempotent", "interface_mode": "composite", @@ -11031,7 +11031,7 @@ }, { "agent_metadata_source": "embedded-skill-metadata", - "agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)", + "agent_summary": "按群名或 openConversationId 直接给群发消息", "agent_summary_source": "dws-agent-selection/chat", "availability": "available", "avoid_when": [ @@ -11041,7 +11041,7 @@ "cli_name": "+send-to-group", "cli_path": "chat +send-to-group", "confirmation": "user_required", - "description": "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。", + "description": "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。", "effect": "write", "idempotency": "unknown", "interface_mode": "composite", @@ -11050,9 +11050,9 @@ "primary_cli_path": "chat +send-to-group", "reviewed": true, "risk": "medium", - "title": "按群名直接给群发消息(自动搜群解析 openConversationId)", + "title": "按群名或 openConversationId 直接给群发消息", "use_when": [ - "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。" + "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。" ] }, { diff --git a/internal/cli/schema_catalog/tools/chat.json b/internal/cli/schema_catalog/tools/chat.json index 0f41f921..09e18c82 100644 --- a/internal/cli/schema_catalog/tools/chat.json +++ b/internal/cli/schema_catalog/tools/chat.json @@ -61314,17 +61314,19 @@ "mutually_exclusive": [ [ "group", - "conversation-id" + "conversation-id", + "chat-query" ] ], "require_one_of": [ [ "group", - "conversation-id" + "conversation-id", + "chat-query" ] ] }, - "description": "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。\n\n参数约束:\n - --group、--conversation-id 必须且只能指定一个\n - --member-types 仅接受 user/bot\n - --page-limit 必须在 1-500 之间", + "description": "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。\n\n参数约束:\n - --group、--conversation-id、--chat-query、--chat、--open-conversation-id 必须且只能指定一个\n - --member-types 仅接受 user/bot\n - --page-limit 必须在 1-500 之间", "display": "群聊 / 消息 / 机器人", "effect": "read", "effect_source": "agent-hint", @@ -61423,13 +61425,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。\n\n参数约束:\n - --group、--conversation-id 必须且只能指定一个\n - --member-types 仅接受 user/bot\n - --page-limit 必须在 1-500 之间" + "value": "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。\n\n参数约束:\n - --group、--conversation-id、--chat-query、--chat、--open-conversation-id 必须且只能指定一个\n - --member-types 仅接受 user/bot\n - --page-limit 必须在 1-500 之间" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。\n\n参数约束:\n - --group、--conversation-id 必须且只能指定一个\n - --member-types 仅接受 user/bot\n - --page-limit 必须在 1-500 之间" + "value": "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。\n\n参数约束:\n - --group、--conversation-id、--chat-query、--chat、--open-conversation-id 必须且只能指定一个\n - --member-types 仅接受 user/bot\n - --page-limit 必须在 1-500 之间" }, "effect": { "candidates": [ @@ -61594,7 +61596,7 @@ "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" + "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" ] } ], @@ -61603,7 +61605,7 @@ "review_reason": "Agent-authored from full user pagination, stable-ID de-duplication, separate all-bot projection, deterministic group resolution, typed bucket completeness, and partial failure facts.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" + "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" ] } }, @@ -61613,10 +61615,10 @@ "interface_reason": "Reviewed member-bucket adapter: it accepts a stable conversation ID or resolves one complete group-name candidate set, fully paginates and de-duplicates user members, reads the lower all-bot list, and publishes im.group-members.v1 bucket completeness and partial-failure facts.", "is_alias": false, "name": "shortcut_chat_members_list", - "parameter_count": 4, + "parameter_count": 5, "parameters": { - "conversation-id": { - "description": "群 openConversationId(与 --group 二选一)", + "chat-query": { + "description": "按群名解析唯一 openConversationId", "field_provenance": { "description": { "candidates": [ @@ -61624,7 +61626,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "群 openConversationId(与 --group 二选一)" + "value": "按群名解析唯一 openConversationId" }, { "precedence": "default", @@ -61636,7 +61638,97 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "群 openConversationId(与 --group 二选一)" + "value": "按群名解析唯一 openConversationId" + }, + "property": { + "candidates": [ + { + "precedence": "inference", + "selected": true, + "source": "flag_name_inference", + "value": "chatQuery" + } + ], + "precedence": "inference", + "resolution": "highest_precedence", + "source": "flag_name_inference", + "value": "chatQuery" + }, + "required": { + "candidates": [ + { + "precedence": "command_constraint", + "selected": true, + "source": "require_one_of_constraint", + "value": false + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": false + } + ], + "precedence": "command_constraint", + "resolution": "highest_precedence", + "source": "require_one_of_constraint", + "value": false + }, + "required_when": { + "candidates": [ + { + "precedence": "default", + "selected": true, + "source": "default", + "value": "" + } + ], + "precedence": "default", + "resolution": "highest_precedence", + "source": "default", + "value": "" + }, + "type": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_flag_type", + "value": "string" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_flag_type", + "value": "string" + } + }, + "property": "chatQuery", + "required": false, + "type": "string" + }, + "conversation-id": { + "description": "显式群 openConversationId", + "field_provenance": { + "description": { + "candidates": [ + { + "precedence": "cobra_contract", + "selected": true, + "source": "cobra_usage", + "value": "显式群 openConversationId" + }, + { + "precedence": "default", + "selected": false, + "source": "default", + "value": "" + } + ], + "precedence": "cobra_contract", + "resolution": "highest_precedence", + "source": "cobra_usage", + "value": "显式群 openConversationId" }, "property": { "candidates": [ @@ -61706,7 +61798,7 @@ "type": "string" }, "group": { - "description": "群名称(与 --conversation-id 二选一)", + "description": "群名称或 openConversationId", "field_provenance": { "description": { "candidates": [ @@ -61714,7 +61806,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "群名称(与 --conversation-id 二选一)" + "value": "群名称或 openConversationId" }, { "precedence": "default", @@ -61726,7 +61818,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "群名称(与 --conversation-id 二选一)" + "value": "群名称或 openConversationId" }, "property": { "candidates": [ @@ -61979,7 +62071,7 @@ "source": "reviewed_command_registry", "title": "列出群成员并把用户与机器人分桶(支持群名语义解析)", "use_when": [ - "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" + "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" ] }, "chat.shortcut_chat_messages": { @@ -62022,7 +62114,7 @@ ] ] }, - "description": "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。\n\n参数约束:\n - --group、--conversation-id、--id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个\n - --page-limit 仅与 --page-all 一起使用且范围 1-500\n - --max-results 仅与 --page-all 一起使用且不能为负数\n - --output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", + "description": "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。\n\n参数约束:\n - --group、--conversation-id、--id、--open-conversation-id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个\n - --limit、--size、--page-size 互斥,最多指定一个\n - --page-limit 仅与 --page-all 一起使用且范围 1-500\n - --max-results 仅与 --page-all 一起使用且不能为负数\n - --output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", "display": "群聊 / 消息 / 机器人", "effect": "read", "effect_source": "agent-hint", @@ -62121,13 +62213,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。\n\n参数约束:\n - --group、--conversation-id、--id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个\n - --page-limit 仅与 --page-all 一起使用且范围 1-500\n - --max-results 仅与 --page-all 一起使用且不能为负数\n - --output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" + "value": "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。\n\n参数约束:\n - --group、--conversation-id、--id、--open-conversation-id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个\n - --limit、--size、--page-size 互斥,最多指定一个\n - --page-limit 仅与 --page-all 一起使用且范围 1-500\n - --max-results 仅与 --page-all 一起使用且不能为负数\n - --output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。\n\n参数约束:\n - --group、--conversation-id、--id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个\n - --page-limit 仅与 --page-all 一起使用且范围 1-500\n - --max-results 仅与 --page-all 一起使用且不能为负数\n - --output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" + "value": "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。\n\n参数约束:\n - --group、--conversation-id、--id、--open-conversation-id、--chat-query、--user、--user-query、--open-dingtalk-id 必须且只能指定一个\n - --limit、--size、--page-size 互斥,最多指定一个\n - --page-limit 仅与 --page-all 一起使用且范围 1-500\n - --max-results 仅与 --page-all 一起使用且不能为负数\n - --output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" }, "effect": { "candidates": [ @@ -62292,7 +62384,7 @@ "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" + "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" ] } ], @@ -62301,7 +62393,7 @@ "review_reason": "Agent-authored from stable-ID/natural-target routing, bounded all-page collection, message-ID de-duplication, fail-closed continuation, im.message-list.v1 completeness facts, atomic JSON export, and the optional safe resource workflow.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" + "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" ] } }, @@ -62602,7 +62694,7 @@ "type": "boolean" }, "group": { - "description": "群会话 ID(openConversationId),与 --user 互斥", + "description": "群名称或 openConversationId,与单聊目标互斥", "field_provenance": { "description": { "candidates": [ @@ -62610,7 +62702,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "群会话 ID(openConversationId),与 --user 互斥" + "value": "群名称或 openConversationId,与单聊目标互斥" }, { "precedence": "default", @@ -62622,7 +62714,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "群会话 ID(openConversationId),与 --user 互斥" + "value": "群名称或 openConversationId,与单聊目标互斥" }, "property": { "candidates": [ @@ -63752,7 +63844,7 @@ "source": "reviewed_command_registry", "title": "按会话 ID、群名或姓名读取消息,支持有界全量分页与原子 JSON 导出", "use_when": [ - "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" + "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" ] }, "chat.shortcut_chat_mute": { @@ -83668,7 +83760,7 @@ ] ] }, - "description": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话可传稳定 ID 或 --chat-query,发送者可传稳定 ID 或 --sender-query,多个自然目标会先做全量解析,任一零命中/多命中都会在搜索前返回结构化失败。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", + "description": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话可传稳定 ID 或 --chat-query,发送者可传稳定 ID 或 --sender-query,多个自然目标会先做全量解析,任一零命中/多命中都会在搜索前返回结构化失败。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --query、--keyword、--text、--text-query 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸", "display": "群聊 / 消息 / 机器人", "effect": "read", "effect_source": "agent-hint", @@ -83767,13 +83859,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话可传稳定 ID 或 --chat-query,发送者可传稳定 ID 或 --sender-query,多个自然目标会先做全量解析,任一零命中/多命中都会在搜索前返回结构化失败。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" + "value": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话可传稳定 ID 或 --chat-query,发送者可传稳定 ID 或 --sender-query,多个自然目标会先做全量解析,任一零命中/多命中都会在搜索前返回结构化失败。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --query、--keyword、--text、--text-query 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话可传稳定 ID 或 --chat-query,发送者可传稳定 ID 或 --sender-query,多个自然目标会先做全量解析,任一零命中/多命中都会在搜索前返回结构化失败。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" + "value": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话可传稳定 ID 或 --chat-query,发送者可传稳定 ID 或 --sender-query,多个自然目标会先做全量解析,任一零命中/多命中都会在搜索前返回结构化失败。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --query、--keyword、--text、--text-query 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸" }, "effect": { "candidates": [ @@ -86595,7 +86687,7 @@ "internal/cli/schema_hints/metadata/chat.json", "internal/cli/schema_hints/selection/chat.json" ], - "agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)", + "agent_summary": "按群名或 openConversationId 直接给群发消息", "agent_summary_source": "dws-agent-selection/chat", "availability": "available", "avoid_when": [ @@ -86605,7 +86697,7 @@ "cli_name": "+send-to-group", "cli_path": "chat +send-to-group", "confirmation": "user_required", - "description": "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。", + "description": "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。", "display": "群聊 / 消息 / 机器人", "effect": "write", "effect_source": "agent-hint", @@ -86620,14 +86712,14 @@ "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", - "value": "按群名直接给群发消息(自动搜群解析 openConversationId)" + "value": "按群名或 openConversationId 直接给群发消息" } ], "precedence": "reviewed_explicit", "resolution": "highest_precedence", "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", "source": "internal/cli/schema_hints/selection/chat.json", - "value": "按群名直接给群发消息(自动搜群解析 openConversationId)" + "value": "按群名或 openConversationId 直接给群发消息" }, "availability": { "candidates": [ @@ -86703,13 +86795,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。" + "value": "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。" + "value": "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。" }, "effect": { "candidates": [ @@ -86856,13 +86948,13 @@ "precedence": "cobra_help", "selected": true, "source": "cobra_help", - "value": "按群名直接给群发消息(自动搜群解析 openConversationId)" + "value": "按群名或 openConversationId 直接给群发消息" } ], "precedence": "cobra_help", "resolution": "highest_precedence", "source": "cobra_help", - "value": "按群名直接给群发消息(自动搜群解析 openConversationId)" + "value": "按群名或 openConversationId 直接给群发消息" }, "use_when": { "candidates": [ @@ -86872,7 +86964,7 @@ "selected": true, "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。" + "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。" ] } ], @@ -86881,7 +86973,7 @@ "review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.", "source": "internal/cli/schema_hints/selection/chat.json", "value": [ - "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。" + "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。" ] } }, @@ -86985,7 +87077,7 @@ "type": "boolean" }, "group": { - "description": "群名称(搜群关键词,用群名里连续的核心词)(必填)", + "description": "群名称或 openConversationId(必填)", "field_provenance": { "description": { "candidates": [ @@ -86993,7 +87085,7 @@ "precedence": "cobra_contract", "selected": true, "source": "cobra_usage", - "value": "群名称(搜群关键词,用群名里连续的核心词)(必填)" + "value": "群名称或 openConversationId(必填)" }, { "precedence": "default", @@ -87005,7 +87097,7 @@ "precedence": "cobra_contract", "resolution": "highest_precedence", "source": "cobra_usage", - "value": "群名称(搜群关键词,用群名里连续的核心词)(必填)" + "value": "群名称或 openConversationId(必填)" }, "property": { "candidates": [ @@ -87183,9 +87275,9 @@ "reviewed": true, "risk": "medium", "source": "reviewed_command_registry", - "title": "按群名直接给群发消息(自动搜群解析 openConversationId)", + "title": "按群名或 openConversationId 直接给群发消息", "use_when": [ - "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。" + "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。" ] }, "chat.shortcut_thread_replies": { diff --git a/internal/cli/schema_hints/selection/chat.json b/internal/cli/schema_hints/selection/chat.json index 17b42426..898bd24a 100644 --- a/internal/cli/schema_hints/selection/chat.json +++ b/internal/cli/schema_hints/selection/chat.json @@ -2141,7 +2141,7 @@ "chat.shortcut_chat_members_list": { "agent_summary": "列出群成员并把用户与机器人分桶(支持群名语义解析)", "use_when": [ - "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" + "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。" ], "avoid_when": [ "只需要按群名列出真人用户时可用较小入口 +group-members;不要把 complete=false 或 partial=true 的结果称为完整成员列表" @@ -2161,7 +2161,7 @@ "chat.shortcut_chat_messages": { "agent_summary": "按会话 ID、群名或姓名读取消息,支持有界全量分页与原子 JSON 导出", "use_when": [ - "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" + "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。" ], "avoid_when": [ "要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget" @@ -2508,9 +2508,9 @@ ] }, "chat.shortcut_send_to_group": { - "agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)", + "agent_summary": "按群名或 openConversationId 直接给群发消息", "use_when": [ - "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。" + "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。" ], "avoid_when": [ "需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令" diff --git a/internal/errors/errors.go b/internal/errors/errors.go index b657e6d0..cb24dd25 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -46,6 +46,9 @@ type Error struct { Message string Operation string ServerKey string + Origin string + FailureStage string + ExecutionStarted *bool Retryable bool RetryableSet bool RetryAfterSeconds *int64 @@ -108,6 +111,32 @@ func WithServerKey(serverKey string) Option { } } +// WithOrigin records the component that produced the failure, such as the +// client, MCP gateway, or DingTalk API. It is independent from Category, +// which remains the stable exit-code contract. +func WithOrigin(origin string) Option { + return func(err *Error) { + err.Origin = strings.TrimSpace(origin) + } +} + +// WithFailureStage records the execution stage at which the failure occurred. +func WithFailureStage(stage string) Option { + return func(err *Error) { + err.FailureStage = strings.TrimSpace(stage) + } +} + +// WithExecutionStarted records whether the downstream business operation was +// known to have started. Unknown state must be represented by omitting this +// option, which is important for safe retry decisions on write operations. +func WithExecutionStarted(started bool) Option { + return func(err *Error) { + value := started + err.ExecutionStarted = &value + } +} + // WithRetryable marks whether the error can be retried safely. func WithRetryable(retryable bool) Option { return func(err *Error) { @@ -311,6 +340,15 @@ func PrintJSON(w io.Writer, err error) error { if typed.ServerKey != "" { errorPayload["server_key"] = typed.ServerKey } + if typed.Origin != "" { + errorPayload["origin"] = typed.Origin + } + if typed.FailureStage != "" { + errorPayload["stage"] = typed.FailureStage + } + if typed.ExecutionStarted != nil { + errorPayload["execution_started"] = *typed.ExecutionStarted + } if typed.RetryableSet { errorPayload["retryable"] = typed.Retryable } @@ -466,6 +504,15 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { if typed.ServerKey != "" { lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey))) } + if typed.Origin != "" { + lines = append(lines, tui.Dim(fmt.Sprintf("Origin: %s", typed.Origin))) + } + if typed.FailureStage != "" { + lines = append(lines, tui.Dim(fmt.Sprintf("Stage: %s", typed.FailureStage))) + } + if typed.ExecutionStarted != nil { + lines = append(lines, tui.Dim(fmt.Sprintf("Execution Started: %t", *typed.ExecutionStarted))) + } if typed.Snapshot != "" { lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot))) } diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 2fd70f66..112d7957 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -49,6 +49,9 @@ func TestPrintJSON(t *testing.T) { if err := PrintJSON(&b, NewValidation( "bad flag", WithReason("missing_required_flag"), + WithOrigin("client"), + WithFailureStage("request_validation"), + WithExecutionStarted(false), WithHint("Pass the required flag and retry."), WithRetryable(true), WithActions("dws schema doc.create_document", "retry command"), @@ -71,6 +74,11 @@ func TestPrintJSON(t *testing.T) { if !strings.Contains(got, "\"reason\": \"missing_required_flag\"") { t.Fatalf("expected reason in output, got %q", got) } + if !strings.Contains(got, "\"origin\": \"client\"") || + !strings.Contains(got, "\"stage\": \"request_validation\"") || + !strings.Contains(got, "\"execution_started\": false") { + t.Fatalf("expected failure provenance in output, got %q", got) + } if !strings.Contains(got, "\"retryable\": true") { t.Fatalf("expected retryable in output, got %q", got) } diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index dde2941f..3ea4e274 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -596,15 +596,11 @@ var ChatBots = shortcut.Shortcut{ // contract. Stable cid values bypass search; natural names always go through // the shared exact-match, full-pagination and ambiguity rules. func resolveStableOrNamedChat(rt *shortcut.RuntimeContext) (string, error) { - query := strings.TrimSpace(rt.StrFirst("chat-query", "group-query")) - if query == "" { - group := strings.TrimSpace(rt.Str("group")) - if targetresolver.LooksLikeOpenConversationID(group) { - return group, nil - } - query = group - } - resolved, err := targetresolver.ResolveChat(rt, query) + resolved, err := targetresolver.ResolveChatTarget( + rt, + strings.TrimSpace(rt.Str("group")), + strings.TrimSpace(rt.StrFirst("chat-query", "group-query")), + ) if err != nil { return "", err } diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index 66f4b14a..56e86137 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -154,18 +154,23 @@ func normalizeCreatedConversation(data map[string]any) { var ChatUpdate = shortcut.Shortcut{ Service: "chat", Command: "+chat-update", + Aliases: []string{"+chat-rename"}, Product: "chat", Description: "更新群名称(仅名称,不支持 description)", - Intent: "当你只需要修改群名称时使用;这是 lark-cli +chat-update 的诚实子集,只接受群 openConversationId 和新名称。修改群 description、个人备注、群昵称或其他群设置时不要使用。", + Intent: "当你只需要修改群名称时使用;--group 可传群名或 openConversationId,群名必须唯一解析后才会写入。修改群 description、个人备注、群昵称或其他群设置时不要使用。", Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId", Required: true}, {Name: "name", Type: shortcut.FlagString, Desc: "新的群名称", Required: true}, }, - Tips: []string{`dws chat +chat-update --group --name "新群名"`}, + Tips: []string{`dws chat +chat-update --group <群名或openConversationId> --name "新群名"`}, Execute: func(rt *shortcut.RuntimeContext) error { + resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), "") + if err != nil { + return err + } return rt.CallMCP("update_group_name", map[string]any{ - "openconversation_id": rt.Str("group"), + "openconversation_id": resolved.Selected.OpenConversationID, "group_name": rt.Str("name"), }) }, diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index 96d2ab8d..ef047c40 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -124,6 +124,20 @@ func TestEvaluationRegressionNaturalGroupTargetsAndRecallInference(t *testing.T) } }) + t.Run("stable id in group query bypasses search", func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-invite-url", "--chat-query", "cidACeQ0fCtKfLsFGvA47gXaQ=="}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "get_group_invite_url" || + fake.calls[0].args["openConversationId"] != "cidACeQ0fCtKfLsFGvA47gXaQ==" { + t.Fatalf("calls = %#v", fake.calls) + } + }) + t.Run("message id fills conversation before recall", func(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) @@ -356,11 +370,11 @@ func TestLarkAlignmentWriteMappings(t *testing.T) { }{ { name: "chat-update-name-only", - args: []string{"chat", "+chat-update", "--group", "cid", "--name", "新群名", "--yes"}, + args: []string{"chat", "+chat-update", "--group", "cidACeQ0fCtKfLsFGvA47gXaQ==", "--name", "新群名", "--yes"}, product: "chat", tool: "update_group_name", wantArgs: map[string]any{ - "openconversation_id": "cid", + "openconversation_id": "cidACeQ0fCtKfLsFGvA47gXaQ==", "group_name": "新群名", }, }, @@ -415,6 +429,24 @@ func TestLarkAlignmentWriteMappings(t *testing.T) { } } +func TestObservedChatRenameAliasResolvesNameBeforeWrite(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/search_groups": `{"result":[{"openConversationId":"cid-project","title":"dws测试群02"}],"hasMore":false}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-rename", "--group", "dws测试群02", "--name", "测试群02-技术讨论", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || fake.calls[0].tool != "search_groups" || fake.calls[1].tool != "update_group_name" { + t.Fatalf("calls = %#v", fake.calls) + } + if fake.calls[1].args["openconversation_id"] != "cid-project" || fake.calls[1].args["group_name"] != "测试群02-技术讨论" { + t.Fatalf("write args = %#v", fake.calls[1].args) + } +} + func TestMessagesReplyPublishesPlainTextBoundary(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ "chat/send_personal_message": `{"result":{"openMessageId":"new-msg","openConvThreadId":"thread-1","sendStatus":"accepted"}}`, diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go index 37aa1f7b..afda06a5 100644 --- a/internal/shortcut/smart/at_me.go +++ b/internal/shortcut/smart/at_me.go @@ -75,16 +75,10 @@ var AtMe = shortcut.Shortcut{ Validate: chatshortcut.ValidateMessageResourceDownload, Execute: func(rt *shortcut.RuntimeContext) error { groupID := "" - groupQuery := strings.TrimSpace(rt.StrFirst("chat-query", "group-query")) - if groupQuery == "" { - groupQuery = strings.TrimSpace(rt.Str("group")) - if targetresolver.LooksLikeOpenConversationID(groupQuery) { - groupID = groupQuery - groupQuery = "" - } - } - if groupQuery != "" { - resolved, err := targetresolver.ResolveChat(rt, groupQuery) + directTarget := strings.TrimSpace(rt.Str("group")) + queryTarget := strings.TrimSpace(rt.StrFirst("chat-query", "group-query")) + if directTarget != "" || queryTarget != "" { + resolved, err := targetresolver.ResolveChatTarget(rt, directTarget, queryTarget) if err != nil { return err } diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 0c910c09..55fdb44d 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -49,15 +49,16 @@ var ChatMessages = shortcut.Shortcut{ Product: "chat", Description: "按会话 ID、群名或姓名读取消息,支持有界全量分页与原子 JSON 导出", Intent: "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" + - "群聊可传 --group 或 --chat-query,单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。" + + "群聊的 --group 可传群名或 openConversationId,也可用 --chat-query 显式按群名解析、用 --conversation-id 显式传稳定 ID;单聊可传 --user、--open-dingtalk-id 或 --user-query,所有目标参数互斥且必须选一个。自然目标只在唯一解析后读取,多候选会返回结构化 candidates。" + "省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" + "全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" + "默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。", Risk: shortcut.RiskRead, Flags: append([]shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId),与 --user 互斥"}, + {Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId,与单聊目标互斥"}, {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, {Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, {Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名解析唯一 openConversationId"}, {Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"}, {Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId"}, @@ -65,6 +66,7 @@ var ChatMessages = shortcut.Shortcut{ {Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";省略时从当前时间向前读取最近消息"}, {Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数(可选)"}, {Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true}, + {Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true}, {Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"}, {Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"}, {Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-results 仅与 --page-all 一起使用且不能为负数"}, @@ -73,7 +75,8 @@ var ChatMessages = shortcut.Shortcut{ {Name: "output", Type: shortcut.FlagString, Desc: "把完整结构化 ledger 原子写入工作目录内的相对 JSON 文件"}, }, chatshortcut.MessageResourceDownloadFlags()...), Constraints: append([]shortcut.Constraint{ - {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "chat-query", "user", "user-query", "open-dingtalk-id"}}, + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "open-conversation-id", "chat-query", "user", "user-query", "open-dingtalk-id"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "size", "page-size"}}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"}, { @@ -124,7 +127,7 @@ type chatMessagesRequest struct { } func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) { - groupID := rt.StrFirst("group", "conversation-id", "id") + groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "open-conversation-id")) userID := rt.Str("user") openID := rt.Str("open-dingtalk-id") if targetresolver.LooksLikeOpenConversationID(openID) { @@ -132,8 +135,8 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques "--open-dingtalk-id 收到的是群 openConversationId;群聊请改用 --group(兼容别名 --chat)", ) } - if query := rt.Str("chat-query"); query != "" { - resolved, err := targetresolver.ResolveChat(rt, query) + if groupID == "" && (rt.Str("group") != "" || rt.Str("chat-query") != "") { + resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query")) if err != nil { return chatMessagesRequest{}, err } @@ -158,7 +161,7 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques if rt.Changed("time") && rt.Str("time") != "" { params["time"] = rt.Str("time") } - if limit := rt.IntFirst("limit", "size"); limit > 0 { + if limit := rt.IntFirst("limit", "size", "page-size"); limit > 0 { params["limit"] = limit } else if rt.Bool("page-all") { params["limit"] = chatMessagesAllPageSize diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index c9ad1bd1..98f61a68 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -144,7 +144,7 @@ func TestChatMessagesPageAllUsesTypedBoundaryAndDeduplicates(t *testing.T) { var output bytes.Buffer root.SetOut(&output) root.SetArgs([]string{ - "chat", "+chat-messages", "--group", "cid", + "chat", "+chat-messages", "--conversation-id", "cid", "--time", "2026-01-03 00:00:00", "--page-all", "--page-limit", "5", }) if err := root.Execute(); err != nil { @@ -173,7 +173,7 @@ func TestChatMessagesPageAllPublishesBoundedContinuation(t *testing.T) { var output bytes.Buffer root.SetOut(&output) root.SetArgs([]string{ - "chat", "+chat-messages", "--group", "cid", + "chat", "+chat-messages", "--conversation-id", "cid", "--page-all", "--page-limit", "1", }) if err := root.Execute(); err != nil { @@ -199,7 +199,7 @@ func TestChatMessagesPageAllFailsClosedOnStalledBoundary(t *testing.T) { root := newPlatformCoverageRoot() var output bytes.Buffer root.SetOut(&output) - root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid", "--page-all"}) + root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all"}) if err := root.Execute(); err != nil { t.Fatal(err) } @@ -224,7 +224,7 @@ func TestChatMessagesExportIsAtomicAndNoClobber(t *testing.T) { helpers.InitDeps(newCaller()) root := newPlatformCoverageRoot() root.SetOut(&bytes.Buffer{}) - args := []string{"chat", "+chat-messages", "--group", "cid", "--page-all", "--output", "exports/messages.json"} + args := []string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all", "--output", "exports/messages.json"} if overwrite { args = append(args, "--overwrite") } @@ -261,7 +261,7 @@ func TestChatMessagesExportRejectsNonJSONPlaceholder(t *testing.T) { root := newPlatformCoverageRoot() root.SetOut(&bytes.Buffer{}) root.SetArgs([]string{ - "chat", "+chat-messages", "--group", "cid", "--output", "{}", + "chat", "+chat-messages", "--conversation-id", "cid", "--output", "{}", }) if err := root.Execute(); err == nil { t.Fatal("non-JSON placeholder output unexpectedly succeeded") diff --git a/internal/shortcut/smart/compatibility_coverage_test.go b/internal/shortcut/smart/compatibility_coverage_test.go index 4bfd3fb5..4dddd734 100644 --- a/internal/shortcut/smart/compatibility_coverage_test.go +++ b/internal/shortcut/smart/compatibility_coverage_test.go @@ -99,6 +99,63 @@ func newPlatformCoverageRoot() *cobra.Command { return root } +func TestIMObservedCompatibilityAliasesReachCanonicalInvocation(t *testing.T) { + tests := []struct { + name string + args []string + wantTool string + wantKey string + wantValue any + }{ + { + name: "chat messages open conversation id", + args: []string{"chat", "+chat-messages", "--open-conversation-id", "cid-placeholder", "--page-size", "5"}, + wantTool: "list_conversation_message_v2", + wantKey: "limit", + wantValue: 5, + }, + { + name: "search text query", + args: []string{"chat", "+search-msg", "--text-query", "评测", "--no-enrich"}, + wantTool: "search_messages", + wantKey: "keyword", + wantValue: "评测", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(tt.args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != tt.wantTool || !reflect.DeepEqual(fake.calls[0].args[tt.wantKey], tt.wantValue) { + t.Fatalf("calls = %#v", fake.calls) + } + }) + } +} + +func TestIMObservedCompatibilityAliasesConflictWithCanonicalFlags(t *testing.T) { + for _, args := range [][]string{ + {"chat", "+chat-messages", "--limit", "5", "--page-size", "5", "--conversation-id", "cid"}, + {"chat", "+search-msg", "--query", "评测", "--text", "评测", "--no-enrich"}, + } { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(args) + if err := root.Execute(); err == nil { + t.Fatalf("conflicting aliases unexpectedly succeeded: %#v", args) + } + if len(fake.calls) != 0 { + t.Fatalf("conflicting aliases reached transport: %#v", fake.calls) + } + } +} + func TestCrossPlatformCoverageAIMessageTag(t *testing.T) { t.Setenv(agentproduct.EnvName, "qwenwork") diff --git a/internal/shortcut/smart/gap_fill_test.go b/internal/shortcut/smart/gap_fill_test.go index 835e5d50..cbb580e4 100644 --- a/internal/shortcut/smart/gap_fill_test.go +++ b/internal/shortcut/smart/gap_fill_test.go @@ -56,7 +56,7 @@ func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t name: "chat messages", tool: "chat/list_conversation_message_v2", response: `{"result":{"messages":[` + message + `]}}`, - args: []string{"chat", "+chat-messages", "--group", "cid"}, + args: []string{"chat", "+chat-messages", "--conversation-id", "cid"}, resultKey: "messages", }, { @@ -124,7 +124,7 @@ func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t func TestCrossPlatformCoverageMessageReadShortcutResourceOutputValidation(t *testing.T) { for _, args := range [][]string{ - {"chat", "+chat-messages", "--group", "cid"}, + {"chat", "+chat-messages", "--conversation-id", "cid"}, {"chat", "+search-msg", "--query", "x", "--no-enrich"}, {"chat", "+at-me"}, {"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"}, @@ -143,7 +143,7 @@ func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T) helpers.InitDeps(caller) root := newPlatformCoverageRoot() before := time.Now().Add(-2 * time.Second) - root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid", "--limit", "5"}) + root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--limit", "5"}) if err := root.Execute(); err != nil { t.Fatal(err) } @@ -181,7 +181,7 @@ func TestCrossPlatformCoverageChatMessagesPreservesExplicitTime(t *testing.T) { root := newPlatformCoverageRoot() root.SetArgs([]string{ "chat", "+chat-messages", - "--group", "cid", + "--conversation-id", "cid", "--time", "2026-07-01 12:34:56", "--yes", }) diff --git a/internal/shortcut/smart/group_members.go b/internal/shortcut/smart/group_members.go index c2b0459a..009e2096 100644 --- a/internal/shortcut/smart/group_members.go +++ b/internal/shortcut/smart/group_members.go @@ -83,18 +83,21 @@ var ChatMembersList = shortcut.Shortcut{ Product: "chat", Description: "列出群成员并把用户与机器人分桶(支持群名语义解析)", Intent: "当你要完整查看一个群的参与者,并需要区分真人用户和机器人时使用;" + - "可直接传 --conversation-id,或传 --group 群名让命令先做唯一匹配。" + + "--group 可传群名或 openConversationId,也可用 --conversation-id 显式传稳定 ID、用 --chat-query 显式按群名解析。" + "默认同时返回 users/bots 两个桶,也可用 --member-types 只取 user 或 bot;" + "用户桶自动翻页并按稳定 ID 去重,结果用 buckets、complete、hasMore、nextCursor 和 failures 证明完整性;--page-limit 保证有界。机器人桶按下层全量列表投影。", Risk: shortcut.RiskRead, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群名称(与 --conversation-id 二选一)"}, - {Name: "conversation-id", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --group 二选一)"}, + {Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId"}, + {Name: "conversation-id", Type: shortcut.FlagString, Desc: "显式群 openConversationId"}, + {Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名解析唯一 openConversationId"}, + {Name: "chat", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, + {Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true}, {Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型;--member-types 仅接受 user/bot;不传则同时返回"}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "用户成员桶最大页数;--page-limit 必须在 1-500 之间"}, }, Constraints: []shortcut.Constraint{ - {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id"}}, + {Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "chat-query", "chat", "open-conversation-id"}}, {Kind: shortcut.ConstraintCustom, Flags: []string{"member-types"}, Description: "--member-types 仅接受 user/bot"}, {Kind: shortcut.ConstraintCustom, Flags: []string{"page-limit"}, Description: "--page-limit 必须在 1-500 之间"}, }, @@ -104,14 +107,13 @@ var ChatMembersList = shortcut.Shortcut{ }, Validate: validateGroupMembersPageLimit, Execute: func(rt *shortcut.RuntimeContext) error { - groupID := strings.TrimSpace(rt.Str("conversation-id")) - groupName := strings.TrimSpace(rt.Str("group")) + groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "chat", "open-conversation-id")) if groupID == "" { - resolved, err := resolveGroupName(rt, groupName) + resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query")) if err != nil { return err } - groupID = resolved + groupID = resolved.Selected.OpenConversationID } wantUsers, wantBots, err := resolveMemberTypes(rt.StrSlice("member-types")) @@ -192,14 +194,6 @@ var ChatMembersList = shortcut.Shortcut{ }, } -func resolveGroupName(rt *shortcut.RuntimeContext, groupName string) (string, error) { - resolved, err := targetresolver.ResolveChat(rt, groupName) - if err != nil { - return "", err - } - return resolved.Selected.OpenConversationID, nil -} - func validateGroupMembersPageLimit(rt *shortcut.RuntimeContext) error { if limit := rt.Int("page-limit"); limit < 1 || limit > groupMembersHardPageLimit { return apperrors.NewValidation("--page-limit 必须在 1-500 之间") diff --git a/internal/shortcut/smart/natural_target_resolution_test.go b/internal/shortcut/smart/natural_target_resolution_test.go index c1807452..a51652a7 100644 --- a/internal/shortcut/smart/natural_target_resolution_test.go +++ b/internal/shortcut/smart/natural_target_resolution_test.go @@ -27,6 +27,13 @@ func TestChatMessagesResolvesNaturalChatAndUserTargets(t *testing.T) { wantKey: "openconversation_id", wantValue: "cid-1", }, + { + name: "natural group through group flag", + args: []string{"chat", "+chat-messages", "--group", "项目冲刺"}, + wantTool: "list_conversation_message_v2", + wantKey: "openconversation_id", + wantValue: "cid-1", + }, { name: "user query", args: []string{"chat", "+chat-messages", "--user-query", "张三"}, @@ -55,6 +62,20 @@ func TestChatMessagesResolvesNaturalChatAndUserTargets(t *testing.T) { } } +func TestChatMessagesStableGroupBypassesNaturalResolution(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+chat-messages", "--group", "cidACeQ0fCtKfLsFGvA47gXaQ=="}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "list_conversation_message_v2" || + fake.calls[0].args["openconversation_id"] != "cidACeQ0fCtKfLsFGvA47gXaQ==" { + t.Fatalf("calls = %#v", fake.calls) + } +} + func TestChatMessagesNaturalUserAmbiguityStopsBeforeMessageRead(t *testing.T) { fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`} helpers.InitDeps(fake) @@ -95,6 +116,34 @@ func TestAtMeResolvesNaturalGroupBeforeSearch(t *testing.T) { } } +func TestAtMeStableIDInQueryBypassesNaturalResolution(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+at-me", "--chat-query", "cidACeQ0fCtKfLsFGvA47gXaQ=="}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "search_at_me_message" || + fake.calls[0].args["openConversationId"] != "cidACeQ0fCtKfLsFGvA47gXaQ==" { + t.Fatalf("calls = %#v", fake.calls) + } +} + +func TestSendToGroupStableIDBypassesNaturalResolution(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{"chat", "+send-to-group", "--group", "cidACeQ0fCtKfLsFGvA47gXaQ==", "--text", "评测", "--yes"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "send_personal_message" || + fake.calls[0].args["openConversationId"] != "cidACeQ0fCtKfLsFGvA47gXaQ==" { + t.Fatalf("calls = %#v", fake.calls) + } +} + func TestSearchMsgResolvesNaturalChatAndSenderBeforeSearch(t *testing.T) { fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"}]}`} helpers.InitDeps(fake) @@ -123,6 +172,55 @@ func TestSearchMsgResolvesNaturalChatAndSenderBeforeSearch(t *testing.T) { } } +func TestSearchMsgAcceptsStableIDInChatQuery(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+search-msg", + "--chat-query", "cidRYtcWlwRzUKMD1/o2/J4PA==", + "--text", "评测", + "--no-enrich", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "search_messages" { + t.Fatalf("calls = %#v", fake.calls) + } + if got, want := fake.calls[0].args["openConversationIds"], []string{"cidRYtcWlwRzUKMD1/o2/J4PA=="}; !reflect.DeepEqual(got, want) { + t.Fatalf("openConversationIds = %#v, want %#v", got, want) + } + if fake.calls[0].args["keyword"] != "评测" { + t.Fatalf("keyword = %#v", fake.calls[0].args["keyword"]) + } +} + +func TestChatMembersListGroupAcceptsNameAndStableID(t *testing.T) { + for _, tt := range []struct { + name string + args []string + wantCalls int + }{ + {name: "name", args: []string{"chat", "+chat-members-list", "--group", "项目冲刺", "--member-types", "user"}, wantCalls: 2}, + {name: "stable id", args: []string{"chat", "+chat-members-list", "--group", "cidACeQ0fCtKfLsFGvA47gXaQ==", "--member-types", "user"}, wantCalls: 1}, + {name: "compat alias", args: []string{"chat", "+chat-members-list", "--open-conversation-id", "cid-short-placeholder", "--member-types", "user"}, wantCalls: 1}, + } { + t.Run(tt.name, func(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs(tt.args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != tt.wantCalls || fake.calls[len(fake.calls)-1].tool != "get_group_members" { + t.Fatalf("calls = %#v", fake.calls) + } + }) + } +} + func TestSearchMsgNaturalSenderAmbiguityStopsBeforeSearch(t *testing.T) { fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`} helpers.InitDeps(fake) diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index 97c93cf8..bed097d6 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -44,6 +44,8 @@ var SearchMsg = shortcut.Shortcut{ Flags: append([]shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"}, {Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true}, + {Name: "text", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true}, + {Name: "text-query", Type: shortcut.FlagString, Desc: "--query 的兼容别名", Hidden: true}, {Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"}, {Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, {Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true}, @@ -75,7 +77,7 @@ var SearchMsg = shortcut.Shortcut{ Constraints: append([]shortcut.Constraint{ { Kind: shortcut.ConstraintAtLeastOne, - Flags: []string{"query", "keyword", "group", "conversation-id", "id", "groups", "chat-id", "chat-query", "senders", "sender", "sender-query", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"}, + Flags: []string{"query", "keyword", "text", "text-query", "group", "conversation-id", "id", "groups", "chat-id", "chat-query", "senders", "sender", "sender-query", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"}, Description: "至少指定一个内容、身份、会话或消息类型过滤条件", }, { @@ -92,6 +94,7 @@ var SearchMsg = shortcut.Shortcut{ {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"at-me", "is-at-me"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"conversation-type", "chat-type"}}, + {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"query", "keyword", "text", "text-query"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}}, {Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}}, }, chatshortcut.MessageResourceDownloadConstraints()...), @@ -234,7 +237,7 @@ func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error { } func validateSearchMsg(rt *shortcut.RuntimeContext) error { - hasFilter := rt.StrFirst("query", "keyword", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" || + hasFilter := rt.StrFirst("query", "keyword", "text", "text-query", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" || len(rt.StrSlice("groups")) > 0 || len(rt.StrSlice("chat-id")) > 0 || len(rt.StrSlice("chat-query")) > 0 || @@ -266,7 +269,7 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error { func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) { params := map[string]any{"limit": rt.IntFirst("limit", "page-size")} - if value := rt.StrFirst("query", "keyword"); value != "" { + if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" { params["keyword"] = value } conversationIDs := append([]string{}, rt.StrSlice("groups")...) @@ -275,11 +278,11 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) { conversationIDs = append(conversationIDs, value) } if queries := rt.StrSlice("chat-query"); len(queries) > 0 { - resolvedChats, err := targetresolver.ResolveChats(rt, queries) - if err != nil { - return nil, err - } - for _, resolved := range resolvedChats { + for _, query := range queries { + resolved, err := targetresolver.ResolveChatTarget(rt, "", query) + if err != nil { + return nil, err + } conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID) } } diff --git a/internal/shortcut/smart/send_to_group.go b/internal/shortcut/smart/send_to_group.go index a3f28df6..32d488b2 100644 --- a/internal/shortcut/smart/send_to_group.go +++ b/internal/shortcut/smart/send_to_group.go @@ -19,10 +19,11 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" ) -// SendToGroup: message a group by its NAME, no openConversationId juggling. +// SendToGroup: message a group by its name or stable openConversationId. // -// Steps: search groups by name → resolve to a single openConversationId -// (disambiguate on multiple matches, never guess) → send a markdown message. +// Steps: normalize a stable ID, or search a name and resolve it to a single +// openConversationId (disambiguate on multiple matches, never guess), then +// send a markdown message. // Replaces `chat search --query <群名>` (copy openConversationId) → // `chat +messages-send --group `. // @@ -35,12 +36,11 @@ var SendToGroup = shortcut.Shortcut{ Service: "chat", Command: "+send-to-group", Product: "chat", - Description: "按群名直接给群发消息(自动搜群解析 openConversationId)", - Intent: "当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;" + - "内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。", - Risk: shortcut.RiskWrite, + Description: "按群名或 openConversationId 直接给群发消息", + Intent: "当你有群名或 openConversationId、想直接往该群发送简单文本或 Markdown 时使用;稳定 ID 不进入搜索,群名则必须唯一解析,零命中或多候选都会在发送前停止。会真实发出群消息。", + Risk: shortcut.RiskWrite, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群名称(搜群关键词,用群名里连续的核心词)", Required: true}, + {Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId", Required: true}, {Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true}, shortcut.AIMessageTagFlag(), }, @@ -49,7 +49,7 @@ var SendToGroup = shortcut.Shortcut{ groupName := rt.Str("group") text := rt.Str("text") - resolved, err := targetresolver.ResolveChat(rt, groupName) + resolved, err := targetresolver.ResolveChatTarget(rt, groupName, "") if err != nil { return err } diff --git a/internal/shortcut/targetresolver/resolver.go b/internal/shortcut/targetresolver/resolver.go index e2283cd6..7e59cea6 100644 --- a/internal/shortcut/targetresolver/resolver.go +++ b/internal/shortcut/targetresolver/resolver.go @@ -118,9 +118,21 @@ func ResolveChat(rt Reader, query string) (ChatResolution, error) { query = strings.TrimSpace(query) if LooksLikeOpenConversationID(query) { return ChatResolution{}, apperrors.NewValidation(fmt.Sprintf( - "%q 看起来是群 openConversationId;请通过稳定 ID 参数(如 --group 或 --chat)传入,不要作为群名搜索", + "群目标参数类型不匹配:%q 符合 openConversationId 格式,当前参数仅接受群名称", query, - )) + ), + apperrors.WithReason("target_type_mismatch"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), + apperrors.WithHint("请使用当前命令的稳定 ID 参数;运行当前 leaf --help 可查看准确参数名。"), + apperrors.WithDetails(map[string]any{ + "type": "resolution", + "entityType": "chat", + "providedType": "openConversationId", + "expectedType": "chatName", + }), + ) } cursor := "0" seenCursors := map[string]bool{cursor: true} @@ -199,6 +211,48 @@ func ResolveChat(rt Reader, query string) (ChatResolution, error) { }, nil } +// ResolveChatTarget accepts either a stable openConversationId or a natural +// group name and returns one normalized ChatResolution. Stable IDs never reach +// search_groups; natural names retain ResolveChat's complete-pagination and +// fail-closed ambiguity semantics. +func ResolveChatTarget(rt Reader, directValue, queryValue string) (ChatResolution, error) { + directValue = strings.TrimSpace(directValue) + queryValue = strings.TrimSpace(queryValue) + if directValue != "" && queryValue != "" { + return ChatResolution{}, apperrors.NewValidation( + "稳定群目标与群名查询参数不能同时指定", + apperrors.WithReason("target_arguments_conflict"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("request_validation"), + apperrors.WithExecutionStarted(false), + ) + } + value := directValue + if value == "" { + value = queryValue + } + if value == "" { + return ChatResolution{}, apperrors.NewValidation( + "必须指定群名或 openConversationId", + apperrors.WithReason("missing_target"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("request_validation"), + apperrors.WithExecutionStarted(false), + ) + } + if LooksLikeOpenConversationID(value) { + return ChatResolution{ + Status: StatusResolved, + EntityType: "chat", + Query: value, + MatchType: "stable_id", + Selected: Chat{OpenConversationID: value}, + Profile: profilectx.Get(), + }, nil + } + return ResolveChat(rt, value) +} + // LooksLikeOpenConversationID identifies the opaque group-conversation IDs // returned by DingTalk. It is deliberately conservative: only the stable // "cid..." form is recognized, so ordinary short names are still resolved by @@ -510,6 +564,9 @@ func newResolutionError(status Status, entityType, query string, candidates any) return apperrors.NewValidation( fmt.Sprintf("没有找到与 %q 唯一匹配且可用于当前操作的%s", query, entityLabel(entityType)), apperrors.WithReason("resolution_not_found"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false), apperrors.WithHint("请提供更完整的名称或直接传稳定 ID"), apperrors.WithDetails(details), @@ -518,6 +575,9 @@ func newResolutionError(status Status, entityType, query string, candidates any) return apperrors.NewValidation( fmt.Sprintf("%q 匹配到多个%s:%s;请提供更精确的名称或直接传稳定 ID", query, entityLabel(entityType), candidateLabels(candidates)), apperrors.WithReason("resolution_ambiguous"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false), apperrors.WithHint("禁止默认选择第一个候选"), apperrors.WithDetails(details), @@ -539,6 +599,9 @@ func newIncompleteChatResolutionError(query string, candidates []Chat, cause str return apperrors.NewAPI( fmt.Sprintf("群名 %q 的候选未能完整读取,已停止后续操作", query), apperrors.WithReason("resolution_incomplete"), + apperrors.WithOrigin("mcp_gateway"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true), apperrors.WithHint("请重试,或直接传 openConversationId 跳过群名解析"), apperrors.WithDetails(details), @@ -549,6 +612,9 @@ func batchResolutionError(entityType string, failures []map[string]any) error { return apperrors.NewValidation( fmt.Sprintf("%d 个%s目标未能唯一解析;已停止后续操作", len(failures), entityLabel(entityType)), apperrors.WithReason("resolution_batch_failed"), + apperrors.WithOrigin("client"), + apperrors.WithFailureStage("target_resolution"), + apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false), apperrors.WithHint("请逐项消歧或直接传稳定 ID"), apperrors.WithDetails(map[string]any{ diff --git a/internal/shortcut/targetresolver/resolver_test.go b/internal/shortcut/targetresolver/resolver_test.go index b60854d9..ae944314 100644 --- a/internal/shortcut/targetresolver/resolver_test.go +++ b/internal/shortcut/targetresolver/resolver_test.go @@ -60,14 +60,60 @@ func TestOpenConversationIDIsNeverSearchedAsAGroupName(t *testing.T) { reader := &chatResolutionReader{} _, err := ResolveChat(reader, "cidACeQ0fCtKfLsFGvA47gXaQ==") - if err == nil || !strings.Contains(err.Error(), "--group") { + var typed *apperrors.Error + if err == nil || !stderrors.As(err, &typed) || typed.Reason != "target_type_mismatch" { t.Fatalf("ResolveChat(stable id) error = %v", err) } + if strings.Contains(typed.Message, "看起来是") || !strings.Contains(typed.Message, "群目标参数类型不匹配") { + t.Fatalf("ResolveChat(stable id) message = %q", typed.Message) + } + if typed.Details["providedType"] != "openConversationId" || typed.Details["expectedType"] != "chatName" { + t.Fatalf("ResolveChat(stable id) details = %#v", typed.Details) + } if len(reader.calls) != 0 { t.Fatalf("stable id unexpectedly reached search: %#v", reader.calls) } } +func TestResolveChatTargetStableIDBypassesSearch(t *testing.T) { + reader := &chatResolutionReader{} + resolved, err := ResolveChatTarget(reader, " cidACeQ0fCtKfLsFGvA47gXaQ== ", "") + if err != nil { + t.Fatal(err) + } + if resolved.Selected.OpenConversationID != "cidACeQ0fCtKfLsFGvA47gXaQ==" || resolved.MatchType != "stable_id" { + t.Fatalf("resolved = %#v", resolved) + } + if len(reader.calls) != 0 { + t.Fatalf("stable id unexpectedly reached search: %#v", reader.calls) + } +} + +func TestResolveChatTargetNaturalDirectValueUsesResolver(t *testing.T) { + reader := &chatResolutionReader{responses: []map[string]any{{ + "result": []any{map[string]any{"openConversationId": "cid-project-1", "title": "项目群"}}, + "hasMore": false, + }}} + resolved, err := ResolveChatTarget(reader, "项目群", "") + if err != nil { + t.Fatal(err) + } + if resolved.Selected.OpenConversationID != "cid-project-1" || len(reader.calls) != 1 { + t.Fatalf("resolved = %#v calls = %#v", resolved, reader.calls) + } +} + +func TestResolveChatTargetQueryStableIDAlsoBypassesSearch(t *testing.T) { + reader := &chatResolutionReader{} + resolved, err := ResolveChatTarget(reader, "", "cid-query-123456") + if err != nil { + t.Fatal(err) + } + if resolved.Selected.OpenConversationID != "cid-query-123456" || len(reader.calls) != 0 { + t.Fatalf("resolved = %#v calls = %#v", resolved, reader.calls) + } +} + func TestUserSelectionDedupesButDoesNotHideNamesakes(t *testing.T) { users := dedupeUsers([]User{ {UserID: "u1", OpenDingTalkID: "D1", Name: "张三"}, @@ -180,6 +226,9 @@ func TestResolveChatFailsClosedWhenPaginationCannotAdvance(t *testing.T) { if typed.Details["subtype"] != StatusIncomplete { t.Fatalf("details = %#v", typed.Details) } + if typed.Origin != "mcp_gateway" || typed.FailureStage != "target_resolution" || typed.ExecutionStarted == nil || *typed.ExecutionStarted { + t.Fatalf("failure semantics = origin %q stage %q execution_started %v", typed.Origin, typed.FailureStage, typed.ExecutionStarted) + } cause, _ := typed.Details["cause"].(string) if cause == "" || !strings.Contains(cause, tc.wantFragment) { t.Fatalf("cause = %q, want fragment %q", cause, tc.wantFragment) @@ -212,6 +261,9 @@ func TestResolutionErrorCarriesStructuredCandidates(t *testing.T) { if typed.Reason != "resolution_ambiguous" { t.Fatalf("reason = %q", typed.Reason) } + if typed.Origin != "client" || typed.FailureStage != "target_resolution" || typed.ExecutionStarted == nil || *typed.ExecutionStarted { + t.Fatalf("failure semantics = origin %q stage %q execution_started %v", typed.Origin, typed.FailureStage, typed.ExecutionStarted) + } if typed.Details["type"] != "resolution" || typed.Details["subtype"] != StatusAmbiguous { t.Fatalf("details = %#v", typed.Details) } diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index de248c6d..134d7b47 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -40,13 +40,14 @@ metadata: | 用户意图 | 唯一推荐入口 | 关键边界 | |---|---|---| | 按姓名发简单文本或 Markdown | `dws chat +dm --to <姓名> --text <内容>` | CLI 解析唯一用户;多候选时停止,不先手工查 ID | -| 按群名发简单文本或 Markdown | `dws chat +send-to-group --group <群名> --text <内容>` | CLI 精确匹配优先;多候选时停止,不先手工查群 ID | +| 按群名或 ID 发简单文本或 Markdown | `dws chat +send-to-group --group <群名或ID> --text <内容>` | 稳定 ID 直接使用;群名多候选时停止 | | 文件、Bot、Webhook、复杂 @、已知 ID 或自然目标的高级发送 | `dws chat +messages-send` | user 可用自然目标;Bot 多群用 `--groups/--groups-file` 并检查逐项 ledger | -| 读取一个指定群聊或单聊 | `dws chat +chat-messages` | 全量加 `--page-all`;导出加 `--output <相对.json>`,检查完整性 ledger | -| 跨会话、多维过滤或自动翻页搜索 | `dws chat +search-msg` | 支持 `--chat-query` / `--sender-query`;全量时用 `--page-all` 并检查 `complete` | -| 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名>` | 唯一解析并全量读取 | +| 读取一个指定群聊或单聊 | `dws chat +chat-messages` | 群聊 `--group` 可传群名或 ID;全量加 `--page-all`,检查完整性 ledger | +| 跨会话、多维过滤或自动翻页搜索 | `dws chat +search-msg` | 内容用 `--query`,自然目标用 `--chat-query` / `--sender-query`;检查 `complete` | +| 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 | | 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 | | 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` | +| 修改群名称 | `dws chat group rename --id --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` | | 查看指定群内 @我的消息 | `dws chat +at-me --chat-query <群名>` | 空结果仍返回数组 | | 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` | | 读取并下载消息资源 | 查询命令加 `--download-resources` | 不另起手工下载循环;下载失败项保留在结果中 | @@ -68,7 +69,7 @@ metadata: ### 发送入口边界 - `+dm`:姓名目标的简单文本/Markdown,参数空间最小。 -- `+send-to-group`:群名目标的简单文本/Markdown,避免暴露无关身份矩阵。 +- `+send-to-group`:群名或稳定 ID 目标的简单文本/Markdown,避免暴露无关身份矩阵。 - `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。user 已知 ID 可直接传,也可用 `--user-query` / `--chat-query` 运行同一只读解析链;Bot 多群使用 `--groups/--groups-file`,返回 `im.batch-write.v1`;bot/webhook 只使用下层真实支持的文本/Markdown 能力。 - 文件直接传 `+messages-send --file <相对路径>`;不要先独立上传并提取 mediaId。 - Webhook 使用 `+messages-send --as webhook --webhook-token `;不要退回原子 Webhook 命令。 @@ -111,4 +112,5 @@ metadata: 2. `unknown command` / `unknown flag`:读取精确 leaf Help,修正后最多重试一次。 3. 参数约束或 confirmation 不清楚:读取精确 leaf Schema,以 Runtime gate 为准。 4. 认证、权限、profile 或 confirmation 错误:读取 `dws-shared` 的对应 reference;正常 IM 不读取完整 shared Skill。 -5. 其他错误:保留真实错误和已完成/失败项;不要连续尝试同义原子命令。 +5. `backend_dependency_unavailable`:保持原参数,对只读命令最多重试一次;不要改 flag、猜认证命令或切换同义原子命令,持续失败时保留 Trace ID。 +6. 其他错误:保留真实错误和已完成/失败项;不要连续尝试同义原子命令。 diff --git a/skills/multi/dingtalk-chat/references/chat/chat-group.md b/skills/multi/dingtalk-chat/references/chat/chat-group.md index 91e74e8f..99341a06 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-group.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-group.md @@ -59,7 +59,7 @@ dws chat group create --name "话题圈" --users userId1,userId2 --thread | 命令 | 用途 | 必填参数 | |------|------|----------| -| `group rename` | 更新群名称 | `--id` `--name` | +| `group rename` | 更新群名称 | `--id` `--name`;只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID,不猜 `+chat-rename` | | `group quit` | 当前用户退出群聊 | `--group` | | `group dismiss` | 解散群聊,不可逆 | `--group` | @@ -67,7 +67,7 @@ dws chat group create --name "话题圈" --users userId1,userId2 --thread | 命令 | 用途 | 示例 | |------|------|------| -| `+chat-members-list` | 全量查看群成员并分桶用户/机器人 | `dws chat +chat-members-list --group <群名>` 或 `--conversation-id `;检查 buckets/complete/failures,`--page-limit` 保证有界 | +| `+chat-members-list` | 全量查看群成员并分桶用户/机器人 | `--group <群名或openConversationId>`;显式 ID 也可用 `--conversation-id`,检查 buckets/complete/failures | | `group members add` | 添加群成员 | `dws chat group members add --id --users userId1,userId2` | | `group members remove` | 移除群成员 | `dws chat group members remove --id --users userId1,userId2` | | `group members list-by-ids` | 按 openDingTalkId 批量查成员详情 | `dws chat group members list-by-ids --id --users openDingTalkId1,openDingTalkId2` | diff --git a/skills/multi/dingtalk-chat/references/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md index 7fe4798a..854f512e 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -92,7 +92,8 @@ dws chat message send --group --msg-type profile --contact- ### 拉取消息的底层 fallback -默认使用 `dws chat +chat-messages`。全量读取加 `--page-all`,必要时用 `--page-limit`、 +默认使用 `dws chat +chat-messages`。群聊的 `--group` 可传群名或 openConversationId; +也可用 `--chat-query` 显式按群名解析、用 `--conversation-id` 显式传稳定 ID。全量读取加 `--page-all`,必要时用 `--page-limit`、 `--max-results` 控制边界,用 `--output <相对.json>` 原子导出。只有需要原始响应或显式手工 continuation 时才使用下表;原子 `message list` 不代表自动全量分页。 @@ -116,7 +117,8 @@ continuation 时才使用下表;原子 `message list` 不代表自动全量分 ### 搜索消息的底层 fallback -默认使用 `dws chat +search-msg`;需要 Shortcut 未暴露的原始过滤字段或响应时,才评估 +默认使用 `dws chat +search-msg`;搜索内容使用 canonical `--query`,群名和发送者姓名分别使用 +`--chat-query` / `--sender-query`。需要 Shortcut 未暴露的原始过滤字段或响应时,才评估 `message search-advanced`。它是原子 `message search` 的严格超集,但不是 Agent 高频默认入口。 ```bash