fix(wechat): guard bare page-continuation follow-ups from session loss and memory drift
Memind CI / Test, build, and release guards (push) Failing after 3s
Memind CI / Test, build, and release guards (push) Failing after 3s
Prevent chat-to-page flows like writing a poem then saying "做成页面吧" from opening a fresh session without carried content, failing with a vague error, or letting Memory page-template preferences replace the immediate topic. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+110
-3
@@ -44,6 +44,13 @@ import {
|
|||||||
isWechatSessionPageContinuation,
|
isWechatSessionPageContinuation,
|
||||||
isWechatContentEditFollowup,
|
isWechatContentEditFollowup,
|
||||||
} from './wechat/intent/page-continuation.mjs';
|
} from './wechat/intent/page-continuation.mjs';
|
||||||
|
import {
|
||||||
|
extractLastSubstantiveAssistantFromConversation,
|
||||||
|
filterMemoryForImmediatePageContext,
|
||||||
|
isWechatPageContinuationRepairableError,
|
||||||
|
shouldFailFastWechatPageContinuation,
|
||||||
|
WECHAT_PAGE_CONTINUATION_MISSING_SOURCE_TEXT,
|
||||||
|
} from './wechat/intent/page-continuation-context.mjs';
|
||||||
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
|
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
|
||||||
import {
|
import {
|
||||||
MEMORY_V2_PRODUCT_EVENT_TYPES,
|
MEMORY_V2_PRODUCT_EVENT_TYPES,
|
||||||
@@ -231,6 +238,18 @@ function messageVisibleText(message) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchLastSubstantiveAssistantFromSession(fetchForSession, sessionId) {
|
||||||
|
if (!sessionId || typeof fetchForSession !== 'function') return '';
|
||||||
|
try {
|
||||||
|
const payload = await readJsonResponse(
|
||||||
|
await fetchForSession(sessionId, `/sessions/${encodeURIComponent(sessionId)}`),
|
||||||
|
);
|
||||||
|
return extractLastSubstantiveAssistantFromConversation(payload?.conversation ?? []);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pushMessage(messages, incoming) {
|
function pushMessage(messages, incoming) {
|
||||||
const last = messages[messages.length - 1];
|
const last = messages[messages.length - 1];
|
||||||
if (last?.id && incoming?.id && last.id === incoming.id) {
|
if (last?.id && incoming?.id && last.id === incoming.id) {
|
||||||
@@ -2118,6 +2137,7 @@ export function createWechatMpService({
|
|||||||
await userAuth.getAgentSessionPolicy(userId);
|
await userAuth.getAgentSessionPolicy(userId);
|
||||||
const publishLayout = await userAuth.getUserPublishLayout(userId);
|
const publishLayout = await userAuth.getUserPublishLayout(userId);
|
||||||
const addressName = resolveWechatAddressName(userContext);
|
const addressName = resolveWechatAddressName(userContext);
|
||||||
|
let carriedSessionContentForNewSession = '';
|
||||||
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
|
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
|
||||||
if (existingRoute?.agentSessionId) {
|
if (existingRoute?.agentSessionId) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -2223,7 +2243,25 @@ export function createWechatMpService({
|
|||||||
logger.warn?.('WeChat MP route touch failed:', err);
|
logger.warn?.('WeChat MP route touch failed:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { sessionId: existingRoute.agentSessionId, isNewSession: false };
|
return {
|
||||||
|
sessionId: existingRoute.agentSessionId,
|
||||||
|
isNewSession: false,
|
||||||
|
carriedSessionContent: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let carriedSessionContent = '';
|
||||||
|
if (retainUnlinkedRoute) {
|
||||||
|
carriedSessionContent = await fetchLastSubstantiveAssistantFromSession(
|
||||||
|
fetchForSession,
|
||||||
|
existingRoute.agentSessionId,
|
||||||
|
);
|
||||||
|
if (carriedSessionContent) {
|
||||||
|
logger.warn?.('WeChat MP retaining prior assistant content after route tool mismatch:', {
|
||||||
|
agentSessionId: existingRoute.agentSessionId,
|
||||||
|
contentLength: carriedSessionContent.length,
|
||||||
|
});
|
||||||
|
carriedSessionContentForNewSession = carriedSessionContent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
||||||
rememberedWechatContexts.delete(existingRoute.agentSessionId);
|
rememberedWechatContexts.delete(existingRoute.agentSessionId);
|
||||||
@@ -2304,7 +2342,11 @@ export function createWechatMpService({
|
|||||||
openid,
|
openid,
|
||||||
agentSessionId: sessionId,
|
agentSessionId: sessionId,
|
||||||
});
|
});
|
||||||
return { sessionId, isNewSession: true };
|
return {
|
||||||
|
sessionId,
|
||||||
|
isNewSession: true,
|
||||||
|
carriedSessionContent: carriedSessionContentForNewSession,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshWechatSessionSnapshot = async (sessionId, userId) => {
|
const refreshWechatSessionSnapshot = async (sessionId, userId) => {
|
||||||
@@ -2452,12 +2494,13 @@ export function createWechatMpService({
|
|||||||
?? messageVisibleText(userMessage),
|
?? messageVisibleText(userMessage),
|
||||||
).trim();
|
).trim();
|
||||||
try {
|
try {
|
||||||
const memoryContext = await chatIntentRouter.resolveAgentMemoryContext({
|
const memoryContextRaw = await chatIntentRouter.resolveAgentMemoryContext({
|
||||||
userId,
|
userId,
|
||||||
sessionId,
|
sessionId,
|
||||||
text: displayText,
|
text: displayText,
|
||||||
forceDeepReasoning: false,
|
forceDeepReasoning: false,
|
||||||
});
|
});
|
||||||
|
let memoryContext = memoryContextRaw;
|
||||||
if (
|
if (
|
||||||
!memoryContext?.injectionEnabled
|
!memoryContext?.injectionEnabled
|
||||||
|| !Array.isArray(memoryContext.memories)
|
|| !Array.isArray(memoryContext.memories)
|
||||||
@@ -2465,6 +2508,15 @@ export function createWechatMpService({
|
|||||||
) {
|
) {
|
||||||
return userMessage;
|
return userMessage;
|
||||||
}
|
}
|
||||||
|
if (preserveAgentPrompt) {
|
||||||
|
memoryContext = {
|
||||||
|
...memoryContext,
|
||||||
|
memories: filterMemoryForImmediatePageContext(memoryContext.memories),
|
||||||
|
};
|
||||||
|
if (memoryContext.memories.length === 0) {
|
||||||
|
return userMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
const memoryCount = memoryContext.memories.length;
|
const memoryCount = memoryContext.memories.length;
|
||||||
if (mysqlPool?.query) {
|
if (mysqlPool?.query) {
|
||||||
void recordMemoryV2ProductEvent(mysqlPool, {
|
void recordMemoryV2ProductEvent(mysqlPool, {
|
||||||
@@ -2597,6 +2649,7 @@ export function createWechatMpService({
|
|||||||
retainUnlinkedRoute: sessionPageContinuation,
|
retainUnlinkedRoute: sessionPageContinuation,
|
||||||
});
|
});
|
||||||
let sessionId = route.sessionId;
|
let sessionId = route.sessionId;
|
||||||
|
let carriedSessionContent = String(route.carriedSessionContent ?? '').trim();
|
||||||
await ensureSessionProvider(sessionId);
|
await ensureSessionProvider(sessionId);
|
||||||
if (wechatIntent.kind === 'session.reset') {
|
if (wechatIntent.kind === 'session.reset') {
|
||||||
await sendCustomerServiceText(
|
await sendCustomerServiceText(
|
||||||
@@ -2607,6 +2660,34 @@ export function createWechatMpService({
|
|||||||
return { sessionId };
|
return { sessionId };
|
||||||
}
|
}
|
||||||
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
||||||
|
if (
|
||||||
|
wechatIntent.kind === 'page.generate'
|
||||||
|
&& sessionPageContinuation
|
||||||
|
) {
|
||||||
|
const sessionAssistantContent = carriedSessionContent
|
||||||
|
|| await fetchLastSubstantiveAssistantFromSession(fetchForSession, sessionId);
|
||||||
|
if (
|
||||||
|
shouldFailFastWechatPageContinuation({
|
||||||
|
sessionPageContinuation,
|
||||||
|
userText: resetCandidate,
|
||||||
|
carriedSessionContent,
|
||||||
|
sessionAssistantContent,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
const text = WECHAT_PAGE_CONTINUATION_MISSING_SOURCE_TEXT;
|
||||||
|
try {
|
||||||
|
await sendWechatFailureNotice(inbound.fromUserName, text, user, {
|
||||||
|
sourceMsgId: intent.msgId,
|
||||||
|
});
|
||||||
|
} catch (sendErr) {
|
||||||
|
logger.error?.('WeChat MP page continuation missing-source notice failed:', sendErr);
|
||||||
|
}
|
||||||
|
throw markWechatUserNotified(new Error(text));
|
||||||
|
}
|
||||||
|
if (!carriedSessionContent && sessionAssistantContent) {
|
||||||
|
carriedSessionContent = sessionAssistantContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
let finished = false;
|
let finished = false;
|
||||||
let progressTimer = null;
|
let progressTimer = null;
|
||||||
const skipProgressReply =
|
const skipProgressReply =
|
||||||
@@ -2641,6 +2722,7 @@ export function createWechatMpService({
|
|||||||
wantsDocx: wechatIntent.wantsDocx,
|
wantsDocx: wechatIntent.wantsDocx,
|
||||||
imagePolicy,
|
imagePolicy,
|
||||||
preferImmediateContext: sessionPageContinuation,
|
preferImmediateContext: sessionPageContinuation,
|
||||||
|
carriedSessionContent,
|
||||||
})
|
})
|
||||||
: buildWechatAgentPrompt(intent, {
|
: buildWechatAgentPrompt(intent, {
|
||||||
imagePolicy,
|
imagePolicy,
|
||||||
@@ -2765,6 +2847,14 @@ export function createWechatMpService({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (pageOutcome.action === 'session_retry') {
|
if (pageOutcome.action === 'session_retry') {
|
||||||
|
if (sessionPageContinuation && pageAttempt < maxImmediateContextPageAttempts) {
|
||||||
|
logger.warn?.('WeChat MP immediate-context fake delivery repair retry:', {
|
||||||
|
sessionId,
|
||||||
|
userId: user.userId,
|
||||||
|
pageAttempt,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
throw new Error('stale_session_poisoned_completion');
|
throw new Error('stale_session_poisoned_completion');
|
||||||
}
|
}
|
||||||
if (pageOutcome.action === 'fail') {
|
if (pageOutcome.action === 'fail') {
|
||||||
@@ -2987,6 +3077,22 @@ export function createWechatMpService({
|
|||||||
sessionId
|
sessionId
|
||||||
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
|
&& (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message));
|
||||||
if (mayBeStaleSession) {
|
if (mayBeStaleSession) {
|
||||||
|
if (sessionPageContinuation && isWechatPageContinuationRepairableError(message)) {
|
||||||
|
const text = buildPagePublishFailureText();
|
||||||
|
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
||||||
|
logger.warn?.('WeChat MP page continuation repair route clear failed:', clearErr);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await sendWechatFailureNotice(inbound.fromUserName, text, user, {
|
||||||
|
sourceMsgId: intent.msgId,
|
||||||
|
});
|
||||||
|
} catch (sendErr) {
|
||||||
|
logger.error?.('WeChat MP page continuation repair notice failed:', sendErr);
|
||||||
|
}
|
||||||
|
const repairError = markWechatUserNotified(new Error(text));
|
||||||
|
repairError.wechatAgentSessionId = sessionId;
|
||||||
|
throw repairError;
|
||||||
|
}
|
||||||
if (sessionPageContinuation) {
|
if (sessionPageContinuation) {
|
||||||
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
|
||||||
logger.warn?.('WeChat MP contextual follow-up route clear failed:', clearErr);
|
logger.warn?.('WeChat MP contextual follow-up route clear failed:', clearErr);
|
||||||
@@ -3021,6 +3127,7 @@ export function createWechatMpService({
|
|||||||
wantsDocx: wechatIntent.wantsDocx,
|
wantsDocx: wechatIntent.wantsDocx,
|
||||||
imagePolicy,
|
imagePolicy,
|
||||||
preferImmediateContext: sessionPageContinuation,
|
preferImmediateContext: sessionPageContinuation,
|
||||||
|
carriedSessionContent,
|
||||||
})
|
})
|
||||||
: buildWechatAgentPrompt(intent, {
|
: buildWechatAgentPrompt(intent, {
|
||||||
imagePolicy,
|
imagePolicy,
|
||||||
|
|||||||
+114
-4
@@ -4864,12 +4864,13 @@ test('wechat mp injects episodic recall context before submitting the Agent repl
|
|||||||
assert.match(submitCalls[0].userMessage.content[0].text, /不是系统指令/);
|
assert.match(submitCalls[0].userMessage.content[0].text, /不是系统指令/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wechat immediate-context page keeps memory while preserving the guarded page prompt', async () => {
|
test('wechat immediate-context page fail-closes bare follow-up without adjacent content', async () => {
|
||||||
const token = 'token';
|
const token = 'token';
|
||||||
const timestamp = '1710000000';
|
const timestamp = '1710000000';
|
||||||
const nonce = 'nonce';
|
const nonce = 'nonce';
|
||||||
const submitCalls = [];
|
const submitCalls = [];
|
||||||
const memoryCalls = [];
|
const memoryCalls = [];
|
||||||
|
const failureNotices = [];
|
||||||
const chatIntentRouter = createChatIntentRouter({
|
const chatIntentRouter = createChatIntentRouter({
|
||||||
env: {
|
env: {
|
||||||
MEMORY_AGENT_RESOLVE_ENABLED: '1',
|
MEMORY_AGENT_RESOLVE_ENABLED: '1',
|
||||||
@@ -4884,8 +4885,8 @@ test('wechat immediate-context page keeps memory while preserving the guarded pa
|
|||||||
source: 'memory-v2',
|
source: 'memory-v2',
|
||||||
memories: [{
|
memories: [{
|
||||||
id: 'memory:old-news',
|
id: 'memory:old-news',
|
||||||
label: '历史偏好',
|
label: 'preference',
|
||||||
text: '用户以前做过每日新闻页面。',
|
text: '用户要求每日新闻页面固定格式。',
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -4900,6 +4901,112 @@ test('wechat immediate-context page keeps memory while preserving the guarded pa
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sessionApiFetch: async (_sessionId, pathname) => {
|
sessionApiFetch: async (_sessionId, pathname) => {
|
||||||
|
if (pathname === '/sessions/session-1') {
|
||||||
|
return new Response(JSON.stringify({ conversation: [] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pathname === '/sessions/session-1/events') {
|
||||||
|
return new Response('', { 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, init = {}) => {
|
||||||
|
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')) {
|
||||||
|
failureNotices.push(JSON.parse(init.body));
|
||||||
|
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, 0);
|
||||||
|
assert.equal(submitCalls.length, 0);
|
||||||
|
assert.equal(failureNotices.length, 1);
|
||||||
|
assert.match(failureNotices[0].text.content, /没能确定您要把哪段内容做成页面/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wechat immediate-context page keeps non-template memory when adjacent content exists', async () => {
|
||||||
|
const token = 'token';
|
||||||
|
const timestamp = '1710000000';
|
||||||
|
const nonce = 'nonce';
|
||||||
|
const submitCalls = [];
|
||||||
|
const memoryCalls = [];
|
||||||
|
const poem = `《临江仙·秋思》${'昨夜西风凋碧树,独上高楼,望尽天涯路。'.repeat(3)}`;
|
||||||
|
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: 'preference',
|
||||||
|
text: '用户要求每日新闻页面固定格式。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'memory:name',
|
||||||
|
label: 'fact',
|
||||||
|
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') {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
conversation: [{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: poem }],
|
||||||
|
}],
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
if (pathname === '/sessions/session-1/events') {
|
if (pathname === '/sessions/session-1/events') {
|
||||||
return new Response(
|
return new Response(
|
||||||
[
|
[
|
||||||
@@ -4949,9 +5056,12 @@ test('wechat immediate-context page keeps memory while preserving the guarded pa
|
|||||||
const submitted = submitCalls[0].userMessage;
|
const submitted = submitCalls[0].userMessage;
|
||||||
assert.equal(submitted.metadata.displayText, '帮我做成页面吧');
|
assert.equal(submitted.metadata.displayText, '帮我做成页面吧');
|
||||||
assert.match(submitted.content[0].text, /\[Memory Context\]/);
|
assert.match(submitted.content[0].text, /\[Memory Context\]/);
|
||||||
assert.match(submitted.content[0].text, /用户以前做过每日新闻页面/);
|
assert.doesNotMatch(submitted.content[0].text, /每日新闻页面固定格式/);
|
||||||
|
assert.match(submitted.content[0].text, /用户名字是唐/);
|
||||||
assert.match(submitted.content[0].text, /微信服务号 · 页面生成任务/);
|
assert.match(submitted.content[0].text, /微信服务号 · 页面生成任务/);
|
||||||
assert.match(submitted.content[0].text, /即时上下文优先/);
|
assert.match(submitted.content[0].text, /即时上下文优先/);
|
||||||
|
assert.match(submitted.content[0].text, /待排版正文/);
|
||||||
|
assert.match(submitted.content[0].text, /临江仙/);
|
||||||
assert.match(submitted.content[0].text, /用户需求:帮我做成页面吧/);
|
assert.match(submitted.content[0].text, /用户需求:帮我做成页面吧/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/** Guards for WeChat “做成页面” follow-ups that must bind to in-session content. */
|
||||||
|
|
||||||
|
const PAGE_SOURCE_REFERENCE_PATTERN =
|
||||||
|
/(?:这个|刚才|上面|前面|上一条|它|这首|这篇|这段|以下内容|以上内容|刚才生成|上面生成)/iu;
|
||||||
|
|
||||||
|
const PAGE_TEMPLATE_MEMORY_PATTERN =
|
||||||
|
/(?:做成页面|新闻页|新闻页面|固定格式|daily[- ]?news|国际国内热门新闻)/iu;
|
||||||
|
|
||||||
|
const ORCHESTRATION_PREFIX_PATTERN =
|
||||||
|
/^【Memind 任务编排】[\s\S]*?用户任务:\s*/u;
|
||||||
|
|
||||||
|
const MIN_SUBSTANTIVE_ASSISTANT_CHARS = 48;
|
||||||
|
|
||||||
|
export function hasExplicitPageSourceReference(text) {
|
||||||
|
return PAGE_SOURCE_REFERENCE_PATTERN.test(String(text ?? '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBareImmediatePageCreateRequest(text) {
|
||||||
|
const normalized = String(text ?? '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (!/(?:吧|呀|呢|一下)[。.!!\s]*$/u.test(normalized)) return false;
|
||||||
|
return (
|
||||||
|
/^(?:(?:请|麻烦|可以|能不能)\s*)?(?:(?:帮我|帮忙|给我)\s*)?(?:做|弄|改|转|写|排版|生成|制作)(?:成|为)?\s*(?:一个|个)?\s*(?:页面|网页)/iu.test(
|
||||||
|
normalized,
|
||||||
|
)
|
||||||
|
&& !hasExplicitPageSourceReference(normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripOrchestrationFromAssistantText(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(ORCHESTRATION_PREFIX_PATTERN, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSubstantiveAssistantText(text) {
|
||||||
|
const normalized = stripOrchestrationFromAssistantText(text);
|
||||||
|
if (normalized.length < MIN_SUBSTANTIVE_ASSISTANT_CHARS) return '';
|
||||||
|
if (/^(?:好的|收到|我先|让我|ok|let me)\b/iu.test(normalized) && normalized.length < 120) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterMemoryForImmediatePageContext(memories) {
|
||||||
|
if (!Array.isArray(memories) || memories.length === 0) return memories;
|
||||||
|
return memories.filter((entry) => {
|
||||||
|
const label = String(entry?.label ?? entry?.type ?? '').trim().toLowerCase();
|
||||||
|
const body = String(entry?.text ?? entry?.content ?? entry?.body ?? '').trim();
|
||||||
|
if (label === 'preference' && PAGE_TEMPLATE_MEMORY_PATTERN.test(body)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWechatPageContinuationSource({
|
||||||
|
userText = '',
|
||||||
|
carriedSessionContent = '',
|
||||||
|
sessionAssistantContent = '',
|
||||||
|
} = {}) {
|
||||||
|
const carried = extractSubstantiveAssistantText(carriedSessionContent);
|
||||||
|
if (carried) {
|
||||||
|
return { ok: true, sourceText: carried, source: 'carried_session' };
|
||||||
|
}
|
||||||
|
const inSession = extractSubstantiveAssistantText(sessionAssistantContent);
|
||||||
|
if (inSession) {
|
||||||
|
return { ok: true, sourceText: inSession, source: 'session_assistant' };
|
||||||
|
}
|
||||||
|
const normalized = String(userText ?? '').trim();
|
||||||
|
if (hasExplicitPageSourceReference(normalized) && normalized.length >= MIN_SUBSTANTIVE_ASSISTANT_CHARS) {
|
||||||
|
return { ok: true, sourceText: normalized, source: 'inline_user' };
|
||||||
|
}
|
||||||
|
return { ok: false, sourceText: '', source: 'missing' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldFailFastWechatPageContinuation({
|
||||||
|
sessionPageContinuation = false,
|
||||||
|
userText = '',
|
||||||
|
carriedSessionContent = '',
|
||||||
|
sessionAssistantContent = '',
|
||||||
|
} = {}) {
|
||||||
|
if (!sessionPageContinuation) return false;
|
||||||
|
if (!isBareImmediatePageCreateRequest(userText) && hasExplicitPageSourceReference(userText)) {
|
||||||
|
return !resolveWechatPageContinuationSource({
|
||||||
|
userText,
|
||||||
|
carriedSessionContent,
|
||||||
|
sessionAssistantContent,
|
||||||
|
}).ok;
|
||||||
|
}
|
||||||
|
if (!isBareImmediatePageCreateRequest(userText)) return false;
|
||||||
|
return !resolveWechatPageContinuationSource({
|
||||||
|
userText,
|
||||||
|
carriedSessionContent,
|
||||||
|
sessionAssistantContent,
|
||||||
|
}).ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WECHAT_PAGE_CONTINUATION_MISSING_SOURCE_TEXT =
|
||||||
|
'我没能确定您要把哪段内容做成页面。请一条消息发完整主题,或直接粘贴要做成页面的正文(例如诗词、文章全文)。';
|
||||||
|
|
||||||
|
export function isWechatPageContinuationRepairableError(message) {
|
||||||
|
const normalized = String(message ?? '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return (
|
||||||
|
/stale_session_poisoned_completion/i.test(normalized)
|
||||||
|
|| /wechat_page_fresh_thumbnail_required:/i.test(normalized)
|
||||||
|
|| /wechat_agent_incomplete_reply/i.test(normalized)
|
||||||
|
|| /missing_page_artifact/i.test(normalized)
|
||||||
|
|| /poisoned_or_fake_claim/i.test(normalized)
|
||||||
|
|| /skill_or_stub/i.test(normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractLastSubstantiveAssistantFromConversation(messages = []) {
|
||||||
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const message = messages[index];
|
||||||
|
if (message?.role !== 'assistant') continue;
|
||||||
|
const text = extractSubstantiveAssistantText(
|
||||||
|
Array.isArray(message?.content)
|
||||||
|
? message.content
|
||||||
|
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join('')
|
||||||
|
: String(message?.text ?? ''),
|
||||||
|
);
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractLastSubstantiveAssistantFromConversation,
|
||||||
|
extractSubstantiveAssistantText,
|
||||||
|
filterMemoryForImmediatePageContext,
|
||||||
|
hasExplicitPageSourceReference,
|
||||||
|
isBareImmediatePageCreateRequest,
|
||||||
|
isWechatPageContinuationRepairableError,
|
||||||
|
resolveWechatPageContinuationSource,
|
||||||
|
shouldFailFastWechatPageContinuation,
|
||||||
|
} from './intent/page-continuation-context.mjs';
|
||||||
|
|
||||||
|
test('detects bare vs explicit page continuation phrasing', () => {
|
||||||
|
assert.equal(hasExplicitPageSourceReference('帮我把这首词做成页面'), true);
|
||||||
|
assert.equal(hasExplicitPageSourceReference('帮我吧以上内容做成页面'), true);
|
||||||
|
assert.equal(isBareImmediatePageCreateRequest('帮我做成页面吧'), true);
|
||||||
|
assert.equal(isBareImmediatePageCreateRequest('帮我把这首词做成页面'), false);
|
||||||
|
assert.equal(isBareImmediatePageCreateRequest('帮我做个页面'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveWechatPageContinuationSource prefers carried session content', () => {
|
||||||
|
const poem = '《临江仙·秋思》'.padEnd(60, '词');
|
||||||
|
const resolved = resolveWechatPageContinuationSource({
|
||||||
|
userText: '帮我做成页面吧',
|
||||||
|
carriedSessionContent: poem,
|
||||||
|
});
|
||||||
|
assert.equal(resolved.ok, true);
|
||||||
|
assert.equal(resolved.source, 'carried_session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldFailFastWechatPageContinuation blocks bare follow-up without source', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldFailFastWechatPageContinuation({
|
||||||
|
sessionPageContinuation: true,
|
||||||
|
userText: '帮我做成页面吧',
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
shouldFailFastWechatPageContinuation({
|
||||||
|
sessionPageContinuation: true,
|
||||||
|
userText: '帮我做成页面吧',
|
||||||
|
sessionAssistantContent: '《临江仙·秋思》'.padEnd(60, '。'),
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filterMemoryForImmediatePageContext removes page template preferences', () => {
|
||||||
|
const filtered = filterMemoryForImmediatePageContext([
|
||||||
|
{ label: 'preference', text: '用户要求每日新闻页面固定格式' },
|
||||||
|
{ label: 'fact', text: '用户名字是唐' },
|
||||||
|
]);
|
||||||
|
assert.equal(filtered.length, 1);
|
||||||
|
assert.match(filtered[0].text, /唐/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractLastSubstantiveAssistantFromConversation skips short acknowledgements', () => {
|
||||||
|
const text = extractLastSubstantiveAssistantFromConversation([
|
||||||
|
{ role: 'user', content: [{ type: 'text', text: '写首词' }] },
|
||||||
|
{ role: 'assistant', content: [{ type: 'text', text: '好的,我来写。' }] },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `《临江仙·秋思》${'昨夜西风凋碧树,独上高楼,望尽天涯路。'.repeat(3)}`,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.match(text, /临江仙/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isWechatPageContinuationRepairableError covers fake delivery markers', () => {
|
||||||
|
assert.equal(isWechatPageContinuationRepairableError('stale_session_poisoned_completion'), true);
|
||||||
|
assert.equal(isWechatPageContinuationRepairableError('unknown variant `image_url`'), false);
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ export function buildPageGenerateAgentPrompt(
|
|||||||
wantsDocx = false,
|
wantsDocx = false,
|
||||||
imagePolicy = null,
|
imagePolicy = null,
|
||||||
preferImmediateContext = false,
|
preferImmediateContext = false,
|
||||||
|
carriedSessionContent = '',
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
const topic = String(intent?.agentText ?? intent?.displayText ?? '').trim();
|
||||||
@@ -41,6 +42,13 @@ export function buildPageGenerateAgentPrompt(
|
|||||||
'',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: '';
|
: '';
|
||||||
|
const carriedSessionBlock = String(carriedSessionContent ?? '').trim()
|
||||||
|
? [
|
||||||
|
'【待排版正文(来自上一轮会话,必须以此为主题,禁止改用 Memory 或其他任务)】',
|
||||||
|
String(carriedSessionContent).trim(),
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
: '';
|
||||||
const coverExample = imageBlock
|
const coverExample = imageBlock
|
||||||
? '<本轮 generate_image 返回的 asset.htmlSrc>'
|
? '<本轮 generate_image 返回的 asset.htmlSrc>'
|
||||||
: 'assets/hero.jpg';
|
: 'assets/hero.jpg';
|
||||||
@@ -62,6 +70,7 @@ export function buildPageGenerateAgentPrompt(
|
|||||||
docxBlock,
|
docxBlock,
|
||||||
imageBlock,
|
imageBlock,
|
||||||
immediateContextBlock,
|
immediateContextBlock,
|
||||||
|
carriedSessionBlock,
|
||||||
pageDataBlock,
|
pageDataBlock,
|
||||||
'步骤(必须全部完成):',
|
'步骤(必须全部完成):',
|
||||||
pageDataTask
|
pageDataTask
|
||||||
|
|||||||
Reference in New Issue
Block a user