























@@ -0,0 +1,317 @@
1+import { randomBytes, randomUUID } from "node:crypto";
2+import path from "node:path";
3+import { afterEach, describe, expect, it } from "vitest";
4+import { clearRuntimeConfigSnapshot, type OpenClawConfig } from "../config/config.js";
5+import { callGateway as realCallGateway } from "../gateway/call.js";
6+import { GatewayClient } from "../gateway/client.js";
7+import { dispatchGatewayMethodInProcess as realDispatchGatewayMethodInProcess } from "../gateway/server-plugins.js";
8+import { startGatewayServer, type GatewayServer } from "../gateway/server.js";
9+import { extractPayloadText } from "../gateway/test-helpers.agent-results.js";
10+import { isTruthyEnvValue } from "../infra/env.js";
11+import { clearCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
12+import {
13+createOpenClawTestState,
14+type OpenClawTestState,
15+} from "../test-utils/openclaw-test-state.js";
16+import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
17+import { isLiveTestEnabled } from "./live-test-helpers.js";
18+import { __testing as subagentAnnounceDeliveryTesting } from "./subagent-announce-delivery.js";
19+import { __testing as subagentAnnounceTesting } from "./subagent-announce.js";
20+import { listSubagentRunsForRequester } from "./subagent-registry.js";
21+22+const LIVE = isLiveTestEnabled() && isTruthyEnvValue(process.env.OPENCLAW_LIVE_SUBAGENT_E2E);
23+const describeLive = LIVE ? describe : describe.skip;
24+25+type AgentPayload = {
26+status?: string;
27+result?: unknown;
28+};
29+30+type InProcessAgentDispatch =
31+| { phase: "started"; resultText?: undefined }
32+| { phase: "completed"; resultText: string };
33+34+const REQUEST_TIMEOUT_MS = 4 * 60_000;
35+const WAIT_TIMEOUT_MS = 5 * 60_000;
36+37+function sleep(ms: number): Promise<void> {
38+return new Promise((resolve) => setTimeout(resolve, ms));
39+}
40+41+function openAiConfig(
42+modelKey: string,
43+workspace: string,
44+port: number,
45+token: string,
46+): OpenClawConfig {
47+return {
48+gateway: {
49+mode: "local",
50+ port,
51+auth: { mode: "token", token },
52+controlUi: { enabled: false },
53+},
54+plugins: { enabled: false },
55+tools: {
56+allow: ["sessions_spawn", "sessions_yield", "subagents"],
57+},
58+models: {
59+providers: {
60+openai: {
61+api: "openai-responses",
62+agentRuntime: { id: "pi" },
63+apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
64+baseUrl: "https://api.openai.com/v1",
65+timeoutSeconds: 180,
66+models: [
67+{
68+id: modelKey.replace(/^openai\//u, ""),
69+name: modelKey.replace(/^openai\//u, ""),
70+api: "openai-responses",
71+agentRuntime: { id: "pi" },
72+input: ["text"],
73+reasoning: true,
74+contextWindow: 1_047_576,
75+maxTokens: 8_192,
76+cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
77+},
78+],
79+},
80+},
81+},
82+agents: {
83+defaults: {
84+ workspace,
85+model: { primary: modelKey },
86+models: { [modelKey]: { agentRuntime: { id: "pi" }, params: { maxTokens: 1024 } } },
87+sandbox: { mode: "off" },
88+subagents: {
89+allowAgents: ["*"],
90+runTimeoutSeconds: 120,
91+announceTimeoutMs: 120_000,
92+archiveAfterMinutes: 60,
93+},
94+},
95+},
96+};
97+}
98+99+async function waitFor<T>(
100+label: string,
101+fn: () => T | undefined | Promise<T | undefined>,
102+): Promise<T> {
103+const started = Date.now();
104+let lastValue: T | undefined;
105+while (Date.now() - started < WAIT_TIMEOUT_MS) {
106+lastValue = await fn();
107+if (lastValue !== undefined) {
108+return lastValue;
109+}
110+await sleep(1_000);
111+}
112+throw new Error(`timed out waiting for ${label}`);
113+}
114+115+function createGatewayClient(params: {
116+port: number;
117+token: string;
118+onEvent?: ConstructorParameters<typeof GatewayClient>[0]["onEvent"];
119+}): Promise<GatewayClient> {
120+return new Promise((resolve, reject) => {
121+const client = new GatewayClient({
122+url: `ws://127.0.0.1:${params.port}`,
123+token: params.token,
124+deviceIdentity: null,
125+clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
126+mode: GATEWAY_CLIENT_MODES.BACKEND,
127+scopes: ["operator.admin"],
128+requestTimeoutMs: REQUEST_TIMEOUT_MS,
129+onEvent: params.onEvent,
130+onHelloOk: () => resolve(client),
131+onConnectError: reject,
132+});
133+client.start();
134+});
135+}
136+137+describeLive("subagent announce live", () => {
138+let state: OpenClawTestState | undefined;
139+let server: GatewayServer | undefined;
140+let client: GatewayClient | undefined;
141+142+afterEach(async () => {
143+subagentAnnounceTesting.setDepsForTest();
144+subagentAnnounceDeliveryTesting.setDepsForTest();
145+await client?.stopAndWait().catch(() => undefined);
146+await server?.close({ reason: "subagent announce live test done" }).catch(() => undefined);
147+await state?.cleanup().catch(() => undefined);
148+clearRuntimeConfigSnapshot();
149+clearCurrentPluginMetadataSnapshot();
150+client = undefined;
151+server = undefined;
152+state = undefined;
153+});
154+155+it(
156+"lets a parent steer a subagent and receives completion through in-process agent dispatch",
157+async () => {
158+expect(process.env.OPENAI_API_KEY?.trim(), "OPENAI_API_KEY").toBeTruthy();
159+160+const token = `subagent-live-${randomUUID()}`;
161+const port = 30_000 + Math.floor(Math.random() * 10_000);
162+const modelKey = process.env.OPENCLAW_LIVE_SUBAGENT_E2E_MODEL?.trim() || "openai/gpt-5.5";
163+const nonce = randomBytes(3).toString("hex").toUpperCase();
164+const childToken = `CHILD_STEERED_${nonce}`;
165+const parentToken = `PARENT_SAW_${childToken}`;
166+const steerToken = `STEER_${nonce}`;
167+const childTask = [
168+`Immediately call sessions_yield with message="waiting for ${steerToken}".`,
169+`After a steering message containing ${steerToken} arrives, reply exactly ${childToken}.`,
170+`Do not reply with ${childToken} before receiving ${steerToken}.`,
171+].join(" ");
172+const sessionKey = `agent:main:live-subagent-${nonce.toLowerCase()}`;
173+const inProcessAgentDispatches: InProcessAgentDispatch[] = [];
174+175+const forbiddenAgentRpc: typeof realCallGateway = async (request) => {
176+if (request.method === "agent") {
177+throw new Error("subagent announce live test forbids gateway RPC method=agent");
178+}
179+return await realCallGateway(request);
180+};
181+const instrumentedDispatch: typeof realDispatchGatewayMethodInProcess = async <T>(
182+method: string,
183+params: Record<string, unknown>,
184+options?: Parameters<typeof realDispatchGatewayMethodInProcess>[2],
185+): Promise<T> => {
186+if (method === "agent") {
187+inProcessAgentDispatches.push({ phase: "started" });
188+}
189+const result = await realDispatchGatewayMethodInProcess<T>(method, params, options);
190+if (method === "agent") {
191+inProcessAgentDispatches.push({
192+phase: "completed",
193+resultText: extractPayloadText((result as AgentPayload).result),
194+});
195+}
196+return result;
197+};
198+199+subagentAnnounceTesting.setDepsForTest({
200+callGateway: forbiddenAgentRpc,
201+dispatchGatewayMethodInProcess: instrumentedDispatch,
202+});
203+subagentAnnounceDeliveryTesting.setDepsForTest({
204+callGateway: forbiddenAgentRpc,
205+dispatchGatewayMethodInProcess: instrumentedDispatch,
206+getRequesterSessionActivity: () => ({
207+sessionId: "requester-session-local",
208+isActive: false,
209+}),
210+});
211+212+state = await createOpenClawTestState({
213+label: "subagent-announce-live",
214+layout: "split",
215+env: {
216+OPENCLAW_SKIP_CHANNELS: "1",
217+OPENCLAW_SKIP_CRON: "1",
218+OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
219+OPENCLAW_SKIP_CANVAS_HOST: "1",
220+OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
221+OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
222+OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY: "1",
223+OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"),
224+OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
225+OPENCLAW_PLUGIN_CATALOG_PATHS: undefined,
226+OPENCLAW_PLUGINS_PATHS: undefined,
227+},
228+});
229+await state.writeConfig(openAiConfig(modelKey, state.workspaceDir, port, token));
230+clearRuntimeConfigSnapshot();
231+clearCurrentPluginMetadataSnapshot();
232+233+server = await startGatewayServer(port, {
234+bind: "loopback",
235+auth: { mode: "token", token },
236+controlUiEnabled: false,
237+});
238+client = await createGatewayClient({ port, token });
239+240+let initialError: unknown;
241+const initialRequest = client.request<AgentPayload>(
242+"agent",
243+{
244+ sessionKey,
245+idempotencyKey: `live-subagent-${randomUUID()}`,
246+deliver: false,
247+timeout: 180,
248+message: [
249+"Run this exact OpenClaw subagent steering scenario. Use tool calls, not prose.",
250+`Use nonce ${nonce}.`,
251+`Step 1: call sessions_spawn with exactly this JSON input: ${JSON.stringify({
252+ task: childTask,
253+ taskName: "steered_child",
254+ cleanup: "keep",
255+ context: "isolated",
256+ runTimeoutSeconds: 120,
257+ })}.`,
258+`Step 2: after spawn returns status="accepted", call subagents with exactly this JSON input: ${JSON.stringify(
259+ {
260+ action: "steer",
261+ target: "steered_child",
262+ message: steerToken,
263+ },
264+ )}.`,
265+`Step 3: call sessions_yield with message="waiting for ${childToken}" and wait for the child completion event.`,
266+`Step 4: after the completion event arrives, reply exactly ${parentToken}.`,
267+"Do not reply with the parent token until the child completion event is visible.",
268+].join("\n"),
269+},
270+{ expectFinal: true, timeoutMs: REQUEST_TIMEOUT_MS },
271+);
272+initialRequest.catch((error: unknown) => {
273+initialError = error;
274+});
275+276+const steeredRun = await waitFor("steered child completion", () => {
277+if (initialError) {
278+throw initialError;
279+}
280+return listSubagentRunsForRequester(sessionKey).find(
281+(run) =>
282+run.taskName === "steered_child" &&
283+run.frozenResultText?.includes(childToken) === true &&
284+run.outcome?.status === "ok",
285+);
286+});
287+expect(steeredRun.endedReason).toBe("subagent-complete");
288+expect(steeredRun.lastAnnounceDeliveryError).toBeUndefined();
289+290+await waitFor("in-process subagent completion agent dispatch start", () => {
291+if (initialError) {
292+throw initialError;
293+}
294+return inProcessAgentDispatches.some((entry) => entry.phase === "started")
295+ ? true
296+ : undefined;
297+});
298+299+const completedDispatch = inProcessAgentDispatches.find(
300+(entry) => entry.phase === "completed",
301+);
302+if (completedDispatch) {
303+expect(completedDispatch.resultText).toContain(childToken);
304+}
305+expect(
306+inProcessAgentDispatches.some((entry) => {
307+if (initialError) {
308+throw initialError;
309+}
310+return entry.phase === "started";
311+}),
312+).toBe(true);
313+expect(inProcessAgentDispatches.length).toBeGreaterThanOrEqual(1);
314+},
315+6 * 60_000,
316+);
317+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。