Skip to content

Commit 257e2f5

Browse files
dutifulbobosolmaz
andauthored
fix: relay ACP sessions_spawn parent streaming (#34310) (thanks @vincentkoc) (#34310)
Co-authored-by: Onur Solmaz <[email protected]>
1 parent 61f7cea commit 257e2f5

File tree

10 files changed

+893
-3
lines changed

10 files changed

+893
-3
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ Docs: https://docs.openclaw.ai
6969
- iOS/TTS playback fallback: keep voice playback resilient by switching from PCM to MP3 when provider format support is unavailable, while avoiding sticky fallback on generic local playback errors. (#33032) thanks @mbelinky.
7070
- Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.
7171
- Telegram/plugin outbound hook parity: run `message_sending` + `message_sent` in Telegram reply delivery, include reply-path hook metadata (`mediaUrls`, `threadId`), and report `message_sent.success=false` when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee.
72+
- CLI/Coding-agent reliability: switch default `claude-cli` non-interactive args to `--permission-mode bypassPermissions`, auto-normalize legacy `--dangerously-skip-permissions` backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. (#28610, #31149, #34055). Thanks @niceysam, @cryptomaltese and @vincentkoc.
73+
- ACP/ACPX session bootstrap: retry with `sessions new` when `sessions ensure` returns no session identifiers so ACP spawns avoid `NO_SESSION`/`ACP_TURN_FAILED` failures on affected agents. (#28786, #31338, #34055). Thanks @Sid-Qin and @vincentkoc.
74+
- ACP/sessions_spawn parent stream visibility: add `streamTo: "parent"` for `runtime: "acp"` to forward initial child-run progress/no-output/completion updates back into the requester session as system events (instead of direct child delivery), and emit a tail-able session-scoped relay log (`<sessionId>.acp-stream.jsonl`, returned as `streamLogPath` when available), improving orchestrator visibility for blocked or long-running harness turns. (#34310, #29909; reopened from #34055). Thanks @vincentkoc.
7275
- Agents/bootstrap truncation warning handling: unify bootstrap budget/truncation analysis across embedded + CLI runtime, `/context`, and `openclaw doctor`; add `agents.defaults.bootstrapPromptTruncationWarning` (`off|once|always`, default `once`) and persist warning-signature metadata so truncation warnings are consistent and deduped across turns. (#32769) Thanks @gumadeiras.
7376
- Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras.
7477
- Agents/Session startup date grounding: substitute `YYYY-MM-DD` placeholders in startup/post-compaction AGENTS context and append runtime current-time lines for `/new` and `/reset` prompts so daily-memory references resolve correctly. (#32381) Thanks @chengzhichao-xydt.

docs/tools/acp-agents.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ Interface details:
119119
- `mode: "session"` requires `thread: true`
120120
- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
121121
- `label` (optional): operator-facing label used in session/banner text.
122+
- `streamTo` (optional): `"parent"` streams initial ACP run progress summaries back to the requester session as system events.
123+
- When available, accepted responses include `streamLogPath` pointing to a session-scoped JSONL log (`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
122124

123125
## Sandbox compatibility
124126

docs/tools/index.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ Core parameters:
472472
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
473473
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
474474
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
475-
- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`, `attachments?`, `attachAs?`
475+
- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`, `streamTo?`, `attachments?`, `attachAs?`
476476
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
477477

478478
Notes:
@@ -483,6 +483,7 @@ Notes:
483483
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
484484
- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
485485
- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents).
486+
- For ACP runtime, `streamTo: "parent"` routes initial-run progress summaries back to the requester session as system events instead of direct child delivery.
486487
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
487488
- Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`).
488489
- If `thread: true` and `mode` is omitted, mode defaults to `session`.
@@ -496,6 +497,7 @@ Notes:
496497
- Configure limits via `tools.sessions_spawn.attachments` (`enabled`, `maxTotalBytes`, `maxFiles`, `maxFileBytes`, `retainOnSessionKeep`).
497498
- `attachAs.mountPath` is a reserved hint for future mount implementations.
498499
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
500+
- ACP `streamTo: "parent"` responses may include `streamLogPath` (session-scoped `*.acp-stream.jsonl`) for tailing progress history.
499501
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
500502
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
501503
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { emitAgentEvent } from "../infra/agent-events.js";
3+
import {
4+
resolveAcpSpawnStreamLogPath,
5+
startAcpSpawnParentStreamRelay,
6+
} from "./acp-spawn-parent-stream.js";
7+
8+
const enqueueSystemEventMock = vi.fn();
9+
const requestHeartbeatNowMock = vi.fn();
10+
const readAcpSessionEntryMock = vi.fn();
11+
const resolveSessionFilePathMock = vi.fn();
12+
const resolveSessionFilePathOptionsMock = vi.fn();
13+
14+
vi.mock("../infra/system-events.js", () => ({
15+
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
16+
}));
17+
18+
vi.mock("../infra/heartbeat-wake.js", () => ({
19+
requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args),
20+
}));
21+
22+
vi.mock("../acp/runtime/session-meta.js", () => ({
23+
readAcpSessionEntry: (...args: unknown[]) => readAcpSessionEntryMock(...args),
24+
}));
25+
26+
vi.mock("../config/sessions/paths.js", () => ({
27+
resolveSessionFilePath: (...args: unknown[]) => resolveSessionFilePathMock(...args),
28+
resolveSessionFilePathOptions: (...args: unknown[]) => resolveSessionFilePathOptionsMock(...args),
29+
}));
30+
31+
function collectedTexts() {
32+
return enqueueSystemEventMock.mock.calls.map((call) => String(call[0] ?? ""));
33+
}
34+
35+
describe("startAcpSpawnParentStreamRelay", () => {
36+
beforeEach(() => {
37+
enqueueSystemEventMock.mockClear();
38+
requestHeartbeatNowMock.mockClear();
39+
readAcpSessionEntryMock.mockReset();
40+
resolveSessionFilePathMock.mockReset();
41+
resolveSessionFilePathOptionsMock.mockReset();
42+
resolveSessionFilePathOptionsMock.mockImplementation((value: unknown) => value);
43+
vi.useFakeTimers();
44+
vi.setSystemTime(new Date("2026-03-04T01:00:00.000Z"));
45+
});
46+
47+
afterEach(() => {
48+
vi.useRealTimers();
49+
});
50+
51+
it("relays assistant progress and completion to the parent session", () => {
52+
const relay = startAcpSpawnParentStreamRelay({
53+
runId: "run-1",
54+
parentSessionKey: "agent:main:main",
55+
childSessionKey: "agent:codex:acp:child-1",
56+
agentId: "codex",
57+
streamFlushMs: 10,
58+
noOutputNoticeMs: 120_000,
59+
});
60+
61+
emitAgentEvent({
62+
runId: "run-1",
63+
stream: "assistant",
64+
data: {
65+
delta: "hello from child",
66+
},
67+
});
68+
vi.advanceTimersByTime(15);
69+
70+
emitAgentEvent({
71+
runId: "run-1",
72+
stream: "lifecycle",
73+
data: {
74+
phase: "end",
75+
startedAt: 1_000,
76+
endedAt: 3_100,
77+
},
78+
});
79+
80+
const texts = collectedTexts();
81+
expect(texts.some((text) => text.includes("Started codex session"))).toBe(true);
82+
expect(texts.some((text) => text.includes("codex: hello from child"))).toBe(true);
83+
expect(texts.some((text) => text.includes("codex run completed in 2s"))).toBe(true);
84+
expect(requestHeartbeatNowMock).toHaveBeenCalledWith(
85+
expect.objectContaining({
86+
reason: "acp:spawn:stream",
87+
sessionKey: "agent:main:main",
88+
}),
89+
);
90+
relay.dispose();
91+
});
92+
93+
it("emits a no-output notice and a resumed notice when output returns", () => {
94+
const relay = startAcpSpawnParentStreamRelay({
95+
runId: "run-2",
96+
parentSessionKey: "agent:main:main",
97+
childSessionKey: "agent:codex:acp:child-2",
98+
agentId: "codex",
99+
streamFlushMs: 1,
100+
noOutputNoticeMs: 1_000,
101+
noOutputPollMs: 250,
102+
});
103+
104+
vi.advanceTimersByTime(1_500);
105+
expect(collectedTexts().some((text) => text.includes("has produced no output for 1s"))).toBe(
106+
true,
107+
);
108+
109+
emitAgentEvent({
110+
runId: "run-2",
111+
stream: "assistant",
112+
data: {
113+
delta: "resumed output",
114+
},
115+
});
116+
vi.advanceTimersByTime(5);
117+
118+
const texts = collectedTexts();
119+
expect(texts.some((text) => text.includes("resumed output."))).toBe(true);
120+
expect(texts.some((text) => text.includes("codex: resumed output"))).toBe(true);
121+
122+
emitAgentEvent({
123+
runId: "run-2",
124+
stream: "lifecycle",
125+
data: {
126+
phase: "error",
127+
error: "boom",
128+
},
129+
});
130+
expect(collectedTexts().some((text) => text.includes("run failed: boom"))).toBe(true);
131+
relay.dispose();
132+
});
133+
134+
it("auto-disposes stale relays after max lifetime timeout", () => {
135+
const relay = startAcpSpawnParentStreamRelay({
136+
runId: "run-3",
137+
parentSessionKey: "agent:main:main",
138+
childSessionKey: "agent:codex:acp:child-3",
139+
agentId: "codex",
140+
streamFlushMs: 1,
141+
noOutputNoticeMs: 0,
142+
maxRelayLifetimeMs: 1_000,
143+
});
144+
145+
vi.advanceTimersByTime(1_001);
146+
expect(collectedTexts().some((text) => text.includes("stream relay timed out after 1s"))).toBe(
147+
true,
148+
);
149+
150+
const before = enqueueSystemEventMock.mock.calls.length;
151+
emitAgentEvent({
152+
runId: "run-3",
153+
stream: "assistant",
154+
data: {
155+
delta: "late output",
156+
},
157+
});
158+
vi.advanceTimersByTime(5);
159+
160+
expect(enqueueSystemEventMock.mock.calls).toHaveLength(before);
161+
relay.dispose();
162+
});
163+
164+
it("supports delayed start notices", () => {
165+
const relay = startAcpSpawnParentStreamRelay({
166+
runId: "run-4",
167+
parentSessionKey: "agent:main:main",
168+
childSessionKey: "agent:codex:acp:child-4",
169+
agentId: "codex",
170+
emitStartNotice: false,
171+
});
172+
173+
expect(collectedTexts().some((text) => text.includes("Started codex session"))).toBe(false);
174+
175+
relay.notifyStarted();
176+
177+
expect(collectedTexts().some((text) => text.includes("Started codex session"))).toBe(true);
178+
relay.dispose();
179+
});
180+
181+
it("preserves delta whitespace boundaries in progress relays", () => {
182+
const relay = startAcpSpawnParentStreamRelay({
183+
runId: "run-5",
184+
parentSessionKey: "agent:main:main",
185+
childSessionKey: "agent:codex:acp:child-5",
186+
agentId: "codex",
187+
streamFlushMs: 10,
188+
noOutputNoticeMs: 120_000,
189+
});
190+
191+
emitAgentEvent({
192+
runId: "run-5",
193+
stream: "assistant",
194+
data: {
195+
delta: "hello",
196+
},
197+
});
198+
emitAgentEvent({
199+
runId: "run-5",
200+
stream: "assistant",
201+
data: {
202+
delta: " world",
203+
},
204+
});
205+
vi.advanceTimersByTime(15);
206+
207+
const texts = collectedTexts();
208+
expect(texts.some((text) => text.includes("codex: hello world"))).toBe(true);
209+
relay.dispose();
210+
});
211+
212+
it("resolves ACP spawn stream log path from session metadata", () => {
213+
readAcpSessionEntryMock.mockReturnValue({
214+
storePath: "/tmp/openclaw/agents/codex/sessions/sessions.json",
215+
entry: {
216+
sessionId: "sess-123",
217+
sessionFile: "/tmp/openclaw/agents/codex/sessions/sess-123.jsonl",
218+
},
219+
});
220+
resolveSessionFilePathMock.mockReturnValue(
221+
"/tmp/openclaw/agents/codex/sessions/sess-123.jsonl",
222+
);
223+
224+
const resolved = resolveAcpSpawnStreamLogPath({
225+
childSessionKey: "agent:codex:acp:child-1",
226+
});
227+
228+
expect(resolved).toBe("/tmp/openclaw/agents/codex/sessions/sess-123.acp-stream.jsonl");
229+
expect(readAcpSessionEntryMock).toHaveBeenCalledWith({
230+
sessionKey: "agent:codex:acp:child-1",
231+
});
232+
expect(resolveSessionFilePathMock).toHaveBeenCalledWith(
233+
"sess-123",
234+
expect.objectContaining({
235+
sessionId: "sess-123",
236+
}),
237+
expect.objectContaining({
238+
storePath: "/tmp/openclaw/agents/codex/sessions/sessions.json",
239+
}),
240+
);
241+
});
242+
});

0 commit comments

Comments
 (0)