fix(wechat): isolate page data sessions

This commit is contained in:
john
2026-07-12 21:11:12 +08:00
parent cef3750ac6
commit ca4805533b
5 changed files with 97 additions and 11 deletions
@@ -234,7 +234,13 @@ test('integration: finish guard auto-bind clears unbound state for valid survey
const before = evaluatePageDataFinishGuard({ const before = evaluatePageDataFinishGuard({
publishDir: workspaceRoot, publishDir: workspaceRoot,
agentText: SURVEY_USER_TEXT, agentText: SURVEY_USER_TEXT,
messages: [], messages: [{
createdAt: Date.now(),
content: [{
type: 'toolRequest',
toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } },
}],
}],
}); });
assert.equal(before.unboundFiles.length, 1); assert.equal(before.unboundFiles.length, 1);
@@ -264,7 +270,13 @@ test('integration: finish guard auto-bind clears unbound state for valid survey
const after = evaluatePageDataFinishGuard({ const after = evaluatePageDataFinishGuard({
publishDir: workspaceRoot, publishDir: workspaceRoot,
agentText: SURVEY_USER_TEXT, agentText: SURVEY_USER_TEXT,
messages: [], messages: [{
createdAt: Date.now(),
content: [{
type: 'toolRequest',
toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } },
}],
}],
}); });
assert.equal(after.unboundFiles.length, 0); assert.equal(after.unboundFiles.length, 0);
assert.equal(after.htmlIssues.length, 0); assert.equal(after.htmlIssues.length, 0);
@@ -285,7 +297,13 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm
sessionId: 'session-h5-page-data', sessionId: 'session-h5-page-data',
userId: 'user-page-data', userId: 'user-page-data',
publishDir: workspaceRoot, publishDir: workspaceRoot,
messages: [], messages: [{
createdAt: Date.now(),
content: [{
type: 'toolRequest',
toolCall: { value: { name: 'write_file', arguments: { path: 'public/children-diet-survey.html' } } },
}],
}],
pool: null, pool: null,
h5Root: workspaceRoot, h5Root: workspaceRoot,
storageRoot: workspaceRoot, storageRoot: workspaceRoot,
@@ -307,3 +325,27 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm
fs.rmSync(workspaceRoot, { recursive: true, force: true }); fs.rmSync(workspaceRoot, { recursive: true, force: true });
} }
}); });
test('integration: finish guard never scans historical Page Data files without a current HTML write', async () => {
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-historical-skip-'));
try {
await setupSurveyWorkspace(workspaceRoot);
fs.writeFileSync(path.join(workspaceRoot, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8');
const result = await maybeRepairPageDataAfterFinish({
sessionId: 'session-with-history',
userId: 'user-page-data',
publishDir: workspaceRoot,
messages: [],
pool: null,
h5Root: workspaceRoot,
storageRoot: workspaceRoot,
userText: SURVEY_USER_TEXT,
tkmindProxy: { async submitSessionReplyForUser() { throw new Error('must not repair history'); } },
});
assert.equal(result.repaired, false);
assert.equal(result.triggered, undefined);
assert.equal(result.relevantFiles.length, 0);
} finally {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
});
+7 -4
View File
@@ -228,7 +228,8 @@ export function extractRecentPageDataHtmlWrites(messages = [], { sinceMs = 0 } =
if (item?.type !== 'toolRequest') continue; if (item?.type !== 'toolRequest') continue;
const toolCall = item.toolCall?.value; const toolCall = item.toolCall?.value;
const name = String(toolCall?.name ?? '').trim(); const name = String(toolCall?.name ?? '').trim();
if (!['write_file', 'edit_file', 'write', 'edit'].includes(name)) continue; const normalizedName = name.split('__').at(-1);
if (!['write_file', 'edit_file', 'write', 'edit'].includes(normalizedName)) continue;
const args = toolCall?.arguments ?? {}; const args = toolCall?.arguments ?? {};
const candidate = String(args.path ?? args.file_path ?? '').trim().replace(/\\/g, '/'); const candidate = String(args.path ?? args.file_path ?? '').trim().replace(/\\/g, '/');
if (!candidate.toLowerCase().endsWith('.html')) continue; if (!candidate.toLowerCase().endsWith('.html')) continue;
@@ -248,9 +249,11 @@ export function evaluatePageDataFinishGuard({
const pageDataIntent = isPageDataIntent(agentText); const pageDataIntent = isPageDataIntent(agentText);
const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir); const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir);
const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt }); const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt });
const relevantFiles = pageDataFiles.filter((file) => // A session may be reused long after unrelated Page Data files were written.
pageDataIntent || recentWrites.includes(file.relativePath), // Only the HTML paths written in this finish window are safe to bind or
); // repair; scanning every historical Page Data file turns one new request
// into a quota-exhausting batch publish attempt.
const relevantFiles = pageDataFiles.filter((file) => recentWrites.includes(file.relativePath));
const htmlIssues = relevantFiles.flatMap((file) => const htmlIssues = relevantFiles.flatMap((file) =>
file.evaluation.issues.map((issue) => ({ file.evaluation.issues.map((issue) => ({
+3 -3
View File
@@ -57,7 +57,7 @@ test('evaluatePageDataFinishGuard detects unbound page data html', () => {
const evaluation = evaluatePageDataFinishGuard({ const evaluation = evaluatePageDataFinishGuard({
publishDir, publishDir,
agentText: '帮我设计一个调查问卷,要加一个后台', agentText: '帮我设计一个调查问卷,要加一个后台',
messages: [], messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } } }] }],
}); });
assert.equal(evaluation.pageDataIntent, true); assert.equal(evaluation.pageDataIntent, true);
assert.equal(evaluation.unboundFiles.length, 1); assert.equal(evaluation.unboundFiles.length, 1);
@@ -74,7 +74,7 @@ test('shouldRetryPageDataCollectReply retries when localStorage fallback exists'
fs.writeFileSync(path.join(publishDir, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8'); fs.writeFileSync(path.join(publishDir, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8');
assert.equal( assert.equal(
shouldRetryPageDataCollectReply({ shouldRetryPageDataCollectReply({
reply: { text: '问卷已发布', messages: [] }, reply: { text: '问卷已发布', messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/children-diet-survey.html' } } } }] }] },
intent: { agentText: '帮我设计一个调查问卷,要加一个后台' }, intent: { agentText: '帮我设计一个调查问卷,要加一个后台' },
publishDir, publishDir,
}), }),
@@ -134,7 +134,7 @@ test('evaluatePageDataFinishGuard flags html when dataset is not registered', ()
const evaluation = evaluatePageDataFinishGuard({ const evaluation = evaluatePageDataFinishGuard({
publishDir, publishDir,
agentText: '调查问卷和后台', agentText: '调查问卷和后台',
messages: [], messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } } }] }],
}); });
assert.equal(evaluation.unboundFiles.length, 1); assert.equal(evaluation.unboundFiles.length, 1);
assert.equal(evaluation.htmlIssues.length, 0); assert.equal(evaluation.htmlIssues.length, 0);
+32 -1
View File
@@ -21,6 +21,7 @@ import {
} from './wechat/handlers/sync-replies.mjs'; } from './wechat/handlers/sync-replies.mjs';
import { classifyWechatIntent } from './wechat/intent/classifier.mjs'; import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs'; import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs';
import { isPageDataIntent } from './chat-skills.mjs';
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs'; import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs'; import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
import { import {
@@ -973,6 +974,14 @@ function isTopicResetIntent(text) {
return isTopicResetText(text); return isTopicResetText(text);
} }
export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) {
return (
wechatIntent?.kind === 'session.reset' ||
isTopicResetIntent(resetCandidate) ||
isPageDataIntent(resetCandidate)
);
}
export function isRecoverableWechatAgentSessionError(message) { export function isRecoverableWechatAgentSessionError(message) {
const normalized = String(message ?? '').trim(); const normalized = String(message ?? '').trim();
if (!normalized) return false; if (!normalized) return false;
@@ -1905,7 +1914,11 @@ export function createWechatMpService({
const wechatIntent = classifyWechatIntent(intent); const wechatIntent = classifyWechatIntent(intent);
const resetCandidate = const resetCandidate =
intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : ''; intent.msgType === 'text' || intent.msgType === 'voice' ? intent.agentText : '';
const forceNew = wechatIntent.kind === 'session.reset' || isTopicResetIntent(resetCandidate); // Page Data delivery owns persistent files, datasets and two publication
// policies. Reusing a conversational route here can make a new request
// inspect/retry unrelated historical pages from that session.
const isPageDataRequest = isPageDataIntent(resetCandidate);
const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate);
let route = await ensureWechatAgentSession({ let route = await ensureWechatAgentSession({
userId: user.userId, userId: user.userId,
openid: inbound.fromUserName, openid: inbound.fromUserName,
@@ -2064,6 +2077,24 @@ export function createWechatMpService({
return { sessionId }; return { sessionId };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
// A Page Data request must fail closed. Retrying a poisoned completion in
// another session while the finish guard is also active can turn one
// request into repairs against historical pages. Drop only this user's
// route so the next explicit request starts cleanly.
if (isPageDataRequest) {
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
logger.warn?.('WeChat MP page data route clear failed:', clearErr);
});
if (!err?.wechatUserNotified) {
const text = buildPageDataCollectFailureText();
try {
await sendCustomerServiceText(inbound.fromUserName, text, user);
} catch (sendErr) {
logger.error?.('WeChat MP page data failure notice failed:', sendErr);
}
}
throw markWechatUserNotified(err instanceof Error ? err : new Error(message));
}
const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message); const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message);
if (mayBeStaleSession) { if (mayBeStaleSession) {
route = await ensureWechatAgentSession({ route = await ensureWechatAgentSession({
+10
View File
@@ -16,6 +16,7 @@ import {
loadWechatMpConfig, loadWechatMpConfig,
maybeAttachPublishedHtmlLink, maybeAttachPublishedHtmlLink,
shouldRetryHtmlGenerationReply, shouldRetryHtmlGenerationReply,
shouldForceNewWechatAgentSession,
splitWechatText, splitWechatText,
verifyWechatMpSignature, verifyWechatMpSignature,
verifyWechatMpUrlChallenge, verifyWechatMpUrlChallenge,
@@ -144,6 +145,15 @@ test('splitWechatText respects WeChat 2048-byte customer service limit', () => {
assert.equal(chunks.join(''), longChinese); assert.equal(chunks.join(''), longChinese);
}); });
test('Page Data requests always rotate away from an existing WeChat route', () => {
assert.equal(
shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '做一个问卷,后台可以查看提交数据'),
true,
);
assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '继续聊德川家康'), false);
assert.equal(shouldForceNewWechatAgentSession({ kind: 'session.reset' }, '继续聊德川家康'), true);
});
test('buildWechatAgentPrompt requires docx generation before html when Word download is requested', () => { test('buildWechatAgentPrompt requires docx generation before html when Word download is requested', () => {
const prompt = buildWechatAgentPrompt({ const prompt = buildWechatAgentPrompt({
msgType: 'text', msgType: 'text',