Skip to content

Commit db8515d

Browse files
committed
tests
1 parent 3c5c885 commit db8515d

5 files changed

Lines changed: 325 additions & 22 deletions

File tree

TODO.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,34 @@ maxToolCalls=0 (0 тулов, без зависания); keepMessages=0 (пус
698698
реентрантный getState-в-setState (+сохранение порядка); path-traversal
699699
(двухпроцессный тест: запись НЕ вылезает за пределы cwd).
700700

701-
## Найденные и исправленные баги (33 итого)
701+
## 30-й заход: крупный интеграционный тест (навигации+тулы+MCP+оператор) (+5 тестов, +2 бага)
702+
703+
### Баг №34: агент видел только ОДИН навигационный тул из нескольких
704+
Файл: `src/client/ClientAgent.ts` (_resolveTools)
705+
- Фильтр дедуплицировал navigation-тулы до одного (navigationFound). Роутер-
706+
триаж с tools:[nav-sales, nav-tech, nav-human] реально получал в completion
707+
только nav-sales; вызов nav-tech/nav-human → "No target function" → resque.
708+
Любой мультинаправленный роутер был сломан. Исправление: navigation-тулы НЕ
709+
дедуплицируются (разные направления — разные тулы); дедуп оставлен только для
710+
commit-action (одно действие за ход осмысленно), дубли по имени — seen-фильтр.
711+
712+
### Баг №35: навигация к оператору вешала сессию навсегда
713+
Файл: `src/client/ClientOperator.ts`
714+
- Навигация завершается executeForce (mode "tool"); ClientOperator.execute в
715+
tool-mode тихо возвращал (warn "should not be called") и НЕ пересылал сообщение
716+
→ waitForOutput висел вечно, оператор недостижим через навигацию.
717+
Исправление: tool-mode execute тоже пересылает сообщение оператору-человеку.
718+
719+
### Новые тесты (5), сьют вырос до 287/287 — test/spec/integration.test.mjs
720+
Одна support-сварма (триаж-роутер с 3 nav-тулами → sales[plain tool] /
721+
tech[MCP tool] / human[operator]):
722+
- навигация + plain tool (маршрут, тул, ответ);
723+
- навигация + MCP tool (mcp_diagnose вызван, ответ);
724+
- навигация к оператору (человек ответил);
725+
- полный мультихоп в ОДНОЙ сессии: chat→sales→triage→tech→triage→operator;
726+
- два клиента проходят разные ветки конкурентно и изолированно.
727+
728+
## Найденные и исправленные баги (35 итого)
702729

703730
### 1. Дедлок waitForOutput при functools-kit v4 (причина 39 упавших тестов)
704731
Файл: `src/client/ClientSwarm.ts`

src/client/ClientAgent.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,6 @@ export class ClientAgent implements IAgent {
919919
}
920920
if (mcpToolList.length) {
921921
let commitActionFound = false;
922-
let navigationFound = false;
923922
return agentToolList
924923
.concat(mcpToolList.map((tool) => mapMcpToolCall(tool, this)))
925924
.filter(({ function: { name } }) => {
@@ -935,13 +934,11 @@ export class ClientAgent implements IAgent {
935934
return aStarts === bStarts ? 0 : aStarts ? -1 : 1;
936935
})
937936
.filter((tool) => {
938-
const isNavigation = swarm.navigationSchemaService.hasTool(tool.toolName);
939-
if (isNavigation) {
940-
if (navigationFound) {
941-
return false;
942-
}
943-
navigationFound = true;
944-
}
937+
// Do NOT collapse navigation tools to a single one: a router agent
938+
// legitimately exposes several (nav-to-sales/tech/human) and the model
939+
// must be able to call any of them. Only commit-action tools are
940+
// limited to one, since running multiple actions in one turn is
941+
// ambiguous.
945942
const isCommitAction = swarm.actionSchemaService.hasTool(tool.toolName);
946943
if (isCommitAction) {
947944
if (commitActionFound) {
@@ -953,7 +950,6 @@ export class ClientAgent implements IAgent {
953950
});
954951
}
955952
let commitActionFound = false;
956-
let navigationFound = false;
957953
return agentToolList
958954
.filter(({ function: { name } }) => {
959955
if (!seen.has(name)) {
@@ -968,13 +964,8 @@ export class ClientAgent implements IAgent {
968964
return aStarts === bStarts ? 0 : aStarts ? -1 : 1;
969965
})
970966
.filter((tool) => {
971-
const isNavigation = swarm.navigationSchemaService.hasTool(tool.toolName);
972-
if (isNavigation) {
973-
if (navigationFound) {
974-
return false;
975-
}
976-
navigationFound = true;
977-
}
967+
// See the MCP branch above: navigation tools are not deduplicated —
968+
// only commit-action tools are limited to one per turn.
978969
const isCommitAction = swarm.actionSchemaService.hasTool(tool.toolName);
979970
if (isCommitAction) {
980971
if (commitActionFound) {

src/client/ClientOperator.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,15 @@ export class ClientOperator implements IAgent {
106106
{ input, mode }
107107
);
108108
if (mode === "tool") {
109-
console.warn(
110-
`ClientOperator: execute with tool mode should not be called for clientId=${this.params.clientId} agentName=${this.params.agentName}`
111-
);
109+
// A tool-mode execute reaching an operator is normal: navigation to an
110+
// operator agent ends with executeForce (mode "tool"). Forward it to the
111+
// human instead of returning silently — returning would leave the
112+
// enclosing waitForOutput hanging forever, making the operator agent
113+
// unreachable through navigation.
112114
GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&
113115
this.params.logger.debug(
114-
`ClientOperator agentName=${this.params.agentName} clientId=${this.params.clientId} execute - tool mode not supported`
116+
`ClientOperator agentName=${this.params.agentName} clientId=${this.params.clientId} execute tool mode forwarded to operator`
115117
);
116-
return;
117118
}
118119
this._operatorSignal.sendMessage(input, this._outgoingSubject.next);
119120
GLOBAL_CONFIG.CC_LOGGER_ENABLE_DEBUG &&

test/index.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import "./spec/crashrecovery.test.mjs";
4242
import "./spec/finalguard.test.mjs";
4343
import "./spec/navprev.test.mjs";
4444
import "./spec/edgecases.test.mjs";
45+
import "./spec/integration.test.mjs";
4546

4647
run(import.meta.url, () => {
4748
console.log("All tests are finished");

test/spec/integration.test.mjs

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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

Comments
 (0)