From 7e8b4bb30e40812d7f0bda7ecd000ccdefa43360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Fri, 1 May 2026 00:21:53 +0100 Subject: [PATCH] feat(quick-chat): eager-init agent on profile selection (#747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- apps/backend/cmd/mock-agent/main.go | 29 ++- .../task/handlers/task_http_handlers.go | 26 ++- .../quick-chat/quick-chat-modal.tsx | 58 ++++-- .../quick-chat/use-quick-chat-modal.test.ts | 196 ++++++++++++++++++ .../quick-chat/use-quick-chat-modal.ts | 160 +++++++++----- apps/web/e2e/tests/chat/message-queue.spec.ts | 7 +- apps/web/e2e/tests/chat/quick-chat.spec.ts | 83 +++++++- 7 files changed, 477 insertions(+), 82 deletions(-) create mode 100644 apps/web/components/quick-chat/use-quick-chat-modal.test.ts diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index bc20719a52..de6567f4c9 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -12,6 +12,7 @@ import ( "slices" "strings" "sync" + "time" acp "github.com/coder/acp-go-sdk" ) @@ -89,6 +90,11 @@ func (a *mockAgent) NewSession(_ context.Context, req acp.NewSessionRequest) (ac // This bridges ACP protocol MCP config to the mock agent's MCP client. registerACPMcpServers(req.McpServers) + // Emit available commands asynchronously after the session/new response + // flushes. Real ACP agents (OpenCode, Claude) emit available_commands_update + // here, which lets clients populate slash menus before the first prompt. + go a.emitAvailableCommandsAfterDelay(sid) + return acp.NewSessionResponse{ SessionId: sid, Models: mockSessionModels(), @@ -131,8 +137,15 @@ func mockSessionModes() *acp.SessionModeState { func (a *mockAgent) LoadSession(_ context.Context, req acp.LoadSessionRequest) (acp.LoadSessionResponse, error) { a.mu.Lock() a.sessions[req.SessionId] = true + // Reset emit state so the resume re-advertises commands (matches real + // agents which re-emit on session/load). + delete(a.commandsEmitted, req.SessionId) a.mu.Unlock() _, _ = fmt.Fprintf(logOutput, "mock-agent[%d]: resumed session %s\n", os.Getpid(), req.SessionId) + + // Re-emit available commands after the session/load response flushes. + go a.emitAvailableCommandsAfterDelay(req.SessionId) + return acp.LoadSessionResponse{}, nil } @@ -162,9 +175,9 @@ func (a *mockAgent) SetSessionConfigOption(_ context.Context, _ acp.SetSessionCo return acp.SetSessionConfigOptionResponse{}, nil } -// emitAvailableCommandsOnce sends the available commands list once per session, -// on the first prompt. Emitting during Prompt (not NewSession) avoids writing -// notifications to stdout before the JSON-RPC session response. +// emitAvailableCommandsOnce sends the available commands list once per session. +// Idempotent — guarded by `commandsEmitted` so callers (NewSession, LoadSession, +// first Prompt) can call it freely. func (a *mockAgent) emitAvailableCommandsOnce(ctx context.Context, sid acp.SessionId) { a.mu.Lock() if a.commandsEmitted[sid] { @@ -184,6 +197,16 @@ func (a *mockAgent) emitAvailableCommandsOnce(ctx context.Context, sid acp.Sessi }) } +// emitAvailableCommandsAfterDelay defers the emit so the JSON-RPC response for +// session/new or session/load is written to stdout first. Real ACP agents +// (OpenCode, Claude) emit available_commands_update notifications immediately +// after these handshakes, so kandev clients can populate slash menus before +// the first prompt — eager-init for quick chat depends on this. +func (a *mockAgent) emitAvailableCommandsAfterDelay(sid acp.SessionId) { + time.Sleep(50 * time.Millisecond) + a.emitAvailableCommandsOnce(context.Background(), sid) +} + // mockAvailableCommands returns the slash commands supported by the mock agent. func mockAvailableCommands() []acp.AvailableCommand { hint := func(h string) *acp.AvailableCommandInput { diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index aa976da81f..f07a437490 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -978,22 +978,30 @@ func (h *TaskHandlers) httpStartQuickChat(c *gin.Context) { return } + // Eager-init: launch the agent process up-front so ACP `initialize` + `session/new` + // fire and the agent emits available_commands/modes/models. This populates the + // slash menu, mode dropdown, and model selector before the user sends any prompt. resp, err := h.orchestrator.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{ - TaskID: task.ID, - Intent: orchestrator.IntentPrepare, - AgentProfileID: params.agentProfileID, - ExecutorID: params.executorID, - LaunchWorkspace: true, + TaskID: task.ID, + Intent: orchestrator.IntentStart, + AgentProfileID: params.agentProfileID, + ExecutorID: params.executorID, }) if err != nil { - // Rollback: delete the ephemeral task to prevent orphans - if deleteErr := h.service.DeleteTask(ctx, task.ID); deleteErr != nil { + // Rollback: delete the ephemeral task to prevent orphans. Use a fresh + // background context — the request context may already be cancelled + // (e.g. client aborted, deadline exceeded), and we still want cleanup + // to run. TaskDeleteTimeout matches the other DeleteTask call sites + // in this file so a future change to the constant covers this path too. + rollbackCtx, cancel := context.WithTimeout(context.Background(), constants.TaskDeleteTimeout) + defer cancel() + if deleteErr := h.service.DeleteTask(rollbackCtx, task.ID); deleteErr != nil { h.logger.Error("failed to rollback quick chat task", zap.String("task_id", task.ID), zap.Error(deleteErr)) } - h.logger.Error("failed to prepare quick chat session", zap.Error(err), zap.String("task_id", task.ID)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare session"}) + h.logger.Error("failed to start quick chat session", zap.Error(err), zap.String("task_id", task.ID)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start session"}) return } diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx index 2034190530..23c25d60fc 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.tsx +++ b/apps/web/components/quick-chat/quick-chat-modal.tsx @@ -4,7 +4,7 @@ import { memo } from "react"; import { useShallow } from "zustand/react/shallow"; import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; import { Button } from "@kandev/ui/button"; -import { IconMessageCircle, IconPlus, IconX } from "@tabler/icons-react"; +import { IconLoader2, IconMessageCircle, IconPlus, IconX } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { PassthroughTerminal } from "@/components/task/passthrough-terminal"; import { QuickChatContent } from "./quick-chat-content"; @@ -104,8 +104,15 @@ function QuickChatSessionView({ sessionId }: { sessionId: string }) { return ; } -function AgentPickerView({ onSelectAgent }: { onSelectAgent: (agentId: string) => void }) { +function AgentPickerView({ + onSelectAgent, + pendingAgentId, +}: { + onSelectAgent: (agentId: string) => void; + pendingAgentId: string | null; +}) { const agentProfiles = useAppStore((s) => s.agentProfiles.items) ?? []; + const isLoading = pendingAgentId !== null; return (
@@ -117,23 +124,33 @@ function AgentPickerView({ onSelectAgent }: { onSelectAgent: (agentId: string) =

- {agentProfiles.map((profile) => ( -
- - ))} + + ); + })} @@ -147,6 +164,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic activeSessionId, sessionToClose, activeSessionNeedsAgent, + pendingAgentId, setActiveQuickChatSession, setSessionToClose, handleOpenChange, @@ -175,7 +193,9 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic {activeSessionId && !activeSessionNeedsAgent && ( )} - {activeSessionNeedsAgent && } + {activeSessionNeedsAgent && ( + + )} diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.test.ts b/apps/web/components/quick-chat/use-quick-chat-modal.test.ts new file mode 100644 index 0000000000..3713545ca8 --- /dev/null +++ b/apps/web/components/quick-chat/use-quick-chat-modal.test.ts @@ -0,0 +1,196 @@ +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[1]; + +function makeStore(overrides: Partial = {}): 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(); + }); +}); diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.ts b/apps/web/components/quick-chat/use-quick-chat-modal.ts index bf91eab647..b59ccff41a 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; @@ -28,65 +28,131 @@ function useQuickChatStore() { ); } -export function useQuickChatModal(workspaceId: string) { +type QuickChatStore = ReturnType; + +/** POSTs to start a quick-chat session and returns the response. */ +async function startQuickChatForAgent(workspaceId: string, agentId: string, store: QuickChatStore) { + const agent = store.agentProfiles.find((p) => p.id === agentId); + const sessionCount = store.sessions.filter((s) => s.sessionId !== "").length + 1; + const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`; + const response = await startQuickChat(workspaceId, { + agent_profile_id: agentId, + title: initialName, + }); + return { sessionId: response.session_id, name: initialName, taskId: response.task_id }; +} + +/** Manages the eager agent-init lifecycle for the picker. + * + * Eager init means the backend boots a real agent process before responding. + * Aborting the fetch on a rapid second click would NOT stop the backend agent + * (it's already running by the time the abort lands), and we'd never see the + * task_id on the FE — orphaning the task. Instead we let every request run + * to completion and reconcile by request id: if a newer pick superseded this + * one before the response arrived, we delete the now-orphaned ephemeral task. + * + * Exported for unit testing — see `use-quick-chat-modal.test.ts`. */ +export function useAgentSelection(workspaceId: string, store: QuickChatStore) { const { toast } = useToast(); - const store = useQuickChatStore(); - const [isCreating, setIsCreating] = useState(false); - const [showAgentPicker, setShowAgentPicker] = useState(false); - const [sessionToClose, setSessionToClose] = useState(null); + const [pendingAgentId, setPendingAgentId] = useState(null); + // Monotonic request id; the latest click "wins" — older responses get + // cleaned up if the backend already started their agent. + const latestRequestId = useRef(0); - const handleOpenChange = useCallback( - (open: boolean) => { - if (!open) { - store.closeQuickChat(); - setShowAgentPicker(false); - } - }, - [store], - ); - - const handleNewChat = useCallback(() => { - store.openQuickChat("", workspaceId); - }, [store, workspaceId]); + const reset = useCallback(() => { + latestRequestId.current += 1; + setPendingAgentId(null); + }, []); const handleSelectAgent = useCallback( - async (agentId: string) => { - if (isCreating) return; - setIsCreating(true); + async (agentId: string, onSuccess: () => void) => { + const requestId = ++latestRequestId.current; + setPendingAgentId(agentId); try { - const agent = store.agentProfiles.find((p) => p.id === agentId); - const sessionCount = store.sessions.filter((s) => s.sessionId !== "").length + 1; - const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`; - const response = await startQuickChat(workspaceId, { - agent_profile_id: agentId, - title: initialName, - }); + const result = await startQuickChatForAgent(workspaceId, agentId, store); + if (latestRequestId.current !== requestId) { + // A newer pick superseded us — the backend already booted this + // agent, so delete the orphan task. Best-effort: ignore failures. + deleteQuickChatTask(result.taskId).catch((err) => + console.error("Failed to clean up superseded quick chat task:", err), + ); + return; + } if (store.activeSessionId === "") store.closeQuickChatSession(""); - store.openQuickChat(response.session_id, workspaceId, agentId); - store.renameQuickChatSession(response.session_id, initialName); - setShowAgentPicker(false); + store.openQuickChat(result.sessionId, workspaceId, agentId); + store.renameQuickChatSession(result.sessionId, result.name); + onSuccess(); } catch (error) { + if (latestRequestId.current !== requestId) return; toast({ title: "Failed to start quick chat", description: error instanceof Error ? error.message : "Unknown error", variant: "error", }); } finally { - setIsCreating(false); + if (latestRequestId.current === requestId) { + setPendingAgentId(null); + } } }, - [workspaceId, isCreating, store, toast], + [workspaceId, store, toast], + ); + + return { pendingAgentId, reset, handleSelectAgent }; +} + +export function useQuickChatModal(workspaceId: string) { + const { toast } = useToast(); + const store = useQuickChatStore(); + const [showAgentPicker, setShowAgentPicker] = useState(false); + const [sessionToClose, setSessionToClose] = useState(null); + const { + pendingAgentId, + reset, + handleSelectAgent: doSelectAgent, + } = useAgentSelection(workspaceId, store); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open) return; + reset(); + store.closeQuickChat(); + setShowAgentPicker(false); + }, + [store, reset], + ); + + // Any picker-bypassing user action while a pick is pending should supersede + // the in-flight start, so the resolved request cleans up its orphan task + // instead of yanking the user back to that session. + const handleNewChat = useCallback(() => { + reset(); + store.openQuickChat("", workspaceId); + }, [reset, store, workspaceId]); + + const handleSelectAgent = useCallback( + (agentId: string) => doSelectAgent(agentId, () => setShowAgentPicker(false)), + [doSelectAgent], + ); + + const setActiveQuickChatSession = useCallback( + (sessionId: string) => { + reset(); + store.setActiveQuickChatSession(sessionId); + }, + [reset, store], ); const handleCloseTab = useCallback( (sessionId: string) => { + reset(); if (sessionId === "") { store.closeQuickChatSession(sessionId); return; } setSessionToClose(sessionId); }, - [store], + [reset, store], ); const handleConfirmClose = useCallback(async () => { @@ -95,17 +161,16 @@ export function useQuickChatModal(workspaceId: string) { setSessionToClose(null); const taskId = store.taskSessions[sessionId]?.task_id; store.closeQuickChatSession(sessionId); - if (taskId) { - try { - await deleteQuickChatTask(taskId); - } catch (error) { - console.error("Failed to delete quick chat task:", error); - toast({ - title: "Failed to delete quick chat", - description: error instanceof Error ? error.message : "Unknown error", - variant: "error", - }); - } + if (!taskId) return; + try { + await deleteQuickChatTask(taskId); + } catch (error) { + console.error("Failed to delete quick chat task:", error); + toast({ + title: "Failed to delete quick chat", + description: error instanceof Error ? error.message : "Unknown error", + variant: "error", + }); } }, [sessionToClose, store, toast]); @@ -115,7 +180,8 @@ export function useQuickChatModal(workspaceId: string) { activeSessionId: store.activeSessionId, sessionToClose, activeSessionNeedsAgent: store.activeSessionId === "" || showAgentPicker, - setActiveQuickChatSession: store.setActiveQuickChatSession, + pendingAgentId, + setActiveQuickChatSession, setSessionToClose, handleOpenChange, handleNewChat, diff --git a/apps/web/e2e/tests/chat/message-queue.spec.ts b/apps/web/e2e/tests/chat/message-queue.spec.ts index 751fe8a38e..c647c929b4 100644 --- a/apps/web/e2e/tests/chat/message-queue.spec.ts +++ b/apps/web/e2e/tests/chat/message-queue.spec.ts @@ -60,7 +60,12 @@ async function openQuickChatWithAgent(page: Page): Promise { await expect(agentCard).toBeVisible({ timeout: 5_000 }); await agentCard.click(); - await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 15_000 }); + // Wait for chat input to appear AND become editable. Eager init means the + // agent starts during the picker → tab transition; the input is briefly + // disabled while the FE store catches up to the RUNNING session state. + const editor = dialog.locator(".tiptap.ProseMirror"); + await expect(editor).toBeVisible({ timeout: 15_000 }); + await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 }); return dialog; } diff --git a/apps/web/e2e/tests/chat/quick-chat.spec.ts b/apps/web/e2e/tests/chat/quick-chat.spec.ts index 3cf2de62b3..158723a27c 100644 --- a/apps/web/e2e/tests/chat/quick-chat.spec.ts +++ b/apps/web/e2e/tests/chat/quick-chat.spec.ts @@ -32,14 +32,23 @@ async function openQuickChatWithAgent(page: Page): Promise { await expect(agentCard).toBeVisible({ timeout: 5_000 }); await agentCard.click(); - // Wait for chat input to appear (session created and content rendered). - await expect(dialog.locator(".tiptap.ProseMirror")).toBeVisible({ timeout: 15_000 }); + // Wait for chat input to appear AND become editable. Eager init means the + // agent starts during the HTTP request, so the input is briefly disabled + // while the FE store catches up to the RUNNING session state. + const editor = dialog.locator(".tiptap.ProseMirror"); + await expect(editor).toBeVisible({ timeout: 15_000 }); + await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 }); return dialog; } async function sendQuickChatMessage(dialog: Locator, page: Page, text: string) { const editor = dialog.locator(".tiptap.ProseMirror"); + // Wait for the editor to be editable. With eager init, the agent boots + // during the picker → tab transition and the input is briefly disabled + // while the FE store catches up to RUNNING. Multi-tab scenarios share an + // agent slot so the second boot can take longer. + await expect(editor).toHaveAttribute("contenteditable", "true", { timeout: 30_000 }); await editor.click(); await editor.fill(text); const modifier = process.platform === "darwin" ? "Meta" : "Control"; @@ -106,6 +115,72 @@ test.describe("Quick Chat", () => { ); }); + test("slash command menu populates before first message (eager agent init)", async ({ + testPage, + }) => { + // Picking an agent in quick chat should boot the agent process eagerly, + // so available_commands_update fires from session/new — the slash menu is + // populated before the user sends their first prompt. Mock-agent emits + // /slow, /error, /thinking, etc. on session/new (parity with real ACP + // agents like OpenCode and Claude). + // Hook WebSocket BEFORE navigating to capture all session.available_commands + // frames into window.__wsAvailableCommands for assertion. + await testPage.addInitScript(() => { + const w = window as unknown as { + __wsAvailableCommands: unknown[]; + __wsAll: unknown[]; + }; + w.__wsAvailableCommands = []; + w.__wsAll = []; + const OrigWS = window.WebSocket; + const Hooked = function (this: WebSocket, url: string | URL, protocols?: string | string[]) { + const ws = new OrigWS(url, protocols); + w.__wsAll.push({ event: "open", url: String(url) }); + ws.addEventListener("message", (ev: MessageEvent) => { + try { + const m = JSON.parse(String(ev.data)); + w.__wsAll.push({ event: "msg", action: m.action }); + if (m.action === "session.available_commands") { + w.__wsAvailableCommands.push({ + session_id: m.payload?.session_id, + count: m.payload?.available_commands?.length ?? 0, + }); + } + } catch { + // ignore + } + }); + return ws; + } as unknown as typeof WebSocket; + Hooked.prototype = OrigWS.prototype; + Object.assign(Hooked, OrigWS); + window.WebSocket = Hooked; + }); + + const dialog = await openQuickChatWithAgent(testPage); + + // Wait for the available_commands WS frame to land. Eager init kicks off + // session/new during the HTTP request, but the agent emits commands + // asynchronously after the response flushes — so the frame can arrive + // moments after openQuickChatWithAgent resolves. + await testPage.waitForFunction( + () => { + const w = window as unknown as { __wsAvailableCommands?: unknown[] }; + return (w.__wsAvailableCommands?.length ?? 0) > 0; + }, + { timeout: 15_000 }, + ); + + const editor = dialog.locator(".tiptap.ProseMirror"); + await editor.click(); + await editor.pressSequentially("/"); + + // SlashCommandMenu renders into a portal at document root, so query at page level. + await expect(testPage.getByText("Commands").first()).toBeVisible({ timeout: 10_000 }); + await expect(testPage.getByText("/slow")).toBeVisible({ timeout: 5_000 }); + await expect(testPage.getByText("/error")).toBeVisible({ timeout: 5_000 }); + }); + test("supports multiple chat tabs and switching between them", async ({ testPage }) => { test.setTimeout(90_000); @@ -138,7 +213,9 @@ test.describe("Quick Chat", () => { // Send a message in the second tab using script mode. await sendQuickChatMessage(dialog, testPage, 'e2e:message("second tab response")'); - await expect(dialog.getByText("second tab response", { exact: false })).toBeVisible({ + // The user message bubble also contains "second tab response" — match only + // the agent reply (the rendered text without the surrounding script call). + await expect(dialog.getByText("second tab response", { exact: true })).toBeVisible({ timeout: 30_000, });