导出面板失败通道按 ADR 分流:真故障原样抛出,复制失败不再静默

- 新增 state/xhsMinitoolOutputTail.ts:宿主只回尾部原文与省略量,「已省略前 N 个字符」这句话在前端拼,失败卡片与成功提示共用
- xhsMinitoolFailure.ts 接住新变体 commandDenied(策略拒绝是预期拒绝,句子在前端拼),commandFailed 的现场改走尾部说明
- useXhsMinitoolExport 的三处 catch:认不出形状的拒绝与 exportUnavailable 先给现场再原样抛出进错误池,按指纹去重(面板 2 秒一轮重读,不会把报告池 count 刷成轮询次数),不再把裸 message 当业务提示
- CopyButton 改用 Tauri 剪贴板插件并显示失败态:原来的 navigator.clipboard 在 WebView 里可能根本没有、失败还被吞掉
- 用例 18 → 19 例:省略说明由前端拼、未分类拒绝原样抛出、宿主故障先给卡片再抛一次、复制失败可见
This commit is contained in:
2026-10-05 19:25:22 +08:00
parent dffe4de555
commit df3a6826b1
5 changed files with 257 additions and 34 deletions
@@ -10,6 +10,11 @@
* - 注册表指纹是写回时的同步细节,不进对外状态:它只在 `baselineRef` 里,且只有进编辑会话或 * - 注册表指纹是写回时的同步细节,不进对外状态:它只在 `baselineRef` 里,且只有进编辑会话或
* 内容真的被采纳时才单独取一次。自动刷新([CONTENT_REFRESH_INTERVAL_MS])读的是不含指纹的 * 内容真的被采纳时才单独取一次。自动刷新([CONTENT_REFRESH_INTERVAL_MS])读的是不含指纹的
* 内容命令,所以「刷新」在类型上就动不了写回基线。 * 内容命令,所以「刷新」在类型上就动不了写回基线。
*
* 失败按 ADR 的两道口子分流(`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`、
* `【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`):typed 变体里除了宿主侧事实故障
* (`exportUnavailable`)都留在面板里就地消化;**宿主故障与认不出形状的拒绝原样抛出**,由全局
* `unhandledrejection` 送进错误池,面板不把原始 message 当成业务提示糊给用户。
*/ */
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
@@ -36,6 +41,7 @@ import {
buildXhsMinitoolRepairInstruction, buildXhsMinitoolRepairInstruction,
XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, XHS_MINITOOL_ARTIFACT_RELATIVE_PATH,
} from './xhsMinitoolInstruction'; } from './xhsMinitoolInstruction';
import { describeOutputTail } from './xhsMinitoolOutputTail';
export type XhsMinitoolExportState = ReturnType<typeof useXhsMinitoolExport>; export type XhsMinitoolExportState = ReturnType<typeof useXhsMinitoolExport>;
@@ -104,6 +110,8 @@ export function useXhsMinitoolExport({
const baselineRef = useRef(''); const baselineRef = useRef('');
/** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */ /** 轮询回调里要判断「用户手上有没有未保存输入」,但它们不该把轮询节拍打乱。 */
const adoptableRef = useRef(false); const adoptableRef = useRef(false);
/** 上一次抛进错误池的故障指纹:同一个只抛一次,见 [useXhsMinitoolExport.throwToErrorPool]。 */
const reportedFailureRef = useRef('');
const formRef = useRef<XHSMiniToolExportForm | null>(null); const formRef = useRef<XHSMiniToolExportForm | null>(null);
useEffect(() => { useEffect(() => {
@@ -114,6 +122,22 @@ export function useXhsMinitoolExport({
adoptableRef.current = !dirty && !conflict && !fieldError; adoptableRef.current = !dirty && !conflict && !fieldError;
}, [dirty, conflict, fieldError]); }, [dirty, conflict, fieldError]);
/**
* 真故障(认不出形状的拒绝、宿主侧事实故障)原样抛出,交给全局 `unhandledrejection` 结算。
*
* 面板每 2 秒重读一次内容,同一个故障会反复落到这里:按指纹去重,只把第一次抛出去,故障变了再抛。
* 否则报告池里的 `count` 统计的是「面板开了多久」,不是故障发生了几次。去重不改变「有人接手」的
* 判定——第一次已经报过,后续是同一个故障的重复现场。
*/
const throwToErrorPool = useCallback(
(fingerprint: string, error: unknown): void => {
if (reportedFailureRef.current === fingerprint) return;
reportedFailureRef.current = fingerprint;
throw error;
},
[],
);
/** /**
* 重读内容:注册表(宿主缺则补空表单)+ 脚本是否存在。首次进入与自动刷新走同一条。 * 重读内容:注册表(宿主缺则补空表单)+ 脚本是否存在。首次进入与自动刷新走同一条。
* *
@@ -151,12 +175,19 @@ export function useXhsMinitoolExport({
); );
} catch (error) { } catch (error) {
const structured = asXhsMinitoolFailure(error); const structured = asXhsMinitoolFailure(error);
if (structured) setFailure(failureState(structured)); if (!structured) {
else setNotice(error instanceof Error ? error.message : String(error)); throwToErrorPool(String(error), error);
return;
}
setFailure(failureState(structured));
// 宿主侧事实故障:先让面板显示现场,再抛进错误池(策略拒绝等预期拒绝不走这一步)。
if (structured.type === 'exportUnavailable') {
throwToErrorPool(JSON.stringify(structured), error);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [projectPath]); }, [projectPath, throwToErrorPool]);
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
@@ -193,15 +224,20 @@ export function useXhsMinitoolExport({
setDirty(false); setDirty(false);
const structured = asXhsMinitoolFailure(error); const structured = asXhsMinitoolFailure(error);
if (!structured) { if (!structured) {
setNotice(error instanceof Error ? error.message : String(error)); throwToErrorPool(String(error), error);
return; return;
} }
if (structured.type === 'formInvalid') setFieldError(structured); if (structured.type === 'formInvalid') setFieldError(structured);
else if (structured.type === 'saveConflict') setConflict(structured); else if (structured.type === 'saveConflict') setConflict(structured);
else setFailure(failureState(structured)); else {
setFailure(failureState(structured));
if (structured.type === 'exportUnavailable') {
throwToErrorPool(JSON.stringify(structured), error);
}
}
} }
}, },
[projectPath], [projectPath, throwToErrorPool],
); );
useEffect(() => { useEffect(() => {
@@ -251,18 +287,27 @@ export function useXhsMinitoolExport({
const result = await runXhsMinitoolExportBuild(invoke, projectPath); const result = await runXhsMinitoolExportBuild(invoke, projectPath);
// 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。 // 打包脚本会把 zip 的条目数与体积打在输出尾部,正是用户上传前要核对的数字。
setNotice( setNotice(
[`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`, result.outputTail] [
`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}`,
describeOutputTail(result),
]
.filter((line) => line.trim() !== '') .filter((line) => line.trim() !== '')
.join('\n'), .join('\n'),
); );
} catch (error) { } catch (error) {
const structured = asXhsMinitoolFailure(error); const structured = asXhsMinitoolFailure(error);
if (structured) setFailure(failureState(structured)); if (!structured) {
else setNotice(error instanceof Error ? error.message : String(error)); throwToErrorPool(String(error), error);
return;
}
setFailure(failureState(structured));
if (structured.type === 'exportUnavailable') {
throwToErrorPool(JSON.stringify(structured), error);
}
} finally { } finally {
setRunning(false); setRunning(false);
} }
}, [projectPath]); }, [projectPath, throwToErrorPool]);
const sendToAgent = useCallback( const sendToAgent = useCallback(
async (instruction: string): Promise<void> => { async (instruction: string): Promise<void> => {
@@ -2,11 +2,18 @@
* 结构化失败 → 用户看得懂的一句话 + 下一步动作。 * 结构化失败 → 用户看得懂的一句话 + 下一步动作。
* *
* 与入队失败同一口径:分流只看 `error.type`,句子按变体写死,**不从载荷里"读"出文案**。载荷 * 与入队失败同一口径:分流只看 `error.type`,句子按变体写死,**不从载荷里"读"出文案**。载荷
* 字段只是拼句子的原料(路径、退出码、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。 * 字段只是拼句子的原料(路径、退出码、输出尾部、解析器原文)。穷尽性由 `expectNever` 在编译期钉住。
*
* 这里是**纯映射**,不判「要不要上报」:上报判据写在 `useXhsMinitoolExport` 的 catch 里
* (策略拒绝留在面板,宿主侧事实故障给完现场后原样抛出进错误池)。它与
* [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`]「不抽 presenter」的差异是刻意的:
* 那条针对只有一个消费方的认证 catch,而这份映射有两个消费方——面板卡片与交给 agent 的修复指令
* (两者必须说同一句话,否则用户在面板里看到的和 agent 拿到的会分叉)。
*/ */
import { expectNever } from '../../../../app/expectNever'; import { expectNever } from '../../../../app/expectNever';
import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError'; import type { XHSMiniToolExportError } from '../generated/XHSMiniToolExportError';
import { XHS_MINITOOL_FIELD_LABELS } from './xhsMinitoolFields'; import { XHS_MINITOOL_FIELD_LABELS } from './xhsMinitoolFields';
import { describeOutputTail } from './xhsMinitoolOutputTail';
/** /**
* 失败之后最合适的下一步,也就是那颗按钮干什么: * 失败之后最合适的下一步,也就是那颗按钮干什么:
@@ -52,13 +59,20 @@ export function describeXhsMinitoolFailure(
detail: '', detail: '',
action: 'retry', action: 'retry',
}; };
case 'commandDenied':
return {
// 句子在这里拼:宿主只给被拒的命令点位,不拼人话。
message: `项目权限策略拒绝执行:${error.commandId}`,
detail: '',
action: 'retry',
};
case 'commandFailed': case 'commandFailed':
return { return {
message: message:
error.exitCode === null error.exitCode === null
? '构建脚本没能启动(沙箱 / 运行环境层面就失败了)。' ? '构建脚本没能启动(沙箱 / 运行环境层面就失败了)。'
: `构建脚本没有正常退出(exit code ${error.exitCode})。`, : `构建脚本没有正常退出(exit code ${error.exitCode})。`,
detail: error.outputTail, detail: describeOutputTail(error),
action: 'repair', action: 'repair',
}; };
case 'artifactMissing': case 'artifactMissing':
@@ -0,0 +1,16 @@
/**
* 构建输出尾部的显示文本:宿主只给两个事实(原文 + 省略了多少字符),句子在这里拼。
*
* 宿主不预拼用户可见文案([`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`]):省略量是事实,
* 「省略了多少」怎么说、放在前还是后、要不要提示「尾巴不完整」,都由拿得到现场的这一层决定。
* 失败卡片与导出成功提示共用这一份,避免两处各写一句。
*/
export function describeOutputTail(input: {
outputTail: string;
omittedCharacters: number;
}): string {
const tail = input.outputTail.trim();
if (input.omittedCharacters <= 0) return tail;
const note = `(日志过长,已省略前 ${input.omittedCharacters} 个字符)`;
return tail ? `${note}\n${tail}` : note;
}
@@ -1,43 +1,63 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { Check, Copy } from 'lucide-react'; import { Check, Copy } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
/** 复制按钮:成功后显示一小会儿「已复制」,不弹提示、不动别处状态。 */ type CopyState = 'idle' | 'copied' | 'failed';
/**
* 复制按钮:成功后显示一小会儿「已复制」,失败也**必须**说一声。
*
* 走 Tauri 的 clipboard 插件而不是 `navigator.clipboard`:后者在 WebView 里是安全上下文相关的能力,
* 拿不到时只会静默失败,用户点了没反应;插件是仓库既有口径(`UiEditorCopyPathButton`、
* `useAccountReferral`、`RichInputArea` 三处),权限位为 `clipboard-manager:allow-write-text`。
* 失败不再吞掉:写剪贴板是宿主/环境事实,调用方给一行可见反馈就是这一层该做的事。
*/
export function CopyButton({ value, label }: { value: string; label: string }) { export function CopyButton({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false); const [state, setState] = useState<CopyState>('idle');
useEffect(() => { useEffect(() => {
if (!copied) return; if (state === 'idle') return;
const timer = setTimeout(() => setCopied(false), 1500); const timer = setTimeout(() => setState('idle'), 1500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [copied]); }, [state]);
const text =
state === 'copied' ? '已复制' : state === 'failed' ? '复制失败' : '复制';
return ( return (
<button <button
type="button" type="button"
aria-label={`复制${label}`} aria-label={`${text}${label}`}
title={`复制${label}`} title={
state === 'failed' ? `复制${label}失败,请手动复制` : `复制${label}`
}
disabled={!value} disabled={!value}
className="inline-flex shrink-0 items-center gap-1 rounded-lg border border-[#e2cbb8] bg-[#fffaf5] px-2 py-1 text-xs text-[#6f5848] transition hover:border-[#c7653d] hover:text-[#c7653d] disabled:cursor-not-allowed disabled:opacity-45" className={`inline-flex shrink-0 items-center gap-1 rounded-lg border px-2 py-1 text-xs transition disabled:cursor-not-allowed disabled:opacity-45 ${
state === 'failed'
? 'border-red-300 bg-red-50 text-red-600'
: 'border-[#e2cbb8] bg-[#fffaf5] text-[#6f5848] hover:border-[#c7653d] hover:text-[#c7653d]'
}`}
onClick={() => { onClick={() => {
void copyText(value).then(setCopied); setState('idle');
void copyText(value).then((copied) =>
setState(copied ? 'copied' : 'failed'),
);
}} }}
> >
{copied ? ( {state === 'copied' ? (
<Check size={13} aria-hidden="true" /> <Check size={13} aria-hidden="true" />
) : ( ) : (
<Copy size={13} aria-hidden="true" /> <Copy size={13} aria-hidden="true" />
)} )}
<span>{copied ? '已复制' : '复制'}</span> <span>{text}</span>
</button> </button>
); );
} }
async function copyText(value: string): Promise<boolean> { async function copyText(value: string): Promise<boolean> {
if (!value) return false; if (!value) return false;
const clipboard = navigator.clipboard;
if (!clipboard) return false;
try { try {
await clipboard.writeText(value); await writeText(value);
return true; return true;
} catch { } catch {
return false; return false;
@@ -7,6 +7,7 @@
* - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步); * - 左半面板的可见动作(复制、下载、适配 / 导出 / 刷新、冲突逐字段二选一、失败卡片的下一步);
* - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。 * - 状态 hook 的自动保存与入队(宿主命令参数、`baseHash` 回传、以及"适配才叫 agent")。
*/ */
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import { import {
act, act,
@@ -40,6 +41,8 @@ import { ArtifactsPane } from '../src/view/project-development/export/tabs/xiaoh
import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake'; import { installTauriRuntime, resetTauriRuntime } from './tauriRuntimeFake';
vi.mock('@tauri-apps/plugin-dialog', () => ({ save: vi.fn() })); vi.mock('@tauri-apps/plugin-dialog', () => ({ save: vi.fn() }));
// 剪贴板走 Tauri 插件(`navigator.clipboard` 在 WebView 里可能根本没有)。
vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ writeText: vi.fn() }));
const PROJECT_PATH = '/tmp/export-panel-project'; const PROJECT_PATH = '/tmp/export-panel-project';
@@ -56,10 +59,7 @@ const FORM = {
}; };
beforeEach(() => { beforeEach(() => {
Object.defineProperty(navigator, 'clipboard', { vi.mocked(writeText).mockResolvedValue(undefined);
configurable: true,
value: { writeText: vi.fn(async () => undefined) },
});
vi.mocked(saveNativeFileDialog).mockResolvedValue('/tmp/icon.png'); vi.mocked(saveNativeFileDialog).mockResolvedValue('/tmp/icon.png');
}); });
@@ -125,12 +125,33 @@ describe('交给 code agent 的指令', () => {
type: 'commandFailed', type: 'commandFailed',
exitCode: 1, exitCode: 1,
outputTail: 'vite build failed', outputTail: 'vite build failed',
omittedCharacters: 0,
}), }),
).toMatchObject({ action: 'repair', detail: 'vite build failed' }); ).toMatchObject({ action: 'repair', detail: 'vite build failed' });
// 截断是宿主给的事实,提示那句话在这里拼:载荷原文里不含任何宿主写的说明。
expect(
describeXhsMinitoolFailure({
type: 'commandFailed',
exitCode: 1,
outputTail: '最后一行报错',
omittedCharacters: 4096,
}).detail,
).toBe('(日志过长,已省略前 4096 个字符)\n最后一行报错');
// 策略拒绝是预期拒绝:宿主只给点位,句子在前端,且不进错误池。
expect(
describeXhsMinitoolFailure({
type: 'commandDenied',
commandId: 'command.exec',
}),
).toEqual({
message: '项目权限策略拒绝执行:command.exec',
detail: '',
action: 'retry',
});
expect( expect(
describeXhsMinitoolFailure({ describeXhsMinitoolFailure({
type: 'exportUnavailable', type: 'exportUnavailable',
cause: '项目权限策略拒绝执行:command.exec', cause: '项目根不可用:找不到目录',
}).action, }).action,
).toBe('retry'); ).toBe('retry');
expect( expect(
@@ -149,8 +170,23 @@ describe('面板左半', () => {
fireEvent.click(screen.getByRole('button', { name: '复制名称' })); fireEvent.click(screen.getByRole('button', { name: '复制名称' }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith('小工具'));
// 成功态也写进无障碍名,用户与读屏都看得到「已复制」。
await waitFor(() => await waitFor(() =>
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('小工具'), expect(screen.getByRole('button', { name: '已复制名称' })).not.toBeNull(),
);
});
it('复制失败不静默吞掉,按钮自己说一声', async () => {
vi.mocked(writeText).mockRejectedValue(new Error('剪贴板不可用'));
renderPane();
fireEvent.click(screen.getByRole('button', { name: '复制名称' }));
await waitFor(() =>
expect(
screen.getByRole('button', { name: '复制失败名称' }),
).not.toBeNull(),
); );
}); });
@@ -278,6 +314,7 @@ describe('面板左半', () => {
type: 'commandFailed', type: 'commandFailed',
exitCode: 1, exitCode: 1,
outputTail: 'vite build failed', outputTail: 'vite build failed',
omittedCharacters: 0,
}), }),
}); });
renderPane(broken); renderPane(broken);
@@ -425,10 +462,11 @@ describe('状态 hook', () => {
it('导出把打包脚本的输出尾部一并报给用户', async () => { it('导出把打包脚本的输出尾部一并报给用户', async () => {
const invoke = vi.fn(async (command: string) => { const invoke = vi.fn(async (command: string) => {
if (command === 'read_xhs_minitool_export') { if (command === 'read_xhs_minitool_export') {
return { form: FORM, hasScript: true, contentHash: CONTENT_HASH }; return { form: FORM, hasScript: true };
} }
return { return {
outputTail: `zip 已生成:${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}(3 个条目,0.42 MiB)`, outputTail: `zip 已生成:${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}(3 个条目,0.42 MiB)`,
omittedCharacters: 0,
}; };
}); });
installTauriRuntime({ core: { invoke } }); installTauriRuntime({ core: { invoke } });
@@ -455,6 +493,97 @@ describe('状态 hook', () => {
expect(result.current.running).toBe(false); expect(result.current.running).toBe(false);
}); });
it('输出被宿主截断时,说明那句话由前端拼', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_xhs_minitool_export') {
return { form: FORM, hasScript: true };
}
if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH;
return { outputTail: 'zip 已生成', omittedCharacters: 4096 };
});
installTauriRuntime({ core: { invoke } });
const { result } = renderHook(() =>
useXhsMinitoolExport({
enabled: true,
projectPath: PROJECT_PATH,
submitTurn: vi.fn(async () => true),
}),
);
await waitFor(() => expect(result.current.form).toEqual(FORM));
await act(async () => {
await result.current.runExport();
});
// 宿主只回事实(原文 + 省略量),提示里那句「已省略」是这里拼的。
expect(result.current.notice).toBe(
`已重新产出 ${XHS_MINITOOL_ARTIFACT_RELATIVE_PATH}\n(日志过长,已省略前 4096 个字符)\nzip 已生成`,
);
});
it('认不出形状的拒绝原样抛出进错误池,不当成业务提示糊给用户', async () => {
// Tauri 传输失败 / 命令 panic / 参数序列化失败都是真 `Error`:它不是结构化拒绝,
// 面板不许把它的 message 当成业务失败的解释,只把它交回错误池。
const transport = new Error('ipc 通道断了');
const invoke = vi.fn(async (command: string) => {
if (command === 'read_xhs_minitool_export') {
return { form: FORM, hasScript: true };
}
if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH;
throw transport;
});
installTauriRuntime({ core: { invoke } });
const { result } = renderHook(() =>
useXhsMinitoolExport({
enabled: true,
projectPath: PROJECT_PATH,
submitTurn: vi.fn(async () => true),
}),
);
await waitFor(() => expect(result.current.form).toEqual(FORM));
await expect(result.current.runExport()).rejects.toBe(transport);
expect(result.current.notice).toBe('');
expect(result.current.failure).toBeNull();
// 同一个故障只抛一次:轮询每 2 秒重读一次,不能让错误池的 count 变成轮询次数。
await expect(result.current.runExport()).resolves.toBeUndefined();
});
it('宿主侧事实故障先给卡片、再把同一个故障抛一次', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_xhs_minitool_export') {
return { form: FORM, hasScript: true };
}
if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH;
throw { type: 'exportUnavailable', cause: '项目根不可用:找不到目录' };
});
installTauriRuntime({ core: { invoke } });
const { result } = renderHook(() =>
useXhsMinitoolExport({
enabled: true,
projectPath: PROJECT_PATH,
submitTurn: vi.fn(async () => true),
}),
);
await waitFor(() => expect(result.current.form).toEqual(FORM));
// 卡片照常给现场,同时原样抛出(真实 WebView 里由全局 handler 结算进错误池)。
await act(async () => {
await expect(result.current.runExport()).rejects.toBeInstanceOf(Error);
});
expect(result.current.failure?.notice.message).toBe(
'宿主这次没能执行导出。',
);
expect(result.current.failure?.notice.detail).toBe(
'项目根不可用:找不到目录',
);
await act(async () => {
await expect(result.current.runExport()).resolves.toBeUndefined();
});
});
it('适配把指令当成一条用户消息交给同一个会话', async () => { it('适配把指令当成一条用户消息交给同一个会话', async () => {
const submitTurn = vi.fn(async () => true); const submitTurn = vi.fn(async () => true);
installTauriRuntime({ installTauriRuntime({
@@ -462,7 +591,6 @@ describe('状态 hook', () => {
invoke: vi.fn(async () => ({ invoke: vi.fn(async () => ({
form: FORM, form: FORM,
hasScript: false, hasScript: false,
contentHash: CONTENT_HASH,
})), })),
}, },
}); });