diff --git a/apps/backend/internal/orchestrator/peer_message_resume_test.go b/apps/backend/internal/orchestrator/peer_message_resume_test.go new file mode 100644 index 0000000000..3012ea7ec5 --- /dev/null +++ b/apps/backend/internal/orchestrator/peer_message_resume_test.go @@ -0,0 +1,120 @@ +package orchestrator + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/task/models" +) + +// TestQueueAndInterruptForPeerMessage_StoppedSessionResumesAgent pins the +// "a message received from another task auto-starts the agent" contract +// behind the composer hint: a recovered-idle session (WAITING_FOR_INPUT with +// a resume token and no running agent — the post-restart shape the +// prevent-auto-start-on-open preference leaves it in) must be RESUMED by the +// queue dispatch when a peer (sub-task) message arrives, and the message must +// reach the agent. The preference only gates open-time starts in the +// frontend; message-driven starts go through ensureSessionRunning here. +func TestQueueAndInterruptForPeerMessage_StoppedSessionResumesAgent(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + agentMgr := &mockAgentManager{isAgentRunning: false, promptDone: make(chan struct{})} + taskRepo := newMockTaskRepo() + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + msgCreator := &mockMessageCreator{} + svc.messageCreator = msgCreator + // Mirrors real agentctl bootstrap timing: LaunchAgent fires + // handleAgentBootReady ~50ms later so the resume path completes in unit + // tests without a real subprocess. Wrap the simulator to capture the + // LaunchAgent request so we can assert the RESUME semantics (the seeded + // ACP token must be passed through), not merely that some launch fired. + wireBootReadySimulator(svc, agentMgr, "exec-resumed") + var launchMu sync.Mutex + var launchReqs []*executor.LaunchAgentRequest + baseLaunch := agentMgr.launchAgentFunc + agentMgr.launchAgentFunc = func( + launchCtx context.Context, + req *executor.LaunchAgentRequest, + ) (*executor.LaunchAgentResponse, error) { + launchMu.Lock() + launchReqs = append(launchReqs, req) + launchMu.Unlock() + return baseLaunch(launchCtx, req) + } + + // The post-restart recovered-idle shape: session stopped with history, + // resumable via its executors_running resume token, no agent process. + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("GetTaskSession: %v", err) + } + session.AgentProfileID = "profile-1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("UpdateTaskSession: %v", err) + } + now := time.Now().UTC() + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "er1", SessionID: "session1", TaskID: "task1", + ResumeToken: "acp-session-xyz", + Resumable: true, + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("UpsertExecutorRunning: %v", err) + } + + queued, dispatched, err := svc.QueueAndInterruptForPeerMessage( + ctx, "task1", "session1", "subtask status update", + map[string]interface{}{"sender_task_id": "subtask-1"}, + ) + if err != nil { + t.Fatalf("queue and interrupt for peer message: %v", err) + } + if queued == nil { + t.Fatal("expected a queued entry") + } + if !dispatched { + t.Fatal("expected QueueAndInterruptForPeerMessage to report the message as dispatched") + } + + // The dispatch is async: join the executeQueuedMessage goroutine via the + // mock's PromptAgent signal — PromptAgent only runs AFTER + // ensureSessionRunning resumed the agent, so this proves both halves. + select { + case <-agentMgr.promptDone: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the peer message to be dispatched after resuming the agent") + } + agentMgr.mu.Lock() + prompts := append([]string(nil), agentMgr.capturedPrompts...) + agentMgr.mu.Unlock() + if len(prompts) != 1 || prompts[0] != "subtask status update" { + t.Fatalf("expected the peer message to be dispatched to the agent, got prompts=%v", prompts) + } + + // The agent must have been RESUMED by the dispatch: the launch the + // boot-ready simulator wires into LaunchAgent must have registered the + // new execution on the session's executors_running row, and it must have + // carried the seeded ACP resume token (a fresh-start regression would + // lose the prior ACP conversation but still satisfy the execution-ID + // check). + er, err := repo.GetExecutorRunningBySessionID(ctx, "session1") + if err != nil || er == nil { + t.Fatalf("ExecutorRunning lookup after dispatch failed: err=%v nil=%v", err, er == nil) + } + if er.AgentExecutionID != "exec-resumed" { + t.Fatalf("expected the peer-message dispatch to resume the agent (execution exec-resumed), got %q", er.AgentExecutionID) + } + launchMu.Lock() + defer launchMu.Unlock() + if len(launchReqs) != 1 { + t.Fatalf("expected exactly 1 resume launch, got %d", len(launchReqs)) + } + if got := launchReqs[0].ACPSessionID; got != "acp-session-xyz" { + t.Fatalf("expected the resume launch to carry the seeded ACP token, got %q", got) + } +} diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 19d2b664e6..d796385893 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -13,6 +13,7 @@ import { type ChatInputContainerHandle, } from "@/components/task/chat/chat-input-container"; import { QueueAffordance } from "@/components/task/chat/queued-ghost-list"; +import { ComposerAgentStartHint } from "./composer-agent-start-hint"; import { formatReviewCommentsAsMarkdown, formatPRFeedbackAsMarkdown, @@ -339,6 +340,10 @@ type ChatInputAreaProps = { * dependency chip is about. */ statusTaskId?: string | null; + /** Recovered-idle (resume-skipped) sessions render the "a message will + * auto-start the agent" hint above the composer while the agent is + * stopped. */ + showAgentStartHint?: boolean; }; /** Resolves whether this session's executor environment is unavailable, and why. */ @@ -426,6 +431,7 @@ export function ChatInputArea({ showScrollToStart, onScrollToStart, statusTaskId = null, + showAgentStartHint = false, }: ChatInputAreaProps) { const { resolvedSessionId, taskId, isAgentBusy } = panelState; const statusRowTaskId = resolveStatusRowTaskId(taskId, statusTaskId); @@ -461,6 +467,12 @@ export function ChatInputArea({ data-testid="chat-input-area" className={cn("bg-card flex-shrink-0 px-2 pb-2 pt-1", surfaceClassName)} > + { + it("renders the hint line when shown and the composer accepts sends", () => { + render(); + expect(screen.getByTestId(HINT_TEST_ID)).toBeTruthy(); + }); + + it("renders nothing when hidden", () => { + render(); + expect(screen.queryByTestId(HINT_TEST_ID)).toBeNull(); + }); + + it("renders nothing while a recovery is required (the composer is disabled)", () => { + render(); + expect(screen.queryByTestId(HINT_TEST_ID)).toBeNull(); + }); + + it("renders nothing while the executor environment is unavailable", () => { + render(); + expect(screen.queryByTestId(HINT_TEST_ID)).toBeNull(); + }); + + it("renders nothing while a clarification is pending (sends only enqueue)", () => { + render(); + expect(screen.queryByTestId(HINT_TEST_ID)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/chat/composer-agent-start-hint.tsx b/apps/web/components/task/chat/composer-agent-start-hint.tsx new file mode 100644 index 0000000000..7c06dff424 --- /dev/null +++ b/apps/web/components/task/chat/composer-agent-start-hint.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useTranslation } from "react-i18next"; + +/** + * The composer's "a message will auto-start the agent" hint for + * recovered-idle (resume-skipped) sessions, shown while the agent is + * stopped AND the composer can actually accept a send. Informational only: + * no click action. + * + * The extra gates mirror the composer's own send-availability signals: a + * session in recovery (needsRecovery — WAITING_FOR_INPUT with an error + * message) disables the editor, an unavailable executor environment + * replaces it with the FailedSessionBanner, and a pending clarification + * routes every submission to the message queue (which only persists — + * it does not resume the stopped agent). In all three cases no message can + * be delivered to the agent, so promising that sending will start it would + * be a dead end. + */ +export function ComposerAgentStartHint({ + show, + needsRecovery = false, + executorUnavailable = false, + hasPendingClarification = false, +}: { + show: boolean; + needsRecovery?: boolean; + executorUnavailable?: boolean; + hasPendingClarification?: boolean; +}) { + const { t } = useTranslation(); + if (!show || needsRecovery || executorUnavailable || hasPendingClarification) return null; + return ( +

+ {t("task:composerStartAgentHint")} +

