First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("createAccessGate", () => {
|
||||
it("allows when token is unset", async () => {
|
||||
const { createAccessGate } = await import("../../server/access-gate");
|
||||
const gate = createAccessGate({ token: "" });
|
||||
expect(gate.allowUpgrade({ headers: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects /api requests without cookie when enabled", async () => {
|
||||
const { createAccessGate } = await import("../../server/access-gate");
|
||||
const gate = createAccessGate({ token: "abc" });
|
||||
|
||||
let statusCode = 0;
|
||||
let ended = false;
|
||||
const res = {
|
||||
setHeader: () => {},
|
||||
end: () => {
|
||||
ended = true;
|
||||
},
|
||||
get statusCode() {
|
||||
return statusCode;
|
||||
},
|
||||
set statusCode(value: number) {
|
||||
statusCode = value;
|
||||
},
|
||||
};
|
||||
|
||||
const handled = gate.handleHttp(
|
||||
{ url: "/api/studio", headers: { host: "example.test" } },
|
||||
res
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(statusCode).toBe(401);
|
||||
expect(ended).toBe(true);
|
||||
});
|
||||
|
||||
it("allows upgrades when cookie matches", async () => {
|
||||
const { createAccessGate } = await import("../../server/access-gate");
|
||||
const gate = createAccessGate({ token: "abc" });
|
||||
expect(
|
||||
gate.allowUpgrade({ headers: { cookie: "studio_access=abc" } })
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentBrainPanel } from "@/features/agents/components/AgentInspectPanels";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (agentId: string, name: string, sessionKey: string): AgentState => ({
|
||||
agentId,
|
||||
name,
|
||||
sessionKey,
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: null,
|
||||
thinkingLevel: null,
|
||||
avatarSeed: `seed-${agentId}`,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
const createMockClient = () => {
|
||||
const filesByAgent: Record<string, Record<string, string>> = {
|
||||
"agent-1": {
|
||||
"AGENTS.md": "alpha agents",
|
||||
"SOUL.md": "# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful.",
|
||||
"IDENTITY.md": "# IDENTITY.md - Who Am I?\n\n- Name: Alpha\n- Creature: droid\n- Vibe: calm\n- Emoji: 🤖\n",
|
||||
"USER.md": "# USER.md - About Your Human\n\n- Name: George\n- What to call them: GP\n\n## Context\n\nBuilding Claw3D.",
|
||||
"TOOLS.md": "tool notes",
|
||||
"HEARTBEAT.md": "heartbeat notes",
|
||||
"MEMORY.md": "durable memory",
|
||||
},
|
||||
"agent-2": {
|
||||
"AGENTS.md": "beta agents",
|
||||
},
|
||||
};
|
||||
|
||||
const calls: Array<{ method: string; params: unknown }> = [];
|
||||
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params: unknown) => {
|
||||
calls.push({ method, params });
|
||||
if (method === "agents.files.get") {
|
||||
const record = params && typeof params === "object" ? (params as Record<string, unknown>) : {};
|
||||
const agentId = typeof record.agentId === "string" ? record.agentId : "";
|
||||
const name = typeof record.name === "string" ? record.name : "";
|
||||
const content = filesByAgent[agentId]?.[name];
|
||||
if (typeof content !== "string") {
|
||||
return { file: { name, missing: true } };
|
||||
}
|
||||
return { file: { name, missing: false, content } };
|
||||
}
|
||||
if (method === "agents.files.set") {
|
||||
const record = params && typeof params === "object" ? (params as Record<string, unknown>) : {};
|
||||
const agentId = typeof record.agentId === "string" ? record.agentId : "";
|
||||
const name = typeof record.name === "string" ? record.name : "";
|
||||
const content = typeof record.content === "string" ? record.content : "";
|
||||
if (!filesByAgent[agentId]) {
|
||||
filesByAgent[agentId] = {};
|
||||
}
|
||||
filesByAgent[agentId][name] = content;
|
||||
return { ok: true };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
return { client, calls, filesByAgent };
|
||||
};
|
||||
|
||||
describe("AgentBrainPanel", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders_behavior_sections_and_loads_agent_files", async () => {
|
||||
const { client } = createMockClient();
|
||||
const agents = [
|
||||
createAgent("agent-1", "Alpha", "session-1"),
|
||||
createAgent("agent-2", "Beta", "session-2"),
|
||||
];
|
||||
|
||||
render(
|
||||
createElement(AgentBrainPanel, {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "agent-1",
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Persona" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Directives" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Context" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Identity" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Directives")).toHaveValue("alpha agents");
|
||||
expect(screen.getByLabelText("Persona")).toHaveValue(
|
||||
"# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful."
|
||||
);
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("Alpha");
|
||||
});
|
||||
|
||||
it("shows_actionable_message_when_session_key_missing", async () => {
|
||||
const { client } = createMockClient();
|
||||
const agents = [createAgent("", "Alpha", "session-1")];
|
||||
|
||||
render(
|
||||
createElement(AgentBrainPanel, {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "",
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agent ID is missing for this agent.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("saves_updated_behavior_files", async () => {
|
||||
const { client, calls, filesByAgent } = createMockClient();
|
||||
const agents = [createAgent("agent-1", "Alpha", "session-1")];
|
||||
|
||||
render(
|
||||
createElement(AgentBrainPanel, {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "agent-1",
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Directives")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Directives"), {
|
||||
target: { value: "alpha directives updated" },
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(calls.some((entry) => entry.method === "agents.files.set")).toBe(true);
|
||||
});
|
||||
expect(filesByAgent["agent-1"]["AGENTS.md"]).toBe("alpha directives updated");
|
||||
});
|
||||
|
||||
it("discards_unsaved_changes_without_writing_files", async () => {
|
||||
const { client, calls } = createMockClient();
|
||||
const agents = [createAgent("agent-1", "Alpha", "session-1")];
|
||||
|
||||
render(
|
||||
createElement(AgentBrainPanel, {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "agent-1",
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name"), {
|
||||
target: { value: "Alpha Prime" },
|
||||
});
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("Alpha Prime");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||
expect(screen.getByLabelText("Name")).toHaveValue("Alpha");
|
||||
expect(calls.some((entry) => entry.method === "agents.files.set")).toBe(false);
|
||||
});
|
||||
|
||||
it("does_not_render_name_editor_in_personality_panel", async () => {
|
||||
const { client } = createMockClient();
|
||||
const agents = [createAgent("agent-1", "Alpha", "session-1")];
|
||||
|
||||
render(
|
||||
createElement(AgentBrainPanel, {
|
||||
client,
|
||||
agents,
|
||||
selectedAgentId: "agent-1",
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Persona" })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
|
||||
const createAgent = (): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
const createApproval = (overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({
|
||||
id: "approval-1",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
command: "npm run test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
resolvedPath: "/bin/npm",
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 1_700_000_000_000,
|
||||
resolving: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("AgentChatPanel exec approvals", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders pending approval card with metadata", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models: [],
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
pendingExecApprovals: [createApproval()],
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("exec-approval-card-approval-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Exec approval required")).toBeInTheDocument();
|
||||
expect(screen.getByText("npm run test")).toBeInTheDocument();
|
||||
expect(screen.getByText("Host: gateway")).toBeInTheDocument();
|
||||
expect(screen.getByText("CWD: /repo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders pending approvals after transcript content", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
...createAgent(),
|
||||
outputLines: ["> inspect approvals", "assistant says hello"],
|
||||
},
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models: [],
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
pendingExecApprovals: [createApproval()],
|
||||
})
|
||||
);
|
||||
|
||||
const transcriptText = screen.getByText("assistant says hello");
|
||||
const approvalCard = screen.getByTestId("exec-approval-card-approval-1");
|
||||
expect(transcriptText.compareDocumentPosition(approvalCard) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("invokes resolve callback for all approval decisions", () => {
|
||||
const onResolveExecApproval = vi.fn();
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models: [],
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
pendingExecApprovals: [createApproval()],
|
||||
onResolveExecApproval,
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Allow once for exec approval approval-1" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Always allow for exec approval approval-1" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Deny exec approval approval-1" }));
|
||||
|
||||
expect(onResolveExecApproval).toHaveBeenNthCalledWith(1, "approval-1", "allow-once");
|
||||
expect(onResolveExecApproval).toHaveBeenNthCalledWith(2, "approval-1", "allow-always");
|
||||
expect(onResolveExecApproval).toHaveBeenNthCalledWith(3, "approval-1", "deny");
|
||||
});
|
||||
|
||||
it("disables actions while approval is resolving", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models: [],
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
pendingExecApprovals: [createApproval({ resolving: true })],
|
||||
onResolveExecApproval: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Allow once for exec approval approval-1" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Always allow for exec approval approval-1" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Deny exec approval approval-1" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createElement, useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
|
||||
const createAgent = (patch?: Partial<AgentState>): AgentState => {
|
||||
const base: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: null,
|
||||
thinkingLevel: null,
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
};
|
||||
const merged = { ...base, ...(patch ?? {}) };
|
||||
|
||||
return {
|
||||
...merged,
|
||||
historyFetchLimit: merged.historyFetchLimit ?? null,
|
||||
historyFetchedCount: merged.historyFetchedCount ?? null,
|
||||
historyMaybeTruncated: merged.historyMaybeTruncated ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
describe("AgentChatPanel composer autoresize", () => {
|
||||
const models: GatewayModelChoice[] = [{ provider: "openai", id: "gpt-5", name: "gpt-5" }];
|
||||
let originalScrollHeightDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
if (originalScrollHeightDescriptor) {
|
||||
Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", originalScrollHeightDescriptor);
|
||||
} else {
|
||||
delete (HTMLTextAreaElement.prototype as unknown as { scrollHeight?: unknown }).scrollHeight;
|
||||
}
|
||||
originalScrollHeightDescriptor = undefined;
|
||||
});
|
||||
|
||||
it("resets_textarea_height_after_send_when_draft_is_cleared", async () => {
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
|
||||
|
||||
originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"scrollHeight"
|
||||
);
|
||||
Object.defineProperty(HTMLTextAreaElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.value.trim().length > 0 ? 200 : 20;
|
||||
},
|
||||
});
|
||||
|
||||
const Harness = () => {
|
||||
const [agent, setAgent] = useState(
|
||||
createAgent({
|
||||
draft: "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8",
|
||||
})
|
||||
);
|
||||
|
||||
return createElement(AgentChatPanel, {
|
||||
agent,
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: (value: string) => {
|
||||
setAgent((prev) => ({ ...prev, draft: value }));
|
||||
},
|
||||
onSend: () => {
|
||||
setAgent((prev) => ({ ...prev, draft: "" }));
|
||||
},
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
render(createElement(Harness));
|
||||
|
||||
const textarea = screen.getByPlaceholderText("type a message") as HTMLTextAreaElement;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textarea.style.height).toBe("200px");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textarea.value).toBe("");
|
||||
});
|
||||
|
||||
expect(textarea.style.height).toBe("20px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,618 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
import { formatThinkingMarkdown } from "@/lib/text/message-extract";
|
||||
|
||||
const createAgent = (): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: null,
|
||||
thinkingLevel: null,
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
describe("AgentChatPanel controls", () => {
|
||||
const models: GatewayModelChoice[] = [
|
||||
{ provider: "openai", id: "gpt-5", name: "gpt-5", reasoning: true },
|
||||
{ provider: "openai", id: "gpt-5-mini", name: "gpt-5-mini", reasoning: false },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders_runtime_controls_in_agent_header_and_no_inline_name_editor", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onRename: vi.fn(async () => true),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Thinking")).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue("Agent One")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-rename-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Rename agent")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-new-session-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Start new session")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("agent-settings-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Open behavior")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Inspect")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renames_agent_inline_from_header", async () => {
|
||||
const onRename = vi.fn(async () => true);
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onRename,
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-rename-toggle"));
|
||||
const input = screen.getByTestId("agent-rename-input") as HTMLInputElement;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveFocus();
|
||||
expect(input.selectionStart).toBe(0);
|
||||
expect(input.selectionEnd).toBe("Agent One".length);
|
||||
});
|
||||
|
||||
fireEvent.change(input, { target: { value: " Agent Prime " } });
|
||||
fireEvent.click(screen.getByTestId("agent-rename-save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRename).toHaveBeenCalledWith("Agent Prime");
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels_inline_rename_without_saving", () => {
|
||||
const onRename = vi.fn(async () => true);
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onRename,
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-rename-toggle"));
|
||||
fireEvent.change(screen.getByTestId("agent-rename-input"), {
|
||||
target: { value: "Edited Name" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("agent-rename-cancel"));
|
||||
|
||||
expect(onRename).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("agent-rename-input")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes_on_new_session_when_control_clicked", () => {
|
||||
const onNewSession = vi.fn(async () => {});
|
||||
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onNewSession,
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-new-session-toggle"));
|
||||
expect(onNewSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does_not_render_inline_status_badge_markers", () => {
|
||||
const { rerender, container } = render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const idleBadge = container.querySelector('[data-status="idle"]');
|
||||
expect(idleBadge).toBeNull();
|
||||
|
||||
rerender(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...createAgent(), status: "running" },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const runningBadge = container.querySelector('[data-status="running"]');
|
||||
expect(runningBadge).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes_on_model_change_when_model_select_changes_and_blurs_select", () => {
|
||||
const onModelChange = vi.fn();
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange,
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const modelSelect = screen.getByLabelText("Model") as HTMLSelectElement;
|
||||
modelSelect.focus();
|
||||
expect(modelSelect).toHaveFocus();
|
||||
|
||||
fireEvent.change(modelSelect, {
|
||||
target: { value: "openai/gpt-5-mini" },
|
||||
});
|
||||
expect(onModelChange).toHaveBeenCalledWith("openai/gpt-5-mini");
|
||||
expect(modelSelect).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it("invokes_on_thinking_change_when_thinking_select_changes", () => {
|
||||
const onThinkingChange = vi.fn();
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange,
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Thinking"), {
|
||||
target: { value: "high" },
|
||||
});
|
||||
expect(onThinkingChange).toHaveBeenCalledWith("high");
|
||||
});
|
||||
|
||||
it("invokes_on_open_settings_when_control_clicked", () => {
|
||||
const onOpenSettings = vi.fn();
|
||||
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings,
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("agent-settings-toggle"));
|
||||
expect(onOpenSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows_stop_button_while_running_and_invokes_stop_handler", () => {
|
||||
const onStopRun = vi.fn();
|
||||
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...createAgent(), status: "running" },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun,
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
||||
expect(onStopRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows_send_while_running_so_follow_up_can_be_queued", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...createAgent(), status: "running" },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend,
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText("type a message");
|
||||
fireEvent.change(textarea, { target: { value: "follow up" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith("follow up");
|
||||
});
|
||||
|
||||
it("renders_queue_bar_and_supports_removing_queued_messages", () => {
|
||||
const onRemoveQueuedMessage = vi.fn();
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...createAgent(), queuedMessages: ["first queued", "second queued"] },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onRemoveQueuedMessage,
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("queued-messages-bar")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove queued message 1" }));
|
||||
expect(onRemoveQueuedMessage).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("disables_stop_button_with_tooltip_when_stop_is_unavailable", () => {
|
||||
const stopDisabledReason =
|
||||
"This task is running as an automatic heartbeat check. Stopping heartbeat runs from Studio isn't available yet (coming soon).";
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...createAgent(), status: "running" },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
stopDisabledReason,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const stopButton = screen.getByRole("button", {
|
||||
name: `Stop unavailable: ${stopDisabledReason}`,
|
||||
});
|
||||
expect(stopButton).toBeDisabled();
|
||||
expect(stopButton.parentElement).toHaveAttribute("title", stopDisabledReason);
|
||||
});
|
||||
|
||||
it("shows_thinking_indicator_while_running_before_stream_text", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...createAgent(), status: "running", outputLines: ["> test"] },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-typing-indicator")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("agent-typing-indicator")).getByText("Thinking")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows_thinking_indicator_after_stream_starts", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
...createAgent(),
|
||||
status: "running",
|
||||
outputLines: ["> test"],
|
||||
streamText: "working on it",
|
||||
},
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("agent-typing-indicator")).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("agent-typing-indicator")).getByText("Thinking")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does_not_render_duplicate_typing_indicator_when_internal_thinking_is_visible", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
...createAgent(),
|
||||
status: "running",
|
||||
outputLines: ["> test", formatThinkingMarkdown("thinking now")],
|
||||
},
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("agent-typing-indicator")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Thinking (internal)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders thinking row collapsed by default", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
...createAgent(),
|
||||
status: "running",
|
||||
outputLines: ["> test", formatThinkingMarkdown("thinking now"), "final response"],
|
||||
},
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const details = screen.getByText("Thinking (internal)").closest("details");
|
||||
expect(details).toBeTruthy();
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
});
|
||||
|
||||
it("does_not_overwrite_active_draft_with_stale_nonempty_agent_draft", () => {
|
||||
const onDraftChange = vi.fn();
|
||||
const onSend = vi.fn();
|
||||
const { rerender } = render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange,
|
||||
onSend,
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText("type a message") as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "hello world" } });
|
||||
expect(textarea.value).toBe("hello world");
|
||||
|
||||
rerender(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...createAgent(), draft: "hello" },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange,
|
||||
onSend,
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
expect(textarea.value).toBe("hello world");
|
||||
|
||||
rerender(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...createAgent(), draft: "" },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange,
|
||||
onSend,
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
expect(textarea.value).toBe("");
|
||||
});
|
||||
|
||||
it("does_not_send_when_enter_is_pressed_during_composition", () => {
|
||||
const onSend = vi.fn();
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: createAgent(),
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend,
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText("type a message");
|
||||
fireEvent.change(textarea, { target: { value: "draft text" } });
|
||||
|
||||
fireEvent.keyDown(textarea, {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 229,
|
||||
isComposing: true,
|
||||
});
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter", code: "Enter" });
|
||||
expect(onSend).toHaveBeenCalledWith("draft text");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
import { formatThinkingMarkdown, formatToolCallMarkdown } from "@/lib/text/message-extract";
|
||||
|
||||
const createAgent = (): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: null,
|
||||
thinkingLevel: null,
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
describe("AgentChatPanel markdown rendering", () => {
|
||||
const models: GatewayModelChoice[] = [{ provider: "openai", id: "gpt-5", name: "gpt-5" }];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders assistant markdown separately from tool detail cards", () => {
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
...createAgent(),
|
||||
outputLines: [
|
||||
"> summarize rendering changes",
|
||||
"Here is the output:\n- keep assistant markdown\n- keep tool boundaries\n\n```ts\nconst answer = 42;\n```",
|
||||
"[[tool-result]] shell (call-2)\nok\n```text\ndone\n```",
|
||||
],
|
||||
},
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const assistantListItem = screen.getByText("keep assistant markdown");
|
||||
expect(assistantListItem).toBeInTheDocument();
|
||||
expect(screen.getByText("keep tool boundaries")).toBeInTheDocument();
|
||||
expect(screen.getByText("const answer = 42;")).toBeInTheDocument();
|
||||
expect(assistantListItem.closest("details")).toBeNull();
|
||||
|
||||
expect(screen.queryByText(/^Output$/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Extract output")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Thinking (internal)"));
|
||||
const toolSummary = screen.getByText("SHELL · ok");
|
||||
const toolDetails = toolSummary.closest("details");
|
||||
expect(toolDetails).toBeTruthy();
|
||||
fireEvent.click(toolSummary);
|
||||
expect(within(toolDetails as HTMLElement).getByText("done")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("nests tool calls inside the associated thinking details block", () => {
|
||||
const firstToolCall = formatToolCallMarkdown({
|
||||
id: "call_1",
|
||||
name: "memory_search",
|
||||
arguments: { query: "priority ledger" },
|
||||
});
|
||||
const secondToolCall = formatToolCallMarkdown({
|
||||
id: "call_2",
|
||||
name: "memory_search",
|
||||
arguments: { query: "youtube channel tasks" },
|
||||
});
|
||||
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
...createAgent(),
|
||||
outputLines: [
|
||||
"> how are you prioritizing this?",
|
||||
firstToolCall,
|
||||
secondToolCall,
|
||||
formatThinkingMarkdown("Proposing multi-lane tracking system"),
|
||||
"Short answer: a pinned priority ledger keeps the loop aligned.",
|
||||
],
|
||||
},
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const thinkingDetails = screen.getByText("Thinking (internal)").closest("details");
|
||||
expect(thinkingDetails).toBeTruthy();
|
||||
fireEvent.click(screen.getByText("Thinking (internal)"));
|
||||
expect(within(thinkingDetails as HTMLElement).getByText(/proposing multi-lane tracking system/i)).toBeInTheDocument();
|
||||
|
||||
const memorySearchSummaries = screen.getAllByText(/MEMORY_SEARCH/);
|
||||
expect(memorySearchSummaries.length).toBe(2);
|
||||
for (const summary of memorySearchSummaries) {
|
||||
expect(thinkingDetails).toContainElement(summary);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders read tool calls as inline path labels instead of collapsible JSON blocks", () => {
|
||||
const readToolCall = formatToolCallMarkdown({
|
||||
id: "call_read_1",
|
||||
name: "read",
|
||||
arguments: { file_path: "/tmp/README.md" },
|
||||
});
|
||||
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
...createAgent(),
|
||||
outputLines: [formatThinkingMarkdown("Reviewing docs"), readToolCall],
|
||||
},
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Thinking (internal)"));
|
||||
expect(screen.getByText("read /tmp/README.md")).toBeInTheDocument();
|
||||
expect(screen.queryByText("read /tmp/README.md", { selector: "summary" })).toBeNull();
|
||||
expect(screen.queryByText(/"file_path"/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
|
||||
const createAgent = (): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: null,
|
||||
thinkingLevel: null,
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
describe("AgentChatPanel scrolling", () => {
|
||||
const models: GatewayModelChoice[] = [{ provider: "openai", id: "gpt-5", name: "gpt-5" }];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (Element.prototype as unknown as { scrollIntoView?: unknown }).scrollIntoView;
|
||||
});
|
||||
|
||||
it("shows jump-to-latest when unpinned and new output arrives, and jumps on click", async () => {
|
||||
(Element.prototype as unknown as { scrollIntoView: unknown }).scrollIntoView = vi.fn();
|
||||
|
||||
const agent = createAgent();
|
||||
const { rerender } = render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...agent, outputLines: ["> hello", "first answer"] },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const scrollEl = screen.getByTestId("agent-chat-scroll");
|
||||
Object.defineProperty(scrollEl, "clientHeight", { value: 100, configurable: true });
|
||||
Object.defineProperty(scrollEl, "scrollHeight", { value: 1000, configurable: true });
|
||||
Object.defineProperty(scrollEl, "scrollTop", { value: 0, writable: true, configurable: true });
|
||||
|
||||
fireEvent.scroll(scrollEl);
|
||||
|
||||
rerender(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: { ...agent, outputLines: ["> hello", "first answer", "second answer"] },
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Jump to latest" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Jump to latest" }));
|
||||
|
||||
expect(
|
||||
(Element.prototype as unknown as { scrollIntoView: ReturnType<typeof vi.fn> })
|
||||
.scrollIntoView
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows history truncation banner only when scrolled to top", () => {
|
||||
const agent = createAgent();
|
||||
render(
|
||||
createElement(AgentChatPanel, {
|
||||
agent: {
|
||||
...agent,
|
||||
historyMaybeTruncated: true,
|
||||
historyFetchedCount: 200,
|
||||
historyFetchLimit: 200,
|
||||
outputLines: ["> hello", "response"],
|
||||
},
|
||||
isSelected: true,
|
||||
canSend: true,
|
||||
models,
|
||||
stopBusy: false,
|
||||
onLoadMoreHistory: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onThinkingChange: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onStopRun: vi.fn(),
|
||||
onAvatarShuffle: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const scrollEl = screen.getByTestId("agent-chat-scroll");
|
||||
Object.defineProperty(scrollEl, "clientHeight", { value: 100, configurable: true });
|
||||
Object.defineProperty(scrollEl, "scrollHeight", { value: 1000, configurable: true });
|
||||
Object.defineProperty(scrollEl, "scrollTop", { value: 120, writable: true, configurable: true });
|
||||
|
||||
fireEvent.scroll(scrollEl);
|
||||
expect(screen.queryByText(/Showing most recent 200 messages/i)).not.toBeInTheDocument();
|
||||
|
||||
scrollEl.scrollTop = 0;
|
||||
fireEvent.scroll(scrollEl);
|
||||
expect(screen.getByText(/Showing most recent 200 messages/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { AgentCreateModal } from "@/features/agents/components/AgentCreateModal";
|
||||
|
||||
const openModal = (overrides?: {
|
||||
busy?: boolean;
|
||||
onClose?: () => void;
|
||||
onSubmit?: (payload: unknown) => void;
|
||||
}) => {
|
||||
const onClose = overrides?.onClose ?? vi.fn();
|
||||
const onSubmit = overrides?.onSubmit ?? vi.fn();
|
||||
render(
|
||||
createElement(AgentCreateModal, {
|
||||
open: true,
|
||||
suggestedName: "New Agent",
|
||||
busy: overrides?.busy,
|
||||
onClose,
|
||||
onSubmit,
|
||||
})
|
||||
);
|
||||
return { onClose, onSubmit };
|
||||
};
|
||||
|
||||
describe("AgentCreateModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("submits simple payload with name and avatar seed", () => {
|
||||
const onSubmit = vi.fn();
|
||||
openModal({ onSubmit });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Agent name"), {
|
||||
target: { value: "Execution Operator" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Launch agent" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Execution Operator",
|
||||
avatarSeed: expect.any(String),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("submits when the form is submitted from keyboard flow", () => {
|
||||
const onSubmit = vi.fn();
|
||||
openModal({ onSubmit });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Agent name"), {
|
||||
target: { value: "Keyboard Agent" },
|
||||
});
|
||||
fireEvent.submit(screen.getByTestId("agent-create-modal"));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "Keyboard Agent",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("renders one-step create form without guided wizard copy", () => {
|
||||
openModal();
|
||||
|
||||
expect(screen.getByRole("button", { name: "Launch agent" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Agent name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Choose avatar")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Shuffle avatar selection" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Define Ownership")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Set Authority Level")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables launch when the name is blank", () => {
|
||||
const onSubmit = vi.fn();
|
||||
openModal({ onSubmit });
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Agent name"), {
|
||||
target: { value: " " },
|
||||
});
|
||||
const launchButton = screen.getByRole("button", { name: "Launch agent" });
|
||||
expect(launchButton).toBeDisabled();
|
||||
fireEvent.click(launchButton);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows launching state while busy", () => {
|
||||
openModal({ busy: true });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Launching..." })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("calls onClose when close is pressed", () => {
|
||||
const onClose = vi.fn();
|
||||
openModal({ onClose });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reset typed name when suggestedName changes while open", () => {
|
||||
const onClose = vi.fn();
|
||||
const onSubmit = vi.fn();
|
||||
const view = render(
|
||||
createElement(AgentCreateModal, {
|
||||
open: true,
|
||||
suggestedName: "New Agent",
|
||||
onClose,
|
||||
onSubmit,
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Agent name"), {
|
||||
target: { value: "My Draft Name" },
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
createElement(AgentCreateModal, {
|
||||
open: true,
|
||||
suggestedName: "New Agent 2",
|
||||
onClose,
|
||||
onSubmit,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Agent name")).toHaveValue("My Draft Name");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { writeGatewayAgentFiles } from "@/lib/gateway/agentFiles";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
describe("writeGatewayAgentFiles", () => {
|
||||
it("writes each provided file to agents.files.set", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ ok: true })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await writeGatewayAgentFiles({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
files: {
|
||||
"AGENTS.md": "# mission",
|
||||
"SOUL.md": "# tone",
|
||||
},
|
||||
});
|
||||
|
||||
expect(client.call).toHaveBeenCalledTimes(2);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([
|
||||
"agents.files.set",
|
||||
{ agentId: "agent-1", name: "AGENTS.md", content: "# mission" },
|
||||
]);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls[1]).toEqual([
|
||||
"agents.files.set",
|
||||
{ agentId: "agent-1", name: "SOUL.md", content: "# tone" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails fast for empty agent id", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ ok: true })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(
|
||||
writeGatewayAgentFiles({
|
||||
client,
|
||||
agentId: " ",
|
||||
files: { "AGENTS.md": "# mission" },
|
||||
})
|
||||
).rejects.toThrow("agentId is required.");
|
||||
expect(client.call).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
|
||||
import type { StudioSettings } from "@/lib/studio/settings";
|
||||
|
||||
describe("hydrateAgentFleetFromGateway", () => {
|
||||
it("maps_gateway_results_into_seeds_and_selects_latest_assistant_agent", async () => {
|
||||
const gatewayUrl = "ws://127.0.0.1:18789";
|
||||
|
||||
const settings: StudioSettings = {
|
||||
version: 1,
|
||||
gateway: null,
|
||||
focused: {},
|
||||
avatars: {
|
||||
[gatewayUrl]: {
|
||||
"agent-1": "persisted-seed",
|
||||
},
|
||||
},
|
||||
deskAssignments: {},
|
||||
analytics: {},
|
||||
voiceReplies: {},
|
||||
};
|
||||
|
||||
const call = vi.fn(async (method: string, params: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
hash: "hash-1",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5",
|
||||
},
|
||||
list: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "agents.list") {
|
||||
return {
|
||||
defaultId: "agent-1",
|
||||
mainKey: "main",
|
||||
agents: [
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "One",
|
||||
identity: { avatarUrl: "https://example.com/one.png" },
|
||||
},
|
||||
{
|
||||
id: "agent-2",
|
||||
name: "Two",
|
||||
identity: { avatarUrl: "https://example.com/two.png" },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "exec.approvals.get") {
|
||||
return {
|
||||
file: {
|
||||
agents: {
|
||||
"agent-1": { security: "allowlist", ask: "always" },
|
||||
"agent-2": { security: "full", ask: "off" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
const { agentId, search } = params as Record<string, unknown>;
|
||||
return {
|
||||
sessions: [
|
||||
{
|
||||
key: search,
|
||||
updatedAt: 1,
|
||||
displayName: "Main",
|
||||
thinkingLevel: "medium",
|
||||
modelProvider: "openai",
|
||||
model: agentId === "agent-2" ? "gpt-5" : "gpt-4.1",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "status") {
|
||||
return {
|
||||
sessions: {
|
||||
recent: [],
|
||||
byAgent: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "sessions.preview") {
|
||||
return {
|
||||
ts: 1,
|
||||
previews: [
|
||||
{
|
||||
key: "agent:agent-1:main",
|
||||
status: "ok",
|
||||
items: [
|
||||
{ role: "assistant", text: "one", timestamp: "2026-02-10T00:00:00Z" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "agent:agent-2:main",
|
||||
status: "ok",
|
||||
items: [
|
||||
{ role: "assistant", text: "two", timestamp: "2026-02-10T01:00:00Z" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unhandled method: ${method}`);
|
||||
});
|
||||
|
||||
const result = await hydrateAgentFleetFromGateway({
|
||||
client: { call },
|
||||
gatewayUrl,
|
||||
cachedConfigSnapshot: null,
|
||||
loadStudioSettings: async () => settings,
|
||||
isDisconnectLikeError: () => false,
|
||||
});
|
||||
|
||||
expect(call).toHaveBeenCalledWith("agents.list", {});
|
||||
expect(call).toHaveBeenCalledWith("exec.approvals.get", {});
|
||||
expect(result.seeds).toHaveLength(2);
|
||||
expect(result.seeds[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
name: "One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
avatarSeed: "persisted-seed",
|
||||
avatarUrl: "https://example.com/one.png",
|
||||
model: "openai/gpt-4.1",
|
||||
thinkingLevel: "medium",
|
||||
sessionExecHost: "gateway",
|
||||
sessionExecSecurity: "allowlist",
|
||||
sessionExecAsk: "always",
|
||||
})
|
||||
);
|
||||
expect(result.seeds[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-2",
|
||||
sessionExecHost: "gateway",
|
||||
sessionExecSecurity: "full",
|
||||
sessionExecAsk: "off",
|
||||
})
|
||||
);
|
||||
expect(result.sessionCreatedAgentIds).toEqual(["agent-1", "agent-2"]);
|
||||
expect(result.sessionSettingsSyncedAgentIds).toEqual([]);
|
||||
expect(result.suggestedSelectedAgentId).toBe("agent-2");
|
||||
expect(result.summaryPatches.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { deriveHydrateAgentFleetResult } from "@/features/agents/operations/agentFleetHydrationDerivation";
|
||||
import type { StudioSettings } from "@/lib/studio/settings";
|
||||
|
||||
describe("deriveHydrateAgentFleetResult", () => {
|
||||
it("derives_seeds_and_sync_sets_from_snapshots", () => {
|
||||
const gatewayUrl = "ws://127.0.0.1:18789";
|
||||
|
||||
const settings: StudioSettings = {
|
||||
version: 1,
|
||||
gateway: null,
|
||||
focused: {},
|
||||
avatars: {
|
||||
[gatewayUrl]: {
|
||||
"agent-1": "persisted-seed",
|
||||
},
|
||||
},
|
||||
deskAssignments: {},
|
||||
analytics: {},
|
||||
voiceReplies: {},
|
||||
};
|
||||
|
||||
const result = deriveHydrateAgentFleetResult({
|
||||
gatewayUrl,
|
||||
configSnapshot: {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "openai/gpt-5",
|
||||
},
|
||||
list: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
settings,
|
||||
execApprovalsSnapshot: {
|
||||
file: {
|
||||
agents: {
|
||||
"agent-1": { security: "allowlist", ask: "always" },
|
||||
"agent-2": { security: "full", ask: "off" },
|
||||
},
|
||||
},
|
||||
},
|
||||
agentsResult: {
|
||||
defaultId: "agent-1",
|
||||
mainKey: "main",
|
||||
agents: [
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "One",
|
||||
identity: { avatarUrl: "https://example.com/one.png" },
|
||||
},
|
||||
{
|
||||
id: "agent-2",
|
||||
name: "Two",
|
||||
identity: { avatarUrl: "https://example.com/two.png" },
|
||||
},
|
||||
],
|
||||
},
|
||||
mainSessionByAgentId: new Map([
|
||||
[
|
||||
"agent-1",
|
||||
{
|
||||
key: "agent:agent-1:main",
|
||||
updatedAt: 1,
|
||||
displayName: "Main",
|
||||
thinkingLevel: "medium",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-4.1",
|
||||
},
|
||||
],
|
||||
[
|
||||
"agent-2",
|
||||
{
|
||||
key: "agent:agent-2:main",
|
||||
updatedAt: 1,
|
||||
displayName: "Main",
|
||||
thinkingLevel: "medium",
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5",
|
||||
},
|
||||
],
|
||||
]),
|
||||
statusSummary: null,
|
||||
previewResult: null,
|
||||
});
|
||||
|
||||
expect(result.seeds).toHaveLength(2);
|
||||
expect(result.seeds[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
name: "One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
avatarSeed: "persisted-seed",
|
||||
avatarUrl: "https://example.com/one.png",
|
||||
model: "openai/gpt-4.1",
|
||||
thinkingLevel: "medium",
|
||||
sessionExecHost: "gateway",
|
||||
sessionExecSecurity: "allowlist",
|
||||
sessionExecAsk: "always",
|
||||
})
|
||||
);
|
||||
expect(result.seeds[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-2",
|
||||
sessionExecHost: "gateway",
|
||||
sessionExecSecurity: "full",
|
||||
sessionExecAsk: "off",
|
||||
})
|
||||
);
|
||||
expect(result.sessionCreatedAgentIds).toEqual(["agent-1", "agent-2"]);
|
||||
expect(result.sessionSettingsSyncedAgentIds).toEqual([]);
|
||||
expect(result.suggestedSelectedAgentId).toBe(null);
|
||||
expect(result.summaryPatches).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives_summary_patches_and_suggested_agent_when_preview_present", () => {
|
||||
const gatewayUrl = "ws://127.0.0.1:18789";
|
||||
|
||||
const result = deriveHydrateAgentFleetResult({
|
||||
gatewayUrl,
|
||||
configSnapshot: null,
|
||||
settings: null,
|
||||
execApprovalsSnapshot: null,
|
||||
agentsResult: {
|
||||
defaultId: "agent-1",
|
||||
mainKey: "main",
|
||||
agents: [
|
||||
{ id: "agent-1", name: "One", identity: {} },
|
||||
{ id: "agent-2", name: "Two", identity: {} },
|
||||
],
|
||||
},
|
||||
mainSessionByAgentId: new Map([
|
||||
["agent-1", { key: "agent:agent-1:main" }],
|
||||
["agent-2", { key: "agent:agent-2:main" }],
|
||||
]),
|
||||
statusSummary: {
|
||||
sessions: {
|
||||
recent: [],
|
||||
byAgent: [],
|
||||
},
|
||||
},
|
||||
previewResult: {
|
||||
ts: 1,
|
||||
previews: [
|
||||
{
|
||||
key: "agent:agent-1:main",
|
||||
status: "ok",
|
||||
items: [
|
||||
{ role: "assistant", text: "one", timestamp: "2026-02-10T00:00:00Z" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "agent:agent-2:main",
|
||||
status: "ok",
|
||||
items: [
|
||||
{ role: "assistant", text: "two", timestamp: "2026-02-10T01:00:00Z" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.summaryPatches.length).toBeGreaterThan(0);
|
||||
expect(result.suggestedSelectedAgentId).toBe("agent-2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isPermissionsCustom,
|
||||
resolveAgentPermissionsDraft,
|
||||
resolveCommandModeFromRole,
|
||||
resolvePresetDefaultsForRole,
|
||||
resolveRoleForCommandMode,
|
||||
resolveToolGroupOverrides,
|
||||
resolveToolGroupStateFromConfigEntry,
|
||||
} from "@/features/agents/operations/agentPermissionsOperation";
|
||||
|
||||
describe("agentPermissionsOperation", () => {
|
||||
it("maps command mode and preset role in both directions", () => {
|
||||
expect(resolveRoleForCommandMode("off")).toBe("conservative");
|
||||
expect(resolveRoleForCommandMode("ask")).toBe("collaborative");
|
||||
expect(resolveRoleForCommandMode("auto")).toBe("autonomous");
|
||||
|
||||
expect(resolveCommandModeFromRole("conservative")).toBe("off");
|
||||
expect(resolveCommandModeFromRole("collaborative")).toBe("ask");
|
||||
expect(resolveCommandModeFromRole("autonomous")).toBe("auto");
|
||||
});
|
||||
|
||||
it("resolves autonomous preset defaults to permissive capabilities", () => {
|
||||
expect(resolvePresetDefaultsForRole("autonomous")).toEqual({
|
||||
commandMode: "auto",
|
||||
webAccess: true,
|
||||
fileTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives tool-group state from allow and deny with deny precedence", () => {
|
||||
const state = resolveToolGroupStateFromConfigEntry({
|
||||
allow: ["group:web", "group:runtime"],
|
||||
deny: ["group:web"],
|
||||
});
|
||||
|
||||
expect(state.usesAllow).toBe(true);
|
||||
expect(state.runtime).toBe(true);
|
||||
expect(state.web).toBe(false);
|
||||
expect(state.fs).toBeNull();
|
||||
});
|
||||
|
||||
it("merges group toggles while preserving allow mode", () => {
|
||||
const overrides = resolveToolGroupOverrides({
|
||||
existingTools: {
|
||||
allow: ["group:web", "custom:tool"],
|
||||
deny: ["group:runtime", "group:fs"],
|
||||
},
|
||||
runtimeEnabled: true,
|
||||
webEnabled: false,
|
||||
fsEnabled: true,
|
||||
});
|
||||
|
||||
expect(overrides.tools.allow).toEqual(
|
||||
expect.arrayContaining(["custom:tool", "group:runtime", "group:fs"])
|
||||
);
|
||||
expect(overrides.tools.allow).not.toEqual(expect.arrayContaining(["group:web"]));
|
||||
expect(overrides.tools.deny).toEqual(expect.arrayContaining(["group:web"]));
|
||||
expect(overrides.tools.deny).not.toEqual(
|
||||
expect.arrayContaining(["group:runtime", "group:fs"])
|
||||
);
|
||||
});
|
||||
|
||||
it("merges group toggles while preserving alsoAllow mode", () => {
|
||||
const overrides = resolveToolGroupOverrides({
|
||||
existingTools: {
|
||||
alsoAllow: ["group:web"],
|
||||
deny: [],
|
||||
},
|
||||
runtimeEnabled: true,
|
||||
webEnabled: true,
|
||||
fsEnabled: false,
|
||||
});
|
||||
|
||||
expect(overrides.tools).not.toHaveProperty("allow");
|
||||
expect(overrides.tools.alsoAllow).toEqual(
|
||||
expect.arrayContaining(["group:web", "group:runtime"])
|
||||
);
|
||||
expect(overrides.tools.deny).toEqual(expect.arrayContaining(["group:fs"]));
|
||||
});
|
||||
|
||||
it("resolves draft from session role and config group overrides", () => {
|
||||
const draft = resolveAgentPermissionsDraft({
|
||||
agent: {
|
||||
sessionExecSecurity: "allowlist",
|
||||
sessionExecAsk: "always",
|
||||
},
|
||||
existingTools: {
|
||||
allow: ["group:web"],
|
||||
deny: ["group:fs"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(draft).toEqual({
|
||||
commandMode: "ask",
|
||||
webAccess: true,
|
||||
fileTools: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("flags custom draft when advanced values diverge from preset baseline", () => {
|
||||
expect(
|
||||
isPermissionsCustom({
|
||||
role: "autonomous",
|
||||
draft: {
|
||||
commandMode: "auto",
|
||||
webAccess: false,
|
||||
fileTools: true,
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveExecApprovalsPolicyForRole,
|
||||
resolveRuntimeToolOverridesForRole,
|
||||
resolveSessionExecSettingsForRole,
|
||||
} from "@/features/agents/operations/agentPermissionsOperation";
|
||||
|
||||
describe("permissions role helpers", () => {
|
||||
it("maps roles to exec approvals policy while preserving allowlist", () => {
|
||||
const allowlist = [{ pattern: "a" }, { pattern: "b" }];
|
||||
|
||||
expect(resolveExecApprovalsPolicyForRole({ role: "conservative", allowlist })).toBeNull();
|
||||
|
||||
const collaborative = resolveExecApprovalsPolicyForRole({
|
||||
role: "collaborative",
|
||||
allowlist,
|
||||
});
|
||||
expect(collaborative).toEqual({
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
allowlist,
|
||||
});
|
||||
expect(collaborative?.allowlist).toBe(allowlist);
|
||||
|
||||
const autonomous = resolveExecApprovalsPolicyForRole({
|
||||
role: "autonomous",
|
||||
allowlist,
|
||||
});
|
||||
expect(autonomous).toEqual({
|
||||
security: "full",
|
||||
ask: "off",
|
||||
allowlist,
|
||||
});
|
||||
expect(autonomous?.allowlist).toBe(allowlist);
|
||||
});
|
||||
|
||||
it("updates tool overrides using allow when existing tools.allow is present", () => {
|
||||
const existingTools = { allow: ["group:web"], deny: ["group:runtime"] };
|
||||
|
||||
const collaborative = resolveRuntimeToolOverridesForRole({
|
||||
role: "collaborative",
|
||||
existingTools,
|
||||
});
|
||||
expect(collaborative.tools.allow).toEqual(expect.arrayContaining(["group:web", "group:runtime"]));
|
||||
expect(collaborative.tools).not.toHaveProperty("alsoAllow");
|
||||
expect(collaborative.tools.deny).not.toEqual(expect.arrayContaining(["group:runtime"]));
|
||||
|
||||
const autonomous = resolveRuntimeToolOverridesForRole({
|
||||
role: "autonomous",
|
||||
existingTools,
|
||||
});
|
||||
expect(autonomous.tools.allow).toEqual(expect.arrayContaining(["group:web", "group:runtime"]));
|
||||
expect(autonomous.tools).not.toHaveProperty("alsoAllow");
|
||||
expect(autonomous.tools.deny).not.toEqual(expect.arrayContaining(["group:runtime"]));
|
||||
|
||||
const conservative = resolveRuntimeToolOverridesForRole({
|
||||
role: "conservative",
|
||||
existingTools,
|
||||
});
|
||||
expect(conservative.tools.allow).toEqual(expect.arrayContaining(["group:web"]));
|
||||
expect(conservative.tools.allow).not.toEqual(expect.arrayContaining(["group:runtime"]));
|
||||
expect(conservative.tools.deny).toEqual(expect.arrayContaining(["group:runtime"]));
|
||||
});
|
||||
|
||||
it("updates tool overrides using alsoAllow when tools.allow is absent", () => {
|
||||
const existingTools = { alsoAllow: ["group:web"], deny: [] as string[] };
|
||||
|
||||
const collaborative = resolveRuntimeToolOverridesForRole({
|
||||
role: "collaborative",
|
||||
existingTools,
|
||||
});
|
||||
expect(collaborative.tools.alsoAllow).toEqual(expect.arrayContaining(["group:web", "group:runtime"]));
|
||||
expect(collaborative.tools).not.toHaveProperty("allow");
|
||||
|
||||
const conservative = resolveRuntimeToolOverridesForRole({
|
||||
role: "conservative",
|
||||
existingTools,
|
||||
});
|
||||
expect(conservative.tools.alsoAllow).toEqual(expect.arrayContaining(["group:web"]));
|
||||
expect(conservative.tools.alsoAllow).not.toEqual(expect.arrayContaining(["group:runtime"]));
|
||||
expect(conservative.tools.deny).toEqual(expect.arrayContaining(["group:runtime"]));
|
||||
});
|
||||
|
||||
it("resolves session exec settings from role and sandbox mode", () => {
|
||||
expect(resolveSessionExecSettingsForRole({ role: "conservative", sandboxMode: "all" })).toEqual({
|
||||
execHost: null,
|
||||
execSecurity: "deny",
|
||||
execAsk: "off",
|
||||
});
|
||||
|
||||
expect(resolveSessionExecSettingsForRole({ role: "collaborative", sandboxMode: "all" }).execHost).toBe(
|
||||
"sandbox"
|
||||
);
|
||||
expect(resolveSessionExecSettingsForRole({ role: "autonomous", sandboxMode: "all" }).execHost).toBe(
|
||||
"sandbox"
|
||||
);
|
||||
|
||||
expect(resolveSessionExecSettingsForRole({ role: "collaborative", sandboxMode: "none" }).execHost).toBe(
|
||||
"gateway"
|
||||
);
|
||||
expect(resolveSessionExecSettingsForRole({ role: "autonomous", sandboxMode: "none" }).execHost).toBe(
|
||||
"gateway"
|
||||
);
|
||||
});
|
||||
|
||||
it("treats missing tools config as empty lists and still enforces group:runtime semantics", () => {
|
||||
const collaborative = resolveRuntimeToolOverridesForRole({
|
||||
role: "collaborative",
|
||||
existingTools: null,
|
||||
});
|
||||
expect(collaborative.tools.alsoAllow).toEqual(expect.arrayContaining(["group:runtime"]));
|
||||
expect(collaborative.tools).not.toHaveProperty("allow");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { buildReconcileTerminalPatch } from "@/features/agents/operations/fleetLifecycleWorkflow";
|
||||
import {
|
||||
executeAgentReconcileCommands,
|
||||
runAgentReconcileOperation,
|
||||
} from "@/features/agents/operations/agentReconcileOperation";
|
||||
|
||||
describe("agentReconcileOperation", () => {
|
||||
it("reconciles terminal runs and requests history refresh", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "agent.wait") {
|
||||
return { status: "ok" };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
|
||||
const agent = {
|
||||
agentId: "a1",
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
runId: "run-1",
|
||||
} as unknown as AgentState;
|
||||
|
||||
const commands = await runAgentReconcileOperation({
|
||||
client: { call },
|
||||
agents: [agent],
|
||||
getLatestAgent: () => agent,
|
||||
claimRunId: () => true,
|
||||
releaseRunId: () => {},
|
||||
isDisconnectLikeError: () => false,
|
||||
});
|
||||
|
||||
expect(call).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 1 });
|
||||
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "clearRunTracking", runId: "run-1" },
|
||||
{
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: "a1",
|
||||
patch: buildReconcileTerminalPatch({ outcome: "ok" }),
|
||||
},
|
||||
{ kind: "requestHistoryRefresh", agentId: "a1" },
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("skips when agent is not eligible", async () => {
|
||||
const call = vi.fn();
|
||||
const agent = {
|
||||
agentId: "a1",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
runId: "run-1",
|
||||
} as unknown as AgentState;
|
||||
|
||||
const commands = await runAgentReconcileOperation({
|
||||
client: { call },
|
||||
agents: [agent],
|
||||
getLatestAgent: () => agent,
|
||||
claimRunId: () => true,
|
||||
releaseRunId: () => {},
|
||||
isDisconnectLikeError: () => false,
|
||||
});
|
||||
|
||||
expect(call).not.toHaveBeenCalled();
|
||||
expect(commands).toEqual([]);
|
||||
});
|
||||
|
||||
it("reconciles shared run only once and triggers one history refresh", async () => {
|
||||
const call = vi.fn(async () => ({ status: "ok" }));
|
||||
const agentOne = {
|
||||
agentId: "a1",
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
runId: "run-shared",
|
||||
} as unknown as AgentState;
|
||||
const agentTwo = {
|
||||
agentId: "a2",
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
runId: "run-shared",
|
||||
} as unknown as AgentState;
|
||||
|
||||
let claimed = false;
|
||||
const commands = await runAgentReconcileOperation({
|
||||
client: { call },
|
||||
agents: [agentOne, agentTwo],
|
||||
getLatestAgent: (agentId) => (agentId === "a1" ? agentOne : agentTwo),
|
||||
claimRunId: () => {
|
||||
if (claimed) return false;
|
||||
claimed = true;
|
||||
return true;
|
||||
},
|
||||
releaseRunId: () => {},
|
||||
isDisconnectLikeError: () => false,
|
||||
});
|
||||
|
||||
const historyRefreshes = commands.filter((entry) => entry.kind === "requestHistoryRefresh");
|
||||
expect(call).toHaveBeenCalledTimes(1);
|
||||
expect(historyRefreshes).toEqual([{ kind: "requestHistoryRefresh", agentId: "a1" }]);
|
||||
|
||||
const dispatch = vi.fn();
|
||||
const clearRunTracking = vi.fn();
|
||||
const requestHistoryRefresh = vi.fn();
|
||||
const logInfo = vi.fn();
|
||||
const logWarn = vi.fn();
|
||||
executeAgentReconcileCommands({
|
||||
commands,
|
||||
dispatch,
|
||||
clearRunTracking,
|
||||
requestHistoryRefresh,
|
||||
logInfo,
|
||||
logWarn,
|
||||
});
|
||||
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledWith("a1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
planAgentSettingsMutation,
|
||||
type AgentSettingsMutationContext,
|
||||
} from "@/features/agents/operations/agentSettingsMutationWorkflow";
|
||||
|
||||
const createContext = (
|
||||
overrides?: Partial<AgentSettingsMutationContext>
|
||||
): AgentSettingsMutationContext => ({
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
cronCreateBusy: false,
|
||||
cronRunBusyJobId: null,
|
||||
cronDeleteBusyJobId: null,
|
||||
...(overrides ?? {}),
|
||||
});
|
||||
|
||||
describe("agentSettingsMutationWorkflow", () => {
|
||||
it("denies_guarded_actions_when_not_connected", () => {
|
||||
const renameResult = planAgentSettingsMutation(
|
||||
{ kind: "rename-agent", agentId: "agent-1" },
|
||||
createContext({ status: "disconnected" })
|
||||
);
|
||||
const skillsResult = planAgentSettingsMutation(
|
||||
{ kind: "use-all-skills", agentId: "agent-1" },
|
||||
createContext({ status: "disconnected" })
|
||||
);
|
||||
const installResult = planAgentSettingsMutation(
|
||||
{ kind: "install-skill", agentId: "agent-1", skillKey: "browser" },
|
||||
createContext({ status: "disconnected" })
|
||||
);
|
||||
const allowlistResult = planAgentSettingsMutation(
|
||||
{ kind: "set-skills-allowlist", agentId: "agent-1" },
|
||||
createContext({ status: "disconnected" })
|
||||
);
|
||||
const globalToggleResult = planAgentSettingsMutation(
|
||||
{ kind: "set-skill-global-enabled", agentId: "agent-1", skillKey: "browser" },
|
||||
createContext({ status: "disconnected" })
|
||||
);
|
||||
const removeResult = planAgentSettingsMutation(
|
||||
{ kind: "remove-skill", agentId: "agent-1", skillKey: "browser" },
|
||||
createContext({ status: "disconnected" })
|
||||
);
|
||||
|
||||
expect(renameResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
message: null,
|
||||
guardReason: "not-connected",
|
||||
});
|
||||
expect(skillsResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
message: null,
|
||||
guardReason: "not-connected",
|
||||
});
|
||||
expect(installResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
message: null,
|
||||
guardReason: "not-connected",
|
||||
});
|
||||
expect(allowlistResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
message: null,
|
||||
guardReason: "not-connected",
|
||||
});
|
||||
expect(globalToggleResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
message: null,
|
||||
guardReason: "not-connected",
|
||||
});
|
||||
expect(removeResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
message: null,
|
||||
guardReason: "not-connected",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies_delete_for_reserved_main_agent_with_actionable_message", () => {
|
||||
const result = planAgentSettingsMutation(
|
||||
{ kind: "delete-agent", agentId: " main " },
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "deny",
|
||||
reason: "reserved-main-delete",
|
||||
message: "The main agent cannot be deleted.",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies_guarded_actions_when_mutation_block_is_active", () => {
|
||||
const result = planAgentSettingsMutation(
|
||||
{ kind: "update-agent-permissions", agentId: "agent-1" },
|
||||
createContext({ hasCreateBlock: true })
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "deny",
|
||||
reason: "start-guard-deny",
|
||||
message: null,
|
||||
guardReason: "create-block-active",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies_cron_run_delete_when_other_cron_action_is_busy", () => {
|
||||
const result = planAgentSettingsMutation(
|
||||
{ kind: "run-cron-job", agentId: "agent-1", jobId: "job-1" },
|
||||
createContext({ cronDeleteBusyJobId: "job-2" })
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "deny",
|
||||
reason: "cron-action-busy",
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows_with_normalized_agent_and_job_ids", () => {
|
||||
const runResult = planAgentSettingsMutation(
|
||||
{ kind: "run-cron-job", agentId: " agent-1 ", jobId: " job-1 " },
|
||||
createContext()
|
||||
);
|
||||
const deleteResult = planAgentSettingsMutation(
|
||||
{ kind: "delete-agent", agentId: " agent-2 " },
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(runResult).toEqual({
|
||||
kind: "allow",
|
||||
normalizedAgentId: "agent-1",
|
||||
normalizedJobId: "job-1",
|
||||
});
|
||||
expect(deleteResult).toEqual({
|
||||
kind: "allow",
|
||||
normalizedAgentId: "agent-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies_skill_toggle_when_skill_name_is_missing", () => {
|
||||
const result = planAgentSettingsMutation(
|
||||
{ kind: "set-skill-enabled", agentId: "agent-1", skillName: " " },
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "deny",
|
||||
reason: "missing-skill-name",
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("denies_skill_setup_when_skill_key_is_missing", () => {
|
||||
const installResult = planAgentSettingsMutation(
|
||||
{ kind: "install-skill", agentId: "agent-1", skillKey: " " },
|
||||
createContext()
|
||||
);
|
||||
const saveResult = planAgentSettingsMutation(
|
||||
{ kind: "save-skill-api-key", agentId: "agent-1", skillKey: " " },
|
||||
createContext()
|
||||
);
|
||||
const globalToggleResult = planAgentSettingsMutation(
|
||||
{ kind: "set-skill-global-enabled", agentId: "agent-1", skillKey: " " },
|
||||
createContext()
|
||||
);
|
||||
const removeResult = planAgentSettingsMutation(
|
||||
{ kind: "remove-skill", agentId: "agent-1", skillKey: " " },
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(installResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "missing-skill-key",
|
||||
message: null,
|
||||
});
|
||||
expect(saveResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "missing-skill-key",
|
||||
message: null,
|
||||
});
|
||||
expect(globalToggleResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "missing-skill-key",
|
||||
message: null,
|
||||
});
|
||||
expect(removeResult).toEqual({
|
||||
kind: "deny",
|
||||
reason: "missing-skill-key",
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows_setting_skills_allowlist_with_normalized_agent_id", () => {
|
||||
const result = planAgentSettingsMutation(
|
||||
{ kind: "set-skills-allowlist", agentId: " agent-1 " },
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "allow",
|
||||
normalizedAgentId: "agent-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { AgentSettingsPanel } from "@/features/agents/components/AgentInspectPanels";
|
||||
|
||||
const createAgent = (): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Web Researcher",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
describe("AgentSettingsPanel header", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("uses inspect header style with agent title", () => {
|
||||
render(
|
||||
createElement(AgentSettingsPanel, {
|
||||
agent: createAgent(),
|
||||
onClose: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onToolCallingToggle: vi.fn(),
|
||||
onThinkingTracesToggle: vi.fn(),
|
||||
cronJobs: [],
|
||||
cronLoading: false,
|
||||
cronError: null,
|
||||
cronRunBusyJobId: null,
|
||||
cronDeleteBusyJobId: null,
|
||||
onRunCronJob: vi.fn(),
|
||||
onDeleteCronJob: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Capabilities")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Web Researcher")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Close panel")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runSshJson } from "@/lib/ssh/gateway-host";
|
||||
import {
|
||||
restoreAgentStateOverSsh,
|
||||
trashAgentStateOverSsh,
|
||||
} from "@/lib/ssh/agent-state";
|
||||
|
||||
vi.mock("@/lib/ssh/gateway-host", () => ({
|
||||
runSshJson: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("agent state ssh executor", () => {
|
||||
const mockedRunSshJson = vi.mocked(runSshJson);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedRunSshJson.mockReset();
|
||||
});
|
||||
|
||||
it("trashes agent state via ssh", () => {
|
||||
mockedRunSshJson.mockReturnValueOnce({ trashDir: "/tmp/trash", moved: [] });
|
||||
|
||||
const result = trashAgentStateOverSsh({ sshTarget: "me@host", agentId: "my-agent" });
|
||||
|
||||
expect(result).toEqual({ trashDir: "/tmp/trash", moved: [] });
|
||||
expect(runSshJson).toHaveBeenCalledTimes(1);
|
||||
expect(runSshJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sshTarget: "me@host",
|
||||
argv: ["bash", "-s", "--", "my-agent"],
|
||||
label: "trash agent state (my-agent)",
|
||||
input: expect.stringContaining('python3 - "$1"'),
|
||||
})
|
||||
);
|
||||
const call = mockedRunSshJson.mock.calls[0]?.[0];
|
||||
expect(call?.input).toContain("workspace-{agent_id}");
|
||||
});
|
||||
|
||||
it("restores agent state via ssh", () => {
|
||||
mockedRunSshJson.mockReturnValueOnce({ restored: [] });
|
||||
|
||||
const result = restoreAgentStateOverSsh({
|
||||
sshTarget: "me@host",
|
||||
agentId: "my-agent",
|
||||
trashDir: "/tmp/trash",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ restored: [] });
|
||||
expect(runSshJson).toHaveBeenCalledTimes(1);
|
||||
expect(runSshJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sshTarget: "me@host",
|
||||
argv: ["bash", "-s", "--", "my-agent", "/tmp/trash"],
|
||||
label: "restore agent state (my-agent)",
|
||||
input: expect.stringContaining('python3 - "$1" "$2"'),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local";
|
||||
|
||||
const mkTmpStateDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "claw3d-test-"));
|
||||
|
||||
describe("agent state local", () => {
|
||||
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR;
|
||||
else process.env.OPENCLAW_STATE_DIR = originalStateDir;
|
||||
});
|
||||
|
||||
it("trashes and restores agent workspace + state", () => {
|
||||
const stateDir = mkTmpStateDir();
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
const agentId = "test-agent";
|
||||
const workspace = path.join(stateDir, `workspace-${agentId}`);
|
||||
const agentDir = path.join(stateDir, "agents", agentId);
|
||||
fs.mkdirSync(workspace, { recursive: true });
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(workspace, "hello.txt"), "hi", "utf8");
|
||||
fs.writeFileSync(path.join(agentDir, "state.json"), "{}", "utf8");
|
||||
|
||||
const trashed = trashAgentStateLocally({ agentId });
|
||||
expect(fs.existsSync(workspace)).toBe(false);
|
||||
expect(fs.existsSync(agentDir)).toBe(false);
|
||||
expect(fs.existsSync(trashed.trashDir)).toBe(true);
|
||||
|
||||
const restored = restoreAgentStateLocally({ agentId, trashDir: trashed.trashDir });
|
||||
expect(restored.restored.length).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(workspace)).toBe(true);
|
||||
expect(fs.existsSync(agentDir)).toBe(true);
|
||||
expect(fs.readFileSync(path.join(workspace, "hello.txt"), "utf8")).toBe("hi");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { POST, PUT } from "@/app/api/gateway/agent-state/route";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>(
|
||||
"node:child_process"
|
||||
);
|
||||
return {
|
||||
default: actual,
|
||||
...actual,
|
||||
spawnSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedSpawnSync = vi.mocked(spawnSync);
|
||||
const mockedConsoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const writeStudioSettings = (gatewayUrl: string) => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "studio-state-"));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
const settingsDir = path.join(stateDir, "claw3d");
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, "settings.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
gateway: { url: gatewayUrl, token: "token-123" },
|
||||
focused: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
};
|
||||
|
||||
describe("agent state route", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.OPENCLAW_GATEWAY_SSH_TARGET;
|
||||
delete process.env.OPENCLAW_GATEWAY_SSH_USER;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
mockedSpawnSync.mockReset();
|
||||
mockedConsoleError.mockClear();
|
||||
});
|
||||
|
||||
it("rejects missing agentId", async () => {
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/gateway/agent-state", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects unsafe agentId", async () => {
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/gateway/agent-state", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ agentId: "../nope" }),
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("moves agent state via ssh", async () => {
|
||||
writeStudioSettings("ws://example.test:18789");
|
||||
|
||||
mockedSpawnSync.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ trashDir: "/home/ubuntu/.openclaw/trash/x", moved: [] }),
|
||||
stderr: "",
|
||||
error: undefined,
|
||||
} as never);
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/gateway/agent-state", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ agentId: "my-agent" }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedSpawnSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [cmd, args, options] = mockedSpawnSync.mock.calls[0] as [
|
||||
string,
|
||||
string[],
|
||||
{ encoding?: string; input?: string }
|
||||
];
|
||||
expect(cmd).toBe("ssh");
|
||||
expect(args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"ubuntu@example.test",
|
||||
"bash",
|
||||
"-s",
|
||||
"--",
|
||||
"my-agent",
|
||||
])
|
||||
);
|
||||
expect(options.encoding).toBe("utf8");
|
||||
expect(options.input).toContain("python3 - \"$1\"");
|
||||
expect(options.input).toContain("workspace-{agent_id}");
|
||||
});
|
||||
|
||||
it("restores agent state via ssh", async () => {
|
||||
writeStudioSettings("ws://example.test:18789");
|
||||
|
||||
mockedSpawnSync.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ restored: [] }),
|
||||
stderr: "",
|
||||
error: undefined,
|
||||
} as never);
|
||||
|
||||
const response = await PUT(
|
||||
new Request("http://localhost/api/gateway/agent-state", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ agentId: "my-agent", trashDir: "/tmp/trash" }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedSpawnSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [cmd, args] = mockedSpawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(cmd).toBe("ssh");
|
||||
expect(args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"ubuntu@example.test",
|
||||
"bash",
|
||||
"-s",
|
||||
"--",
|
||||
"my-agent",
|
||||
"/tmp/trash",
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured ssh target without studio settings", async () => {
|
||||
process.env.OPENCLAW_GATEWAY_SSH_TARGET = "me@host.test";
|
||||
|
||||
mockedSpawnSync.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ trashDir: "/home/ubuntu/.openclaw/trash/x", moved: [] }),
|
||||
stderr: "",
|
||||
error: undefined,
|
||||
} as never);
|
||||
|
||||
const response = await POST(
|
||||
new Request("http://localhost/api/gateway/agent-state", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ agentId: "my-agent" }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedSpawnSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [cmd, args] = mockedSpawnSync.mock.calls[0] as [string, string[]];
|
||||
expect(cmd).toBe("ssh");
|
||||
expect(args).toEqual(expect.arrayContaining(["me@host.test"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
agentStoreReducer,
|
||||
buildNewSessionAgentPatch,
|
||||
getFilteredAgents,
|
||||
initialAgentStoreState,
|
||||
type AgentStoreSeed,
|
||||
} from "@/features/agents/state/store";
|
||||
|
||||
describe("agent store", () => {
|
||||
it("hydrates agents with defaults and selection", () => {
|
||||
const seed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
};
|
||||
const next = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
expect(next.loading).toBe(false);
|
||||
expect(next.selectedAgentId).toBe("agent-1");
|
||||
expect(next.agents).toHaveLength(1);
|
||||
expect(next.agents[0].status).toBe("idle");
|
||||
expect(next.agents[0].thinkingLevel).toBe("high");
|
||||
expect(next.agents[0].sessionCreated).toBe(false);
|
||||
expect(next.agents[0].outputLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("hydrates agents with a requested selection when present", () => {
|
||||
const seeds: AgentStoreSeed[] = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
{
|
||||
agentId: "agent-2",
|
||||
name: "Agent Two",
|
||||
sessionKey: "agent:agent-2:main",
|
||||
},
|
||||
];
|
||||
const next = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: seeds,
|
||||
selectedAgentId: " agent-2 ",
|
||||
});
|
||||
expect(next.selectedAgentId).toBe("agent-2");
|
||||
});
|
||||
|
||||
it("keeps existing selection when requested selection is invalid", () => {
|
||||
const seeds: AgentStoreSeed[] = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
{
|
||||
agentId: "agent-2",
|
||||
name: "Agent Two",
|
||||
sessionKey: "agent:agent-2:main",
|
||||
},
|
||||
];
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: seeds,
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "selectAgent",
|
||||
agentId: "agent-2",
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "hydrateAgents",
|
||||
agents: seeds,
|
||||
selectedAgentId: "missing-agent",
|
||||
});
|
||||
expect(state.selectedAgentId).toBe("agent-2");
|
||||
});
|
||||
|
||||
it("builds a patch that resets runtime state for a session reset", () => {
|
||||
const seed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:old-session",
|
||||
};
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: {
|
||||
status: "running",
|
||||
awaitingUserInput: true,
|
||||
hasUnseenActivity: true,
|
||||
outputLines: ["> hello", "response"],
|
||||
lastResult: "response",
|
||||
lastDiff: "diff",
|
||||
runId: "run-1",
|
||||
streamText: "live",
|
||||
thinkingTrace: "thinking",
|
||||
latestOverride: "override",
|
||||
latestOverrideKind: "heartbeat",
|
||||
lastAssistantMessageAt: 1700000000000,
|
||||
lastActivityAt: 1700000000001,
|
||||
latestPreview: "preview",
|
||||
lastUserMessage: "hello",
|
||||
draft: "draft",
|
||||
historyLoadedAt: 1700000000002,
|
||||
},
|
||||
});
|
||||
|
||||
const agent = state.agents.find((entry) => entry.agentId === "agent-1")!;
|
||||
const patch = buildNewSessionAgentPatch(agent);
|
||||
|
||||
expect(patch.sessionKey).toBe("agent:agent-1:studio:old-session");
|
||||
expect(patch.status).toBe("idle");
|
||||
expect(patch.sessionCreated).toBe(true);
|
||||
expect(patch.sessionSettingsSynced).toBe(true);
|
||||
expect(patch.outputLines).toEqual([]);
|
||||
expect(patch.streamText).toBeNull();
|
||||
expect(patch.thinkingTrace).toBeNull();
|
||||
expect(patch.lastResult).toBeNull();
|
||||
expect(patch.lastDiff).toBeNull();
|
||||
expect(patch.historyLoadedAt).toBeNull();
|
||||
expect(patch.lastUserMessage).toBeNull();
|
||||
expect(patch.runId).toBeNull();
|
||||
expect(patch.runStartedAt).toBeNull();
|
||||
expect(patch.latestPreview).toBeNull();
|
||||
expect(patch.latestOverride).toBeNull();
|
||||
expect(patch.latestOverrideKind).toBeNull();
|
||||
expect(patch.lastAssistantMessageAt).toBeNull();
|
||||
expect(patch.awaitingUserInput).toBe(false);
|
||||
expect(patch.hasUnseenActivity).toBe(false);
|
||||
expect(patch.draft).toBe("");
|
||||
});
|
||||
|
||||
it("preserves_session_created_state_across_hydration", () => {
|
||||
const seed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
};
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { sessionCreated: true },
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
expect(state.agents[0]?.sessionCreated).toBe(true);
|
||||
});
|
||||
|
||||
it("resets_runtime_state_when_session_key_changes_on_hydration", () => {
|
||||
const initialSeed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:legacy",
|
||||
};
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [initialSeed],
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: {
|
||||
sessionCreated: true,
|
||||
outputLines: ["> old"],
|
||||
lastResult: "old result",
|
||||
runId: "run-1",
|
||||
},
|
||||
});
|
||||
|
||||
const nextSeed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
};
|
||||
state = agentStoreReducer(state, {
|
||||
type: "hydrateAgents",
|
||||
agents: [nextSeed],
|
||||
});
|
||||
const next = state.agents[0];
|
||||
expect(next?.sessionKey).toBe("agent:agent-1:main");
|
||||
expect(next?.sessionCreated).toBe(false);
|
||||
expect(next?.outputLines).toEqual([]);
|
||||
expect(next?.lastResult).toBeNull();
|
||||
expect(next?.runId).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps_transcript_references_for_non_transcript_agent_updates", () => {
|
||||
const seed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
};
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { outputLines: ["> hello", "response"] },
|
||||
});
|
||||
|
||||
const beforeDraftUpdate = state.agents[0];
|
||||
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { draft: "x" },
|
||||
});
|
||||
const afterDraftUpdate = state.agents[0];
|
||||
|
||||
expect(afterDraftUpdate.outputLines).toBe(beforeDraftUpdate.outputLines);
|
||||
expect(afterDraftUpdate.transcriptEntries).toBe(beforeDraftUpdate.transcriptEntries);
|
||||
expect(afterDraftUpdate.transcriptSequenceCounter).toBe(beforeDraftUpdate.transcriptSequenceCounter);
|
||||
});
|
||||
|
||||
it("tracks_unseen_activity_for_non_selected_agents", () => {
|
||||
const seeds: AgentStoreSeed[] = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
{
|
||||
agentId: "agent-2",
|
||||
name: "Agent Two",
|
||||
sessionKey: "agent:agent-2:main",
|
||||
},
|
||||
];
|
||||
const hydrated = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: seeds,
|
||||
});
|
||||
const withActivity = agentStoreReducer(hydrated, {
|
||||
type: "markActivity",
|
||||
agentId: "agent-2",
|
||||
at: 1700000000000,
|
||||
});
|
||||
const second = withActivity.agents.find((agent) => agent.agentId === "agent-2");
|
||||
expect(second?.hasUnseenActivity).toBe(true);
|
||||
expect(second?.lastActivityAt).toBe(1700000000000);
|
||||
|
||||
const selected = agentStoreReducer(withActivity, {
|
||||
type: "selectAgent",
|
||||
agentId: "agent-2",
|
||||
});
|
||||
const cleared = selected.agents.find((agent) => agent.agentId === "agent-2");
|
||||
expect(cleared?.hasUnseenActivity).toBe(false);
|
||||
});
|
||||
|
||||
it("filters_agents_by_status_and_approvals", () => {
|
||||
const seeds: AgentStoreSeed[] = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
{
|
||||
agentId: "agent-2",
|
||||
name: "Agent Two",
|
||||
sessionKey: "agent:agent-2:main",
|
||||
},
|
||||
{
|
||||
agentId: "agent-3",
|
||||
name: "Agent Three",
|
||||
sessionKey: "agent:agent-3:main",
|
||||
},
|
||||
];
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: seeds,
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { status: "idle", awaitingUserInput: true },
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-2",
|
||||
patch: { status: "running" },
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-3",
|
||||
patch: { status: "error" },
|
||||
});
|
||||
|
||||
expect(getFilteredAgents(state, "all").map((agent) => agent.agentId)).toEqual([
|
||||
"agent-2",
|
||||
"agent-1",
|
||||
"agent-3",
|
||||
]);
|
||||
expect(getFilteredAgents(state, "running").map((agent) => agent.agentId)).toEqual([
|
||||
"agent-2",
|
||||
]);
|
||||
expect(getFilteredAgents(state, "approvals").map((agent) => agent.agentId)).toEqual([
|
||||
"agent-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears_unseen_indicator_on_focus", () => {
|
||||
const seeds: AgentStoreSeed[] = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
{
|
||||
agentId: "agent-2",
|
||||
name: "Agent Two",
|
||||
sessionKey: "agent:agent-2:main",
|
||||
},
|
||||
];
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: seeds,
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "markActivity",
|
||||
agentId: "agent-2",
|
||||
at: 1700000000100,
|
||||
});
|
||||
|
||||
const before = state.agents.find((agent) => agent.agentId === "agent-2");
|
||||
expect(before?.hasUnseenActivity).toBe(true);
|
||||
|
||||
state = agentStoreReducer(state, {
|
||||
type: "selectAgent",
|
||||
agentId: "agent-2",
|
||||
});
|
||||
const after = state.agents.find((agent) => agent.agentId === "agent-2");
|
||||
expect(after?.hasUnseenActivity).toBe(false);
|
||||
});
|
||||
|
||||
it("sorts_filtered_agents_by_latest_assistant_message", () => {
|
||||
const seeds: AgentStoreSeed[] = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
{
|
||||
agentId: "agent-2",
|
||||
name: "Agent Two",
|
||||
sessionKey: "agent:agent-2:main",
|
||||
},
|
||||
{
|
||||
agentId: "agent-3",
|
||||
name: "Agent Three",
|
||||
sessionKey: "agent:agent-3:main",
|
||||
},
|
||||
];
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: seeds,
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { status: "running", lastAssistantMessageAt: 200 },
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-2",
|
||||
patch: { status: "running", lastAssistantMessageAt: 500 },
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-3",
|
||||
patch: { status: "running", lastAssistantMessageAt: 300 },
|
||||
});
|
||||
|
||||
expect(getFilteredAgents(state, "all").map((agent) => agent.agentId)).toEqual([
|
||||
"agent-2",
|
||||
"agent-3",
|
||||
"agent-1",
|
||||
]);
|
||||
expect(getFilteredAgents(state, "running").map((agent) => agent.agentId)).toEqual([
|
||||
"agent-2",
|
||||
"agent-3",
|
||||
"agent-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prioritizes_running_agents_in_all_filter_even_without_assistant_reply", () => {
|
||||
const seeds: AgentStoreSeed[] = [
|
||||
{
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
{
|
||||
agentId: "agent-2",
|
||||
name: "Agent Two",
|
||||
sessionKey: "agent:agent-2:main",
|
||||
},
|
||||
];
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: seeds,
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { status: "idle", lastAssistantMessageAt: 900 },
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-2",
|
||||
patch: { status: "running", runStartedAt: 1000, lastAssistantMessageAt: null },
|
||||
});
|
||||
|
||||
expect(getFilteredAgents(state, "all").map((agent) => agent.agentId)).toEqual([
|
||||
"agent-2",
|
||||
"agent-1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
normalizeBrowserPreviewUrl,
|
||||
resolveBrowserControlBaseUrl,
|
||||
shouldPreferBrowserScreenshot,
|
||||
} from "@/lib/office/browserPreview";
|
||||
|
||||
describe("browserPreview helpers", () => {
|
||||
it("prefers screenshots for frame-blocked social sites", () => {
|
||||
expect(shouldPreferBrowserScreenshot("https://x.com/example-user")).toBe(true);
|
||||
expect(shouldPreferBrowserScreenshot("https://www.linkedin.com/in/example")).toBe(true);
|
||||
expect(shouldPreferBrowserScreenshot("https://example.com/dashboard")).toBe(false);
|
||||
});
|
||||
|
||||
it("derives the local browser control service url from the gateway websocket url", () => {
|
||||
expect(resolveBrowserControlBaseUrl("ws://localhost:18789")).toBe("http://localhost:18791");
|
||||
expect(resolveBrowserControlBaseUrl("ws://0.0.0.0:19000")).toBe("http://127.0.0.1:19002");
|
||||
expect(resolveBrowserControlBaseUrl("wss://localhost:443")).toBe("https://localhost:445");
|
||||
});
|
||||
|
||||
it("ignores non-local gateway urls", () => {
|
||||
expect(resolveBrowserControlBaseUrl("ws://10.0.0.42:18789")).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes hash-only browser url differences", () => {
|
||||
expect(
|
||||
normalizeBrowserPreviewUrl("https://example.com/dashboard#section-a"),
|
||||
).toBe("https://example.com/dashboard");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
planDraftFlushIntent,
|
||||
planDraftTimerIntent,
|
||||
planNewSessionIntent,
|
||||
planStopRunIntent,
|
||||
} from "@/features/agents/operations/chatInteractionWorkflow";
|
||||
|
||||
describe("chatInteractionWorkflow", () => {
|
||||
it("denies stop-run when gateway is disconnected", () => {
|
||||
const intent = planStopRunIntent({
|
||||
status: "disconnected",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "session-1",
|
||||
busyAgentId: null,
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "deny",
|
||||
reason: "not-connected",
|
||||
message: "Connect to gateway before stopping a run.",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies stop-run when session key is missing", () => {
|
||||
const intent = planStopRunIntent({
|
||||
status: "connected",
|
||||
agentId: "agent-1",
|
||||
sessionKey: " ",
|
||||
busyAgentId: null,
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "deny",
|
||||
reason: "missing-session-key",
|
||||
message: "Missing session key for agent.",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips duplicate stop-run requests while same agent is busy", () => {
|
||||
const intent = planStopRunIntent({
|
||||
status: "connected",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "session-1",
|
||||
busyAgentId: "agent-1",
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "skip-busy",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows stop-run with a connected gateway and normalized session key", () => {
|
||||
const intent = planStopRunIntent({
|
||||
status: "connected",
|
||||
agentId: "agent-1",
|
||||
sessionKey: " session-1 ",
|
||||
busyAgentId: "agent-2",
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "allow",
|
||||
sessionKey: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies new-session when the agent cannot be found", () => {
|
||||
const intent = planNewSessionIntent({
|
||||
hasAgent: false,
|
||||
sessionKey: "session-1",
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "deny",
|
||||
reason: "missing-agent",
|
||||
message: "Failed to start new session: agent not found.",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies new-session when session key is missing", () => {
|
||||
const intent = planNewSessionIntent({
|
||||
hasAgent: true,
|
||||
sessionKey: "",
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "deny",
|
||||
reason: "missing-session-key",
|
||||
message: "Missing session key for agent.",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows new-session when agent exists and session key is present", () => {
|
||||
const intent = planNewSessionIntent({
|
||||
hasAgent: true,
|
||||
sessionKey: " session-1 ",
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "allow",
|
||||
sessionKey: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips draft flush when agent id is missing", () => {
|
||||
const intent = planDraftFlushIntent({
|
||||
agentId: null,
|
||||
hasPendingValue: true,
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "skip",
|
||||
reason: "missing-agent-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips draft flush when there is no pending draft value", () => {
|
||||
const intent = planDraftFlushIntent({
|
||||
agentId: "agent-1",
|
||||
hasPendingValue: false,
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "skip",
|
||||
reason: "missing-pending-value",
|
||||
});
|
||||
});
|
||||
|
||||
it("flushes draft when an agent id and pending value are present", () => {
|
||||
const intent = planDraftFlushIntent({
|
||||
agentId: "agent-1",
|
||||
hasPendingValue: true,
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "flush",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("schedules draft timer with default debounce", () => {
|
||||
const intent = planDraftTimerIntent({
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "schedule",
|
||||
agentId: "agent-1",
|
||||
delayMs: 250,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows overriding draft timer delay", () => {
|
||||
const intent = planDraftTimerIntent({
|
||||
agentId: "agent-1",
|
||||
delayMs: 500,
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "schedule",
|
||||
agentId: "agent-1",
|
||||
delayMs: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips draft timer scheduling when agent id is missing", () => {
|
||||
const intent = planDraftTimerIntent({
|
||||
agentId: "",
|
||||
delayMs: 250,
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "skip",
|
||||
reason: "missing-agent-id",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildAgentChatItems,
|
||||
buildAgentChatRenderBlocks,
|
||||
buildFinalAgentChatItems,
|
||||
summarizeToolLabel,
|
||||
} from "@/features/agents/components/chatItems";
|
||||
import { formatMetaMarkdown, formatThinkingMarkdown, formatToolCallMarkdown, formatToolResultMarkdown } from "@/lib/text/message-extract";
|
||||
|
||||
describe("buildAgentChatItems", () => {
|
||||
it("keeps thinking traces aligned with each assistant turn", () => {
|
||||
const items = buildAgentChatItems({
|
||||
outputLines: [
|
||||
"> first question",
|
||||
formatThinkingMarkdown("first plan"),
|
||||
"first answer",
|
||||
"> second question",
|
||||
formatThinkingMarkdown("second plan"),
|
||||
"second answer",
|
||||
],
|
||||
streamText: null,
|
||||
liveThinkingTrace: "",
|
||||
showThinkingTraces: true,
|
||||
toolCallingEnabled: true,
|
||||
});
|
||||
|
||||
expect(items.map((item) => item.kind)).toEqual([
|
||||
"user",
|
||||
"thinking",
|
||||
"assistant",
|
||||
"user",
|
||||
"thinking",
|
||||
"assistant",
|
||||
]);
|
||||
expect(items[1]).toMatchObject({ kind: "thinking", text: "_first plan_" });
|
||||
expect(items[4]).toMatchObject({ kind: "thinking", text: "_second plan_" });
|
||||
});
|
||||
|
||||
it("does not include saved traces when thinking traces are disabled", () => {
|
||||
const items = buildAgentChatItems({
|
||||
outputLines: [
|
||||
"> first question",
|
||||
formatThinkingMarkdown("first plan"),
|
||||
"first answer",
|
||||
],
|
||||
streamText: null,
|
||||
liveThinkingTrace: "live plan",
|
||||
showThinkingTraces: false,
|
||||
toolCallingEnabled: true,
|
||||
});
|
||||
|
||||
expect(items.map((item) => item.kind)).toEqual(["user", "assistant"]);
|
||||
});
|
||||
|
||||
it("adds a live trace before the live assistant stream", () => {
|
||||
const items = buildAgentChatItems({
|
||||
outputLines: ["first answer"],
|
||||
streamText: "stream answer",
|
||||
liveThinkingTrace: "first plan",
|
||||
showThinkingTraces: true,
|
||||
toolCallingEnabled: true,
|
||||
});
|
||||
|
||||
expect(items.map((item) => item.kind)).toEqual(["assistant", "thinking", "assistant"]);
|
||||
expect(items[1]).toMatchObject({ kind: "thinking", text: "_first plan_", live: true });
|
||||
});
|
||||
|
||||
it("merges adjacent thinking traces into a single item", () => {
|
||||
const items = buildAgentChatItems({
|
||||
outputLines: [formatThinkingMarkdown("first plan"), formatThinkingMarkdown("second plan"), "answer"],
|
||||
streamText: null,
|
||||
liveThinkingTrace: "",
|
||||
showThinkingTraces: true,
|
||||
toolCallingEnabled: true,
|
||||
});
|
||||
|
||||
expect(items.map((item) => item.kind)).toEqual(["thinking", "assistant"]);
|
||||
expect(items[0]).toMatchObject({
|
||||
kind: "thinking",
|
||||
text: "_first plan_\n\n_second plan_",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFinalAgentChatItems", () => {
|
||||
it("does not include live thinking or live assistant items", () => {
|
||||
const items = buildFinalAgentChatItems({
|
||||
outputLines: ["> question", formatThinkingMarkdown("plan"), "answer"],
|
||||
showThinkingTraces: true,
|
||||
toolCallingEnabled: true,
|
||||
});
|
||||
|
||||
expect(items.map((item) => item.kind)).toEqual(["user", "thinking", "assistant"]);
|
||||
});
|
||||
|
||||
it("propagates meta timestamps and thinking duration into subsequent items", () => {
|
||||
const items = buildFinalAgentChatItems({
|
||||
outputLines: [
|
||||
formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }),
|
||||
"> hello",
|
||||
formatMetaMarkdown({ role: "assistant", timestamp: 1700000001234, thinkingDurationMs: 1800 }),
|
||||
formatThinkingMarkdown("plan"),
|
||||
"answer",
|
||||
],
|
||||
showThinkingTraces: true,
|
||||
toolCallingEnabled: true,
|
||||
});
|
||||
|
||||
expect(items[0]).toMatchObject({ kind: "user", text: "hello", timestampMs: 1700000000000 });
|
||||
expect(items[1]).toMatchObject({
|
||||
kind: "thinking",
|
||||
text: "_plan_",
|
||||
timestampMs: 1700000001234,
|
||||
thinkingDurationMs: 1800,
|
||||
});
|
||||
expect(items[2]).toMatchObject({
|
||||
kind: "assistant",
|
||||
text: "answer",
|
||||
timestampMs: 1700000001234,
|
||||
thinkingDurationMs: 1800,
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses adjacent duplicate user items when optimistic and persisted turns match", () => {
|
||||
const items = buildFinalAgentChatItems({
|
||||
outputLines: [
|
||||
"> hello\n\nworld",
|
||||
formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }),
|
||||
"> hello world",
|
||||
],
|
||||
showThinkingTraces: true,
|
||||
toolCallingEnabled: true,
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
kind: "user",
|
||||
text: "hello world",
|
||||
timestampMs: 1700000000000,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does_not_collapse_repeated_user_message_when_second_turn_is_only_optimistic", () => {
|
||||
const items = buildFinalAgentChatItems({
|
||||
outputLines: [
|
||||
formatMetaMarkdown({ role: "user", timestamp: 1700000000000 }),
|
||||
"> repeat",
|
||||
"> repeat",
|
||||
],
|
||||
showThinkingTraces: true,
|
||||
toolCallingEnabled: true,
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
kind: "user",
|
||||
text: "repeat",
|
||||
timestampMs: 1700000000000,
|
||||
},
|
||||
{
|
||||
kind: "user",
|
||||
text: "repeat",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps assistant markdown as assistant content", () => {
|
||||
const items = buildFinalAgentChatItems({
|
||||
outputLines: ["- first item\n- second item"],
|
||||
showThinkingTraces: true,
|
||||
toolCallingEnabled: true,
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ kind: "assistant" });
|
||||
expect(items[0]?.text).toContain("- first item");
|
||||
});
|
||||
|
||||
it("classifies tool markdown as tool items when tool calling is enabled", () => {
|
||||
const callLine = formatToolCallMarkdown({
|
||||
id: "call_123",
|
||||
name: "exec",
|
||||
arguments: { command: "pwd" },
|
||||
});
|
||||
const toolLine = formatToolResultMarkdown({
|
||||
toolCallId: "call_123",
|
||||
toolName: "exec",
|
||||
details: { status: "completed", exitCode: 0 },
|
||||
text: "pwd",
|
||||
isError: false,
|
||||
});
|
||||
const items = buildFinalAgentChatItems({
|
||||
outputLines: [callLine, toolLine],
|
||||
showThinkingTraces: true,
|
||||
toolCallingEnabled: true,
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
kind: "tool",
|
||||
text: callLine,
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
text: toolLine,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides tool results when tool calling is disabled", () => {
|
||||
const toolLine = formatToolResultMarkdown({
|
||||
toolCallId: "call_456",
|
||||
toolName: "exec",
|
||||
details: { status: "completed", exitCode: 0 },
|
||||
text: "pwd",
|
||||
isError: false,
|
||||
});
|
||||
const items = buildFinalAgentChatItems({
|
||||
outputLines: [toolLine],
|
||||
showThinkingTraces: true,
|
||||
toolCallingEnabled: false,
|
||||
});
|
||||
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeToolLabel", () => {
|
||||
it("hides long tool call ids and prefers showing the command/path/url value", () => {
|
||||
const toolCallLine = formatToolCallMarkdown({
|
||||
id: "call_ABC123|fc_456",
|
||||
name: "functions.exec",
|
||||
arguments: { command: "gh auth status" },
|
||||
});
|
||||
|
||||
const { summaryText: callSummary } = summarizeToolLabel(toolCallLine);
|
||||
expect(callSummary).toContain("gh auth status");
|
||||
expect(callSummary).not.toContain("call_");
|
||||
|
||||
const toolResultLine = formatToolResultMarkdown({
|
||||
toolCallId: "call_ABC123|fc_456",
|
||||
toolName: "functions.exec",
|
||||
details: { status: "completed", exitCode: 0, durationMs: 168 },
|
||||
isError: false,
|
||||
text: "ok",
|
||||
});
|
||||
|
||||
const { summaryText: resultSummary } = summarizeToolLabel(toolResultLine);
|
||||
expect(resultSummary).toContain("completed");
|
||||
expect(resultSummary).toContain("exit 0");
|
||||
expect(resultSummary).not.toContain("call_");
|
||||
});
|
||||
|
||||
it("renders read file calls as inline path labels without JSON body", () => {
|
||||
const toolCallLine = formatToolCallMarkdown({
|
||||
id: "call_read_1",
|
||||
name: "read",
|
||||
arguments: { file_path: "/path/to/openclaw-agent-home/README.md" },
|
||||
});
|
||||
|
||||
const summary = summarizeToolLabel(toolCallLine);
|
||||
expect(summary.summaryText).toBe(
|
||||
"read /path/to/openclaw-agent-home/README.md"
|
||||
);
|
||||
expect(summary.inlineOnly).toBe(true);
|
||||
expect(summary.body).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAgentChatRenderBlocks", () => {
|
||||
it("groups thinking and tool events into one assistant block in original order", () => {
|
||||
const toolCallLine = formatToolCallMarkdown({
|
||||
id: "call_1",
|
||||
name: "exec",
|
||||
arguments: { command: "pwd" },
|
||||
});
|
||||
const toolResultLine = formatToolResultMarkdown({
|
||||
toolCallId: "call_1",
|
||||
toolName: "exec",
|
||||
details: { status: "completed", exitCode: 0 },
|
||||
text: "/repo",
|
||||
isError: false,
|
||||
});
|
||||
|
||||
const blocks = buildAgentChatRenderBlocks([
|
||||
{ kind: "thinking", text: "_plan before tool_", timestampMs: 100 },
|
||||
{ kind: "tool", text: toolCallLine, timestampMs: 101 },
|
||||
{ kind: "thinking", text: "_plan after tool_", timestampMs: 102 },
|
||||
{ kind: "tool", text: toolResultLine, timestampMs: 103 },
|
||||
{ kind: "assistant", text: "done", timestampMs: 104 },
|
||||
]);
|
||||
|
||||
expect(blocks).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
text: "done",
|
||||
timestampMs: 100,
|
||||
traceEvents: [
|
||||
{ kind: "thinking", text: "_plan before tool_" },
|
||||
{ kind: "tool", text: toolCallLine },
|
||||
{ kind: "thinking", text: "_plan after tool_" },
|
||||
{ kind: "tool", text: toolResultLine },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts a new assistant block after a user turn", () => {
|
||||
const blocks = buildAgentChatRenderBlocks([
|
||||
{ kind: "thinking", text: "_first plan_", timestampMs: 10 },
|
||||
{ kind: "assistant", text: "first answer", timestampMs: 11 },
|
||||
{ kind: "user", text: "next question", timestampMs: 12 },
|
||||
{ kind: "thinking", text: "_second plan_", timestampMs: 13 },
|
||||
{ kind: "assistant", text: "second answer", timestampMs: 14 },
|
||||
]);
|
||||
|
||||
expect(blocks.map((block) => block.kind)).toEqual(["assistant", "user", "assistant"]);
|
||||
});
|
||||
|
||||
it("merges adjacent incremental thinking updates", () => {
|
||||
const blocks = buildAgentChatRenderBlocks([
|
||||
{ kind: "thinking", text: "_a_", timestampMs: 10 },
|
||||
{ kind: "thinking", text: "_a_\n\n_b_", timestampMs: 10 },
|
||||
{ kind: "assistant", text: "answer", timestampMs: 10 },
|
||||
]);
|
||||
|
||||
expect(blocks).toEqual([
|
||||
{
|
||||
kind: "assistant",
|
||||
text: "answer",
|
||||
timestampMs: 10,
|
||||
traceEvents: [{ kind: "thinking", text: "_a_\n\n_b_" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,665 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
import { formatMetaMarkdown } from "@/lib/text/message-extract";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
|
||||
const base: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: false,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
};
|
||||
const merged = { ...base, ...(overrides ?? {}) };
|
||||
|
||||
return {
|
||||
...merged,
|
||||
historyFetchLimit: merged.historyFetchLimit ?? null,
|
||||
historyFetchedCount: merged.historyFetchedCount ?? null,
|
||||
historyMaybeTruncated: merged.historyMaybeTruncated ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
const createWebchatBlockedPatchError = () =>
|
||||
new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "webchat clients cannot patch sessions; use chat.send for session-scoped updates",
|
||||
});
|
||||
|
||||
describe("sendChatMessageViaStudio", () => {
|
||||
it("handles_reset_command", async () => {
|
||||
const agent = createAgent({
|
||||
outputLines: ["old"],
|
||||
streamText: "stream",
|
||||
thinkingTrace: "thinking",
|
||||
lastResult: "result",
|
||||
sessionSettingsSynced: true,
|
||||
});
|
||||
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async () => ({}));
|
||||
const clearRunTracking = vi.fn();
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "/reset",
|
||||
clearRunTracking,
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
expect(clearRunTracking).toHaveBeenCalledWith("run-1");
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "updateAgent",
|
||||
agentId: agent.agentId,
|
||||
patch: expect.objectContaining({
|
||||
outputLines: [],
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
lastResult: null,
|
||||
transcriptEntries: [],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs_session_settings_when_not_synced", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.patch") {
|
||||
return {
|
||||
ok: true,
|
||||
key: agent.sessionKey,
|
||||
entry: { thinkingLevel: "medium" },
|
||||
resolved: { modelProvider: "openai", model: "gpt-5" },
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const methods = call.mock.calls.map((entry) => entry[0]);
|
||||
expect(methods).toEqual(["sessions.patch", "chat.send"]);
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
"sessions.patch",
|
||||
expect.objectContaining({
|
||||
key: agent.sessionKey,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
})
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "updateAgent",
|
||||
agentId: agent.agentId,
|
||||
patch: { sessionSettingsSynced: true, sessionCreated: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("continues_send_when_webchat_patch_is_blocked", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async (method: string, payload?: unknown) => {
|
||||
if (method === "sessions.patch") {
|
||||
throw createWebchatBlockedPatchError();
|
||||
}
|
||||
if (method === "chat.send") {
|
||||
const runId =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"idempotencyKey" in payload &&
|
||||
typeof payload.idempotencyKey === "string"
|
||||
? payload.idempotencyKey
|
||||
: "run";
|
||||
return { runId, status: "started" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const methods = call.mock.calls.map((entry) => entry[0]);
|
||||
expect(methods).toEqual(["sessions.patch", "chat.send"]);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "updateAgent",
|
||||
agentId: agent.agentId,
|
||||
patch: { sessionSettingsSynced: true, sessionCreated: true },
|
||||
});
|
||||
|
||||
const errorLines = dispatch.mock.calls
|
||||
.map((entry) => entry[0])
|
||||
.filter(
|
||||
(
|
||||
action
|
||||
): action is {
|
||||
type: "appendOutput";
|
||||
line: string;
|
||||
} =>
|
||||
action &&
|
||||
typeof action === "object" &&
|
||||
"type" in action &&
|
||||
action.type === "appendOutput" &&
|
||||
"line" in action &&
|
||||
typeof action.line === "string" &&
|
||||
action.line.startsWith("Error:")
|
||||
)
|
||||
.map((action) => action.line);
|
||||
expect(errorLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails_send_when_patch_error_is_not_webchat_blocked", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.patch") {
|
||||
throw new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "invalid model ref",
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const methods = call.mock.calls.map((entry) => entry[0]);
|
||||
expect(methods).toEqual(["sessions.patch"]);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "appendOutput",
|
||||
agentId: agent.agentId,
|
||||
line: "Error: invalid model ref",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses_patch_retry_after_webchat_blocked_patch_error", async () => {
|
||||
let agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
|
||||
const dispatch = vi.fn(
|
||||
(action: { type: string; agentId?: string; patch?: Partial<AgentState> }) => {
|
||||
if (action.type !== "updateAgent" || action.agentId !== agent.agentId || !action.patch) {
|
||||
return;
|
||||
}
|
||||
agent = { ...agent, ...action.patch };
|
||||
}
|
||||
);
|
||||
const call = vi.fn(async (method: string, payload?: unknown) => {
|
||||
if (method === "sessions.patch") {
|
||||
throw createWebchatBlockedPatchError();
|
||||
}
|
||||
if (method === "chat.send") {
|
||||
const runId =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"idempotencyKey" in payload &&
|
||||
typeof payload.idempotencyKey === "string"
|
||||
? payload.idempotencyKey
|
||||
: "run";
|
||||
return { runId, status: "started" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "first",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "second",
|
||||
now: () => 1240,
|
||||
generateRunId: () => "run-2",
|
||||
});
|
||||
|
||||
const methods = call.mock.calls.map((entry) => entry[0]);
|
||||
expect(methods.filter((method) => method === "sessions.patch")).toHaveLength(1);
|
||||
expect(methods.filter((method) => method === "chat.send")).toHaveLength(2);
|
||||
expect(agent.sessionSettingsSynced).toBe(true);
|
||||
expect(agent.sessionCreated).toBe(true);
|
||||
});
|
||||
|
||||
it("syncs exec session overrides for ask-first agents", async () => {
|
||||
const agent = createAgent({
|
||||
sessionSettingsSynced: false,
|
||||
sessionCreated: false,
|
||||
sessionExecHost: "gateway",
|
||||
sessionExecSecurity: "allowlist",
|
||||
sessionExecAsk: "always",
|
||||
});
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.patch") {
|
||||
return {
|
||||
ok: true,
|
||||
key: agent.sessionKey,
|
||||
entry: { thinkingLevel: "medium" },
|
||||
resolved: { modelProvider: "openai", model: "gpt-5" },
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
"sessions.patch",
|
||||
expect.objectContaining({
|
||||
key: agent.sessionKey,
|
||||
execHost: "gateway",
|
||||
execSecurity: "allowlist",
|
||||
execAsk: "always",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does_not_sync_session_settings_when_already_synced", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: true });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async () => ({ runId: "run-1", status: "started" }));
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
"chat.send",
|
||||
expect.objectContaining({ sessionKey: agent.sessionKey })
|
||||
);
|
||||
expect(call).not.toHaveBeenCalledWith(
|
||||
"sessions.patch",
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("clears_running_state_for_unknown_success_payload_shape", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const idlePatchAction = dispatch.mock.calls
|
||||
.map((entry) => entry[0])
|
||||
.find(
|
||||
(action) =>
|
||||
action?.type === "updateAgent" &&
|
||||
action?.agentId === agent.agentId &&
|
||||
action?.patch?.status === "idle" &&
|
||||
action?.patch?.runId === null
|
||||
);
|
||||
expect(idlePatchAction).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears_running_state_for_stop_style_immediate_success_payload", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async () => ({ ok: true, aborted: false, runIds: [] }));
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "stop please",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const idlePatchAction = dispatch.mock.calls
|
||||
.map((entry) => entry[0])
|
||||
.find(
|
||||
(action) =>
|
||||
action?.type === "updateAgent" &&
|
||||
action?.agentId === agent.agentId &&
|
||||
action?.patch?.status === "idle" &&
|
||||
action?.patch?.runId === null &&
|
||||
action?.patch?.runStartedAt === null &&
|
||||
action?.patch?.streamText === null &&
|
||||
action?.patch?.thinkingTrace === null
|
||||
);
|
||||
expect(idlePatchAction).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps_running_state_for_matching_streaming_status_payloads", async () => {
|
||||
const payloads = [{ runId: "run-1", status: "started" }, { runId: "run-1", status: "in_flight" }];
|
||||
|
||||
for (const payload of payloads) {
|
||||
const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async () => payload);
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const idlePatchAction = dispatch.mock.calls
|
||||
.map((entry) => entry[0])
|
||||
.find(
|
||||
(action) =>
|
||||
action?.type === "updateAgent" &&
|
||||
action?.agentId === agent.agentId &&
|
||||
action?.patch?.status === "idle"
|
||||
);
|
||||
expect(idlePatchAction).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears_running_state_for_streaming_shape_with_mismatched_run_id", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: true, sessionCreated: true });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async () => ({ runId: "different-run", status: "started" }));
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const idlePatchAction = dispatch.mock.calls
|
||||
.map((entry) => entry[0])
|
||||
.find(
|
||||
(action) =>
|
||||
action?.type === "updateAgent" &&
|
||||
action?.agentId === agent.agentId &&
|
||||
action?.patch?.status === "idle" &&
|
||||
action?.patch?.runId === null
|
||||
);
|
||||
expect(idlePatchAction).toBeTruthy();
|
||||
});
|
||||
|
||||
it("supports_internal_send_without_local_user_echo", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: true });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "internal follow-up",
|
||||
echoUserMessage: false,
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const dispatchedActions = dispatch.mock.calls.map((entry) => entry[0]);
|
||||
expect(
|
||||
dispatchedActions.some(
|
||||
(action) => action.type === "appendOutput" && action.line === "> internal follow-up"
|
||||
)
|
||||
).toBe(false);
|
||||
const runningUpdate = dispatchedActions.find(
|
||||
(action) => action.type === "updateAgent" && action.patch?.status === "running"
|
||||
);
|
||||
expect(runningUpdate).toBeTruthy();
|
||||
if (runningUpdate && runningUpdate.type === "updateAgent") {
|
||||
expect(runningUpdate.patch.lastUserMessage).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks_error_on_gateway_failure", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: true });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "chat.send") {
|
||||
throw new Error("boom");
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "updateAgent",
|
||||
agentId: agent.agentId,
|
||||
patch: { status: "error", runId: null, runStartedAt: null, streamText: null, thinkingTrace: null },
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "appendOutput",
|
||||
agentId: agent.agentId,
|
||||
line: "Error: boom",
|
||||
});
|
||||
});
|
||||
|
||||
it("optimistically_appends_only_user_content_line", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: true });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "Hello world",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const appendLines = dispatch.mock.calls
|
||||
.map((entry) => entry[0])
|
||||
.filter((action): action is { type: "appendOutput"; line: string } => {
|
||||
return Boolean(
|
||||
action &&
|
||||
typeof action === "object" &&
|
||||
"type" in action &&
|
||||
action.type === "appendOutput" &&
|
||||
"line" in action &&
|
||||
typeof action.line === "string"
|
||||
);
|
||||
})
|
||||
.map((action) => action.line);
|
||||
|
||||
expect(appendLines).toContain("> Hello world");
|
||||
expect(appendLines.some((line) => line.startsWith("[[meta]]"))).toBe(false);
|
||||
});
|
||||
|
||||
it("uses_monotonic_timestamp_for_optimistic_user_turn_ordering", async () => {
|
||||
const sessionKey = "agent:agent-1:studio:test-session";
|
||||
const agent = createAgent({
|
||||
sessionSettingsSynced: true,
|
||||
transcriptEntries: [
|
||||
{
|
||||
entryId: "history:assistant:1",
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
text: "previous assistant",
|
||||
sessionKey,
|
||||
runId: null,
|
||||
source: "history",
|
||||
timestampMs: 5000,
|
||||
sequenceKey: 10,
|
||||
confirmed: true,
|
||||
fingerprint: "fp-prev-assistant",
|
||||
},
|
||||
],
|
||||
});
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "new message",
|
||||
now: () => 1000,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const optimisticUserAppend = dispatch.mock.calls
|
||||
.map((entry) => entry[0])
|
||||
.find(
|
||||
(action) =>
|
||||
action &&
|
||||
typeof action === "object" &&
|
||||
"type" in action &&
|
||||
action.type === "appendOutput" &&
|
||||
"line" in action &&
|
||||
action.line === "> new message"
|
||||
);
|
||||
expect(optimisticUserAppend).toBeTruthy();
|
||||
expect((optimisticUserAppend as { transcript?: { timestampMs?: number } }).transcript?.timestampMs).toBe(5001);
|
||||
});
|
||||
|
||||
it("uses_output_meta_timestamps_when_transcript_entries_are_missing", async () => {
|
||||
const sessionKey = "agent:agent-1:studio:test-session";
|
||||
const agent = createAgent({
|
||||
sessionSettingsSynced: true,
|
||||
transcriptEntries: undefined,
|
||||
outputLines: [
|
||||
formatMetaMarkdown({ role: "assistant", timestamp: 12_000 }),
|
||||
"previous assistant",
|
||||
],
|
||||
});
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey,
|
||||
message: "new message",
|
||||
now: () => 1000,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const optimisticUserAppend = dispatch.mock.calls
|
||||
.map((entry) => entry[0])
|
||||
.find(
|
||||
(action) =>
|
||||
action &&
|
||||
typeof action === "object" &&
|
||||
"type" in action &&
|
||||
action.type === "appendOutput" &&
|
||||
"line" in action &&
|
||||
action.line === "> new message"
|
||||
);
|
||||
expect(optimisticUserAppend).toBeTruthy();
|
||||
expect((optimisticUserAppend as { transcript?: { timestampMs?: number } }).transcript?.timestampMs).toBe(12_001);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AGENT_STATUS_BADGE_CLASS,
|
||||
AGENT_STATUS_LABEL,
|
||||
GATEWAY_STATUS_BADGE_CLASS,
|
||||
GATEWAY_STATUS_LABEL,
|
||||
NEEDS_APPROVAL_BADGE_CLASS,
|
||||
resolveAgentStatusBadgeClass,
|
||||
resolveAgentStatusLabel,
|
||||
resolveGatewayStatusBadgeClass,
|
||||
resolveGatewayStatusLabel,
|
||||
} from "@/features/agents/components/colorSemantics";
|
||||
|
||||
describe("colorSemantics", () => {
|
||||
it("maps agent statuses to semantic badge classes and labels", () => {
|
||||
expect(AGENT_STATUS_LABEL.idle).toBe("Idle");
|
||||
expect(AGENT_STATUS_LABEL.running).toBe("Running");
|
||||
expect(AGENT_STATUS_LABEL.error).toBe("Error");
|
||||
|
||||
expect(AGENT_STATUS_BADGE_CLASS.idle).toBe("ui-badge-status-idle");
|
||||
expect(AGENT_STATUS_BADGE_CLASS.running).toBe("ui-badge-status-running");
|
||||
expect(AGENT_STATUS_BADGE_CLASS.error).toBe("ui-badge-status-error");
|
||||
|
||||
expect(resolveAgentStatusLabel("idle")).toBe("Idle");
|
||||
expect(resolveAgentStatusBadgeClass("running")).toBe("ui-badge-status-running");
|
||||
});
|
||||
|
||||
it("maps gateway statuses to semantic badge classes and labels", () => {
|
||||
expect(GATEWAY_STATUS_LABEL.disconnected).toBe("Disconnected");
|
||||
expect(GATEWAY_STATUS_LABEL.connecting).toBe("Connecting");
|
||||
expect(GATEWAY_STATUS_LABEL.connected).toBe("Connected");
|
||||
|
||||
expect(GATEWAY_STATUS_BADGE_CLASS.disconnected).toBe("ui-badge-status-disconnected");
|
||||
expect(GATEWAY_STATUS_BADGE_CLASS.connecting).toBe("ui-badge-status-connecting");
|
||||
expect(GATEWAY_STATUS_BADGE_CLASS.connected).toBe("ui-badge-status-connected");
|
||||
|
||||
expect(resolveGatewayStatusLabel("connected")).toBe("Connected");
|
||||
expect(resolveGatewayStatusBadgeClass("disconnected")).toBe("ui-badge-status-disconnected");
|
||||
});
|
||||
|
||||
it("keeps approval state on its own semantic class", () => {
|
||||
expect(NEEDS_APPROVAL_BADGE_CLASS).toBe("ui-badge-approval");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const TEST_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(TEST_DIR, "..", "..");
|
||||
|
||||
const COLOR_OWNED_FILES = [
|
||||
"src/features/agents/components/FleetSidebar.tsx",
|
||||
"src/features/agents/components/AgentChatPanel.tsx",
|
||||
"src/features/agents/components/ConnectionPanel.tsx",
|
||||
"src/features/agents/components/AgentInspectPanels.tsx",
|
||||
"src/features/agents/components/GatewayConnectScreen.tsx",
|
||||
"src/features/agents/components/AgentCreateModal.tsx",
|
||||
"src/features/agents/components/HeaderBar.tsx",
|
||||
"src/app/page.tsx",
|
||||
] as const;
|
||||
|
||||
const RAW_HUE_UTILITY_PATTERN =
|
||||
/\b(?:bg|text|border|from|to|via)-(?:amber|cyan|emerald|orange|violet|red|green|blue|zinc)-\d{2,3}(?:\/\d{1,3})?\b/g;
|
||||
|
||||
describe("color semantic guard", () => {
|
||||
it("blocks raw hue utility classes in color-owned UI files", () => {
|
||||
const offenders: string[] = [];
|
||||
|
||||
for (const relativePath of COLOR_OWNED_FILES) {
|
||||
const source = readFileSync(resolve(REPO_ROOT, relativePath), "utf8");
|
||||
const matches = source.match(RAW_HUE_UTILITY_PATTERN) ?? [];
|
||||
for (const match of matches) {
|
||||
offenders.push(`${relativePath}: ${match}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldStartNextConfigMutation } from "@/features/agents/operations/configMutationGatePolicy";
|
||||
|
||||
describe("shouldStartNextConfigMutation", () => {
|
||||
it("returns_false_when_queue_empty", () => {
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connected",
|
||||
hasRunningAgents: false,
|
||||
nextMutationRequiresIdleAgents: false,
|
||||
hasActiveMutation: false,
|
||||
hasRestartBlockInProgress: false,
|
||||
queuedCount: 0,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns_false_when_not_connected", () => {
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connecting",
|
||||
hasRunningAgents: false,
|
||||
nextMutationRequiresIdleAgents: false,
|
||||
hasActiveMutation: false,
|
||||
hasRestartBlockInProgress: false,
|
||||
queuedCount: 1,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns_false_when_running_agents_and_next_mutation_requires_idle_agents", () => {
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connected",
|
||||
hasRunningAgents: true,
|
||||
nextMutationRequiresIdleAgents: true,
|
||||
hasActiveMutation: false,
|
||||
hasRestartBlockInProgress: false,
|
||||
queuedCount: 1,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns_true_when_running_agents_but_next_mutation_does_not_require_idle_agents", () => {
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connected",
|
||||
hasRunningAgents: true,
|
||||
nextMutationRequiresIdleAgents: false,
|
||||
hasActiveMutation: false,
|
||||
hasRestartBlockInProgress: false,
|
||||
queuedCount: 1,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns_false_when_active_mutation", () => {
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connected",
|
||||
hasRunningAgents: false,
|
||||
nextMutationRequiresIdleAgents: false,
|
||||
hasActiveMutation: true,
|
||||
hasRestartBlockInProgress: false,
|
||||
queuedCount: 1,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns_false_when_restart_block_in_progress", () => {
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connected",
|
||||
hasRunningAgents: false,
|
||||
nextMutationRequiresIdleAgents: false,
|
||||
hasActiveMutation: false,
|
||||
hasRestartBlockInProgress: true,
|
||||
queuedCount: 1,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns_true_when_connected_idle_and_queue_non_empty", () => {
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connected",
|
||||
hasRunningAgents: false,
|
||||
nextMutationRequiresIdleAgents: false,
|
||||
hasActiveMutation: false,
|
||||
hasRestartBlockInProgress: false,
|
||||
queuedCount: 1,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ConnectionPanel } from "@/features/agents/components/ConnectionPanel";
|
||||
|
||||
describe("ConnectionPanel close control", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders close control and calls handler when provided", () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
createElement(ConnectionPanel, {
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
token: "token",
|
||||
status: "disconnected",
|
||||
error: null,
|
||||
onGatewayUrlChange: vi.fn(),
|
||||
onTokenChange: vi.fn(),
|
||||
onConnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
onClose,
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("gateway-connection-close"));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not render close control when handler is missing", () => {
|
||||
render(
|
||||
createElement(ConnectionPanel, {
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
token: "token",
|
||||
status: "disconnected",
|
||||
error: null,
|
||||
onGatewayUrlChange: vi.fn(),
|
||||
onTokenChange: vi.fn(),
|
||||
onConnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("gateway-connection-close")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders semantic gateway status class markers", () => {
|
||||
const { rerender } = render(
|
||||
createElement(ConnectionPanel, {
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
token: "token",
|
||||
status: "disconnected",
|
||||
error: null,
|
||||
onGatewayUrlChange: vi.fn(),
|
||||
onTokenChange: vi.fn(),
|
||||
onConnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const disconnected = screen.getByText("Disconnected");
|
||||
expect(disconnected).toHaveAttribute("data-status", "disconnected");
|
||||
expect(disconnected).toHaveClass("ui-badge-status-disconnected");
|
||||
|
||||
rerender(
|
||||
createElement(ConnectionPanel, {
|
||||
gatewayUrl: "ws://127.0.0.1:18789",
|
||||
token: "token",
|
||||
status: "connected",
|
||||
error: null,
|
||||
onGatewayUrlChange: vi.fn(),
|
||||
onTokenChange: vi.fn(),
|
||||
onConnect: vi.fn(),
|
||||
onDisconnect: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const connected = screen.getByText("Connected");
|
||||
expect(connected).toHaveAttribute("data-status", "connected");
|
||||
expect(connected).toHaveClass("ui-badge-status-connected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
CREATE_AGENT_DEFAULT_PERMISSIONS,
|
||||
runCreateAgentBootstrapOperation,
|
||||
} from "@/features/agents/operations/createAgentBootstrapOperation";
|
||||
|
||||
describe("createAgentBootstrapOperation", () => {
|
||||
it("exports_autonomous_create_defaults", () => {
|
||||
expect(CREATE_AGENT_DEFAULT_PERMISSIONS).toEqual({
|
||||
commandMode: "auto",
|
||||
webAccess: true,
|
||||
fileTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries load and lookup once before unresolved-created-agent disposition", async () => {
|
||||
const loadAgents = vi.fn(async () => undefined);
|
||||
const findAgentById = vi.fn(() => null);
|
||||
const applyDefaultPermissions = vi.fn(async () => undefined);
|
||||
const refreshGatewayConfigSnapshot = vi.fn(async () => undefined);
|
||||
|
||||
const commands = await runCreateAgentBootstrapOperation({
|
||||
completion: { agentId: "agent-1", agentName: "Agent One" },
|
||||
focusedAgentId: "focused-1",
|
||||
loadAgents,
|
||||
findAgentById,
|
||||
applyDefaultPermissions,
|
||||
refreshGatewayConfigSnapshot,
|
||||
});
|
||||
|
||||
expect(loadAgents).toHaveBeenCalledTimes(2);
|
||||
expect(findAgentById).toHaveBeenCalledTimes(2);
|
||||
expect(findAgentById).toHaveBeenNthCalledWith(1, "agent-1");
|
||||
expect(findAgentById).toHaveBeenNthCalledWith(2, "agent-1");
|
||||
expect(applyDefaultPermissions).not.toHaveBeenCalled();
|
||||
expect(refreshGatewayConfigSnapshot).not.toHaveBeenCalled();
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
kind: "set-create-modal-error",
|
||||
message: 'Agent "Agent One" was created, but Studio could not load it yet.',
|
||||
},
|
||||
{
|
||||
kind: "set-global-error",
|
||||
message: 'Agent "Agent One" was created, but Studio could not load it yet.',
|
||||
},
|
||||
{ kind: "set-create-block", value: null },
|
||||
{ kind: "set-create-modal-open", open: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs bootstrap success flow and refreshes gateway config snapshot", async () => {
|
||||
const loadAgents = vi.fn(async () => undefined);
|
||||
const findAgentById = vi.fn(() => ({ agentId: "agent-1", sessionKey: "session-1" }));
|
||||
const applyDefaultPermissions = vi.fn(async () => undefined);
|
||||
const refreshGatewayConfigSnapshot = vi.fn(async () => undefined);
|
||||
|
||||
const commands = await runCreateAgentBootstrapOperation({
|
||||
completion: { agentId: "agent-1", agentName: "Agent One" },
|
||||
focusedAgentId: "focused-1",
|
||||
loadAgents,
|
||||
findAgentById,
|
||||
applyDefaultPermissions,
|
||||
refreshGatewayConfigSnapshot,
|
||||
});
|
||||
|
||||
const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft");
|
||||
const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent");
|
||||
|
||||
expect(loadAgents).toHaveBeenCalledTimes(1);
|
||||
expect(findAgentById).toHaveBeenCalledTimes(1);
|
||||
expect(applyDefaultPermissions).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
sessionKey: "session-1",
|
||||
});
|
||||
expect(refreshGatewayConfigSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(flushIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(selectIndex).toBeGreaterThan(flushIndex);
|
||||
expect(commands.find((entry) => entry.kind === "set-global-error")).toBeUndefined();
|
||||
expect(commands).toContainEqual({ kind: "set-create-modal-error", message: null });
|
||||
});
|
||||
|
||||
it("keeps create success disposition when bootstrap fails and skips snapshot refresh", async () => {
|
||||
const loadAgents = vi.fn(async () => undefined);
|
||||
const findAgentById = vi.fn(() => ({ agentId: "agent-1", sessionKey: "session-1" }));
|
||||
const applyDefaultPermissions = vi.fn(async () => {
|
||||
throw new Error("permissions exploded");
|
||||
});
|
||||
const refreshGatewayConfigSnapshot = vi.fn(async () => undefined);
|
||||
|
||||
const commands = await runCreateAgentBootstrapOperation({
|
||||
completion: { agentId: "agent-1", agentName: "Agent One" },
|
||||
focusedAgentId: "focused-1",
|
||||
loadAgents,
|
||||
findAgentById,
|
||||
applyDefaultPermissions,
|
||||
refreshGatewayConfigSnapshot,
|
||||
});
|
||||
|
||||
const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft");
|
||||
const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent");
|
||||
|
||||
expect(loadAgents).toHaveBeenCalledTimes(1);
|
||||
expect(findAgentById).toHaveBeenCalledTimes(1);
|
||||
expect(applyDefaultPermissions).toHaveBeenCalledTimes(1);
|
||||
expect(refreshGatewayConfigSnapshot).not.toHaveBeenCalled();
|
||||
expect(flushIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(selectIndex).toBeGreaterThan(flushIndex);
|
||||
expect(commands).toContainEqual({
|
||||
kind: "set-global-error",
|
||||
message: "Agent created, but default permissions could not be applied: permissions exploded",
|
||||
});
|
||||
expect(commands).toContainEqual({
|
||||
kind: "set-create-modal-error",
|
||||
message: "Default permissions failed: permissions exploded",
|
||||
});
|
||||
expect(commands).toContainEqual({ kind: "select-agent", agentId: "agent-1" });
|
||||
});
|
||||
|
||||
it("uses fallback bootstrap error message for non-Error throws", async () => {
|
||||
const commands = await runCreateAgentBootstrapOperation({
|
||||
completion: { agentId: "agent-1", agentName: "Agent One" },
|
||||
focusedAgentId: "focused-1",
|
||||
loadAgents: async () => undefined,
|
||||
findAgentById: () => ({ agentId: "agent-1", sessionKey: "session-1" }),
|
||||
applyDefaultPermissions: async () => {
|
||||
throw "boom";
|
||||
},
|
||||
refreshGatewayConfigSnapshot: async () => undefined,
|
||||
});
|
||||
|
||||
expect(commands).toContainEqual({
|
||||
kind: "set-global-error",
|
||||
message: "Agent created, but default permissions could not be applied: Failed to apply default permissions.",
|
||||
});
|
||||
expect(commands).toContainEqual({
|
||||
kind: "set-create-modal-error",
|
||||
message: "Default permissions failed: Failed to apply default permissions.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { planCreateAgentBootstrapCommands } from "@/features/agents/operations/createAgentBootstrapWorkflow";
|
||||
|
||||
describe("createAgentBootstrapWorkflow", () => {
|
||||
it("plans unresolved-created-agent failure disposition", () => {
|
||||
const commands = planCreateAgentBootstrapCommands({
|
||||
completion: { agentId: "agent-1", agentName: "Agent One" },
|
||||
createdAgent: null,
|
||||
bootstrapErrorMessage: null,
|
||||
focusedAgentId: "focused-1",
|
||||
});
|
||||
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
kind: "set-create-modal-error",
|
||||
message: 'Agent "Agent One" was created, but Studio could not load it yet.',
|
||||
},
|
||||
{
|
||||
kind: "set-global-error",
|
||||
message: 'Agent "Agent One" was created, but Studio could not load it yet.',
|
||||
},
|
||||
{ kind: "set-create-block", value: null },
|
||||
{ kind: "set-create-modal-open", open: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("plans bootstrap success disposition with draft flush before selection", () => {
|
||||
const commands = planCreateAgentBootstrapCommands({
|
||||
completion: { agentId: "agent-1", agentName: "Agent One" },
|
||||
createdAgent: { agentId: "agent-1", sessionKey: "session-1" },
|
||||
bootstrapErrorMessage: null,
|
||||
focusedAgentId: "focused-1",
|
||||
});
|
||||
|
||||
const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft");
|
||||
const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent");
|
||||
|
||||
expect(flushIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(selectIndex).toBeGreaterThan(flushIndex);
|
||||
expect(commands).toContainEqual({ kind: "set-create-modal-error", message: null });
|
||||
expect(commands).toContainEqual({ kind: "flush-pending-draft", agentId: "focused-1" });
|
||||
expect(commands).toContainEqual({ kind: "select-agent", agentId: "agent-1" });
|
||||
expect(commands).toContainEqual({
|
||||
kind: "set-inspect-sidebar",
|
||||
agentId: "agent-1",
|
||||
tab: "capabilities",
|
||||
});
|
||||
expect(commands).toContainEqual({ kind: "set-mobile-pane", pane: "chat" });
|
||||
expect(commands).toContainEqual({ kind: "set-create-block", value: null });
|
||||
expect(commands).toContainEqual({ kind: "set-create-modal-open", open: false });
|
||||
expect(commands.find((entry) => entry.kind === "set-global-error")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("plans bootstrap failure disposition without blocking selection flow", () => {
|
||||
const commands = planCreateAgentBootstrapCommands({
|
||||
completion: { agentId: "agent-1", agentName: "Agent One" },
|
||||
createdAgent: { agentId: "agent-1", sessionKey: "session-1" },
|
||||
bootstrapErrorMessage: "permissions exploded",
|
||||
focusedAgentId: "focused-1",
|
||||
});
|
||||
|
||||
const flushIndex = commands.findIndex((entry) => entry.kind === "flush-pending-draft");
|
||||
const selectIndex = commands.findIndex((entry) => entry.kind === "select-agent");
|
||||
|
||||
expect(flushIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(selectIndex).toBeGreaterThan(flushIndex);
|
||||
expect(commands).toContainEqual({
|
||||
kind: "set-global-error",
|
||||
message: "Agent created, but default permissions could not be applied: permissions exploded",
|
||||
});
|
||||
expect(commands).toContainEqual({
|
||||
kind: "set-create-modal-error",
|
||||
message: "Default permissions failed: permissions exploded",
|
||||
});
|
||||
expect(commands).toContainEqual({ kind: "select-agent", agentId: "agent-1" });
|
||||
expect(commands).toContainEqual({
|
||||
kind: "set-inspect-sidebar",
|
||||
agentId: "agent-1",
|
||||
tab: "capabilities",
|
||||
});
|
||||
expect(commands).toContainEqual({ kind: "set-mobile-pane", pane: "chat" });
|
||||
expect(commands).toContainEqual({ kind: "set-create-block", value: null });
|
||||
expect(commands).toContainEqual({ kind: "set-create-modal-open", open: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { performCronCreateFlow } from "@/features/agents/operations/cronCreateOperation";
|
||||
import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder";
|
||||
import type { CronJobCreateInput, CronJobSummary } from "@/lib/cron/types";
|
||||
|
||||
const createDraft = (): CronCreateDraft => ({
|
||||
templateId: "custom",
|
||||
name: "Nightly sync",
|
||||
taskText: "Sync project status and report blockers.",
|
||||
scheduleKind: "every",
|
||||
everyAmount: 30,
|
||||
everyUnit: "minutes",
|
||||
deliveryMode: "announce",
|
||||
deliveryChannel: "last",
|
||||
});
|
||||
|
||||
const createJob = (id: string, agentId: string, updatedAtMs: number): CronJobSummary => ({
|
||||
id,
|
||||
name: id,
|
||||
agentId,
|
||||
enabled: true,
|
||||
updatedAtMs,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Run task" },
|
||||
state: {},
|
||||
});
|
||||
|
||||
const createInput = (): CronJobCreateInput => ({
|
||||
name: "Nightly sync",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 1_800_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Sync project status and report blockers." },
|
||||
delivery: { mode: "announce", channel: "last" },
|
||||
});
|
||||
|
||||
describe("cron create flow state", () => {
|
||||
it("successful_create_refreshes_list_for_selected_agent", async () => {
|
||||
const client = {} as never;
|
||||
const onBusyChange = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const onJobs = vi.fn();
|
||||
|
||||
const buildInput = vi.fn(() => createInput());
|
||||
const createCronJob = vi.fn(async () => createJob("created", "agent-1", 15));
|
||||
const listCronJobs = vi.fn(async () => ({
|
||||
jobs: [
|
||||
createJob("older", "agent-1", 10),
|
||||
createJob("newer", "agent-1", 20),
|
||||
createJob("other-agent", "agent-2", 30),
|
||||
],
|
||||
}));
|
||||
|
||||
await expect(
|
||||
performCronCreateFlow({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
draft: createDraft(),
|
||||
busy: { createBusy: false, runBusyJobId: null, deleteBusyJobId: null },
|
||||
onBusyChange,
|
||||
onError,
|
||||
onJobs,
|
||||
deps: { buildInput, createCronJob, listCronJobs },
|
||||
})
|
||||
).resolves.toBe("created");
|
||||
|
||||
expect(buildInput).toHaveBeenCalledWith("agent-1", expect.any(Object));
|
||||
expect(createCronJob).toHaveBeenCalledWith(client, createInput());
|
||||
expect(listCronJobs).toHaveBeenCalledWith(client, { includeDisabled: true });
|
||||
expect(onJobs).toHaveBeenCalledWith([
|
||||
createJob("newer", "agent-1", 20),
|
||||
createJob("older", "agent-1", 10),
|
||||
]);
|
||||
expect(onError).toHaveBeenCalledWith(null);
|
||||
expect(onBusyChange).toHaveBeenNthCalledWith(1, true);
|
||||
expect(onBusyChange).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
it("create_failure_surfaces_cron_error_message", async () => {
|
||||
const onBusyChange = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const onJobs = vi.fn();
|
||||
const expectedError = new Error("Gateway exploded");
|
||||
|
||||
await expect(
|
||||
performCronCreateFlow({
|
||||
client: {} as never,
|
||||
agentId: "agent-1",
|
||||
draft: createDraft(),
|
||||
busy: { createBusy: false, runBusyJobId: null, deleteBusyJobId: null },
|
||||
onBusyChange,
|
||||
onError,
|
||||
onJobs,
|
||||
deps: {
|
||||
buildInput: vi.fn(() => createInput()),
|
||||
createCronJob: vi.fn(async () => {
|
||||
throw expectedError;
|
||||
}),
|
||||
listCronJobs: vi.fn(async () => ({ jobs: [] })),
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("Gateway exploded");
|
||||
|
||||
expect(onError).toHaveBeenNthCalledWith(1, null);
|
||||
expect(onError).toHaveBeenNthCalledWith(2, "Gateway exploded");
|
||||
expect(onBusyChange).toHaveBeenNthCalledWith(1, true);
|
||||
expect(onBusyChange).toHaveBeenNthCalledWith(2, false);
|
||||
expect(onJobs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("create_is_blocked_while_run_or_delete_busy", async () => {
|
||||
const onBusyChange = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const onJobs = vi.fn();
|
||||
const buildInput = vi.fn(() => createInput());
|
||||
const createCronJob = vi.fn(async () => createJob("created", "agent-1", 15));
|
||||
const listCronJobs = vi.fn(async () => ({ jobs: [] }));
|
||||
|
||||
await expect(
|
||||
performCronCreateFlow({
|
||||
client: {} as never,
|
||||
agentId: "agent-1",
|
||||
draft: createDraft(),
|
||||
busy: { createBusy: false, runBusyJobId: "job-1", deleteBusyJobId: null },
|
||||
onBusyChange,
|
||||
onError,
|
||||
onJobs,
|
||||
deps: { buildInput, createCronJob, listCronJobs },
|
||||
})
|
||||
).rejects.toThrow("Please wait for the current cron action to finish.");
|
||||
|
||||
expect(onError).toHaveBeenCalledWith("Please wait for the current cron action to finish.");
|
||||
expect(onBusyChange).not.toHaveBeenCalled();
|
||||
expect(onJobs).not.toHaveBeenCalled();
|
||||
expect(buildInput).not.toHaveBeenCalled();
|
||||
expect(createCronJob).not.toHaveBeenCalled();
|
||||
expect(listCronJobs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails_fast_when_agent_id_missing", async () => {
|
||||
const onBusyChange = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const onJobs = vi.fn();
|
||||
const buildInput = vi.fn(() => createInput());
|
||||
const createCronJob = vi.fn(async () => createJob("created", "agent-1", 15));
|
||||
const listCronJobs = vi.fn(async () => ({ jobs: [] }));
|
||||
|
||||
await expect(
|
||||
performCronCreateFlow({
|
||||
client: {} as never,
|
||||
agentId: " ",
|
||||
draft: createDraft(),
|
||||
busy: { createBusy: false, runBusyJobId: null, deleteBusyJobId: null },
|
||||
onBusyChange,
|
||||
onError,
|
||||
onJobs,
|
||||
deps: { buildInput, createCronJob, listCronJobs },
|
||||
})
|
||||
).rejects.toThrow("Failed to create cron job: missing agent id.");
|
||||
|
||||
expect(onError).toHaveBeenCalledWith("Failed to create cron job: missing agent id.");
|
||||
expect(onBusyChange).not.toHaveBeenCalled();
|
||||
expect(onJobs).not.toHaveBeenCalled();
|
||||
expect(buildInput).not.toHaveBeenCalled();
|
||||
expect(createCronJob).not.toHaveBeenCalled();
|
||||
expect(listCronJobs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildCronJobCreateInput,
|
||||
type CronCreateDraft,
|
||||
} from "@/lib/cron/createPayloadBuilder";
|
||||
|
||||
describe("cron create payload builder", () => {
|
||||
it("builds_agent_scoped_isolated_every_days_payload_with_anchor", () => {
|
||||
const nowMs = Date.UTC(2026, 1, 11, 6, 30, 0);
|
||||
const draft: CronCreateDraft = {
|
||||
templateId: "morning-brief",
|
||||
name: "Morning brief",
|
||||
taskText: "Summarize overnight updates and priorities.",
|
||||
scheduleKind: "every",
|
||||
everyAmount: 1,
|
||||
everyUnit: "days",
|
||||
everyAtTime: "07:00",
|
||||
everyTimeZone: "UTC",
|
||||
};
|
||||
|
||||
const input = buildCronJobCreateInput("agent-1", draft, nowMs);
|
||||
|
||||
expect(input).toEqual({
|
||||
name: "Morning brief",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
schedule: {
|
||||
kind: "every",
|
||||
everyMs: 86_400_000,
|
||||
anchorMs: Date.UTC(2026, 1, 11, 7, 0, 0),
|
||||
},
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "Summarize overnight updates and priorities.",
|
||||
},
|
||||
delivery: { mode: "none" },
|
||||
});
|
||||
});
|
||||
|
||||
it("builds_main_system_event_payload_when_advanced_mode_selected", () => {
|
||||
const draft: CronCreateDraft = {
|
||||
templateId: "reminder",
|
||||
name: "Standup reminder",
|
||||
taskText: "Reminder: standup starts in 10 minutes.",
|
||||
scheduleKind: "every",
|
||||
everyAmount: 30,
|
||||
everyUnit: "minutes",
|
||||
advancedSessionTarget: "main",
|
||||
advancedWakeMode: "next-heartbeat",
|
||||
};
|
||||
|
||||
const input = buildCronJobCreateInput("agent-2", draft);
|
||||
|
||||
expect(input).toEqual({
|
||||
name: "Standup reminder",
|
||||
agentId: "agent-2",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 1_800_000 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "Reminder: standup starts in 10 minutes.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects_invalid_one_time_schedule_input", () => {
|
||||
const draft: CronCreateDraft = {
|
||||
templateId: "custom",
|
||||
name: "One time",
|
||||
taskText: "Run once later.",
|
||||
scheduleKind: "at",
|
||||
scheduleAt: "not-a-date",
|
||||
};
|
||||
|
||||
expect(() => buildCronJobCreateInput("agent-1", draft)).toThrow("Invalid run time.");
|
||||
});
|
||||
|
||||
it("rejects_invalid_interval_amount_for_every_schedule", () => {
|
||||
const draft: CronCreateDraft = {
|
||||
templateId: "custom",
|
||||
name: "Invalid interval",
|
||||
taskText: "Run repeatedly.",
|
||||
scheduleKind: "every",
|
||||
everyAmount: 0,
|
||||
everyUnit: "minutes",
|
||||
};
|
||||
|
||||
expect(() => buildCronJobCreateInput("agent-1", draft)).toThrow("Invalid interval amount.");
|
||||
});
|
||||
|
||||
it("rejects_every_days_without_time", () => {
|
||||
const draft: CronCreateDraft = {
|
||||
templateId: "custom",
|
||||
name: "Daily report",
|
||||
taskText: "Compile report.",
|
||||
scheduleKind: "every",
|
||||
everyAmount: 1,
|
||||
everyUnit: "days",
|
||||
everyTimeZone: "UTC",
|
||||
};
|
||||
|
||||
expect(() => buildCronJobCreateInput("agent-1", draft)).toThrow(
|
||||
"Daily schedule time is required."
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects_invalid_timezone_for_every_days", () => {
|
||||
const draft: CronCreateDraft = {
|
||||
templateId: "custom",
|
||||
name: "Daily report",
|
||||
taskText: "Compile report.",
|
||||
scheduleKind: "every",
|
||||
everyAmount: 1,
|
||||
everyUnit: "days",
|
||||
everyAtTime: "07:00",
|
||||
everyTimeZone: "Mars/OlympusMons",
|
||||
};
|
||||
|
||||
expect(() => buildCronJobCreateInput("agent-1", draft)).toThrow("Invalid timezone.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createCronJob,
|
||||
listCronJobs,
|
||||
removeCronJob,
|
||||
removeCronJobsForAgent,
|
||||
removeCronJobsForAgentWithBackup,
|
||||
restoreCronJobs,
|
||||
runCronJobNow,
|
||||
type CronJobSummary,
|
||||
} from "@/lib/cron/types";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createListedJob = (params: {
|
||||
id: string;
|
||||
name: string;
|
||||
agentId?: string;
|
||||
updatedAtMs?: number;
|
||||
}): CronJobSummary => ({
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
agentId: params.agentId,
|
||||
enabled: true,
|
||||
updatedAtMs: params.updatedAtMs ?? 1_700_000_000_000,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Run checks." },
|
||||
state: {},
|
||||
});
|
||||
|
||||
describe("cron gateway client", () => {
|
||||
it("lists_jobs_via_cron_list_include_disabled_true", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ jobs: [] })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await listCronJobs(client);
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith("cron.list", { includeDisabled: true });
|
||||
});
|
||||
|
||||
it("runs_job_now_with_force_mode", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ ok: true, ran: true })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await runCronJobNow(client, "job-1");
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith("cron.run", { id: "job-1", mode: "force" });
|
||||
});
|
||||
|
||||
it("removes_job_by_id", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ ok: true, removed: true })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await removeCronJob(client, "job-1");
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith("cron.remove", { id: "job-1" });
|
||||
});
|
||||
|
||||
it("throws_when_job_id_missing_for_run_or_remove", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ ok: true })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(runCronJobNow(client, " ")).rejects.toThrow("Cron job id is required.");
|
||||
await expect(removeCronJob(client, "")).rejects.toThrow("Cron job id is required.");
|
||||
});
|
||||
|
||||
it("removes_all_jobs_for_agent", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, payload: { id?: string }) => {
|
||||
if (method === "cron.list") {
|
||||
return {
|
||||
jobs: [
|
||||
createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }),
|
||||
createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-2" }),
|
||||
createListedJob({ id: "job-3", name: "Job 3", agentId: "agent-1" }),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "cron.remove") {
|
||||
return { ok: true, removed: payload.id !== "job-3" };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(removeCronJobsForAgent(client, "agent-1")).resolves.toBe(1);
|
||||
expect(client.call).toHaveBeenCalledWith("cron.list", { includeDisabled: true });
|
||||
expect(client.call).toHaveBeenCalledWith("cron.remove", { id: "job-1" });
|
||||
expect(client.call).toHaveBeenCalledWith("cron.remove", { id: "job-3" });
|
||||
});
|
||||
|
||||
it("throws_when_agent_id_missing_for_bulk_remove", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ jobs: [] })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(removeCronJobsForAgent(client, " ")).rejects.toThrow("Agent id is required.");
|
||||
});
|
||||
|
||||
it("throws_when_any_bulk_remove_call_fails", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "cron.list") {
|
||||
return {
|
||||
jobs: [createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" })],
|
||||
};
|
||||
}
|
||||
if (method === "cron.remove") {
|
||||
return { ok: false, removed: false };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(removeCronJobsForAgent(client, "agent-1")).rejects.toThrow(
|
||||
'Failed to delete cron job "Job 1" (job-1).'
|
||||
);
|
||||
});
|
||||
|
||||
it("returns_restore_inputs_when_removing_jobs_with_backup", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, payload: { id?: string }) => {
|
||||
if (method === "cron.list") {
|
||||
return {
|
||||
jobs: [
|
||||
createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }),
|
||||
createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-2" }),
|
||||
createListedJob({ id: "job-3", name: "Job 3", agentId: "agent-1" }),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "cron.remove") {
|
||||
return { ok: true, removed: payload.id !== "job-3" };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).resolves.toEqual([
|
||||
{
|
||||
name: "Job 1",
|
||||
agentId: "agent-1",
|
||||
sessionKey: undefined,
|
||||
description: undefined,
|
||||
enabled: true,
|
||||
deleteAfterRun: undefined,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Run checks." },
|
||||
delivery: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores_removed_jobs_when_backup_remove_fails_midway", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, payload: { id?: string; name?: string }) => {
|
||||
if (method === "cron.list") {
|
||||
return {
|
||||
jobs: [
|
||||
createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }),
|
||||
createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-1" }),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "cron.remove") {
|
||||
if (payload.id === "job-1") return { ok: true, removed: true };
|
||||
return { ok: false, removed: false };
|
||||
}
|
||||
if (method === "cron.add") {
|
||||
return { id: "restored-job-1", name: payload.name };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).rejects.toThrow(
|
||||
'Failed to delete cron job "Job 2" (job-2).'
|
||||
);
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith("cron.add", {
|
||||
name: "Job 1",
|
||||
agentId: "agent-1",
|
||||
sessionKey: undefined,
|
||||
description: undefined,
|
||||
enabled: true,
|
||||
deleteAfterRun: undefined,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Run checks." },
|
||||
delivery: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("restores_removed_jobs_when_remove_call_throws_midway", async () => {
|
||||
const thrown = new Error("network interrupted");
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, payload: { id?: string; name?: string }) => {
|
||||
if (method === "cron.list") {
|
||||
return {
|
||||
jobs: [
|
||||
createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }),
|
||||
createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-1" }),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "cron.remove") {
|
||||
if (payload.id === "job-1") return { ok: true, removed: true };
|
||||
throw thrown;
|
||||
}
|
||||
if (method === "cron.add") {
|
||||
return { id: "restored-job-1", name: payload.name };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).rejects.toBe(thrown);
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith("cron.add", {
|
||||
name: "Job 1",
|
||||
agentId: "agent-1",
|
||||
sessionKey: undefined,
|
||||
description: undefined,
|
||||
enabled: true,
|
||||
deleteAfterRun: undefined,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Run checks." },
|
||||
delivery: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws_actionable_error_when_restore_fails", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (_method: string, payload: { name?: string }) => {
|
||||
if (payload.name === "Job 2") {
|
||||
throw new Error("cron.add failed");
|
||||
}
|
||||
return { id: "job-restored", name: payload.name };
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(
|
||||
restoreCronJobs(client, [
|
||||
{
|
||||
name: "Job 1",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Run checks." },
|
||||
},
|
||||
{
|
||||
name: "Job 2",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 120_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Run checks again." },
|
||||
},
|
||||
])
|
||||
).rejects.toThrow('Failed to restore cron job "Job 2" (agent-1): cron.add failed');
|
||||
});
|
||||
|
||||
it("creates_job_via_cron_add", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ id: "job-1", name: "Morning brief" })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
const input = {
|
||||
name: "Morning brief",
|
||||
agentId: "agent-1",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron" as const, expr: "0 7 * * *", tz: "America/Chicago" },
|
||||
sessionTarget: "isolated" as const,
|
||||
wakeMode: "now" as const,
|
||||
payload: { kind: "agentTurn" as const, message: "Summarize overnight updates." },
|
||||
delivery: { mode: "announce" as const, channel: "last" },
|
||||
};
|
||||
|
||||
await createCronJob(client, input);
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith("cron.add", input);
|
||||
});
|
||||
|
||||
it("throws_when_create_payload_missing_required_name", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ id: "job-1" })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(
|
||||
createCronJob(client, {
|
||||
name: " ",
|
||||
agentId: "agent-1",
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Run checks." },
|
||||
})
|
||||
).rejects.toThrow("Cron job name is required.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
filterCronJobsForAgent,
|
||||
formatCronJobDisplay,
|
||||
formatCronPayload,
|
||||
formatCronSchedule,
|
||||
resolveLatestCronJobForAgent,
|
||||
} from "@/lib/cron/types";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
|
||||
const buildJob = (input: {
|
||||
id: string;
|
||||
agentId?: string;
|
||||
updatedAtMs: number;
|
||||
}): CronJobSummary => ({
|
||||
id: input.id,
|
||||
name: input.id,
|
||||
enabled: true,
|
||||
updatedAtMs: input.updatedAtMs,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "agentTurn", message: "hello" },
|
||||
state: {},
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
});
|
||||
|
||||
describe("cron selectors", () => {
|
||||
it("filters_jobs_to_selected_agent", () => {
|
||||
const jobs = [
|
||||
buildJob({ id: "one", agentId: "agent-1", updatedAtMs: 10 }),
|
||||
buildJob({ id: "two", agentId: "agent-2", updatedAtMs: 20 }),
|
||||
buildJob({ id: "three", updatedAtMs: 30 }),
|
||||
];
|
||||
|
||||
expect(filterCronJobsForAgent(jobs, "agent-1").map((job) => job.id)).toEqual(["one"]);
|
||||
expect(filterCronJobsForAgent(jobs, "agent-2").map((job) => job.id)).toEqual(["two"]);
|
||||
expect(filterCronJobsForAgent(jobs, "missing")).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves_latest_agent_job_by_updated_at", () => {
|
||||
const jobs = [
|
||||
buildJob({ id: "older", agentId: "agent-1", updatedAtMs: 10 }),
|
||||
buildJob({ id: "newer", agentId: "agent-1", updatedAtMs: 30 }),
|
||||
buildJob({ id: "other", agentId: "agent-2", updatedAtMs: 40 }),
|
||||
];
|
||||
|
||||
expect(resolveLatestCronJobForAgent(jobs, "agent-1")?.id).toBe("newer");
|
||||
expect(resolveLatestCronJobForAgent(jobs, "agent-2")?.id).toBe("other");
|
||||
expect(resolveLatestCronJobForAgent(jobs, "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches_agent_ids_after_trimming_whitespace", () => {
|
||||
const jobs = [
|
||||
buildJob({ id: "trimmed", agentId: "agent-1", updatedAtMs: 20 }),
|
||||
buildJob({ id: "other", agentId: "agent-2", updatedAtMs: 30 }),
|
||||
];
|
||||
|
||||
expect(filterCronJobsForAgent(jobs, " agent-1 ").map((job) => job.id)).toEqual(["trimmed"]);
|
||||
expect(resolveLatestCronJobForAgent(jobs, " agent-1 ")?.id).toBe("trimmed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron formatting", () => {
|
||||
it("formats_every_schedule_with_h_m_s_ms_suffixes", () => {
|
||||
expect(formatCronSchedule({ kind: "every", everyMs: 3_600_000 })).toBe("Every 1h");
|
||||
expect(formatCronSchedule({ kind: "every", everyMs: 60_000 })).toBe("Every 1m");
|
||||
expect(formatCronSchedule({ kind: "every", everyMs: 1_000 })).toBe("Every 1s");
|
||||
expect(formatCronSchedule({ kind: "every", everyMs: 1_500 })).toBe("Every 1500ms");
|
||||
});
|
||||
|
||||
it("formats_cron_schedule_with_optional_tz", () => {
|
||||
expect(formatCronSchedule({ kind: "cron", expr: "0 0 * * *" })).toBe("Cron: 0 0 * * *");
|
||||
expect(formatCronSchedule({ kind: "cron", expr: "0 0 * * *", tz: "UTC" })).toBe(
|
||||
"Cron: 0 0 * * * (UTC)"
|
||||
);
|
||||
});
|
||||
|
||||
it("formats_at_schedule_as_raw_when_not_parseable", () => {
|
||||
expect(formatCronSchedule({ kind: "at", at: "not-a-date" })).toBe("At: not-a-date");
|
||||
});
|
||||
|
||||
it("formats_cron_payload_text", () => {
|
||||
expect(formatCronPayload({ kind: "systemEvent", text: "hello" })).toBe("hello");
|
||||
expect(formatCronPayload({ kind: "agentTurn", message: "hi" })).toBe("hi");
|
||||
});
|
||||
|
||||
it("formats_cron_job_display_as_three_lines", () => {
|
||||
const job: CronJobSummary = {
|
||||
id: "job-1",
|
||||
name: "Job name",
|
||||
enabled: true,
|
||||
updatedAtMs: 10,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "agentTurn", message: "hi" },
|
||||
state: {},
|
||||
};
|
||||
|
||||
expect(formatCronJobDisplay(job)).toBe("Job name\nEvery 1m\nhi");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
removeCronJobsForAgentWithBackup,
|
||||
restoreCronJobs,
|
||||
type CronJobRestoreInput,
|
||||
} from "@/lib/cron/types";
|
||||
import { deleteGatewayAgent } from "@/lib/gateway/agentConfig";
|
||||
import { deleteAgentViaStudio } from "@/features/agents/operations/deleteAgentOperation";
|
||||
|
||||
vi.mock("@/lib/cron/types", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/cron/types")>("@/lib/cron/types");
|
||||
return {
|
||||
...actual,
|
||||
removeCronJobsForAgentWithBackup: vi.fn(),
|
||||
restoreCronJobs: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/gateway/agentConfig", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/gateway/agentConfig")>(
|
||||
"@/lib/gateway/agentConfig"
|
||||
);
|
||||
return { ...actual, deleteGatewayAgent: vi.fn() };
|
||||
});
|
||||
|
||||
type FetchJson = <T>(input: RequestInfo | URL, init?: RequestInit) => Promise<T>;
|
||||
|
||||
const createTrashResult = (overrides?: {
|
||||
trashDir?: string;
|
||||
moved?: Array<{ from: string; to: string }>;
|
||||
}) => ({
|
||||
trashDir: "/tmp/trash",
|
||||
moved: [],
|
||||
...(overrides ?? {}),
|
||||
});
|
||||
|
||||
const createCronRestoreInput = (name = "Job 1", agentId = "agent-1"): CronJobRestoreInput => ({
|
||||
name,
|
||||
agentId,
|
||||
enabled: true,
|
||||
schedule: { kind: "every", everyMs: 60_000 },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "agentTurn", message: "Run checks." },
|
||||
});
|
||||
|
||||
describe("delete agent via studio operation", () => {
|
||||
const mockedRemoveCronJobsForAgentWithBackup = vi.mocked(removeCronJobsForAgentWithBackup);
|
||||
const mockedRestoreCronJobs = vi.mocked(restoreCronJobs);
|
||||
const mockedDeleteGatewayAgent = vi.mocked(deleteGatewayAgent);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedRemoveCronJobsForAgentWithBackup.mockReset();
|
||||
mockedRestoreCronJobs.mockReset();
|
||||
mockedDeleteGatewayAgent.mockReset();
|
||||
});
|
||||
|
||||
it("runs_steps_in_order_on_success", async () => {
|
||||
const calls: string[] = [];
|
||||
const fetchJson: FetchJson = vi.fn(async (_input, init) => {
|
||||
if (init?.method === "POST") {
|
||||
calls.push("trash");
|
||||
return { result: createTrashResult() } as never;
|
||||
}
|
||||
throw new Error("Unexpected fetchJson call");
|
||||
});
|
||||
|
||||
mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => {
|
||||
calls.push("removeCron");
|
||||
return [];
|
||||
});
|
||||
mockedRestoreCronJobs.mockImplementation(async () => {
|
||||
calls.push("restoreCron");
|
||||
});
|
||||
mockedDeleteGatewayAgent.mockImplementation(async () => {
|
||||
calls.push("deleteGatewayAgent");
|
||||
return { removed: true, removedBindings: 0 };
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson })
|
||||
).resolves.toEqual({
|
||||
trashed: createTrashResult(),
|
||||
restored: null,
|
||||
});
|
||||
|
||||
expect(calls).toEqual(["trash", "removeCron", "deleteGatewayAgent"]);
|
||||
});
|
||||
|
||||
it("attempts_restore_when_remove_cron_fails_and_trash_moved_paths", async () => {
|
||||
const calls: string[] = [];
|
||||
const originalErr = new Error("boom");
|
||||
const trash = createTrashResult({
|
||||
trashDir: "/tmp/trash-2",
|
||||
moved: [{ from: "/a", to: "/b" }],
|
||||
});
|
||||
|
||||
const fetchJson: FetchJson = vi.fn(async (_input, init) => {
|
||||
if (init?.method === "POST") {
|
||||
calls.push("trash");
|
||||
return { result: trash } as never;
|
||||
}
|
||||
if (init?.method === "PUT") {
|
||||
calls.push("restore:agent-1:/tmp/trash-2");
|
||||
return { result: { restored: [] } } as never;
|
||||
}
|
||||
throw new Error("Unexpected fetchJson call");
|
||||
});
|
||||
|
||||
mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => {
|
||||
calls.push("removeCron");
|
||||
throw originalErr;
|
||||
});
|
||||
mockedRestoreCronJobs.mockImplementation(async () => {
|
||||
calls.push("restoreCron");
|
||||
});
|
||||
mockedDeleteGatewayAgent.mockImplementation(async () => {
|
||||
calls.push("deleteGatewayAgent");
|
||||
return { removed: true, removedBindings: 0 };
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson })
|
||||
).rejects.toBe(originalErr);
|
||||
|
||||
expect(calls).toEqual(["trash", "removeCron", "restore:agent-1:/tmp/trash-2"]);
|
||||
expect(mockedRestoreCronJobs).not.toHaveBeenCalled();
|
||||
expect(mockedDeleteGatewayAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attempts_cron_restore_then_state_restore_when_gateway_delete_fails_and_trash_moved_paths", async () => {
|
||||
const calls: string[] = [];
|
||||
const originalErr = new Error("boom");
|
||||
const backups = [createCronRestoreInput("Job X", "agent-1")];
|
||||
|
||||
const fetchJson: FetchJson = vi.fn(async (_input, init) => {
|
||||
if (init?.method === "POST") {
|
||||
calls.push("trash");
|
||||
return {
|
||||
result: createTrashResult({
|
||||
trashDir: "/tmp/trash-3",
|
||||
moved: [{ from: "/a", to: "/b" }],
|
||||
}),
|
||||
} as never;
|
||||
}
|
||||
if (init?.method === "PUT") {
|
||||
calls.push("restore:agent-1:/tmp/trash-3");
|
||||
return { result: { restored: [] } } as never;
|
||||
}
|
||||
throw new Error("Unexpected fetchJson call");
|
||||
});
|
||||
|
||||
mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => {
|
||||
calls.push("removeCron");
|
||||
return backups;
|
||||
});
|
||||
mockedRestoreCronJobs.mockImplementation(async () => {
|
||||
calls.push("restoreCron");
|
||||
});
|
||||
mockedDeleteGatewayAgent.mockImplementation(async () => {
|
||||
calls.push("deleteGatewayAgent");
|
||||
throw originalErr;
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson })
|
||||
).rejects.toBe(originalErr);
|
||||
|
||||
expect(calls).toEqual([
|
||||
"trash",
|
||||
"removeCron",
|
||||
"deleteGatewayAgent",
|
||||
"restoreCron",
|
||||
"restore:agent-1:/tmp/trash-3",
|
||||
]);
|
||||
expect(mockedRestoreCronJobs).toHaveBeenCalledWith(expect.anything(), backups);
|
||||
});
|
||||
|
||||
it("does_not_restore_when_trash_moved_is_empty", async () => {
|
||||
const originalErr = new Error("boom");
|
||||
const methods: string[] = [];
|
||||
|
||||
const fetchJson: FetchJson = vi.fn(async (_input, init) => {
|
||||
const method = init?.method ?? "GET";
|
||||
methods.push(method);
|
||||
if (method === "POST") {
|
||||
return { result: createTrashResult({ moved: [] }) } as never;
|
||||
}
|
||||
if (method === "PUT") {
|
||||
throw new Error("restore should not be called");
|
||||
}
|
||||
throw new Error("Unexpected fetchJson call");
|
||||
});
|
||||
|
||||
mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => {
|
||||
throw originalErr;
|
||||
});
|
||||
mockedRestoreCronJobs.mockResolvedValue(undefined);
|
||||
mockedDeleteGatewayAgent.mockImplementation(async () => {
|
||||
return { removed: true, removedBindings: 0 };
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteAgentViaStudio({ client: {} as never, agentId: "agent-1", fetchJson })
|
||||
).rejects.toBe(originalErr);
|
||||
|
||||
expect(methods).toEqual(["POST"]);
|
||||
expect(mockedDeleteGatewayAgent).not.toHaveBeenCalled();
|
||||
expect(mockedRestoreCronJobs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs_cron_and_state_restore_failures_and_still_throws_original_error", async () => {
|
||||
const originalErr = new Error("boom");
|
||||
const cronRestoreErr = new Error("cron-restore-failed");
|
||||
const restoreErr = new Error("restore-failed");
|
||||
const logError = vi.fn();
|
||||
const backups = [createCronRestoreInput("Job Z", "agent-1")];
|
||||
|
||||
const fetchJson: FetchJson = vi.fn(async (_input, init) => {
|
||||
if (init?.method === "POST") {
|
||||
return {
|
||||
result: createTrashResult({
|
||||
trashDir: "/tmp/trash-4",
|
||||
moved: [{ from: "/a", to: "/b" }],
|
||||
}),
|
||||
} as never;
|
||||
}
|
||||
if (init?.method === "PUT") {
|
||||
throw restoreErr;
|
||||
}
|
||||
throw new Error("Unexpected fetchJson call");
|
||||
});
|
||||
|
||||
mockedRemoveCronJobsForAgentWithBackup.mockImplementation(async () => {
|
||||
return backups;
|
||||
});
|
||||
mockedRestoreCronJobs.mockImplementation(async () => {
|
||||
throw cronRestoreErr;
|
||||
});
|
||||
mockedDeleteGatewayAgent.mockImplementation(async () => {
|
||||
throw originalErr;
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteAgentViaStudio({
|
||||
client: {} as never,
|
||||
agentId: "agent-1",
|
||||
fetchJson,
|
||||
logError,
|
||||
})
|
||||
).rejects.toBe(originalErr);
|
||||
|
||||
expect(logError).toHaveBeenCalledTimes(2);
|
||||
expect(logError).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"Failed to restore removed cron jobs.",
|
||||
cronRestoreErr
|
||||
);
|
||||
expect(logError).toHaveBeenNthCalledWith(2, "Failed to restore trashed agent state.", restoreErr);
|
||||
});
|
||||
|
||||
it("fails_fast_when_agent_id_is_missing", async () => {
|
||||
const fetchJson: FetchJson = vi.fn(async () => {
|
||||
throw new Error("fetch should not be called");
|
||||
});
|
||||
|
||||
await expect(
|
||||
deleteAgentViaStudio({ client: {} as never, agentId: " ", fetchJson })
|
||||
).rejects.toThrow("Agent id is required.");
|
||||
|
||||
expect(fetchJson).not.toHaveBeenCalled();
|
||||
expect(mockedRemoveCronJobsForAgentWithBackup).not.toHaveBeenCalled();
|
||||
expect(mockedRestoreCronJobs).not.toHaveBeenCalled();
|
||||
expect(mockedDeleteGatewayAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { TranscriptEntry } from "@/features/agents/state/transcript";
|
||||
import {
|
||||
reduceOfficeDeskHoldState,
|
||||
reduceOfficeQaHoldState,
|
||||
resolveOfficeIntentSnapshot,
|
||||
resolveOfficeDeskDirective,
|
||||
resolveOfficeQaDirective,
|
||||
} from "@/lib/office/deskDirectives";
|
||||
|
||||
const createUserEntry = (
|
||||
text: string,
|
||||
sequenceKey: number,
|
||||
): TranscriptEntry => ({
|
||||
entryId: `entry-${sequenceKey}`,
|
||||
role: "user",
|
||||
kind: "user",
|
||||
text,
|
||||
sessionKey: "agent:agent-1:studio:test",
|
||||
runId: null,
|
||||
source: "history",
|
||||
timestampMs: sequenceKey * 1_000,
|
||||
sequenceKey,
|
||||
confirmed: true,
|
||||
fingerprint: `fp-${sequenceKey}`,
|
||||
});
|
||||
|
||||
describe("deskDirectives", () => {
|
||||
it("recognizes desk and release directives", () => {
|
||||
expect(resolveOfficeDeskDirective("Go to your desk.")).toBe("desk");
|
||||
expect(resolveOfficeDeskDirective("Please head to your desk now.")).toBe("desk");
|
||||
expect(resolveOfficeDeskDirective("Go back to the desk.")).toBe("desk");
|
||||
expect(resolveOfficeDeskDirective("> ok you can leave the desk")).toBe("release");
|
||||
expect(resolveOfficeDeskDirective("go on a walk")).toBe("release");
|
||||
expect(resolveOfficeDeskDirective("you can leave your desk now")).toBe("release");
|
||||
expect(resolveOfficeDeskDirective("go to walk")).toBe("release");
|
||||
});
|
||||
|
||||
it("builds a unified office intent snapshot", () => {
|
||||
expect(resolveOfficeIntentSnapshot("Let's go to the gym.")).toMatchObject({
|
||||
gym: { directive: "gym", source: "manual" },
|
||||
desk: null,
|
||||
standup: null,
|
||||
});
|
||||
expect(resolveOfficeIntentSnapshot("lets have a scrum meeting")).toMatchObject({
|
||||
standup: "standup",
|
||||
gym: null,
|
||||
qa: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the desk hold through unrelated messages", () => {
|
||||
expect(
|
||||
reduceOfficeDeskHoldState({
|
||||
currentHeld: true,
|
||||
lastUserMessage: "Can you summarize that for me?",
|
||||
transcriptEntries: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rebuilds the desk hold from transcript history", () => {
|
||||
expect(
|
||||
reduceOfficeDeskHoldState({
|
||||
currentHeld: false,
|
||||
lastUserMessage: "What did you finish?",
|
||||
transcriptEntries: [
|
||||
createUserEntry("> Go to your desk.", 1),
|
||||
createUserEntry("> What did you finish?", 2),
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the desk hold when a release directive arrives", () => {
|
||||
expect(
|
||||
reduceOfficeDeskHoldState({
|
||||
currentHeld: true,
|
||||
lastUserMessage: "ok you can leave the desk",
|
||||
transcriptEntries: [createUserEntry("> Go to your desk.", 1)],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes QA lab directives and releases", () => {
|
||||
expect(resolveOfficeQaDirective("Please write tests for this flow.")).toBe("qa_lab");
|
||||
expect(resolveOfficeQaDirective("Can you run tests and verify it?")).toBe(
|
||||
"qa_lab",
|
||||
);
|
||||
expect(resolveOfficeQaDirective("Reproduce this bug in the QA room.")).toBe(
|
||||
"qa_lab",
|
||||
);
|
||||
expect(resolveOfficeQaDirective("done testing")).toBe("release");
|
||||
expect(resolveOfficeQaDirective("leave the QA lab")).toBe("release");
|
||||
});
|
||||
|
||||
it("rebuilds the QA lab hold from transcript history", () => {
|
||||
expect(
|
||||
reduceOfficeQaHoldState({
|
||||
currentHeld: false,
|
||||
lastUserMessage: "What failed?",
|
||||
transcriptEntries: [
|
||||
createUserEntry("> Run tests in the QA lab.", 1),
|
||||
createUserEntry("> What failed?", 2),
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the QA lab hold when a release directive arrives", () => {
|
||||
expect(
|
||||
reduceOfficeQaHoldState({
|
||||
currentHeld: true,
|
||||
lastUserMessage: "stop testing",
|
||||
transcriptEntries: [createUserEntry("> Run tests in the QA lab.", 1)],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { buildOfficeDeskMonitor } from "@/lib/office/deskMonitor";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
queuedMessages: [],
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
transcriptEntries: [],
|
||||
transcriptRevision: 0,
|
||||
transcriptSequenceCounter: 0,
|
||||
sessionEpoch: 0,
|
||||
lastHistoryRequestRevision: null,
|
||||
lastAppliedHistoryRequestId: null,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: null,
|
||||
avatarUrl: null,
|
||||
...(overrides ?? {}),
|
||||
});
|
||||
|
||||
describe("buildOfficeDeskMonitor", () => {
|
||||
it("builds a live coding monitor from runtime state", () => {
|
||||
const monitor = buildOfficeDeskMonitor(
|
||||
createAgent({
|
||||
status: "running",
|
||||
outputLines: ["> implement a desk monitor."],
|
||||
streamText: "Updating the office scene right now.",
|
||||
thinkingTrace: "Scanning camera controls.",
|
||||
latestPreview: "Updating the office scene.",
|
||||
lastActivityAt: 1_000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(monitor.mode).toBe("coding");
|
||||
expect(monitor.live).toBe(true);
|
||||
expect(monitor.entries.some((entry) => entry.kind === "user")).toBe(true);
|
||||
expect(
|
||||
monitor.entries.some(
|
||||
(entry) =>
|
||||
entry.kind === "assistant" &&
|
||||
entry.text.includes("Updating the office scene"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects browser activity and extracts the current url", () => {
|
||||
const monitor = buildOfficeDeskMonitor(
|
||||
createAgent({
|
||||
status: "running",
|
||||
outputLines: [
|
||||
"[[tool]] browser.navigate\nurl: https://example.com/dashboard",
|
||||
"[[tool-result]] browser.navigate\nNavigation complete.",
|
||||
],
|
||||
latestPreview: "Browsing example.com.",
|
||||
lastActivityAt: 2_000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(monitor.mode).toBe("browser");
|
||||
expect(monitor.browserUrl).toBe("https://example.com/dashboard");
|
||||
expect(monitor.subtitle).toContain("example.com");
|
||||
});
|
||||
|
||||
it("builds a fake editor document for coding mode", () => {
|
||||
const monitor = buildOfficeDeskMonitor(
|
||||
createAgent({
|
||||
status: "running",
|
||||
lastUserMessage: "Create a contact form page",
|
||||
outputLines: ["> Create a contact form page"],
|
||||
latestPreview: "Building the contact form page.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(monitor.mode).toBe("coding");
|
||||
expect(monitor.editor?.fileName).toBe("ContactForm.tsx");
|
||||
expect(
|
||||
monitor.editor?.lines.some((line) => line.includes("Contact us")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses waiting mode when the agent needs user input", () => {
|
||||
const monitor = buildOfficeDeskMonitor(
|
||||
createAgent({
|
||||
awaitingUserInput: true,
|
||||
latestPreview: "Please choose the next step.",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(monitor.mode).toBe("waiting");
|
||||
expect(monitor.title).toBe("Waiting");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
planAutoResumeIntent,
|
||||
planAwaitingUserInputPatches,
|
||||
planIngressCommands,
|
||||
planPausedRunMapCleanup,
|
||||
planPauseRunIntent,
|
||||
planPendingPruneDelay,
|
||||
planPrunedPendingState,
|
||||
} from "@/features/agents/approvals/execApprovalControlLoopWorkflow";
|
||||
import type { ApprovalPendingState } from "@/features/agents/approvals/execApprovalRuntimeCoordinator";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: "run-1",
|
||||
runStartedAt: 1,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
sessionExecAsk: "always",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createApproval = (
|
||||
id: string,
|
||||
overrides?: Partial<PendingExecApproval>
|
||||
): PendingExecApproval => ({
|
||||
id,
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
command: "npm run test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 10_000,
|
||||
resolving: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createPendingState = (
|
||||
overrides?: Partial<ApprovalPendingState>
|
||||
): ApprovalPendingState => ({
|
||||
approvalsByAgentId: {},
|
||||
unscopedApprovals: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("execApprovalControlLoopWorkflow", () => {
|
||||
it("plans stale paused-run cleanup from paused map", () => {
|
||||
const stale = planPausedRunMapCleanup({
|
||||
pausedRunIdByAgentId: new Map([
|
||||
["agent-1", "run-1"],
|
||||
["agent-2", "run-old"],
|
||||
["missing-agent", "run-x"],
|
||||
]),
|
||||
agents: [
|
||||
createAgent(),
|
||||
createAgent({
|
||||
agentId: "agent-2",
|
||||
sessionKey: "agent:agent-2:main",
|
||||
runId: "run-2",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(stale).toEqual(["agent-2", "missing-agent"]);
|
||||
});
|
||||
|
||||
it("plans pause intent for a running agent that needs exec approval", () => {
|
||||
const intent = planPauseRunIntent({
|
||||
approval: createApproval("approval-1"),
|
||||
preferredAgentId: "agent-1",
|
||||
agents: [createAgent()],
|
||||
pausedRunIdByAgentId: new Map(),
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
kind: "pause",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
runId: "run-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips pause intent when the run is already paused", () => {
|
||||
const intent = planPauseRunIntent({
|
||||
approval: createApproval("approval-1"),
|
||||
preferredAgentId: "agent-1",
|
||||
agents: [createAgent()],
|
||||
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
|
||||
});
|
||||
|
||||
expect(intent).toEqual({ kind: "skip", reason: "pause-policy-denied" });
|
||||
});
|
||||
|
||||
it("plans ingress commands for approval requested events", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.requested",
|
||||
payload: {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "npm run test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
agentId: "agent-1",
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
createdAtMs: 100,
|
||||
expiresAtMs: 200,
|
||||
},
|
||||
};
|
||||
|
||||
const commands = planIngressCommands({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
pendingState: createPendingState(),
|
||||
pausedRunIdByAgentId: new Map(),
|
||||
seenCronDedupeKeys: new Set(),
|
||||
nowMs: 150,
|
||||
});
|
||||
|
||||
expect(commands[0]).toMatchObject({ kind: "replacePendingState" });
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "pauseRunForApproval",
|
||||
preferredAgentId: "agent-1",
|
||||
}),
|
||||
{ kind: "markActivity", agentId: "agent-1" },
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("plans cron ingress commands with dedupe and transcript append", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
jobId: "job-1",
|
||||
sessionId: "session-1",
|
||||
runAtMs: 123,
|
||||
status: "ok",
|
||||
summary: "cron summary",
|
||||
},
|
||||
};
|
||||
|
||||
const commands = planIngressCommands({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
pendingState: createPendingState(),
|
||||
pausedRunIdByAgentId: new Map(),
|
||||
seenCronDedupeKeys: new Set(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(commands).toEqual([
|
||||
{ kind: "recordCronDedupeKey", dedupeKey: "cron:job-1:session-1" },
|
||||
{
|
||||
kind: "appendCronTranscript",
|
||||
intent: {
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
dedupeKey: "cron:job-1:session-1",
|
||||
line: "Cron finished (ok): job-1\n\ncron summary",
|
||||
timestampMs: 123,
|
||||
activityAtMs: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("plans prune delay, pruned state, and awaiting-user-input patches", () => {
|
||||
const pendingState = createPendingState({
|
||||
approvalsByAgentId: {
|
||||
"agent-1": [createApproval("a-1", { expiresAtMs: 6_000 })],
|
||||
},
|
||||
unscopedApprovals: [
|
||||
createApproval("a-2", {
|
||||
agentId: null,
|
||||
sessionKey: "agent:agent-2:main",
|
||||
expiresAtMs: 7_000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const delay = planPendingPruneDelay({
|
||||
pendingState,
|
||||
nowMs: 5_000,
|
||||
graceMs: 500,
|
||||
});
|
||||
expect(delay).toBe(1_500);
|
||||
|
||||
const pruned = planPrunedPendingState({
|
||||
pendingState: {
|
||||
approvalsByAgentId: {
|
||||
"agent-1": [
|
||||
createApproval("expired", { expiresAtMs: 4_000 }),
|
||||
createApproval("active", { expiresAtMs: 6_000 }),
|
||||
],
|
||||
},
|
||||
unscopedApprovals: [
|
||||
createApproval("active-unscoped", {
|
||||
agentId: null,
|
||||
sessionKey: "agent:agent-2:main",
|
||||
expiresAtMs: 7_000,
|
||||
}),
|
||||
createApproval("expired-unscoped", {
|
||||
agentId: null,
|
||||
sessionKey: "agent:agent-2:main",
|
||||
expiresAtMs: 4_100,
|
||||
}),
|
||||
],
|
||||
},
|
||||
nowMs: 5_000,
|
||||
graceMs: 500,
|
||||
});
|
||||
|
||||
expect(pruned.approvalsByAgentId).toEqual({
|
||||
"agent-1": [createApproval("active", { expiresAtMs: 6_000 })],
|
||||
});
|
||||
expect(pruned.unscopedApprovals).toEqual([
|
||||
createApproval("active-unscoped", {
|
||||
agentId: null,
|
||||
sessionKey: "agent:agent-2:main",
|
||||
expiresAtMs: 7_000,
|
||||
}),
|
||||
]);
|
||||
|
||||
const patches = planAwaitingUserInputPatches({
|
||||
agents: [
|
||||
createAgent({ agentId: "agent-1", awaitingUserInput: false }),
|
||||
createAgent({
|
||||
agentId: "agent-2",
|
||||
sessionKey: "agent:agent-2:main",
|
||||
runId: "run-2",
|
||||
awaitingUserInput: true,
|
||||
}),
|
||||
],
|
||||
approvalsByAgentId: {
|
||||
"agent-1": [createApproval("a-1")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(patches).toEqual([
|
||||
{ agentId: "agent-1", awaitingUserInput: true },
|
||||
{ agentId: "agent-2", awaitingUserInput: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("plans auto-resume intent only when preflight and dispatch both pass", () => {
|
||||
const skip = planAutoResumeIntent({
|
||||
approval: createApproval("approval-1"),
|
||||
targetAgentId: "agent-1",
|
||||
pendingState: createPendingState({
|
||||
approvalsByAgentId: {
|
||||
"agent-1": [createApproval("approval-1"), createApproval("sibling")],
|
||||
},
|
||||
}),
|
||||
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
|
||||
agents: [createAgent()],
|
||||
});
|
||||
expect(skip).toEqual({ kind: "skip", reason: "blocking-pending-approvals" });
|
||||
|
||||
const resume = planAutoResumeIntent({
|
||||
approval: createApproval("approval-1"),
|
||||
targetAgentId: "agent-1",
|
||||
pendingState: createPendingState(),
|
||||
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
|
||||
agents: [createAgent({ status: "running", runId: "run-1" })],
|
||||
});
|
||||
|
||||
expect(resume).toEqual({
|
||||
kind: "resume",
|
||||
targetAgentId: "agent-1",
|
||||
pausedRunId: "run-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import {
|
||||
parseExecApprovalRequested,
|
||||
parseExecApprovalResolved,
|
||||
resolveExecApprovalAgentId,
|
||||
} from "@/features/agents/approvals/execApprovalEvents";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (agentId: string, sessionKey: string): AgentState => ({
|
||||
agentId,
|
||||
name: agentId,
|
||||
sessionKey,
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: agentId,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
describe("execApprovalEvents", () => {
|
||||
it("parses exec.approval.requested payload", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.requested",
|
||||
payload: {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "npm run test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
agentId: "agent-1",
|
||||
resolvedPath: "/bin/npm",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
createdAtMs: 123,
|
||||
expiresAtMs: 456,
|
||||
},
|
||||
};
|
||||
|
||||
expect(parseExecApprovalRequested(event)).toEqual({
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "npm run test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
agentId: "agent-1",
|
||||
resolvedPath: "/bin/npm",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
createdAtMs: 123,
|
||||
expiresAtMs: 456,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for invalid requested payload", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.requested",
|
||||
payload: {
|
||||
id: "approval-1",
|
||||
request: { command: "" },
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 0,
|
||||
},
|
||||
};
|
||||
expect(parseExecApprovalRequested(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("parses exec.approval.resolved payload", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.resolved",
|
||||
payload: {
|
||||
id: "approval-1",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "studio",
|
||||
ts: 987,
|
||||
},
|
||||
};
|
||||
expect(parseExecApprovalResolved(event)).toEqual({
|
||||
id: "approval-1",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "studio",
|
||||
ts: 987,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for unknown resolved decision", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.resolved",
|
||||
payload: {
|
||||
id: "approval-1",
|
||||
decision: "approve",
|
||||
ts: 987,
|
||||
},
|
||||
};
|
||||
expect(parseExecApprovalResolved(event)).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves approval agent by explicit agent id", () => {
|
||||
const requested = {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "pwd",
|
||||
cwd: null,
|
||||
host: "gateway",
|
||||
security: null,
|
||||
ask: null,
|
||||
agentId: "agent-2",
|
||||
resolvedPath: null,
|
||||
sessionKey: "agent:agent-2:main",
|
||||
},
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
};
|
||||
const agents = [
|
||||
createAgent("agent-1", "agent:agent-1:main"),
|
||||
createAgent("agent-2", "agent:agent-2:main"),
|
||||
];
|
||||
expect(resolveExecApprovalAgentId({ requested, agents })).toBe("agent-2");
|
||||
});
|
||||
|
||||
it("trusts explicit agent id even when the local agent list has not hydrated it yet", () => {
|
||||
const requested = {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "pwd",
|
||||
cwd: null,
|
||||
host: "gateway",
|
||||
security: null,
|
||||
ask: null,
|
||||
agentId: "agent-prehydration",
|
||||
resolvedPath: null,
|
||||
sessionKey: "agent:agent-prehydration:main",
|
||||
},
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
};
|
||||
const agents = [createAgent("agent-1", "agent:agent-1:main")];
|
||||
expect(resolveExecApprovalAgentId({ requested, agents })).toBe("agent-prehydration");
|
||||
});
|
||||
|
||||
it("falls back to session key when agent id missing", () => {
|
||||
const requested = {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "pwd",
|
||||
cwd: null,
|
||||
host: "gateway",
|
||||
security: null,
|
||||
ask: null,
|
||||
agentId: null,
|
||||
resolvedPath: null,
|
||||
sessionKey: "agent:agent-3:main",
|
||||
},
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
};
|
||||
const agents = [createAgent("agent-3", "agent:agent-3:main")];
|
||||
expect(resolveExecApprovalAgentId({ requested, agents })).toBe("agent-3");
|
||||
});
|
||||
|
||||
it("returns null when no agent mapping matches", () => {
|
||||
const requested = {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "pwd",
|
||||
cwd: null,
|
||||
host: "gateway",
|
||||
security: null,
|
||||
ask: null,
|
||||
agentId: null,
|
||||
resolvedPath: null,
|
||||
sessionKey: "agent:missing:main",
|
||||
},
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
};
|
||||
const agents = [createAgent("agent-1", "agent:agent-1:main")];
|
||||
expect(resolveExecApprovalAgentId({ requested, agents })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
resolveExecApprovalEventEffects,
|
||||
resolveExecApprovalFollowUpIntent,
|
||||
shouldTreatExecApprovalResolveErrorAsUnknownId,
|
||||
} from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { GatewayResponseError, type EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (agentId: string, sessionKey: string): AgentState => ({
|
||||
agentId,
|
||||
name: agentId,
|
||||
sessionKey,
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: agentId,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
const createApproval = (params?: Partial<PendingExecApproval>): PendingExecApproval => ({
|
||||
id: "approval-1",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
command: "npm test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
resolving: false,
|
||||
error: null,
|
||||
...params,
|
||||
});
|
||||
|
||||
describe("execApprovalLifecycleWorkflow", () => {
|
||||
it("maps requested approval into scoped or unscoped upsert effect", () => {
|
||||
const agents = [createAgent("agent-1", "agent:agent-1:main")];
|
||||
const scopedEvent: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.requested",
|
||||
payload: {
|
||||
id: "approval-scoped",
|
||||
request: {
|
||||
command: "npm run test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
agentId: "agent-1",
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
createdAtMs: 123,
|
||||
expiresAtMs: 456,
|
||||
},
|
||||
};
|
||||
const unscopedEvent: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.requested",
|
||||
payload: {
|
||||
id: "approval-unscoped",
|
||||
request: {
|
||||
command: "npm run lint",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
agentId: null,
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
sessionKey: "agent:missing:main",
|
||||
},
|
||||
createdAtMs: 222,
|
||||
expiresAtMs: 333,
|
||||
},
|
||||
};
|
||||
|
||||
const scopedEffects = resolveExecApprovalEventEffects({
|
||||
event: scopedEvent,
|
||||
agents,
|
||||
});
|
||||
expect(scopedEffects?.scopedUpserts.map((entry) => entry.agentId)).toEqual(["agent-1"]);
|
||||
expect(scopedEffects?.unscopedUpserts).toEqual([]);
|
||||
expect(scopedEffects?.markActivityAgentIds).toEqual(["agent-1"]);
|
||||
|
||||
const unscopedEffects = resolveExecApprovalEventEffects({
|
||||
event: unscopedEvent,
|
||||
agents,
|
||||
});
|
||||
expect(unscopedEffects?.scopedUpserts).toEqual([]);
|
||||
expect(unscopedEffects?.unscopedUpserts).toHaveLength(1);
|
||||
expect(unscopedEffects?.markActivityAgentIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps resolved approval event into remove effects", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.resolved",
|
||||
payload: {
|
||||
id: "approval-1",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "studio",
|
||||
ts: 999,
|
||||
},
|
||||
};
|
||||
|
||||
const effects = resolveExecApprovalEventEffects({
|
||||
event,
|
||||
agents: [createAgent("agent-1", "agent:agent-1:main")],
|
||||
});
|
||||
|
||||
expect(effects).toEqual({
|
||||
scopedUpserts: [],
|
||||
unscopedUpserts: [],
|
||||
removals: ["approval-1"],
|
||||
markActivityAgentIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns follow-up intent only for allow decisions", () => {
|
||||
const agents = [createAgent("agent-1", "agent:agent-1:main")];
|
||||
const approval = createApproval({ agentId: null, sessionKey: "agent:agent-1:main" });
|
||||
|
||||
expect(
|
||||
resolveExecApprovalFollowUpIntent({
|
||||
decision: "allow-once",
|
||||
approval,
|
||||
agents,
|
||||
followUpMessage: "approval granted",
|
||||
})
|
||||
).toEqual({
|
||||
shouldSend: true,
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
message: "approval granted",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveExecApprovalFollowUpIntent({
|
||||
decision: "deny",
|
||||
approval,
|
||||
agents,
|
||||
followUpMessage: "approval granted",
|
||||
})
|
||||
).toEqual({
|
||||
shouldSend: false,
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps unknown approval id gateway error to local removal intent", () => {
|
||||
expect(
|
||||
shouldTreatExecApprovalResolveErrorAsUnknownId(
|
||||
new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "Unknown approval id",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldTreatExecApprovalResolveErrorAsUnknownId(
|
||||
new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "approval denied by policy",
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shouldPauseRunForPendingExecApproval } from "@/features/agents/approvals/execApprovalPausePolicy";
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: "run-1",
|
||||
runStartedAt: 1,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "high",
|
||||
avatarSeed: "seed",
|
||||
avatarUrl: null,
|
||||
sessionExecAsk: "always",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createApproval = (overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({
|
||||
id: "approval-1",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
command: "ls -la",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
resolvedPath: null,
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
resolving: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("execApprovalPausePolicy", () => {
|
||||
it("pauses run when approval ask is always", () => {
|
||||
expect(
|
||||
shouldPauseRunForPendingExecApproval({
|
||||
agent: createAgent(),
|
||||
approval: createApproval({ ask: "always" }),
|
||||
pausedRunId: null,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not pause when approval ask is not always", () => {
|
||||
expect(
|
||||
shouldPauseRunForPendingExecApproval({
|
||||
agent: createAgent({ sessionExecAsk: "always" }),
|
||||
approval: createApproval({ ask: "on-miss" }),
|
||||
pausedRunId: null,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to agent ask when approval ask is missing", () => {
|
||||
expect(
|
||||
shouldPauseRunForPendingExecApproval({
|
||||
agent: createAgent({ sessionExecAsk: "always" }),
|
||||
approval: createApproval({ ask: null }),
|
||||
pausedRunId: null,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldPauseRunForPendingExecApproval({
|
||||
agent: createAgent({ sessionExecAsk: "on-miss" }),
|
||||
approval: createApproval({ ask: null }),
|
||||
pausedRunId: null,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not pause when run is already paused for the same run id", () => {
|
||||
expect(
|
||||
shouldPauseRunForPendingExecApproval({
|
||||
agent: createAgent({ runId: "run-1" }),
|
||||
approval: createApproval({ ask: "always" }),
|
||||
pausedRunId: "run-1",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not pause without active running state", () => {
|
||||
expect(
|
||||
shouldPauseRunForPendingExecApproval({
|
||||
agent: createAgent({ status: "idle" }),
|
||||
approval: createApproval({ ask: "always" }),
|
||||
pausedRunId: null,
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPauseRunForPendingExecApproval({
|
||||
agent: createAgent({ runId: null }),
|
||||
approval: createApproval({ ask: "always" }),
|
||||
pausedRunId: null,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
import { resolveExecApprovalViaStudio } from "@/features/agents/approvals/execApprovalResolveOperation";
|
||||
|
||||
type SetState<T> = (next: T | ((current: T) => T)) => void;
|
||||
|
||||
const createState = <T,>(initial: T): { get: () => T; set: SetState<T> } => {
|
||||
let value = initial;
|
||||
return {
|
||||
get: () => value,
|
||||
set: (next) => {
|
||||
value = typeof next === "function" ? (next as (current: T) => T)(value) : next;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("execApprovalResolveOperation", () => {
|
||||
it("removes approval and refreshes history after allow-once", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "exec.approval.resolve") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (method === "agent.wait") {
|
||||
return { status: "ok" };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
|
||||
const approval: PendingExecApproval = {
|
||||
id: "appr-1",
|
||||
agentId: "a1",
|
||||
sessionKey: "sess-1",
|
||||
command: "echo hi",
|
||||
cwd: null,
|
||||
host: null,
|
||||
security: null,
|
||||
ask: null,
|
||||
resolvedPath: null,
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
resolving: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const agent = {
|
||||
agentId: "a1",
|
||||
sessionKey: "sess-1",
|
||||
sessionCreated: true,
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
} as unknown as AgentState;
|
||||
|
||||
const approvalsByAgentId = createState<Record<string, PendingExecApproval[]>>({
|
||||
a1: [approval],
|
||||
});
|
||||
const unscopedApprovals = createState<PendingExecApproval[]>([]);
|
||||
const requestHistoryRefresh = vi.fn();
|
||||
const onAllowResolved = vi.fn();
|
||||
const onAllowed = vi.fn();
|
||||
|
||||
await resolveExecApprovalViaStudio({
|
||||
client: { call },
|
||||
approvalId: "appr-1",
|
||||
decision: "allow-once",
|
||||
getAgents: () => [agent],
|
||||
getLatestAgent: () => agent,
|
||||
getPendingState: () => ({
|
||||
approvalsByAgentId: approvalsByAgentId.get(),
|
||||
unscopedApprovals: unscopedApprovals.get(),
|
||||
}),
|
||||
setPendingExecApprovalsByAgentId: approvalsByAgentId.set,
|
||||
setUnscopedPendingExecApprovals: unscopedApprovals.set,
|
||||
requestHistoryRefresh,
|
||||
onAllowResolved,
|
||||
onAllowed,
|
||||
isDisconnectLikeError: () => false,
|
||||
});
|
||||
|
||||
expect(call).toHaveBeenCalledWith("exec.approval.resolve", { id: "appr-1", decision: "allow-once" });
|
||||
expect(call).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 15_000 });
|
||||
|
||||
expect(approvalsByAgentId.get()).toEqual({});
|
||||
expect(unscopedApprovals.get()).toEqual([]);
|
||||
expect(onAllowResolved).toHaveBeenCalledWith({ approval, targetAgentId: "a1" });
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledWith("a1");
|
||||
expect(onAllowed).toHaveBeenCalledWith({ approval, targetAgentId: "a1" });
|
||||
expect(onAllowResolved.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
requestHistoryRefresh.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it("treats unknown approval id as already removed", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "exec.approval.resolve") {
|
||||
throw new GatewayResponseError({
|
||||
code: "NOT_FOUND",
|
||||
message: "unknown approval id appr-1",
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
|
||||
const approval: PendingExecApproval = {
|
||||
id: "appr-1",
|
||||
agentId: "a1",
|
||||
sessionKey: "sess-1",
|
||||
command: "echo hi",
|
||||
cwd: null,
|
||||
host: null,
|
||||
security: null,
|
||||
ask: null,
|
||||
resolvedPath: null,
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
resolving: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const agent = {
|
||||
agentId: "a1",
|
||||
sessionKey: "sess-1",
|
||||
sessionCreated: true,
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
} as unknown as AgentState;
|
||||
|
||||
const approvalsByAgentId = createState<Record<string, PendingExecApproval[]>>({
|
||||
a1: [approval],
|
||||
});
|
||||
const unscopedApprovals = createState<PendingExecApproval[]>([]);
|
||||
const onAllowed = vi.fn();
|
||||
|
||||
await resolveExecApprovalViaStudio({
|
||||
client: { call },
|
||||
approvalId: "appr-1",
|
||||
decision: "allow-once",
|
||||
getAgents: () => [agent],
|
||||
getLatestAgent: () => agent,
|
||||
getPendingState: () => ({
|
||||
approvalsByAgentId: approvalsByAgentId.get(),
|
||||
unscopedApprovals: unscopedApprovals.get(),
|
||||
}),
|
||||
setPendingExecApprovalsByAgentId: approvalsByAgentId.set,
|
||||
setUnscopedPendingExecApprovals: unscopedApprovals.set,
|
||||
requestHistoryRefresh: vi.fn(),
|
||||
onAllowed,
|
||||
isDisconnectLikeError: () => false,
|
||||
});
|
||||
|
||||
expect(approvalsByAgentId.get()).toEqual({});
|
||||
expect(unscopedApprovals.get()).toEqual([]);
|
||||
expect(onAllowed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not trigger onAllowed for deny decisions", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "exec.approval.resolve") {
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
});
|
||||
|
||||
const approval: PendingExecApproval = {
|
||||
id: "appr-1",
|
||||
agentId: "a1",
|
||||
sessionKey: "sess-1",
|
||||
command: "echo hi",
|
||||
cwd: null,
|
||||
host: null,
|
||||
security: null,
|
||||
ask: null,
|
||||
resolvedPath: null,
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
resolving: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const agent = {
|
||||
agentId: "a1",
|
||||
sessionKey: "sess-1",
|
||||
sessionCreated: true,
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
} as unknown as AgentState;
|
||||
|
||||
const approvalsByAgentId = createState<Record<string, PendingExecApproval[]>>({
|
||||
a1: [approval],
|
||||
});
|
||||
const unscopedApprovals = createState<PendingExecApproval[]>([]);
|
||||
const onAllowed = vi.fn();
|
||||
|
||||
await resolveExecApprovalViaStudio({
|
||||
client: { call },
|
||||
approvalId: "appr-1",
|
||||
decision: "deny",
|
||||
getAgents: () => [agent],
|
||||
getLatestAgent: () => agent,
|
||||
getPendingState: () => ({
|
||||
approvalsByAgentId: approvalsByAgentId.get(),
|
||||
unscopedApprovals: unscopedApprovals.get(),
|
||||
}),
|
||||
setPendingExecApprovalsByAgentId: approvalsByAgentId.set,
|
||||
setUnscopedPendingExecApprovals: unscopedApprovals.set,
|
||||
requestHistoryRefresh: vi.fn(),
|
||||
onAllowed,
|
||||
isDisconnectLikeError: () => false,
|
||||
});
|
||||
|
||||
expect(onAllowed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
|
||||
runExecApprovalAutoResumeOperation,
|
||||
runGatewayEventIngressOperation,
|
||||
runPauseRunForExecApprovalOperation,
|
||||
runResolveExecApprovalOperation,
|
||||
} from "@/features/agents/approvals/execApprovalRunControlOperation";
|
||||
import type { ExecApprovalPendingSnapshot } from "@/features/agents/approvals/execApprovalControlLoopWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: "run-1",
|
||||
runStartedAt: 1,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
sessionExecAsk: "always",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createApproval = (id: string, overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({
|
||||
id,
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
command: "npm run test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 10_000,
|
||||
resolving: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createPendingState = (
|
||||
overrides?: Partial<ExecApprovalPendingSnapshot>
|
||||
): ExecApprovalPendingSnapshot => ({
|
||||
approvalsByAgentId: {},
|
||||
unscopedApprovals: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("execApprovalRunControlOperation", () => {
|
||||
it("pauses a run for pending approval after stale paused-run cleanup", async () => {
|
||||
const call = vi.fn(async () => ({ ok: true }));
|
||||
const pausedRunIdByAgentId = new Map<string, string>([
|
||||
["stale-agent", "stale-run"],
|
||||
]);
|
||||
|
||||
await runPauseRunForExecApprovalOperation({
|
||||
status: "connected",
|
||||
client: { call },
|
||||
approval: createApproval("approval-1"),
|
||||
preferredAgentId: "agent-1",
|
||||
getAgents: () => [createAgent({ runId: "run-1" })],
|
||||
pausedRunIdByAgentId,
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
});
|
||||
|
||||
expect(pausedRunIdByAgentId.has("stale-agent")).toBe(false);
|
||||
expect(pausedRunIdByAgentId.get("agent-1")).toBe("run-1");
|
||||
expect(call).toHaveBeenCalledWith("chat.abort", {
|
||||
sessionKey: "agent:agent-1:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("reverts paused-run map entry when pause abort call fails", async () => {
|
||||
const call = vi.fn(async () => {
|
||||
throw new Error("abort failed");
|
||||
});
|
||||
const logWarn = vi.fn();
|
||||
const pausedRunIdByAgentId = new Map<string, string>();
|
||||
|
||||
await runPauseRunForExecApprovalOperation({
|
||||
status: "connected",
|
||||
client: { call },
|
||||
approval: createApproval("approval-1"),
|
||||
preferredAgentId: "agent-1",
|
||||
getAgents: () => [createAgent({ runId: "run-1" })],
|
||||
pausedRunIdByAgentId,
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn,
|
||||
});
|
||||
|
||||
expect(pausedRunIdByAgentId.has("agent-1")).toBe(false);
|
||||
expect(logWarn).toHaveBeenCalledWith(
|
||||
"Failed to pause run for pending exec approval.",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-resumes in order: dispatch running, wait paused run, then send follow-up", async () => {
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "agent.wait") return { status: "ok" };
|
||||
throw new Error(`Unexpected method ${method}`);
|
||||
});
|
||||
const dispatch = vi.fn();
|
||||
const sendChatMessage = vi.fn(async () => undefined);
|
||||
const pausedRunIdByAgentId = new Map<string, string>([["agent-1", "run-1"]]);
|
||||
|
||||
await runExecApprovalAutoResumeOperation({
|
||||
client: { call },
|
||||
dispatch,
|
||||
approval: createApproval("approval-1"),
|
||||
targetAgentId: "agent-1",
|
||||
getAgents: () => [createAgent({ status: "running", runId: "run-1" })],
|
||||
getPendingState: () => createPendingState(),
|
||||
pausedRunIdByAgentId,
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
sendChatMessage,
|
||||
now: () => 777,
|
||||
});
|
||||
|
||||
expect(pausedRunIdByAgentId.has("agent-1")).toBe(false);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { status: "running", runId: "run-1", lastActivityAt: 777 },
|
||||
});
|
||||
expect(call).toHaveBeenCalledWith("agent.wait", {
|
||||
runId: "run-1",
|
||||
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
expect(sendChatMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
echoUserMessage: false,
|
||||
message: `${EXEC_APPROVAL_AUTO_RESUME_MARKER}\nContinue where you left off and finish the task.`,
|
||||
})
|
||||
);
|
||||
|
||||
expect(dispatch.mock.invocationCallOrder[0]).toBeLessThan(call.mock.invocationCallOrder[0]);
|
||||
expect(call.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
sendChatMessage.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it("skips follow-up send when post-wait auto-resume intent no longer holds", async () => {
|
||||
const call = vi.fn(async () => ({ status: "ok" }));
|
||||
const dispatch = vi.fn();
|
||||
const sendChatMessage = vi.fn(async () => undefined);
|
||||
const pausedRunIdByAgentId = new Map<string, string>([["agent-1", "run-1"]]);
|
||||
|
||||
let readCount = 0;
|
||||
const getAgents = () => {
|
||||
readCount += 1;
|
||||
if (readCount === 1) {
|
||||
return [createAgent({ status: "running", runId: "run-1" })];
|
||||
}
|
||||
return [createAgent({ status: "running", runId: "run-2" })];
|
||||
};
|
||||
|
||||
await runExecApprovalAutoResumeOperation({
|
||||
client: { call },
|
||||
dispatch,
|
||||
approval: createApproval("approval-1"),
|
||||
targetAgentId: "agent-1",
|
||||
getAgents,
|
||||
getPendingState: () => createPendingState(),
|
||||
pausedRunIdByAgentId,
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
sendChatMessage,
|
||||
});
|
||||
|
||||
expect(call).toHaveBeenCalledWith("agent.wait", {
|
||||
runId: "run-1",
|
||||
timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
expect(sendChatMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves approvals through resolver and delegates allow flow to auto-resume operation", async () => {
|
||||
const resolveExecApproval = vi.fn(async (params: { onAllowed?: (input: {
|
||||
approval: PendingExecApproval;
|
||||
targetAgentId: string;
|
||||
}) => Promise<void> }) => {
|
||||
await params.onAllowed?.({
|
||||
approval: createApproval("approval-1"),
|
||||
targetAgentId: "agent-1",
|
||||
});
|
||||
});
|
||||
const runAutoResume = vi.fn(async () => undefined);
|
||||
|
||||
await runResolveExecApprovalOperation({
|
||||
client: { call: vi.fn(async () => ({ ok: true })) },
|
||||
approvalId: "approval-1",
|
||||
decision: "allow-once",
|
||||
getAgents: () => [createAgent()],
|
||||
getPendingState: () => createPendingState(),
|
||||
setPendingExecApprovalsByAgentId: vi.fn(),
|
||||
setUnscopedPendingExecApprovals: vi.fn(),
|
||||
requestHistoryRefresh: vi.fn(),
|
||||
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
|
||||
dispatch: vi.fn(),
|
||||
isDisconnectLikeError: () => false,
|
||||
resolveExecApproval: resolveExecApproval as never,
|
||||
runAutoResume,
|
||||
});
|
||||
|
||||
expect(resolveExecApproval).toHaveBeenCalledTimes(1);
|
||||
expect(runAutoResume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
approval: expect.objectContaining({ id: "approval-1" }),
|
||||
targetAgentId: "agent-1",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("executes ingress commands from gateway events", () => {
|
||||
const dispatch = vi.fn();
|
||||
const replacePendingState = vi.fn();
|
||||
const pauseRunForApproval = vi.fn(async () => undefined);
|
||||
const recordCronDedupeKey = vi.fn();
|
||||
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
jobId: "job-1",
|
||||
sessionId: "session-1",
|
||||
runAtMs: 123,
|
||||
status: "ok",
|
||||
summary: "cron summary",
|
||||
},
|
||||
};
|
||||
|
||||
const commands = runGatewayEventIngressOperation({
|
||||
event,
|
||||
getAgents: () => [createAgent()],
|
||||
getPendingState: () => createPendingState(),
|
||||
pausedRunIdByAgentId: new Map(),
|
||||
seenCronDedupeKeys: new Set(),
|
||||
nowMs: 1_000,
|
||||
replacePendingState,
|
||||
pauseRunForApproval,
|
||||
dispatch,
|
||||
recordCronDedupeKey,
|
||||
});
|
||||
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(recordCronDedupeKey).toHaveBeenCalledWith("cron:job-1:session-1");
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "appendOutput",
|
||||
agentId: "agent-1",
|
||||
})
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "markActivity",
|
||||
agentId: "agent-1",
|
||||
at: 123,
|
||||
});
|
||||
expect(replacePendingState).not.toHaveBeenCalled();
|
||||
expect(pauseRunForApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
planApprovalIngressRunControl,
|
||||
planAutoResumeRunControl,
|
||||
planPauseRunControl,
|
||||
} from "@/features/agents/approvals/execApprovalRunControlWorkflow";
|
||||
import type { ExecApprovalPendingSnapshot } from "@/features/agents/approvals/execApprovalControlLoopWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: "run-1",
|
||||
runStartedAt: 1,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
sessionExecAsk: "always",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createApproval = (id: string, overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({
|
||||
id,
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
command: "npm run test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 10_000,
|
||||
resolving: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createPendingState = (
|
||||
overrides?: Partial<ExecApprovalPendingSnapshot>
|
||||
): ExecApprovalPendingSnapshot => ({
|
||||
approvalsByAgentId: {},
|
||||
unscopedApprovals: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("execApprovalRunControlWorkflow", () => {
|
||||
it("plans stale paused-run cleanup together with pause intent", () => {
|
||||
const plan = planPauseRunControl({
|
||||
approval: createApproval("approval-1"),
|
||||
preferredAgentId: "agent-1",
|
||||
agents: [
|
||||
createAgent({ agentId: "agent-1", runId: "run-1" }),
|
||||
createAgent({
|
||||
agentId: "agent-2",
|
||||
sessionKey: "agent:agent-2:main",
|
||||
runId: "run-2",
|
||||
}),
|
||||
],
|
||||
pausedRunIdByAgentId: new Map([
|
||||
["agent-2", "stale-run"],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(plan.stalePausedAgentIds).toEqual(["agent-2"]);
|
||||
expect(plan.pauseIntent).toEqual({
|
||||
kind: "pause",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
runId: "run-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("plans pre-wait and post-wait auto-resume intents", () => {
|
||||
const plan = planAutoResumeRunControl({
|
||||
approval: createApproval("approval-1"),
|
||||
targetAgentId: "agent-1",
|
||||
pendingState: createPendingState(),
|
||||
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
|
||||
agents: [createAgent({ status: "running", runId: "run-1" })],
|
||||
});
|
||||
|
||||
expect(plan.preWaitIntent).toEqual({
|
||||
kind: "resume",
|
||||
targetAgentId: "agent-1",
|
||||
pausedRunId: "run-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
});
|
||||
expect(plan.postWaitIntent).toEqual({
|
||||
kind: "resume",
|
||||
targetAgentId: "agent-1",
|
||||
pausedRunId: "run-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns skip intents when pre-wait auto-resume is blocked", () => {
|
||||
const plan = planAutoResumeRunControl({
|
||||
approval: createApproval("approval-1"),
|
||||
targetAgentId: "agent-1",
|
||||
pendingState: createPendingState({
|
||||
approvalsByAgentId: {
|
||||
"agent-1": [createApproval("approval-1"), createApproval("approval-2")],
|
||||
},
|
||||
}),
|
||||
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
|
||||
agents: [createAgent({ status: "running", runId: "run-1" })],
|
||||
});
|
||||
|
||||
expect(plan.preWaitIntent).toEqual({
|
||||
kind: "skip",
|
||||
reason: "blocking-pending-approvals",
|
||||
});
|
||||
expect(plan.postWaitIntent).toEqual({
|
||||
kind: "skip",
|
||||
reason: "blocking-pending-approvals",
|
||||
});
|
||||
});
|
||||
|
||||
it("plans ingress run-control commands from gateway events", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
jobId: "job-1",
|
||||
sessionId: "session-1",
|
||||
runAtMs: 123,
|
||||
status: "ok",
|
||||
summary: "cron summary",
|
||||
},
|
||||
};
|
||||
|
||||
const commands = planApprovalIngressRunControl({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
pendingState: createPendingState(),
|
||||
pausedRunIdByAgentId: new Map(),
|
||||
seenCronDedupeKeys: new Set(),
|
||||
nowMs: 1_000,
|
||||
});
|
||||
|
||||
expect(commands).toEqual([
|
||||
{ kind: "recordCronDedupeKey", dedupeKey: "cron:job-1:session-1" },
|
||||
{
|
||||
kind: "appendCronTranscript",
|
||||
intent: {
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
dedupeKey: "cron:job-1:session-1",
|
||||
line: "Cron finished (ok): job-1\n\ncron summary",
|
||||
timestampMs: 123,
|
||||
activityAtMs: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
applyApprovalIngressEffects,
|
||||
deriveAwaitingUserInputPatches,
|
||||
derivePendingApprovalPruneDelayMs,
|
||||
prunePendingApprovalState,
|
||||
resolveApprovalAutoResumeDispatch,
|
||||
resolveApprovalAutoResumePreflight,
|
||||
type ApprovalPendingState,
|
||||
} from "@/features/agents/approvals/execApprovalRuntimeCoordinator";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: "run-1",
|
||||
runStartedAt: 1,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
sessionExecAsk: "always",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createApproval = (id: string, overrides?: Partial<PendingExecApproval>): PendingExecApproval => ({
|
||||
id,
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
command: "npm run test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 10_000,
|
||||
resolving: false,
|
||||
error: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createPendingState = (overrides?: Partial<ApprovalPendingState>): ApprovalPendingState => ({
|
||||
approvalsByAgentId: {},
|
||||
unscopedApprovals: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("execApprovalRuntimeCoordinator", () => {
|
||||
it("applies scoped/unscoped upserts and removals while deriving pause requests", () => {
|
||||
const existingScoped = createApproval("existing-scoped");
|
||||
const existingUnscoped = createApproval("existing-unscoped", { agentId: null, sessionKey: "agent:other:main" });
|
||||
const scopedUpsert = createApproval("approval-scoped", { ask: "always" });
|
||||
const unscopedUpsert = createApproval("approval-unscoped", {
|
||||
agentId: null,
|
||||
sessionKey: "agent:other:main",
|
||||
ask: "on-miss",
|
||||
});
|
||||
|
||||
const pendingState = createPendingState({
|
||||
approvalsByAgentId: { "agent-1": [existingScoped] },
|
||||
unscopedApprovals: [existingUnscoped],
|
||||
});
|
||||
|
||||
const effects: ExecApprovalEventEffects = {
|
||||
scopedUpserts: [{ agentId: "agent-1", approval: scopedUpsert }],
|
||||
unscopedUpserts: [unscopedUpsert],
|
||||
removals: ["existing-scoped", "existing-unscoped"],
|
||||
markActivityAgentIds: ["agent-1"],
|
||||
};
|
||||
|
||||
const result = applyApprovalIngressEffects({
|
||||
pendingState,
|
||||
approvalEffects: effects,
|
||||
agents: [createAgent(), createAgent({ agentId: "other", sessionKey: "agent:other:main", runId: "run-2", sessionExecAsk: "on-miss" })],
|
||||
pausedRunIdByAgentId: new Map(),
|
||||
});
|
||||
|
||||
expect(result.pendingState.approvalsByAgentId).toEqual({
|
||||
"agent-1": [scopedUpsert],
|
||||
});
|
||||
expect(result.pendingState.unscopedApprovals).toEqual([unscopedUpsert]);
|
||||
expect(result.markActivityAgentIds).toEqual(["agent-1"]);
|
||||
expect(result.pauseRequests).toEqual([{ approval: scopedUpsert, preferredAgentId: "agent-1" }]);
|
||||
});
|
||||
|
||||
it("does not emit pause request when run is already paused for the same run id", () => {
|
||||
const scopedUpsert = createApproval("approval-scoped", { ask: "always" });
|
||||
const effects: ExecApprovalEventEffects = {
|
||||
scopedUpserts: [{ agentId: "agent-1", approval: scopedUpsert }],
|
||||
unscopedUpserts: [],
|
||||
removals: [],
|
||||
markActivityAgentIds: [],
|
||||
};
|
||||
|
||||
const result = applyApprovalIngressEffects({
|
||||
pendingState: createPendingState(),
|
||||
approvalEffects: effects,
|
||||
agents: [createAgent({ runId: "run-1" })],
|
||||
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
|
||||
});
|
||||
|
||||
expect(result.pauseRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it("blocks preflight auto-resume when sibling pending approvals exist", () => {
|
||||
const pendingState = createPendingState({
|
||||
approvalsByAgentId: {
|
||||
"agent-1": [createApproval("a-1"), createApproval("a-2")],
|
||||
},
|
||||
unscopedApprovals: [],
|
||||
});
|
||||
|
||||
const preflight = resolveApprovalAutoResumePreflight({
|
||||
approval: createApproval("a-1"),
|
||||
targetAgentId: "agent-1",
|
||||
pendingState,
|
||||
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
|
||||
});
|
||||
|
||||
expect(preflight).toEqual({ kind: "skip", reason: "blocking-pending-approvals" });
|
||||
});
|
||||
|
||||
it("allows preflight auto-resume when no blocking approvals remain", () => {
|
||||
const preflight = resolveApprovalAutoResumePreflight({
|
||||
approval: createApproval("a-1"),
|
||||
targetAgentId: "agent-1",
|
||||
pendingState: createPendingState(),
|
||||
pausedRunIdByAgentId: new Map([["agent-1", "run-1"]]),
|
||||
});
|
||||
|
||||
expect(preflight).toEqual({
|
||||
kind: "resume",
|
||||
targetAgentId: "agent-1",
|
||||
pausedRunId: "run-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives dispatch auto-resume intent only when run ownership is still valid", () => {
|
||||
const replacedRun = resolveApprovalAutoResumeDispatch({
|
||||
targetAgentId: "agent-1",
|
||||
pausedRunId: "run-1",
|
||||
agents: [createAgent({ status: "running", runId: "run-2" })],
|
||||
});
|
||||
expect(replacedRun).toEqual({ kind: "skip", reason: "run-replaced" });
|
||||
|
||||
const resume = resolveApprovalAutoResumeDispatch({
|
||||
targetAgentId: "agent-1",
|
||||
pausedRunId: "run-1",
|
||||
agents: [createAgent({ status: "running", runId: "run-1", sessionKey: "agent:agent-1:main" })],
|
||||
});
|
||||
expect(resume).toEqual({
|
||||
kind: "resume",
|
||||
targetAgentId: "agent-1",
|
||||
pausedRunId: "run-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives awaiting-user-input patches from scoped pending approvals", () => {
|
||||
const agents = [
|
||||
createAgent({ agentId: "agent-1", awaitingUserInput: false }),
|
||||
createAgent({ agentId: "agent-2", awaitingUserInput: true, runId: "run-2", sessionKey: "agent:agent-2:main" }),
|
||||
createAgent({ agentId: "agent-3", awaitingUserInput: false, runId: "run-3", sessionKey: "agent:agent-3:main" }),
|
||||
];
|
||||
|
||||
const patches = deriveAwaitingUserInputPatches({
|
||||
agents,
|
||||
approvalsByAgentId: {
|
||||
"agent-1": [createApproval("a-1")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(patches).toEqual([
|
||||
{ agentId: "agent-1", awaitingUserInput: true },
|
||||
{ agentId: "agent-2", awaitingUserInput: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives prune delay and pruned pending state", () => {
|
||||
const pendingState = createPendingState({
|
||||
approvalsByAgentId: {
|
||||
"agent-1": [createApproval("a-1", { expiresAtMs: 6_000 })],
|
||||
},
|
||||
unscopedApprovals: [createApproval("u-1", { agentId: null, expiresAtMs: 7_500 })],
|
||||
});
|
||||
|
||||
const delay = derivePendingApprovalPruneDelayMs({
|
||||
pendingState,
|
||||
nowMs: 5_000,
|
||||
graceMs: 500,
|
||||
});
|
||||
expect(delay).toBe(1_500);
|
||||
|
||||
const pruned = prunePendingApprovalState({
|
||||
pendingState: {
|
||||
approvalsByAgentId: {
|
||||
"agent-1": [
|
||||
createApproval("expired", { expiresAtMs: 4_000 }),
|
||||
createApproval("active", { expiresAtMs: 6_000 }),
|
||||
],
|
||||
},
|
||||
unscopedApprovals: [
|
||||
createApproval("expired-u", { agentId: null, expiresAtMs: 4_100 }),
|
||||
createApproval("active-u", { agentId: null, expiresAtMs: 8_000 }),
|
||||
],
|
||||
},
|
||||
nowMs: 5_000,
|
||||
graceMs: 500,
|
||||
});
|
||||
|
||||
expect(pruned.pendingState.approvalsByAgentId).toEqual({
|
||||
"agent-1": [createApproval("active", { expiresAtMs: 6_000 })],
|
||||
});
|
||||
expect(pruned.pendingState.unscopedApprovals).toEqual([
|
||||
createApproval("active-u", { agentId: null, expiresAtMs: 8_000 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extractThinking,
|
||||
extractThinkingFromTaggedStream,
|
||||
extractThinkingFromTaggedText,
|
||||
formatThinkingMarkdown,
|
||||
isTraceMarkdown,
|
||||
stripTraceMarkdown,
|
||||
} from "@/lib/text/message-extract";
|
||||
|
||||
describe("extractThinking", () => {
|
||||
it("extracts thinking blocks from content arrays", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "First idea" },
|
||||
{ type: "text", text: "Reply" },
|
||||
],
|
||||
};
|
||||
|
||||
expect(extractThinking(message)).toBe("First idea");
|
||||
});
|
||||
|
||||
it("joins multiple thinking blocks in order", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "One" },
|
||||
{ type: "thinking", thinking: "Two" },
|
||||
],
|
||||
};
|
||||
|
||||
expect(extractThinking(message)).toBe("One\nTwo");
|
||||
});
|
||||
|
||||
it("extracts thinking from <thinking> tags", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: "<thinking>Plan A</thinking>\nOk.",
|
||||
};
|
||||
|
||||
expect(extractThinking(message)).toBe("Plan A");
|
||||
});
|
||||
|
||||
it("extracts partial thinking from an open thinking tag", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: "Hello <think>Plan A so far",
|
||||
};
|
||||
|
||||
expect(extractThinking(message)).toBe("Plan A so far");
|
||||
});
|
||||
|
||||
it("extracts reasoning from runtime variant fields", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
reasoningText: "Plan A",
|
||||
};
|
||||
|
||||
expect(extractThinking(message)).toBe("Plan A");
|
||||
});
|
||||
|
||||
it("extracts reasoning from nested runtime deltas", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
reasoning: {
|
||||
delta: "still thinking",
|
||||
},
|
||||
};
|
||||
|
||||
expect(extractThinking(message)).toBe("still thinking");
|
||||
});
|
||||
|
||||
it("returns null when no thinking exists", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
};
|
||||
|
||||
expect(extractThinking(message)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for whitespace-only thinking", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: " " }],
|
||||
};
|
||||
|
||||
expect(extractThinking(message)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatThinkingMarkdown", () => {
|
||||
it("formats multi-line thinking into prefixed italic lines", () => {
|
||||
const input = "Line 1\n\n Line 2 ";
|
||||
const formatted = formatThinkingMarkdown(input);
|
||||
expect(isTraceMarkdown(formatted)).toBe(true);
|
||||
expect(stripTraceMarkdown(formatted)).toBe("_Line 1_\n\n_Line 2_");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractThinkingFromTaggedText", () => {
|
||||
it("extracts from closed thinking tags", () => {
|
||||
expect(extractThinkingFromTaggedText("<thinking>Plan A</thinking>\nOk")).toBe("Plan A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractThinkingFromTaggedStream", () => {
|
||||
it("extracts partial thinking from an open thinking tag", () => {
|
||||
expect(extractThinkingFromTaggedStream("Hello <think>Plan A so far")).toBe("Plan A so far");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
|
||||
import { fetchJson } from "@/lib/http";
|
||||
|
||||
type MockResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
text: () => Promise<string>;
|
||||
};
|
||||
|
||||
const createResponse = (body: string, ok: boolean, status: number): MockResponse => ({
|
||||
ok,
|
||||
status,
|
||||
text: vi.fn().mockResolvedValue(body),
|
||||
});
|
||||
|
||||
describe("fetchJson", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("throws when response is not ok", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
createResponse(JSON.stringify({ error: "Nope" }), false, 400)
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(fetchJson("/api/test")).rejects.toThrow("Nope");
|
||||
});
|
||||
|
||||
it("returns parsed JSON for ok responses", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
createResponse(JSON.stringify({ ok: true }), true, 200)
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(fetchJson("/api/test")).resolves.toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildLatestUpdatePatch,
|
||||
resolveLatestUpdateIntent,
|
||||
} from "@/features/agents/operations/latestUpdateWorkflow";
|
||||
import {
|
||||
buildReconcileTerminalPatch,
|
||||
resolveReconcileEligibility,
|
||||
resolveReconcileWaitOutcome,
|
||||
resolveSummarySnapshotIntent,
|
||||
} from "@/features/agents/operations/fleetLifecycleWorkflow";
|
||||
import { buildSummarySnapshotPatches } from "@/features/agents/state/runtimeEventBridge";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
const createAgent = (agentId: string, sessionKey: string, status: AgentState["status"]): AgentState => ({
|
||||
agentId,
|
||||
name: agentId,
|
||||
sessionKey,
|
||||
status,
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: status === "running" ? "run-1" : null,
|
||||
runStartedAt: status === "running" ? 1 : null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: agentId,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
describe("fleetLifecycleWorkflow integration", () => {
|
||||
it("page adapter applies latest-update reset/update intents without behavior drift", () => {
|
||||
const resetIntent = resolveLatestUpdateIntent({
|
||||
message: "regular prompt",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
hasExistingOverride: true,
|
||||
});
|
||||
expect(resetIntent).toEqual({ kind: "reset" });
|
||||
expect(buildLatestUpdatePatch("")).toEqual({
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
});
|
||||
|
||||
const heartbeatIntent = resolveLatestUpdateIntent({
|
||||
message: "heartbeat status please",
|
||||
agentId: "",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
hasExistingOverride: false,
|
||||
});
|
||||
expect(heartbeatIntent).toEqual({
|
||||
kind: "fetch-heartbeat",
|
||||
agentId: "agent-1",
|
||||
sessionLimit: 48,
|
||||
historyLimit: 200,
|
||||
});
|
||||
expect(buildLatestUpdatePatch("Heartbeat is healthy.", "heartbeat")).toEqual({
|
||||
latestOverride: "Heartbeat is healthy.",
|
||||
latestOverrideKind: "heartbeat",
|
||||
});
|
||||
});
|
||||
|
||||
it("summary snapshot flow preserves status + preview patch application semantics", () => {
|
||||
const agents = [
|
||||
createAgent("agent-1", "agent:agent-1:main", "idle"),
|
||||
createAgent("agent-2", "agent:agent-2:main", "running"),
|
||||
];
|
||||
const summaryIntent = resolveSummarySnapshotIntent({
|
||||
agents,
|
||||
maxKeys: 64,
|
||||
});
|
||||
expect(summaryIntent).toEqual({
|
||||
kind: "fetch",
|
||||
keys: ["agent:agent-1:main", "agent:agent-2:main"],
|
||||
limit: 8,
|
||||
maxChars: 240,
|
||||
});
|
||||
|
||||
const patches = buildSummarySnapshotPatches({
|
||||
agents,
|
||||
statusSummary: {
|
||||
sessions: {
|
||||
recent: [{ key: "agent:agent-1:main", updatedAt: 1234 }],
|
||||
byAgent: [],
|
||||
},
|
||||
},
|
||||
previewResult: {
|
||||
ts: 1234,
|
||||
previews: [
|
||||
{
|
||||
key: "agent:agent-1:main",
|
||||
status: "ok",
|
||||
items: [
|
||||
{ role: "user", text: "ping", timestamp: 1000 },
|
||||
{ role: "assistant", text: "pong", timestamp: 1200 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(patches).toEqual([
|
||||
{
|
||||
agentId: "agent-1",
|
||||
patch: {
|
||||
lastActivityAt: 1234,
|
||||
lastAssistantMessageAt: 1200,
|
||||
latestPreview: "pong",
|
||||
lastUserMessage: "ping",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("run reconciliation preserves terminal transition semantics and history reload trigger", () => {
|
||||
const runReconcileAdapter = (params: {
|
||||
status: AgentState["status"];
|
||||
sessionCreated: boolean;
|
||||
runId: string | null;
|
||||
waitStatus: unknown;
|
||||
}) => {
|
||||
const eligibility = resolveReconcileEligibility({
|
||||
status: params.status,
|
||||
sessionCreated: params.sessionCreated,
|
||||
runId: params.runId,
|
||||
});
|
||||
if (!eligibility.shouldCheck) {
|
||||
return { patch: null, shouldReloadHistory: false };
|
||||
}
|
||||
const outcome = resolveReconcileWaitOutcome(params.waitStatus);
|
||||
if (!outcome) {
|
||||
return { patch: null, shouldReloadHistory: false };
|
||||
}
|
||||
return {
|
||||
patch: buildReconcileTerminalPatch({ outcome }),
|
||||
shouldReloadHistory: true,
|
||||
};
|
||||
};
|
||||
|
||||
expect(
|
||||
runReconcileAdapter({
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
runId: "run-1",
|
||||
waitStatus: "ok",
|
||||
})
|
||||
).toEqual({
|
||||
patch: {
|
||||
status: "idle",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
},
|
||||
shouldReloadHistory: true,
|
||||
});
|
||||
expect(
|
||||
runReconcileAdapter({
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
runId: "run-1",
|
||||
waitStatus: "error",
|
||||
})
|
||||
).toEqual({
|
||||
patch: {
|
||||
status: "error",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
},
|
||||
shouldReloadHistory: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildReconcileTerminalPatch,
|
||||
resolveReconcileEligibility,
|
||||
resolveReconcileWaitOutcome,
|
||||
resolveSummarySnapshotIntent,
|
||||
} from "@/features/agents/operations/fleetLifecycleWorkflow";
|
||||
|
||||
describe("fleetLifecycleWorkflow", () => {
|
||||
it("returns summary snapshot skip when no valid session keys exist", () => {
|
||||
expect(
|
||||
resolveSummarySnapshotIntent({
|
||||
agents: [
|
||||
{ sessionCreated: false, sessionKey: "agent:agent-1:main" },
|
||||
{ sessionCreated: true, sessionKey: "" },
|
||||
{ sessionCreated: true, sessionKey: " " },
|
||||
],
|
||||
maxKeys: 64,
|
||||
})
|
||||
).toEqual({ kind: "skip" });
|
||||
});
|
||||
|
||||
it("returns summary snapshot fetch intent when session keys are present", () => {
|
||||
expect(
|
||||
resolveSummarySnapshotIntent({
|
||||
agents: [
|
||||
{ sessionCreated: true, sessionKey: "agent:agent-1:main" },
|
||||
{ sessionCreated: true, sessionKey: "agent:agent-1:main" },
|
||||
{ sessionCreated: true, sessionKey: "agent:agent-2:main" },
|
||||
{ sessionCreated: true, sessionKey: "agent:agent-3:main" },
|
||||
],
|
||||
maxKeys: 2,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "fetch",
|
||||
keys: ["agent:agent-1:main", "agent:agent-2:main"],
|
||||
limit: 8,
|
||||
maxChars: 240,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps reconcile wait result ok/error to idle/error terminal patch", () => {
|
||||
expect(resolveReconcileWaitOutcome("ok")).toBe("ok");
|
||||
expect(resolveReconcileWaitOutcome("error")).toBe("error");
|
||||
expect(resolveReconcileWaitOutcome("running")).toBeNull();
|
||||
expect(buildReconcileTerminalPatch({ outcome: "ok" })).toEqual({
|
||||
status: "idle",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
});
|
||||
expect(buildReconcileTerminalPatch({ outcome: "error" })).toEqual({
|
||||
status: "error",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects reconcile intent for non-running or missing-run agents", () => {
|
||||
expect(
|
||||
resolveReconcileEligibility({
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
runId: "run-1",
|
||||
})
|
||||
).toEqual({
|
||||
shouldCheck: false,
|
||||
reason: "not-running",
|
||||
});
|
||||
expect(
|
||||
resolveReconcileEligibility({
|
||||
status: "running",
|
||||
sessionCreated: false,
|
||||
runId: "run-1",
|
||||
})
|
||||
).toEqual({
|
||||
shouldCheck: false,
|
||||
reason: "not-session-created",
|
||||
});
|
||||
expect(
|
||||
resolveReconcileEligibility({
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
runId: " ",
|
||||
})
|
||||
).toEqual({
|
||||
shouldCheck: false,
|
||||
reason: "missing-run-id",
|
||||
});
|
||||
expect(
|
||||
resolveReconcileEligibility({
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
runId: "run-1",
|
||||
})
|
||||
).toEqual({
|
||||
shouldCheck: true,
|
||||
reason: "ok",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { FleetSidebar } from "@/features/agents/components/FleetSidebar";
|
||||
|
||||
const createAgent = (): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
describe("FleetSidebar new agent action", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders New agent button", () => {
|
||||
render(
|
||||
createElement(FleetSidebar, {
|
||||
agents: [createAgent()],
|
||||
selectedAgentId: "agent-1",
|
||||
filter: "all",
|
||||
onFilterChange: vi.fn(),
|
||||
onSelectAgent: vi.fn(),
|
||||
onCreateAgent: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("fleet-new-agent-button")).toBeInTheDocument();
|
||||
expect(screen.getByText("New agent")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCreateAgent when clicked", () => {
|
||||
const onCreateAgent = vi.fn();
|
||||
render(
|
||||
createElement(FleetSidebar, {
|
||||
agents: [createAgent()],
|
||||
selectedAgentId: "agent-1",
|
||||
filter: "all",
|
||||
onFilterChange: vi.fn(),
|
||||
onSelectAgent: vi.fn(),
|
||||
onCreateAgent,
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("fleet-new-agent-button"));
|
||||
expect(onCreateAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables create button when createDisabled=true", () => {
|
||||
render(
|
||||
createElement(FleetSidebar, {
|
||||
agents: [createAgent()],
|
||||
selectedAgentId: "agent-1",
|
||||
filter: "all",
|
||||
onFilterChange: vi.fn(),
|
||||
onSelectAgent: vi.fn(),
|
||||
onCreateAgent: vi.fn(),
|
||||
createDisabled: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("fleet-new-agent-button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows approvals tab instead of idle tab", () => {
|
||||
render(
|
||||
createElement(FleetSidebar, {
|
||||
agents: [createAgent()],
|
||||
selectedAgentId: "agent-1",
|
||||
filter: "all",
|
||||
onFilterChange: vi.fn(),
|
||||
onSelectAgent: vi.fn(),
|
||||
onCreateAgent: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("fleet-filter-approvals")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("fleet-filter-idle")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows needs approval badge for awaiting agents", () => {
|
||||
render(
|
||||
createElement(FleetSidebar, {
|
||||
agents: [{ ...createAgent(), awaitingUserInput: true }],
|
||||
selectedAgentId: "agent-1",
|
||||
filter: "all",
|
||||
onFilterChange: vi.fn(),
|
||||
onSelectAgent: vi.fn(),
|
||||
onCreateAgent: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const approvalBadge = screen.getByText("Needs approval");
|
||||
expect(approvalBadge).toBeInTheDocument();
|
||||
expect(approvalBadge).toHaveClass("ui-badge-approval");
|
||||
expect(approvalBadge).toHaveAttribute("data-status", "approval");
|
||||
});
|
||||
|
||||
it("renders semantic class and status marker for agent status badge", () => {
|
||||
render(
|
||||
createElement(FleetSidebar, {
|
||||
agents: [{ ...createAgent(), status: "running" }],
|
||||
selectedAgentId: "agent-1",
|
||||
filter: "all",
|
||||
onFilterChange: vi.fn(),
|
||||
onSelectAgent: vi.fn(),
|
||||
onCreateAgent: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const row = screen.getByTestId("fleet-agent-row-agent-1");
|
||||
const statusBadge = within(row).getByText("Running");
|
||||
expect(statusBadge).toHaveAttribute("data-status", "running");
|
||||
expect(statusBadge).toHaveClass("ui-badge-status-running");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { readGatewayAgentFile } from "@/lib/gateway/agentFiles";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createMockClient = (handler: (method: string, params: unknown) => unknown) => {
|
||||
return { call: vi.fn(async (method: string, params: unknown) => handler(method, params)) } as unknown as GatewayClient;
|
||||
};
|
||||
|
||||
describe("gateway agent files helpers", () => {
|
||||
it("returns exists=false when gateway reports missing", async () => {
|
||||
const client = createMockClient((method) => {
|
||||
if (method === "agents.files.get") {
|
||||
return { file: { missing: true } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
await expect(
|
||||
readGatewayAgentFile({ client, agentId: "agent-1", name: "AGENTS.md" })
|
||||
).resolves.toEqual({ exists: false, content: "" });
|
||||
});
|
||||
|
||||
it("returns exists=true and content when gateway returns content", async () => {
|
||||
const client = createMockClient((method) => {
|
||||
if (method === "agents.files.get") {
|
||||
return { file: { missing: false, content: "hello" } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
await expect(
|
||||
readGatewayAgentFile({ client, agentId: "agent-1", name: "AGENTS.md" })
|
||||
).resolves.toEqual({ exists: true, content: "hello" });
|
||||
});
|
||||
|
||||
it("coerces non-string content to empty string", async () => {
|
||||
const client = createMockClient((method) => {
|
||||
if (method === "agents.files.get") {
|
||||
return { file: { missing: false, content: { nope: true } } };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
await expect(
|
||||
readGatewayAgentFile({ client, agentId: "agent-1", name: "AGENTS.md" })
|
||||
).resolves.toEqual({ exists: true, content: "" });
|
||||
});
|
||||
|
||||
it("throws when agentId is empty", async () => {
|
||||
const client = createMockClient(() => ({}));
|
||||
await expect(
|
||||
readGatewayAgentFile({ client, agentId: " ", name: "AGENTS.md" })
|
||||
).rejects.toThrow("agentId is required.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GatewayResponseError } from "@/lib/gateway/GatewayClient";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig";
|
||||
|
||||
describe("updateGatewayAgentOverrides", () => {
|
||||
it("writes additive alsoAllow entries for per-agent tools", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-additive-1",
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "agent-1", tools: { profile: "coding" } }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
const raw = (params as { raw?: string }).raw ?? "";
|
||||
const parsed = JSON.parse(raw) as {
|
||||
agents?: { list?: Array<{ id?: string; tools?: { profile?: string; alsoAllow?: string[]; deny?: string[] } }> };
|
||||
};
|
||||
const entry = parsed.agents?.list?.find((item) => item.id === "agent-1");
|
||||
expect(entry?.tools).toEqual({
|
||||
profile: "coding",
|
||||
alsoAllow: ["group:web", "group:runtime"],
|
||||
deny: ["group:fs"],
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentOverrides({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
overrides: {
|
||||
tools: {
|
||||
profile: "coding",
|
||||
alsoAllow: ["group:web", "group:web", " group:runtime "],
|
||||
deny: ["group:fs", "group:fs"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("drops legacy allow when writing additive alsoAllow", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-additive-2",
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "agent-1", tools: { profile: "coding", allow: ["group:web"] } }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
const raw = (params as { raw?: string }).raw ?? "";
|
||||
const parsed = JSON.parse(raw) as {
|
||||
agents?: {
|
||||
list?: Array<{
|
||||
id?: string;
|
||||
tools?: {
|
||||
profile?: string;
|
||||
allow?: string[];
|
||||
alsoAllow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const entry = parsed.agents?.list?.find((item) => item.id === "agent-1");
|
||||
expect(entry?.tools).toEqual({
|
||||
profile: "coding",
|
||||
alsoAllow: ["group:runtime"],
|
||||
deny: ["group:fs"],
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentOverrides({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
overrides: {
|
||||
tools: {
|
||||
alsoAllow: ["group:runtime"],
|
||||
deny: ["group:fs"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves redacted non-agent fields when writing full config", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-redacted-1",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
xai: {
|
||||
models: [{ id: "grok", maxTokens: "__OPENCLAW_REDACTED__" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "agent-1" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
const raw = (params as { raw?: string }).raw ?? "";
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
expect(parsed.models).toEqual({
|
||||
providers: {
|
||||
xai: {
|
||||
models: [{ id: "grok", maxTokens: "__OPENCLAW_REDACTED__" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.agents).toEqual({
|
||||
list: [
|
||||
{
|
||||
id: "agent-1",
|
||||
tools: {
|
||||
profile: "coding",
|
||||
alsoAllow: ["group:runtime"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentOverrides({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
overrides: {
|
||||
tools: {
|
||||
profile: "coding",
|
||||
alsoAllow: ["group:runtime"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves concurrent config changes when config.set retries after stale hash", async () => {
|
||||
let configGetCount = 0;
|
||||
let configSetCount = 0;
|
||||
const callOrder: string[] = [];
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
callOrder.push(method);
|
||||
if (method === "config.get") {
|
||||
configGetCount += 1;
|
||||
if (configGetCount === 1) {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-retry-1",
|
||||
config: {
|
||||
gateway: {
|
||||
reload: {
|
||||
mode: "hybrid",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "agent-1" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-retry-2",
|
||||
config: {
|
||||
gateway: {
|
||||
reload: {
|
||||
mode: "off",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "agent-1" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
configSetCount += 1;
|
||||
const payload = params as { raw?: string; baseHash?: string };
|
||||
const parsed = JSON.parse(payload.raw ?? "") as {
|
||||
gateway?: { reload?: { mode?: string } };
|
||||
agents?: {
|
||||
list?: Array<{
|
||||
id?: string;
|
||||
tools?: {
|
||||
profile?: string;
|
||||
alsoAllow?: string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
if (configSetCount === 1) {
|
||||
expect(payload.baseHash).toBe("cfg-retry-1");
|
||||
expect(parsed.gateway?.reload?.mode).toBe("hybrid");
|
||||
throw new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "config changed since last load; re-run config.get and retry",
|
||||
});
|
||||
}
|
||||
expect(payload.baseHash).toBe("cfg-retry-2");
|
||||
expect(parsed.gateway?.reload?.mode).toBe("off");
|
||||
expect(parsed.agents?.list?.find((entry) => entry.id === "agent-1")?.tools).toEqual({
|
||||
profile: "coding",
|
||||
alsoAllow: ["group:web"],
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentOverrides({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
overrides: {
|
||||
tools: {
|
||||
profile: "coding",
|
||||
alsoAllow: ["group:web"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(configGetCount).toBe(2);
|
||||
expect(configSetCount).toBe(2);
|
||||
expect(callOrder).toEqual(["config.get", "config.set", "config.get", "config.set"]);
|
||||
});
|
||||
|
||||
it("omits baseHash when config does not exist yet", async () => {
|
||||
let configSetCount = 0;
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: false,
|
||||
config: {
|
||||
agents: {
|
||||
list: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
configSetCount += 1;
|
||||
const payload = params as { raw?: string; baseHash?: string };
|
||||
const parsed = JSON.parse(payload.raw ?? "") as {
|
||||
agents?: {
|
||||
list?: Array<{
|
||||
id?: string;
|
||||
tools?: {
|
||||
alsoAllow?: string[];
|
||||
deny?: string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
expect(payload.baseHash).toBeUndefined();
|
||||
expect(parsed.agents?.list?.find((entry) => entry.id === "agent-1")?.tools).toEqual({
|
||||
alsoAllow: ["group:runtime"],
|
||||
deny: ["group:fs"],
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentOverrides({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
overrides: {
|
||||
tools: {
|
||||
alsoAllow: ["group:runtime"],
|
||||
deny: ["group:fs"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(configSetCount).toBe(1);
|
||||
});
|
||||
|
||||
it("fails after a single stale-hash retry attempt", async () => {
|
||||
let configGetCount = 0;
|
||||
let configSetCount = 0;
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
configGetCount += 1;
|
||||
return {
|
||||
exists: true,
|
||||
hash: `cfg-stale-${configGetCount}`,
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "agent-1" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
configSetCount += 1;
|
||||
throw new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "config changed since last load; re-run config.get and retry",
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(
|
||||
updateGatewayAgentOverrides({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
overrides: {
|
||||
tools: {
|
||||
alsoAllow: ["group:runtime"],
|
||||
},
|
||||
},
|
||||
})
|
||||
).rejects.toBeInstanceOf(GatewayResponseError);
|
||||
|
||||
expect(configGetCount).toBe(2);
|
||||
expect(configSetCount).toBe(2);
|
||||
});
|
||||
|
||||
it("fails fast when both allow and alsoAllow are provided", async () => {
|
||||
const client = {
|
||||
call: vi.fn(),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(
|
||||
updateGatewayAgentOverrides({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
overrides: {
|
||||
tools: {
|
||||
allow: ["group:runtime"],
|
||||
alsoAllow: ["group:web"],
|
||||
},
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("Agent tools overrides cannot set both allow and alsoAllow.");
|
||||
|
||||
expect(client.call).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GatewayResponseError } from "@/lib/gateway/GatewayClient";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
readGatewayAgentSkillsAllowlist,
|
||||
updateGatewayAgentSkillsAllowlist,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
|
||||
describe("gateway agent skills allowlist", () => {
|
||||
it("reads and normalizes existing skills allowlist", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-read-1",
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "agent-1", skills: [" github ", "slack", "github"] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(
|
||||
readGatewayAgentSkillsAllowlist({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
})
|
||||
).resolves.toEqual(["github", "slack"]);
|
||||
});
|
||||
|
||||
it("writes mode all by removing the skills key", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-all-1",
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "agent-1", skills: ["github", "slack"] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
const payload = params as { raw?: string; baseHash?: string };
|
||||
const parsed = JSON.parse(payload.raw ?? "") as {
|
||||
agents?: { list?: Array<{ id?: string; skills?: string[] }> };
|
||||
};
|
||||
expect(payload.baseHash).toBe("cfg-all-1");
|
||||
const entry = parsed.agents?.list?.find((item) => item.id === "agent-1");
|
||||
expect(entry).toEqual({ id: "agent-1" });
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentSkillsAllowlist({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
mode: "all",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes mode none and mode allowlist with normalized names", async () => {
|
||||
const calls: Array<{ method: string; params?: unknown }> = [];
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
calls.push({ method, params });
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: `cfg-${calls.length}`,
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "agent-1" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentSkillsAllowlist({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
mode: "none",
|
||||
});
|
||||
await updateGatewayAgentSkillsAllowlist({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
mode: "allowlist",
|
||||
skillNames: [" slack ", "github", "slack"],
|
||||
});
|
||||
|
||||
const nonePayload = calls.find(
|
||||
(entry) => entry.method === "config.set"
|
||||
)?.params as { raw?: string };
|
||||
const setCalls = calls.filter((entry) => entry.method === "config.set");
|
||||
const allowPayload = setCalls[1]?.params as { raw?: string };
|
||||
|
||||
expect(
|
||||
(JSON.parse(nonePayload.raw ?? "") as { agents?: { list?: Array<{ id?: string; skills?: string[] }> } })
|
||||
.agents?.list?.find((entry) => entry.id === "agent-1")
|
||||
).toEqual({ id: "agent-1", skills: [] });
|
||||
expect(
|
||||
(JSON.parse(allowPayload.raw ?? "") as {
|
||||
agents?: { list?: Array<{ id?: string; skills?: string[] }> };
|
||||
}).agents?.list?.find((entry) => entry.id === "agent-1")
|
||||
).toEqual({ id: "agent-1", skills: ["github", "slack"] });
|
||||
});
|
||||
|
||||
it("retries once after stale hash and preserves concurrent config changes", async () => {
|
||||
let getCount = 0;
|
||||
let setCount = 0;
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
getCount += 1;
|
||||
if (getCount === 1) {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-retry-1",
|
||||
config: {
|
||||
gateway: { reload: { mode: "hybrid" } },
|
||||
agents: { list: [{ id: "agent-1" }] },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-retry-2",
|
||||
config: {
|
||||
gateway: { reload: { mode: "off" } },
|
||||
agents: { list: [{ id: "agent-1" }] },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
setCount += 1;
|
||||
const payload = params as { raw?: string; baseHash?: string };
|
||||
const parsed = JSON.parse(payload.raw ?? "") as {
|
||||
gateway?: { reload?: { mode?: string } };
|
||||
agents?: { list?: Array<{ id?: string; skills?: string[] }> };
|
||||
};
|
||||
if (setCount === 1) {
|
||||
expect(payload.baseHash).toBe("cfg-retry-1");
|
||||
expect(parsed.gateway?.reload?.mode).toBe("hybrid");
|
||||
throw new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "config changed since last load; re-run config.get and retry",
|
||||
});
|
||||
}
|
||||
expect(payload.baseHash).toBe("cfg-retry-2");
|
||||
expect(parsed.gateway?.reload?.mode).toBe("off");
|
||||
expect(parsed.agents?.list?.find((entry) => entry.id === "agent-1")).toEqual({
|
||||
id: "agent-1",
|
||||
skills: ["github"],
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentSkillsAllowlist({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
mode: "allowlist",
|
||||
skillNames: ["github"],
|
||||
});
|
||||
|
||||
expect(getCount).toBe(2);
|
||||
expect(setCount).toBe(2);
|
||||
});
|
||||
|
||||
it("fails fast when mode allowlist omits skill names", async () => {
|
||||
const client = {
|
||||
call: vi.fn(),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(
|
||||
updateGatewayAgentSkillsAllowlist({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
mode: "allowlist",
|
||||
})
|
||||
).rejects.toThrow("Skills allowlist is required when mode is allowlist.");
|
||||
expect(client.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips config.set when mode all is already implied", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-skip-write",
|
||||
config: { agents: { list: [{ id: "other-agent" }] } },
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
throw new Error("config.set should not be called for no-op mode all");
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentSkillsAllowlist({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
mode: "all",
|
||||
});
|
||||
|
||||
expect(client.call).toHaveBeenCalledTimes(1);
|
||||
expect(client.call).toHaveBeenCalledWith("config.get", {});
|
||||
});
|
||||
|
||||
it("skips config.set when mode all has no explicit skills on existing agent entry", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-skip-write-existing",
|
||||
config: { agents: { list: [{ id: "agent-1", name: "Agent One" }] } },
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
throw new Error("config.set should not be called for no-op mode all");
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentSkillsAllowlist({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
mode: "all",
|
||||
});
|
||||
|
||||
expect(client.call).toHaveBeenCalledTimes(1);
|
||||
expect(client.call).toHaveBeenCalledWith("config.get", {});
|
||||
});
|
||||
|
||||
it("skips config.set when allowlist is unchanged after normalization", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "cfg-skip-write-allowlist",
|
||||
config: { agents: { list: [{ id: "agent-1", skills: [" github ", "slack"] }] } },
|
||||
};
|
||||
}
|
||||
if (method === "config.set") {
|
||||
throw new Error("config.set should not be called for unchanged allowlist");
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await updateGatewayAgentSkillsAllowlist({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
mode: "allowlist",
|
||||
skillNames: ["slack", "github", "github"],
|
||||
});
|
||||
|
||||
expect(client.call).toHaveBeenCalledTimes(1);
|
||||
expect(client.call).toHaveBeenCalledWith("config.get", {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GatewayBrowserClient } from "@/lib/gateway/openclaw/GatewayBrowserClient";
|
||||
|
||||
const UUID_V4_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
class MockWebSocket {
|
||||
static OPEN = 1;
|
||||
static CLOSED = 3;
|
||||
static instances: MockWebSocket[] = [];
|
||||
static sent: string[] = [];
|
||||
static closes: Array<{ code: number; reason: string }> = [];
|
||||
|
||||
readyState = MockWebSocket.OPEN;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(public url: string) {
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
MockWebSocket.sent.push(String(data));
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
MockWebSocket.closes.push({ code: code ?? 1000, reason: reason ?? "" });
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.({ code: code ?? 1000, reason: reason ?? "" } as CloseEvent);
|
||||
}
|
||||
}
|
||||
|
||||
describe("GatewayBrowserClient", () => {
|
||||
const originalWebSocket = globalThis.WebSocket;
|
||||
const originalSubtle = globalThis.crypto?.subtle;
|
||||
|
||||
beforeEach(() => {
|
||||
MockWebSocket.instances = [];
|
||||
MockWebSocket.sent = [];
|
||||
MockWebSocket.closes = [];
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
if (globalThis.crypto) {
|
||||
Object.defineProperty(globalThis.crypto, "subtle", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
if (globalThis.crypto) {
|
||||
Object.defineProperty(globalThis.crypto, "subtle", {
|
||||
value: originalSubtle,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("sends connect when connect.challenge arrives", async () => {
|
||||
const client = new GatewayBrowserClient({ url: "ws://example.com" });
|
||||
client.start();
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
if (!ws) {
|
||||
throw new Error("WebSocket not created");
|
||||
}
|
||||
|
||||
ws.onopen?.();
|
||||
|
||||
expect(MockWebSocket.sent).toHaveLength(0);
|
||||
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "abc" },
|
||||
}),
|
||||
} as MessageEvent);
|
||||
|
||||
await vi.runAllTicks();
|
||||
|
||||
expect(MockWebSocket.sent).toHaveLength(1);
|
||||
const frame = JSON.parse(MockWebSocket.sent[0] ?? "{}");
|
||||
expect(frame.type).toBe("req");
|
||||
expect(frame.method).toBe("connect");
|
||||
expect(typeof frame.id).toBe("string");
|
||||
expect(frame.id).toMatch(UUID_V4_RE);
|
||||
expect(frame.params?.client?.id).toBe("openclaw-control-ui");
|
||||
});
|
||||
|
||||
it("truncates connect-failed close reason to websocket limit", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const client = new GatewayBrowserClient({ url: "ws://example.com", token: "secret" });
|
||||
client.start();
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
if (!ws) {
|
||||
throw new Error("WebSocket not created");
|
||||
}
|
||||
|
||||
ws.onopen?.();
|
||||
vi.runAllTimers();
|
||||
|
||||
const connectFrame = JSON.parse(MockWebSocket.sent[0] ?? "{}");
|
||||
const connectId = String(connectFrame.id ?? "");
|
||||
expect(connectId).toMatch(UUID_V4_RE);
|
||||
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "res",
|
||||
id: connectId,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: `invalid config ${"x".repeat(260)}`,
|
||||
},
|
||||
}),
|
||||
} as MessageEvent);
|
||||
|
||||
await vi.runAllTicks();
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
const lastClose = MockWebSocket.closes.at(-1);
|
||||
expect(lastClose?.code).toBe(4008);
|
||||
expect(lastClose?.reason.startsWith("connect failed: INVALID_REQUEST")).toBe(true);
|
||||
expect(new TextEncoder().encode(lastClose?.reason ?? "").byteLength).toBeLessThanOrEqual(123);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
import { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
let lastOpts: Record<string, unknown> | null = null;
|
||||
|
||||
vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => {
|
||||
class GatewayBrowserClient {
|
||||
connected = false;
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
lastOpts = opts;
|
||||
}
|
||||
start() {}
|
||||
stop() {}
|
||||
request() {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
}
|
||||
|
||||
return { GatewayBrowserClient };
|
||||
});
|
||||
|
||||
describe("GatewayClient connect failures", () => {
|
||||
it("rejects connect with GatewayResponseError when close reason encodes connect failed", async () => {
|
||||
const client = new GatewayClient();
|
||||
|
||||
const connectPromise = client.connect({ gatewayUrl: "ws://example.invalid" });
|
||||
|
||||
if (!lastOpts) {
|
||||
throw new Error("Expected GatewayBrowserClient to be constructed");
|
||||
}
|
||||
|
||||
const onClose = lastOpts.onClose as ((info: { code: number; reason: string }) => void) | undefined;
|
||||
if (!onClose) {
|
||||
throw new Error("Expected onClose callback");
|
||||
}
|
||||
|
||||
onClose({
|
||||
code: 4008,
|
||||
reason:
|
||||
"connect failed: studio.gateway_token_missing Upstream gateway token is not configured on the Studio host.",
|
||||
});
|
||||
|
||||
await expect(connectPromise).rejects.toBeInstanceOf(GatewayResponseError);
|
||||
await expect(connectPromise).rejects.toMatchObject({
|
||||
name: "GatewayResponseError",
|
||||
code: "studio.gateway_token_missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
let lastOpts: Record<string, unknown> | null = null;
|
||||
|
||||
vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => {
|
||||
class GatewayBrowserClient {
|
||||
connected = true;
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
lastOpts = opts;
|
||||
}
|
||||
start() {}
|
||||
stop() {}
|
||||
request() {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
}
|
||||
return { GatewayBrowserClient };
|
||||
});
|
||||
|
||||
describe("GatewayClient onGap", () => {
|
||||
it("forwards gateway seq gaps to subscribers", async () => {
|
||||
const client = new GatewayClient();
|
||||
const onGap = vi.fn();
|
||||
client.onGap(onGap);
|
||||
|
||||
const connectPromise = client.connect({ gatewayUrl: "ws://example.invalid" });
|
||||
if (!lastOpts) throw new Error("Expected GatewayBrowserClient to be constructed");
|
||||
|
||||
const onHello = lastOpts.onHello as ((hello: unknown) => void) | undefined;
|
||||
if (!onHello) throw new Error("Expected onHello callback");
|
||||
onHello({} as never);
|
||||
|
||||
await connectPromise;
|
||||
|
||||
const gapCb = lastOpts.onGap as ((info: { expected: number; received: number }) => void) | undefined;
|
||||
if (!gapCb) throw new Error("Expected onGap callback");
|
||||
gapCb({ expected: 10, received: 13 });
|
||||
|
||||
expect(onGap).toHaveBeenCalledTimes(1);
|
||||
expect(onGap).toHaveBeenCalledWith({ expected: 10, received: 13 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
type MockClientOptions = {
|
||||
token?: unknown;
|
||||
onHello?: (hello: unknown) => void;
|
||||
onClose?: (info: { code: number; reason: string }) => void;
|
||||
};
|
||||
|
||||
type MockInstance = {
|
||||
opts: MockClientOptions;
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
let instances: MockInstance[] = [];
|
||||
|
||||
vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => {
|
||||
class GatewayBrowserClient {
|
||||
connected = false;
|
||||
private index: number;
|
||||
|
||||
constructor(opts: MockClientOptions) {
|
||||
this.index = instances.length;
|
||||
instances.push({ opts, stopped: false });
|
||||
}
|
||||
|
||||
start() {
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.connected = false;
|
||||
instances[this.index]!.stopped = true;
|
||||
}
|
||||
|
||||
request() {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
}
|
||||
|
||||
return { GatewayBrowserClient };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
instances = [];
|
||||
});
|
||||
|
||||
describe("GatewayClient reconnect recovery", () => {
|
||||
it("allows a fresh connect after unexpected close", async () => {
|
||||
const client = new GatewayClient();
|
||||
const statuses: string[] = [];
|
||||
client.onStatus((status) => statuses.push(status));
|
||||
|
||||
const firstConnect = client.connect({
|
||||
gatewayUrl: "ws://example.invalid",
|
||||
token: "old-token",
|
||||
});
|
||||
const first = instances[0];
|
||||
if (!first) throw new Error("Expected first GatewayBrowserClient instance");
|
||||
|
||||
const onHelloFirst = first.opts.onHello;
|
||||
const onCloseFirst = first.opts.onClose;
|
||||
if (!onHelloFirst || !onCloseFirst) {
|
||||
throw new Error("Expected first instance callbacks");
|
||||
}
|
||||
|
||||
onHelloFirst({});
|
||||
await expect(firstConnect).resolves.toBeUndefined();
|
||||
|
||||
onCloseFirst({ code: 1012, reason: "upstream closed" });
|
||||
|
||||
expect(first.stopped).toBe(true);
|
||||
expect(statuses.at(-1)).toBe("disconnected");
|
||||
|
||||
const secondConnect = client.connect({
|
||||
gatewayUrl: "ws://example.invalid",
|
||||
token: "new-token",
|
||||
});
|
||||
const second = instances[1];
|
||||
if (!second) throw new Error("Expected second GatewayBrowserClient instance");
|
||||
|
||||
expect(second.opts.token).toBe("new-token");
|
||||
|
||||
const onHelloSecond = second.opts.onHello;
|
||||
if (!onHelloSecond) {
|
||||
throw new Error("Expected second instance onHello callback");
|
||||
}
|
||||
|
||||
onHelloSecond({});
|
||||
await expect(secondConnect).resolves.toBeUndefined();
|
||||
|
||||
expect(statuses.at(-1)).toBe("connected");
|
||||
});
|
||||
|
||||
it("ignores stale onClose callbacks from old instances", async () => {
|
||||
const client = new GatewayClient();
|
||||
const statuses: string[] = [];
|
||||
client.onStatus((status) => statuses.push(status));
|
||||
|
||||
const firstConnect = client.connect({ gatewayUrl: "ws://example.invalid" });
|
||||
const first = instances[0];
|
||||
if (!first) throw new Error("Expected first GatewayBrowserClient instance");
|
||||
|
||||
const onHelloFirst = first.opts.onHello;
|
||||
const onCloseFirst = first.opts.onClose;
|
||||
if (!onHelloFirst || !onCloseFirst) {
|
||||
throw new Error("Expected first instance callbacks");
|
||||
}
|
||||
|
||||
onHelloFirst({});
|
||||
await firstConnect;
|
||||
|
||||
onCloseFirst({ code: 1012, reason: "upstream closed" });
|
||||
|
||||
const secondConnect = client.connect({ gatewayUrl: "ws://example.invalid" });
|
||||
const second = instances[1];
|
||||
if (!second) throw new Error("Expected second GatewayBrowserClient instance");
|
||||
|
||||
const onHelloSecond = second.opts.onHello;
|
||||
if (!onHelloSecond) {
|
||||
throw new Error("Expected second instance onHello callback");
|
||||
}
|
||||
|
||||
onHelloSecond({});
|
||||
await secondConnect;
|
||||
|
||||
const statusCountBeforeStaleClose = statuses.length;
|
||||
onCloseFirst({ code: 1012, reason: "late stale close" });
|
||||
|
||||
expect(statuses.length).toBe(statusCountBeforeStaleClose);
|
||||
expect(statuses.at(-1)).toBe("connected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createGatewayAgent,
|
||||
deleteGatewayAgent,
|
||||
renameGatewayAgent,
|
||||
resolveHeartbeatSettings,
|
||||
removeGatewayHeartbeatOverride,
|
||||
updateGatewayHeartbeat,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
describe("gateway agent helpers", () => {
|
||||
it("creates a new agent via agents.create and derives workspace from the config path", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-create-1",
|
||||
path: "/Users/test/.openclaw/openclaw.json",
|
||||
config: { agents: { list: [{ id: "agent-1", name: "Agent One" }] } },
|
||||
};
|
||||
}
|
||||
if (method === "agents.create") {
|
||||
expect(params).toEqual({
|
||||
name: "New Agent",
|
||||
workspace: "/Users/test/.openclaw/workspace-new-agent",
|
||||
});
|
||||
return { ok: true, agentId: "new-agent", name: "New Agent", workspace: "ignored" };
|
||||
}
|
||||
throw new Error("unexpected method");
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
const entry = await createGatewayAgent({ client, name: "New Agent" });
|
||||
expect(entry.id).toBe("new-agent");
|
||||
expect(entry.name).toBe("New Agent");
|
||||
});
|
||||
|
||||
it("slugifies workspace names from agent names", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-create-slug-1",
|
||||
path: "/Users/test/.openclaw/openclaw.json",
|
||||
config: { agents: { list: [] } },
|
||||
};
|
||||
}
|
||||
if (method === "agents.create") {
|
||||
expect(params).toEqual({
|
||||
name: "My Project",
|
||||
workspace: "/Users/test/.openclaw/workspace-my-project",
|
||||
});
|
||||
return { ok: true, agentId: "my-project", name: "My Project", workspace: "ignored" };
|
||||
}
|
||||
throw new Error("unexpected method");
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
const entry = await createGatewayAgent({ client, name: "My Project" });
|
||||
expect(entry.id).toBe("my-project");
|
||||
expect(entry.name).toBe("My Project");
|
||||
});
|
||||
|
||||
it("returns no-op on deleting a missing agent", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "agents.delete") {
|
||||
throw new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: 'agent "agent-2" not found',
|
||||
});
|
||||
}
|
||||
throw new Error("unexpected method");
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
const result = await deleteGatewayAgent({
|
||||
client,
|
||||
agentId: "agent-2",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ removed: false, removedBindings: 0 });
|
||||
expect(client.call).toHaveBeenCalledTimes(1);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe("agents.delete");
|
||||
});
|
||||
|
||||
it("fails fast on empty create name", async () => {
|
||||
const client = {
|
||||
call: vi.fn(),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(createGatewayAgent({ client, name: " " })).rejects.toThrow(
|
||||
"Agent name is required."
|
||||
);
|
||||
expect(client.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails when create name produces an empty id slug", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-create-empty-slug-1",
|
||||
path: "/Users/test/.openclaw/openclaw.json",
|
||||
config: {
|
||||
agents: { list: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("unexpected method");
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await expect(createGatewayAgent({ client, name: "!!!" })).rejects.toThrow(
|
||||
"Name produced an empty folder name."
|
||||
);
|
||||
expect(client.call).toHaveBeenCalledTimes(1);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toBe("config.get");
|
||||
});
|
||||
|
||||
it("returns current settings when no heartbeat override exists to remove", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-remove-1",
|
||||
path: "/Users/test/.openclaw/openclaw.json",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "10m",
|
||||
target: "last",
|
||||
includeReasoning: false,
|
||||
ackMaxChars: 300,
|
||||
},
|
||||
},
|
||||
list: [{ id: "agent-1", name: "Agent One" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error("unexpected method");
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
const result = await removeGatewayHeartbeatOverride({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
heartbeat: {
|
||||
every: "10m",
|
||||
target: "last",
|
||||
includeReasoning: false,
|
||||
ackMaxChars: 300,
|
||||
activeHours: null,
|
||||
},
|
||||
hasOverride: false,
|
||||
});
|
||||
expect(client.call).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renames an agent via agents.update", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "agents.update") {
|
||||
expect(params).toEqual({ agentId: "agent-1", name: "New Name" });
|
||||
return { ok: true, agentId: "agent-1" };
|
||||
}
|
||||
throw new Error("unexpected method");
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await renameGatewayAgent({ client, agentId: "agent-1", name: "New Name" });
|
||||
});
|
||||
|
||||
it("resolves heartbeat defaults and overrides", () => {
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "2h",
|
||||
target: "last",
|
||||
includeReasoning: false,
|
||||
ackMaxChars: 200,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "agent-1",
|
||||
heartbeat: { every: "30m", target: "none", includeReasoning: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = resolveHeartbeatSettings(config, "agent-1");
|
||||
expect(result.heartbeat.every).toBe("30m");
|
||||
expect(result.heartbeat.target).toBe("none");
|
||||
expect(result.heartbeat.includeReasoning).toBe(true);
|
||||
expect(result.hasOverride).toBe(true);
|
||||
});
|
||||
|
||||
it("updates heartbeat overrides via config.patch", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-2",
|
||||
path: "/Users/test/.openclaw/openclaw.json",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "1h",
|
||||
target: "last",
|
||||
includeReasoning: false,
|
||||
ackMaxChars: 300,
|
||||
},
|
||||
},
|
||||
list: [{ id: "agent-1" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "config.patch") {
|
||||
const raw = (params as { raw?: string }).raw ?? "";
|
||||
const parsed = JSON.parse(raw) as {
|
||||
agents?: { list?: Array<{ id?: string; heartbeat?: unknown }> };
|
||||
};
|
||||
const entry = parsed.agents?.list?.find((item) => item.id === "agent-1");
|
||||
expect(entry && typeof entry === "object").toBe(true);
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error("unexpected method");
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
const result = await updateGatewayHeartbeat({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
payload: {
|
||||
override: true,
|
||||
heartbeat: {
|
||||
every: "15m",
|
||||
target: "none",
|
||||
includeReasoning: true,
|
||||
ackMaxChars: 120,
|
||||
activeHours: { start: "08:00", end: "18:00" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.heartbeat.every).toBe("15m");
|
||||
expect(result.heartbeat.target).toBe("none");
|
||||
expect(result.heartbeat.includeReasoning).toBe(true);
|
||||
expect(result.hasOverride).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveGatewayConfigRecord,
|
||||
resolveGatewayModelsSyncIntent,
|
||||
resolveSandboxRepairAgentIds,
|
||||
resolveSandboxRepairIntent,
|
||||
shouldRefreshGatewayConfigForSettingsRoute,
|
||||
} from "@/features/agents/operations/gatewayConfigSyncWorkflow";
|
||||
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
||||
|
||||
describe("gatewayConfigSyncWorkflow", () => {
|
||||
it("resolves config record only when snapshot config is an object", () => {
|
||||
expect(resolveGatewayConfigRecord(null)).toBeNull();
|
||||
expect(resolveGatewayConfigRecord({ config: [] } as unknown as GatewayModelPolicySnapshot)).toBeNull();
|
||||
|
||||
const snapshot: GatewayModelPolicySnapshot = {
|
||||
config: {
|
||||
agents: {
|
||||
list: [{ id: "agent-1" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveGatewayConfigRecord(snapshot)).toEqual(snapshot.config);
|
||||
});
|
||||
|
||||
it("finds sandbox repair candidates with sandbox all mode and empty sandbox allowlist", () => {
|
||||
const snapshot = {
|
||||
config: {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "agent-broken",
|
||||
sandbox: { mode: "all" },
|
||||
tools: { sandbox: { tools: { allow: [] } } },
|
||||
},
|
||||
{
|
||||
id: "agent-ok-mode",
|
||||
sandbox: { mode: "off" },
|
||||
tools: { sandbox: { tools: { allow: [] } } },
|
||||
},
|
||||
{
|
||||
id: "agent-ok-allow",
|
||||
sandbox: { mode: "all" },
|
||||
tools: { sandbox: { tools: { allow: ["*"] } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as GatewayModelPolicySnapshot;
|
||||
|
||||
expect(resolveSandboxRepairAgentIds(snapshot)).toEqual(["agent-broken"]);
|
||||
});
|
||||
|
||||
it("builds sandbox repair intent from status, attempt guard, and candidate list", () => {
|
||||
const snapshot = {
|
||||
config: {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "agent-broken",
|
||||
sandbox: { mode: "all" },
|
||||
tools: { sandbox: { tools: { allow: [] } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as GatewayModelPolicySnapshot;
|
||||
|
||||
expect(
|
||||
resolveSandboxRepairIntent({
|
||||
status: "disconnected",
|
||||
attempted: false,
|
||||
snapshot,
|
||||
})
|
||||
).toEqual({ kind: "skip", reason: "not-connected" });
|
||||
|
||||
expect(
|
||||
resolveSandboxRepairIntent({
|
||||
status: "connected",
|
||||
attempted: true,
|
||||
snapshot,
|
||||
})
|
||||
).toEqual({ kind: "skip", reason: "already-attempted" });
|
||||
|
||||
expect(
|
||||
resolveSandboxRepairIntent({
|
||||
status: "connected",
|
||||
attempted: false,
|
||||
snapshot,
|
||||
})
|
||||
).toEqual({ kind: "repair", agentIds: ["agent-broken"] });
|
||||
});
|
||||
|
||||
it("gates settings-route refresh on route flag, inspect agent id, and connected status", () => {
|
||||
expect(
|
||||
shouldRefreshGatewayConfigForSettingsRoute({
|
||||
status: "connected",
|
||||
settingsRouteActive: false,
|
||||
inspectSidebarAgentId: "agent-1",
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldRefreshGatewayConfigForSettingsRoute({
|
||||
status: "connected",
|
||||
settingsRouteActive: true,
|
||||
inspectSidebarAgentId: null,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldRefreshGatewayConfigForSettingsRoute({
|
||||
status: "connecting",
|
||||
settingsRouteActive: true,
|
||||
inspectSidebarAgentId: "agent-1",
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldRefreshGatewayConfigForSettingsRoute({
|
||||
status: "connected",
|
||||
settingsRouteActive: true,
|
||||
inspectSidebarAgentId: "agent-1",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns model sync load intent only when connected", () => {
|
||||
expect(resolveGatewayModelsSyncIntent({ status: "connected" })).toEqual({ kind: "load" });
|
||||
expect(resolveGatewayModelsSyncIntent({ status: "connecting" })).toEqual({ kind: "clear" });
|
||||
expect(resolveGatewayModelsSyncIntent({ status: "disconnected" })).toEqual({ kind: "clear" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveGatewayAutoRetryDelayMs } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
describe("resolveGatewayAutoRetryDelayMs", () => {
|
||||
it("does not retry when upstream gateway url is missing on Studio host", () => {
|
||||
const delay = resolveGatewayAutoRetryDelayMs({
|
||||
status: "disconnected",
|
||||
didAutoConnect: true,
|
||||
hasConnectedOnce: true,
|
||||
wasManualDisconnect: false,
|
||||
gatewayUrl: "wss://remote.example",
|
||||
errorMessage: "Gateway error (studio.gateway_url_missing): Upstream gateway URL is missing.",
|
||||
connectErrorCode: "studio.gateway_url_missing",
|
||||
attempt: 0,
|
||||
});
|
||||
|
||||
expect(delay).toBeNull();
|
||||
});
|
||||
|
||||
it("retries for non-auth connect failures", () => {
|
||||
const delay = resolveGatewayAutoRetryDelayMs({
|
||||
status: "disconnected",
|
||||
didAutoConnect: true,
|
||||
hasConnectedOnce: true,
|
||||
wasManualDisconnect: false,
|
||||
gatewayUrl: "wss://remote.example",
|
||||
errorMessage:
|
||||
"Gateway error (studio.upstream_error): Failed to connect to upstream gateway WebSocket.",
|
||||
connectErrorCode: "studio.upstream_error",
|
||||
attempt: 0,
|
||||
});
|
||||
|
||||
expect(delay).toBeTypeOf("number");
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
import { resolveGatewayEventIngressDecision } from "@/features/agents/state/gatewayEventIngressWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
...(overrides ?? {}),
|
||||
});
|
||||
|
||||
describe("gatewayEventIngressWorkflow", () => {
|
||||
it("returns no cron decision for non-cron events", () => {
|
||||
const event: EventFrame = { type: "event", event: "heartbeat", payload: {} };
|
||||
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(decision.cronDedupeKeyToRecord).toBeNull();
|
||||
expect(decision.cronTranscriptIntent).toBeNull();
|
||||
expect(decision.approvalEffects).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores malformed cron payload variants", () => {
|
||||
const malformedEvents: EventFrame[] = [
|
||||
{ type: "event", event: "cron", payload: null },
|
||||
{ type: "event", event: "cron", payload: "bad" },
|
||||
{ type: "event", event: "cron", payload: { action: "started" } },
|
||||
{ type: "event", event: "cron", payload: { action: "finished", sessionKey: "" } },
|
||||
{
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: { action: "finished", sessionKey: "invalid", jobId: "job-1" },
|
||||
},
|
||||
{
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: { action: "finished", sessionKey: "agent:agent-1:main", jobId: "" },
|
||||
},
|
||||
];
|
||||
|
||||
for (const event of malformedEvents) {
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
expect(decision.cronDedupeKeyToRecord).toBeNull();
|
||||
expect(decision.cronTranscriptIntent).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns dedupe and transcript intent for valid finished cron event", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
jobId: "job-1",
|
||||
sessionId: "session-1",
|
||||
runAtMs: 123,
|
||||
status: "ok",
|
||||
summary: "cron summary",
|
||||
},
|
||||
};
|
||||
|
||||
const seen = new Set<string>();
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent({ sessionKey: "agent:agent-1:studio:test-session" })],
|
||||
seenCronDedupeKeys: seen,
|
||||
nowMs: 999,
|
||||
});
|
||||
|
||||
expect(seen.size).toBe(0);
|
||||
expect(decision.cronDedupeKeyToRecord).toBe("cron:job-1:session-1");
|
||||
expect(decision.cronTranscriptIntent).toEqual({
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
dedupeKey: "cron:job-1:session-1",
|
||||
line: "Cron finished (ok): job-1\n\ncron summary",
|
||||
timestampMs: 123,
|
||||
activityAtMs: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns dedupe-only decision for unknown-agent finished cron", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:missing:main",
|
||||
jobId: "job-2",
|
||||
runAtMs: 456,
|
||||
},
|
||||
};
|
||||
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(decision.cronDedupeKeyToRecord).toBe("cron:job-2:456");
|
||||
expect(decision.cronTranscriptIntent).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses cron decision for duplicate dedupe key", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
jobId: "job-3",
|
||||
runAtMs: 777,
|
||||
},
|
||||
};
|
||||
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
seenCronDedupeKeys: new Set(["cron:job-3:777"]),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(decision.cronDedupeKeyToRecord).toBeNull();
|
||||
expect(decision.cronTranscriptIntent).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to nowMs and no-output body when runAtMs/summary/error are missing", () => {
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "cron",
|
||||
payload: {
|
||||
action: "finished",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
jobId: "job-4",
|
||||
},
|
||||
};
|
||||
|
||||
const decision = resolveGatewayEventIngressDecision({
|
||||
event,
|
||||
agents: [createAgent()],
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 4321,
|
||||
});
|
||||
|
||||
expect(decision.cronDedupeKeyToRecord).toBe("cron:job-4:none");
|
||||
expect(decision.cronTranscriptIntent).toEqual({
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
dedupeKey: "cron:job-4:none",
|
||||
line: "Cron finished (unknown): job-4\n\n(no output)",
|
||||
timestampMs: 4321,
|
||||
activityAtMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates approval event effects unchanged", () => {
|
||||
const agents = [createAgent()];
|
||||
const requestedEvent: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.requested",
|
||||
payload: {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "npm test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
agentId: "agent-1",
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
},
|
||||
createdAtMs: 100,
|
||||
expiresAtMs: 200,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedRequested = resolveExecApprovalEventEffects({
|
||||
event: requestedEvent,
|
||||
agents,
|
||||
});
|
||||
const requestedDecision = resolveGatewayEventIngressDecision({
|
||||
event: requestedEvent,
|
||||
agents,
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(requestedDecision.approvalEffects).toEqual(expectedRequested);
|
||||
expect(requestedDecision.approvalEffects?.markActivityAgentIds).toEqual(["agent-1"]);
|
||||
|
||||
const resolvedEvent: EventFrame = {
|
||||
type: "event",
|
||||
event: "exec.approval.resolved",
|
||||
payload: {
|
||||
id: "approval-1",
|
||||
decision: "allow-once",
|
||||
resolvedBy: "studio",
|
||||
ts: 999,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedResolved = resolveExecApprovalEventEffects({ event: resolvedEvent, agents });
|
||||
const resolvedDecision = resolveGatewayEventIngressDecision({
|
||||
event: resolvedEvent,
|
||||
agents,
|
||||
seenCronDedupeKeys: new Set<string>(),
|
||||
nowMs: 1000,
|
||||
});
|
||||
|
||||
expect(resolvedDecision.approvalEffects).toEqual(expectedResolved);
|
||||
expect(resolvedDecision.approvalEffects?.removals).toEqual(["approval-1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { upsertGatewayAgentExecApprovals } from "@/lib/gateway/execApprovals";
|
||||
|
||||
describe("upsertGatewayAgentExecApprovals", () => {
|
||||
it("writes per-agent policy with base hash", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "exec.approvals.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-1",
|
||||
file: {
|
||||
version: 1,
|
||||
agents: {
|
||||
main: {
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
allowlist: [{ pattern: "/bin/main" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "exec.approvals.set") {
|
||||
const payload = params as {
|
||||
baseHash?: string;
|
||||
file?: {
|
||||
agents?: Record<string, { security?: string; ask?: string; allowlist?: Array<{ pattern: string }> }>;
|
||||
};
|
||||
};
|
||||
expect(payload.baseHash).toBe("hash-1");
|
||||
expect(payload.file?.agents?.["agent-2"]).toEqual({
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
allowlist: [{ pattern: "/usr/bin/git" }],
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await upsertGatewayAgentExecApprovals({
|
||||
client,
|
||||
agentId: "agent-2",
|
||||
policy: {
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
allowlist: [{ pattern: "/usr/bin/git" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes per-agent policy when policy is null", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "exec.approvals.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: "hash-2",
|
||||
file: {
|
||||
version: 1,
|
||||
agents: {
|
||||
"agent-1": {
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
allowlist: [{ pattern: "/bin/echo" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "exec.approvals.set") {
|
||||
const payload = params as {
|
||||
file?: { agents?: Record<string, unknown> };
|
||||
};
|
||||
expect(payload.file?.agents?.["agent-1"]).toBeUndefined();
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await upsertGatewayAgentExecApprovals({
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
policy: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries once when gateway reports stale base hash", async () => {
|
||||
let setAttempts = 0;
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "exec.approvals.get") {
|
||||
return {
|
||||
exists: true,
|
||||
hash: setAttempts === 0 ? "hash-old" : "hash-new",
|
||||
file: {
|
||||
version: 1,
|
||||
agents: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "exec.approvals.set") {
|
||||
setAttempts += 1;
|
||||
const payload = params as { baseHash?: string };
|
||||
if (setAttempts === 1) {
|
||||
expect(payload.baseHash).toBe("hash-old");
|
||||
throw new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "exec approvals changed since last load; re-run exec.approvals.get and retry",
|
||||
});
|
||||
}
|
||||
expect(payload.baseHash).toBe("hash-new");
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await upsertGatewayAgentExecApprovals({
|
||||
client,
|
||||
agentId: "agent-3",
|
||||
policy: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
allowlist: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(setAttempts).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseGatewayFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
describe("gateway frames", () => {
|
||||
it("parses event stateVersion objects", () => {
|
||||
const raw = JSON.stringify({
|
||||
type: "event",
|
||||
event: "presence",
|
||||
payload: { presence: [] },
|
||||
stateVersion: { presence: 2, health: 5 },
|
||||
});
|
||||
|
||||
const frame = parseGatewayFrame(raw);
|
||||
|
||||
expect(frame?.type).toBe("event");
|
||||
if (frame?.type !== "event") {
|
||||
throw new Error("Expected event frame");
|
||||
}
|
||||
expect(frame.stateVersion?.presence).toBe(2);
|
||||
expect(frame.stateVersion?.health).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>(
|
||||
"node:child_process"
|
||||
);
|
||||
return {
|
||||
default: actual,
|
||||
...actual,
|
||||
spawnSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedSpawnSync = vi.mocked(spawnSync);
|
||||
|
||||
let GET: typeof import("@/app/api/gateway/media/route")["GET"];
|
||||
|
||||
const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
|
||||
|
||||
const writeStudioSettings = (stateDir: string, gatewayUrl: string) => {
|
||||
const settingsDir = path.join(stateDir, "claw3d");
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, "settings.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
gateway: { url: gatewayUrl, token: "token-123" },
|
||||
focused: {},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ GET } = await import("@/app/api/gateway/media/route"));
|
||||
});
|
||||
|
||||
describe("/api/gateway/media route", () => {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
delete process.env.OPENCLAW_GATEWAY_SSH_TARGET;
|
||||
delete process.env.OPENCLAW_GATEWAY_SSH_USER;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
mockedSpawnSync.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
if (tempDir) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns binary image data when reading remote media over ssh", async () => {
|
||||
tempDir = makeTempDir("gateway-media-route-remote");
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
process.env.OPENCLAW_GATEWAY_SSH_TARGET = "me@host.test";
|
||||
writeStudioSettings(tempDir, "ws://example.test:18789");
|
||||
|
||||
const payloadBytes = Buffer.from("fake", "utf8");
|
||||
mockedSpawnSync.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({
|
||||
ok: true,
|
||||
mime: "image/png",
|
||||
size: payloadBytes.length,
|
||||
data: payloadBytes.toString("base64"),
|
||||
}),
|
||||
stderr: "",
|
||||
error: undefined,
|
||||
} as never);
|
||||
|
||||
const remotePath = "/home/ubuntu/.openclaw/images/pic.png";
|
||||
const response = await GET(
|
||||
new Request(
|
||||
`http://localhost/api/gateway/media?path=${encodeURIComponent(remotePath)}`
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(response.headers.get("Content-Length")).toBe(String(payloadBytes.length));
|
||||
|
||||
const buf = Buffer.from(await response.arrayBuffer());
|
||||
expect(buf.equals(payloadBytes)).toBe(true);
|
||||
|
||||
expect(mockedSpawnSync).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args, options] = mockedSpawnSync.mock.calls[0] as [
|
||||
string,
|
||||
string[],
|
||||
{ encoding?: string; input?: string; maxBuffer?: number },
|
||||
];
|
||||
expect(cmd).toBe("ssh");
|
||||
expect(args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"me@host.test",
|
||||
"bash",
|
||||
"-s",
|
||||
"--",
|
||||
remotePath,
|
||||
])
|
||||
);
|
||||
expect(options.encoding).toBe("utf8");
|
||||
expect(options.input).toContain("python3 - \"$1\"");
|
||||
expect(typeof options.maxBuffer).toBe("number");
|
||||
expect(options.maxBuffer).toBeGreaterThan(payloadBytes.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildAllowedModelKeys,
|
||||
buildGatewayModelChoices,
|
||||
resolveConfiguredModelKey,
|
||||
type GatewayModelPolicySnapshot,
|
||||
} from "@/lib/gateway/models";
|
||||
|
||||
describe("gateway model policy helpers", () => {
|
||||
it("resolves configured aliases and shorthand ids", () => {
|
||||
const modelAliases = {
|
||||
"anthropic/claude-sonnet-4-5": { alias: "sonnet" },
|
||||
"openai/gpt-4o": { alias: "omni" },
|
||||
};
|
||||
|
||||
expect(resolveConfiguredModelKey("sonnet", modelAliases)).toBe(
|
||||
"anthropic/claude-sonnet-4-5"
|
||||
);
|
||||
expect(resolveConfiguredModelKey("omni", modelAliases)).toBe("openai/gpt-4o");
|
||||
expect(resolveConfiguredModelKey("claude-opus-4", modelAliases)).toBe(
|
||||
"anthropic/claude-opus-4"
|
||||
);
|
||||
expect(resolveConfiguredModelKey("openai/o3", modelAliases)).toBe("openai/o3");
|
||||
expect(resolveConfiguredModelKey(" ", modelAliases)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds deduped allowlist keys from defaults and aliases", () => {
|
||||
const snapshot: GatewayModelPolicySnapshot = {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "sonnet",
|
||||
fallbacks: ["omni", "anthropic/claude-sonnet-4-5", ""],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-5": { alias: "sonnet" },
|
||||
"openai/gpt-4o": { alias: "omni" },
|
||||
"openai/o3": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildAllowedModelKeys(snapshot)).toEqual([
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"openai/gpt-4o",
|
||||
"openai/o3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters model catalog and appends configured extras", () => {
|
||||
const snapshot: GatewayModelPolicySnapshot = {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "sonnet",
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-5": { alias: "sonnet" },
|
||||
"openai/o3": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const catalog = [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildGatewayModelChoices(catalog, snapshot)).toEqual([
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
id: "o3",
|
||||
name: "openai/o3",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the catalog unchanged when no config allowlist is present", () => {
|
||||
const catalog = [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildGatewayModelChoices(catalog, null)).toEqual(catalog);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,646 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
|
||||
const waitForEvent = <T = unknown>(
|
||||
target: { once: (event: string, cb: (...args: unknown[]) => void) => void },
|
||||
event: string
|
||||
) =>
|
||||
new Promise<T>((resolve) => {
|
||||
target.once(event, (...args: unknown[]) => resolve(args as unknown as T));
|
||||
});
|
||||
|
||||
const closeHttpServer = (server: import("node:http").Server) =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
|
||||
const closeWebSocketServer = (server: WebSocketServer) =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
|
||||
const closeWebSocket = (ws: WebSocket) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (ws.readyState === WebSocket.CLOSED) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
ws.once("close", () => resolve());
|
||||
ws.close();
|
||||
});
|
||||
|
||||
describe("createGatewayProxy", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("injects gateway token into connect request", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
let seenToken: string | null = null;
|
||||
let seenOrigin: string | undefined;
|
||||
upstream.on("connection", (ws, req) => {
|
||||
seenOrigin = req.headers.origin;
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = JSON.parse(String(raw));
|
||||
if (parsed?.method === "connect") {
|
||||
seenToken = parsed?.params?.auth?.token ?? null;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3, auth: {} },
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||
|
||||
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "token-123" }),
|
||||
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||
logError: () => {},
|
||||
});
|
||||
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||
|
||||
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-1",
|
||||
method: "connect",
|
||||
params: { auth: {} },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForEvent(browser, "message");
|
||||
|
||||
expect(seenToken).toBe("token-123");
|
||||
expect(seenOrigin).toBe(`http://localhost:${address.port}`);
|
||||
} finally {
|
||||
for (const client of upstream.clients) {
|
||||
client.close();
|
||||
}
|
||||
await Promise.all([
|
||||
closeWebSocket(browser),
|
||||
closeWebSocketServer(upstream),
|
||||
closeHttpServer(proxyHttp),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards upstream connect.challenge before browser connect and then passes nonce-based device auth", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
let seenDeviceNonce: string | null = null;
|
||||
upstream.on("connection", (ws) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "strict-gateway-nonce" },
|
||||
})
|
||||
);
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = JSON.parse(String(raw));
|
||||
if (parsed?.method === "connect") {
|
||||
seenDeviceNonce = parsed?.params?.device?.nonce ?? null;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3, auth: {} },
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||
|
||||
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
|
||||
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||
logError: () => {},
|
||||
});
|
||||
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||
|
||||
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
|
||||
const [challengeRaw] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||
const challenge = JSON.parse(String(challengeRaw ?? ""));
|
||||
expect(challenge).toMatchObject({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "strict-gateway-nonce" },
|
||||
});
|
||||
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-after-challenge",
|
||||
method: "connect",
|
||||
params: {
|
||||
device: {
|
||||
id: "device-id-123",
|
||||
publicKey: "device-public-key-123",
|
||||
signature: "device-signature-123",
|
||||
signedAt: Date.now(),
|
||||
nonce: "strict-gateway-nonce",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||
const response = JSON.parse(String(rawMessage ?? ""));
|
||||
expect(response).toMatchObject({ type: "res", id: "connect-after-challenge", ok: true });
|
||||
expect(seenDeviceNonce).toBe("strict-gateway-nonce");
|
||||
} finally {
|
||||
for (const client of upstream.clients) {
|
||||
client.close();
|
||||
}
|
||||
await Promise.all([
|
||||
closeWebSocket(browser),
|
||||
closeWebSocketServer(upstream),
|
||||
closeHttpServer(proxyHttp),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows browser auth token passthrough when host token is missing", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
let seenToken: string | null = null;
|
||||
upstream.on("connection", (ws) => {
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = JSON.parse(String(raw));
|
||||
if (parsed?.method === "connect") {
|
||||
seenToken = parsed?.params?.auth?.token ?? null;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3, auth: {} },
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||
|
||||
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
|
||||
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||
logError: () => {},
|
||||
});
|
||||
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||
|
||||
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-pass-token",
|
||||
method: "connect",
|
||||
params: { auth: { token: "browser-token-123" } },
|
||||
})
|
||||
);
|
||||
|
||||
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||
const response = JSON.parse(String(rawMessage ?? ""));
|
||||
expect(response).toMatchObject({ type: "res", id: "connect-pass-token", ok: true });
|
||||
expect(seenToken).toBe("browser-token-123");
|
||||
} finally {
|
||||
for (const client of upstream.clients) {
|
||||
client.close();
|
||||
}
|
||||
await Promise.all([
|
||||
closeWebSocket(browser),
|
||||
closeWebSocketServer(upstream),
|
||||
closeHttpServer(proxyHttp),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves browser auth token when both browser and host tokens are present", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
let seenToken: string | null = null;
|
||||
upstream.on("connection", (ws) => {
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = JSON.parse(String(raw));
|
||||
if (parsed?.method === "connect") {
|
||||
seenToken = parsed?.params?.auth?.token ?? null;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3, auth: {} },
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||
|
||||
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "host-token-456" }),
|
||||
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||
logError: () => {},
|
||||
});
|
||||
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||
|
||||
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-browser-precedence",
|
||||
method: "connect",
|
||||
params: { auth: { token: "browser-token-789" } },
|
||||
})
|
||||
);
|
||||
|
||||
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||
const response = JSON.parse(String(rawMessage ?? ""));
|
||||
expect(response).toMatchObject({ type: "res", id: "connect-browser-precedence", ok: true });
|
||||
expect(seenToken).toBe("browser-token-789");
|
||||
} finally {
|
||||
for (const client of upstream.clients) {
|
||||
client.close();
|
||||
}
|
||||
await Promise.all([
|
||||
closeWebSocket(browser),
|
||||
closeWebSocketServer(upstream),
|
||||
closeHttpServer(proxyHttp),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows browser device signature passthrough when host token is missing", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
let seenToken: string | null = null;
|
||||
let seenDeviceSignature: string | null = null;
|
||||
let seenDeviceId: string | null = null;
|
||||
let seenDevicePublicKey: string | null = null;
|
||||
let seenDeviceNonce: string | null = null;
|
||||
let seenDeviceSignedAt: number | null = null;
|
||||
upstream.on("connection", (ws) => {
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = JSON.parse(String(raw));
|
||||
if (parsed?.method === "connect") {
|
||||
seenToken = parsed?.params?.auth?.token ?? null;
|
||||
seenDeviceSignature = parsed?.params?.device?.signature ?? null;
|
||||
seenDeviceId = parsed?.params?.device?.id ?? null;
|
||||
seenDevicePublicKey = parsed?.params?.device?.publicKey ?? null;
|
||||
seenDeviceNonce = parsed?.params?.device?.nonce ?? null;
|
||||
seenDeviceSignedAt = parsed?.params?.device?.signedAt ?? null;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3, auth: {} },
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||
|
||||
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
|
||||
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||
logError: () => {},
|
||||
});
|
||||
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||
|
||||
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-pass-device",
|
||||
method: "connect",
|
||||
params: {
|
||||
device: {
|
||||
id: "device-id-123",
|
||||
publicKey: "device-public-key-123",
|
||||
signature: "device-signature-123",
|
||||
signedAt: Date.now(),
|
||||
nonce: "device-nonce-123",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||
const response = JSON.parse(String(rawMessage ?? ""));
|
||||
expect(response).toMatchObject({ type: "res", id: "connect-pass-device", ok: true });
|
||||
expect(seenDeviceSignature).toBe("device-signature-123");
|
||||
expect(seenDeviceId).toBe("device-id-123");
|
||||
expect(seenDevicePublicKey).toBe("device-public-key-123");
|
||||
expect(seenDeviceNonce).toBe("device-nonce-123");
|
||||
expect(typeof seenDeviceSignedAt).toBe("number");
|
||||
expect(seenToken).toBeNull();
|
||||
} finally {
|
||||
for (const client of upstream.clients) {
|
||||
client.close();
|
||||
}
|
||||
await Promise.all([
|
||||
closeWebSocket(browser),
|
||||
closeWebSocketServer(upstream),
|
||||
closeHttpServer(proxyHttp),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows browser password passthrough when host token is missing", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
let seenPassword: string | null = null;
|
||||
let seenToken: string | null = null;
|
||||
upstream.on("connection", (ws) => {
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = JSON.parse(String(raw));
|
||||
if (parsed?.method === "connect") {
|
||||
seenPassword = parsed?.params?.auth?.password ?? null;
|
||||
seenToken = parsed?.params?.auth?.token ?? null;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3, auth: {} },
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||
|
||||
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
|
||||
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||
logError: () => {},
|
||||
});
|
||||
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||
|
||||
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-pass-password",
|
||||
method: "connect",
|
||||
params: { auth: { password: "browser-password-123" } },
|
||||
})
|
||||
);
|
||||
|
||||
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||
const response = JSON.parse(String(rawMessage ?? ""));
|
||||
expect(response).toMatchObject({ type: "res", id: "connect-pass-password", ok: true });
|
||||
expect(seenPassword).toBe("browser-password-123");
|
||||
expect(seenToken).toBeNull();
|
||||
} finally {
|
||||
for (const client of upstream.clients) {
|
||||
client.close();
|
||||
}
|
||||
await Promise.all([
|
||||
closeWebSocket(browser),
|
||||
closeWebSocketServer(upstream),
|
||||
closeHttpServer(proxyHttp),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows browser deviceToken passthrough when host token is missing", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
let seenDeviceToken: string | null = null;
|
||||
let seenToken: string | null = null;
|
||||
upstream.on("connection", (ws) => {
|
||||
ws.on("message", (raw) => {
|
||||
const parsed = JSON.parse(String(raw));
|
||||
if (parsed?.method === "connect") {
|
||||
seenDeviceToken = parsed?.params?.auth?.deviceToken ?? null;
|
||||
seenToken = parsed?.params?.auth?.token ?? null;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: parsed.id,
|
||||
ok: true,
|
||||
payload: { type: "hello-ok", protocol: 3, auth: {} },
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||
|
||||
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
|
||||
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||
logError: () => {},
|
||||
});
|
||||
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||
|
||||
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-pass-device-token",
|
||||
method: "connect",
|
||||
params: { auth: { deviceToken: "browser-device-token-123" } },
|
||||
})
|
||||
);
|
||||
|
||||
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||
const response = JSON.parse(String(rawMessage ?? ""));
|
||||
expect(response).toMatchObject({ type: "res", id: "connect-pass-device-token", ok: true });
|
||||
expect(seenDeviceToken).toBe("browser-device-token-123");
|
||||
expect(seenToken).toBeNull();
|
||||
} finally {
|
||||
for (const client of upstream.clients) {
|
||||
client.close();
|
||||
}
|
||||
await Promise.all([
|
||||
closeWebSocket(browser),
|
||||
closeWebSocketServer(upstream),
|
||||
closeHttpServer(proxyHttp),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns studio.gateway_token_missing when browser auth and host token are both missing", async () => {
|
||||
const upstream = new WebSocketServer({ port: 0 });
|
||||
const address = upstream.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected upstream server to have a port");
|
||||
}
|
||||
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
|
||||
|
||||
let upstreamConnectionCount = 0;
|
||||
upstream.on("connection", () => {
|
||||
upstreamConnectionCount += 1;
|
||||
});
|
||||
|
||||
const { createGatewayProxy } = await import("../../server/gateway-proxy");
|
||||
|
||||
const proxyHttp = await import("node:http").then((m) => m.createServer());
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
|
||||
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
|
||||
logError: () => {},
|
||||
});
|
||||
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
|
||||
|
||||
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
|
||||
const proxyAddr = proxyHttp.address();
|
||||
if (!proxyAddr || typeof proxyAddr === "string") {
|
||||
throw new Error("expected proxy server to have a port");
|
||||
}
|
||||
|
||||
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
|
||||
try {
|
||||
await waitForEvent(browser, "open");
|
||||
const closePromise = waitForEvent<[number, Buffer]>(browser, "close");
|
||||
browser.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: "connect-missing-token",
|
||||
method: "connect",
|
||||
params: { auth: {} },
|
||||
})
|
||||
);
|
||||
|
||||
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
|
||||
const response = JSON.parse(String(rawMessage ?? ""));
|
||||
expect(response).toMatchObject({
|
||||
type: "res",
|
||||
id: "connect-missing-token",
|
||||
ok: false,
|
||||
error: { code: "studio.gateway_token_missing" },
|
||||
});
|
||||
|
||||
const [closeCode] = await closePromise;
|
||||
expect(closeCode).toBe(1011);
|
||||
expect(upstreamConnectionCount).toBe(1);
|
||||
} finally {
|
||||
for (const client of upstream.clients) {
|
||||
client.close();
|
||||
}
|
||||
await Promise.all([
|
||||
closeWebSocket(browser),
|
||||
closeWebSocketServer(upstream),
|
||||
closeHttpServer(proxyHttp),
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ensureGatewayReloadModeHotForLocalStudio,
|
||||
shouldAwaitDisconnectRestartForRemoteMutation,
|
||||
} from "@/lib/gateway/gatewayReloadMode";
|
||||
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
describe("ensureGatewayReloadModeHotForLocalStudio", () => {
|
||||
it("skips non-local upstream gateways", async () => {
|
||||
const client = { call: vi.fn() } as unknown as GatewayClient;
|
||||
await ensureGatewayReloadModeHotForLocalStudio({
|
||||
client,
|
||||
upstreamGatewayUrl: "ws://10.0.0.5:18789",
|
||||
});
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("sets gateway.reload.mode=hot when missing", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "config.get") {
|
||||
return { exists: true, hash: "hash-1", config: {} };
|
||||
}
|
||||
if (method === "config.set") {
|
||||
const payload = params as { raw?: string; baseHash?: string };
|
||||
expect(payload.baseHash).toBe("hash-1");
|
||||
const parsed = JSON.parse(payload.raw ?? "{}") as {
|
||||
gateway?: { reload?: { mode?: string } };
|
||||
};
|
||||
expect(parsed.gateway?.reload?.mode).toBe("hot");
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await ensureGatewayReloadModeHotForLocalStudio({
|
||||
client,
|
||||
upstreamGatewayUrl: "ws://127.0.0.1:18789",
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing when mode is already hot", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { exists: true, hash: "hash-1", config: { gateway: { reload: { mode: "hot" } } } };
|
||||
}
|
||||
if (method === "config.set") {
|
||||
throw new Error("config.set should not be called");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await ensureGatewayReloadModeHotForLocalStudio({
|
||||
client,
|
||||
upstreamGatewayUrl: "ws://localhost:18789",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries once on base-hash mismatch", async () => {
|
||||
let getCount = 0;
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
getCount += 1;
|
||||
return { exists: true, hash: getCount === 1 ? "hash-1" : "hash-2", config: {} };
|
||||
}
|
||||
if (method === "config.set") {
|
||||
if (getCount === 1) {
|
||||
throw new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "config changed since last load; re-run config.get and retry",
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await ensureGatewayReloadModeHotForLocalStudio({
|
||||
client,
|
||||
upstreamGatewayUrl: "ws://127.0.0.1:18789",
|
||||
});
|
||||
|
||||
const setCalls = (client.call as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
([method]) => method === "config.set",
|
||||
);
|
||||
expect(setCalls.length).toBe(2);
|
||||
expect(getCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldAwaitDisconnectRestartForRemoteMutation", () => {
|
||||
it("returns false for cached hot mode", async () => {
|
||||
const client = { call: vi.fn() } as unknown as GatewayClient;
|
||||
const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: { config: { gateway: { reload: { mode: "hot" } } } },
|
||||
});
|
||||
expect(shouldAwait).toBe(false);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns false for cached off mode", async () => {
|
||||
const client = { call: vi.fn() } as unknown as GatewayClient;
|
||||
const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: { config: { gateway: { reload: { mode: "off" } } } },
|
||||
});
|
||||
expect(shouldAwait).toBe(false);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns false for cached hybrid mode", async () => {
|
||||
const client = { call: vi.fn() } as unknown as GatewayClient;
|
||||
const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: { config: { gateway: { reload: { mode: "hybrid" } } } },
|
||||
});
|
||||
expect(shouldAwait).toBe(false);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("treats missing cached reload mode as hybrid", async () => {
|
||||
const client = { call: vi.fn() } as unknown as GatewayClient;
|
||||
const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: { config: {} },
|
||||
});
|
||||
expect(shouldAwait).toBe(false);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns true when reload mode is unknown", async () => {
|
||||
const client = { call: vi.fn() } as unknown as GatewayClient;
|
||||
const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: { config: { gateway: { reload: { mode: "restart" } } } },
|
||||
});
|
||||
expect(shouldAwait).toBe(true);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("loads config when cache is missing and returns false for hot mode", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method !== "config.get") {
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}
|
||||
return { config: { gateway: { reload: { mode: "hot" } } } };
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: null,
|
||||
});
|
||||
expect(shouldAwait).toBe(false);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls).toEqual([["config.get", {}]]);
|
||||
});
|
||||
|
||||
it("loads config when cache is missing and treats missing reload mode as hybrid", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async (method: string) => {
|
||||
if (method !== "config.get") {
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}
|
||||
return { config: {} };
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
const shouldAwait = await shouldAwaitDisconnectRestartForRemoteMutation({
|
||||
client,
|
||||
cachedConfigSnapshot: null,
|
||||
});
|
||||
expect(shouldAwait).toBe(false);
|
||||
expect((client.call as ReturnType<typeof vi.fn>).mock.calls).toEqual([["config.get", {}]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { observeGatewayRestart } from "@/features/agents/operations/gatewayRestartPolicy";
|
||||
|
||||
describe("observeGatewayRestart", () => {
|
||||
it("marks_saw_disconnect_on_non_connected_status_and_completes_on_reconnect", () => {
|
||||
const start = { sawDisconnect: false };
|
||||
|
||||
const connected = observeGatewayRestart(start, "connected");
|
||||
expect(connected).toEqual({ next: { sawDisconnect: false }, restartComplete: false });
|
||||
|
||||
const connecting = observeGatewayRestart(connected.next, "connecting");
|
||||
expect(connecting).toEqual({ next: { sawDisconnect: true }, restartComplete: false });
|
||||
|
||||
const reconnected = observeGatewayRestart(connecting.next, "connected");
|
||||
expect(reconnected).toEqual({ next: { sawDisconnect: true }, restartComplete: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewayRuntimeEventHandler";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
|
||||
const base: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
};
|
||||
const merged = { ...base, ...(overrides ?? {}) };
|
||||
|
||||
return {
|
||||
...merged,
|
||||
historyFetchLimit: merged.historyFetchLimit ?? null,
|
||||
historyFetchedCount: merged.historyFetchedCount ?? null,
|
||||
historyMaybeTruncated: merged.historyMaybeTruncated ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
describe("gateway runtime event handler (agent)", () => {
|
||||
it("updates reasoning stream thinking trace via queueLivePatch", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "reasoning",
|
||||
data: { text: "first" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "reasoning",
|
||||
data: { text: "first second" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
expect(queueLivePatch).toHaveBeenCalled();
|
||||
expect(queueLivePatch).toHaveBeenLastCalledWith(
|
||||
"agent-1",
|
||||
expect.objectContaining({
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
thinkingTrace: "first second",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses assistant stream publish when chat stream already owns it", () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
status: "running",
|
||||
runId: "run-2",
|
||||
runStartedAt: 900,
|
||||
streamText: "already streaming",
|
||||
}),
|
||||
];
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-2",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "delta",
|
||||
message: { role: "user", content: "hi" },
|
||||
},
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-2",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "assistant",
|
||||
data: { delta: "hello" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
const lastCall = queueLivePatch.mock.calls[queueLivePatch.mock.calls.length - 1] as
|
||||
| [string, Partial<AgentState>]
|
||||
| undefined;
|
||||
if (!lastCall) throw new Error("Expected queueLivePatch to be called");
|
||||
const patch = lastCall[1];
|
||||
expect(patch.status).toBe("running");
|
||||
expect(patch.runId).toBe("run-2");
|
||||
expect("streamText" in patch).toBe(false);
|
||||
});
|
||||
|
||||
it("does not publish streamText for assistant open thinking chunk", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-open-think", runStartedAt: 900 })];
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-open-think",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "assistant",
|
||||
data: { text: "<thinking>planning" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
const lastCall = queueLivePatch.mock.calls[queueLivePatch.mock.calls.length - 1] as
|
||||
| [string, Partial<AgentState>]
|
||||
| undefined;
|
||||
if (!lastCall) throw new Error("Expected queueLivePatch to be called");
|
||||
const patch = lastCall[1];
|
||||
expect(patch.status).toBe("running");
|
||||
expect(patch.runId).toBe("run-open-think");
|
||||
expect(patch.thinkingTrace).toBe("planning");
|
||||
expect("streamText" in patch).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes streamText when assistant thinking block is closed and visible text is present", () => {
|
||||
const agents = [
|
||||
createAgent({ status: "running", runId: "run-closed-think", runStartedAt: 900 }),
|
||||
];
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-closed-think",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "assistant",
|
||||
data: { text: "<thinking>same</thinking>same" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
const lastCall = queueLivePatch.mock.calls[queueLivePatch.mock.calls.length - 1] as
|
||||
| [string, Partial<AgentState>]
|
||||
| undefined;
|
||||
if (!lastCall) throw new Error("Expected queueLivePatch to be called");
|
||||
const patch = lastCall[1];
|
||||
expect(patch.status).toBe("running");
|
||||
expect(patch.runId).toBe("run-closed-think");
|
||||
expect(patch.thinkingTrace).toBe("same");
|
||||
expect(patch.streamText).toBe("same");
|
||||
});
|
||||
|
||||
it("allows assistant stream extension when chat stream stalls", () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
status: "running",
|
||||
runId: "run-2",
|
||||
runStartedAt: 900,
|
||||
streamText: "hello",
|
||||
}),
|
||||
];
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-2",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "delta",
|
||||
message: { role: "user", content: "hi" },
|
||||
},
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-2",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "assistant",
|
||||
data: { delta: "hello" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-2",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "assistant",
|
||||
data: { delta: " world" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
const lastCall = queueLivePatch.mock.calls[queueLivePatch.mock.calls.length - 1] as
|
||||
| [string, Partial<AgentState>]
|
||||
| undefined;
|
||||
if (!lastCall) throw new Error("Expected queueLivePatch to be called");
|
||||
const patch = lastCall[1];
|
||||
expect(patch.status).toBe("running");
|
||||
expect(patch.runId).toBe("run-2");
|
||||
expect(patch.streamText).toBe("hello world");
|
||||
});
|
||||
|
||||
it("formats and dedupes tool call lines per run", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-3", runStartedAt: 900 })];
|
||||
const actions: Array<{ type: string; line?: string }> = [];
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn((action) => {
|
||||
actions.push(action as never);
|
||||
}),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
const toolEvent: EventFrame = {
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-3",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "call",
|
||||
name: "myTool",
|
||||
toolCallId: "id-1",
|
||||
arguments: { a: 1 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
handler.handleEvent(toolEvent);
|
||||
handler.handleEvent(toolEvent);
|
||||
|
||||
const toolLines = actions
|
||||
.filter((a) => a.type === "appendOutput")
|
||||
.map((a) => a.line ?? "")
|
||||
.filter((line) => line.startsWith("[[tool]]"));
|
||||
expect(toolLines.length).toBe(1);
|
||||
expect(toolLines[0]).toContain("myTool");
|
||||
});
|
||||
|
||||
it("requests history refresh once per run after first tool result when thinking traces enabled", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-5", runStartedAt: 900 })];
|
||||
const requestHistoryRefresh = vi.fn(async () => {});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh,
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn) => {
|
||||
fn();
|
||||
return 1;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
const toolResultEvent: EventFrame = {
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-5",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "result",
|
||||
name: "exec",
|
||||
toolCallId: "tool-1",
|
||||
result: { content: [{ type: "text", text: "ok" }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
handler.handleEvent(toolResultEvent);
|
||||
handler.handleEvent({
|
||||
...toolResultEvent,
|
||||
payload: {
|
||||
...(toolResultEvent.payload as Record<string, unknown>),
|
||||
data: {
|
||||
phase: "result",
|
||||
name: "exec",
|
||||
toolCallId: "tool-2",
|
||||
result: { content: [{ type: "text", text: "ok again" }] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
reason: "chat-final-no-trace",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
});
|
||||
});
|
||||
|
||||
it("requests history refresh when lifecycle start arrives before any chat event", () => {
|
||||
const agents = [createAgent({ status: "idle", runId: null, runStartedAt: null })];
|
||||
const requestHistoryRefresh = vi.fn(async () => {});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh,
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn) => {
|
||||
fn();
|
||||
return 1;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-telegram",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "start",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
reason: "run-start-no-chat",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps alternate transport session keys back to the same agent id", () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
status: "idle",
|
||||
runId: null,
|
||||
}),
|
||||
];
|
||||
const requestHistoryRefresh = vi.fn(async () => {});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh,
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn) => {
|
||||
fn();
|
||||
return 1;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-telegram",
|
||||
sessionKey: "agent:main:telegram:group:-1003891024811:topic:1",
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "start",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
reason: "run-start-no-chat",
|
||||
sessionKey: "agent:main:telegram:group:-1003891024811:topic:1",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale assistant stream events for non-active runIds", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-2", runStartedAt: 900 })];
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "assistant",
|
||||
data: { text: "stale text" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
expect(queueLivePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies lifecycle transitions and appends final stream text when no chat events", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agents = [createAgent({ streamText: "final text", runId: "run-4" })];
|
||||
const actions: Array<{ type: string; agentId: string; line?: string; patch?: unknown }> = [];
|
||||
const clearPendingLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn((action) => {
|
||||
actions.push(action as never);
|
||||
}),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch,
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-4",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "start" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
expect(
|
||||
actions.some((a) => {
|
||||
if (a.type !== "updateAgent") return false;
|
||||
const patch = a.patch as Record<string, unknown>;
|
||||
return patch.status === "running" && patch.runId === "run-4";
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
actions.length = 0;
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-4",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
expect(
|
||||
actions.some((a) => {
|
||||
if (a.type !== "updateAgent") return false;
|
||||
const patch = a.patch as Record<string, unknown>;
|
||||
return patch.status === "idle" && patch.runId === null;
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(actions.some((a) => a.type === "appendOutput" && a.line === "final text")).toBe(true);
|
||||
expect(
|
||||
actions.some((a) => {
|
||||
if (a.type !== "updateAgent") return false;
|
||||
const patch = a.patch as Record<string, unknown>;
|
||||
return patch.lastResult === "final text" && patch.lastAssistantMessageAt === 1000;
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
actions.some((a) => {
|
||||
if (a.type !== "updateAgent") return false;
|
||||
const patch = a.patch as Record<string, unknown>;
|
||||
return patch.status === "idle" && patch.runId === null;
|
||||
})
|
||||
).toBe(true);
|
||||
expect(clearPendingLivePatch).toHaveBeenCalledWith("agent-1");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not schedule lifecycle fallback final text for error transitions", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agents = [createAgent({ streamText: "partial text", runId: "run-err" })];
|
||||
const actions: Array<{ type: string; line?: string; patch?: unknown }> = [];
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn((action) => {
|
||||
actions.push(action as never);
|
||||
}),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-err",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "error" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(actions.some((entry) => entry.type === "appendOutput")).toBe(false);
|
||||
expect(
|
||||
actions.some((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.status === "error" && patch.runId === null;
|
||||
})
|
||||
).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers canonical chat final over lifecycle fallback when final arrives immediately", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agents = [
|
||||
createAgent({
|
||||
status: "running",
|
||||
runId: "run-7",
|
||||
runStartedAt: 900,
|
||||
streamText: "fallback final",
|
||||
}),
|
||||
];
|
||||
const actions: Array<{
|
||||
type: string;
|
||||
line?: string;
|
||||
transcript?: { kind?: string; role?: string };
|
||||
}> = [];
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn((action) => {
|
||||
actions.push(action as never);
|
||||
}),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-7",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
expect(
|
||||
actions.filter(
|
||||
(entry) => entry.type === "appendOutput" && entry.transcript?.kind === "assistant"
|
||||
)
|
||||
).toHaveLength(0);
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-7",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "canonical final" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
const assistantLines = actions
|
||||
.filter((entry) => entry.type === "appendOutput" && entry.transcript?.kind === "assistant")
|
||||
.map((entry) => entry.line);
|
||||
const assistantMetaLines = actions.filter(
|
||||
(entry) =>
|
||||
entry.type === "appendOutput" &&
|
||||
entry.transcript?.kind === "meta" &&
|
||||
entry.transcript?.role === "assistant"
|
||||
);
|
||||
|
||||
expect(assistantLines).toEqual(["canonical final"]);
|
||||
expect(assistantMetaLines).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes markdown-rich lifecycle fallback assistant text before append and lastResult update", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const normalizedAssistantText = ["- item one", "- item two", "", "```ts", "const n = 1;", "```"].join(
|
||||
"\n"
|
||||
);
|
||||
const agents = [
|
||||
createAgent({
|
||||
streamText: "\n- item one \n- item two\t \n\n\n```ts \nconst n = 1;\t\n```\n\n",
|
||||
runId: "run-6",
|
||||
}),
|
||||
];
|
||||
const actions: Array<{ type: string; line?: string; patch?: unknown }> = [];
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn((action) => {
|
||||
actions.push(action as never);
|
||||
}),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-6",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(
|
||||
actions.some((entry) => entry.type === "appendOutput" && entry.line === normalizedAssistantText)
|
||||
).toBe(true);
|
||||
expect(
|
||||
actions.some((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.lastResult === normalizedAssistantText;
|
||||
})
|
||||
).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,963 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewayRuntimeEventHandler";
|
||||
import {
|
||||
agentStoreReducer,
|
||||
initialAgentStoreState,
|
||||
type AgentState,
|
||||
type AgentStoreSeed,
|
||||
} from "@/features/agents/state/store";
|
||||
import * as transcriptState from "@/features/agents/state/transcript";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
|
||||
const base: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
};
|
||||
const merged = { ...base, ...(overrides ?? {}) };
|
||||
|
||||
return {
|
||||
...merged,
|
||||
historyFetchLimit: merged.historyFetchLimit ?? null,
|
||||
historyFetchedCount: merged.historyFetchedCount ?? null,
|
||||
historyMaybeTruncated: merged.historyMaybeTruncated ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
describe("gateway runtime event handler (chat)", () => {
|
||||
it("applies delta assistant chat stream via queueLivePatch", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatch = vi.fn();
|
||||
const queueLivePatch = vi.fn();
|
||||
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: "Hello" },
|
||||
},
|
||||
};
|
||||
|
||||
handler.handleEvent(event);
|
||||
|
||||
expect(queueLivePatch).toHaveBeenCalledTimes(1);
|
||||
expect(queueLivePatch).toHaveBeenCalledWith(
|
||||
"agent-1",
|
||||
expect.objectContaining({
|
||||
streamText: "Hello",
|
||||
status: "running",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores user/system roles for streaming output", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const queueLivePatch = vi.fn();
|
||||
const dispatch = vi.fn();
|
||||
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "delta",
|
||||
message: { role: "user", content: "Hello" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queueLivePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores stale delta chat events for non-active runIds", () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
status: "running",
|
||||
runId: "run-2",
|
||||
runStartedAt: 900,
|
||||
}),
|
||||
];
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: "stale text" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queueLivePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies final assistant chat by appending output and clearing stream fields", async () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
lastUserMessage: "hello",
|
||||
latestOverride: null,
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
}),
|
||||
];
|
||||
const dispatched: Array<{ type: string; agentId: string; line?: string; patch?: unknown }> = [];
|
||||
const dispatch = vi.fn((action) => {
|
||||
dispatched.push(action as never);
|
||||
});
|
||||
const updateSpecialLatestUpdate = vi.fn();
|
||||
const clearPendingLivePatch = vi.fn();
|
||||
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch,
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate,
|
||||
});
|
||||
|
||||
const ts = "2024-01-01T00:00:00.000Z";
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "Done", timestamp: ts, thinking: "t" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatched.some((entry) => entry.type === "appendOutput" && entry.line === "Done")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
dispatched.some((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.streamText === null && patch.thinkingTrace === null;
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
dispatched.some((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.status === "idle" && patch.runId === null;
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
dispatched.some((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.lastAssistantMessageAt === Date.parse(ts);
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(updateSpecialLatestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(updateSpecialLatestUpdate).toHaveBeenCalledWith("agent-1", agents[0], "hello");
|
||||
expect(clearPendingLivePatch).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
it("uses the current chat agent snapshot for latest-update effects", () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
lastUserMessage: "hello",
|
||||
latestOverride: null,
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
}),
|
||||
];
|
||||
let getAgentsCalls = 0;
|
||||
const getAgents = () => {
|
||||
getAgentsCalls += 1;
|
||||
return getAgentsCalls === 1 ? agents : [];
|
||||
};
|
||||
const updateSpecialLatestUpdate = vi.fn();
|
||||
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate,
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "Done", timestamp: "2024-01-01T00:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateSpecialLatestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(updateSpecialLatestUpdate).toHaveBeenCalledWith("agent-1", agents[0], "hello");
|
||||
});
|
||||
|
||||
it("normalizes markdown-rich final assistant chat text before append and lastResult update", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatched: Array<{ type: string; line?: string; patch?: unknown }> = [];
|
||||
const normalizedAssistantText = ["- item one", "- item two", "", "```ts", "const n = 1;", "```"].join(
|
||||
"\n"
|
||||
);
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn((action) => {
|
||||
dispatched.push(action as never);
|
||||
}),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "\n- item one \r\n- item two\t \r\n\r\n\r\n```ts \r\nconst n = 1;\t\r\n```\r\n\r\n",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
dispatched.some(
|
||||
(entry) => entry.type === "appendOutput" && entry.line === normalizedAssistantText
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
dispatched.some((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.lastResult === normalizedAssistantText;
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requests history refresh through boundary command only when final assistant arrives without trace lines", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agents = [createAgent({ outputLines: [] })];
|
||||
const requestHistoryRefresh = vi.fn(async () => {});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh,
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "Done" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestHistoryRefresh).not.toHaveBeenCalled();
|
||||
vi.runAllTimers();
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(requestHistoryRefresh).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
reason: "chat-final-no-trace",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces committed lifecycle fallback with canonical chat final in reducer state", () => {
|
||||
vi.useFakeTimers();
|
||||
const metricSpy = vi
|
||||
.spyOn(transcriptState, "logTranscriptDebugMetric")
|
||||
.mockImplementation(() => {});
|
||||
try {
|
||||
const agents = [
|
||||
createAgent({
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
streamText: "fallback final",
|
||||
}),
|
||||
];
|
||||
const dispatched: Array<Record<string, unknown>> = [];
|
||||
const dispatch = vi.fn((action) => {
|
||||
dispatched.push(action as never);
|
||||
});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
},
|
||||
} as EventFrame);
|
||||
vi.advanceTimersByTime(400);
|
||||
|
||||
expect(
|
||||
dispatched.some(
|
||||
(entry) =>
|
||||
entry.type === "appendOutput" &&
|
||||
entry.line === "fallback final" &&
|
||||
typeof entry.transcript === "object"
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "canonical final" },
|
||||
},
|
||||
};
|
||||
handler.handleEvent(event);
|
||||
|
||||
const seed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
};
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: {
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
streamText: "fallback final",
|
||||
},
|
||||
});
|
||||
for (const action of dispatched) {
|
||||
if (!action || typeof action !== "object") continue;
|
||||
if (typeof (action as { type?: unknown }).type !== "string") continue;
|
||||
state = agentStoreReducer(state, action as never);
|
||||
}
|
||||
const agentState = state.agents.find((entry) => entry.agentId === "agent-1");
|
||||
const transcriptEntries = agentState?.transcriptEntries ?? [];
|
||||
const assistantEntries = transcriptEntries.filter((entry) => entry.kind === "assistant");
|
||||
const assistantMetaEntries = transcriptEntries.filter(
|
||||
(entry) => entry.kind === "meta" && entry.role === "assistant"
|
||||
);
|
||||
|
||||
expect(assistantEntries).toHaveLength(1);
|
||||
expect(assistantEntries[0]?.text).toBe("canonical final");
|
||||
expect(assistantMetaEntries).toHaveLength(1);
|
||||
expect(metricSpy).toHaveBeenCalledWith(
|
||||
"lifecycle_fallback_replaced_by_chat_final",
|
||||
expect.objectContaining({ runId: "run-1" })
|
||||
);
|
||||
} finally {
|
||||
metricSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores terminal chat events with same-or-lower payload sequence for a run", () => {
|
||||
const metricSpy = vi
|
||||
.spyOn(transcriptState, "logTranscriptDebugMetric")
|
||||
.mockImplementation(() => {});
|
||||
try {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatched: Array<Record<string, unknown>> = [];
|
||||
const dispatch = vi.fn((action) => {
|
||||
dispatched.push(action as never);
|
||||
});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
seq: 4,
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "final seq 4" },
|
||||
},
|
||||
});
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
seq: 4,
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "final seq 4 replay" },
|
||||
},
|
||||
});
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
seq: 3,
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "final seq 3 stale" },
|
||||
},
|
||||
});
|
||||
|
||||
const seed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
};
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: {
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
},
|
||||
});
|
||||
for (const action of dispatched) {
|
||||
if (!action || typeof action !== "object") continue;
|
||||
if (typeof (action as { type?: unknown }).type !== "string") continue;
|
||||
state = agentStoreReducer(state, action as never);
|
||||
}
|
||||
const agentState = state.agents.find((entry) => entry.agentId === "agent-1");
|
||||
const assistantEntries = (agentState?.transcriptEntries ?? []).filter(
|
||||
(entry) => entry.kind === "assistant"
|
||||
);
|
||||
|
||||
expect(assistantEntries).toHaveLength(1);
|
||||
expect(assistantEntries[0]?.text).toBe("final seq 4");
|
||||
const staleCalls = metricSpy.mock.calls.filter(
|
||||
(call) => call[0] === "stale_terminal_chat_event_ignored"
|
||||
);
|
||||
expect(staleCalls).toHaveLength(2);
|
||||
expect(staleCalls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
seq: 4,
|
||||
lastTerminalSeq: 4,
|
||||
commitSource: "chat-final",
|
||||
})
|
||||
);
|
||||
expect(staleCalls[1]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
seq: 3,
|
||||
lastTerminalSeq: 4,
|
||||
commitSource: "chat-final",
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
metricSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts higher-sequence terminal chat events and keeps newest final text", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatched: Array<Record<string, unknown>> = [];
|
||||
const dispatch = vi.fn((action) => {
|
||||
dispatched.push(action as never);
|
||||
});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
seq: 2,
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "final seq 2" },
|
||||
},
|
||||
});
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
seq: 3,
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "final seq 3" },
|
||||
},
|
||||
});
|
||||
|
||||
const seed: AgentStoreSeed = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
};
|
||||
let state = agentStoreReducer(initialAgentStoreState, {
|
||||
type: "hydrateAgents",
|
||||
agents: [seed],
|
||||
});
|
||||
state = agentStoreReducer(state, {
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: {
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
},
|
||||
});
|
||||
for (const action of dispatched) {
|
||||
if (!action || typeof action !== "object") continue;
|
||||
if (typeof (action as { type?: unknown }).type !== "string") continue;
|
||||
state = agentStoreReducer(state, action as never);
|
||||
}
|
||||
const agentState = state.agents.find((entry) => entry.agentId === "agent-1");
|
||||
const assistantEntries = (agentState?.transcriptEntries ?? []).filter(
|
||||
(entry) => entry.kind === "assistant"
|
||||
);
|
||||
|
||||
expect(assistantEntries).toHaveLength(1);
|
||||
expect(assistantEntries[0]?.text).toBe("final seq 3");
|
||||
});
|
||||
|
||||
it("ignores terminal chat events for non-active runIds", () => {
|
||||
const agents = [
|
||||
createAgent({
|
||||
status: "running",
|
||||
runId: "run-2",
|
||||
runStartedAt: 900,
|
||||
streamText: "still streaming",
|
||||
thinkingTrace: "t",
|
||||
}),
|
||||
];
|
||||
const dispatched: Array<{ type: string; agentId: string; patch?: unknown }> = [];
|
||||
const dispatch = vi.fn((action) => {
|
||||
if (action && typeof action === "object") {
|
||||
dispatched.push(action as never);
|
||||
}
|
||||
});
|
||||
const requestHistoryRefresh = vi.fn(async () => {});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh,
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "old done" },
|
||||
},
|
||||
});
|
||||
|
||||
const terminalClears = dispatched.filter((entry) => {
|
||||
if (entry.type !== "updateAgent") return false;
|
||||
const patch = entry.patch as Record<string, unknown>;
|
||||
return patch.streamText === null || patch.thinkingTrace === null || patch.runStartedAt === null;
|
||||
});
|
||||
expect(terminalClears.length).toBe(0);
|
||||
expect(requestHistoryRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles aborted/error by appending output and clearing stream fields", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "aborted",
|
||||
message: { role: "assistant", content: "" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Run aborted." })
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: expect.objectContaining({ status: "idle" }),
|
||||
})
|
||||
);
|
||||
|
||||
const errorDispatch = vi.fn();
|
||||
const errorHandler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: errorDispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
errorHandler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "error",
|
||||
errorMessage: "bad",
|
||||
message: { role: "assistant", content: "" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(errorDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Error: bad" })
|
||||
);
|
||||
expect(errorDispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: expect.objectContaining({ status: "error" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses aborted status line when abort is an approval pause", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const dispatch = vi.fn();
|
||||
const shouldSuppressRunAbortedLine = vi.fn(({ runId, stopReason }) => {
|
||||
return runId === "run-1" && stopReason === "rpc";
|
||||
});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch,
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
shouldSuppressRunAbortedLine,
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "aborted",
|
||||
stopReason: "rpc",
|
||||
message: { role: "assistant", content: "" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(shouldSuppressRunAbortedLine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
stopReason: "rpc",
|
||||
})
|
||||
);
|
||||
expect(dispatch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Run aborted." })
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: expect.objectContaining({ status: "idle" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores late delta chat events after a run has already finalized", () => {
|
||||
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => agents,
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "final",
|
||||
message: { role: "assistant", content: "done" },
|
||||
},
|
||||
});
|
||||
|
||||
queueLivePatch.mockClear();
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: agents[0]!.sessionKey,
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: "late text" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queueLivePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const policyMocks = vi.hoisted(() => ({
|
||||
decideRuntimeChatEvent: vi.fn(),
|
||||
decideRuntimeAgentEvent: vi.fn(),
|
||||
decideSummaryRefreshEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/agents/state/runtimeEventPolicy", () => policyMocks);
|
||||
|
||||
import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewayRuntimeEventHandler";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: "run-1",
|
||||
runStartedAt: 900,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
...(overrides ?? {}),
|
||||
});
|
||||
|
||||
describe("gateway runtime event handler policy delegation", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses chat policy intents to drive delta live patching", () => {
|
||||
policyMocks.decideRuntimeChatEvent.mockReturnValue([
|
||||
{
|
||||
kind: "queueLivePatch",
|
||||
agentId: "agent-1",
|
||||
patch: { streamText: "from-policy", status: "running" },
|
||||
},
|
||||
]);
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => [createAgent()],
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
const event: EventFrame = {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
state: "delta",
|
||||
message: { role: "assistant", content: "raw" },
|
||||
},
|
||||
};
|
||||
handler.handleEvent(event);
|
||||
|
||||
expect(policyMocks.decideRuntimeChatEvent).toHaveBeenCalledTimes(1);
|
||||
expect(queueLivePatch).toHaveBeenCalledWith("agent-1", {
|
||||
streamText: "from-policy",
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses agent policy intents to short-circuit processing", () => {
|
||||
policyMocks.decideRuntimeAgentEvent.mockReturnValue([{ kind: "ignore", reason: "forced" }]);
|
||||
const queueLivePatch = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => [createAgent()],
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot: vi.fn(async () => {}),
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
stream: "assistant",
|
||||
data: { delta: "raw" },
|
||||
},
|
||||
} as EventFrame);
|
||||
|
||||
expect(policyMocks.decideRuntimeAgentEvent).toHaveBeenCalledTimes(1);
|
||||
expect(queueLivePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses summary policy intents for heartbeat refresh behavior", async () => {
|
||||
vi.useFakeTimers();
|
||||
policyMocks.decideSummaryRefreshEvent.mockReturnValue([
|
||||
{
|
||||
kind: "scheduleSummaryRefresh",
|
||||
delayMs: 10,
|
||||
includeHeartbeatRefresh: true,
|
||||
},
|
||||
]);
|
||||
const loadSummarySnapshot = vi.fn(async () => {});
|
||||
const bumpHeartbeatTick = vi.fn();
|
||||
const refreshHeartbeatLatestUpdate = vi.fn();
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => [createAgent()],
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot,
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate,
|
||||
bumpHeartbeatTick,
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({ type: "event", event: "presence", payload: {} });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(policyMocks.decideSummaryRefreshEvent).toHaveBeenCalledTimes(1);
|
||||
expect(bumpHeartbeatTick).toHaveBeenCalledTimes(1);
|
||||
expect(refreshHeartbeatLatestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(loadSummarySnapshot).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewayRuntimeEventHandler";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const createAgent = (): AgentState => ({
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:studio:test-session",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
describe("gateway runtime event handler (summary refresh)", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("debounces summary refresh events and loads summary once", async () => {
|
||||
vi.useFakeTimers();
|
||||
const loadSummarySnapshot = vi.fn(async () => {});
|
||||
const bumpHeartbeatTick = vi.fn();
|
||||
const refreshHeartbeatLatestUpdate = vi.fn();
|
||||
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "connected",
|
||||
getAgents: () => [createAgent()],
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot,
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate,
|
||||
bumpHeartbeatTick,
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
const presence: EventFrame = { type: "event", event: "presence", payload: {} };
|
||||
handler.handleEvent(presence);
|
||||
handler.handleEvent(presence);
|
||||
handler.handleEvent({ type: "event", event: "heartbeat", payload: {} });
|
||||
|
||||
expect(bumpHeartbeatTick).toHaveBeenCalledTimes(1);
|
||||
expect(refreshHeartbeatLatestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(loadSummarySnapshot).toHaveBeenCalledTimes(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(749);
|
||||
expect(loadSummarySnapshot).toHaveBeenCalledTimes(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(loadSummarySnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
handler.dispose();
|
||||
});
|
||||
|
||||
it("ignores summary refresh when not connected", async () => {
|
||||
vi.useFakeTimers();
|
||||
const loadSummarySnapshot = vi.fn(async () => {});
|
||||
const handler = createGatewayRuntimeEventHandler({
|
||||
getStatus: () => "disconnected",
|
||||
getAgents: () => [createAgent()],
|
||||
dispatch: vi.fn(),
|
||||
queueLivePatch: vi.fn(),
|
||||
clearPendingLivePatch: vi.fn(),
|
||||
now: () => 1000,
|
||||
loadSummarySnapshot,
|
||||
requestHistoryRefresh: vi.fn(async () => {}),
|
||||
refreshHeartbeatLatestUpdate: vi.fn(),
|
||||
bumpHeartbeatTick: vi.fn(),
|
||||
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
|
||||
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
|
||||
isDisconnectLikeError: () => false,
|
||||
logWarn: vi.fn(),
|
||||
updateSpecialLatestUpdate: vi.fn(),
|
||||
});
|
||||
|
||||
handler.handleEvent({ type: "event", event: "presence", payload: {} });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(loadSummarySnapshot).toHaveBeenCalledTimes(0);
|
||||
handler.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveConfiguredSshTarget,
|
||||
resolveGatewaySshTargetFromGatewayUrl,
|
||||
} from "@/lib/ssh/gateway-host";
|
||||
|
||||
describe("gateway ssh target resolution", () => {
|
||||
it("uses_configured_target_with_at_sign", () => {
|
||||
expect(
|
||||
resolveConfiguredSshTarget({
|
||||
OPENCLAW_GATEWAY_SSH_TARGET: "me@example.test",
|
||||
} as unknown as NodeJS.ProcessEnv)
|
||||
).toBe("me@example.test");
|
||||
});
|
||||
|
||||
it("combines_user_and_target_when_target_missing_at_sign", () => {
|
||||
expect(
|
||||
resolveConfiguredSshTarget({
|
||||
OPENCLAW_GATEWAY_SSH_TARGET: "example.test",
|
||||
OPENCLAW_GATEWAY_SSH_USER: "me",
|
||||
} as unknown as NodeJS.ProcessEnv)
|
||||
).toBe("me@example.test");
|
||||
});
|
||||
|
||||
it("derives_target_from_gateway_url_with_default_user_ubuntu", () => {
|
||||
expect(
|
||||
resolveGatewaySshTargetFromGatewayUrl(
|
||||
"ws://example.test:18789",
|
||||
{} as unknown as NodeJS.ProcessEnv
|
||||
)
|
||||
).toBe("ubuntu@example.test");
|
||||
});
|
||||
|
||||
it("throws_on_missing_gateway_url_when_no_env_override", () => {
|
||||
expect(() =>
|
||||
resolveGatewaySshTargetFromGatewayUrl("", {} as unknown as NodeJS.ProcessEnv)
|
||||
).toThrow("Gateway URL is missing.");
|
||||
});
|
||||
|
||||
it("throws_on_invalid_gateway_url", () => {
|
||||
expect(() =>
|
||||
resolveGatewaySshTargetFromGatewayUrl("not a url", {} as unknown as NodeJS.ProcessEnv)
|
||||
).toThrow("Invalid gateway URL:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createElement } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { HeaderBar } from "@/features/agents/components/HeaderBar";
|
||||
|
||||
describe("HeaderBar controls", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does_not_render_brain_toggle_in_header", () => {
|
||||
render(
|
||||
createElement(HeaderBar, {
|
||||
status: "disconnected",
|
||||
onConnectionSettings: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("brain-files-toggle")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens_menu_and_calls_connection_settings_handler", () => {
|
||||
const onConnectionSettings = vi.fn();
|
||||
|
||||
render(
|
||||
createElement(HeaderBar, {
|
||||
status: "disconnected",
|
||||
onConnectionSettings,
|
||||
})
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("studio-menu-toggle"));
|
||||
fireEvent.click(screen.getByTestId("gateway-settings-toggle"));
|
||||
|
||||
expect(onConnectionSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
listHeartbeatsForAgent,
|
||||
readConfigAgentList,
|
||||
resolveHeartbeatSettings,
|
||||
upsertConfigAgentEntry,
|
||||
writeConfigAgentList,
|
||||
type ConfigAgentEntry,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
|
||||
const makeFakeClient = (responses: {
|
||||
config: Record<string, unknown>;
|
||||
status: Record<string, unknown>;
|
||||
}) => {
|
||||
return {
|
||||
call: async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { config: responses.config, hash: "hash", exists: true };
|
||||
}
|
||||
if (method === "status") {
|
||||
return responses.status;
|
||||
}
|
||||
if (method === "wake") {
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
} as unknown as GatewayClient;
|
||||
};
|
||||
|
||||
describe("heartbeat gateway helpers", () => {
|
||||
it("resolveHeartbeatSettings merges defaults and per-agent overrides", () => {
|
||||
const config: Record<string, unknown> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "30m",
|
||||
target: "last",
|
||||
includeReasoning: false,
|
||||
ackMaxChars: 111,
|
||||
activeHours: { start: "09:00", end: "17:00" },
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "alpha",
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "last",
|
||||
includeReasoning: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveHeartbeatSettings(config, "alpha");
|
||||
expect(resolved.hasOverride).toBe(true);
|
||||
expect(resolved.heartbeat.every).toBe("5m");
|
||||
expect(resolved.heartbeat.includeReasoning).toBe(true);
|
||||
expect(resolved.heartbeat.ackMaxChars).toBe(111);
|
||||
expect(resolved.heartbeat.activeHours).toEqual({ start: "09:00", end: "17:00" });
|
||||
|
||||
const fallback = resolveHeartbeatSettings(config, "beta");
|
||||
expect(fallback.hasOverride).toBe(false);
|
||||
expect(fallback.heartbeat.every).toBe("30m");
|
||||
expect(fallback.heartbeat.includeReasoning).toBe(false);
|
||||
expect(fallback.heartbeat.ackMaxChars).toBe(111);
|
||||
expect(fallback.heartbeat.activeHours).toEqual({ start: "09:00", end: "17:00" });
|
||||
});
|
||||
|
||||
it("listHeartbeatsForAgent returns [] when disabled and no override exists", async () => {
|
||||
const config: Record<string, unknown> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "30m", target: "last", includeReasoning: false },
|
||||
},
|
||||
list: [],
|
||||
},
|
||||
};
|
||||
const status: Record<string, unknown> = {
|
||||
heartbeat: { agents: [{ agentId: "alpha", enabled: false }] },
|
||||
};
|
||||
const client = makeFakeClient({ config, status });
|
||||
|
||||
const result = await listHeartbeatsForAgent(client, "alpha");
|
||||
expect(result.heartbeats).toEqual([]);
|
||||
});
|
||||
|
||||
it('listHeartbeatsForAgent returns one entry with source "default" when enabled and no override exists', async () => {
|
||||
const config: Record<string, unknown> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "30m", target: "last", includeReasoning: false },
|
||||
},
|
||||
list: [],
|
||||
},
|
||||
};
|
||||
const status: Record<string, unknown> = {
|
||||
heartbeat: { agents: [{ agentId: "alpha", enabled: true }] },
|
||||
};
|
||||
const client = makeFakeClient({ config, status });
|
||||
|
||||
const result = await listHeartbeatsForAgent(client, "alpha");
|
||||
expect(result.heartbeats).toHaveLength(1);
|
||||
expect(result.heartbeats[0]?.source).toBe("default");
|
||||
expect(result.heartbeats[0]?.enabled).toBe(true);
|
||||
expect(result.heartbeats[0]?.heartbeat.every).toBe("30m");
|
||||
});
|
||||
|
||||
it('listHeartbeatsForAgent returns one entry with source "override" when an override exists', async () => {
|
||||
const config: Record<string, unknown> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "30m", target: "last", includeReasoning: false },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "alpha",
|
||||
heartbeat: { every: "5m", target: "last", includeReasoning: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const status: Record<string, unknown> = {
|
||||
heartbeat: { agents: [{ agentId: "alpha", enabled: false }] },
|
||||
};
|
||||
const client = makeFakeClient({ config, status });
|
||||
|
||||
const result = await listHeartbeatsForAgent(client, "alpha");
|
||||
expect(result.heartbeats).toHaveLength(1);
|
||||
expect(result.heartbeats[0]?.source).toBe("override");
|
||||
expect(result.heartbeats[0]?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("listHeartbeatsForAgent prefers status every over config-derived every", async () => {
|
||||
const config: Record<string, unknown> = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "30m", target: "last", includeReasoning: false },
|
||||
},
|
||||
list: [],
|
||||
},
|
||||
};
|
||||
const status: Record<string, unknown> = {
|
||||
heartbeat: { agents: [{ agentId: "alpha", enabled: true, every: "7m" }] },
|
||||
};
|
||||
const client = makeFakeClient({ config, status });
|
||||
|
||||
const result = await listHeartbeatsForAgent(client, "alpha");
|
||||
expect(result.heartbeats).toHaveLength(1);
|
||||
expect(result.heartbeats[0]?.heartbeat.every).toBe("7m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway config list helpers", () => {
|
||||
it("reads an empty list when agents.list is missing", () => {
|
||||
expect(readConfigAgentList({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters invalid list entries and keeps id-based entries", () => {
|
||||
const config = {
|
||||
agents: {
|
||||
list: [
|
||||
null,
|
||||
{ id: "agent-1", name: "One" },
|
||||
{ id: "" },
|
||||
{ name: "missing-id" },
|
||||
{ id: "agent-2", heartbeat: { every: "30m" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(readConfigAgentList(config)).toEqual([
|
||||
{ id: "agent-1", name: "One" },
|
||||
{ id: "agent-2", heartbeat: { every: "30m" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes agents.list immutably", () => {
|
||||
const initial: Record<string, unknown> = {
|
||||
agents: { defaults: { heartbeat: { every: "1h" } } },
|
||||
bindings: [{ agentId: "agent-1" }],
|
||||
};
|
||||
const list: ConfigAgentEntry[] = [{ id: "agent-1", name: "One" }];
|
||||
const next = writeConfigAgentList(initial, list);
|
||||
|
||||
expect(next).not.toBe(initial);
|
||||
expect(next.agents).not.toBe(initial.agents);
|
||||
expect((next.agents as Record<string, unknown>).list).toEqual(list);
|
||||
expect((next.agents as Record<string, unknown>).defaults).toEqual({
|
||||
heartbeat: { every: "1h" },
|
||||
});
|
||||
expect(next.bindings).toEqual([{ agentId: "agent-1" }]);
|
||||
});
|
||||
|
||||
it("upserts agent entries", () => {
|
||||
const list: ConfigAgentEntry[] = [
|
||||
{ id: "agent-1", name: "One" },
|
||||
{ id: "agent-2", name: "Two" },
|
||||
];
|
||||
|
||||
const updated = upsertConfigAgentEntry(list, "agent-2", (entry) => ({
|
||||
...entry,
|
||||
name: "Two Updated",
|
||||
}));
|
||||
expect(updated.list).toEqual([
|
||||
{ id: "agent-1", name: "One" },
|
||||
{ id: "agent-2", name: "Two Updated" },
|
||||
]);
|
||||
expect(updated.entry).toEqual({ id: "agent-2", name: "Two Updated" });
|
||||
|
||||
const inserted = upsertConfigAgentEntry(updated.list, "agent-3", (entry) => ({
|
||||
...entry,
|
||||
name: "Three",
|
||||
}));
|
||||
expect(inserted.list).toEqual([
|
||||
{ id: "agent-1", name: "One" },
|
||||
{ id: "agent-2", name: "Two Updated" },
|
||||
{ id: "agent-3", name: "Three" },
|
||||
]);
|
||||
expect(inserted.entry).toEqual({ id: "agent-3", name: "Three" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
listHeartbeatsForAgent,
|
||||
triggerHeartbeatNow,
|
||||
} from "@/lib/gateway/agentConfig";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
describe("heartbeat gateway client", () => {
|
||||
it("returns_empty_list_when_agent_has_no_heartbeat", async () => {
|
||||
const client = {
|
||||
call: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ config: { agents: { list: [{ id: "agent-1" }] } } })
|
||||
.mockResolvedValueOnce({
|
||||
heartbeat: { agents: [{ agentId: "agent-1", enabled: false, every: "disabled" }] },
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
const result = await listHeartbeatsForAgent(client, "agent-1");
|
||||
|
||||
expect(result.heartbeats).toEqual([]);
|
||||
expect(client.call).toHaveBeenCalledTimes(2);
|
||||
expect(client.call).toHaveBeenNthCalledWith(1, "config.get", {});
|
||||
expect(client.call).toHaveBeenNthCalledWith(2, "status", {});
|
||||
});
|
||||
|
||||
it("returns_override_heartbeat_for_agent", async () => {
|
||||
const client = {
|
||||
call: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { heartbeat: { every: "30m", target: "last", includeReasoning: false } },
|
||||
list: [
|
||||
{
|
||||
id: "agent-1",
|
||||
heartbeat: { every: "15m", target: "none", includeReasoning: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
heartbeat: { agents: [{ agentId: "agent-1", enabled: true, every: "15m" }] },
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
const result = await listHeartbeatsForAgent(client, "agent-1");
|
||||
|
||||
expect(result.heartbeats).toEqual([
|
||||
{
|
||||
id: "agent-1",
|
||||
agentId: "agent-1",
|
||||
source: "override",
|
||||
enabled: true,
|
||||
heartbeat: {
|
||||
every: "15m",
|
||||
target: "none",
|
||||
includeReasoning: true,
|
||||
ackMaxChars: 300,
|
||||
activeHours: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("triggers_wake_now_for_heartbeat", async () => {
|
||||
const client = {
|
||||
call: vi.fn(async () => ({ ok: true })),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await triggerHeartbeatNow(client, "agent-1");
|
||||
|
||||
expect(client.call).toHaveBeenCalledWith("wake", {
|
||||
mode: "now",
|
||||
text: "Claw3D heartbeat trigger (agent-1).",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildHistoryMetadataPatch,
|
||||
resolveHistoryRequestIntent,
|
||||
resolveHistoryResponseDisposition,
|
||||
} from "@/features/agents/operations/historyLifecycleWorkflow";
|
||||
import { buildHistorySyncPatch, type ChatHistoryMessage } from "@/features/agents/state/runtimeEventBridge";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
|
||||
const base: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
};
|
||||
return { ...base, ...(overrides ?? {}) };
|
||||
};
|
||||
|
||||
const runPageHistoryAdapter = (params: {
|
||||
requestAgent: AgentState;
|
||||
latestAgent: AgentState | null;
|
||||
messages: ChatHistoryMessage[];
|
||||
requestedLimit?: number;
|
||||
}) => {
|
||||
const requestIntent = resolveHistoryRequestIntent({
|
||||
agent: params.requestAgent,
|
||||
requestedLimit: params.requestedLimit,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-1",
|
||||
loadedAt: 1_234,
|
||||
});
|
||||
if (requestIntent.kind === "skip") {
|
||||
return { disposition: "skip" as const, patch: null, next: params.requestAgent };
|
||||
}
|
||||
|
||||
const latest = params.latestAgent;
|
||||
const disposition = resolveHistoryResponseDisposition({
|
||||
latestAgent: latest,
|
||||
expectedSessionKey: requestIntent.sessionKey,
|
||||
requestEpoch: requestIntent.requestEpoch,
|
||||
requestRevision: requestIntent.requestRevision,
|
||||
});
|
||||
const metadataPatch = buildHistoryMetadataPatch({
|
||||
loadedAt: requestIntent.loadedAt,
|
||||
fetchedCount: params.messages.length,
|
||||
limit: requestIntent.limit,
|
||||
requestId: requestIntent.requestId,
|
||||
});
|
||||
|
||||
if (!latest) {
|
||||
return { disposition: "drop" as const, patch: null, next: params.requestAgent };
|
||||
}
|
||||
|
||||
if (disposition.kind === "drop") {
|
||||
return { disposition: "drop" as const, patch: null, next: latest };
|
||||
}
|
||||
|
||||
const applyPatch = buildHistorySyncPatch({
|
||||
messages: params.messages,
|
||||
currentLines: latest.outputLines,
|
||||
loadedAt: requestIntent.loadedAt,
|
||||
status: latest.status,
|
||||
runId: latest.runId,
|
||||
});
|
||||
const patch = { ...applyPatch, ...metadataPatch };
|
||||
return {
|
||||
disposition: "apply" as const,
|
||||
patch,
|
||||
next: { ...latest, ...patch },
|
||||
};
|
||||
};
|
||||
|
||||
describe("historyLifecycleWorkflow integration", () => {
|
||||
it("page adapter applies transcript patch even when running run is still active", () => {
|
||||
const latest = createAgent({
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
outputLines: ["> user", "assistant draft"],
|
||||
transcriptRevision: 2,
|
||||
});
|
||||
|
||||
const result = runPageHistoryAdapter({
|
||||
requestAgent: latest,
|
||||
latestAgent: latest,
|
||||
messages: [{ role: "assistant", content: "final" }],
|
||||
});
|
||||
|
||||
expect(result.disposition).toBe("apply");
|
||||
expect(result.next.outputLines).toEqual(["> user", "assistant draft", "final"]);
|
||||
expect(result.patch).toEqual({
|
||||
outputLines: ["> user", "assistant draft", "final"],
|
||||
lastResult: "final",
|
||||
latestPreview: "final",
|
||||
historyLoadedAt: 1_234,
|
||||
historyFetchLimit: 200,
|
||||
historyFetchedCount: 1,
|
||||
historyMaybeTruncated: false,
|
||||
lastAppliedHistoryRequestId: "req-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("page adapter drops responses when session epoch changed and preserves existing transcript", () => {
|
||||
const requestAgent = createAgent({
|
||||
outputLines: ["> user", "assistant current"],
|
||||
transcriptRevision: 7,
|
||||
});
|
||||
const latest = createAgent({
|
||||
outputLines: ["> user", "assistant current"],
|
||||
transcriptRevision: 8,
|
||||
sessionEpoch: 1,
|
||||
});
|
||||
|
||||
const result = runPageHistoryAdapter({
|
||||
requestAgent,
|
||||
latestAgent: latest,
|
||||
messages: [{ role: "assistant", content: "assistant stale" }],
|
||||
});
|
||||
|
||||
expect(result.disposition).toBe("drop");
|
||||
expect(result.next.outputLines).toEqual(["> user", "assistant current"]);
|
||||
expect(result.patch).toBeNull();
|
||||
});
|
||||
|
||||
it("page adapter applies transcript merge patch when workflow disposition is apply", () => {
|
||||
const latest = createAgent({
|
||||
outputLines: ["> local question"],
|
||||
transcriptRevision: 1,
|
||||
});
|
||||
|
||||
const result = runPageHistoryAdapter({
|
||||
requestAgent: latest,
|
||||
latestAgent: latest,
|
||||
messages: [{ role: "assistant", content: "Merged answer" }],
|
||||
});
|
||||
|
||||
expect(result.disposition).toBe("apply");
|
||||
expect(result.next.outputLines).toContain("> local question");
|
||||
expect(result.next.outputLines).toContain("Merged answer");
|
||||
expect(result.next.lastResult).toBe("Merged answer");
|
||||
expect(result.next.lastAppliedHistoryRequestId).toBe("req-1");
|
||||
});
|
||||
|
||||
it("page adapter collapses duplicate terminal assistant lines after reconcile-driven history apply", () => {
|
||||
const requestAgent = createAgent({
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
outputLines: ["> question", "final answer", "final answer"],
|
||||
transcriptRevision: 5,
|
||||
});
|
||||
const latest = createAgent({
|
||||
status: "idle",
|
||||
runId: null,
|
||||
outputLines: ["> question", "final answer", "final answer"],
|
||||
transcriptRevision: 5,
|
||||
});
|
||||
|
||||
const result = runPageHistoryAdapter({
|
||||
requestAgent,
|
||||
latestAgent: latest,
|
||||
messages: [
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "final answer" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.disposition).toBe("apply");
|
||||
expect(result.next.outputLines.filter((line) => line === "final answer")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildHistoryMetadataPatch,
|
||||
resolveHistoryRequestIntent,
|
||||
resolveHistoryResponseDisposition,
|
||||
} from "@/features/agents/operations/historyLifecycleWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
|
||||
const base: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
};
|
||||
return { ...base, ...(overrides ?? {}) };
|
||||
};
|
||||
|
||||
describe("historyLifecycleWorkflow", () => {
|
||||
it("returns skip intent when session is missing or not created", () => {
|
||||
expect(
|
||||
resolveHistoryRequestIntent({
|
||||
agent: null,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-1",
|
||||
loadedAt: 1000,
|
||||
})
|
||||
).toEqual({ kind: "skip", reason: "missing-agent" });
|
||||
|
||||
expect(
|
||||
resolveHistoryRequestIntent({
|
||||
agent: createAgent({ sessionCreated: false }),
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-1",
|
||||
loadedAt: 1000,
|
||||
})
|
||||
).toEqual({ kind: "skip", reason: "session-not-created" });
|
||||
|
||||
expect(
|
||||
resolveHistoryRequestIntent({
|
||||
agent: createAgent({ sessionKey: " " }),
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-1",
|
||||
loadedAt: 1000,
|
||||
})
|
||||
).toEqual({ kind: "skip", reason: "missing-session-key" });
|
||||
});
|
||||
|
||||
it("plans history request with bounded limit and request identifiers", () => {
|
||||
expect(
|
||||
resolveHistoryRequestIntent({
|
||||
agent: createAgent({ transcriptRevision: 14, outputLines: ["one", "two"] }),
|
||||
requestedLimit: 9000,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-42",
|
||||
loadedAt: 777,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "fetch",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
limit: 5000,
|
||||
requestRevision: 14,
|
||||
requestEpoch: 0,
|
||||
requestId: "req-42",
|
||||
loadedAt: 777,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveHistoryRequestIntent({
|
||||
agent: createAgent(),
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-2",
|
||||
loadedAt: 2000,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "fetch",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
limit: 200,
|
||||
requestRevision: 0,
|
||||
requestEpoch: 0,
|
||||
requestId: "req-2",
|
||||
loadedAt: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale responses when session key, epoch, or revision changed", () => {
|
||||
expect(
|
||||
resolveHistoryResponseDisposition({
|
||||
latestAgent: createAgent({ sessionKey: "agent:agent-1:other" }),
|
||||
expectedSessionKey: "agent:agent-1:main",
|
||||
requestEpoch: 0,
|
||||
requestRevision: 0,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "drop",
|
||||
reason: "session-key-changed",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveHistoryResponseDisposition({
|
||||
latestAgent: createAgent({ sessionEpoch: 4 }),
|
||||
expectedSessionKey: "agent:agent-1:main",
|
||||
requestEpoch: 3,
|
||||
requestRevision: 0,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "drop",
|
||||
reason: "session-epoch-changed",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveHistoryResponseDisposition({
|
||||
latestAgent: createAgent({ transcriptRevision: 12 }),
|
||||
expectedSessionKey: "agent:agent-1:main",
|
||||
requestEpoch: 0,
|
||||
requestRevision: 11,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "drop",
|
||||
reason: "transcript-revision-changed",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveHistoryResponseDisposition({
|
||||
latestAgent: createAgent({ outputLines: ["one", "two"] }),
|
||||
expectedSessionKey: "agent:agent-1:main",
|
||||
requestEpoch: 0,
|
||||
requestRevision: 1,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "drop",
|
||||
reason: "transcript-revision-changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies history even while run is still active", () => {
|
||||
expect(
|
||||
resolveHistoryResponseDisposition({
|
||||
latestAgent: createAgent({
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
transcriptRevision: 9,
|
||||
}),
|
||||
expectedSessionKey: "agent:agent-1:main",
|
||||
requestEpoch: 0,
|
||||
requestRevision: 9,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "apply",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveHistoryResponseDisposition({
|
||||
latestAgent: createAgent({
|
||||
status: "idle",
|
||||
runId: null,
|
||||
transcriptRevision: 9,
|
||||
}),
|
||||
expectedSessionKey: "agent:agent-1:main",
|
||||
requestEpoch: 0,
|
||||
requestRevision: 9,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "apply",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveHistoryResponseDisposition({
|
||||
latestAgent: createAgent({
|
||||
status: "idle",
|
||||
runId: null,
|
||||
outputLines: ["> q1", "a1"],
|
||||
}),
|
||||
expectedSessionKey: "agent:agent-1:main",
|
||||
requestEpoch: 0,
|
||||
requestRevision: 2,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "apply",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds metadata patch with truncation semantics", () => {
|
||||
expect(
|
||||
buildHistoryMetadataPatch({
|
||||
loadedAt: 123,
|
||||
fetchedCount: 8,
|
||||
limit: 8,
|
||||
requestId: "req-77",
|
||||
})
|
||||
).toEqual({
|
||||
historyLoadedAt: 123,
|
||||
historyFetchLimit: 8,
|
||||
historyFetchedCount: 8,
|
||||
historyMaybeTruncated: true,
|
||||
lastAppliedHistoryRequestId: "req-77",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,553 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
executeHistorySyncCommands,
|
||||
runHistorySyncOperation,
|
||||
type HistorySyncCommand,
|
||||
} from "@/features/agents/operations/historySyncOperation";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { createTranscriptEntryFromLine } from "@/features/agents/state/transcript";
|
||||
|
||||
describe("historySyncOperation integration", () => {
|
||||
it("executes dispatch and metric commands and suppresses disconnect-like errors", () => {
|
||||
const dispatch = vi.fn();
|
||||
const logMetric = vi.fn();
|
||||
const logError = vi.fn();
|
||||
const commands: HistorySyncCommand[] = [
|
||||
{
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { historyLoadedAt: 1234 } as Partial<AgentState>,
|
||||
},
|
||||
{
|
||||
kind: "logMetric",
|
||||
metric: "history_sync_test_metric",
|
||||
meta: { agentId: "agent-1", requestId: "req-1", runId: "run-1" },
|
||||
},
|
||||
{
|
||||
kind: "logError",
|
||||
message: "Disconnected",
|
||||
error: new Error("socket disconnected"),
|
||||
},
|
||||
{
|
||||
kind: "logError",
|
||||
message: "Unexpected failure",
|
||||
error: new Error("boom"),
|
||||
},
|
||||
{ kind: "noop", reason: "missing-agent" },
|
||||
];
|
||||
|
||||
executeHistorySyncCommands({
|
||||
commands,
|
||||
dispatch,
|
||||
logMetric,
|
||||
isDisconnectLikeError: (error) =>
|
||||
error instanceof Error && error.message.toLowerCase().includes("disconnected"),
|
||||
logError,
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { historyLoadedAt: 1234 },
|
||||
});
|
||||
expect(logMetric).toHaveBeenCalledTimes(1);
|
||||
expect(logMetric).toHaveBeenCalledWith("history_sync_test_metric", {
|
||||
agentId: "agent-1",
|
||||
requestId: "req-1",
|
||||
runId: "run-1",
|
||||
});
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
expect(logError).toHaveBeenCalledWith("Unexpected failure", expect.any(Error));
|
||||
});
|
||||
|
||||
it("collapses duplicate non-active run assistant terminals during gap recovery history sync", async () => {
|
||||
const sessionKey = "agent:agent-1:main";
|
||||
const duplicateOne = createTranscriptEntryFromLine({
|
||||
line: "final answer",
|
||||
sessionKey,
|
||||
source: "runtime-agent",
|
||||
sequenceKey: 10,
|
||||
runId: "run-1",
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
entryId: "runtime-agent:run-1:final-1",
|
||||
confirmed: false,
|
||||
});
|
||||
const duplicateTwo = createTranscriptEntryFromLine({
|
||||
line: "final answer",
|
||||
sessionKey,
|
||||
source: "runtime-chat",
|
||||
sequenceKey: 11,
|
||||
runId: "run-1",
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
entryId: "runtime-chat:run-1:final-2",
|
||||
confirmed: true,
|
||||
});
|
||||
if (!duplicateOne || !duplicateTwo) {
|
||||
throw new Error("Expected transcript entries.");
|
||||
}
|
||||
|
||||
const requestAgent: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey,
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: ["> question", "final answer", "final answer"],
|
||||
lastResult: "final answer",
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: "final answer",
|
||||
lastUserMessage: "question",
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
transcriptEntries: [duplicateOne, duplicateTwo],
|
||||
transcriptRevision: 2,
|
||||
transcriptSequenceCounter: 12,
|
||||
};
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey,
|
||||
messages: [
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "final answer" },
|
||||
],
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => requestAgent,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-gap-1",
|
||||
loadedAt: 10_000,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent");
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate || finalUpdate.kind !== "dispatchUpdateAgent") {
|
||||
throw new Error("Expected final dispatch update.");
|
||||
}
|
||||
const lines = finalUpdate.patch.outputLines ?? [];
|
||||
expect(lines.filter((line) => line === "final answer")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves assistant duplicates for the active running run", async () => {
|
||||
const sessionKey = "agent:agent-1:main";
|
||||
const duplicateOne = createTranscriptEntryFromLine({
|
||||
line: "stream line",
|
||||
sessionKey,
|
||||
source: "runtime-agent",
|
||||
sequenceKey: 20,
|
||||
runId: "run-active",
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
entryId: "runtime-agent:run-active:1",
|
||||
confirmed: false,
|
||||
});
|
||||
const duplicateTwo = createTranscriptEntryFromLine({
|
||||
line: "stream line",
|
||||
sessionKey,
|
||||
source: "runtime-chat",
|
||||
sequenceKey: 21,
|
||||
runId: "run-active",
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
entryId: "runtime-chat:run-active:2",
|
||||
confirmed: true,
|
||||
});
|
||||
if (!duplicateOne || !duplicateTwo) {
|
||||
throw new Error("Expected transcript entries.");
|
||||
}
|
||||
|
||||
const runningAgent: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey,
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: ["> question", "stream line", "stream line"],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: "run-active",
|
||||
runStartedAt: 1_000,
|
||||
streamText: "stream line",
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: "stream line",
|
||||
lastUserMessage: "question",
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
transcriptEntries: [duplicateOne, duplicateTwo],
|
||||
transcriptRevision: 4,
|
||||
transcriptSequenceCounter: 22,
|
||||
};
|
||||
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey,
|
||||
messages: [{ role: "assistant", content: "stream line" }],
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => runningAgent,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-gap-2",
|
||||
loadedAt: 11_000,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent");
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate || finalUpdate.kind !== "dispatchUpdateAgent") {
|
||||
throw new Error("Expected final dispatch update.");
|
||||
}
|
||||
const lines = finalUpdate.patch.outputLines ?? [];
|
||||
expect(lines.filter((line) => line === "stream line")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps repeated canonical history entries when content and timestamp are identical", async () => {
|
||||
const sessionKey = "agent:agent-1:main";
|
||||
const agent: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey,
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
transcriptEntries: [],
|
||||
transcriptRevision: 0,
|
||||
transcriptSequenceCounter: 0,
|
||||
};
|
||||
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey,
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
content: "same line",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
content: "same line",
|
||||
},
|
||||
],
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => agent,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-dup-history",
|
||||
loadedAt: 12_000,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent");
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate || finalUpdate.kind !== "dispatchUpdateAgent") {
|
||||
throw new Error("Expected final dispatch update.");
|
||||
}
|
||||
const lines = finalUpdate.patch.outputLines ?? [];
|
||||
expect(lines.filter((line) => line === "same line")).toHaveLength(2);
|
||||
const transcriptEntries = finalUpdate.patch.transcriptEntries ?? [];
|
||||
expect(
|
||||
transcriptEntries.filter((entry) => entry.kind === "assistant" && entry.text === "same line")
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not replay prior confirmed assistant turn during running history refresh", async () => {
|
||||
const sessionKey = "agent:agent-1:main";
|
||||
const priorUser = createTranscriptEntryFromLine({
|
||||
line: "> what should we work on today?",
|
||||
sessionKey,
|
||||
source: "history",
|
||||
sequenceKey: 1,
|
||||
runId: null,
|
||||
role: "user",
|
||||
kind: "user",
|
||||
confirmed: true,
|
||||
entryId: "history:user:prior",
|
||||
});
|
||||
const priorAssistant = createTranscriptEntryFromLine({
|
||||
line: "win + progress + cleanup",
|
||||
sessionKey,
|
||||
source: "runtime-chat",
|
||||
sequenceKey: 2,
|
||||
runId: "run-prior",
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
confirmed: true,
|
||||
entryId: "run:run-prior:assistant:final",
|
||||
});
|
||||
const nextUser = createTranscriptEntryFromLine({
|
||||
line: "> naw - sounds boring",
|
||||
sessionKey,
|
||||
source: "local-send",
|
||||
sequenceKey: 3,
|
||||
runId: "run-active",
|
||||
role: "user",
|
||||
kind: "user",
|
||||
confirmed: false,
|
||||
entryId: "local:user:next",
|
||||
});
|
||||
if (!priorUser || !priorAssistant || !nextUser) {
|
||||
throw new Error("Expected transcript entries.");
|
||||
}
|
||||
|
||||
const runningAgent: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey,
|
||||
status: "running",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [
|
||||
"> what should we work on today?",
|
||||
"win + progress + cleanup",
|
||||
"> naw - sounds boring",
|
||||
],
|
||||
lastResult: "win + progress + cleanup",
|
||||
lastDiff: null,
|
||||
runId: "run-active",
|
||||
runStartedAt: 10_000,
|
||||
streamText: "",
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: 9_000,
|
||||
lastActivityAt: 10_000,
|
||||
latestPreview: "win + progress + cleanup",
|
||||
lastUserMessage: "naw - sounds boring",
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
transcriptEntries: [priorUser, priorAssistant, nextUser],
|
||||
transcriptRevision: 7,
|
||||
transcriptSequenceCounter: 4,
|
||||
};
|
||||
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey,
|
||||
messages: [
|
||||
{ role: "user", content: "what should we work on today?" },
|
||||
{ role: "assistant", content: "win + progress + cleanup" },
|
||||
],
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => runningAgent,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-replay-1",
|
||||
loadedAt: 15_000,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent");
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate || finalUpdate.kind !== "dispatchUpdateAgent") {
|
||||
throw new Error("Expected final dispatch update.");
|
||||
}
|
||||
const lines = finalUpdate.patch.outputLines ?? runningAgent.outputLines;
|
||||
expect(lines.filter((line) => line === "win + progress + cleanup")).toHaveLength(1);
|
||||
const transcriptEntries =
|
||||
finalUpdate.patch.transcriptEntries ?? runningAgent.transcriptEntries ?? [];
|
||||
expect(
|
||||
transcriptEntries.filter(
|
||||
(entry) => entry.kind === "assistant" && entry.text === "win + progress + cleanup"
|
||||
)
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("drops stale history response when transcript revision changes after request", async () => {
|
||||
const requestAgent: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: ["> local question", "assistant current"],
|
||||
lastResult: "assistant current",
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: "assistant current",
|
||||
lastUserMessage: "local question",
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
transcriptEntries: [],
|
||||
transcriptRevision: 7,
|
||||
transcriptSequenceCounter: 0,
|
||||
sessionEpoch: 0,
|
||||
};
|
||||
const latestAgent: AgentState = {
|
||||
...requestAgent,
|
||||
transcriptRevision: 8,
|
||||
};
|
||||
let readCount = 0;
|
||||
const inFlightSessionKeys = new Set<string>();
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey: requestAgent.sessionKey,
|
||||
messages: [{ role: "assistant", content: "stale remote answer" }],
|
||||
}) as T,
|
||||
},
|
||||
agentId: requestAgent.agentId,
|
||||
getAgent: () => {
|
||||
readCount += 1;
|
||||
return readCount <= 1 ? requestAgent : latestAgent;
|
||||
},
|
||||
inFlightSessionKeys,
|
||||
requestId: "req-revision-drop-1",
|
||||
loadedAt: 16_000,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = commands.filter((entry) => entry.kind === "dispatchUpdateAgent");
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]).toEqual({
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { lastHistoryRequestRevision: 7 },
|
||||
});
|
||||
|
||||
const staleDropMetrics = commands.filter(
|
||||
(entry) => entry.kind === "logMetric" && entry.metric === "history_response_dropped_stale"
|
||||
);
|
||||
expect(staleDropMetrics).toEqual([
|
||||
{
|
||||
kind: "logMetric",
|
||||
metric: "history_response_dropped_stale",
|
||||
meta: {
|
||||
reason: "transcript_revision_changed",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-revision-drop-1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
updates.some((entry) => {
|
||||
const patch = entry.patch;
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(patch, "outputLines") ||
|
||||
Object.prototype.hasOwnProperty.call(patch, "lastAppliedHistoryRequestId")
|
||||
);
|
||||
})
|
||||
).toBe(false);
|
||||
expect(inFlightSessionKeys.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
runHistorySyncOperation,
|
||||
type HistorySyncCommand,
|
||||
} from "@/features/agents/operations/historySyncOperation";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
type ChatHistoryMessage = Record<string, unknown>;
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
|
||||
const base: AgentState = {
|
||||
agentId: "agent-1",
|
||||
name: "Agent One",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: "seed-1",
|
||||
avatarUrl: null,
|
||||
};
|
||||
return { ...base, ...(overrides ?? {}) };
|
||||
};
|
||||
|
||||
const getCommandsByKind = <TKind extends HistorySyncCommand["kind"]>(
|
||||
commands: HistorySyncCommand[],
|
||||
kind: TKind
|
||||
): Array<Extract<HistorySyncCommand, { kind: TKind }>> =>
|
||||
commands.filter((command) => command.kind === kind) as Array<
|
||||
Extract<HistorySyncCommand, { kind: TKind }>
|
||||
>;
|
||||
|
||||
describe("historySyncOperation", () => {
|
||||
it("returns noop when request intent resolves to skip", async () => {
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() => ({ messages: [] as ChatHistoryMessage[] }) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => null,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-1",
|
||||
loadedAt: 1_234,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
expect(commands).toEqual([{ kind: "noop", reason: "missing-agent" }]);
|
||||
});
|
||||
|
||||
it("applies history updates even when latest agent is running with active run", async () => {
|
||||
const agent = createAgent({
|
||||
status: "running",
|
||||
runId: "run-1",
|
||||
transcriptRevision: 3,
|
||||
outputLines: ["> local question", "assistant draft"],
|
||||
});
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey: agent.sessionKey,
|
||||
messages: [{ role: "assistant", content: "remote answer" }],
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => agent,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-2",
|
||||
loadedAt: 2_345,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = getCommandsByKind(commands, "dispatchUpdateAgent");
|
||||
const metrics = getCommandsByKind(commands, "logMetric");
|
||||
expect(metrics).toEqual([]);
|
||||
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate) throw new Error("Expected final update command.");
|
||||
const patch = finalUpdate.patch;
|
||||
expect(patch.outputLines).toContain("> local question");
|
||||
expect(patch.outputLines).toContain("assistant draft");
|
||||
expect(patch.outputLines).toContain("remote answer");
|
||||
expect(patch.lastResult).toBe("remote answer");
|
||||
expect(patch.latestPreview).toBe("remote answer");
|
||||
expect(patch.lastAppliedHistoryRequestId).toBe("req-2");
|
||||
});
|
||||
|
||||
it("returns transcript merge update commands when disposition is apply and transcript v2 is enabled", async () => {
|
||||
const agent = createAgent({
|
||||
transcriptRevision: 1,
|
||||
outputLines: ["> local question"],
|
||||
});
|
||||
const markdownAssistant = [
|
||||
"- first bullet",
|
||||
"- second bullet",
|
||||
"",
|
||||
"```ts",
|
||||
"console.log('merged answer');",
|
||||
"```",
|
||||
].join("\n");
|
||||
const messages: ChatHistoryMessage[] = [{ role: "assistant", content: markdownAssistant }];
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey: agent.sessionKey,
|
||||
messages,
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => agent,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-3",
|
||||
loadedAt: 3_456,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = getCommandsByKind(commands, "dispatchUpdateAgent");
|
||||
expect(updates.length).toBeGreaterThanOrEqual(2);
|
||||
expect(updates).toContainEqual({
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { lastHistoryRequestRevision: 1 },
|
||||
});
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate) throw new Error("Expected final update command.");
|
||||
const patch = finalUpdate.patch;
|
||||
expect(Array.isArray(patch.outputLines)).toBe(true);
|
||||
expect(patch.outputLines).toContain("> local question");
|
||||
expect(patch.outputLines).toContain(markdownAssistant);
|
||||
expect(patch.lastResult).toBe(markdownAssistant);
|
||||
expect(patch.latestPreview).toBe(markdownAssistant);
|
||||
expect(patch.lastAppliedHistoryRequestId).toBe("req-3");
|
||||
});
|
||||
|
||||
it("normalizes assistant text in transcript-v2 history sync patches", async () => {
|
||||
const agent = createAgent({
|
||||
transcriptRevision: 1,
|
||||
outputLines: ["> local question"],
|
||||
});
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey: agent.sessionKey,
|
||||
messages: [{ role: "assistant", content: "\n- alpha \n\n\n- beta\t \n\n" }],
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => agent,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-3b",
|
||||
loadedAt: 3_789,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = getCommandsByKind(commands, "dispatchUpdateAgent");
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate) throw new Error("Expected final update command.");
|
||||
const patch = finalUpdate.patch;
|
||||
expect(Array.isArray(patch.outputLines)).toBe(true);
|
||||
expect(patch.outputLines).toContain("> local question");
|
||||
expect(patch.outputLines).toContain("- alpha\n\n- beta");
|
||||
expect(patch.lastResult).toBe("- alpha\n\n- beta");
|
||||
expect(patch.latestPreview).toBe("- alpha\n\n- beta");
|
||||
expect(patch.lastAppliedHistoryRequestId).toBe("req-3b");
|
||||
});
|
||||
|
||||
it("infers running state from recent user-terminal history in transcript-v2 mode", async () => {
|
||||
const agent = createAgent({
|
||||
status: "idle",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
transcriptRevision: 1,
|
||||
outputLines: [],
|
||||
});
|
||||
const loadedAt = Date.parse("2024-01-01T00:30:00.000Z");
|
||||
const lastUserAt = Date.parse("2024-01-01T00:29:40.000Z");
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey: agent.sessionKey,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
timestamp: new Date(lastUserAt).toISOString(),
|
||||
content: "still working?",
|
||||
},
|
||||
],
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => agent,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-3c",
|
||||
loadedAt,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = getCommandsByKind(commands, "dispatchUpdateAgent");
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate) throw new Error("Expected final update command.");
|
||||
const patch = finalUpdate.patch;
|
||||
expect(patch.status).toBe("running");
|
||||
expect(patch.runId).toBeNull();
|
||||
expect(patch.runStartedAt).toBe(lastUserAt);
|
||||
expect(patch.streamText).toBeNull();
|
||||
expect(patch.thinkingTrace).toBeNull();
|
||||
expect(patch.lastUserMessage).toBe("still working?");
|
||||
expect(patch.lastAppliedHistoryRequestId).toBe("req-3c");
|
||||
});
|
||||
|
||||
it("does not infer running state when terminal history includes an aborted assistant message", async () => {
|
||||
const agent = createAgent({
|
||||
status: "idle",
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
transcriptRevision: 1,
|
||||
outputLines: [],
|
||||
});
|
||||
const loadedAt = Date.parse("2024-01-01T00:30:00.000Z");
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey: agent.sessionKey,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
timestamp: "2024-01-01T00:29:40.000Z",
|
||||
content: "still working?",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
timestamp: "2024-01-01T00:29:41.000Z",
|
||||
content: [],
|
||||
stopReason: "aborted",
|
||||
errorMessage: "Request was aborted",
|
||||
},
|
||||
],
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => agent,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-3d",
|
||||
loadedAt,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const updates = getCommandsByKind(commands, "dispatchUpdateAgent");
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate) throw new Error("Expected final update command.");
|
||||
const patch = finalUpdate.patch;
|
||||
expect(patch.status).toBeUndefined();
|
||||
expect(patch.runId).toBeUndefined();
|
||||
expect(Array.isArray(patch.outputLines)).toBe(true);
|
||||
expect(patch.outputLines).toContain("Run aborted.");
|
||||
expect(patch.lastResult).toBe("Run aborted.");
|
||||
expect(patch.latestPreview).toBe("Run aborted.");
|
||||
expect(patch.lastAppliedHistoryRequestId).toBe("req-3d");
|
||||
});
|
||||
|
||||
it("returns legacy history sync patch command when transcript v2 is disabled", async () => {
|
||||
const agent = createAgent({
|
||||
transcriptRevision: 0,
|
||||
outputLines: ["> local question"],
|
||||
});
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey: agent.sessionKey,
|
||||
messages: [{ role: "assistant", content: "Legacy answer" }],
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => agent,
|
||||
inFlightSessionKeys: new Set<string>(),
|
||||
requestId: "req-4",
|
||||
loadedAt: 4_567,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: false,
|
||||
});
|
||||
|
||||
const updates = getCommandsByKind(commands, "dispatchUpdateAgent");
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate) throw new Error("Expected final update command.");
|
||||
const patch = finalUpdate.patch;
|
||||
expect(patch.outputLines).toContain("> local question");
|
||||
expect(patch.outputLines).toContain("Legacy answer");
|
||||
expect(patch.lastResult).toBe("Legacy answer");
|
||||
expect(patch.lastAppliedHistoryRequestId).toBe("req-4");
|
||||
});
|
||||
|
||||
it("drops stale history when transcript revision changes during fetch", async () => {
|
||||
const requestAgent = createAgent({
|
||||
transcriptRevision: 7,
|
||||
outputLines: ["> local question", "assistant current"],
|
||||
});
|
||||
const latestAgent = createAgent({
|
||||
transcriptRevision: 8,
|
||||
outputLines: ["> local question", "assistant current"],
|
||||
});
|
||||
let readCount = 0;
|
||||
const inFlight = new Set<string>();
|
||||
const commands = await runHistorySyncOperation({
|
||||
client: {
|
||||
call: async <T>() =>
|
||||
({
|
||||
sessionKey: requestAgent.sessionKey,
|
||||
messages: [{ role: "assistant", content: "stale remote answer" }],
|
||||
}) as T,
|
||||
},
|
||||
agentId: "agent-1",
|
||||
getAgent: () => {
|
||||
readCount += 1;
|
||||
return readCount <= 1 ? requestAgent : latestAgent;
|
||||
},
|
||||
inFlightSessionKeys: inFlight,
|
||||
requestId: "req-5",
|
||||
loadedAt: 5_678,
|
||||
defaultLimit: 200,
|
||||
maxLimit: 5000,
|
||||
transcriptV2Enabled: true,
|
||||
});
|
||||
|
||||
const metrics = getCommandsByKind(commands, "logMetric");
|
||||
expect(metrics).toEqual([
|
||||
{
|
||||
kind: "logMetric",
|
||||
metric: "history_response_dropped_stale",
|
||||
meta: {
|
||||
reason: "transcript_revision_changed",
|
||||
agentId: "agent-1",
|
||||
requestId: "req-5",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const updates = getCommandsByKind(commands, "dispatchUpdateAgent");
|
||||
expect(updates).toContainEqual({
|
||||
kind: "dispatchUpdateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: { lastHistoryRequestRevision: 7 },
|
||||
});
|
||||
const finalUpdate = updates[updates.length - 1];
|
||||
if (!finalUpdate) throw new Error("Expected final update command.");
|
||||
const patch = finalUpdate.patch;
|
||||
expect(patch).not.toHaveProperty("outputLines");
|
||||
expect(patch).not.toHaveProperty("lastAppliedHistoryRequestId");
|
||||
expect(patch).not.toHaveProperty("lastResult");
|
||||
expect(patch).not.toHaveProperty("latestPreview");
|
||||
expect(inFlight.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildJanitorActorsForCue,
|
||||
JANITOR_SWEEP_DURATION_MS,
|
||||
pruneExpiredJanitorActors,
|
||||
} from "@/features/retro-office/core/janitors";
|
||||
|
||||
describe("janitor actors", () => {
|
||||
it("builds temporary janitor actors with routes and expiry", () => {
|
||||
const cue = {
|
||||
id: "cue-1",
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent One",
|
||||
ts: 1_700_000_000_000,
|
||||
};
|
||||
const actors = buildJanitorActorsForCue(cue, [
|
||||
{ x: 100, y: 100, facing: 0 },
|
||||
{ x: 200, y: 200, facing: Math.PI / 2 },
|
||||
{ x: 300, y: 300, facing: Math.PI },
|
||||
]);
|
||||
|
||||
expect(actors).toHaveLength(3);
|
||||
expect(actors.every((actor) => actor.role === "janitor")).toBe(true);
|
||||
expect(actors.every((actor) => actor.janitorRoute.length >= 6)).toBe(true);
|
||||
expect(
|
||||
actors.every(
|
||||
(actor) => actor.janitorDespawnAt === cue.ts + JANITOR_SWEEP_DURATION_MS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("prunes janitors after their sweep duration ends", () => {
|
||||
const cue = {
|
||||
id: "cue-2",
|
||||
agentId: "agent-2",
|
||||
agentName: "Agent Two",
|
||||
ts: 10_000,
|
||||
};
|
||||
const actors = buildJanitorActorsForCue(cue, [
|
||||
{ x: 140, y: 160, facing: 0 },
|
||||
{ x: 260, y: 260, facing: Math.PI / 2 },
|
||||
]);
|
||||
|
||||
expect(pruneExpiredJanitorActors(actors, cue.ts + JANITOR_SWEEP_DURATION_MS - 1)).toHaveLength(
|
||||
actors.length,
|
||||
);
|
||||
expect(pruneExpiredJanitorActors(actors, cue.ts + JANITOR_SWEEP_DURATION_MS)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AgentState, AgentStoreSeed } from "@/features/agents/state/store";
|
||||
import { buildSessionEpochSnapshot, resolveResetAgentIds } from "@/lib/office/janitorReset";
|
||||
|
||||
const makeAgent = (
|
||||
overrides: Partial<AgentState> & Pick<AgentStoreSeed, "agentId" | "name" | "sessionKey">,
|
||||
): AgentState => ({
|
||||
agentId: overrides.agentId,
|
||||
name: overrides.name,
|
||||
sessionKey: overrides.sessionKey,
|
||||
avatarSeed: overrides.avatarSeed ?? overrides.agentId,
|
||||
avatarUrl: overrides.avatarUrl ?? null,
|
||||
model: overrides.model ?? null,
|
||||
thinkingLevel: overrides.thinkingLevel ?? "high",
|
||||
sessionExecHost: overrides.sessionExecHost,
|
||||
sessionExecSecurity: overrides.sessionExecSecurity,
|
||||
sessionExecAsk: overrides.sessionExecAsk,
|
||||
status: overrides.status ?? "idle",
|
||||
sessionCreated: overrides.sessionCreated ?? false,
|
||||
awaitingUserInput: overrides.awaitingUserInput ?? false,
|
||||
hasUnseenActivity: overrides.hasUnseenActivity ?? false,
|
||||
outputLines: overrides.outputLines ?? [],
|
||||
lastResult: overrides.lastResult ?? null,
|
||||
lastDiff: overrides.lastDiff ?? null,
|
||||
runId: overrides.runId ?? null,
|
||||
runStartedAt: overrides.runStartedAt ?? null,
|
||||
streamText: overrides.streamText ?? null,
|
||||
thinkingTrace: overrides.thinkingTrace ?? null,
|
||||
latestOverride: overrides.latestOverride ?? null,
|
||||
latestOverrideKind: overrides.latestOverrideKind ?? null,
|
||||
lastAssistantMessageAt: overrides.lastAssistantMessageAt ?? null,
|
||||
lastActivityAt: overrides.lastActivityAt ?? null,
|
||||
latestPreview: overrides.latestPreview ?? null,
|
||||
lastUserMessage: overrides.lastUserMessage ?? null,
|
||||
draft: overrides.draft ?? "",
|
||||
queuedMessages: overrides.queuedMessages ?? [],
|
||||
sessionSettingsSynced: overrides.sessionSettingsSynced ?? false,
|
||||
historyLoadedAt: overrides.historyLoadedAt ?? null,
|
||||
historyFetchLimit: overrides.historyFetchLimit ?? null,
|
||||
historyFetchedCount: overrides.historyFetchedCount ?? null,
|
||||
historyMaybeTruncated: overrides.historyMaybeTruncated ?? false,
|
||||
toolCallingEnabled: overrides.toolCallingEnabled ?? false,
|
||||
showThinkingTraces: overrides.showThinkingTraces ?? true,
|
||||
transcriptEntries: overrides.transcriptEntries ?? [],
|
||||
transcriptRevision: overrides.transcriptRevision ?? 0,
|
||||
transcriptSequenceCounter: overrides.transcriptSequenceCounter ?? 0,
|
||||
sessionEpoch: overrides.sessionEpoch ?? 0,
|
||||
lastHistoryRequestRevision: overrides.lastHistoryRequestRevision ?? null,
|
||||
lastAppliedHistoryRequestId: overrides.lastAppliedHistoryRequestId ?? null,
|
||||
});
|
||||
|
||||
describe("janitor reset signal", () => {
|
||||
it("triggers janitors when sessionEpoch increases", () => {
|
||||
const previous = [
|
||||
makeAgent({ agentId: "a1", name: "A1", sessionKey: "key", sessionEpoch: 2 }),
|
||||
];
|
||||
const current = [
|
||||
makeAgent({ agentId: "a1", name: "A1", sessionKey: "key", sessionEpoch: 3 }),
|
||||
];
|
||||
|
||||
const triggered = resolveResetAgentIds({
|
||||
previous: buildSessionEpochSnapshot(previous),
|
||||
agents: current,
|
||||
});
|
||||
|
||||
expect(triggered).toEqual(["a1"]);
|
||||
});
|
||||
|
||||
it("triggers janitors when session key changes and epoch increases", () => {
|
||||
const previous = [
|
||||
makeAgent({ agentId: "a1", name: "A1", sessionKey: "old", sessionEpoch: 2 }),
|
||||
];
|
||||
const current = [
|
||||
makeAgent({ agentId: "a1", name: "A1", sessionKey: "new", sessionEpoch: 3 }),
|
||||
];
|
||||
|
||||
const triggered = resolveResetAgentIds({
|
||||
previous: buildSessionEpochSnapshot(previous),
|
||||
agents: current,
|
||||
});
|
||||
|
||||
expect(triggered).toEqual(["a1"]);
|
||||
});
|
||||
|
||||
it("does not trigger when sessionEpoch stays the same", () => {
|
||||
const previous = [
|
||||
makeAgent({ agentId: "a1", name: "A1", sessionKey: "key", sessionEpoch: 2 }),
|
||||
];
|
||||
const current = [
|
||||
makeAgent({ agentId: "a1", name: "A1", sessionKey: "key", sessionEpoch: 2 }),
|
||||
];
|
||||
|
||||
const triggered = resolveResetAgentIds({
|
||||
previous: buildSessionEpochSnapshot(previous),
|
||||
agents: current,
|
||||
});
|
||||
|
||||
expect(triggered).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores first hydration without prior snapshot", () => {
|
||||
const current = [
|
||||
makeAgent({ agentId: "a1", name: "A1", sessionKey: "key", sessionEpoch: 1 }),
|
||||
];
|
||||
|
||||
const triggered = resolveResetAgentIds({
|
||||
previous: {},
|
||||
agents: current,
|
||||
});
|
||||
|
||||
expect(triggered).toEqual([]);
|
||||
});
|
||||
|
||||
it("triggers for multiple agents independently", () => {
|
||||
const previous = [
|
||||
makeAgent({ agentId: "a1", name: "A1", sessionKey: "k1", sessionEpoch: 1 }),
|
||||
makeAgent({ agentId: "a2", name: "A2", sessionKey: "k2", sessionEpoch: 3 }),
|
||||
];
|
||||
const current = [
|
||||
makeAgent({ agentId: "a1", name: "A1", sessionKey: "k1", sessionEpoch: 2 }),
|
||||
makeAgent({ agentId: "a2", name: "A2", sessionKey: "k2", sessionEpoch: 3 }),
|
||||
];
|
||||
|
||||
const triggered = resolveResetAgentIds({
|
||||
previous: buildSessionEpochSnapshot(previous),
|
||||
agents: current,
|
||||
});
|
||||
|
||||
expect(triggered).toEqual(["a1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildLatestUpdatePatch,
|
||||
resolveLatestUpdateIntent,
|
||||
resolveLatestUpdateKind,
|
||||
} from "@/features/agents/operations/latestUpdateWorkflow";
|
||||
|
||||
describe("latestUpdateWorkflow", () => {
|
||||
it("resolves latest-update kind as heartbeat, cron, or none from message content", () => {
|
||||
expect(resolveLatestUpdateKind("")).toBeNull();
|
||||
expect(resolveLatestUpdateKind("check heartbeat status")).toBe("heartbeat");
|
||||
expect(resolveLatestUpdateKind("cron report pending")).toBe("cron");
|
||||
expect(resolveLatestUpdateKind("heartbeat then cron")).toBe("cron");
|
||||
expect(resolveLatestUpdateKind("cron then heartbeat")).toBe("heartbeat");
|
||||
});
|
||||
|
||||
it("returns reset intent when no latest-update kind is present and existing override is set", () => {
|
||||
expect(
|
||||
resolveLatestUpdateIntent({
|
||||
message: "plain user prompt",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
hasExistingOverride: true,
|
||||
})
|
||||
).toEqual({ kind: "reset" });
|
||||
expect(
|
||||
resolveLatestUpdateIntent({
|
||||
message: "plain user prompt",
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
hasExistingOverride: false,
|
||||
})
|
||||
).toEqual({ kind: "noop" });
|
||||
});
|
||||
|
||||
it("returns heartbeat fetch intent with fallback session strategy", () => {
|
||||
expect(
|
||||
resolveLatestUpdateIntent({
|
||||
message: "heartbeat please",
|
||||
agentId: "",
|
||||
sessionKey: "agent:fallback-agent:main",
|
||||
hasExistingOverride: false,
|
||||
})
|
||||
).toEqual({
|
||||
kind: "fetch-heartbeat",
|
||||
agentId: "fallback-agent",
|
||||
sessionLimit: 48,
|
||||
historyLimit: 200,
|
||||
});
|
||||
expect(
|
||||
resolveLatestUpdateIntent({
|
||||
message: "heartbeat please",
|
||||
agentId: "",
|
||||
sessionKey: "invalid",
|
||||
hasExistingOverride: false,
|
||||
})
|
||||
).toEqual({ kind: "reset" });
|
||||
});
|
||||
|
||||
it("maps fetched content into latest override patch semantics", () => {
|
||||
expect(buildLatestUpdatePatch("", "heartbeat")).toEqual({
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
});
|
||||
expect(buildLatestUpdatePatch("Heartbeat is healthy.", "heartbeat")).toEqual({
|
||||
latestOverride: "Heartbeat is healthy.",
|
||||
latestOverrideKind: "heartbeat",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import { resolveExecApprovalFollowUpIntent } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
|
||||
const createAgent = (agentId: string, sessionKey: string): AgentState => ({
|
||||
agentId,
|
||||
name: agentId,
|
||||
sessionKey,
|
||||
status: "idle",
|
||||
sessionCreated: true,
|
||||
awaitingUserInput: false,
|
||||
hasUnseenActivity: false,
|
||||
outputLines: [],
|
||||
lastResult: null,
|
||||
lastDiff: null,
|
||||
runId: null,
|
||||
runStartedAt: null,
|
||||
streamText: null,
|
||||
thinkingTrace: null,
|
||||
latestOverride: null,
|
||||
latestOverrideKind: null,
|
||||
lastAssistantMessageAt: null,
|
||||
lastActivityAt: null,
|
||||
latestPreview: null,
|
||||
lastUserMessage: null,
|
||||
draft: "",
|
||||
sessionSettingsSynced: true,
|
||||
historyLoadedAt: null,
|
||||
historyFetchLimit: null,
|
||||
historyFetchedCount: null,
|
||||
historyMaybeTruncated: false,
|
||||
toolCallingEnabled: true,
|
||||
showThinkingTraces: true,
|
||||
model: "openai/gpt-5",
|
||||
thinkingLevel: "medium",
|
||||
avatarSeed: agentId,
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
const createApproval = (): PendingExecApproval => ({
|
||||
id: "approval-1",
|
||||
agentId: null,
|
||||
sessionKey: "agent:agent-1:main",
|
||||
command: "npm run test",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
resolvedPath: "/usr/bin/npm",
|
||||
createdAtMs: 1,
|
||||
expiresAtMs: 2,
|
||||
resolving: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
describe("lifecycleControllerWorkflow integration", () => {
|
||||
it("allow-once and allow-always still trigger follow-up message send once", () => {
|
||||
const approval = createApproval();
|
||||
const agents = [createAgent("agent-1", "agent:agent-1:main")];
|
||||
let sendCount = 0;
|
||||
|
||||
for (const decision of ["allow-once", "allow-always"] as const) {
|
||||
const intent = resolveExecApprovalFollowUpIntent({
|
||||
decision,
|
||||
approval,
|
||||
agents,
|
||||
followUpMessage: "An exec approval was granted.",
|
||||
});
|
||||
if (intent.shouldSend) {
|
||||
sendCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
expect(sendCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deny decision does not trigger follow-up message send", () => {
|
||||
const intent = resolveExecApprovalFollowUpIntent({
|
||||
decision: "deny",
|
||||
approval: createApproval(),
|
||||
agents: [createAgent("agent-1", "agent:agent-1:main")],
|
||||
followUpMessage: "An exec approval was granted.",
|
||||
});
|
||||
|
||||
expect(intent).toEqual({
|
||||
shouldSend: false,
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
message: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mergePendingLivePatch } from "@/features/agents/state/livePatchQueue";
|
||||
|
||||
describe("mergePendingLivePatch", () => {
|
||||
it("replaces pending patch when incoming runId differs", () => {
|
||||
const merged = mergePendingLivePatch(
|
||||
{
|
||||
runId: "run-old",
|
||||
streamText: "old text",
|
||||
thinkingTrace: "old trace",
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
runId: "run-new",
|
||||
thinkingTrace: "new trace",
|
||||
status: "running",
|
||||
}
|
||||
);
|
||||
|
||||
expect(merged).toEqual({
|
||||
runId: "run-new",
|
||||
thinkingTrace: "new trace",
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale live text when incoming patch introduces runId", () => {
|
||||
const merged = mergePendingLivePatch(
|
||||
{
|
||||
streamText: "old text",
|
||||
thinkingTrace: "old trace",
|
||||
runStartedAt: 100,
|
||||
},
|
||||
{
|
||||
runId: "run-2",
|
||||
thinkingTrace: "new trace",
|
||||
status: "running",
|
||||
}
|
||||
);
|
||||
|
||||
expect(merged).toEqual({
|
||||
runStartedAt: 100,
|
||||
runId: "run-2",
|
||||
thinkingTrace: "new trace",
|
||||
status: "running",
|
||||
});
|
||||
});
|
||||
|
||||
it("merges same-run patches normally", () => {
|
||||
const merged = mergePendingLivePatch(
|
||||
{
|
||||
runId: "run-1",
|
||||
thinkingTrace: "thinking",
|
||||
},
|
||||
{
|
||||
runId: "run-1",
|
||||
streamText: "answer",
|
||||
}
|
||||
);
|
||||
|
||||
expect(merged).toEqual({
|
||||
runId: "run-1",
|
||||
thinkingTrace: "thinking",
|
||||
streamText: "answer",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
|
||||
|
||||
describe("media-markdown", () => {
|
||||
it("rewrites MEDIA: lines pointing to images into markdown images", () => {
|
||||
const input = "Hello\nMEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png\nDone";
|
||||
const out = rewriteMediaLinesToMarkdown(input);
|
||||
|
||||
expect(out).toContain(";
|
||||
expect(out).toContain("MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png");
|
||||
expect(out).toContain("Hello");
|
||||
expect(out).toContain("Done");
|
||||
});
|
||||
|
||||
it("rewrites MEDIA: with the image path on the next line", () => {
|
||||
const input = "Hello\nMEDIA:\n/home/ubuntu/.openclaw/workspace-agent/foo.png\nDone";
|
||||
const out = rewriteMediaLinesToMarkdown(input);
|
||||
|
||||
expect(out).toContain(";
|
||||
expect(out).toContain("MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png");
|
||||
expect(out).toContain("Hello");
|
||||
expect(out).toContain("Done");
|
||||
});
|
||||
|
||||
it("does not rewrite inside fenced code blocks", () => {
|
||||
const input = "```\nMEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png\n```";
|
||||
const out = rewriteMediaLinesToMarkdown(input);
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
import {
|
||||
buildAgentInstruction,
|
||||
EXEC_APPROVAL_AUTO_RESUME_MARKER,
|
||||
extractText,
|
||||
extractTextCached,
|
||||
extractThinking,
|
||||
extractThinkingCached,
|
||||
extractToolLines,
|
||||
isUiMetadataPrefix,
|
||||
stripUiMetadata,
|
||||
} from "@/lib/text/message-extract";
|
||||
|
||||
describe("message-extract", () => {
|
||||
it("strips envelope headers from user messages", () => {
|
||||
const message = {
|
||||
role: "user",
|
||||
content:
|
||||
"[Discord Guild #claw3d channel id:123 +0s 2026-02-01 00:00 UTC] hello there",
|
||||
};
|
||||
|
||||
expect(extractText(message)).toBe("hello there");
|
||||
});
|
||||
|
||||
it("removes <thinking>/<analysis> blocks from assistant-visible text", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: "<thinking>Plan A</thinking>\n<analysis>Details</analysis>\nOk.",
|
||||
};
|
||||
|
||||
expect(extractText(message)).toBe("Ok.");
|
||||
});
|
||||
|
||||
it("strips assistant control prefixes in single- and double-bracket forms", () => {
|
||||
expect(extractText({ role: "assistant", content: "[reply_to_current] hello" })).toBe("hello");
|
||||
expect(extractText({ role: "assistant", content: "[[reply_to_current]] hello" })).toBe("hello");
|
||||
});
|
||||
|
||||
it("extractTextCached matches extractText and is consistent", () => {
|
||||
const message = { role: "user", content: "plain text" };
|
||||
|
||||
expect(extractTextCached(message)).toBe(extractText(message));
|
||||
expect(extractTextCached(message)).toBe("plain text");
|
||||
expect(extractTextCached(message)).toBe("plain text");
|
||||
});
|
||||
|
||||
it("extractThinkingCached matches extractThinking and is consistent", () => {
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Plan A" }],
|
||||
};
|
||||
|
||||
expect(extractThinkingCached(message)).toBe(extractThinking(message));
|
||||
expect(extractThinkingCached(message)).toBe("Plan A");
|
||||
expect(extractThinkingCached(message)).toBe("Plan A");
|
||||
});
|
||||
|
||||
it("formats tool call + tool result lines", () => {
|
||||
const callMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "functions.exec",
|
||||
arguments: { command: "echo hi" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const resultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "functions.exec",
|
||||
details: { status: "ok", exitCode: 0 },
|
||||
content: "hi\n",
|
||||
};
|
||||
|
||||
const callLines = extractToolLines(callMessage).join("\n");
|
||||
expect(callLines).toContain("[[tool]] functions.exec (call-1)");
|
||||
expect(callLines).toContain("\"command\": \"echo hi\"");
|
||||
|
||||
const resultLines = extractToolLines(resultMessage).join("\n");
|
||||
expect(resultLines).toContain("[[tool-result]] functions.exec (call-1)");
|
||||
expect(resultLines).toContain("ok");
|
||||
expect(resultLines).toContain("hi");
|
||||
});
|
||||
|
||||
it("does not treat normal messages as UI metadata", () => {
|
||||
const built = buildAgentInstruction({
|
||||
message: "hello",
|
||||
});
|
||||
|
||||
expect(isUiMetadataPrefix(built)).toBe(false);
|
||||
expect(stripUiMetadata(built)).toContain("hello");
|
||||
expect(stripUiMetadata(built)).not.toContain("Execution approval policy:");
|
||||
});
|
||||
|
||||
it("strips leading system event blocks from queued session updates", () => {
|
||||
const raw = `System: [2026-02-12 01:09:16 UTC] Exec failed (mild-she, signal SIGKILL)
|
||||
|
||||
[Thu 2026-02-12 01:14 UTC] nope none of those are it. keep looking
|
||||
[message_id: e050a641-aa32-4950-8083-c3bb7efdfc6d]`;
|
||||
|
||||
expect(stripUiMetadata(raw)).toBe("nope none of those are it. keep looking");
|
||||
});
|
||||
|
||||
it("hides internal exec approval auto-resume messages from transcript text", () => {
|
||||
const raw = `[Tue 2026-02-17 12:52 PST] ${EXEC_APPROVAL_AUTO_RESUME_MARKER}
|
||||
Continue where you left off and finish the task.`;
|
||||
expect(stripUiMetadata(raw)).toBe("");
|
||||
});
|
||||
|
||||
it("hides legacy auto-resume messages without internal marker", () => {
|
||||
const raw = `printf "\\n== Root (/) ==\\n" ls -la /
|
||||
[Tue 2026-02-17 12:52 PST] The exec approval was granted. Continue where you left off and finish the task.`;
|
||||
expect(stripUiMetadata(raw)).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes assistant helper text shape", () => {
|
||||
expect(normalizeAssistantDisplayText("first\r\n\r\n\r\nsecond")).toBe("first\n\nsecond");
|
||||
expect(normalizeAssistantDisplayText("line one \nline two\t \n")).toBe("line one\nline two");
|
||||
expect(normalizeAssistantDisplayText("\n\nalpha\n\n\nbeta\n\n")).toBe("alpha\n\nbeta");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildAgentInstruction } from "@/lib/text/message-extract";
|
||||
|
||||
describe("buildAgentInstruction", () => {
|
||||
it("returns trimmed message for normal prompts", () => {
|
||||
const message = buildAgentInstruction({
|
||||
message: " Ship it ",
|
||||
});
|
||||
expect(message).toBe("Ship it");
|
||||
});
|
||||
|
||||
it("returns command messages untouched", () => {
|
||||
const message = buildAgentInstruction({
|
||||
message: "/help",
|
||||
});
|
||||
expect(message).toBe("/help");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildAvatarDataUrl, buildAvatarSvg } from "@/lib/avatars/multiavatar";
|
||||
|
||||
describe("multiavatar helpers", () => {
|
||||
it("buildAvatarSvg returns svg markup", () => {
|
||||
const svg = buildAvatarSvg("Agent A");
|
||||
expect(svg).toContain("<svg");
|
||||
expect(svg).toContain("</svg>");
|
||||
expect(svg).toContain("AA");
|
||||
});
|
||||
|
||||
it("buildAvatarDataUrl returns a data url", () => {
|
||||
const url = buildAvatarDataUrl("Agent A");
|
||||
expect(url.startsWith("data:image/svg+xml;utf8,")).toBe(true);
|
||||
expect(url).toContain("%3Csvg");
|
||||
});
|
||||
|
||||
it("is deterministic for the same seed", () => {
|
||||
expect(buildAvatarSvg("Agent A")).toBe(buildAvatarSvg("Agent A"));
|
||||
});
|
||||
|
||||
it("varies across different seeds", () => {
|
||||
expect(buildAvatarSvg("Agent A")).not.toBe(buildAvatarSvg("Agent B"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
|
||||
import type { CreateAgentMutationLifecycleDeps } from "@/features/agents/operations/mutationLifecycleWorkflow";
|
||||
import {
|
||||
isCreateBlockTimedOut,
|
||||
runCreateAgentMutationLifecycle,
|
||||
} from "@/features/agents/operations/mutationLifecycleWorkflow";
|
||||
|
||||
const createPayload = (
|
||||
overrides: Partial<AgentCreateModalSubmitPayload> = {}
|
||||
): AgentCreateModalSubmitPayload => ({
|
||||
name: "Agent One",
|
||||
avatarSeed: "seed-1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createDeps = (
|
||||
overrides: Partial<CreateAgentMutationLifecycleDeps> = {}
|
||||
): CreateAgentMutationLifecycleDeps => ({
|
||||
enqueueConfigMutation: async ({ run }) => {
|
||||
await run();
|
||||
},
|
||||
createAgent: async () => ({ id: "agent-1" }),
|
||||
setQueuedBlock: () => undefined,
|
||||
setCreatingBlock: () => undefined,
|
||||
onCompletion: async () => undefined,
|
||||
setCreateAgentModalError: () => undefined,
|
||||
setCreateAgentBusy: () => undefined,
|
||||
clearCreateBlock: () => undefined,
|
||||
onError: () => undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("mutationLifecycleWorkflow create lifecycle", () => {
|
||||
it("blocks create and sets modal error when disconnected", async () => {
|
||||
const setCreateAgentModalError = vi.fn();
|
||||
const enqueueConfigMutation = vi.fn(async () => undefined);
|
||||
|
||||
const result = await runCreateAgentMutationLifecycle(
|
||||
{
|
||||
payload: createPayload(),
|
||||
status: "disconnected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
createAgentBusy: false,
|
||||
},
|
||||
createDeps({
|
||||
setCreateAgentModalError,
|
||||
enqueueConfigMutation,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(setCreateAgentModalError).toHaveBeenCalledWith("Connect to gateway before creating an agent.");
|
||||
expect(enqueueConfigMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails fast when the submitted name is empty", async () => {
|
||||
const setCreateAgentModalError = vi.fn();
|
||||
const enqueueConfigMutation = vi.fn(async () => undefined);
|
||||
|
||||
const result = await runCreateAgentMutationLifecycle(
|
||||
{
|
||||
payload: createPayload({ name: " " }),
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
createAgentBusy: false,
|
||||
},
|
||||
createDeps({
|
||||
setCreateAgentModalError,
|
||||
enqueueConfigMutation,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(setCreateAgentModalError).toHaveBeenCalledWith("Agent name is required.");
|
||||
expect(enqueueConfigMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs create-only lifecycle and completion callback", async () => {
|
||||
const order: string[] = [];
|
||||
const onCompletion = vi.fn(async (completion: { agentId: string; agentName: string }) => {
|
||||
order.push(`completion:${completion.agentId}:${completion.agentName}`);
|
||||
});
|
||||
|
||||
const result = await runCreateAgentMutationLifecycle(
|
||||
{
|
||||
payload: createPayload(),
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
createAgentBusy: false,
|
||||
},
|
||||
createDeps({
|
||||
setCreateAgentBusy: (busy) => {
|
||||
order.push(`busy:${busy ? "on" : "off"}`);
|
||||
},
|
||||
setCreateAgentModalError: (message) => {
|
||||
order.push(`modalError:${message === null ? "clear" : "set"}`);
|
||||
},
|
||||
setQueuedBlock: () => {
|
||||
order.push("queued");
|
||||
},
|
||||
enqueueConfigMutation: async ({ run }) => {
|
||||
order.push("enqueue");
|
||||
await run();
|
||||
},
|
||||
setCreatingBlock: () => {
|
||||
order.push("creating");
|
||||
},
|
||||
createAgent: async () => {
|
||||
order.push("createAgent");
|
||||
return { id: "agent-1" };
|
||||
},
|
||||
onCompletion,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(order).toEqual([
|
||||
"busy:on",
|
||||
"modalError:clear",
|
||||
"queued",
|
||||
"enqueue",
|
||||
"creating",
|
||||
"createAgent",
|
||||
"completion:agent-1:Agent One",
|
||||
"busy:off",
|
||||
]);
|
||||
expect(onCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("surfaces create errors and clears create block", async () => {
|
||||
const clearCreateBlock = vi.fn();
|
||||
const setCreateAgentModalError = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
const result = await runCreateAgentMutationLifecycle(
|
||||
{
|
||||
payload: createPayload(),
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
createAgentBusy: false,
|
||||
},
|
||||
createDeps({
|
||||
createAgent: async () => {
|
||||
throw new Error("create exploded");
|
||||
},
|
||||
clearCreateBlock,
|
||||
setCreateAgentModalError,
|
||||
onError,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(clearCreateBlock).toHaveBeenCalledTimes(1);
|
||||
expect(setCreateAgentModalError).toHaveBeenCalledWith("create exploded");
|
||||
expect(onError).toHaveBeenCalledWith("create exploded");
|
||||
});
|
||||
|
||||
it("maps create block timeout through shared mutation timeout policy", () => {
|
||||
expect(
|
||||
isCreateBlockTimedOut({
|
||||
block: null,
|
||||
nowMs: 100_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isCreateBlockTimedOut({
|
||||
block: {
|
||||
agentName: "Agent One",
|
||||
phase: "queued",
|
||||
startedAt: 0,
|
||||
},
|
||||
nowMs: 100_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isCreateBlockTimedOut({
|
||||
block: {
|
||||
agentName: "Agent One",
|
||||
phase: "creating",
|
||||
startedAt: 0,
|
||||
},
|
||||
nowMs: 100_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildConfigMutationFailureMessage,
|
||||
buildMutationSideEffectCommands,
|
||||
buildQueuedMutationBlock,
|
||||
resolveConfigMutationPostRunEffects,
|
||||
resolveConfigMutationStatusLine,
|
||||
resolveMutationStartGuard,
|
||||
runConfigMutationWorkflow,
|
||||
} from "@/features/agents/operations/mutationLifecycleWorkflow";
|
||||
import { shouldStartNextConfigMutation } from "@/features/agents/operations/configMutationGatePolicy";
|
||||
|
||||
describe("mutationLifecycleWorkflow integration", () => {
|
||||
it("page create handler uses shared start guard and queued block shape", () => {
|
||||
const denied = resolveMutationStartGuard({
|
||||
status: "disconnected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
});
|
||||
expect(denied).toEqual({ kind: "deny", reason: "not-connected" });
|
||||
|
||||
const allowed = resolveMutationStartGuard({
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
});
|
||||
expect(allowed).toEqual({ kind: "allow" });
|
||||
|
||||
const queued = buildQueuedMutationBlock({
|
||||
kind: "create-agent",
|
||||
agentId: "",
|
||||
agentName: "Agent One",
|
||||
startedAt: 42,
|
||||
});
|
||||
expect(queued).toEqual({
|
||||
kind: "create-agent",
|
||||
agentId: "",
|
||||
agentName: "Agent One",
|
||||
phase: "queued",
|
||||
startedAt: 42,
|
||||
sawDisconnect: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("delete workflow maps awaiting-restart outcome to awaiting-restart block phase", async () => {
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: "delete-agent", isLocalGateway: false },
|
||||
{
|
||||
executeMutation: async () => undefined,
|
||||
shouldAwaitRemoteRestart: async () => true,
|
||||
}
|
||||
);
|
||||
|
||||
const effects = resolveConfigMutationPostRunEffects(result);
|
||||
expect(effects).toEqual({
|
||||
shouldReloadAgents: false,
|
||||
shouldClearBlock: false,
|
||||
awaitingRestartPatch: {
|
||||
phase: "awaiting-restart",
|
||||
sawDisconnect: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rename workflow maps completed outcome to load-and-clear flow", async () => {
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: "rename-agent", isLocalGateway: false },
|
||||
{
|
||||
executeMutation: async () => undefined,
|
||||
shouldAwaitRemoteRestart: async () => false,
|
||||
}
|
||||
);
|
||||
|
||||
const effects = resolveConfigMutationPostRunEffects(result);
|
||||
let didLoadAgents = false;
|
||||
let block: { phase: string; sawDisconnect: boolean } | null = {
|
||||
phase: "mutating",
|
||||
sawDisconnect: false,
|
||||
};
|
||||
if (effects.shouldReloadAgents) {
|
||||
didLoadAgents = true;
|
||||
}
|
||||
if (effects.shouldClearBlock) {
|
||||
block = null;
|
||||
}
|
||||
|
||||
expect(didLoadAgents).toBe(true);
|
||||
expect(block).toBeNull();
|
||||
expect(effects.awaitingRestartPatch).toBeNull();
|
||||
});
|
||||
|
||||
it("page rename and delete handlers share lifecycle guard plus post-run transitions", async () => {
|
||||
const blocked = resolveMutationStartGuard({
|
||||
status: "connected",
|
||||
hasCreateBlock: true,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
});
|
||||
expect(blocked).toEqual({
|
||||
kind: "deny",
|
||||
reason: "create-block-active",
|
||||
});
|
||||
|
||||
const allowed = resolveMutationStartGuard({
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
});
|
||||
expect(allowed).toEqual({ kind: "allow" });
|
||||
|
||||
const executeMutation = vi.fn(async () => undefined);
|
||||
|
||||
const renameCompleted = await runConfigMutationWorkflow(
|
||||
{
|
||||
kind: "rename-agent",
|
||||
isLocalGateway: false,
|
||||
},
|
||||
{
|
||||
executeMutation,
|
||||
shouldAwaitRemoteRestart: async () => false,
|
||||
}
|
||||
);
|
||||
expect(buildMutationSideEffectCommands({ disposition: renameCompleted.disposition })).toEqual([
|
||||
{ kind: "reload-agents" },
|
||||
{ kind: "clear-mutation-block" },
|
||||
{ kind: "set-mobile-pane", pane: "chat" },
|
||||
]);
|
||||
|
||||
const deleteAwaitingRestart = await runConfigMutationWorkflow(
|
||||
{
|
||||
kind: "delete-agent",
|
||||
isLocalGateway: false,
|
||||
},
|
||||
{
|
||||
executeMutation,
|
||||
shouldAwaitRemoteRestart: async () => true,
|
||||
}
|
||||
);
|
||||
expect(buildMutationSideEffectCommands({ disposition: deleteAwaitingRestart.disposition })).toEqual(
|
||||
[{ kind: "patch-mutation-block", patch: { phase: "awaiting-restart", sawDisconnect: false } }]
|
||||
);
|
||||
expect(executeMutation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses typed mutation commands for lifecycle side effects instead of inline branching", async () => {
|
||||
const commandLog: string[] = [];
|
||||
const runCommands = async (
|
||||
disposition: "completed" | "awaiting-restart"
|
||||
): Promise<{ phase: string; sawDisconnect: boolean } | null> => {
|
||||
let block: { phase: string; sawDisconnect: boolean } | null = {
|
||||
phase: "mutating",
|
||||
sawDisconnect: false,
|
||||
};
|
||||
for (const command of buildMutationSideEffectCommands({ disposition })) {
|
||||
if (command.kind === "reload-agents") {
|
||||
commandLog.push("reload");
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "clear-mutation-block") {
|
||||
commandLog.push("clear");
|
||||
block = null;
|
||||
continue;
|
||||
}
|
||||
if (command.kind === "set-mobile-pane") {
|
||||
commandLog.push(`pane:${command.pane}`);
|
||||
continue;
|
||||
}
|
||||
commandLog.push(`patch:${command.patch.phase}`);
|
||||
block = {
|
||||
phase: command.patch.phase,
|
||||
sawDisconnect: command.patch.sawDisconnect,
|
||||
};
|
||||
}
|
||||
return block;
|
||||
};
|
||||
|
||||
const completedBlock = await runCommands("completed");
|
||||
const awaitingBlock = await runCommands("awaiting-restart");
|
||||
|
||||
expect(completedBlock).toBeNull();
|
||||
expect(awaitingBlock).toEqual({
|
||||
phase: "awaiting-restart",
|
||||
sawDisconnect: false,
|
||||
});
|
||||
expect(commandLog).toEqual(["reload", "clear", "pane:chat", "patch:awaiting-restart"]);
|
||||
});
|
||||
|
||||
it("workflow errors clear block and set page error message", async () => {
|
||||
let block: { phase: string; sawDisconnect: boolean } | null = {
|
||||
phase: "mutating",
|
||||
sawDisconnect: false,
|
||||
};
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
try {
|
||||
await runConfigMutationWorkflow(
|
||||
{ kind: "rename-agent", isLocalGateway: false },
|
||||
{
|
||||
executeMutation: async () => {
|
||||
throw new Error("rename exploded");
|
||||
},
|
||||
shouldAwaitRemoteRestart: async () => false,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
block = null;
|
||||
errorMessage = buildConfigMutationFailureMessage({
|
||||
kind: "rename-agent",
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
expect(block).toBeNull();
|
||||
expect(errorMessage).toBe("rename exploded");
|
||||
});
|
||||
|
||||
it("preserves queue gating when restart block is active", () => {
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connected",
|
||||
hasRunningAgents: false,
|
||||
nextMutationRequiresIdleAgents: false,
|
||||
hasActiveMutation: false,
|
||||
hasRestartBlockInProgress: true,
|
||||
queuedCount: 1,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldStartNextConfigMutation({
|
||||
status: "connected",
|
||||
hasRunningAgents: false,
|
||||
nextMutationRequiresIdleAgents: false,
|
||||
hasActiveMutation: false,
|
||||
hasRestartBlockInProgress: false,
|
||||
queuedCount: 1,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves lock-status text behavior across queued, mutating, and awaiting-restart phases", () => {
|
||||
expect(
|
||||
resolveConfigMutationStatusLine({
|
||||
block: { phase: "queued", sawDisconnect: false },
|
||||
status: "connected",
|
||||
})
|
||||
).toBe("Waiting for active runs to finish");
|
||||
|
||||
expect(
|
||||
resolveConfigMutationStatusLine({
|
||||
block: { phase: "mutating", sawDisconnect: false },
|
||||
status: "connected",
|
||||
})
|
||||
).toBe("Submitting config change");
|
||||
|
||||
expect(
|
||||
resolveConfigMutationStatusLine({
|
||||
block: { phase: "awaiting-restart", sawDisconnect: false },
|
||||
status: "connected",
|
||||
})
|
||||
).toBe("Waiting for gateway to restart");
|
||||
|
||||
expect(
|
||||
resolveConfigMutationStatusLine({
|
||||
block: { phase: "awaiting-restart", sawDisconnect: true },
|
||||
status: "disconnected",
|
||||
})
|
||||
).toBe("Gateway restart in progress");
|
||||
|
||||
expect(
|
||||
resolveConfigMutationStatusLine({
|
||||
block: { phase: "awaiting-restart", sawDisconnect: true },
|
||||
status: "connected",
|
||||
})
|
||||
).toBe("Gateway is back online, syncing agents");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/mutationLifecycleWorkflow";
|
||||
|
||||
describe("mutationLifecycleWorkflow lifecycle runner", () => {
|
||||
it("runs completed rename lifecycle commands in order", async () => {
|
||||
const order: string[] = [];
|
||||
const enqueueConfigMutation = vi.fn(async ({ run }: { run: () => Promise<void> }) => {
|
||||
order.push("enqueue");
|
||||
await run();
|
||||
});
|
||||
const setQueuedBlock = vi.fn(() => {
|
||||
order.push("queued");
|
||||
});
|
||||
const setMutatingBlock = vi.fn(() => {
|
||||
order.push("mutating");
|
||||
});
|
||||
const executeMutation = vi.fn(async () => {
|
||||
order.push("execute");
|
||||
});
|
||||
const shouldAwaitRemoteRestart = vi.fn(async () => {
|
||||
order.push("await-check");
|
||||
return false;
|
||||
});
|
||||
const reloadAgents = vi.fn(async () => {
|
||||
order.push("reload");
|
||||
});
|
||||
const clearBlock = vi.fn(() => {
|
||||
order.push("clear");
|
||||
});
|
||||
const setMobilePaneChat = vi.fn(() => {
|
||||
order.push("pane");
|
||||
});
|
||||
const patchBlockAwaitingRestart = vi.fn(() => {
|
||||
order.push("patch");
|
||||
});
|
||||
const onError = vi.fn();
|
||||
|
||||
const result = await runAgentConfigMutationLifecycle({
|
||||
kind: "rename-agent",
|
||||
label: "Rename Agent One",
|
||||
isLocalGateway: false,
|
||||
deps: {
|
||||
enqueueConfigMutation,
|
||||
setQueuedBlock,
|
||||
setMutatingBlock,
|
||||
patchBlockAwaitingRestart,
|
||||
clearBlock,
|
||||
executeMutation,
|
||||
shouldAwaitRemoteRestart,
|
||||
reloadAgents,
|
||||
setMobilePaneChat,
|
||||
onError,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(order).toEqual([
|
||||
"queued",
|
||||
"enqueue",
|
||||
"mutating",
|
||||
"execute",
|
||||
"await-check",
|
||||
"reload",
|
||||
"clear",
|
||||
"pane",
|
||||
]);
|
||||
expect(patchBlockAwaitingRestart).not.toHaveBeenCalled();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
expect(enqueueConfigMutation).toHaveBeenCalledWith({
|
||||
kind: "rename-agent",
|
||||
label: "Rename Agent One",
|
||||
run: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("applies awaiting-restart patch for remote delete", async () => {
|
||||
const clearBlock = vi.fn();
|
||||
const patchBlockAwaitingRestart = vi.fn();
|
||||
|
||||
const result = await runAgentConfigMutationLifecycle({
|
||||
kind: "delete-agent",
|
||||
label: "Delete Agent One",
|
||||
isLocalGateway: false,
|
||||
deps: {
|
||||
enqueueConfigMutation: async ({ run }) => {
|
||||
await run();
|
||||
},
|
||||
setQueuedBlock: () => undefined,
|
||||
setMutatingBlock: () => undefined,
|
||||
patchBlockAwaitingRestart,
|
||||
clearBlock,
|
||||
executeMutation: async () => undefined,
|
||||
shouldAwaitRemoteRestart: async () => true,
|
||||
reloadAgents: async () => undefined,
|
||||
setMobilePaneChat: () => undefined,
|
||||
onError: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(patchBlockAwaitingRestart).toHaveBeenCalledWith({
|
||||
phase: "awaiting-restart",
|
||||
sawDisconnect: false,
|
||||
});
|
||||
expect(clearBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call restart-check on local gateway", async () => {
|
||||
const shouldAwaitRemoteRestart = vi.fn(async () => true);
|
||||
|
||||
const result = await runAgentConfigMutationLifecycle({
|
||||
kind: "rename-agent",
|
||||
label: "Rename Agent One",
|
||||
isLocalGateway: true,
|
||||
deps: {
|
||||
enqueueConfigMutation: async ({ run }) => {
|
||||
await run();
|
||||
},
|
||||
setQueuedBlock: () => undefined,
|
||||
setMutatingBlock: () => undefined,
|
||||
patchBlockAwaitingRestart: () => undefined,
|
||||
clearBlock: () => undefined,
|
||||
executeMutation: async () => undefined,
|
||||
shouldAwaitRemoteRestart,
|
||||
reloadAgents: async () => undefined,
|
||||
setMobilePaneChat: () => undefined,
|
||||
onError: () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(shouldAwaitRemoteRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears block and reports mapped error on mutation failure", async () => {
|
||||
const clearBlock = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
const result = await runAgentConfigMutationLifecycle({
|
||||
kind: "rename-agent",
|
||||
label: "Rename Agent One",
|
||||
isLocalGateway: false,
|
||||
deps: {
|
||||
enqueueConfigMutation: async ({ run }) => {
|
||||
await run();
|
||||
},
|
||||
setQueuedBlock: () => undefined,
|
||||
setMutatingBlock: () => undefined,
|
||||
patchBlockAwaitingRestart: () => undefined,
|
||||
clearBlock,
|
||||
executeMutation: async () => {
|
||||
throw new Error("rename exploded");
|
||||
},
|
||||
shouldAwaitRemoteRestart: async () => false,
|
||||
reloadAgents: async () => undefined,
|
||||
setMobilePaneChat: () => undefined,
|
||||
onError,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(clearBlock).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith("rename exploded");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildConfigMutationFailureMessage,
|
||||
buildMutationSideEffectCommands,
|
||||
buildMutatingMutationBlock,
|
||||
buildQueuedMutationBlock,
|
||||
resolveMutationPostRunIntent,
|
||||
resolveMutationStartGuard,
|
||||
resolveMutationTimeoutIntent,
|
||||
runConfigMutationWorkflow,
|
||||
type MutationWorkflowKind,
|
||||
} from "@/features/agents/operations/mutationLifecycleWorkflow";
|
||||
|
||||
describe("mutationLifecycleWorkflow", () => {
|
||||
it("returns completed for local gateway mutations without restart wait", async () => {
|
||||
const executeMutation = vi.fn(async () => undefined);
|
||||
const shouldAwaitRemoteRestart = vi.fn(async () => true);
|
||||
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: "delete-agent", isLocalGateway: true },
|
||||
{ executeMutation, shouldAwaitRemoteRestart }
|
||||
);
|
||||
|
||||
expect(result).toEqual({ disposition: "completed" });
|
||||
expect(executeMutation).toHaveBeenCalledTimes(1);
|
||||
expect(shouldAwaitRemoteRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns completed for remote mutation when restart wait is not required", async () => {
|
||||
const executeMutation = vi.fn(async () => undefined);
|
||||
const shouldAwaitRemoteRestart = vi.fn(async () => false);
|
||||
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: "rename-agent", isLocalGateway: false },
|
||||
{ executeMutation, shouldAwaitRemoteRestart }
|
||||
);
|
||||
|
||||
expect(result).toEqual({ disposition: "completed" });
|
||||
expect(executeMutation).toHaveBeenCalledTimes(1);
|
||||
expect(shouldAwaitRemoteRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns awaiting-restart for remote mutation when restart wait is required", async () => {
|
||||
const executeMutation = vi.fn(async () => undefined);
|
||||
const shouldAwaitRemoteRestart = vi.fn(async () => true);
|
||||
|
||||
const result = await runConfigMutationWorkflow(
|
||||
{ kind: "delete-agent", isLocalGateway: false },
|
||||
{ executeMutation, shouldAwaitRemoteRestart }
|
||||
);
|
||||
|
||||
expect(result).toEqual({ disposition: "awaiting-restart" });
|
||||
expect(executeMutation).toHaveBeenCalledTimes(1);
|
||||
expect(shouldAwaitRemoteRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("maps mutation failures to user-facing errors", () => {
|
||||
const fallbackByKind: Record<MutationWorkflowKind, string> = {
|
||||
"rename-agent": "Failed to rename agent.",
|
||||
"delete-agent": "Failed to delete agent.",
|
||||
};
|
||||
for (const [kind, fallback] of Object.entries(fallbackByKind) as Array<
|
||||
[MutationWorkflowKind, string]
|
||||
>) {
|
||||
expect(buildConfigMutationFailureMessage({ kind, error: new Error("boom") })).toBe("boom");
|
||||
expect(buildConfigMutationFailureMessage({ kind, error: 123 })).toBe(fallback);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid mutation input before side effects", async () => {
|
||||
const executeMutation = vi.fn(async () => undefined);
|
||||
const shouldAwaitRemoteRestart = vi.fn(async () => true);
|
||||
|
||||
await expect(
|
||||
runConfigMutationWorkflow(
|
||||
// @ts-expect-error intentional invalid kind check
|
||||
{ kind: "unknown-kind", isLocalGateway: false },
|
||||
{ executeMutation, shouldAwaitRemoteRestart }
|
||||
)
|
||||
).rejects.toThrow("Unknown config mutation kind: unknown-kind");
|
||||
|
||||
expect(executeMutation).not.toHaveBeenCalled();
|
||||
expect(shouldAwaitRemoteRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks mutation starts when another mutation block is active", () => {
|
||||
expect(
|
||||
resolveMutationStartGuard({
|
||||
status: "disconnected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
})
|
||||
).toEqual({ kind: "deny", reason: "not-connected" });
|
||||
|
||||
expect(
|
||||
resolveMutationStartGuard({
|
||||
status: "connected",
|
||||
hasCreateBlock: true,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
})
|
||||
).toEqual({ kind: "deny", reason: "create-block-active" });
|
||||
|
||||
expect(
|
||||
resolveMutationStartGuard({
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: true,
|
||||
hasDeleteBlock: false,
|
||||
})
|
||||
).toEqual({ kind: "deny", reason: "rename-block-active" });
|
||||
|
||||
expect(
|
||||
resolveMutationStartGuard({
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: true,
|
||||
})
|
||||
).toEqual({ kind: "deny", reason: "delete-block-active" });
|
||||
|
||||
expect(
|
||||
resolveMutationStartGuard({
|
||||
status: "connected",
|
||||
hasCreateBlock: false,
|
||||
hasRenameBlock: false,
|
||||
hasDeleteBlock: false,
|
||||
})
|
||||
).toEqual({ kind: "allow" });
|
||||
});
|
||||
|
||||
it("builds deterministic queued and mutating block transitions", () => {
|
||||
const queued = buildQueuedMutationBlock({
|
||||
kind: "rename-agent",
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent One",
|
||||
startedAt: 123,
|
||||
});
|
||||
|
||||
expect(queued).toEqual({
|
||||
kind: "rename-agent",
|
||||
agentId: "agent-1",
|
||||
agentName: "Agent One",
|
||||
phase: "queued",
|
||||
startedAt: 123,
|
||||
sawDisconnect: false,
|
||||
});
|
||||
|
||||
expect(buildMutatingMutationBlock(queued)).toEqual({
|
||||
...queued,
|
||||
phase: "mutating",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves post-mutation block outcomes for completed vs awaiting-restart", () => {
|
||||
expect(resolveMutationPostRunIntent({ disposition: "completed" })).toEqual({
|
||||
kind: "clear",
|
||||
});
|
||||
|
||||
expect(resolveMutationPostRunIntent({ disposition: "awaiting-restart" })).toEqual({
|
||||
kind: "awaiting-restart",
|
||||
patch: {
|
||||
phase: "awaiting-restart",
|
||||
sawDisconnect: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds typed side-effect commands for completed and awaiting-restart dispositions", () => {
|
||||
expect(buildMutationSideEffectCommands({ disposition: "completed" })).toEqual([
|
||||
{ kind: "reload-agents" },
|
||||
{ kind: "clear-mutation-block" },
|
||||
{ kind: "set-mobile-pane", pane: "chat" },
|
||||
]);
|
||||
|
||||
expect(buildMutationSideEffectCommands({ disposition: "awaiting-restart" })).toEqual([
|
||||
{
|
||||
kind: "patch-mutation-block",
|
||||
patch: { phase: "awaiting-restart", sawDisconnect: false },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns timeout intent when mutation block exceeds max wait", () => {
|
||||
expect(
|
||||
resolveMutationTimeoutIntent({
|
||||
block: null,
|
||||
nowMs: 10_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toEqual({ kind: "none" });
|
||||
|
||||
const createBlock = buildQueuedMutationBlock({
|
||||
kind: "create-agent",
|
||||
agentId: "agent-1",
|
||||
agentName: "A",
|
||||
startedAt: 1_000,
|
||||
});
|
||||
const renameBlock = buildQueuedMutationBlock({
|
||||
kind: "rename-agent",
|
||||
agentId: "agent-1",
|
||||
agentName: "A",
|
||||
startedAt: 1_000,
|
||||
});
|
||||
const deleteBlock = buildQueuedMutationBlock({
|
||||
kind: "delete-agent",
|
||||
agentId: "agent-1",
|
||||
agentName: "A",
|
||||
startedAt: 1_000,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMutationTimeoutIntent({
|
||||
block: createBlock,
|
||||
nowMs: 91_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toEqual({ kind: "timeout", reason: "create-timeout" });
|
||||
|
||||
expect(
|
||||
resolveMutationTimeoutIntent({
|
||||
block: renameBlock,
|
||||
nowMs: 91_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toEqual({ kind: "timeout", reason: "rename-timeout" });
|
||||
|
||||
expect(
|
||||
resolveMutationTimeoutIntent({
|
||||
block: deleteBlock,
|
||||
nowMs: 91_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toEqual({ kind: "timeout", reason: "delete-timeout" });
|
||||
|
||||
expect(
|
||||
resolveMutationTimeoutIntent({
|
||||
block: createBlock,
|
||||
nowMs: 50_000,
|
||||
maxWaitMs: 90_000,
|
||||
})
|
||||
).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AgentState, AgentStoreSeed } from "@/features/agents/state/store";
|
||||
import { createTranscriptEntryFromLine } from "@/features/agents/state/transcript";
|
||||
import {
|
||||
buildOfficeAnimationState,
|
||||
clearOfficeAnimationTriggerHold,
|
||||
createOfficeAnimationTriggerState,
|
||||
reconcileOfficeAnimationTriggerState,
|
||||
reduceOfficeAnimationTriggerEvent,
|
||||
} from "@/lib/office/eventTriggers";
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
const makeAgent = (
|
||||
overrides: Partial<AgentState> & Pick<AgentStoreSeed, "agentId" | "name" | "sessionKey">,
|
||||
): AgentState => ({
|
||||
agentId: overrides.agentId,
|
||||
name: overrides.name,
|
||||
sessionKey: overrides.sessionKey,
|
||||
avatarSeed: overrides.avatarSeed ?? overrides.agentId,
|
||||
avatarUrl: overrides.avatarUrl ?? null,
|
||||
model: overrides.model ?? null,
|
||||
thinkingLevel: overrides.thinkingLevel ?? "high",
|
||||
sessionExecHost: overrides.sessionExecHost,
|
||||
sessionExecSecurity: overrides.sessionExecSecurity,
|
||||
sessionExecAsk: overrides.sessionExecAsk,
|
||||
status: overrides.status ?? "idle",
|
||||
sessionCreated: overrides.sessionCreated ?? false,
|
||||
awaitingUserInput: overrides.awaitingUserInput ?? false,
|
||||
hasUnseenActivity: overrides.hasUnseenActivity ?? false,
|
||||
outputLines: overrides.outputLines ?? [],
|
||||
lastResult: overrides.lastResult ?? null,
|
||||
lastDiff: overrides.lastDiff ?? null,
|
||||
runId: overrides.runId ?? null,
|
||||
runStartedAt: overrides.runStartedAt ?? null,
|
||||
streamText: overrides.streamText ?? null,
|
||||
thinkingTrace: overrides.thinkingTrace ?? null,
|
||||
latestOverride: overrides.latestOverride ?? null,
|
||||
latestOverrideKind: overrides.latestOverrideKind ?? null,
|
||||
lastAssistantMessageAt: overrides.lastAssistantMessageAt ?? null,
|
||||
lastActivityAt: overrides.lastActivityAt ?? null,
|
||||
latestPreview: overrides.latestPreview ?? null,
|
||||
lastUserMessage: overrides.lastUserMessage ?? null,
|
||||
draft: overrides.draft ?? "",
|
||||
queuedMessages: overrides.queuedMessages ?? [],
|
||||
sessionSettingsSynced: overrides.sessionSettingsSynced ?? false,
|
||||
historyLoadedAt: overrides.historyLoadedAt ?? null,
|
||||
historyFetchLimit: overrides.historyFetchLimit ?? null,
|
||||
historyFetchedCount: overrides.historyFetchedCount ?? null,
|
||||
historyMaybeTruncated: overrides.historyMaybeTruncated ?? false,
|
||||
toolCallingEnabled: overrides.toolCallingEnabled ?? false,
|
||||
showThinkingTraces: overrides.showThinkingTraces ?? true,
|
||||
transcriptEntries: overrides.transcriptEntries ?? [],
|
||||
transcriptRevision: overrides.transcriptRevision ?? 0,
|
||||
transcriptSequenceCounter: overrides.transcriptSequenceCounter ?? 0,
|
||||
sessionEpoch: overrides.sessionEpoch ?? 0,
|
||||
lastHistoryRequestRevision: overrides.lastHistoryRequestRevision ?? null,
|
||||
lastAppliedHistoryRequestId: overrides.lastAppliedHistoryRequestId ?? null,
|
||||
});
|
||||
|
||||
const makeTranscriptEntry = (params: {
|
||||
line: string;
|
||||
role: "user" | "assistant";
|
||||
sequenceKey: number;
|
||||
sessionKey: string;
|
||||
timestampMs: number;
|
||||
}) => {
|
||||
const entry = createTranscriptEntryFromLine({
|
||||
line: params.line,
|
||||
sessionKey: params.sessionKey,
|
||||
source: "history",
|
||||
sequenceKey: params.sequenceKey,
|
||||
timestampMs: params.timestampMs,
|
||||
fallbackTimestampMs: params.timestampMs,
|
||||
role: params.role,
|
||||
kind: params.role === "user" ? "user" : "assistant",
|
||||
confirmed: true,
|
||||
});
|
||||
if (!entry) {
|
||||
throw new Error("Failed to create transcript entry.");
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
describe("office event triggers", () => {
|
||||
it("derives room holds from agent messages", () => {
|
||||
const agents = [
|
||||
makeAgent({
|
||||
agentId: "main",
|
||||
name: "Main",
|
||||
sessionKey: "agent:main:main",
|
||||
lastUserMessage: "Check GitHub for pull requests.",
|
||||
}),
|
||||
makeAgent({
|
||||
agentId: "qa",
|
||||
name: "QA",
|
||||
sessionKey: "agent:qa:main",
|
||||
lastUserMessage: "Please test this build in the QA lab.",
|
||||
}),
|
||||
makeAgent({
|
||||
agentId: "skill",
|
||||
name: "Skill",
|
||||
sessionKey: "agent:skill:main",
|
||||
lastUserMessage: "Build another OpenClaw skill.",
|
||||
status: "running",
|
||||
runId: "run-skill",
|
||||
}),
|
||||
];
|
||||
|
||||
const state = reconcileOfficeAnimationTriggerState({
|
||||
state: createOfficeAnimationTriggerState(),
|
||||
agents,
|
||||
nowMs: 1_000,
|
||||
});
|
||||
const animationState = buildOfficeAnimationState({
|
||||
state,
|
||||
agents,
|
||||
nowMs: 1_000,
|
||||
});
|
||||
|
||||
expect(animationState.githubHoldByAgentId.main).toBe(true);
|
||||
expect(animationState.qaHoldByAgentId.qa).toBe(true);
|
||||
expect(animationState.skillGymHoldByAgentId.skill).toBe(true);
|
||||
});
|
||||
|
||||
it("reacts to runtime chat commands regardless of channel transport", () => {
|
||||
const agents = [
|
||||
makeAgent({
|
||||
agentId: "main",
|
||||
name: "Main",
|
||||
sessionKey: "agent:main:main",
|
||||
}),
|
||||
makeAgent({
|
||||
agentId: "worker",
|
||||
name: "Worker",
|
||||
sessionKey: "agent:worker:slack",
|
||||
}),
|
||||
];
|
||||
const gymEvent: EventFrame = {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-worker",
|
||||
sessionKey: "agent:worker:slack",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "user",
|
||||
text: "Let's go to the gym.",
|
||||
},
|
||||
},
|
||||
};
|
||||
const standupEvent: EventFrame = {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-main",
|
||||
sessionKey: "agent:main:main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "user",
|
||||
text: "Start the standup meeting.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const afterGym = reduceOfficeAnimationTriggerEvent({
|
||||
state: createOfficeAnimationTriggerState(),
|
||||
agents,
|
||||
event: gymEvent,
|
||||
nowMs: 5_000,
|
||||
});
|
||||
const afterStandup = reduceOfficeAnimationTriggerEvent({
|
||||
state: afterGym,
|
||||
agents,
|
||||
event: standupEvent,
|
||||
nowMs: 6_000,
|
||||
});
|
||||
|
||||
expect(afterStandup.manualGymUntilByAgentId.worker).toBeGreaterThan(5_000);
|
||||
expect(afterStandup.pendingStandupRequest?.message).toBe(
|
||||
"Start the standup meeting.",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats final transport chat messages without an explicit user role as commands", () => {
|
||||
const agents = [
|
||||
makeAgent({
|
||||
agentId: "worker",
|
||||
name: "Worker",
|
||||
sessionKey: "agent:worker:main",
|
||||
}),
|
||||
];
|
||||
|
||||
const afterDesk = reduceOfficeAnimationTriggerEvent({
|
||||
state: createOfficeAnimationTriggerState(),
|
||||
agents,
|
||||
nowMs: 7_000,
|
||||
event: {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-worker-telegram",
|
||||
sessionKey: "agent:worker:telegram",
|
||||
state: "final",
|
||||
message: {
|
||||
text: "Please head to your desk now.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const afterGym = reduceOfficeAnimationTriggerEvent({
|
||||
state: afterDesk,
|
||||
agents,
|
||||
nowMs: 8_000,
|
||||
event: {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-worker-telegram",
|
||||
sessionKey: "agent:worker:telegram",
|
||||
state: "final",
|
||||
message: {
|
||||
text: "Go to the gym.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(afterGym.deskHoldByAgentId.worker).toBe(true);
|
||||
expect(afterGym.manualGymUntilByAgentId.worker).toBeGreaterThan(8_000);
|
||||
});
|
||||
|
||||
it("tracks streaming and reasoning activity from runtime events", () => {
|
||||
const agents = [
|
||||
makeAgent({
|
||||
agentId: "agent-1",
|
||||
name: "Agent 1",
|
||||
sessionKey: "agent:agent-1:web",
|
||||
}),
|
||||
];
|
||||
|
||||
const afterChat = reduceOfficeAnimationTriggerEvent({
|
||||
state: createOfficeAnimationTriggerState(),
|
||||
agents,
|
||||
nowMs: 10_000,
|
||||
event: {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: "agent:agent-1:web",
|
||||
state: "delta",
|
||||
message: {
|
||||
role: "assistant",
|
||||
text: "Streaming reply.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const afterReasoning = reduceOfficeAnimationTriggerEvent({
|
||||
state: afterChat,
|
||||
agents,
|
||||
nowMs: 10_100,
|
||||
event: {
|
||||
type: "event",
|
||||
event: "agent",
|
||||
payload: {
|
||||
runId: "run-1",
|
||||
sessionKey: "agent:agent-1:web",
|
||||
stream: "reasoning_trace",
|
||||
data: {
|
||||
text: "Thinking about a plan.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const animationState = buildOfficeAnimationState({
|
||||
state: afterReasoning,
|
||||
agents,
|
||||
nowMs: 10_150,
|
||||
});
|
||||
|
||||
expect(animationState.streamingByAgentId["agent-1"]).toBe(true);
|
||||
expect(animationState.thinkingByAgentId["agent-1"]).toBe(true);
|
||||
expect(animationState.workingUntilByAgentId["agent-1"]).toBeGreaterThan(10_000);
|
||||
});
|
||||
|
||||
it("suppresses desk holds while a gym command is active", () => {
|
||||
const agents = [
|
||||
makeAgent({
|
||||
agentId: "agent-1",
|
||||
name: "Agent 1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
}),
|
||||
];
|
||||
|
||||
const animationState = buildOfficeAnimationState({
|
||||
agents,
|
||||
nowMs: 11_000,
|
||||
state: {
|
||||
...createOfficeAnimationTriggerState(),
|
||||
deskHoldByAgentId: { "agent-1": true },
|
||||
manualGymUntilByAgentId: { "agent-1": 12_000 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(animationState.gymHoldByAgentId["agent-1"]).toBe(true);
|
||||
expect(animationState.deskHoldByAgentId["agent-1"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses dismissed github holds until a new command arrives and emits cleanup cues", () => {
|
||||
const baseAgent = makeAgent({
|
||||
agentId: "main",
|
||||
name: "Main",
|
||||
sessionKey: "agent:main:main",
|
||||
lastUserMessage: "Check GitHub for pull requests.",
|
||||
sessionEpoch: 1,
|
||||
});
|
||||
|
||||
const initialState = reconcileOfficeAnimationTriggerState({
|
||||
state: createOfficeAnimationTriggerState(),
|
||||
agents: [baseAgent],
|
||||
nowMs: 20_000,
|
||||
});
|
||||
const dismissedState = clearOfficeAnimationTriggerHold({
|
||||
state: initialState,
|
||||
hold: "github",
|
||||
agentId: "main",
|
||||
});
|
||||
const sameMessageState = reconcileOfficeAnimationTriggerState({
|
||||
state: dismissedState,
|
||||
agents: [baseAgent],
|
||||
nowMs: 20_500,
|
||||
});
|
||||
const nextMessageState = reconcileOfficeAnimationTriggerState({
|
||||
state: sameMessageState,
|
||||
agents: [
|
||||
makeAgent({
|
||||
...baseAgent,
|
||||
lastUserMessage: "Review some code in the server room.",
|
||||
sessionEpoch: 2,
|
||||
}),
|
||||
],
|
||||
nowMs: 21_000,
|
||||
});
|
||||
|
||||
expect(sameMessageState.githubHoldByAgentId.main).toBeUndefined();
|
||||
expect(nextMessageState.githubHoldByAgentId.main).toBe(true);
|
||||
expect(nextMessageState.cleaningCues).toHaveLength(1);
|
||||
expect(nextMessageState.cleaningCues[0]?.agentId).toBe("main");
|
||||
});
|
||||
|
||||
it("does not restore completed messaging booth requests from transcript history", () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
const agents = [
|
||||
makeAgent({
|
||||
agentId: "main",
|
||||
name: "Main",
|
||||
sessionKey,
|
||||
lastUserMessage: "Text my wife that I am running late.",
|
||||
transcriptEntries: [
|
||||
makeTranscriptEntry({
|
||||
line: "Text my wife that I am running late.",
|
||||
role: "user",
|
||||
sequenceKey: 1,
|
||||
sessionKey,
|
||||
timestampMs: 40_000,
|
||||
}),
|
||||
makeTranscriptEntry({
|
||||
line: "[messaging booth] Message to my wife sent.",
|
||||
role: "assistant",
|
||||
sequenceKey: 2,
|
||||
sessionKey,
|
||||
timestampMs: 41_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const state = reconcileOfficeAnimationTriggerState({
|
||||
state: createOfficeAnimationTriggerState(),
|
||||
agents,
|
||||
nowMs: 45_000,
|
||||
});
|
||||
const animationState = buildOfficeAnimationState({
|
||||
state,
|
||||
agents,
|
||||
nowMs: 45_000,
|
||||
});
|
||||
|
||||
expect(state.textMessageByAgentId.main).toBeUndefined();
|
||||
expect(animationState.smsBoothHoldByAgentId.main).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not restore stale messaging booth requests from old transcript history", () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
const agents = [
|
||||
makeAgent({
|
||||
agentId: "main",
|
||||
name: "Main",
|
||||
sessionKey,
|
||||
lastUserMessage: "Text my wife.",
|
||||
transcriptEntries: [
|
||||
makeTranscriptEntry({
|
||||
line: "Text my wife.",
|
||||
role: "user",
|
||||
sequenceKey: 1,
|
||||
sessionKey,
|
||||
timestampMs: 10_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const state = reconcileOfficeAnimationTriggerState({
|
||||
state: createOfficeAnimationTriggerState(),
|
||||
agents,
|
||||
nowMs: 200_000,
|
||||
});
|
||||
const animationState = buildOfficeAnimationState({
|
||||
state,
|
||||
agents,
|
||||
nowMs: 200_000,
|
||||
});
|
||||
|
||||
expect(state.textMessageByAgentId.main).toBeUndefined();
|
||||
expect(animationState.textMessageByAgentId.main).toBeUndefined();
|
||||
expect(animationState.smsBoothHoldByAgentId.main).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("package manifest", () => {
|
||||
it("does not export local claw3d bin", () => {
|
||||
const packageJsonPath = path.join(process.cwd(), "package.json");
|
||||
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
bin?: Record<string, unknown>;
|
||||
};
|
||||
const hasOpenclawStudioBin = Object.prototype.hasOwnProperty.call(
|
||||
parsed.bin ?? {},
|
||||
"claw3d"
|
||||
);
|
||||
expect(hasOpenclawStudioBin).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
let tempHome: string | null = null;
|
||||
|
||||
const setupHome = () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-home-"));
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
|
||||
fs.mkdirSync(path.join(tempHome, "Documents"), { recursive: true });
|
||||
fs.mkdirSync(path.join(tempHome, "Downloads"), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempHome, "Doc.txt"), "doc", "utf8");
|
||||
fs.writeFileSync(path.join(tempHome, "Notes.txt"), "notes", "utf8");
|
||||
fs.writeFileSync(path.join(tempHome, ".secret"), "hidden", "utf8");
|
||||
};
|
||||
|
||||
const cleanupHome = () => {
|
||||
const home = tempHome;
|
||||
tempHome = null;
|
||||
vi.restoreAllMocks();
|
||||
if (!home) return;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
let GET: typeof import("@/app/api/path-suggestions/route")["GET"];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ GET } = await import("@/app/api/path-suggestions/route"));
|
||||
});
|
||||
|
||||
beforeEach(setupHome);
|
||||
afterEach(cleanupHome);
|
||||
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
describe("/api/path-suggestions route", () => {
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("returns non-hidden entries for home by default", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/path-suggestions"));
|
||||
const body = (await response.json()) as { entries: Array<{ displayPath: string }> };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.entries.map((entry) => entry.displayPath)).toEqual([
|
||||
"~/Documents/",
|
||||
"~/Downloads/",
|
||||
"~/Doc.txt",
|
||||
"~/Notes.txt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters by prefix within the current directory", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/path-suggestions?q=~/Doc"));
|
||||
const body = (await response.json()) as { entries: Array<{ displayPath: string }> };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.entries.map((entry) => entry.displayPath)).toEqual([
|
||||
"~/Documents/",
|
||||
"~/Doc.txt",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects paths outside the home directory", async () => {
|
||||
const response = await GET(new Request("http://localhost/api/path-suggestions?q=~/../"));
|
||||
const body = (await response.json()) as { error: string };
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toMatch(/home/i);
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 for missing directories", async () => {
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/path-suggestions?q=~/Missing/")
|
||||
);
|
||||
const body = (await response.json()) as { error: string };
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toMatch(/does not exist/i);
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PendingExecApproval } from "@/features/agents/approvals/types";
|
||||
import {
|
||||
mergePendingApprovalsForFocusedAgent,
|
||||
removePendingApprovalEverywhere,
|
||||
nextPendingApprovalPruneDelayMs,
|
||||
pruneExpiredPendingApprovals,
|
||||
pruneExpiredPendingApprovalsMap,
|
||||
removePendingApprovalById,
|
||||
removePendingApprovalByIdMap,
|
||||
upsertPendingApproval,
|
||||
updatePendingApprovalById,
|
||||
} from "@/features/agents/approvals/pendingStore";
|
||||
|
||||
const createApproval = (id: string, expiresAtMs: number): PendingExecApproval => ({
|
||||
id,
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:agent-1:main",
|
||||
command: "pwd",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
resolvedPath: "/bin/pwd",
|
||||
createdAtMs: expiresAtMs - 1000,
|
||||
expiresAtMs,
|
||||
resolving: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
describe("pending approval store", () => {
|
||||
it("removes an approval id from scoped and unscoped collections", () => {
|
||||
const approvalA = createApproval("a", 10_000);
|
||||
const approvalB = createApproval("b", 20_000);
|
||||
const approvalC = createApproval("c", 30_000);
|
||||
const scoped = {
|
||||
"agent-1": [approvalA, approvalB],
|
||||
"agent-2": [approvalC],
|
||||
};
|
||||
const unscoped = [approvalB, approvalC];
|
||||
|
||||
const removed = removePendingApprovalEverywhere({
|
||||
approvalsByAgentId: scoped,
|
||||
unscopedApprovals: unscoped,
|
||||
approvalId: "b",
|
||||
});
|
||||
|
||||
expect(removed.approvalsByAgentId).toEqual({
|
||||
"agent-1": [approvalA],
|
||||
"agent-2": [approvalC],
|
||||
});
|
||||
expect(removed.unscopedApprovals).toEqual([approvalC]);
|
||||
});
|
||||
|
||||
it("is idempotent when approval id is missing", () => {
|
||||
const scoped = {
|
||||
"agent-1": [createApproval("a", 10_000)],
|
||||
};
|
||||
const unscoped = [createApproval("b", 20_000)];
|
||||
|
||||
const removed = removePendingApprovalEverywhere({
|
||||
approvalsByAgentId: scoped,
|
||||
unscopedApprovals: unscoped,
|
||||
approvalId: "missing",
|
||||
});
|
||||
|
||||
expect(removed.approvalsByAgentId).toBe(scoped);
|
||||
expect(removed.unscopedApprovals).toBe(unscoped);
|
||||
});
|
||||
|
||||
it("drops empty scoped buckets after removal", () => {
|
||||
const scoped = {
|
||||
"agent-1": [createApproval("a", 10_000)],
|
||||
"agent-2": [createApproval("b", 20_000)],
|
||||
};
|
||||
|
||||
const removed = removePendingApprovalEverywhere({
|
||||
approvalsByAgentId: scoped,
|
||||
unscopedApprovals: [],
|
||||
approvalId: "a",
|
||||
});
|
||||
|
||||
expect(removed.approvalsByAgentId).toEqual({
|
||||
"agent-2": [createApproval("b", 20_000)],
|
||||
});
|
||||
});
|
||||
|
||||
it("upserts approvals and keeps most recent at the top", () => {
|
||||
const a = createApproval("a", 10_000);
|
||||
const b = createApproval("b", 20_000);
|
||||
const updatedA = { ...a, command: "ls" };
|
||||
|
||||
const added = upsertPendingApproval([], a);
|
||||
expect(added).toEqual([a]);
|
||||
|
||||
const withB = upsertPendingApproval(added, b);
|
||||
expect(withB).toEqual([b, a]);
|
||||
|
||||
const upsertedA = upsertPendingApproval(withB, updatedA);
|
||||
expect(upsertedA).toEqual([b, updatedA]);
|
||||
});
|
||||
|
||||
it("updates and removes approvals by id", () => {
|
||||
const approvals = [createApproval("a", 10_000), createApproval("b", 20_000)];
|
||||
const updated = updatePendingApprovalById(approvals, "a", (approval) => ({
|
||||
...approval,
|
||||
resolving: true,
|
||||
}));
|
||||
expect(updated[0]?.resolving).toBe(true);
|
||||
|
||||
const removed = removePendingApprovalById(updated, "a");
|
||||
expect(removed).toHaveLength(1);
|
||||
expect(removed[0]?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("removes approvals by id across agent map and drops empty keys", () => {
|
||||
const map = {
|
||||
"agent-1": [createApproval("a", 10_000)],
|
||||
"agent-2": [createApproval("b", 20_000)],
|
||||
};
|
||||
const removed = removePendingApprovalByIdMap(map, "a");
|
||||
expect(removed).toEqual({
|
||||
"agent-2": [createApproval("b", 20_000)],
|
||||
});
|
||||
});
|
||||
|
||||
it("prunes expired approvals with grace window", () => {
|
||||
const nowMs = 10_000;
|
||||
const graceMs = 500;
|
||||
const expired = createApproval("a", nowMs - 600);
|
||||
const graceBoundary = createApproval("b", nowMs - 500);
|
||||
const active = createApproval("c", nowMs + 200);
|
||||
|
||||
const pruned = pruneExpiredPendingApprovals([expired, graceBoundary, active], {
|
||||
nowMs,
|
||||
graceMs,
|
||||
});
|
||||
|
||||
expect(pruned.map((entry) => entry.id)).toEqual(["b", "c"]);
|
||||
|
||||
const mapPruned = pruneExpiredPendingApprovalsMap(
|
||||
{
|
||||
"agent-1": [expired, active],
|
||||
"agent-2": [graceBoundary],
|
||||
},
|
||||
{ nowMs, graceMs }
|
||||
);
|
||||
|
||||
expect(mapPruned).toEqual({
|
||||
"agent-1": [active],
|
||||
"agent-2": [graceBoundary],
|
||||
});
|
||||
});
|
||||
|
||||
it("computes next prune delay from the earliest expiry", () => {
|
||||
const nowMs = 5_000;
|
||||
const delay = nextPendingApprovalPruneDelayMs({
|
||||
approvalsByAgentId: {
|
||||
"agent-1": [createApproval("a", 9_000)],
|
||||
"agent-2": [createApproval("b", 6_000)],
|
||||
},
|
||||
unscopedApprovals: [createApproval("c", 7_000)],
|
||||
nowMs,
|
||||
graceMs: 500,
|
||||
});
|
||||
expect(delay).toBe(1_500);
|
||||
|
||||
const none = nextPendingApprovalPruneDelayMs({
|
||||
approvalsByAgentId: {},
|
||||
unscopedApprovals: [],
|
||||
nowMs,
|
||||
graceMs: 500,
|
||||
});
|
||||
expect(none).toBeNull();
|
||||
});
|
||||
|
||||
it("merges focused approvals without rendering duplicate ids", () => {
|
||||
const unscopedA = createApproval("same", 10_000);
|
||||
const unscopedB = createApproval("unscoped-only", 11_000);
|
||||
const scopedSame = { ...createApproval("same", 12_000), agentId: "agent-2" };
|
||||
const scopedC = { ...createApproval("scoped-only", 13_000), agentId: "agent-2" };
|
||||
|
||||
const merged = mergePendingApprovalsForFocusedAgent({
|
||||
scopedApprovals: [scopedSame, scopedC],
|
||||
unscopedApprovals: [unscopedA, unscopedB],
|
||||
});
|
||||
|
||||
expect(merged.map((entry) => entry.id)).toEqual(["same", "unscoped-only", "scoped-only"]);
|
||||
expect(merged[0]).toEqual(scopedSame);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createAgentFilesState } from "@/lib/agents/agentFiles";
|
||||
import {
|
||||
parsePersonalityFiles,
|
||||
serializePersonalityFiles,
|
||||
type PersonalityBuilderDraft,
|
||||
} from "@/lib/agents/personalityBuilder";
|
||||
|
||||
const createFiles = () => createAgentFilesState();
|
||||
|
||||
describe("personalityBuilder", () => {
|
||||
it("parseIdentityMarkdown_extracts_fields_from_template_style_list", () => {
|
||||
const files = createFiles();
|
||||
files["IDENTITY.md"] = {
|
||||
exists: true,
|
||||
content: `# IDENTITY.md - Who Am I?\n\n- **Name:** Nova\n- **Creature:** fox spirit\n- **Vibe:** calm + direct\n- **Emoji:** 🦊\n- **Avatar:** avatars/nova.png\n`,
|
||||
};
|
||||
|
||||
const draft = parsePersonalityFiles(files);
|
||||
|
||||
expect(draft.identity).toEqual({
|
||||
name: "Nova",
|
||||
creature: "fox spirit",
|
||||
vibe: "calm + direct",
|
||||
emoji: "🦊",
|
||||
avatar: "avatars/nova.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("parseUserMarkdown_extracts_context_block_and_profile_fields", () => {
|
||||
const files = createFiles();
|
||||
files["USER.md"] = {
|
||||
exists: true,
|
||||
content: `# USER.md - About Your Human\n\n- **Name:** George\n- **What to call them:** GP\n- **Pronouns:** he/him\n- **Timezone:** America/Chicago\n- **Notes:** Building Claw3D.\n\n## Context\n\nWants concise technical answers.\nPrefers implementation over discussion.\n`,
|
||||
};
|
||||
|
||||
const draft = parsePersonalityFiles(files);
|
||||
|
||||
expect(draft.user).toEqual({
|
||||
name: "George",
|
||||
callThem: "GP",
|
||||
pronouns: "he/him",
|
||||
timezone: "America/Chicago",
|
||||
notes: "Building Claw3D.",
|
||||
context: "Wants concise technical answers.\nPrefers implementation over discussion.",
|
||||
});
|
||||
});
|
||||
|
||||
it("parseSoulMarkdown_extracts_core_sections", () => {
|
||||
const files = createFiles();
|
||||
files["SOUL.md"] = {
|
||||
exists: true,
|
||||
content: `# SOUL.md - Who You Are\n\n## Core Truths\n\nBe direct.\nAvoid filler.\n\n## Boundaries\n\n- Keep user data private.\n\n## Vibe\n\nPragmatic and calm.\n\n## Continuity\n\nUpdate files when behavior changes.\n`,
|
||||
};
|
||||
|
||||
const draft = parsePersonalityFiles(files);
|
||||
|
||||
expect(draft.soul).toEqual({
|
||||
coreTruths: "Be direct.\nAvoid filler.",
|
||||
boundaries: "- Keep user data private.",
|
||||
vibe: "Pragmatic and calm.",
|
||||
continuity: "Update files when behavior changes.",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores_template_placeholders_for_identity_and_user", () => {
|
||||
const files = createFiles();
|
||||
files["IDENTITY.md"] = {
|
||||
exists: true,
|
||||
content:
|
||||
"# IDENTITY.md - Who Am I?\n\n- **Name:** _(pick something you like)_\n- **Creature:** _(AI? robot? familiar? ghost in the machine? something weirder?)_\n- **Vibe:** _(how do you come across? sharp? warm? chaotic? calm?)_\n- **Emoji:** _(your signature — pick one that feels right)_\n- **Avatar:** _(workspace-relative path, http(s) URL, or data URI)_\n",
|
||||
};
|
||||
files["USER.md"] = {
|
||||
exists: true,
|
||||
content:
|
||||
"# USER.md - About Your Human\n\n- **Name:**\n- **What to call them:**\n- **Pronouns:** _(optional)_\n- **Timezone:**\n- **Notes:**\n\n## Context\n\n_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_\n",
|
||||
};
|
||||
|
||||
const draft = parsePersonalityFiles(files);
|
||||
|
||||
expect(draft.identity).toEqual({
|
||||
name: "",
|
||||
creature: "",
|
||||
vibe: "",
|
||||
emoji: "",
|
||||
avatar: "",
|
||||
});
|
||||
expect(draft.user).toEqual({
|
||||
name: "",
|
||||
callThem: "",
|
||||
pronouns: "",
|
||||
timezone: "",
|
||||
notes: "",
|
||||
context: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("serializePersonalityFiles_emits_stable_markdown_for_identity_user_soul", () => {
|
||||
const draft: PersonalityBuilderDraft = {
|
||||
identity: {
|
||||
name: "Nova",
|
||||
creature: "fox spirit",
|
||||
vibe: "calm + direct",
|
||||
emoji: "🦊",
|
||||
avatar: "avatars/nova.png",
|
||||
},
|
||||
user: {
|
||||
name: "George",
|
||||
callThem: "GP",
|
||||
pronouns: "he/him",
|
||||
timezone: "America/Chicago",
|
||||
notes: "Building Claw3D.",
|
||||
context: "Wants concise technical answers.\nPrefers implementation over discussion.",
|
||||
},
|
||||
soul: {
|
||||
coreTruths: "Be direct.\nAvoid filler.",
|
||||
boundaries: "- Keep user data private.",
|
||||
vibe: "Pragmatic and calm.",
|
||||
continuity: "Update files when behavior changes.",
|
||||
},
|
||||
agents: "Top-level operating rules.",
|
||||
tools: "Tool conventions.",
|
||||
heartbeat: "Heartbeat notes.",
|
||||
memory: "Durable memory.",
|
||||
};
|
||||
|
||||
const files = serializePersonalityFiles(draft);
|
||||
|
||||
expect(files["IDENTITY.md"]).toBe(
|
||||
[
|
||||
"# IDENTITY.md - Who Am I?",
|
||||
"",
|
||||
"- Name: Nova",
|
||||
"- Creature: fox spirit",
|
||||
"- Vibe: calm + direct",
|
||||
"- Emoji: 🦊",
|
||||
"- Avatar: avatars/nova.png",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
expect(files["USER.md"]).toBe(
|
||||
[
|
||||
"# USER.md - About Your Human",
|
||||
"",
|
||||
"- Name: George",
|
||||
"- What to call them: GP",
|
||||
"- Pronouns: he/him",
|
||||
"- Timezone: America/Chicago",
|
||||
"- Notes: Building Claw3D.",
|
||||
"",
|
||||
"## Context",
|
||||
"",
|
||||
"Wants concise technical answers.",
|
||||
"Prefers implementation over discussion.",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
expect(files["SOUL.md"]).toBe(
|
||||
[
|
||||
"# SOUL.md - Who You Are",
|
||||
"",
|
||||
"## Core Truths",
|
||||
"",
|
||||
"Be direct.",
|
||||
"Avoid filler.",
|
||||
"",
|
||||
"## Boundaries",
|
||||
"",
|
||||
"- Keep user data private.",
|
||||
"",
|
||||
"## Vibe",
|
||||
"",
|
||||
"Pragmatic and calm.",
|
||||
"",
|
||||
"## Continuity",
|
||||
"",
|
||||
"Update files when behavior changes.",
|
||||
"",
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
expect(files["AGENTS.md"]).toBe("Top-level operating rules.");
|
||||
expect(files["TOOLS.md"]).toBe("Tool conventions.");
|
||||
expect(files["HEARTBEAT.md"]).toBe("Heartbeat notes.");
|
||||
expect(files["MEMORY.md"]).toBe("Durable memory.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createRafBatcher } from "@/lib/dom";
|
||||
|
||||
describe("createRafBatcher", () => {
|
||||
const originalRaf = globalThis.requestAnimationFrame;
|
||||
const originalCaf = globalThis.cancelAnimationFrame;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.requestAnimationFrame = originalRaf;
|
||||
globalThis.cancelAnimationFrame = originalCaf;
|
||||
});
|
||||
|
||||
it("flushes at most once per animation frame", () => {
|
||||
const flush = vi.fn();
|
||||
let queued: unknown = null;
|
||||
globalThis.requestAnimationFrame = vi.fn((cb: (time: number) => void) => {
|
||||
queued = cb;
|
||||
return 1;
|
||||
});
|
||||
globalThis.cancelAnimationFrame = vi.fn();
|
||||
|
||||
const batcher = createRafBatcher(flush);
|
||||
batcher.schedule();
|
||||
batcher.schedule();
|
||||
batcher.schedule();
|
||||
|
||||
expect(flush).not.toHaveBeenCalled();
|
||||
if (typeof queued !== "function") {
|
||||
throw new Error("requestAnimationFrame was not scheduled.");
|
||||
}
|
||||
(queued as (time: number) => void)(0);
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels a scheduled flush", () => {
|
||||
const flush = vi.fn();
|
||||
globalThis.requestAnimationFrame = vi.fn(() => 123);
|
||||
globalThis.cancelAnimationFrame = vi.fn();
|
||||
|
||||
const batcher = createRafBatcher(flush);
|
||||
batcher.schedule();
|
||||
batcher.cancel();
|
||||
|
||||
expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(123);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user