mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 02:15:26 -06:00
8d168c836a
* 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>
386 lines
12 KiB
TypeScript
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"]);
|
|
});
|
|
});
|