Skip to content

Commit f48c0de

Browse files
committed
patch
1 parent 547c168 commit f48c0de

3 files changed

Lines changed: 385 additions & 0 deletions

File tree

TODO.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,23 @@ callbacks: v2}) → прогон флоу → «v2 сработали, v1 мол
370370
- fetch info: throwing validateParams (проксируется в validate тула) →
371371
"Function validation failed" → placeholder (фикс №21).
372372

373+
## 18-й заход: два и более клиентов одновременно в одной сварме (+7 тестов)
374+
375+
### Новые тесты (7), сьют вырос до 218/218 — test/spec/multiclient.test.mjs
376+
Инстансы (ClientSwarm/ClientSession/ClientAgent/ClientHistory) мемоизируются по
377+
`clientId-swarmName` — клиенты полностью изолированы; общие только схемы,
378+
shared state/storage и кэш эмбеддингов. Багов не найдено, изоляция закреплена:
379+
- параллельные complete двух клиентов: попарность вход→выход у каждого своя,
380+
история одного не попадает в контекст модели другого;
381+
- навигация per-client: A ушёл на target-агента, B остался на default, следующие
382+
сообщения каждого идут своему агенту;
383+
- стресс: 10 клиентов одновременно с tool-флоу (commitToolOutput+executeForce,
384+
случайные задержки) — каждый получает ровно свой ответ;
385+
- клиентский state изолирован (A=1, B=2), shared state общий (42 у обоих);
386+
- клиентский storage изолирован, shared storage общий;
387+
- бан политики бьёт только по забаненному клиенту, сосед работает;
388+
- dispose одного клиента посреди медленного выполнения другого не ломает его.
389+
373390
## Найденные и исправленные баги (21 итого)
374391

375392
### 1. Дедлок waitForOutput при functools-kit v4 (причина 39 упавших тестов)

test/index.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import "./spec/schema-callbacks.test.mjs";
3030
import "./spec/loop.test.mjs";
3131
import "./spec/toolguard.test.mjs";
3232
import "./spec/aliastools.test.mjs";
33+
import "./spec/multiclient.test.mjs";
3334

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

