diff --git a/scripts/mock-api/admin.mjs b/scripts/mock-api/admin.mjs index 42ee290b2..dc603520c 100644 --- a/scripts/mock-api/admin.mjs +++ b/scripts/mock-api/admin.mjs @@ -1,13 +1,30 @@ import { json } from "./http.mjs"; +import { resetConversationFixturesState } from "./routes/conversations.mjs"; +import { resetCronFixturesState } from "./routes/cron.mjs"; import { + clearSocketEventLog, clearRequestLog, + getSocketEventLog, + listSocketSessions, getMockBehavior, + getMockConversations, + getMockCronJobs, + getMockMessages, + listMockLlmThreads, + getMockWebhookTriggers, getRequestLog, resetMockBehavior, + resetMockConversations, + resetMockCronJobs, + resetMockMessages, + resetMockLlmThreads, + resetSocketSessions, resetMockTunnels, + resetMockWebhookTriggers, setMockBehavior, setMockBehaviors, } from "./state.mjs"; +import { disconnectMockSockets, emitMockSocketEvent } from "./socket.mjs"; export function handleAdmin(ctx) { const { method, url, parsedBody, res, getPort } = ctx; @@ -24,12 +41,44 @@ export function handleAdmin(ctx) { json(res, 200, { success: true, data: getMockBehavior() }); return true; } + if (method === "GET" && /^\/__admin\/state\/?$/.test(url)) { + json(res, 200, { + success: true, + data: { + requestCount: getRequestLog().length, + conversations: getMockConversations(), + messages: getMockMessages(), + cronJobs: getMockCronJobs(), + webhookTriggers: getMockWebhookTriggers(), + llmThreads: listMockLlmThreads(), + socketSessions: listSocketSessions(), + socketEventCount: getSocketEventLog().length, + }, + }); + return true; + } + if (method === "GET" && /^\/__admin\/socket\/sessions\/?$/.test(url)) { + json(res, 200, { success: true, data: listSocketSessions() }); + return true; + } + if (method === "GET" && /^\/__admin\/socket\/events\/?$/.test(url)) { + json(res, 200, { success: true, data: getSocketEventLog() }); + return true; + } if (method === "POST" && /^\/__admin\/reset\/?$/.test(url)) { const keepBehavior = parsedBody?.keepBehavior === true; const keepRequests = parsedBody?.keepRequests === true; if (!keepBehavior) resetMockBehavior(); if (!keepRequests) clearRequestLog(); resetMockTunnels(); + resetMockConversations(); + resetMockMessages(); + resetMockCronJobs(); + resetMockWebhookTriggers(); + resetMockLlmThreads(); + resetConversationFixturesState(); + resetCronFixturesState(); + resetSocketSessions(); json(res, 200, { success: true, data: { @@ -48,5 +97,30 @@ export function handleAdmin(ctx) { json(res, 200, { success: true, data: getMockBehavior() }); return true; } + if (method === "POST" && /^\/__admin\/socket\/emit\/?$/.test(url)) { + const delivered = emitMockSocketEvent({ + event: parsedBody?.event, + data: parsedBody?.data, + targetSid: parsedBody?.targetSid, + targetUserId: parsedBody?.targetUserId, + excludeSid: parsedBody?.excludeSid, + delayMs: parsedBody?.delayMs, + }); + json(res, 200, { success: true, data: { delivered } }); + return true; + } + if (method === "POST" && /^\/__admin\/socket\/disconnect\/?$/.test(url)) { + const disconnected = disconnectMockSockets({ + targetSid: parsedBody?.targetSid, + targetUserId: parsedBody?.targetUserId, + }); + json(res, 200, { success: true, data: { disconnected } }); + return true; + } + if (method === "POST" && /^\/__admin\/socket\/clear-events\/?$/.test(url)) { + clearSocketEventLog(); + json(res, 200, { success: true, data: [] }); + return true; + } return false; } diff --git a/scripts/mock-api/index.mjs b/scripts/mock-api/index.mjs index a635ada71..f9b80b0d5 100644 --- a/scripts/mock-api/index.mjs +++ b/scripts/mock-api/index.mjs @@ -8,13 +8,23 @@ * The legacy entrypoint at `scripts/mock-api-core.mjs` is a re-export shim * over this module so existing import paths keep working. */ -export { getMockServerPort, startMockServer, stopMockServer } from "./server.mjs"; +export { + getMockServerPort, + startMockServer, + stopMockServer, +} from "./server.mjs"; export { DEFAULT_PORT, + clearSocketEventLog, clearRequestLog, + getSocketEventLog, getMockBehavior, + listMockLlmThreads, getRequestLog, + listSocketSessions, resetMockBehavior, + resetMockLlmThreads, setMockBehavior, setMockBehaviors, } from "./state.mjs"; +export { disconnectMockSockets, emitMockSocketEvent } from "./socket.mjs"; diff --git a/scripts/mock-api/routes/__tests__/llm.test.mjs b/scripts/mock-api/routes/__tests__/llm.test.mjs index 0fe916746..f6c8e4058 100644 --- a/scripts/mock-api/routes/__tests__/llm.test.mjs +++ b/scripts/mock-api/routes/__tests__/llm.test.mjs @@ -2,7 +2,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import { handleLlmCompletions } from "../llm.mjs"; -import { resetMockBehavior, setMockBehaviors } from "../../state.mjs"; +import { handleIntegrations } from "../integrations.mjs"; +import { + listMockLlmThreads, + resetMockBehavior, + resetMockLlmThreads, + setMockBehaviors, +} from "../../state.mjs"; function createMockResponse() { return { @@ -33,7 +39,10 @@ function createMockResponse() { function makeCtx({ method = "POST", url = "/chat/completions", - parsedBody = { model: "gpt-oss", messages: [{ role: "user", content: "hello" }] }, + parsedBody = { + model: "gpt-oss", + messages: [{ role: "user", content: "hello" }], + }, headers = {}, } = {}) { return { @@ -47,6 +56,7 @@ function makeCtx({ test.beforeEach(() => { resetMockBehavior(); + resetMockLlmThreads(); }); test("handles root chat completions path with default fallback", () => { @@ -58,10 +68,7 @@ test("handles root chat completions path with default fallback", () => { assert.equal(ctx.res.statusCode, 200); const body = JSON.parse(ctx.res.body); assert.equal(body.model, "gpt-oss"); - assert.equal( - body.choices[0].message.content, - "Hello from e2e mock agent", - ); + assert.equal(body.choices[0].message.content, "Hello from e2e mock agent"); }); test("matches request rules against path and authorization header", () => { @@ -170,3 +177,165 @@ test("returns false for non-LLM routes", () => { const ctx = makeCtx({ method: "GET", url: "/chat/completions" }); assert.equal(handleLlmCompletions(ctx), false); }); + +test("streams reasoning deltas for reasoning-family models", async () => { + const ctx = makeCtx({ + url: "/chat/completions", + parsedBody: { + model: "openhuman-reasoning-mock", + stream: true, + messages: [{ role: "user", content: "compare these rollout options" }], + }, + }); + + assert.equal(handleLlmCompletions(ctx), true); + await new Promise((resolve) => setTimeout(resolve, 220)); + + assert.match(ctx.res.body, /reasoning_content/); + assert.match(ctx.res.body, /Recommendation:/); + assert.match(ctx.res.body, /data: \[DONE\]/); +}); + +test("returns tool calls for agentic models and resolves follow-up turns", () => { + const first = makeCtx({ + parsedBody: { + model: "openhuman-agentic-mock", + mockThreadId: "agent-thread-1", + messages: [ + { role: "user", content: "search the release notes and report back" }, + ], + tools: [{ type: "function", function: { name: "web_search" } }], + }, + }); + + assert.equal(handleLlmCompletions(first), true); + const firstBody = JSON.parse(first.res.body); + assert.equal(firstBody.choices[0].finish_reason, "tool_calls"); + assert.equal( + firstBody.choices[0].message.tool_calls[0].function.name, + "web_search", + ); + + const second = makeCtx({ + parsedBody: { + model: "openhuman-agentic-mock", + mockThreadId: "agent-thread-1", + messages: [ + { role: "user", content: "search the release notes and report back" }, + { + role: "tool", + content: + "Release notes mention Socket.IO support and dynamic mock routes.", + }, + { role: "user", content: "okay now summarize the result" }, + ], + }, + }); + + assert.equal(handleLlmCompletions(second), true); + const secondBody = JSON.parse(second.res.body); + assert.match( + secondBody.choices[0].message.content, + /Socket\.IO support and dynamic mock routes/i, + ); +}); + +test("updates coding responses across turns with thread memory", () => { + const first = makeCtx({ + parsedBody: { + model: "gpt-5-codex-mock", + mockThreadId: "code-thread-1", + messages: [{ role: "user", content: "write a tiny typescript helper" }], + }, + }); + + assert.equal(handleLlmCompletions(first), true); + const firstBody = JSON.parse(first.res.body); + assert.match(firstBody.choices[0].message.content, /```ts/); + + const second = makeCtx({ + parsedBody: { + model: "gpt-5-codex-mock", + mockThreadId: "code-thread-1", + messages: [ + { role: "user", content: "make it async and keep it in typescript" }, + ], + }, + }); + + assert.equal(handleLlmCompletions(second), true); + const secondBody = JSON.parse(second.res.body); + assert.match(secondBody.choices[0].message.content, /Updated TS version/i); + assert.match(secondBody.choices[0].message.content, /async function runTask/); +}); + +test("shortens summarization responses across turns", () => { + const first = makeCtx({ + parsedBody: { + model: "openhuman-summary-mock", + mockThreadId: "summary-thread-1", + messages: [ + { + role: "user", + content: + "Summarize this: the mock backend now supports stateful routes, socket sessions, fault injection, and more realistic provider flows.", + }, + ], + }, + }); + + assert.equal(handleLlmCompletions(first), true); + const firstBody = JSON.parse(first.res.body); + + const second = makeCtx({ + parsedBody: { + model: "openhuman-summary-mock", + mockThreadId: "summary-thread-1", + messages: [{ role: "user", content: "shorter" }], + }, + }); + + assert.equal(handleLlmCompletions(second), true); + const secondBody = JSON.parse(second.res.body); + assert.ok( + secondBody.choices[0].message.content.length <= + firstBody.choices[0].message.content.length, + ); +}); + +test("lists multiple mock model families from the integrations catalog", () => { + const ctx = { + method: "GET", + url: "/openai/v1/models", + parsedBody: null, + res: createMockResponse(), + }; + + assert.equal(handleIntegrations(ctx), true); + assert.equal(ctx.res.statusCode, 200); + const body = JSON.parse(ctx.res.body); + const ids = body.data.map((item) => item.id); + assert.ok(ids.includes("openhuman-reasoning-mock")); + assert.ok(ids.includes("openhuman-agentic-mock")); + assert.ok(ids.includes("gpt-5-codex-mock")); + assert.ok(ids.includes("openhuman-summary-mock")); +}); + +test("records thread state for multi-turn mock LLM sessions", () => { + const ctx = makeCtx({ + parsedBody: { + model: "openhuman-summary-mock", + mockThreadId: "thread-state-1", + messages: [ + { role: "user", content: "summarize the latest provider status" }, + ], + }, + }); + + assert.equal(handleLlmCompletions(ctx), true); + const threads = listMockLlmThreads(); + const thread = threads.find((entry) => entry.key === "thread-state-1"); + assert.ok(thread); + assert.equal(thread.lastFamily, "summarization"); + assert.equal(thread.turnCount, 1); +}); diff --git a/scripts/mock-api/routes/__tests__/stateful.test.mjs b/scripts/mock-api/routes/__tests__/stateful.test.mjs new file mode 100644 index 000000000..91cb5e47b --- /dev/null +++ b/scripts/mock-api/routes/__tests__/stateful.test.mjs @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + resetMockBehavior, + setMockBehaviors, + startMockServer, + stopMockServer, +} from "../../index.mjs"; + +test.beforeEach(async () => { + await stopMockServer(); + resetMockBehavior(); +}); + +test.afterEach(async () => { + await stopMockServer(); +}); + +test("persists created conversations across requests", async () => { + const started = await startMockServer(18575, { retryIfInUse: true }); + const baseUrl = `http://127.0.0.1:${started.port}`; + + const createdResponse = await fetch(`${baseUrl}/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Fixture conversation", channel: "web" }), + }); + assert.equal(createdResponse.status, 200); + const createdBody = await createdResponse.json(); + const createdId = createdBody?.data?.id; + assert.equal(typeof createdId, "string"); + + const listResponse = await fetch(`${baseUrl}/conversations`); + const listBody = await listResponse.json(); + assert.equal(Array.isArray(listBody?.data), true); + assert.equal( + listBody.data.some( + (entry) => + entry.id === createdId && entry.title === "Fixture conversation", + ), + true, + ); + + const detailResponse = await fetch(`${baseUrl}/conversations/${createdId}`); + const detailBody = await detailResponse.json(); + assert.equal(detailBody?.data?.id, createdId); + assert.equal(Array.isArray(detailBody?.data?.messages), true); +}); + +test("does not reseed conversations after deleting everything until reset", async () => { + const started = await startMockServer(18577, { retryIfInUse: true }); + const baseUrl = `http://127.0.0.1:${started.port}`; + + const initialList = await fetch(`${baseUrl}/conversations`); + const initialBody = await initialList.json(); + const ids = Array.isArray(initialBody?.data) + ? initialBody.data.map((entry) => entry.id) + : []; + + for (const id of ids) { + await fetch(`${baseUrl}/conversations/${id}`, { method: "DELETE" }); + } + + const afterDelete = await fetch(`${baseUrl}/conversations`); + const afterDeleteBody = await afterDelete.json(); + assert.deepEqual(afterDeleteBody?.data, []); +}); + +test("keeps server-controlled ids on create and patch routes", async () => { + const started = await startMockServer(18578, { retryIfInUse: true }); + const baseUrl = `http://127.0.0.1:${started.port}`; + + const createdConversation = await fetch(`${baseUrl}/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "client-conv", title: "Client title" }), + }).then((response) => response.json()); + assert.notEqual(createdConversation?.data?.id, "client-conv"); + + const createdCron = await fetch(`${baseUrl}/settings/cron-jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "client-cron", name: "Client cron" }), + }).then((response) => response.json()); + const cronId = createdCron?.data?.id; + assert.notEqual(cronId, "client-cron"); + + await fetch(`${baseUrl}/settings/cron-jobs/${cronId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: "mutated-cron", enabled: false }), + }); + + const cronList = await fetch(`${baseUrl}/settings/cron-jobs`).then( + (response) => response.json(), + ); + const patchedCron = cronList.data.find((entry) => entry.id === cronId); + assert.equal(patchedCron.id, cronId); + assert.equal(patchedCron.enabled, false); +}); + +test("applies injected HTTP fault rules without route-specific controller logic", async () => { + const started = await startMockServer(18576, { retryIfInUse: true }); + const baseUrl = `http://127.0.0.1:${started.port}`; + + setMockBehaviors( + { + httpFaultRules: JSON.stringify([ + { + method: "GET", + pathRegex: "^/auth/me(?:\\?.*)?$", + status: 503, + body: { success: false, error: "Synthetic outage" }, + }, + ]), + }, + "replace", + ); + + const response = await fetch(`${baseUrl}/auth/me`); + assert.equal(response.status, 503); + const body = await response.json(); + assert.equal(body.error, "Synthetic outage"); +}); diff --git a/scripts/mock-api/routes/conversations.mjs b/scripts/mock-api/routes/conversations.mjs index 2019d5668..fd6c94a32 100644 --- a/scripts/mock-api/routes/conversations.mjs +++ b/scripts/mock-api/routes/conversations.mjs @@ -1,26 +1,106 @@ import { json } from "../http.mjs"; +import { + behavior, + createMockId, + fuzzyNumber, + fuzzyPick, + fuzzyTimestamp, + getMockConversations, + getMockMessages, +} from "../state.mjs"; + +let fixturesSeeded = false; + +export function resetConversationFixturesState() { + fixturesSeeded = false; +} + +function ensureConversationFixtures() { + if (fixturesSeeded) return; + const conversations = getMockConversations(); + const messages = getMockMessages(); + if (conversations.length > 0 || messages.length > 0) { + fixturesSeeded = true; + return; + } + + const mockBehavior = behavior(); + const count = Math.max( + 0, + Number(mockBehavior.conversationCount || fuzzyNumber("conv:count", 1, 4)), + ); + + const titles = [ + "Daily standup", + "Onboarding thread", + "Billing follow-up", + "Release prep", + "Research scratchpad", + ]; + + for (let i = 0; i < count; i += 1) { + const id = createMockId("conv"); + conversations.push({ + id, + title: fuzzyPick(`conv:title:${i}`, titles, "Mock Conversation"), + channel: fuzzyPick( + `conv:channel:${i}`, + ["web", "telegram", "discord"], + "web", + ), + unreadCount: fuzzyNumber(`conv:unread:${i}`, 0, 3), + archived: false, + createdAt: fuzzyTimestamp(`conv:created:${i}`), + updatedAt: fuzzyTimestamp(`conv:updated:${i}`, 3 * 24 * 60 * 60 * 1000), + }); + + const messageCount = fuzzyNumber(`conv:messages:${i}`, 1, 3); + for (let j = 0; j < messageCount; j += 1) { + messages.push({ + id: createMockId("msg"), + conversationId: id, + role: j % 2 === 0 ? "user" : "assistant", + text: + j % 2 === 0 + ? `Mock prompt ${j + 1} for ${id}` + : `Mock response ${j + 1} for ${id}`, + createdAt: fuzzyTimestamp(`msg:${i}:${j}`, 2 * 24 * 60 * 60 * 1000), + }); + } + } + + fixturesSeeded = true; +} -// Gap fill: conversations / messages / channels / notifications. -// -// The real backend serves rich, paginated data here; for e2e we return empty -// lists wrapped in the standard envelope. Specs that need richer fixtures can -// override via `setMockBehavior` and a future scenario knob. export function handleConversations(ctx) { const { method, url, parsedBody, res } = ctx; + ensureConversationFixtures(); + const conversations = getMockConversations(); + const messages = getMockMessages(); // /conversations if (method === "GET" && /^\/conversations\/?(\?.*)?$/.test(url)) { - json(res, 200, { success: true, data: [] }); + json(res, 200, { success: true, data: conversations }); return true; } if (method === "POST" && /^\/conversations\/?$/.test(url)) { + const created = { + ...(parsedBody || {}), + id: createMockId("conv"), + title: + typeof parsedBody?.title === "string" && parsedBody.title.trim() + ? parsedBody.title.trim() + : "Untitled mock conversation", + channel: parsedBody?.channel || "web", + unreadCount: 0, + archived: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + conversations.unshift(created); json(res, 200, { success: true, - data: { - id: "conv_mock_" + Date.now(), - ...(parsedBody || {}), - createdAt: new Date().toISOString(), - }, + data: created, }); return true; } @@ -28,14 +108,32 @@ export function handleConversations(ctx) { /^\/conversations\/([^/?]+)\/?(\?.*)?$/, ); if (conversationItemMatch) { + const conversation = conversations.find( + (entry) => entry.id === conversationItemMatch[1], + ); if (method === "GET") { json(res, 200, { success: true, - data: { id: conversationItemMatch[1], messages: [] }, + data: { + ...(conversation || { + id: conversationItemMatch[1], + title: "Mock Conversation", + channel: "web", + }), + messages: messages.filter( + (entry) => entry.conversationId === conversationItemMatch[1], + ), + }, }); return true; } if (method === "DELETE") { + const index = conversations.findIndex( + (entry) => entry.id === conversationItemMatch[1], + ); + if (index >= 0) { + conversations.splice(index, 1); + } json(res, 200, { success: true, data: { deleted: true } }); return true; } @@ -54,17 +152,25 @@ export function handleConversations(ctx) { return true; } if (method === "GET" && /^\/messages\/?(\?.*)?$/.test(url)) { - json(res, 200, { success: true, data: [] }); + json(res, 200, { success: true, data: messages }); return true; } if (method === "POST" && /^\/messages\/?$/.test(url)) { + const created = { + ...(parsedBody || {}), + id: createMockId("msg"), + conversationId: parsedBody?.conversationId || null, + role: parsedBody?.role || "user", + text: + parsedBody?.text || + parsedBody?.content || + "Synthetic mock message created by the test harness", + createdAt: new Date().toISOString(), + }; + messages.push(created); json(res, 200, { success: true, - data: { - id: "msg_mock_" + Date.now(), - ...(parsedBody || {}), - createdAt: new Date().toISOString(), - }, + data: created, }); return true; } diff --git a/scripts/mock-api/routes/cron.mjs b/scripts/mock-api/routes/cron.mjs index 39832ab20..65f249947 100644 --- a/scripts/mock-api/routes/cron.mjs +++ b/scripts/mock-api/routes/cron.mjs @@ -1,28 +1,97 @@ import { json } from "../http.mjs"; +import { + createMockId, + fuzzyNumber, + fuzzyPick, + fuzzyTimestamp, + getMockCronJobs, + getMockWebhookTriggers, +} from "../state.mjs"; + +let fixturesSeeded = false; + +export function resetCronFixturesState() { + fixturesSeeded = false; +} + +function ensureCronFixtures() { + if (fixturesSeeded) return; + const cronJobs = getMockCronJobs(); + const webhookTriggers = getMockWebhookTriggers(); + if (cronJobs.length === 0) { + const count = fuzzyNumber("cron:count", 0, 2); + for (let i = 0; i < count; i += 1) { + cronJobs.push({ + id: createMockId("cron"), + name: fuzzyPick( + `cron:name:${i}`, + ["Morning digest", "Support sweep", "Usage snapshot"], + "Mock cron job", + ), + schedule: fuzzyPick( + `cron:schedule:${i}`, + ["0 8 * * *", "*/30 * * * *", "15 17 * * 1-5"], + "0 8 * * *", + ), + enabled: i % 2 === 0, + createdAt: fuzzyTimestamp(`cron:created:${i}`), + }); + } + } + if (webhookTriggers.length === 0) { + const count = fuzzyNumber("trg:count", 0, 2); + for (let i = 0; i < count; i += 1) { + webhookTriggers.push({ + id: createMockId("trg"), + name: fuzzyPick( + `trg:name:${i}`, + ["Github push", "Stripe paid", "CRM sync"], + "Mock trigger", + ), + enabled: true, + createdAt: fuzzyTimestamp(`trg:created:${i}`), + }); + } + } + fixturesSeeded = true; +} -// Gap fill: cron-job and webhook-trigger configuration endpoints stored on -// the user's settings document. The real backend persists arrays; mock just -// returns empty lists and accepts writes as no-ops. export function handleCron(ctx) { const { method, url, parsedBody, res } = ctx; + ensureCronFixtures(); + const cronJobs = getMockCronJobs(); + const webhookTriggers = getMockWebhookTriggers(); if (method === "GET" && /^\/settings\/cron-jobs\/?(\?.*)?$/.test(url)) { - json(res, 200, { success: true, data: [] }); + json(res, 200, { success: true, data: cronJobs }); return true; } if (method === "POST" && /^\/settings\/cron-jobs\/?$/.test(url)) { + const created = { + ...(parsedBody || {}), + id: createMockId("cron"), + createdAt: new Date().toISOString(), + }; + cronJobs.unshift(created); json(res, 200, { success: true, - data: { - id: "cron_mock_" + Date.now(), - ...(parsedBody || {}), - createdAt: new Date().toISOString(), - }, + data: created, }); return true; } const cronItem = url.match(/^\/settings\/cron-jobs\/([^/?]+)\/?(\?.*)?$/); if (cronItem && (method === "PATCH" || method === "DELETE")) { + const index = cronJobs.findIndex((entry) => entry.id === cronItem[1]); + if (index >= 0 && method === "PATCH") { + const { id: _ignoredId, ...patch } = parsedBody || {}; + cronJobs[index] = { + ...cronJobs[index], + ...patch, + }; + } + if (index >= 0 && method === "DELETE") { + cronJobs.splice(index, 1); + } json(res, 200, { success: true, data: { id: cronItem[1], deleted: method === "DELETE" }, @@ -34,17 +103,19 @@ export function handleCron(ctx) { method === "GET" && /^\/settings\/webhooks-triggers\/?(\?.*)?$/.test(url) ) { - json(res, 200, { success: true, data: [] }); + json(res, 200, { success: true, data: webhookTriggers }); return true; } if (method === "POST" && /^\/settings\/webhooks-triggers\/?$/.test(url)) { + const created = { + ...(parsedBody || {}), + id: createMockId("trg"), + createdAt: new Date().toISOString(), + }; + webhookTriggers.unshift(created); json(res, 200, { success: true, - data: { - id: "trg_mock_" + Date.now(), - ...(parsedBody || {}), - createdAt: new Date().toISOString(), - }, + data: created, }); return true; } @@ -52,6 +123,17 @@ export function handleCron(ctx) { /^\/settings\/webhooks-triggers\/([^/?]+)\/?(\?.*)?$/, ); if (trgItem && (method === "PATCH" || method === "DELETE")) { + const index = webhookTriggers.findIndex((entry) => entry.id === trgItem[1]); + if (index >= 0 && method === "PATCH") { + const { id: _ignoredId, ...patch } = parsedBody || {}; + webhookTriggers[index] = { + ...webhookTriggers[index], + ...patch, + }; + } + if (index >= 0 && method === "DELETE") { + webhookTriggers.splice(index, 1); + } json(res, 200, { success: true, data: { id: trgItem[1], deleted: method === "DELETE" }, diff --git a/scripts/mock-api/routes/integrations.mjs b/scripts/mock-api/routes/integrations.mjs index 0cc1b30d3..985649f80 100644 --- a/scripts/mock-api/routes/integrations.mjs +++ b/scripts/mock-api/routes/integrations.mjs @@ -1,5 +1,6 @@ import { json } from "../http.mjs"; import { behavior, parseBehaviorJson, setMockBehavior } from "../state.mjs"; +import { listMockLlmModels } from "./llm/shared.mjs"; export function handleIntegrations(ctx) { const { method, url, parsedBody, res } = ctx; @@ -137,7 +138,7 @@ export function handleIntegrations(ctx) { // ── OpenAI proxy ─────────────────────────────────────────── if (method === "GET" && /^\/openai\/v1\/models\/?(\?.*)?$/.test(url)) { - json(res, 200, { data: [{ id: "e2e-mock-model", object: "model" }] }); + json(res, 200, { data: listMockLlmModels() }); return true; } @@ -287,12 +288,16 @@ export function handleIntegrations(ctx) { messages: [ { id: "e2e-gmail-message-1", - snippet: "Welcome to OpenHuman. No profile link is required for this run.", + snippet: + "Welcome to OpenHuman. No profile link is required for this run.", }, ], } : { ok: true }; - json(res, 200, { success: true, data: { successful: true, data, error: null } }); + json(res, 200, { + success: true, + data: { successful: true, data, error: null }, + }); return true; } @@ -308,7 +313,11 @@ export function handleIntegrations(ctx) { } else { json(res, 200, { success: true, - data: { id: runId, status: "SUCCEEDED", finishedAt: new Date().toISOString() }, + data: { + id: runId, + status: "SUCCEEDED", + finishedAt: new Date().toISOString(), + }, }); } return true; diff --git a/scripts/mock-api/routes/llm.mjs b/scripts/mock-api/routes/llm.mjs index 489ce9026..56a0cbd74 100644 --- a/scripts/mock-api/routes/llm.mjs +++ b/scripts/mock-api/routes/llm.mjs @@ -1,11 +1,13 @@ import { json, setCors } from "../http.mjs"; -import { behavior, parseBehaviorJson, setMockBehavior } from "../state.mjs"; - -function headerValue(headers, name) { - const raw = headers?.[name]; - if (Array.isArray(raw)) return raw.join(", "); - return typeof raw === "string" ? raw : ""; -} +import { + behavior, + getMockLlmThread, + parseBehaviorJson, + recordMockLlmTurn, + setMockBehavior, +} from "../state.mjs"; +import { buildDynamicCompletion } from "./llm/dynamic.mjs"; +import { headerValue, pickProbeText, resolveThreadKey } from "./llm/shared.mjs"; function requestRuleMatches(rule, ctx) { if (!rule || typeof rule !== "object") return false; @@ -221,7 +223,14 @@ function defaultStreamScript({ content, toolCalls }) { return script; } -function handleStreamingCompletion({ res, model, mockBehavior, parsedBody, rule }) { +function handleStreamingCompletion({ + res, + model, + mockBehavior, + parsedBody, + rule, + dynamic, +}) { writeSseHead(res); // 1. Explicit streaming script overrides everything. @@ -264,17 +273,24 @@ function handleStreamingCompletion({ res, model, mockBehavior, parsedBody, rule if (!Array.isArray(script)) { // 4. Default: stream a short greeting in a few chunks. - const fallback = - typeof rule?.content === "string" && rule.content.length > 0 - ? rule.content - : typeof mockBehavior.llmFallbackContent === "string" && - mockBehavior.llmFallbackContent.length > 0 - ? mockBehavior.llmFallbackContent - : "Hello from e2e mock agent"; - script = defaultStreamScript({ - content: fallback, - toolCalls: Array.isArray(rule?.toolCalls) ? rule.toolCalls : undefined, - }); + if ( + Array.isArray(dynamic?.streamScript) && + dynamic.streamScript.length > 0 + ) { + script = dynamic.streamScript; + } else { + const fallback = + typeof rule?.content === "string" && rule.content.length > 0 + ? rule.content + : typeof mockBehavior.llmFallbackContent === "string" && + mockBehavior.llmFallbackContent.length > 0 + ? mockBehavior.llmFallbackContent + : "Hello from e2e mock agent"; + script = defaultStreamScript({ + content: fallback, + toolCalls: Array.isArray(rule?.toolCalls) ? rule.toolCalls : undefined, + }); + } } const defaultDelayMs = Number.isFinite( @@ -309,7 +325,9 @@ async function streamScriptToResponse({ res, model, script, defaultDelayMs }) { let trailingUsage = null; for (let i = 0; i < script.length; i += 1) { const entry = script[i] ?? {}; - const delay = Number.isFinite(entry.delayMs) ? entry.delayMs : defaultDelayMs; + const delay = Number.isFinite(entry.delayMs) + ? entry.delayMs + : defaultDelayMs; if (delay > 0) await sleep(delay); if (entry.error) { @@ -325,10 +343,7 @@ async function streamScriptToResponse({ res, model, script, defaultDelayMs }) { } if (typeof entry.text === "string") { - writeSseEvent( - res, - sseChunkEnvelope({ model, contentDelta: entry.text }), - ); + writeSseEvent(res, sseChunkEnvelope({ model, contentDelta: entry.text })); continue; } @@ -441,24 +456,6 @@ async function streamScriptToResponse({ res, model, script, defaultDelayMs }) { * mental model applies on both sides of the FFI. */ -function pickProbeText(parsedBody) { - if (!parsedBody || !Array.isArray(parsedBody.messages)) return ""; - for (let i = parsedBody.messages.length - 1; i >= 0; i -= 1) { - const m = parsedBody.messages[i]; - if (!m || typeof m !== "object") continue; - if (m.role === "user" || m.role === "tool") { - if (typeof m.content === "string") return m.content; - if (Array.isArray(m.content)) { - return m.content - .filter((c) => c && c.type === "text" && typeof c.text === "string") - .map((c) => c.text) - .join(" "); - } - } - } - return ""; -} - function makeChoice({ content, toolCalls, callIdSeed }) { const message = { role: "assistant", content: content ?? "" }; if (Array.isArray(toolCalls) && toolCalls.length > 0) { @@ -475,7 +472,11 @@ function makeChoice({ content, toolCalls, callIdSeed }) { })); if (!content) message.content = null; } - return { index: 0, message, finish_reason: toolCalls?.length ? "tool_calls" : "stop" }; + return { + index: 0, + message, + finish_reason: toolCalls?.length ? "tool_calls" : "stop", + }; } function buildResponse({ model, content, toolCalls }) { @@ -514,8 +515,15 @@ export function handleLlmCompletions(ctx) { const model = typeof parsedBody?.model === "string" ? parsedBody.model : "e2e-mock-model"; const requestRule = resolveRequestRule(ctx); + const threadKey = resolveThreadKey(ctx); + const thread = threadKey ? getMockLlmThread(threadKey) : null; + const dynamic = buildDynamicCompletion({ model, parsedBody, thread }); + const requestText = pickProbeText(parsedBody); - if (requestRule?.error || (requestRule?.status && requestRule.status >= 400)) { + if ( + requestRule?.error || + (requestRule?.status && requestRule.status >= 400) + ) { if ( parsedBody?.stream === true && requestRule?.error && @@ -524,8 +532,7 @@ export function handleLlmCompletions(ctx) { writeSseHead(res); writeSseEvent(res, { error: { - message: - requestRule?.error || "mock LLM streaming request rejected", + message: requestRule?.error || "mock LLM streaming request rejected", type: requestRule?.type || "invalid_request_error", code: requestRule?.code || null, }, @@ -543,17 +550,47 @@ export function handleLlmCompletions(ctx) { // attached, which is the production code path — non-streaming is // only the OH-backend fallback. See compatible.rs `chat()`. if (parsedBody?.stream === true) { + const recordTurn = (result) => { + if (!threadKey) return; + recordMockLlmTurn(threadKey, { + requestText, + responseText: result?.content ?? null, + toolCalls: result?.toolCalls ?? [], + model, + family: result?.family ?? dynamic.family, + codeLanguage: result?.codeLanguage ?? null, + }); + }; + + if ( + requestRule?.streamScript || + requestRule?.content || + requestRule?.toolCalls + ) { + recordTurn({ + content: requestRule?.content ?? "", + toolCalls: requestRule?.toolCalls ?? [], + family: dynamic.family, + }); + } else { + recordTurn(dynamic); + } return handleStreamingCompletion({ res, model, mockBehavior, parsedBody, rule: requestRule, + dynamic, }); } if (requestRule?.body && typeof requestRule.body === "object") { - json(res, Number.isInteger(requestRule.status) ? requestRule.status : 200, requestRule.body); + json( + res, + Number.isInteger(requestRule.status) ? requestRule.status : 200, + requestRule.body, + ); return true; } @@ -561,6 +598,15 @@ export function handleLlmCompletions(ctx) { Array.isArray(requestRule?.toolCalls) || typeof requestRule?.content === "string" ) { + if (threadKey) { + recordMockLlmTurn(threadKey, { + requestText, + responseText: requestRule?.content ?? "", + toolCalls: requestRule?.toolCalls ?? [], + model, + family: dynamic.family, + }); + } json( res, Number.isInteger(requestRule?.status) ? requestRule.status : 200, @@ -579,6 +625,15 @@ export function handleLlmCompletions(ctx) { const next = forced.shift(); // Persist the shrunk queue back so subsequent requests advance. setMockBehavior("llmForcedResponses", JSON.stringify(forced)); + if (threadKey) { + recordMockLlmTurn(threadKey, { + requestText, + responseText: next.content ?? "", + toolCalls: next.toolCalls ?? [], + model, + family: dynamic.family, + }); + } json(res, 200, buildResponse({ model, ...next })); return true; } @@ -590,6 +645,15 @@ export function handleLlmCompletions(ctx) { for (const rule of rules) { if (!rule || typeof rule.keyword !== "string") continue; if (probe.includes(rule.keyword.toLowerCase())) { + if (threadKey) { + recordMockLlmTurn(threadKey, { + requestText, + responseText: rule.content ?? "", + toolCalls: rule.toolCalls ?? [], + model, + family: dynamic.family, + }); + } json( res, 200, @@ -606,10 +670,30 @@ export function handleLlmCompletions(ctx) { // 3. Default fallback. const fallback = - typeof mockBehavior.llmFallbackContent === "string" && + dynamic.content || + (typeof mockBehavior.llmFallbackContent === "string" && mockBehavior.llmFallbackContent.length > 0 ? mockBehavior.llmFallbackContent - : "Hello from e2e mock agent"; - json(res, 200, buildResponse({ model, content: fallback })); + : "Hello from e2e mock agent"); + if (threadKey) { + recordMockLlmTurn(threadKey, { + requestText, + responseText: fallback, + toolCalls: dynamic.toolCalls ?? [], + toolResultText: null, + model, + family: dynamic.family, + codeLanguage: dynamic.codeLanguage ?? null, + }); + } + json( + res, + 200, + buildResponse({ + model, + content: fallback, + toolCalls: dynamic.toolCalls ?? [], + }), + ); return true; } diff --git a/scripts/mock-api/routes/llm/dynamic.mjs b/scripts/mock-api/routes/llm/dynamic.mjs new file mode 100644 index 000000000..c8861e80e --- /dev/null +++ b/scripts/mock-api/routes/llm/dynamic.mjs @@ -0,0 +1,327 @@ +import { createMockId } from "../../state.mjs"; +import { + collectMessagesByRole, + detectModelFamily, + latestRoleMessage, +} from "./shared.mjs"; + +function compactWhitespace(text) { + return String(text || "") + .replace(/\s+/g, " ") + .trim(); +} + +function sentenceParts(text) { + return compactWhitespace(text) + .split(/(?<=[.!?])\s+/) + .map((part) => part.trim()) + .filter(Boolean); +} + +function truncate(text, limit = 160) { + const compact = compactWhitespace(text); + if (compact.length <= limit) return compact; + return `${compact.slice(0, Math.max(0, limit - 1)).trimEnd()}…`; +} + +function inferLanguage(prompt, thread) { + const lower = String(prompt || "").toLowerCase(); + if (/typescript|tsx|ts\b/.test(lower)) return "ts"; + if (/javascript|node|react|js\b/.test(lower)) return "js"; + if (/python|pytest/.test(lower)) return "python"; + if (/rust|cargo/.test(lower)) return "rust"; + if (/bash|shell|zsh|sh\b/.test(lower)) return "bash"; + if (/json/.test(lower)) return "json"; + return thread?.lastCodeLanguage || "ts"; +} + +function makeCodeSnippet(language, prompt, thread) { + const lower = String(prompt || "").toLowerCase(); + if (language === "python") { + if (/async/.test(lower)) { + return [ + "import asyncio", + "", + "async def run_task(name: str) -> str:", + " await asyncio.sleep(0)", + ' return f\"done:{name}\"', + ].join("\n"); + } + if (/test/.test(lower)) { + return [ + "def test_run_task_returns_done():", + ' assert run_task_sync(\"build\") == \"done:build\"', + ].join("\n"); + } + return [ + "def run_task_sync(name: str) -> str:", + ' return f\"done:{name}\"', + ].join("\n"); + } + + if (language === "rust") { + if (/async/.test(lower)) { + return [ + "pub async fn run_task(name: &str) -> String {", + ' format!("done:{name}")', + "}", + ].join("\n"); + } + return [ + "pub fn run_task(name: &str) -> String {", + ' format!("done:{name}")', + "}", + ].join("\n"); + } + + if (language === "bash") { + return [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'name=\"${1:-build}\"', + 'printf \"done:%s\\n\" \"$name\"', + ].join("\n"); + } + + if (language === "json") { + return JSON.stringify( + { + task: "mock", + prompt: truncate(prompt, 48), + mode: /async/.test(lower) ? "async" : "sync", + }, + null, + 2, + ); + } + + const asyncKeyword = language === "ts" ? "async " : ""; + const typeSuffix = language === "ts" ? ": Promise" : ""; + const awaitLine = /async/.test(lower) ? " await Promise.resolve();\n" : ""; + const testBlock = /test/.test(lower) + ? `\nexport function runTaskTest() {\n return runTask("build");\n}\n` + : "\n"; + + return [ + `${asyncKeyword}function runTask(name${language === "ts" ? ": string" : ""})${typeSuffix} {`, + awaitLine ? awaitLine.trimEnd() : " return `done:${name}`;", + awaitLine ? " return `done:${name}`;" : "", + "}", + testBlock.trimEnd(), + ] + .filter(Boolean) + .join("\n"); +} + +function summarizeText(source, style = "balanced") { + const sentences = sentenceParts(source); + if (sentences.length === 0) return "Nothing substantial to summarize yet."; + if (style === "brief") return truncate(sentences.slice(0, 1).join(" "), 120); + if (style === "bullets") { + return sentences + .slice(0, 3) + .map((sentence) => `- ${truncate(sentence, 90)}`) + .join("\n"); + } + return truncate(sentences.slice(0, 3).join(" "), 240); +} + +function buildReasoningSummary(prompt, toolText, thread) { + const basis = toolText || prompt || thread?.lastUserMessage || "the request"; + return [ + `Recommendation: focus on ${truncate(basis, 80)}.`, + thread?.turnCount > 0 + ? "This follows the previous turn and keeps the same working context." + : "This is the first pass, so the answer stays broad and reversible.", + "If you want, I can tighten this into an implementation plan or a direct patch.", + ].join(" "); +} + +function inferToolCalls(prompt, toolDefs = []) { + const lower = String(prompt || "").toLowerCase(); + const declared = Array.isArray(toolDefs) ? toolDefs : []; + const picked = (name, args) => ({ + id: createMockId("tool"), + name, + arguments: args, + }); + + if (/search|look up|find|research/.test(lower)) { + return [ + picked(declared[0]?.function?.name || declared[0]?.name || "web_search", { + q: truncate(prompt, 120), + }), + ]; + } + if (/read file|open file|inspect file/.test(lower)) { + return [ + picked(declared[0]?.function?.name || declared[0]?.name || "fs_read", { + path: "src/example.ts", + }), + ]; + } + if (/run|shell|command|git/.test(lower)) { + return [ + picked(declared[0]?.function?.name || declared[0]?.name || "shell_exec", { + cmd: "echo mock-agentic-run", + }), + ]; + } + if (/fetch|download|http|url/.test(lower)) { + return [ + picked(declared[0]?.function?.name || declared[0]?.name || "http_fetch", { + url: "https://example.com/mock", + }), + ]; + } + + if (declared.length > 0) { + return [ + picked(declared[0]?.function?.name || declared[0]?.name || "mock_tool", { + input: truncate(prompt, 120), + }), + ]; + } + + return []; +} + +function latestToolResult(parsedBody, thread) { + const toolMessage = latestRoleMessage(parsedBody, "tool"); + return toolMessage?.normalizedContent || thread?.lastToolResult || ""; +} + +function followUpMode(prompt) { + const lower = String(prompt || "").toLowerCase(); + return { + shorter: /shorter|brief|condense|tldr|tl;dr/.test(lower), + continue: /continue|go on|next/.test(lower), + expand: /expand|more detail|elaborate/.test(lower), + async: /async/.test(lower), + tests: /test|spec/.test(lower), + }; +} + +export function buildDynamicCompletion({ model, parsedBody, thread }) { + const family = detectModelFamily({ model, parsedBody }); + const latestUser = latestRoleMessage(parsedBody, "user"); + const prompt = latestUser?.normalizedContent || ""; + const toolText = latestToolResult(parsedBody, thread); + const mode = followUpMode(prompt); + + if (family === "summarization") { + const sourceCandidates = [ + toolText, + thread?.lastAssistantContent, + ...collectMessagesByRole(parsedBody, "assistant").map( + (item) => item.normalizedContent, + ), + ...collectMessagesByRole(parsedBody, "user") + .slice(0, -1) + .map((item) => item.normalizedContent), + ].filter(Boolean); + const source = sourceCandidates.join(" "); + const content = summarizeText( + source || + prompt || + thread?.lastUserMessage || + "No prior material was provided.", + mode.shorter + ? "brief" + : /bullet|list/.test(prompt.toLowerCase()) + ? "bullets" + : "balanced", + ); + return { + family, + content, + streamScript: [{ text: content }, { finish: "stop" }], + codeLanguage: null, + }; + } + + if (family === "coding") { + const language = inferLanguage(prompt, thread); + const code = makeCodeSnippet(language, prompt, thread); + const intro = + thread?.turnCount > 0 + ? `Updated ${language.toUpperCase()} version based on the previous turn:` + : `Generated ${language.toUpperCase()} starter:`; + const content = `${intro}\n\n\`\`\`${language}\n${code}\n\`\`\``; + return { + family, + content, + streamScript: [ + { text: intro }, + { text: `\n\n\`\`\`${language}\n${code}\n\`\`\`` }, + { finish: "stop" }, + ], + codeLanguage: language, + }; + } + + if (family === "agentic") { + const toolCalls = toolText ? [] : inferToolCalls(prompt, parsedBody?.tools); + const content = toolText + ? `Tool result received. The relevant outcome is: ${truncate( + toolText, + mode.shorter ? 100 : 220, + )}${mode.expand ? " I can turn this into a fuller answer if needed." : ""}` + : "Planning tool work now."; + return { + family, + content, + toolCalls, + streamScript: + toolCalls.length > 0 + ? [ + { text: "Planning tool work now." }, + ...toolCalls.map((toolCall) => ({ toolCall })), + { finish: "tool_calls" }, + ] + : [{ text: content }, { finish: "stop" }], + codeLanguage: null, + }; + } + + if (family === "reasoning") { + const content = buildReasoningSummary(prompt, toolText, thread); + return { + family, + content, + streamScript: [ + { + thinking: `Assessing the request about ${truncate(prompt || toolText || "the current task", 72)}.`, + }, + { + thinking: + thread?.turnCount > 0 + ? "Incorporating prior turn context." + : "Choosing a safe first-pass approach.", + }, + { text: content }, + { + usage: { prompt_tokens: 18, completion_tokens: 24, total_tokens: 42 }, + }, + { finish: "stop" }, + ], + codeLanguage: null, + }; + } + + const content = + mode.shorter && thread?.lastAssistantContent + ? truncate(thread.lastAssistantContent, 120) + : thread?.turnCount > 0 && (mode.continue || mode.expand) + ? `Continuing from the previous turn: ${truncate( + thread.lastAssistantContent || toolText || prompt || "ready", + mode.expand ? 220 : 140, + )}` + : "Hello from e2e mock agent"; + return { + family, + content, + streamScript: [{ text: content }, { finish: "stop" }], + codeLanguage: null, + }; +} diff --git a/scripts/mock-api/routes/llm/shared.mjs b/scripts/mock-api/routes/llm/shared.mjs new file mode 100644 index 000000000..092e7aa52 --- /dev/null +++ b/scripts/mock-api/routes/llm/shared.mjs @@ -0,0 +1,153 @@ +import { behavior, parseBehaviorJson } from "../../state.mjs"; + +const DEFAULT_MODELS = [ + { id: "e2e-mock-model", family: "chat", owned_by: "openhuman-mock" }, + { + id: "openhuman-reasoning-mock", + family: "reasoning", + owned_by: "openhuman-mock", + }, + { id: "o3-mini-mock", family: "reasoning", owned_by: "openhuman-mock" }, + { + id: "openhuman-agentic-mock", + family: "agentic", + owned_by: "openhuman-mock", + }, + { id: "gpt-4.1-agent-mock", family: "agentic", owned_by: "openhuman-mock" }, + { id: "openhuman-coder-mock", family: "coding", owned_by: "openhuman-mock" }, + { id: "gpt-5-codex-mock", family: "coding", owned_by: "openhuman-mock" }, + { + id: "openhuman-summary-mock", + family: "summarization", + owned_by: "openhuman-mock", + }, + { + id: "memory-summarizer-mock", + family: "summarization", + owned_by: "openhuman-mock", + }, +]; + +export function listMockLlmModels() { + const configured = parseBehaviorJson("llmModelCatalog", null); + const catalog = + Array.isArray(configured) && configured.length > 0 + ? configured + : DEFAULT_MODELS; + return catalog.map((entry, index) => ({ + id: String(entry.id || `mock-model-${index + 1}`), + object: "model", + created: entry.created || 1_710_000_000, + owned_by: entry.owned_by || entry.ownedBy || "openhuman-mock", + family: + entry.family || + detectModelFamily({ + model: String(entry.id || `mock-model-${index + 1}`), + }), + })); +} + +export function headerValue(headers, name) { + const raw = headers?.[name]; + if (Array.isArray(raw)) return raw.join(", "); + return typeof raw === "string" ? raw : ""; +} + +export function pickProbeText(parsedBody) { + if (!parsedBody || !Array.isArray(parsedBody.messages)) return ""; + for (let i = parsedBody.messages.length - 1; i >= 0; i -= 1) { + const m = parsedBody.messages[i]; + if (!m || typeof m !== "object") continue; + if (m.role === "user" || m.role === "tool") { + if (typeof m.content === "string") return m.content; + if (Array.isArray(m.content)) { + return m.content + .filter((c) => c && c.type === "text" && typeof c.text === "string") + .map((c) => c.text) + .join(" "); + } + } + } + return ""; +} + +export function normalizeMessageContent(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter( + (item) => item && item.type === "text" && typeof item.text === "string", + ) + .map((item) => item.text) + .join(" "); +} + +export function collectMessagesByRole(parsedBody, role) { + if (!Array.isArray(parsedBody?.messages)) return []; + return parsedBody.messages + .filter((message) => message?.role === role) + .map((message) => ({ + ...message, + normalizedContent: normalizeMessageContent(message.content), + })); +} + +export function latestRoleMessage(parsedBody, role) { + const matches = collectMessagesByRole(parsedBody, role); + return matches[matches.length - 1] || null; +} + +export function resolveThreadKey(ctx) { + const { parsedBody, req } = ctx; + const headers = req?.headers || {}; + const body = parsedBody || {}; + return ( + headerValue(headers, "x-mock-thread-id") || + headerValue(headers, "x-thread-id") || + body.mockThreadId || + body.threadId || + body.conversationId || + body.sessionId || + body.metadata?.thread_id || + body.metadata?.conversation_id || + body.metadata?.session_id || + body.user || + null + ); +} + +function overrideFamilyForModel(model) { + const overrides = parseBehaviorJson("llmModelFamilyOverrides", []); + if (!Array.isArray(overrides)) return null; + for (const entry of overrides) { + if (!entry || typeof entry.family !== "string") continue; + if (typeof entry.model === "string" && entry.model === model) { + return entry.family; + } + if ( + typeof entry.match === "string" && + model.includes(entry.match.toLowerCase()) + ) { + return entry.family; + } + } + return null; +} + +export function detectModelFamily({ model = "", parsedBody } = {}) { + const lower = String(model || "").toLowerCase(); + const override = overrideFamilyForModel(lower); + if (override) return override; + if (/codex|coder|code|devstral|program|repair/.test(lower)) return "coding"; + if (/summary|summar|memory|brief|distill|extract/.test(lower)) { + return "summarization"; + } + if (/agent|tool|operator|workflow|computer|action/.test(lower)) { + return "agentic"; + } + if (/reason|thinking|o1|o3|o4|r1|deepseek/.test(lower)) return "reasoning"; + if (Array.isArray(parsedBody?.tools) && parsedBody.tools.length > 0) { + return "agentic"; + } + return behavior().llmDefaultFamily || "chat"; +} diff --git a/scripts/mock-api/server.mjs b/scripts/mock-api/server.mjs index 119199b24..db23d5d83 100644 --- a/scripts/mock-api/server.mjs +++ b/scripts/mock-api/server.mjs @@ -20,12 +20,16 @@ import { handlePayments } from "./routes/payments.mjs"; import { handleUser } from "./routes/user.mjs"; import { handleVersion } from "./routes/version.mjs"; import { handleWebhooks } from "./routes/webhooks.mjs"; -import { handleEnginePollingOpen, handleWebSocketUpgrade } from "./socket.mjs"; +import { handleSocketRequest, handleWebSocketUpgrade } from "./socket.mjs"; import { appendRequest, + behavior, DEFAULT_PORT, + hashString, MAX_PORT_RETRY_ATTEMPTS, openSockets, + parseBehaviorJson, + sleep, } from "./state.mjs"; let server = null; @@ -84,8 +88,11 @@ async function handleRequest(req, res) { if (handleAdmin(ctx)) return; + const maybeShortCircuit = await maybeApplyGlobalBehavior(ctx); + if (maybeShortCircuit) return; + if (url.startsWith("/socket.io/")) { - handleEnginePollingOpen(req, res); + handleSocketRequest(ctx); return; } @@ -101,6 +108,65 @@ async function handleRequest(req, res) { }); } +function requestHash(ctx) { + return hashString(`${ctx.method}:${ctx.url}:${ctx.body || ""}`); +} + +function ruleMatches(rule, ctx) { + if (!rule || typeof rule !== "object") return false; + if (rule.method && String(rule.method).toUpperCase() !== ctx.method) + return false; + if (typeof rule.path === "string" && rule.path !== ctx.url) return false; + if (typeof rule.pathRegex === "string") { + try { + const regex = new RegExp(rule.pathRegex); + if (!regex.test(ctx.url)) return false; + } catch { + return false; + } + } + if (typeof rule.contains === "string" && !ctx.url.includes(rule.contains)) { + return false; + } + return true; +} + +async function maybeApplyGlobalBehavior(ctx) { + const mockBehavior = behavior(); + const baseDelay = Number(mockBehavior.globalDelayMs || 0); + const jitterMax = Number(mockBehavior.globalJitterMs || 0); + const jitter = + Number.isFinite(jitterMax) && jitterMax > 0 + ? requestHash(ctx) % Math.min(jitterMax, 5000) + : 0; + const totalDelay = Math.max(0, baseDelay) + jitter; + if (totalDelay > 0) { + await sleep(Math.min(totalDelay, 30_000)); + } + + const rules = parseBehaviorJson("httpFaultRules", []); + if (!Array.isArray(rules)) return false; + + for (const rule of rules) { + if (!ruleMatches(rule, ctx)) continue; + const status = Number(rule.status || 500); + const body = + rule.body && typeof rule.body === "object" + ? rule.body + : { + success: false, + error: rule.error || "Injected mock fault", + }; + console.warn( + `[MockServer] Injected fault ${ctx.method} ${ctx.url} -> ${status}`, + ); + json(ctx.res, status, body); + return true; + } + + return false; +} + export function getMockServerPort() { const address = server?.address(); return typeof address === "object" && address ? address.port : null; @@ -117,7 +183,9 @@ function createServerInstance() { openSockets.add(socket); socket.on("close", () => openSockets.delete(socket)); }); - nextServer.on("upgrade", (req, socket) => handleWebSocketUpgrade(req, socket)); + nextServer.on("upgrade", (req, socket) => + handleWebSocketUpgrade(req, socket), + ); return nextServer; } diff --git a/scripts/mock-api/socket.auth.test.mjs b/scripts/mock-api/socket.auth.test.mjs new file mode 100644 index 000000000..876d7da9c --- /dev/null +++ b/scripts/mock-api/socket.auth.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + clearSocketEventLog, + disconnectMockSockets, + listSocketSessions, + resetMockBehavior, + setMockBehaviors, + startMockServer, + stopMockServer, +} from "./index.mjs"; +import { createSocket, onceSocket } from "./test-helpers/socket-client.mjs"; + +test.beforeEach(async () => { + await stopMockServer(); + resetMockBehavior(); + clearSocketEventLog(); +}); + +test.afterEach(async () => { + disconnectMockSockets(); + await stopMockServer(); +}); + +test("rejects connections that omit a required token", async () => { + const started = await startMockServer(18575, { retryIfInUse: true }); + const baseUrl = `http://127.0.0.1:${started.port}`; + + setMockBehaviors({ socketAuthMode: "required" }, "replace"); + const rejectedSocket = createSocket(baseUrl, { + auth: {}, + transports: ["polling"], + upgrade: false, + }); + + try { + const error = await onceSocket(rejectedSocket, "connect_error"); + assert.match(String(error?.message || error), /No token provided/); + assert.equal(listSocketSessions().length, 0); + } finally { + rejectedSocket.disconnect(); + } +}); diff --git a/scripts/mock-api/socket.mjs b/scripts/mock-api/socket.mjs index 5503cb25d..2a177e560 100644 --- a/scripts/mock-api/socket.mjs +++ b/scripts/mock-api/socket.mjs @@ -1,142 +1,6 @@ -import crypto from "node:crypto"; - -import { setCors } from "./http.mjs"; - -export function handleEnginePollingOpen(req, res) { - if (!req.url?.startsWith("/socket.io/")) return false; - const eioOpen = JSON.stringify({ - sid: "mock-sid-" + Date.now(), - upgrades: ["websocket"], - pingInterval: 25000, - pingTimeout: 20000, - }); - const packet = `${eioOpen.length + 1}:0${eioOpen}`; - setCors(res); - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end(packet); - return true; -} - -function handleSocketIOMessage(socket, text, sid) { - if (text === "2") { - sendWsText(socket, "3"); - return; - } - if (text.startsWith("40")) { - sendWsText(socket, `40{"sid":"${sid}"}`); - } -} - -function sendWsText(socket, text) { - sendWsFrame(socket, 0x01, Buffer.from(text, "utf-8")); -} - -function sendWsFrame(socket, opcode, payload) { - if (socket.destroyed) return; - - const len = payload.length; - let header; - if (len < 126) { - header = Buffer.alloc(2); - header[0] = 0x80 | opcode; - header[1] = len; - } else if (len < 65536) { - header = Buffer.alloc(4); - header[0] = 0x80 | opcode; - header[1] = 126; - header.writeUInt16BE(len, 2); - } else { - header = Buffer.alloc(10); - header[0] = 0x80 | opcode; - header[1] = 127; - header.writeBigUInt64BE(BigInt(len), 2); - } - try { - socket.write(header); - socket.write(payload); - } catch { - // noop - } -} - -export function handleWebSocketUpgrade(req, socket) { - if (!req.url?.startsWith("/socket.io/")) { - socket.destroy(); - return; - } - const key = req.headers["sec-websocket-key"]; - if (!key) { - socket.destroy(); - return; - } - const acceptKey = crypto - .createHash("sha1") - .update(key + "258EAFA5-E914-47DA-95CA-5AB5DC085B11") - .digest("base64"); - socket.write( - "HTTP/1.1 101 Switching Protocols\r\n" + - "Upgrade: websocket\r\n" + - "Connection: Upgrade\r\n" + - `Sec-WebSocket-Accept: ${acceptKey}\r\n` + - "\r\n", - ); - - const mockSid = "mock-ws-" + Date.now(); - const eioOpen = JSON.stringify({ - sid: mockSid, - upgrades: [], - pingInterval: 25000, - pingTimeout: 60000, - maxPayload: 1000000, - }); - sendWsText(socket, `0${eioOpen}`); - - let buffer = Buffer.alloc(0); - socket.on("data", (chunk) => { - buffer = Buffer.concat([buffer, chunk]); - while (buffer.length >= 2) { - const firstByte = buffer[0]; - const opcode = firstByte & 0x0f; - const secondByte = buffer[1]; - const masked = (secondByte & 0x80) !== 0; - let payloadLen = secondByte & 0x7f; - let offset = 2; - - if (payloadLen === 126) { - if (buffer.length < 4) return; - payloadLen = buffer.readUInt16BE(2); - offset = 4; - } else if (payloadLen === 127) { - if (buffer.length < 10) return; - payloadLen = Number(buffer.readBigUInt64BE(2)); - offset = 10; - } - - const maskSize = masked ? 4 : 0; - const totalLen = offset + maskSize + payloadLen; - if (buffer.length < totalLen) return; - let payload = buffer.subarray(offset + maskSize, totalLen); - if (masked) { - const mask = buffer.subarray(offset, offset + 4); - payload = Buffer.from(payload); - for (let i = 0; i < payload.length; i += 1) { - payload[i] ^= mask[i % 4]; - } - } - buffer = buffer.subarray(totalLen); - if (opcode === 0x08) { - socket.end(); - return; - } - if (opcode === 0x09) { - sendWsFrame(socket, 0x0a, payload); - continue; - } - if (opcode === 0x01) { - handleSocketIOMessage(socket, payload.toString("utf-8"), mockSid); - } - } - }); - socket.on("error", () => {}); - socket.on("close", () => {}); -} +export { + disconnectMockSockets, + emitMockSocketEvent, + handleSocketRequest, + handleWebSocketUpgrade, +} from "./socket/index.mjs"; diff --git a/scripts/mock-api/socket.transport.test.mjs b/scripts/mock-api/socket.transport.test.mjs new file mode 100644 index 000000000..e29b57dc0 --- /dev/null +++ b/scripts/mock-api/socket.transport.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; + +import { + clearSocketEventLog, + disconnectMockSockets, + emitMockSocketEvent, + listSocketSessions, + resetMockBehavior, + startMockServer, + stopMockServer, +} from "./index.mjs"; +import { handleWebSocketUpgrade } from "./socket.mjs"; +import { getSocketSession, registerSocketSession } from "./state.mjs"; +import { createSocket, onceSocket } from "./test-helpers/socket-client.mjs"; + +class FakeWebSocket extends EventEmitter { + constructor() { + super(); + this.destroyed = false; + this.writes = []; + } + + write(chunk) { + this.writes.push(String(chunk)); + } + + end() { + this.destroyed = true; + } + + destroy() { + this.destroyed = true; + } +} + +test.beforeEach(async () => { + await stopMockServer(); + resetMockBehavior(); + clearSocketEventLog(); +}); + +test.afterEach(async () => { + disconnectMockSockets(); + await stopMockServer(); +}); + +test("accepts a real socket.io client and delivers server-pushed events", async () => { + const started = await startMockServer(18573, { retryIfInUse: true }); + const baseUrl = `http://127.0.0.1:${started.port}`; + + const socket = createSocket(baseUrl, { + auth: { token: "mock-jwt-token" }, + transports: ["polling", "websocket"], + }); + + try { + const readyPayload = await onceSocket(socket, "ready"); + assert.equal(typeof readyPayload.sid, "string"); + assert.equal(readyPayload.userId, "mock-user"); + assert.equal(listSocketSessions().length, 1); + + const donePromise = onceSocket(socket, "chat_done"); + const delivered = emitMockSocketEvent({ + event: "chat_done", + data: { + thread_id: "thread-1", + request_id: "request-1", + full_response: "mock transport works", + rounds_used: 1, + total_input_tokens: 12, + total_output_tokens: 4, + }, + }); + assert.equal(delivered, 1); + + const donePayload = await donePromise; + assert.equal(donePayload.full_response, "mock transport works"); + assert.equal(donePayload.thread_id, "thread-1"); + } finally { + socket.disconnect(); + } +}); + +test("supports polling-only clients", async () => { + const started = await startMockServer(18574, { retryIfInUse: true }); + const baseUrl = `http://127.0.0.1:${started.port}`; + + const pollingSocket = createSocket(baseUrl, { + auth: { token: "polling-only" }, + transports: ["polling"], + upgrade: false, + }); + + try { + const readyPayload = await onceSocket(pollingSocket, "ready"); + assert.equal(readyPayload.userId, "mock-user"); + } finally { + pollingSocket.disconnect(); + } +}); + +test("keeps polling session alive when websocket probe closes before upgrade", () => { + const session = registerSocketSession({ + sid: "probe-fallback-sid", + socketId: "probe-fallback-sid", + transport: "polling", + createdAt: new Date().toISOString(), + }); + const socket = new FakeWebSocket(); + + handleWebSocketUpgrade( + { + url: `/socket.io/?transport=websocket&sid=${session.sid}`, + headers: { "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==" }, + }, + socket, + ); + + socket.emit("close"); + + const live = getSocketSession(session.sid); + assert.ok(live); + assert.equal(live.transport, "polling"); + assert.equal(live.upgradedToWebSocket, false); + assert.equal(live.webSocket, null); +}); diff --git a/scripts/mock-api/socket/core.mjs b/scripts/mock-api/socket/core.mjs new file mode 100644 index 000000000..36ac15d99 --- /dev/null +++ b/scripts/mock-api/socket/core.mjs @@ -0,0 +1,426 @@ +import { json, setCors } from "../http.mjs"; +import { + appendSocketEvent, + attachWebSocketToSession, + behavior, + buildSocketReadyPayload, + createMockId, + drainSocketPackets, + getSocketSession, + listSocketSessions, + parseBehaviorJson, + queueSocketPacket, + registerSocketSession, + touchSocketSession, + dropSocketSession, +} from "../state.mjs"; +import { + decodePollingPayload, + engineOpenPacket, + normalizeAuthPayload, + parseRequestUrl, + socketConnectErrorPacket, + socketConnectPacket, + socketEventPacket, + encodePollingPayload, +} from "./protocol.mjs"; +import { + acceptWebSocket, + decodeWebSocketFrames, + sendWsText, +} from "./websocket.mjs"; + +function socketIoSid() { + return `mock-sio-${createMockId("sid")}`; +} + +function authenticateSession(authPayload) { + const mockBehavior = behavior(); + const socketAuthMode = mockBehavior.socketAuthMode || "required"; + const token = + authPayload && typeof authPayload === "object" + ? authPayload.token + : undefined; + + if (socketAuthMode !== "disabled" && !token) { + return { ok: false, message: "No token provided" }; + } + if ( + mockBehavior.socketAuthMode === "reject" || + mockBehavior.socketReject === "true" || + String(token || "").includes("invalid") + ) { + return { + ok: false, + message: mockBehavior.socketRejectMessage || "Authentication failed", + }; + } + + return { + ok: true, + token: token || null, + userId: mockBehavior.socketUserId || mockBehavior.userId || "mock-user", + }; +} + +function logSocketCheckpoint(kind, detail = {}) { + appendSocketEvent({ direction: "system", kind, ...detail }); +} + +function writePollingResponse(res, packets) { + setCors(res); + res.writeHead(200, { "Content-Type": "text/plain; charset=UTF-8" }); + res.end(encodePollingPayload(packets)); +} + +function writePollingOk(res) { + setCors(res); + res.writeHead(200, { "Content-Type": "text/plain; charset=UTF-8" }); + res.end("ok"); +} + +function sendSocketPacket(session, packet) { + const target = getSocketSession(session.sid); + if (!target) return false; + target.lastSeenAt = new Date().toISOString(); + if ( + target.webSocket && + !target.webSocket.destroyed && + target.upgradedToWebSocket === true + ) { + sendWsText(target.webSocket, packet); + return true; + } + return queueSocketPacket(target.sid, packet); +} + +function cleanupRejectedSession(session) { + const live = getSocketSession(session.sid); + if (!live) return; + if (live.webSocket && !live.webSocket.destroyed) { + try { + live.webSocket.end?.(); + live.webSocket.destroy?.(); + } catch { + // noop + } + dropSocketSession(live.sid); + return; + } + touchSocketSession(live.sid, { disconnectAfterDrain: true }); +} + +function scheduleMockSocketActions(session, actions = []) { + for (const action of actions) { + const delayMs = Math.max(0, Number(action?.delayMs || 0)); + setTimeout(() => { + if (action?.disconnect === true) { + disconnectMockSockets({ targetSid: session.sid }); + return; + } + if (typeof action?.event === "string" && action.event) { + emitMockSocketEvent({ + event: action.event, + data: + action.data === "__ready__" + ? buildSocketReadyPayload(session) + : (action.data ?? null), + targetSid: session.sid, + }); + } + }, delayMs); + } +} + +function onSocketConnected(session) { + touchSocketSession(session.sid, { connected: true }); + sendSocketPacket(session, socketConnectPacket(session)); + sendSocketPacket( + session, + socketEventPacket("ready", buildSocketReadyPayload(session)), + ); + + logSocketCheckpoint("connected", { + sid: session.sid, + socketId: session.socketId, + userId: session.userId, + transport: session.transport, + }); + + const connectScript = parseBehaviorJson("socketConnectScript", []); + if (Array.isArray(connectScript) && connectScript.length > 0) { + scheduleMockSocketActions(session, connectScript); + } +} + +function handleClientSocketEvent(session, event, data) { + appendSocketEvent({ + direction: "inbound", + kind: "event", + sid: session.sid, + socketId: session.socketId, + userId: session.userId, + event, + data, + }); + + if (event === "webhook:response" && data?.correlationId) { + sendSocketPacket( + session, + socketEventPacket(`webhook:response:${data.correlationId}`, data), + ); + } + + const scripts = parseBehaviorJson("socketClientEventScripts", {}); + const actions = Array.isArray(scripts?.[event]) ? scripts[event] : []; + if (actions.length > 0) { + scheduleMockSocketActions(session, actions); + } +} + +function handleSocketPacket(session, packet) { + touchSocketSession(session.sid); + + if (packet === "2") { + sendSocketPacket(session, "3"); + return; + } + + if (packet === "2probe") { + sendSocketPacket(session, "3probe"); + return; + } + + if (packet === "5") { + touchSocketSession(session.sid, { + upgradedToWebSocket: true, + transport: "websocket", + }); + logSocketCheckpoint("upgrade_complete", { sid: session.sid }); + const pending = drainSocketPackets(session.sid); + const live = getSocketSession(session.sid); + if (live?.webSocket && pending.length > 0) { + for (const queued of pending) { + sendWsText(live.webSocket, queued); + } + } + return; + } + + if (packet.startsWith("40")) { + const auth = normalizeAuthPayload(packet); + const result = authenticateSession(auth); + if (!result.ok) { + sendSocketPacket(session, socketConnectErrorPacket(result.message)); + appendSocketEvent({ + direction: "outbound", + kind: "connect_error", + sid: session.sid, + message: result.message, + }); + cleanupRejectedSession(session); + return; + } + touchSocketSession(session.sid, { + token: result.token, + userId: result.userId, + }); + onSocketConnected(getSocketSession(session.sid)); + return; + } + + if (packet.startsWith("42")) { + try { + const payload = JSON.parse(packet.slice(2)); + const [event, data] = Array.isArray(payload) ? payload : []; + if (typeof event === "string" && event) { + handleClientSocketEvent(session, event, data); + } + } catch { + appendSocketEvent({ + direction: "system", + kind: "parse_error", + sid: session.sid, + packet, + }); + } + } +} + +function createSession(transport) { + const sid = socketIoSid(); + const session = registerSocketSession({ + sid, + socketId: sid, + transport, + createdAt: new Date().toISOString(), + }); + logSocketCheckpoint("session_created", { sid, transport }); + return session; +} + +function lookupSessionFromUrl(urlObj) { + const sid = urlObj.searchParams.get("sid"); + if (!sid) return null; + return getSocketSession(sid); +} + +function matchSession(session, filters = {}) { + if (filters.targetSid && session.sid !== filters.targetSid) return false; + if (filters.excludeSid && session.sid === filters.excludeSid) return false; + if (filters.targetUserId && session.userId !== filters.targetUserId) { + return false; + } + return true; +} + +export function handleSocketRequest(ctx) { + const { method, url, body, res } = ctx; + if (!url?.startsWith("/socket.io/")) return false; + + const urlObj = parseRequestUrl(url); + const transport = urlObj.searchParams.get("transport"); + if (transport !== "polling") { + json(res, 400, { + success: false, + error: "Mock socket only handles polling HTTP", + }); + return true; + } + + if (method === "GET") { + const existing = lookupSessionFromUrl(urlObj); + if (!existing) { + const session = createSession("polling"); + writePollingResponse(res, [engineOpenPacket(session.sid)]); + return true; + } + + const packets = drainSocketPackets(existing.sid); + if (existing.disconnectAfterDrain === true) { + dropSocketSession(existing.sid); + } + writePollingResponse(res, packets.length > 0 ? packets : ["6"]); + return true; + } + + if (method === "POST") { + const session = lookupSessionFromUrl(urlObj); + if (!session) { + json(res, 400, { success: false, error: "Unknown socket session" }); + return true; + } + const packets = decodePollingPayload(body); + for (const packet of packets) { + handleSocketPacket(session, packet); + } + writePollingOk(res); + return true; + } + + json(res, 405, { success: false, error: "Method not allowed" }); + return true; +} + +export function handleWebSocketUpgrade(req, socket) { + if (!req.url?.startsWith("/socket.io/")) { + socket.destroy(); + return; + } + + if (!acceptWebSocket(req, socket)) return; + + const urlObj = parseRequestUrl(req.url); + const requestedSid = urlObj.searchParams.get("sid"); + let session = requestedSid ? getSocketSession(requestedSid) : null; + + if (!session) { + session = createSession("websocket"); + attachWebSocketToSession(session.sid, socket, { + transport: "websocket", + upgraded: true, + }); + sendWsText(socket, engineOpenPacket(session.sid, [])); + } else { + attachWebSocketToSession(session.sid, socket, { upgraded: false }); + } + + logSocketCheckpoint("websocket_attached", { sid: session.sid, requestedSid }); + + decodeWebSocketFrames(socket, (packet) => + handleSocketPacket(session, packet), + ); + + socket.on("close", () => { + const live = getSocketSession(session.sid); + if (live?.upgradedToWebSocket === true) { + dropSocketSession(session.sid); + } else if (live) { + touchSocketSession(session.sid, { + webSocket: null, + transport: "polling", + }); + } + logSocketCheckpoint("websocket_closed", { sid: session.sid }); + }); + socket.on("error", () => {}); +} + +export function emitMockSocketEvent({ + event, + data, + targetSid, + targetUserId, + excludeSid, + delayMs = 0, +}) { + if (typeof event !== "string" || !event) return 0; + + const matchingSessions = listSocketSessions().filter((session) => + matchSession(session, { targetSid, targetUserId, excludeSid }), + ); + + const deliver = () => { + for (const info of matchingSessions) { + const session = getSocketSession(info.sid); + if (!session) continue; + sendSocketPacket(session, socketEventPacket(event, data)); + appendSocketEvent({ + direction: "outbound", + kind: "event", + sid: session.sid, + socketId: session.socketId, + userId: session.userId, + event, + data, + }); + } + }; + + const normalizedDelay = Math.max(0, Number(delayMs || 0)); + if (normalizedDelay > 0) { + setTimeout(deliver, normalizedDelay); + } else { + deliver(); + } + + return matchingSessions.length; +} + +export function disconnectMockSockets({ targetSid, targetUserId } = {}) { + let disconnected = 0; + for (const sessionInfo of listSocketSessions()) { + if (!matchSession(sessionInfo, { targetSid, targetUserId })) continue; + const session = getSocketSession(sessionInfo.sid); + if (!session) continue; + try { + session.webSocket?.end?.(); + session.webSocket?.destroy?.(); + } catch { + // noop + } + dropSocketSession(session.sid); + disconnected += 1; + } + return disconnected; +} diff --git a/scripts/mock-api/socket/index.mjs b/scripts/mock-api/socket/index.mjs new file mode 100644 index 000000000..8d8dc985d --- /dev/null +++ b/scripts/mock-api/socket/index.mjs @@ -0,0 +1,6 @@ +export { + disconnectMockSockets, + emitMockSocketEvent, + handleSocketRequest, + handleWebSocketUpgrade, +} from "./core.mjs"; diff --git a/scripts/mock-api/socket/protocol.mjs b/scripts/mock-api/socket/protocol.mjs new file mode 100644 index 000000000..c607ac72c --- /dev/null +++ b/scripts/mock-api/socket/protocol.mjs @@ -0,0 +1,52 @@ +export const EIO_PING_INTERVAL = 25_000; +export const EIO_PING_TIMEOUT = 20_000; +export const EIO_MAX_PAYLOAD = 1_000_000; +export const POLLING_SEPARATOR = "\x1e"; + +export function parseRequestUrl(rawUrl) { + return new URL(rawUrl || "/socket.io/", "http://127.0.0.1"); +} + +export function engineOpenPacket(sid, upgrades = ["websocket"]) { + return `0${JSON.stringify({ + sid, + upgrades, + pingInterval: EIO_PING_INTERVAL, + pingTimeout: EIO_PING_TIMEOUT, + maxPayload: EIO_MAX_PAYLOAD, + })}`; +} + +export function socketConnectPacket(session) { + return `40${JSON.stringify({ sid: session.socketId })}`; +} + +export function socketConnectErrorPacket(message) { + return `44${JSON.stringify({ message })}`; +} + +export function socketEventPacket(event, data) { + const payload = data === undefined ? [event] : [event, data]; + return `42${JSON.stringify(payload)}`; +} + +export function encodePollingPayload(packets) { + return packets.join(POLLING_SEPARATOR); +} + +export function decodePollingPayload(rawBody) { + const body = String(rawBody || ""); + if (!body) return []; + return body.split(POLLING_SEPARATOR).filter(Boolean); +} + +export function normalizeAuthPayload(packet) { + if (!packet.startsWith("40")) return null; + const payload = packet.slice(2).trim(); + if (!payload) return {}; + try { + return JSON.parse(payload); + } catch { + return {}; + } +} diff --git a/scripts/mock-api/socket/websocket.mjs b/scripts/mock-api/socket/websocket.mjs new file mode 100644 index 000000000..9546a1dc5 --- /dev/null +++ b/scripts/mock-api/socket/websocket.mjs @@ -0,0 +1,109 @@ +import crypto from "node:crypto"; + +export function sendWsFrame(socket, opcode, payload) { + if (!socket || socket.destroyed) return; + + const len = payload.length; + let header; + if (len < 126) { + header = Buffer.alloc(2); + header[0] = 0x80 | opcode; + header[1] = len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = 0x80 | opcode; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x80 | opcode; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + + try { + socket.write(header); + socket.write(payload); + } catch { + // noop + } +} + +export function sendWsText(socket, text) { + sendWsFrame(socket, 0x01, Buffer.from(text, "utf-8")); +} + +export function acceptWebSocket(req, socket) { + const key = req.headers["sec-websocket-key"]; + if (!key) { + socket.destroy(); + return false; + } + const acceptKey = crypto + .createHash("sha1") + .update(key + "258EAFA5-E914-47DA-95CA-5AB5DC085B11") + .digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${acceptKey}\r\n` + + "\r\n", + ); + return true; +} + +export function decodeWebSocketFrames(socket, onText) { + let buffer = Buffer.alloc(0); + + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 2) { + const firstByte = buffer[0]; + const opcode = firstByte & 0x0f; + const secondByte = buffer[1]; + const masked = (secondByte & 0x80) !== 0; + let payloadLen = secondByte & 0x7f; + let offset = 2; + + if (payloadLen === 126) { + if (buffer.length < 4) return; + payloadLen = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLen === 127) { + if (buffer.length < 10) return; + payloadLen = Number(buffer.readBigUInt64BE(2)); + offset = 10; + } + + const maskSize = masked ? 4 : 0; + const totalLen = offset + maskSize + payloadLen; + if (buffer.length < totalLen) return; + + let payload = buffer.subarray(offset + maskSize, totalLen); + if (masked) { + const mask = buffer.subarray(offset, offset + 4); + payload = Buffer.from(payload); + for (let i = 0; i < payload.length; i += 1) { + payload[i] ^= mask[i % 4]; + } + } + + buffer = buffer.subarray(totalLen); + + if (opcode === 0x08) { + socket.end(); + return; + } + + if (opcode === 0x09) { + sendWsFrame(socket, 0x0a, payload); + continue; + } + + if (opcode === 0x01) { + onText(payload.toString("utf-8")); + } + } + }); +} diff --git a/scripts/mock-api/state.mjs b/scripts/mock-api/state.mjs index 88a372df4..4822065a5 100644 --- a/scripts/mock-api/state.mjs +++ b/scripts/mock-api/state.mjs @@ -8,6 +8,15 @@ export const MAX_MOCK_DELAY_MS = 30_000; let requestLog = []; let mockBehavior = {}; let mockTunnels = []; +let mockConversations = []; +let mockMessages = []; +let mockCronJobs = []; +let mockWebhookTriggers = []; +let socketEventLog = []; +let mockLlmThreads = new Map(); +let nextSequence = 1; + +const socketSessions = new Map(); export const openSockets = new Set(); @@ -23,6 +32,12 @@ export function appendRequest(entry) { requestLog.push(entry); } +export function nextMockSequence() { + const value = nextSequence; + nextSequence += 1; + return value; +} + export function getMockBehavior() { return { ...mockBehavior }; } @@ -48,6 +63,53 @@ export function behavior() { return mockBehavior; } +export function parseInteger(value, fallback = 0) { + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : fallback; +} + +export function hashString(input) { + const text = String(input ?? ""); + let hash = 2166136261; + for (let i = 0; i < text.length; i += 1) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +export function behaviorSeed() { + return String(mockBehavior.seed || "openhuman-mock"); +} + +export function fuzzyNumber(label, min, max) { + const lower = Math.min(min, max); + const upper = Math.max(min, max); + if (lower === upper) return lower; + const hash = hashString(`${behaviorSeed()}:${label}`); + return lower + (hash % (upper - lower + 1)); +} + +export function fuzzyPick(label, values, fallback = null) { + if (!Array.isArray(values) || values.length === 0) return fallback; + const index = fuzzyNumber(label, 0, values.length - 1); + return values[index]; +} + +export function fuzzyTimestamp(label, spreadMs = 14 * 24 * 60 * 60 * 1000) { + const now = Date.now(); + const offset = fuzzyNumber(label, 0, spreadMs); + return new Date(now - offset).toISOString(); +} + +export function createMockId(prefix = "mock") { + return `${prefix}_${nextMockSequence()}_${hashString( + `${prefix}:${behaviorSeed()}:${Date.now()}:${Math.random()}`, + ) + .toString(16) + .slice(0, 8)}`; +} + export function parseBehaviorJson(key, fallback) { const raw = mockBehavior[key]; if (!raw) return JSON.parse(JSON.stringify(fallback)); @@ -80,6 +142,250 @@ export function resetMockTunnels() { mockTunnels = []; } +export function getMockConversations() { + return mockConversations; +} + +export function setMockConversations(next) { + mockConversations = Array.isArray(next) ? next : []; +} + +export function resetMockConversations() { + mockConversations = []; +} + +export function getMockMessages() { + return mockMessages; +} + +export function setMockMessages(next) { + mockMessages = Array.isArray(next) ? next : []; +} + +export function resetMockMessages() { + mockMessages = []; +} + +export function getMockCronJobs() { + return mockCronJobs; +} + +export function setMockCronJobs(next) { + mockCronJobs = Array.isArray(next) ? next : []; +} + +export function resetMockCronJobs() { + mockCronJobs = []; +} + +export function getMockWebhookTriggers() { + return mockWebhookTriggers; +} + +export function setMockWebhookTriggers(next) { + mockWebhookTriggers = Array.isArray(next) ? next : []; +} + +export function resetMockWebhookTriggers() { + mockWebhookTriggers = []; +} + +export function listMockLlmThreads() { + return Array.from(mockLlmThreads.values()).map((thread) => ({ + key: thread.key, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + turnCount: thread.turnCount || 0, + lastModel: thread.lastModel || null, + lastFamily: thread.lastFamily || null, + lastUserMessage: thread.lastUserMessage || null, + lastAssistantContent: thread.lastAssistantContent || null, + lastToolCallCount: Array.isArray(thread.lastToolCalls) + ? thread.lastToolCalls.length + : 0, + })); +} + +export function getMockLlmThread(key) { + return mockLlmThreads.get(String(key)) || null; +} + +export function touchMockLlmThread(key, patch = {}) { + if (!key) return null; + const threadKey = String(key); + const current = getMockLlmThread(threadKey) || { + key: threadKey, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + turnCount: 0, + history: [], + lastModel: null, + lastFamily: null, + lastUserMessage: null, + lastAssistantContent: null, + lastToolCalls: [], + lastToolResult: null, + lastCodeLanguage: null, + }; + const next = { + ...current, + ...patch, + updatedAt: new Date().toISOString(), + }; + mockLlmThreads.set(threadKey, next); + return next; +} + +export function recordMockLlmTurn(key, turn = {}) { + if (!key) return null; + const thread = touchMockLlmThread(key); + const history = Array.isArray(thread.history) ? [...thread.history] : []; + history.push({ + timestamp: new Date().toISOString(), + requestText: turn.requestText || null, + responseText: turn.responseText || null, + toolCalls: Array.isArray(turn.toolCalls) ? turn.toolCalls : [], + toolResultText: turn.toolResultText || null, + model: turn.model || null, + family: turn.family || null, + }); + const next = touchMockLlmThread(key, { + history: history.slice(-12), + turnCount: (thread.turnCount || 0) + 1, + lastModel: turn.model || thread.lastModel || null, + lastFamily: turn.family || thread.lastFamily || null, + lastUserMessage: turn.requestText || thread.lastUserMessage || null, + lastAssistantContent: + turn.responseText ?? thread.lastAssistantContent ?? null, + lastToolCalls: Array.isArray(turn.toolCalls) + ? turn.toolCalls + : (thread.lastToolCalls ?? []), + lastToolResult: turn.toolResultText ?? thread.lastToolResult ?? null, + lastCodeLanguage: turn.codeLanguage ?? thread.lastCodeLanguage ?? null, + }); + return next; +} + +export function resetMockLlmThreads() { + mockLlmThreads = new Map(); +} + +export function listSocketSessions() { + return Array.from(socketSessions.values()).map((session) => ({ + sid: session.sid, + socketId: session.socketId, + connected: session.connected === true, + upgradedToWebSocket: session.upgradedToWebSocket === true, + transport: session.transport || "polling", + userId: session.userId || null, + tokenPresent: Boolean(session.token), + createdAt: session.createdAt, + lastSeenAt: session.lastSeenAt, + outgoingQueueLength: Array.isArray(session.pendingPackets) + ? session.pendingPackets.length + : 0, + })); +} + +export function getSocketSession(sid) { + return socketSessions.get(String(sid)) || null; +} + +export function registerSocketSession(session) { + const next = { + sid: session.sid, + socketId: session.socketId || session.sid, + connected: false, + upgradedToWebSocket: false, + transport: session.transport || "polling", + token: session.token || null, + userId: session.userId || null, + createdAt: session.createdAt || new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + pendingPackets: [], + webSocket: null, + }; + socketSessions.set(next.sid, next); + return next; +} + +export function touchSocketSession(sid, patch = {}) { + const session = getSocketSession(sid); + if (!session) return null; + Object.assign(session, patch, { lastSeenAt: new Date().toISOString() }); + return session; +} + +export function attachWebSocketToSession(sid, webSocket, options = {}) { + return touchSocketSession(sid, { + webSocket, + ...(options.transport ? { transport: options.transport } : {}), + ...(typeof options.upgraded === "boolean" + ? { upgradedToWebSocket: options.upgraded } + : {}), + }); +} + +export function dropSocketSession(sid) { + const session = getSocketSession(sid); + if (!session) return; + try { + session.webSocket?.destroy?.(); + } catch { + // noop + } + socketSessions.delete(session.sid); +} + +export function queueSocketPacket(sid, packet) { + const session = getSocketSession(sid); + if (!session) return false; + session.pendingPackets.push(String(packet)); + session.lastSeenAt = new Date().toISOString(); + return true; +} + +export function drainSocketPackets(sid) { + const session = getSocketSession(sid); + if (!session) return []; + const packets = [...session.pendingPackets]; + session.pendingPackets = []; + session.lastSeenAt = new Date().toISOString(); + return packets; +} + +export function appendSocketEvent(entry) { + socketEventLog.push({ + id: createMockId("sockevt"), + timestamp: new Date().toISOString(), + ...entry, + }); +} + +export function getSocketEventLog() { + return [...socketEventLog]; +} + +export function clearSocketEventLog() { + socketEventLog = []; +} + +export function resetSocketSessions() { + for (const sid of socketSessions.keys()) { + dropSocketSession(sid); + } + socketSessions.clear(); + clearSocketEventLog(); +} + +export function buildSocketReadyPayload(session) { + return { + sid: session.socketId, + userId: session.userId || "mock-user", + transport: session.transport, + }; +} + export function createMockTunnel(payload = {}) { const now = new Date().toISOString(); return { @@ -94,14 +400,37 @@ export function createMockTunnel(payload = {}) { } export function getMockUser() { + const firstName = + mockBehavior.firstName || + fuzzyPick( + "user:first", + ["Test", "Casey", "Robin", "Jordan", "Taylor"], + "Test", + ); + const lastName = + mockBehavior.lastName || + fuzzyPick( + "user:last", + ["User", "Walker", "Lane", "Rivera", "Stone"], + "User", + ); + const username = + mockBehavior.username || + `${String(firstName).toLowerCase()}${fuzzyNumber("user:username", 10, 99)}`; return { - _id: "user-123", - telegramId: 12345678, + _id: mockBehavior.userId || "user-123", + telegramId: fuzzyNumber("user:telegramId", 10_000_000, 99_999_999), hasAccess: true, - magicWord: "alpha", - firstName: "Test", - lastName: "User", - username: "testuser", + magicWord: + mockBehavior.magicWord || + fuzzyPick( + "user:magicWord", + ["alpha", "delta", "spruce", "harbor", "ember"], + "alpha", + ), + firstName, + lastName, + username, role: "user", activeTeamId: "team-1", referral: {}, @@ -130,11 +459,18 @@ export function getMockTeam() { const plan = mockBehavior.plan || "FREE"; const isActive = mockBehavior.planActive === "true"; const expiry = mockBehavior.planExpiry || null; + const teamName = + mockBehavior.teamName || + fuzzyPick( + "team:name", + ["Personal", "Studio", "Field Ops", "Research"], + "Personal", + ); return { team: { _id: "team-1", - name: "Personal", - slug: "personal", + name: teamName, + slug: String(teamName).toLowerCase().replace(/\s+/g, "-"), createdBy: "test-user-123", isPersonal: true, maxMembers: 1, @@ -144,9 +480,9 @@ export function getMockTeam() { planExpiry: expiry, }, usage: { - dailyTokenLimit: 1000, - remainingTokens: 1000, - activeSessionCount: 0, + dailyTokenLimit: fuzzyNumber("team:dailyTokenLimit", 500, 5000), + remainingTokens: fuzzyNumber("team:remainingTokens", 100, 5000), + activeSessionCount: listSocketSessions().length, }, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/scripts/mock-api/test-helpers/socket-client.mjs b/scripts/mock-api/test-helpers/socket-client.mjs new file mode 100644 index 000000000..390c95357 --- /dev/null +++ b/scripts/mock-api/test-helpers/socket-client.mjs @@ -0,0 +1,49 @@ +import { createRequire } from "node:module"; + +const requireFromApp = createRequire( + new URL("../../../app/package.json", import.meta.url), +); + +export const { io: SocketClient } = requireFromApp("socket.io-client"); + +export function onceSocket(socket, event) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for socket event: ${event}`)); + }, 5_000); + + const onEvent = (...args) => { + cleanup(); + resolve(args[0]); + }; + + const onError = (err) => { + cleanup(); + reject(err instanceof Error ? err : new Error(String(err))); + }; + + const cleanup = () => { + clearTimeout(timeout); + socket.off(event, onEvent); + if (event !== "connect_error") { + socket.off("connect_error", onError); + } + }; + + socket.on(event, onEvent); + if (event !== "connect_error") { + socket.on("connect_error", onError); + } + }); +} + +export function createSocket(baseUrl, options = {}) { + return SocketClient(baseUrl, { + path: "/socket.io/", + reconnection: false, + forceNew: true, + timeout: 3_000, + ...options, + }); +}