Files
kandev/apps/web/components/quick-chat/use-quick-chat-modal.test.ts
T
José Almeida 7e8b4bb30e feat(quick-chat): eager-init agent on profile selection (#747)
* feat(quick-chat): eager-init agent on profile selection

Switch quick-chat backend from `IntentPrepare` to `IntentStart` so the
agent process boots immediately when the user picks a profile, instead
of waiting for the first prompt. The ACP `session/new` handshake fires
before the user types anything, so the slash menu, mode dropdown, and
model selector are populated up-front. Fixes the empty-slash-menu UX
in quick chat for ACP agents (OpenCode, Claude, Codex, etc.).

Frontend picker shows a per-card spinner + disables sibling cards
during boot. Rapid profile switches (and other picker-bypassing
actions like opening a new tab, switching tabs, or closing the modal)
supersede the in-flight start via a monotonic request id; the
resolved task is cleaned up via `deleteQuickChatTask` so we never
orphan an ephemeral task or its agent process.

Backend rollback uses a fresh background context so `DeleteTask`
runs even when the request context has already been cancelled by
the client.

`mock-agent` now emits `available_commands_update` from `NewSession`
and `LoadSession` (matching real OpenCode/Claude) so the eager-init
pipeline is exercised in e2e. Existing prompt-time emit kept as an
idempotent fallback.

* test+cleanup(quick-chat): unit tests for useAgentSelection; minor cleanups

- Add 5 unit tests for useAgentSelection covering happy path, rapid-pick
  supersession, reset() during in-flight, and error toast vs silent-on-superseded.
  Exercises the orphan-task cleanup that the eager-init flow depends on.
  (claude bot review)
- Drop redundant isDisabled alias in AgentPickerView. (claude bot review)
- Drop unused ctx parameter from emitAvailableCommandsAfterDelay — it was
  always called with context.Background() so the Done() branch was dead
  code. (claude bot review)
- Re-format with prettier after the rebase.

* fix(quick-chat): use constants.TaskDeleteTimeout for rollback ctx

Match the other DeleteTask call sites in task_http_handlers.go (line 851)
instead of an ad-hoc 10s timeout. (claude bot review)
2026-05-01 00:21:53 +01:00

197 lines
6.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
// Mocks must be declared before importing the hook so vi.mock hoists correctly.
const mockToast = vi.fn();
const mockStartQuickChat = vi.fn();
const mockDeleteTask = vi.fn();
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mockToast }),
}));
vi.mock("@/lib/api/domains/workspace-api", () => ({
startQuickChat: (...args: unknown[]) => mockStartQuickChat(...args),
}));
vi.mock("@/lib/api/domains/kanban-api", () => ({
deleteTask: (...args: unknown[]) => mockDeleteTask(...args),
}));
import { useAgentSelection } from "./use-quick-chat-modal";
const WORKSPACE_ID = "ws-1";
type MockStore = Parameters<typeof useAgentSelection>[1];
function makeStore(overrides: Partial<MockStore> = {}): MockStore {
return {
isOpen: true,
sessions: [],
activeSessionId: "",
closeQuickChat: vi.fn(),
closeQuickChatSession: vi.fn(),
setActiveQuickChatSession: vi.fn(),
renameQuickChatSession: vi.fn(),
openQuickChat: vi.fn(),
agentProfiles: [
{ id: "agent-a", label: "Agent A", agent_id: "a", agent_name: "Agent A" },
{ id: "agent-b", label: "Agent B", agent_id: "b", agent_name: "Agent B" },
] as MockStore["agentProfiles"],
taskSessions: {},
...overrides,
};
}
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("useAgentSelection — happy path", () => {
it("opens the chat and clears pending state when the request resolves", async () => {
const store = makeStore();
mockStartQuickChat.mockResolvedValue({ task_id: "task-a", session_id: "sess-a" });
const onSuccess = vi.fn();
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
await act(async () => {
await result.current.handleSelectAgent("agent-a", onSuccess);
});
expect(store.openQuickChat).toHaveBeenCalledWith("sess-a", WORKSPACE_ID, "agent-a");
expect(store.renameQuickChatSession).toHaveBeenCalledWith("sess-a", expect.any(String));
expect(onSuccess).toHaveBeenCalledTimes(1);
expect(mockDeleteTask).not.toHaveBeenCalled();
expect(result.current.pendingAgentId).toBeNull();
});
});
describe("useAgentSelection — supersession", () => {
it("rapid-pick: a newer pick deletes the older orphan task", async () => {
const store = makeStore();
let resolveFirst!: (v: { task_id: string; session_id: string }) => void;
const firstPromise = new Promise<{ task_id: string; session_id: string }>((r) => {
resolveFirst = r;
});
mockStartQuickChat
.mockImplementationOnce(() => firstPromise)
.mockResolvedValueOnce({ task_id: "task-b", session_id: "sess-b" });
const onSuccessA = vi.fn();
const onSuccessB = vi.fn();
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
// Click A — request hangs.
act(() => {
void result.current.handleSelectAgent("agent-a", onSuccessA);
});
expect(result.current.pendingAgentId).toBe("agent-a");
// Click B — supersedes A.
await act(async () => {
await result.current.handleSelectAgent("agent-b", onSuccessB);
});
expect(onSuccessB).toHaveBeenCalledTimes(1);
expect(store.openQuickChat).toHaveBeenCalledWith("sess-b", WORKSPACE_ID, "agent-b");
// Now A resolves — its onSuccess must NOT fire and the orphan task is deleted.
await act(async () => {
resolveFirst({ task_id: "task-a", session_id: "sess-a" });
await flushPromises();
});
expect(onSuccessA).not.toHaveBeenCalled();
expect(mockDeleteTask).toHaveBeenCalledWith("task-a");
expect(store.openQuickChat).not.toHaveBeenCalledWith(
"sess-a",
expect.anything(),
expect.anything(),
);
});
it("reset() during in-flight request: resolved task is deleted, onSuccess not called", async () => {
const store = makeStore();
let resolveStart!: (v: { task_id: string; session_id: string }) => void;
mockStartQuickChat.mockImplementationOnce(
() =>
new Promise<{ task_id: string; session_id: string }>((r) => {
resolveStart = r;
}),
);
const onSuccess = vi.fn();
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
act(() => {
void result.current.handleSelectAgent("agent-a", onSuccess);
});
expect(result.current.pendingAgentId).toBe("agent-a");
// User does something that supersedes the in-flight pick (handleNewChat, tab switch, etc.).
act(() => {
result.current.reset();
});
expect(result.current.pendingAgentId).toBeNull();
await act(async () => {
resolveStart({ task_id: "task-a", session_id: "sess-a" });
await flushPromises();
});
expect(onSuccess).not.toHaveBeenCalled();
expect(store.openQuickChat).not.toHaveBeenCalled();
expect(mockDeleteTask).toHaveBeenCalledWith("task-a");
});
});
describe("useAgentSelection — error handling", () => {
it("does not toast when a superseded request rejects (avoid noise from races)", async () => {
const store = makeStore();
let rejectStart!: (e: Error) => void;
mockStartQuickChat.mockImplementationOnce(
() =>
new Promise<{ task_id: string; session_id: string }>((_resolve, reject) => {
rejectStart = reject;
}),
);
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
act(() => {
void result.current.handleSelectAgent("agent-a", vi.fn());
});
act(() => {
result.current.reset();
});
await act(async () => {
rejectStart(new Error("network blew up"));
await flushPromises();
});
expect(mockToast).not.toHaveBeenCalled();
});
it("toasts when the current (non-superseded) request rejects", async () => {
const store = makeStore();
mockStartQuickChat.mockRejectedValueOnce(new Error("server exploded"));
const { result } = renderHook(() => useAgentSelection(WORKSPACE_ID, store));
await act(async () => {
await result.current.handleSelectAgent("agent-a", vi.fn());
});
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
title: "Failed to start quick chat",
description: "server exploded",
variant: "error",
}),
);
expect(result.current.pendingAgentId).toBeNull();
});
});