From 4e2df72eeb8bb841de75238a3036692273685e9c Mon Sep 17 00:00:00 2001 From: AetherNo2332 <130164460+AetherNo2332@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:53:32 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(cloud-agent):=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=BD=92=E7=B1=BB=E4=B8=8E=E5=8F=AF=E8=A1=8C?= =?UTF-8?q?=E5=8A=A8=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工具失败此前只有一句自然语言(tool_failed + text),无法区分"模型自己出的错"和真实的 权限/状态/上游问题:模型拿不到可自纠的反馈,排查时分不清该改提示词、改权限还是等上游。 - 新增 backend/internal/app/cloud_agent_tool_error_class.go:把失败归成 invalid_model_output(幻觉工具名;本轮契约之外、只被 contextScope 挡掉的调用)、 schema_error(缺必填字段/类型错/未知字段/非法 JSON)、state_conflict(画布在读取之后被改动)、 permission_violation(只读轮发起写入等真实授权边界)、upstream_failure(上游 4xx/5xx、 超时、媒体上游拒绝),其余归 tool_error。 - 每类附 retryable 与稳定的 requiredAction(fix_arguments / reread_canvas / use_advertised_tools / ask_user / report_to_user),写进 tool_failed 的回执与载荷顶层。 只加标注:没有任何放行或拒绝逻辑读它。 - 缺必填字段的通用判定:各工具的校验点抛的多是普通 AppError,从错误类型看不出"模型漏了字段"。 现在用本轮实际暴露的 schema 的 required 与参数比对,命中即按参数契约错误处理(回传 schema 与 guidance 供模型自纠);只在业务侧确实按参数问题拒绝(400/422)时套用,已分类的参数错误不套壳。 - 幻觉工具名的拒绝文案更具体:过去一律"工具未获本轮权限授权",现在指向"不存在的工具 + 只使用 本轮工具表里列出的工具"。 - 前端工具卡失败时显示归类标签,比统一的"执行失败"更能说明下一步该做什么。 --- backend/internal/app/cloud_agent_runtime.go | 32 ++- .../app/cloud_agent_tool_error_class.go | 174 +++++++++++++++ .../app/cloud_agent_tool_error_class_test.go | 130 +++++++++++ .../canvas/canvas-cloud-agent-chat-ui.tsx | 207 ++++++++++++------ web/src/lib/canvas/agent-tool-presentation.ts | 36 ++- web/test/agent-tool-presentation.test.ts | 14 +- 6 files changed, 514 insertions(+), 79 deletions(-) create mode 100644 backend/internal/app/cloud_agent_tool_error_class.go create mode 100644 backend/internal/app/cloud_agent_tool_error_class_test.go diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 0915a8def..4ac672348 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -950,6 +950,20 @@ func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgen if !ok || detail == nil { detail = map[string]any{} } + // 模型漏了必填字段(或参数不是对象)时,即便业务校验抛的是普通 AppError,也按 + // "参数契约错误"处理:把本轮实际暴露的 schema 回给模型,它才改得对。 + // 只在业务侧确实按参数问题拒绝(400/422)时套用:上游 5xx 或网络错误即便同时漏字段, + // 也该算上游故障,别把锅扣到参数上。已分类的参数错误不再套壳,避免覆盖更精确的反馈。 + var appErr *AppError + var existingArgumentErr *cloudAgentArgumentError + if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, call); len(missing) > 0 && + !errors.As(err, &existingArgumentErr) && + errors.As(err, &appErr) && appErr != nil && (appErr.Status == 400 || appErr.Status == 422) { + err = &cloudAgentFieldArgumentError{ + error: &cloudAgentArgumentError{err}, + Field: strings.Join(missing, ","), Issue: "required", + } + } message := cloudAgentSafeToolError(err) detail["error"] = message var admissionErr *cloudAgentMediaAdmissionError @@ -979,6 +993,16 @@ func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgen detail["exampleArguments"] = map[string]any{"snapshotHash": "", "ops": []any{map[string]any{"type": "add_node", "id": "", "nodeType": "text", "content": ""}}} } } + // 稳定归类 + 可行动字段:只加标注,不改任何放行/拒绝判定。 + // allowed 与执行器用的是同一份判定(cloudAgentToolAllowed 是纯函数,结果一致)。 + if class, retryable, requiredAction := cloudAgentToolErrorClass(state.Request, call, err, cloudAgentToolAllowed(state.Request, call.Function.Name)); class != "" { + detail["errorClass"], detail["errorClassLabel"] = class, cloudAgentToolErrorLabel(class) + detail["retryable"] = retryable + if requiredAction != "" { + detail["requiredAction"] = requiredAction + } + payload["errorClass"] = class + } result = detail kind = "tool_failed" payload["text"] = message @@ -1173,7 +1197,13 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c var toolErr error switch { case !allowed: - toolErr = BadAuthRequest("工具未获本轮权限授权") + // 幻觉出来的工具名与"真被权限挡住"要分开反馈:前者模型根本不该发这个调用, + // 后者是授权边界问题。两者仍然一律拒绝,只是给模型的下一步更明确。 + if !cloudAgentPlatformToolNames()[call.Function.Name] { + toolErr = BadAuthRequest("模型调用了不存在的工具「" + truncateRunes(call.Function.Name, 60) + "」,本轮已拒绝;请只使用本轮工具表里列出的工具") + } else { + toolErr = BadAuthRequest("工具未获本轮权限授权") + } case call.Function.Name == "canvas_apply_ops": result, toolErr = applyCloudAgentCanvas(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) case call.Function.Name == "canvas_create_storyboard", call.Function.Name == "canvas_edit_storyboard": diff --git a/backend/internal/app/cloud_agent_tool_error_class.go b/backend/internal/app/cloud_agent_tool_error_class.go new file mode 100644 index 000000000..755d5a676 --- /dev/null +++ b/backend/internal/app/cloud_agent_tool_error_class.go @@ -0,0 +1,174 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "strings" +) + +// 工具调用失败的稳定归类(handoff 工作项 B 的第一步:只加分类,不改执行行为)。 +// +// 目标是把"模型自己出的错"和"真实世界/上游的错"分开: +// - 模型幻觉出不存在的工具、在只暴露画布工具的这一轮里调画布工具、参数不合法 → 模型输出问题; +// - 只读轮里发起写入、目标不具备能力 → 权限边界问题; +// - 画布在模型读取之后被改动 → 状态冲突(可重读后重试); +// - 上游拒绝/超时/网络 → 上游故障。 +// +// 分类只进事件载荷,不参与任何放行或拒绝判定:拒绝逻辑仍然在各自的校验点。 +const ( + cloudAgentToolErrorInvalidModelOutput = "invalid_model_output" + cloudAgentToolErrorSchemaError = "schema_error" + cloudAgentToolErrorStateConflict = "state_conflict" + cloudAgentToolErrorPermission = "permission_violation" + cloudAgentToolErrorUpstream = "upstream_failure" + cloudAgentToolErrorUnknown = "tool_error" +) + +// cloudAgentToolErrorClass 返回 (errorClass, retryable, requiredAction)。 +// +// requiredAction 是给模型/界面看的"下一步该做什么",取稳定的短标识,不写自然语言: +// fix_arguments(按 schema 改参数后重试)、reread_canvas(重读画布再试)、 +// use_advertised_tools(只使用本轮工具表里列出的工具)、ask_user(权限被拒,别自己绕)、 +// report_to_user(上游故障,告诉用户)。 +func cloudAgentToolErrorClass(req CloudAgentRequest, call cloudAgentCall, err error, allowed bool) (string, bool, string) { + if err == nil { + return "", true, "" + } + if !allowed { + // 工具名不在平台工具集里 = 模型幻觉;只被 contextScope 挡掉 = 模型在本轮契约之外调用。 + // 两者都是模型输出问题,不是权限问题(它本来就不该发这个调用)。 + if !cloudAgentPlatformToolNames()[call.Function.Name] { + return cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools" + } + scopeRequest := req + scopeRequest.ContextScope = []string{"canvas"} + if cloudAgentToolAllowed(scopeRequest, call.Function.Name) { + return cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools" + } + return cloudAgentToolErrorPermission, false, "ask_user" + } + // 过期快照在上游是结构化字段错误(Field/Issue),先于通用参数错误判定。 + var fieldErr *cloudAgentFieldArgumentError + if errors.As(err, &fieldErr) { + if fieldErr.Issue == "stale_snapshot" { + return cloudAgentToolErrorStateConflict, true, "reread_canvas" + } + return cloudAgentToolErrorSchemaError, true, "fix_arguments" + } + var argumentErr *cloudAgentArgumentError + if errors.As(err, &argumentErr) { + return cloudAgentToolErrorSchemaError, true, "fix_arguments" + } + var admissionErr *cloudAgentMediaAdmissionError + if errors.As(err, &admissionErr) { + // 媒体准入里"上游拒绝该规格"属于上游故障;其余是参数/状态问题。 + if strings.Contains(admissionErr.Error(), "上游") { + return cloudAgentToolErrorUpstream, false, "report_to_user" + } + return cloudAgentToolErrorSchemaError, true, "fix_arguments" + } + var httpErr providerHTTPError + if errors.As(err, &httpErr) { + return cloudAgentToolErrorUpstream, httpErr.StatusCode >= 500 || httpErr.StatusCode == 429, "report_to_user" + } + var pendingErr providerStatePendingError + if errors.As(err, &pendingErr) { + return cloudAgentToolErrorUpstream, true, "report_to_user" + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return cloudAgentToolErrorUpstream, true, "report_to_user" + } + return cloudAgentToolErrorUnknown, true, "" +} + +// cloudAgentPlatformToolNames 是平台支持的工具全集(含只在特定条件下暴露的工具)。 +func cloudAgentPlatformToolNames() map[string]bool { + names := make(map[string]bool) + for _, name := range CloudAgentSupportedToolNames() { + names[name] = true + } + return names +} + +// cloudAgentToolErrorLabel 把归类转成一句给人看的短标签(界面与诊断包共用)。 +func cloudAgentToolErrorLabel(class string) string { + switch class { + case cloudAgentToolErrorInvalidModelOutput: + return "模型输出问题" + case cloudAgentToolErrorSchemaError: + return "参数不符合契约" + case cloudAgentToolErrorStateConflict: + return "画布状态已变化" + case cloudAgentToolErrorPermission: + return "超出本轮权限" + case cloudAgentToolErrorUpstream: + return "上游故障" + default: + return "工具执行失败" + } +} + +// schemaStringList 读 schema 里的字符串列表:既可能是 JSON 解码出来的 []any, +// 也可能是 Go 里直接构造的 []string。 +func schemaStringList(value any) []string { + switch typed := value.(type) { + case []string: + return append([]string(nil), typed...) + case []any: + list := make([]string, 0, len(typed)) + for _, item := range typed { + if text, ok := item.(string); ok && text != "" { + list = append(list, text) + } + } + return list + default: + return nil + } +} + +// cloudAgentMissingRequiredArguments 用"本轮实际暴露的 schema"检查必填字段。 +// +// 为什么需要它:各工具的校验点是业务代码,抛的多是普通 AppError(例如分镜读取参数无效), +// 从错误类型上看不出"这是模型漏了必填字段"。而模型漏字段恰恰是最常见的工具失败 +// (handoff 工作项 B 的证据里,缺 size / 缺 nodeId 类占大头)。这里按 schema 的 required +// 直接判定,把这些错误归到参数契约问题,并把 schema 回给模型让它改对。 +// +// 参数不是 JSON 对象时返回空列表(那种情况由 decodeCloudAgentJSONObject 的既有路径覆盖)。 +func cloudAgentMissingRequiredArguments(tools []map[string]any, call cloudAgentCall) []string { + name := strings.TrimSpace(call.Function.Name) + if name == "" { + return nil + } + var schema map[string]any + for _, tool := range tools { + function, _ := tool["function"].(map[string]any) + if stringValue(function["name"]) != name { + continue + } + schema, _ = function["parameters"].(map[string]any) + break + } + if schema == nil { + return nil + } + required := schemaStringList(schema["required"]) + if len(required) == 0 { + return nil + } + arguments := map[string]any{} + raw := strings.TrimSpace(call.Function.Arguments) + if raw != "" { + if json.Unmarshal([]byte(raw), &arguments) != nil { + return nil + } + } + missing := make([]string, 0, len(required)) + for _, key := range required { + if _, ok := arguments[key]; !ok { + missing = append(missing, key) + } + } + return missing +} diff --git a/backend/internal/app/cloud_agent_tool_error_class_test.go b/backend/internal/app/cloud_agent_tool_error_class_test.go new file mode 100644 index 000000000..b80dbd38f --- /dev/null +++ b/backend/internal/app/cloud_agent_tool_error_class_test.go @@ -0,0 +1,130 @@ +package app + +import ( + "context" + "errors" + "testing" +) + +// handoff 工作项 B 的第一步:把"模型输出问题"与"权限/状态/上游"分开,并且只加标注不改行为。 +func TestCloudAgentToolErrorClass(t *testing.T) { + canvasRequest := CloudAgentRequest{PermissionMode: "auto", ContextScope: []string{"canvas"}} + readOnlyRequest := CloudAgentRequest{PermissionMode: "read_only", ContextScope: []string{"canvas"}} + scopedRequest := CloudAgentRequest{PermissionMode: "auto"} // contextScope 为空:只暴露通用工具 + call := func(name string) cloudAgentCall { + var c cloudAgentCall + c.Function.Name = name + return c + } + + cases := []struct { + name string + request CloudAgentRequest + call cloudAgentCall + err error + allowed bool + wantClass string + wantRetryable bool + wantAction string + }{ + {"幻觉工具名", canvasRequest, call("make_coffee"), BadAuthRequest("工具未获本轮权限授权"), false, cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools"}, + {"contextScope 为空却调画布工具", scopedRequest, call("canvas_get_state"), BadAuthRequest("工具未获本轮权限授权"), false, cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools"}, + {"只读轮里发起生成", readOnlyRequest, call("generate_media"), BadAuthRequest("工具未获本轮权限授权"), false, cloudAgentToolErrorPermission, false, "ask_user"}, + {"参数不符合契约", canvasRequest, call("canvas_get_state"), cloudAgentJSONArgumentError(errors.New("unknown field")), true, cloudAgentToolErrorSchemaError, true, "fix_arguments"}, + {"字段级参数错误", canvasRequest, call("canvas_apply_ops"), cloudAgentFieldError("snapshotHash", "required", "缺 snapshotHash"), true, cloudAgentToolErrorSchemaError, true, "fix_arguments"}, + {"画布已变化", canvasRequest, call("canvas_apply_ops"), cloudAgentFieldError("snapshotHash", "stale_snapshot", "画布已变化"), true, cloudAgentToolErrorStateConflict, true, "reread_canvas"}, + {"上游 5xx", canvasRequest, call("task_get"), providerHTTPError{StatusCode: 503}, true, cloudAgentToolErrorUpstream, true, "report_to_user"}, + {"上游 4xx", canvasRequest, call("task_get"), providerHTTPError{StatusCode: 400}, true, cloudAgentToolErrorUpstream, false, "report_to_user"}, + {"超时", canvasRequest, call("task_get"), context.DeadlineExceeded, true, cloudAgentToolErrorUpstream, true, "report_to_user"}, + {"其它", canvasRequest, call("task_get"), errors.New("说不清"), true, cloudAgentToolErrorUnknown, true, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + class, retryable, action := cloudAgentToolErrorClass(tc.request, tc.call, tc.err, tc.allowed) + if class != tc.wantClass || retryable != tc.wantRetryable || action != tc.wantAction { + t.Fatalf("class=%q retryable=%v action=%q,期望 %q/%v/%q", class, retryable, action, tc.wantClass, tc.wantRetryable, tc.wantAction) + } + if label := cloudAgentToolErrorLabel(class); label == "" { + t.Fatalf("归类 %q 缺少人读标签", class) + } + }) + } +} + +// 归类必须落到工具失败事件上:payload 与回执都要带,界面与诊断包不用再猜。 +func TestCloudAgentToolResultCarriesErrorClass(t *testing.T) { + state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "auto", ContextScope: []string{"canvas"}}, Events: []CloudAgentEvent{}} + var call cloudAgentCall + call.ID = "call-1" + call.Function.Name = "canvas_apply_ops" + call.Function.Arguments = "{}" + cloudAgentToolResult("run-1", state, call, nil, cloudAgentFieldError("snapshotHash", "stale_snapshot", "画布已变化,本次未写入;请重新读取并重新申请审批")) + + var payload map[string]any + for _, event := range state.Events { + if event.Type == "tool_failed" { + payload = event.Payload + } + } + if payload == nil || payload["errorClass"] != cloudAgentToolErrorStateConflict { + t.Fatalf("tool_failed 载荷缺少归类:%+v", payload) + } + detail, _ := payload["result"].(map[string]any) + if detail["errorClassLabel"] != "画布状态已变化" || detail["retryable"] != true || detail["requiredAction"] != "reread_canvas" { + t.Fatalf("回执里的归类字段不对:%+v", detail) + } +} + +// 真机踩到的场景:canvas_read_storyboard 缺 nodeId 时,业务校验抛的是普通 AppError +// ("分镜节点ID、分页参数或每页行数无效")。按 schema 的 required 判定后必须归成 +// schema_error,并把 schema 回给模型,而不是落到兜底的 tool_error。 +func TestCloudAgentToolResultClassifiesMissingRequiredArguments(t *testing.T) { + schema := []map[string]any{{"type": "function", "function": map[string]any{ + "name": "canvas_read_storyboard", + "parameters": map[string]any{ + "type": "object", + "required": []any{"nodeId"}, + "properties": map[string]any{ + "nodeId": map[string]any{"type": "string"}, + }, + }, + }}} + state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "read_only", ContextScope: []string{"canvas"}}, Canonical: canonicalAgentRequest{Tools: schema}, Events: []CloudAgentEvent{}} + var call cloudAgentCall + call.ID = "call-1" + call.Function.Name = "canvas_read_storyboard" + call.Function.Arguments = `{}` + if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, call); len(missing) != 1 || missing[0] != "nodeId" { + t.Fatalf("必填字段检测 = %v,期望 [nodeId]", missing) + } + cloudAgentToolResult("run-1", state, call, nil, BadAuthRequest("分镜节点ID、分页参数或每页行数无效")) + + var payload map[string]any + for _, event := range state.Events { + if event.Type == "tool_failed" { + payload = event.Payload + } + } + if payload == nil { + t.Fatal("应当记成 tool_failed") + } + if payload["errorClass"] != cloudAgentToolErrorSchemaError { + t.Fatalf("归类 = %v,期望 %s", payload["errorClass"], cloudAgentToolErrorSchemaError) + } + detail, _ := payload["result"].(map[string]any) + if detail["reason"] != "invalid_tool_arguments" || detail["requiredAction"] != "fix_arguments" { + t.Fatalf("缺少可自纠的参数错误标注:%+v", detail) + } + if detail["field"] != "nodeId" { + t.Fatalf("field = %v,期望 nodeId", detail["field"]) + } + if _, ok := detail["parameters"].(map[string]any); !ok { + t.Fatalf("必须把本轮暴露的 schema 回给模型:%+v", detail["parameters"]) + } + // 必填字段齐备时不误判:参数没问题就不该被当成契约错误。 + full := call + full.Function.Arguments = `{"nodeId":"node-1"}` + if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, full); len(missing) != 0 { + t.Fatalf("字段齐备却报缺字段:%v", missing) + } +} diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index 399e9e83d..25f996220 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -13,7 +13,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { Skill } from "@/services/api/skills"; import { buildSkillMentionReferences } from "@/services/skill-runtime"; -import { agentToolCategory, agentToolCategoryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation"; +import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation"; import { agentToolRetry, type AgentToolRetryAttempt } from "@/lib/canvas/agent-tool-retry"; export type CloudAgentChatAttachment = { id: string; name: string; url: string }; @@ -77,7 +77,6 @@ function clampAgentPromptHeight(height: number) { return Math.min(MAX_AGENT_PROMPT_HEIGHT, Math.max(MIN_AGENT_PROMPT_HEIGHT, Math.ceil(height))); } - export function AgentChatMessage({ item, theme, @@ -107,7 +106,9 @@ export function AgentChatMessage({
- + {item.streaming ? "模型正在思考" : "模型思考"} {item.streaming ? "整理目标与下一步" : "推理摘要"} @@ -149,9 +150,17 @@ export function AgentChatMessage({
} />
-
{item.title || "Agent 暂时无法继续"}
-
{item.text}
- {item.meta ?
{item.meta}
: null} +
+ {item.title || "Agent 暂时无法继续"} +
+
+ {item.text} +
+ {item.meta ? ( +
+ {item.meta} +
+ ) : null} {onRetry ? (
    - {(attempts as AgentToolRetryAttempt[]).map((attempt, index) =>
  1. 第 {index + 1} 次:{attempt.text}
  2. )} + {(attempts as AgentToolRetryAttempt[]).map((attempt, index) => ( +
  3. + 第 {index + 1} 次:{attempt.text} +
  4. + ))}
); } return (
- +
- {categoryIcon}{categoryLabel} + + {categoryIcon} + {categoryLabel} + {summary} - {state.label !== "已完成" ? {state.label} : null} -
- {actions.length ?
- {visibleActions.map((action) => )} - {isNodeRead && (collapsedReadNodeCount > 0 || readExpanded) ? ( - + {state.label !== "已完成" ? ( + + {state.label} + ) : null} -
: null} - {state.isError && text && text !== summary ? {conciseError} : null} - {state.isError && text.length > 180 ?
查看完整错误详情

{text}

: null} - {state.isError && objectField(objectField(detail, "result"), "taskId") ? 任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))} : null} +
+ {actions.length ? ( +
+ {visibleActions.map((action) => ( + + ))} + {isNodeRead && (collapsedReadNodeCount > 0 || readExpanded) ? ( + + ) : null} +
+ ) : null} + {state.isError && text && text !== summary ? ( + + {conciseError} + + ) : null} + {state.isError && text.length > 180 ? ( +
+ 查看完整错误详情 +

{text}

+
+ ) : null} + {state.isError && objectField(objectField(detail, "result"), "taskId") ? ( + + 任务 ID:{String(objectField(objectField(detail, "result"), "taskId"))} + + ) : null}
{state.label === "已完成" ? 已完成 : null}
@@ -370,19 +437,16 @@ export function AgentToolCard({ title, text, detail, theme, references = [], onF export function AgentWorkingMessage({ theme, label = WORKING_TEXT }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; label?: string }) { return (
-
); } -export function AgentPlanBar({ items, theme, minimized, onToggle }: { - items: CloudAgentPlanItem[]; - theme: (typeof canvasThemes)[keyof typeof canvasThemes]; - minimized: boolean; - onToggle: () => void; -}) { +export function AgentPlanBar({ items, theme, minimized, onToggle }: { items: CloudAgentPlanItem[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; minimized: boolean; onToggle: () => void }) { const doneCount = items.filter((entry) => entry.status === "done").length; const allDone = doneCount === items.length; return ( @@ -390,7 +454,9 @@ export function AgentPlanBar({ items, theme, minimized, onToggle }: { ))} -
- {question.allowFreeform === false ? "请从上面选一项。" : "点一项即可,也可以在下方输入框里自己说明。"} -
+
{question.allowFreeform === false ? "请从上面选一项。" : "点一项即可,也可以在下方输入框里自己说明。"}
); } @@ -519,7 +578,7 @@ export function AgentChatComposer({ const nextHeight = clampAgentPromptHeight(naturalHeight); // 用户开始拖动后,面板高度由用户掌控;超出部分交给内部滚动区, // 避免输入新内容时又把手动缩小的面板强行撑开。 - setPromptHeight((currentHeight) => manualPromptHeightRef.current === null ? nextHeight : currentHeight); + setPromptHeight((currentHeight) => (manualPromptHeightRef.current === null ? nextHeight : currentHeight)); }, []); useEffect(() => { @@ -639,12 +698,7 @@ export function AgentChatComposer({ {item.name}
- @@ -691,7 +745,9 @@ export function AgentChatComposer({ sendOnEnter={canSubmit ? "both" : false} disabled={disabled} onChange={handlePromptChange} - onSubmit={() => { if (canSubmit) onSubmit(); }} + onSubmit={() => { + if (canSubmit) onSubmit(); + }} onContentSizeChange={handlePromptContentSizeChange} className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45" containerClassName="min-h-[60px] h-full" @@ -761,21 +817,23 @@ export function AgentChatComposer({ {canStop ? "运行中可插话" : "Enter 发送"} )} - {canStop ? void onStop?.()} - whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined} - whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined} - animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }} - transition={{ type: "spring", stiffness: 420, damping: 24 }} - className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed" - style={{ background: theme.accent.danger, color: theme.accent.onPrimary }} - > - {stopping ? : } - : null} + {canStop ? ( + void onStop?.()} + whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined} + whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined} + animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }} + transition={{ type: "spring", stiffness: 420, damping: 24 }} + className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed" + style={{ background: theme.accent.danger, color: theme.accent.onPrimary }} + > + {stopping ? : } + + ) : null} , isError: false }; - if (status === "failed") return { label: "执行失败", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: , isError: true }; + if (status === "failed") return { label: errorClassLabel ?? "执行失败", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: , isError: true }; if (status === "noop") return { label: "未生效", color: "#d97706", softBg: "rgba(217,119,6,.04)", icon: , isError: false }; - if (status === "rejected") return { label: "拒绝执行", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: , isError: true }; + if (status === "rejected") return { label: errorClassLabel ?? "拒绝执行", color: "#dc2626", softBg: "rgba(220,38,38,.04)", icon: , isError: true }; return { label: "处理中", color: "#64748b", softBg: "rgba(100,116,139,.04)", icon: , isError: false }; } diff --git a/web/src/lib/canvas/agent-tool-presentation.ts b/web/src/lib/canvas/agent-tool-presentation.ts index 38f4e3426..496e58e39 100644 --- a/web/src/lib/canvas/agent-tool-presentation.ts +++ b/web/src/lib/canvas/agent-tool-presentation.ts @@ -4,21 +4,25 @@ export const AGENT_TOOL_METADATA: Record pending ? "准备更新画布内容" : "画布内容已保存至服务端", failureMessage: "更新画布内容失败" }, + canvas_apply_ops: { summary: ({ pending }) => (pending ? "准备更新画布内容" : "画布内容已保存至服务端"), failureMessage: "更新画布内容失败" }, model_list: { summary: "已获取可用模型", failureMessage: "获取可用模型失败" }, - generate_media: { summary: ({ pending, detail }) => pending ? "准备创建媒体节点并生成" : field(detail, "eventType") === "tool_completed" ? "生成结果已回写画布节点" : "媒体节点已创建,生成任务已提交", failureMessage: "媒体生成未完成" }, + generate_media: { summary: ({ pending, detail }) => (pending ? "准备创建媒体节点并生成" : field(detail, "eventType") === "tool_completed" ? "生成结果已回写画布节点" : "媒体节点已创建,生成任务已提交"), failureMessage: "媒体生成未完成" }, }; export type AgentToolCategory = "read" | "create" | "operate"; function record(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function toolArguments(detail?: unknown) { const value = record(detail).arguments; if (typeof value !== "string") return record(value); - try { return record(JSON.parse(value)); } catch { return {}; } + try { + return record(JSON.parse(value)); + } catch { + return {}; + } } /** @@ -88,3 +92,27 @@ export function friendlyAgentToolSummary(toolName: string, text: string, detail? const failure = metadata?.failureMessage; return failed ? failure || "操作未完成" : summary || (pending ? "准备执行操作" : "操作已完成"); } + +/** + * 工具失败的稳定归类标签(后端 `errorClass`,见 handoff 工作项 B 的第一步)。 + * 界面上把"模型自己出的错"和"真实的权限/状态/上游问题"分开显示,用户与排查都不用猜。 + */ +export const AGENT_TOOL_ERROR_CLASS_LABELS: Record = { + invalid_model_output: "模型输出问题", + schema_error: "参数不符合契约", + state_conflict: "画布状态已变化", + permission_violation: "超出本轮权限", + upstream_failure: "上游故障", + tool_error: "工具执行失败", +}; + +/** 从工具事件载荷里取归类标签:优先用后端给的 label,其次查本地映射。 */ +export function agentToolErrorClassLabel(detail?: unknown): string | undefined { + const payload = record(detail); + const nested = record(payload.result); + const label = payload.errorClassLabel ?? nested.errorClassLabel; + if (typeof label === "string" && label) return label; + const errorClass = payload.errorClass ?? nested.errorClass; + if (typeof errorClass !== "string" || !errorClass) return undefined; + return AGENT_TOOL_ERROR_CLASS_LABELS[errorClass] ?? "工具执行失败"; +} diff --git a/web/test/agent-tool-presentation.test.ts b/web/test/agent-tool-presentation.test.ts index 478450e28..f55259727 100644 --- a/web/test/agent-tool-presentation.test.ts +++ b/web/test/agent-tool-presentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { agentToolCategory, agentToolCategoryLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation"; +import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolStatus, friendlyAgentToolSummary } from "@/lib/canvas/agent-tool-presentation"; describe("Agent tool presentation", () => { it("separates read, create, and canvas operation activity", () => { @@ -43,3 +43,15 @@ describe("Agent tool presentation", () => { expect(friendlyAgentToolSummary("canvas_get_state", "无法访问", { eventType: "tool_failed" })).toBe("获取画布内容失败"); }); }); + +describe("Agent tool error classification", () => { + it("maps backend errorClass to a human label, with a local fallback", () => { + expect(agentToolErrorClassLabel({ errorClass: "schema_error" })).toBe("参数不符合契约"); + expect(agentToolErrorClassLabel({ result: { errorClass: "state_conflict" } })).toBe("画布状态已变化"); + expect(agentToolErrorClassLabel({ errorClass: "permission_violation", errorClassLabel: "超出本轮权限" })).toBe("超出本轮权限"); + expect(agentToolErrorClassLabel({ result: { errorClass: "upstream_failure" } })).toBe("上游故障"); + expect(agentToolErrorClassLabel({ errorClass: "invalid_model_output" })).toBe("模型输出问题"); + expect(agentToolErrorClassLabel({ errorClass: "unknown_new_class" })).toBe("工具执行失败"); + expect(agentToolErrorClassLabel({ text: "没有归类" })).toBeUndefined(); + }); +}); From 33a0121cd73676c4a8c6c47070ff796cf5babe99 Mon Sep 17 00:00:00 2001 From: AetherNo2332 <130164460+AetherNo2332@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:50:20 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(web):=20=E7=94=BB=E5=B8=83=20Agent=20?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20-=20=E4=BF=AE=E6=AD=A3=20Skill=20=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E6=8F=90=E7=A4=BA=E8=AF=AD=E7=9A=84=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 输入框提示语已随外观配置搬进 lib/canvas/agent-appearance.ts,面板只消费 appearance.inputPlaceholder(canvas-cloud-agent-panel.tsx 的 agentCopy(...)),而这条用例仍在 面板源码里找那句字面文案,于是 Web checks 的 Run tests 一直红:干净 upstream/main 检出上可复现 1994 pass / 1 fail,同期其它作者的 PR 也是同一条失败。 改为断言"文案在 agent-appearance 里 + 面板仍在使用它",与重构后的架构一致。 --- web/test/canvas-resource-mention-editor.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/test/canvas-resource-mention-editor.test.ts b/web/test/canvas-resource-mention-editor.test.ts index fdfbb672f..bbf129013 100644 --- a/web/test/canvas-resource-mention-editor.test.ts +++ b/web/test/canvas-resource-mention-editor.test.ts @@ -122,7 +122,10 @@ describe("canvas resource mention editor", () => { expect(component).toContain("slash.start + 1 + slash.query.length"); expect(component).toContain("buildSkillMentionReferences(availableSlashSkills)"); expect(component).toContain("[/、]([^\\s/、]*)$"); - expect(source("../src/components/canvas/canvas-cloud-agent-panel.tsx")).toContain("用 / 或 、 引用 Skills"); + // 输入框提示语已随外观配置搬进 agent-appearance(面板只消费 appearance.inputPlaceholder), + // 这里断言"文案在、面板仍用它",而不是把文案钉在面板源码里。 + expect(source("../src/lib/canvas/agent-appearance.ts")).toContain("用 / 或 、 引用 Skills"); + expect(source("../src/components/canvas/canvas-cloud-agent-panel.tsx")).toContain("agentCopy(appearance.inputPlaceholder, appearance.agentName)"); }); test("skill chips use one colored icon instead of exposing the serialized token", () => {