Skip to content

Commit ae2626b

Browse files
committed
tests
1 parent 653b552 commit ae2626b

8 files changed

Lines changed: 361 additions & 12 deletions

File tree

TODO.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,47 @@ storage, state, file-history) и умирает БЕЗ dispose (process.exit); r
624624

625625
### Новые тесты (6), сьют вырос до 271/271 — test/spec/finalguard.test.mjs
626626

627-
## Найденные и исправленные баги (27 итого)
627+
## 28-й заход: глубокая перепроверка навигации (+7 тестов, +2 бага)
628+
629+
### Баг №28: changeToPrevAgent вёл на самого себя (off-by-one стека) + ложное покрытие
630+
Файлы: `src/client/ClientSwarm.ts`, `src/functions/navigate/changeToPrevAgent.ts`,
631+
`test/spec/navigation.test.mjs`
632+
- setAgentName пушит НОВОГО агента → вершина стека всегда текущий агент;
633+
navigationPop возвращал текущего: после A→B→C «prev» пытался перейти на C
634+
(сам в себя) и падал recursion-guard'ом; реальный возврат получался только
635+
со второго вызова, с исключением на первом.
636+
- Оригинальный тест «Will navigate back to prev agent» был ЛОЖНО-зелёным: его
637+
мок навигирует только с triage-агента, второй запрос (sales→refund) ничего
638+
не делал, prev «переходил» в самого себя и ассерт case-ом совпадал.
639+
- Исправление: navigationPop отбрасывает вершину, если она равна активному
640+
агенту, и возвращает следующий элемент (или default); changeToPrevAgent
641+
больше не проверяет recursion-route (возврат назад легитимен и ограничен
642+
глубиной стека), но no-op'ится при возврате в текущего агента. Мок
643+
оригинального теста дополнен веткой sales→refund — теперь проверяет
644+
задуманный флоу.
645+
646+
### Баг №29: server-side changeToAgent посреди выполнения вешал сессию навсегда
647+
Файл: `src/client/ClientAgent.ts` (найден пробой N4)
648+
- changeToAgent диспоузит старый инстанс агента; если его выполнение ещё шло
649+
(медленный getCompletion), будущий вывод терялся: ожидатель complete() и
650+
busy-lock висли навсегда, все последующие сообщения — тоже.
651+
- Исправление: счётчик _activeExecutions (обёртка queued execute); dispose при
652+
живом выполнении инкрементит _outputEpoch (поздний результат умирающего
653+
инстанса дропается) и резолвит ожидателя пустым выводом — complete()
654+
возвращает "", busy освобождается, следующее сообщение идёт новому агенту.
655+
656+
### Новые тесты (7), сьют вырос до 278/278 — test/spec/navprev.test.mjs
657+
- полный обход стека: A→B→C, prev→B, prev→A(default), prev→no-op;
658+
- легитимная ре-навигация A↔B между отдельными сообщениями НЕ гасится;
659+
- параллельные changeToAgent сериализуются (queued per client);
660+
- смена агента посреди медленного выполнения: "" + следующий ответ от нового
661+
агента (регрессия бага №29);
662+
- changeToDefaultAgent после цепочки + prev по стеку;
663+
- навигация без dependsOn работает (warning-контракт);
664+
- navigation stack восстанавливается из persist-адаптера, prev после
665+
пересоздания сессии ведёт назад корректно.
666+
667+
## Найденные и исправленные баги (29 итого)
628668

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

