import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { emitAgentEvent } from "../infra/agent-events.js"; import { resolveAcpSpawnStreamLogPath, startAcpSpawnParentStreamRelay, } from "./acp-spawn-parent-stream.js"; const enqueueSystemEventMock = vi.fn(); const requestHeartbeatNowMock = vi.fn(); const readAcpSessionEntryMock = vi.fn(); const resolveSessionFilePathMock = vi.fn(); const resolveSessionFilePathOptionsMock = vi.fn(); vi.mock("../infra/system-events.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), })); vi.mock("../infra/heartbeat-wake.js", () => ({ requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args), })); vi.mock("../acp/runtime/session-meta.js", () => ({ readAcpSessionEntry: (...args: unknown[]) => readAcpSessionEntryMock(...args), })); vi.mock("../config/sessions/paths.js", () => ({ resolveSessionFilePath: (...args: unknown[]) => resolveSessionFilePathMock(...args), resolveSessionFilePathOptions: (...args: unknown[]) => resolveSessionFilePathOptionsMock(...args), })); function collectedTexts() { return enqueueSystemEventMock.mock.calls.map((call) => String(call[0] ?? "")); } describe("startAcpSpawnParentStreamRelay", () => { beforeEach(() => { enqueueSystemEventMock.mockClear(); requestHeartbeatNowMock.mockClear(); readAcpSessionEntryMock.mockReset(); resolveSessionFilePathMock.mockReset(); resolveSessionFilePathOptionsMock.mockReset(); resolveSessionFilePathOptionsMock.mockImplementation((value: unknown) => value); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-04T01:00:00.000Z")); }); afterEach(() => { vi.useRealTimers(); }); it("relays assistant progress and completion to the parent session", () => { const relay = startAcpSpawnParentStreamRelay({ runId: "run-1", parentSessionKey: "agent:main:main", childSessionKey: "agent:codex:acp:child-1", agentId: "codex", streamFlushMs: 10, noOutputNoticeMs: 120_000, }); emitAgentEvent({ runId: "run-1", stream: "assistant", data: { delta: "hello from child", }, }); vi.advanceTimersByTime(15); emitAgentEvent({ runId: "run-1", stream: "lifecycle", data: { phase: "end", startedAt: 1_000, endedAt: 3_100, }, }); const texts = collectedTexts(); expect(texts.some((text) => text.includes("Started codex session"))).toBe(true); expect(texts.some((text) => text.includes("codex: hello from child"))).toBe(true); expect(texts.some((text) => text.includes("codex run completed in 2s"))).toBe(true); expect(requestHeartbeatNowMock).toHaveBeenCalledWith( expect.objectContaining({ reason: "acp:spawn:stream", sessionKey: "agent:main:main", }), ); relay.dispose(); }); it("emits a no-output notice and a resumed notice when output returns", () => { const relay = startAcpSpawnParentStreamRelay({ runId: "run-2", parentSessionKey: "agent:main:main", childSessionKey: "agent:codex:acp:child-2", agentId: "codex", streamFlushMs: 1, noOutputNoticeMs: 1_000, noOutputPollMs: 250, }); vi.advanceTimersByTime(1_500); expect(collectedTexts().some((text) => text.includes("has produced no output for 1s"))).toBe( true, ); emitAgentEvent({ runId: "run-2", stream: "assistant", data: { delta: "resumed output", }, }); vi.advanceTimersByTime(5); const texts = collectedTexts(); expect(texts.some((text) => text.includes("resumed output."))).toBe(true); expect(texts.some((text) => text.includes("codex: resumed output"))).toBe(true); emitAgentEvent({ runId: "run-2", stream: "lifecycle", data: { phase: "error", error: "boom", }, }); expect(collectedTexts().some((text) => text.includes("run failed: boom"))).toBe(true); relay.dispose(); }); it("auto-disposes stale relays after max lifetime timeout", () => { const relay = startAcpSpawnParentStreamRelay({ runId: "run-3", parentSessionKey: "agent:main:main", childSessionKey: "agent:codex:acp:child-3", agentId: "codex", streamFlushMs: 1, noOutputNoticeMs: 0, maxRelayLifetimeMs: 1_000, }); vi.advanceTimersByTime(1_001); expect(collectedTexts().some((text) => text.includes("stream relay timed out after 1s"))).toBe( true, ); const before = enqueueSystemEventMock.mock.calls.length; emitAgentEvent({ runId: "run-3", stream: "assistant", data: { delta: "late output", }, }); vi.advanceTimersByTime(5); expect(enqueueSystemEventMock.mock.calls).toHaveLength(before); relay.dispose(); }); it("supports delayed start notices", () => { const relay = startAcpSpawnParentStreamRelay({ runId: "run-4", parentSessionKey: "agent:main:main", childSessionKey: "agent:codex:acp:child-4", agentId: "codex", emitStartNotice: false, }); expect(collectedTexts().some((text) => text.includes("Started codex session"))).toBe(false); relay.notifyStarted(); expect(collectedTexts().some((text) => text.includes("Started codex session"))).toBe(true); relay.dispose(); }); it("preserves delta whitespace boundaries in progress relays", () => { const relay = startAcpSpawnParentStreamRelay({ runId: "run-5", parentSessionKey: "agent:main:main", childSessionKey: "agent:codex:acp:child-5", agentId: "codex", streamFlushMs: 10, noOutputNoticeMs: 120_000, }); emitAgentEvent({ runId: "run-5", stream: "assistant", data: { delta: "hello", }, }); emitAgentEvent({ runId: "run-5", stream: "assistant", data: { delta: " world", }, }); vi.advanceTimersByTime(15); const texts = collectedTexts(); expect(texts.some((text) => text.includes("codex: hello world"))).toBe(true); relay.dispose(); }); it("resolves ACP spawn stream log path from session metadata", () => { readAcpSessionEntryMock.mockReturnValue({ storePath: "/tmp/openclaw/agents/codex/sessions/sessions.json", entry: { sessionId: "sess-123", sessionFile: "/tmp/openclaw/agents/codex/sessions/sess-123.jsonl", }, }); resolveSessionFilePathMock.mockReturnValue( "/tmp/openclaw/agents/codex/sessions/sess-123.jsonl", ); const resolved = resolveAcpSpawnStreamLogPath({ childSessionKey: "agent:codex:acp:child-1", }); expect(resolved).toBe("/tmp/openclaw/agents/codex/sessions/sess-123.acp-stream.jsonl"); expect(readAcpSessionEntryMock).toHaveBeenCalledWith({ sessionKey: "agent:codex:acp:child-1", }); expect(resolveSessionFilePathMock).toHaveBeenCalledWith( "sess-123", expect.objectContaining({ sessionId: "sess-123", }), expect.objectContaining({ storePath: "/tmp/openclaw/agents/codex/sessions/sessions.json", }), ); }); });