First Release of Claw3D (#11)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-19 23:14:04 -05:00
committed by GitHub
parent 5ea96b2650
commit 4fa4f13558
431 changed files with 105438 additions and 14 deletions
+48
View File
@@ -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);
});
});
+218
View File
@@ -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();
});
});
+176
View File
@@ -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");
});
});
+618
View File
@@ -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();
});
});
+148
View File
@@ -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();
});
});
+130
View File
@@ -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");
});
});
+45
View File
@@ -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();
});
});
+151
View File
@@ -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");
});
});
+122
View File
@@ -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
+59
View File
@@ -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"'),
})
);
});
});
+43
View File
@@ -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");
});
});
+184
View File
@@ -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"]));
});
});
+432
View File
@@ -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",
]);
});
});
+31
View File
@@ -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");
});
});
+178
View File
@@ -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",
});
});
});
+338
View File
@@ -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_" }],
},
]);
});
});
+665
View File
@@ -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);
});
});
+44
View File
@@ -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");
});
});
+37
View File
@@ -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);
});
});
+84
View File
@@ -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 });
});
});
+173
View File
@@ -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();
});
});
+126
View File
@@ -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.");
});
});
+314
View File
@@ -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.");
});
});
+104
View File
@@ -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");
});
});
+277
View File
@@ -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();
});
});
+120
View File
@@ -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);
});
});
+120
View File
@@ -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",
});
});
});
+213
View File
@@ -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);
});
});
+125
View File
@@ -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 }),
]);
});
});
+113
View File
@@ -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");
});
});
+39
View File
@@ -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,
});
});
});
+105
View File
@@ -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",
});
});
});
+147
View File
@@ -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");
});
});
+57
View File
@@ -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.");
});
});
+373
View File
@@ -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", {});
});
});
+136
View File
@@ -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",
});
});
});
+45
View File
@@ -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");
});
});
+267
View File
@@ -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"]);
});
});
+135
View File
@@ -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);
});
});
+23
View File
@@ -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);
});
});
+127
View File
@@ -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);
});
});
+106
View File
@@ -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);
});
});
+646
View File
@@ -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),
]);
}
});
});
+181
View File
@@ -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", {}]]);
});
});
+19
View File
@@ -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();
});
});
+46
View File
@@ -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:");
});
});
+54
View File
@@ -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);
});
});
+225
View File
@@ -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" });
});
});
+81
View File
@@ -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);
});
});
+238
View File
@@ -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);
});
});
+394
View File
@@ -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);
});
});
+50
View File
@@ -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([]);
});
});
+132
View File
@@ -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"]);
});
});
+71
View File
@@ -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,
});
});
});
+68
View File
@@ -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",
});
});
});
+31
View File
@@ -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("![](/api/gateway/media?path=");
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("![](/api/gateway/media?path=");
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);
});
});
+127
View File
@@ -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");
});
});
+19
View File
@@ -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");
});
});
+26
View File
@@ -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" });
});
});
+430
View File
@@ -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();
});
});
+19
View File
@@ -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);
});
});
+87
View File
@@ -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);
});
});
+190
View File
@@ -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.");
});
});
+47
View File
@@ -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