macro-agent 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/agent/agent-manager-v2.d.ts.map +1 -1
  2. package/dist/agent/agent-manager-v2.js +182 -43
  3. package/dist/agent/agent-manager-v2.js.map +1 -1
  4. package/dist/agent/agent-manager.d.ts +17 -0
  5. package/dist/agent/agent-manager.d.ts.map +1 -1
  6. package/dist/agent/agent-manager.js.map +1 -1
  7. package/dist/agent/types.d.ts +21 -2
  8. package/dist/agent/types.d.ts.map +1 -1
  9. package/dist/agent/types.js.map +1 -1
  10. package/dist/cli/acp.js +0 -0
  11. package/dist/cli/index.js +0 -0
  12. package/dist/cli/mcp.js +0 -0
  13. package/dist/cognitive/macro-agent-backend.d.ts.map +1 -1
  14. package/dist/cognitive/macro-agent-backend.js +37 -8
  15. package/dist/cognitive/macro-agent-backend.js.map +1 -1
  16. package/dist/cognitive/types.d.ts +24 -0
  17. package/dist/cognitive/types.d.ts.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/map/mail-bridge.d.ts +7 -0
  22. package/dist/map/mail-bridge.d.ts.map +1 -1
  23. package/dist/map/mail-bridge.js +39 -9
  24. package/dist/map/mail-bridge.js.map +1 -1
  25. package/dist/map/sidecar.d.ts.map +1 -1
  26. package/dist/map/sidecar.js +19 -4
  27. package/dist/map/sidecar.js.map +1 -1
  28. package/dist/teams/team-runtime-v2.d.ts.map +1 -1
  29. package/dist/teams/team-runtime-v2.js +23 -6
  30. package/dist/teams/team-runtime-v2.js.map +1 -1
  31. package/package.json +4 -2
  32. package/renovate.json5 +6 -0
  33. package/src/agent/__tests__/agent-manager-v2.completion-signal.test.ts +284 -0
  34. package/src/agent/agent-manager-v2.ts +216 -50
  35. package/src/agent/agent-manager.ts +17 -0
  36. package/src/agent/types.ts +23 -2
  37. package/src/cognitive/__tests__/macro-agent-backend.test.ts +158 -5
  38. package/src/cognitive/macro-agent-backend.ts +39 -7
  39. package/src/cognitive/types.ts +24 -0
  40. package/src/index.ts +2 -0
  41. package/src/map/__tests__/mail-bridge.test.ts +39 -2
  42. package/src/map/__tests__/sidecar-diff-install-smoke.test.ts +17 -2
  43. package/src/map/mail-bridge.ts +61 -13
  44. package/src/map/sidecar.ts +21 -4
  45. package/src/teams/team-runtime-v2.ts +29 -6
  46. package/dist/workspace/dataplane-adapter.d.ts +0 -260
  47. package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
  48. package/dist/workspace/dataplane-adapter.js +0 -416
  49. package/dist/workspace/dataplane-adapter.js.map +0 -1
@@ -48,7 +48,7 @@ import type {
48
48
  AgentLifecycleEvent,
49
49
  AgentConfig,
50
50
  ContinueAgentOptions,
51
- MCPServerConfig,
51
+ MCPServerStdioConfig,
52
52
  } from "./types.js";
53
53
  import { AgentManagerError } from "./types.js";
54
54
  import type { RoleRegistry, Capability } from "../roles/types.js";
@@ -220,6 +220,12 @@ export function createAgentManagerV2(
220
220
  const activeSessions = new Map<AgentId, ActiveSession>();
221
221
  const agentWorkspaces = new Map<AgentId, Workspace>();
222
222
  const lifecycleListeners = new Set<AgentLifecycleCallback>();
223
+ // In-flight fire-and-forget terminate() promises (e.g. the promptUntilDone
224
+ // completion poller tears the agent down without awaiting it, to keep
225
+ // completion latency low). close() awaits these so shutdown cannot return
226
+ // while a group-kill is still racing — which would let process.exit orphan
227
+ // the subtree.
228
+ const pendingTerminations = new Set<Promise<unknown>>();
223
229
  let spawnInterceptor: SpawnInterceptor | null = null;
224
230
  let isShuttingDown = false;
225
231
  let mapServerUrl: string | undefined;
@@ -285,7 +291,7 @@ export function createAgentManagerV2(
285
291
  lineage?: string[];
286
292
  sessionId?: string;
287
293
  streamId?: string;
288
- }): MCPServerConfig {
294
+ }): MCPServerStdioConfig {
289
295
  const env: Record<string, string> = {
290
296
  MACRO_AGENT_ID: opts.agentId,
291
297
  MACRO_PARENT_ID: opts.parentId,
@@ -759,15 +765,28 @@ export function createAgentManagerV2(
759
765
  value: v,
760
766
  })),