test/spec/multiclient.test.mjs

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
import { test } from "worker-testbed";
2+
3+
import {
4+
addAgent,
5+
addAgentNavigation,
6+
addCompletion,
7+
addPolicy,
8+
addState,
9+
addStorage,
10+
addEmbedding,
11+
addSwarm,
12+
addTool,
13+
commitToolOutput,
14+
executeForce,
15+
getAgentName,
16+
session,
17+
setConfig,
18+
Policy,
19+
State,
20+
SharedState,
21+
Storage,
22+
SharedStorage,
23+
} from "../../build/index.mjs";
24+
import { randomString, sleep } from "functools-kit";
25+
26+
test("Will keep pairing and history isolation for parallel clients", async ({ pass, fail }) => {
27+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
28+
29+
const contexts = new Map();
30+
addCompletion({
31+
completionName: "u1-completion",
32+
getCompletion: async ({ agentName, clientId, messages }) => {
33+
await sleep(Math.floor(Math.random() * 20));
34+
contexts.set(
35+
clientId,
36+
messages.filter((m) => m.role === "user").map((m) => m.content)
37+
);
38+
const [last] = messages.slice(-1);
39+
return { agentName, content: `echo:${clientId}:${last.content}`, role: "assistant" };
40+
},
41+
});
42+
const AGENT = addAgent({ agentName: "u1-agent", completion: "u1-completion", prompt: "" });
43+
const SWARM = addSwarm({ swarmName: "u1-swarm", agentList: [AGENT], defaultAgent: AGENT });
44+
45+
const CLIENT_A = `a-${randomString()}`;
46+
const CLIENT_B = `b-${randomString()}`;
47+
const sessionA = session(CLIENT_A, SWARM);
48+
const sessionB = session(CLIENT_B, SWARM);
49+
50+
const [a1, b1] = await Promise.all([
51+
sessionA.complete("hello-from-a"),
52+
sessionB.complete("hello-from-b"),
53+
]);
54+
const [a2, b2] = await Promise.all([
55+
sessionA.complete("second-a"),
56+
sessionB.complete("second-b"),
57+
]);
58+
await sessionA.dispose();
59+
await sessionB.dispose();
60+
61+
const historyA = contexts.get(CLIENT_A) ?? [];
62+
const historyB = contexts.get(CLIENT_B) ?? [];
63+
const ok =
64+
a1 === `echo:${CLIENT_A}:hello-from-a` &&
65+
b1 === `echo:${CLIENT_B}:hello-from-b` &&
66+
a2 === `echo:${CLIENT_A}:second-a` &&
67+
b2 === `echo:${CLIENT_B}:second-b` &&
68+
historyA.every((c) => !c.includes("-b")) &&
69+
historyB.every((c) => !c.includes("-a"));
70+
if (ok) {
71+
pass();
72+
return;
73+
}
74+
fail(`a1=${a1} b1=${b1} a2=${a2} b2=${b2} historyA=${JSON.stringify(historyA)} historyB=${JSON.stringify(historyB)}`);
75+
});
76+
77+
test("Will keep navigation per-client within one swarm", async ({ pass, fail }) => {
78+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
79+
80+
addCompletion({
81+
completionName: "u2-completion",
82+
getCompletion: async ({ agentName, messages }) => {
83+
const [last] = messages.slice(-1);
84+
if (agentName === "u2-main" && last.content === "navigate") {
85+
return {
86+
agentName,
87+
content: "",
88+
role: "assistant",
89+
tool_calls: [{ function: { name: "u2-nav", arguments: {} } }],
90+
};
91+
}
92+
return { agentName, content: `echo:${agentName}:${last.content}`, role: "assistant" };
93+
},
94+
});
95+
const TARGET = addAgent({ agentName: "u2-target", completion: "u2-completion", prompt: "" });
96+
const NAV = addAgentNavigation({ toolName: "u2-nav", description: "", navigateTo: TARGET });
97+
const MAIN = addAgent({
98+
agentName: "u2-main",
99+
completion: "u2-completion",
100+
prompt: "",
101+
tools: [NAV],
102+
dependsOn: [TARGET],
103+
});
104+
const SWARM = addSwarm({ swarmName: "u2-swarm", agentList: [MAIN, TARGET], defaultAgent: MAIN });
105+
106+
const CLIENT_A = `a-${randomString()}`;
107+
const CLIENT_B = `b-${randomString()}`;
108+
const sessionA = session(CLIENT_A, SWARM);
109+
const sessionB = session(CLIENT_B, SWARM);
110+
111+
const [navResult, plainResult] = await Promise.all([
112+
sessionA.complete("navigate"),
113+
sessionB.complete("stay"),
114+
]);
115+
const activeA = await getAgentName(CLIENT_A);
116+
const activeB = await getAgentName(CLIENT_B);
117+
const afterB = await sessionB.complete("still-here");
118+
await sessionA.dispose();
119+
await sessionB.dispose();
120+
121+
const ok =
122+
navResult.startsWith("echo:u2-target:") &&
123+
plainResult === "echo:u2-main:stay" &&
124+
activeA === TARGET &&
125+
activeB === MAIN &&
126+
afterB === "echo:u2-main:still-here";
127+
if (ok) {
128+
pass();
129+
return;
130+
}
131+
fail(`navResult=${navResult} plainResult=${plainResult} activeA=${activeA} activeB=${activeB} afterB=${afterB}`);
132+
});
133+
134+
test("Will keep ten concurrent clients with tool flows paired", async ({ pass, fail }) => {
135+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
136+
137+
addCompletion({
138+
completionName: "u3-completion",
139+
getCompletion: async ({ agentName, clientId, messages }) => {
140+
await sleep(Math.floor(Math.random() * 15));
141+
const [last] = messages.slice(-1);
142+
if (last.content === "start") {
143+
return {
144+
agentName,
145+
content: "",
146+
role: "assistant",
147+
tool_calls: [{ function: { name: "u3_tool", arguments: { tag: clientId } } }],
148+
};
149+
}
150+
return { agentName, content: `done:${clientId}:${last.content}`, role: "assistant" };
151+
},
152+
});
153+
addTool({
154+
toolName: "u3_tool",
155+
validate: () => true,
156+
type: "function",
157+
function: {
158+
name: "u3_tool",
159+
description: "",
160+
parameters: { type: "object", properties: { tag: { type: "string" } }, required: [] },
161+
},
162+
call: async ({ toolId, clientId, agentName, params, isLast }) => {
163+
await sleep(Math.floor(Math.random() * 10));
164+
await commitToolOutput(toolId, `out:${params.tag}`, clientId, agentName);
165+
if (isLast) {
166+
await executeForce(`finish:${params.tag}`, clientId);
167+
}
168+
},
169+
});
170+
const AGENT = addAgent({ agentName: "u3-agent", completion: "u3-completion", prompt: "", tools: ["u3_tool"] });
171+
const SWARM = addSwarm({ swarmName: "u3-swarm", agentList: [AGENT], defaultAgent: AGENT });
172+
173+
const clients = Array.from({ length: 10 }, (_, i) => `c${i}-${randomString()}`);
174+
const sessions = clients.map((clientId) => session(clientId, SWARM));
175+
const results = await Promise.all(sessions.map((s) => s.complete("start")));
176+
await Promise.all(sessions.map((s) => s.dispose()));
177+
178+
const ok = results.every(
179+
(result, i) => result === `done:${clients[i]}:finish:${clients[i]}`
180+
);
181+
if (ok) {
182+
pass();
183+
return;
184+
}
185+
fail(`results=${JSON.stringify(results.map((r, i) => [clients[i].slice(0, 4), r.slice(0, 40)]))}`);
186+
});
187+
188+
test("Will isolate client state while sharing shared state", async ({ pass, fail }) => {
189+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
190+
191+
addCompletion({
192+
completionName: "u4-completion",
193+
getCompletion: async ({ agentName, messages }) => {
194+
const [last] = messages.slice(-1);
195+
return { agentName, content: `echo:${last.content}`, role: "assistant" };
196+
},
197+
});
198+
addState({ stateName: "u4-own", getDefaultState: () => ({ v: 0 }) });
199+
addState({ stateName: "u4-shared", shared: true, getDefaultState: () => ({ v: 0 }) });
200+
const AGENT = addAgent({
201+
agentName: "u4-agent",
202+
completion: "u4-completion",
203+
prompt: "",
204+
states: ["u4-own", "u4-shared"],
205+
});
206+
const SWARM = addSwarm({ swarmName: "u4-swarm", agentList: [AGENT], defaultAgent: AGENT });
207+
208+
const CLIENT_A = `a-${randomString()}`;
209+
const CLIENT_B = `b-${randomString()}`;
210+
const sessionA = session(CLIENT_A, SWARM);
211+
const sessionB = session(CLIENT_B, SWARM);
212+
213+
await Promise.all([
214+
State.setState(() => ({ v: 1 }), { clientId: CLIENT_A, agentName: AGENT, stateName: "u4-own" }),
215+
State.setState(() => ({ v: 2 }), { clientId: CLIENT_B, agentName: AGENT, stateName: "u4-own" }),
216+
]);
217+
await SharedState.setState(() => ({ v: 42 }), "u4-shared");
218+
219+
const ownA = await State.getState({ clientId: CLIENT_A, agentName: AGENT, stateName: "u4-own" });
220+
const ownB = await State.getState({ clientId: CLIENT_B, agentName: AGENT, stateName: "u4-own" });
221+
const sharedA = await State.getState({ clientId: CLIENT_A, agentName: AGENT, stateName: "u4-shared" });
222+
const sharedB = await State.getState({ clientId: CLIENT_B, agentName: AGENT, stateName: "u4-shared" });
223+
await sessionA.dispose();
224+
await sessionB.dispose();
225+
226+
const ok = ownA.v === 1 && ownB.v === 2 && sharedA.v === 42 && sharedB.v === 42;
227+
if (ok) {
228+
pass();
229+
return;
230+
}
231+
fail(`ownA=${JSON.stringify(ownA)} ownB=${JSON.stringify(ownB)} sharedA=${JSON.stringify(sharedA)} sharedB=${JSON.stringify(sharedB)}`);
232+
});
233+
234+
test("Will isolate client storage while sharing shared storage", async ({ pass, fail }) => {
235+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
236+
237+
addCompletion({
238+
completionName: "u5-completion",
239+
getCompletion: async ({ agentName, messages }) => {
240+
const [last] = messages.slice(-1);
241+
return { agentName, content: `echo:${last.content}`, role: "assistant" };
242+
},
243+
});
244+
addEmbedding({
245+
embeddingName: "u5-embedding",
246+
createEmbedding: async (text) => [text.length],
247+
calculateSimilarity: async (a, b) => (a[0] === b[0] ? 1 : 0),
248+
});
249+
addStorage({
250+
storageName: "u5-own",
251+
embedding: "u5-embedding",
252+
createIndex: (item) => item.text,
253+
});
254+
addStorage({
255+
storageName: "u5-shared",
256+
embedding: "u5-embedding",
257+
shared: true,
258+
createIndex: (item) => item.text,
259+
});
260+
const AGENT = addAgent({
261+
agentName: "u5-agent",
262+
completion: "u5-completion",
263+
prompt: "",
264+
storages: ["u5-own", "u5-shared"],
265+
});
266+
const SWARM = addSwarm({ swarmName: "u5-swarm", agentList: [AGENT], defaultAgent: AGENT });
267+
268+
const CLIENT_A = `a-${randomString()}`;
269+
const CLIENT_B = `b-${randomString()}`;
270+
const sessionA = session(CLIENT_A, SWARM);
271+
const sessionB = session(CLIENT_B, SWARM);
272+
273+
await Storage.upsert({ clientId: CLIENT_A, agentName: AGENT, storageName: "u5-own", item: { id: 1, text: "from-a" } });
274+
await SharedStorage.upsert({ id: 7, text: "common" }, "u5-shared");
275+
276+
const ownA = await Storage.list({ clientId: CLIENT_A, agentName: AGENT, storageName: "u5-own" });
277+
const ownB = await Storage.list({ clientId: CLIENT_B, agentName: AGENT, storageName: "u5-own" });
278+
const sharedB = await SharedStorage.list("u5-shared");
279+
await sessionA.dispose();
280+
await sessionB.dispose();
281+
282+
const ok = ownA.length === 1 && ownB.length === 0 && sharedB.length === 1 && sharedB[0].id === 7;
283+
if (ok) {
284+
pass();
285+
return;
286+
}
287+
fail(`ownA=${JSON.stringify(ownA)} ownB=${JSON.stringify(ownB)} sharedB=${JSON.stringify(sharedB)}`);
288+
});
289+
290+
test("Will apply policy ban only to the banned client", async ({ pass, fail }) => {
291+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
292+
293+
addCompletion({
294+
completionName: "u6-completion",
295+
getCompletion: async ({ agentName, messages }) => {
296+
const [last] = messages.slice(-1);
297+
return { agentName, content: `echo:${last.content}`, role: "assistant" };
298+
},
299+
});
300+
const POLICY = addPolicy({
301+
policyName: "u6-policy",
302+
persist: false,
303+
banMessage: "BLOCKED",
304+
validateInput: async () => true,
305+
});
306+
const AGENT = addAgent({ agentName: "u6-agent", completion: "u6-completion", prompt: "" });
307+
const SWARM = addSwarm({
308+
swarmName: "u6-swarm",
309+
agentList: [AGENT],
310+
defaultAgent: AGENT,
311+
policies: [POLICY],
312+
});
313+
314+
const CLIENT_A = `a-${randomString()}`;
315+
const CLIENT_B = `b-${randomString()}`;
316+
const sessionA = session(CLIENT_A, SWARM);
317+
const sessionB = session(CLIENT_B, SWARM);
318+
319+
await Policy.banClient({ clientId: CLIENT_A, swarmName: SWARM, policyName: POLICY });
320+
const [resultA, resultB] = await Promise.all([
321+
sessionA.complete("hi"),
322+
sessionB.complete("hi"),
323+
]);
324+
await sessionA.dispose();
325+
await sessionB.dispose();
326+
327+
const ok = resultA === "BLOCKED" && resultB === "echo:hi";
328+
if (ok) {
329+
pass();
330+
return;
331+
}
332+
fail(`resultA=${resultA} resultB=${resultB}`);
333+
});
334+
335+
test("Will keep one client alive when another disposes mid-flight", async ({ pass, fail }) => {
336+
setConfig({ CC_PERSIST_ENABLED_BY_DEFAULT: false });
337+
338+
addCompletion({
339+
completionName: "u7-completion",
340+
getCompletion: async ({ agentName, clientId, messages }) => {
341+
await sleep(150);
342+
const [last] = messages.slice(-1);
343+
return { agentName, content: `echo:${clientId}:${last.content}`, role: "assistant" };
344+
},
345+
});
346+
const AGENT = addAgent({ agentName: "u7-agent", completion: "u7-completion", prompt: "" });
347+
const SWARM = addSwarm({ swarmName: "u7-swarm", agentList: [AGENT], defaultAgent: AGENT });
348+
349+
const CLIENT_A = `a-${randomString()}`;
350+
const CLIENT_B = `b-${randomString()}`;
351+
const sessionA = session(CLIENT_A, SWARM);
352+
const sessionB = session(CLIENT_B, SWARM);
353+
354+
const slowB = sessionB.complete("slow");
355+
await sleep(30);
356+
await sessionA.dispose();
357+
const resultB = await slowB;
358+
await sessionB.dispose();
359+
360+
const ok = resultB === `echo:${CLIENT_B}:slow`;
361+
if (ok) {
362+
pass();
363+
return;
364+
}
365+
fail(`resultB=${resultB}`);
366+
});
367+

0 commit comments

Comments
 (0)