docs(spec): archive chat wait-error-retry UX spec and implementation plan
This commit is contained in:
@@ -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<void>`
|
||||
- `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<string, unknown>,
|
||||
handlers: {
|
||||
onEvent: (ev: StreamEvent) => void;
|
||||
onRequestId?: (id: string) => void;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
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.
|
||||
@@ -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。
|
||||
- 修改知识库共享 / 三环境数据隔离架构。
|
||||
Reference in New Issue
Block a user