built-scope-carrier.e2e.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. /**
  2. * Built-artifact guard for the scope carrier shared by `dsh-subagent` and
  3. * `dsh-sdk-jsonrpc-server`. The carrier registry is module-local, so both bundles must
  4. * externalize `dsh-scope`; source-mode tests cannot expose an accidentally
  5. * inlined second registry. This test runs the real `lib/index.js` bundles in a
  6. * plain Node subprocess, disposes the child before settlement, and requires the
  7. * SDK completion notification to retain the delegating parent.
  8. */
  9. import { execFile } from 'node:child_process'
  10. import { existsSync } from 'node:fs'
  11. import { fileURLToPath } from 'node:url'
  12. import { promisify } from 'node:util'
  13. import { describe, expect, it } from 'vitest'
  14. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  15. const jsonrpcBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url))
  16. const execFileAsync = promisify(execFile)
  17. const builtRuntimeProbe = String.raw`
  18. import { mkdtemp, rm } from "node:fs/promises";
  19. import { tmpdir } from "node:os";
  20. import { join, resolve } from "node:path";
  21. import { pathToFileURL } from "node:url";
  22. const load = (path) => import(pathToFileURL(resolve(path)).href);
  23. const [
  24. { Context },
  25. { default: AgentLoop },
  26. { mountAgentLoopTestDependencies },
  27. { default: SubagentRuntime },
  28. { default: JsonlSessionPersistence },
  29. { HarnessSdkJsonRpcServer },
  30. { SessionId },
  31. ] = await Promise.all([
  32. load("vendor/cordis/lib/index.js"),
  33. load("packages/core/agent-loop/lib/index.js"),
  34. load("packages/test-support/agent-loop-testkit/lib/index.js"),
  35. load("packages/subagent/subagent/lib/index.js"),
  36. load("packages/session/session-persistence-jsonl/lib/index.js"),
  37. load("packages/sdk/server/lib/index.js"),
  38. load("packages/core/session/lib/index.js"),
  39. ]);
  40. const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-"));
  41. const ctx = new Context();
  42. try {
  43. await mountAgentLoopTestDependencies(ctx);
  44. await ctx.plugin(AgentLoop, { agents: [] });
  45. await ctx.plugin(SubagentRuntime);
  46. await ctx.plugin(JsonlSessionPersistence, { root: storageRoot });
  47. await new Promise((ready) => setTimeout(ready, 50));
  48. const notifications = [];
  49. const server = new HarnessSdkJsonRpcServer(ctx, {
  50. request() { return Promise.reject(new Error("unexpected host request")); },
  51. notify(method, params) { notifications.push({ method, params }); },
  52. });
  53. const parent = await ctx.agents.create({
  54. sessionId: SessionId("built-parent"),
  55. meta: { cwd: storageRoot },
  56. agentOptions: { model: "test" },
  57. });
  58. const child = await parent.agent.ctx.agents.create({
  59. sessionId: SessionId("built-child"),
  60. meta: { cwd: storageRoot, parentSession: SessionId("built-parent") },
  61. agentOptions: { model: "test" },
  62. parentAgent: parent.agent,
  63. });
  64. const result = Promise.withResolvers();
  65. const unregister = ctx.subagents.registerProvider({
  66. name: "built-local",
  67. capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
  68. inheritsParentContext: false,
  69. start() {
  70. return Promise.resolve({
  71. id: child.agent.id,
  72. localAgent: child.agent,
  73. result: result.promise,
  74. dispose() { return Promise.resolve(); },
  75. });
  76. },
  77. });
  78. const run = await ctx.subagents.start("built-local", {
  79. parent: parent.agent,
  80. prompt: [],
  81. signal: new AbortController().signal,
  82. });
  83. await child.dispose();
  84. result.resolve({ output: [], stopReason: "completed" });
  85. await run.result;
  86. await Promise.resolve();
  87. console.log(JSON.stringify(notifications.filter(({ method }) => method === "subagent.finished")));
  88. await run.dispose();
  89. unregister();
  90. await parent.dispose();
  91. await server.shutdown();
  92. } finally {
  93. await ctx.fiber.dispose();
  94. await rm(storageRoot, { recursive: true, force: true });
  95. }
  96. `
  97. describe.skipIf(!existsSync(jsonrpcBundle))('dsh-sdk-jsonrpc-server BUILT scope carrier', () => {
  98. it('preserves parent-scoped completion after child disposal', async () => {
  99. const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', builtRuntimeProbe], {
  100. cwd: repoRoot,
  101. timeout: 15_000,
  102. })
  103. expect(stderr).not.toContain('listener threw')
  104. // A result without output omits lastAssistantMessage from the wire; it
  105. // never sends `[]`.
  106. expect(JSON.parse(stdout) as unknown).toEqual([{
  107. method: 'subagent.finished',
  108. params: {
  109. provider: 'built-local',
  110. agentId: 'built-child',
  111. parentSessionId: 'built-parent',
  112. childSessionId: 'built-child',
  113. status: 'ok',
  114. stopReason: 'completed',
  115. },
  116. }])
  117. })
  118. })