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({
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user