merge: fix/taskrun-error-transparency-2026-09-21

This commit is contained in:
weichen
2026-09-21 03:09:59 +00:00
8 changed files with 433 additions and 102 deletions
+2 -2
View File
@@ -5,7 +5,7 @@
## 焦点
1. **任务错误透传**:`fix/taskrun-error-transparency-2026-09-21` 修复 `RunRecord` PascalCase 与前端 `NETWORK_ERROR` 误报(见 [short-term](./short-term/2026-09-21-taskrun-error-transparency.md))。追踪面板冒烟槽位已释放,可以合入。
1. **任务错误透传**:`fix/taskrun-error-transparency-2026-09-21` 修复 `RunRecord` PascalCase 与前端 `NETWORK_ERROR` 误报(见 [short-term](./short-term/2026-09-21-taskrun-error-transparency.md))。正在占槽合入 dev。
2. **阶段回溯与影响闭环已合入 dev**:T1–T3 与 B1–B3 在 `dev`。空追踪 JSON `null` 已修复,登录后显示「已索引 0 份文档 · 条目 0」(见 [报告](../test-reports/2026-09-21-trace-panel-null-crash-test-report.md))。test/main 未晋升。不改 `production_ready`,不签 D7 对话 ADR。
3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。
@@ -172,7 +172,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| - | idle | 2026-09-21 | ProjectTracePanel 空追踪修复已合入 dev;`/health` schema_version=59;浏览器显示「已索引 0 份文档 · 条目 0」 | - |
| Cursor | merging | 2026-09-21 | 合入 `fix/taskrun-error-transparency-2026-09-21`:RunRecord snake_case 与前端错误透传;随后 `go vet`、`go test -short`、dev rebuild、`/health`、GET run JSON | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -0,0 +1,29 @@
---
title: "HTTP 请求访问日志"
status: planned
created: 2026-09-21
updated: 2026-09-21
---
# HTTP 请求访问日志
## 动机
2026-09-21 排查 test 环境「网络连接异常」时,app 容器没有请求级访问日志。无法区分「请求未到达」与「到达后在鉴权/SSE 早期失败」。数据库里也没有失败请求的消息行。
## 成功标准
- 每条 HTTP 请求打一行结构化日志:方法、路径、状态码、耗时、request_id;不记录正文、token、密码
- 默认在 dev/test 开启;不把密钥或完整 prompt 写入日志
## 依赖
- 与本次错误透传修复独立,可单独认领
## 暂不启动原因
本次只修错误透传(json 标签 + 前端文案)。访问日志会碰到所有路由,需要单独设计和验证,避免日志泄漏。
## 相关链接
- Short-term: [2026-09-21-taskrun-error-transparency.md](../short-term/2026-09-21-taskrun-error-transparency.md)
@@ -0,0 +1,54 @@
---
title: "任务错误透传:NETWORK_ERROR 误报"
status: in_progress
created: 2026-09-21
updated: 2026-09-21
owner: Cursor
branch: fix/taskrun-error-transparency-2026-09-21
worktree: "-"
mutex: chat+web
---
# 任务错误透传:NETWORK_ERROR 误报
## 目标
任务失败或 SSE 断线后,界面展示后端真实原因(或明确的业务拒绝),不再把未知失败统一显示为「网络连接异常」。
## 范围
- `internal/taskrun/types.go`:`RunRecord` 补 snake_case json 标签,使 `GET /api/v1/tasks/runs/{id}` 与前端 `status` / `error_message` 对齐
- `web/src/hooks/useTaskRunAttempts.ts`:`failed` 透传 `error_message`;`cancelled` 单独文案;扩展业务码;未知 HTTP 4xx 不再伪装成网络错误
- 回归测试(Go + 前端)
## 非目标
- 不晋升、不重建 test 环境(随下次 dev→test 生效)
- 不改 natfrp 隧道
- 不在本次加请求访问日志(见 long-term)
## 步骤
1. [x] 从 `dev` 检出 `fix/taskrun-error-transparency-2026-09-21`
2. [ ] `RunRecord` json 标签 + 序列化回归测试
3. [ ] 前端错误透传与用例
4. [ ] 占集成槽位合入 `dev` 并冒烟(槽位空闲后)
5. [ ] 验收报告
## 验收标准
- [ ] `GET /api/v1/tasks/runs/{id}` JSON 含 `status`、`error_message`、`run_id`,不含 `Status`
- [ ] 任务 `failed` 且有 `error_message` 时,气泡 code 为 `TASK_FAILED`,不是 `NETWORK_ERROR`
- [ ] `blocked_pending_governance` 仍走治理提示
- [ ] 未知 4xx 业务码不显示「请检查网络」
## 相关链接
- 根因:test 环境今晨 natfrp 中继中断是诱因;前端 fallback 与 PascalCase 响应使真实原因不可见
- Long-term: [2026-09-21-http-request-access-log.md](../long-term/2026-09-21-http-request-access-log.md)
- Test report: `.doc/test-reports/2026-09-21-taskrun-error-transparency-test-report.md`
## 证据 / 备注
- 2026-09-21 实测 test `GET /api/v1/tasks/runs/run_1789882153312866054` 返回 `"Status"` / `"ErrorMessage"`(无 json 标签)
- 前端文案「请检查网络后重试」来自 `useTaskRunAttempts.ts` 的 `buildNetworkErrorInfo`,不是 `classify.go`
+109
View File
@@ -0,0 +1,109 @@
package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/taskrun"
)
func TestTaskRunResponseMarshalUsesSnakeCase(t *testing.T) {
raw, err := json.Marshal(taskRunResponse{
RunRecord: taskrun.RunRecord{
RunID: "run_snake",
Status: "failed",
ErrorMessage: "dial tcp connection refused",
TaskKey: "user_requirements",
WorkspaceID: "ws_1",
},
Resumable: false,
})
if err != nil {
t.Fatal(err)
}
var body map[string]any
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
for _, key := range []string{"run_id", "status", "error_message", "workspace_id", "task_key", "resumable"} {
if _, ok := body[key]; !ok {
t.Fatalf("missing %q in %s", key, raw)
}
}
for _, pascal := range []string{"Status", "ErrorMessage", "RunID", "WorkspaceID"} {
if _, ok := body[pascal]; ok {
t.Fatalf("pascal key %q leaked into %s", pascal, raw)
}
}
if body["status"] != "failed" || body["error_message"] != "dial tcp connection refused" {
t.Fatalf("payload=%s", raw)
}
}
func TestGetTaskRunJSONUsesSnakeCase(t *testing.T) {
srv := newTestServer(t)
ts := httptest.NewServer(srv.Routes())
t.Cleanup(ts.Close)
runID := "run_snake_http"
rec := &taskrun.RunRecord{
RunID: runID,
SessionID: "sess-snake",
Status: "failed",
ErrorMessage: "dial tcp connection refused",
TaskKey: "intake",
UserID: 1,
}
if _, err := srv.taskRuns.CreateAndStart(
t.Context(),
runID,
rec,
bizdev.TaskRunRequest{TaskKey: "intake", ProjectName: "snake-json"},
bizdev.StageRunRequest{},
"",
); err != nil {
t.Fatalf("CreateAndStart: %v", err)
}
token := testBearerToken(t, ts.URL, &config.Config{
AdminUsername: "admin",
AdminPassword: "admin-test-pass",
})
req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/tasks/runs/"+runID, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
payload, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET status=%d body=%s", resp.StatusCode, payload)
}
var body map[string]any
if err := json.Unmarshal(payload, &body); err != nil {
t.Fatalf("decode: %v body=%s", err, payload)
}
status, _ := body["status"].(string)
if status == "" {
t.Fatalf("status key missing or empty (pascal leak?): %s", payload)
}
if _, ok := body["Status"]; ok || strings.Contains(string(payload), `"ErrorMessage"`) {
t.Fatalf("pascal keys in GET body: %s", payload)
}
if _, ok := body["error_message"]; !ok {
t.Fatalf("missing error_message: %s", payload)
}
if _, ok := body["run_id"]; !ok {
t.Fatalf("missing run_id: %s", payload)
}
}
+22 -20
View File
@@ -24,27 +24,29 @@ type CreateRequest struct {
}
// RunRecord 运行实例状态。
// JSON 键与前端约定及 task_runs 列名一致;缺标签时 encoding/json 会输出 PascalCase,
// 前端读 status/error_message 会得到 undefined,断线恢复分支全部失效。
type RunRecord struct {
RunID string
SessionID string
ProjectID int64
UserID int64
ClientTurnID string
IdempotencyKey string // 新 run 稳定键;旧行保持 NULL 不参与唯一索引
TaskKey string
Stage string
WorkflowInstanceID int64
StageInstanceID int64
WorkflowStageKey string
Status string // pending | running | completed | failed | cancelled | pending_author_confirmation | blocked_pending_governance | …
WorkspaceID string
AssistantMsgID int64
WorkerID string
LeaseExpiresAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
ErrorMessage string
Attempt int
RunID string `json:"run_id"`
SessionID string `json:"session_id"`
ProjectID int64 `json:"project_id"`
UserID int64 `json:"user_id"`
ClientTurnID string `json:"client_turn_id"`
IdempotencyKey string `json:"idempotency_key"`
TaskKey string `json:"task_key"`
Stage string `json:"stage"`
WorkflowInstanceID int64 `json:"workflow_instance_id"`
StageInstanceID int64 `json:"stage_instance_id"`
WorkflowStageKey string `json:"workflow_stage_key"`
Status string `json:"status"` // pending | running | completed | failed | cancelled | pending_author_confirmation | blocked_pending_governance | …
WorkspaceID string `json:"workspace_id"`
AssistantMsgID int64 `json:"assistant_message_id"`
WorkerID string `json:"worker_id"`
LeaseExpiresAt *time.Time `json:"lease_expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ErrorMessage string `json:"error_message"`
Attempt int `json:"attempt"`
}
// BuildTaskRunIdempotencyKey 新创建用稳定幂等键;空 client_turn 不设键。
+49
View File
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy';
import {
buildFailedTaskErrorInfo,
buildNetworkErrorInfo,
readRemoteTaskRun,
} from '../lib/taskRunErrorInfo';
test('chat and task attempts live in one hook', () => {
const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
@@ -12,4 +18,47 @@ test('chat and task attempts live in one hook', () => {
assert.equal(hook.includes('createTaskRun'), true);
assert.equal(hook.includes('shouldSubscribeExistingRun'), true);
assert.equal(hook.includes('禁止无条件 setRunId(null)'), true);
assert.equal(hook.includes('buildFailedTaskErrorInfo'), true);
assert.equal(hook.includes('buildCancelledTaskErrorInfo'), true);
});
test('failed run with error_message surfaces TASK_FAILED not NETWORK_ERROR', () => {
const info = buildFailedTaskErrorInfo('pi remote runner call failed: connection refused');
assert.equal(info.code, 'TASK_FAILED');
assert.equal(info.class, 'fatal');
assert.equal(info.userMessage.includes('connection refused'), true);
assert.equal(info.userMessage.includes('网络连接异常'), false);
assert.deepEqual(info.actions, ['retry', 'restart']);
});
test('snake_case blocked_pending_governance is governance, not a network error', () => {
const hook = readFileSync(new URL('./useTaskRunAttempts.ts', import.meta.url), 'utf8');
const governanceAt = hook.indexOf('isBlockedGovernanceStatus(view.status)');
const failedAt = hook.indexOf('failOrRetry(buildFailedTaskErrorInfo');
assert.ok(governanceAt > 0 && failedAt > governanceAt);
const view = readRemoteTaskRun({ status: 'blocked_pending_governance', workspace_id: 'ws-1' });
assert.equal(isBlockedGovernanceStatus(view.status), true);
assert.equal(view.workspaceId, 'ws-1');
const legacy = readRemoteTaskRun({ Status: 'blocked_pending_governance', WorkspaceID: 'ws-legacy' });
assert.equal(isBlockedGovernanceStatus(legacy.status), true);
assert.equal(legacy.workspaceId, 'ws-legacy');
});
test('unknown 4xx business code is not reported as a network error', () => {
const info = buildNetworkErrorInfo(409, { errorCode: 'DOC_TASK_DISABLED', detail: '该文档任务未启用' });
assert.equal(info.code, 'DOC_TASK_DISABLED');
assert.equal(info.class, 'fatal');
assert.equal(info.userMessage.includes('网络'), false);
const unknown = buildNetworkErrorInfo(422, { errorCode: 'SOME_UNKNOWN', detail: '字段不合法' });
assert.equal(unknown.code, 'SOME_UNKNOWN');
assert.equal(unknown.userMessage, '字段不合法');
assert.equal(unknown.userMessage.includes('请检查网络'), false);
const bare = buildNetworkErrorInfo(400, { errorCode: 'TASK_SCOPE_UNRESOLVED' });
assert.equal(bare.code, 'TASK_SCOPE_UNRESOLVED');
assert.match(bare.userMessage, /请求失败/);
assert.equal(bare.userMessage.includes('请检查网络'), false);
});
+30 -80
View File
@@ -1,7 +1,6 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import {
autoRetryDelayMs,
fallbackErrorClass,
shouldAutoRetryChat,
shouldAutoRetryTask,
} from '../api/chatRetry';
@@ -25,6 +24,12 @@ import {
pollUntilPostTaskServerDoc,
shouldSwitchTemplateSlotToServer,
} from '../lib/postTaskDeliverableSync';
import {
buildCancelledTaskErrorInfo,
buildFailedTaskErrorInfo,
buildNetworkErrorInfo,
readRemoteTaskRun,
} from '../lib/taskRunErrorInfo';
import {
isRemoteTaskTerminal,
nextAttemptOpts,
@@ -105,68 +110,6 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
applyControlledInvalidationPlan,
} = input;
const buildNetworkErrorInfo = (
httpStatus?: number,
opts?: { errorCode?: string; detail?: string },
): ChatErrorInfo => {
const code = opts?.errorCode || '';
const detail = opts?.detail || '';
if (code === 'DOC_BUSY' || code === 'document_busy') {
return {
userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务',
code: 'DOC_BUSY',
class: 'fatal',
actions: [],
};
}
if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') {
return {
userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试',
code,
class: 'transient',
actions: ['retry'],
};
}
if (code === 'DOC_DUTY_FORBIDDEN') {
return {
userMessage: detail || '当前账号不是该文档任务的职责人,无法执行',
code,
class: 'fatal',
actions: ['retry'],
};
}
if (httpStatus === 401) {
return {
userMessage: '登录已失效,请重新登录后再试',
code: 'ERR_UNAUTHENTICATED',
class: 'fatal',
actions: ['retry'],
};
}
if (httpStatus === 403) {
if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') {
return {
userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份',
code: code || 'ERR_FORBIDDEN',
class: 'fatal',
actions: ['retry'],
};
}
return {
userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段',
code: code || 'ERR_FORBIDDEN',
class: 'fatal',
actions: ['retry'],
};
}
return {
userMessage: '网络连接异常,请检查网络后重试',
code: 'NETWORK_ERROR',
class: fallbackErrorClass({ network: true }),
actions: ['retry'],
};
};
// 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled。
// 闲聊路径在无 client_turn_id 幂等前禁止自动重放 POST;失败仅展示手动「重试」。
type StreamAttemptOpts = {
@@ -567,8 +510,9 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
let preferredDocId: string | null = null;
if (finishedRunId) {
const remote = await getTaskRun(finishedRunId).catch(() => null);
if (remote?.status) {
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant');
const view = readRemoteTaskRun(remote);
if (view.status) {
const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant');
if (mapped) nextStatus = mapped;
}
}
@@ -605,20 +549,21 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
});
if (attemptIndex > 0 && existingRunId) {
const remote = await getTaskRun(existingRunId).catch(() => null);
if (remote && shouldSubscribeExistingRun(remote.status)) {
const view = readRemoteTaskRun(remote);
if (remote && shouldSubscribeExistingRun(view.status)) {
retainedRunId = existingRunId;
taskRunStream.setRunId(sessionId, existingRunId);
patchAsst({ taskRunId: existingRunId, livePhaseLabel: '正在重连任务…' });
await subscribeTaskRunEvents(existingRunId, { onEvent }, controller.signal);
return;
}
if (remote && isRemoteTaskTerminal(remote.status)) {
if (remote && isRemoteTaskTerminal(view.status)) {
settled = true;
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'failed';
const mapped = mapTaskStatusToMessageStatus(view.status, 'assistant') || 'failed';
patchAsst({
status: mapped,
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskWorkspaceId: view.workspaceId,
taskRunId: existingRunId,
});
taskRunStream.setThinking(sessionId, false);
@@ -671,47 +616,48 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
if (runId) {
try {
const remote = await getTaskRun(runId);
if (remote.status === 'quality_failed') {
const view = readRemoteTaskRun(remote);
if (view.status === 'quality_failed') {
settled = true;
patchAsst({
status: 'quality_failed',
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskWorkspaceId: view.workspaceId,
taskRunId: runId,
});
taskRunStream.setThinking(sessionId, false);
return;
}
if (isBlockedGovernanceStatus(remote.status)) {
if (isBlockedGovernanceStatus(view.status)) {
settled = true;
patchAsst({
status: 'blocked_pending_governance',
content: GOVERNANCE_BLOCKED_HINT,
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskWorkspaceId: view.workspaceId,
taskRunId: runId,
});
taskRunStream.setThinking(sessionId, false);
return;
}
if (remote.status === 'completed') {
if (view.status === 'completed') {
settled = true;
patchAsst({
status: 'completed',
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskWorkspaceId: view.workspaceId,
taskRunId: runId,
});
taskRunStream.setThinking(sessionId, false);
await syncControlledDocsAfterTask(task.taskKey || 'intake');
return;
}
if (remote.status === 'pending_author_confirmation') {
if (view.status === 'pending_author_confirmation') {
settled = true;
patchAsst({
status: 'pending_author_confirmation',
livePhaseLabel: undefined,
taskWorkspaceId: remote.workspace_id,
taskWorkspaceId: view.workspaceId,
taskRunId: runId,
});
taskRunStream.setThinking(sessionId, false);
@@ -719,7 +665,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
await chatWindow.refresh().catch(() => undefined);
return;
}
if (shouldSubscribeExistingRun(remote.status)) {
if (shouldSubscribeExistingRun(view.status)) {
// 原 run 仍在跑:重订阅,不 POST 新 run
settled = false;
retainedRunId = runId;
@@ -727,8 +673,12 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
await subscribeTaskRunEvents(runId, { onEvent }, controller.signal);
return;
}
if (remote.status === 'failed' || remote.status === 'cancelled') {
failOrRetry(buildNetworkErrorInfo());
if (view.status === 'failed') {
failOrRetry(buildFailedTaskErrorInfo(view.errorMessage));
return;
}
if (view.status === 'cancelled') {
failOrRetry(buildCancelledTaskErrorInfo());
return;
}
} catch {
+138
View File
@@ -0,0 +1,138 @@
import { fallbackErrorClass } from '../api/chatRetry';
import type { ChatErrorInfo } from '../types/workbench';
/** GET /api/v1/tasks/runs/{id} 的最小视图。兼容修复前的 PascalCase 响应。 */
export type RemoteTaskRunView = {
status?: string;
Status?: string;
error_message?: string;
ErrorMessage?: string;
workspace_id?: string;
WorkspaceID?: string;
};
export function readRemoteTaskRun(remote: RemoteTaskRunView | null | undefined): {
status: string;
errorMessage: string;
workspaceId: string;
} {
if (!remote) {
return { status: '', errorMessage: '', workspaceId: '' };
}
return {
status: remote.status || remote.Status || '',
errorMessage: (remote.error_message || remote.ErrorMessage || '').trim(),
workspaceId: remote.workspace_id || remote.WorkspaceID || '',
};
}
const fatalDenialCopy: Record<string, string> = {
DOC_TASK_DISABLED: '该文档任务未启用,当前请求未执行',
WORKFLOW_NOT_ACTIVATED: '当前项目尚未激活工作流,文档任务不会启动',
WORKFLOW_INSTANCE_CLOSED: '工作流实例已关闭,无法执行该文档任务',
DOC_DUTY_NOT_MANAGED: '该文档未配置职责,无法执行',
SESSION_FORBIDDEN: '无权访问该会话',
};
export function buildNetworkErrorInfo(
httpStatus?: number,
opts?: { errorCode?: string; detail?: string },
): ChatErrorInfo {
const code = opts?.errorCode || '';
const detail = opts?.detail || '';
if (code === 'DOC_BUSY' || code === 'document_busy') {
return {
userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务',
code: 'DOC_BUSY',
class: 'fatal',
actions: [],
};
}
if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') {
return {
userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试',
code,
class: 'transient',
actions: ['retry'],
};
}
if (code === 'DOC_DUTY_FORBIDDEN') {
return {
userMessage: detail || '当前账号不是该文档任务的职责人,无法执行',
code,
class: 'fatal',
actions: ['retry'],
};
}
if (fatalDenialCopy[code]) {
const retryable = code === 'WORKFLOW_NOT_ACTIVATED' || code === 'DOC_DUTY_NOT_MANAGED';
return {
userMessage: detail || fatalDenialCopy[code],
code,
class: 'fatal',
actions: retryable ? ['retry'] : [],
};
}
if (httpStatus === 401) {
return {
userMessage: '登录已失效,请重新登录后再试',
code: 'ERR_UNAUTHENTICATED',
class: 'fatal',
actions: ['retry'],
};
}
if (httpStatus === 403) {
if (code === 'ERR_FORBIDDEN' || code === 'AUTHZ') {
return {
userMessage: detail || '无权访问该项目,请确认登录账号与项目成员身份',
code: code || 'ERR_FORBIDDEN',
class: 'fatal',
actions: ['retry'],
};
}
return {
userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段',
code: code || 'ERR_FORBIDDEN',
class: 'fatal',
actions: ['retry'],
};
}
if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
const shown = code || `HTTP_${httpStatus}`;
return {
userMessage: detail || `请求失败(${shown}),请重试或联系管理员`,
code: shown,
class: 'fatal',
actions: ['retry'],
};
}
return {
userMessage: '网络连接异常,请检查网络后重试',
code: 'NETWORK_ERROR',
class: fallbackErrorClass({ network: true }),
actions: ['retry'],
};
}
/** 服务端已判定 failed:有 error_message 时展示真实原因,否则才回落到网络错误。 */
export function buildFailedTaskErrorInfo(errorMessage: string): ChatErrorInfo {
const message = errorMessage.trim();
if (!message) {
return buildNetworkErrorInfo();
}
return {
userMessage: message,
code: 'TASK_FAILED',
class: 'fatal',
actions: ['retry', 'restart'],
};
}
export function buildCancelledTaskErrorInfo(): ChatErrorInfo {
return {
userMessage: '任务已取消',
code: 'TASK_CANCELLED',
class: 'cancelled',
actions: ['restart'],
};
}