|
| 1 | +import { test } from "worker-testbed"; |
| 2 | + |
| 3 | +import { |
| 4 | + addAgent, |
| 5 | + addAgentNavigation, |
| 6 | + addCompletion, |
| 7 | + addMCP, |
| 8 | + addSwarm, |
| 9 | + addTool, |
| 10 | + changeToAgent, |
| 11 | + commitToolOutput, |
| 12 | + executeForce, |
| 13 | + getAgentName, |
| 14 | + getNavigationRoute, |
| 15 | + session, |
| 16 | + setConfig, |
| 17 | + Operator, |
| 18 | + OperatorInstance, |
| 19 | +} from "../../build/index.mjs"; |
| 20 | +import { randomString, sleep } from "functools-kit"; |
| 21 | + |
| 22 | +// One integration surface exercised end to end: a triage router with three |
| 23 | +// navigation tools hands off to a plain-tool sales agent, an MCP-tool tech |
| 24 | +// agent, or a human operator, and the conversation returns via changeToAgent. |
| 25 | +// A single if-branching mock drives every agent's completion. |
| 26 | +const buildSupportSwarm = () => { |
| 27 | + const events = []; |
| 28 | + |
| 29 | + const MOCK_COMPLETION = addCompletion({ |
| 30 | + completionName: "int-completion", |
| 31 | + getCompletion: async ({ agentName, messages }) => { |
| 32 | + const last = messages[messages.length - 1] ?? { content: "" }; |
| 33 | + const text = last.content; |
| 34 | + |
| 35 | + if (agentName === "int-triage") { |
| 36 | + if (text === "i want to buy") { |
| 37 | + return { agentName, content: "", role: "assistant", tool_calls: [{ function: { name: "nav-to-sales", arguments: {} } }] }; |
| 38 | + } |
| 39 | + if (text === "my device is broken") { |
| 40 | + return { agentName, content: "", role: "assistant", tool_calls: [{ function: { name: "nav-to-tech", arguments: {} } }] }; |
| 41 | + } |
| 42 | + if (text === "let me speak to a human") { |
| 43 | + return { agentName, content: "", role: "assistant", tool_calls: [{ function: { name: "nav-to-human", arguments: {} } }] }; |
| 44 | + } |
| 45 | + return { agentName, content: `triage:${text}`, role: "assistant" }; |
| 46 | + } |
| 47 | + |
| 48 | + if (agentName === "int-sales") { |
| 49 | + if (text === "i want to buy") { |
| 50 | + return { agentName, content: "", role: "assistant", tool_calls: [{ function: { name: "make-quote", arguments: { sku: "X1" } } }] }; |
| 51 | + } |
| 52 | + return { agentName, content: `sales:${text}`, role: "assistant" }; |
| 53 | + } |
| 54 | + |
| 55 | + if (agentName === "int-tech") { |
| 56 | + if (text === "my device is broken") { |
| 57 | + return { agentName, content: "", role: "assistant", tool_calls: [{ function: { name: "mcp_diagnose", arguments: { code: 42 } } }] }; |
| 58 | + } |
| 59 | + return { agentName, content: `tech:${text}`, role: "assistant" }; |
| 60 | + } |
| 61 | + |
| 62 | + return { agentName, content: `echo:${agentName}:${text}`, role: "assistant" }; |
| 63 | + }, |
| 64 | + }); |
| 65 | + |
| 66 | + const SALES_AGENT = addAgent({ |
| 67 | + agentName: "int-sales", |
| 68 | + completion: MOCK_COMPLETION, |
| 69 | + prompt: "You are the sales agent", |
| 70 | + tools: ["make-quote"], |
| 71 | + }); |
| 72 | + const TECH_AGENT = addAgent({ |
| 73 | + agentName: "int-tech", |
| 74 | + completion: MOCK_COMPLETION, |
| 75 | + prompt: "You are the tech agent", |
| 76 | + mcp: ["int-mcp"], |
| 77 | + }); |
| 78 | + const HUMAN_AGENT = addAgent({ |
| 79 | + agentName: "int-human", |
| 80 | + completion: MOCK_COMPLETION, |
| 81 | + operator: true, |
| 82 | + prompt: "", |
| 83 | + }); |
| 84 | + |
| 85 | + addMCP({ |
| 86 | + mcpName: "int-mcp", |
| 87 | + listTools: async () => [ |
| 88 | + { |
| 89 | + name: "mcp_diagnose", |
| 90 | + description: "Run device diagnostics", |
| 91 | + inputSchema: { type: "object", properties: { code: { type: "number" } }, required: ["code"] }, |
| 92 | + }, |
| 93 | + ], |
| 94 | + callTool: async (toolName, { toolId, clientId, params }) => { |
| 95 | + events.push(`mcp-called:${toolName}:${params.code}`); |
| 96 | + await commitToolOutput(toolId, `diagnosis-for-${params.code}`, clientId, TECH_AGENT); |
| 97 | + await executeForce("diagnosis complete", clientId); |
| 98 | + }, |
| 99 | + }); |
| 100 | + |
| 101 | + addTool({ |
| 102 | + toolName: "make-quote", |
| 103 | + validate: () => true, |
| 104 | + type: "function", |
| 105 | + function: { |
| 106 | + name: "make-quote", |
| 107 | + description: "Produce a price quote", |
| 108 | + parameters: { type: "object", properties: { sku: { type: "string" } }, required: ["sku"] }, |
| 109 | + }, |
| 110 | + call: async ({ toolId, clientId, agentName, params }) => { |
| 111 | + events.push(`quote-called:${params.sku}`); |
| 112 | + await commitToolOutput(toolId, `quote-${params.sku}-$100`, clientId, agentName); |
| 113 | + await executeForce("here is your quote", clientId); |
| 114 | + }, |
| 115 | + }); |
| 116 | + |
| 117 | + const NAV_SALES = addAgentNavigation({ toolName: "nav-to-sales", description: "Route to sales", navigateTo: SALES_AGENT }); |
| 118 | + const NAV_TECH = addAgentNavigation({ toolName: "nav-to-tech", description: "Route to tech support", navigateTo: TECH_AGENT }); |
| 119 | + const NAV_HUMAN = addAgentNavigation({ toolName: "nav-to-human", description: "Route to a human operator", navigateTo: HUMAN_AGENT }); |
| 120 | + |
| 121 | + const TRIAGE_AGENT = addAgent({ |
| 122 | + agentName: "int-triage", |
| 123 | + completion: MOCK_COMPLETION, |
| 124 | + prompt: "You are triage. Route the user.", |
| 125 | + tools: [NAV_SALES, NAV_TECH, NAV_HUMAN], |
| 126 | + dependsOn: [SALES_AGENT, TECH_AGENT, HUMAN_AGENT], |
| 127 | + }); |
| 128 | + |
| 129 | + const SUPPORT_SWARM = addSwarm({ |
| 130 | + swarmName: "int-swarm", |
| 131 | + agentList: [TRIAGE_AGENT, SALES_AGENT, TECH_AGENT, HUMAN_AGENT], |
| 132 | + defaultAgent: TRIAGE_AGENT, |
| 133 | + }); |
| 134 | + |
| 135 | + Operator.useOperatorAdapter( |
| 136 | + class extends OperatorInstance { |
| 137 | + async recieveMessage(message) { |
| 138 | + await super.recieveMessage(message); |
| 139 | + events.push(`operator-received:${message}`); |
| 140 | + setTimeout(() => this.answer(`human-says: handled "${message}"`), 15); |
| 141 | + } |
| 142 | + } |
| 143 | + ); |
| 144 | + |
| 145 | + return { events, SUPPORT_SWARM, TRIAGE_AGENT, SALES_AGENT, TECH_AGENT, HUMAN_AGENT }; |
| 146 | +}; |
| 147 | + |
| 148 | +test("Will route via navigation and call a plain tool", async ({ pass, fail }) => { |
| 149 | + setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false }); |
| 150 | + const { events, SUPPORT_SWARM, SALES_AGENT } = buildSupportSwarm(); |
| 151 | + |
| 152 | + const CLIENT_ID = randomString(); |
| 153 | + const chatSession = session(CLIENT_ID, SUPPORT_SWARM); |
| 154 | + const answer = await chatSession.complete("i want to buy"); |
| 155 | + const active = await getAgentName(CLIENT_ID); |
| 156 | + const route = [...getNavigationRoute(CLIENT_ID, SUPPORT_SWARM)]; |
| 157 | + await chatSession.dispose(); |
| 158 | + |
| 159 | + const ok = |
| 160 | + active === SALES_AGENT && |
| 161 | + route.includes(SALES_AGENT) && |
| 162 | + events.includes("quote-called:X1") && |
| 163 | + answer === "sales:here is your quote"; |
| 164 | + |
| 165 | + if (ok) { |
| 166 | + pass(); |
| 167 | + return; |
| 168 | + } |
| 169 | + fail(`active=${active} route=${JSON.stringify(route)} answer=${answer}`); |
| 170 | +}); |
| 171 | + |
| 172 | +test("Will route via navigation and call an MCP tool", async ({ pass, fail }) => { |
| 173 | + setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false }); |
| 174 | + const { events, SUPPORT_SWARM, TECH_AGENT } = buildSupportSwarm(); |
| 175 | + |
| 176 | + const CLIENT_ID = randomString(); |
| 177 | + const chatSession = session(CLIENT_ID, SUPPORT_SWARM); |
| 178 | + const answer = await chatSession.complete("my device is broken"); |
| 179 | + const active = await getAgentName(CLIENT_ID); |
| 180 | + await chatSession.dispose(); |
| 181 | + |
| 182 | + const ok = |
| 183 | + active === TECH_AGENT && |
| 184 | + events.includes("mcp-called:mcp_diagnose:42") && |
| 185 | + answer === "tech:diagnosis complete"; |
| 186 | + |
| 187 | + if (ok) { |
| 188 | + pass(); |
| 189 | + return; |
| 190 | + } |
| 191 | + fail(`active=${active} answer=${answer} events=${JSON.stringify(events.filter((e) => e.startsWith("mcp")))}`); |
| 192 | +}); |
| 193 | + |
| 194 | +test("Will route via navigation to a human operator", async ({ pass, fail }) => { |
| 195 | + setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false }); |
| 196 | + const { events, SUPPORT_SWARM, HUMAN_AGENT } = buildSupportSwarm(); |
| 197 | + |
| 198 | + const CLIENT_ID = randomString(); |
| 199 | + const chatSession = session(CLIENT_ID, SUPPORT_SWARM); |
| 200 | + const answer = await chatSession.complete("let me speak to a human"); |
| 201 | + const active = await getAgentName(CLIENT_ID); |
| 202 | + await chatSession.dispose(); |
| 203 | + await sleep(50); |
| 204 | + |
| 205 | + const ok = |
| 206 | + active === HUMAN_AGENT && |
| 207 | + events.some((e) => e.startsWith("operator-received:")) && |
| 208 | + answer.startsWith("human-says: handled"); |
| 209 | + |
| 210 | + if (ok) { |
| 211 | + pass(); |
| 212 | + return; |
| 213 | + } |
| 214 | + fail(`active=${active} answer=${answer}`); |
| 215 | +}); |
| 216 | + |
| 217 | +test("Will run a full multi-hop journey in one session", async ({ pass, fail }) => { |
| 218 | + setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false }); |
| 219 | + const { SUPPORT_SWARM, TRIAGE_AGENT, SALES_AGENT, TECH_AGENT, HUMAN_AGENT } = buildSupportSwarm(); |
| 220 | + |
| 221 | + const CLIENT_ID = randomString(); |
| 222 | + const chatSession = session(CLIENT_ID, SUPPORT_SWARM); |
| 223 | + |
| 224 | + const r1 = await chatSession.complete("hello"); |
| 225 | + const r2 = await chatSession.complete("i want to buy"); |
| 226 | + const afterSales = await getAgentName(CLIENT_ID); |
| 227 | + await changeToAgent(TRIAGE_AGENT, CLIENT_ID); |
| 228 | + const backOnTriage = await getAgentName(CLIENT_ID); |
| 229 | + const r3 = await chatSession.complete("my device is broken"); |
| 230 | + const afterTech = await getAgentName(CLIENT_ID); |
| 231 | + await changeToAgent(TRIAGE_AGENT, CLIENT_ID); |
| 232 | + const r4 = await chatSession.complete("let me speak to a human"); |
| 233 | + const afterHuman = await getAgentName(CLIENT_ID); |
| 234 | + await chatSession.dispose(); |
| 235 | + await sleep(50); |
| 236 | + |
| 237 | + const ok = |
| 238 | + r1 === "triage:hello" && |
| 239 | + afterSales === SALES_AGENT && |
| 240 | + r2 === "sales:here is your quote" && |
| 241 | + backOnTriage === TRIAGE_AGENT && |
| 242 | + afterTech === TECH_AGENT && |
| 243 | + r3 === "tech:diagnosis complete" && |
| 244 | + afterHuman === HUMAN_AGENT && |
| 245 | + r4.startsWith("human-says: handled"); |
| 246 | + |
| 247 | + if (ok) { |
| 248 | + pass(); |
| 249 | + return; |
| 250 | + } |
| 251 | + fail(`r1=${r1} afterSales=${afterSales} r2=${r2} backOnTriage=${backOnTriage} afterTech=${afterTech} r3=${r3} afterHuman=${afterHuman} r4=${r4}`); |
| 252 | +}); |
| 253 | + |
| 254 | +test("Will run two concurrent client journeys in isolation", async ({ pass, fail }) => { |
| 255 | + setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false }); |
| 256 | + const { SUPPORT_SWARM, SALES_AGENT, TECH_AGENT } = buildSupportSwarm(); |
| 257 | + |
| 258 | + const CLIENT_A = `a-${randomString()}`; |
| 259 | + const CLIENT_B = `b-${randomString()}`; |
| 260 | + const sessionA = session(CLIENT_A, SUPPORT_SWARM); |
| 261 | + const sessionB = session(CLIENT_B, SUPPORT_SWARM); |
| 262 | + |
| 263 | + const [a, b] = await Promise.all([ |
| 264 | + sessionA.complete("i want to buy"), |
| 265 | + sessionB.complete("my device is broken"), |
| 266 | + ]); |
| 267 | + const activeA = await getAgentName(CLIENT_A); |
| 268 | + const activeB = await getAgentName(CLIENT_B); |
| 269 | + await sessionA.dispose(); |
| 270 | + await sessionB.dispose(); |
| 271 | + |
| 272 | + const ok = |
| 273 | + a === "sales:here is your quote" && |
| 274 | + b === "tech:diagnosis complete" && |
| 275 | + activeA === SALES_AGENT && |
| 276 | + activeB === TECH_AGENT; |
| 277 | + |
| 278 | + if (ok) { |
| 279 | + pass(); |
| 280 | + return; |
| 281 | + } |
| 282 | + fail(`a=${a} b=${b} activeA=${activeA} activeB=${activeB}`); |
| 283 | +}); |
0 commit comments