import assert from 'node:assert/strict'; import test from 'node:test'; import { attachPortalAgentRuntimeRoutes } from './portal-agent-runtime-routes.mjs'; function createRouterRecorder() { const routes = new Map(); return { routes, get(path, handler) { routes.set(`GET ${path}`, handler); }, post(path, handler) { routes.set(`POST ${path}`, handler); }, }; } function createResponseRecorder() { return { statusCode: 200, body: undefined, status(code) { this.statusCode = code; return this; }, json(body) { this.body = body; return this; }, }; } function createRequest(overrides = {}) { return { body: {}, params: {}, query: {}, ...overrides, }; } function createProxy(calls) { const handler = (name) => (req, _res, next) => { calls.push({ kind: 'handler', name, req }); next(); }; return { requireUser: handler('require-user'), ensureChatAllowed: handler('ensure-chat-allowed'), handlers: { 'POST /agent/start': [handler('start-1'), handler('start-2')], 'POST /agent/resume': [handler('resume')], 'GET /sessions': [handler('sessions')], }, }; } test('Agent runtime module preserves route inventory and order', () => { const api = createRouterRecorder(); attachPortalAgentRuntimeRoutes(api); assert.deepEqual([...api.routes.keys()], [ 'POST /internal/deep-search/llm', 'POST /llm/apply-local-fallback', 'POST /agent/start', 'POST /agent/runs', 'GET /agent/runs/:runId', 'GET /agent/runs/:runId/events', 'POST /agent/resume', 'GET /sessions', ]); }); test('deep search LLM gateway preserves internal auth, payload, response, and errors', async () => { const calls = []; const api = createRouterRecorder(); const logger = { warn(...args) { calls.push(['warn', ...args]); }, }; attachPortalAgentRuntimeRoutes(api, { getLlmProviderService: () => ({ id: 'llm' }), getDeepSearchInternalSecret: () => 'expected-secret', bearerToken: () => 'provided-secret', async executeDeepSearchLlmGatewayFn(options) { calls.push(['gateway', options]); return { status: 202, body: { ok: true, accepted: true }, }; }, logger, }); const req = createRequest({ body: { prompt: 'research' }, get: () => 'header-secret', }); const res = createResponseRecorder(); await api.routes.get('POST /internal/deep-search/llm')( req, res, ); assert.equal(res.statusCode, 202); assert.deepEqual(res.body, { ok: true, accepted: true, }); assert.deepEqual(calls[0], [ 'gateway', { llmProviderService: { id: 'llm' }, expectedSecret: 'expected-secret', providedSecret: 'provided-secret', input: { prompt: 'research' }, }, ]); attachPortalAgentRuntimeRoutes(api, { async executeDeepSearchLlmGatewayFn() { throw new Error('gateway unavailable'); }, logger, }); const errorRes = createResponseRecorder(); await api.routes.get('POST /internal/deep-search/llm')( req, errorRes, ); assert.equal(errorRes.statusCode, 502); assert.deepEqual(errorRes.body, { ok: false, message: 'gateway unavailable', }); assert.equal(calls.at(-1)[0], 'warn'); }); test('local fallback preserves readiness, auth, ownership, and status gates', async () => { const scenarios = [ { name: 'unavailable', dependencies: {}, expectedStatus: 503, expectedBody: { message: '未启用 LLM 配置' }, }, { name: 'signed out', dependencies: { getUserAuth: () => ({ getMe: async () => null }), getLlmProviderService: () => ({}), getTkmindProxy: () => ({}), }, expectedStatus: 401, expectedBody: { message: '未登录' }, }, { name: 'missing session', dependencies: { getUserAuth: () => ({ getMe: async () => ({ id: 'user-1' }) }), getLlmProviderService: () => ({}), getTkmindProxy: () => ({}), }, expectedStatus: 400, expectedBody: { message: '缺少 session_id' }, }, { name: 'not owner', request: createRequest({ body: { session_id: 'session-1' } }), dependencies: { getUserAuth: () => ({ getMe: async () => ({ id: 'user-1' }) }), getLlmProviderService: () => ({}), getTkmindProxy: () => ({}), }, expectedStatus: 403, expectedBody: { message: '无权访问该会话' }, }, ]; for (const scenario of scenarios) { let ready = false; const api = createRouterRecorder(); attachPortalAgentRuntimeRoutes(api, { waitForUserAuthReady: async () => { ready = true; }, ...scenario.dependencies, }); const res = createResponseRecorder(); await api.routes.get('POST /llm/apply-local-fallback')( scenario.request ?? createRequest(), res, ); assert.equal(ready, true, scenario.name); assert.equal(res.statusCode, scenario.expectedStatus, scenario.name); assert.deepEqual(res.body, scenario.expectedBody, scenario.name); } }); test('local fallback preserves token lookup, success, upstream failure, and exceptions', async () => { const calls = []; const proxy = { async applyLocalFallbackForSession(sessionId) { calls.push({ kind: 'fallback', sessionId }); return { ok: true, provider: 'local' }; }, }; const api = createRouterRecorder(); attachPortalAgentRuntimeRoutes(api, { getUserAuth: () => ({ async getMe(token) { calls.push({ kind: 'get-me', token }); return { id: 'user-1' }; }, }), getLlmProviderService: () => ({}), getTkmindProxy: () => proxy, getUserToken: () => 'token-1', async ownsAgentSession(userId, sessionId) { calls.push({ kind: 'owns', userId, sessionId }); return true; }, }); const req = createRequest({ body: { session_id: ' session-1 ' } }); const res = createResponseRecorder(); await api.routes.get('POST /llm/apply-local-fallback')(req, res); assert.equal(res.statusCode, 200); assert.deepEqual(res.body, { ok: true, provider: 'local' }); assert.deepEqual(calls, [ { kind: 'get-me', token: 'token-1' }, { kind: 'owns', userId: 'user-1', sessionId: 'session-1' }, { kind: 'fallback', sessionId: 'session-1' }, ]); proxy.applyLocalFallbackForSession = async () => ({ ok: false, reason: 'relay_unavailable', }); const unavailableRes = createResponseRecorder(); await api.routes.get('POST /llm/apply-local-fallback')(req, unavailableRes); assert.equal(unavailableRes.statusCode, 503); assert.deepEqual(unavailableRes.body, { ok: false, reason: 'relay_unavailable', }); proxy.applyLocalFallbackForSession = async () => { throw new Error('switch failed'); }; const errorRes = createResponseRecorder(); await api.routes.get('POST /llm/apply-local-fallback')(req, errorRes); assert.equal(errorRes.statusCode, 500); assert.deepEqual(errorRes.body, { message: 'switch failed' }); }); test('proxy-backed routes preserve handler order and fall through after completion', async () => { const calls = []; const api = createRouterRecorder(); const proxy = createProxy(calls); attachPortalAgentRuntimeRoutes(api, { getUserAuth: () => ({ id: 'auth' }), getTkmindProxy: () => proxy, getAgentRunGateway: () => ({ id: 'gateway' }), getSessionAccess: () => ({ id: 'session-access' }), getMindSpaceAssetAgent: () => ({ id: 'asset-agent' }), getCodeRunPolicyService: () => ({ id: 'code-run-policy' }), createPostAgentRunsHandlerFn(dependencies) { calls.push({ kind: 'create-post', dependencies }); return (req, _res, next) => { calls.push({ kind: 'handler', name: 'post-run', req }); next(); }; }, createGetAgentRunHandlerFn(dependencies) { calls.push({ kind: 'create-get', dependencies }); return (req, _res, next) => { calls.push({ kind: 'handler', name: 'get-run', req }); next(); }; }, createAgentRunEventsHandlerFn(dependencies) { calls.push({ kind: 'create-events', dependencies }); return (req, _res, next) => { calls.push({ kind: 'handler', name: 'run-events', req }); next(); }; }, }); for (const route of [ 'POST /agent/start', 'POST /agent/runs', 'GET /agent/runs/:runId', 'GET /agent/runs/:runId/events', 'POST /agent/resume', 'GET /sessions', ]) { const req = createRequest({ route }); let nextCount = 0; await api.routes.get(route)(req, createResponseRecorder(), () => { nextCount += 1; }); assert.equal(nextCount, 0, `${route} does not fall through after a handled chain`); } assert.deepEqual( calls.filter((call) => call.kind === 'handler').map((call) => call.name), [ 'start-1', 'start-2', 'require-user', 'ensure-chat-allowed', 'post-run', 'require-user', 'get-run', 'require-user', 'run-events', 'resume', 'sessions', ], ); assert.deepEqual( calls.find((call) => call.kind === 'create-post').dependencies, { userAuth: { id: 'auth' }, sessionAccess: { id: 'session-access' }, agentRunGateway: { id: 'gateway' }, mindSpaceAssetAgent: { id: 'asset-agent' }, codeRunPolicyService: { id: 'code-run-policy' }, }, ); }); test('proxy-backed routes preserve unavailable fallthrough and middleware errors', async () => { const api = createRouterRecorder(); attachPortalAgentRuntimeRoutes(api); for (const route of [ 'POST /agent/start', 'POST /agent/runs', 'GET /agent/runs/:runId', 'GET /agent/runs/:runId/events', 'POST /agent/resume', 'GET /sessions', ]) { let nextCount = 0; await api.routes.get(route)( createRequest(), createResponseRecorder(), () => { nextCount += 1; }, ); assert.equal(nextCount, 1, route); } const failure = new Error('middleware failed'); const errorApi = createRouterRecorder(); attachPortalAgentRuntimeRoutes(errorApi, { getUserAuth: () => ({}), getTkmindProxy: () => ({ handlers: { 'POST /agent/start': [ (_req, _res, next) => next(failure), () => assert.fail('chain must stop after an error'), ], }, }), }); let forwarded; await errorApi.routes.get('POST /agent/start')( createRequest(), createResponseRecorder(), (error) => { forwarded = error; }, ); assert.equal(forwarded, failure); });