From 9cff7bf04935392144a91be481514e9cff5a1602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 18:53:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9B=B4=E6=97=A9=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=AF=BB=E5=8F=96=E5=A4=B1=E8=B4=A5=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA=E6=97=B6=E6=8C=82=E8=B5=B7=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=A1=8C=E4=B8=80=E8=B5=B7=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 失败消息统一收口成非空兜底文案,空 message 不再让 historyError 变 falsy - 新增控制器回归用例:宿主抛 new Error() 时挂起态仍成立 Co-authored-by: Junie --- .../useDirectProjectChatController.test.tsx | 64 ++++++++++++++----- .../useDirectProjectChatController.ts | 13 +++- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx index 7aa8e06fc..1b9999c58 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx @@ -10,6 +10,7 @@ type SliceRequest = { projectPath: string; beforeItemId: string | undefined; resolve: (slice: HistorySlice) => void; + reject: (error: unknown) => void; }; function userItem(itemId: string): ThreadItem { @@ -25,7 +26,7 @@ function slice( } /** - * 装一个可控的 Tauri invoke:历史切片读取全部挂起,用例自己决定哪一页何时落地。 + * 装一个可控的 Tauri invoke:历史切片读取全部挂起,用例自己决定哪一页何时落地 / 落空。 * * `resolveTauriInvoke` 只读 `window.__TAURI__.core.invoke`,这里就是控制器唯一的外部接缝。 */ @@ -34,11 +35,12 @@ function installDeferredHistoryInvoke() { const invoke = vi.fn( async (command: string, args?: Record) => { if (command !== 'read_direct_project_history_slice') return undefined; - return new Promise((resolve) => { + return new Promise((resolve, reject) => { requests.push({ projectPath: String(args?.projectPath ?? ''), beforeItemId: args?.beforeItemId as string | undefined, resolve, + reject, }); }); }, @@ -64,6 +66,23 @@ function installDeferredHistoryInvoke() { }; } +function renderChatController(initialProjectPath: string) { + const allowed = async () => true; + return renderHook( + ({ projectPath }: { projectPath: string }) => + useDirectProjectChatController({ + enabled: true, + ensureConversationReadAllowed: allowed, + ensureConversationWriteAllowed: allowed, + onRuntimeError: () => undefined, + projectId: 'project-1', + projectPath, + refreshManifest: () => undefined, + }), + { initialProps: { projectPath: initialProjectPath } }, + ); +} + afterEach(() => { cleanup(); delete window.__TAURI__; @@ -72,20 +91,7 @@ afterEach(() => { describe('更早历史加载态的项目归属', () => { it('切项目后旧项目在飞的读取才落地,不能清掉新项目正在进行的加载态', async () => { const tauri = installDeferredHistoryInvoke(); - const allowed = async () => true; - const { result, rerender } = renderHook( - ({ projectPath }: { projectPath: string }) => - useDirectProjectChatController({ - enabled: true, - ensureConversationReadAllowed: allowed, - ensureConversationWriteAllowed: allowed, - onRuntimeError: () => undefined, - projectId: 'project-1', - projectPath, - refreshManifest: () => undefined, - }), - { initialProps: { projectPath: '/projects/a' } }, - ); + const { result, rerender } = renderChatController('/projects/a'); // 首屏:/a 的第一页落地,拿到「还有更早」。 const firstScreenA = await tauri.take('/projects/a'); @@ -129,3 +135,29 @@ describe('更早历史加载态的项目归属', () => { expect(result.current.historyLoading).toBe(false); }); }); + +describe('更早历史失败的挂起态', () => { + it('宿主没给出失败消息时,挂起态仍是可判定的非空值', async () => { + const tauri = installDeferredHistoryInvoke(); + const { result } = renderChatController('/projects/a'); + + const firstScreen = await tauri.take('/projects/a'); + await act(async () => { + firstScreen.resolve(slice([userItem('a2')], true, 'a2')); + }); + await waitFor(() => expect(result.current.historyHasMore).toBe(true)); + + await act(async () => { + void result.current.loadEarlierHistory(); + }); + const failing = await tauri.take('/projects/a', 'a2'); + await act(async () => { + failing.reject(new Error('')); + }); + + // `historyError` 同时是「挂起自动加载」的闸门与内联错误行的显隐开关:空串是 falsy, + // 两者会一起消失,滚到顶就变成反复重试同一个失败读取。 + expect(result.current.historyError).toBeTruthy(); + expect(result.current.historyLoading).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 9662f80f2..92f274dad 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -44,6 +44,15 @@ import { useDirectThreadChatSubscription } from './useDirectThreadChatSubscripti export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; export const DIRECT_HISTORY_PAGE_SIZE = CONVERSATION_VISIBLE_STEP; +/** + * 更早历史读取失败时的兜底文案。 + * + * 宿主可能抛出空 message 的错误(裸 `new Error()`、宿主侧空串)。空串是 falsy,而 + * `historyError` 同时是「挂起自动加载」的闸门与内联错误行的显隐开关——空串会让两者一起 + * 消失,滚到顶就变成反复重试同一个失败读取。这里在源头收口成一句稳定文案。 + */ +const DIRECT_HISTORY_LOAD_ERROR_FALLBACK = '读取更早的对话历史失败'; + /** * 工作台壳注入的项目对话读权限门。返回 `false` 表示已入队确认:读取由 `onConfirmed` * 触发,壳自己负责取消时的状态文案。 @@ -707,7 +716,9 @@ export function useDirectProjectChatController({ } catch (error) { // 与加载成功路径同样按项目守卫:切项目后在飞的读取失败不能算到新项目头上。 if (projectPathRef.current !== nextProjectPath) return; - const message = error instanceof Error ? error.message : String(error); + const message = + (error instanceof Error ? error.message : String(error)) || + DIRECT_HISTORY_LOAD_ERROR_FALLBACK; historyErrorRef.current = message; setHistoryError(message); } finally {