diff --git a/src/function/request/entry_comment_regex.ts b/src/function/request/entry_comment_regex.ts
index 2a416b5..86d4edc 100644
--- a/src/function/request/entry_comment_regex.ts
+++ b/src/function/request/entry_comment_regex.ts
@@ -1,9 +1,7 @@
import { tr } from '@/i18n';
-export type EntryCommentRegexCompileResult = {
- regex?: RegExp;
- error?: string;
-};
+export { compileRegex as compileEntryCommentRegex } from '@/util/regex';
+export type { RegexCompileResult as EntryCommentRegexCompileResult } from '@/util/regex';
export type EntryCommentFilterSource = '用户全局配置' | '角色卡配置';
@@ -24,49 +22,6 @@ export function logEntryCommentFilterResult(result: EntryCommentFilterResult[])
);
}
-function isEscaped(value: string, index: number): boolean {
- let backslash_count = 0;
- for (let i = index - 1; i >= 0 && value[i] === '\\'; i--) {
- backslash_count++;
- }
- return backslash_count % 2 === 1;
-}
-
-function findClosingSlash(value: string): number {
- for (let i = value.length - 1; i > 0; i--) {
- if (value[i] === '/' && !isEscaped(value, i)) {
- return i;
- }
- }
- return -1;
-}
-
-export function compileEntryCommentRegex(value: string): EntryCommentRegexCompileResult {
- const trimmed = value.trim();
- if (trimmed === '') {
- return {};
- }
-
- try {
- if (trimmed.startsWith('/')) {
- const closing_slash = findClosingSlash(trimmed);
- if (closing_slash <= 0) {
- throw new Error(tr('runtime.regex.jsStyleSyntax'));
- }
- return {
- regex: new RegExp(
- trimmed.slice(1, closing_slash),
- trimmed.slice(closing_slash + 1)
- ),
- };
- }
-
- return { regex: new RegExp(trimmed) };
- } catch (error) {
- return { error: error instanceof Error ? error.message : String(error) };
- }
-}
-
export function testEntryCommentRegex(regex: RegExp, comment: string): boolean {
regex.lastIndex = 0;
return regex.test(comment);
diff --git a/src/function/update/index.ts b/src/function/update/index.ts
index c08e283..03de1b5 100644
--- a/src/function/update/index.ts
+++ b/src/function/update/index.ts
@@ -12,7 +12,10 @@ export function initResponse() {
const controller = new AbortController();
const receive = (message_id: number) =>
onMessageReceived(message_id, { signal: controller.signal });
- const throttled_receive = _.throttle(receive, 3000);
+ // receive 必须在当次 MESSAGE_RECEIVED 的异步调用链中启动,并返回 Promise 供宿主等待。
+ // 窗口内的重复事件直接丢弃,禁止定时器在尾部补调,避免脱离原事件调用链后
+ // 再读取已经变化的聊天或配置状态。
+ const throttled_receive = _.throttle(receive, 3000, { trailing: false });
const stop_list: Array<() => void> = [];
stop_list.push(
controlledStoppableEventOn(tavern_events.MESSAGE_SENT, handleVariablesInMessage)
diff --git a/src/function/update/on_message_received.ts b/src/function/update/on_message_received.ts
index 004ab7e..1d6369d 100644
--- a/src/function/update/on_message_received.ts
+++ b/src/function/update/on_message_received.ts
@@ -6,6 +6,7 @@ import { isPiMultiproviderEnabled } from '@/function/update/pi/feature_flag';
import { handleVariablesInMessage } from '@/function/update_variables';
import { tr } from '@/i18n';
import { useDataStore } from '@/store';
+import { compileRegex } from '@/util/regex';
/** 接收时启动解析;非工具调用的自动解析在本条消息渲染后等待结果并写回。 */
export async function onMessageReceived(
@@ -63,9 +64,31 @@ export async function onMessageReceived(
return;
}
- if (!force && store.effective_settings.额外模型解析配置.启用自动请求 === false) {
- console.log(tr('runtime.extraModel.autoRequestDisabledLog'));
- return;
+ if (!force) {
+ const settings = store.effective_settings.额外模型解析配置;
+ if (!settings.启用自动请求) {
+ console.log(tr('runtime.extraModel.autoRequestDisabledLog'));
+ return;
+ }
+ // 按 Unicode 字符统计整条回复(去除首尾空白),不依赖模型的 tokenizer。
+ if (
+ settings.最小请求字数 > 0 &&
+ Array.from(message_content.trim()).length < settings.最小请求字数
+ ) {
+ console.log(tr('runtime.extraModel.belowMinLengthLog'));
+ toastr.info(tr('runtime.extraModel.belowMinLengthLog'));
+ return;
+ }
+ const trigger_tag = compileRegex(settings.触发标签);
+ if (trigger_tag.error !== undefined) {
+ console.warn(tr('runtime.extraModel.triggerTagInvalidLog'), trigger_tag.error);
+ return;
+ }
+ if (trigger_tag.regex && !trigger_tag.regex.test(message_content)) {
+ console.log(tr('runtime.extraModel.triggerTagMissingLog'));
+ toastr.info(tr('runtime.extraModel.triggerTagMissingLog'));
+ return;
+ }
}
if (!isCurrentMessage()) {
diff --git a/src/i18n/messages/panel.ts b/src/i18n/messages/panel.ts
index 1ca8911..95aafc7 100644
--- a/src/i18n/messages/panel.ts
+++ b/src/i18n/messages/panel.ts
@@ -431,6 +431,28 @@ export const panelMessages = defineMessages({
'关闭后,AI 回复完成时不会自动触发额外模型解析;你需要主动点击“重试额外模型解析”按钮,才会执行解析并添加状态栏占位符 <StatusPlaceHolderImpl/>。',
en: 'When disabled, extra-model parsing is not triggered automatically after the AI finishes replying. Click “Retry extra-model parsing” to run it and add the status-bar placeholder <StatusPlaceHolderImpl/>.',
},
+ 'panel.request.minLength': {
+ 'zh-CN': '最小请求字数',
+ en: 'Minimum reply length',
+ },
+ 'panel.request.minLengthHelp': {
+ 'zh-CN':
+ 'AI 回复达到此字数才自动请求额外模型解析。按整条回复去除首尾空白后的字符数计算(包含标签,不是 token 数);0 表示不限制。若同时设置触发标签,两个条件都须满足。手动重试不受限制。',
+ en: 'Automatically request extra-model parsing only when the AI reply reaches this many characters. Counts the entire reply, including tags, after trimming leading and trailing whitespace (not tokens). Set to 0 for no minimum. If a trigger tag is also set, both conditions must be met. Manual retries bypass these conditions.',
+ },
+ 'panel.request.triggerTag': {
+ 'zh-CN': '触发标签',
+ en: 'Trigger tag',
+ },
+ 'panel.request.triggerTagHelp': {
+ 'zh-CN':
+ 'AI 回复匹配此正则表达式才自动请求额外模型解析。可直接填写 </content>,或用 </(content|正文)> 匹配多个标签;也支持 /pattern/flags 格式,例如 /content/i 忽略大小写。默认区分大小写,特殊字符需用反斜杠转义。忽略配置首尾空白,留空表示不限制;正则无效时跳过自动请求。若同时设置最小请求字数,两个条件都须满足。手动重试不受限制。',
+ en: 'Automatically request extra-model parsing only when the AI reply matches this regular expression. Enter </content> directly, or use </(content|正文)> to match either tag. The /pattern/flags format is also supported, such as /content/i for case-insensitive matching. Matching is case-sensitive by default; escape special characters with a backslash. Leading and trailing whitespace in this setting is ignored; leave empty for no restriction. Invalid regexes skip automatic requests. If a minimum reply length is also set, both conditions must be met. Manual retries bypass these conditions.',
+ },
+ 'panel.request.triggerTagInvalid': {
+ 'zh-CN': '触发标签正则无效,将跳过自动请求:{error}',
+ en: 'Invalid trigger-tag regex; automatic requests will be skipped: {error}',
+ },
'panel.request.batchWarning': {
'zh-CN':
'请升级酒馆助手到 4.4.3 或更高版本,否则批量请求功能可能让预设的“流式传输”设置失效',
diff --git a/src/i18n/messages/runtime.ts b/src/i18n/messages/runtime.ts
index 33f8c37..6a103e1 100644
--- a/src/i18n/messages/runtime.ts
+++ b/src/i18n/messages/runtime.ts
@@ -598,6 +598,18 @@ export const runtimeMessages = defineMessages({
'zh-CN': '[MVU]不自动触发额外模型解析',
en: '[MVU] Automatic extra-model parsing is disabled',
},
+ 'runtime.extraModel.belowMinLengthLog': {
+ 'zh-CN': '[MVU]回复未达到最小请求字数,跳过自动额外模型解析',
+ en: '[MVU] Reply is below the minimum length; skipping automatic extra-model parsing',
+ },
+ 'runtime.extraModel.triggerTagMissingLog': {
+ 'zh-CN': '[MVU]回复未匹配触发标签正则,跳过自动额外模型解析',
+ en: '[MVU] Reply does not match the trigger-tag regex; skipping automatic extra-model parsing',
+ },
+ 'runtime.extraModel.triggerTagInvalidLog': {
+ 'zh-CN': '[MVU]触发标签正则无效,跳过自动额外模型解析',
+ en: '[MVU] Invalid trigger-tag regex; skipping automatic extra-model parsing',
+ },
'runtime.extraModel.updateFailed': {
'zh-CN': '建议调整变量更新方式;可在“输入框左下角魔棒 → 日志查看器”查看具体情况',
en: 'Try adjusting the variable update method. Open Magic Wand → Log Viewer at the lower-left of the input box for details.',
diff --git a/src/panel/update/Request.vue b/src/panel/update/Request.vue
index 863c369..91d42fd 100644
--- a/src/panel/update/Request.vue
+++ b/src/panel/update/Request.vue
@@ -34,6 +34,40 @@
{{ t('common.enabled') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('panel.request.triggerTagInvalid', { error: trigger_tag_error }) }}
+
+
+
@@ -50,11 +84,24 @@ import Select from '@/panel/component/Select.vue';
import request_method_help_en from '@/panel/update/request_method.en.md';
import request_method_help_zh_cn from '@/panel/update/request_method.zh-CN.md';
import { useDataStore } from '@/store';
+import { compileRegex } from '@/util/regex';
import { compare } from 'compare-versions';
import { computed, watch } from 'vue';
const store = useDataStore();
const { locale, t } = useMvuI18n();
+const trigger_tag_error = computed(
+ () => compileRegex(store.settings.额外模型解析配置.触发标签).error
+);
+const min_request_length = computed({
+ get: () => store.settings.额外模型解析配置.最小请求字数,
+ set: value => {
+ const length = Number(value);
+ store.settings.额外模型解析配置.最小请求字数 = Number.isFinite(length)
+ ? Math.max(0, Math.floor(length))
+ : 0;
+ },
+});
const request_method_help = computed(() =>
locale.value === 'zh-CN' ? request_method_help_zh_cn : request_method_help_en
);
@@ -84,3 +131,9 @@ watch(
}
);
+
+
diff --git a/src/store.ts b/src/store.ts
index 7d05fde..5ded485 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -242,6 +242,12 @@ const NewSettings = z
随机头部: z.boolean().default(true),
启用自动请求: z.boolean().default(true),
+ 最小请求字数: z.coerce
+ .number()
+ .catch(0)
+ .default(0)
+ .transform(value => Math.max(0, Math.floor(value))),
+ 触发标签: z.string().default(''),
请求方式: z
.enum([
'依次请求,失败后重试',
diff --git a/src/util/regex.ts b/src/util/regex.ts
new file mode 100644
index 0000000..af162a1
--- /dev/null
+++ b/src/util/regex.ts
@@ -0,0 +1,49 @@
+import { tr } from '@/i18n';
+
+export type RegexCompileResult = {
+ regex?: RegExp;
+ error?: string;
+};
+
+function isEscaped(value: string, index: number): boolean {
+ let backslash_count = 0;
+ for (let i = index - 1; i >= 0 && value[i] === '\\'; i--) {
+ backslash_count++;
+ }
+ return backslash_count % 2 === 1;
+}
+
+function findClosingSlash(value: string): number {
+ for (let i = value.length - 1; i > 0; i--) {
+ if (value[i] === '/' && !isEscaped(value, i)) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+export function compileRegex(value: string): RegexCompileResult {
+ const trimmed = value.trim();
+ if (trimmed === '') {
+ return {};
+ }
+
+ try {
+ if (trimmed.startsWith('/')) {
+ const closing_slash = findClosingSlash(trimmed);
+ if (closing_slash <= 0) {
+ throw new Error(tr('runtime.regex.jsStyleSyntax'));
+ }
+ return {
+ regex: new RegExp(
+ trimmed.slice(1, closing_slash),
+ trimmed.slice(closing_slash + 1)
+ ),
+ };
+ }
+
+ return { regex: new RegExp(trimmed) };
+ } catch (error) {
+ return { error: error instanceof Error ? error.message : String(error) };
+ }
+}
diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts
index 8096c60..944707f 100644
--- a/tests/i18n.test.ts
+++ b/tests/i18n.test.ts
@@ -85,6 +85,14 @@ describe('MVU localization', () => {
);
});
+ test.each(APP_LOCALES)('keeps the complete trigger regex help in %s', locale => {
+ locale_ref.value = locale;
+
+ const help = tr('panel.request.triggerTagHelp');
+ expect(help).toBe(panelMessages['panel.request.triggerTagHelp'][locale]);
+ expect(help).toContain('</(content|正文)>');
+ });
+
// Pi 界面文案:组合来源标签与协议名称都有对应翻译。
test('translates provider labels and protocols used in combined source choices', () => {
locale_ref.value = 'zh-CN';
diff --git a/tests/on_message_received_conditions.test.ts b/tests/on_message_received_conditions.test.ts
new file mode 100644
index 0000000..c62166e
--- /dev/null
+++ b/tests/on_message_received_conditions.test.ts
@@ -0,0 +1,157 @@
+/** 验证自动请求条件在消息入口阻止调用,手动重试与随 AI 输出解析保持原行为。 */
+import { isExtraModelSupported } from '@/function/is_extra_model_supported';
+import { invokeExtraModelWithStrategy } from '@/function/update/invoke_extra_model';
+import { onMessageReceived } from '@/function/update/on_message_received';
+import { handleVariablesInMessage } from '@/function/update_variables';
+import { useDataStore } from '@/store';
+
+jest.mock('@/function/is_extra_model_supported', () => ({
+ isExtraModelSupported: jest.fn(),
+}));
+jest.mock('@/function/update/invoke_extra_model', () => ({
+ invokeExtraModelWithStrategy: jest.fn(),
+}));
+jest.mock('@/function/update_variables', () => ({
+ handleVariablesInMessage: jest.fn(),
+}));
+
+const mockInvoke = jest.mocked(invokeExtraModelWithStrategy);
+const mockHandleVariables = jest.mocked(handleVariablesInMessage);
+
+describe('自动请求条件', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (globalThis as any).toastr = { info: jest.fn() };
+ Object.assign(SillyTavern, {
+ extensionSettings: {},
+ name2: 'Assistant',
+ chat: [{}, {}],
+ getCurrentChatId: jest.fn().mockReturnValue('conditions-chat'),
+ });
+ jest.mocked(getChatMessages).mockReturnValue([
+ { name: 'Assistant', message: '这是一条完整回复' } as ChatMessage,
+ ]);
+ jest.mocked(isExtraModelSupported).mockResolvedValue(true);
+ mockInvoke.mockResolvedValue('_.set("health", 80);');
+ mockHandleVariables.mockResolvedValue(undefined);
+
+ const store = useDataStore();
+ store.settings.更新方式 = '额外模型解析';
+ store.settings.兼容性.额外模型解析非阻塞 = false;
+ Object.assign(store.settings.额外模型解析配置, {
+ 启用自动请求: true,
+ 模型来源: '自定义',
+ 应答格式: '聊天消息',
+ });
+ });
+
+ test.each([
+ ['默认条件', '这是一条普通回复', 0, '', true],
+ ['低于最小字数', '这是一条普通回复', 9, '', false],
+ ['恰好达到最小字数', '这是一条普通回复', 8, '', true],
+ ['超过最小字数', '这是一条普通回复', 7, '', true],
+ ['忽略首尾空白', ' 这是一条普通回复\n', 9, '', false],
+ ['Unicode 字符不重复计数', '正文加上😀', 6, '', false],
+ ['Unicode 字符达到边界', '正文加上😀', 5, '', true],
+ ['缺少标签', '这是一条普通回复', 0, '', false],
+ ['包含标签', '这是一条普通回复', 0, '', true],
+ ['支持正则通配', '这是一条普通回复', 0, '', true],
+ ['支持多个标签', '这是一条普通回复正文>', 0, '(content|正文)>', true],
+ ['正则未匹配', '这是一条普通回复', 0, '(content|正文)>', false],
+ ['支持忽略大小写标志', '这是一条普通回复', 0, '/<\\/content>/i', true],
+ ['支持多行匹配标志', '这是一条普通回复\n', 0, '/^<\\/content>$/m', true],
+ ['支持转义特殊字符', '这是一条普通回复[end]', 0, '\\[end\\]$', true],
+ ['无效正则', '这是一条普通回复', 0, '[', false],
+ ['无效正则标志', '这是一条普通回复', 0, '/content/invalid', false],
+ ['缺少正则结束分隔符', '这是一条普通回复', 0, '/content', false],
+ ['标签区分大小写', '这是一条普通回复', 0, '', false],
+ ['忽略标签设置首尾空白', '这是一条普通回复', 0, ' \n', true],
+ ['空白标签不限制', '这是一条普通回复', 0, ' \n ', true],
+ ['仅字数满足', '这是一条普通回复', 8, '', false],
+ ['仅标签满足', '这是一条普通回复', 500, '', false],
+ ['两个条件均满足', '这是一条普通回复', 8, '', true],
+ ])('%s', async (_name, message, min_length, trigger_tag, should_request) => {
+ Object.assign(useDataStore().settings.额外模型解析配置, {
+ 最小请求字数: min_length,
+ 触发标签: trigger_tag,
+ });
+ jest.mocked(getChatMessages).mockReturnValue([
+ { name: 'Assistant', message } as ChatMessage,
+ ]);
+
+ await onMessageReceived(1);
+
+ expect(mockInvoke).toHaveBeenCalledTimes(should_request ? 1 : 0);
+ expect(setChatMessages).toHaveBeenCalledTimes(should_request ? 1 : 0);
+ expect(mockHandleVariables).toHaveBeenCalledTimes(should_request ? 1 : 0);
+ expect(eventOn).not.toHaveBeenCalledWith(
+ tavern_events.CHARACTER_MESSAGE_RENDERED,
+ expect.any(Function)
+ );
+ });
+
+ test('关闭自动请求时即使满足条件也不会请求', async () => {
+ useDataStore().settings.额外模型解析配置.启用自动请求 = false;
+
+ await onMessageReceived(1);
+
+ expect(mockInvoke).not.toHaveBeenCalled();
+ });
+
+ test('带全局标志的正则在多次请求间不保留匹配位置', async () => {
+ useDataStore().settings.额外模型解析配置.触发标签 = '/<\\/content>/g';
+
+ await onMessageReceived(1);
+ await onMessageReceived(1);
+
+ expect(mockInvoke).toHaveBeenCalledTimes(2);
+ });
+
+ test('手动重试绕过无效正则', async () => {
+ useDataStore().settings.额外模型解析配置.触发标签 = '[';
+
+ await onMessageReceived(1, { force: true });
+
+ expect(mockInvoke).toHaveBeenCalledTimes(1);
+ });
+
+ test.each([true, false])('手动重试绕过条件(自动请求开关:%s)', async enabled => {
+ Object.assign(useDataStore().settings.额外模型解析配置, {
+ 启用自动请求: enabled,
+ 最小请求字数: 500,
+ 触发标签: '',
+ });
+
+ await onMessageReceived(1, { force: true });
+
+ expect(mockInvoke).toHaveBeenCalledTimes(1);
+ expect(mockHandleVariables).toHaveBeenCalledWith(1);
+ });
+
+ test('随 AI 输出模式仍解析回复中的变量', async () => {
+ const store = useDataStore();
+ store.settings.更新方式 = '随AI输出';
+ store.settings.额外模型解析配置.最小请求字数 = 500;
+ store.settings.额外模型解析配置.触发标签 = '';
+
+ await onMessageReceived(1);
+
+ expect(mockInvoke).not.toHaveBeenCalled();
+ expect(mockHandleVariables).toHaveBeenCalledWith(1);
+ });
+
+ test('非阻塞模式跳过时不注册渲染监听', async () => {
+ const store = useDataStore();
+ store.settings.兼容性.额外模型解析非阻塞 = true;
+ store.settings.额外模型解析配置.最小请求字数 = 500;
+
+ await onMessageReceived(1);
+
+ expect(mockInvoke).not.toHaveBeenCalled();
+ expect(eventOn).not.toHaveBeenCalledWith(
+ tavern_events.CHARACTER_MESSAGE_RENDERED,
+ expect.any(Function)
+ );
+ expect(setChatMessages).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/store_response_format.test.ts b/tests/store_response_format.test.ts
index 627cf81..92953ad 100644
--- a/tests/store_response_format.test.ts
+++ b/tests/store_response_format.test.ts
@@ -20,6 +20,39 @@ describe('extra model response format settings', () => {
expect(store.settings.额外模型解析配置.兼容假流式).toBe(false);
});
+ test('defaults automatic request conditions to unrestricted', () => {
+ const settings = useDataStore().settings.额外模型解析配置;
+
+ expect(settings.最小请求字数).toBe(0);
+ expect(settings.触发标签).toBe('');
+ });
+
+ test('preserves automatic request conditions when loading and reloading', () => {
+ (globalThis as any).SillyTavern.extensionSettings = {
+ mvu_settings: {
+ 额外模型解析配置: { 最小请求字数: 500, 触发标签: '' },
+ },
+ };
+ const store = useDataStore();
+ store._reload_settings();
+
+ expect(store.settings.额外模型解析配置.最小请求字数).toBe(500);
+ expect(store.settings.额外模型解析配置.触发标签).toBe('');
+ });
+
+ test.each([
+ [-10, 0],
+ [12.8, 12],
+ ['500', 500],
+ ['invalid', 0],
+ ])('normalizes minimum reply length %s to %s', (value, expected) => {
+ (globalThis as any).SillyTavern.extensionSettings = {
+ mvu_settings: { 额外模型解析配置: { 最小请求字数: value } },
+ };
+
+ expect(useDataStore().settings.额外模型解析配置.最小请求字数).toBe(expected);
+ });
+
test('migrates legacy function calling flag to tool calling response format', () => {
(globalThis as any).SillyTavern.extensionSettings = {
mvu_settings: {
diff --git a/tests/update_receive_throttle.test.ts b/tests/update_receive_throttle.test.ts
new file mode 100644
index 0000000..c8818d9
--- /dev/null
+++ b/tests/update_receive_throttle.test.ts
@@ -0,0 +1,79 @@
+/** 覆盖生产环境的节流路径:当次事件立即启动处理,窗口内重复事件不在尾部补调。 */
+import { initResponse } from '@/function/update';
+import { onMessageReceived } from '@/function/update/on_message_received';
+
+jest.mock('@/jest', () => ({ is_jest_environment: false }));
+jest.mock('@/function/update/on_message_received', () => ({
+ onMessageReceived: jest.fn(),
+}));
+jest.mock('@/function/update/pi/controller_registry', () => ({
+ clearPiRequestControllers: jest.fn(),
+}));
+jest.mock('@/function/update_variables', () => ({
+ handleVariablesInMessage: jest.fn(),
+}));
+jest.mock('@/util', () => ({
+ controlledStoppableEventOn: (event: EventType, listener: (...args: any[]) => any) => {
+ eventOn(event, listener);
+ return () => eventRemoveListener(event, listener);
+ },
+}));
+
+describe('MESSAGE_RECEIVED 生产环境节流', () => {
+ const mockReceive = jest.mocked(onMessageReceived);
+ let stop: () => void;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.clearAllMocks();
+ mockReceive.mockReset().mockResolvedValue(undefined);
+ stop = initResponse();
+ });
+
+ afterEach(() => {
+ stop();
+ jest.useRealTimers();
+ });
+
+ test('立即调用并丢弃窗口内的事件,到期不补调,后续事件可立即执行', async () => {
+ const first = eventEmit(tavern_events.MESSAGE_RECEIVED, 1, 'normal');
+ // 不推进计时器、不让出当前调用栈,处理应当已经开始。
+ expect(mockReceive).toHaveBeenCalledWith(1, { signal: expect.any(AbortSignal) });
+ await first;
+
+ await jest.advanceTimersByTimeAsync(1000);
+ await eventEmit(tavern_events.MESSAGE_RECEIVED, 2, 'normal');
+ await jest.advanceTimersByTimeAsync(1000);
+ await eventEmit(tavern_events.MESSAGE_RECEIVED, 3, 'normal');
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(mockReceive).toHaveBeenCalledTimes(1);
+
+ const next = eventEmit(tavern_events.MESSAGE_RECEIVED, 4, 'normal');
+ expect(mockReceive).toHaveBeenCalledTimes(2);
+ expect(mockReceive).toHaveBeenLastCalledWith(4, { signal: expect.any(AbortSignal) });
+ await next;
+ await jest.advanceTimersByTimeAsync(3000);
+ expect(mockReceive).toHaveBeenCalledTimes(2);
+ });
+
+ test('宿主事件等待当次处理的 Promise,并收到其异常', async () => {
+ const pending = Promise.withResolvers();
+ mockReceive.mockReturnValueOnce(pending.promise);
+ let settled = false;
+ const emitted = eventEmit(tavern_events.MESSAGE_RECEIVED, 1, 'normal').finally(() => {
+ settled = true;
+ });
+ const error = new Error('receive failed');
+ const assertion = expect(emitted).rejects.toBe(error);
+
+ await Promise.resolve();
+ expect(mockReceive).toHaveBeenCalledTimes(1);
+ expect(settled).toBe(false);
+
+ pending.reject(error);
+ await assertion;
+ expect(settled).toBe(true);
+ await jest.advanceTimersByTimeAsync(3000);
+ expect(mockReceive).toHaveBeenCalledTimes(1);
+ });
+});