+ ); +} diff --git a/apps/web/components/task/chat/message-list-footer.test.tsx b/apps/web/components/task/chat/message-list-footer.test.tsx index a48389711b..ea6d1c1781 100644 --- a/apps/web/components/task/chat/message-list-footer.test.tsx +++ b/apps/web/components/task/chat/message-list-footer.test.tsx @@ -12,23 +12,10 @@ vi.mock("@/components/task/chat/message-renderer", () => ({ ), })); -let mockResumeSkippedSessionIds: Record = {}; - -vi.mock("@/components/state-provider", () => ({ - useAppStore: ( - selector: (state: { tasks: { resumeSkippedSessionIds: Record } }) => unknown, - ) => selector({ tasks: { resumeSkippedSessionIds: mockResumeSkippedSessionIds } }), -})); - -vi.mock("@/components/task/chat/session-resume-start-button", () => ({ - SessionResumeStartButton: () =>
Start agent
, -})); - import { MessageListFooter } from "./message-list-footer"; const AGENT_STATUS_TEST_ID = "agent-status"; const SESSION_ID = "session-1"; -const RESUME_BUTTON_TEST_ID = "resume-start-button"; afterEach(cleanup); @@ -125,85 +112,3 @@ describe("MessageListFooter", () => { expect(screen.queryByTestId("action-message")).toBeNull(); }); }); - -const recoveryMessage = { - id: "recovery-1", - session_id: toSessionId("session-1"), - task_id: toTaskId("task-1"), - author_type: "agent", - type: "status", - created_at: "2026-07-22T00:00:00Z", - content: "Agent process exited with code 1", - metadata: { - variant: "danger", - recovery_actions: true, - actions: [ - { type: "resume_session", label: "Resume session" }, - { type: "start_fresh", label: "Start fresh session" }, - ], - }, -} satisfies Message; - -describe("MessageListFooter resume-skipped start button", () => { - const withMessages = [actionableFailure]; - - it("renders the Start agent button for a resume-skipped session with messages", () => { - mockResumeSkippedSessionIds = { [SESSION_ID]: true }; - render( - , - ); - expect(screen.getByTestId(RESUME_BUTTON_TEST_ID)).toBeTruthy(); - }); - - it("hides the Start agent button for a resume-skipped FAILED session (recovery owns the affordance)", () => { - mockResumeSkippedSessionIds = { [SESSION_ID]: true }; - render( - , - ); - expect(screen.queryByTestId(RESUME_BUTTON_TEST_ID)).toBeNull(); - }); - - it("renders the Start agent button for an empty resume-skipped session (empty description fallback)", () => { - mockResumeSkippedSessionIds = { [SESSION_ID]: true }; - render( - , - ); - expect(screen.getByTestId(RESUME_BUTTON_TEST_ID)).toBeTruthy(); - }); - - it("hides the Start agent button when recovery actions are already visible", () => { - mockResumeSkippedSessionIds = { [SESSION_ID]: true }; - render( - , - ); - expect(screen.queryByTestId(RESUME_BUTTON_TEST_ID)).toBeNull(); - }); - - it("renders the Start agent button when no recovery actions are visible", () => { - mockResumeSkippedSessionIds = { [SESSION_ID]: true }; - render( - , - ); - expect(screen.getByTestId(RESUME_BUTTON_TEST_ID)).toBeTruthy(); - }); - - it("hides the Start agent button for a resume-skipped RUNNING session", () => { - mockResumeSkippedSessionIds = { [SESSION_ID]: true }; - render( - , - ); - expect(screen.queryByTestId(RESUME_BUTTON_TEST_ID)).toBeNull(); - }); -}); diff --git a/apps/web/components/task/chat/message-list-footer.tsx b/apps/web/components/task/chat/message-list-footer.tsx index 243e25a74b..6cd8dcffce 100644 --- a/apps/web/components/task/chat/message-list-footer.tsx +++ b/apps/web/components/task/chat/message-list-footer.tsx @@ -1,11 +1,8 @@ "use client"; -import { useMemo } from "react"; import type { Message, TaskSessionState } from "@/lib/types/http"; import { AgentStatus } from "@/components/task/chat/messages/agent-status"; import { MessageRenderer } from "@/components/task/chat/message-renderer"; -import { SessionResumeStartButton } from "@/components/task/chat/session-resume-start-button"; -import { useAppStore } from "@/components/state-provider"; type MessageListFooterProps = { sessionState?: TaskSessionState; @@ -37,6 +34,11 @@ function findCurrentActionableFailure( return footerActionMessages.at(-1); } +/** + * Footer rendered below the transcript rows: the agent status summary plus + * any footer action messages (actionable failures / recovery), with + * missing-branch recovery owning the failure presentation when present. + */ export function MessageListFooter({ sessionState, sessionId, @@ -52,39 +54,6 @@ export function MessageListFooter({ const visibleFooterActionMessages = footerActionMessages.filter( (message) => !isMissingBranchFailure(message) || message.id === currentActionableFailure?.id, ); - // The task-description start button only renders for EMPTY sessions. A - // resume-skipped session (prevent-auto-start-on-open preference, agent - // stopped with history) gets its Start agent button here instead — but only - // when no recovery actions are already visible: if the conversation shows - // "Resume session" / "Start fresh session" (an agent-kill recovery action - // message), that is the manual affordance and a second Start agent button - // would be duplicate noise. - const resumeSkipped = useAppStore((state) => - sessionId ? state.tasks.resumeSkippedSessionIds[sessionId] === true : false, - ); - const hasRecoveryActions = useMemo( - () => - messages.some( - (message) => - (message.metadata as Record | undefined)?.recovery_actions === true, - ) || - footerActionMessages.some( - (message) => - (message.metadata as Record | undefined)?.recovery_actions === true, - ), - [messages, footerActionMessages], - ); - // The footer is the single Start-agent surface for resume-skipped sessions - // (prevent-auto-start-on-open): it must render even when the session has - // no messages or the task has no description, so every gated recovered-idle - // session keeps the manual affordance. - const showResumeStartButton = - resumeSkipped && - !hasRecoveryActions && - sessionState !== "FAILED" && - sessionState !== "RUNNING" && - sessionState !== "STARTING" && - sessionId !== null; return ( <> {!recoveryOwnsFailure && ( @@ -95,9 +64,6 @@ export function MessageListFooter({ isWorking={isWorking} /> )} - {showResumeStartButton && sessionId !== null && ( - - )} {visibleFooterActionMessages.map((message) => ( ))} diff --git a/apps/web/components/task/chat/message-renderer.test.ts b/apps/web/components/task/chat/message-renderer.test.ts index 5085d5747a..301ece09d1 100644 --- a/apps/web/components/task/chat/message-renderer.test.ts +++ b/apps/web/components/task/chat/message-renderer.test.ts @@ -17,7 +17,7 @@ describe("shouldShowDescriptionStartButton", () => { ).toBe(true); }); - it("does NOT show for a resume-skipped session (the footer owns that surface)", () => { + it("does NOT show for a resume-skipped session (the composer hint owns that surface)", () => { expect( shouldShowDescriptionStartButton({ sessionState: "WAITING_FOR_INPUT", diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 262f174bbb..b048b169f8 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -8,10 +8,7 @@ import type { Message, TaskSessionState } from "@/lib/types/http"; import type { ToolCallMetadata } from "@/components/task/chat/types"; import { launchSession } from "@/lib/services/session-launch-service"; import { isLaunchStateRegression } from "@/lib/session-state"; -import { - buildResumeRequest, - buildStartCreatedRequest, -} from "@/lib/services/session-launch-helpers"; +import { buildStartCreatedRequest } from "@/lib/services/session-launch-helpers"; import { useAppStore } from "@/components/state-provider"; import { useTask } from "@/hooks/use-task"; import { ChatMessage } from "@/components/task/chat/messages/chat-message"; @@ -53,10 +50,10 @@ type AdapterContext = { /** * Decides whether the task-description message renders the Start agent * button. Shown only for never-started (CREATED) sessions; resume-skipped - * (prevent-auto-start-on-open) sessions get their Start agent button from - * the message-list footer instead (which also covers empty sessions and - * empty descriptions). Hidden while the task is SCHEDULING (the launch is - * in flight) and when no task/session context is bound. + * (prevent-auto-start-on-open) sessions get the composer hint affordance + * instead (which also covers empty sessions and empty descriptions). Hidden + * while the task is SCHEDULING (the launch is in flight) and when no + * task/session context is bound. */ export function shouldShowDescriptionStartButton({ sessionState, @@ -72,31 +69,32 @@ export function shouldShowDescriptionStartButton({ return sessionState === "CREATED" && taskState !== "SCHEDULING" && !!taskId && !!sessionId; } +/** + * "Start agent" button inside the task-description message, shown only for + * never-started (CREATED) sessions. Dispatches the start_created launch and + * hides while the workspace environment is being prepared. + */ function TaskDescriptionStartButton({ taskId, sessionId }: { taskId: string; sessionId: string }) { const { t } = useTranslation(); const [isStarting, setIsStarting] = useState(false); const prepareStatus = useAppStore( (state) => state.prepareProgress.bySessionId[sessionId]?.status ?? null, ); - const resumeSkipped = useAppStore( - (state) => state.tasks.resumeSkippedSessionIds[sessionId] === true, - ); - const setResumeSkipped = useAppStore((state) => state.setResumeSkipped); const session = useAppStore((state) => state.taskSessions.items[sessionId] ?? null); const setTaskSession = useAppStore((state) => state.setTaskSession); const handleStart = useCallback(async () => { setIsStarting(true); try { - // Never-started (CREATED) sessions use the start_created intent; a - // resume-skipped (recovered-idle) session resumes its existing agent. - const { request } = resumeSkipped - ? buildResumeRequest(taskId, sessionId) - : buildStartCreatedRequest(taskId, sessionId); + // This button only renders for never-started (CREATED) sessions + // (shouldShowDescriptionStartButton); resume-skipped (recovered-idle) + // sessions get their affordance from the composer hint instead, so the + // launch intent is always start_created. + const { request } = buildStartCreatedRequest(taskId, sessionId); const response = await launchSession(request); if (response.success && response.state) { // Hydrate the launch state (commonly STARTING) so the button hides - // immediately and repeated start_created/resume requests cannot fire + // immediately and repeated start_created requests cannot fire // against an already-starting session before the WS transition lands. // Never apply it over a newer live state: a WS RUNNING/FAILED // transition can land before the launch response resolves, and the @@ -112,19 +110,12 @@ function TaskDescriptionStartButton({ taskId, sessionId }: { taskId: string; ses }); } } - // Only confirmed RUNNING clears the resume-skipped marker: a resume - // response commonly reports STARTING (launch accepted, agent starting), - // and the WS RUNNING transition clears it later. Failed launches keep - // the button as a retry affordance. - if (response.state === "RUNNING") { - setResumeSkipped(sessionId, false); - } } catch (error) { console.error("Failed to start agent:", error); } finally { setIsStarting(false); } - }, [taskId, sessionId, resumeSkipped, setResumeSkipped, session, setTaskSession]); + }, [taskId, sessionId, session, setTaskSession]); // Hide while environment is being prepared if (prepareStatus === "preparing") return null; diff --git a/apps/web/components/task/chat/session-resume-start-button.tsx b/apps/web/components/task/chat/session-resume-start-button.tsx deleted file mode 100644 index 9764074947..0000000000 --- a/apps/web/components/task/chat/session-resume-start-button.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { useCallback, useState } from "react"; -import { IconPlayerPlay } from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; -import { useTranslation } from "react-i18next"; -import type { TaskSessionState } from "@/lib/types/http"; -import { useAppStore } from "@/components/state-provider"; -import { launchSession } from "@/lib/services/session-launch-service"; -import { buildResumeRequest } from "@/lib/services/session-launch-helpers"; -import { isLaunchStateRegression } from "@/lib/session-state"; - -/** - * "Start agent" affordance for a resume-skipped session (prevent-auto-start-on- - * open preference): the session exists with history (its agent process is not - * running) and the open-time auto-resume was skipped, so the session stayed - * stopped. Clicking resumes the agent. - * - * Rendered in the message-list footer for resume-skipped sessions (the - * task-description start button only handles never-started CREATED sessions). The resume - * is NOT gated by the preference — it is the explicit manual action. - */ -export function SessionResumeStartButton({ sessionId }: { sessionId: string }) { - const { t } = useTranslation(); - const [isStarting, setIsStarting] = useState(false); - const session = useAppStore((state) => state.taskSessions.items[sessionId] ?? null); - const setTaskSession = useAppStore((state) => state.setTaskSession); - const setResumeSkipped = useAppStore((state) => state.setResumeSkipped); - - const handleStart = useCallback(async () => { - if (!session) return; - setIsStarting(true); - try { - const { request } = buildResumeRequest(session.task_id, sessionId); - const response = await launchSession(request); - if (response.success && response.state) { - // Hydrate the launch state (commonly STARTING) so the button hides - // immediately and the UI reflects the in-flight resume instead of - // staying stopped and clickable for repeated resumes. Never apply it - // over a newer live state: a WS RUNNING/FAILED transition can land - // before the launch response resolves, and the delayed STARTING must - // not hide a running agent or a failure's recovery affordances. - if (!isLaunchStateRegression(session.state, response.state)) { - setTaskSession({ - id: session.id, - task_id: session.task_id, - state: response.state as TaskSessionState, - started_at: session.started_at ?? "", - updated_at: session.updated_at ?? "", - }); - } - } - // Only confirmed RUNNING clears the resume-skipped marker: a resume - // response commonly reports STARTING (launch accepted, agent starting), - // and the WS RUNNING transition clears it later. Failed launches keep - // the button as a retry affordance. - if (response.state === "RUNNING") { - setResumeSkipped(sessionId, false); - } - } catch (error) { - console.error("Failed to resume agent:", error); - } finally { - setIsStarting(false); - } - }, [session, sessionId, setTaskSession, setResumeSkipped]); - - return ( -
- -
- ); -} diff --git a/apps/web/components/task/chat/use-composer-agent-start-hint.test.ts b/apps/web/components/task/chat/use-composer-agent-start-hint.test.ts new file mode 100644 index 0000000000..af21b8932d --- /dev/null +++ b/apps/web/components/task/chat/use-composer-agent-start-hint.test.ts @@ -0,0 +1,92 @@ +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http"; + +let mockResumeSkippedSessionIds: Record = {}; + +type MockState = { + tasks: { resumeSkippedSessionIds: Record }; +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => + selector({ tasks: { resumeSkippedSessionIds: mockResumeSkippedSessionIds } }), +})); + +import { useComposerAgentStartHint } from "./use-composer-agent-start-hint"; + +const SESSION_ID = "session-1"; + +function message(overrides: Partial = {}): Message { + return { + id: "m1", + session_id: toSessionId(SESSION_ID), + task_id: toTaskId("task-1"), + author_type: "user", + type: "text", + created_at: "2026-07-22T00:00:00Z", + content: "hello", + ...overrides, + } as Message; +} + +afterEach(cleanup); + +beforeEach(() => { + mockResumeSkippedSessionIds = {}; +}); + +describe("useComposerAgentStartHint", () => { + it("shows for a resume-skipped WAITING_FOR_INPUT session without recovery actions", () => { + mockResumeSkippedSessionIds = { [SESSION_ID]: true }; + const { result } = renderHook(() => + useComposerAgentStartHint(SESSION_ID, "WAITING_FOR_INPUT", [], []), + ); + expect(result.current).toBe(true); + }); + + it("hides when the session is not marked resume-skipped", () => { + const { result } = renderHook(() => + useComposerAgentStartHint(SESSION_ID, "WAITING_FOR_INPUT", [], []), + ); + expect(result.current).toBe(false); + }); + + it("hides when no session is bound", () => { + mockResumeSkippedSessionIds = { [SESSION_ID]: true }; + const { result } = renderHook(() => + useComposerAgentStartHint(null, "WAITING_FOR_INPUT", [], []), + ); + expect(result.current).toBe(false); + }); + + it("hides when a transcript message carries recovery actions", () => { + mockResumeSkippedSessionIds = { [SESSION_ID]: true }; + const recovery = message({ + metadata: { recovery_actions: true, actions: [{ type: "resume_session", label: "Resume" }] }, + }); + const { result } = renderHook(() => + useComposerAgentStartHint(SESSION_ID, "WAITING_FOR_INPUT", [recovery], []), + ); + expect(result.current).toBe(false); + }); + + it("hides when a footer action message carries recovery actions", () => { + mockResumeSkippedSessionIds = { [SESSION_ID]: true }; + const recovery = message({ + metadata: { recovery_actions: true, actions: [{ type: "resume_session", label: "Resume" }] }, + }); + const { result } = renderHook(() => + useComposerAgentStartHint(SESSION_ID, "WAITING_FOR_INPUT", [], [recovery]), + ); + expect(result.current).toBe(false); + }); + + it("hides for FAILED, STARTING, RUNNING and terminal sessions", () => { + mockResumeSkippedSessionIds = { [SESSION_ID]: true }; + for (const state of ["FAILED", "STARTING", "RUNNING", "CANCELLED", "COMPLETED"] as const) { + const { result } = renderHook(() => useComposerAgentStartHint(SESSION_ID, state, [], [])); + expect(result.current).toBe(false); + } + }); +}); diff --git a/apps/web/components/task/chat/use-composer-agent-start-hint.ts b/apps/web/components/task/chat/use-composer-agent-start-hint.ts new file mode 100644 index 0000000000..b953946112 --- /dev/null +++ b/apps/web/components/task/chat/use-composer-agent-start-hint.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useMemo } from "react"; +import type { Message, TaskSessionState } from "@/lib/types/http"; +import { useAppStore } from "@/components/state-provider"; +import { shouldShowComposerAgentStartHint } from "@/lib/session-state"; + +/** True when any message in the list carries recovery-action metadata. */ +function hasRecoveryActions(messages: Message[]): boolean { + return messages.some( + (message) => + (message.metadata as Record | undefined)?.recovery_actions === true, + ); +} + +/** + * Whether the composer shows the "a message will auto-start the agent" hint + * for a recovered-idle (resume-skipped) session — the affordance that + * replaced the footer Start agent button (prevent-auto-start-on-open + * preference). Reads the resume-skipped flag from the store and derives the + * recovery-actions check from the transcript, then delegates to the shared + * {@link shouldShowComposerAgentStartHint} predicate. + */ +export function useComposerAgentStartHint( + sessionId: string | null, + sessionState: TaskSessionState | undefined, + messages: Message[], + footerActionMessages: Message[], +): boolean { + const resumeSkipped = useAppStore((state) => + sessionId ? state.tasks.resumeSkippedSessionIds[sessionId] === true : false, + ); + const recoveryActionsVisible = useMemo( + () => hasRecoveryActions(messages) || hasRecoveryActions(footerActionMessages), + [messages, footerActionMessages], + ); + return shouldShowComposerAgentStartHint({ + resumeSkipped, + sessionState, + hasRecoveryActions: recoveryActionsVisible, + sessionId, + }); +} diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index c1d1d0d0c4..60e9877808 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -23,6 +23,7 @@ import { useIsTaskArchived } from "./task-archived-context"; import { useChatPanelState } from "./chat/use-chat-panel-state"; import { ChatInputArea, useSubmitHandler, useChatPanelHandlers } from "./chat/chat-input-area"; import { ClarificationInputOverlay } from "./chat/clarification-input-overlay"; +import { useComposerAgentStartHint } from "./chat/use-composer-agent-start-hint"; import { ResizeHandle } from "./chat/resize-handle"; import { useResizableClarificationOverlay } from "@/hooks/use-resizable-clarification-overlay"; import { PanelSearchBar } from "@/components/search/panel-search-bar"; @@ -241,6 +242,12 @@ export const TaskChatPanel = memo(function TaskChatPanel({ groupedItems, isInitialMessagesLoading, ); + const showAgentStartHint = useComposerAgentStartHint( + resolvedSessionId, + session?.state, + allMessages, + footerActionMessages, + ); const { handleCancelTurn } = useChatPanelHandlers(resolvedSessionId, chatInputRef); const { clarificationKey, handleClarificationResolved } = useClarificationKey(agentMessageCount); const { @@ -423,6 +430,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ showScrollToStart={showScrollToStartButton} onScrollToStart={scrollToStart} statusTaskId={statusTaskId ?? taskIdHint} + showAgentStartHint={showAgentStartHint} /> ); @@ -489,8 +497,15 @@ type ChatFooterProps = { showScrollToStart: boolean; onScrollToStart: () => void; statusTaskId: string | null; + /** Recovered-idle sessions render the composer hint (see ChatInputArea). */ + showAgentStartHint: boolean; }; +/** + * Composer footer: renders the chat input area (or the read-only archived + * banner) and forwards the recovered-idle agent-start-hint visibility from + * the panel down to the input. + */ function ChatFooter({ isArchived, chatInputRef, @@ -509,6 +524,7 @@ function ChatFooter({ showScrollToStart, onScrollToStart, statusTaskId, + showAgentStartHint, }: ChatFooterProps) { const { t } = useTranslation(); if (isArchived) { @@ -536,6 +552,7 @@ function ChatFooter({ showScrollToStart={showScrollToStart} onScrollToStart={onScrollToStart} statusTaskId={statusTaskId} + showAgentStartHint={showAgentStartHint} /> ); } diff --git a/apps/web/e2e/tests/settings/mobile-agent-start-hint.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-start-hint.spec.ts new file mode 100644 index 0000000000..cadd76f148 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-agent-start-hint.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "../../fixtures/test-base"; +import { openTaskSession } from "../../helpers/git-helper"; + +// Mobile variant of the composer agent-start hint: the resume-skipped gate +// and the hint render through the shared responsive TaskPageContent +// (session-mobile-layout renders TaskChatPanel), so a recovered-idle session +// must show the hint on a phone viewport and a sent message must start the +// agent. Mirrors the desktop restart-resume flow in +// settings/prevent-auto-start-on-open.spec.ts. + +test.describe("Mobile agent-start hint", () => { + test("recovered-idle session shows the composer hint and a message starts the agent", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(180_000); + await apiClient.saveUserSettings({ prevent_auto_start_agent_on_open: true }); + try { + await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile Agent Start Hint Task", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + // Let the agent finish its first turn so the session ends idle with a + // resume token (the post-restart needs_resume shape). + const session = await openTaskSession(testPage, "Mobile Agent Start Hint Task"); + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + await session.waitForChatIdle({ timeout: 15_000 }); + + // Restart the backend and reload — the resume-skipped gate must keep + // the agent stopped and the composer hint must replace the footer + // Start agent button. + await backend.restart(); + await testPage.reload(); + await session.waitForLoad(); + + await expect(testPage.getByTestId("composer-agent-start-hint")).toBeVisible({ + timeout: 60_000, + }); + await expect(testPage.getByTestId("session-resume-start-button")).toHaveCount(0); + await expect(testPage.getByText("Resumed agent", { exact: false })).toHaveCount(0); + + // Sending a message is the explicit start: the agent resumes and + // keeps working. Touch layouts don't submit on Ctrl/Cmd+Enter, so use + // the Send-button path like the other mobile specs. Index 1: the first + // "simple mock response" from the pre-restart turn is still in the + // transcript, so the post-send reply must be the SECOND match. + await session.sendMessageViaButton("/e2e:simple-message"); + await expect(testPage.getByText("Resumed agent", { exact: false })).toBeVisible({ + timeout: 30_000, + }); + await session.expectChatResponseVisible("simple mock response", 1, { timeout: 30_000 }); + } finally { + await apiClient.saveUserSettings({ prevent_auto_start_agent_on_open: false }); + } + }); +}); diff --git a/apps/web/e2e/tests/settings/prevent-auto-start-on-open.spec.ts b/apps/web/e2e/tests/settings/prevent-auto-start-on-open.spec.ts index e31b2f73d6..d5db8c04f9 100644 --- a/apps/web/e2e/tests/settings/prevent-auto-start-on-open.spec.ts +++ b/apps/web/e2e/tests/settings/prevent-auto-start-on-open.spec.ts @@ -152,22 +152,30 @@ test.describe("Prevent auto-start on open", () => { await session.waitForChatIdle({ timeout: 15_000 }); // Restart the backend and reload — the resume-skipped gate must keep - // the agent stopped and render the Start agent button instead. + // the agent stopped. The composer hint replaces the footer Start agent + // button: it says a message will auto-start the agent, and it is + // always visible (the composer sits outside the transcript scroll + // container, so the clipped-button failure cannot occur). await backend.restart(); await testPage.reload(); await session.waitForLoad(); - const startButton = testPage.getByTestId("session-resume-start-button"); - await expect(startButton).toBeVisible({ timeout: 60_000 }); + await expect(testPage.getByTestId("composer-agent-start-hint")).toBeVisible({ + timeout: 60_000, + }); + await expect(testPage.getByTestId("session-resume-start-button")).toHaveCount(0); await expect(testPage.getByText("Resumed agent", { exact: false })).toHaveCount(0); - // Manual start resumes the agent and it keeps working. - await startButton.click(); + // Sending a message is the explicit start: the agent resumes and + // keeps working. Index 1: the first "simple mock response" from the + // pre-restart turn is still in the transcript, so the post-send reply + // must be the SECOND match — asserting index 0 would pass even if the + // resumed launch dropped the prompt. + await session.sendMessage("/e2e:simple-message"); await expect(testPage.getByText("Resumed agent", { exact: false })).toBeVisible({ timeout: 30_000, }); - await session.sendMessage("/e2e:simple-message"); - await session.expectChatResponseVisible("simple mock response", 0, { timeout: 30_000 }); + await session.expectChatResponseVisible("simple mock response", 1, { timeout: 30_000 }); } finally { await apiClient.saveUserSettings({ prevent_auto_start_agent_on_open: false }); } diff --git a/apps/web/lib/session-state.test.ts b/apps/web/lib/session-state.test.ts index c2e3c74290..3e5325db38 100644 --- a/apps/web/lib/session-state.test.ts +++ b/apps/web/lib/session-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isLaunchStateRegression } from "./session-state"; +import { isLaunchStateRegression, shouldShowComposerAgentStartHint } from "./session-state"; describe("isLaunchStateRegression", () => { it("applies the launch response over a never-started (CREATED) session", () => { @@ -44,3 +44,58 @@ describe("isLaunchStateRegression", () => { expect(isLaunchStateRegression("CREATED", "SOMETHING_NEW")).toBe(false); }); }); + +describe("shouldShowComposerAgentStartHint", () => { + const base = { + resumeSkipped: true, + hasRecoveryActions: false, + sessionId: "s1", + } as const; + + it("shows for a resume-skipped WAITING_FOR_INPUT session without recovery actions", () => { + expect(shouldShowComposerAgentStartHint({ ...base, sessionState: "WAITING_FOR_INPUT" })).toBe( + true, + ); + }); + + it("shows for a resume-skipped IDLE session (office idle shape)", () => { + expect(shouldShowComposerAgentStartHint({ ...base, sessionState: "IDLE" })).toBe(true); + }); + + it("hides when the session state is unknown/absent (a send might be rejected)", () => { + expect(shouldShowComposerAgentStartHint({ ...base, sessionState: undefined })).toBe(false); + }); + + it("hides when the session is not resume-skipped", () => { + expect(shouldShowComposerAgentStartHint({ ...base, resumeSkipped: false })).toBe(false); + }); + + it("hides for a FAILED session (recovery owns the surface)", () => { + expect(shouldShowComposerAgentStartHint({ ...base, sessionState: "FAILED" })).toBe(false); + }); + + it("hides for terminal CANCELLED/COMPLETED sessions (the composer rejects sends)", () => { + expect(shouldShowComposerAgentStartHint({ ...base, sessionState: "CANCELLED" })).toBe(false); + expect(shouldShowComposerAgentStartHint({ ...base, sessionState: "COMPLETED" })).toBe(false); + }); + + it("hides while the session is STARTING", () => { + expect(shouldShowComposerAgentStartHint({ ...base, sessionState: "STARTING" })).toBe(false); + }); + + it("hides while the session is RUNNING", () => { + expect(shouldShowComposerAgentStartHint({ ...base, sessionState: "RUNNING" })).toBe(false); + }); + + it("hides for a never-started CREATED session (the description button owns that surface)", () => { + expect(shouldShowComposerAgentStartHint({ ...base, sessionState: "CREATED" })).toBe(false); + }); + + it("hides when recovery actions are already visible", () => { + expect(shouldShowComposerAgentStartHint({ ...base, hasRecoveryActions: true })).toBe(false); + }); + + it("hides when no session is bound", () => { + expect(shouldShowComposerAgentStartHint({ ...base, sessionId: null })).toBe(false); + }); +}); diff --git a/apps/web/lib/session-state.ts b/apps/web/lib/session-state.ts index 53d974df5a..5a110af400 100644 --- a/apps/web/lib/session-state.ts +++ b/apps/web/lib/session-state.ts @@ -36,3 +36,33 @@ export function isLaunchStateRegression( if (live === undefined || incoming === undefined) return false; return incoming < live; } + +/** + * Visibility rule for the composer's "a message will auto-start the agent" + * hint (the recovered-idle / resume-skipped affordance that replaced the + * footer Start agent button). The hint promises that sending a message will + * start the agent, so it may only render for states the composer can + * actually send into: WAITING_FOR_INPUT or IDLE (deriveSessionInputMode's + * "direct" set for non-CREATED sessions). Terminal states (FAILED, + * CANCELLED, COMPLETED) are excluded: the composer rejects sends there + * ("Session has ended"), so the hint would be a dead-end promise; recovery + * actions or the session menu own those surfaces. + */ +export function shouldShowComposerAgentStartHint({ + resumeSkipped, + sessionState, + hasRecoveryActions, + sessionId, +}: { + resumeSkipped: boolean; + sessionState?: TaskSessionState; + hasRecoveryActions: boolean; + sessionId: string | null; +}): boolean { + return ( + resumeSkipped && + !hasRecoveryActions && + (sessionState === "WAITING_FOR_INPUT" || sessionState === "IDLE") && + sessionId !== null + ); +} diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 2cad240210..87623eddd0 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1270,6 +1270,7 @@ "start2": "Start", "startAgent": "Start agent", "startAgent2": "Start Agent", + "composerStartAgentHint": "Sending a message will auto-start the agent.", "startDevServerPreview": "Start dev server preview", "startFreshSession": "Start fresh session", "startPlanMode": "Start Plan Mode", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 9a8641d0e9..a206d2451c 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1270,6 +1270,7 @@ "start2": "Śţàŕţ", "startAgent": "Śţàŕţ àĝēńţ", "startAgent2": "Śţàŕţ Àĝēńţ", + "composerStartAgentHint": "Śēńďĩńĝ à ḿēśśàĝē ŵĩļļ àũţō-śţàŕţ ţħē àĝēńţ.", "startDevServerPreview": "Śţàŕţ ďēv śēŕvēŕ ƥŕēvĩēŵ", "startFreshSession": "Śţàŕţ ƒŕēśĥ śēśśĩōń", "startPlanMode": "Śţàŕţ Ƥĺàń Ḿōďē", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 3381728a9f..de038b133f 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1249,6 +1249,7 @@ "start2": "Iniciar", "startAgent": "Iniciar o agente", "startAgent2": "Iniciar o agente", + "composerStartAgentHint": "Enviar uma mensagem iniciará automaticamente o agente.", "startDevServerPreview": "Iniciar a pré-visualização do servidor de desenvolvimento", "startFreshSession": "Iniciar uma sessão nova", "startPlanMode": "Iniciar o modo de planeamento", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 2c1b0034ba..c9e2e17492 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1249,6 +1249,7 @@ "start2": "开始", "startAgent": "启动智能体", "startAgent2": "启动智能体", + "composerStartAgentHint": "发送消息将自动启动智能体。", "startDevServerPreview": "启动开发服务器预览", "startFreshSession": "开始全新会话", "startPlanMode": "启动计划模式", diff --git a/docs/plans/start-agent-composer-hint/plan.md b/docs/plans/start-agent-composer-hint/plan.md new file mode 100644 index 0000000000..29994b984c --- /dev/null +++ b/docs/plans/start-agent-composer-hint/plan.md @@ -0,0 +1,188 @@ +--- +spec: ../../specs/prevent-agent-autostart-on-open/spec.md +created: 2026-08-13 +status: done +--- + +# Implementation Plan: Start-Agent Composer Hint + +## Overview + +PR #2556 added the Start agent button for recovered-idle (resume-skipped) +sessions in the message-list footer. The button sits at the bottom of the +transcript and can be clipped (only a few pixels of its top visible) when the +transcript is not fully scrolled down, e.g. right after navigating into a +task whose auto-scroll did not reach the bottom. + +Product decision (2026-08-13): for recovered-idle sessions the footer Start +agent button is REMOVED and replaced by a single informational hint line in +the chat composer: "Sending a message will auto-start the agent." Sending a +message already is the explicit user action that starts the agent, so the +hint describes real behavior and removes the clipped-button failure entirely +(the composer sits outside the transcript scroll container and is always +visible). The never-started (`CREATED`) task-description Start agent button is +UNCHANGED: an explicitly-never-started task keeps its explicit Start agent +affordance under the task description. + +The hint's promise must also hold for messages RECEIVED by the session, not +only the user's composer sends: a message from another task (a sub-task via +`message_task_kandev`) lands on the same message queue +(`QueueAndInterruptForPeerMessage` → `QueueMessageWithMetadata` with +`QueuedByAgent`), and the queue dispatch +(`takeIfPromptableLocked` → `executeQueuedMessage` → `PromptTask` → +`ensureSessionRunning`) resumes a stopped-but-resumable agent. This path +already exists on the backend and is never gated by the preference; it has no +direct test pinning "peer message at a stopped session → agent resumed", so a +backend regression test is added (task-04). + +## Behavior + +| Surface | Before | After | +|---|---|---| +| Recovered-idle session (resume-skipped, non-FAILED, no recovery actions, not STARTING/RUNNING) | footer `SessionResumeStartButton` ("Start agent") | composer hint line, no button | +| FAILED-but-resumable session | recovery actions own the surface (no button) | unchanged; hint also hidden | +| Never-started (`CREATED`) session | `TaskDescriptionStartButton` under the task description | unchanged; no hint | + +The composer hint condition is the exact condition set the footer button used +today: `resumeSkipped && !hasRecoveryActions && sessionState not in +{FAILED, RUNNING, STARTING} && sessionId != null`. + +## Frontend Changes + +### 1. Shared predicate (`apps/web/lib/session-state.ts`) + +Add a pure, exported `shouldShowComposerAgentStartHint({ resumeSkipped, +sessionState, hasRecoveryActions, sessionId })` next to the existing +`isLaunchStateRegression`, unit-tested in `apps/web/lib/session-state.test.ts`. +This keeps the visibility decision testable without rendering the composer. + +### 2. Remove the footer resume button + +- Delete `apps/web/components/task/chat/session-resume-start-button.tsx` (its + launch/resume logic dies with it; no other file imports it). +- `apps/web/components/task/chat/message-list-footer.tsx`: remove the + `resumeSkipped` selector, the `hasRecoveryActions` memo, the + `showResumeStartButton` computation, and the `SessionResumeStartButton` + render. +- `apps/web/components/task/chat/message-list-footer.test.tsx`: remove the + `SessionResumeStartButton` mock and the "resume-skipped start button" + describe blocks. +- `apps/web/components/task/chat/message-renderer.tsx`: remove the now + unreachable `resumeSkipped` branch in `TaskDescriptionStartButton`'s + `handleStart` (the description button only renders for CREATED sessions, + which are never resume-skipped; the resume intent can no longer reach this + button). Keep `shouldShowDescriptionStartButton` CREATED-only. Update the + stale test comment in `message-renderer.test.ts` ("the footer owns that + surface" → "the composer hint owns that surface"). +- i18n: no copy change here; `task:startAgent` stays (used by the CREATED + description button). + +### 3. Composer hint UI + +- New hook `useComposerAgentStartHint(sessionId, sessionState, messages, + footerActionMessages)` in `apps/web/components/task/chat/` (+ test): reads + `state.tasks.resumeSkippedSessionIds[sessionId] === true` from the store, + derives `hasRecoveryActions` from `messages`/`footerActionMessages` + (`metadata.recovery_actions === true`, same rule the footer used), and + calls the shared predicate. +- `apps/web/components/task/task-chat-panel.tsx`: compute + `showAgentStartHint` from the hook (session id, `session?.state`, + `allMessages`, `footerActionMessages`) and thread it through `ChatFooter` + into `ChatInputArea` (`apps/web/components/task/chat/chat-input-area.tsx`) + as a new `showAgentStartHint?: boolean` prop. +- `chat-input-area.tsx`: render a muted one-line hint above + `QueueAffordance`, e.g. + `

` with + `t("task:composerStartAgentHint")`. No click action. +- i18n: add `composerStartAgentHint` to + `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn}/task.json`. Copy: "Sending a + message will auto-start the agent." (plain punctuation, no em dash). +- Component test: extend `chat-input-area.test.tsx` (or a focused test) to + assert the hint renders when `showAgentStartHint` is true and is absent + when false. + +## E2E Changes + +- `apps/web/e2e/tests/settings/prevent-auto-start-on-open.spec.ts`, + "post-restart session does not auto-resume when the preference is on": + - assert `composer-agent-start-hint` visible and + `session-resume-start-button` count 0 (no "Resumed agent" on its own); + - send `/e2e:simple-message` via the composer (`session.sendMessage`) and + assert the mock response appears and "Resumed agent" becomes visible + (sending a message is now the explicit start). + - The two final-step (CREATED) tests keep asserting + `task-description-start-button`; unchanged. +- Mobile (`mobile-chrome` project): new + `apps/web/e2e/tests/settings/mobile-agent-start-hint.spec.ts` using the + same restart pattern (task + first turn → `backend.restart()` → + `testPage.reload()`) on a phone viewport: hint visible, no resume button, + send message → mock response. The existing + `mobile-prevent-auto-start-on-open.spec.ts` (CREATED button) stays green. + +## Risks + +- The desktop restart test's send-message path must prove auto-start-on-send + (same mock flow as today's post-click send; the mock agent answers + `/e2e:simple-message` after a resumed launch). +- Mobile restart flow is slower (desktop restart test already needs + `test.setTimeout(180_000)`); reuse the same timeouts. +- i18n ratchet: the hint copy must go through `t()` in the component, and the + new key must exist in all four locale files (`i18n:check` fails otherwise). + +## Verification + +```bash +(cd apps/web && pnpm run typecheck) +``` + +```bash +(cd apps/web && pnpm vitest run lib/session-state.test.ts components/task/chat/message-list-footer.test.tsx components/task/chat/message-renderer.test.ts components/task/chat/chat-input-area.test.tsx) +``` + +```bash +(cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet) +``` + +```bash +(cd apps/backend && go test ./internal/orchestrator/ -run TestQueueAndInterruptForPeerMessage_StoppedSessionResumesAgent -race) +``` + +```bash +(cd apps/web && KANDEV_E2E_MOCK=true pnpm e2e:raw --project=chromium settings/prevent-auto-start-on-open.spec.ts) +(cd apps/web && KANDEV_E2E_MOCK=true pnpm e2e:raw --project=mobile-chrome settings/mobile-agent-start-hint.spec.ts settings/mobile-prevent-auto-start-on-open.spec.ts) +``` + +## Implementation Waves And Parallel Candidates + +``` +Wave 1 (sequential): +- [x] [task-01-shared-predicate-and-removal](task-01-shared-predicate-and-removal.md) +- [x] [task-02-composer-hint-ui](task-02-composer-hint-ui.md) +- [x] [task-03-e2e](task-03-e2e.md) +- [x] [task-04-peer-message-resumes-stopped-session](task-04-peer-message-resumes-stopped-session.md) +``` + +Tasks 01→03 are sequential (02 depends on 01's predicate, 03 depends on 02's +rendered hint). Task 04 is backend-only and independent of the frontend +tasks; it can run at any point in the wave. + +## Verification Results + +- Frontend: `make typecheck` and `make fmt` clean; `make lint` clean (0 + warnings). Targeted vitest (session-state, message-list-footer, + message-renderer, use-composer-agent-start-hint, composer-agent-start-hint) + 38/38 passing. `pnpm run i18n:check` + `pnpm run i18n:ratchet` clean. +- Backend: `go test ./internal/orchestrator/ -count=1` clean (75s, includes + the new `TestQueueAndInterruptForPeerMessage_StoppedSessionResumesAgent` + with `-race`). +- E2E (mock agent): `settings/prevent-auto-start-on-open.spec.ts` on + chromium 3/3; `settings/mobile-agent-start-hint.spec.ts` + + `settings/mobile-prevent-auto-start-on-open.spec.ts` on mobile-chrome 2/2. +- Full `make test` was NOT clean on this VM, but every failure is unrelated + to this change: `internal/agentctl/server/{api,config,process}`, + `internal/launcher`, `internal/system/updates` reproduce on a clean tree + (host init-system/process-output environment tests), and the web suite's + 18 failures are load-flaky 5-10s timeouts (git-server, azure-devops, + sentry, file-browser, settings, git-base) that all pass in isolation on + both the clean tree and this branch. `lib/http-git-server.test.ts` fails + on the clean tree too. diff --git a/docs/plans/start-agent-composer-hint/task-01-shared-predicate-and-removal.md b/docs/plans/start-agent-composer-hint/task-01-shared-predicate-and-removal.md new file mode 100644 index 0000000000..0a74b0c682 --- /dev/null +++ b/docs/plans/start-agent-composer-hint/task-01-shared-predicate-and-removal.md @@ -0,0 +1,76 @@ +--- +id: "01-shared-predicate-and-removal" +title: "Shared hint predicate and footer resume-button removal" +status: done +wave: 1 +parallelism: sequential +depends_on: [] +plan: "plan.md" +spec: "../../specs/prevent-agent-autostart-on-open/spec.md" +--- + +# Task 01: Shared hint predicate and footer resume-button removal + +## Acceptance + +- `shouldShowComposerAgentStartHint({ resumeSkipped, sessionState, + hasRecoveryActions, sessionId })` is exported from + `apps/web/lib/session-state.ts` and returns true only when ALL of: + `resumeSkipped === true`, `hasRecoveryActions === false`, `sessionState` is + not `FAILED` / `RUNNING` / `STARTING`, and `sessionId` is non-null. This is + the exact condition set the footer button used. +- Unit tests in `apps/web/lib/session-state.test.ts` cover: visible for a + resume-skipped `WAITING_FOR_INPUT` session; hidden when not resume-skipped; + hidden for `FAILED`; hidden for `RUNNING` and `STARTING`; hidden when + recovery actions are present; hidden when `sessionId` is null. +- `apps/web/components/task/chat/session-resume-start-button.tsx` is deleted + and no file imports `session-resume-start-button` anymore. +- `apps/web/components/task/chat/message-list-footer.tsx` no longer renders + `SessionResumeStartButton` and no longer reads + `resumeSkippedSessionIds` / computes `hasRecoveryActions` / + `showResumeStartButton` (those existed only for the button). +- `apps/web/components/task/chat/message-list-footer.test.tsx` has no + `SessionResumeStartButton` mock and no resume-button describe blocks; the + remaining footer tests (AgentStatus, failure presentation) still pass. +- `apps/web/components/task/chat/message-renderer.tsx`: + `TaskDescriptionStartButton`'s `handleStart` drops the now-unreachable + `resumeSkipped ? buildResumeRequest : buildStartCreatedRequest` branch and + always dispatches `buildStartCreatedRequest`; `shouldShowDescriptionStartButton` + stays CREATED-only. The test comment "the footer owns that surface" in + `message-renderer.test.ts` is updated to name the composer hint. + +## Verification + +```bash +(cd apps/web && pnpm run typecheck) +``` + +```bash +(cd apps/web && pnpm vitest run lib/session-state.test.ts components/task/chat/message-list-footer.test.tsx components/task/chat/message-renderer.test.ts) +``` + +## Files Likely Touched + +- `apps/web/lib/session-state.ts` (+ `session-state.test.ts`) +- `apps/web/components/task/chat/session-resume-start-button.tsx` (deleted) +- `apps/web/components/task/chat/message-list-footer.tsx` (+ test) +- `apps/web/components/task/chat/message-renderer.tsx` (+ test) + +## Dependencies + +None. The predicate is new and the removal is independent of the hint UI. + +## Inputs + +- Spec: "What" bullets 1 and 3, scenarios 1 and 5. +- Current footer predicate at `message-list-footer.tsx:62-88` + (`resumeSkipped`, `hasRecoveryActions`, `showResumeStartButton`). +- Current `TaskDescriptionStartButton` branch at `message-renderer.tsx:90-95` + and `shouldShowDescriptionStartButton` at `message-renderer.tsx:67-72`. + +## Output Contract + +No UI change yet (the hint renders in task 02): the resume-skipped session +simply has no Start agent button anywhere, and the shared predicate pins the +exact condition set the hint will reuse. The CREATED description button is +behaviorally unchanged. diff --git a/docs/plans/start-agent-composer-hint/task-02-composer-hint-ui.md b/docs/plans/start-agent-composer-hint/task-02-composer-hint-ui.md new file mode 100644 index 0000000000..87f688212e --- /dev/null +++ b/docs/plans/start-agent-composer-hint/task-02-composer-hint-ui.md @@ -0,0 +1,85 @@ +--- +id: "02-composer-hint-ui" +title: "Composer hint UI and i18n" +status: done +wave: 1 +parallelism: sequential +depends_on: ["01-shared-predicate-and-removal"] +plan: "plan.md" +spec: "../../specs/prevent-agent-autostart-on-open/spec.md" +--- + +# Task 02: Composer hint UI and i18n + +## Acceptance + +- `useComposerAgentStartHint(sessionId, sessionState, messages, + footerActionMessages)` (new hook in `apps/web/components/task/chat/`) + returns `shouldShowComposerAgentStartHint(...)` with `resumeSkipped` read + from `state.tasks.resumeSkippedSessionIds[sessionId] === true` and + `hasRecoveryActions` derived from `messages`/`footerActionMessages` using + the same rule the footer used (`metadata.recovery_actions === true` on any + message). `sessionId === null` short-circuits to false. Hook unit tests + cover: resume-skipped + `WAITING_FOR_INPUT` + no recovery → true; FAILED / + STARTING / RUNNING → false; recovery action present in `messages` or + `footerActionMessages` → false; no resume-skipped flag → false. +- `apps/web/components/task/task-chat-panel.tsx` computes + `showAgentStartHint` from the hook (`session?.state`, `allMessages`, + `footerActionMessages`) and passes it through `ChatFooter` to + `ChatInputArea` (`apps/web/components/task/chat/chat-input-area.tsx`) as a + new optional `showAgentStartHint` prop. +- `chat-input-area.tsx` renders, above `QueueAffordance`, a single muted + one-line hint when `showAgentStartHint` is true: + `

{t("task:composerStartAgentHint")}

`. + It is absent when the prop is false/undefined. No click handler. +- Component test (extend `chat-input-area.test.tsx` or a focused test) + asserts the hint element renders for `showAgentStartHint` true and is + absent for false. +- i18n: `composerStartAgentHint` added to + `apps/web/src/locales/{en,pseudo,pt-pt,zh-cn}/task.json` with copy "Sending + a message will auto-start the agent." (plain punctuation; pseudo and + translations follow the existing per-locale style). `pnpm run i18n:check` + and `pnpm run i18n:ratchet` pass. + +## Verification + +```bash +(cd apps/web && pnpm run typecheck) +``` + +```bash +(cd apps/web && pnpm vitest run components/task/chat/chat-input-area.test.tsx components/task/chat/use-composer-agent-start-hint.test.ts) +``` + +```bash +(cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet) +``` + +## Files Likely Touched + +- `apps/web/components/task/chat/use-composer-agent-start-hint.ts` (new, + test) +- `apps/web/components/task/task-chat-panel.tsx` (`ChatFooter` props) +- `apps/web/components/task/chat/chat-input-area.tsx` (+ test) +- `apps/web/src/locales/en/task.json`, `pseudo/task.json`, + `pt-pt/task.json`, `zh-cn/task.json` + +## Dependencies + +Task 01 (the `shouldShowComposerAgentStartHint` predicate and the removal of +the footer button, so no second affordance competes with the hint). + +## Inputs + +- Spec: "What" bullet 3 (hint visibility rules), scenarios 6 and 7. +- `ChatInputArea` render at `chat-input-area.tsx:629-659` (the + `data-testid="chat-input-area"` wrapper above `QueueAffordance`). +- `ChatFooter` prop threading at `task-chat-panel.tsx:400-418` and + `484-528`. + +## Output Contract + +A recovered-idle session shows exactly one affordance: the composer hint +("Sending a message will auto-start the agent."), always visible because the +composer is outside the transcript scroll container. The hint disappears when +the session starts/runs, fails, or recovery actions are visible. The CREATED +description button remains the never-started affordance. diff --git a/docs/plans/start-agent-composer-hint/task-03-e2e.md b/docs/plans/start-agent-composer-hint/task-03-e2e.md new file mode 100644 index 0000000000..9cc152e74b --- /dev/null +++ b/docs/plans/start-agent-composer-hint/task-03-e2e.md @@ -0,0 +1,71 @@ +--- +id: "03-e2e" +title: "E2E: hint replaces resume button, desktop and mobile" +status: done +wave: 1 +parallelism: sequential +depends_on: ["02-composer-hint-ui"] +plan: "plan.md" +spec: "../../specs/prevent-agent-autostart-on-open/spec.md" +--- + +# Task 03: E2E — hint replaces resume button, desktop and mobile + +## Acceptance + +- `apps/web/e2e/tests/settings/prevent-auto-start-on-open.spec.ts`, + "post-restart session does not auto-resume when the preference is on": + - after `backend.restart()` + `testPage.reload()`, asserts + `composer-agent-start-hint` is visible and + `session-resume-start-button` has count 0 (and "Resumed agent" never + appears on its own); + - sends `/e2e:simple-message` via the composer (`session.sendMessage`) and + asserts the mock response ("simple mock response") becomes visible and + "Resumed agent" appears — sending a message is the explicit start. + - `test.setTimeout(180_000)` retained; settings/task teardown unchanged. +- The two final-step tests in the same file still assert + `task-description-start-button` visibility/absence exactly as today + (CREATED button unchanged, setting-off control unchanged). +- `apps/web/e2e/tests/settings/mobile-prevent-auto-start-on-open.spec.ts` + (CREATED button on mobile) stays green unchanged. +- New `apps/web/e2e/tests/settings/mobile-agent-start-hint.spec.ts` + (`mobile-chrome` project, phone viewport from the project device, no + per-test `setViewportSize`): same restart pattern — task with + `/e2e:simple-message` description, first turn completes, `backend.restart()` + + `testPage.reload()`, then asserts `composer-agent-start-hint` visible, + `session-resume-start-button` count 0, sends a message, and asserts the + mock response appears. Timeouts mirror the desktop restart test + (`test.setTimeout(180_000)`). + +## Verification + +```bash +(cd apps/web && KANDEV_E2E_MOCK=true pnpm e2e:raw --project=chromium settings/prevent-auto-start-on-open.spec.ts) +(cd apps/web && KANDEV_E2E_MOCK=true pnpm e2e:raw --project=mobile-chrome settings/mobile-agent-start-hint.spec.ts settings/mobile-prevent-auto-start-on-open.spec.ts) +``` + +## Files Likely Touched + +- `apps/web/e2e/tests/settings/prevent-auto-start-on-open.spec.ts` +- `apps/web/e2e/tests/settings/mobile-agent-start-hint.spec.ts` (new) + +## Dependencies + +Task 02 (the hint must be rendered before the specs can assert it). + +## Inputs + +- Current restart test body at + `prevent-auto-start-on-open.spec.ts:117-166` (uses `backend.restart()` + + `testPage.reload()`, `SessionPage.waitForLoad`, + `session.sendMessage("/e2e:simple-message")`, + `session.expectChatResponseVisible("simple mock response", 0, …)`). +- Mobile restart flow mirrors the desktop one; the mobile-chrome project + auto-selects `mobile-*.spec.ts` files and supplies the device. + +## Output Contract + +Desktop and mobile E2E prove: a recovered-idle session shows the composer +hint and no Start agent button; sending a message starts the agent and the +mock response appears; the CREATED final-step button flows (desktop and +mobile) are regression-green. diff --git a/docs/plans/start-agent-composer-hint/task-04-peer-message-resumes-stopped-session.md b/docs/plans/start-agent-composer-hint/task-04-peer-message-resumes-stopped-session.md new file mode 100644 index 0000000000..cf39c19dc6 --- /dev/null +++ b/docs/plans/start-agent-composer-hint/task-04-peer-message-resumes-stopped-session.md @@ -0,0 +1,81 @@ +--- +id: "04-peer-message-resumes-stopped-session" +title: "Backend pin test: peer message resumes a stopped session" +status: done +wave: 1 +parallelism: sequential +depends_on: [] +plan: "plan.md" +spec: "../../specs/prevent-agent-autostart-on-open/spec.md" +--- + +# Task 04: Backend pin test — peer message resumes a stopped session + +## Acceptance + +- A new Go test in `apps/backend/internal/orchestrator/` + (`task_operations_test.go`, next to the other + `TestQueueAndInterruptForPeerMessage_*` tests) pins the requirement that a + message received from another task (sub-task via `message_task_kandev`, + delivered as `QueueAndInterruptForPeerMessage` with `QueuedByAgent`) + auto-starts the agent when the receiving session is stopped-but-resumable + (the post-restart / recovered-idle shape the prevent-auto-start preference + leaves the session in). +- Test shape: seed a task + session in `WAITING_FOR_INPUT` with a resume + token (an `executors_running` row carrying the token, like + `TestResumeTaskSession_*` / `TestEnsureSessionRunning_*` fixtures), an + agent manager reporting `isAgentRunning: false` (so `ensureSessionRunning` + takes the resume path) with a recording `LaunchAgent`, and the executor + wired like the existing resume-path tests. Call + `QueueAndInterruptForPeerMessage(ctx, taskID, sessionID, prompt, metadata)` + and assert the agent was launched (resume) and the queued message was + dispatched to the agent (PromptAgent reached), mirroring the assertions of + `TestQueueAndInterruptForPeerMessage_DeliversQueuedMessageWithoutUserCancelSideEffects` + but with the stopped-agent resume path instead of + `isAgentRunning: true`. +- The test must PASS against the current backend (the behavior already + exists: `takeIfPromptableLocked` → `executeQueuedMessage` → `PromptTask` → + `ensureSessionRunning` resumes). It is a regression pin, not a fix; if it + fails, stop and report — that would mean the requirement is actually + broken and needs a fix design. + +## Verification + +```bash +(cd apps/backend && go test ./internal/orchestrator/ -run TestQueueAndInterruptForPeerMessage_StoppedSessionResumesAgent -race) +``` + +## Files Likely Touched + +- `apps/backend/internal/orchestrator/task_operations_test.go` (new test) +- Possibly `apps/backend/internal/orchestrator/task_operations.go` ONLY if the + test proves the behavior is broken (then stop and report; do not fix + silently) + +## Dependencies + +None. Backend-only; independent of the frontend hint tasks. + +## Inputs + +- Spec: "What" bullet "A message auto-starts the agent covers every message…" + and the scenario "GIVEN a recovered-idle session whose composer hint is + visible, WHEN a message is received from another task…". +- `QueueAndInterruptForPeerMessage` at + `task_operations.go:5363-5423` (queues with `QueuedByAgent`, dispatches via + `takeIfPromptableLocked` when promptable). +- Resume-path fixture examples: `TestResumeTaskSession_*` at + `task_operations_test.go:5161-5204` (sets `AgentProfileID`, wires the + executor with the mock agent manager, asserts the resumed state and prompt + dispatch); `TestQueueAndInterruptForPeerMessage_*` at + `task_operations_test.go:1905-2920` (queue + dispatch assertions). +- `ensureSessionRunning` at `task_operations.go:1891` (resumes + WAITING_FOR_INPUT sessions with a resume token). + +## Output Contract + +The backend test suite proves: a peer (sub-task) message delivered to a +stopped-but-resumable session starts the agent and the message reaches the +agent. Combined with the frontend E2E (task-03), the hint's promise — +"a message auto-starts the agent" — is verified for both the user's composer +sends and received sub-task messages. diff --git a/docs/specs/prevent-agent-autostart-on-open/spec.md b/docs/specs/prevent-agent-autostart-on-open/spec.md index 88d8ca5808..bae1ab306a 100644 --- a/docs/specs/prevent-agent-autostart-on-open/spec.md +++ b/docs/specs/prevent-agent-autostart-on-open/spec.md @@ -1,9 +1,17 @@ --- status: draft created: 2026-08-11 +updated: 2026-08-13 owner: platform --- +> Follow-up (2026-08-13): for recovered-idle sessions the in-chat Start agent +> button was replaced by a hint in the chat composer ("sending a message will +> auto-start the agent"), because the button rendered at the bottom of the +> transcript and could be clipped when the transcript was not scrolled all the +> way down. The never-started (CREATED) task-description Start agent button is +> unchanged. The sections below describe the current behavior. + # Prevent Agent Auto-Start On Open ## Why @@ -22,16 +30,36 @@ open and start it explicitly instead. surfaced in Settings → Task Actions as a switch. - When the setting is ON, opening a task whose session exists but whose agent is not running (the post-restart / recovered-idle shape) MUST NOT - automatically resume the agent. The task opens with the agent stopped and a - Start agent button is shown in the chat; clicking it resumes the agent. - (The existing session-menu resume and chat composer remain available too.) - For FAILED-but-resumable sessions the existing recovery actions (Resume - session / Start fresh session buttons) remain the manual affordance; the - new Start agent button is not rendered for FAILED sessions. + automatically resume the agent. The task opens with the agent stopped and + the chat composer shows a hint that sending a message will auto-start the + agent; sending a message starts it (the explicit-user-action rule below). + The Start agent button is NOT shown for recovered-idle sessions: the + composer hint replaces it (a footer button could be clipped when the + transcript is not scrolled to the bottom). For FAILED-but-resumable + sessions the existing recovery actions (Resume session / Start fresh + session buttons) remain the manual affordance; the hint is not rendered for + FAILED sessions. - When the setting is ON, opening a task whose current workflow step is the final step of its workflow MUST NOT auto-start the agent when the task has no session. The session is created in the never-started (prepared) state - and the Start agent button is shown. + and the Start agent button is shown under the task description. This button + is unchanged: a task that was explicitly never started keeps its explicit + Start agent affordance (no hint replaces it, because the CREATED transcript + is short and the button cannot be clipped). +- The composer hint is a single informational line above the chat input, + shown exactly when the recovered-idle session is stopped and no other + affordance owns the surface: it is hidden while the session is FAILED + (recovery actions own the surface), while it is STARTING or RUNNING, and + when recovery actions are already visible in the transcript. The hint has + no click action; it only communicates that a message auto-starts the + agent. +- "A message auto-starts the agent" covers every message that reaches the + session's message queue, not only the user's composer sends: messages + received from other tasks (`message_task_kandev`, e.g. a sub-task + reporting back) are queued the same way and the queue dispatch resumes a + stopped-but-resumable agent through the same + `ensureSessionRunning` path. The preference never gates message-driven + starts — it only gates starts triggered by *opening* the task. - When the setting is OFF (default), opening a task behaves exactly as today. - The setting gates only automatic starts triggered by *opening* a task. It MUST NOT affect: workflow step transitions whose on-enter actions include @@ -84,10 +112,11 @@ auto-start or auto-resume is *triggered by opening the task page*: | Trigger | Setting OFF (default) | Setting ON | |---|---|---| | Open task, no session, step allows auto-start | agent starts (`created_start`) | non-final step: unchanged; final step: `created_prepare`, Start agent button shown | -| Open task, session exists, agent not running, session resumable | auto-resume (`session.launch` intent=resume) | no auto-resume; session stays stopped and a Start agent button (resume action) is shown in the chat | +| Open task, session exists, agent not running, session resumable | auto-resume (`session.launch` intent=resume) | no auto-resume; session stays stopped; the chat composer shows a hint that sending a message will auto-start the agent | | Open task, agent already running | no-op | no-op (unchanged) | | Workflow step transition with `auto_start_agent` on-enter | agent starts | agent starts (unchanged) | | User clicks Start agent / Resume / sends a message | agent starts | agent starts (unchanged) | +| Message received from another task (sub-task via `message_task`), agent stopped | agent resumes (queue dispatch runs `ensureSessionRunning`) | agent resumes (unchanged; message-driven starts are never gated by the preference) | ## Failure modes @@ -113,13 +142,14 @@ per-task variant in this feature. - **GIVEN** the setting is ON and a task's session is in an active state with a resume token but no running agent (post-restart shape), **WHEN** the user opens the task page, **THEN** no `session.launch` resume request is sent on - open, the session stays stopped, and the task page shows a Start agent - button that resumes the agent when clicked. + open, the session stays stopped, and the chat composer shows a hint that + sending a message will auto-start the agent; sending a message starts the + agent. - **GIVEN** the setting is ON and a task's session is FAILED but resumable, **WHEN** the user opens the task page, **THEN** no auto-resume is sent on open and the session keeps the existing recovery actions (Resume session and - Start fresh session buttons). The new Start agent button is not rendered for - FAILED sessions. + Start fresh session buttons). The composer hint is not rendered for FAILED + sessions. - **GIVEN** the setting is ON and the task's current workflow step is the final step, **WHEN** the user opens the task and it has no session, **THEN** `session.ensure` is called with `auto_start: false`, the session is created @@ -133,7 +163,18 @@ per-task variant in this feature. **THEN** today's auto-start and auto-resume behavior is unchanged. - **GIVEN** the setting is ON and a task in the final step already has a never-started (`CREATED`) session, **WHEN** the task is opened, **THEN** the - Start agent button is shown and no agent starts. + Start agent button is shown under the task description and no agent starts. +- **GIVEN** a recovered-idle session whose composer hint is visible, **WHEN** + a message is received from another task (e.g. a sub-task via + `message_task_kandev`), **THEN** the agent resumes automatically (the + message is queued and the queue dispatch runs `ensureSessionRunning`) and + the hint disappears once the session reports STARTING/RUNNING. +- **GIVEN** a recovered-idle session whose composer hint is visible, **WHEN** + the session state becomes STARTING or RUNNING (e.g. the user sent a + message), **THEN** the hint disappears. +- **GIVEN** a recovered-idle session, **WHEN** recovery actions are already + visible in the transcript, **THEN** the composer hint is hidden (recovery + owns the surface). - **GIVEN** the setting is ON, **WHEN** the user explicitly starts or resumes the agent (Start agent button, session menu Resume, or sending a message), **THEN** the agent starts normally.