fix(design): preserve command outcome certainty
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# Task: Diagnose AI Design message send failure in client
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260903-design-message-send-client-8d3f2a71
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260903-design-message-send-client-8d3f2a71-design-message-send-client
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-design-send-failure-8d3f2a71
|
||||
- Base commit: e9875145b41a2cb1827de10d27a4fc6a352704ea
|
||||
- Owner: codex-root
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Diagnose the reported AI Design chat path from the Renderer composer through the
|
||||
Main-owned Works Square V2 command submission, Agent run polling, canonical
|
||||
Workspace refresh, and Renderer pending-operation reconciliation.
|
||||
- Own only the Main-owned Works Square V2 adapter/error boundary, its local Host API
|
||||
error transport, the Renderer Image Workspace API/store/chat presentation, their
|
||||
shared command-outcome type, focused tests, and this task record.
|
||||
- Do not modify the Works Square server while its task registry is inconsistent.
|
||||
Do not touch Plugin Marketplace/My Plugins/Project Plugins pages, plugin
|
||||
navigation, sidebar/router code, database/API DTOs, packaging, deployment,
|
||||
publication, or paid Provider activation.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Reproduce the exact user-visible failure with a narrow automated seam before
|
||||
changing behavior, and distinguish command receipt from Agent-run success and
|
||||
canonical Workspace commit.
|
||||
- Preserve ADR-007: the Current Specification remains authoritative, mutation
|
||||
transport ambiguity retains the original stable command/operation identity, and
|
||||
no timeout or failure creates an automatic fresh request.
|
||||
- Preserve the local chat draft until success is confirmed. Project terminal Agent
|
||||
failures and transport-unknown results with accurate, youth-readable Chinese copy
|
||||
instead of presenting every rejection as a network send failure.
|
||||
- Treat the supplied trace as evidence only for request dispatch and polling: it has
|
||||
no `reasoner_response` or successful canonical update event. Do not infer a
|
||||
completed write from HTTP access-log `200` lines alone.
|
||||
|
||||
## Concurrent Task Gate
|
||||
|
||||
- Task ID: `20260903-design-message-send-client-8d3f2a71`.
|
||||
- Mode: Feature.
|
||||
- Branch/worktree:
|
||||
`codex/20260903-design-message-send-client-8d3f2a71-design-message-send-client` /
|
||||
`D:\Datas\OthersProjects\makelore-design-send-failure-8d3f2a71`.
|
||||
- Base commit: `e9875145b41a2cb1827de10d27a4fc6a352704ea`.
|
||||
- Ownership result: isolated and claimed by `codex-root`; the canonical root's three
|
||||
existing untracked task records remain untouched.
|
||||
- Peer boundary: `20260903-plugin-navigation-design-7c4e2a91` owns read-only planning
|
||||
for the three Plugin entrances. This task will not touch its pages, navigation,
|
||||
sidebar, or router. The integrated Youth Design task is read-only historical
|
||||
context and no active peer owns the files listed above.
|
||||
- Gate result: Passed.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
- Read the startup memory set, active task, project positioning, current state,
|
||||
decision index, system overview, business rules, success criteria, evidence index,
|
||||
ADR-007, module map, data flow, and the relevant Youth Design and live-client hang
|
||||
task scopes.
|
||||
- Confirmed the supported V2 flow is Renderer draft -> stable command -> Main-owned
|
||||
Agent command/run -> canonical Workspace refresh; access-log receipt is not the
|
||||
semantic success boundary.
|
||||
- Planning Gate result: Passed for the bounded client diagnosis and minimal fix above.
|
||||
|
||||
## Outcome
|
||||
|
||||
- The supplied production trace proves that the command reached the V2 reasoner and
|
||||
that the client polled the Agent run, but it contains neither a structured reasoner
|
||||
result nor the run's terminal payload. HTTP `200` polling lines therefore do not
|
||||
prove that the design mutation succeeded.
|
||||
- Reproduced three client defects with failing tests: terminal
|
||||
`design_reasoner_invalid` was projected as HTTP 422, terminal Agent failures were
|
||||
retained as transport-unknown operations, and the chat pane discarded the safe
|
||||
failure and always claimed that the message was not sent.
|
||||
- Added an explicit `commandOutcome` contract from Main through the local route,
|
||||
Host API/AppError, Renderer API error, and store. Main now marks a failed/cancelled
|
||||
Agent run as `definitive_failure`; an accepted command whose polling or canonical
|
||||
refresh fails remains `unknown` with the original stable operation ID.
|
||||
- Definitive failures preserve the local chat draft, remove the settled pending
|
||||
operation and any partial assistant stream, and show fixed youth-readable Chinese
|
||||
copy. Unknown outcomes preserve the draft, pending command identity, and partial
|
||||
stream so retry cannot silently create a second mutation. Polling auth expiry is
|
||||
explicitly covered by that unknown path.
|
||||
- Corrected V2 run error status projection, including
|
||||
`design_reasoner_invalid -> 502`, while keeping canonical 409 refresh behavior.
|
||||
- No server, Plugin Marketplace/My Plugins/Project Plugins, navigation, sidebar, or
|
||||
router file was changed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Red phase: the initial focused suite failed in the three reported seams (safe chat
|
||||
copy, terminal store settlement, and reasoner status/copy). The outcome-transport
|
||||
extension then produced six expected failures across Main, local route, Host API,
|
||||
Renderer API, and store before implementation.
|
||||
- Focused and adjacent suites: 10 files / 85 tests passed after implementation;
|
||||
the final stream-settlement pair passed 20/20.
|
||||
- Full ordinary suite: 221 files and 1,822 tests passed, with 2 declared skips. Its
|
||||
only first-run failure occurred before product code loaded because Windows denied
|
||||
the Electron dependency self-installer permission to replace an in-use
|
||||
`electron.exe`. Re-running that real-process file through its supported
|
||||
`MAKELORE_PI_ELECTRON_EXECUTABLE` override passed 2/2 with the same 2 skips.
|
||||
- The isolated pressure suite passed 1/1 with Main-to-React p95 37.94 ms.
|
||||
- `pnpm exec tsc --noEmit`, scoped ESLint for every changed TypeScript file,
|
||||
`pnpm run build:vite`, and `git diff --check` passed. Vite emitted only existing
|
||||
Browserslist, chunk-size, and dynamic/static import warnings.
|
||||
- Independent final read-only review passed with no P1/P2 findings after two found
|
||||
settlement gaps were fixed: non-enumerated terminal errors and accepted-command
|
||||
polling auth ambiguity, followed by partial-stream cleanup on definitive failure.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Query production run `0bb3a074-aee2-43bf-af7d-ca4585b96132` read-only and inspect
|
||||
`status`, `error_code`, retryability, attempts, and timestamps. The attached trace
|
||||
alone cannot distinguish `design_reasoner_invalid`, provider/runtime unavailable,
|
||||
or a still-running run.
|
||||
- If the production run confirms a server-side reasoner failure, remediate it in a
|
||||
separately owned server task after the server task registry inconsistency is
|
||||
repaired; this client task intentionally made no server write.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None. The explicit outcome transport implements the already accepted ADR-007
|
||||
requirement that ambiguous mutation results retain their stable operation identity.
|
||||
@@ -332,6 +332,7 @@ function sendRouteError(res: ServerResponse, error: unknown): void {
|
||||
status: error.status,
|
||||
code: error.code,
|
||||
error: error.message,
|
||||
...(error.commandOutcome ? { commandOutcome: error.commandOutcome } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DesignAsset,
|
||||
DesignAssetUploadInput,
|
||||
DesignCommandFailureOutcome,
|
||||
DesignCommandInput,
|
||||
DesignCommandResult,
|
||||
DesignCreateWorkspaceInput,
|
||||
@@ -20,12 +21,19 @@ export type CloseEventSessionsOptions = {
|
||||
export class DesignWorkspaceModuleError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly commandOutcome?: DesignCommandFailureOutcome;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
constructor(
|
||||
status: number,
|
||||
code: string,
|
||||
message: string,
|
||||
commandOutcome?: DesignCommandFailureOutcome,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DesignWorkspaceModuleError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.commandOutcome = commandOutcome;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type DesignAssistantDeltaEvent,
|
||||
type DesignCapabilities,
|
||||
type DesignChangeSet,
|
||||
type DesignCommandFailureOutcome,
|
||||
type DesignCommandInput,
|
||||
type DesignCommandResult,
|
||||
type DesignCompilationIssue,
|
||||
@@ -628,25 +629,87 @@ function userFacingErrorMessage(code: string): string {
|
||||
budget_denied: '当前设计点不足,无法开始生成',
|
||||
policy_blocked: '当前内容不符合创作安全规则',
|
||||
design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试',
|
||||
design_reasoner_invalid: 'AI 没有整理好这次想法,请再试一次',
|
||||
design_agent_run_failed: 'AI 这次没有完成设计整理,请再试一次',
|
||||
design_agent_run_cancelled: '这次设计整理已停止',
|
||||
design_runtime_unavailable: 'AI 设计服务暂时不可用',
|
||||
design_production_unavailable: '当前生成能力暂时不可用',
|
||||
agent_runtime_unavailable: 'AI 设计服务暂时不可用,请稍后重试',
|
||||
agent_command_invalid: '设计请求内容无效,请检查后重试',
|
||||
auth_required: '请先登录后再使用 AI 设计',
|
||||
auth_expired: '登录状态已失效,请重新登录',
|
||||
};
|
||||
return messages[code] ?? 'AI 设计请求失败,请稍后重试';
|
||||
return messages[code.toLowerCase()] ?? 'AI 设计请求失败,请稍后重试';
|
||||
}
|
||||
|
||||
function agentRunErrorStatus(code: string): number {
|
||||
if (code.includes('not_found')) return 404;
|
||||
if (code === 'budget_denied') return 402;
|
||||
if (code.includes('conflict') || code.includes('expired') || code.includes('consumed')) return 409;
|
||||
if (code.includes('invalid') || code === 'policy_blocked' || code === 'design_quote_blocked') {
|
||||
const normalizedCode = code.toLowerCase();
|
||||
const exactStatuses: Record<string, number> = {
|
||||
design_direction_not_found: 404,
|
||||
design_direction_unavailable: 409,
|
||||
design_revision_conflict: 409,
|
||||
design_idempotency_conflict: 409,
|
||||
design_prompt_unavailable: 409,
|
||||
design_reasoner_unavailable: 503,
|
||||
design_reasoner_invalid: 502,
|
||||
design_field_locked: 409,
|
||||
design_generation_unavailable: 503,
|
||||
design_quote_expired: 409,
|
||||
design_quote_consumed: 409,
|
||||
design_quote_invalid: 409,
|
||||
design_quote_dependency_invalid: 409,
|
||||
budget_denied: 402,
|
||||
design_billing_unavailable: 503,
|
||||
design_state_unavailable: 409,
|
||||
design_command_invalid: 400,
|
||||
design_input_invalid: 422,
|
||||
design_agent_run_failed: 502,
|
||||
design_agent_run_cancelled: 409,
|
||||
};
|
||||
if (exactStatuses[normalizedCode] !== undefined) return exactStatuses[normalizedCode];
|
||||
if (normalizedCode.includes('not_found')) return 404;
|
||||
if (normalizedCode.includes('conflict')
|
||||
|| normalizedCode.includes('expired')
|
||||
|| normalizedCode.includes('consumed')) return 409;
|
||||
if (normalizedCode.includes('invalid')
|
||||
|| normalizedCode === 'policy_blocked'
|
||||
|| normalizedCode === 'design_quote_blocked') {
|
||||
return 422;
|
||||
}
|
||||
if (code.includes('unavailable')) return 503;
|
||||
if (normalizedCode.includes('unavailable')) return 503;
|
||||
return 502;
|
||||
}
|
||||
|
||||
function commandFailureError(
|
||||
error: unknown,
|
||||
outcome: DesignCommandFailureOutcome,
|
||||
): DesignWorkspaceModuleError {
|
||||
if (error instanceof DesignWorkspaceModuleError) {
|
||||
return new DesignWorkspaceModuleError(
|
||||
error.status,
|
||||
error.code,
|
||||
userFacingErrorMessage(error.code),
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
return new DesignWorkspaceModuleError(
|
||||
502,
|
||||
'DESIGN_WORKSPACE_REQUEST_FAILED',
|
||||
'AI 设计服务暂时无法连接',
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
|
||||
function submissionFailureOutcome(error: unknown): DesignCommandFailureOutcome {
|
||||
if (error instanceof DesignWorkspaceModuleError
|
||||
&& error.status >= 400
|
||||
&& error.status < 500
|
||||
&& error.status !== 408) {
|
||||
return 'definitive_failure';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function designRequestTimeoutError(): DesignWorkspaceModuleError {
|
||||
return new DesignWorkspaceModuleError(
|
||||
504,
|
||||
@@ -716,11 +779,22 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
}
|
||||
|
||||
async submitCommand(input: DesignCommandInput): Promise<DesignCommandResult> {
|
||||
const command = await this.requestJson<ServerAgentCommand>(
|
||||
`/api/agents/sessions/${encodeURIComponent(input.sessionId)}/commands`,
|
||||
{ method: 'POST', body: JSON.stringify(agentCommand(input)) },
|
||||
);
|
||||
const run = await this.waitForRun(input.sessionId, command.run_id);
|
||||
let command: ServerAgentCommand;
|
||||
try {
|
||||
command = await this.requestJson<ServerAgentCommand>(
|
||||
`/api/agents/sessions/${encodeURIComponent(input.sessionId)}/commands`,
|
||||
{ method: 'POST', body: JSON.stringify(agentCommand(input)) },
|
||||
);
|
||||
} catch (error) {
|
||||
throw commandFailureError(error, submissionFailureOutcome(error));
|
||||
}
|
||||
|
||||
let run: ServerAgentRun;
|
||||
try {
|
||||
run = await this.waitForRun(input.sessionId, command.run_id);
|
||||
} catch (error) {
|
||||
throw commandFailureError(error, 'unknown');
|
||||
}
|
||||
if (run.status !== 'succeeded') {
|
||||
const code = run.error?.code ?? (
|
||||
run.status === 'cancelled' ? 'design_agent_run_cancelled' : 'design_agent_run_failed'
|
||||
@@ -729,12 +803,19 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
agentRunErrorStatus(code),
|
||||
code,
|
||||
userFacingErrorMessage(code),
|
||||
'definitive_failure',
|
||||
);
|
||||
}
|
||||
let workspace: DesignWorkspace;
|
||||
try {
|
||||
workspace = await this.getWorkspace(input.workspaceId);
|
||||
} catch (error) {
|
||||
throw commandFailureError(error, 'unknown');
|
||||
}
|
||||
return {
|
||||
clientOperationId: input.clientOperationId,
|
||||
runId: run.run_id,
|
||||
workspace: await this.getWorkspace(input.workspaceId),
|
||||
workspace,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export const IMAGE_WORKSPACE_UNAVAILABLE_CODE = 'IMAGE_WORKSPACE_UNAVAILABLE';
|
||||
export const IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE = 'AI 设计暂不可用';
|
||||
export const IMAGE_WORKSPACE_API_PATH = '/api/works/image-workspace';
|
||||
|
||||
export type DesignCommandFailureOutcome = 'definitive_failure' | 'unknown';
|
||||
export type DesignMedium = 'image' | 'video';
|
||||
export type DesignResolution = 'open' | 'proposed' | 'resolved' | 'delegated' | 'not_applicable';
|
||||
export type DesignProvenance =
|
||||
|
||||
@@ -112,14 +112,23 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
let backendCode: string | undefined;
|
||||
let commandOutcome: string | undefined;
|
||||
try {
|
||||
const payload = await response.json() as { error?: string; code?: unknown };
|
||||
const payload = await response.json() as {
|
||||
error?: string;
|
||||
code?: unknown;
|
||||
commandOutcome?: unknown;
|
||||
};
|
||||
if (payload?.error) {
|
||||
message = payload.error;
|
||||
}
|
||||
if (typeof payload?.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/u.test(payload.code)) {
|
||||
backendCode = payload.code;
|
||||
}
|
||||
if (payload?.commandOutcome === 'definitive_failure'
|
||||
|| payload?.commandOutcome === 'unknown') {
|
||||
commandOutcome = payload.commandOutcome;
|
||||
}
|
||||
} catch {
|
||||
// ignore body parse failure
|
||||
}
|
||||
@@ -127,6 +136,7 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
||||
source: 'browser-fallback',
|
||||
status: response.status,
|
||||
...(backendCode ? { backendCode } : {}),
|
||||
...(commandOutcome ? { commandOutcome } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,6 +165,9 @@ function createProxyHttpError(data: HostApiProxyData): Error {
|
||||
return normalizeAppError(new Error(message), {
|
||||
status: data.status,
|
||||
...(payload && typeof payload.code === 'string' ? { backendCode: payload.code } : {}),
|
||||
...(payload?.commandOutcome === 'definitive_failure' || payload?.commandOutcome === 'unknown'
|
||||
? { commandOutcome: payload.commandOutcome }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
designAssetDownloadPath,
|
||||
type DesignAsset,
|
||||
type DesignAssetSaveResult,
|
||||
type DesignCommandFailureOutcome,
|
||||
type DesignCommandInput,
|
||||
type DesignCommandResult,
|
||||
type DesignDeleteWorkspaceResult,
|
||||
@@ -24,6 +25,7 @@ type ImageWorkspaceEnvelope<T> = {
|
||||
status?: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
commandOutcome?: DesignCommandFailureOutcome;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
@@ -32,12 +34,19 @@ export const IMAGE_WORKSPACE_CREATE_PROJECT_EVENT = 'niancode:image-workspace:cr
|
||||
export class ImageWorkspaceApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly commandOutcome?: DesignCommandFailureOutcome;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
constructor(
|
||||
status: number,
|
||||
code: string,
|
||||
message: string,
|
||||
commandOutcome?: DesignCommandFailureOutcome,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ImageWorkspaceApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.commandOutcome = commandOutcome;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +75,11 @@ function getErrorCode(error: unknown, status: number): string {
|
||||
return 'IMAGE_WORKSPACE_REQUEST_FAILED';
|
||||
}
|
||||
|
||||
function getCommandOutcome(error: unknown): DesignCommandFailureOutcome | undefined {
|
||||
const outcome = error instanceof AppError ? error.details?.commandOutcome : undefined;
|
||||
return outcome === 'definitive_failure' || outcome === 'unknown' ? outcome : undefined;
|
||||
}
|
||||
|
||||
async function requestData<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
let response: ImageWorkspaceEnvelope<T>;
|
||||
try {
|
||||
@@ -76,6 +90,7 @@ async function requestData<T>(path: string, init: RequestInit = {}): Promise<T>
|
||||
status,
|
||||
getErrorCode(error, status),
|
||||
error instanceof Error ? error.message : IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
|
||||
getCommandOutcome(error),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,6 +99,7 @@ async function requestData<T>(path: string, init: RequestInit = {}): Promise<T>
|
||||
response.status ?? 502,
|
||||
response.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED',
|
||||
response.error ?? 'AI 设计请求失败',
|
||||
response.commandOutcome,
|
||||
);
|
||||
}
|
||||
return response.data;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
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 { DesignWorkspace } from '../../../shared/image-workspace';
|
||||
@@ -14,6 +15,32 @@ const STARTER_MESSAGES = [
|
||||
'做一张社团活动海报',
|
||||
];
|
||||
|
||||
function chatFailureMessage(error: unknown): string {
|
||||
if (error instanceof ImageWorkspaceApiError) {
|
||||
if (error.commandOutcome === 'unknown') {
|
||||
return error.status === 401 || error.status === 403
|
||||
? '登录已过期,这条消息的结果还没确认;重新登录后请继续原操作'
|
||||
: '暂时没能确认这条消息的处理结果,内容还在输入框里';
|
||||
}
|
||||
const messages: Record<string, string> = {
|
||||
design_reasoner_invalid: 'AI 没有整理好这次想法,请再试一次',
|
||||
design_reasoner_unavailable: 'AI 现在有点忙,暂时没能整理这次想法,请稍后再试',
|
||||
design_agent_run_failed: 'AI 这次没有整理好,内容还在输入框里,请再试一次',
|
||||
design_agent_run_cancelled: '这次整理已停止,内容还在输入框里',
|
||||
design_revision_conflict: '设计内容刚刚更新了,请再发送一次',
|
||||
design_direction_revision_conflict: '设计内容刚刚更新了,请再发送一次',
|
||||
auth_required: '请先登录,再继续创作',
|
||||
auth_expired: '登录已过期,请重新登录后继续创作',
|
||||
};
|
||||
const message = messages[error.code.toLowerCase()];
|
||||
if (message) return message;
|
||||
if (error.commandOutcome === 'definitive_failure') {
|
||||
return 'AI 这次没有完成设计整理,内容还在输入框里,请再试一次';
|
||||
}
|
||||
}
|
||||
return '暂时没能确认这条消息的处理结果,内容还在输入框里';
|
||||
}
|
||||
|
||||
export function DesignConversationPane({ workspace }: { workspace: DesignWorkspace }) {
|
||||
const chatDraft = useImageWorkspaceStore((state) => state.chatDraft);
|
||||
const setChatDraft = useImageWorkspaceStore((state) => state.setChatDraft);
|
||||
@@ -43,8 +70,8 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
|
||||
|
||||
const submit = () => {
|
||||
if (!chatDraft.trim() || submittingChat) return;
|
||||
void sendChat().catch(() => {
|
||||
toast.error('消息没有发出去,请再试一次');
|
||||
void sendChat().catch((error) => {
|
||||
toast.error(chatFailureMessage(error));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -137,9 +137,11 @@ function isUnavailableError(error: unknown): boolean {
|
||||
|
||||
function isDirectionConflict(error: unknown): boolean {
|
||||
return error instanceof ImageWorkspaceApiError
|
||||
&& (error.status === 409
|
||||
|| error.code === 'DESIGN_DIRECTION_REVISION_CONFLICT'
|
||||
|| error.code === 'DIRECTION_REVISION_CONFLICT');
|
||||
&& [
|
||||
'design_revision_conflict',
|
||||
'design_direction_revision_conflict',
|
||||
'direction_revision_conflict',
|
||||
].includes(error.code.toLowerCase());
|
||||
}
|
||||
|
||||
function isQuoteBlocked(error: unknown): boolean {
|
||||
@@ -147,6 +149,15 @@ function isQuoteBlocked(error: unknown): boolean {
|
||||
&& error.code.toLowerCase() === 'design_quote_blocked';
|
||||
}
|
||||
|
||||
function isDefinitiveCommandFailure(error: unknown): error is ImageWorkspaceApiError {
|
||||
return error instanceof ImageWorkspaceApiError
|
||||
&& error.commandOutcome === 'definitive_failure';
|
||||
}
|
||||
|
||||
function isUnknownCommandOutcome(error: unknown): boolean {
|
||||
return error instanceof ImageWorkspaceApiError && error.commandOutcome === 'unknown';
|
||||
}
|
||||
|
||||
function parseWorkspaceEvent(event: Event): DesignWorkspaceEvent | null {
|
||||
const data = (event as MessageEvent<unknown>).data;
|
||||
if (typeof data !== 'string') return null;
|
||||
@@ -299,7 +310,16 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
return result.workspace;
|
||||
} catch (error) {
|
||||
const message = messageForError(error);
|
||||
if (isDirectionConflict(error)) {
|
||||
if (isUnknownCommandOutcome(error)) {
|
||||
set((state) => ({
|
||||
pendingOperations: {
|
||||
...state.pendingOperations,
|
||||
[operation.id]: { ...operation, status: 'unknown', error: message },
|
||||
},
|
||||
...(isAuthError(error) ? { status: 'auth-required' as const } : {}),
|
||||
error: '操作结果尚未确认,可使用同一操作标识安全重试',
|
||||
}));
|
||||
} else if (isDirectionConflict(error)) {
|
||||
set((state) => {
|
||||
const pendingOperations = { ...state.pendingOperations };
|
||||
delete pendingOperations[operation.id];
|
||||
@@ -318,6 +338,15 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
||||
delete pendingOperations[operation.id];
|
||||
return { pendingOperations, error: null };
|
||||
});
|
||||
} else if (isDefinitiveCommandFailure(error)) {
|
||||
set((state) => {
|
||||
const pendingOperations = { ...state.pendingOperations };
|
||||
delete pendingOperations[operation.id];
|
||||
const assistantStreams = { ...state.assistantStreams };
|
||||
delete assistantStreams[operation.id];
|
||||
return { pendingOperations, assistantStreams, error: message };
|
||||
});
|
||||
if (error.status === 409) await get().refreshWorkspace().catch(() => null);
|
||||
} else {
|
||||
set((state) => ({
|
||||
pendingOperations: {
|
||||
|
||||
@@ -54,6 +54,32 @@ describe('host-api', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves command outcome certainty from the Host API error envelope', async () => {
|
||||
invokeIpcMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: {
|
||||
status: 502,
|
||||
ok: false,
|
||||
json: {
|
||||
success: false,
|
||||
code: 'design_reasoner_invalid',
|
||||
error: 'AI 没有整理好这次想法,请再试一次',
|
||||
commandOutcome: 'definitive_failure',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { hostApiFetch } = await import('@/lib/host-api');
|
||||
|
||||
await expect(hostApiFetch('/api/works/image-workspace/workspaces/workspace-1/commands'))
|
||||
.rejects.toMatchObject({
|
||||
details: expect.objectContaining({
|
||||
backendCode: 'design_reasoner_invalid',
|
||||
commandOutcome: 'definitive_failure',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for a unified 204 response', async () => {
|
||||
invokeIpcMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
||||
import { ImageCanvas } from '@/pages/ImageCanvas';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
|
||||
@@ -11,6 +12,12 @@ import {
|
||||
designWorkspaceFixture,
|
||||
} from '../fixtures/design-workspace-v2';
|
||||
|
||||
const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() }));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: toastErrorMock, success: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||
return {
|
||||
@@ -138,6 +145,26 @@ describe('youth AI Design Canvas page', () => {
|
||||
expect(actions.sendChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the safe reasoner failure instead of claiming the message was not sent', async () => {
|
||||
const { actions } = prepareWorkspace();
|
||||
actions.sendChat.mockRejectedValueOnce(new ImageWorkspaceApiError(
|
||||
502,
|
||||
'design_reasoner_invalid',
|
||||
'AI 没有整理好这次想法,请再试一次',
|
||||
));
|
||||
render(<ImageCanvas />);
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '告诉 AI 你想创作什么' }), {
|
||||
target: { value: '画一只会做饭的机器人' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送消息' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('AI 没有整理好这次想法,请再试一次');
|
||||
});
|
||||
expect(toastErrorMock).not.toHaveBeenCalledWith('消息没有发出去,请再试一次');
|
||||
});
|
||||
|
||||
it('explains an unknown accepted result without suggesting a second production', () => {
|
||||
const { actions } = prepareWorkspace();
|
||||
useImageWorkspaceStore.setState({
|
||||
|
||||
@@ -189,12 +189,14 @@ describe('AI design V2 Renderer API boundary', () => {
|
||||
hostApiFetchMock.mockRejectedValueOnce(new AppError('UNKNOWN', '后端拒绝', undefined, {
|
||||
status: 409,
|
||||
backendCode: 'design_direction_revision_conflict',
|
||||
commandOutcome: 'definitive_failure',
|
||||
}));
|
||||
|
||||
await expect(fetchImageWorkspace()).rejects.toEqual(expect.objectContaining({
|
||||
name: 'ImageWorkspaceApiError',
|
||||
status: 409,
|
||||
code: 'design_direction_revision_conflict',
|
||||
commandOutcome: 'definitive_failure',
|
||||
} satisfies Partial<ImageWorkspaceApiError>));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Readable, Writable } from 'node:stream';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleImageWorkspaceRoutes } from '@electron/api/routes/image-workspace';
|
||||
import type { HostApiContext } from '@electron/api/context';
|
||||
import type { DesignWorkspaceModule } from '@electron/image-workspace/module';
|
||||
import {
|
||||
DesignWorkspaceModuleError,
|
||||
type DesignWorkspaceModule,
|
||||
} from '@electron/image-workspace/module';
|
||||
import {
|
||||
designBootstrapFixture,
|
||||
designWorkspaceFixture,
|
||||
@@ -250,6 +253,37 @@ describe('AI design V2 Main route boundary', () => {
|
||||
expect(module.submitCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves command outcome certainty in the local error envelope', async () => {
|
||||
const module = moduleFixture({
|
||||
submitCommand: vi.fn().mockRejectedValue(new DesignWorkspaceModuleError(
|
||||
502,
|
||||
'design_reasoner_invalid',
|
||||
'AI 没有整理好这次想法,请再试一次',
|
||||
'definitive_failure',
|
||||
)),
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handleImageWorkspaceRoutes(
|
||||
createRequest('POST', {
|
||||
kind: 'apply_input',
|
||||
sessionId: 'session-1',
|
||||
expectedDirectionRevision: 4,
|
||||
clientOperationId: 'operation-1',
|
||||
input: { kind: 'chat', message: '画一只会做饭的机器人' },
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/image-workspace/workspaces/workspace-1/commands'),
|
||||
context(module),
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.json()).toMatchObject({
|
||||
code: 'design_reasoner_invalid',
|
||||
commandOutcome: 'definitive_failure',
|
||||
});
|
||||
});
|
||||
|
||||
it('relays resumable normalized events over local SSE', async () => {
|
||||
const close = vi.fn();
|
||||
const openWorkspaceEvents = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -134,6 +134,97 @@ describe('V2 Living Form store', () => {
|
||||
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
||||
});
|
||||
|
||||
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.delta', {
|
||||
id: 'session-1:7',
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-1',
|
||||
directionId: 'direction-1',
|
||||
clientOperationId: 'operation-1',
|
||||
directionRevision: 4,
|
||||
chunkIndex: 0,
|
||||
delta: '正在整理',
|
||||
});
|
||||
throw new ImageWorkspaceApiError(
|
||||
502,
|
||||
'design_reasoner_invalid',
|
||||
'AI 没有整理好这次想法,请再试一次',
|
||||
'definitive_failure',
|
||||
);
|
||||
});
|
||||
|
||||
await expect(useImageWorkspaceStore.getState().sendChat()).rejects.toMatchObject({
|
||||
status: 502,
|
||||
code: 'design_reasoner_invalid',
|
||||
});
|
||||
|
||||
expect(useImageWorkspaceStore.getState()).toMatchObject({
|
||||
chatDraft: '画一只会做饭的机器人',
|
||||
error: 'AI 没有整理好这次想法,请再试一次',
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
||||
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({});
|
||||
});
|
||||
|
||||
it('settles any Main-confirmed terminal run without an error-code allowlist', async () => {
|
||||
await loadedStore();
|
||||
submitCommandMock.mockRejectedValueOnce(new ImageWorkspaceApiError(
|
||||
422,
|
||||
'policy_blocked',
|
||||
'当前内容不符合创作安全规则',
|
||||
'definitive_failure',
|
||||
));
|
||||
|
||||
await expect(useImageWorkspaceStore.getState().requestQuote()).rejects.toMatchObject({
|
||||
code: 'policy_blocked',
|
||||
commandOutcome: 'definitive_failure',
|
||||
});
|
||||
|
||||
expect(useImageWorkspaceStore.getState().pendingOperations).toEqual({});
|
||||
expect(useImageWorkspaceStore.getState().error).toBe('当前内容不符合创作安全规则');
|
||||
});
|
||||
|
||||
it('keeps the accepted command identity when polling auth expires', async () => {
|
||||
const source = await loadedStore();
|
||||
useImageWorkspaceStore.getState().setChatDraft('画一只会做饭的机器人');
|
||||
submitCommandMock.mockImplementationOnce(async () => {
|
||||
source.emit('design.assistant.delta', {
|
||||
id: 'session-1:7',
|
||||
type: 'design.assistant.delta',
|
||||
workspaceId: 'workspace-1',
|
||||
directionId: 'direction-1',
|
||||
clientOperationId: 'operation-1',
|
||||
directionRevision: 4,
|
||||
chunkIndex: 0,
|
||||
delta: '正在整理',
|
||||
});
|
||||
throw new ImageWorkspaceApiError(
|
||||
401,
|
||||
'AUTH_EXPIRED',
|
||||
'登录状态已失效,请重新登录',
|
||||
'unknown',
|
||||
);
|
||||
});
|
||||
|
||||
await expect(useImageWorkspaceStore.getState().sendChat()).rejects.toMatchObject({
|
||||
status: 401,
|
||||
code: 'AUTH_EXPIRED',
|
||||
commandOutcome: 'unknown',
|
||||
});
|
||||
|
||||
expect(useImageWorkspaceStore.getState().chatDraft).toBe('画一只会做饭的机器人');
|
||||
expect(useImageWorkspaceStore.getState().pendingOperations['operation-1']).toMatchObject({
|
||||
id: 'operation-1',
|
||||
status: 'unknown',
|
||||
});
|
||||
expect(useImageWorkspaceStore.getState().assistantStreams).toEqual({
|
||||
'operation-1': '正在整理',
|
||||
});
|
||||
});
|
||||
|
||||
it('expires quote blockers only after the canonical Specification changes', async () => {
|
||||
await loadedStore();
|
||||
const blocker = {
|
||||
|
||||
@@ -254,6 +254,77 @@ describe('Works Square V2 Design Workspace adapter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('projects an invalid reasoner result as a safe actionable message', async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/commands')) {
|
||||
return jsonResponse({ run_id: 'run-1', status: 'queued', error: null });
|
||||
}
|
||||
if (url.endsWith('/runs/run-1')) {
|
||||
return jsonResponse({
|
||||
run_id: 'run-1',
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'design_reasoner_invalid',
|
||||
message: 'Design guidance returned an invalid result',
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
const module = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://works.example',
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(module.submitCommand({
|
||||
kind: 'apply_input',
|
||||
workspaceId: 'workspace-1',
|
||||
sessionId: 'session-1',
|
||||
expectedDirectionRevision: 4,
|
||||
clientOperationId: 'operation-chat-1',
|
||||
input: { kind: 'chat', message: '画一只会做饭的机器人' },
|
||||
})).rejects.toMatchObject({
|
||||
status: 502,
|
||||
code: 'design_reasoner_invalid',
|
||||
message: 'AI 没有整理好这次想法,请再试一次',
|
||||
commandOutcome: 'definitive_failure',
|
||||
} satisfies Partial<DesignWorkspaceModuleError>);
|
||||
});
|
||||
|
||||
it('marks polling auth failure as unknown after the command was accepted', async () => {
|
||||
getTokenMock
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce('access-token')
|
||||
.mockResolvedValueOnce(null);
|
||||
const fetchMock = vi.fn(async (input: string | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('/commands')) {
|
||||
return jsonResponse({ run_id: 'run-1', status: 'queued', error: null });
|
||||
}
|
||||
if (url.endsWith('/runs/run-1')) return jsonResponse({ detail: 'expired' }, 401);
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
const module = new WorksSquareDesignWorkspace({
|
||||
apiBaseUrl: 'https://works.example',
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(module.submitCommand({
|
||||
kind: 'apply_input',
|
||||
workspaceId: 'workspace-1',
|
||||
sessionId: 'session-1',
|
||||
expectedDirectionRevision: 4,
|
||||
clientOperationId: 'operation-chat-1',
|
||||
input: { kind: 'chat', message: '画一只会做饭的机器人' },
|
||||
})).rejects.toMatchObject({
|
||||
status: 401,
|
||||
code: 'AUTH_EXPIRED',
|
||||
commandOutcome: 'unknown',
|
||||
} satisfies Partial<DesignWorkspaceModuleError>);
|
||||
});
|
||||
|
||||
it('resumes the V2 event stream from the supplied sequence and normalizes deltas', async () => {
|
||||
const socket = createSocket();
|
||||
let streamUrl = '';
|
||||
|
||||
Reference in New Issue
Block a user