fix(wechat): isolate page data sessions
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user