From db225b784e28b0afca94c7d639a36ba51e822e41 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 22 Jul 2026 13:30:44 +0800 Subject: [PATCH] fix(wechat): isolate stale images and page links --- chat-image-turn-scope.mjs | 4 + chat-image-turn-scope.test.mjs | 5 + server.mjs | 4 +- tkmind-proxy.mjs | 28 ++++- tkmind-proxy.test.mjs | 60 +++++++++- user-space.mjs | 16 ++- user-space.test.mjs | 25 ++++ wechat-mp.mjs | 104 +++++++++++++---- wechat-mp.test.mjs | 204 +++++++++++++++++++++++++++++++++ wechat/intent/patterns.mjs | 2 +- wechat/wechat-channel.test.mjs | 1 + 11 files changed, 416 insertions(+), 37 deletions(-) diff --git a/chat-image-turn-scope.mjs b/chat-image-turn-scope.mjs index 5502884..1d5aff7 100644 --- a/chat-image-turn-scope.mjs +++ b/chat-image-turn-scope.mjs @@ -102,6 +102,10 @@ export function scrubUserMessageImageAttachments(message) { let contentChanged = false; const content = Array.isArray(message.content) ? message.content.map((item) => { + if (item?.type === 'image_url') { + contentChanged = true; + return null; + } if (item?.type !== 'text' || typeof item.text !== 'string') return item; const nextText = stripAgentImageText(item.text); if (nextText === item.text) return item; diff --git a/chat-image-turn-scope.test.mjs b/chat-image-turn-scope.test.mjs index a2849be..9d50704 100644 --- a/chat-image-turn-scope.test.mjs +++ b/chat-image-turn-scope.test.mjs @@ -14,6 +14,10 @@ test('extractCurrentTurnImageUrls prefers metadata and dedupes asset aliases', ( imageUrls: ['/api/mindspace/v1/assets/asset-1/download?inline=1'], }, content: [ + { + type: 'image_url', + image_url: { url: '/api/mindspace/v1/assets/old-asset/download?inline=1' }, + }, { type: 'text', text: @@ -64,6 +68,7 @@ test('scrubUserMessageImageAttachments archives urls for ui and strips agent tex ]); assert.deepEqual(scrubbed.message.metadata.archivedPreviewImageUrls, ['blob:preview-old']); assert.equal(scrubbed.message.metadata.displayText, '上一轮图片'); + assert.equal(scrubbed.message.content.some((item) => item.type === 'image_url'), false); assert.equal(scrubbed.message.content[0].text, '上一轮图片'); }); diff --git a/server.mjs b/server.mjs index 0c57227..2e350dc 100644 --- a/server.mjs +++ b/server.mjs @@ -876,8 +876,8 @@ async function bootstrapUserAuth() { const target = await tkmindProxy.resolveTarget(sessionId); return tkmindProxy.apiFetchTo(target, pathname, init); }, - submitSessionReply: ({ userId, sessionId, requestId, userMessage }) => - tkmindProxy.submitSessionReplyForUser(userId, sessionId, requestId, userMessage), + submitSessionReply: ({ userId, sessionId, requestId, userMessage, options }) => + tkmindProxy.submitSessionReplyForUser(userId, sessionId, requestId, userMessage, options), scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null, wechatScheduleLlmConfigService, llmProviderService, diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index ca75627..bf88283 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -1591,7 +1591,7 @@ export function createTkmindProxy({ async function syncHistoricalImageTurnIsolation(sessionId, activeMessageId) { const activeId = String(activeMessageId ?? '').trim(); - if (!sessionId || !activeId) return; + if (!sessionId || !activeId) return { changed: false, updated: false }; try { const target = await resolveTarget(sessionId); const upstream = await apiFetch( @@ -1599,13 +1599,15 @@ export function createTkmindProxy({ apiSecret, `/sessions/${encodeURIComponent(sessionId)}`, ); - if (!upstream.ok) return; + if (!upstream.ok) { + return { changed: false, updated: false, status: upstream.status }; + } const session = await upstream.json().catch(() => null); const { conversation, changed } = scrubConversationHistoricalImageAttachments( session?.conversation ?? [], activeId, ); - if (!changed) return; + if (!changed) return { changed: false, updated: false }; const update = await apiFetch( target, apiSecret, @@ -1619,12 +1621,15 @@ export function createTkmindProxy({ console.warn( `Historical image scrub skipped for session ${sessionId}: upstream ${update.status}`, ); + return { changed: true, updated: false, status: update.status }; } + return { changed: true, updated: true, status: update.status }; } catch (err) { console.warn( 'Historical image scrub skipped:', err instanceof Error ? err.message : err, ); + return { changed: false, updated: false, error: err }; } } @@ -1633,7 +1638,11 @@ export function createTkmindProxy({ sessionId, requestId, userMessage, - { toolMode = 'chat', forceDeepReasoning = false } = {}, + { + toolMode = 'chat', + forceDeepReasoning = false, + requireHistoricalImageIsolation = false, + } = {}, ) { if (!userId || !sessionId) throw new Error('缺少会话信息'); const owns = await sessionStore.validateOwnership(userId, sessionId); @@ -1655,8 +1664,15 @@ export function createTkmindProxy({ const user = await userAuth.getUserById(userId); if (!user) throw new Error('用户不存在'); - if (messageHasImages(userMessage)) { - await syncHistoricalImageTurnIsolation(sessionId, userMessage?.id); + if (requireHistoricalImageIsolation || messageHasImages(userMessage)) { + const imageIsolation = await syncHistoricalImageTurnIsolation(sessionId, userMessage?.id); + if (requireHistoricalImageIsolation && imageIsolation.changed && !imageIsolation.updated) { + const error = new Error( + `historical_image_session_update_unsupported:${imageIsolation.status ?? 'unknown'}`, + ); + error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED'; + throw error; + } } let finalUserMessage = userMessage; if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) { diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs index 1370eeb..15afbdb 100644 --- a/tkmind-proxy.test.mjs +++ b/tkmind-proxy.test.mjs @@ -13,7 +13,7 @@ import { } from './tkmind-proxy.mjs'; import { createMemoryV2 } from './memory-v2.mjs'; -async function withFakeGoosedSession(handler) { +async function withFakeGoosedSession(handler, { conversation = [] } = {}) { const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memind-memory-v2-')); const harnessEntries = []; const replyBodies = []; @@ -42,9 +42,15 @@ async function withFakeGoosedSession(handler) { id: 'session-1', working_dir: workingDir, goose_mode: 'chat', + conversation, })); return; } + if (req.method === 'PUT' && req.url === '/sessions/session-1') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: 'method not allowed' })); + return; + } if (req.method === 'GET' && req.url === '/sessions/session-1/extensions') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ @@ -898,6 +904,58 @@ test('submitSessionReplyForUser adds goose metadata visibility flags before repl }); }); +test('submitSessionReplyForUser fails closed when historical image scrub is unsupported', async () => { + await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => { + const proxy = createTkmindProxy({ + apiTarget, + apiSecret: 'test-secret', + userAuth: { + ...createMemoryTestUserAuth(workingDir), + async ownsSession() { + return true; + }, + async canUseChat() { + return { ok: true }; + }, + async getUserById() { + return { id: 'user-1' }; + }, + async resolveUserPolicies() { + return { unrestricted: true, policies: {} }; + }, + }, + }); + + await assert.rejects( + proxy.submitSessionReplyForUser( + 'user-1', + 'session-1', + 'request-after-image', + { + id: 'message-current', + role: 'user', + content: [{ type: 'text', text: '继续分析' }], + }, + { requireHistoricalImageIsolation: true }, + ), + /historical_image_session_update_unsupported:405/, + ); + assert.equal(replyBodies.length, 0); + }, { + conversation: [ + { + id: 'message-old-image', + role: 'user', + metadata: { imageUrls: ['https://example.com/old.png'] }, + content: [ + { type: 'text', text: '上一张图片' }, + { type: 'image_url', image_url: { url: 'https://example.com/old.png' } }, + ], + }, + ], + }); +}); + test('submitSessionReplyForUser applies the shared Qwen vision preprocessing path', async () => { await withFakeGoosedSession(async ({ apiTarget, workingDir, replyBodies }) => { const proxy = createTkmindProxy({ diff --git a/user-space.mjs b/user-space.mjs index f800699..da84822 100644 --- a/user-space.mjs +++ b/user-space.mjs @@ -50,12 +50,22 @@ export function ensureUserZoneDirs(workspaceRoot) { } /** 上传完成后镜像到 MindSpace///文件名 */ -export function mirrorAssetToZone({ workspaceRoot, categoryCode, filename, sourcePath }) { +export function mirrorAssetToZone({ + workspaceRoot, + categoryCode, + filename, + sourcePath, + overwrite = true, +}) { if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null; if (!sourcePath || !fs.existsSync(sourcePath)) return null; ensureUserZoneDirs(workspaceRoot); const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename); fs.mkdirSync(path.dirname(dest), { recursive: true }); + // Layout bootstrap is a recovery/backfill path. Re-copying every canonical + // asset on every chat request changes workspace mtimes, can overwrite a + // newer Agent edit, and makes historical HTML look newly generated. + if (!overwrite && fs.existsSync(dest)) return dest; fs.copyFileSync(sourcePath, dest); return dest; } @@ -165,7 +175,8 @@ export async function syncUserZonesFromAssets(pool, storageRoot, userId, workspa FROM h5_assets a JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id JOIN h5_asset_versions v ON v.id = a.current_version_id - WHERE a.user_id = ? AND a.status <> 'deleted' AND c.category_code IN (${placeholders})`, + WHERE a.user_id = ? AND a.status <> 'deleted' AND c.category_code IN (${placeholders}) + ORDER BY a.updated_at DESC`, [userId, ...UPLOAD_ZONE_CODES], ); for (const row of rows) { @@ -175,6 +186,7 @@ export async function syncUserZonesFromAssets(pool, storageRoot, userId, workspa categoryCode: row.category_code, filename: row.original_filename, sourcePath, + overwrite: false, }); } return { workspaceRoot, mirrored: rows.length }; diff --git a/user-space.test.mjs b/user-space.test.mjs index cb86261..0c1d5a7 100644 --- a/user-space.test.mjs +++ b/user-space.test.mjs @@ -63,6 +63,31 @@ test('syncUserZonesFromAssets backfills from canonical storage', async () => { ); }); +test('syncUserZonesFromAssets does not overwrite an existing workspace edit', async () => { + const h5Root = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-existing-')); + const storageRoot = path.join(h5Root, 'data', 'mindspace'); + const userId = USER_ID; + const storageKey = path.posix.join('users', userId, 'assets', 'page-1', 'versions', 'v1'); + const sourcePath = path.join(storageRoot, storageKey); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, 'stored version'); + const workspace = resolveUserWorkspaceRoot(h5Root, { id: userId, username: 'john' }); + const workspacePath = path.join(workspace, 'public', 'page.html'); + fs.mkdirSync(path.dirname(workspacePath), { recursive: true }); + fs.writeFileSync(workspacePath, 'new Agent edit'); + const before = fs.statSync(workspacePath).mtimeMs; + const pool = { + async query() { + return [[{ original_filename: 'page.html', category_code: 'public', storage_key: storageKey }]]; + }, + }; + + await syncUserZonesFromAssets(pool, storageRoot, userId, workspace); + + assert.equal(fs.readFileSync(workspacePath, 'utf8'), 'new Agent edit'); + assert.equal(fs.statSync(workspacePath).mtimeMs, before); +}); + test('user space publishing guidance points to sandbox file tools', () => { const workspaceRoot = `/var/h5/MindSpace/${USER_ID}`; const hints = renderUserSpaceHints({ diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 1bfb7c5..9ee29ae 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -271,6 +271,7 @@ async function executeSessionReply( const decoder = new TextDecoder(); let buffer = ''; let messages = []; + let requestMessages = []; let hasScopedAssistantUpdate = false; while (true) { @@ -301,6 +302,7 @@ async function executeSessionReply( } if (event.message.role === 'assistant') hasScopedAssistantUpdate = true; messages = pushMessage(messages, event.message); + requestMessages = pushMessage(requestMessages, event.message); } else if (event.type === 'UpdateConversation') { // Ignore unscoped snapshots until this request has yielded an assistant update. // Otherwise a stale session snapshot can overwrite the current reply with a @@ -319,6 +321,10 @@ async function executeSessionReply( text: messageVisibleText(assistant), tokenState: event.token_state ?? null, messages, + // Keep request-scoped stream messages separate from a later full + // UpdateConversation snapshot. Artifact delivery must never inspect + // historical tool calls from the whole dedicated session. + requestMessages, }; assertWechatAgentReplyIsSendable(reply); return reply; @@ -416,6 +422,10 @@ function looksLikeHtmlGenerationIntent(text) { return isPageGenerateText(text); } +function replyRequestMessages(reply) { + return reply?.requestMessages ?? reply?.messages ?? []; +} + function looksLikeDocxDownloadIntent(text) { const normalized = String(text ?? '').trim(); if (!normalized) return false; @@ -449,8 +459,8 @@ function hasAnyToolRequest(messages = []) { function isSuspiciousBareCompletionReply(reply, intent) { if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; if (!isBareCompletionText(reply?.text)) return false; - if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false; - return !hasAnyToolRequest(reply?.messages ?? []); + if (extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0) return false; + return !hasAnyToolRequest(replyRequestMessages(reply)); } function looksLikePublishSuccessClaim(text) { @@ -485,15 +495,15 @@ async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = d if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; const text = String(reply?.text ?? '').trim(); if (!looksLikePublishSuccessClaim(text)) return false; - if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false; + if (extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0) return false; return !(await hasAnyValidPublishedHtmlLink(text, linkExists)); } function isMissingRequiredPublishSkill(reply, intent) { if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; - const wroteHtml = extractHtmlWriteTargets(reply?.messages ?? []).length > 0; + const wroteHtml = extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0; if (!wroteHtml) return false; - return !usedStaticPagePublishSkill(reply?.messages ?? []); + return !usedStaticPagePublishSkill(replyRequestMessages(reply)); } function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) { @@ -528,7 +538,7 @@ function ensurePublicHtmlArtifact(htmlPath, workingDir) { function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) { const artifacts = []; - const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []); + const htmlTargets = extractHtmlWriteTargets(replyRequestMessages(reply)); for (const target of htmlTargets) { const artifact = ensurePublicHtmlArtifact(target, workingDir); if (!artifact) continue; @@ -674,7 +684,16 @@ function rewritePublishedHtmlLinks(text, artifacts = []) { return next; } -async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl, artifacts: providedArtifacts = null }) { +async function maybeAttachPublishedHtmlLink( + reply, + { + workingDir, + publicBaseUrl, + artifacts: providedArtifacts = null, + allowAttachment = true, + }, +) { + if (!allowAttachment) return String(reply?.text ?? '').trim(); const artifacts = Array.isArray(providedArtifacts) ? providedArtifacts : collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }); @@ -938,12 +957,15 @@ function resolveHtmlPublishArtifacts({ userId = '', sessionId = '', onPageGenerated = null, + allowRecentArtifacts = true, }) { + const artifactMessages = reply?.requestMessages ?? reply?.messages ?? []; + const artifactReply = { ...reply, messages: artifactMessages }; materializeMissingPublicHtmlWrites({ - messages: reply?.messages ?? [], + messages: artifactMessages, publishDir: workingDir, }); - const publishedArtifacts = collectPublishedHtmlArtifacts(reply, { + const publishedArtifacts = collectPublishedHtmlArtifacts(artifactReply, { workingDir, publicBaseUrl, }); @@ -951,12 +973,14 @@ function resolveHtmlPublishArtifacts({ workingDir, publicBaseUrl, }); - const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, { - workingDir, - publicBaseUrl, - replyText: reply?.text, - sinceMs: requestStartedAt, - }); + const recentArtifacts = allowRecentArtifacts + ? collectRecentPublishedHtmlArtifacts(intent, { + workingDir, + publicBaseUrl, + replyText: reply?.text, + sinceMs: requestStartedAt, + }) + : []; const confirmedArtifacts = allExistingHtmlArtifacts({ publishedArtifacts, expectedArtifacts, @@ -968,7 +992,11 @@ function resolveHtmlPublishArtifacts({ recentArtifacts, replyText: reply?.text, }); - if (typeof onPageGenerated === 'function' && confirmedArtifacts.length > 0) { + if ( + typeof onPageGenerated === 'function' + && confirmedArtifacts.length > 0 + && (allowRecentArtifacts || publishedArtifacts.length > 0) + ) { void onPageGenerated({ userId, sessionId, artifacts: confirmedArtifacts }); } return { @@ -1019,7 +1047,7 @@ export function shouldRetryHtmlGenerationReply({ if (isMissingRequiredPublishSkill(reply, intent) || isSuspiciousBareCompletionReply(reply, intent)) { return true; } - if (!usedStaticPagePublishSkill(reply?.messages ?? [])) return false; + if (!usedStaticPagePublishSkill(replyRequestMessages(reply))) return false; return !hasAnyUrl(reply?.text); } @@ -1042,11 +1070,22 @@ export function isRecoverableWechatAgentSessionError(message) { if (/wechat_page_fresh_thumbnail_required:/i.test(normalized)) return true; if (/403|404|not found|无权访问/i.test(normalized)) return true; if (/tool_calls|tool_call_id|insufficient tool messages/i.test(normalized)) return true; + if (/historical_image_session_update_unsupported|unknown variant [`']?image_url/i.test(normalized)) { + return true; + } if (/session already has an active request|active request.*cancel/i.test(normalized)) return true; if (/wechat_agent_incomplete_reply/i.test(normalized)) return true; return false; } +export function shouldDeliverWechatHtmlArtifacts(wechatIntent, intent) { + return ( + wechatIntent?.kind === 'page.generate' + || looksLikeHtmlGenerationIntent(intent?.agentText) + || isPageDataIntent(intent?.agentText) + ); +} + function collectWechatAgentReplyVisibleTexts(reply) { const texts = []; const seen = new Set(); @@ -1057,7 +1096,7 @@ function collectWechatAgentReplyVisibleTexts(reply) { texts.push(normalized); }; append(reply?.text); - for (const message of reply?.messages ?? []) { + for (const message of replyRequestMessages(reply)) { if (message?.role !== 'assistant') continue; append(messageVisibleText(message)); } @@ -1856,7 +1895,7 @@ export function createWechatMpService({ notifyFailure = true, }) => { if (imagePolicy?.pageThumbnailMode !== WECHAT_PAGE_THUMBNAIL_MODE.REQUIRED_FRESH) return; - const images = collectWechatGeneratedImages(reply?.messages ?? []); + const images = collectWechatGeneratedImages(replyRequestMessages(reply)); let verification = verifyFreshWechatPageThumbnails(artifacts, images); if (!verification.ok) { logger.warn?.( @@ -1867,7 +1906,7 @@ export function createWechatMpService({ const repair = repairUnambiguousFreshWechatPageThumbnail({ artifacts, images, - currentRunHtmlArtifacts: extractPublicHtmlWriteArtifacts(reply?.messages ?? [], { + currentRunHtmlArtifacts: extractPublicHtmlWriteArtifacts(replyRequestMessages(reply), { publishDir, }), verificationReason: verification.reason, @@ -2293,6 +2332,7 @@ export function createWechatMpService({ // policies. Reusing a conversational route here can make a new request // inspect/retry unrelated historical pages from that session. const isPageDataRequest = isPageDataIntent(resetCandidate); + const htmlArtifactDeliveryExpected = shouldDeliverWechatHtmlArtifacts(wechatIntent, intent); const forceNew = shouldForceNewWechatAgentSession(wechatIntent, resetCandidate); let route = await ensureWechatAgentSession({ userId: user.userId, @@ -2353,11 +2393,12 @@ export function createWechatMpService({ sessionId, requestId: replyRequestId, userMessage, + options: { requireHistoricalImageIsolation: true }, }) : null, }, ); - const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []); + const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply)); if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) { const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试'); error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; @@ -2380,6 +2421,7 @@ export function createWechatMpService({ userId: user.userId, sessionId, onPageGenerated, + allowRecentArtifacts: htmlArtifactDeliveryExpected, }); const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { confirmedArtifacts, @@ -2396,7 +2438,10 @@ export function createWechatMpService({ hasValidLinkInReply, }); const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); - let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); + let publishArtifacts = + htmlArtifactDeliveryExpected || publishedArtifacts.length > 0 + ? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }) + : []; if (wechatIntent.kind === 'page.generate') { const pageOutcome = resolvePageGenerateOutcome({ @@ -2482,6 +2527,7 @@ export function createWechatMpService({ workingDir, publicBaseUrl: config.publicBaseUrl, artifacts: publishArtifacts, + allowAttachment: publishArtifacts.length > 0, }); if ( wechatIntent.kind !== 'page.generate' @@ -2521,7 +2567,9 @@ export function createWechatMpService({ } throw markWechatUserNotified(err instanceof Error ? err : new Error(message)); } - const mayBeStaleSession = sessionId && isRecoverableWechatAgentSessionError(message); + const mayBeStaleSession = + sessionId + && (isRecoverableWechatAgentSessionError(message) || isWechatAgentApiErrorText(message)); if (mayBeStaleSession) { route = await ensureWechatAgentSession({ userId: user.userId, @@ -2560,11 +2608,12 @@ export function createWechatMpService({ sessionId, requestId: replyRequestId, userMessage, + options: { requireHistoricalImageIsolation: true }, }) : null, }, ); - const generatedImages = collectWechatGeneratedImages(reply?.messages ?? []); + const generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply)); if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) { const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试'); error.code = 'WECHAT_IMAGE_GENERATION_REQUIRED'; @@ -2587,6 +2636,7 @@ export function createWechatMpService({ userId: user.userId, sessionId, onPageGenerated, + allowRecentArtifacts: htmlArtifactDeliveryExpected, }); const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { confirmedArtifacts, @@ -2603,7 +2653,10 @@ export function createWechatMpService({ hasValidLinkInReply, }); const bareCompletionReply = isSuspiciousBareCompletionReply(reply, intent); - let publishArtifacts = selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }); + let publishArtifacts = + htmlArtifactDeliveryExpected || publishedArtifacts.length > 0 + ? selectSendableHtmlArtifacts({ verifiedArtifacts, confirmedArtifacts }) + : []; if (wechatIntent.kind === 'page.generate') { const pageOutcome = resolvePageGenerateOutcome({ @@ -2685,6 +2738,7 @@ export function createWechatMpService({ workingDir, publicBaseUrl: config.publicBaseUrl, artifacts: publishArtifacts, + allowAttachment: publishArtifacts.length > 0, }); if ( wechatIntent.kind !== 'page.generate' diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 69c6992..ccdc907 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -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, 'Old'); + 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)."; diff --git a/wechat/intent/patterns.mjs b/wechat/intent/patterns.mjs index c79d7de..536fb7d 100644 --- a/wechat/intent/patterns.mjs +++ b/wechat/intent/patterns.mjs @@ -10,7 +10,7 @@ export const DOCX_DOWNLOAD_PATTERN = /(?:(?:word|docx|\.docx|\.doc|文档).*(?:下载|链接|导出|给我)|(?:下载|导出|提供|给我).*(?:word|docx|\.docx|\.doc|文档))/iu; export const TOPIC_RESET_PATTERN = - /^(换(个)?话题|新问题|忽略之前|不管之前|重新开始|reset)$/iu; + /^(换(个)?话题|换新会话|新会话|开新会话|另开会话|清空上下文|新问题|忽略之前|不管之前|重新开始|reset)$/iu; export const TOPIC_RESET_LOOSE_PATTERN = /忽略.*之前|不要管.*之前|别管.*之前/u; diff --git a/wechat/wechat-channel.test.mjs b/wechat/wechat-channel.test.mjs index 925bf74..0d1e5bf 100644 --- a/wechat/wechat-channel.test.mjs +++ b/wechat/wechat-channel.test.mjs @@ -34,6 +34,7 @@ test('classifyWechatIntent detects page.generate', () => { test('classifyWechatIntent detects session.reset', () => { assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换话题' }).kind, 'session.reset'); + assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换新会话' }).kind, 'session.reset'); }); test('selectSendableHtmlArtifacts never returns stub html', () => {