|
7 | 7 | MAX_FLEET_RECORDS, |
8 | 8 | type AgentFleetDeps, |
9 | 9 | } from "./agent-fleet.js"; |
| 10 | +import { createInterruptAgentTool } from "./lifecycle-tools.js"; |
10 | 11 | import { createSubAgentSessionStore } from "./session-store.js"; |
11 | 12 | import { createPermissionGate } from "../permission/gate.js"; |
12 | 13 | import { forcedStopReport } from "./stop-policy.js"; |
@@ -374,3 +375,200 @@ describe("fleetRecords retention cap", () => { |
374 | 375 | expect(results[0]!.hint).toContain("read_agent_trace"); |
375 | 376 | }); |
376 | 377 | }); |
| 378 | + |
| 379 | +describe("spawn_agent parentage", () => { |
| 380 | + test("records the caller session as parentSessionId", async () => { |
| 381 | + const gate = deferred<RunSubAgentResult>(); |
| 382 | + const deps = makeDeps(async () => gate.promise); |
| 383 | + deps.parentSessionId = "parent-orch"; |
| 384 | + const spawn = createSpawnAgentTool(deps); |
| 385 | + |
| 386 | + const spawned = await callTool(spawn, { |
| 387 | + description: "child", |
| 388 | + prompt: "do it", |
| 389 | + intent: "explore", |
| 390 | + }); |
| 391 | + const session = deps.sessions.get(spawned.agent_id as string); |
| 392 | + expect(session?.parentSessionId).toBe("parent-orch"); |
| 393 | + |
| 394 | + gate.resolve({ report: "done" }); |
| 395 | + }); |
| 396 | +}); |
| 397 | + |
| 398 | +describe("wait_agents caller scope", () => { |
| 399 | + test("omitted targets wait only on this fleet, not every running session in the shared store", async () => { |
| 400 | + const gate = deferred<RunSubAgentResult>(); |
| 401 | + const deps = makeDeps(async () => gate.promise); |
| 402 | + const foreign = deps.sessions.start({ |
| 403 | + id: "foreign-sibling", |
| 404 | + description: "someone else's worker", |
| 405 | + agentId: "explorer", |
| 406 | + brief: "b", |
| 407 | + }); |
| 408 | + deps.sessions.markRunning(foreign.id); |
| 409 | + |
| 410 | + const spawn = createSpawnAgentTool(deps); |
| 411 | + const wait = createWaitAgentsTool({ |
| 412 | + sessions: deps.sessions, |
| 413 | + fleetRecords: deps.fleetRecords, |
| 414 | + }); |
| 415 | + const spawned = await callTool(spawn, { |
| 416 | + description: "mine", |
| 417 | + prompt: "do it", |
| 418 | + intent: "explore", |
| 419 | + }); |
| 420 | + |
| 421 | + const waited = await callTool(wait, { timeout_ms: 50 }); |
| 422 | + expect(waited.timed_out).toBe(true); |
| 423 | + const results = waited.results as { agent_id: string; status: string }[]; |
| 424 | + expect(results.map((r) => r.agent_id)).toEqual([spawned.agent_id as string]); |
| 425 | + expect(results.every((r) => r.agent_id !== foreign.id)).toBe(true); |
| 426 | + |
| 427 | + gate.resolve({ report: "done" }); |
| 428 | + }); |
| 429 | + |
| 430 | + test("mode=all stays blocked until every target is terminal", async () => { |
| 431 | + const gates = [deferred<RunSubAgentResult>(), deferred<RunSubAgentResult>()]; |
| 432 | + let callIndex = 0; |
| 433 | + const deps = makeDeps(async () => gates[callIndex++]!.promise); |
| 434 | + const spawn = createSpawnAgentTool(deps); |
| 435 | + const wait = createWaitAgentsTool({ |
| 436 | + sessions: deps.sessions, |
| 437 | + fleetRecords: deps.fleetRecords, |
| 438 | + }); |
| 439 | + |
| 440 | + const first = await callTool(spawn, { |
| 441 | + description: "a", |
| 442 | + prompt: "do it", |
| 443 | + intent: "explore", |
| 444 | + }); |
| 445 | + const second = await callTool(spawn, { |
| 446 | + description: "b", |
| 447 | + prompt: "do it", |
| 448 | + intent: "explore", |
| 449 | + }); |
| 450 | + const ids = [first.agent_id as string, second.agent_id as string]; |
| 451 | + |
| 452 | + gates[0]!.resolve({ report: "a done" }); |
| 453 | + const partial = await callTool(wait, { targets: ids, mode: "all", timeout_ms: 50 }); |
| 454 | + expect(partial.timed_out).toBe(true); |
| 455 | + const partialResults = partial.results as { status: string }[]; |
| 456 | + expect(partialResults.some((r) => r.status === "running")).toBe(true); |
| 457 | + |
| 458 | + gates[1]!.resolve({ report: "b done" }); |
| 459 | + const finished = await callTool(wait, { targets: ids, mode: "all", timeout_ms: 5000 }); |
| 460 | + expect(finished.timed_out).toBe(false); |
| 461 | + const finishedResults = finished.results as { status: string }[]; |
| 462 | + expect(finishedResults.every((r) => r.status === "done")).toBe(true); |
| 463 | + }); |
| 464 | + |
| 465 | + test("aborting the wait returns without cancelling workers", async () => { |
| 466 | + const gate = deferred<RunSubAgentResult>(); |
| 467 | + const deps = makeDeps(async () => gate.promise); |
| 468 | + const spawn = createSpawnAgentTool(deps); |
| 469 | + const wait = createWaitAgentsTool({ |
| 470 | + sessions: deps.sessions, |
| 471 | + fleetRecords: deps.fleetRecords, |
| 472 | + }); |
| 473 | + const spawned = await callTool(spawn, { |
| 474 | + description: "slow", |
| 475 | + prompt: "do it", |
| 476 | + intent: "explore", |
| 477 | + }); |
| 478 | + const id = spawned.agent_id as string; |
| 479 | + |
| 480 | + if (wait.kind !== "full") throw new Error("expected full tool"); |
| 481 | + const ac = new AbortController(); |
| 482 | + const started = Date.now(); |
| 483 | + const pending = wait.handler( |
| 484 | + { id: "wait-1", name: "wait_agents", arguments: { targets: [id], timeout_ms: 5000 } }, |
| 485 | + ac.signal, |
| 486 | + ); |
| 487 | + ac.abort(); |
| 488 | + const result = await pending; |
| 489 | + expect(Date.now() - started).toBeLessThan(500); |
| 490 | + const content = |
| 491 | + typeof result.content === "string" ? result.content : JSON.stringify(result.content); |
| 492 | + const parsed = JSON.parse(content) as { |
| 493 | + timed_out: boolean; |
| 494 | + results: { status: string }[]; |
| 495 | + }; |
| 496 | + expect(parsed.timed_out).toBe(true); |
| 497 | + expect(parsed.results[0]!.status).toBe("running"); |
| 498 | + expect(deps.sessions.get(id)?.status).toBe("running"); |
| 499 | + |
| 500 | + gate.resolve({ report: "done" }); |
| 501 | + }); |
| 502 | +}); |
| 503 | + |
| 504 | +describe("interrupt_agent unblocks wait_agents", () => { |
| 505 | + test("interrupt marks the fleet record terminal so wait returns without the run settling", async () => { |
| 506 | + const gate = deferred<RunSubAgentResult>(); |
| 507 | + const deps = makeDeps(async (params) => { |
| 508 | + params.onAgentReady?.({ |
| 509 | + close: async () => {}, |
| 510 | + interrupt: () => {}, |
| 511 | + followup: async () => "", |
| 512 | + }); |
| 513 | + return gate.promise; |
| 514 | + }); |
| 515 | + const spawn = createSpawnAgentTool(deps); |
| 516 | + const wait = createWaitAgentsTool({ |
| 517 | + sessions: deps.sessions, |
| 518 | + fleetRecords: deps.fleetRecords, |
| 519 | + }); |
| 520 | + const interrupt = createInterruptAgentTool({ |
| 521 | + sessions: deps.sessions, |
| 522 | + fleetRecords: deps.fleetRecords, |
| 523 | + }); |
| 524 | + |
| 525 | + const spawned = await callTool(spawn, { |
| 526 | + description: "looping", |
| 527 | + prompt: "do it", |
| 528 | + intent: "explore", |
| 529 | + }); |
| 530 | + const id = spawned.agent_id as string; |
| 531 | + |
| 532 | + const waiting = callTool(wait, { targets: [id], timeout_ms: 5000 }); |
| 533 | + if (interrupt.kind !== "full") throw new Error("expected full tool"); |
| 534 | + await interrupt.handler( |
| 535 | + { id: "int-1", name: "interrupt_agent", arguments: { target: id } }, |
| 536 | + new AbortController().signal, |
| 537 | + ); |
| 538 | + |
| 539 | + const waited = await waiting; |
| 540 | + expect(waited.timed_out).toBe(false); |
| 541 | + const results = waited.results as { agent_id: string; status: string }[]; |
| 542 | + expect(results).toEqual([{ agent_id: id, status: "interrupted" }]); |
| 543 | + expect(deps.sessions.get(id)?.lifecycleStatus).toBe("interrupted"); |
| 544 | + expect(deps.sessions.get(id)?.status).toBe("running"); |
| 545 | + }); |
| 546 | + |
| 547 | + test("an interrupted run result terminalizes a still-running fleet record", async () => { |
| 548 | + const settle = deferred<RunSubAgentResult>(); |
| 549 | + const deps = makeDeps(async () => settle.promise); |
| 550 | + const spawn = createSpawnAgentTool(deps); |
| 551 | + const wait = createWaitAgentsTool({ |
| 552 | + sessions: deps.sessions, |
| 553 | + fleetRecords: deps.fleetRecords, |
| 554 | + }); |
| 555 | + |
| 556 | + const spawned = await callTool(spawn, { |
| 557 | + description: "looping", |
| 558 | + prompt: "do it", |
| 559 | + intent: "explore", |
| 560 | + }); |
| 561 | + const id = spawned.agent_id as string; |
| 562 | + |
| 563 | + settle.resolve({ |
| 564 | + report: "## Summary\nStopped.\n## Findings\npartial\n## Blockers\ninterrupted\n## Paths\n", |
| 565 | + interrupted: true, |
| 566 | + }); |
| 567 | + |
| 568 | + const waited = await callTool(wait, { targets: [id], timeout_ms: 5000 }); |
| 569 | + expect(waited.timed_out).toBe(false); |
| 570 | + const results = waited.results as { status: string; report?: string }[]; |
| 571 | + expect(results[0]!.status).toBe("interrupted"); |
| 572 | + expect(results[0]!.report).toContain("partial"); |
| 573 | + }); |
| 574 | +}); |
0 commit comments