From 1e7cb67ab4d6e38afc342595ef97d4776b255226 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Wed, 9 Sep 2026 18:10:12 +0800 Subject: [PATCH] docs(spec): archive chat wait-error-retry UX spec and implementation plan --- .../2026-09-09-chat-wait-error-retry-ux.md | 750 ++++++++++++++++++ ...6-09-09-chat-wait-error-retry-ux-design.md | 235 ++++++ 2 files changed, 985 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-09-chat-wait-error-retry-ux.md create mode 100644 docs/superpowers/specs/2026-09-09-chat-wait-error-retry-ux-design.md diff --git a/docs/superpowers/plans/2026-09-09-chat-wait-error-retry-ux.md b/docs/superpowers/plans/2026-09-09-chat-wait-error-retry-ux.md new file mode 100644 index 00000000..a1b4605c --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-chat-wait-error-retry-ux.md @@ -0,0 +1,750 @@ +# Chat Wait / Error / Retry UX Implementation Plan (P1) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire the Web workbench to existing chat SSE so users see live wait-state phases within 1s, get visible structured errors with copyable diagnostics, and recover via limited auto-retry (transient) plus manual retry — without silent template fallbacks. + +**Architecture:** Extend `internal/stream` with `ErrorInfo` on `event: error`; emit richer phases on the casual-chat path; add `web/src/api/chatStream.ts` to consume `/api/v1/chat/stream`; drive assistant bubble state machine in `App.tsx` + `MessageBubble`. P1 keeps whole-reply `delta` (no token streaming). P2/P3 (long-task continue/restart, true token stream) are out of this plan. + +**Tech Stack:** Go 1.22+ (`net/http` SSE), React 19 + TypeScript + Vite, `node:test` / vitest for web unit tests, real `pi-agent` + MiniMax for E2E acceptance. + +**Spec:** `docs/superpowers/specs/2026-09-09-chat-wait-error-retry-ux-design.md` + +## Global Constraints + +- Do not silently replace failures with success template replies in Web. +- Auto-retry only when `error_info.class === "transient"`; max 2 auto-retries (3 attempts total); backoff 1s then 2s. +- Frontend renders only `error_info.actions` when present; fallback map only if `class` missing. +- P1 does not implement token-level streaming or full long-task continue/restart UI. +- Real-Agent E2E evidence must go to `.doc/test-reports/YYYY-MM-DD-...-test-report.md` (no stub-only acceptance). +- Progressive commits: `feat(stream)`, `feat(agent)`, `feat(api)`, `feat(web)`, `test(...)`, `docs(test)`. + +--- + +## File Structure + +| File | Responsibility | +| :--- | :--- | +| `internal/stream/types.go` | Add `ErrorInfo`, `ErrorClass`, wire `StreamEvent.ErrorInfo` | +| `internal/stream/stream.go` | `EmitErrorInfo`; keep `EmitError` as thin wrapper | +| `internal/stream/classify.go` | Map Go errors → `ErrorInfo` (class/code/actions) | +| `internal/stream/stream_test.go` / `classify_test.go` | Unit tests | +| `internal/chat/session.go` | Emit `dialogue_generating`; use classified errors | +| `internal/coordinator/dialogue_llm.go` | Emit phase around pi-agent call via streamer context | +| `internal/server/sse.go` | `X-Request-Id`; classified error on handler failure | +| `web/src/types/workbench.ts` | Message status + `ErrorInfo` types | +| `web/src/api/chatStream.ts` | SSE client + abort + parse | +| `web/src/api/chatStream.test.ts` | Parser / retry helper tests | +| `web/src/api/chatRetry.ts` | Auto-retry policy helpers | +| `web/src/components/chat/MessageBubble.tsx` | Wait / error / actions UI | +| `web/src/App.tsx` | Replace blocking chat fetch with stream + state machine | +| `web/src/components/chat/InputArea.tsx` | Wire stop → abort | +| `.doc/test-reports/2026-09-09-chat-wait-error-retry-ux-test-report.md` | Real E2E report | + +--- + +### Task 1: Structured `ErrorInfo` on stream events + +**Files:** +- Modify: `internal/stream/types.go` +- Modify: `internal/stream/stream.go` +- Create: `internal/stream/classify.go` +- Modify: `internal/stream/stream_test.go` +- Create: `internal/stream/classify_test.go` + +**Interfaces:** +- Consumes: existing `Streamer.Emit`, `EmitError` +- Produces: `ErrorClass`, `ErrorInfo`, `EmitErrorInfo(info ErrorInfo)`, `ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string) ErrorInfo` + +- [ ] **Step 1: Write failing classify tests** + +Create `internal/stream/classify_test.go`: + +```go +package stream + +import ( + "errors" + "net" + "testing" +) + +func TestClassifyError_TimeoutIsTransient(t *testing.T) { + info := ClassifyError(errors.New("context deadline exceeded"), "dialogue_generating", "req_1", "sess_1", "") + if info.Class != ErrorClassTransient { + t.Fatalf("class=%s", info.Class) + } + if info.Code != "CHAT_TIMEOUT" && info.Code != "TRANSIENT_ERROR" { + // Accept either stable code chosen in implementation; prefer CHAT_TIMEOUT when message contains timeout/deadline + t.Fatalf("code=%s", info.Code) + } + if len(info.Actions) != 1 || info.Actions[0] != "retry" { + t.Fatalf("actions=%v", info.Actions) + } + if info.RequestID != "req_1" || info.PhaseID != "dialogue_generating" { + t.Fatalf("meta=%+v", info) + } +} + +func TestClassifyError_GenericIsFatal(t *testing.T) { + info := ClassifyError(errors.New("invalid project config"), "intent_recognizing", "req_2", "sess_1", "") + if info.Class != ErrorClassFatal { + t.Fatalf("class=%s", info.Class) + } + hasRestart := false + for _, a := range info.Actions { + if a == "restart" { + hasRestart = true + } + } + if !hasRestart { + t.Fatalf("expected restart action, got %v", info.Actions) + } +} + +func TestClassifyError_WorkspaceHintIsResumable(t *testing.T) { + info := ClassifyError(errors.New("step failed mid-run"), "step_executing", "req_3", "sess_1", "research_123") + if info.Class != ErrorClassResumable { + t.Fatalf("class=%s", info.Class) + } + hasContinue := false + for _, a := range info.Actions { + if a == "continue" { + hasContinue = true + } + } + if !hasContinue { + t.Fatalf("expected continue, got %v", info.Actions) + } +} + +func TestClassifyError_NetworkIsTransient(t *testing.T) { + info := ClassifyError(&net.OpError{Op: "dial", Err: errors.New("connection refused")}, "dialogue_generating", "req_4", "sess_1", "") + if info.Class != ErrorClassTransient { + t.Fatalf("class=%s", info.Class) + } +} +``` + +- [ ] **Step 2: Run tests — expect FAIL** + +Run: `go test ./internal/stream/ -run ClassifyError -v` +Expected: FAIL (undefined `ClassifyError` / `ErrorClass`) + +- [ ] **Step 3: Add types + ClassifyError + EmitErrorInfo** + +In `internal/stream/types.go` add: + +```go +type ErrorClass string + +const ( + ErrorClassTransient ErrorClass = "transient" + ErrorClassResumable ErrorClass = "resumable" + ErrorClassFatal ErrorClass = "fatal" + ErrorClassCancelled ErrorClass = "cancelled" +) + +type ErrorInfo struct { + UserMessage string `json:"user_message"` + Code string `json:"code"` + Class ErrorClass `json:"class"` + PhaseID string `json:"phase_id,omitempty"` + Upstream string `json:"upstream,omitempty"` + RequestID string `json:"request_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + Actions []string `json:"actions"` +} +``` + +On `StreamEvent` add: + +```go +ErrorInfo *ErrorInfo `json:"error_info,omitempty"` +``` + +Keep existing `Error string` field. + +In `internal/stream/classify.go` implement heuristics: + +- message contains `timeout` / `deadline` / `timed out` → `CHAT_TIMEOUT`, `transient`, actions `["retry"]` +- `net.Error` temporary / dial / connection refused / EOF / broken pipe → `NETWORK_ERROR`, `transient`, `["retry"]` +- non-empty `workspaceID` → `TASK_INTERRUPTED`, `resumable`, `["continue","restart"]` +- else → `INTERNAL_ERROR`, `fatal`, `["restart"]` +- `UserMessage`: short Chinese copy for each class (e.g. 「生成回复超时,请稍后重试」) +- Always set `PhaseID`, `RequestID`, `SessionID`, `WorkspaceID` from args +- `Upstream`: first 80 runes of `err.Error()` (compact) + +In `stream.go`: + +```go +func (s *Streamer) EmitErrorInfo(info ErrorInfo) { + if s == nil { + return + } + cp := info + s.Emit(StreamEvent{ + Type: EventError, + Error: info.UserMessage, + ErrorInfo: &cp, + }) +} + +func (s *Streamer) EmitError(err error) { + if s == nil || err == nil { + return + } + s.EmitErrorInfo(ClassifyError(err, "", "", "", "")) +} +``` + +Update `TestStreamer_BasicEvents` if needed so `EmitError` still yields `EventError` (count stays 9). + +- [ ] **Step 4: Run tests — expect PASS** + +Run: `go test ./internal/stream/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/stream/ +git commit -m "$(cat <<'EOF' +feat(stream): add structured ErrorInfo and error classification + +EOF +)" +``` + +--- + +### Task 2: Emit dialogue phases + classified errors on chat/SSE path + +**Files:** +- Modify: `internal/chat/session.go` +- Modify: `internal/coordinator/dialogue_llm.go` +- Modify: `internal/server/sse.go` +- Test: extend or add `internal/server/server_test.go` / session stream test if present; otherwise add focused test in `internal/stream` already covered + manual SSE smoke in Task 6 + +**Interfaces:** +- Consumes: `stream.ClassifyError`, `EmitErrorInfo`, `EmitPhase`, `FromContext` +- Produces: SSE responses with `X-Request-Id`; phases `intent_recognizing`, `dialogue_generating`; errors carrying `error_info` + +- [ ] **Step 1: Write failing test for request id header helper (optional small unit)** + +If `server_test.go` already hits stream, add assertion that marshaled error includes `error_info` when using `EmitErrorInfo`. Prefer testing classify+emit in stream package (already done). For SSE, add in `internal/server/sse_error_test.go`: + +```go +package server + +import ( + "encoding/json" + "testing" + + "chat-agent-system/internal/stream" +) + +func TestErrorEventJSONIncludesErrorInfo(t *testing.T) { + info := stream.ClassifyError(errTimeout(), "dialogue_generating", "req_x", "sess_y", "") + ev := stream.StreamEvent{Type: stream.EventError, Error: info.UserMessage, ErrorInfo: &info} + b, err := json.Marshal(ev) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + _ = json.Unmarshal(b, &raw) + ei, ok := raw["error_info"].(map[string]any) + if !ok { + t.Fatalf("missing error_info in %s", string(b)) + } + if ei["request_id"] != "req_x" { + t.Fatalf("request_id=%v", ei["request_id"]) + } +} + +func errTimeout() error { + return errString("context deadline exceeded") +} + +type errString string + +func (e errString) Error() string { return string(e) } +``` + +Place this file under `internal/server/` only if it does not pull heavy server deps; otherwise keep JSON round-trip test in `internal/stream/error_json_test.go`. Prefer **`internal/stream/error_json_test.go`**. + +- [ ] **Step 2: Run — FAIL only if test added before types exist; else proceed to implement** + +- [ ] **Step 3: Instrument session + dialogue + SSE** + +In `session.go` `sendMessageInternal`: + +1. Before `Recognizer.Recognize`, keep existing `intent_recognizing` phase. +2. Replace `streamer.EmitError(err)` with: + +```go +streamer.EmitErrorInfo(stream.ClassifyError(err, "intent_recognizing", stream.RequestIDFromContext(ctx), s.ID, "")) +``` + +(If RequestID helper not yet on context, pass `""` for now and set in SSE only on handler-level errors; session can read from context key added in Step 3b.) + +3. Before `Coordinator.ProcessIntent`, emit: + +```go +streamer.EmitPhase("dialogue_generating", "正在整理回复...", "assistant", 0, 0, 40) +``` + +4. On coordination error, classify with `phase_id: "dialogue_generating"` (or `coordination` if prefer). + +In `dialogue_llm.go` at start of pi-agent call: + +```go +if st := stream.FromContext(ctx); st != nil { + st.EmitPhase("dialogue_generating", "正在生成项目助手回复...", "assistant", 0, 0, 60) +} +``` + +In `sse.go` `handleChatStream`: + +```go +requestID := fmt.Sprintf("req_%d", time.Now().UnixNano()) +w.Header().Set("X-Request-Id", requestID) +// set SSE headers AFTER or include request id with other headers before first Flush +ctx := stream.WithRequestID(r.Context(), requestID) +``` + +Add to `internal/stream/context.go` (or `stream.go`): + +```go +type requestIDKey struct{} + +func WithRequestID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, requestIDKey{}, id) +} + +func RequestIDFromContext(ctx context.Context) string { + if v, ok := ctx.Value(requestIDKey{}).(string); ok { + return v + } + return "" +} +``` + +On handler `err != nil` after `SendMessageStream`, build: + +```go +info := stream.ClassifyError(err, "chat_stream", requestID, sess.ID, "") +errEv := stream.StreamEvent{Type: stream.EventError, Error: info.UserMessage, ErrorInfo: &info, Timestamp: time.Now()} +``` + +Ensure `SendMessageStream` is called with `ctx` that carries request ID (wrap `r.Context()`). + +- [ ] **Step 4: Run Go tests** + +Run: `go test ./internal/stream/ ./internal/chat/ ./internal/server/ -count=1` +Expected: PASS (skip or fix any tests broken by EmitError signature — signature unchanged) + +- [ ] **Step 5: Commit** + +```bash +git add internal/stream/ internal/chat/session.go internal/coordinator/dialogue_llm.go internal/server/sse.go +git commit -m "$(cat <<'EOF' +feat(agent): emit dialogue phases and classified SSE errors with request id + +EOF +)" +``` + +--- + +### Task 3: Web SSE client + retry helpers + +**Files:** +- Create: `web/src/api/chatStream.ts` +- Create: `web/src/api/chatStream.test.ts` +- Create: `web/src/api/chatRetry.ts` +- Create: `web/src/api/chatRetry.test.ts` +- Modify: `web/src/types/workbench.ts` + +**Interfaces:** +- Consumes: `POST /api/v1/chat/stream` SSE frames +- Produces: + - `parseSSEChunk(buffer: string): { events: StreamEvent[]; rest: string }` + - `streamChat(params, handlers, signal): Promise` + - `shouldAutoRetry(info, attemptIndex): boolean` // attemptIndex 0 = first failure + - `autoRetryDelayMs(attemptIndex): number` // 1000, 2000 + - Types: `AssistantMessageStatus`, `ChatErrorInfo` + +- [ ] **Step 1: Write failing tests** + +`web/src/api/chatStream.test.ts`: + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSSEChunk } from './chatStream'; + +test('parseSSEChunk: parses event and data lines', () => { + const raw = + 'event: phase\ndata: {"type":"phase","phase":{"phase_id":"intent_recognizing","message":"正在研判您的意图与项目上下文..."}}\n\n'; + const { events, rest } = parseSSEChunk(raw); + assert.equal(rest, ''); + assert.equal(events.length, 1); + assert.equal(events[0].type, 'phase'); + assert.equal(events[0].phase?.phase_id, 'intent_recognizing'); +}); + +test('parseSSEChunk: keeps incomplete frame in rest', () => { + const { events, rest } = parseSSEChunk('event: delta\ndata: {"type":"delta"'); + assert.equal(events.length, 0); + assert.ok(rest.includes('event: delta')); +}); +``` + +`web/src/api/chatRetry.test.ts`: + +```ts +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { shouldAutoRetry, autoRetryDelayMs, fallbackErrorClass } from './chatRetry'; + +test('shouldAutoRetry: transient only, max 2', () => { + assert.equal(shouldAutoRetry({ class: 'transient' }, 0), true); + assert.equal(shouldAutoRetry({ class: 'transient' }, 1), true); + assert.equal(shouldAutoRetry({ class: 'transient' }, 2), false); + assert.equal(shouldAutoRetry({ class: 'fatal' }, 0), false); + assert.equal(shouldAutoRetry({ class: 'resumable' }, 0), false); +}); + +test('autoRetryDelayMs: 1s then 2s', () => { + assert.equal(autoRetryDelayMs(0), 1000); + assert.equal(autoRetryDelayMs(1), 2000); +}); + +test('fallbackErrorClass: network → transient, workspace → resumable', () => { + assert.equal(fallbackErrorClass({ network: true }), 'transient'); + assert.equal(fallbackErrorClass({ workspaceId: 'research_1' }), 'resumable'); + assert.equal(fallbackErrorClass({}), 'fatal'); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +Run: `cd web && npx tsx --test src/api/chatStream.test.ts src/api/chatRetry.test.ts` +Expected: FAIL (module not found) + +- [ ] **Step 3: Implement types + modules** + +Extend `ChatMessage` in `workbench.ts`: + +```ts +export type AssistantMessageStatus = + | 'pending' + | 'streaming_status' + | 'auto_retrying' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface ChatErrorInfo { + userMessage: string; + code: string; + class: 'transient' | 'resumable' | 'fatal' | 'cancelled'; + phaseId?: string; + upstream?: string; + requestId?: string; + sessionId?: string; + workspaceId?: string; + actions: Array<'retry' | 'continue' | 'restart'>; +} + +// on ChatMessage: +status?: AssistantMessageStatus; +livePhaseLabel?: string; +errorInfo?: ChatErrorInfo; +requestId?: string; +sourceUserMessageId?: string; +``` + +Implement `chatRetry.ts` exactly per tests. + +Implement `chatStream.ts`: + +```ts +export type StreamEvent = { + id?: string; + type: string; + phase?: { phase_id?: string; message?: string; step_index?: number; total_steps?: number; progress?: number }; + status?: { message?: string; intent?: string }; + delta?: string; + error?: string; + error_info?: { + user_message?: string; + code?: string; + class?: string; + phase_id?: string; + upstream?: string; + request_id?: string; + session_id?: string; + workspace_id?: string; + actions?: string[]; + }; + done?: { session_id?: string; duration_ms?: number }; +}; + +export function parseSSEChunk(buffer: string): { events: StreamEvent[]; rest: string } { /* split on \n\n, parse event:/data: */ } + +export async function streamChat( + body: Record, + handlers: { + onEvent: (ev: StreamEvent) => void; + onRequestId?: (id: string) => void; + }, + signal?: AbortSignal, +): Promise { + const resp = await fetch('/api/v1/chat/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, + body: JSON.stringify(body), + signal, + }); + const rid = resp.headers.get('X-Request-Id') || resp.headers.get('x-request-id'); + if (rid) handlers.onRequestId?.(rid); + if (!resp.ok || !resp.body) { + throw Object.assign(new Error(`HTTP ${resp.status}`), { httpStatus: resp.status, requestId: rid }); + } + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const parsed = parseSSEChunk(buf); + buf = parsed.rest; + for (const ev of parsed.events) handlers.onEvent(ev); + } +} + +export function mapErrorInfo(ev: StreamEvent): ChatErrorInfo { /* map snake → camel; default actions from class */ } +``` + +- [ ] **Step 4: Run tests — PASS** + +Run: `cd web && npx tsx --test src/api/chatStream.test.ts src/api/chatRetry.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add web/src/types/workbench.ts web/src/api/chatStream.ts web/src/api/chatStream.test.ts web/src/api/chatRetry.ts web/src/api/chatRetry.test.ts +git commit -m "$(cat <<'EOF' +feat(web): add chat SSE client and transient auto-retry helpers + +EOF +)" +``` + +--- + +### Task 4: MessageBubble wait / error / actions UI + +**Files:** +- Modify: `web/src/components/chat/MessageBubble.tsx` +- Modify: `web/src/components/chat/InputArea.tsx` (ensure stop callback props already exist — wire later in App) +- Optional: `web/src/components/chat/MessageBubble.test.ts` if cheap; otherwise visual verify in Task 5 + +**Interfaces:** +- Consumes: `ChatMessage.status`, `livePhaseLabel`, `errorInfo` +- Produces: `onErrorAction?: (messageId, action) => void` + +- [ ] **Step 1: Extend MessageBubble props and render branches** + +When `!isUser && (status === 'pending' || status === 'streaming_status' || status === 'auto_retrying')`: + +- Show spinner (reuse lucide `Loader2` with CSS spin) + `livePhaseLabel || '助手正在处理…'` +- Do not render empty markdown as success + +When `status === 'failed' && errorInfo`: + +- Border/background using danger tokens (`var(--danger)` or existing error color) +- Title: `errorInfo.userMessage` +- One-line diag: `` `${code} · ${phaseId} · ${upstream} · ${requestId}` `` (omit empty parts) +- Button「复制诊断信息」→ `navigator.clipboard.writeText(JSON.stringify(errorInfo))` +- For each action in `errorInfo.actions`: buttons 重试 / 继续任务 / 重新开始 calling `onErrorAction(message.id, action)` + +When `status === 'cancelled'`: muted「已停止生成」+ optional 重新发送 via `onErrorAction(..., 'retry')` + +When `completed` or legacy messages without status: keep current content rendering. + +- [ ] **Step 2: Typecheck** + +Run: `cd web && npx tsc --noEmit` +Expected: PASS (or only pre-existing errors unrelated — fix any new ones) + +- [ ] **Step 3: Commit** + +```bash +git add web/src/components/chat/MessageBubble.tsx +git commit -m "$(cat <<'EOF' +feat(web): render chat wait-state and structured error actions in bubbles + +EOF +)" +``` + +--- + +### Task 5: Wire App.tsx stream state machine (remove silent fallback) + +**Files:** +- Modify: `web/src/App.tsx` +- Modify: `web/src/components/chat/InputArea.tsx` if stop props need wiring from App +- Modify: `web/src/components/chat/MessageStream.tsx` only if need to pass `onErrorAction` + +**Interfaces:** +- Consumes: `streamChat`, `shouldAutoRetry`, `autoRetryDelayMs`, `mapErrorInfo` +- Produces: end-to-end wait → complete / fail / auto-retry / stop + +- [ ] **Step 1: Refactor `handleSendMessage` backend branch** + +Replace the `setTimeout(..., 600)` + `fetch('/api/v1/chat')` block for the real-backend path with: + +1. Immediately append assistant placeholder: + +```ts +const asstId = 'ast-' + Date.now(); +const placeholder: ChatMessage = { + id: asstId, + sessionId: activeSessionId, + role: 'assistant', + authorName: '项目助手', + content: '', + timestamp: new Date().toTimeString().substring(0, 5), + status: 'pending', + livePhaseLabel: '助手正在处理…', + sourceUserMessageId: userMsg.id, +}; +setMessages(prev => [...prev, placeholder]); +setIsThinking(true); +``` + +2. Keep an `AbortController` in a ref (`streamAbortRef`). On new send or stop, abort previous. + +3. Define async `runStreamAttempt(text, asstId, attemptIndex)` calling `streamChat`: + +- `onEvent`: + - `phase`/`status` → update message `status: 'streaming_status'`, `livePhaseLabel: message` + - `delta` → append/set `content`, keep streaming until done + - `error` → capture `mapErrorInfo`; if `shouldAutoRetry(info, attemptIndex)` then set `auto_retrying`, delay, recurse `attemptIndex+1`; else set `failed` + `errorInfo` + - `done` → if not failed, set `status: 'completed'`, clear thinking +- On fetch throw (network): synthesize transient `errorInfo`, same auto-retry path +- On abort: set `cancelled`, clear thinking +- **Never** append the old template success string in `catch` + +4. Wire `InputArea` `isStreaming={true}` while status in pending/streaming/auto_retrying; `onStopStreaming` → `streamAbortRef.current?.abort()`. + +5. `onErrorAction`: +- `retry` → reset bubble to pending, `runStreamAttempt` with original user text from `sourceUserMessageId` +- `continue` / `restart` → for P1 show toast or set `livePhaseLabel`「长任务恢复将在下一期提供」**or** if easy, only enable these when actions present but still no-op with clear user_message — prefer disable buttons in P1 unless actions filtered: **P1 filter actions to `retry` only in UI** unless `continue`/`restart` handlers exist. Spec allows P2 for continue/restart — **filter displayed actions to `retry` in P1** even if server sends more. + +- [ ] **Step 2: Remove silent catch fallback** + +Delete the `.catch` block that inserts 「收到您的要求…右侧已实时同步…」 template. + +- [ ] **Step 3: Build** + +Run: `cd web && npm run build` +Expected: SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add web/src/App.tsx web/src/components/chat/InputArea.tsx web/src/components/chat/MessageStream.tsx web/src/components/chat/MessageBubble.tsx +git commit -m "$(cat <<'EOF' +feat(web): drive chat bubbles from SSE with auto-retry and stop + +EOF +)" +``` + +--- + +### Task 6: Real-Agent E2E + test report + +**Files:** +- Create: `.doc/test-reports/2026-09-09-chat-wait-error-retry-ux-test-report.md` +- Use: running `agent-dev` on `:8080`, real MiniMax pi-agent + +**Interfaces:** +- Consumes: P1 implementation +- Produces: archive report per test-reports-standard + +- [ ] **Step 1: Ensure dev stack up** + +Confirm compose `agent-dev` + pi-agent healthy; Web vite proxy to `:8080`. + +- [ ] **Step 2: Manual E2E — casual chat wait-state** + +Input: `请用两句话解释逆压电效应。` +Record: time-to-first phase UI (<1s), final reply, whether fake template absent. +Capture console/network SSE event types (`phase`, `delta`, `done`). + +- [ ] **Step 3: Manual E2E — error + retry path** + +Simulate: stop pi-agent or use invalid backend briefly, or DevTools Offline after send. +Expect: auto-retry label, then failed bubble with diag line + copy; manual 重试 recovers when service back. + +- [ ] **Step 4: Write report** + +Follow fixed sections 一–七 in `.cursor/rules/test-reports-standard.mdc`. Include real console excerpts. Mark PASS/FAIL per case. Note P2/P3 deferred. + +- [ ] **Step 5: Regression** + +Run: `go test ./...` and `cd web && npm test` +Expected: PASS (or document known unrelated failures) + +- [ ] **Step 6: Commit** + +```bash +git add .doc/test-reports/2026-09-09-chat-wait-error-retry-ux-test-report.md +git commit -m "$(cat <<'EOF' +docs(test): archive chat wait-state error-retry UX real-agent report + +EOF +)" +``` + +--- + +## Out of scope (follow-up plans) + +- **P2:** Live `PhaseEventStrip` for long tasks; `continue` / `restart` → `ResumeOrModifyWorkspace` / task stream. +- **P3:** Token-level `EmitDelta` from pi-agent runner through Go to Web typewriter UX. + +--- + +## Spec coverage self-check + +| Spec requirement | Task | +| :--- | :--- | +| Web uses chat SSE | Task 5 | +| Wait-state phase/status ≤1s | Task 2 + 5 | +| Whole delta (no typewriter) | Task 5 | +| Structured error_info + diag line + copy | Task 1 + 4 | +| Class → actions; P1 manual retry | Task 1 + 5 | +| Transient auto-retry ≤2, 1s/2s | Task 3 + 5 | +| Stop aborts auto-retry | Task 5 | +| No silent success template | Task 5 | +| request_id | Task 2 | +| dialogue phases | Task 2 | +| Real Agent report | Task 6 | +| P2/P3 | Explicitly out of scope | + +## Placeholder / consistency check + +- Action names unified: `retry` | `continue` | `restart`. +- Class names unified: `transient` | `resumable` | `fatal` | `cancelled`. +- Phase ids: `intent_recognizing`, `dialogue_generating`. +- No TBD steps remaining. diff --git a/docs/superpowers/specs/2026-09-09-chat-wait-error-retry-ux-design.md b/docs/superpowers/specs/2026-09-09-chat-wait-error-retry-ux-design.md new file mode 100644 index 00000000..f256eccc --- /dev/null +++ b/docs/superpowers/specs/2026-09-09-chat-wait-error-retry-ux-design.md @@ -0,0 +1,235 @@ +# Web 对话等待态、错误提示与分型重试 UX 设计 + +## 一、背景与业务痛点 + +用户在 Web 工作台发送闲聊/知识问答(例如「请解释逆压电效应,以及双频吸引刀头变幅杆如何做阻抗匹配」)后,常出现 **10–15 秒无反馈**,随后整段回复突然出现。长任务(小队研判、文档生成)同样缺少可见进度。 + +现状拆解: + +1. **后端 SSE 已就绪**:`/api/v1/chat/stream`、`/api/v1/tasks/run/stream` 与 `phase` / `status` / `delta` / `error` / `done` 事件模型已在 Go 与 CLI 侧落地(见 `2026-09-08-streaming-events-and-digestible-ux-design.md`)。 +2. **Web 仍走阻塞路径**:`App.tsx` 使用 `POST /api/v1/chat`;`isThinking` 含人为 600ms 延迟;`PhaseEventStrip` / 停止按钮多为 mock 或未接线。 +3. **错误被吞掉**:`.catch()` 静默回落为模板成功回复,用户无法感知失败,工程师缺少可反馈的诊断信息。 +4. **无重试契约**:无按失败类型的「重试 / 继续任务 / 重新开始」;亦无有限自动重试。 + +本设计是既有流式协议的 **Web 接线增量**,并补齐 **错误可见性、分型恢复动作、transient 有限自动重试**;不推翻三层信息架构(phase → summary/delta → detail)。 + +--- + +## 二、目标与原则 + +### 2.1 目标 + +1. **消除黑盒等待**:发送后 ≤1s 出现助手占位气泡与真实/兜底状态文案。 +2. **闲聊与长任务同一 SSE 契约**:统一消费事件,分阶段接线。 +3. **失败一眼可辨**:醒目错误气泡 + 简短白话 + 一行诊断 + 可复制诊断包。 +4. **分型恢复**:`transient` / `resumable` / `fatal` / `cancelled` 映射不同动作;`transient` 另做有限自动重试以尽量推进。 +5. **为真·逐字流式留接口**:P1 接受整段 `delta`;P3 再做 token 级吐出。 + +### 2.2 原则 + +- **进度求真**:优先真实 `phase`/`status`,禁止假进度轮播作为主真相。 +- **失败不伪装成功**:禁止再将错误静默写成模板助手回复。 +- **自动重试保守**:仅 `transient`;有副作用/可续跑路径标 `resumable`,交给用户「继续」。 +- **动作由服务端声明**:前端只渲染 `error_info.actions`,本地仅在缺省时做兜底映射。 +- **渐进交付**:C 目标(闲聊+长任务)分 P1→P2→P3 落地。 + +--- + +## 三、信息架构与用户心智 + +| 层 | 用户看到什么 | 第一期(P1)行为 | +| :--- | :--- | :--- | +| **等待态** | 气泡内脉冲/小图标 + 一行动态文案 | 消费 `phase` / `status`;无事件时兜底「助手正在处理…」 | +| **结果态** | 正文回复 | 整段 `delta`(或 `done` 前最终文本)一次写入;**不做打字机** | +| **失败态** | 同位置错误气泡 | 白话 + 一行诊断 + 复制诊断 + 分型按钮 | + +闲聊:等待态 → 整段正文。长任务:等待态 + `PhaseEventStrip` 随 `phase` 更新(P2 完整接线)。输入区既有「正在调取知识…」与脉冲按钮保留为辅状态,**主真相以消息气泡为准**。 + +P1 明确不做:真·逐字流式、默认展开思考链/工具日志、假进度文案轮播。 + +--- + +## 四、事件与错误 / 重试契约 + +### 4.1 传输 + +- Web 对话主路径:`POST /api/v1/chat/stream`(SSE)。 +- 长任务:既有 `/api/v1/tasks/run/stream`。 +- 事件类型集合与既有协议一致。 + +### 4.2 P1 Web 必消费事件 + +| 事件 | 用途 | +| :--- | :--- | +| `phase` / `status` | 更新等待态文案与(长任务)步骤条 | +| `delta` | 写入助手正文(兼容一次整段或多次追加) | +| `error` | 进入失败态 | +| `done` | 结束等待;成功定稿或失败收尾 | + +`summary` / `briefing` / `detail` / `deliverable` / `governance`:P1 可忽略或轻量挂载,不阻塞本需求。 + +### 4.3 结构化错误载荷 + +在现有 `StreamEvent.Error string` 上扩展,保持向后兼容: + +```json +{ + "type": "error", + "error": "生成回复超时,请稍后重试", + "error_info": { + "user_message": "生成回复超时,请稍后重试", + "code": "CHAT_TIMEOUT", + "class": "transient", + "phase_id": "dialogue_generating", + "upstream": "pi-agent:504", + "request_id": "req_01J...", + "session_id": "sess_...", + "workspace_id": "research_...", + "actions": ["retry"] + } +} +``` + +字段说明: + +| 字段 | 含义 | +| :--- | :--- | +| `user_message` | 面向用户的白话(与顶层 `error` 可同文) | +| `code` | 稳定错误码,便于日志检索与反馈 | +| `class` | `transient` \| `resumable` \| `fatal` \| `cancelled` | +| `phase_id` | 失败时所在阶段 | +| `upstream` | 一行上游状态(如 `pi-agent:504`、`http:503`) | +| `request_id` | 关联日志的请求 ID(优先服务端生成,响应头可回传 `X-Request-Id`) | +| `session_id` / `workspace_id` | 会话与可续跑工作区(可空) | +| `actions` | 允许的手动动作:`retry` \| `continue` \| `restart` | + +用户可见诊断一行(尽量短): + +`CHAT_TIMEOUT · dialogue_generating · pi-agent:504 · req_01J…` + +另提供「复制诊断信息」:复制含上述字段的紧凑 JSON,便于用户反馈工程师。 + +### 4.4 失败分类 → 手动动作 + +| class | 含义 | 主按钮 | 行为 | +| :--- | :--- | :--- | :--- | +| `transient` | 网络断开、超时、5xx、流中断 | **重试** | 同一用户原文再发 stream(新 `request_id`) | +| `resumable` | 长任务中途失败、工作区可续跑 | **继续任务** | 调 `ResumeOrModifyWorkspace` / 对应 task stream | +| `fatal` | 参数不可用、工作区损坏、结果无效 | **重新开始** | 确认后按原意图新 run;可选「编辑后重发」 | +| `cancelled` | 用户停止 | (可选)重新发送 | 标为已取消 | + +前端只渲染服务端 `actions`。若个别路径尚未填 `class`,兜底:超时/网络 → `transient`;带 `workspace_id` 的 task 失败 → `resumable`;其余 → `fatal`。 + +### 4.5 有限自动重试(仅 transient) + +| 规则 | 默认 | +| :--- | :--- | +| 触发条件 | 仅 `class=transient` | +| 次数 | 最多 **2 次自动重试**(合计最多 3 次尝试) | +| 间隔 | 指数退避 **1s → 2s** | +| 文案 | 「网络不稳,正在自动重试 (n/2)…」 | +| 用尽后 | 失败气泡 + 手动「重试」等 | +| 不自动重试 | `resumable`、`fatal`、`cancelled`、业务校验失败 | +| 幂等 | 每次自动重试新 `request_id`;有副作用且不可安全重入 → 标 `resumable`,禁止自动重试 | +| 取消 | 用户停止或发送新消息 → abort 当前流并取消后续自动重试 | + +禁止无限自动重试。 + +--- + +## 五、Web 组件与状态机 + +### 5.1 助手消息状态机 + +``` +pending → streaming_status → completed + ↘ auto_retrying ↻(仅 transient,≤2) + ↘ failed + ↘ cancelled +``` + +| 状态 | UI | +| :--- | :--- | +| `pending` / `streaming_status` | 占位气泡:脉冲 + 当前 phase 文案;长任务刷新 `PhaseEventStrip` | +| `auto_retrying` | 同上,自动重试文案 | +| `completed` | 渲染 markdown 正文,去掉等待动画 | +| `failed` | 错误样式 + 白话 + 诊断行 + 复制 + actions | +| `cancelled` | 「已停止」;可重新发送 | + +### 5.2 组件职责 + +| 组件 / 模块 | 职责 | +| :--- | :--- | +| `App.tsx` | 发送改 SSE;气泡状态机;自动/手动重试与 continue/restart | +| `web/src/api/chatStream.ts`(新建) | `fetch` + stream 解析 SSE;`AbortController`;透传 `request_id` | +| `MessageBubble` | `status` / `errorInfo` / 动作区;失败醒目样式 | +| `PhaseEventStrip` | live `phase` 驱动(P2 完整;P1 闲聊可仅单行状态) | +| `InputArea` | `isThinking`/`isStreaming` 对齐;P1 接线停止(abort → `cancelled`) | + +### 5.3 `ChatMessage` 扩展字段 + +- `status`:上述状态枚举 +- `livePhaseLabel`:当前等待文案 +- `errorInfo`:结构化错误 +- `requestId`:最近一次尝试 ID +- `sourceUserMessageId`:关联用户原文,便于重试 + +### 5.4 交互要点 + +1. 发送立即插入 user + assistant(`pending`),取消人为 600ms delay。 +2. 失败不得写入成功模板。 +3. 自动重试中停止或新消息 → abort 并取消后续自动重试。 +4. 「继续 / 重新开始」仅当 `actions` 含对应项时显示。 + +--- + +## 六、分期、测试与验收 + +### 6.1 分期 + +| 期次 | 交付 | 不做 | +| :--- | :--- | :--- | +| **P1** | Web 接 chat stream;等待态;整段 delta;`error_info`;失败气泡;transient 自动重试≤2 + 手动重试;停止;闲聊真实 Agent 验收 | 真·逐字;长任务 continue/restart 完整打磨 | +| **P2** | 长任务 `PhaseEventStrip` live;`resumable`→继续;`fatal`→重新开始;对齐 Resume/task stream | token 流 | +| **P3** | pi-agent → Go 持续 `EmitDelta`;Web 打字机;可选 detail 折叠 | — | + +### 6.2 后端最小增量(P1) + +1. `EmitError` 扩展为 `error` + `error_info`(class/code/phase/upstream/request_id/actions)。 +2. 闲聊路径补齐关键 phase(如 `intent_recognizing`、`dialogue_generating`)。 +3. 日志与响应关联 `request_id`。 + +### 6.3 验收标准(P1) + +1. 闲聊发送后 **≤1s** 出现占位气泡与状态文案。 +2. 成功定稿前用户能感知「仍在生成」;完成后等待动画消失。 +3. 断网/超时:可见自动重试文案;用尽后错误气泡含白话 + 一行诊断 + 可复制诊断;无假成功模板。 +4. 手动「重试」可再次推进;停止可打断流与自动重试。 +5. 真实环境(`pi-agent` + MiniMax)至少 1 条闲聊 E2E,并按 `.doc/test-reports/` 规范归档测试报告。 + +### 6.4 测试分层 + +- **单元**:SSE 解析、class 兜底映射、自动重试计数与退避。 +- **集成**:stream handler 发出带 `error_info` 的事件。 +- **E2E(真实 Agent)**:闲聊等待态 + 成功定稿;可选注入 transient 验证自动重试。 + +### 6.5 风险 + +- 闲聊正文仍可能整段等待至生成结束(P3 才消首字延迟);P1 用状态文案缓解焦虑。 +- 长任务副作用路径若误标 `transient`,可能导致不安全自动重试 → 默认偏保守,有 workspace 副作用标 `resumable`。 + +--- + +## 七、与既有文档的关系 + +- 继承并实现:`docs/superpowers/specs/2026-09-08-streaming-events-and-digestible-ux-design.md` 中 Web 侧「进度条 + 对话呈现」缺口。 +- 不替代该文档的三层信息架构与事件类型定义;本文件专注 **Web 等待态、错误诊断、分型重试与自动重试策略**。 + +--- + +## 八、非目标(本设计范围外) + +- 无限或对 `fatal`/`resumable` 的自动重试。 +- P1 真·token 流式与 CLI 体验改造。 +- 用 WebSocket 替换 SSE。 +- 修改知识库共享 / 三环境数据隔离架构。