fix(wechat): isolate stale images and page links
Memind CI / Test, build, and release guards (pull_request) Successful in 2m36s

This commit is contained in:
john
2026-07-22 13:30:44 +08:00
parent aea3c1e83e
commit db225b784e
11 changed files with 416 additions and 37 deletions
+204
View File
@@ -160,6 +160,7 @@ test('Page Data requests always rotate away from an existing WeChat route', () =
);
assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '继续聊德川家康'), false);
assert.equal(shouldForceNewWechatAgentSession({ kind: 'session.reset' }, '继续聊德川家康'), true);
assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '换新会话'), true);
});
test('buildWechatAgentPrompt requires docx generation before html when Word download is requested', () => {
@@ -552,6 +553,107 @@ test('maybeAttachPublishedHtmlLink can attach a verified existing public html li
assert.match(text, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/);
});
test('maybeAttachPublishedHtmlLink leaves normal chat untouched when attachment is disabled', async () => {
const text = await maybeAttachPublishedHtmlLink(
{ text: '这是普通聊天回复。', messages: [] },
{
workingDir: '/tmp/user-1',
publicBaseUrl: 'https://m.tkmind.cn',
artifacts: [
{
localPath: '/tmp/user-1/public/old.html',
relativePath: 'public/old.html',
url: 'https://m.tkmind.cn/MindSpace/user-1/public/old.html',
},
],
allowAttachment: false,
},
);
assert.equal(text, '这是普通聊天回复。');
assert.doesNotMatch(text, /查看页面|old\.html/);
});
test('wechat mp normal chat ignores historical html touched during the request', async (t) => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-normal-html-');
const oldHtmlPath = path.join(workspaceRoot, 'public', 'old-page.html');
const sentPayloads = [];
t.after(() => fs.rmSync(workspaceRoot, { recursive: true, force: true }));
const service = createBoundWechatService({
token,
userAuth: {
async resolveWorkingDir() {
return workspaceRoot;
},
async getUserPublishLayout() {
return {
publishDir: workspaceRoot,
displayName: 'John',
username: 'john',
slug: 'john',
constraints: null,
};
},
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') {
return new Response(
[
'data: {"type":"Message","request_id":"req-normal-html","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"这是普通路线建议。"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-normal-html","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/sessions/session-1/reply') {
fs.mkdirSync(path.dirname(oldHtmlPath), { recursive: true });
fs.writeFileSync(oldHtmlPath, '<!doctype html><title>Old</title>');
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
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}`);
},
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')) {
sentPayloads.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 originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-normal-html';
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '帮我推荐一条跑步路线' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.equal(sentPayloads.length, 1);
assert.equal(sentPayloads[0].text.content, '这是普通路线建议。');
assert.doesNotMatch(sentPayloads[0].text.content, /查看页面|old-page\.html/);
});
test('wechat mp service splits long agent replies into multiple customer messages', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -2739,9 +2841,111 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
isRecoverableWechatAgentSessionError('Session already has an active request. Cancel it first.'),
true,
);
assert.equal(
isRecoverableWechatAgentSessionError(
'Request failed: Bad request (400): messages[4]: unknown variant `image_url`, expected `text`',
),
true,
);
assert.equal(
isRecoverableWechatAgentSessionError('historical_image_session_update_unsupported:405'),
true,
);
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
});
test('wechat mp rotates and retries when historical image isolation is unsupported', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const submittedSessions = [];
const sentPayloads = [];
let activeSessionId = 'session-1';
let routeCleared = false;
const service = createBoundWechatService({
token,
startAgentSession: async () => ({ id: 'session-2' }),
userAuth: {
async getWechatAgentRoute() {
return routeCleared ? null : { agentSessionId: activeSessionId, status: 'active' };
},
async clearWechatAgentRoute() {
routeCleared = true;
},
async upsertWechatAgentRoute({ agentSessionId }) {
activeSessionId = agentSessionId;
routeCleared = false;
},
},
submitSessionReply: async ({ sessionId, options }) => {
submittedSessions.push(sessionId);
assert.equal(options?.requireHistoricalImageIsolation, true);
if (sessionId === 'session-1') {
throw new Error('historical_image_session_update_unsupported:405');
}
return { ok: true };
},
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === `/sessions/${sessionId}/events`) {
if (sessionId === 'session-1') {
return new Response('', {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
});
}
return new Response(
[
'data: {"type":"Message","request_id":"req-image-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"新会话已恢复,可以继续。"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-image-retry","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
].join(''),
{ 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: ${sessionId} ${pathname}`);
},
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')) {
sentPayloads.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 originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = (() => {
const ids = ['req-image-first', 'req-image-retry'];
return () => ids.shift() ?? 'req-image-retry';
})();
try {
const result = await service.handleInboundMessage(
inboundXml({ content: '继续分析' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.deepEqual(submittedSessions, ['session-1', 'session-2']);
assert.equal(activeSessionId, 'session-2');
assert.equal(sentPayloads.length, 1);
assert.equal(sentPayloads[0].text.content, '新会话已恢复,可以继续。');
});
test('findRecoverableWechatAgentErrorInReply scans all assistant messages', () => {
const toolCallsError =
"Request failed: Bad request (400): An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. (insufficient tool messages following tool_calls message).";