243 lines
7.1 KiB
TypeScript
243 lines
7.1 KiB
TypeScript
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",
|
|
}),
|
|
);
|
|
});
|
|
});
|