fix: isolate WeChat runtime delivery path

This commit is contained in:
john
2026-07-28 09:33:25 +08:00
parent 5a13950bc3
commit 7f9453433c
8 changed files with 226 additions and 9 deletions
@@ -268,7 +268,13 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra
const order = [];
const reviewCalls = [];
const wechatCalls = [];
const submittedMessages = [];
const finishedMessages = [];
let failReview = false;
const userAuth = createWechatUserAuth(workspaceRoot);
userAuth.finishWechatMpMessage = async (input) => {
finishedMessages.push(input);
};
const service = createWechatMpService({
config: {
@@ -286,7 +292,7 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra
pageDataAiderReviewEnabled: true,
pageDataAiderReviewUsers: ['唐'],
},
userAuth: createWechatUserAuth(workspaceRoot),
userAuth,
htmlDeliveryAuthority: {
async prepareWechatHtmlDelivery() {
const artifact = {
@@ -339,7 +345,7 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra
};
},
},
sessionApiFetch: async (sessionId, pathname) => {
sessionApiFetch: async (sessionId, pathname, init = {}) => {
if (pathname === `/sessions/${sessionId}/events`) {
return new Response(
[
@@ -351,6 +357,7 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra
);
}
if (pathname === `/sessions/${sessionId}/reply`) {
submittedMessages.push(JSON.parse(init.body ?? '{}').user_message);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
@@ -377,6 +384,10 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra
assert.equal(reviewCalls[0].sourceChannel, 'wechat_mp');
assert.equal(reviewCalls[0].sourceMessageId, '10001');
assert.equal(reviewCalls[0].forcePageData, true);
assert.equal(
submittedMessages[0]?.metadata?.memindRun?.pgRequired,
true,
);
assert.deepEqual(
reviewCalls[0].relativePaths,
['public/children-diet-survey.html'],
@@ -398,6 +409,11 @@ test('integration: WeChat Page Data runs Aider review before the delivery contra
assert.ok(failedSend);
const failedPayload = JSON.parse(failedSend[2]);
assert.match(failedPayload.text.content, /Page Data|数据收集|绑定/);
assert.equal(finishedMessages.at(-1)?.status, 'failed');
assert.equal(
finishedMessages.at(-1)?.agentSessionId,
'session-page-data-1',
);
assert.doesNotMatch(
failedPayload.text.content,
/https:\/\/m\.tkmind\.cn\/MindSpace\/user-page-data\/public\/children-diet-survey\.html/,
+1
View File
@@ -512,6 +512,7 @@ async function bootstrapUserAuth() {
await bootstrapPortalIntegrationServices({
pool,
h5Root: H5_ROOT,
codeRoot: __dirname,
env: process.env,
usersRoot: USERS_ROOT,
wechatMpConfig: WECHAT_MP_CONFIG,
@@ -20,6 +20,7 @@ import {
export async function bootstrapPortalIntegrationServices({
pool,
h5Root,
codeRoot = h5Root,
env = process.env,
usersRoot,
wechatMpConfig,
@@ -76,7 +77,9 @@ export async function bootstrapPortalIntegrationServices({
);
}
const wechatMp = await loadWechatMpModuleFn(h5Root);
const wechatMpRoot = codeRoot || h5Root;
logger.log?.(`[WeChat MP] Loading runtime module from code root: ${wechatMpRoot}`);
const wechatMp = await loadWechatMpModuleFn(wechatMpRoot);
const pageDataDeliveryReviewer =
wechatMpConfig?.pageDataAiderReviewEnabled === true
? createPageDataDeliveryCodeReviewServiceFn({
@@ -122,6 +125,7 @@ export async function bootstrapPortalIntegrationServices({
tkmindProxy.startSessionForUser(userId, {
workingDir,
sessionPolicy,
origin: 'wechat',
}),
sessionApiFetch: async (
sessionId,
@@ -97,6 +97,7 @@ function createSetup(overrides = {}) {
const options = {
pool,
h5Root: '/app',
codeRoot: '/runtime',
env: {
H5_SCHEDULE_ENABLED: '1',
H5_REMINDER_WORKER_ENABLED: '1',
@@ -236,6 +237,10 @@ test('preserves WeChat service configuration and proxy callbacks', async () => {
);
const { wechatOptions } = setup.getCaptured();
assert.deepEqual(
setup.calls.find(([kind]) => kind === 'load-wechat'),
['load-wechat', '/runtime'],
);
assert.equal(
wechatOptions.pageDataFinishGuard,
setup.options.mindSpacePublicFinish,
@@ -268,6 +273,18 @@ test('preserves WeChat service configuration and proxy callbacks', async () => {
}),
{ sessionId: 'session-1' },
);
assert.deepEqual(
setup.calls.find(([kind]) => kind === 'start-session'),
[
'start-session',
'user-1',
{
workingDir: '/workspace',
sessionPolicy: { sandboxed: true },
origin: 'wechat',
},
],
);
assert.deepEqual(
await wechatOptions.sessionApiFetch(
'session-1',
+7 -1
View File
@@ -1430,6 +1430,7 @@ export function createTkmindProxy({
sessionPolicy = null,
recipe = null,
disableImageReading = false,
origin = 'h5',
} = {},
) {
const resolvedWorkingDir = workingDir ?? await userAuth.resolveWorkingDir(userId);
@@ -1458,7 +1459,12 @@ export function createTkmindProxy({
throw new Error('创建会话失败:缺少 session id');
}
await rememberSessionTarget(session.id, startTarget);
await sessionStore.registerAgentSession(userId, session.id, startTarget);
await sessionStore.registerSession({
userId,
sessionId: session.id,
target: startTarget,
origin: origin === 'wechat' ? 'wechat' : 'h5',
});
const baseSessionScopedPolicy =
typeof userAuth.getAgentSessionPolicy ===
'function'
+28
View File
@@ -1006,6 +1006,34 @@ test('startSessionForUser resolves memories through Memory V2 facade', async ()
});
});
test('startSessionForUser records WeChat origin without changing the selected target', async () => {
const registrations = [];
let selectedTarget = '';
await withFakeGoosedSession(async ({ apiTarget, workingDir }) => {
selectedTarget = apiTarget;
const proxy = createTkmindProxy({
apiTarget,
apiSecret: 'test-secret',
userAuth: createMemoryTestUserAuth(workingDir),
sessionAccess: {
enabled: true,
async registerSession(input) {
registrations.push(input);
},
},
});
await proxy.startSessionForUser('user-1', { origin: 'wechat' });
});
assert.deepEqual(registrations, [{
userId: 'user-1',
sessionId: 'session-1',
target: selectedTarget,
origin: 'wechat',
}]);
});
test('startSessionForUser does not inject memories when Memory V2 is disabled', async () => {
let legacyTouched = false;
await withFakeGoosedSession(async ({ apiTarget, workingDir, harnessEntries }) => {
+62 -5
View File
@@ -65,6 +65,7 @@ const DEFAULT_ASR_TARGET = process.env.H5_ASR_TARGET ?? 'https://asr.tkmind.cn';
const WECHAT_RECENT_MEDIA_TTL_MS = 15 * 60 * 1000;
const WECHAT_RECENT_IMAGE_MAX_COUNT = 10;
const DEFAULT_WECHAT_AGENT_REPLY_TIMEOUT_MS = 15 * 60 * 1000;
const WECHAT_SESSION_RESET_ACK_TEXT = '已切换到新会话,请发送你的新需求。';
export { loadWechatMpConfig };
const PUBLIC_HTML_LINK_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
@@ -1517,6 +1518,7 @@ export function createWechatMpService({
expiresAt: 0,
};
const rememberedWechatContexts = new Map();
const confirmedWechatSessionOrigins = new Set();
const messageTasksByOpenid = new Map();
const recentMediaByOpenid = new Map();
let jsapiTicketCache = {
@@ -1527,6 +1529,19 @@ export function createWechatMpService({
const fetchForSession = (sessionId, pathname, init) =>
sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch(pathname, init);
const confirmWechatSessionOrigin = async (sessionId) => {
if (!sessionId || confirmedWechatSessionOrigins.has(sessionId)) return;
if (typeof userAuth.setSessionOrigin === 'function') {
try {
await userAuth.setSessionOrigin(sessionId, 'wechat');
} catch (err) {
logger.warn?.('WeChat MP session origin update failed:', err);
return;
}
}
confirmedWechatSessionOrigins.add(sessionId);
};
const enqueueMessageTask = (openid, taskFactory) => {
const key = String(openid ?? '').trim();
const previous = messageTasksByOpenid.get(key) ?? Promise.resolve();
@@ -2038,6 +2053,7 @@ export function createWechatMpService({
sessionPolicy,
).catch(() => false);
if (routeHasTools) {
await confirmWechatSessionOrigin(existingRoute.agentSessionId);
if (typeof userAuth.touchWechatAgentRoute === 'function') {
await userAuth.touchWechatAgentRoute(config.appId, openid, now).catch((err) => {
logger.warn?.('WeChat MP route touch failed:', err);
@@ -2057,7 +2073,12 @@ export function createWechatMpService({
throw new Error(gate.message || '当前用户无法使用聊天能力');
}
const started = startAgentSession
? await startAgentSession({ userId, workingDir, sessionPolicy })
? await startAgentSession({
userId,
workingDir,
sessionPolicy,
origin: 'wechat',
})
: await readJsonResponse(
await apiFetch('/agent/start', {
method: 'POST',
@@ -2094,6 +2115,7 @@ export function createWechatMpService({
origin: 'wechat',
});
}
await confirmWechatSessionOrigin(sessionId);
await reconcileAgentSession(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -2167,7 +2189,14 @@ export function createWechatMpService({
}
};
const buildIntentMetadata = (intent, { mediaAnalysisEnabled = false, imagePolicy = null } = {}) => {
const buildIntentMetadata = (
intent,
{
mediaAnalysisEnabled = false,
imagePolicy = null,
pgRequired = false,
} = {},
) => {
const mediaPublicUrl = intent.media?.publicUrl || null;
const mediaItems = Array.isArray(intent.recentMediaItems) && intent.recentMediaItems.length > 0
? intent.recentMediaItems
@@ -2199,7 +2228,10 @@ export function createWechatMpService({
recognition: intent.msgType === 'voice' ? intent.agentText || null : null,
location: intent.location || null,
link: intent.link || null,
memindRun: buildWechatImageRunMetadata(imagePolicy),
memindRun: {
...buildWechatImageRunMetadata(imagePolicy),
...(pgRequired ? { pgRequired: true } : {}),
},
};
};
@@ -2348,6 +2380,14 @@ export function createWechatMpService({
});
let sessionId = route.sessionId;
await ensureSessionProvider(sessionId);
if (wechatIntent.kind === 'session.reset') {
await sendCustomerServiceText(
inbound.fromUserName,
WECHAT_SESSION_RESET_ACK_TEXT,
user,
);
return { sessionId };
}
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
let finished = false;
let progressTimer = null;
@@ -2384,7 +2424,11 @@ export function createWechatMpService({
sessionId,
requestId,
agentPrompt,
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
buildIntentMetadata(intent, {
mediaAnalysisEnabled,
imagePolicy,
pgRequired: isPageDataRequest,
}),
{
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
userId: user.userId,
@@ -2574,6 +2618,14 @@ export function createWechatMpService({
});
return { sessionId };
} catch (err) {
if (
err &&
typeof err === 'object' &&
sessionId &&
!err.wechatAgentSessionId
) {
err.wechatAgentSessionId = sessionId;
}
const message = err instanceof Error ? err.message : String(err);
if (err?.code === 'WECHAT_AGENT_REPLY_TIMEOUT') {
await userAuth.clearWechatAgentRoute(config.appId, inbound.fromUserName).catch((clearErr) => {
@@ -2628,7 +2680,11 @@ export function createWechatMpService({
sessionId,
retryId,
retryPrompt,
buildIntentMetadata(intent, { mediaAnalysisEnabled, imagePolicy }),
buildIntentMetadata(intent, {
mediaAnalysisEnabled,
imagePolicy,
pgRequired: isPageDataRequest,
}),
{
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
userId: user.userId,
@@ -3284,6 +3340,7 @@ export function createWechatMpService({
openid: inbound.fromUserName,
msgId: inbound.msgId,
status: 'failed',
agentSessionId: err?.wechatAgentSessionId ?? null,
}).catch(() => {});
}
logger.error?.('WeChat MP background reply failed:', err);
+88
View File
@@ -333,6 +333,94 @@ test('Page Data requests always rotate away from an existing WeChat route', () =
assert.equal(shouldForceNewWechatAgentSession({ kind: 'chat.general' }, '换新会话'), true);
});
test('WeChat session reset acknowledges without sending the control text to Agent', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const sentPayloads = [];
const finishedMessages = [];
let activeRoute = { agentSessionId: 'session-old' };
let startedSessions = 0;
let agentReplies = 0;
const sessionOrigins = [];
const service = createBoundWechatService({
token,
startAgentSession: async (input) => {
startedSessions += 1;
assert.equal(input.origin, 'wechat');
return { id: 'session-new' };
},
userAuth: {
async getWechatAgentRoute() {
return activeRoute;
},
async clearWechatAgentRoute() {
activeRoute = null;
},
async upsertWechatAgentRoute({ agentSessionId }) {
activeRoute = { agentSessionId };
},
async setSessionOrigin(sessionId, origin) {
sessionOrigins.push({ sessionId, origin });
},
async finishWechatMpMessage(input) {
finishedMessages.push(input);
},
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname.includes('/reply')) {
agentReplies += 1;
throw new Error('session reset must not reach Agent');
}
return new Response('{}', {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
},
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 result = await service.handleInboundMessage(
inboundXml({ content: '新会话' }),
{
timestamp,
nonce,
signature: signatureFor(token, timestamp, nonce),
},
);
await result.task;
assert.equal(startedSessions, 1);
assert.equal(agentReplies, 0);
assert.equal(activeRoute?.agentSessionId, 'session-new');
assert.deepEqual(sessionOrigins, [{
sessionId: 'session-new',
origin: 'wechat',
}]);
assert.match(sentPayloads[0]?.text?.content ?? '', /已切换到新会话/);
assert.equal(finishedMessages.at(-1)?.status, 'done');
assert.equal(finishedMessages.at(-1)?.agentSessionId, 'session-new');
});
test('WeChat Page Data repair intent receives the guarded repair workflow', () => {
assert.equal(isWechatPageDataTask('提交失败,数据没有保存成功'), true);
assert.equal(isWechatPageDataTask('修复问卷的 Page Data 绑定'), true);