mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-15 19:21:08 +00:00
338 lines
9.4 KiB
TypeScript
338 lines
9.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const callGatewayMock = vi.fn();
|
|
vi.mock("../gateway/call.js", () => ({
|
|
callGateway: (opts: unknown) => callGatewayMock(opts),
|
|
}));
|
|
|
|
import {
|
|
__testing,
|
|
readLatestAssistantReply,
|
|
readLatestAssistantReplySnapshot,
|
|
waitForAgentRun,
|
|
waitForAgentRunsToDrain,
|
|
waitForAgentRunAndReadUpdatedAssistantReply,
|
|
} from "./run-wait.js";
|
|
|
|
describe("readLatestAssistantReply", () => {
|
|
beforeEach(() => {
|
|
callGatewayMock.mockClear();
|
|
__testing.setDepsForTest({
|
|
callGateway: async (opts) => await callGatewayMock(opts),
|
|
});
|
|
});
|
|
|
|
it("returns the most recent assistant message when compaction markers trail history", async () => {
|
|
callGatewayMock.mockResolvedValue({
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "All checks passed and changes were pushed." }],
|
|
},
|
|
{ role: "toolResult", content: [{ type: "text", text: "tool output" }] },
|
|
{ role: "system", content: [{ type: "text", text: "Compaction" }] },
|
|
],
|
|
});
|
|
|
|
const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" });
|
|
|
|
expect(result).toBe("All checks passed and changes were pushed.");
|
|
expect(callGatewayMock).toHaveBeenCalledWith({
|
|
method: "chat.history",
|
|
params: { sessionKey: "agent:main:child", limit: 50 },
|
|
});
|
|
});
|
|
|
|
it("falls back to older assistant text when latest assistant has no text", async () => {
|
|
callGatewayMock.mockResolvedValue({
|
|
messages: [
|
|
{ role: "assistant", content: [{ type: "text", text: "older output" }] },
|
|
{ role: "assistant", content: [] },
|
|
{ role: "system", content: [{ type: "text", text: "Compaction" }] },
|
|
],
|
|
});
|
|
|
|
const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" });
|
|
|
|
expect(result).toBe("older output");
|
|
});
|
|
|
|
it("returns assistant fingerprints for delta comparisons", async () => {
|
|
callGatewayMock.mockResolvedValue({
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "new output" }],
|
|
timestamp: 42,
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await readLatestAssistantReplySnapshot({ sessionKey: "agent:main:child" });
|
|
|
|
expect(result.text).toBe("new output");
|
|
expect(result.fingerprint).toContain('"timestamp":42');
|
|
});
|
|
|
|
it("reads only final_answer text from phased assistant history", async () => {
|
|
callGatewayMock.mockResolvedValue({
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Need fix line quoting properly.",
|
|
textSignature: JSON.stringify({ v: 1, id: "commentary", phase: "commentary" }),
|
|
},
|
|
{
|
|
type: "text",
|
|
text: "Fixed the quoting issue.",
|
|
textSignature: JSON.stringify({ v: 1, id: "final", phase: "final_answer" }),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" });
|
|
|
|
expect(result).toBe("Fixed the quoting issue.");
|
|
});
|
|
|
|
it("preserves spaces across split final_answer history blocks", async () => {
|
|
callGatewayMock.mockResolvedValue({
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Need fix line quoting properly.",
|
|
textSignature: JSON.stringify({ v: 1, id: "commentary", phase: "commentary" }),
|
|
},
|
|
{
|
|
type: "text",
|
|
text: "Hi ",
|
|
textSignature: JSON.stringify({ v: 1, id: "final_1", phase: "final_answer" }),
|
|
},
|
|
{
|
|
type: "text",
|
|
text: "there",
|
|
textSignature: JSON.stringify({ v: 1, id: "final_2", phase: "final_answer" }),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" });
|
|
|
|
expect(result).toBe("Hi there");
|
|
});
|
|
});
|
|
|
|
describe("waitForAgentRun", () => {
|
|
beforeEach(() => {
|
|
callGatewayMock.mockClear();
|
|
__testing.setDepsForTest({
|
|
callGateway: async (opts) => await callGatewayMock(opts),
|
|
});
|
|
});
|
|
|
|
it("maps gateway timeouts to timeout status", async () => {
|
|
callGatewayMock.mockRejectedValue(new Error("gateway timeout while waiting"));
|
|
|
|
const result = await waitForAgentRun({ runId: "run-1", timeoutMs: 500 });
|
|
|
|
expect(result).toEqual({
|
|
status: "timeout",
|
|
error: "gateway timeout while waiting",
|
|
});
|
|
});
|
|
|
|
it("preserves timing metadata from agent.wait", async () => {
|
|
callGatewayMock.mockResolvedValue({
|
|
status: "ok",
|
|
startedAt: 100,
|
|
endedAt: 200,
|
|
});
|
|
|
|
const result = await waitForAgentRun({ runId: "run-2", timeoutMs: 500 });
|
|
|
|
expect(result).toEqual({
|
|
status: "ok",
|
|
startedAt: 100,
|
|
endedAt: 200,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("waitForAgentRunAndReadUpdatedAssistantReply", () => {
|
|
beforeEach(() => {
|
|
callGatewayMock.mockClear();
|
|
__testing.setDepsForTest({
|
|
callGateway: async (opts) => await callGatewayMock(opts),
|
|
});
|
|
});
|
|
|
|
it("returns undefined when the latest assistant fingerprint matches the baseline", async () => {
|
|
const assistantMessage = {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "same reply" }],
|
|
timestamp: 42,
|
|
};
|
|
callGatewayMock
|
|
.mockResolvedValueOnce({
|
|
status: "ok",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
messages: [assistantMessage],
|
|
});
|
|
|
|
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
|
|
runId: "run-1",
|
|
sessionKey: "agent:main:child",
|
|
timeoutMs: 1_000,
|
|
baseline: {
|
|
text: "same reply",
|
|
fingerprint: JSON.stringify(assistantMessage),
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
status: "ok",
|
|
replyText: undefined,
|
|
});
|
|
});
|
|
|
|
it("returns the new assistant text when the fingerprint changes", async () => {
|
|
callGatewayMock
|
|
.mockResolvedValueOnce({
|
|
status: "ok",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "fresh reply" }],
|
|
timestamp: 99,
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await waitForAgentRunAndReadUpdatedAssistantReply({
|
|
runId: "run-2",
|
|
sessionKey: "agent:main:child",
|
|
timeoutMs: 1_000,
|
|
baseline: {
|
|
text: "older reply",
|
|
fingerprint: "old-fingerprint",
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
status: "ok",
|
|
replyText: "fresh reply",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("waitForAgentRunsToDrain", () => {
|
|
beforeEach(() => {
|
|
callGatewayMock.mockClear();
|
|
__testing.setDepsForTest({
|
|
callGateway: async (opts) => await callGatewayMock(opts),
|
|
});
|
|
});
|
|
|
|
it("waits across rounds until descendant runs stop changing", async () => {
|
|
let activeRunIds = ["run-1"];
|
|
callGatewayMock.mockImplementation(async (opts) => {
|
|
const request = opts as { method?: string; params?: { runId?: string } };
|
|
if (request.method !== "agent.wait") {
|
|
throw new Error(`unexpected method: ${String(request.method)}`);
|
|
}
|
|
if (request.params?.runId === "run-1") {
|
|
activeRunIds = ["run-2"];
|
|
} else if (request.params?.runId === "run-2") {
|
|
activeRunIds = [];
|
|
}
|
|
return { status: "ok" };
|
|
});
|
|
|
|
const result = await waitForAgentRunsToDrain({
|
|
timeoutMs: 1_000,
|
|
getPendingRunIds: () => activeRunIds,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
timedOut: false,
|
|
pendingRunIds: [],
|
|
deadlineAtMs: expect.any(Number),
|
|
});
|
|
expect(callGatewayMock.mock.calls.map((call) => call[0])).toEqual([
|
|
{
|
|
method: "agent.wait",
|
|
params: {
|
|
runId: "run-1",
|
|
timeoutMs: expect.any(Number),
|
|
},
|
|
timeoutMs: expect.any(Number),
|
|
},
|
|
{
|
|
method: "agent.wait",
|
|
params: {
|
|
runId: "run-2",
|
|
timeoutMs: expect.any(Number),
|
|
},
|
|
timeoutMs: expect.any(Number),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("deduplicates and trims pending run ids", async () => {
|
|
callGatewayMock.mockResolvedValue({ status: "ok" });
|
|
let activeRunIds = [" run-1 ", "run-1", "", "run-2"];
|
|
|
|
const result = await waitForAgentRunsToDrain({
|
|
timeoutMs: 1_000,
|
|
getPendingRunIds: () => {
|
|
const current = activeRunIds;
|
|
activeRunIds = [];
|
|
return current;
|
|
},
|
|
});
|
|
|
|
expect(result.timedOut).toBe(false);
|
|
expect(callGatewayMock.mock.calls).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps the initial pending run ids before refreshing", async () => {
|
|
callGatewayMock.mockResolvedValue({ status: "ok" });
|
|
let activeRunIds = ["run-2"];
|
|
|
|
const result = await waitForAgentRunsToDrain({
|
|
timeoutMs: 1_000,
|
|
initialPendingRunIds: ["run-1"],
|
|
getPendingRunIds: () => {
|
|
const current = activeRunIds;
|
|
activeRunIds = [];
|
|
return current;
|
|
},
|
|
});
|
|
|
|
expect(result.timedOut).toBe(false);
|
|
expect(callGatewayMock.mock.calls.map((call) => call[0])).toEqual([
|
|
expect.objectContaining({
|
|
method: "agent.wait",
|
|
params: expect.objectContaining({ runId: "run-1" }),
|
|
}),
|
|
expect.objectContaining({
|
|
method: "agent.wait",
|
|
params: expect.objectContaining({ runId: "run-2" }),
|
|
}),
|
|
]);
|
|
});
|
|
});
|