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({
publishDir: workspaceRoot,
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);
@@ -264,7 +270,13 @@ test('integration: finish guard auto-bind clears unbound state for valid survey
const after = evaluatePageDataFinishGuard({
publishDir: workspaceRoot,
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.htmlIssues.length, 0);
@@ -285,7 +297,13 @@ test('integration: H5 finish guard triggers repair prompt for invalid survey htm
sessionId: 'session-h5-page-data',
userId: 'user-page-data',
publishDir: workspaceRoot,
messages: [],
messages: [{
createdAt: Date.now(),
content: [{
type: 'toolRequest',
toolCall: { value: { name: 'write_file', arguments: { path: 'public/children-diet-survey.html' } } },
}],
}],
pool: null,
h5Root: 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 });
}
});
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;
const toolCall = item.toolCall?.value;
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 candidate = String(args.path ?? args.file_path ?? '').trim().replace(/\\/g, '/');
if (!candidate.toLowerCase().endsWith('.html')) continue;
@@ -248,9 +249,11 @@ export function evaluatePageDataFinishGuard({
const pageDataIntent = isPageDataIntent(agentText);
const pageDataFiles = collectPageDataPublicHtmlFiles(publishDir);
const recentWrites = extractRecentPageDataHtmlWrites(messages, { sinceMs: requestStartedAt });
const relevantFiles = pageDataFiles.filter((file) =>
pageDataIntent || recentWrites.includes(file.relativePath),
);
// A session may be reused long after unrelated Page Data files were written.
// 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) =>
file.evaluation.issues.map((issue) => ({
+3 -3
View File
@@ -57,7 +57,7 @@ test('evaluatePageDataFinishGuard detects unbound page data html', () => {
const evaluation = evaluatePageDataFinishGuard({
publishDir,
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.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');
assert.equal(
shouldRetryPageDataCollectReply({
reply: { text: '问卷已发布', messages: [] },
reply: { text: '问卷已发布', messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/children-diet-survey.html' } } } }] }] },
intent: { agentText: '帮我设计一个调查问卷,要加一个后台' },
publishDir,
}),
@@ -134,7 +134,7 @@ test('evaluatePageDataFinishGuard flags html when dataset is not registered', ()
const evaluation = evaluatePageDataFinishGuard({
publishDir,
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.htmlIssues.length, 0);
+32 -1
View File
@@ -21,6 +21,7 @@ import {
} from './wechat/handlers/sync-replies.mjs';
import { classifyWechatIntent } from './wechat/intent/classifier.mjs';
import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs';
import { isPageDataIntent } from './chat-skills.mjs';
import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs';
import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs';
import {
@@ -973,6 +974,14 @@ function isTopicResetIntent(text) {
return isTopicResetText(text);
}
export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) {
return (
wechatIntent?.kind === 'session.reset' ||
isTopicResetIntent(resetCandidate) ||
isPageDataIntent(resetCandidate)
);
}
export function isRecoverableWechatAgentSessionError(message) {
const normalized = String(message ?? '').trim();
if (!normalized) return false;
@@ -1905,7 +1914,11 @@ export function createWechatMpService({
const wechatIntent = classifyWechatIntent(intent);
const resetCandidate =
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({
userId: user.userId,
openid: inbound.fromUserName,
@@ -2064,6 +2077,24 @@ export function createWechatMpService({
return { sessionId };
} catch (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);
if (mayBeStaleSession) {
route = await ensureWechatAgentSession({
+10
View File
@@ -16,6 +16,7 @@ import {
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
shouldRetryHtmlGenerationReply,
shouldForceNewWechatAgentSession,
splitWechatText,
verifyWechatMpSignature,
verifyWechatMpUrlChallenge,
@@ -144,6 +145,15 @@ test('splitWechatText respects WeChat 2048-byte customer service limit', () => {
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', () => {
const prompt = buildWechatAgentPrompt({
msgType: 'text',