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)
This commit is contained in:
José Almeida
2026-05-01 00:21:53 +01:00
committed by GitHub
parent 47f875a61e
commit 7e8b4bb30e
7 changed files with 477 additions and 82 deletions
+26 -3
View File
@@ -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 {
@@ -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
}
@@ -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 <QuickChatContent sessionId={sessionId} />;
}
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 (
<div className="flex-1 flex flex-col items-center justify-center p-8">
@@ -117,23 +124,33 @@ function AgentPickerView({ onSelectAgent }: { onSelectAgent: (agentId: string) =
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{agentProfiles.map((profile) => (
<button
key={profile.id}
onClick={() => onSelectAgent(profile.id)}
className="group relative flex flex-col items-start gap-2 rounded-lg border p-4 text-left transition-all hover:border-primary hover:bg-accent cursor-pointer"
>
<div className="flex items-center gap-2 w-full">
<div className="flex h-8 w-8 items-center justify-center rounded-md border bg-background">
<IconMessageCircle className="h-4 w-4" />
{agentProfiles.map((profile) => {
const isPending = pendingAgentId === profile.id;
return (
<button
key={profile.id}
onClick={() => onSelectAgent(profile.id)}
disabled={isLoading}
className="group relative flex flex-col items-start gap-2 rounded-lg border p-4 text-left transition-all hover:border-primary hover:bg-accent cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-transparent"
>
<div className="flex items-center gap-2 w-full">
<div className="flex h-8 w-8 items-center justify-center rounded-md border bg-background">
{isPending ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconMessageCircle className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{profile.label}</p>
<p className="text-xs text-muted-foreground truncate">
{isPending ? "Starting agent..." : profile.agent_name}
</p>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{profile.label}</p>
<p className="text-xs text-muted-foreground truncate">{profile.agent_name}</p>
</div>
</div>
</button>
))}
</button>
);
})}
</div>
</div>
</div>
@@ -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 && (
<QuickChatSessionView sessionId={activeSessionId} />
)}
{activeSessionNeedsAgent && <AgentPickerView onSelectAgent={handleSelectAgent} />}
{activeSessionNeedsAgent && (
<AgentPickerView onSelectAgent={handleSelectAgent} pendingAgentId={pendingAgentId} />
)}
</DialogContent>
</Dialog>
@@ -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<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();
});
});
@@ -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<typeof useQuickChatStore>;
/** 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<string | null>(null);
const [pendingAgentId, setPendingAgentId] = useState<string | null>(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<string | null>(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,
@@ -60,7 +60,12 @@ async function openQuickChatWithAgent(page: Page): Promise<Locator> {
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;
}
+80 -3
View File
@@ -32,14 +32,23 @@ async function openQuickChatWithAgent(page: Page): Promise<Locator> {
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,
});