fix: improve UX for subtasks with repo setup scripts (#1105)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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<string, unknown>) => 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["metadata"] & object> = {}): 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(<PrepareProgress sessionId="session-1" />);
|
||||
|
||||
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(<PrepareProgress sessionId="session-1" />);
|
||||
|
||||
expect(screen.getByText("Script exited with code 2")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
<pre className="font-mono text-xs bg-muted/30 rounded px-3 py-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
{stripAnsi(content)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -230,7 +231,7 @@ export const ScriptExecutionMessage = memo(function ScriptExecutionMessage({
|
||||
{comment.content && (
|
||||
<div className="pl-4 border-l-2 border-border/30">
|
||||
<pre className="font-mono text-xs bg-muted/30 rounded px-3 py-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap break-words">
|
||||
{comment.content}
|
||||
{stripAnsi(comment.content)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, "");
|
||||
}
|
||||
Reference in New Issue
Block a user