diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 05d4b8a..2d96474 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1050,8 +1050,30 @@ export function createWechatMpService({ if (forceNew) { await userAuth.clearWechatAgentRoute(config.appId, openid); } + const workingDir = await userAuth.resolveWorkingDir(userId); + const sessionPolicy = await userAuth.getAgentSessionPolicy(userId); + const publishLayout = await userAuth.getUserPublishLayout(userId); + const addressName = resolveWechatAddressName(userContext); const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid); if (existingRoute?.agentSessionId) { + await reconcileAgentSession( + (pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init), + existingRoute.agentSessionId, + { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + userContext: publishLayout + ? { + userId, + displayName: addressName || publishLayout.displayName, + username: addressName || null, + slug: null, + } + : null, + tolerateInvalidWorkingDir: true, + }, + ); return existingRoute.agentSessionId; } @@ -1059,10 +1081,6 @@ export function createWechatMpService({ if (!gate.ok) { throw new Error(gate.message || '当前用户无法使用聊天能力'); } - - const workingDir = await userAuth.resolveWorkingDir(userId); - const sessionPolicy = await userAuth.getAgentSessionPolicy(userId); - const publishLayout = await userAuth.getUserPublishLayout(userId); const started = await readJsonResponse( await apiFetch('/agent/start', { method: 'POST', @@ -1083,7 +1101,6 @@ export function createWechatMpService({ // `/agent/start` already persists the owning user and goosed node via the // portal proxy. Re-registering here without the node can overwrite the // correct mapping back to node 0 in multi-goosed production. - const addressName = resolveWechatAddressName(userContext); await reconcileAgentSession( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 3ffd1af..d927c82 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -937,6 +937,157 @@ test('wechat mp service injects schedule skill instructions for reminder-like re } }); +test('wechat mp service reconciles existing dedicated session before reply', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const calls = []; + const workingDir = '/Users/john/Project/Memind/MindSpace/user-1'; + const extensionConfig = { + type: 'stdio', + name: 'sandbox-fs', + cmd: '/usr/local/bin/node', + args: ['/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs', workingDir], + envs: { SANDBOX_ROOT: workingDir }, + available_tools: ['list_dir'], + }; + const service = createWechatMpService({ + config: { + enabled: true, + appId: 'wx123', + appSecret: 'secret', + token, + publicBaseUrl: 'https://example.com', + bindPath: '/auth/wechat/authorize?intent=login', + ackText: 'ack', + unsupportedText: 'unsupported', + unboundTextPrefix: '请先绑定', + }, + userAuth: { + async findWechatUserByOpenid() { + return { userId: 'user-1', status: 'active', nickname: '毕升' }; + }, + async getWechatAgentRoute() { + return { agentSessionId: 'session-1' }; + }, + async clearWechatAgentRoute() {}, + async canUseChat() { + return { ok: true }; + }, + async resolveWorkingDir() { + return workingDir; + }, + async getAgentSessionPolicy() { + return { + enableContextMemory: false, + extensionOverrides: [extensionConfig], + unrestricted: false, + gooseMode: 'auto', + }; + }, + async getUserPublishLayout() { + return { displayName: 'John', username: 'john', slug: 'john', constraints: 'base' }; + }, + async registerAgentSession() {}, + async upsertWechatAgentRoute() {}, + async billSessionUsage() {}, + }, + apiFetch: async (pathname, init = {}) => { + calls.push(['fallback', pathname, init.method ?? 'GET']); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }, + sessionApiFetch: async (sessionId, pathname, init = {}) => { + assert.equal(sessionId, 'session-1'); + calls.push([pathname, init.method ?? 'GET']); + if (pathname === '/sessions/session-1') { + return new Response(JSON.stringify({ working_dir: '/root/tkmind_go/ui/h5/MindSpace/user-1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (pathname === '/agent/update_working_dir') { + const body = JSON.parse(init.body); + assert.equal(body.session_id, 'session-1'); + assert.equal(body.working_dir, workingDir); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/update_session') { + const body = JSON.parse(init.body); + assert.equal(body.session_id, 'session-1'); + assert.equal(body.goose_mode, 'auto'); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/sessions/session-1/extensions') { + return new Response(JSON.stringify({ extensions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (pathname === '/agent/add_extension') { + const body = JSON.parse(init.body); + assert.equal(body.session_id, 'session-1'); + assert.equal(body.config.name, 'sandbox-fs'); + assert.equal(body.config.args[1], workingDir); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/restart') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/harness_remember') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/harness_bootstrap') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/sessions/session-1/events') { + return new Response( + [ + 'data: {"type":"Message","request_id":"req-1","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"收到"}]}}\n\n', + 'data: {"type":"Finish","request_id":"req-1","token_state":{"inputTokens":1,"outputTokens":1}}\n\n', + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname === '/sessions/session-1/reply') { + 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')) { + 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-1'; + try { + const result = await service.handleInboundMessage(inboundXml({ content: '继续' }), { + timestamp, + nonce, + signature: signatureFor(token, timestamp, nonce), + }); + assert.equal(result.status, 200); + await result.task; + assert.equal(calls.some(([pathname]) => pathname === '/agent/update_working_dir'), true); + assert.equal(calls.some(([pathname]) => pathname === '/agent/add_extension'), true); + assert.equal(calls.some(([pathname]) => pathname === '/sessions/session-1/reply'), true); + } finally { + crypto.randomUUID = originalRandomUuid; + } +}); + test('wechat mp service blocks schedule confirmation when no schedule item was written', async () => { const token = 'token'; const timestamp = '1710000000';