src/client/ClientAgent.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,15 @@ export class ClientAgent implements IAgent {
737737
*/
738738
_activeToolChains = 0;
739739

740+
/**
741+
* Count of EXECUTE_FN executions currently in flight for this agent instance.
742+
* dispose() checks it: tearing the instance down mid-execution (e.g. a
743+
* server-side changeToAgent while the completion is still running) must
744+
* resolve the pending output waiter with an empty result — otherwise the
745+
* waiter and the session busy lock would hang forever.
746+
*/
747+
_activeExecutions = 0;
748+
740749
/**
741750
* Generation counter for output emissions. Bumped by commitCancelOutput and by
742751
* swarm-level output substitution (emit). Executions capture the epoch at start
@@ -1701,9 +1710,14 @@ export class ClientAgent implements IAgent {
17011710
* Executes the incoming message and processes tool calls if present, queued to prevent overlapping executions.
17021711
* Implements IAgent.execute, delegating to EXECUTE_FN with queuing via functools-kit’s queued decorator.
17031712
*/
1704-
execute = queued(
1705-
async (incoming, mode) => await EXECUTE_FN(incoming, mode, this)
1706-
) as IAgent["execute"];
1713+
execute = queued(async (incoming, mode) => {
1714+
this._activeExecutions += 1;
1715+
try {
1716+
return await EXECUTE_FN(incoming, mode, this);
1717+
} finally {
1718+
this._activeExecutions -= 1;
1719+
}
1720+
}) as IAgent["execute"];
17071721

17081722
/**
17091723
* Runs a stateless completion for the incoming message, queued to prevent overlapping executions.
@@ -1722,6 +1736,14 @@ export class ClientAgent implements IAgent {
17221736
this.params.logger.debug(
17231737
`ClientAgent agentName=${this.params.agentName} clientId=${this.params.clientId} dispose`
17241738
);
1739+
if (this._activeExecutions > 0) {
1740+
// The instance is being torn down while its execution is still running
1741+
// (e.g. changeToAgent mid-completion). Resolve the pending waiter with an
1742+
// empty output and invalidate the epoch so the late completion result of
1743+
// the dying instance is dropped instead of poisoning the next waiter.
1744+
this._outputEpoch += 1;
1745+
await this._outputSubject.next("");
1746+
}
17251747
{
17261748
this._agentChangeSubject.unsubscribeAll();
17271749
this._resqueSubject.unsubscribeAll();

src/client/ClientSwarm.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,15 @@ export class ClientSwarm implements ISwarm {
409409
this.params.swarmName
410410
);
411411
}
412+
// setAgentName pushes the NEW agent, so the top of the stack is the agent
413+
// we are currently on — drop it first, otherwise "previous" would resolve
414+
// to the active agent itself (self-navigation instead of going back).
415+
const activeAgent = await this.getAgentName();
416+
if (
417+
this._navigationStack[this._navigationStack.length - 1] === activeAgent
418+
) {
419+
this._navigationStack.pop();
420+
}
412421
const prevAgent = this._navigationStack.pop();
413422
await this.params.setNavigationStack(
414423
this.params.clientId,

src/functions/navigate/changeToPrevAgent.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,15 @@ const createChangeToPrevAgent = memoize(
3434
agentName: AgentName,
3535
swarmName: SwarmName
3636
) => {
37-
if (
38-
!swarm.navigationValidationService.shouldNavigate(
39-
agentName,
40-
clientId,
41-
swarmName
42-
)
43-
) {
37+
// Going back is legitimate by definition and naturally bounded by the
38+
// depth of the navigation stack, so the recursion route guard does not
39+
// apply here. Only a no-op transition to the current agent is skipped.
40+
const activeAgent = await swarm.swarmPublicService.getAgentName(
41+
METHOD_NAME,
42+
clientId,
43+
swarmName
44+
);
45+
if (agentName === activeAgent) {
4446
return false;
4547
}
4648
// Notify all agents in the swarm of the change

test/index.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import "./spec/hookguard.test.mjs";
4040
import "./spec/sweepguard.test.mjs";
4141
import "./spec/crashrecovery.test.mjs";
4242
import "./spec/finalguard.test.mjs";
43+
import "./spec/navprev.test.mjs";
4344

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

test/spec/navigation.test.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const beforeAll = () => {
5151
agentName: SALES_AGENT,
5252
completion: "mock-completion",
5353
prompt: "Tell your name to the user",
54+
tools: [NAVIGATE_TO_REFUND_TOOL],
5455
});
5556

5657
addAgent({
@@ -154,7 +155,7 @@ const beforeAll = () => {
154155
};
155156
}
156157
if (
157-
agentName === TRIAGE_AGENT &&
158+
(agentName === TRIAGE_AGENT || agentName === SALES_AGENT) &&
158159
lastMessage.content === NAVIGATE_TO_REFUND_REQUEST
159160
) {
160161
return {

0 commit comments

Comments
 (0)