merge: guard WeChat page follow-up context
Memind CI / Test, build, and release guards (push) Successful in 4m46s
Memind CI / Test, build, and release guards (push) Successful in 4m46s
This commit is contained in:
+114
-33
@@ -974,6 +974,30 @@ export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) {
|
||||
);
|
||||
}
|
||||
|
||||
const WECHAT_IMMEDIATE_CONTEXT_PAGE_PATTERN =
|
||||
/^(?:(?:请|麻烦|可以|能不能)\s*)?(?:(?:帮我|帮忙|给我)\s*)?(?:(?:把\s*)?(?:这个|刚才(?:的)?(?:诗|文章|内容)?|上面(?:的)?|前面(?:的)?|上一条|它|这首(?:诗)?|这篇(?:文章)?|这段(?:内容)?)\s*)?(?:继续\s*)?(?:做|弄|改|转|写|排版|生成|制作)(?:成|为)?\s*(?:一个|个)?\s*(?:h5|html)?\s*(?:页面|网页)(?:吧|呀|呢|一下)?[。.!!\s]*$/iu;
|
||||
|
||||
export function isWechatImmediateContextFollowup(wechatIntent, text) {
|
||||
if (wechatIntent?.kind !== 'page.generate') return false;
|
||||
const normalized = String(text ?? '').trim();
|
||||
return Boolean(normalized && WECHAT_IMMEDIATE_CONTEXT_PAGE_PATTERN.test(normalized));
|
||||
}
|
||||
|
||||
export function shouldRotateUnlinkedWechatRoute({
|
||||
routeUpdatedAt,
|
||||
wechatMessageCount,
|
||||
snapshotMessageCount,
|
||||
now = Date.now(),
|
||||
} = {}) {
|
||||
const updatedAt = Number(routeUpdatedAt ?? 0);
|
||||
return (
|
||||
updatedAt > 0 &&
|
||||
Number(now) - updatedAt > 60_000 &&
|
||||
Number(wechatMessageCount) === 0 &&
|
||||
Number(snapshotMessageCount ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isWechatPageDataTask(text) {
|
||||
return isPageDataIntent(text) || isPageDataDevIntent(text);
|
||||
}
|
||||
@@ -1978,42 +2002,56 @@ export function createWechatMpService({
|
||||
config.sessionIdleRotateMs > 0 &&
|
||||
routeUpdatedAt > 0 &&
|
||||
now - routeUpdatedAt > config.sessionIdleRotateMs;
|
||||
let routeWechatMessageCount = null;
|
||||
let routeSnapshotMessageCount = null;
|
||||
let routeMessageCount = 0;
|
||||
if (config.sessionMessageRotateCount > 0) {
|
||||
const counts = [];
|
||||
if (typeof userAuth.countWechatAgentSessionMessages === 'function') {
|
||||
counts.push(
|
||||
userAuth
|
||||
.countWechatAgentSessionMessages({
|
||||
appId: config.appId,
|
||||
openid,
|
||||
agentSessionId: existingRoute.agentSessionId,
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn?.('WeChat MP route message count lookup failed:', err);
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (typeof userAuth.getWechatAgentSessionSnapshotMessageCount === 'function') {
|
||||
counts.push(
|
||||
userAuth
|
||||
.getWechatAgentSessionSnapshotMessageCount(existingRoute.agentSessionId)
|
||||
.catch((err) => {
|
||||
logger.warn?.('WeChat MP snapshot message count lookup failed:', err);
|
||||
return 0;
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (counts.length > 0) {
|
||||
const resolved = await Promise.all(counts);
|
||||
routeMessageCount = Math.max(0, ...resolved.map((value) => Number(value) || 0));
|
||||
}
|
||||
[routeWechatMessageCount, routeSnapshotMessageCount] = await Promise.all([
|
||||
typeof userAuth.countWechatAgentSessionMessages === 'function'
|
||||
? userAuth
|
||||
.countWechatAgentSessionMessages({
|
||||
appId: config.appId,
|
||||
openid,
|
||||
agentSessionId: existingRoute.agentSessionId,
|
||||
})
|
||||
.then((value) => Number(value) || 0)
|
||||
.catch((err) => {
|
||||
logger.warn?.('WeChat MP route message count lookup failed:', err);
|
||||
return null;
|
||||
})
|
||||
: null,
|
||||
typeof userAuth.getWechatAgentSessionSnapshotMessageCount === 'function'
|
||||
? userAuth
|
||||
.getWechatAgentSessionSnapshotMessageCount(existingRoute.agentSessionId)
|
||||
.then((value) => Number(value) || 0)
|
||||
.catch((err) => {
|
||||
logger.warn?.('WeChat MP snapshot message count lookup failed:', err);
|
||||
return null;
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
routeMessageCount = Math.max(
|
||||
0,
|
||||
Number(routeWechatMessageCount) || 0,
|
||||
Number(routeSnapshotMessageCount) || 0,
|
||||
);
|
||||
}
|
||||
const routeIsTooLong =
|
||||
config.sessionMessageRotateCount > 0 &&
|
||||
Number(routeMessageCount ?? 0) >= config.sessionMessageRotateCount;
|
||||
if (routeIsIdle || routeIsTooLong) {
|
||||
const routeHasUnlinkedConversation = shouldRotateUnlinkedWechatRoute({
|
||||
routeUpdatedAt,
|
||||
wechatMessageCount: routeWechatMessageCount,
|
||||
snapshotMessageCount: routeSnapshotMessageCount,
|
||||
now,
|
||||
});
|
||||
if (routeIsIdle || routeIsTooLong || routeHasUnlinkedConversation) {
|
||||
if (routeHasUnlinkedConversation) {
|
||||
logger.warn?.('WeChat MP orphaned route rotated before reuse:', {
|
||||
agentSessionId: existingRoute.agentSessionId,
|
||||
snapshotMessageCount: routeSnapshotMessageCount,
|
||||
});
|
||||
}
|
||||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
||||
rememberedWechatContexts.delete(existingRoute.agentSessionId);
|
||||
} else {
|
||||
@@ -2271,7 +2309,12 @@ export function createWechatMpService({
|
||||
}
|
||||
};
|
||||
|
||||
const prepareWechatAgentUserMessage = async ({ userId, sessionId, userMessage }) => {
|
||||
const prepareWechatAgentUserMessage = async ({
|
||||
userId,
|
||||
sessionId,
|
||||
userMessage,
|
||||
preserveAgentPrompt = false,
|
||||
}) => {
|
||||
if (
|
||||
!chatIntentRouter?.resolveAgentMemoryContext
|
||||
|| !chatIntentRouter?.applyAgentOrchestration
|
||||
@@ -2296,8 +2339,17 @@ export function createWechatMpService({
|
||||
) {
|
||||
return userMessage;
|
||||
}
|
||||
return chatIntentRouter.applyAgentOrchestration(
|
||||
userMessage,
|
||||
const orchestrationMessage = preserveAgentPrompt
|
||||
? {
|
||||
...userMessage,
|
||||
metadata: {
|
||||
...(userMessage?.metadata ?? {}),
|
||||
displayText: messageVisibleText(userMessage),
|
||||
},
|
||||
}
|
||||
: userMessage;
|
||||
const prepared = chatIntentRouter.applyAgentOrchestration(
|
||||
orchestrationMessage,
|
||||
{
|
||||
route: 'agent_orchestration',
|
||||
reason: '微信服务号消息由 Agent 处理',
|
||||
@@ -2305,6 +2357,14 @@ export function createWechatMpService({
|
||||
},
|
||||
{ memoryContext },
|
||||
);
|
||||
if (!preserveAgentPrompt) return prepared;
|
||||
return {
|
||||
...prepared,
|
||||
metadata: {
|
||||
...(prepared?.metadata ?? {}),
|
||||
displayText: displayText || undefined,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn?.(
|
||||
'WeChat MP agent memory resolve skipped:',
|
||||
@@ -2371,6 +2431,8 @@ export function createWechatMpService({
|
||||
// inspect/retry unrelated historical pages from that session.
|
||||
const isPageDataRequest = isWechatPageDataTask(resetCandidate);
|
||||
const htmlArtifactDeliveryExpected = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent);
|
||||
const immediateContextFollowup =
|
||||
isWechatImmediateContextFollowup(wechatIntent, resetCandidate);
|
||||
const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate);
|
||||
let route = await ensureWechatAgentSession({
|
||||
userId: user.userId,
|
||||
@@ -2417,6 +2479,7 @@ export function createWechatMpService({
|
||||
? buildPageGenerateAgentPrompt(intent, {
|
||||
wantsDocx: wechatIntent.wantsDocx,
|
||||
imagePolicy,
|
||||
preferImmediateContext: immediateContextFollowup,
|
||||
})
|
||||
: buildWechatAgentPrompt(intent, { imagePolicy });
|
||||
const reply = await executeSessionReply(
|
||||
@@ -2434,6 +2497,7 @@ export function createWechatMpService({
|
||||
userId: user.userId,
|
||||
sessionId,
|
||||
userMessage,
|
||||
preserveAgentPrompt: immediateContextFollowup,
|
||||
}),
|
||||
submitReply: submitSessionReply
|
||||
? ({ requestId: replyRequestId, userMessage }) =>
|
||||
@@ -2657,6 +2721,21 @@ export function createWechatMpService({
|
||||
sessionId
|
||||
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
|
||||
if (mayBeStaleSession) {
|
||||
if (immediateContextFollowup) {
|
||||
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
||||
logger.warn?.('WeChat MP contextual follow-up route clear failed:', clearErr);
|
||||
});
|
||||
const text =
|
||||
'我没能可靠确认“做成页面”指的是哪段内容,已经停止沿用旧主题。请把要做成页面的主题或原文再发一次。';
|
||||
try {
|
||||
await sendCustomerServiceText(inbound.fromUserName, text, user);
|
||||
} catch (sendErr) {
|
||||
logger.error?.('WeChat MP contextual follow-up notice failed:', sendErr);
|
||||
}
|
||||
const contextualError = markWechatUserNotified(new Error(text));
|
||||
contextualError.wechatAgentSessionId = sessionId;
|
||||
throw contextualError;
|
||||
}
|
||||
route = await ensureWechatAgentSession({
|
||||
userId: user.userId,
|
||||
openid: inbound.fromUserName,
|
||||
@@ -2673,6 +2752,7 @@ export function createWechatMpService({
|
||||
? buildPageGenerateAgentPrompt(intent, {
|
||||
wantsDocx: wechatIntent.wantsDocx,
|
||||
imagePolicy,
|
||||
preferImmediateContext: immediateContextFollowup,
|
||||
})
|
||||
: buildWechatAgentPrompt(intent, { imagePolicy });
|
||||
const reply = await executeSessionReply(
|
||||
@@ -2690,6 +2770,7 @@ export function createWechatMpService({
|
||||
userId: user.userId,
|
||||
sessionId,
|
||||
userMessage,
|
||||
preserveAgentPrompt: immediateContextFollowup,
|
||||
}),
|
||||
submitReply: submitSessionReply
|
||||
? ({ requestId: replyRequestId, userMessage }) =>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -8,7 +8,14 @@ import { buildWechatPageImageInstruction } from '../image-generation-policy.mjs'
|
||||
/**
|
||||
* Service-account-only page generation prompt. Does not use H5 buildAutoChatSkillPrefix.
|
||||
*/
|
||||
export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imagePolicy = null } = {}) {
|
||||
export function buildPageGenerateAgentPrompt(
|
||||
intent,
|
||||
{
|
||||
wantsDocx = false,
|
||||
imagePolicy = null,
|
||||
preferImmediateContext = false,
|
||||
} = {},
|
||||
) {
|
||||
const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||||
const pageDataTask = isPageDataIntent(topic) || isPageDataDevIntent(topic);
|
||||
const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
||||
@@ -22,6 +29,14 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imageP
|
||||
const imageBlock = buildWechatPageImageInstruction(imagePolicy, {
|
||||
sourceMessageId: intent?.msgId,
|
||||
});
|
||||
const immediateContextBlock = preferImmediateContext
|
||||
? [
|
||||
'【即时上下文优先】当前需求是对本会话紧邻内容的续作。',
|
||||
'“这个/刚才/做成页面”等指代只能解析为当前会话最近一轮内容,禁止用长期记忆、历史偏好或旧任务替换主题。',
|
||||
'如果当前会话没有可辨识的紧邻内容,必须请用户补充主题,禁止猜测并生成其他页面。',
|
||||
'',
|
||||
].join('\n')
|
||||
: '';
|
||||
const coverExample = imageBlock
|
||||
? '<本轮 generate_image 返回的 asset.htmlSrc>'
|
||||
: 'assets/hero.jpg';
|
||||
@@ -42,6 +57,7 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false, imageP
|
||||
'',
|
||||
docxBlock,
|
||||
imageBlock,
|
||||
immediateContextBlock,
|
||||
pageDataBlock,
|
||||
'步骤(必须全部完成):',
|
||||
pageDataTask
|
||||
|
||||
Reference in New Issue
Block a user