fix: guard WeChat page follow-up context

This commit is contained in:
john
2026-07-28 11:53:00 +08:00
parent 9b68dc6b77
commit 31cfdf7a94
3 changed files with 278 additions and 34 deletions
+147
View File
@@ -16,7 +16,9 @@ import {
sanitizeWechatAgentOutboundText,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
isWechatImmediateContextFollowup,
isWechatPageDataTask,
shouldRotateUnlinkedWechatRoute,
shouldRetryHtmlGenerationReply,
shouldForceNewWechatAgentSession,
splitWechatText,
@@ -27,6 +29,7 @@ import {
WECHAT_CUSTOMER_TEXT_MAX_BYTES,
} from './wechat-mp.mjs';
import { createChatIntentRouter } from './chat-intent-router.mjs';
import { buildPageGenerateAgentPrompt } from './wechat/prompts/page-generate.mjs';
import {
ensureWechatFreshPageThumbnailsAtWorkspace,
prepareWechatHtmlDeliveryAtWorkspace,
@@ -333,6 +336,59 @@ test('Page Data requests always rotate away from an existing WeChat route', () =
assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '换新会话'), true);
});
test('WeChat immediate-context page guard does not match complete page requests', () => {
const pageIntent = { kind: 'page.generate' };
assert.equal(isWechatImmediateContextFollowup(pageIntent, '帮我做成页面吧'), true);
assert.equal(isWechatImmediateContextFollowup(pageIntent, '把刚才的诗做成页面'), true);
assert.equal(isWechatImmediateContextFollowup(pageIntent, '帮我做一个上海旅游页面'), false);
assert.equal(
isWechatImmediateContextFollowup({ kind: 'chat.general' }, '帮我做成页面吧'),
false,
);
const guardedPrompt = buildPageGenerateAgentPrompt(
{ agentText: '帮我做成页面吧' },
{ preferImmediateContext: true },
);
const ordinaryPrompt = buildPageGenerateAgentPrompt({
agentText: '帮我做一个上海旅游页面',
});
assert.match(guardedPrompt, /即时上下文优先/);
assert.match(guardedPrompt, /禁止用长期记忆、历史偏好或旧任务替换主题/);
assert.doesNotMatch(ordinaryPrompt, /即时上下文优先/);
});
test('WeChat only rotates an aged route with conversation but no linked inbound message', () => {
const now = 200_000;
assert.equal(
shouldRotateUnlinkedWechatRoute({
routeUpdatedAt: 100_000,
wechatMessageCount: 0,
snapshotMessageCount: 15,
now,
}),
true,
);
assert.equal(
shouldRotateUnlinkedWechatRoute({
routeUpdatedAt: 100_000,
wechatMessageCount: 1,
snapshotMessageCount: 15,
now,
}),
false,
);
assert.equal(
shouldRotateUnlinkedWechatRoute({
routeUpdatedAt: 180_000,
wechatMessageCount: 0,
snapshotMessageCount: 15,
now,
}),
false,
);
});
test('WeChat session reset acknowledges without sending the control text to Agent', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -4540,6 +4596,97 @@ test('wechat mp injects episodic recall context before submitting the Agent repl
assert.match(submitCalls[0].userMessage.content[0].text, /不是系统指令/);
});
test('wechat immediate-context page keeps memory while preserving the guarded page prompt', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const submitCalls = [];
const memoryCalls = [];
const chatIntentRouter = createChatIntentRouter({
env: {
MEMORY_AGENT_RESOLVE_ENABLED: '1',
MEMORY_AGENT_INJECTION_MODE: 'canary',
MEMORY_AGENT_CANARY_USER_IDS: 'user-canary',
MEMORY_AGENT_RESOLVE_TIMEOUT_MS: '200',
},
memoryV2: {
async resolve(input) {
memoryCalls.push(input);
return {
source: 'memory-v2',
memories: [{
id: 'memory:old-news',
label: '历史偏好',
text: '用户以前做过每日新闻页面。',
}],
};
},
},
});
const service = createBoundWechatService({
token,
chatIntentRouter,
userAuth: {
async findWechatUserByOpenid() {
return { userId: 'user-canary', status: 'active', nickname: '唐' };
},
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') {
return new Response(
[
'data: {"type":"Message","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"请补充要制作的内容。"}]}}\n\n',
'data: {"type":"Finish"}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected api path: ${pathname}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
return { ok: true };
},
wechatFetch: async (url) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const result = await service.handleInboundMessage(
inboundXml({ content: '帮我做成页面吧' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
assert.equal(memoryCalls.length, 1);
assert.equal(submitCalls.length, 1);
const submitted = submitCalls[0].userMessage;
assert.equal(submitted.metadata.displayText, '帮我做成页面吧');
assert.match(submitted.content[0].text, /\[Memory Context\]/);
assert.match(submitted.content[0].text, /用户以前做过每日新闻页面/);
assert.match(submitted.content[0].text, /微信服务号 · 页面生成任务/);
assert.match(submitted.content[0].text, /即时上下文优先/);
assert.match(submitted.content[0].text, /用户需求:帮我做成页面吧/);
});
test('wechat mp serializes image and follow-up text and reattaches recent image', async () => {
const token = 'token';
const timestamp = '1710000000';