761
767
  },
762
- ...(agentConfig?.mcpServers?.map((s) => ({
763
- name: s.name,
764
- command: s.command,
765
- args: s.args ?? [],
766
- env: Object.entries(s.env ?? {}).map(([k, v]) => ({
767
- name: k,
768
- value: v,
769
- })),
770
- })) ?? []),
768
+ ...(agentConfig?.mcpServers?.map((s) => {
769
+ if ("command" in s) {
770
+ return {
771
+ name: s.name,
772
+ command: s.command,
773
+ args: s.args ?? [],
774
+ env: Object.entries(s.env ?? {}).map(([k, v]) => ({
775
+ name: k,
776
+ value: v,
777
+ })),
778
+ };
779
+ }
780
+ return {
781
+ name: s.name,
782
+ type: s.type,
783
+ url: s.url,
784
+ headers: Object.entries(s.headers ?? {}).map(([k, v]) => ({
785
+ name: k,
786
+ value: v,
787
+ })),
788
+ };
789
+ }) ?? []),
771
790
  ];
772
791
 
773
792
  // Always-on subsystem MCP servers (the "trinity"). The macro-agent
@@ -1753,63 +1772,189 @@ export function createAgentManagerV2(
1753
1772
  options?: {
1754
1773
  maxFollowUps?: number;
1755
1774
  onUpdate?: (update: ExtendedSessionUpdate) => void;
1775
+ /**
1776
+ * Optional caller-supplied completion predicate. When it returns true the
1777
+ * loop stops consuming, terminates the agent as "completed", and returns
1778
+ * `completedExternally: true` — WITHOUT requiring the macro `done()` tool.
1779
+ *
1780
+ * Checked three ways (all fire terminate + return completedExternally):
1781
+ * (a) reactively after each streamed update (fast path),
1782
+ * (b) reactively after each attempt's stream ends (fast path),
1783
+ * (c) on a CONCURRENT ~2s timer while consuming the stream. (c) is the
1784
+ * critical path for the live τ rollout: the agent runs the whole
1785
+ * episode in ONE prompt() call, calls τ's finish, the env writes its
1786
+ * reward sink (done:true), and then the prompt() stream goes SILENT
1787
+ * and does NOT close — the async iterator just blocks. With only the
1788
+ * reactive checks (a)/(b) the predicate is never re-evaluated and
1789
+ * solve() hangs until the rollout backstop. The concurrent poller
1790
+ * observes done:true independent of update arrival, flips
1791
+ * completedExternally, and terminate()s the agent — which ends the
1792
+ * in-flight prompt() generator (via return or throw, both handled).
1793
+ *
1794
+ * Generic by design: this module has NO knowledge of what the predicate
1795
+ * reads (e.g. an external reward sink). Back-compat: when absent, NO poller
1796
+ * is started and behavior is unchanged — only `done()` detection drives
1797
+ * completion.
1798
+ *
1799
+ * @param pollIntervalMs Override the concurrent poll cadence (default
1800
+ * 2000ms). Primarily a test seam.
1801
+ */
1802
+ isComplete?: () => boolean | Promise<boolean>;
1803
+ pollIntervalMs?: number;
1756
1804
  }
1757
1805
  ): Promise<{
1758
1806
  doneCalled: boolean;
1759
1807
  doneStatus?: string;
1808
+ completedExternally: boolean;
1760
1809
  updates: ExtendedSessionUpdate[];
1761
1810
  }> {
1762
1811
  const maxFollowUps = options?.maxFollowUps ?? 2;
1812
+ const pollIntervalMs = options?.pollIntervalMs ?? 2000;
1763
1813
  const allUpdates: ExtendedSessionUpdate[] = [];
1764
1814
  let doneCalled = false;
1765
1815
  let doneStatus: string | undefined;
1816
+ let completedExternally = false;
1766
1817
 
1767
1818
  let currentMessage = message;
1768
1819
 
1769
1820
  for (let attempt = 0; attempt <= maxFollowUps; attempt++) {
1770
- for await (const update of prompt(agentId, currentMessage)) {
1771
- allUpdates.push(update);
1772
- options?.onUpdate?.(update);
1821
+ // ── Concurrent completion poller ─────────────────────────────────────
1822
+ // Only armed when the caller supplies a predicate (back-compat: no
1823
+ // predicate → no poller, identical behavior). The live failure mode is a
1824
+ // prompt() stream that has gone SILENT but not CLOSED after the episode
1825
+ // externally completed; the reactive per-update check never fires again
1826
+ // because no updates arrive. This timer re-evaluates isComplete()
1827
+ // independent of update arrival. On true it flips completedExternally and
1828
+ // terminate()s the agent, which ends the in-flight prompt() generator
1829
+ // (return or throw) so the for-await below unblocks. The interval is
1830
+ // always cleared in `finally` so no timer leaks across attempts.
1831
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
1832
+ let polling = false;
1833
+
1834
+ // Resolves the instant completion is detected (external predicate OR the
1835
+ // macro done() tool). The attempt is RACED against this signal so it
1836
+ // returns WITHOUT waiting for the prompt() generator to drain or for
1837
+ // terminate() to finish. The prior design relied on terminate() ending
1838
+ // the silent in-flight stream to unblock the for-await; when that teardown
1839
+ // stalled (e.g. a long, busy real episode whose subprocess is slow to die),
1840
+ // promptUntilDone never returned and the macro session stayed "running"
1841
+ // until the multi-hour hard-timer — the refinement "finalization hang".
1842
+ let signalDone: () => void = () => {};
1843
+ const doneSignal = new Promise<void>((resolve) => {
1844
+ signalDone = resolve;
1845
+ });
1773
1846
 
1774
- // Detect done() tool call from session updates.
1775
- // acp-factory uses { sessionUpdate: "tool_call", title: "mcp__macro-agent__done" }
1776
- const uAny = update as any;
1847
+ const startPoller = () => {
1848
+ if (!options?.isComplete) return;
1849
+ pollTimer = setInterval(() => {
1850
+ if (polling || completedExternally) return;
1851
+ polling = true;
1852
+ void (async () => {
1853
+ try {
1854
+ if (!completedExternally && options.isComplete && (await options.isComplete())) {
1855
+ completedExternally = true;
1856
+ signalDone();
1857
+ // Tear down the agent/session in the BACKGROUND — never await it
1858
+ // here, so a slow teardown cannot gate the return. Tracked in
1859
+ // pendingTerminations so close() awaits it before shutdown
1860
+ // returns (else a still-racing group-kill is abandoned by a
1861
+ // subsequent process.exit, orphaning the subtree). Best effort.
1862
+ {
1863
+ const t = terminate(agentId, "completed" as any).catch(() => {});
1864
+ pendingTerminations.add(t);
1865
+ void t.finally(() => pendingTerminations.delete(t));
1866
+ }
1867
+ }
1868
+ } catch {
1869
+ // Predicate errored — leave completion to the reactive checks.
1870
+ } finally {
1871
+ polling = false;
1872
+ }
1873
+ })();
1874
+ }, pollIntervalMs);
1875
+ };
1777
1876
 
1778
- // Check title field (primary detection)
1779
- if (
1780
- (uAny.sessionUpdate === "tool_call" || uAny.sessionUpdate === "tool_call_update") &&
1781
- typeof uAny.title === "string" &&
1782
- uAny.title.endsWith("__done")
1783
- ) {
1784
- doneCalled = true;
1785
- // Extract status from rawInput (may arrive across multiple updates —
1786
- // first update has rawInput={}, subsequent has full input)
1787
- try {
1788
- const raw = uAny.rawInput;
1789
- const input =
1790
- typeof raw === "string" ? JSON.parse(raw) :
1791
- typeof raw === "object" ? raw :
1792
- uAny.input;
1793
- if (input?.status) {
1794
- doneStatus = input.status;
1877
+ // Consume the stream as a background task so a silent-but-open generator
1878
+ // can be raced against the completion signal below.
1879
+ const consume = (async () => {
1880
+ for await (const update of prompt(agentId, currentMessage)) {
1881
+ allUpdates.push(update);
1882
+ options?.onUpdate?.(update);
1883
+
1884
+ // External completion predicate (reactive fast path).
1885
+ if (options?.isComplete && (await options.isComplete())) {
1886
+ completedExternally = true;
1887
+ signalDone();
1888
+ break;
1889
+ }
1890
+ if (completedExternally) break;
1891
+
1892
+ // Detect done() tool call from session updates.
1893
+ // acp-factory uses { sessionUpdate: "tool_call", title: "mcp__macro-agent__done" }
1894
+ const uAny = update as any;
1895
+
1896
+ // Check title field (primary detection)
1897
+ if (
1898
+ (uAny.sessionUpdate === "tool_call" || uAny.sessionUpdate === "tool_call_update") &&
1899
+ typeof uAny.title === "string" &&
1900
+ uAny.title.endsWith("__done")
1901
+ ) {
1902
+ doneCalled = true;
1903
+ // Extract status from rawInput (may arrive across multiple updates —
1904
+ // first update has rawInput={}, subsequent has full input)
1905
+ try {
1906
+ const raw = uAny.rawInput;
1907
+ const input =
1908
+ typeof raw === "string" ? JSON.parse(raw) :
1909
+ typeof raw === "object" ? raw :
1910
+ uAny.input;
1911
+ if (input?.status) {
1912
+ doneStatus = input.status;
1913
+ }
1914
+ } catch {
1915
+ // Best effort — rawInput may not be parseable yet
1795
1916
  }
1796
- } catch {
1797
- // Best effort — rawInput may not be parseable yet
1917
+ signalDone();
1798
1918
  }
1799
- }
1800
1919
 
1801
- // Fallback: check older format
1802
- if (
1803
- uAny.type === "result" &&
1804
- uAny.subtype === "tool_result" &&
1805
- uAny.toolName === "done"
1806
- ) {
1807
- doneCalled = true;
1808
- doneStatus = uAny.result?.status;
1920
+ // Fallback: check older format
1921
+ if (
1922
+ uAny.type === "result" &&
1923
+ uAny.subtype === "tool_result" &&
1924
+ uAny.toolName === "done"
1925
+ ) {
1926
+ doneCalled = true;
1927
+ doneStatus = uAny.result?.status;
1928
+ signalDone();
1929
+ }
1809
1930
  }
1931
+ })();
1932
+ // Prevent an unhandled rejection if we return via doneSignal before the
1933
+ // now-orphaned stream throws from a background teardown.
1934
+ consume.catch(() => {});
1935
+
1936
+ try {
1937
+ startPoller();
1938
+ // Return as soon as EITHER the stream drains naturally OR completion is
1939
+ // signalled — whichever comes first. This is the key change: completion
1940
+ // no longer depends on the generator unblocking.
1941
+ await Promise.race([consume, doneSignal]);
1942
+ } catch (err) {
1943
+ // A genuine stream error (not a post-completion teardown) re-throws.
1944
+ if (!completedExternally && !doneCalled) throw err;
1945
+ } finally {
1946
+ // Always clear the concurrent poller so no timer leaks across attempts.
1947
+ if (pollTimer) clearInterval(pollTimer);
1948
+ }
1949
+
1950
+ // Re-check after the stream ends in case completion landed exactly as the
1951
+ // attempt's stream drained (the inner break / poller already set the flag
1952
+ // when it fired mid-stream).
1953
+ if (!completedExternally && options?.isComplete && (await options.isComplete())) {
1954
+ completedExternally = true;
1810
1955
  }
1811
1956
 
1812
- if (doneCalled) break;
1957
+ if (doneCalled || completedExternally) break;
1813
1958
 
1814
1959
  if (attempt < maxFollowUps) {
1815
1960
  currentMessage =
@@ -1817,9 +1962,11 @@ export function createAgentManagerV2(
1817
1962
  }
1818
1963
  }
1819
1964
 
1820
- // Auto-terminate when done() was called and the handler signaled shouldTerminate.
1821
- // This closes the lifecycle gap: without this, agents stay in "running" state
1822
- // after calling done() because nothing triggers terminate().
1965
+ // Auto-terminate when the agent completed either via the macro done() tool
1966
+ // or via the caller's external completion predicate. This closes the
1967
+ // lifecycle gap: without this, agents stay in "running" state because
1968
+ // nothing triggers terminate(). The external path mirrors the done() path
1969
+ // (terminate as "completed").
1823
1970
  if (doneCalled) {
1824
1971
  const reason = doneStatus === "completed" ? "completed" : (doneStatus ?? "failed");
1825
1972
  try {
@@ -1827,9 +1974,15 @@ export function createAgentManagerV2(
1827
1974
  } catch {
1828
1975
  // Best effort — agent may already be stopping
1829
1976
  }
1977
+ } else if (completedExternally) {
1978
+ try {
1979
+ await terminate(agentId, "completed" as any);
1980
+ } catch {
1981
+ // Best effort — agent may already be stopping
1982
+ }
1830
1983
  }
1831
1984
 
1832
- return { doneCalled, doneStatus, updates: allUpdates };
1985
+ return { doneCalled, doneStatus, completedExternally, updates: allUpdates };
1833
1986
  }
1834
1987
 
1835
1988
  function getSession(agentId: AgentId): Session | null {
@@ -1974,6 +2127,19 @@ export function createAgentManagerV2(
1974
2127
  });
1975
2128
  }
1976
2129
  activeSessions.clear();
2130
+
2131
+ // Await any in-flight fire-and-forget terminations (e.g. the completion
2132
+ // poller's background teardown) so shutdown does not return while a
2133
+ // group-kill is still racing — otherwise a follow-on process.exit abandons
2134
+ // it and orphans the acp/claude/mcp_env subtree. Bounded by a backstop so a
2135
+ // genuinely stuck close() cannot wedge shutdown.
2136
+ if (pendingTerminations.size > 0) {
2137
+ await Promise.race([
2138
+ Promise.allSettled([...pendingTerminations]),
2139
+ new Promise((resolve) => setTimeout(resolve, 6000)),
2140
+ ]);
2141
+ }
2142
+
1977
2143
  agentWorkspaces.clear();
1978
2144
  lifecycleListeners.clear();
1979
2145
 
@@ -164,10 +164,27 @@ export interface AgentManager {
164
164
  maxFollowUps?: number;
165
165
  /** Callback for each update during prompting */
166
166
  onUpdate?: (update: ExtendedSessionUpdate) => void;
167
+ /**
168
+ * Optional caller-supplied completion predicate. When it returns true the
169
+ * loop stops early and terminates the agent as "completed" WITHOUT
170
+ * requiring the macro done() tool. Back-compat: absent → unchanged.
171
+ *
172
+ * Evaluated reactively per-update/per-attempt AND on a concurrent timer
173
+ * (see implementation) so a silent-but-open prompt() stream still
174
+ * completes. No predicate → no poller, unchanged behavior.
175
+ */
176
+ isComplete?: () => boolean | Promise<boolean>;
177
+ /**
178
+ * Override the concurrent isComplete() poll cadence in ms (default 2000).
179
+ * Only meaningful alongside `isComplete`; primarily a test seam.
180
+ */
181
+ pollIntervalMs?: number;
167
182
  },
168
183
  ): Promise<{
169
184
  doneCalled: boolean;
170
185
  doneStatus?: string;
186
+ /** True when the caller's isComplete() predicate ended the loop. */
187
+ completedExternally: boolean;
171
188
  updates: ExtendedSessionUpdate[];
172
189
  }>;
173
190
 
@@ -202,15 +202,36 @@ export interface AgentConfig {
202
202
  }
203
203
 
204
204
  /**
205
- * MCP server configuration
205
+ * MCP server configuration.
206
+ *
207
+ * A discriminated union over transport. The legacy stdio shape (no `type`,
208
+ * or `type: "stdio"`) is preserved byte-for-byte; new `http`/`sse` variants
209
+ * carry a `url` + optional `headers` instead of a `command`. These map
210
+ * directly onto the ACP wire's `McpServerStdio` / `McpServerHttp` /
211
+ * `McpServerSse` shapes (see acp-factory `createSession({ mcpServers })`).
206
212
  */
207
- export interface MCPServerConfig {
213
+ export type MCPServerConfig = MCPServerStdioConfig | MCPServerHttpConfig;
214
+
215
+ /** Stdio transport (legacy default — preserved for M0 compatibility). */
216
+ export interface MCPServerStdioConfig {
208
217
  name: string;
218
+ /** Optional transport discriminant; absent means stdio. */
219
+ type?: "stdio";
209
220
  command: string;
210
221
  args?: string[];
211
222
  env?: Record<string, string>;
212
223
  }
213
224
 
225
+ /** HTTP (streamable-http) or SSE transport for a remote MCP server. */
226
+ export interface MCPServerHttpConfig {
227
+ name: string;
228
+ type: "http" | "sse";
229
+ /** URL to the remote MCP server. */
230
+ url: string;
231
+ /** HTTP headers to set on requests to the server. */
232
+ headers?: Record<string, string>;
233
+ }
234
+
214
235
  // ─────────────────────────────────────────────────────────────────
215
236
  // Spawned Agent Result
216
237
  // ─────────────────────────────────────────────────────────────────
@@ -6,16 +6,23 @@ import type { RoleRegistry, RoleDefinition } from "../../roles/types.js";
6
6
 
7
7
  // ── Mock Helpers ─────────────────────────────────────────────────
8
8
 
9
+ // Stand-in for the built-in "generic" role the REAL DefaultRoleRegistry falls
10
+ // back to. Used by resolveRole() below so the mock matches production
11
+ // semantics (resolveRole NEVER throws on a miss).
12
+ const GENERIC_FALLBACK = { name: "generic" } as unknown as RoleDefinition;
13
+
9
14
  function createMockRoleRegistry(): RoleRegistry {
10
15
  const roles = new Map<string, RoleDefinition>();
11
16
  return {
12
17
  registerRole: vi.fn((role: RoleDefinition) => {
13
18
  roles.set(role.name, role);
14
19
  }),
20
+ // MUST mirror the real DefaultRoleRegistry.resolveRole(): on an unknown
21
+ // role it does NOT throw — it logs and returns the GenericRole fallback.
22
+ // A throwing mock (the old behavior) masked the constructor bug where
23
+ // analyst registration was gated on resolveRole() throwing.
15
24
  resolveRole: vi.fn((name: string) => {
16
- const role = roles.get(name);
17
- if (!role) throw new Error(`Role not found: ${name}`);
18
- return role;
25
+ return roles.get(name) ?? GENERIC_FALLBACK;
19
26
  }),
20
27
  getRole: vi.fn((name: string) => roles.get(name)),
21
28
  hasCapability: vi.fn(() => true),
@@ -92,10 +99,33 @@ describe("MacroAgentBackend", () => {
92
99
  );
93
100
  });
94
101
 
102
+ it("registers analyst against a real-semantics registry (resolveRole does NOT throw on miss)", () => {
103
+ // Regression guard for the live Arm-B bug: the constructor must NOT rely
104
+ // on resolveRole() throwing to detect a missing analyst role. With a
105
+ // non-throwing registry (production behavior), analyst must still be
106
+ // registered, and resolveRole("analyst") must then return analyst —
107
+ // NOT fall back to "generic" (which would give the spawned agent
108
+ // all built-in tools + own-workspace + persistent lifecycle, exactly
109
+ // the conditions that produced 0 env-tool calls and a 600s timeout).
110
+ const registry = createMockRoleRegistry();
111
+ const am = createMockAgentManager({
112
+ getRoleRegistry: vi.fn().mockReturnValue(registry),
113
+ });
114
+
115
+ new MacroAgentBackend(am);
116
+
117
+ expect(registry.registerRole).toHaveBeenCalledWith(
118
+ expect.objectContaining({ name: "analyst" }),
119
+ );
120
+ // After construction, the role must resolve to analyst, not generic.
121
+ expect(registry.resolveRole("analyst").name).toBe("analyst");
122
+ });
123
+
95
124
  it("skips registration if analyst role already exists", () => {
96
125
  const registry = createMockRoleRegistry();
97
- // Pre-register analyst
98
- (registry.resolveRole as ReturnType<typeof vi.fn>).mockReturnValue({
126
+ // Pre-register analyst via getRole (the exact-match existence check the
127
+ // constructor now uses), mirroring the real registry's API.
128
+ (registry.getRole as ReturnType<typeof vi.fn>).mockReturnValue({
99
129
  name: "analyst",
100
130
  });
101
131
 
@@ -203,6 +233,129 @@ describe("MacroAgentBackend", () => {
203
233
  );
204
234
  });
205
235
 
236
+ it("forwards mcpServers into the raw macro spawn config (Arm B chain)", async () => {
237
+ const mcpServers = [
238
+ {
239
+ name: "tauenv",
240
+ command: "/venv/bin/python",
241
+ args: ["-m", "autonomation_tau_bench.mcp_env", "--split", "airline"],
242
+ env: { TAU_REWARD_FILE: "/tmp/reward.json" },
243
+ },
244
+ ];
245
+
246
+ await backend.spawn({
247
+ agentType: "claude-code",
248
+ task: { description: "handle the customer" },
249
+ mcpServers,
250
+ });
251
+
252
+ expect(agentManager.spawn).toHaveBeenCalledWith(
253
+ expect.objectContaining({
254
+ config: expect.objectContaining({ mcpServers }),
255
+ }),
256
+ );
257
+ });
258
+
259
+ it("forwards both env and mcpServers together into the raw spawn config", async () => {
260
+ const mcpServers = [
261
+ { name: "tauenv", command: "/venv/bin/python", args: ["-m", "x"] },
262
+ ];
263
+
264
+ await backend.spawn({
265
+ agentType: "claude-code",
266
+ task: { description: "test" },
267
+ env: { TAU_REWARD_FILE: "/tmp/r.json" },
268
+ mcpServers,
269
+ });
270
+
271
+ expect(agentManager.spawn).toHaveBeenCalledWith(
272
+ expect.objectContaining({
273
+ config: {
274
+ env: { TAU_REWARD_FILE: "/tmp/r.json" },
275
+ mcpServers,
276
+ },
277
+ }),
278
+ );
279
+ });
280
+
281
+ it("invokes beforeSpawn BEFORE driving the agent (per-attempt reset hook)", async () => {
282
+ // beforeSpawn must run before agentManager.spawn so the env subprocess /
283
+ // reward sink is reset for THIS attempt. We assert ordering by recording
284
+ // the call sequence.
285
+ const order: string[] = [];
286
+ const beforeSpawn = vi.fn(() => {
287
+ order.push("beforeSpawn");
288
+ });
289
+ const am = createMockAgentManager({
290
+ spawn: vi.fn().mockImplementation(async () => {
291
+ order.push("spawn");
292
+ return { id: "agent_test123", session_id: "session_test123" };
293
+ }),
294
+ });
295
+ const b = new MacroAgentBackend(am);
296
+
297
+ await b.spawn({
298
+ agentType: "claude-code",
299
+ task: { description: "tau episode" },
300
+ beforeSpawn,
301
+ });
302
+
303
+ expect(beforeSpawn).toHaveBeenCalledTimes(1);
304
+ expect(order).toEqual(["beforeSpawn", "spawn"]);
305
+ });
306
+
307
+ it("passes completionSignal into promptUntilDone (S1 threading)", async () => {
308
+ const completionSignal = vi.fn(() => false);
309
+
310
+ await backend.spawn({
311
+ agentType: "claude-code",
312
+ task: { description: "tau episode" },
313
+ completionSignal,
314
+ });
315
+
316
+ await vi.waitFor(() => {
317
+ expect(agentManager.promptUntilDone).toHaveBeenCalledWith(
318
+ expect.any(String),
319
+ expect.any(String),
320
+ expect.objectContaining({ isComplete: completionSignal }),
321
+ );
322
+ });
323
+ });
324
+
325
+ it("treats completedExternally as a success (session → completed) even without done()", async () => {
326
+ const am = createMockAgentManager({
327
+ promptUntilDone: vi.fn().mockResolvedValue({
328
+ doneCalled: false,
329
+ completedExternally: true,
330
+ updates: [],
331
+ }),
332
+ });
333
+ const b = new MacroAgentBackend(am);
334
+
335
+ const session = await b.spawn({
336
+ agentType: "claude-code",
337
+ task: { description: "tau episode" },
338
+ completionSignal: () => true,
339
+ });
340
+
341
+ await vi.waitFor(async () => {
342
+ const s = await b.getSession(session.id);
343
+ expect(s!.state).toBe("completed");
344
+ expect(s!.error).toBeUndefined();
345
+ });
346
+ });
347
+
348
+ it("passes config: undefined when neither env nor mcpServers set (M0 back-compat)", async () => {
349
+ await backend.spawn({
350
+ agentType: "claude-code",
351
+ task: { description: "test" },
352
+ });
353
+
354
+ expect(agentManager.spawn).toHaveBeenCalledWith(
355
+ expect.objectContaining({ config: undefined }),
356
+ );
357
+ });
358
+
206
359
  it("stores macro agent ID in session metadata", async () => {
207
360
  const session = await backend.spawn({
208
361
  agentType: "claude-code",