diff --git a/apps/backend/internal/agent/runtime/lifecycle/persistence.go b/apps/backend/internal/agent/runtime/lifecycle/persistence.go index 4af979ea29..1b0710f578 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/persistence.go +++ b/apps/backend/internal/agent/runtime/lifecycle/persistence.go @@ -55,7 +55,7 @@ func buildRunningFromExecution(execution *AgentExecution, prior *models.Executor SessionID: execution.SessionID, TaskID: execution.TaskID, Runtime: execution.RuntimeName, - Status: "starting", + Status: models.ExecutorRunningStatusStarting, Resumable: true, AgentExecutionID: execution.ID, ContainerID: execution.ContainerID, diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 8fe3b8f1d2..324a3e966c 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -478,7 +478,7 @@ IMPORTANT: - Pass base_branch explicitly to override either default. Use list_repositories_kandev to see each repo's default_branch. - Top-level tasks need a repository via repository_url, repository_id, or local_path - 'description' is the sub-agent's initial prompt — be specific and detailed -- Set start_agent=false to create without starting an agent` +- start_agent defaults to true and is what you want in nearly every case — the new task auto-launches an agent that immediately works on the description. Pass start_agent=false ONLY for an explicit placeholder (e.g. queuing work the user will start later, or creating a tracking task with no immediate work). When in doubt, leave it true.` parentDesc := "Parent task ID for subtasks. Use 'self' to create a subtask of your current task (RECOMMENDED for plan phases, delegated work). Omit only for unrelated top-level tasks." if s.mode == ModeExternal { @@ -488,7 +488,7 @@ IMPORTANT: - Provide a repository via repository_url, repository_id, or local_path - workspace_id and workflow_id are auto-resolved if only one exists; provide explicitly if ambiguous - 'description' is the agent's initial prompt — be specific and detailed -- Set start_agent=false to create without starting an agent +- start_agent defaults to true and is what you want in nearly every case — the new task auto-launches an agent that immediately works on the description. Pass start_agent=false ONLY for an explicit placeholder (e.g. queuing work the user will start later). When in doubt, leave it true. - Use parent_id only when delegating to a known existing task by its ID` parentDesc = "Optional parent task ID. Omit for top-level tasks; provide an existing task ID only to create a subtask of that task." } @@ -504,7 +504,7 @@ IMPORTANT: mcp.WithString("description", mcp.Description("The initial prompt for the sub-agent. This is the ONLY context the agent receives when it starts — treat it as the agent's first user message. REQUIRED for subtasks: without a description the sub-agent starts with no context and cannot do useful work. Be specific and detailed.")), mcp.WithString("agent_profile_id", mcp.Description("Agent profile ID to use. For subtasks, inherited from the parent session. For top-level tasks, ask the user which agent profile they want (e.g. Claude Code, OpenCode) if not already known.")), mcp.WithString("executor_profile_id", mcp.Description("Executor profile ID to use (determines the runtime environment: local, worktree, docker, etc.). For subtasks, inherited from the parent session. For top-level tasks, ask the user which executor profile they want if not already known.")), - mcp.WithBoolean("start_agent", mcp.Description("Whether to auto-start an agent on the created task. Default: true. Set to false to create the task without starting an agent.")), + mcp.WithBoolean("start_agent", mcp.Description("Whether to auto-start an agent on the created task. Default: true — leave it true unless you specifically want a placeholder task with no agent running. Setting false leaves the task waiting for the user to click 'Start agent' in the UI; the description is preserved but no work happens automatically.")), mcp.WithString("repository_id", mcp.Description("Repository ID. Required for top-level tasks unless local_path or repository_url is provided. For subtasks: optional — supply only when the subtask should target a different repo than the parent.")), mcp.WithString("local_path", mcp.Description("Local repository folder path (e.g. '/Users/me/projects/myrepo'). Will create/find the repository automatically. Preferred for local worktree flow. For subtasks: supply only when the subtask should target a different repo than the parent.")), mcp.WithString("repository_url", mcp.Description("GitHub repository URL (e.g. 'https://github.com/owner/repo'). The repository will be cloned automatically on first use. For subtasks: supply only when the subtask should target a different repo than the parent.")), diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 7c83cbb3a6..405144967c 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -48,6 +48,7 @@ type executorStore interface { UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error HasExecutorRunningRow(ctx context.Context, sessionID string) (bool, error) DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error + UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error // Workspace GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) // Task environment diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 9acb2ccbfb..926201b3b2 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -570,6 +570,23 @@ func (e *Executor) finalizeLaunch(ctx context.Context, task *v1.Task, session *m if startAgent { e.startAgentProcessAsync(ctx, task.ID, sessionID, resp.AgentExecutionID) + } else { + // Prepare-only launch: the workspace + agentctl are up but the agent + // process is intentionally not being started. The lifecycle manager + // always writes status='starting' on row creation; flip it to + // 'prepared' so the row doesn't look stuck mid-launch. When the user + // later starts the agent (StartCreatedSession), Launch re-runs and + // rewrites the row with status='starting' via the usual path. + // + // Detach from the caller context so a client disconnect / WS timeout + // right after launch returns can't drop this write — that would leave + // the row stuck on "starting", which is the exact UX this fix closes. + statusCtx := context.WithoutCancel(ctx) + if err := e.repo.UpdateExecutorRunningStatus(statusCtx, sessionID, models.ExecutorRunningStatusPrepared); err != nil { + e.logger.Warn("failed to mark executors_running as prepared", + zap.String("session_id", sessionID), + zap.Error(err)) + } } e.logger.Info("agent launched for prepared session", diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 2210b55ef4..58d9cf1fb6 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -705,6 +705,22 @@ func (m *mockRepository) HasExecutorRunningRow(ctx context.Context, sessionID st return false, nil } +// UpdateExecutorRunningStatus mirrors the production sqlite repo: returns +// ErrExecutorRunningNotFound when no row exists for the session. Tests that +// exercise the "no row" warning-log path can rely on this, and tests that want +// the status flip to land must seed m.executorsRunning[sessionID] first. +func (m *mockRepository) UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error { + if m.executorsRunning == nil { + return models.ErrExecutorRunningNotFound + } + row, ok := m.executorsRunning[sessionID] + if !ok { + return models.ErrExecutorRunningNotFound + } + row.Status = status + return nil +} + // Environment operations func (m *mockRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { return nil diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 52b393431f..831e859904 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -473,6 +473,55 @@ func TestLaunchPreparedSession_WorkspaceOnly(t *testing.T) { } } +// TestLaunchPreparedSession_WorkspaceOnly_FlipsExecutorRunningStatus asserts +// the prepare-only branch in finalizeLaunch updates executors_running.status +// from the lifecycle-manager default "starting" to "prepared", so the row +// doesn't look stuck mid-launch on a session that's actually ready by design. +func TestLaunchPreparedSession_WorkspaceOnly_FlipsExecutorRunningStatus(t *testing.T) { + repo := newMockRepository() + + session := &models.TaskSession{ + ID: "session-123", + TaskID: "task-123", + AgentProfileID: "profile-123", + State: models.TaskSessionStateCreated, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.sessions[session.ID] = session + + // Seed the executors_running row the way production's lifecycle manager + // would after LaunchAgent — status="starting" until something flips it. + repo.executorsRunning[session.ID] = &models.ExecutorRunning{ + SessionID: session.ID, + TaskID: session.TaskID, + Status: models.ExecutorRunningStatusStarting, + } + + agentManager := &mockAgentManager{ + launchAgentFunc: func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { + return &LaunchAgentResponse{ + AgentExecutionID: "exec-123", + ContainerID: "container-123", + Status: v1.AgentStatusStarting, + }, nil + }, + } + + executor := newTestExecutor(t, agentManager, repo) + task := &v1.Task{ID: "task-123", WorkspaceID: "workspace-123"} + + _, err := executor.LaunchPreparedSession(context.Background(), task, "session-123", LaunchOptions{AgentProfileID: "profile-123", StartAgent: false}) + if err != nil { + t.Fatalf("LaunchPreparedSession(startAgent=false) failed: %v", err) + } + + got := repo.executorsRunning["session-123"].Status + if got != models.ExecutorRunningStatusPrepared { + t.Errorf("executors_running.status = %q, want %q", got, models.ExecutorRunningStatusPrepared) + } +} + func TestLaunchPreparedSession_ExistingWorkspace_StartAgent(t *testing.T) { repo := newMockRepository() diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 8b48fdfd7c..3f7b9f9f94 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -163,6 +163,7 @@ type sessionExecutorStore interface { DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error HasExecutorRunningRow(ctx context.Context, sessionID string) (bool, error) UpdateResumeToken(ctx context.Context, sessionID, expectedExecID, resumeToken, lastMessageUUID string) error + UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error // Executor GetExecutor(ctx context.Context, id string) (*models.Executor, error) // Task diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 3146f1c607..c32dbf14d6 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -365,6 +365,9 @@ func (m *mockRepository) HasExecutorRunningRow(ctx context.Context, sessionID st func (m *mockRepository) UpdateResumeToken(ctx context.Context, sessionID, expectedExecID, resumeToken, lastMessageUUID string) error { return nil } +func (m *mockRepository) UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error { + return nil +} func (m *mockRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { return nil } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 8065149b3b..37aa0b22f7 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -28,6 +28,15 @@ var ErrExecutorNotFound = errors.New("executor not found") // will produce its own events. var ErrExecutionRotated = errors.New("execution rotated; CAS write rejected") +// Status values for executors_running.status. The lifecycle manager defaults +// rows to "starting" on creation (see runtime/lifecycle/persistence.go); the +// orchestrator flips a row to "prepared" when a prepare-only launch finishes +// with the agent process intentionally not started. +const ( + ExecutorRunningStatusStarting = "starting" + ExecutorRunningStatusPrepared = "prepared" +) + // ListMessagesOptions defines pagination options for listing messages type ListMessagesOptions struct { Limit int diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index caa5b82aa6..d57ab1ccc5 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -212,6 +212,11 @@ type ExecutorRepository interface { // and writes nothing. Use when persisting state from a specific execution that may have been // replaced concurrently — typically resume tokens emitted by ACP session events. UpdateResumeToken(ctx context.Context, sessionID, expectedExecID, resumeToken, lastMessageUUID string) error + // UpdateExecutorRunningStatus performs a narrow status update on the row. + // Used when the agent process is intentionally not being started (prepare-only + // launch) so the row doesn't sit on the misleading default "starting" forever. + // Returns models.ErrExecutorRunningNotFound if no row exists for the session. + UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error } // EnvironmentRepository handles environment CRUD. diff --git a/apps/backend/internal/task/repository/sqlite/executor.go b/apps/backend/internal/task/repository/sqlite/executor.go index f72688c153..8f9dbc0660 100644 --- a/apps/backend/internal/task/repository/sqlite/executor.go +++ b/apps/backend/internal/task/repository/sqlite/executor.go @@ -411,6 +411,28 @@ func (r *Repository) UpdateResumeToken(ctx context.Context, sessionID, expectedE return nil } +// UpdateExecutorRunningStatus narrowly updates the status column. +// Returns ErrExecutorRunningNotFound when no row exists for the session. +func (r *Repository) UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error { + if sessionID == "" { + return fmt.Errorf("session_id is required") + } + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE executors_running + SET status = ?, updated_at = ? + WHERE session_id = ? + `), status, now, sessionID) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID) + } + return nil +} + func (r *Repository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) { var exists int err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` diff --git a/apps/web/components/session/prepare-progress.test.tsx b/apps/web/components/session/prepare-progress.test.tsx index e50ce74fc7..9e2d4da24d 100644 --- a/apps/web/components/session/prepare-progress.test.tsx +++ b/apps/web/components/session/prepare-progress.test.tsx @@ -1,10 +1,12 @@ import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Message } from "@/lib/types/http"; import type { PrepareStepInfo } from "@/lib/state/slices/session-runtime/types"; let mockSteps: PrepareStepInfo[] = []; let mockPrepareStatus: "preparing" | "completed" | "failed" = "preparing"; let mockSessionState: string = "STARTING"; +let mockMessages: Message[] = []; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: Record) => unknown) => @@ -29,13 +31,21 @@ vi.mock("@/components/state-provider", () => ({ sessionAgentctl: { itemsBySessionId: {}, }, + messages: { + bySession: { + "session-1": mockMessages, + }, + }, }), })); import { PrepareProgress } from "./prepare-progress"; describe("PrepareProgress", () => { - afterEach(() => cleanup()); + afterEach(() => { + cleanup(); + mockMessages = []; + }); it("hides skipped steps that have no useful details", () => { mockPrepareStatus = "preparing"; @@ -118,3 +128,59 @@ describe("PrepareProgress", () => { expect(screen.queryByText("Environment prepared on a fresh sandbox")).toBeNull(); }); }); + +function makeSetupScriptMessage(overrides: Partial = {}): Message { + return { + id: "msg-1", + task_id: "task-1" as Message["task_id"], + session_id: "session-1" as Message["session_id"], + author_type: "agent", + content: "Installing deps...\nDone.", + type: "script_execution", + created_at: "2026-05-27T19:06:51Z", + metadata: { + script_type: "setup", + command: "make install", + status: "exited", + exit_code: 0, + started_at: "2026-05-27T19:06:51Z", + completed_at: "2026-05-27T19:06:54Z", + ...overrides, + }, + }; +} + +describe("PrepareProgress per-repo setup script", () => { + afterEach(() => { + cleanup(); + mockMessages = []; + }); + + it("renders the per-repo setup script as a step inside the panel", () => { + // `preparing` keeps the panel auto-expanded so step rows render. + mockPrepareStatus = "preparing"; + mockSessionState = "STARTING"; + mockSteps = [ + { name: "Validate repository", status: "completed" }, + { name: "Create worktree", status: "completed" }, + ]; + mockMessages = [makeSetupScriptMessage()]; + + render(); + + expect(screen.getByText("Run repository setup script")).toBeTruthy(); + expect(screen.getByText("make install")).toBeTruthy(); + }); + + it("marks a failed setup script as a failed step with an error message", () => { + // `failed` keeps the panel auto-expanded. + mockPrepareStatus = "failed"; + mockSessionState = "FAILED"; + mockSteps = [{ name: "Create worktree", status: "completed" }]; + mockMessages = [makeSetupScriptMessage({ exit_code: 2 })]; + + render(); + + expect(screen.getByText("Script exited with code 2")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/session/prepare-progress.tsx b/apps/web/components/session/prepare-progress.tsx index 00c27e03a5..ade53476d3 100644 --- a/apps/web/components/session/prepare-progress.tsx +++ b/apps/web/components/session/prepare-progress.tsx @@ -14,16 +14,14 @@ import { useAppStore } from "@/components/state-provider"; import { ExpandableRow } from "@/components/task/chat/messages/expandable-row"; import { isFallbackNoticeStep } from "@/lib/prepare/summarize"; import { cn } from "@/lib/utils"; +import { stripAnsi } from "@/lib/utils/ansi"; +import { isSetupScriptMessage } from "@/hooks/use-processed-messages"; +import type { Message } from "@/lib/types/http"; import type { PrepareStepInfo, SessionPrepareState, } from "@/lib/state/slices/session-runtime/types"; -const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; -function stripAnsi(s: string): string { - return s.replace(ANSI_RE, ""); -} - function formatStepDuration(startedAt?: string, endedAt?: string): string | null { if (!startedAt || !endedAt) return null; const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime(); @@ -316,6 +314,64 @@ function isVisibleStep(step: PrepareStepInfo): boolean { return step.name.trim() !== "" || hasStepDetails(step); } +type ScriptStatus = "starting" | "running" | "exited" | "failed"; + +// Map the script_execution status vocabulary (starting/running/exited/failed) +// onto the PrepareStep status vocabulary (running/completed/failed). +function scriptStatusToStepStatus( + status: ScriptStatus | undefined, + exitCode: number | undefined, +): string { + if (status === "starting" || status === "running") return "running"; + if (status === "failed") return "failed"; + if (status === "exited") { + if (exitCode === undefined || exitCode === 0) return "completed"; + return "failed"; + } + return status ?? "running"; +} + +type ScriptMetadata = { + script_type?: string; + command?: string; + status?: ScriptStatus; + exit_code?: number; + error?: string; + started_at?: string; + completed_at?: string; +}; + +// Convert a setup-script `script_execution` message into the same step shape +// the existing prepare panel renders, so it slots in alongside Validate/Sync/ +// Create-worktree without a parallel render path. +function setupScriptMessageToStep(message: Message): PrepareStepInfo { + const meta = (message.metadata ?? {}) as ScriptMetadata; + const command = meta.command ?? ""; + const status = scriptStatusToStepStatus(meta.status, meta.exit_code); + const stepError = + status === "failed" + ? meta.error || + (meta.exit_code !== undefined && meta.exit_code !== 0 + ? `Script exited with code ${meta.exit_code}` + : "Script failed") + : undefined; + return { + name: "Run repository setup script", + command, + status, + output: message.content || undefined, + error: stepError, + startedAt: meta.started_at, + endedAt: meta.completed_at, + }; +} + +function useSetupScriptSteps(sessionId: string): PrepareStepInfo[] { + const messages = useAppStore((state) => state.messages.bySession[sessionId]); + if (!messages || messages.length === 0) return []; + return messages.filter(isSetupScriptMessage).map(setupScriptMessageToStep); +} + function SessionInfo({ sessionId }: { sessionId: string }) { const session = useAppStore((state) => state.taskSessions.items[sessionId]); if (!session) return null; @@ -358,6 +414,7 @@ function SessionInfo({ sessionId }: { sessionId: string }) { export function PrepareProgress({ sessionId }: PrepareProgressProps) { const { status, prepareState } = usePrepareStatus(sessionId); const session = useAppStore((state) => state.taskSessions.items[sessionId]); + const setupScriptSteps = useSetupScriptSteps(sessionId); // Only the clean-success status auto-collapses. Anything with running/error/ // warning context stays open by default so the user sees what's happening. const autoExpand = status !== "completed"; @@ -366,7 +423,10 @@ export function PrepareProgress({ sessionId }: PrepareProgressProps) { if (!prepareState) return null; const hasSessionInfo = Boolean(session?.worktree_path || session?.worktree_branch); - const visibleSteps = prepareState.steps.filter(isVisibleStep); + // Per-repo setup scripts run inside worktree.Manager.Create, so visually they + // belong after Create-worktree finishes. Appending keeps the existing prepare + // step order intact. + const visibleSteps = [...prepareState.steps.filter(isVisibleStep), ...setupScriptSteps]; const headerLabel = getHeaderLabel(status, prepareState); const isErrorStatus = status === "failed" || status === "completed_with_error"; const headerClass = cn("text-xs", isErrorStatus ? "text-destructive" : "text-muted-foreground"); diff --git a/apps/web/components/task/chat/messages/script-execution-message.tsx b/apps/web/components/task/chat/messages/script-execution-message.tsx index 7cc12efff0..dc897af71e 100644 --- a/apps/web/components/task/chat/messages/script-execution-message.tsx +++ b/apps/web/components/task/chat/messages/script-execution-message.tsx @@ -4,6 +4,7 @@ import { memo } from "react"; import { IconCheck, IconX, IconTerminal } from "@tabler/icons-react"; import { GridSpinner } from "@/components/grid-spinner"; import type { Message } from "@/lib/types/http"; +import { stripAnsi } from "@/lib/utils/ansi"; import { Badge } from "@kandev/ui/badge"; import { ExpandableRow } from "./expandable-row"; import { useExpandState } from "./use-expand-state"; @@ -167,7 +168,7 @@ function ScriptExpandedContent({ Output
-            {content}
+            {stripAnsi(content)}
           
)} @@ -230,7 +231,7 @@ export const ScriptExecutionMessage = memo(function ScriptExecutionMessage({ {comment.content && (
-              {comment.content}
+              {stripAnsi(comment.content)}
             
)} diff --git a/apps/web/hooks/use-processed-messages.test.ts b/apps/web/hooks/use-processed-messages.test.ts index 60a7a391a6..51f71c9369 100644 --- a/apps/web/hooks/use-processed-messages.test.ts +++ b/apps/web/hooks/use-processed-messages.test.ts @@ -10,6 +10,7 @@ import { collapseTodoSnapshotsPerTurn, deduplicateAgentBootResumes, isAgentBootResumeMessage, + isSetupScriptMessage, } from "./use-processed-messages"; function makeMessage( @@ -83,6 +84,27 @@ describe("isAgentBootResumeMessage", () => { }); }); +describe("isSetupScriptMessage", () => { + it("returns true for a script_execution with script_type=setup", () => { + const msg = makeMessage("x", "script_execution", { script_type: "setup", status: "exited" }); + expect(isSetupScriptMessage(msg)).toBe(true); + }); + + it("returns false for agent_boot and cleanup scripts", () => { + expect(isSetupScriptMessage(bootStarted("s1"))).toBe(false); + const cleanup = makeMessage("c1", "script_execution", { script_type: "cleanup" }); + expect(isSetupScriptMessage(cleanup)).toBe(false); + }); + + it("returns false for non-script messages", () => { + expect(isSetupScriptMessage(makeMessage("m1", "message"))).toBe(false); + }); + + it("returns false when metadata is missing", () => { + expect(isSetupScriptMessage(makeMessage("x", "script_execution"))).toBe(false); + }); +}); + describe("deduplicateAgentBootResumes", () => { it("returns an empty list unchanged", () => { expect(deduplicateAgentBootResumes([])).toEqual([]); diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts index dfbb043c2e..029fa4a622 100644 --- a/apps/web/hooks/use-processed-messages.ts +++ b/apps/web/hooks/use-processed-messages.ts @@ -183,6 +183,18 @@ export function isAgentBootResumeMessage(message: Message): boolean { return meta?.script_type === "agent_boot" && meta?.is_resuming === true; } +/** Per-repo setup scripts (`Repository.setup_script`) run during worktree + * creation and are persisted as `script_execution` rows. They belong + * conceptually to environment preparation, not the chat thread — `PrepareProgress` + * surfaces them as steps inside the env-prep panel. Hiding them from the chat + * prevents the script from rendering above the env-prep panel (which happens + * when the user prompt hasn't been recorded yet, e.g. MCP-auto-started subtasks). */ +export function isSetupScriptMessage(message: Message): boolean { + if (message.type !== "script_execution") return false; + const meta = message.metadata as { script_type?: string } | undefined; + return meta?.script_type === "setup"; +} + /** A resumed session may produce many "Resumed agent …" boot messages over its * lifetime (every backend restart emits one). They all convey the same info; * keep only the most recent and drop the rest — unconditionally, even if user @@ -207,6 +219,7 @@ function filterVisibleMessages( ): Message[] { const filtered = messages.filter((message) => { if (subagentChildIds.has(message.id)) return false; + if (isSetupScriptMessage(message)) return false; if (message.type === "clarification_request") { const metadata = message.metadata as ClarificationRequestMetadata | undefined; return !(!metadata?.status || metadata.status === "pending"); diff --git a/apps/web/lib/utils/ansi.test.ts b/apps/web/lib/utils/ansi.test.ts new file mode 100644 index 0000000000..bc08d26c21 --- /dev/null +++ b/apps/web/lib/utils/ansi.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { stripAnsi } from "./ansi"; + +describe("stripAnsi", () => { + it("returns input unchanged when there are no escape sequences", () => { + expect(stripAnsi("hello world")).toBe("hello world"); + }); + + it("strips SGR colour codes", () => { + const input = "\x1b[36mInstalling backend dependencies...\x1b[0m"; + expect(stripAnsi(input)).toBe("Installing backend dependencies..."); + }); + + it("strips bold + colour wrapped output across multiple lines", () => { + const input = + "\x1b[36mInstalling deps...\x1b[0m\nDownloading...\n\x1b[32m\x1b[1m✓ All dependencies installed!\x1b[0m"; + expect(stripAnsi(input)).toBe( + "Installing deps...\nDownloading...\n✓ All dependencies installed!", + ); + }); + + it("handles empty string", () => { + expect(stripAnsi("")).toBe(""); + }); + + it("strips OSC hyperlinks terminated by ST", () => { + const input = "click \x1b]8;;https://example.com\x1b\\here\x1b]8;;\x1b\\!"; + expect(stripAnsi(input)).toBe("click here!"); + }); + + it("strips OSC window-title sequences terminated by BEL", () => { + const input = "\x1b]0;my-shell\x07ready"; + expect(stripAnsi(input)).toBe("ready"); + }); + + it("strips CSI private-prefix sequences (hide cursor, bracketed paste)", () => { + expect(stripAnsi("before\x1b[?25lafter")).toBe("beforeafter"); + expect(stripAnsi("\x1b[?2004hpaste\x1b[?2004l")).toBe("paste"); + }); +}); diff --git a/apps/web/lib/utils/ansi.ts b/apps/web/lib/utils/ansi.ts new file mode 100644 index 0000000000..04f644cec8 --- /dev/null +++ b/apps/web/lib/utils/ansi.ts @@ -0,0 +1,17 @@ +// ANSI escape sequences. Covers: +// - CSI sequences per ECMA-48: `ESC [ param* intermediate* final` where +// param bytes are 0x30-0x3F (digits + `:;<=>?`), intermediate bytes are +// 0x20-0x2F (space + `!"#$%&'()*+,-./`), and final byte is 0x40-0x7E. +// This catches both vanilla SGR (`\x1b[36m`, `\x1b[0m`) and private-prefix +// sequences like `\x1b[?25l` (hide cursor) and `\x1b[?2004h` (bracketed +// paste) that tools like pnpm and Playwright emit. The narrower +// `[0-9;]*` pattern used to let those leak through and render as +// literal `[?25l` noise. +// - OSC sequences (hyperlinks, window title) terminated by ST or BEL: +// `\x1b]8;;url\x1b\\` or `\x1b]0;title\x07`. npm, pnpm, yarn and some +// make targets routinely emit the hyperlink variant. +const ANSI_RE = /\x1b(?:\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g; + +export function stripAnsi(s: string): string { + return s.replace(ANSI_RE, ""); +}