fix(weixin): fail closed on unavailable quoted text
This commit is contained in:
@@ -439,6 +439,14 @@ type HermesFollowupImageContext = {
|
||||
quotedMessageId?: string;
|
||||
};
|
||||
|
||||
type HermesQuotedTextContext = {
|
||||
referenced: boolean;
|
||||
handled: boolean;
|
||||
prompt?: string;
|
||||
replyText?: string;
|
||||
reason: "none" | "quoted_text_resolved" | "quoted_text_unavailable";
|
||||
};
|
||||
|
||||
const DEFAULT_HERMES_LAST_IMAGE_CACHE_PATH = "/var/lib/aios/openclaw-session/openclaw-weixin/hermes-last-image-cache.json";
|
||||
const DEFAULT_HERMES_LAST_IMAGE_MAX_AGE_MS = 15 * 60 * 1000;
|
||||
const DEFAULT_HERMES_MEDIA_DEBUG_PATH = "/var/log/aios/hermes-route-memory/weixin_media_debug.jsonl";
|
||||
@@ -576,6 +584,91 @@ function hasHermesReferenceContext(value: any, depth = 0): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractHermesReferencedText(value: any, depth = 0, insideReference = false): string {
|
||||
if (depth > 8 || value == null) return "";
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = extractHermesReferencedText(item, depth + 1, insideReference);
|
||||
if (found) return found;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (typeof value !== "object") return "";
|
||||
const obj = value as Record<string, any>;
|
||||
const referenceKeys = [
|
||||
"ref_msg",
|
||||
"refMsg",
|
||||
"quoted_message",
|
||||
"quotedMessage",
|
||||
"quoted",
|
||||
"quote",
|
||||
"referenced_message",
|
||||
"referencedMessage",
|
||||
];
|
||||
for (const key of referenceKeys) {
|
||||
if (key in obj) {
|
||||
const found = extractHermesReferencedText(obj[key], depth + 1, true);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
if (insideReference) {
|
||||
const directCandidates = [
|
||||
obj.text_item?.text,
|
||||
obj.textItem?.text,
|
||||
];
|
||||
for (const candidate of directCandidates) {
|
||||
const normalized = String(candidate ?? "").trim();
|
||||
if (normalized) return normalized.slice(0, 4000);
|
||||
}
|
||||
for (const key of ["message_item", "messageItem", "text_item", "textItem", "item_list", "itemList", "message", "msg", "payload"]) {
|
||||
if (key in obj) {
|
||||
const found = extractHermesReferencedText(obj[key], depth + 1, true);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
|
||||
if (key in obj) {
|
||||
const found = extractHermesReferencedText(obj[key], depth + 1, false);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveHermesQuotedTextContext(items: any, currentText: string): HermesQuotedTextContext {
|
||||
if (!hasHermesReferenceContext(items)) {
|
||||
return { referenced: false, handled: false, prompt: String(currentText ?? "").trim(), reason: "none" };
|
||||
}
|
||||
const quotedText = extractHermesReferencedText(items);
|
||||
if (!quotedText) {
|
||||
return {
|
||||
referenced: true,
|
||||
handled: true,
|
||||
replyText: "无法读取被引用文字,请复制原文后再提问。",
|
||||
reason: "quoted_text_unavailable",
|
||||
};
|
||||
}
|
||||
const normalizedCurrent = String(currentText ?? "").trim().slice(0, 4000);
|
||||
return {
|
||||
referenced: true,
|
||||
handled: false,
|
||||
prompt: `[被引用文字]\n${quotedText}\n[/被引用文字]\n\n[当前问题]\n${normalizedCurrent}\n[/当前问题]`,
|
||||
reason: "quoted_text_resolved",
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchHermesQuotedTextFailClosed(context: HermesQuotedTextContext, dispatcher: any): Promise<boolean> {
|
||||
if (!context.handled) return false;
|
||||
dispatcher.sendFinalReply({
|
||||
text: context.replyText ?? "无法读取被引用文字,请复制原文后再提问。",
|
||||
});
|
||||
dispatcher.markComplete();
|
||||
await dispatcher.waitForIdle();
|
||||
return true;
|
||||
}
|
||||
|
||||
function findHermesReferencedMediaItem(value: any, depth = 0, insideReference = false): any {
|
||||
if (depth > 8 || value == null) return undefined;
|
||||
if (Array.isArray(value)) {
|
||||
@@ -1002,6 +1095,97 @@ function hasHermesReferenceContext(value, depth = 0) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function extractHermesReferencedText(value, depth = 0, insideReference = false) {
|
||||
if (depth > 8 || value == null)
|
||||
return "";
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const found = extractHermesReferencedText(item, depth + 1, insideReference);
|
||||
if (found)
|
||||
return found;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (typeof value !== "object")
|
||||
return "";
|
||||
const obj = value;
|
||||
const referenceKeys = [
|
||||
"ref_msg",
|
||||
"refMsg",
|
||||
"quoted_message",
|
||||
"quotedMessage",
|
||||
"quoted",
|
||||
"quote",
|
||||
"referenced_message",
|
||||
"referencedMessage",
|
||||
];
|
||||
for (const key of referenceKeys) {
|
||||
if (key in obj) {
|
||||
const found = extractHermesReferencedText(obj[key], depth + 1, true);
|
||||
if (found)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
if (insideReference) {
|
||||
const directCandidates = [
|
||||
obj.text_item?.text,
|
||||
obj.textItem?.text,
|
||||
];
|
||||
for (const candidate of directCandidates) {
|
||||
const normalized = String(candidate ?? "").trim();
|
||||
if (normalized)
|
||||
return normalized.slice(0, 4000);
|
||||
}
|
||||
for (const key of ["message_item", "messageItem", "text_item", "textItem", "item_list", "itemList", "message", "msg", "payload"]) {
|
||||
if (key in obj) {
|
||||
const found = extractHermesReferencedText(obj[key], depth + 1, true);
|
||||
if (found)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (const key of ["item_list", "itemList", "message", "msg", "payload"]) {
|
||||
if (key in obj) {
|
||||
const found = extractHermesReferencedText(obj[key], depth + 1, false);
|
||||
if (found)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
function resolveHermesQuotedTextContext(items, currentText) {
|
||||
if (!hasHermesReferenceContext(items)) {
|
||||
return { referenced: false, handled: false, prompt: String(currentText ?? "").trim(), reason: "none" };
|
||||
}
|
||||
const quotedText = extractHermesReferencedText(items);
|
||||
if (!quotedText) {
|
||||
return {
|
||||
referenced: true,
|
||||
handled: true,
|
||||
replyText: "无法读取被引用文字,请复制原文后再提问。",
|
||||
reason: "quoted_text_unavailable",
|
||||
};
|
||||
}
|
||||
const normalizedCurrent = String(currentText ?? "").trim().slice(0, 4000);
|
||||
return {
|
||||
referenced: true,
|
||||
handled: false,
|
||||
prompt: `[被引用文字]\n${quotedText}\n[/被引用文字]\n\n[当前问题]\n${normalizedCurrent}\n[/当前问题]`,
|
||||
reason: "quoted_text_resolved",
|
||||
};
|
||||
}
|
||||
async function dispatchHermesQuotedTextFailClosed(context, dispatcher) {
|
||||
if (!context.handled)
|
||||
return false;
|
||||
dispatcher.sendFinalReply({
|
||||
text: context.replyText ?? "无法读取被引用文字,请复制原文后再提问。",
|
||||
});
|
||||
dispatcher.markComplete();
|
||||
await dispatcher.waitForIdle();
|
||||
return true;
|
||||
}
|
||||
function findHermesReferencedMediaItem(value, depth = 0, insideReference = false) {
|
||||
if (depth > 8 || value == null)
|
||||
return undefined;
|
||||
@@ -1331,6 +1515,18 @@ TS_PROCESS_HOOK = r'''
|
||||
: (hermesReferencedImageFollowupIntent
|
||||
? resolveHermesReferencedImageFollowup(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId)
|
||||
: resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent)));
|
||||
const hermesQuotedTextContext = hermesReferenceContextPresent &&
|
||||
!hermesExplicitSlash &&
|
||||
!hermesCurrentImage &&
|
||||
!hermesReferencedMediaIsImage &&
|
||||
!hermesFollowup.matched
|
||||
? resolveHermesQuotedTextContext(full.item_list, hermesContent)
|
||||
: { referenced: false, handled: false, prompt: hermesContent, reason: "none" as const };
|
||||
if (hermesQuotedTextContext.reason === "quoted_text_resolved" && hermesQuotedTextContext.prompt) {
|
||||
ctx.Body = hermesQuotedTextContext.prompt;
|
||||
ctx.CommandBody = hermesQuotedTextContext.prompt;
|
||||
logger.info(`[weixin] quoted text context resolved quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
|
||||
}
|
||||
if (hermesFollowup.reason === "referenced_image_unresolved") {
|
||||
writeHermesMediaDebug("referenced_image_unresolved", {
|
||||
accountId: deps.accountId,
|
||||
@@ -1378,7 +1574,7 @@ TS_PROCESS_HOOK = r'''
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!textBody.startsWith("/")) {
|
||||
if (!hermesQuotedTextContext.referenced && !textBody.startsWith("/")) {
|
||||
const hermesImageResult = await handleHermesImplicitImage(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
|
||||
if (hermesImageResult.handled) {
|
||||
logger.info(`[weixin] Hermes implicit image handled, skipping AI pipeline`);
|
||||
@@ -1390,7 +1586,7 @@ TS_PROCESS_HOOK = r'''
|
||||
}
|
||||
}
|
||||
// AIOS Hermes image ingress hook end
|
||||
'''.rstrip()
|
||||
'''.strip()
|
||||
|
||||
|
||||
JS_PROCESS_HOOK = r'''
|
||||
@@ -1411,6 +1607,18 @@ JS_PROCESS_HOOK = r'''
|
||||
: (hermesReferencedImageFollowupIntent
|
||||
? resolveHermesReferencedImageFollowup(deps.accountId, hermesSenderId, hermesContent, hermesQuotedMessageId)
|
||||
: resolveHermesFollowupImageContext(deps.accountId, hermesSenderId, hermesContent)));
|
||||
const hermesQuotedTextContext = hermesReferenceContextPresent &&
|
||||
!hermesExplicitSlash &&
|
||||
!hermesCurrentImage &&
|
||||
!hermesReferencedMediaIsImage &&
|
||||
!hermesFollowup.matched
|
||||
? resolveHermesQuotedTextContext(full.item_list, hermesContent)
|
||||
: { referenced: false, handled: false, prompt: hermesContent, reason: "none" };
|
||||
if (hermesQuotedTextContext.reason === "quoted_text_resolved" && hermesQuotedTextContext.prompt) {
|
||||
ctx.Body = hermesQuotedTextContext.prompt;
|
||||
ctx.CommandBody = hermesQuotedTextContext.prompt;
|
||||
logger.info(`[weixin] quoted text context resolved quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
|
||||
}
|
||||
if (hermesFollowup.reason === "referenced_image_unresolved") {
|
||||
writeHermesMediaDebug("referenced_image_unresolved", {
|
||||
accountId: deps.accountId,
|
||||
@@ -1458,7 +1666,7 @@ JS_PROCESS_HOOK = r'''
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!textBody.startsWith("/")) {
|
||||
if (!hermesQuotedTextContext.referenced && !textBody.startsWith("/")) {
|
||||
const hermesImageResult = await handleHermesImplicitImage(hermesContent, hermesRouteCtx, receivedAt, full.create_time_ms);
|
||||
if (hermesImageResult.handled) {
|
||||
logger.info(`[weixin] Hermes implicit image handled, skipping AI pipeline`);
|
||||
@@ -1470,7 +1678,29 @@ JS_PROCESS_HOOK = r'''
|
||||
}
|
||||
}
|
||||
// AIOS Hermes image ingress hook end
|
||||
'''.rstrip()
|
||||
'''.strip()
|
||||
|
||||
|
||||
TS_QUOTED_TEXT_DISPATCH_GUARD = r'''
|
||||
// AIOS quoted text dispatch guard begin
|
||||
if (await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)) {
|
||||
logger.info(`[weixin] quoted text fail-closed reason=${hermesQuotedTextContext.reason} quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
|
||||
markDispatchIdle();
|
||||
return;
|
||||
}
|
||||
// AIOS quoted text dispatch guard end
|
||||
'''.strip()
|
||||
|
||||
|
||||
JS_QUOTED_TEXT_DISPATCH_GUARD = r'''
|
||||
// AIOS quoted text dispatch guard begin
|
||||
if (await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)) {
|
||||
logger.info(`[weixin] quoted text fail-closed reason=${hermesQuotedTextContext.reason} quoted_message_id=${hermesQuotedMessageId ? "present" : "none"}`);
|
||||
markDispatchIdle();
|
||||
return;
|
||||
}
|
||||
// AIOS quoted text dispatch guard end
|
||||
'''.strip()
|
||||
|
||||
|
||||
TS_HANDLE_SLASH_COMMAND = r'''
|
||||
@@ -1886,23 +2116,38 @@ def patch_channel_outbound_cache(text: str) -> str:
|
||||
raise RuntimeError("sendWeixinMediaFile success branch not found for outbound cache patch")
|
||||
|
||||
|
||||
def remove_existing_process_hermes_hook(text: str) -> str:
|
||||
start_marker = "// AIOS Hermes image ingress hook begin"
|
||||
end_marker = "// AIOS Hermes image ingress hook end"
|
||||
start = text.find(start_marker)
|
||||
if start >= 0:
|
||||
def remove_managed_process_block(text: str, start_marker: str, end_marker: str, label: str) -> str:
|
||||
while True:
|
||||
start = text.find(start_marker)
|
||||
if start < 0:
|
||||
return text
|
||||
line_start = text.rfind("\n", 0, start)
|
||||
block_start = 0 if line_start < 0 else line_start + 1
|
||||
if line_start > 0 and text[line_start - 1] == "\n":
|
||||
block_start = line_start
|
||||
end = text.find(end_marker, start)
|
||||
if end < 0:
|
||||
raise RuntimeError("unterminated Hermes process hook block")
|
||||
raise RuntimeError(f"unterminated {label} block")
|
||||
end = text.find("\n", end)
|
||||
block_end = len(text) if end < 0 else end + 1
|
||||
while block_end < len(text) and text[block_end] == "\n":
|
||||
block_end += 1
|
||||
return text[:block_start] + text[block_end:]
|
||||
text = text[:block_start] + text[block_end:]
|
||||
|
||||
|
||||
def remove_existing_process_hermes_hook(text: str) -> str:
|
||||
text = remove_managed_process_block(
|
||||
text,
|
||||
"// AIOS Hermes image ingress hook begin",
|
||||
"// AIOS Hermes image ingress hook end",
|
||||
"Hermes process hook",
|
||||
)
|
||||
text = remove_managed_process_block(
|
||||
text,
|
||||
"// AIOS quoted text dispatch guard begin",
|
||||
"// AIOS quoted text dispatch guard end",
|
||||
"quoted text dispatch guard",
|
||||
)
|
||||
|
||||
old_hook_pattern = re.compile(
|
||||
r'\n\s*if \(!textBody\.startsWith\("/"\)\) \{\n'
|
||||
@@ -2004,6 +2249,16 @@ def patch_process_message_file(path: pathlib.Path, is_ts: bool) -> bool:
|
||||
if needle not in text:
|
||||
raise RuntimeError("ctx.CommandAuthorized assignment not found")
|
||||
text = text.replace(needle, needle + hook + "\n", 1)
|
||||
dispatch_guard = TS_QUOTED_TEXT_DISPATCH_GUARD if is_ts else JS_QUOTED_TEXT_DISPATCH_GUARD
|
||||
dispatch_needle = (
|
||||
' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'
|
||||
if is_ts
|
||||
else ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'
|
||||
)
|
||||
if dispatch_needle not in text:
|
||||
raise RuntimeError("dispatchReplyFromConfig start log not found")
|
||||
text = text.replace("\n\n" + dispatch_needle, "\n" + dispatch_needle, 1)
|
||||
text = text.replace(dispatch_needle, dispatch_guard + "\n" + dispatch_needle, 1)
|
||||
return write_if_changed(path, text, original)
|
||||
|
||||
|
||||
@@ -2104,6 +2359,10 @@ def verify_plugin_hook(root: pathlib.Path) -> dict[str, object]:
|
||||
and "resolveHermesFollowupImageContext" in text
|
||||
and "matched cached media" in text
|
||||
and "has no cached media, fail-closed" in text
|
||||
and "resolveHermesQuotedTextContext" in text
|
||||
and "dispatchHermesQuotedTextFailClosed" in text
|
||||
and "AIOS quoted text dispatch guard begin" in text
|
||||
and "无法读取被引用文字,请复制原文后再提问。" in text
|
||||
and "mediaPath" in text
|
||||
)
|
||||
|
||||
|
||||
@@ -211,6 +211,137 @@ def test_referenced_text_without_media_evidence_stays_out_of_image_cache(tmp_pat
|
||||
assert results['hermesFollowup']['reason'] == 'referenced_image_not_confirmed'
|
||||
|
||||
|
||||
def test_explicit_quoted_text_builds_grounded_plain_text_context(tmp_path: Path) -> None:
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const available = typeof resolveHermesQuotedTextContext === "function";',
|
||||
'const items = [{ type: 1, ref_msg: { message_item: { type: 1, text_item: { text: "被引用的正文" } } } }];',
|
||||
'const result = available ? resolveHermesQuotedTextContext(items, "这是什么意思?") : null;',
|
||||
'console.log(JSON.stringify({ available, result }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results['available'] is True
|
||||
assert results['result'] == {
|
||||
'referenced': True,
|
||||
'handled': False,
|
||||
'prompt': '[被引用文字]\n被引用的正文\n[/被引用文字]\n\n[当前问题]\n这是什么意思?\n[/当前问题]',
|
||||
'reason': 'quoted_text_resolved',
|
||||
}
|
||||
|
||||
|
||||
def test_missing_quoted_text_fails_closed_without_model_prompt(tmp_path: Path) -> None:
|
||||
results = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const available = typeof resolveHermesQuotedTextContext === "function";',
|
||||
'const items = [{ type: 1, ref_msg: { message_item: { type: 0, msg_id: "7481729105240418312" } } }];',
|
||||
'const result = available ? resolveHermesQuotedTextContext(items, "这是什么意思?") : null;',
|
||||
'let modelCalls = 0;',
|
||||
'if (result && !result.handled) modelCalls += 1;',
|
||||
'console.log(JSON.stringify({ available, result, modelCalls }));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert results['available'] is True
|
||||
assert results['result'] == {
|
||||
'referenced': True,
|
||||
'handled': True,
|
||||
'replyText': '无法读取被引用文字,请复制原文后再提问。',
|
||||
'reason': 'quoted_text_unavailable',
|
||||
}
|
||||
assert results['modelCalls'] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hook_name', ['TS_PROCESS_HOOK', 'JS_PROCESS_HOOK'])
|
||||
def test_process_hook_stops_unavailable_quote_before_model_dispatch(hook_name: str) -> None:
|
||||
patcher = load_patcher_module()
|
||||
hook = getattr(patcher, hook_name)
|
||||
guard_name = 'TS_QUOTED_TEXT_DISPATCH_GUARD' if hook_name.startswith('TS_') else 'JS_QUOTED_TEXT_DISPATCH_GUARD'
|
||||
guard = getattr(patcher, guard_name)
|
||||
|
||||
assert 'resolveHermesQuotedTextContext(full.item_list, hermesContent)' in hook
|
||||
assert '!hermesQuotedTextContext.referenced && !textBody.startsWith("/")' in hook
|
||||
assert 'ctx.Body = hermesQuotedTextContext.prompt' in hook
|
||||
assert 'await dispatchHermesQuotedTextFailClosed(hermesQuotedTextContext, dispatcher)' in guard
|
||||
assert 'markDispatchIdle();' in guard
|
||||
|
||||
|
||||
def test_dispatch_quoted_text_fail_closed_uses_dispatcher_without_model(tmp_path: Path) -> None:
|
||||
result = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'(async () => {',
|
||||
' const calls = [];',
|
||||
' const dispatcher = {',
|
||||
' sendFinalReply(payload) { calls.push(["final", payload]); },',
|
||||
' markComplete() { calls.push(["complete"]); },',
|
||||
' async waitForIdle() { calls.push(["idle"]); },',
|
||||
' };',
|
||||
' const context = resolveHermesQuotedTextContext([{ ref_msg: { message_item: { type: 0, msg_id: "quoted-1" } } }], "这是什么意思?");',
|
||||
' let modelCalls = 0;',
|
||||
' const handled = await dispatchHermesQuotedTextFailClosed(context, dispatcher);',
|
||||
' if (!handled) modelCalls += 1;',
|
||||
' console.log(JSON.stringify({ handled, modelCalls, calls }));',
|
||||
'})();',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert result['handled'] is True
|
||||
assert result['modelCalls'] == 0
|
||||
assert result['calls'] == [
|
||||
['final', {'text': '无法读取被引用文字,请复制原文后再提问。'}],
|
||||
['complete'],
|
||||
['idle'],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('is_ts', 'guard_name', 'needle'),
|
||||
[
|
||||
(True, 'TS_QUOTED_TEXT_DISPATCH_GUARD', ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'),
|
||||
(False, 'JS_QUOTED_TEXT_DISPATCH_GUARD', ' logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);\n'),
|
||||
],
|
||||
ids=['ts', 'js'],
|
||||
)
|
||||
def test_dispatch_guard_remove_and_reinsert_is_byte_idempotent(is_ts: bool, guard_name: str, needle: str) -> None:
|
||||
patcher = load_patcher_module()
|
||||
guard = getattr(patcher, guard_name)
|
||||
|
||||
def cycle(text: str) -> str:
|
||||
without = patcher.remove_existing_process_hermes_hook(text)
|
||||
return without.replace(needle, guard + '\n' + needle, 1)
|
||||
|
||||
once = cycle(needle)
|
||||
twice = cycle(once)
|
||||
|
||||
assert guard + '\n' + needle in once
|
||||
assert twice == once
|
||||
|
||||
|
||||
def test_reference_metadata_content_is_not_treated_as_quoted_text(tmp_path: Path) -> None:
|
||||
result = run_js_process_helpers(
|
||||
tmp_path,
|
||||
'\n'.join(
|
||||
[
|
||||
'const items = [{ ref_msg: { message_item: { type: 0, msg_id: "quoted-1", content: "metadata only" } } }];',
|
||||
'console.log(JSON.stringify(resolveHermesQuotedTextContext(items, "这是什么意思?")));',
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
assert result['handled'] is True
|
||||
assert result['reason'] == 'quoted_text_unavailable'
|
||||
assert result['replyText'] == '无法读取被引用文字,请复制原文后再提问。'
|
||||
|
||||
|
||||
def test_patch_plugin_process_only_skips_slash_and_channel(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
patcher = load_patcher_module()
|
||||
calls: list[tuple[str, Path]] = []
|
||||
|
||||
Reference in New Issue
Block a user