fix(web): replace clipped start agent button with composer hint (#2624)

This commit is contained in:
Clément Fiere
2026-08-15 22:39:41 +02:00
committed by GitHub
parent 48ec8796c1
commit 44773eec59
26 changed files with 1107 additions and 264 deletions
@@ -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)
}
}
@@ -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)}
>
<ComposerAgentStartHint
show={showAgentStartHint}
needsRecovery={panelState.needsRecovery}
executorUnavailable={executor.unavailable}
hasPendingClarification={Boolean(panelState.pendingClarification)}
/>
<QueueAffordance
sessionId={resolvedSessionId}
canDrain={canDrainQueue}
@@ -0,0 +1,34 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { ComposerAgentStartHint } from "./composer-agent-start-hint";
const HINT_TEST_ID = "composer-agent-start-hint";
afterEach(cleanup);
describe("ComposerAgentStartHint", () => {
it("renders the hint line when shown and the composer accepts sends", () => {
render(<ComposerAgentStartHint show />);
expect(screen.getByTestId(HINT_TEST_ID)).toBeTruthy();
});
it("renders nothing when hidden", () => {
render(<ComposerAgentStartHint show={false} />);
expect(screen.queryByTestId(HINT_TEST_ID)).toBeNull();
});
it("renders nothing while a recovery is required (the composer is disabled)", () => {
render(<ComposerAgentStartHint show needsRecovery />);
expect(screen.queryByTestId(HINT_TEST_ID)).toBeNull();
});
it("renders nothing while the executor environment is unavailable", () => {
render(<ComposerAgentStartHint show executorUnavailable />);
expect(screen.queryByTestId(HINT_TEST_ID)).toBeNull();
});
it("renders nothing while a clarification is pending (sends only enqueue)", () => {
render(<ComposerAgentStartHint show hasPendingClarification />);
expect(screen.queryByTestId(HINT_TEST_ID)).toBeNull();
});
});
@@ -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 (
<p data-testid="composer-agent-start-hint" className="px-1 pb-1 text-xs text-muted-foreground">
{t("task:composerStartAgentHint")}
</p>
);
}
@@ -12,23 +12,10 @@ vi.mock("@/components/task/chat/message-renderer", () => ({
),
}));
let mockResumeSkippedSessionIds: Record<string, true> = {};
vi.mock("@/components/state-provider", () => ({
useAppStore: (
selector: (state: { tasks: { resumeSkippedSessionIds: Record<string, true> } }) => unknown,
) => selector({ tasks: { resumeSkippedSessionIds: mockResumeSkippedSessionIds } }),
}));
vi.mock("@/components/task/chat/session-resume-start-button", () => ({
SessionResumeStartButton: () => <div data-testid={RESUME_BUTTON_TEST_ID}>Start agent</div>,
}));
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(
<MessageListFooter
sessionState="WAITING_FOR_INPUT"
sessionId={SESSION_ID}
messages={withMessages}
/>,
);
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(
<MessageListFooter sessionState="FAILED" sessionId={SESSION_ID} messages={withMessages} />,
);
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(
<MessageListFooter sessionState="WAITING_FOR_INPUT" sessionId={SESSION_ID} messages={[]} />,
);
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(
<MessageListFooter
sessionState="WAITING_FOR_INPUT"
sessionId={SESSION_ID}
messages={[recoveryMessage]}
/>,
);
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(
<MessageListFooter
sessionState="WAITING_FOR_INPUT"
sessionId={SESSION_ID}
messages={withMessages}
/>,
);
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(
<MessageListFooter sessionState="RUNNING" sessionId={SESSION_ID} messages={withMessages} />,
);
expect(screen.queryByTestId(RESUME_BUTTON_TEST_ID)).toBeNull();
});
});
@@ -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<string, unknown> | undefined)?.recovery_actions === true,
) ||
footerActionMessages.some(
(message) =>
(message.metadata as Record<string, unknown> | 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 && (
<SessionResumeStartButton sessionId={sessionId} />
)}
{visibleFooterActionMessages.map((message) => (
<MessageRenderer key={message.id} comment={message} isTaskDescription={false} />
))}
@@ -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",
@@ -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;
@@ -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 (
<div className="flex justify-end mt-1.5">
<Button
size="sm"
variant="default"
className="cursor-pointer gap-1.5"
onClick={handleStart}
disabled={isStarting}
data-testid="session-resume-start-button"
>
<IconPlayerPlay className="h-3.5 w-3.5" />
{isStarting ? t("task:startingEllipsis") : t("task:startAgent")}
</Button>
</div>
);
}
@@ -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<string, true> = {};
type MockState = {
tasks: { resumeSkippedSessionIds: Record<string, true> };
};
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> = {}): 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);
}
});
});
@@ -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<string, unknown> | 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,
});
}
@@ -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}
/>
</PanelRoot>
);
@@ -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}
/>
);
}
@@ -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 });
}
});
});
@@ -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 });
}
+56 -1
View File
@@ -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);
});
});
+30
View File
@@ -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
);
}
+1
View File
@@ -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",
+1
View File
@@ -1270,6 +1270,7 @@
"start2": "Śţàŕţ",
"startAgent": "Śţàŕţ àĝēńţ",
"startAgent2": "Śţàŕţ Àĝēńţ",
"composerStartAgentHint": "Śēńďĩńĝ à ḿēśśàĝē ŵĩļļ àũţō-śţàŕţ ţħē àĝēńţ.",
"startDevServerPreview": "Śţàŕţ ďēv śēŕvēŕ ƥŕēvĩēŵ",
"startFreshSession": "Śţàŕţ ƒŕēśĥ śēśśĩōń",
"startPlanMode": "Śţàŕţ Ƥĺàń Ḿōďē",
+1
View File
@@ -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",
+1
View File
@@ -1249,6 +1249,7 @@
"start2": "开始",
"startAgent": "启动智能体",
"startAgent2": "启动智能体",
"composerStartAgentHint": "发送消息将自动启动智能体。",
"startDevServerPreview": "启动开发服务器预览",
"startFreshSession": "开始全新会话",
"startPlanMode": "启动计划模式",
@@ -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.
`<p data-testid="composer-agent-start-hint" className="...">` 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.
@@ -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.
@@ -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:
`<p data-testid="composer-agent-start-hint" …>{t("task:composerStartAgentHint")}</p>`.
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.
@@ -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.
@@ -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.
@@ -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.