Files
openclaw/src/auto-reply/reply/agent-runner-cli-dispatch.test.ts
T
snowzlmbot 8d168c836a fix(agents): preserve structured tool result visible text (#97268)
* fix(agents): preserve structured tool result visible text

* fix(agents): address structured tool result review blockers

* fix(agents): cover real structured tool result shapes

* fix(agents): preserve canonical tool error statuses

* fix(agents): harden structured result rendering

* fix(agents): preserve typed structured results

* fix(agents): bound provider result media

---------

Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
2026-06-28 02:38:43 -07:00

386 lines
12 KiB
TypeScript

// Tests CLI dispatch arguments and runtime selection for agent runner turns.
import { afterEach, describe, expect, it, vi } from "vitest";
import type { EmbeddedAgentRunResult } from "../../agents/embedded-agent-runner/types.js";
import { createAgentRunRestartAbortError } from "../../agents/run-termination.js";
import {
emitAgentEvent,
getAgentEventLifecycleGeneration,
onAgentEvent,
resetAgentEventsForTest,
} from "../../infra/agent-events.js";
import {
createCliToolSummaryTracker,
keepCliSessionBindingOnlyWhenReused,
runCliAgentWithLifecycle,
} from "./agent-runner-cli-dispatch.js";
const cliDispatchState = vi.hoisted(() => ({
runCliAgentMock: vi.fn(),
}));
vi.mock("../../agents/cli-runner.js", () => ({
runCliAgent: (...args: unknown[]) => cliDispatchState.runCliAgentMock(...args),
}));
afterEach(() => {
vi.useRealTimers();
resetAgentEventsForTest();
cliDispatchState.runCliAgentMock.mockReset();
});
describe("runCliAgentWithLifecycle", () => {
it("keeps the captured lifecycle generation on start and terminal events", async () => {
const events: Array<{
stream?: string;
lifecycleGeneration?: string;
data?: Record<string, unknown>;
}> = [];
const lifecycleGeneration = getAgentEventLifecycleGeneration();
const stop = onAgentEvent((event) => {
if (event.runId === "run-before-restart") {
events.push(event);
}
});
cliDispatchState.runCliAgentMock.mockResolvedValueOnce({
payloads: [],
meta: { durationMs: 1 },
} satisfies EmbeddedAgentRunResult);
try {
await runCliAgentWithLifecycle({
runId: "run-before-restart",
lifecycleGeneration,
provider: "claude-cli",
runParams: {
sessionId: "session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
prompt: "hello",
provider: "claude-cli",
model: "claude",
thinkLevel: "off",
timeoutMs: 1_000,
runId: "run-before-restart",
},
});
} finally {
stop();
}
const lifecycleEvents = events.filter((event) => event.stream === "lifecycle");
expect(lifecycleEvents).toHaveLength(2);
expect(
lifecycleEvents.every((event) => event.lifecycleGeneration === lifecycleGeneration),
).toBe(true);
});
it("preserves restart ownership when the CLI resolves after cancellation", async () => {
const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
const stop = onAgentEvent((event) => {
if (event.runId === "run-restart") {
events.push(event);
}
});
const controller = new AbortController();
cliDispatchState.runCliAgentMock.mockImplementationOnce(async () => {
controller.abort(createAgentRunRestartAbortError());
return {
payloads: [{ text: "stale result" }],
meta: { durationMs: 1 },
} satisfies EmbeddedAgentRunResult;
});
await expect(
runCliAgentWithLifecycle({
runId: "run-restart",
provider: "claude-cli",
runParams: {
sessionId: "session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
prompt: "hello",
provider: "claude-cli",
model: "claude",
thinkLevel: "off",
timeoutMs: 1_000,
runId: "run-restart",
abortSignal: controller.signal,
},
}),
).rejects.toThrow("agent run aborted for restart");
stop();
const terminal = events.find(
(event) => event.stream === "lifecycle" && event.data?.phase === "error",
);
expect(terminal?.data).toMatchObject({
aborted: true,
stopReason: "restart",
});
expect(events.some((event) => event.stream === "assistant")).toBe(false);
});
it("propagates yielded result metadata on lifecycle end", async () => {
const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
const stop = onAgentEvent((event) => {
if (event.runId === "run-yielded") {
events.push(event);
}
});
cliDispatchState.runCliAgentMock.mockResolvedValueOnce({
payloads: [],
meta: {
durationMs: 1,
yielded: true,
livenessState: "paused",
stopReason: "end_turn",
},
} satisfies EmbeddedAgentRunResult);
try {
await runCliAgentWithLifecycle({
runId: "run-yielded",
provider: "claude-cli",
runParams: {
sessionId: "session-1",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
prompt: "hello",
provider: "claude-cli",
model: "claude",
thinkLevel: "off",
timeoutMs: 1_000,
runId: "run-yielded",
},
});
} finally {
stop();
}
const terminal = events.find(
(event) => event.stream === "lifecycle" && event.data?.phase === "end",
);
expect(terminal?.data).toMatchObject({
yielded: true,
livenessState: "paused",
stopReason: "end_turn",
});
});
});
describe("keepCliSessionBindingOnlyWhenReused", () => {
it("keeps the first room-event CLI binding when no binding exists yet", () => {
const result = {
payloads: [],
meta: {
durationMs: 1,
agentMeta: {
sessionId: "new-cli-session",
provider: "claude-cli",
model: "claude-opus-4-8",
cliSessionBinding: {
sessionId: "new-cli-session",
authProfileId: "profile",
},
},
},
} satisfies EmbeddedAgentRunResult;
expect(keepCliSessionBindingOnlyWhenReused({ result })).toBe(result);
});
it("drops a replacement room-event CLI binding when an existing binding was reused", () => {
const onDroppedReplacement = vi.fn();
const result = keepCliSessionBindingOnlyWhenReused({
existingSessionId: "existing-cli-session",
onDroppedReplacement,
result: {
payloads: [],
meta: {
durationMs: 1,
agentMeta: {
sessionId: "replacement-cli-session",
provider: "claude-cli",
model: "claude-opus-4-8",
cliSessionBinding: {
sessionId: "replacement-cli-session",
authProfileId: "profile",
},
},
},
} satisfies EmbeddedAgentRunResult,
});
expect(onDroppedReplacement).toHaveBeenCalledOnce();
expect(result.meta.agentMeta?.sessionId).toBe("");
expect(result.meta.agentMeta?.cliSessionBinding).toBeUndefined();
});
});
describe("createCliToolSummaryTracker", () => {
const startEvent = {
name: "exec",
phase: "start" as const,
args: { command: "date -u" },
toolCallId: "tool-1",
};
const resultEvent = {
name: "exec",
phase: "result" as const,
args: undefined,
toolCallId: "tool-1",
isError: false,
result: { content: [{ type: "text", text: "Wed Jun 10 2026" }] },
};
it("delivers a tool summary for a result using meta captured at start", async () => {
const deliver = vi.fn();
const tracker = createCliToolSummaryTracker({
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
deliver,
});
await tracker.noteToolEvent(startEvent);
await tracker.noteToolEvent(resultEvent);
expect(deliver).toHaveBeenCalledTimes(1);
const payload = deliver.mock.calls[0]?.[0] as { text: string; isError?: boolean };
expect(payload.text).toContain("date -u");
expect(payload.text).not.toContain("Wed Jun 10 2026");
expect(payload.isError).toBeUndefined();
});
it("appends the tool output block when full verbose output is enabled", async () => {
const deliver = vi.fn();
const tracker = createCliToolSummaryTracker({
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => true,
deliver,
});
await tracker.noteToolEvent(startEvent);
await tracker.noteToolEvent(resultEvent);
const payload = deliver.mock.calls[0]?.[0] as { text: string };
expect(payload.text).toContain("```txt");
expect(payload.text).toContain("Wed Jun 10 2026");
});
it("renders top-level structured CLI results in full verbose output", async () => {
const deliver = vi.fn();
const tracker = createCliToolSummaryTracker({
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => true,
deliver,
});
await tracker.noteToolEvent(startEvent);
await tracker.noteToolEvent({
...resultEvent,
result: [{ type: "web_search_result", title: "OpenClaw", url: "https://example.com" }],
});
const payload = deliver.mock.calls[0]?.[0] as { text: string };
expect(payload.text).toContain('"type":"web_search_result"');
expect(payload.text).toContain('"title":"OpenClaw"');
});
it("emits nothing while tool summaries are disabled", async () => {
const deliver = vi.fn();
const tracker = createCliToolSummaryTracker({
shouldEmitToolResult: () => false,
shouldEmitToolOutput: () => false,
deliver,
});
await tracker.noteToolEvent(startEvent);
await tracker.noteToolEvent(resultEvent);
expect(deliver).not.toHaveBeenCalled();
});
it("propagates tool errors on the summary payload", async () => {
const deliver = vi.fn();
const tracker = createCliToolSummaryTracker({
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
deliver,
});
await tracker.noteToolEvent(startEvent);
await tracker.noteToolEvent({ ...resultEvent, isError: true });
const payload = deliver.mock.calls[0]?.[0] as { isError?: boolean };
expect(payload.isError).toBe(true);
});
it("summarizes results without a tracked start event", async () => {
const deliver = vi.fn();
const tracker = createCliToolSummaryTracker({
shouldEmitToolResult: () => true,
shouldEmitToolOutput: () => false,
deliver,
});
await tracker.noteToolEvent({ ...resultEvent, toolCallId: "unseen" });
expect(deliver).toHaveBeenCalledTimes(1);
});
});
describe("runCliAgentWithLifecycle fast auto progress", () => {
it("emits auto-off after the first CLI tool boundary past the threshold", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const events: Array<{ stream: string; data: Record<string, unknown> }> = [];
const stop = onAgentEvent((evt) => {
if (evt.runId === "run-fast-cli") {
events.push({ stream: evt.stream, data: evt.data });
}
});
const progressPayloads: string[] = [];
cliDispatchState.runCliAgentMock.mockImplementation(async () => {
emitAgentEvent({
runId: "run-fast-cli",
stream: "tool",
data: { phase: "start", name: "bash", toolCallId: "call-1" },
});
vi.setSystemTime(7_100);
emitAgentEvent({
runId: "run-fast-cli",
stream: "tool",
data: { phase: "result", name: "bash", toolCallId: "call-1" },
});
return {
payloads: [{ text: "done" }],
meta: {
durationMs: 7_100,
agentMeta: { sessionId: "session-1", provider: "codex-cli", model: "gpt-5.5" },
},
} satisfies EmbeddedAgentRunResult;
});
await runCliAgentWithLifecycle({
runId: "run-fast-cli",
provider: "codex-cli",
runParams: {
sessionId: "session-1",
sessionKey: "agent:main:cli-fast",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp/workspace",
prompt: "run one tool",
provider: "codex-cli",
model: "gpt-5.5",
timeoutMs: 60_000,
runId: "run-fast-cli",
fastMode: "auto",
fastModeStartedAtMs: 1_000,
fastModeAutoOnSeconds: 5,
},
onFastModeAutoProgress: async (payload) => {
if (payload.text) {
progressPayloads.push(payload.text);
}
},
});
stop();
const summaries = events
.filter((event) => event.stream === "item")
.map((event) => event.data.summary);
expect(summaries).toContain("💨Fast: auto-off(6s>=5s)");
expect(summaries).toContain("💨Fast: auto-on");
expect(progressPayloads).toEqual(["💨Fast: auto-off(6s>=5s)", "💨Fast: auto-on"]);
});
});