diff --git a/sdks/typescript-agent-sdk/tests/mock-proxy.ts b/sdks/typescript-agent-sdk/tests/mock-proxy.ts new file mode 100644 index 00000000000..c9b4d3c9926 --- /dev/null +++ b/sdks/typescript-agent-sdk/tests/mock-proxy.ts @@ -0,0 +1,432 @@ +/** + * In-memory mock proxy for SDK tests. + * + * We don't use msw here because we need to test SSE streaming + mid-stream + * socket drops, which fetch interception libraries handle awkwardly. + * Instead we spin up a real `http.Server` on a random port and point the SDK + * at it. + */ + +import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; + +interface AgentRecord { + id: string; + name: string; + model: { id: string }; + systemPrompt?: string; + metadata: Record; + createdAt: string; + sessions: Map; +} + +interface SessionRecord { + id: string; + agentId: string; + status: "pending" | "running" | "idle" | "terminated" | "failed"; + vmId: string; + createdAt: string; + runs: Map; + followups: string[]; + conversation: { role: string; content: string; runId?: string; createdAt: string }[]; +} + +interface RunRecord { + id: string; + sessionId: string; + status: "queued" | "running" | "completed" | "failed" | "cancelled"; + result: string | null; + events: { seq: number; type: string; data: unknown }[]; + startedAt: string | null; + completedAt: string | null; + /** active SSE connections — set when /events is connected */ + sseClients: Set; +} + +export interface MockProxyOptions { + /** + * If set, the first SSE connection for a run is killed after this many ms, + * to simulate a mid-stream socket drop. Reset to `false` after the first + * drop to let reconnection succeed. + */ + killSseAfterMs?: number; + /** If true, emit a 503 with retry-after on the first request. */ + failFirstRequest?: boolean; +} + +export class MockProxy { + readonly server: http.Server; + readonly agents: Map = new Map(); + private firstRequestServed = false; + private firstStreamDropped = false; + private idCounter = 0; + options: MockProxyOptions; + + constructor(options: MockProxyOptions = {}) { + this.options = options; + this.server = http.createServer((req, res) => this.handle(req, res)); + } + + async start(): Promise { + await new Promise((resolve) => this.server.listen(0, "127.0.0.1", resolve)); + const addr = this.server.address() as AddressInfo; + return `http://127.0.0.1:${addr.port}`; + } + + async stop(): Promise { + // Force-close any open SSE clients first. + for (const agent of this.agents.values()) { + for (const session of agent.sessions.values()) { + for (const run of session.runs.values()) { + for (const c of run.sseClients) c.end(); + run.sseClients.clear(); + } + } + } + await new Promise((resolve, reject) => + this.server.close((err) => (err ? reject(err) : resolve())) + ); + } + + /** Lookup helpers exposed for tests. */ + findRun(runId: string): RunRecord | undefined { + for (const agent of this.agents.values()) { + for (const session of agent.sessions.values()) { + const run = session.runs.get(runId); + if (run) return run; + } + } + return undefined; + } + + findSession(sessionId: string): SessionRecord | undefined { + for (const agent of this.agents.values()) { + const s = agent.sessions.get(sessionId); + if (s) return s; + } + return undefined; + } + + /** Append a new event to a run, fan it out to live SSE clients. */ + emit(runId: string, type: string, data: unknown): void { + const run = this.findRun(runId); + if (!run) return; + const seq = run.events.length; + const event = { seq, type, data }; + run.events.push(event); + const payload = `id: ${seq}\ndata: ${JSON.stringify(event)}\n\n`; + for (const client of run.sseClients) { + client.write(payload); + } + } + + /** Mark a run completed and emit a `done` event. */ + complete(runId: string, result: string): void { + const run = this.findRun(runId); + if (!run) return; + run.status = "completed"; + run.result = result; + run.completedAt = new Date().toISOString(); + this.emit(runId, "done", { result }); + for (const client of run.sseClients) client.end(); + run.sseClients.clear(); + } + + private nextId(prefix: string): string { + this.idCounter++; + return `${prefix}_${this.idCounter}`; + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + if (this.options.failFirstRequest && !this.firstRequestServed) { + this.firstRequestServed = true; + res.writeHead(503, { "retry-after": "0", "content-type": "application/json" }); + res.end(JSON.stringify({ error: { code: "transient", message: "boom" } })); + return; + } + this.firstRequestServed = true; + + if (req.headers.authorization !== "Bearer test-key") { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { code: "unauthorized", message: "bad key" } })); + return; + } + + const url = new URL(req.url ?? "/", "http://localhost"); + const method = req.method ?? "GET"; + const segments = url.pathname.split("/").filter(Boolean); + + try { + if (segments[0] !== "v1") { + return notFound(res); + } + // /v1/agents + if (segments.length === 2 && segments[1] === "agents") { + if (method === "POST") return this.createAgent(req, res); + if (method === "GET") return this.listAgents(res); + } + if (segments.length === 3 && segments[1] === "agents") { + const agent = this.agents.get(segments[2]); + if (!agent) return notFound(res); + if (method === "GET") return ok(res, this.serializeAgent(agent)); + if (method === "DELETE") { + this.agents.delete(agent.id); + return ok(res, { ok: true }); + } + } + if (segments.length === 4 && segments[1] === "agents" && segments[3] === "sessions") { + const agent = this.agents.get(segments[2]); + if (!agent) return notFound(res); + if (method === "POST") return this.createSession(agent, req, res); + if (method === "GET") + return ok(res, { + items: [...agent.sessions.values()].map(serializeSession), + }); + } + if (segments.length === 5 && segments[1] === "agents" && segments[3] === "sessions") { + const agent = this.agents.get(segments[2]); + const session = agent?.sessions.get(segments[4]); + if (!session) return notFound(res); + if (method === "GET") return ok(res, serializeSession(session)); + } + // /v1/sessions/{sid}/... + if (segments[1] === "sessions" && segments.length >= 3) { + const session = this.findSession(segments[2]); + if (!session) return notFound(res); + + if (segments.length === 3) { + if (method === "DELETE") { + session.status = "terminated"; + return ok(res, { ok: true }); + } + } + if (segments.length === 4 && segments[3] === "runs" && method === "POST") { + return this.createRun(session, req, res); + } + if (segments.length === 4 && segments[3] === "runs" && method === "GET") { + return ok(res, { + items: [...session.runs.values()].map(serializeRun), + }); + } + if (segments.length === 4 && segments[3] === "followup" && method === "POST") { + const body = await readJson(req); + session.followups.push(body.message); + session.conversation.push({ + role: "user", + content: body.message, + createdAt: new Date().toISOString(), + }); + return ok(res, { ok: true }); + } + if (segments.length === 4 && segments[3] === "conversation" && method === "GET") { + return ok(res, { turns: session.conversation }); + } + if (segments.length === 5 && segments[3] === "runs") { + const run = session.runs.get(segments[4]); + if (!run) return notFound(res); + if (method === "GET") return ok(res, serializeRun(run)); + } + if (segments.length === 6 && segments[3] === "runs" && segments[5] === "events") { + const run = session.runs.get(segments[4]); + if (!run) return notFound(res); + return this.streamEvents(run, req, res, url); + } + if (segments.length === 6 && segments[3] === "runs" && segments[5] === "conversation") { + const run = session.runs.get(segments[4]); + if (!run) return notFound(res); + return ok(res, { turns: session.conversation.filter((t) => !t.runId || t.runId === run.id) }); + } + if (segments.length === 6 && segments[3] === "runs" && segments[5] === "cancel" && method === "POST") { + const run = session.runs.get(segments[4]); + if (!run) return notFound(res); + run.status = "cancelled"; + run.completedAt = new Date().toISOString(); + return ok(res, { ok: true }); + } + } + return notFound(res); + } catch (e) { + res.writeHead(500, { "content-type": "application/json" }); + res.end( + JSON.stringify({ error: { code: "internal", message: (e as Error).message } }) + ); + } + } + + private async createAgent(req: IncomingMessage, res: ServerResponse): Promise { + const body = await readJson(req); + const id = this.nextId("agt"); + const agent: AgentRecord = { + id, + name: body.name ?? "unnamed", + model: body.model ?? { id: "test-model" }, + systemPrompt: body.systemPrompt, + metadata: body.metadata ?? {}, + createdAt: new Date().toISOString(), + sessions: new Map(), + }; + this.agents.set(id, agent); + return ok(res, this.serializeAgent(agent)); + } + + private listAgents(res: ServerResponse): void { + return ok(res, { + items: [...this.agents.values()].map((a) => this.serializeAgent(a)), + }); + } + + private async createSession( + agent: AgentRecord, + req: IncomingMessage, + res: ServerResponse + ): Promise { + await readJson(req); + const id = this.nextId("ses"); + const vmId = this.nextId("vm"); + const session: SessionRecord = { + id, + agentId: agent.id, + status: "idle", + vmId, + createdAt: new Date().toISOString(), + runs: new Map(), + followups: [], + conversation: [], + }; + agent.sessions.set(id, session); + return ok(res, serializeSession(session)); + } + + private async createRun( + session: SessionRecord, + req: IncomingMessage, + res: ServerResponse + ): Promise { + const body = await readJson(req); + // 409 if any non-terminal run exists. + for (const r of session.runs.values()) { + if (r.status === "queued" || r.status === "running") { + res.writeHead(409, { "content-type": "application/json" }); + res.end( + JSON.stringify({ error: { code: "session_busy", message: "run in flight" } }) + ); + return; + } + } + const id = this.nextId("run"); + const run: RunRecord = { + id, + sessionId: session.id, + status: "queued", + result: null, + events: [], + startedAt: new Date().toISOString(), + completedAt: null, + sseClients: new Set(), + }; + session.runs.set(id, run); + session.conversation.push({ + role: "user", + content: body.text ?? "", + runId: id, + createdAt: new Date().toISOString(), + }); + return ok(res, serializeRun(run)); + } + + private streamEvents( + run: RunRecord, + req: IncomingMessage, + res: ServerResponse, + url: URL + ): void { + const startingSeq = Number(url.searchParams.get("starting_seq") ?? -1); + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + + // Replay events from startingSeq forward. + const replayFrom = Number.isFinite(startingSeq) && startingSeq >= 0 ? startingSeq : 0; + for (const event of run.events) { + if (event.seq < replayFrom) continue; + res.write(`id: ${event.seq}\ndata: ${JSON.stringify(event)}\n\n`); + } + + if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") { + res.end(); + return; + } + + run.sseClients.add(res); + const drop = !this.firstStreamDropped && this.options.killSseAfterMs; + if (drop) { + this.firstStreamDropped = true; + setTimeout(() => { + // Force-close socket without flushing any final event. + req.socket.destroy(); + run.sseClients.delete(res); + }, this.options.killSseAfterMs); + } + req.on("close", () => { + run.sseClients.delete(res); + }); + } + + private serializeAgent(agent: AgentRecord) { + return { + id: agent.id, + name: agent.name, + model: agent.model, + createdAt: agent.createdAt, + }; + } +} + +function serializeSession(s: SessionRecord) { + return { + id: s.id, + agentId: s.agentId, + status: s.status, + createdAt: s.createdAt, + }; +} + +function serializeRun(r: RunRecord) { + return { + id: r.id, + sessionId: r.sessionId, + status: r.status, + result: r.result, + startedAt: r.startedAt, + completedAt: r.completedAt, + }; +} + +function ok(res: ServerResponse, body: unknown): void { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function notFound(res: ServerResponse): void { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { code: "not_found", message: "not found" } })); +} + +function readJson(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString(); + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch (e) { + reject(e); + } + }); + req.on("error", reject); + }); +}