fix(web): replace clipped start agent button with composer hint (#2624)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1270,6 +1270,7 @@
|
||||
"start2": "Śţàŕţ",
|
||||
"startAgent": "Śţàŕţ àĝēńţ",
|
||||
"startAgent2": "Śţàŕţ Àĝēńţ",
|
||||
"composerStartAgentHint": "Śēńďĩńĝ à ḿēśśàĝē ŵĩļļ àũţō-śţàŕţ ţħē àĝēńţ.",
|
||||
"startDevServerPreview": "Śţàŕţ ďēv śēŕvēŕ ƥŕēvĩēŵ",
|
||||
"startFreshSession": "Śţàŕţ ƒŕēśĥ śēśśĩōń",
|
||||
"startPlanMode": "Śţàŕţ Ƥĺàń Ḿōďē",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user