Files
horus-3d/tests/unit/useChatInteractionController.test.ts
Luke The Dev 4fa4f13558 First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
2026-03-19 23:14:04 -05:00

552 lines
16 KiB
TypeScript

import { createElement, useEffect } from "react";
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
import type { AgentState } from "@/features/agents/state/store";
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
vi.mock("@/features/agents/operations/chatSendOperation", () => ({
sendChatMessageViaStudio: vi.fn(async () => undefined),
}));
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: "run-1",
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,
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,
};
};
type ControllerValue = ReturnType<typeof useChatInteractionController>;
type GatewayStatus = "disconnected" | "connecting" | "connected";
type InteractionDispatchAction =
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
| { type: "appendOutput"; agentId: string; line: string }
| { type: "enqueueQueuedMessage"; agentId: string; message: string }
| { type: "removeQueuedMessage"; agentId: string; index: number }
| { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string };
type CallFn = (method: string, params: unknown) => Promise<unknown>;
type DispatchFn = (action: InteractionDispatchAction) => void;
type ErrorFn = (message: string) => void;
type RunTrackingFn = (runId?: string | null) => void;
type HistoryInFlightFn = (sessionKey: string) => void;
type AgentIdFn = (agentId: string) => void;
type VoidFn = () => void;
type RenderControllerContext = {
getValue: () => ControllerValue;
unmount: () => void;
setAgents: (next: AgentState[]) => void;
call: ReturnType<typeof vi.fn<CallFn>>;
dispatch: ReturnType<typeof vi.fn<DispatchFn>>;
setError: ReturnType<typeof vi.fn<ErrorFn>>;
clearRunTracking: ReturnType<typeof vi.fn<RunTrackingFn>>;
clearHistoryInFlight: ReturnType<typeof vi.fn<HistoryInFlightFn>>;
clearSpecialUpdateMarker: ReturnType<typeof vi.fn<AgentIdFn>>;
clearSpecialLatestUpdateInFlight: ReturnType<typeof vi.fn<AgentIdFn>>;
setInspectSidebarNull: ReturnType<typeof vi.fn<VoidFn>>;
setMobilePaneChat: ReturnType<typeof vi.fn<VoidFn>>;
};
const renderController = (
overrides?: Partial<{
status: GatewayStatus;
agents: AgentState[];
call: CallFn;
dispatch: DispatchFn;
setError: ErrorFn;
clearRunTracking: RunTrackingFn;
clearHistoryInFlight: HistoryInFlightFn;
clearSpecialUpdateMarker: AgentIdFn;
clearSpecialLatestUpdateInFlight: AgentIdFn;
setInspectSidebarNull: VoidFn;
setMobilePaneChat: VoidFn;
}>
): RenderControllerContext => {
let agents = overrides?.agents ?? [createAgent()];
const call = vi.fn<CallFn>(overrides?.call ?? (async () => ({})));
const dispatch = vi.fn<DispatchFn>(overrides?.dispatch ?? (() => undefined));
const setError = vi.fn<ErrorFn>(overrides?.setError ?? (() => undefined));
const clearRunTracking = vi.fn<RunTrackingFn>(
overrides?.clearRunTracking ?? (() => undefined)
);
const clearHistoryInFlight = vi.fn<HistoryInFlightFn>(
overrides?.clearHistoryInFlight ?? (() => undefined)
);
const clearSpecialUpdateMarker = vi.fn<AgentIdFn>(
overrides?.clearSpecialUpdateMarker ?? (() => undefined)
);
const clearSpecialLatestUpdateInFlight = vi.fn<AgentIdFn>(
overrides?.clearSpecialLatestUpdateInFlight ?? (() => undefined)
);
const setInspectSidebarNull = vi.fn<VoidFn>(
overrides?.setInspectSidebarNull ?? (() => undefined)
);
const setMobilePaneChat = vi.fn<VoidFn>(overrides?.setMobilePaneChat ?? (() => undefined));
const valueRef: { current: ControllerValue | null } = { current: null };
const Probe = ({
onValue,
}: {
onValue: (value: ControllerValue) => void;
}) => {
const value = useChatInteractionController({
client: {
call,
},
status: overrides?.status ?? "connected",
agents,
dispatch,
setError,
getAgents: () => agents,
clearRunTracking,
clearHistoryInFlight,
clearSpecialUpdateMarker,
clearSpecialLatestUpdateInFlight,
setInspectSidebarNull,
setMobilePaneChat,
});
useEffect(() => {
onValue(value);
}, [onValue, value]);
return createElement("div", { "data-testid": "probe" }, "ok");
};
const rendered = render(
createElement(Probe, {
onValue: (value) => {
valueRef.current = value;
},
})
);
return {
getValue: () => {
if (!valueRef.current) throw new Error("controller value unavailable");
return valueRef.current;
},
unmount: () => {
rendered.unmount();
},
setAgents: (next) => {
agents = next;
rendered.rerender(
createElement(Probe, {
onValue: (value) => {
valueRef.current = value;
},
})
);
},
call,
dispatch,
setError,
clearRunTracking,
clearHistoryInFlight,
clearSpecialUpdateMarker,
clearSpecialLatestUpdateInFlight,
setInspectSidebarNull,
setMobilePaneChat,
};
};
describe("useChatInteractionController", () => {
const mockedSendChatMessageViaStudio = vi.mocked(sendChatMessageViaStudio);
const originalRaf = globalThis.requestAnimationFrame;
const originalCaf = globalThis.cancelAnimationFrame;
beforeEach(() => {
vi.useFakeTimers();
mockedSendChatMessageViaStudio.mockReset();
mockedSendChatMessageViaStudio.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCaf;
vi.restoreAllMocks();
});
it("flushes pending draft and cancels debounce timer", async () => {
const ctx = renderController();
act(() => {
ctx.getValue().handleDraftChange("agent-1", "first");
ctx.getValue().handleDraftChange("agent-1", "second");
});
expect(ctx.dispatch).not.toHaveBeenCalled();
act(() => {
ctx.getValue().flushPendingDraft("agent-1");
});
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "updateAgent",
agentId: "agent-1",
patch: { draft: "second" },
});
await vi.advanceTimersByTimeAsync(1000);
const draftUpdates = ctx.dispatch.mock.calls
.map(([action]: [InteractionDispatchAction]) => action)
.filter(
(action) =>
action.type === "updateAgent" &&
action.agentId === "agent-1" &&
action.patch?.draft === "second"
);
expect(draftUpdates).toHaveLength(1);
});
it("clears pending draft timer/value and live patch before send", async () => {
let queuedFrame: ((time: number) => void) | null = null;
globalThis.requestAnimationFrame = vi.fn((callback: (time: number) => void) => {
queuedFrame = callback;
return 77;
});
globalThis.cancelAnimationFrame = vi.fn();
const ctx = renderController();
act(() => {
ctx.getValue().handleDraftChange("agent-1", "queued draft");
ctx.getValue().queueLivePatch("agent-1", { streamText: "pending stream" });
});
await act(async () => {
await ctx.getValue().handleSend("agent-1", "session-1", " hello world ");
});
expect(mockedSendChatMessageViaStudio).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
sessionKey: "session-1",
message: "hello world",
})
);
expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(77);
await vi.advanceTimersByTimeAsync(300);
expect(
ctx.dispatch.mock.calls.some(
([action]: [InteractionDispatchAction]) =>
action.type === "updateAgent" &&
action.agentId === "agent-1" &&
action.patch?.draft === "queued draft"
)
).toBe(false);
if (queuedFrame) {
act(() => {
queuedFrame?.(0);
});
}
expect(
ctx.dispatch.mock.calls.some(
([action]: [InteractionDispatchAction]) =>
action.type === "updateAgent" &&
action.agentId === "agent-1" &&
action.patch?.streamText === "pending stream"
)
).toBe(false);
});
it("queues messages instead of sending while the agent is running", async () => {
const ctx = renderController({
agents: [createAgent({ status: "running", queuedMessages: [] })],
});
await act(async () => {
await ctx.getValue().handleSend("agent-1", "session-1", " follow up ");
});
expect(mockedSendChatMessageViaStudio).not.toHaveBeenCalled();
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "enqueueQueuedMessage",
agentId: "agent-1",
message: "follow up",
});
});
it("drains one queued message when an agent becomes idle", async () => {
const ctx = renderController({
agents: [createAgent({ status: "running", queuedMessages: ["next message"] })],
});
act(() => {
ctx.setAgents([
createAgent({
status: "idle",
sessionKey: "agent:agent-1:studio:drain",
queuedMessages: ["next message"],
}),
]);
});
await act(async () => {
await Promise.resolve();
});
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "shiftQueuedMessage",
agentId: "agent-1",
expectedMessage: "next message",
});
expect(mockedSendChatMessageViaStudio).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "agent-1",
sessionKey: "agent:agent-1:studio:drain",
message: "next message",
})
);
});
it("does not drain queued messages while disconnected", async () => {
const ctx = renderController({
status: "disconnected",
agents: [createAgent({ status: "idle", queuedMessages: ["keep queued"] })],
});
await act(async () => {
await Promise.resolve();
});
expect(mockedSendChatMessageViaStudio).not.toHaveBeenCalled();
expect(
ctx.dispatch.mock.calls.some(
([action]: [InteractionDispatchAction]) => action.type === "shiftQueuedMessage"
)
).toBe(false);
});
it("removes a queued message by index", () => {
const ctx = renderController({
agents: [createAgent({ queuedMessages: ["first", "second"] })],
});
act(() => {
ctx.getValue().removeQueuedMessage("agent-1", 0);
});
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "removeQueuedMessage",
agentId: "agent-1",
index: 0,
});
});
it("deduplicates stop-run while busy and clears busy state after success", async () => {
let resolveAbort: ((value?: void | PromiseLike<void>) => void) | undefined;
const abortPromise = new Promise<void>((resolve) => {
resolveAbort = resolve;
});
const call = vi.fn(async (method: string) => {
if (method === "chat.abort") {
await abortPromise;
return {};
}
return {};
});
const ctx = renderController({ call });
let firstCall: Promise<void> | null = null;
act(() => {
firstCall = ctx.getValue().handleStopRun("agent-1", " session-1 ");
});
expect(ctx.getValue().stopBusyAgentId).toBe("agent-1");
await act(async () => {
await ctx.getValue().handleStopRun("agent-1", "session-1");
});
expect(call).toHaveBeenCalledTimes(1);
resolveAbort?.();
await act(async () => {
await firstCall;
});
expect(ctx.getValue().stopBusyAgentId).toBeNull();
});
it("reports stop-run failures and clears busy state", async () => {
const logSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const ctx = renderController({
call: vi.fn(async (method: string) => {
if (method === "chat.abort") {
throw new Error("abort failed");
}
return {};
}),
});
await act(async () => {
await ctx.getValue().handleStopRun("agent-1", "session-1");
});
expect(ctx.setError).toHaveBeenCalledWith("abort failed");
expect(logSpy).toHaveBeenCalledWith("abort failed");
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "appendOutput",
agentId: "agent-1",
line: "Stop failed: abort failed",
});
expect(ctx.getValue().stopBusyAgentId).toBeNull();
});
it("runs new-session side effects in sequence and updates agent state", async () => {
const order: string[] = [];
const call = vi.fn(async (method: string) => {
if (method === "sessions.reset") {
order.push("sessions.reset");
}
return {};
});
const dispatch = vi.fn<DispatchFn>((action) => {
if (action.type === "updateAgent") {
order.push("dispatch:updateAgent");
}
});
const clearRunTracking = vi.fn(() => {
order.push("clearRunTracking");
});
const clearHistoryInFlight = vi.fn(() => {
order.push("clearHistoryInFlight");
});
const clearSpecialUpdateMarker = vi.fn(() => {
order.push("clearSpecialUpdateMarker");
});
const clearSpecialLatestUpdateInFlight = vi.fn(() => {
order.push("clearSpecialLatestUpdateInFlight");
});
const setInspectSidebarNull = vi.fn(() => {
order.push("setInspectSidebarNull");
});
const setMobilePaneChat = vi.fn(() => {
order.push("setMobilePaneChat");
});
const ctx = renderController({
call,
dispatch,
clearRunTracking,
clearHistoryInFlight,
clearSpecialUpdateMarker,
clearSpecialLatestUpdateInFlight,
setInspectSidebarNull,
setMobilePaneChat,
agents: [
createAgent({
agentId: "agent-1",
runId: "run-42",
sessionKey: " session-42 ",
}),
],
});
await act(async () => {
await ctx.getValue().handleNewSession("agent-1");
});
expect(call).toHaveBeenCalledWith("sessions.reset", { key: "session-42" });
expect(order).toEqual([
"sessions.reset",
"clearRunTracking",
"clearHistoryInFlight",
"clearSpecialUpdateMarker",
"clearSpecialLatestUpdateInFlight",
"dispatch:updateAgent",
"setInspectSidebarNull",
"setMobilePaneChat",
]);
});
it("appends output when new-session fails", async () => {
const ctx = renderController({
agents: [
createAgent({
agentId: "agent-1",
sessionKey: " ",
}),
],
});
await act(async () => {
await ctx.getValue().handleNewSession("agent-1");
});
expect(ctx.setError).toHaveBeenCalledWith("Missing session key for agent.");
expect(ctx.dispatch).toHaveBeenCalledWith({
type: "appendOutput",
agentId: "agent-1",
line: "New session failed: Missing session key for agent.",
});
});
it("cleans up draft timers and queued frame on unmount", async () => {
globalThis.requestAnimationFrame = vi.fn(() => 555);
globalThis.cancelAnimationFrame = vi.fn();
const clearTimeoutSpy = vi.spyOn(window, "clearTimeout");
const ctx = renderController();
act(() => {
ctx.getValue().handleDraftChange("agent-1", "queued");
ctx.getValue().queueLivePatch("agent-1", { streamText: "delta" });
});
ctx.unmount();
expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(555);
expect(clearTimeoutSpy).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(
ctx.dispatch.mock.calls.some(
([action]: [InteractionDispatchAction]) =>
action.type === "updateAgent" &&
action.agentId === "agent-1" &&
action.patch?.draft === "queued"
)
).toBe(false);
});
});