First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user