Skip to content

Commit 7757d87

Browse files
LaZzyMantanzhenxin
andauthored
feat(core): Workflow tool P1 — minimal node:vm sandbox + sequential agent() (#4721) (#4732)
* feat(core): register Workflow tool name (P1) * feat(core): isWorkflowsEnabled config gate with env-var override (P1) * feat(core): stripExportMeta helper for workflow sandbox (P1) * feat(core): createWorkflowSandbox with determinism stubs (P1) * test(core): cover workflow sandbox phase/log/agent primitives (P1) * feat(core): WorkflowOrchestrator with injectable dispatch (P1) * feat(core): WorkflowOrchestrator production dispatch via AgentHeadless (P1) * feat(core): WorkflowTool wraps WorkflowOrchestrator (P1) * feat(core): register WorkflowTool behind isWorkflowsEnabled gate (P1) * feat(core): export WorkflowTool from package index (P1) * fix(core): harden workflow sandbox + tighten agent() opts surface (P1) SEC-C1: deep-null-proto + hardenClosure blocks args/closure realm-escape PoC. SEC-C2: vm 30s timeout kills sync infinite loops. UP-C1: agent() throws on unsupported opts (schema/isolation/model/agentType). UP-I1: keep verbatim subagent system prompt comment. ARCH-C1: thread AbortSignal into buildProductionDispatch → subagent.execute(). UP-C2: llmContent carries script result verbatim; metadata moves to returnDisplay. SEC-I1: add WORKFLOW to EXCLUDED_TOOLS_FOR_SUBAGENTS to prevent recursive fan-out. SEC-I2: cap logs[] at 10 000 lines with a truncation marker. REUSE-I1: use ToolErrorType.EXECUTION_FAILED in workflow error returns. TST: add security PoC tests, unique-runId, dispatch-rejection, llmContent-unwrap. TST-I1: remove setter/getter tautology test from config.workflows.test.ts. * refactor(core): decouple WorkflowOrchestrator from Config (P1) - Extract WORKFLOW_SUBAGENT_SYSTEM_PROMPT into workflow-prompts.ts - Lift buildProductionDispatch() into exported createProductionDispatch(config, signal?) - WorkflowOrchestrator constructor now takes dispatch directly: (dispatch: WorkflowAgentDispatch) - Remove WorkflowOrchestratorOptions interface - WorkflowToolOptions.orchestratorOverrides replaced by WorkflowToolOptions.dispatch - WorkflowToolInvocation.execute() calls createProductionDispatch() when no override is set - Tests updated: orchestrator tests inject dispatch directly; production-dispatch tests moved to createProductionDispatch describe block * fix(core): Math proxy hardening, subagent prompt verbatim, Date.now throw, phases cap, test fidelity (P1) * fix(core): construct Math+Date in vm realm, sever proto chains on injected closures (P1) * fix(core): sever Array.prototype on args, cap deep-null-proto recursion, consolidate WorkflowAgentResult (P1) * fix(core): stub parallel/pipeline/workflow/budget globals with P1-unsupported errors * fix(core): harden budget inner functions, regression-test anti-recursion + args threading * fix(core): P2/P5 forward-compat injection seams + document error.stack limitation (P1) * fix(core): vm-realm wrap async sandbox globals + stripExportMeta hardening (PR #4732 R1) Closes T1/T8/T14: thrown Errors and async-function Promises used to leak the host Function constructor through their prototype chains. PoC: agent('x').constructor.constructor('return process')() try { throw } catch(e) { e.constructor.constructor('return process')() } Build every async/sync global (agent, parallel, pipeline, workflow, budget, console, phase, log, args) inside the vm-realm via the existing init script. Host only exposes a primitive bridge that the init script reads once and deletes from globalThis. Both rejection and resolution paths cross the boundary as vm-realm values. Closes T2: deepNullProto used to setPrototypeOf(null) on array args, breaking for-of / .map / .filter / spread / destructuring. Replaced with vm-realm JSON.parse of an args string — arrays retain vm-realm Array.prototype methods. Closes T13: runtime allowlist on agent() opts catches typos like 'scema'. Closes T9/T16/T17: stripExportMeta now recognises //, /* */, and regex literals; throws on unbalanced braces instead of silently returning ''. Closes T6: validateArgs rejects functions, BigInt, circular refs, and over-deep nesting (previously functions silently disappeared). Closes T5: regression test for console.log/warn/error → getLogs routing. * fix(core): change WorkflowTool export to type-only (PR #4732 R1 T3) Production callers use Config.createToolRegistry's registerLazy path which dynamic-imports './tools/workflow/workflow.js'. The barrel export at index.ts previously forced eager evaluation of the workflow.js → workflow-orchestrator → workflow-sandbox → node:vm module chain for every consumer of @qwen-code/core, even when workflows are disabled. Sibling tool exports (AgentTool, SkillTool) are type-only; align WorkflowTool with the same pattern. SDK consumers can still annotate types; instantiation happens through the registry, not the barrel. * fix(core): subagent terminateMode + bounded runConfig + disallowedTools + failure context + defensive serialization (PR #4732 R1) Closes T10: runReasoningLoop returns terminateMode = CANCELLED|MAX_TURNS| TIMEOUT|ERROR rather than throwing. Without checking it, await agent(...) resolved to '' on user cancel and the workflow kept looping. Now check getTerminateMode() after execute() and throw on non-GOAL — mirrors AgentTool's existing handling. Closes T11: workflow subagents previously ran with runConfig: {} (no max_turns / max_time_minutes guard) and tools: ['*'] without disallowedTools. A single agent() could loop the model indefinitely. Bound to 50 turns / 10 minutes; add disallowedTools: [SEND_MESSAGE, EXIT_PLAN_MODE] to mirror upstream Tg8 — defense in depth with the §XmO system prompt. Closes T19: phases / logs accumulated before a script failure used to be discarded with the sandbox instance. WorkflowExecutionError carries them through the rejection so the user-visible display can show what ran. Closes T12 / T18: defensive serialization. A successful workflow returning a BigInt or circular value used to be reported as 'Workflow failed: Converting circular structure to JSON' because JSON.stringify was inside the try block. safeStringifyResult / safeStringifyDisplayPayload degrade to a clear placeholder so a serialization issue doesn't masquerade as a run failure. Closes T4: regression test for ToolErrorType.EXECUTION_FAILED assertion. Closes T7: vi as vitest alias removed (now matches every other test file). * chore(core): add missing @license headers + remove stale config-session-env reference (PR #4732 R1) Closes T20: 6 of 9 new workflow files were missing the standard @license Apache-2.0 header. Add Qwen-style header (matches sibling tools/agent/agent.ts and others) to: workflow-sandbox.ts, workflow-sandbox.test.ts, workflow-orchestrator.ts, workflow-orchestrator.test.ts, workflow-prompts.ts, workflow.test.ts. Closes T21: config.workflows.test.ts and config.workflow-registration.test.ts both contained 'mirrors config-session-env.test.ts' in a setup comment, but that file does not exist in the repo. Drop the dangling reference. * chore(core): clean up stray rebase conflict marker (PR #4732) * fix(core): sever sandboxGlobals proto + add async wall-clock timeout (PR #4732 R2) Closes T22: sandboxGlobals was a plain host-realm Object literal. Its prototype chain reached host Object → host Function → host process, bypassing every per-global hardening measure. PoC confirmed leak via `globalThis.constructor.constructor('return process')()` returning host process before fix. Fix: Object.setPrototypeOf(null) on both sandboxGlobals and the bridge object before vm.createContext. Regression tests cover both globalThis.constructor and implicit-this escape paths. Closes T23: vm.runInContext timeout only covers synchronous execution. Once the async IIFE yields its first await, the watchdog disarms and `return new Promise(() => {})` hangs forever. Fix: wrap in Promise.race with a wall-clock timeout (default 30 minutes, configurable via SandboxOptions.maxWallClockMs or QWEN_CODE_MAX_WORKFLOW_SECONDS env var). This is a permanent defense-in-depth — not P1-only: P2/P3/P5 all add resource caps measured in agent-calls or tokens, but a 0-token / 0-agent hang requires a wall-clock cap. Documented limitation: an in-script async microtask loop continues consuming microtasks after the outer wall-clock rejects (node:vm provides no way to halt async execution). In production the workflow surface returns the timeout error and the vm context becomes unreferenced; the leaked microtask loop is a host-process concern that requires worker_threads-level isolation (out of P1 scope). * fix(core): pre-sanitize non-serializable result before display payload (PR #4732 R3) Sibling drift of the R1 T12/T18 fix. safeStringifyResult already degrades per-field when the script's `result` is a BigInt / circular value, so llmContent survives. But the success-path display payload wraps {runId, phases, logs, result} in a single JSON.stringify — one bad `result` collapsed the whole display to the generic "(display payload not JSON-serializable)" string and the user lost the runId (needed for log correlation), the accumulated phases, AND the logs. Pre-sanitize `result` only; runId / phases / logs are always serializable. Add regression test that scripts a circular `result` with a `phase()` in front: assertions check runId, the phase, and the non-JSON-serializable placeholder all appear in returnDisplay, and that the atomic-failure fallback string does NOT appear. RED at 10:56:23 → fix → GREEN at 10:56:48. 14/14 workflow.test, 109/109 across the workflow test suite, typecheck silent. * refactor(core): push display-payload per-field fallback into the helper (PR #4732) Post-R3 /simplify pass. The R3 fix special-cased `result` at the call site by pre-probing JSON.stringify and substituting a placeholder. Four review angles (reuse / simplification / efficiency / altitude) all converged on the same root cause: per-field degradation belongs in `safeStringifyDisplayPayload`, not duplicated at every caller. - Altitude: the bug ("all-or-nothing stringify is too coarse") names a property of the helper; the fix now lives in the helper. Any new payload field that becomes non-serializable in a future round (`metrics: bigint`, etc.) is handled without a fresh call-site patch. - Reuse: the third try/JSON.stringify probe in this file is gone; the helper owns the probe. - Simplification: call site reverts to the pre-R3 clean shape. - Efficiency: success path is back to one stringify per payload. Helper behavior: happy path → 1 stringify (unchanged) one field fails → walk top-level keys, probe each, replace failing value with `(non-JSON-serializable value of type X)`, re-stringify; total 2 stringifies of payload + N field probes. Fall through to the original generic fallback if the sanitized re-stringify also fails. R3 regression test (`execute() preserves runId/phases/logs in returnDisplay when result is non-JSON-serializable`) passes unchanged — it tests observable behavior, not the implementation site. 109/109 across the workflow suite, typecheck silent. * fix(core): honest description + meta-strip anchor + wall-clock cancellation + stray gitignore (PR #4732 R4) Four fixes from R4 review: T32 (workflow.ts tool description) — P1 description claimed "sequential only" but `Promise.all([agent(), agent()])` bypasses the claim because the vm cannot intercept JS built-ins. Rewrite the description to be honest: P1 ships sequential primitives only (no parallel/pipeline); Promise.all spawns concurrent subagents that share Config and may race on file edits. Matches upstream Claude Code behavior — they also expose Promise.all without enforcement. T33 (workflow-sandbox.ts stripExportMeta) — drop the `/m` flag on the anchor regex. With `/m`, a template literal containing `\nexport const meta = {\n` triggered a false match, and the brace walker ripped content out of the string body, silently corrupting the script. Per design intent ("required first statement of every script") meta must be file-start; anchoring there closes the corruption surface. Adds two RED-confirmed regression tests (template literal + leading code) and a sanity test for leading whitespace. T35 (packages/core/.gitignore) — removed. The `.qwen/computer-use/` entry was stray scope pollution committed accidentally in R1's license- header cleanup (`d118c55f8`) and unrelated to the Workflow P1 surface. T40 (sandbox.ts + orchestrator.ts + workflow.ts) — completes the R2 wall-clock defense. When the timer fires the sandbox now `abort()`s a caller-supplied AbortController BEFORE rejecting; the controller's signal is also threaded into `createProductionDispatch`, so in-flight subagent.execute() calls see the cancellation and stop burning tokens. Without this, R2's "30 min wall-clock" still let subagents run for up to their internal `max_time_minutes: 10` after the user-side timed out. WorkflowTool.execute now derives `dispatchController`, forwards caller signal abort to it, passes its signal to dispatch and the controller itself to `orchestrator.run({abortOnTimeout})`. A `finally` block aborts the controller on natural completion (cancel any straggler subagent) and detaches the caller-signal listener to avoid leaks. Adds two sandbox unit tests (RED-confirmed): controller IS aborted on timeout, controller is NOT aborted on normal completion. 114/114 workflow suite tests pass, typecheck silent. * refactor(core): use createChildAbortController for T40 dispatch-signal bridge (PR #4732) Post-R4 /simplify pass. Four review angles converged on one finding: the T40 manual AbortController-bridging at the call site re-implements `createChildAbortController` from `packages/core/src/utils/abortController.ts:61`, which is already the project-idiomatic helper for this exact pattern (used at agent-headless.ts:231, agent-interactive.ts:157, agent-core.ts:603 & 952). The replacement collapses 5 lines of imperative listener wiring at `workflow.ts:execute()` head + 1 line of finally cleanup into a single `createChildAbortController(signal)` call, plus inherits the helper's hardenings: - WeakRef on the parent (so a long-lived caller signal doesn't pin the child controller) - Auto-removal of the parent listener when the child fires (covers both the wall-clock-fire path and the natural-completion path) - Default 50-listener cap via `setMaxListeners` No behavior change at the API boundary — the wall-clock `abortOnTimeout` contract and the test assertions for T40's two cases (controller IS aborted on wall-clock; controller is NOT aborted on normal completion) all still hold. The /simplify "altitude" finding (push the bridging into the orchestrator) is deferred — that would change WorkflowOrchestrator's constructor/run signature and is outside this PR's scope. Also trims a redundant inline comment at workflow-orchestrator.ts:172 (the `abortOnTimeout: req.abortOnTimeout` line; the field's type comment at line 71-81 already explains the contract). 114/114 workflow suite tests pass, typecheck silent. * chore(core): compress over-weight T40 comments after createChildAbortController refactor (PR #4732) The previous commit moved the bridging logic into the helper, so the inline comments restating the helper's contract (parent forwarding, already-aborted fast path, WeakRef, auto-removal) became redundant — that's the helper's job to document. Compress to the load-bearing semantics: the child controller sees both caller-driven and wall-clock-driven aborts, and `finally` cancels stragglers on normal completion. No code change. * chore(core): align copyright header to Qwen on 2 PR-new test files (PR #4732 R7 F4) Both files were derived from a template (the stale `config-session-env.test.ts` reference cleaned up in R1 T21) and retained the upstream `Copyright 2025 Google LLC` header. The other six new workflow source/test files in this PR carry `Copyright 2025 Qwen`. Align for same-PR consistency. Per DragonnZhang R7 F4. No behavior change; header text only. --------- Co-authored-by: tanzhenxin <tanzhenxing1987@gmail.com>
1 parent cc141ff commit 7757d87

13 files changed

Lines changed: 3112 additions & 0 deletions

packages/core/src/agents/runtime/agent-core.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet<string> = new Set([
100100
// never enter or exit the user's worktree state independently.
101101
ToolNames.ENTER_WORKTREE,
102102
ToolNames.EXIT_WORKTREE,
103+
// FIX-8 (SEC-I1): WORKFLOW is excluded to prevent unbounded recursive
104+
// fan-out: a subagent spawned by Workflow that calls Workflow would create
105+
// O(k^n) subagents.
106+
ToolNames.WORKFLOW,
103107
]);
104108

105109
/**
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
// T7 (PR #4732 R1): the `vi as vitest` alias diverges from every other
8+
// test file in the repo. Use `vi` directly.
9+
import { describe, it, expect, beforeEach, vi } from 'vitest';
10+
import {
11+
WorkflowOrchestrator,
12+
WorkflowExecutionError,
13+
createProductionDispatch,
14+
} from './workflow-orchestrator.js';
15+
import type { Config } from '../../config/config.js';
16+
17+
// FIX-C3 (TST-2-C1): use vi.hoisted so `created` is initialised before the
18+
// vi.mock factory runs AND remains accessible inside tests for assertion +
19+
// reset between cases. Without this, the module-level `created` array
20+
// accumulated across tests, so a later test could pass by coincidence.
21+
//
22+
// FIX-C8 (TST-2-I2): record the full 9-arg signature of AgentHeadless.create
23+
// and the (ctx, signal?) shape of execute so any drift between the production
24+
// call site and the real AgentHeadless surface becomes a test failure.
25+
const { created, nextTerminateMode } = vi.hoisted(() => ({
26+
created: [] as Array<{
27+
name: string;
28+
prompt: string;
29+
signal?: AbortSignal;
30+
promptConfigSystemPrompt?: string;
31+
runConfig?: { max_turns?: number; max_time_minutes?: number };
32+
toolConfig?: { tools?: string[]; disallowedTools?: string[] };
33+
}>,
34+
// T10 (PR #4732 R1): the production dispatch checks getTerminateMode() and
35+
// throws on non-GOAL. Tests set `nextTerminateMode.value` to simulate
36+
// CANCELLED / MAX_TURNS / TIMEOUT outcomes.
37+
nextTerminateMode: { value: 'GOAL' as string },
38+
}));
39+
40+
vi.mock('./agent-headless.js', () => ({
41+
AgentHeadless: {
42+
create: async (
43+
name: string,
44+
_runtimeContext: unknown,
45+
promptConfig: { systemPrompt?: string },
46+
_modelConfig: unknown,
47+
runConfig: { max_turns?: number; max_time_minutes?: number },
48+
toolConfig?: { tools?: string[]; disallowedTools?: string[] },
49+
// The next three optional params reflect the real AgentHeadless.create
50+
// signature (eventEmitter?, hooks?, runtimeView?). Accepting them as
51+
// `unknown` lets the mock detect if the production call site ever adds
52+
// a positional argument that the mock would silently drop.
53+
_eventEmitter?: unknown,
54+
_hooks?: unknown,
55+
_runtimeView?: unknown,
56+
) => ({
57+
execute: async (
58+
ctx: { get: (k: string) => unknown },
59+
signal?: AbortSignal,
60+
) => {
61+
created.push({
62+
name,
63+
prompt: ctx.get('task_prompt') as string,
64+
signal,
65+
promptConfigSystemPrompt: promptConfig.systemPrompt,
66+
runConfig,
67+
toolConfig,
68+
});
69+
if (
70+
!promptConfig.systemPrompt?.includes('subagent spawned by a workflow')
71+
) {
72+
throw new Error(
73+
'orchestrator did not pass workflow subagent system prompt',
74+
);
75+
}
76+
},
77+
getFinalText: () =>
78+
`headless-said:${created[created.length - 1]!.prompt}`,
79+
getTerminateMode: () => nextTerminateMode.value,
80+
}),
81+
},
82+
ContextState: class ContextState {
83+
private state: Record<string, unknown> = {};
84+
get(key: string): unknown {
85+
return this.state[key];
86+
}
87+
set(key: string, value: unknown): void {
88+
this.state[key] = value;
89+
}
90+
},
91+
}));
92+
93+
function fakeConfig(): Config {
94+
// createProductionDispatch uses Config only when constructing a real subagent.
95+
// In tests we either inject a mock dispatch or test createProductionDispatch
96+
// directly against the vi.mock above. An empty object cast is safe.
97+
return {} as unknown as Config;
98+
}
99+
100+
describe('WorkflowOrchestrator', () => {
101+
it('runs a script with injected mock dispatch and returns the script value', async () => {
102+
const orchestrator = new WorkflowOrchestrator(
103+
async (prompt) => `mock:${prompt}`,
104+
);
105+
const outcome = await orchestrator.run({
106+
script: `phase("plan");
107+
const x = await agent("hi", { label: "a" });
108+
return x;`,
109+
args: undefined,
110+
});
111+
expect(outcome.result).toBe('mock:hi');
112+
expect(outcome.runId).toMatch(/^wf_[0-9a-f]{16}$/);
113+
expect(outcome.phases).toEqual(['plan']);
114+
});
115+
116+
it('passes args through to the script', async () => {
117+
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
118+
const outcome = await orchestrator.run({
119+
script: `return args.who`,
120+
args: { who: 'world' },
121+
});
122+
expect(outcome.result).toBe('world');
123+
});
124+
125+
it('surfaces a thrown error from the script', async () => {
126+
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
127+
await expect(
128+
orchestrator.run({
129+
script: `throw new Error("boom")`,
130+
args: undefined,
131+
}),
132+
).rejects.toThrow(/boom/);
133+
});
134+
135+
it('runId is stable for the lifetime of a single run call', async () => {
136+
const captured: string[] = [];
137+
const orchestrator = new WorkflowOrchestrator(async (prompt) => {
138+
captured.push(prompt);
139+
return 'ok';
140+
});
141+
const outcome = await orchestrator.run({
142+
script: `await agent("first"); await agent("second"); return 0;`,
143+
args: undefined,
144+
});
145+
expect(captured).toEqual(['first', 'second']);
146+
expect(outcome.runId).toMatch(/^wf_[0-9a-f]{16}$/);
147+
});
148+
149+
// TST-C1: concurrent runs must produce distinct runIds.
150+
it('runId is unique across concurrent runs', async () => {
151+
const orchestrator = new WorkflowOrchestrator(async () => 'ok');
152+
const [a, b, c] = await Promise.all([
153+
orchestrator.run({ script: 'return 1', args: undefined }),
154+
orchestrator.run({ script: 'return 2', args: undefined }),
155+
orchestrator.run({ script: 'return 3', args: undefined }),
156+
]);
157+
expect(a.runId).not.toBe(b.runId);
158+
expect(b.runId).not.toBe(c.runId);
159+
expect(a.runId).not.toBe(c.runId);
160+
});
161+
162+
// TST-C2: a dispatch rejection must propagate out through the sandbox.
163+
it('propagates dispatch rejection through the script', async () => {
164+
const orchestrator = new WorkflowOrchestrator(async () => {
165+
throw new Error('agent-crashed');
166+
});
167+
await expect(
168+
orchestrator.run({
169+
script: 'await agent("x"); return 0;',
170+
args: undefined,
171+
}),
172+
).rejects.toThrow(/agent-crashed/);
173+
});
174+
});
175+
176+
describe('createProductionDispatch', () => {
177+
// FIX-C3: reset the shared mock-state array between tests so each case
178+
// observes its own subagent.execute call only. Also reset the simulated
179+
// terminate mode back to 'goal' (success).
180+
beforeEach(() => {
181+
created.length = 0;
182+
nextTerminateMode.value = 'GOAL';
183+
});
184+
185+
it('routes calls through AgentHeadless and returns getFinalText', async () => {
186+
const dispatch = createProductionDispatch(fakeConfig());
187+
const result = await dispatch('hello', { label: 'h1' });
188+
expect(result).toBe('headless-said:hello');
189+
expect(created.length).toBe(1);
190+
expect(created[0]!.name).toBe('h1');
191+
expect(created[0]!.prompt).toBe('hello');
192+
});
193+
194+
// FIX-C4 (TST-2-C2): the previous test only asserted no-crash. This one
195+
// actually captures the signal in the mock and asserts identity, so a
196+
// regression that drops the second arg of subagent.execute() would fail.
197+
it('threads abort signal through to subagent.execute', async () => {
198+
const controller = new AbortController();
199+
const dispatch = createProductionDispatch(fakeConfig(), controller.signal);
200+
await dispatch('hello', { label: 'h1' });
201+
expect(created.length).toBe(1);
202+
expect(created[0]!.signal).toBe(controller.signal);
203+
});
204+
205+
it('passes undefined signal when none provided', async () => {
206+
const dispatch = createProductionDispatch(fakeConfig());
207+
await dispatch('hello', { label: 'h1' });
208+
expect(created.length).toBe(1);
209+
expect(created[0]!.signal).toBeUndefined();
210+
});
211+
212+
// FIX-C2 (UP-2-C1): the subagent system prompt must include the binary's
213+
// §XmO bullets. We assert the JSON-format instruction is present because
214+
// its absence causes JSON-returning subagents to wrap output in code fences.
215+
it('passes the binary §XmO verbatim system prompt to subagent', async () => {
216+
const dispatch = createProductionDispatch(fakeConfig());
217+
await dispatch('hello', { label: 'h1' });
218+
const sp = created[0]!.promptConfigSystemPrompt ?? '';
219+
expect(sp).toContain('subagent spawned by a workflow');
220+
expect(sp).toContain('return ONLY the raw JSON');
221+
expect(sp).toContain('no code fences');
222+
expect(sp).toContain('SendUserMessage');
223+
});
224+
225+
// T11 (PR #4732 R1): subagents must be bounded so a single agent() call
226+
// cannot loop the model indefinitely.
227+
it('passes bounded runConfig (max_turns + max_time_minutes)', async () => {
228+
const dispatch = createProductionDispatch(fakeConfig());
229+
await dispatch('hello', { label: 'h1' });
230+
expect(created[0]!.runConfig).toEqual({
231+
max_turns: 50,
232+
max_time_minutes: 10,
233+
});
234+
});
235+
236+
// T11: disallow SendMessage / ExitPlanMode to mirror upstream Tg8.
237+
it('disallows SendMessage and ExitPlanMode for workflow subagents', async () => {
238+
const dispatch = createProductionDispatch(fakeConfig());
239+
await dispatch('hello', { label: 'h1' });
240+
expect(created[0]!.toolConfig?.tools).toEqual(['*']);
241+
expect(created[0]!.toolConfig?.disallowedTools).toEqual([
242+
'send_message',
243+
'exit_plan_mode',
244+
]);
245+
});
246+
247+
// T10 (PR #4732 R1): the production dispatch must throw when the
248+
// subagent terminates with a non-GOAL mode. Without this, `await agent(...)`
249+
// would resolve to '' on user cancel and the script would keep running.
250+
it.each([
251+
['CANCELLED', /terminate mode: CANCELLED/],
252+
['MAX_TURNS', /terminate mode: MAX_TURNS/],
253+
['TIMEOUT', /terminate mode: TIMEOUT/],
254+
['ERROR', /terminate mode: ERROR/],
255+
])(
256+
'throws when subagent terminate mode is %s',
257+
async (mode, expectedMessage) => {
258+
nextTerminateMode.value = mode;
259+
const dispatch = createProductionDispatch(fakeConfig());
260+
await expect(dispatch('hello', { label: 'h1' })).rejects.toThrow(
261+
expectedMessage,
262+
);
263+
},
264+
);
265+
});
266+
267+
describe('WorkflowOrchestrator failure-context preservation', () => {
268+
// T19 (PR #4732 R1): phases / logs accumulated before a script failure
269+
// must be preserved on the thrown error so the tool layer can display
270+
// them. Previously the sandbox instance was discarded with the error.
271+
it('throws WorkflowExecutionError carrying phases and logs on script failure', async () => {
272+
const orchestrator = new WorkflowOrchestrator(async () => 'ok');
273+
let caught: unknown;
274+
try {
275+
await orchestrator.run({
276+
script: `
277+
phase("plan");
278+
log("starting");
279+
phase("execute");
280+
log("about to fail");
281+
throw new Error("scripted failure");
282+
`,
283+
args: undefined,
284+
});
285+
} catch (err) {
286+
caught = err;
287+
}
288+
expect(caught).toBeInstanceOf(WorkflowExecutionError);
289+
const wfErr = caught as WorkflowExecutionError;
290+
expect(wfErr.message).toContain('scripted failure');
291+
expect(wfErr.phases).toEqual(['plan', 'execute']);
292+
expect(wfErr.logs).toEqual(['starting', 'about to fail']);
293+
});
294+
});

0 commit comments

Comments
 (0)