From db52ad9d302f99f4adf40c9539dddb78c0c2b68a Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 24 May 2026 07:58:26 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix(maxturnerro):=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=88=B0=E8=BE=BE=E6=9C=80=E5=A4=A7=E8=BD=AE=E6=95=B0=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/gateway-error-catalog.md | 1 + docs/reference/gateway-error-catalog.md | 3 +- internal/cli/gateway_runtime_bridge.go | 9 +- internal/gateway/bootstrap.go | 27 ++++-- internal/gateway/bootstrap_test.go | 102 ++++++++++++++++++++ internal/gateway/errors.go | 3 + internal/gateway/errors_test.go | 5 + internal/gateway/protocol/jsonrpc.go | 5 +- internal/gateway/runtime_errors.go | 43 ++++++++- internal/runtime/errors_test.go | 13 +++ internal/runtime/max_turn_error.go | 11 ++- web/src/api/protocol.ts | 14 +++ web/src/utils/eventBridge.test.ts | 119 ++++++++++++++++++++++++ web/src/utils/eventBridge.ts | 56 ++++++++++- 14 files changed, 396 insertions(+), 15 deletions(-) diff --git a/docs/gateway-error-catalog.md b/docs/gateway-error-catalog.md index 7c7e482f..1c2d33f0 100644 --- a/docs/gateway-error-catalog.md +++ b/docs/gateway-error-catalog.md @@ -10,6 +10,7 @@ | `missing_required_field` | 200 | -32602 | 缺失必填字段(如 `params.session_id`、`params.request_id`、`payload.run_id`)。 | 直接失败,补齐字段。 | | `unsupported_action` | 200 | -32601 | 方法不存在或当前版本未实现。 | 降级到兼容方法,或提示版本不支持。 | | `internal_error` | 200 | -32603 | 网关内部异常、运行时不可用、不可归类的执行失败。 | 可短暂重试;持续失败需告警。 | +| `max_turn_exceeded` | 200 | -32602 | Runtime 达到 `runtime.max_turns` 后受控停止;异步 `gateway.run` 会通过 `run_error.stop_reason=max_turn_exceeded` 透传。 | 提示用户可继续发送消息、拆分任务或调高 `runtime.max_turns`,不要按网关内部错误告警。 | | `timeout` | 200 | -32603 | Gateway 调用 runtime 超过操作超时窗口。 | 可重试并增加客户端超时预算;必要时调用 `gateway.cancel`。 | | `unauthorized` | 401 | -32602 | 未提供有效 token 或连接未完成认证。 | 刷新凭据并重新认证,不建议盲重试。 | | `access_denied` | 403 | -32602 | 已认证但 ACL/主体权限不允许当前动作或资源访问。 | 直接失败,提示权限不足。 | diff --git a/docs/reference/gateway-error-catalog.md b/docs/reference/gateway-error-catalog.md index 7967c374..1c3de61e 100644 --- a/docs/reference/gateway-error-catalog.md +++ b/docs/reference/gateway-error-catalog.md @@ -2,7 +2,7 @@ 本文档用于第三方客户端实现统一异常处理策略,覆盖 Gateway 稳定错误码集合: -`invalid_frame`、`invalid_action`、`invalid_multimodal_payload`、`missing_required_field`、`unsupported_action`、`internal_error`、`timeout`、`unauthorized`、`access_denied`、`resource_not_found`。 +`invalid_frame`、`invalid_action`、`invalid_multimodal_payload`、`missing_required_field`、`unsupported_action`、`internal_error`、`max_turn_exceeded`、`timeout`、`unauthorized`、`access_denied`、`resource_not_found`。 ## 1. 错误码对照表 @@ -14,6 +14,7 @@ | `missing_required_field` | `200` | `-32600` / `-32602` | 缺失必填字段。请求层字段缺失多映射为 `-32600`,方法参数层字段缺失多映射为 `-32602`。 | 缺失 `id`;缺失 `params`;`cancel` 缺失 `run_id`。 | 调整参数补齐必填项再重试。 | | `unsupported_action` | `200` | `-32601` | 方法未注册或不被网关识别。 | 调用不存在的方法名。 | 客户端按能力探测降级,或升级服务端版本。 | | `internal_error` | `200` | `-32603` | 网关内部异常或未分类下游异常。 | 结果编码失败;runtime port 不可用;未知运行时错误。 | 采用指数退避重试;持续失败时告警。 | +| `max_turn_exceeded` | `200` | `-32602` | Runtime 达到 `runtime.max_turns` 后受控停止。 | 异步 `gateway.run` 通过 `run_error` 返回 `stop_reason=max_turn_exceeded`。 | 提示用户继续发送消息、拆分任务或调高 `runtime.max_turns`;不要按网关内部错误告警。 | | `timeout` | `200` | `-32603` | 网关调用 runtime 超时(`context.DeadlineExceeded`)。 | `run/compact/cancel/loadSession/resolvePermission` 下游调用超时。 | 可重试且建议带幂等键(如固定 `run_id`)。 | | `unauthorized` | `401`(仅 /rpc) | `-32602` | 请求未通过认证。 | 未携带 token;token 非法;连接未先 `authenticate`。 | 先刷新凭证并重新认证,认证成功后再发业务请求。 | | `access_denied` | `403`(仅 /rpc) | `-32602` | 已认证但不具备该方法或资源权限。 | ACL 拒绝当前来源调用该方法;runtime 返回 access denied。 | 终止当前请求并提示授权不足,不要盲重试。 | diff --git a/internal/cli/gateway_runtime_bridge.go b/internal/cli/gateway_runtime_bridge.go index d51195d2..c3f8b61f 100644 --- a/internal/cli/gateway_runtime_bridge.go +++ b/internal/cli/gateway_runtime_bridge.go @@ -314,6 +314,9 @@ func (b *gatewayRuntimePortBridge) Run(ctx context.Context, input gateway.RunInp return err } err := b.runtime.Submit(ctx, convertGatewayRunInput(input)) + if agentruntime.IsMaxTurnLimitError(err) { + return gateway.NewRuntimeMaxTurnExceededError(err.Error()) + } if err != nil && isRuntimeNotFoundError(err) { sessionID := strings.TrimSpace(input.SessionID) if sessionID == "" { @@ -326,7 +329,11 @@ func (b *gatewayRuntimePortBridge) Run(ctx context.Context, input gateway.RunInp if _, createErr := creator.CreateSession(ctx, sessionID); createErr != nil { return err } - return b.runtime.Submit(ctx, convertGatewayRunInput(input)) + retryErr := b.runtime.Submit(ctx, convertGatewayRunInput(input)) + if agentruntime.IsMaxTurnLimitError(retryErr) { + return gateway.NewRuntimeMaxTurnExceededError(retryErr.Error()) + } + return retryErr } return err } diff --git a/internal/gateway/bootstrap.go b/internal/gateway/bootstrap.go index 08f01ea8..397c591b 100644 --- a/internal/gateway/bootstrap.go +++ b/internal/gateway/bootstrap.go @@ -523,15 +523,19 @@ func dispatchRunFrameWithSubjectID( ) } if relayExists && relay != nil { - errorCode := "INTERNAL_ERROR" + errorCode := ErrorCodeInternalError.String() errorMessage := "run failed" + stopReason := "" if failedFrame.Error != nil { - if normalizedCode := strings.ToUpper(strings.TrimSpace(failedFrame.Error.Code)); normalizedCode != "" { + if normalizedCode := strings.TrimSpace(failedFrame.Error.Code); normalizedCode != "" { errorCode = normalizedCode } if normalizedMessage := strings.TrimSpace(failedFrame.Error.Message); normalizedMessage != "" { errorMessage = normalizedMessage } + if strings.TrimSpace(failedFrame.Error.Code) == ErrorCodeMaxTurnExceeded.String() { + stopReason = ErrorCodeMaxTurnExceeded.String() + } } fallbackSessionID := strings.TrimSpace(frameSnapshot.SessionID) if fallbackSessionID == "" { @@ -542,14 +546,18 @@ func dispatchRunFrameWithSubjectID( fallbackRunID = strings.TrimSpace(inputSnapshot.RunID) } if fallbackSessionID != "" { + payload := map[string]any{ + "code": errorCode, + "message": errorMessage, + } + if stopReason != "" { + payload["stop_reason"] = stopReason + } relay.PublishRuntimeEvent(RuntimeEvent{ Type: RuntimeEventTypeRunError, SessionID: fallbackSessionID, RunID: fallbackRunID, - Payload: map[string]any{ - "code": errorCode, - "message": errorMessage, - }, + Payload: payload, }) } } @@ -1842,6 +1850,13 @@ func runtimeCallFailedFrame(ctx context.Context, frame MessageFrame, err error, case errors.Is(err, ErrRuntimeResourceNotFound): errorCode = ErrorCodeResourceNotFound message = fmt.Sprintf("%s target not found", normalizedOperation) + case errors.Is(err, ErrRuntimeMaxTurnExceeded): + errorCode = ErrorCodeMaxTurnExceeded + if detail := RuntimeMaxTurnExceededDetail(err); detail != "" { + message = detail + } else { + message = fmt.Sprintf("%s max turn exceeded", normalizedOperation) + } case errors.Is(err, ErrRuntimeInvalidAction): errorCode = ErrorCodeInvalidAction message = fmt.Sprintf("%s invalid action", normalizedOperation) diff --git a/internal/gateway/bootstrap_test.go b/internal/gateway/bootstrap_test.go index 04aa6374..69a788d3 100644 --- a/internal/gateway/bootstrap_test.go +++ b/internal/gateway/bootstrap_test.go @@ -2057,6 +2057,95 @@ ASSERT: } } +func TestDispatchRequestFrameRunMaxTurnFailurePublishesStopReason(t *testing.T) { + relay := NewStreamRelay(StreamRelayOptions{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + connectionID := NewConnectionID() + connectionCtx := WithConnectionID(ctx, connectionID) + connectionCtx = WithStreamRelay(connectionCtx, relay) + + messageCh := make(chan RelayMessage, 8) + if err := relay.RegisterConnection(ConnectionRegistration{ + ConnectionID: connectionID, + Channel: StreamChannelIPC, + Context: connectionCtx, + Cancel: cancel, + Write: func(message RelayMessage) error { + messageCh <- message + return nil + }, + Close: func() {}, + }); err != nil { + t.Fatalf("register connection: %v", err) + } + defer relay.dropConnection(connectionID) + + if err := relay.BindConnection(connectionID, StreamBinding{ + SessionID: "run-session-max-turn", + RunID: "run-max-turn", + Channel: StreamChannelIPC, + Role: StreamRoleNone, + Explicit: true, + }); err != nil { + t.Fatalf("bind connection: %v", err) + } + + runtime := &bootstrapRuntimeStub{ + runFn: func(_ context.Context, _ RunInput) error { + return NewRuntimeMaxTurnExceededError("runtime: max turn limit reached (40)") + }, + } + response := dispatchRequestFrame(connectionCtx, MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + RequestID: "req-run-max-turn", + SessionID: "run-session-max-turn", + RunID: "run-max-turn", + InputText: "hello", + }, runtime) + if response.Type != FrameTypeAck { + t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck) + } + + deadline := time.After(2 * time.Second) + for { + select { + case message := <-messageCh: + notification, ok := message.Payload.(protocol.JSONRPCNotification) + if !ok || notification.Method != protocol.MethodGatewayEvent { + continue + } + eventFrame := MessageFrame{} + raw, err := json.Marshal(notification.Params) + if err != nil { + t.Fatalf("marshal payload params: %v", err) + } + if err := json.Unmarshal(raw, &eventFrame); err != nil { + t.Fatalf("unmarshal event frame: %v", err) + } + payloadMap, _ := eventFrame.Payload.(map[string]any) + if strings.TrimSpace(fmt.Sprint(payloadMap["event_type"])) != string(RuntimeEventTypeRunError) { + continue + } + envelope, _ := payloadMap["payload"].(map[string]any) + if got := strings.TrimSpace(fmt.Sprint(envelope["code"])); got != ErrorCodeMaxTurnExceeded.String() { + t.Fatalf("payload.code = %q, want %q", got, ErrorCodeMaxTurnExceeded.String()) + } + if got := strings.TrimSpace(fmt.Sprint(envelope["stop_reason"])); got != ErrorCodeMaxTurnExceeded.String() { + t.Fatalf("payload.stop_reason = %q, want %q", got, ErrorCodeMaxTurnExceeded.String()) + } + if got := strings.TrimSpace(fmt.Sprint(envelope["message"])); got != "runtime: max turn limit reached (40)" { + t.Fatalf("payload.message = %q, want max turn detail", got) + } + return + case <-deadline: + t.Fatal("expected max-turn run_error event") + } + } +} + func TestRuntimeCallFailedFrameSanitizesErrorAndMapsCode(t *testing.T) { var buf bytes.Buffer ctx := WithGatewayLogger(context.Background(), log.New(&buf, "", 0)) @@ -2108,6 +2197,19 @@ func TestRuntimeCallFailedFrameSanitizesErrorAndMapsCode(t *testing.T) { if invalidActionErr.Error.Message != "approve_plan invalid action" { t.Fatalf("invalid action message = %q, want %q", invalidActionErr.Error.Message, "approve_plan invalid action") } + + maxTurnErr := runtimeCallFailedFrame( + context.Background(), + frame, + NewRuntimeMaxTurnExceededError("runtime: max turn limit reached (40)"), + "run", + ) + if maxTurnErr.Error == nil || maxTurnErr.Error.Code != ErrorCodeMaxTurnExceeded.String() { + t.Fatalf("max turn error payload = %#v, want max_turn_exceeded", maxTurnErr.Error) + } + if maxTurnErr.Error.Message != "runtime: max turn limit reached (40)" { + t.Fatalf("max turn message = %q, want runtime detail", maxTurnErr.Error.Message) + } } func TestNormalizeRunID(t *testing.T) { diff --git a/internal/gateway/errors.go b/internal/gateway/errors.go index 46c667c4..99417f82 100644 --- a/internal/gateway/errors.go +++ b/internal/gateway/errors.go @@ -18,6 +18,8 @@ const ( ErrorCodeUnsupportedAction ErrorCode = "unsupported_action" // ErrorCodeInternalError 表示网关内部错误。 ErrorCodeInternalError ErrorCode = "internal_error" + // ErrorCodeMaxTurnExceeded 表示 runtime 达到单次运行最大轮数后受控停止。 + ErrorCodeMaxTurnExceeded ErrorCode = "max_turn_exceeded" // ErrorCodeTimeout 表示网关下游调用超时。 ErrorCodeTimeout ErrorCode = "timeout" // ErrorCodeUnauthorized 表示请求未通过认证校验。 @@ -41,6 +43,7 @@ var stableErrorCodes = map[string]struct{}{ string(ErrorCodeMissingRequiredField): {}, string(ErrorCodeUnsupportedAction): {}, string(ErrorCodeInternalError): {}, + string(ErrorCodeMaxTurnExceeded): {}, string(ErrorCodeTimeout): {}, string(ErrorCodeUnauthorized): {}, string(ErrorCodeAccessDenied): {}, diff --git a/internal/gateway/errors_test.go b/internal/gateway/errors_test.go index b394ad7b..c42120aa 100644 --- a/internal/gateway/errors_test.go +++ b/internal/gateway/errors_test.go @@ -10,9 +10,14 @@ func TestStableErrorCodes(t *testing.T) { ErrorCodeMissingRequiredField, ErrorCodeUnsupportedAction, ErrorCodeInternalError, + ErrorCodeMaxTurnExceeded, ErrorCodeTimeout, ErrorCodeUnauthorized, ErrorCodeAccessDenied, + ErrorCodeResourceNotFound, + ErrorCodeRunnerOffline, + ErrorCodeCapabilityDenied, + ErrorCodeToolExecutionFailed, } for _, code := range codes { diff --git a/internal/gateway/protocol/jsonrpc.go b/internal/gateway/protocol/jsonrpc.go index 33e758ad..ac41d62a 100644 --- a/internal/gateway/protocol/jsonrpc.go +++ b/internal/gateway/protocol/jsonrpc.go @@ -140,6 +140,8 @@ const ( GatewayCodeUnsupportedAction = "unsupported_action" // GatewayCodeInternalError 表示网关内部错误。 GatewayCodeInternalError = "internal_error" + // GatewayCodeMaxTurnExceeded 表示 runtime 达到单次运行最大轮数后受控停止。 + GatewayCodeMaxTurnExceeded = "max_turn_exceeded" // GatewayCodeTimeout 表示网关处理请求时发生超时。 GatewayCodeTimeout = "timeout" // GatewayCodeUnsafePath 表示路径存在安全风险。 @@ -1201,7 +1203,8 @@ func MapGatewayCodeToJSONRPCCode(gatewayCode string) int { GatewayCodeUnsafePath, GatewayCodeUnauthorized, GatewayCodeAccessDenied, - GatewayCodeResourceNotFound: + GatewayCodeResourceNotFound, + GatewayCodeMaxTurnExceeded: return JSONRPCCodeInvalidParams case GatewayCodeInternalError: return JSONRPCCodeInternalError diff --git a/internal/gateway/runtime_errors.go b/internal/gateway/runtime_errors.go index 63c58566..3b44c73d 100644 --- a/internal/gateway/runtime_errors.go +++ b/internal/gateway/runtime_errors.go @@ -1,6 +1,9 @@ package gateway -import "errors" +import ( + "errors" + "strings" +) var ( // ErrRuntimeAccessDenied 表示运行时拒绝当前主体访问目标资源。 @@ -9,4 +12,42 @@ var ( ErrRuntimeResourceNotFound = errors.New("runtime resource not found") // ErrRuntimeInvalidAction 表示运行时拒绝了语义非法或已过期的动作。 ErrRuntimeInvalidAction = errors.New("runtime invalid action") + // ErrRuntimeMaxTurnExceeded 表示运行时达到 runtime.max_turns 后受控停止。 + ErrRuntimeMaxTurnExceeded = errors.New("runtime max turn exceeded") ) + +// RuntimeMaxTurnExceededError 携带 runtime 原始 max_turns 停止说明,供 Gateway 对外展示。 +type RuntimeMaxTurnExceededError struct { + Detail string +} + +// Error 返回可展示的 max_turns 停止说明。 +func (e RuntimeMaxTurnExceededError) Error() string { + detail := strings.TrimSpace(e.Detail) + if detail != "" { + return detail + } + return ErrRuntimeMaxTurnExceeded.Error() +} + +// Unwrap 保留稳定哨兵错误,便于 errors.Is 做语义判断。 +func (e RuntimeMaxTurnExceededError) Unwrap() error { + return ErrRuntimeMaxTurnExceeded +} + +// NewRuntimeMaxTurnExceededError 创建带细节的 max_turns 受控停止错误。 +func NewRuntimeMaxTurnExceededError(detail string) error { + return RuntimeMaxTurnExceededError{Detail: detail} +} + +// RuntimeMaxTurnExceededDetail 提取 max_turns 受控停止错误中的展示文本。 +func RuntimeMaxTurnExceededDetail(err error) string { + var target RuntimeMaxTurnExceededError + if errors.As(err, &target) { + return target.Error() + } + if errors.Is(err, ErrRuntimeMaxTurnExceeded) { + return ErrRuntimeMaxTurnExceeded.Error() + } + return "" +} diff --git a/internal/runtime/errors_test.go b/internal/runtime/errors_test.go index db914dc0..24826fbd 100644 --- a/internal/runtime/errors_test.go +++ b/internal/runtime/errors_test.go @@ -40,3 +40,16 @@ func TestHandleRunErrorProviderErrorDoesNotWriteStdLog(t *testing.T) { } } + +func TestIsMaxTurnLimitError(t *testing.T) { + err := newMaxTurnLimitError(40) + if !IsMaxTurnLimitError(err) { + t.Fatal("expected direct max turn error to be recognized") + } + if !IsMaxTurnLimitError(errors.Join(errors.New("outer"), err)) { + t.Fatal("expected joined max turn error to be recognized") + } + if IsMaxTurnLimitError(errors.New("runtime: max turn limit reached (40)")) { + t.Fatal("plain text error should not be treated as max turn error") + } +} diff --git a/internal/runtime/max_turn_error.go b/internal/runtime/max_turn_error.go index d52616d8..831c023f 100644 --- a/internal/runtime/max_turn_error.go +++ b/internal/runtime/max_turn_error.go @@ -1,6 +1,9 @@ package runtime -import "fmt" +import ( + "errors" + "fmt" +) // maxTurnLimitError 表示 Run 达到 runtime.max_turns 上限后触发的受控停止错误。 type maxTurnLimitError struct { @@ -21,3 +24,9 @@ func (e maxTurnLimitError) Limit() int { func newMaxTurnLimitError(limit int) error { return maxTurnLimitError{limit: limit} } + +// IsMaxTurnLimitError 判断错误链是否来自 runtime.max_turns 受控停止。 +func IsMaxTurnLimitError(err error) bool { + var target maxTurnLimitError + return errors.As(err, &target) +} diff --git a/web/src/api/protocol.ts b/web/src/api/protocol.ts index c6006809..15bad0b1 100644 --- a/web/src/api/protocol.ts +++ b/web/src/api/protocol.ts @@ -104,6 +104,7 @@ export const EventType = { BudgetEstimateFailed: "budget_estimate_failed", LedgerReconciled: "ledger_reconciled", StopReasonDecided: "stop_reason_decided", + RunError: "run_error", InputNormalized: "input_normalized", SkillActivated: "skill_activated", SkillDeactivated: "skill_deactivated", @@ -137,7 +138,20 @@ export const StopReason = { FatalError: "fatal_error", BudgetExceeded: "budget_exceeded", MaxTurnExceeded: "max_turn_exceeded", + VerificationFailed: "verification_failed", Accepted: "accepted", + EmptyResponse: "empty_response", + AcceptContinue: "accept_continue", + AcceptContinueExhausted: "accept_continue_exhausted", + TodoNotConverged: "todo_not_converged", + TodoWaitingExternal: "todo_waiting_external", + RepeatCycle: "repeat_cycle", + MaxTurnExceededWithUnconvergedTodos: "max_turn_exceeded_with_unconverged_todos", + MaxTurnExceededWithFailedVerification: "max_turn_exceeded_with_failed_verification", + VerificationConfigMissing: "verification_config_missing", + VerificationExecutionDenied: "verification_execution_denied", + VerificationExecutionError: "verification_execution_error", + RequiredTodoFailed: "required_todo_failed", RetryExhausted: "retry_exhausted", } as const; diff --git a/web/src/utils/eventBridge.test.ts b/web/src/utils/eventBridge.test.ts index e80bce68..eeb5700c 100644 --- a/web/src/utils/eventBridge.test.ts +++ b/web/src/utils/eventBridge.test.ts @@ -987,6 +987,125 @@ describe("eventBridge", () => { expect(useChatStore.getState().messages[0].toolStatus).toBe("error"); }); + it("StopReasonDecided marks running tool calls as error and shows max-turn toast", () => { + const api = createMockGatewayAPI(); + useChatStore.getState().addMessage({ + id: "tool-running-max-turn", + role: "tool", + type: "tool_call", + content: "", + toolName: "bash", + toolCallId: "tc-max-turn", + toolStatus: "running", + timestamp: Date.now(), + }); + useChatStore.getState().setGenerating(true); + + handleGatewayEvent( + { + type: EventType.StopReasonDecided, + payload: { + payload: { + runtime_event_type: EventType.StopReasonDecided, + payload: { reason: "max_turn_exceeded", detail: "runtime: max turn limit reached (40)" }, + }, + }, + session_id: "sess-1", + run_id: "run-max-turn", + }, + api, + ); + + expect(useChatStore.getState().isGenerating).toBe(false); + expect(useChatStore.getState().stopReason).toBe("max_turn_exceeded"); + expect(useChatStore.getState().messages[0].toolStatus).toBe("error"); + expect(useUIStore.getState().toasts.at(-1)?.message).toBe( + "已达到本次运行最大轮数,可继续发送消息或调高 runtime.max_turns", + ); + }); + + it("RunError with max-turn stop reason uses explicit max-turn UX instead of generic run failed", () => { + const api = createMockGatewayAPI(); + useChatStore.getState().addMessage({ + id: "tool-running-run-error", + role: "tool", + type: "tool_call", + content: "", + toolName: "bash", + toolCallId: "tc-run-error", + toolStatus: "running", + timestamp: Date.now(), + }); + useChatStore.getState().setGenerating(true); + + handleGatewayEvent( + { + type: EventType.RunError, + payload: { + event_type: EventType.RunError, + payload: { + code: "max_turn_exceeded", + message: "runtime: max turn limit reached (40)", + stop_reason: "max_turn_exceeded", + }, + }, + session_id: "sess-1", + run_id: "run-max-turn-error", + }, + api, + ); + + expect(useChatStore.getState().isGenerating).toBe(false); + expect(useChatStore.getState().stopReason).toBe("max_turn_exceeded"); + expect(useChatStore.getState().messages[0].toolStatus).toBe("error"); + expect(useUIStore.getState().toasts.at(-1)?.message).toBe( + "已达到本次运行最大轮数,可继续发送消息或调高 runtime.max_turns", + ); + expect( + useUIStore.getState().toasts.some((toast) => toast.message === "runtime: max turn limit reached (40)"), + ).toBe(false); + }); + + it("RunError with max-turn stop reason is handled during session mismatch", () => { + const api = createMockGatewayAPI(); + useSessionStore.setState({ currentSessionId: "sess-current" } as any); + useChatStore.getState().addMessage({ + id: "tool-running-run-error-mismatch", + role: "tool", + type: "tool_call", + content: "", + toolName: "bash", + toolCallId: "tc-run-error-mismatch", + toolStatus: "running", + timestamp: Date.now(), + }); + useChatStore.getState().setGenerating(true); + + handleGatewayEvent( + { + type: EventType.RunError, + payload: { + event_type: EventType.RunError, + payload: { + code: "max_turn_exceeded", + message: "runtime: max turn limit reached (40)", + stop_reason: "max_turn_exceeded", + }, + }, + session_id: "sess-stale", + run_id: "run-max-turn-mismatch", + }, + api, + ); + + expect(useChatStore.getState().isGenerating).toBe(false); + expect(useChatStore.getState().stopReason).toBe("max_turn_exceeded"); + expect(useChatStore.getState().messages[0].toolStatus).toBe("error"); + expect(useUIStore.getState().toasts.at(-1)?.message).toBe( + "已达到本次运行最大轮数,可继续发送消息或调高 runtime.max_turns", + ); + }); + it("RunCanceled does not convert running tool calls to done", () => { const api = createMockGatewayAPI(); useChatStore.getState().addMessage({ diff --git a/web/src/utils/eventBridge.ts b/web/src/utils/eventBridge.ts index 59014e96..18cb290b 100644 --- a/web/src/utils/eventBridge.ts +++ b/web/src/utils/eventBridge.ts @@ -1,5 +1,6 @@ import { EventType, + StopReason, type AcceptanceDecidedPayload, type BashSideEffectPayload, type BudgetCheckedPayload, @@ -56,7 +57,10 @@ let _pendingRollbackAppliedRunId: string | undefined; // plan 模式下先缓存文本流,等待结构化 plan_updated 决定最终展示。 let _planChunkBufferByRunId = new Map(); let _planUpdatedRunIds = new Set(); +let _maxTurnToastRunIds = new Set(); const CHECKPOINT_REASON_PRE_RESTORE_GUARD = "pre_restore_guard"; +const MAX_TURN_EXCEEDED_MESSAGE = + "已达到本次运行最大轮数,可继续发送消息或调高 runtime.max_turns"; /** 重置模块级游标 —— 在截断聊天历史 / 切换会话等场景调用,避免后续事件挂到已被移除的消息上 */ export function resetEventBridgeCursors() { @@ -74,6 +78,7 @@ export function resetEventBridgeCursors() { : undefined; _planChunkBufferByRunId = new Map(); _planUpdatedRunIds = new Set(); + _maxTurnToastRunIds = new Set(); _latestRunDiffRequestId += 1; if (!keepCheckpointBaseline) { _latestRestoreSyncRequestId += 1; @@ -528,8 +533,11 @@ function normalizeUserQuestionRequestedPayload( }; } -const CRITICAL_EVENTS = new Set([EventType.Error]); -const SESSION_AGNOSTIC_EVENTS = new Set([EventType.Error]); +const CRITICAL_EVENTS = new Set([EventType.Error, EventType.RunError]); +const SESSION_AGNOSTIC_EVENTS = new Set([ + EventType.Error, + EventType.RunError, +]); function strField(payload: unknown, key: string): string { return ((payload as PayloadRecord)?.[key] as string) ?? ""; @@ -539,6 +547,20 @@ function getRunKey(frameRunId: string | undefined): string { return (frameRunId || useGatewayStore.getState().currentRunId || "").trim(); } +function isMaxTurnExceeded(reason: string, code = ""): boolean { + return ( + reason === StopReason.MaxTurnExceeded || + code === StopReason.MaxTurnExceeded + ); +} + +function showMaxTurnExceededToastOnce(frameRunId: string | undefined) { + const runKey = getRunKey(frameRunId) || "__unknown_run__"; + if (_maxTurnToastRunIds.has(runKey)) return; + _maxTurnToastRunIds.add(runKey); + useUIStore.getState().showToast(MAX_TURN_EXCEEDED_MESSAGE, "error"); +} + function extractAgentDoneContent(eventPayload: unknown): string { const parts = (eventPayload as { parts?: { text?: string }[] } | undefined) ?.parts; @@ -634,7 +656,11 @@ export function handleGatewayEvent( const payload = frame.payload as PayloadRecord; if (!payload) return; - const innerEnvelope = payload.payload as PayloadRecord; + const rawInnerPayload = payload.payload; + const innerEnvelope = + rawInnerPayload && typeof rawInnerPayload === "object" + ? (rawInnerPayload as PayloadRecord) + : undefined; const eventType = (innerEnvelope?.runtime_event_type as string | undefined) ?? (payload.event_type as string | undefined); @@ -661,7 +687,10 @@ export function handleGatewayEvent( return; } - const eventPayload = innerEnvelope?.payload; + const eventPayload = + innerEnvelope?.runtime_event_type !== undefined + ? innerEnvelope.payload + : rawInnerPayload; const chatStore = useChatStore.getState(); const uiStore = useUIStore.getState(); @@ -910,6 +939,21 @@ export function handleGatewayEvent( break; } + case EventType.RunError: { + const code = strField(eventPayload, "code"); + const reason = strField(eventPayload, "stop_reason"); + const message = strField(eventPayload, "message") || "Run failed"; + useChatStore.getState().resetGeneratingState(); + useChatStore.getState().finalizeRunningToolCalls("error"); + if (isMaxTurnExceeded(reason, code)) { + useChatStore.getState().setStopReason(StopReason.MaxTurnExceeded); + showMaxTurnExceededToastOnce(frameRunId); + } else { + uiStore.showToast(message, "error"); + } + break; + } + case EventType.StopReasonDecided: { const reason = strField(eventPayload, "reason"); const detail = strField(eventPayload, "detail"); @@ -921,6 +965,10 @@ export function handleGatewayEvent( if (reason === "fatal_error") { uiStore.showToast(detail || "模型调用失败,请检查配置", "error"); } + if (reason === StopReason.MaxTurnExceeded) { + useChatStore.getState().finalizeRunningToolCalls("error"); + showMaxTurnExceededToastOnce(frameRunId); + } break; } From 7372b0036cd865c4192857979c2f37dd8d85a949 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Sun, 24 May 2026 09:33:29 -0400 Subject: [PATCH 2/2] fix(web): scope run errors to current run --- web/src/utils/eventBridge.test.ts | 89 +++++++++++++++++++++++++++++++ web/src/utils/eventBridge.ts | 22 ++++++-- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/web/src/utils/eventBridge.test.ts b/web/src/utils/eventBridge.test.ts index eeb5700c..2d135a31 100644 --- a/web/src/utils/eventBridge.test.ts +++ b/web/src/utils/eventBridge.test.ts @@ -1022,6 +1022,8 @@ describe("eventBridge", () => { expect(useUIStore.getState().toasts.at(-1)?.message).toBe( "已达到本次运行最大轮数,可继续发送消息或调高 runtime.max_turns", ); + useSessionStore.setState({ currentSessionId: "" } as any); + useGatewayStore.setState({ currentRunId: "" } as any); }); it("RunError with max-turn stop reason uses explicit max-turn UX instead of generic run failed", () => { @@ -1069,6 +1071,7 @@ describe("eventBridge", () => { it("RunError with max-turn stop reason is handled during session mismatch", () => { const api = createMockGatewayAPI(); useSessionStore.setState({ currentSessionId: "sess-current" } as any); + useGatewayStore.setState({ currentRunId: "run-max-turn-mismatch" } as any); useChatStore.getState().addMessage({ id: "tool-running-run-error-mismatch", role: "tool", @@ -1106,6 +1109,92 @@ describe("eventBridge", () => { ); }); + it("RunError during session mismatch is ignored when run id is stale", () => { + const api = createMockGatewayAPI(); + useSessionStore.setState({ currentSessionId: "sess-current" } as any); + useGatewayStore.setState({ currentRunId: "run-current" } as any); + useChatStore.getState().addMessage({ + id: "tool-running-stale-run-error", + role: "tool", + type: "tool_call", + content: "", + toolName: "bash", + toolCallId: "tc-stale-run-error", + toolStatus: "running", + timestamp: Date.now(), + }); + useChatStore.getState().setGenerating(true); + + handleGatewayEvent( + { + type: EventType.RunError, + payload: { + event_type: EventType.RunError, + payload: { + code: "max_turn_exceeded", + message: "runtime: max turn limit reached (40)", + stop_reason: "max_turn_exceeded", + }, + }, + session_id: "sess-stale", + run_id: "run-stale", + }, + api, + ); + + expect(useChatStore.getState().isGenerating).toBe(true); + expect(useChatStore.getState().stopReason).toBe(""); + expect(useChatStore.getState().messages[0].toolStatus).toBe("running"); + expect(useUIStore.getState().toasts).toHaveLength(0); + useSessionStore.setState({ currentSessionId: "" } as any); + useGatewayStore.setState({ currentRunId: "" } as any); + }); + + it("RunError for current run is handled while transitioning", () => { + const api = createMockGatewayAPI(); + useSessionStore.setState({ currentSessionId: "sess-current" } as any); + useGatewayStore.setState({ currentRunId: "run-transition" } as any); + useChatStore.setState({ isTransitioning: true } as any); + useChatStore.getState().addMessage({ + id: "tool-running-transition-run-error", + role: "tool", + type: "tool_call", + content: "", + toolName: "bash", + toolCallId: "tc-transition-run-error", + toolStatus: "running", + timestamp: Date.now(), + }); + useChatStore.getState().setGenerating(true); + + handleGatewayEvent( + { + type: EventType.RunError, + payload: { + event_type: EventType.RunError, + payload: { + code: "max_turn_exceeded", + message: "runtime: max turn limit reached (40)", + stop_reason: "max_turn_exceeded", + }, + }, + session_id: "sess-stale", + run_id: "run-transition", + }, + api, + ); + + expect(useChatStore.getState().isGenerating).toBe(false); + expect(useChatStore.getState().stopReason).toBe("max_turn_exceeded"); + expect(useChatStore.getState().messages[0].toolStatus).toBe("error"); + expect(useUIStore.getState().toasts.at(-1)?.message).toBe( + "已达到本次运行最大轮数,可继续发送消息或调高 runtime.max_turns", + ); + useSessionStore.setState({ currentSessionId: "" } as any); + useGatewayStore.setState({ currentRunId: "" } as any); + useChatStore.setState({ isTransitioning: false } as any); + }); + it("RunCanceled does not convert running tool calls to done", () => { const api = createMockGatewayAPI(); useChatStore.getState().addMessage({ diff --git a/web/src/utils/eventBridge.ts b/web/src/utils/eventBridge.ts index 18cb290b..49a2844e 100644 --- a/web/src/utils/eventBridge.ts +++ b/web/src/utils/eventBridge.ts @@ -534,10 +534,7 @@ function normalizeUserQuestionRequestedPayload( } const CRITICAL_EVENTS = new Set([EventType.Error, EventType.RunError]); -const SESSION_AGNOSTIC_EVENTS = new Set([ - EventType.Error, - EventType.RunError, -]); +const SESSION_AGNOSTIC_EVENTS = new Set([EventType.Error]); function strField(payload: unknown, key: string): string { return ((payload as PayloadRecord)?.[key] as string) ?? ""; @@ -547,6 +544,20 @@ function getRunKey(frameRunId: string | undefined): string { return (frameRunId || useGatewayStore.getState().currentRunId || "").trim(); } +function isCurrentRunScopedTerminalEvent( + eventType: string, + frameRunId: string | undefined, +): boolean { + if (eventType !== EventType.RunError) return false; + const eventRunId = (frameRunId || "").trim(); + const currentRunId = useGatewayStore.getState().currentRunId.trim(); + return ( + eventRunId !== "" && + currentRunId !== "" && + eventRunId === currentRunId + ); +} + function isMaxTurnExceeded(reason: string, code = ""): boolean { return ( reason === StopReason.MaxTurnExceeded || @@ -682,7 +693,8 @@ export function handleGatewayEvent( frameSessionId && currentSessionId && frameSessionId !== currentSessionId && - !SESSION_AGNOSTIC_EVENTS.has(eventType) + !SESSION_AGNOSTIC_EVENTS.has(eventType) && + !isCurrentRunScopedTerminalEvent(eventType, frameRunId) ) { return; }