4fa4f13558
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
552 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|