This commit is contained in:
2026-05-11 20:27:41 +08:00
parent e30b733b17
commit 481a27fc53
60 changed files with 6357 additions and 1100 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import { useEffect } from 'react';
import { useEffect, useRef, useState } from 'react';
import { expect, test, vi } from 'vitest';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
@@ -31,6 +31,7 @@ function TestHarness({
options?: { onUpdate?: (text: string) => void },
) => Promise<TestSession>;
}) {
const hasOpenedRef = useRef(false);
const flow = usePlatformCreationAgentFlowController<
TestSession,
Record<string, never>,
@@ -80,8 +81,12 @@ function TestHarness({
});
useEffect(() => {
if (hasOpenedRef.current) {
return;
}
hasOpenedRef.current = true;
void flow.openWorkspace({});
}, []);
}, [flow]);
return (
<div>
@@ -98,7 +103,9 @@ function TestHarness({
</button>
<div data-testid="messages">
{flow.session?.messages.map((message) => (
<div key={message.id}>{`${message.role}:${message.kind}:${message.text}`}</div>
<div
key={message.id}
>{`${message.role}:${message.kind}:${message.text}`}</div>
))}
</div>
{flow.error ? <div>{flow.error}</div> : null}
@@ -106,6 +113,193 @@ function TestHarness({
);
}
type ActionTestSession = TestSession & {
draft?: { profileId: string } | null;
};
function ActionErrorHarness({
onActionError,
}: {
onActionError: (params: {
session: ActionTestSession;
setSession: (session: ActionTestSession) => void;
}) => void;
}) {
const [stage, setStage] = useState('platform');
const hasOpenedRef = useRef(false);
const flow = usePlatformCreationAgentFlowController<
ActionTestSession,
Record<string, never>,
{ session: ActionTestSession },
TestMessagePayload,
{ action: string },
{ session: ActionTestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
getSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
streamMessage: async () => ({
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
}),
executeAction: async () => {
throw new Error('模型生成失败');
},
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: () => true,
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: setStage,
onActionError: ({ session, setSession }) => {
onActionError({ session, setSession });
},
});
useEffect(() => {
if (hasOpenedRef.current) {
return;
}
hasOpenedRef.current = true;
void flow.openWorkspace({});
}, [flow]);
return (
<div>
<button
type="button"
onClick={() => {
void flow.executeAction({ action: 'match3d_compile_draft' });
}}
>
</button>
<div data-testid="stage">{stage}</div>
<div data-testid="profile">
{flow.session?.draft?.profileId ?? 'missing'}
</div>
{flow.error ? <div>{flow.error}</div> : null}
</div>
);
}
function ActionCompleteHarness({
onActionComplete,
}: {
onActionComplete: () => { openResult?: boolean } | void;
}) {
const [stage, setStage] = useState('platform');
const hasOpenedRef = useRef(false);
const flow = usePlatformCreationAgentFlowController<
ActionTestSession,
Record<string, never>,
{ session: ActionTestSession },
TestMessagePayload,
{ action: string },
{ session: ActionTestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
getSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
streamMessage: async () => ({
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
}),
executeAction: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-ready-1' },
},
}),
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: () => true,
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: setStage,
onActionComplete: ({ setSession, response }) => {
setSession(response.session);
return onActionComplete();
},
});
useEffect(() => {
if (hasOpenedRef.current) {
return;
}
hasOpenedRef.current = true;
void flow.openWorkspace({});
}, [flow]);
return (
<div>
<button
type="button"
onClick={() => {
void flow.executeAction({ action: 'match3d_compile_draft' });
}}
>
</button>
<div data-testid="stage">{stage}</div>
<div data-testid="profile">
{flow.session?.draft?.profileId ?? 'missing'}
</div>
</div>
);
}
test('creation agent flow preserves streamed assistant text when stream fails', async () => {
const streamMessage = vi.fn(async (_sessionId, _payload, options) => {
options?.onUpdate?.('先把方洞万能的反差定住。');
@@ -123,9 +317,7 @@ test('creation agent flow preserves streamed assistant text when stream fails',
});
await waitFor(() => {
expect(
screen.getByText('方洞挑战聊天生成失败LLM 请求超时'),
).toBeTruthy();
expect(screen.getByText('方洞挑战聊天生成失败LLM 请求超时')).toBeTruthy();
});
expect(screen.getByTestId('messages').textContent).toContain(
@@ -135,3 +327,67 @@ test('creation agent flow preserves streamed assistant text when stream fails',
'assistant:warning:先把方洞万能的反差定住。',
);
});
test('creation agent flow exposes session setter after compile action fails', async () => {
const onActionError = vi.fn(
({
setSession,
}: {
session: ActionTestSession;
setSession: (session: ActionTestSession) => void;
}) => {
setSession({
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-after-failure' },
});
},
);
render(<ActionErrorHarness onActionError={onActionError} />);
await waitFor(() => {
expect(screen.getByRole('button', { name: '执行' })).toBeTruthy();
});
await act(async () => {
screen.getByRole('button', { name: '执行' }).click();
});
await waitFor(() => {
expect(screen.getByText('模型生成失败')).toBeTruthy();
});
expect(onActionError).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('profile').textContent).toBe(
'profile-after-failure',
);
expect(screen.getByTestId('stage').textContent).toBe(
'match3d-agent-workspace',
);
});
test('creation agent flow suppresses compile result stage for background completion', async () => {
const onActionComplete = vi.fn(() => ({ openResult: false }));
render(<ActionCompleteHarness onActionComplete={onActionComplete} />);
await waitFor(() => {
expect(screen.getByTestId('stage').textContent).toBe(
'match3d-agent-workspace',
);
});
await act(async () => {
screen.getByRole('button', { name: '执行' }).click();
});
await waitFor(() => {
expect(onActionComplete).toHaveBeenCalledTimes(1);
});
expect(screen.getByTestId('profile').textContent).toBe('profile-ready-1');
expect(screen.getByTestId('stage').textContent).toBe(
'match3d-agent-workspace',
);
});

View File

@@ -75,16 +75,16 @@ type PlatformCreationAgentFlowControllerOptions<
enterCreateTab: () => void;
setSelectionStage: (stage: SelectionStage) => void;
onSessionOpened?: () => void;
onOpenError?: (params: {
error: unknown;
errorMessage: string;
}) => void;
onOpenError?: (params: { error: unknown; errorMessage: string }) => void;
onActionComplete?: (params: {
payload: TActionPayload;
response: TActionResponse;
session: TSession;
setSession: (session: TSession) => void;
}) => Promise<void> | void;
}) =>
| Promise<{ openResult?: boolean } | void>
| { openResult?: boolean }
| void;
beforeExecuteAction?: (params: {
payload: TActionPayload;
session: TSession;
@@ -93,12 +93,14 @@ type PlatformCreationAgentFlowControllerOptions<
payload: TActionPayload;
error: unknown;
errorMessage: string;
}) => void;
session: TSession;
setSession: (session: TSession) => void;
}) => void | Promise<void>;
};
function buildOptimisticMessage<TMessagePayload extends CreationAgentMessageLike>(
payload: TMessagePayload,
) {
function buildOptimisticMessage<
TMessagePayload extends CreationAgentMessageLike,
>(payload: TMessagePayload) {
return {
id: payload.clientMessageId,
role: 'user',
@@ -157,40 +159,43 @@ export function usePlatformCreationAgentFlowController<
setIsStreamingReply(false);
}, []);
const openWorkspace = useCallback(async (createPayload?: TCreatePayload) => {
if (isBusy) {
return null;
}
const openWorkspace = useCallback(
async (createPayload?: TCreatePayload) => {
if (isBusy) {
return null;
}
setIsBusy(true);
setError(null);
resetStreamingReply();
setIsBusy(true);
setError(null);
resetStreamingReply();
try {
const response = await options.client.createSession(
createPayload ?? options.createPayload,
);
const nextSession = options.client.selectSession(response);
setSession(nextSession);
options.enterCreateTab();
options.onSessionOpened?.();
options.setSelectionStage(options.workspaceStage);
return nextSession;
} catch (caughtError) {
const errorMessage = options.resolveErrorMessage(
caughtError,
options.errorMessages.open,
);
setError(errorMessage);
options.onOpenError?.({
error: caughtError,
errorMessage,
});
return null;
} finally {
setIsBusy(false);
}
}, [isBusy, options, resetStreamingReply]);
try {
const response = await options.client.createSession(
createPayload ?? options.createPayload,
);
const nextSession = options.client.selectSession(response);
setSession(nextSession);
options.enterCreateTab();
options.onSessionOpened?.();
options.setSelectionStage(options.workspaceStage);
return nextSession;
} catch (caughtError) {
const errorMessage = options.resolveErrorMessage(
caughtError,
options.errorMessages.open,
);
setError(errorMessage);
options.onOpenError?.({
error: caughtError,
errorMessage,
});
return null;
} finally {
setIsBusy(false);
}
},
[isBusy, options, resetStreamingReply],
);
const restoreDraft = useCallback(
async (sessionId: string | null | undefined) => {
@@ -215,7 +220,10 @@ export function usePlatformCreationAgentFlowController<
return nextSession;
} catch (caughtError) {
setError(
options.resolveErrorMessage(caughtError, options.errorMessages.restore),
options.resolveErrorMessage(
caughtError,
options.errorMessages.restore,
),
);
options.enterCreateTab();
options.setSelectionStage(options.platformStage);
@@ -259,8 +267,7 @@ export function usePlatformCreationAgentFlowController<
setSession(nextSession);
updateStreamingReplyText('');
} catch (caughtError) {
const interruptedReplyText =
latestStreamingReplyTextRef.current.trim();
const interruptedReplyText = latestStreamingReplyTextRef.current.trim();
// 上游流可能在已经吐出可读回复后才失败;把这段回复落进本地消息列表,避免 UI 收尾时突然消失。
if (interruptedReplyText) {
const interruptedMessage =
@@ -276,7 +283,10 @@ export function usePlatformCreationAgentFlowController<
);
}
setError(
options.resolveErrorMessage(caughtError, options.errorMessages.submit),
options.resolveErrorMessage(
caughtError,
options.errorMessages.submit,
),
);
} finally {
setIsStreamingReply(false);
@@ -301,13 +311,17 @@ export function usePlatformCreationAgentFlowController<
targetSession.sessionId,
payload,
);
await options.onActionComplete?.({
const actionCompleteResult = await options.onActionComplete?.({
payload,
response,
session: targetSession,
setSession,
});
if (options.isCompileAction(payload)) {
if (
options.isCompileAction(payload) &&
(typeof actionCompleteResult !== 'object' ||
actionCompleteResult?.openResult !== false)
) {
options.setSelectionStage(options.resultStage);
}
} catch (caughtError) {
@@ -316,10 +330,12 @@ export function usePlatformCreationAgentFlowController<
options.errorMessages.execute,
);
setError(errorMessage);
options.onActionError?.({
await options.onActionError?.({
payload,
error: caughtError,
errorMessage,
session: targetSession,
setSession,
});
} finally {
setIsBusy(false);