diff --git a/.project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md b/.project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md new file mode 100644 index 0000000..d8612d1 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260907-design-agent-activity-client-b6d913e4.md @@ -0,0 +1,103 @@ +# Task: Render AI Design agent activity progress + +## Identity + +- Task ID: 20260907-design-agent-activity-client-b6d913e4 +- Mode: Feature +- Branch: codex/20260907-design-agent-activity-client-b6d913e4-design-agent-activity-client +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260907-design-agent-activity-client-b6d913e4 +- Base commit: da29294979da4ce041edf992b590ee6ea170a44f +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Consume operation-scoped AI Design activity events through the existing Main-owned + Works Square transport. +- Render one transient activity area attached to the optimistic user message, update + meaningful steps in place, and settle it correctly on final reply or terminal error. +- Cover reconnect/replay, transport-unknown, stale-operation, and compact responsive + presentation without duplicating the right-side Design summary. + +## Intent And Constraints + +- User messages remain immediately visible and final assistant replies remain normal + conversation content. +- Activity is presentation state, not canonical Design state or a durable chat bubble. +- Show at most a few youth-safe phase/action/outcome lines; never render raw reasoning, + tool arguments, prompts, provider output, or technical error details. +- Preserve stable operation identity across uncertain transport outcomes and ignore + progress that belongs to another Workspace/direction/operation. + +## Project Context Loaded + +- Project goal: a youth-friendly desktop creation flow with Electron Main as the only + network authority and server-owned Design Specification semantics. +- Current integrated focus: Living Form V2 and immediate optimistic user messages are + integrated; the experimental `050d78d` progress UI is not assumed present. +- Decisions: ADR-007 keeps Renderer drafts temporary and Gateway operations stable; + the activity view may observe events but cannot become another authority. +- Evidence/commitments: real paid Design production smoke remains separate and is not + claimed by this client presentation task. +- Relevant peers: the Enter-key E2E task is test-only; older generic-error tasks have + undefined scope. No semantic conflict was found. +- Likely modules: Main Workspace adapter/DTO mapping, Renderer store event reduction, + Design conversation pane, and focused unit/Electron E2E tests. +- Unknowns to resolve in source: current stream event DTO shape and the best existing + operation-to-optimistic-message association seam. +- Gate result: Passed. + +## Outcome + +- Added a dedicated `AI 处理过程` panel beneath the optimistic user message. Active + steps update in place; completed processing collapses into a compact, user-toggleable + summary while the final assistant reply continues to stream as normal conversation. +- Added Main-owned normalization for the four public progress stages. Main maps stages + to fixed youth-safe copy instead of trusting arbitrary server/provider text. +- Preserved exact operation association across optimistic, streaming, canonical, and + transport-unknown states by recording the pre-command turn sequence and matching the + earliest exact canonical user turn after that boundary. +- Added Gateway command terminal-event handling so accepted commands converge after a + polling disconnect. WebSocket-terminal-first races remain authoritative, stale + connections cannot mutate current state, duplicate chunks advance the replay cursor, + and late results from another Workspace cannot replace the active Workspace. +- Activity remains transient presentation state: it is filtered by Workspace and + direction, never becomes a chat message or right-side Design authority, and contains + no raw model reasoning, prompts, tool arguments, or technical provider output. + +## Verification + +- `pnpm exec vitest run tests/unit/image-workspace-store.test.ts + tests/unit/image-canvas-page.test.tsx + tests/unit/works-square-design-workspace.test.ts + tests/unit/image-workspace-route.test.ts` — 67 passed. +- `pnpm exec tsc --noEmit` — passed. +- Focused ESLint across all eight changed TypeScript/TSX files — passed. +- `pnpm run build:vite` — passed; only existing chunk-size, dynamic-import, and stale + Browserslist-data warnings were reported. +- `git diff --check` — passed. +- Independent client and lifecycle reviews were repeated after the terminal-race, + exact-turn-association, and Workspace A→B→A fixes — PASS; no reachable P1/P2 + remained. + +## Follow-ups + +- Add an explicit snapshot/cursor reseed contract for a Gateway `cursor expired` + response in a separate transport-resilience task. That pre-existing recovery gap is + broader than the AI activity presentation slice. + +## Promotion Candidates + +- Target canonical documents: ADR-007 and the client/Main transport architecture. +- Proposal: document operation-scoped AI activity as transient Renderer state fed by + Main-normalized public Gateway events; generic Gateway terminal events must settle + uncertain writes without changing operation identity. +- Evidence: fixed public stage projection, exact turn association, terminal/poll race + tests, stale-source and cross-Workspace tests, 67 passing focused tests, typecheck, + lint, and production build. +- Future impact: future Agents SDK lifecycle hooks can reuse the same UI seam, while + canonical Design state continues to come only from Workspace snapshots/events. +- Semantic conflicts: none identified; this preserves ADR-007's Main ownership and + temporary Renderer-state boundaries. +- Human confirmation required: yes, during a future integration-mode promotion to + canonical architecture documentation. diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 063a74e..6e0507b 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -4,11 +4,13 @@ import { type DesignAsset, type DesignAssetUploadInput, type DesignAssistantDeltaEvent, + type DesignAssistantProgressEvent, type DesignCapabilities, type DesignChangeSet, type DesignCommandFailureOutcome, type DesignCommandInput, type DesignCommandResult, + type DesignCommandTerminalEvent, type DesignCompilationIssue, type DesignCreateWorkspaceInput, type DesignDecisionPrompt, @@ -67,6 +69,7 @@ type ServerAgentEvent = { sequence: number; runtime: string; type: string; + client_command_id?: unknown; schema_version: number; payload: unknown; }; @@ -92,6 +95,12 @@ const RUN_MAX_POLL_MS = 4_000; const WEBSOCKET_OPEN_TIMEOUT_MS = 10_000; const WEBSOCKET_PING_INTERVAL_MS = 20_000; const WEBSOCKET_OPEN = 1; +const PUBLIC_ASSISTANT_PROGRESS_MESSAGES: Record = { + understanding: '正在听懂你刚补充的内容…', + reviewing_context: '正在把它和前面的设计想法放在一起看…', + validating: '正在确认整理结果没有漏掉或弄错…', + composing: '已经整理好,正在准备回复…', +}; function record(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -419,6 +428,29 @@ function normalizeWorkspaceEvent( || event.sequence < 1) return null; const payload = record(event.payload); const id = `${sessionId}:${event.sequence}`; + if ([ + 'command.completed', + 'command.failed', + 'command.cancelled', + ].includes(event.type)) { + const clientOperationId = stringValue(event.client_command_id, 'Operation ID'); + const outcome = event.type === 'command.completed' + ? 'succeeded' + : event.type === 'command.failed' + ? 'failed' + : 'cancelled'; + const errorCode = outcome === 'failed' + ? stringValue(record(payload.error).code, 'Command error code') + : null; + return { + id, + type: event.type as DesignCommandTerminalEvent['type'], + workspaceId, + clientOperationId, + outcome, + errorCode, + } satisfies DesignCommandTerminalEvent; + } if (event.type === 'design.session.snapshot') { return { id, type: event.type, form: mapForm(payload.form) } satisfies DesignSessionSnapshotEvent; } @@ -435,12 +467,34 @@ function normalizeWorkspaceEvent( delta: stringValue(payload.delta, 'Assistant delta'), } satisfies DesignAssistantDeltaEvent; } + if (event.type === 'design.assistant.progress') { + if (payload.workspace_id !== workspaceId) return null; + const stage = stringValue(payload.stage, 'Assistant progress stage'); + if (![ + 'understanding', + 'reviewing_context', + 'validating', + 'composing', + ].includes(stage)) return null; + return { + id, + type: event.type, + workspaceId, + directionId: stringValue(payload.direction_id, 'Direction ID'), + clientOperationId: stringValue(payload.client_operation_id, 'Operation ID'), + stage: stage as DesignAssistantProgressEvent['stage'], + message: PUBLIC_ASSISTANT_PROGRESS_MESSAGES[ + stage as DesignAssistantProgressEvent['stage'] + ], + } satisfies DesignAssistantProgressEvent; + } if (event.type === 'design.direction.updated') { const form = mapForm(payload.form); if (form.workspaceId !== workspaceId) return null; return { id, type: event.type, + clientOperationId: stringValue(event.client_command_id, 'Operation ID'), replayed: Boolean(payload.replayed), operation: mapInteraction(payload.operation), form, diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index 6135fed..08f0748 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -448,9 +448,35 @@ export type DesignAssistantDeltaEvent = { delta: string; }; +export type DesignAssistantProgressStage = + | 'understanding' + | 'reviewing_context' + | 'validating' + | 'composing'; + +export type DesignAssistantProgressEvent = { + id: string; + type: 'design.assistant.progress'; + workspaceId: string; + directionId: string; + clientOperationId: string; + stage: DesignAssistantProgressStage; + message: string; +}; + +export type DesignCommandTerminalEvent = { + id: string; + type: 'command.completed' | 'command.failed' | 'command.cancelled'; + workspaceId: string; + clientOperationId: string; + outcome: 'succeeded' | 'failed' | 'cancelled'; + errorCode: string | null; +}; + export type DesignDirectionUpdatedEvent = { id: string; type: 'design.direction.updated'; + clientOperationId: string; replayed: boolean; operation: DesignInteractionOutcome; form: DesignForm; @@ -480,7 +506,9 @@ export type DesignWorkspaceUpdatedEvent = { export type DesignWorkspaceEvent = | DesignSessionSnapshotEvent + | DesignAssistantProgressEvent | DesignAssistantDeltaEvent + | DesignCommandTerminalEvent | DesignDirectionUpdatedEvent | DesignQuoteBlockedEvent | DesignWorkspaceUpdatedEvent; diff --git a/src/pages/ImageCanvas/DesignConversationPane.tsx b/src/pages/ImageCanvas/DesignConversationPane.tsx index 501286b..63df463 100644 --- a/src/pages/ImageCanvas/DesignConversationPane.tsx +++ b/src/pages/ImageCanvas/DesignConversationPane.tsx @@ -1,5 +1,7 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { + Check, + ChevronDown, Clock3, Loader2, MessageSquareText, @@ -12,7 +14,10 @@ import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { ImageWorkspaceApiError } from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; -import { useImageWorkspaceStore } from '@/stores/image-workspace'; +import { + type DesignAssistantActivity, + useImageWorkspaceStore, +} from '@/stores/image-workspace'; import type { DesignWorkspace } from '../../../shared/image-workspace'; const STARTER_MESSAGES = [ @@ -21,6 +26,83 @@ const STARTER_MESSAGES = [ '把我的涂鸦变成一段会动的小故事', ]; +function DesignAssistantActivityPanel({ + activity, + operationId, +}: { + activity: DesignAssistantActivity; + operationId: string; +}) { + const [expanded, setExpanded] = useState(activity.status !== 'completed'); + + const summary = activity.status === 'active' + ? `正在进行第 ${activity.steps.length} 个步骤` + : activity.status === 'completed' + ? `完成了 ${activity.steps.length} 个步骤` + : '连接暂时中断,已完成的步骤还在这里'; + + return ( +
+ + + {expanded && ( +
    + {activity.steps.map((step, index) => { + const isCurrent = activity.status === 'active' && index === activity.steps.length - 1; + const isUncertain = activity.status === 'unknown' && index === activity.steps.length - 1; + return ( +
  1. + + {isCurrent + ? + {step.message} +
  2. + ); + })} +
+ )} +
+ ); +} + function chatFailureMessage(error: unknown): string { if (error instanceof ImageWorkspaceApiError) { if (error.commandOutcome === 'unknown') { @@ -60,6 +142,7 @@ export function DesignConversationPane({ const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft); const sendChat = useImageWorkspaceStore((state) => state.sendChat); const requestQuote = useImageWorkspaceStore((state) => state.requestQuote); + const assistantActivities = useImageWorkspaceStore((state) => state.assistantActivities); const assistantStreams = useImageWorkspaceStore((state) => state.assistantStreams); const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations); const scrollAnchorRef = useRef(null); @@ -77,7 +160,9 @@ export function DesignConversationPane({ }]; }); const submittingDesignInput = Object.values(pendingOperations).some( - (operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input', + (operation) => operation.status === 'submitting' + && operation.command.kind === 'apply_input' + && operation.command.workspaceId === workspace.workspace.workspaceId, ); const quoteRequestPending = Object.values(pendingOperations).some( (operation) => operation.command.kind === 'request_quote' @@ -96,12 +181,28 @@ export function DesignConversationPane({ const streamingReplyKey = streamingReplies .map((reply) => `${reply.operationId}:${reply.text}`) .join('|'); + const visibleActivities = Object.entries(assistantActivities).filter(([, activity]) => ( + activity.workspaceId === workspace.workspace.workspaceId + && activity.directionId === workspace.form.directionId + && activity.steps.length > 0 + )); + const activityByOperationId = new Map(visibleActivities); + const activityByTurnId = new Map( + visibleActivities.flatMap(([operationId, activity]) => ( + activity.turnId ? [[activity.turnId, { operationId, activity }] as const] : [] + )), + ); + const activityKey = visibleActivities + .map(([operationId, activity]) => ( + `${operationId}:${activity.status}:${activity.steps.map((step) => step.stage).join(',')}` + )) + .join('|'); useEffect(() => { const scrollAnchor = scrollAnchorRef.current; if (typeof scrollAnchor?.scrollIntoView === 'function') { scrollAnchor.scrollIntoView({ block: 'end' }); } - }, [canRequestQuote, pendingChatKey, streamingReplyKey, workspace.turns.length]); + }, [activityKey, canRequestQuote, pendingChatKey, streamingReplyKey, workspace.turns.length]); const submit = () => { if (!composerDraft.trim() || submittingDesignInput) return; @@ -162,40 +263,64 @@ export function DesignConversationPane({ )}
- {workspace.turns.map((turn) => ( -
- {turn.userMessage && ( -
- {turn.userMessage} -
- )} - {turn.assistantMessage && ( -
- {turn.assistantMessage} -
- )} -
- ))} + {workspace.turns.map((turn) => { + const linkedActivity = activityByTurnId.get(turn.turnId); + return ( +
+ {turn.userMessage && ( +
+ {turn.userMessage} +
+ )} + {linkedActivity && ( + + )} + {turn.assistantMessage && ( +
+ {turn.assistantMessage} +
+ )} +
+ ); + })} - {pendingChatMessages.map((message) => ( -
-
- {message.message} + {pendingChatMessages.map((message) => { + const linkedActivity = activityByOperationId.get(message.operationId); + return ( +
+
+
+ {message.message} +
+
+ {message.status === 'submitting' + ?
+
+ {linkedActivity && ( + + )}
-
- {message.status === 'submitting' - ?
-
- ))} + ); + })} {streamingReplies.map((reply) => (
; }; type ExecuteCommandOptions = { @@ -63,6 +79,7 @@ type ImageWorkspaceState = { pendingOperations: Record; fieldDrafts: Record; chatDraft: string; + assistantActivities: Record; assistantStreams: Record; quoteBlockers: DesignCompilationIssue[]; error: string | null; @@ -98,7 +115,15 @@ let activeEventSource: EventSource | null = null; let activeEventWorkspaceId: string | null = null; let eventConnectionGeneration = 0; let selectionGeneration = 0; +let workspaceSelectionGeneration = 0; const assistantStreamChunkIndexes = new Map(); +const observedCommandTerminalEvents = new Map(); +const ASSISTANT_PROGRESS_STAGES: DesignAssistantProgressStage[] = [ + 'understanding', + 'reviewing_context', + 'validating', + 'composing', +]; function updateBackgroundLease(tasks: DesignGenerationTask[]): void { const active = tasks.some((task) => task.status === 'queued' || task.status === 'running'); @@ -161,6 +186,140 @@ function isUnknownCommandOutcome(error: unknown): boolean { return error instanceof ImageWorkspaceApiError && error.commandOutcome === 'unknown'; } +function terminalCommandError(event: DesignCommandTerminalEvent): ImageWorkspaceApiError { + const cancelled = event.outcome === 'cancelled'; + return new ImageWorkspaceApiError( + 422, + event.errorCode ?? (cancelled ? 'design_agent_run_cancelled' : 'design_agent_run_failed'), + cancelled + ? '这次整理已停止,内容还在输入框里' + : 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次', + 'definitive_failure', + ); +} + +function isChatCommand(command: DesignCommandInput): boolean { + return command.kind === 'apply_input' && command.input.kind === 'chat'; +} + +function matchingChatTurn( + workspace: DesignWorkspace, + operation: PendingDesignOperation, + pendingOperations: Record, + activity: DesignAssistantActivity | undefined, +): DesignWorkspace['turns'][number] | null { + if (activity?.turnId) { + return workspace.turns.find((turn) => turn.turnId === activity.turnId) ?? null; + } + const baseRawTurnSequence = operation.baseRawTurnSequence; + const command = operation.command; + if (baseRawTurnSequence === null + || command.kind !== 'apply_input' + || command.input.kind !== 'chat') return null; + const message = command.input.message; + const competingOperation = Object.values(pendingOperations).some((candidate) => ( + candidate.id !== operation.id + && candidate.baseRawTurnSequence === baseRawTurnSequence + && candidate.command.kind === 'apply_input' + && candidate.command.input.kind === 'chat' + && candidate.command.input.message === message + )); + if (competingOperation) return null; + const matches = workspace.turns + .filter((turn) => ( + turn.rawTurnSequence > baseRawTurnSequence + && turn.userMessage === message + )) + .sort((left, right) => left.rawTurnSequence - right.rawTurnSequence); + return matches.length === 1 ? matches[0] : null; +} + +function isCurrentOperationSelection( + state: Pick, + operation: PendingDesignOperation, +): boolean { + return state.activeWorkspaceId === operation.command.workspaceId + && (operation.workspaceSelectionGeneration ?? workspaceSelectionGeneration) + === workspaceSelectionGeneration; +} + +function withoutAssistantActivity( + activities: Record, + operationId: string, +): Record { + if (!activities[operationId]) return activities; + const next = { ...activities }; + delete next[operationId]; + return next; +} + +function withAssistantActivityStatus( + activities: Record, + operationId: string, + status: DesignAssistantActivity['status'], + turnId?: string | null, +): Record { + const activity = activities[operationId]; + if (!activity) return activities; + return { + ...activities, + [operationId]: { + ...activity, + status, + ...(turnId === undefined ? {} : { turnId }), + }, + }; +} + +function withAssistantProgress( + activities: Record, + event: DesignAssistantProgressEvent, +): Record { + const activity = activities[event.clientOperationId]; + if (activity?.status === 'completed') return activities; + const incomingRank = ASSISTANT_PROGRESS_STAGES.indexOf(event.stage); + const lastStage = activity?.steps.at(-1)?.stage; + const lastRank = lastStage ? ASSISTANT_PROGRESS_STAGES.indexOf(lastStage) : -1; + if (incomingRank < lastRank) return activities; + + const steps = [...(activity?.steps ?? [])]; + const existingIndex = steps.findIndex((step) => step.stage === event.stage); + const nextStep = { stage: event.stage, message: event.message }; + if (existingIndex >= 0) { + steps[existingIndex] = nextStep; + steps.splice(existingIndex + 1); + } else { + steps.push(nextStep); + } + return { + ...activities, + [event.clientOperationId]: { + workspaceId: event.workspaceId, + directionId: event.directionId, + status: 'active', + turnId: activity?.turnId ?? null, + steps, + }, + }; +} + +function withCompletedAssistantActivity( + activities: Record, + pendingOperations: Record, + operation: PendingDesignOperation, + workspace: DesignWorkspace, +): Record { + const turn = matchingChatTurn( + workspace, + operation, + pendingOperations, + activities[operation.id], + ); + return turn + ? withAssistantActivityStatus(activities, operation.id, 'completed', turn.turnId) + : withAssistantActivityStatus(activities, operation.id, 'completed'); +} + function parseWorkspaceEvent(event: Event): DesignWorkspaceEvent | null { const data = (event as MessageEvent).data; if (typeof data !== 'string') return null; @@ -169,7 +328,11 @@ function parseWorkspaceEvent(event: Event): DesignWorkspaceEvent | null { if (typeof parsed.id !== 'string' || typeof parsed.type !== 'string') return null; if (![ 'design.session.snapshot', + 'design.assistant.progress', 'design.assistant.delta', + 'command.completed', + 'command.failed', + 'command.cancelled', 'design.direction.updated', 'design.quote.blocked', 'design.workspace.updated', @@ -275,10 +438,98 @@ export const useImageWorkspaceStore = create((set, get) => return workspace; }; + const settleSuccessfulCommand = ( + operation: PendingDesignOperation, + workspace: DesignWorkspace, + ): boolean => { + let settled = false; + set((state) => { + const currentOperation = state.pendingOperations[operation.id]; + if (!currentOperation) return {}; + const pendingOperations = { ...state.pendingOperations }; + const assistantStreams = { ...state.assistantStreams }; + + if (!isCurrentOperationSelection(state, currentOperation)) { + delete pendingOperations[operation.id]; + delete assistantStreams[operation.id]; + settled = true; + return { + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + operation.id, + ), + assistantStreams, + }; + } + + const canonicalWorkspace = state.workspace?.workspace.workspaceId + === workspace.workspace.workspaceId + && state.workspace.workspace.workspaceViewRevision + > workspace.workspace.workspaceViewRevision + ? state.workspace + : workspace; + const chatMessage = currentOperation.command.kind === 'apply_input' + && currentOperation.command.input.kind === 'chat' + ? currentOperation.command.input.message + : null; + const canonicalTurn = chatMessage !== null + ? matchingChatTurn( + canonicalWorkspace, + currentOperation, + state.pendingOperations, + state.assistantActivities[operation.id], + ) + : null; + if (chatMessage !== null && !canonicalTurn) { + return { + ...withWorkspace(state, canonicalWorkspace), + pendingOperations: { + ...state.pendingOperations, + [operation.id]: { ...currentOperation, status: 'unknown', error: null }, + }, + assistantActivities: withAssistantActivityStatus( + state.assistantActivities, + operation.id, + 'completed', + ), + error: null, + }; + } + + delete pendingOperations[operation.id]; + delete assistantStreams[operation.id]; + const fieldDrafts = { ...state.fieldDrafts }; + for (const path of currentOperation.clearDraftPaths) delete fieldDrafts[path]; + const clearChatDraft = currentOperation.clearChatDraft + && (chatMessage === null || state.chatDraft.trim() === chatMessage); + settled = true; + return { + ...withWorkspace(state, canonicalWorkspace), + pendingOperations, + fieldDrafts, + chatDraft: clearChatDraft ? '' : state.chatDraft, + assistantActivities: chatMessage !== null + ? withCompletedAssistantActivity( + state.assistantActivities, + state.pendingOperations, + currentOperation, + canonicalWorkspace, + ) + : withoutAssistantActivity(state.assistantActivities, operation.id), + assistantStreams, + }; + }); + if (settled) assistantStreamChunkIndexes.delete(operation.id); + return settled; + }; + const executeCommand = async ( command: DesignCommandInput, options: ExecuteCommandOptions, ): Promise => { + const existingOperation = get().pendingOperations[command.clientOperationId]; + const currentWorkspace = get().workspace; const operation: PendingDesignOperation = { id: command.clientOperationId, label: options.label, @@ -287,60 +538,137 @@ export const useImageWorkspaceStore = create((set, get) => error: null, clearDraftPaths: options.clearDraftPaths ?? [], clearChatDraft: options.clearChatDraft ?? false, + baseRawTurnSequence: existingOperation?.baseRawTurnSequence + ?? (isChatCommand(command) + && currentWorkspace?.workspace.workspaceId === command.workspaceId + ? currentWorkspace.form.rawTurnSequence + : null), + workspaceSelectionGeneration, }; - set((state) => ({ - pendingOperations: { ...state.pendingOperations, [operation.id]: operation }, - error: null, - quoteBlockers: command.kind === 'request_quote' ? [] : state.quoteBlockers, - })); + set((state) => { + const existingActivity = state.assistantActivities[operation.id]; + const assistantActivities = isChatCommand(command) + ? existingActivity + ? { + [operation.id]: { + ...existingActivity, + status: 'active' as const, + }, + } + : {} + : state.assistantActivities; + return { + pendingOperations: { ...state.pendingOperations, [operation.id]: operation }, + assistantActivities, + error: null, + quoteBlockers: command.kind === 'request_quote' ? [] : state.quoteBlockers, + }; + }); try { const result = await submitImageWorkspaceCommand(command); - assistantStreamChunkIndexes.delete(operation.id); - set((state) => { - const pendingOperations = { ...state.pendingOperations }; - delete pendingOperations[operation.id]; - const fieldDrafts = { ...state.fieldDrafts }; - for (const path of operation.clearDraftPaths) delete fieldDrafts[path]; - const assistantStreams = { ...state.assistantStreams }; - delete assistantStreams[operation.id]; - return { - ...withWorkspace(state, result.workspace), - pendingOperations, - fieldDrafts, - chatDraft: operation.clearChatDraft ? '' : state.chatDraft, - assistantStreams, - }; - }); + const observedTerminal = observedCommandTerminalEvents.get(operation.id); + if (observedTerminal && observedTerminal.outcome !== 'succeeded') { + throw terminalCommandError(observedTerminal); + } + observedCommandTerminalEvents.delete(operation.id); + settleSuccessfulCommand(operation, result.workspace); return result.workspace; } catch (error) { + const observedTerminal = observedCommandTerminalEvents.get(operation.id); + if (observedTerminal) { + if (observedTerminal.outcome === 'succeeded') { + const refreshed = await fetchImageWorkspaceProject(command.workspaceId).catch(() => null); + if (refreshed) { + observedCommandTerminalEvents.delete(operation.id); + settleSuccessfulCommand(operation, refreshed); + return refreshed; + } + throw error; + } + + observedCommandTerminalEvents.delete(operation.id); + assistantStreamChunkIndexes.delete(operation.id); + const terminalError = terminalCommandError(observedTerminal); + set((state) => { + const pendingOperations = { ...state.pendingOperations }; + delete pendingOperations[operation.id]; + const assistantStreams = { ...state.assistantStreams }; + delete assistantStreams[operation.id]; + return { + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + operation.id, + ), + assistantStreams, + error: isCurrentOperationSelection(state, operation) + ? terminalError.message + : state.error, + }; + }); + throw terminalError; + } + const message = messageForError(error); if (isUnknownCommandOutcome(error)) { - set((state) => ({ - pendingOperations: { - ...state.pendingOperations, - [operation.id]: { ...operation, status: 'unknown', error: message }, - }, - ...(isAuthError(error) ? { status: 'auth-required' as const } : {}), - error: '操作结果尚未确认,可使用同一操作标识安全重试', - })); + set((state) => { + const active = isCurrentOperationSelection(state, operation); + return { + pendingOperations: { + ...state.pendingOperations, + [operation.id]: { ...operation, status: 'unknown', error: message }, + }, + ...(isAuthError(error) ? { status: 'auth-required' as const } : {}), + assistantActivities: withAssistantActivityStatus( + state.assistantActivities, + operation.id, + 'unknown', + ), + error: active + ? '操作结果尚未确认,可使用同一操作标识安全重试' + : state.error, + }; + }); } else if (isDirectionConflict(error)) { set((state) => { const pendingOperations = { ...state.pendingOperations }; delete pendingOperations[operation.id]; - return { pendingOperations, error: '设计已在其他位置更新,已刷新最新内容,请再次提交' }; + return { + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + operation.id, + ), + error: isCurrentOperationSelection(state, operation) + ? '设计已在其他位置更新,已刷新最新内容,请再次提交' + : state.error, + }; }); - await get().refreshWorkspace().catch(() => null); + if (isCurrentOperationSelection(get(), operation)) { + await get().refreshWorkspace().catch(() => null); + } } else if (isAuthError(error)) { set((state) => { const pendingOperations = { ...state.pendingOperations }; delete pendingOperations[operation.id]; - return { pendingOperations, status: 'auth-required', error: message }; + return { + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + operation.id, + ), + status: 'auth-required', + error: message, + }; }); } else if (isQuoteBlocked(error)) { set((state) => { const pendingOperations = { ...state.pendingOperations }; delete pendingOperations[operation.id]; - return { pendingOperations, error: null }; + return { + pendingOperations, + error: isCurrentOperationSelection(state, operation) ? null : state.error, + }; }); } else if (isDefinitiveCommandFailure(error)) { assistantStreamChunkIndexes.delete(operation.id); @@ -349,16 +677,33 @@ export const useImageWorkspaceStore = create((set, get) => delete pendingOperations[operation.id]; const assistantStreams = { ...state.assistantStreams }; delete assistantStreams[operation.id]; - return { pendingOperations, assistantStreams, error: message }; + return { + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + operation.id, + ), + assistantStreams, + error: isCurrentOperationSelection(state, operation) ? message : state.error, + }; }); - if (error.status === 409) await get().refreshWorkspace().catch(() => null); + if (error.status === 409 && isCurrentOperationSelection(get(), operation)) { + await get().refreshWorkspace().catch(() => null); + } } else { set((state) => ({ pendingOperations: { ...state.pendingOperations, [operation.id]: { ...operation, status: 'unknown', error: message }, }, - error: '操作结果尚未确认,可使用同一操作标识安全重试', + assistantActivities: withAssistantActivityStatus( + state.assistantActivities, + operation.id, + 'unknown', + ), + error: isCurrentOperationSelection(state, operation) + ? '操作结果尚未确认,可使用同一操作标识安全重试' + : state.error, })); } throw error; @@ -376,6 +721,7 @@ export const useImageWorkspaceStore = create((set, get) => pendingOperations: {}, fieldDrafts: {}, chatDraft: '', + assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], error: null, @@ -396,6 +742,7 @@ export const useImageWorkspaceStore = create((set, get) => ) ? currentId : bootstrap.workspaces[0]?.workspaceId ?? null; + if (selectedId !== currentId) workspaceSelectionGeneration += 1; set({ status: 'ready', bootstrap, @@ -430,12 +777,14 @@ export const useImageWorkspaceStore = create((set, get) => createProject: async (title) => { const workspace = await createImageWorkspaceProject(title); selectionGeneration += 1; + workspaceSelectionGeneration += 1; closeEventSource(); assistantStreamChunkIndexes.clear(); set((state) => ({ ...withWorkspace(state, workspace), fieldDrafts: {}, chatDraft: '', + assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], lastEventId: null, @@ -473,6 +822,7 @@ export const useImageWorkspaceStore = create((set, get) => const deletingActive = get().activeWorkspaceId === workspaceId; if (deletingActive) { selectionGeneration += 1; + workspaceSelectionGeneration += 1; closeEventSource(); assistantStreamChunkIndexes.clear(); } @@ -483,6 +833,7 @@ export const useImageWorkspaceStore = create((set, get) => deletingWorkspaceId: null, fieldDrafts: deletingActive ? {} : state.fieldDrafts, chatDraft: deletingActive ? '' : state.chatDraft, + assistantActivities: deletingActive ? {} : state.assistantActivities, assistantStreams: deletingActive ? {} : state.assistantStreams, error: null, })); @@ -500,6 +851,8 @@ export const useImageWorkspaceStore = create((set, get) => selectProject: async (workspaceId) => { if (workspaceId === get().activeWorkspaceId && get().workspace) return; selectionGeneration += 1; + workspaceSelectionGeneration += 1; + const selectedWorkspaceGeneration = workspaceSelectionGeneration; closeEventSource(); assistantStreamChunkIndexes.clear(); set({ @@ -509,6 +862,7 @@ export const useImageWorkspaceStore = create((set, get) => lastEventId: null, fieldDrafts: {}, chatDraft: '', + assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], error: null, @@ -517,7 +871,10 @@ export const useImageWorkspaceStore = create((set, get) => await refreshWorkspaceById(workspaceId); get().connectEvents(); } catch (error) { - set({ error: messageForError(error), eventState: 'degraded' }); + if (get().activeWorkspaceId === workspaceId + && workspaceSelectionGeneration === selectedWorkspaceGeneration) { + set({ error: messageForError(error), eventState: 'degraded' }); + } } }, @@ -548,20 +905,128 @@ export const useImageWorkspaceStore = create((set, get) => return; } activeEventSource = source; - source.onopen = () => set({ eventState: 'connected' }); + source.onopen = () => { + if (connectionGeneration !== eventConnectionGeneration + || activeEventSource !== source) return; + set({ eventState: 'connected' }); + }; source.onerror = () => { + if (connectionGeneration !== eventConnectionGeneration + || activeEventSource !== source) return; set({ eventState: 'degraded' }); void get().refreshWorkspace().catch(() => null); }; const receive = (raw: Event) => { + if (connectionGeneration !== eventConnectionGeneration + || activeEventSource !== source) return; const event = parseWorkspaceEvent(raw); if (!event || get().activeWorkspaceId !== workspaceId) return; + if (event.type === 'command.completed' + || event.type === 'command.failed' + || event.type === 'command.cancelled') { + const pending = get().pendingOperations[event.clientOperationId]; + if (!pending || pending.command.workspaceId !== workspaceId) { + set({ lastEventId: event.id }); + return; + } + observedCommandTerminalEvents.set(event.clientOperationId, event); + if (event.outcome === 'succeeded') { + set((state) => ({ + lastEventId: event.id, + pendingOperations: { + ...state.pendingOperations, + [event.clientOperationId]: { + ...pending, + status: 'unknown', + error: null, + }, + }, + assistantActivities: withAssistantActivityStatus( + state.assistantActivities, + event.clientOperationId, + 'completed', + ), + })); + void get().refreshWorkspace().then((refreshed) => { + if (refreshed) settleSuccessfulCommand(pending, refreshed); + }).catch(() => undefined).finally(() => { + if (pending.status === 'unknown') { + observedCommandTerminalEvents.delete(event.clientOperationId); + } + }); + return; + } + assistantStreamChunkIndexes.delete(event.clientOperationId); + set((state) => { + const operation = state.pendingOperations[event.clientOperationId]; + if (!operation || operation.command.workspaceId !== workspaceId) { + return { lastEventId: event.id }; + } + const pendingOperations = { ...state.pendingOperations }; + delete pendingOperations[event.clientOperationId]; + const assistantStreams = { ...state.assistantStreams }; + delete assistantStreams[event.clientOperationId]; + return { + lastEventId: event.id, + pendingOperations, + assistantActivities: withoutAssistantActivity( + state.assistantActivities, + event.clientOperationId, + ), + assistantStreams, + error: isCurrentOperationSelection(state, operation) + ? terminalCommandError(event).message + : state.error, + }; + }); + if (pending.status === 'unknown') { + observedCommandTerminalEvents.delete(event.clientOperationId); + } + return; + } + if (event.type === 'design.assistant.progress') { + set((state) => { + const pending = state.pendingOperations[event.clientOperationId]; + if (!state.workspace + || pending?.command.kind !== 'apply_input' + || pending.command.input.kind !== 'chat' + || pending.command.workspaceId !== workspaceId + || event.workspaceId !== workspaceId + || event.directionId !== state.workspace.form.directionId) { + return { lastEventId: event.id }; + } + return { + lastEventId: event.id, + assistantActivities: withAssistantProgress(state.assistantActivities, event), + }; + }); + return; + } if (event.type === 'design.assistant.delta') { + const state = get(); + const pending = state.pendingOperations[event.clientOperationId]; + if (!state.workspace + || pending?.command.kind !== 'apply_input' + || pending.command.input.kind !== 'chat' + || pending.command.workspaceId !== workspaceId + || event.workspaceId !== workspaceId + || event.directionId !== state.workspace.form.directionId) { + set({ lastEventId: event.id }); + return; + } const previousChunkIndex = assistantStreamChunkIndexes.get(event.clientOperationId); - if (previousChunkIndex !== undefined && event.chunkIndex <= previousChunkIndex) return; + if (previousChunkIndex !== undefined && event.chunkIndex <= previousChunkIndex) { + set({ lastEventId: event.id }); + return; + } assistantStreamChunkIndexes.set(event.clientOperationId, event.chunkIndex); set((state) => ({ lastEventId: event.id, + assistantActivities: withAssistantActivityStatus( + state.assistantActivities, + event.clientOperationId, + 'completed', + ), assistantStreams: { ...state.assistantStreams, [event.clientOperationId]: `${state.assistantStreams[event.clientOperationId] ?? ''}${event.delta}`, @@ -569,6 +1034,19 @@ export const useImageWorkspaceStore = create((set, get) => })); return; } + if (event.type === 'design.direction.updated') { + const pending = get().pendingOperations[event.clientOperationId]; + if (pending?.status === 'submitting') { + observedCommandTerminalEvents.set(event.clientOperationId, { + id: event.id, + type: 'command.completed', + workspaceId: event.form.workspaceId, + clientOperationId: event.clientOperationId, + outcome: 'succeeded', + errorCode: null, + }); + } + } set((state) => { if (!state.workspace) return { lastEventId: event.id }; const nextWorkspace = applyEventToWorkspace(state.workspace, event); @@ -578,10 +1056,20 @@ export const useImageWorkspaceStore = create((set, get) => if (event.type === 'design.quote.blocked') { delete pendingOperations[event.clientOperationId]; } + const assistantActivities = event.type === 'design.direction.updated' + && event.operation.turnId + ? withAssistantActivityStatus( + state.assistantActivities, + event.clientOperationId, + 'completed', + event.operation.turnId, + ) + : state.assistantActivities; updateBackgroundLease(nextWorkspace.tasks); return { workspace: nextWorkspace, pendingOperations, + assistantActivities, bootstrap: state.bootstrap ? { ...state.bootstrap, @@ -597,13 +1085,22 @@ export const useImageWorkspaceStore = create((set, get) => error: event.type === 'design.quote.blocked' ? null : state.error, }; }); - if (event.type === 'design.workspace.updated' && event.changedDirection) { + if (event.type === 'design.direction.updated') { + const pending = get().pendingOperations[event.clientOperationId]; + void get().refreshWorkspace().then((refreshed) => { + if (pending && refreshed) settleSuccessfulCommand(pending, refreshed); + }).catch(() => null); + } else if (event.type === 'design.workspace.updated' && event.changedDirection) { void get().refreshWorkspace().catch(() => null); } }; for (const eventType of [ 'design.session.snapshot', + 'design.assistant.progress', 'design.assistant.delta', + 'command.completed', + 'command.failed', + 'command.cancelled', 'design.direction.updated', 'design.quote.blocked', 'design.workspace.updated', @@ -726,8 +1223,10 @@ export const useImageWorkspaceStore = create((set, get) => reset: () => { selectionGeneration += 1; + workspaceSelectionGeneration += 1; closeEventSource(); assistantStreamChunkIndexes.clear(); + observedCommandTerminalEvents.clear(); updateBackgroundLease([]); set({ status: 'idle', @@ -740,6 +1239,7 @@ export const useImageWorkspaceStore = create((set, get) => pendingOperations: {}, fieldDrafts: {}, chatDraft: '', + assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], error: null, diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index dc3bdae..db9893f 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -52,6 +52,7 @@ function prepareWorkspace(overrides = {}) { pendingOperations: {}, fieldDrafts: {}, chatDraft: '', + assistantActivities: {}, assistantStreams: {}, quoteBlockers: [], error: null, @@ -93,6 +94,37 @@ describe('youth AI Design Canvas page', () => { expect(screen.queryByText(/规格版本|编译器|生成策略|字段决策/)).not.toBeInTheDocument(); }); + it('does not disable the current composer for another Workspace command', () => { + prepareWorkspace(); + useImageWorkspaceStore.setState({ + chatDraft: '当前项目的新想法', + pendingOperations: { + 'operation-workspace-2': { + id: 'operation-workspace-2', + label: '发送创作想法', + command: { + kind: 'apply_input', + workspaceId: 'workspace-2', + sessionId: 'session-2', + expectedDirectionRevision: 2, + clientOperationId: 'operation-workspace-2', + input: { kind: 'chat', message: '另一个项目还在处理' }, + }, + status: 'submitting', + error: null, + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + }); + + render(); + + expect(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' })).toBeEnabled(); + expect(screen.getByRole('button', { name: '发送消息' })).toBeEnabled(); + }); + it('shows one unfinished assistant reply incrementally and replaces it with the canonical turn', async () => { const { workspace } = prepareWorkspace(); const firstChunk = '收到,我们要制作'; @@ -115,6 +147,7 @@ describe('youth AI Design Canvas page', () => { error: null, clearDraftPaths: [], clearChatDraft: true, + baseRawTurnSequence: 1, }, }, }); @@ -165,6 +198,95 @@ describe('youth AI Design Canvas page', () => { .toBeInTheDocument(); }); + it('shows public AI activity under the submitted message and collapses it beside the final turn', async () => { + const message = '画一只在月球踢球的熊猫'; + const { workspace } = prepareWorkspace({ turns: [] }); + useImageWorkspaceStore.setState({ + pendingOperations: { + 'operation-chat-1': { + id: 'operation-chat-1', + label: '发送创作想法', + command: { + kind: 'apply_input', + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-chat-1', + input: { kind: 'chat', message }, + }, + status: 'submitting', + error: null, + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-1': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'active', + turnId: null, + steps: [ + { stage: 'understanding', message: '正在听懂你刚补充的内容…' }, + { stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' }, + ], + }, + }, + }); + + render(); + + const conversation = screen.getByTestId('image-workspace-conversation'); + const pending = within(conversation).getByTestId('pending-design-chat-operation-chat-1'); + const activity = within(conversation).getByTestId('design-assistant-activity-operation-chat-1'); + expect(pending.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(activity).getByRole('button', { name: /AI 处理过程/ })) + .toHaveAttribute('aria-expanded', 'true'); + expect(within(activity).getByText('正在听懂你刚补充的内容…')).toBeInTheDocument(); + expect(within(activity).getAllByText('正在把它和前面的设计想法放在一起看…')) + .not.toHaveLength(0); + expect(within(activity).queryByText(/思考过程|推理内容/)).not.toBeInTheDocument(); + + act(() => { + useImageWorkspaceStore.setState({ + workspace: { + ...workspace, + turns: [{ + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '这个画面很有趣。你想让它更像漫画还是电影?', + }], + }, + pendingOperations: {}, + assistantActivities: { + 'operation-chat-1': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'completed', + turnId: 'turn-2', + steps: [ + { stage: 'understanding', message: '正在听懂你刚补充的内容…' }, + { stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' }, + ], + }, + }, + }); + }); + + const completedActivity = within(conversation) + .getByTestId('design-assistant-activity-operation-chat-1'); + await waitFor(() => { + expect(within(completedActivity).getByRole('button', { name: /AI 处理过程/ })) + .toHaveAttribute('aria-expanded', 'false'); + }); + expect(within(completedActivity).queryByText('正在听懂你刚补充的内容…')) + .not.toBeInTheDocument(); + fireEvent.click(within(completedActivity).getByRole('button', { name: /AI 处理过程/ })); + expect(within(completedActivity).getByText('正在听懂你刚补充的内容…')).toBeInTheDocument(); + }); + it('opens the mobile quote confirmation from the ready conversation without starting a paid task', async () => { const { actions } = prepareWorkspace(); const quotedWorkspace = designWorkspaceFixture({ @@ -208,6 +330,7 @@ describe('youth AI Design Canvas page', () => { error: null, clearDraftPaths: [], clearChatDraft: true, + baseRawTurnSequence: 1, }, }, }); @@ -390,6 +513,7 @@ describe('youth AI Design Canvas page', () => { error: '网络已断开', clearDraftPaths: [], clearChatDraft: false, + baseRawTurnSequence: null, }, }, }); diff --git a/tests/unit/image-workspace-route.test.ts b/tests/unit/image-workspace-route.test.ts index ed5c21b..8e630bb 100644 --- a/tests/unit/image-workspace-route.test.ts +++ b/tests/unit/image-workspace-route.test.ts @@ -291,6 +291,15 @@ describe('AI design V2 Main route boundary', () => { async *[Symbol.asyncIterator]() { yield { id: 'session-1:9', + type: 'design.assistant.progress' as const, + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + stage: 'reviewing_context' as const, + message: '正在把它和前面的设计想法放在一起看…', + }; + yield { + id: 'session-1:10', type: 'design.assistant.delta' as const, workspaceId: 'workspace-1', directionId: 'direction-1', @@ -316,7 +325,9 @@ describe('AI design V2 Main route boundary', () => { sessionId: 'session-1', afterEventId: 'session-1:8', }); - expect(Buffer.concat(response.chunks).toString('utf8')).toContain('event: design.assistant.delta'); + const body = Buffer.concat(response.chunks).toString('utf8'); + expect(body).toContain('event: design.assistant.progress'); + expect(body).toContain('event: design.assistant.delta'); expect(close).toHaveBeenCalledOnce(); }); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 18aea60..698b8af 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -76,10 +76,12 @@ class FakeEventSource { function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; + reject = rejectPromise; }); - return { promise, resolve }; + return { promise, reject, resolve }; } async function loadedStore(eventSource = new FakeEventSource()) { @@ -146,6 +148,60 @@ describe('V2 Living Form store', () => { expect(secondSource.close).not.toHaveBeenCalled(); }); + it('ignores late activity from a closed connection after reconnecting to the same Workspace', async () => { + const firstSource = await loadedStore(); + const oldOnOpen = firstSource.onopen; + const oldOnError = firstSource.onerror; + const secondSource = new FakeEventSource(); + useImageWorkspaceStore.setState({ + pendingOperations: { + 'operation-chat-1': { + id: 'operation-chat-1', + label: '发送创作想法', + command: { + kind: 'apply_input', + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-chat-1', + input: { kind: 'chat', message: '继续完善画面' }, + }, + status: 'submitting', + error: null, + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + }); + openEventsMock.mockResolvedValueOnce(secondSource as unknown as EventSource); + + useImageWorkspaceStore.getState().disconnectEvents(); + useImageWorkspaceStore.getState().connectEvents(); + await vi.waitFor(() => expect(openEventsMock).toHaveBeenCalledTimes(2)); + secondSource.onopen?.(new Event('open')); + const fetchCount = fetchProjectMock.mock.calls.length; + const event = { + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-chat-1', + stage: 'understanding', + message: '正在听懂你刚补充的内容…', + } as const; + + firstSource.emit('design.assistant.progress', { ...event, id: 'session-1:7-old' }); + oldOnOpen?.(new Event('open')); + oldOnError?.(new Event('error')); + expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({}); + expect(useImageWorkspaceStore.getState().eventState).toBe('connected'); + expect(fetchProjectMock).toHaveBeenCalledTimes(fetchCount); + + secondSource.emit('design.assistant.progress', { ...event, id: 'session-1:7' }); + expect(useImageWorkspaceStore.getState().assistantActivities['operation-chat-1']) + .toMatchObject({ status: 'active', steps: [{ stage: 'understanding' }] }); + }); + it('allows a new event connection after opening the previous one failed', async () => { const workspace = designWorkspaceFixture(); const recoveredSource = new FakeEventSource(); @@ -168,6 +224,27 @@ describe('V2 Living Form store', () => { it('uses assistant chunk indexes to ignore replayed deltas', async () => { const source = await loadedStore(); + useImageWorkspaceStore.setState({ + pendingOperations: { + 'operation-chat-1': { + id: 'operation-chat-1', + label: '发送创作想法', + command: { + kind: 'apply_input', + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-chat-1', + input: { kind: 'chat', message: '继续完善画面' }, + }, + status: 'submitting', + error: null, + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + }); const baseEvent = { type: 'design.assistant.delta', workspaceId: 'workspace-1', @@ -193,10 +270,17 @@ describe('V2 Living Form store', () => { chunkIndex: 1, delta: ',正在整理', }); + source.emit('design.assistant.delta', { + ...baseEvent, + id: 'session-1:9', + chunkIndex: 1, + delta: ',正在整理', + }); expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({ 'operation-chat-1': '收到,正在整理', }); + expect(useImageWorkspaceStore.getState().lastEventId).toBe('session-1:9'); }); it('keeps a field draft separate until the direct edit succeeds', async () => { @@ -267,10 +351,762 @@ describe('V2 Living Form store', () => { }); }); + it('keeps public AI activity with its chat operation and settles it into the canonical turn', async () => { + const source = await loadedStore(); + const message = '画一只在月球踢球的熊猫'; + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft(message); + + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + const progress = { + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + } as const; + source.emit('design.assistant.progress', { + ...progress, + id: 'session-1:7', + stage: 'understanding', + message: '正在听懂你刚补充的内容…', + }); + source.emit('design.assistant.progress', { + ...progress, + id: 'session-1:8', + stage: 'reviewing_context', + message: '正在把它和前面的设计想法放在一起看…', + }); + + expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']) + .toMatchObject({ + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'active', + turnId: null, + steps: [ + { stage: 'understanding', message: '正在听懂你刚补充的内容…' }, + { stage: 'reviewing_context', message: '正在把它和前面的设计想法放在一起看…' }, + ], + }); + + source.emit('design.assistant.progress', { + ...progress, + id: 'session-1:9', + stage: 'understanding', + message: '迟到的旧阶段', + }); + expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']?.steps) + .toHaveLength(2); + + source.emit('design.assistant.delta', { + id: 'session-1:10', + type: 'design.assistant.delta', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + directionRevision: 5, + chunkIndex: 0, + delta: '这个画面很有趣。', + }); + expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']?.status) + .toBe('completed'); + + const workspace = designWorkspaceFixture({ + turns: [ + { + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '这个画面很有趣。', + }, + { + turnId: 'turn-3', + rawTurnSequence: 3, + userMessage: '后来补充的另一条消息', + assistantMessage: '这是后来的回复。', + }, + ], + }); + submission.resolve({ clientOperationId: 'operation-1', runId: 'run-2', workspace }); + await sendPromise; + + expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']) + .toMatchObject({ status: 'completed', turnId: 'turn-2' }); + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + }); + + it('settles an unknown chat from a replayed Gateway terminal event', async () => { + const source = await loadedStore(); + const message = '画一只在月球踢球的熊猫'; + const canonical = designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + turns: [{ + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '你希望它更像漫画还是电影?', + }], + }); + fetchProjectMock.mockResolvedValueOnce(canonical); + useImageWorkspaceStore.setState({ + chatDraft: message, + pendingOperations: { + 'operation-chat-unknown': { + id: 'operation-chat-unknown', + label: '发送创作想法', + command: { + kind: 'apply_input', + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-chat-unknown', + input: { kind: 'chat', message }, + }, + status: 'unknown', + error: '连接暂时中断', + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-unknown': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'reviewing_context', message: '正在整理上下文' }], + }, + }, + }); + + source.emit('command.completed', { + id: 'session-1:20', + type: 'command.completed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-chat-unknown', + outcome: 'succeeded', + errorCode: null, + }); + + await vi.waitFor(() => { + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '', + workspace: { form: { rawTurnSequence: 2 } }, + assistantActivities: { + 'operation-chat-unknown': { status: 'completed', turnId: 'turn-2' }, + }, + }); + }); + + it('keeps a succeeded terminal event authoritative when command polling then disconnects', async () => { + const source = await loadedStore(); + const message = '画一只在月球踢球的熊猫'; + const canonical = designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + turns: [{ + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '你希望它更像漫画还是电影?', + }], + }); + fetchProjectMock.mockResolvedValue(canonical); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft(message); + + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + source.emit('command.completed', { + id: 'session-1:20', + type: 'command.completed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-1', + outcome: 'succeeded', + errorCode: null, + }); + submission.reject(new ImageWorkspaceApiError( + 502, + 'IMAGE_WORKSPACE_REQUEST_FAILED', + '轮询连接中断', + 'unknown', + )); + + await expect(sendPromise).resolves.toMatchObject({ + form: { rawTurnSequence: 2 }, + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '', + error: null, + pendingOperations: {}, + workspace: { form: { rawTurnSequence: 2 } }, + }); + }); + + it('keeps a failed terminal event authoritative when command polling then disconnects', async () => { + const source = await loadedStore(); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft('画一只机器人'); + + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + source.emit('command.failed', { + id: 'session-1:20', + type: 'command.failed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-1', + outcome: 'failed', + errorCode: 'design_reasoner_invalid', + }); + submission.reject(new ImageWorkspaceApiError( + 502, + 'IMAGE_WORKSPACE_REQUEST_FAILED', + '轮询连接中断', + 'unknown', + )); + + await expect(sendPromise).rejects.toMatchObject({ + code: 'design_reasoner_invalid', + commandOutcome: 'definitive_failure', + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '画一只机器人', + error: 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次', + pendingOperations: {}, + assistantActivities: {}, + assistantStreams: {}, + }); + }); + + it('does not let a late polling success overwrite a Gateway terminal failure', async () => { + const source = await loadedStore(); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft('画一只机器人'); + + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + source.emit('command.failed', { + id: 'session-1:20', + type: 'command.failed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-1', + outcome: 'failed', + errorCode: 'design_reasoner_invalid', + }); + submission.resolve({ + clientOperationId: 'operation-1', + runId: 'run-1', + workspace: designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + }), + }); + + await expect(sendPromise).rejects.toMatchObject({ + code: 'design_reasoner_invalid', + commandOutcome: 'definitive_failure', + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '画一只机器人', + error: 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次', + pendingOperations: {}, + }); + }); + + it('does not resurrect pending after direction success settles before polling disconnects', async () => { + const source = await loadedStore(); + const message = '画一只在月球踢球的熊猫'; + const canonical = designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + turns: [{ + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '你希望它更像漫画还是电影?', + }], + }); + fetchProjectMock.mockResolvedValue(canonical); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft(message); + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + + source.emit('design.direction.updated', { + id: 'session-1:19', + type: 'design.direction.updated', + clientOperationId: 'operation-1', + replayed: false, + operation: { + operationKind: 'chat', + interactionId: 'interaction-2', + baseDirectionRevision: 4, + newDirectionRevision: 5, + rawTurnSequence: 2, + specificationRevision: 3, + specificationRevisionId: 'specification-revision-3', + workspaceViewRevision: 7, + specificationRevisionCreated: false, + meaningChanged: false, + changeSet: { interaction_id: 'interaction-2', changes: [] }, + turnId: 'turn-2', + assistantMessage: '你希望它更像漫画还是电影?', + createdDecisionPromptIds: [], + resolvedDecisionPromptId: null, + supersededDecisionPromptIds: [], + supersededQuoteCount: 0, + quoteId: null, + generationTaskId: null, + }, + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + }); + await vi.waitFor(() => { + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + }); + source.emit('command.completed', { + id: 'session-1:20', + type: 'command.completed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-1', + outcome: 'succeeded', + errorCode: null, + }); + submission.reject(new ImageWorkspaceApiError( + 502, + 'IMAGE_WORKSPACE_REQUEST_FAILED', + '轮询连接中断', + 'unknown', + )); + + await expect(sendPromise).resolves.toMatchObject({ form: { rawTurnSequence: 2 } }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '', + error: null, + pendingOperations: {}, + workspace: { form: { rawTurnSequence: 2 } }, + }); + }); + + it('keeps the optimistic chat until a terminal success is visible in the canonical Workspace', async () => { + const source = await loadedStore(); + const message = '画一只在月球踢球的熊猫'; + useImageWorkspaceStore.setState({ + chatDraft: message, + pendingOperations: { + 'operation-chat-unknown': { + id: 'operation-chat-unknown', + label: '发送创作想法', + command: { + kind: 'apply_input', + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-chat-unknown', + input: { kind: 'chat', message }, + }, + status: 'unknown', + error: '连接暂时中断', + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-unknown': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'composing', message: '已经整理好,正在准备回复…' }], + }, + }, + }); + fetchProjectMock.mockResolvedValueOnce(designWorkspaceFixture()); + + source.emit('command.completed', { + id: 'session-1:20', + type: 'command.completed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-chat-unknown', + outcome: 'succeeded', + errorCode: null, + }); + await vi.waitFor(() => { + expect(fetchProjectMock).toHaveBeenCalledTimes(2); + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: message, + pendingOperations: { + 'operation-chat-unknown': { status: 'unknown' }, + }, + }); + + const canonical = designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + turns: [{ + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '你希望它更像漫画还是电影?', + }], + }); + fetchProjectMock.mockResolvedValueOnce(canonical); + source.emit('design.direction.updated', { + id: 'session-1:21', + type: 'design.direction.updated', + clientOperationId: 'operation-chat-unknown', + replayed: false, + operation: { + operationKind: 'chat', + interactionId: 'interaction-2', + baseDirectionRevision: 4, + newDirectionRevision: 5, + rawTurnSequence: 2, + specificationRevision: 3, + specificationRevisionId: 'specification-revision-3', + workspaceViewRevision: 7, + specificationRevisionCreated: false, + meaningChanged: false, + changeSet: { interaction_id: 'interaction-2', changes: [] }, + turnId: 'turn-2', + assistantMessage: '你希望它更像漫画还是电影?', + createdDecisionPromptIds: [], + resolvedDecisionPromptId: null, + supersededDecisionPromptIds: [], + supersededQuoteCount: 0, + quoteId: null, + generationTaskId: null, + }, + form: designFormFixture({ rawTurnSequence: 2, directionRevision: 5 }), + }); + + await vi.waitFor(() => { + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + }); + expect(useImageWorkspaceStore.getState()).toMatchObject({ + chatDraft: '', + assistantActivities: { + 'operation-chat-unknown': { status: 'completed', turnId: 'turn-2' }, + }, + }); + }); + + it('uses the Gateway operation turn ID when identical chats are both pending', async () => { + const source = await loadedStore(); + const message = '再画一只机器人'; + const command = (operationId: string) => ({ + kind: 'apply_input' as const, + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: operationId, + input: { kind: 'chat' as const, message }, + }); + useImageWorkspaceStore.setState({ + pendingOperations: { + 'operation-chat-1': { + id: 'operation-chat-1', + label: '发送创作想法', + command: command('operation-chat-1'), + status: 'unknown', + error: '结果尚未确认', + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + 'operation-chat-2': { + id: 'operation-chat-2', + label: '发送创作想法', + command: command('operation-chat-2'), + status: 'unknown', + error: '结果尚未确认', + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-1': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'composing', message: '已经整理好,正在准备回复…' }], + }, + 'operation-chat-2': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'composing', message: '已经整理好,正在准备回复…' }], + }, + }, + }); + const canonical = designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 3, directionRevision: 6 }), + turns: [ + { + turnId: 'turn-2', + rawTurnSequence: 2, + userMessage: message, + assistantMessage: '第一条相同消息的回复。', + }, + { + turnId: 'turn-3', + rawTurnSequence: 3, + userMessage: message, + assistantMessage: '第二条相同消息的回复。', + }, + ], + }); + fetchProjectMock.mockResolvedValueOnce(canonical); + + source.emit('design.direction.updated', { + id: 'session-1:22', + type: 'design.direction.updated', + clientOperationId: 'operation-chat-2', + replayed: false, + operation: { + operationKind: 'chat', + interactionId: 'interaction-3', + baseDirectionRevision: 5, + newDirectionRevision: 6, + rawTurnSequence: 3, + specificationRevision: 3, + specificationRevisionId: 'specification-revision-3', + workspaceViewRevision: 8, + specificationRevisionCreated: false, + meaningChanged: false, + changeSet: { interaction_id: 'interaction-3', changes: [] }, + turnId: 'turn-3', + assistantMessage: '第二条相同消息的回复。', + createdDecisionPromptIds: [], + resolvedDecisionPromptId: null, + supersededDecisionPromptIds: [], + supersededQuoteCount: 0, + quoteId: null, + generationTaskId: null, + }, + form: designFormFixture({ rawTurnSequence: 3, directionRevision: 6 }), + }); + + await vi.waitFor(() => { + expect(useImageWorkspaceStore.getState().pendingOperations['operation-chat-2']) + .toBeUndefined(); + }); + expect(useImageWorkspaceStore.getState().pendingOperations['operation-chat-1']) + .toBeDefined(); + expect(useImageWorkspaceStore.getState().assistantActivities).toMatchObject({ + 'operation-chat-1': { turnId: null }, + 'operation-chat-2': { status: 'completed', turnId: 'turn-3' }, + }); + }); + + it('settles an unknown chat when Gateway later reports terminal failure', async () => { + const source = await loadedStore(); + useImageWorkspaceStore.setState({ + pendingOperations: { + 'operation-chat-unknown': { + id: 'operation-chat-unknown', + label: '发送创作想法', + command: { + kind: 'apply_input', + workspaceId: 'workspace-1', + sessionId: 'session-1', + expectedDirectionRevision: 4, + clientOperationId: 'operation-chat-unknown', + input: { kind: 'chat', message: '画一只机器人' }, + }, + status: 'unknown', + error: '连接暂时中断', + clearDraftPaths: [], + clearChatDraft: true, + baseRawTurnSequence: 1, + }, + }, + assistantActivities: { + 'operation-chat-unknown': { + workspaceId: 'workspace-1', + directionId: 'direction-1', + status: 'unknown', + turnId: null, + steps: [{ stage: 'reviewing_context', message: '正在整理上下文' }], + }, + }, + }); + + source.emit('command.failed', { + id: 'session-1:20', + type: 'command.failed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-chat-unknown', + outcome: 'failed', + errorCode: 'design_reasoner_invalid', + }); + + expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({}); + expect(useImageWorkspaceStore.getState().error) + .toBe('AI 这次没有完成设计整理,内容还在输入框里,请再试一次'); + }); + + it('does not replace the active Workspace when an earlier Workspace command completes late', async () => { + await loadedStore(); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft('旧项目里的消息'); + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + const workspaceB = designWorkspaceFixture({ + workspace: designSummaryFixture({ + workspaceId: 'workspace-2', + clientWorkspaceId: 'client-workspace-2', + directionId: 'direction-2', + sessionId: 'session-2', + title: '当前项目', + }), + form: designFormFixture({ workspaceId: 'workspace-2', directionId: 'direction-2' }), + }); + useImageWorkspaceStore.setState({ + activeWorkspaceId: 'workspace-2', + workspace: workspaceB, + assistantActivities: {}, + assistantStreams: {}, + }); + + submission.resolve({ + clientOperationId: 'operation-1', + runId: 'run-old', + workspace: designWorkspaceFixture(), + }); + await sendPromise; + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-2', + workspace: { workspace: { workspaceId: 'workspace-2', title: '当前项目' } }, + pendingOperations: {}, + }); + }); + + it('does not apply an old A result after the user switches A to B and back to A', async () => { + await loadedStore(); + const submission = deferred>>(); + submitCommandMock.mockReturnValueOnce(submission.promise); + useImageWorkspaceStore.getState().setChatDraft('旧的 A 项目消息'); + const sendPromise = useImageWorkspaceStore.getState().sendChat(); + + const workspaceB = designWorkspaceFixture({ + workspace: designSummaryFixture({ + workspaceId: 'workspace-2', + clientWorkspaceId: 'client-workspace-2', + directionId: 'direction-2', + sessionId: 'session-2', + title: '项目 B', + }), + form: designFormFixture({ workspaceId: 'workspace-2', directionId: 'direction-2' }), + }); + const currentWorkspaceA = designWorkspaceFixture({ + workspace: designSummaryFixture({ workspaceViewRevision: 12, title: '重新打开的项目 A' }), + form: designFormFixture({ rawTurnSequence: 4, workspaceViewRevision: 12 }), + turns: [{ + turnId: 'turn-4', + rawTurnSequence: 4, + userMessage: 'A 项目中较新的消息', + assistantMessage: '这是较新的回复。', + }], + }); + fetchProjectMock + .mockResolvedValueOnce(workspaceB) + .mockResolvedValueOnce(currentWorkspaceA); + await useImageWorkspaceStore.getState().selectProject('workspace-2'); + await useImageWorkspaceStore.getState().selectProject('workspace-1'); + + submission.resolve({ + clientOperationId: 'operation-1', + runId: 'run-old-a', + workspace: designWorkspaceFixture({ + form: designFormFixture({ rawTurnSequence: 2, workspaceViewRevision: 7 }), + }), + }); + await sendPromise; + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-1', + pendingOperations: {}, + workspace: { + workspace: { title: '重新打开的项目 A', workspaceViewRevision: 12 }, + form: { rawTurnSequence: 4, workspaceViewRevision: 12 }, + }, + }); + }); + + it('does not show a late B loading error after the user returns to A', async () => { + await loadedStore(); + const workspaceB = deferred>>(); + const currentWorkspaceA = designWorkspaceFixture({ + workspace: designSummaryFixture({ workspaceViewRevision: 12, title: '重新打开的项目 A' }), + form: designFormFixture({ rawTurnSequence: 4, workspaceViewRevision: 12 }), + }); + fetchProjectMock + .mockReturnValueOnce(workspaceB.promise) + .mockResolvedValueOnce(currentWorkspaceA); + + const selectBPromise = useImageWorkspaceStore.getState().selectProject('workspace-2'); + await useImageWorkspaceStore.getState().selectProject('workspace-1'); + workspaceB.reject(new Error('项目 B 加载失败')); + await selectBPromise; + + expect(useImageWorkspaceStore.getState()).toMatchObject({ + activeWorkspaceId: 'workspace-1', + error: null, + workspace: { + workspace: { title: '重新打开的项目 A', workspaceViewRevision: 12 }, + form: { rawTurnSequence: 4, workspaceViewRevision: 12 }, + }, + }); + }); + + it('ignores progress and reply deltas that do not belong to the active pending chat', async () => { + const source = await loadedStore(); + + source.emit('design.assistant.progress', { + id: 'session-1:7', + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'unrelated-operation', + stage: 'understanding', + message: '不应显示', + }); + source.emit('design.assistant.delta', { + id: 'session-1:8', + type: 'design.assistant.delta', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'unrelated-operation', + directionRevision: 4, + chunkIndex: 0, + delta: '不应显示', + }); + + expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({}); + expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({}); + expect(useImageWorkspaceStore.getState().lastEventId).toBe('session-1:8'); + }); + it('settles a definitive reasoner failure without losing the chat draft', async () => { const source = await loadedStore(); useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人'); submitCommandMock.mockImplementationOnce(async () => { + source.emit('design.assistant.progress', { + id: 'session-1:6', + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + stage: 'reviewing_context', + message: '正在把它和前面的设计想法放在一起看…', + }); source.emit('design.assistant.delta', { id: 'session-1:7', type: 'design.assistant.delta', @@ -299,6 +1135,7 @@ describe('V2 Living Form store', () => { error: 'AI 没有整理好这次想法,请再试一次', }); expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({}); + expect(useImageWorkspaceStore.getState().assistantActivities).toEqual({}); expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({}); }); @@ -324,6 +1161,15 @@ describe('V2 Living Form store', () => { const source = await loadedStore(); useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人'); submitCommandMock.mockImplementationOnce(async () => { + source.emit('design.assistant.progress', { + id: 'session-1:6', + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-1', + stage: 'reviewing_context', + message: '正在把它和前面的设计想法放在一起看…', + }); source.emit('design.assistant.delta', { id: 'session-1:7', type: 'design.assistant.delta', @@ -356,6 +1202,8 @@ describe('V2 Living Form store', () => { expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({ 'operation-1': '正在整理', }); + expect(useImageWorkspaceStore.getState().assistantActivities['operation-1']) + .toMatchObject({ status: 'unknown', steps: [{ stage: 'reviewing_context' }] }); }); it('expires quote blockers only after the canonical Specification changes', async () => { @@ -540,6 +1388,7 @@ describe('V2 Living Form store', () => { error: '结果尚未确认', clearDraftPaths: [], clearChatDraft: true, + baseRawTurnSequence: 1, }, }, quoteBlockers: [{ @@ -562,6 +1411,7 @@ describe('V2 Living Form store', () => { source.emit('design.direction.updated', { id: 'session-1:8', type: 'design.direction.updated', + clientOperationId: 'operation-chat-1', replayed: false, operation: { operationKind: 'chat', @@ -594,7 +1444,6 @@ describe('V2 Living Form store', () => { expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({ 'operation-chat-unknown': '这段回复还没确认', - 'operation-chat-1': '正在整理', }); expect(useImageWorkspaceStore.getState()).toMatchObject({ lastEventId: 'session-1:8', diff --git a/tests/unit/works-square-design-workspace.test.ts b/tests/unit/works-square-design-workspace.test.ts index cd1eea6..02ff012 100644 --- a/tests/unit/works-square-design-workspace.test.ts +++ b/tests/unit/works-square-design-workspace.test.ts @@ -390,6 +390,198 @@ describe('Works Square V2 Design Workspace adapter', () => { subscription.close(); }); + it('normalizes bounded public AI activity without exposing provider details', async () => { + const socket = createSocket(); + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ stream_url: 'https://works.example/api/agents/stream/ticket-1' }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const module = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://works.example', + fetchImpl: fetchMock as unknown as typeof fetch, + webSocketFactory: async () => { + setTimeout(() => socket.onopen?.(), 0); + return { socket }; + }, + }); + const subscription = await module.openWorkspaceEvents({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + }); + const nextEvent = subscription.events[Symbol.asyncIterator]().next(); + + socket.onmessage?.({ + data: JSON.stringify({ + type: 'event', + event: { + session_id: 'session-1', + sequence: 8, + runtime: 'design', + type: 'design.assistant.progress', + schema_version: 1, + payload: { + workspace_id: 'workspace-1', + direction_id: 'direction-1', + client_operation_id: 'operation-chat-1', + stage: 'reviewing_context', + message: 'RAW provider chain-of-thought must not cross Main', + }, + }, + }), + }); + + await expect(nextEvent).resolves.toEqual({ + done: false, + value: { + id: 'session-1:8', + type: 'design.assistant.progress', + workspaceId: 'workspace-1', + directionId: 'direction-1', + clientOperationId: 'operation-chat-1', + stage: 'reviewing_context', + message: '正在把它和前面的设计想法放在一起看…', + }, + }); + subscription.close(); + }); + + it('carries the Gateway operation identity with the canonical Design turn', async () => { + const socket = createSocket(); + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ stream_url: 'https://works.example/api/agents/stream/ticket-1' }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const module = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://works.example', + fetchImpl: fetchMock as unknown as typeof fetch, + webSocketFactory: async () => { + setTimeout(() => socket.onopen?.(), 0); + return { socket }; + }, + }); + const subscription = await module.openWorkspaceEvents({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + }); + const nextEvent = subscription.events[Symbol.asyncIterator]().next(); + + socket.onmessage?.({ + data: JSON.stringify({ + type: 'event', + event: { + session_id: 'session-1', + sequence: 9, + runtime: 'design', + type: 'design.direction.updated', + client_command_id: 'operation-chat-1', + schema_version: 1, + payload: { + replayed: false, + operation: { + operation_kind: 'chat', + interaction_id: 'interaction-2', + base_direction_revision: 4, + new_direction_revision: 5, + raw_turn_sequence: 2, + specification_revision: 3, + specification_revision_id: 'specification-revision-3', + workspace_view_revision: 7, + specification_revision_created: false, + meaning_changed: false, + change_set: { interaction_id: 'interaction-2', changes: [] }, + turn_id: 'turn-2', + assistant_message: '你希望它更像漫画还是电影?', + created_decision_prompt_ids: [], + resolved_decision_prompt_id: null, + superseded_decision_prompt_ids: [], + superseded_quote_count: 0, + quote_id: null, + generation_task_id: null, + }, + form: { ...serverForm, raw_turn_sequence: 2, direction_revision: 5 }, + }, + }, + }), + }); + + await expect(nextEvent).resolves.toMatchObject({ + done: false, + value: { + id: 'session-1:9', + type: 'design.direction.updated', + clientOperationId: 'operation-chat-1', + operation: { turnId: 'turn-2' }, + }, + }); + subscription.close(); + }); + + it('normalizes Gateway command terminal events by their stable operation identity', async () => { + const socket = createSocket(); + const fetchMock = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/stream-tickets')) { + return jsonResponse({ stream_url: 'https://works.example/api/agents/stream/ticket-1' }); + } + throw new Error(`Unexpected URL: ${url}`); + }); + const module = new WorksSquareDesignWorkspace({ + apiBaseUrl: 'https://works.example', + fetchImpl: fetchMock as unknown as typeof fetch, + webSocketFactory: async () => { + setTimeout(() => socket.onopen?.(), 0); + return { socket }; + }, + }); + const subscription = await module.openWorkspaceEvents({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + }); + const iterator = subscription.events[Symbol.asyncIterator](); + const nextEvent = iterator.next(); + + socket.onmessage?.({ + data: JSON.stringify({ + type: 'event', + event: { + session_id: 'session-1', + sequence: 9, + runtime: 'design', + type: 'command.failed', + client_command_id: 'operation-chat-1', + schema_version: 1, + terminal: true, + payload: { + error: { + code: 'design_reasoner_invalid', + message: 'private technical detail', + retryable: false, + }, + }, + }, + }), + }); + + await expect(nextEvent).resolves.toEqual({ + done: false, + value: { + id: 'session-1:9', + type: 'command.failed', + workspaceId: 'workspace-1', + clientOperationId: 'operation-chat-1', + outcome: 'failed', + errorCode: 'design_reasoner_invalid', + }, + }); + subscription.close(); + }); + it('refreshes authentication once after a 401', async () => { getTokenMock.mockResolvedValueOnce('expired-token').mockResolvedValueOnce('fresh-token'); const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {