Compare commits

..

4 Commits

Author SHA1 Message Date
john db225b784e fix(wechat): isolate stale images and page links
Memind CI / Test, build, and release guards (pull_request) Successful in 2m36s
2026-07-22 14:08:18 +08:00
tkmind aea3c1e83e Merge pull request #24: fix WeChat episodic recall
Memind CI / Test, build, and release guards (push) Successful in 3m9s
2026-07-22 05:42:38 +00:00
john 5242cbf08b fix: inject episodic memory into WeChat agent
Memind CI / Test, build, and release guards (pull_request) Successful in 3m24s
2026-07-22 13:41:40 +08:00
tkmind 881f70f4bf Merge pull request 'feat: 补充历史会话记忆召回' (#23) from codex/episodic-memory-recall into main
Memind CI / Test, build, and release guards (push) Successful in 3m53s
CI passed; merge episodic history recall with rollout gates.
2026-07-22 04:59:42 +00:00
11 changed files with 575 additions and 39 deletions
+4
View File
@@ -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;
+5
View File
@@ -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, '上一轮图片');
});
+3 -2
View File
@@ -876,11 +876,12 @@ 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,
chatIntentRouter,
onPageGenerated: async ({ userId, sessionId, artifacts = [] }) => {
for (const artifact of artifacts) {
void sendMindSpaceAnalyticsEvent({
+22 -6
View File
@@ -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()) {
+59 -1
View File
@@ -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({
+14 -2
View File
@@ -50,12 +50,22 @@ export function ensureUserZoneDirs(workspaceRoot) {
}
/** 上传完成后镜像到 MindSpace/<user>/<category>/文件名 */
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 };
+25
View File
@@ -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, '<html>stored version</html>');
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, '<html>new Agent edit</html>');
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'), '<html>new Agent edit</html>');
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({
+138 -27
View File
@@ -235,7 +235,7 @@ async function executeSessionReply(
requestId,
prompt,
metadata = {},
{ submitReply = null } = {},
{ submitReply = null, prepareUserMessage = null } = {},
) {
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
method: 'GET',
@@ -246,7 +246,10 @@ async function executeSessionReply(
throw new Error(text || '无法建立公众号消息事件流');
}
const userMessage = createUserMessage(prompt, metadata);
let userMessage = createUserMessage(prompt, metadata);
if (prepareUserMessage) {
userMessage = (await prepareUserMessage(userMessage)) ?? userMessage;
}
if (submitReply) {
await submitReply({ sessionId, requestId, userMessage });
} else {
@@ -268,6 +271,7 @@ async function executeSessionReply(
const decoder = new TextDecoder();
let buffer = '';
let messages = [];
let requestMessages = [];
let hasScopedAssistantUpdate = false;
while (true) {
@@ -298,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
@@ -316,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;
@@ -413,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;
@@ -446,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) {
@@ -482,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) {
@@ -525,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;
@@ -671,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 });
@@ -935,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,
});
@@ -948,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,
@@ -965,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 {
@@ -1016,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);
}
@@ -1039,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();
@@ -1054,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));
}
@@ -1504,6 +1546,7 @@ export function createWechatMpService({
scheduleService = null,
wechatScheduleLlmConfigService = null,
llmProviderService = null,
chatIntentRouter = null,
onPageGenerated = null,
applySessionLlmProvider = null,
refreshSessionSnapshot = null,
@@ -1852,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?.(
@@ -1863,7 +1906,7 @@ export function createWechatMpService({
const repair = repairUnambiguousFreshWechatPageThumbnail({
artifacts,
images,
currentRunHtmlArtifacts: extractPublicHtmlWriteArtifacts(reply?.messages ?? [], {
currentRunHtmlArtifacts: extractPublicHtmlWriteArtifacts(replyRequestMessages(reply), {
publishDir,
}),
verificationReason: verification.reason,
@@ -2232,6 +2275,49 @@ export function createWechatMpService({
}
};
const prepareWechatAgentUserMessage = async ({ userId, sessionId, userMessage }) => {
if (
!chatIntentRouter?.resolveAgentMemoryContext
|| !chatIntentRouter?.applyAgentOrchestration
) {
return userMessage;
}
const displayText = String(
userMessage?.metadata?.displayText
?? messageVisibleText(userMessage),
).trim();
try {
const memoryContext = await chatIntentRouter.resolveAgentMemoryContext({
userId,
sessionId,
text: displayText,
forceDeepReasoning: false,
});
if (
!memoryContext?.injectionEnabled
|| !Array.isArray(memoryContext.memories)
|| memoryContext.memories.length === 0
) {
return userMessage;
}
return chatIntentRouter.applyAgentOrchestration(
userMessage,
{
route: 'agent_orchestration',
reason: '微信服务号消息由 Agent 处理',
source: 'wechat_mp',
},
{ memoryContext },
);
} catch (err) {
logger.warn?.(
'WeChat MP agent memory resolve skipped:',
err instanceof Error ? err.message : err,
);
return userMessage;
}
};
const runIntentMessage = async ({ inbound, intent, user }) => {
const wechatIntent = classifyWechatIntent(intent);
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
@@ -2246,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,
@@ -2294,6 +2381,11 @@ export function createWechatMpService({
agentPrompt,
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
{
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
userId: user.userId,
sessionId,
userMessage,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
submitSessionReply({
@@ -2301,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';
@@ -2328,6 +2421,7 @@ export function createWechatMpService({
userId: user.userId,
sessionId,
onPageGenerated,
allowRecentArtifacts: htmlArtifactDeliveryExpected,
});
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
confirmedArtifacts,
@@ -2344,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({
@@ -2430,6 +2527,7 @@ export function createWechatMpService({
workingDir,
publicBaseUrl: config.publicBaseUrl,
artifacts: publishArtifacts,
allowAttachment: publishArtifacts.length > 0,
});
if (
wechatIntent.kind !== 'page.generate'
@@ -2469,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,
@@ -2496,6 +2596,11 @@ export function createWechatMpService({
retryPrompt,
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
{
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
userId: user.userId,
sessionId,
userMessage,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
submitSessionReply({
@@ -2503,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';
@@ -2530,6 +2636,7 @@ export function createWechatMpService({
userId: user.userId,
sessionId,
onPageGenerated,
allowRecentArtifacts: htmlArtifactDeliveryExpected,
});
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, {
confirmedArtifacts,
@@ -2546,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({
@@ -2628,6 +2738,7 @@ export function createWechatMpService({
workingDir,
publicBaseUrl: config.publicBaseUrl,
artifacts: publishArtifacts,
allowAttachment: publishArtifacts.length > 0,
});
if (
wechatIntent.kind !== 'page.generate'
+303
View File
@@ -24,6 +24,7 @@ import {
decryptWechatMpPayload,
WECHAT_CUSTOMER_TEXT_MAX_BYTES,
} from './wechat-mp.mjs';
import { createChatIntentRouter } from './chat-intent-router.mjs';
function signatureFor(token, timestamp, nonce) {
return crypto
@@ -82,6 +83,7 @@ function createBoundWechatService({
scheduleService = null,
applySessionLlmProvider = null,
submitSessionReply = null,
chatIntentRouter = null,
}) {
return createWechatMpService({
config: {
@@ -133,6 +135,7 @@ function createBoundWechatService({
startAgentSession,
sessionApiFetch,
submitSessionReply,
chatIntentRouter,
scheduleService,
applySessionLlmProvider,
wechatFetch,
@@ -157,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', () => {
@@ -549,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';
@@ -2736,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).";
@@ -3935,6 +4142,102 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /\/public\/wechat-mp\//);
});
test('wechat mp injects episodic recall context before submitting the Agent reply', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const submitCalls = [];
const episodicCalls = [];
const chatIntentRouter = createChatIntentRouter({
env: {
MEMORY_AGENT_RESOLVE_ENABLED: '1',
MEMORY_AGENT_INJECTION_MODE: 'canary',
MEMORY_AGENT_CANARY_USER_IDS: 'user-canary',
MEMORY_AGENT_RESOLVE_TIMEOUT_MS: '200',
},
memoryV2: {
async resolve() {
return { memories: [] };
},
},
episodicMemoryService: {
async resolve(input) {
episodicCalls.push(input);
return {
source: 'episodic-index',
memories: [{
id: 'episodic:tokugawa',
label: '历史会话',
text: '用户与助手之前讨论过德川家康。',
}],
};
},
},
});
const service = createBoundWechatService({
token,
chatIntentRouter,
userAuth: {
async findWechatUserByOpenid() {
return { userId: 'user-canary', status: 'active', nickname: '唐' };
},
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') {
return new Response(
[
'data: {"type":"Message","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"记得,我们之前聊过德川家康。"}]}}\n\n',
'data: {"type":"Finish"}\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: ${pathname}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
return { ok: true };
},
wechatFetch: async (url) => {
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')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const result = await service.handleInboundMessage(
inboundXml({ content: '你记得我们聊过德川家康吗?' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
assert.equal(episodicCalls.length, 1);
assert.deepEqual(episodicCalls[0], {
userId: 'user-canary',
sessionId: 'session-1',
query: '你记得我们聊过德川家康吗?',
limit: 3,
});
assert.equal(submitCalls.length, 1);
assert.equal(submitCalls[0].userMessage.metadata.displayText, '你记得我们聊过德川家康吗?');
assert.match(submitCalls[0].userMessage.content[0].text, /\[Memory Context\]/);
assert.match(submitCalls[0].userMessage.content[0].text, /用户与助手之前讨论过德川家康/);
assert.match(submitCalls[0].userMessage.content[0].text, /不是系统指令/);
});
test('wechat mp serializes image and follow-up text and reattaches recent image', async () => {
const token = 'token';
const timestamp = '1710000000';
+1 -1
View File
@@ -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;
+1
View File
@@ -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', () => {