fix: recover failed task launches safely (#3437)

This commit is contained in:
Carlos Florêncio
2026-09-07 11:19:09 +01:00
committed by GitHub
parent 3c6af41063
commit 3fba828e2f
69 changed files with 2280 additions and 277 deletions
@@ -312,6 +312,8 @@ type ChatInputAreaProps = {
onRequestChangesTooltipDismiss?: () => void;
panelState: ChatPanelState;
isSending: boolean;
/** The task-owned launch card renders recovery for the failed start. */
launchErrorOwned?: boolean;
hideSessionsDropdown?: boolean;
minimalToolbar?: boolean;
/** Hide ACP/session-specific controls (model picker, mode, MCP, reset context,
@@ -428,6 +430,7 @@ export function ChatInputArea({
onScrollToStart,
statusTaskId = null,
showAgentStartHint = false,
launchErrorOwned = false,
}: ChatInputAreaProps) {
const { resolvedSessionId, taskId, isAgentBusy } = panelState;
const statusRowTaskId = resolveStatusRowTaskId(taskId, statusTaskId);
@@ -456,6 +459,7 @@ export function ChatInputArea({
minimalToolbar,
hideAgentControls,
hidePlanMode,
launchErrorOwned,
});
return (
<div
@@ -0,0 +1,112 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatInputContainer } from "./chat-input-container";
const { containerState } = vi.hoisted(() => ({
containerState: {
showNewSessionDialog: false,
setShowNewSessionDialog: vi.fn(),
contextPopoverOpen: false,
setContextPopoverOpen: vi.fn(),
height: "auto",
containerRef: { current: null },
resizeHandleProps: { onMouseDown: vi.fn(), onDoubleClick: vi.fn() },
value: "",
inputRef: { current: null },
addFiles: vi.fn().mockResolvedValue(undefined),
fileInputRef: { current: null },
handleChange: vi.fn(),
handleSubmitWithReset: vi.fn(),
allItems: [],
hasPendingAttachmentUploads: false,
isInputFocused: false,
setIsInputFocused: vi.fn(),
hasClarification: false,
hasPendingComments: false,
hasContextZone: false,
showFocusHint: false,
inputPlaceholder: "Ask to make changes",
isDisabled: false,
submitDisabled: false,
submitDisabledReason: undefined,
},
}));
vi.mock("./use-chat-input-container", () => ({
useChatInputContainer: () => containerState,
}));
vi.mock("./session-stopped-banner", () => ({
SessionStoppedBanner: () => <div data-testid="session-stopped-banner" />,
}));
vi.mock("./chat-input-body", () => ({
ChatInputBody: () => <div data-testid="chat-input-body" />,
}));
vi.mock("@/hooks/domains/session/use-session-recovery-actions", () => ({
useSessionRecoveryActions: () => ({}),
}));
vi.mock("@/hooks/use-is-utility-configured", () => ({
useIsUtilityConfigured: () => false,
}));
vi.mock("@/hooks/use-utility-agent-generator", () => ({
useUtilityAgentGenerator: () => ({
enhancePrompt: vi.fn(),
isEnhancingPrompt: false,
}),
}));
vi.mock("@/hooks/use-prompt-result-delivery", () => ({
usePromptResultDelivery: () => ({
pendingResult: null,
captureScope: vi.fn(),
deliver: vi.fn(),
applyPending: vi.fn(),
copyPending: vi.fn(),
}),
}));
vi.mock("@/lib/i18n", () => ({
t: (key: string) => key,
}));
const baseProps = {
onSubmit: vi.fn(),
sessionId: "session-1",
taskId: "task-1",
taskDescription: "",
planModeEnabled: false,
onPlanModeChange: vi.fn(),
isAgentBusy: false,
isStarting: false,
isSending: false,
onCancel: vi.fn(),
};
afterEach(() => cleanup());
describe("ChatInputContainer launch-error ownership", () => {
it("hides the editor when the task launch card owns a failed session", () => {
render(<ChatInputContainer {...baseProps} isFailed launchErrorOwned />);
expect(screen.queryByTestId("chat-input-body")).toBeNull();
expect(screen.queryByTestId("session-stopped-banner")).toBeNull();
});
it("renders the stopped banner when the failed session has no launch-card owner", () => {
render(<ChatInputContainer {...baseProps} isFailed />);
expect(screen.queryByTestId("chat-input-body")).toBeNull();
expect(screen.getByTestId("session-stopped-banner")).toBeTruthy();
});
it("keeps the editor visible when the owned launch error is not a failed session", () => {
render(<ChatInputContainer {...baseProps} launchErrorOwned />);
expect(screen.getByTestId("chat-input-body")).toBeTruthy();
expect(screen.queryByTestId("session-stopped-banner")).toBeNull();
});
});
@@ -21,6 +21,7 @@ import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { usePromptResultDelivery } from "@/hooks/use-prompt-result-delivery";
import { PromptResultRecovery } from "@/components/prompt-result-recovery";
import { t } from "@/lib/i18n";
import { shouldHideChatInputForLaunchError, shouldRenderStoppedSessionBanner } from "./types";
// Re-export ImageAttachment type for consumers
export type { ImageAttachment } from "./image-attachment-preview";
@@ -96,6 +97,8 @@ type ChatInputContainerProps = {
isCompleted?: boolean;
sessionErrorMessage?: string;
needsRecovery?: boolean;
/** The task-owned launch card renders the failed-start recovery. */
launchErrorOwned?: boolean;
executorUnavailable?: boolean;
executorUnavailableReason?: string;
contextItems?: ContextItem[];
@@ -287,6 +290,13 @@ function useChatPromptEnhancement({
return { handleEnhancePrompt, isEnhancingPrompt, isUtilityConfigured, promptDelivery };
}
function useChatInputRecoveryActions(taskId: string | null, sessionId: string | null) {
return useSessionRecoveryActions({
taskId: taskId ?? "",
sessionId: sessionId ?? "",
});
}
export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInputContainerProps>(
// eslint-disable-next-line complexity -- top-level component chooses the stopped or editor surface after shared hook setup.
function ChatInputContainer(props, ref) {
@@ -322,10 +332,7 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
onSubmit: props.onSubmit,
});
const recoveryActions = useSessionRecoveryActions({
taskId: taskId ?? "",
sessionId: sessionId ?? "",
});
const recoveryActions = useChatInputRecoveryActions(taskId, sessionId);
const promptEnhancement = useChatPromptEnhancement({
inputRef: s.inputRef,
@@ -335,7 +342,23 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
taskDescription,
});
if (p.isFailed || p.isCompleted || executorUnavailable) {
if (
shouldHideChatInputForLaunchError({
isFailed: p.isFailed,
launchErrorOwned: p.launchErrorOwned,
})
) {
return null;
}
if (
shouldRenderStoppedSessionBanner({
isFailed: p.isFailed,
isCompleted: p.isCompleted,
executorUnavailable,
launchErrorOwned: p.launchErrorOwned,
})
) {
return (
<SessionStoppedBanner
mode={p.isCompleted ? "completed" : "recoverable"}
@@ -15,7 +15,9 @@ vi.mock("@/components/task/chat/message-renderer", () => ({
import { MessageListFooter } from "./message-list-footer";
const AGENT_STATUS_TEST_ID = "agent-status";
const ACTION_MESSAGE_TEST_ID = "action-message";
const SESSION_ID = "session-1";
const FAILED_SESSION_STATE = "FAILED" as const;
afterEach(cleanup);
@@ -45,23 +47,30 @@ const laterFailure = {
},
} satisfies Message;
// eslint-disable-next-line max-lines-per-function -- footer ownership cases share one focused fixture.
describe("MessageListFooter", () => {
it("lets an actionable footer failure own the failure presentation", () => {
render(
<MessageListFooter
sessionState="FAILED"
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
footerActionMessages={[actionableFailure]}
/>,
);
expect(screen.getByTestId("action-message")).toBeTruthy();
expect(screen.getByTestId(ACTION_MESSAGE_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(AGENT_STATUS_TEST_ID)).toBeNull();
});
it("keeps the generic status for a failed session without an actionable footer", () => {
render(<MessageListFooter sessionState="FAILED" sessionId={SESSION_ID} messages={[]} />);
render(
<MessageListFooter
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
/>,
);
expect(screen.getByTestId(AGENT_STATUS_TEST_ID)).toBeTruthy();
});
@@ -81,13 +90,17 @@ describe("MessageListFooter", () => {
it("switches ownership when missing-branch recovery arrives after failure", () => {
const { rerender } = render(
<MessageListFooter sessionState="FAILED" sessionId={SESSION_ID} messages={[]} />,
<MessageListFooter
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
/>,
);
expect(screen.getByTestId(AGENT_STATUS_TEST_ID)).toBeTruthy();
rerender(
<MessageListFooter
sessionState="FAILED"
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
footerActionMessages={[actionableFailure]}
@@ -95,13 +108,13 @@ describe("MessageListFooter", () => {
);
expect(screen.queryByTestId(AGENT_STATUS_TEST_ID)).toBeNull();
expect(screen.getByTestId("action-message")).toBeTruthy();
expect(screen.getByTestId(ACTION_MESSAGE_TEST_ID)).toBeTruthy();
});
it("does not restore stale missing-branch recovery after a later failure", () => {
render(
<MessageListFooter
sessionState="FAILED"
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[actionableFailure, laterFailure]}
footerActionMessages={[actionableFailure]}
@@ -109,6 +122,56 @@ describe("MessageListFooter", () => {
);
expect(screen.getByTestId(AGENT_STATUS_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId("action-message")).toBeNull();
expect(screen.queryByTestId(ACTION_MESSAGE_TEST_ID)).toBeNull();
});
it("hides launch failure footer surfaces when the task card owns the error", () => {
render(
<MessageListFooter
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
footerActionMessages={[actionableFailure]}
launchErrorOwned
/>,
);
expect(screen.queryByTestId(AGENT_STATUS_TEST_ID)).toBeNull();
expect(screen.queryByTestId(ACTION_MESSAGE_TEST_ID)).toBeNull();
});
it("keeps stale launch failure footer history when the current card owns the error", () => {
const staleFailure = {
...actionableFailure,
id: "failure-stale",
content: "Stale branch recovery",
metadata: {
...actionableFailure.metadata,
launch_error_stamp: "old-stamp",
},
} satisfies Message;
const currentFailure = {
...actionableFailure,
id: "failure-current",
content: "Current branch recovery",
metadata: {
...actionableFailure.metadata,
launch_error_stamp: "current-stamp",
},
} satisfies Message;
render(
<MessageListFooter
sessionState={FAILED_SESSION_STATE}
sessionId={SESSION_ID}
messages={[]}
footerActionMessages={[staleFailure, currentFailure]}
launchErrorOwned
launchErrorStamp="current-stamp"
/>,
);
expect(screen.getByText("Stale branch recovery")).toBeTruthy();
expect(screen.queryByText("Current branch recovery")).toBeNull();
});
});
@@ -3,6 +3,7 @@
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 { filterLaunchErrorMessages } from "./message-list-shared";
type MessageListFooterProps = {
sessionState?: TaskSessionState;
@@ -10,6 +11,12 @@ type MessageListFooterProps = {
messages: Message[];
isWorking?: boolean;
footerActionMessages?: Message[];
/** The task-owned launch card is rendering the current failure. */
launchErrorOwned?: boolean;
/** Stamp used to keep historical launch-only footer rows visible. */
launchErrorStamp?: string;
/** Timestamp used to identify unstamped synthetic rows from the active failure. */
launchErrorOccurredAt?: string;
};
function isMissingBranchFailure(message: Message): boolean {
@@ -45,18 +52,25 @@ export function MessageListFooter({
messages,
isWorking,
footerActionMessages = [],
launchErrorOwned = false,
launchErrorStamp,
launchErrorOccurredAt,
}: MessageListFooterProps) {
const currentActionableFailure = findCurrentActionableFailure(messages, footerActionMessages);
const recoveryOwnsFailure =
!launchErrorOwned &&
sessionState === "FAILED" &&
currentActionableFailure !== undefined &&
isMissingBranchFailure(currentActionableFailure);
const visibleFooterActionMessages = footerActionMessages.filter(
(message) => !isMissingBranchFailure(message) || message.id === currentActionableFailure?.id,
);
const visibleFooterActionMessages = launchErrorOwned
? filterLaunchErrorMessages(footerActionMessages, true, launchErrorStamp, launchErrorOccurredAt)
: footerActionMessages.filter(
(message) =>
!isMissingBranchFailure(message) || message.id === currentActionableFailure?.id,
);
return (
<>
{!recoveryOwnsFailure && (
{!recoveryOwnsFailure && !(launchErrorOwned && sessionState === "FAILED") && (
<AgentStatus
sessionState={sessionState}
sessionId={sessionId}
@@ -25,6 +25,8 @@ import {
getLastTurnGroupId,
getStreamingAgentMessageId,
canReassertDividerScroll,
filterLaunchErrorItems,
filterLaunchErrorMessages,
resolveLastPromptEdge,
isElementFullyVisible,
} from "./message-list-shared";
@@ -271,6 +273,9 @@ type NativeMessageListBodyProps = {
onScrollToMessage: (messageId: string, options?: { align?: "start" | "center" }) => void;
autoScrollEnabled: boolean;
dividerBeforeItemKey?: string | null;
launchErrorOwned: boolean;
launchErrorStamp?: string;
launchErrorOccurredAt?: string;
};
/**
@@ -474,6 +479,9 @@ function NativeMessageListBody({
onScrollToMessage,
autoScrollEnabled,
dividerBeforeItemKey,
launchErrorOwned,
launchErrorStamp,
launchErrorOccurredAt,
}: NativeMessageListBodyProps) {
return (
<div className="p-4">
@@ -515,6 +523,9 @@ function NativeMessageListBody({
messages={messages}
isWorking={isWorking}
footerActionMessages={footerActionMessages}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorStamp}
launchErrorOccurredAt={launchErrorOccurredAt}
/>
{/* Bottom anchor keeps the view pinned while auto-scroll is enabled.
@@ -558,14 +569,43 @@ export const NativeMessageList = memo(
dividerBeforeItemKey,
anchoredBarHeight,
isVisible = true,
launchErrorOwned = false,
launchErrorStamp,
launchErrorOccurredAt,
}: MessageListProps,
ref,
) {
const scrollRef = useRef<HTMLDivElement>(null);
const visibleItems = useMemo(
() =>
filterLaunchErrorItems(items, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt),
[items, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt],
);
const visibleMessages = useMemo(
() =>
filterLaunchErrorMessages(
messages,
launchErrorOwned,
launchErrorStamp,
launchErrorOccurredAt,
),
[messages, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt],
);
const visibleFooterActionMessages = useMemo(
() =>
filterLaunchErrorMessages(
footerActionMessages ?? [],
launchErrorOwned,
launchErrorStamp,
launchErrorOccurredAt,
),
[footerActionMessages, launchErrorOwned, launchErrorStamp, launchErrorOccurredAt],
);
const { isInitialLoading, showLoadingState } = getConversationLoadingState({
messagesLoading,
messagesCount: messages.length,
messagesCount: visibleMessages.length,
isWorking,
sessionState,
});
@@ -574,15 +614,15 @@ export const NativeMessageList = memo(
});
const { activeTurnId } = useSessionTurn(sessionId);
const effectiveActiveTurnId = getEffectiveActiveTurnId(activeTurnId, isWorking);
const streamingMessageId = getStreamingAgentMessageId(messages);
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]);
const streamingMessageId = getStreamingAgentMessageId(visibleMessages);
const lastTurnGroupId = useMemo(() => getLastTurnGroupId(visibleItems), [visibleItems]);
const autoScrollEnabled = useTranscriptAutoScrollEnabled(sessionId);
const { handleScrollToMessage, sentinelRef, retryLoadMore, showRecovery } =
useNativeMessageListScroll({
scrollRef,
ref,
items,
messages,
items: visibleItems,
messages: visibleMessages,
isWorking,
sessionId,
enabled: autoScrollEnabled,
@@ -617,9 +657,9 @@ export const NativeMessageList = memo(
>
{stickyPromptBar}
<NativeMessageListBody
items={items}
messages={messages}
footerActionMessages={footerActionMessages}
items={visibleItems}
messages={visibleMessages}
footerActionMessages={visibleFooterActionMessages}
permissionsByToolCallId={permissionsByToolCallId}
childrenByParentToolCallId={childrenByParentToolCallId}
taskId={taskId}
@@ -642,6 +682,9 @@ export const NativeMessageList = memo(
onScrollToMessage={handleScrollToMessage}
autoScrollEnabled={autoScrollEnabled}
dividerBeforeItemKey={dividerBeforeItemKey}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorStamp}
launchErrorOccurredAt={launchErrorOccurredAt}
/>
</SessionPanelContent>
);
@@ -93,6 +93,8 @@ import {
getLastUserMessageId,
getFirstUserMessageId,
isElementFullyVisible,
filterLaunchErrorItems,
filterLaunchErrorMessages,
resolveLastPromptControls,
resolveLastPromptEdge,
shouldAutoScrollToBottom,
@@ -131,6 +133,106 @@ describe("getEffectiveActiveTurnId", () => {
});
});
describe("launch error-owned transcript filtering", () => {
it("removes launch-only messages only when the task card owns the error", () => {
const emptyTurn = {
id: "empty-turn-1",
metadata: { empty_turn: true },
} as unknown as Message;
const runtimeFailure = {
id: "runtime-failure-1",
metadata: { failure_kind: "provider_quota_limited" },
} as unknown as Message;
expect(filterLaunchErrorMessages([emptyTurn, runtimeFailure], false)).toEqual([
emptyTurn,
runtimeFailure,
]);
expect(filterLaunchErrorMessages([emptyTurn, runtimeFailure], true)).toEqual([runtimeFailure]);
});
it("removes preparation and previous-agent rows while retaining transcript messages", () => {
const runtimeMessage = { id: "message-1" } as unknown as Message;
const items: RenderItem[] = [
{ type: "prepare_progress", id: "prepare-1", sessionId: "s1" },
{
type: "agent_error_notice",
id: "agent-error-1",
sessionId: "s1",
error: { message: "matching error", stamp: "stamp-1" },
},
{
type: "agent_error_notice",
id: "agent-error-2",
sessionId: "s1",
error: { message: "unrelated error", stamp: "old-stamp" },
},
{
type: "message",
message: {
id: "empty-1",
created_at: "2026-06-14T12:00:01Z",
metadata: { empty_turn: true },
} as unknown as Message,
},
{ type: "message", message: runtimeMessage },
];
expect(filterLaunchErrorItems(items, true, "stamp-1", "2026-06-14T12:00:00Z")).toEqual([
{
type: "agent_error_notice",
id: "agent-error-2",
sessionId: "s1",
error: { message: "unrelated error", stamp: "old-stamp" },
},
{ type: "message", message: runtimeMessage },
]);
});
it("keeps stale launch-only history when the current failure has no carried stamp", () => {
const staleEmptyTurn = {
id: "empty-stale",
created_at: "2026-07-21T00:00:00Z",
metadata: { empty_turn: true },
} as unknown as Message;
const currentEmptyTurn = {
id: "empty-current",
created_at: "2026-07-22T00:00:00Z",
metadata: { empty_turn: true },
} as unknown as Message;
expect(filterLaunchErrorMessages([staleEmptyTurn, currentEmptyTurn], true)).toEqual([
staleEmptyTurn,
]);
expect(
filterLaunchErrorItems(
[
{ type: "message", message: staleEmptyTurn },
{ type: "message", message: currentEmptyTurn },
],
true,
"current-stamp",
"2026-07-22T00:00:00Z",
),
).toEqual([{ type: "message", message: staleEmptyTurn }]);
});
it("filters only launch-only rows carrying the active failure stamp", () => {
const staleMissingBranch = {
id: "missing-stale",
metadata: { failure_kind: "missing_pr_branch", launch_error_stamp: "old-stamp" },
} as unknown as Message;
const currentMissingBranch = {
id: "missing-current",
metadata: { failure_kind: "missing_pr_branch", launch_error_stamp: "current-stamp" },
} as unknown as Message;
expect(
filterLaunchErrorMessages([staleMissingBranch, currentMissingBranch], true, "current-stamp"),
).toEqual([staleMissingBranch]);
});
});
describe("canReassertDividerScroll", () => {
it("never applies without a divider target", () => {
expect(
@@ -17,6 +17,7 @@ import {
lastAgentErrorStamp,
readLastAgentError,
} from "@/lib/session-last-agent-error";
import { isLaunchErrorSurfaceMessage } from "./types";
import { useTranslation } from "react-i18next";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
@@ -67,6 +68,12 @@ export type MessageListProps = {
* remain mounted while inactive and use this transition to recover missed
* oldest-page sentinel observations. */
isVisible?: boolean;
/** The task-owned launch card renders the matching failure. */
launchErrorOwned?: boolean;
/** Stamp used to distinguish a matching last-agent-error notice. */
launchErrorStamp?: string;
/** Timestamp used to identify unstamped synthetic rows from the active failure. */
launchErrorOccurredAt?: string;
};
/** Imperative handle exposed by `MessageList`, letting the chat panel scroll
@@ -91,6 +98,86 @@ export function getItemKey(item: RenderItem): string {
return item.message.id;
}
const LAUNCH_ERROR_STAMP_KEYS = ["launch_error_stamp", "error_stamp"] as const;
function launchErrorSurfaceStamp(message: Message): string | undefined {
const metadata = message.metadata as Record<string, unknown> | undefined;
for (const key of LAUNCH_ERROR_STAMP_KEYS) {
const value = metadata?.[key];
if (typeof value === "string" && value.length > 0) return value;
}
return undefined;
}
function launchErrorSurfaceMessageIds(
messages: Message[],
launchErrorStamp?: string,
launchErrorOccurredAt?: string,
): Set<string> {
const launchMessages = messages.filter(isLaunchErrorSurfaceMessage);
if (launchMessages.length === 0) return new Set();
const unstampedMessages = launchMessages.filter(
(message) => launchErrorSurfaceStamp(message) === undefined,
);
const hiddenIds = new Set<string>();
if (launchErrorStamp) {
const stampedMatches = launchMessages.filter(
(message) => launchErrorSurfaceStamp(message) === launchErrorStamp,
);
stampedMatches.forEach((message) => hiddenIds.add(message.id));
}
const occurredAt = launchErrorOccurredAt ? Date.parse(launchErrorOccurredAt) : Number.NaN;
if (!Number.isNaN(occurredAt)) {
unstampedMessages.forEach((message) => {
const createdAt = Date.parse(message.created_at);
if (!Number.isNaN(createdAt) && createdAt >= occurredAt) hiddenIds.add(message.id);
});
}
if (hiddenIds.size > 0 || launchErrorStamp || launchErrorOccurredAt) return hiddenIds;
// Older synthetic rows do not carry the launch stamp. Keep historical rows
// intact and hide only the most recent row when no identity is available.
const fallbackMessage = launchMessages[launchMessages.length - 1];
return fallbackMessage ? new Set([fallbackMessage.id]) : new Set();
}
/** Remove the launch-only transcript decorations once the task card owns them. */
export function filterLaunchErrorMessages(
messages: Message[],
launchErrorOwned: boolean,
launchErrorStamp?: string,
launchErrorOccurredAt?: string,
): Message[] {
if (!launchErrorOwned) return messages;
const hiddenIds = launchErrorSurfaceMessageIds(messages, launchErrorStamp, launchErrorOccurredAt);
return messages.filter((message) => !hiddenIds.has(message.id));
}
/** Remove synthetic launch rows while preserving ordinary transcript activity. */
export function filterLaunchErrorItems(
items: RenderItem[],
launchErrorOwned: boolean,
launchErrorStamp?: string,
launchErrorOccurredAt?: string,
): RenderItem[] {
if (!launchErrorOwned) return items;
const hiddenMessageIds = launchErrorSurfaceMessageIds(
items.flatMap((item) => (item.type === "message" ? [item.message] : [])),
launchErrorStamp,
launchErrorOccurredAt,
);
return items.filter((item) => {
if (item.type === "prepare_progress") return false;
if (item.type === "agent_error_notice") {
return !launchErrorStamp || lastAgentErrorStamp(item.error) !== launchErrorStamp;
}
return item.type !== "message" || !hiddenMessageIds.has(item.message.id);
});
}
/** The active turn id, but only while the agent is working — turns no longer
* in progress aren't treated as active. */
export function getEffectiveActiveTurnId(
@@ -1,6 +1,7 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { buildStoppedBannerProps } from "./chat-input-container";
import { shouldHideChatInputForLaunchError, shouldRenderStoppedSessionBanner } from "./types";
import { useComposerProps } from "./use-composer-props";
function composerArgs(errorMessage: string) {
@@ -51,6 +52,20 @@ function composerArgs(errorMessage: string) {
}
describe("stopped-session error propagation", () => {
it("gives the task launch card ownership of a failed session", () => {
expect(
shouldRenderStoppedSessionBanner({
isFailed: true,
isCompleted: false,
executorUnavailable: false,
launchErrorOwned: true,
}),
).toBe(false);
expect(shouldHideChatInputForLaunchError({ isFailed: true, launchErrorOwned: true })).toBe(
true,
);
});
it("threads the sanitized session error from panel state into composer props", () => {
const errorMessage = "pods is forbidden: RBAC denied the request";
const { result } = renderHook(() => useComposerProps(composerArgs(errorMessage)));
+70 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
import { isRichOutputMessage, kandevToolStemOf, type ToolCallMetadata } from "./types";
import {
isLaunchErrorSurfaceMessage,
isMatchingTaskLaunchError,
isTaskLaunchErrorOwnedBySession,
isRichOutputMessage,
kandevToolStemOf,
type ToolCallMetadata,
} from "./types";
function toolCall(rawName: string, field: "tool_name" | "title" | "content" = "title"): Message {
const metadata: Record<string, unknown> = {
@@ -65,3 +72,65 @@ describe("isRichOutputMessage", () => {
);
});
});
describe("task launch error presentation predicates", () => {
it("matches a summary error only by session and stamp", () => {
const activeError = { session_id: "session-1", stamp: "stamp-1" };
expect(
isMatchingTaskLaunchError(activeError, {
sessionId: "session-1",
errorStamp: "stamp-1",
}),
).toBe(true);
expect(
isMatchingTaskLaunchError(activeError, {
sessionId: "session-2",
errorStamp: "stamp-1",
}),
).toBe(false);
expect(
isMatchingTaskLaunchError(activeError, {
sessionId: "session-1",
errorStamp: "old-stamp",
}),
).toBe(false);
});
it("matches task-level errors only when the candidate has no session", () => {
const activeError = { stamp: "stamp-1" };
expect(isMatchingTaskLaunchError(activeError, { errorStamp: "stamp-1" })).toBe(true);
expect(
isMatchingTaskLaunchError(activeError, {
sessionId: "session-1",
errorStamp: "stamp-1",
}),
).toBe(false);
});
it("keeps a task-wide card visible without giving it ownership of a prior session", () => {
const activeError = { stamp: "stamp-1" };
expect(isTaskLaunchErrorOwnedBySession(activeError, null)).toBe(true);
expect(isTaskLaunchErrorOwnedBySession(activeError, undefined)).toBe(true);
expect(isTaskLaunchErrorOwnedBySession(activeError, "prior-session")).toBe(false);
});
it("gives a session-owned error ownership only of its exact session", () => {
const activeError = { session_id: "session-1", stamp: "stamp-1" };
expect(isTaskLaunchErrorOwnedBySession(activeError, "session-1")).toBe(true);
expect(isTaskLaunchErrorOwnedBySession(activeError, null)).toBe(false);
expect(isTaskLaunchErrorOwnedBySession(activeError, "session-2")).toBe(false);
});
it.each([
{ metadata: { empty_turn: true }, expected: true },
{ metadata: { failure_kind: "missing_pr_branch" }, expected: true },
{ metadata: { failure_kind: "provider_quota_limited" }, expected: false },
{ metadata: undefined, expected: false },
])("classifies launch-only message noise", ({ metadata, expected }) => {
expect(isLaunchErrorSurfaceMessage({ id: "message-1", metadata } as never)).toBe(expected);
});
});
+59
View File
@@ -1,6 +1,7 @@
"use client";
import type { Message } from "@/lib/types/http";
import type { TaskStatusSummaryActiveError } from "@/lib/types/task-status-summary";
import { extractKandevStem } from "./messages/kandev/parse";
export type SubagentTaskPayload = {
@@ -187,6 +188,64 @@ export function isSubagentMessage(message: Message): boolean {
return metadata?.normalized?.kind === "subagent_task";
}
export type TaskLaunchErrorIdentity = Pick<TaskStatusSummaryActiveError, "session_id" | "stamp">;
export function shouldRenderStoppedSessionBanner(input: {
isFailed: boolean;
isCompleted: boolean;
executorUnavailable: boolean;
launchErrorOwned?: boolean;
}): boolean {
return (
!input.launchErrorOwned && (input.isFailed || input.isCompleted || input.executorUnavailable)
);
}
export function shouldHideChatInputForLaunchError(input: {
isFailed: boolean;
launchErrorOwned?: boolean;
}): boolean {
return input.launchErrorOwned === true && input.isFailed;
}
/** Matches one rendered error surface to the task-owned launch error. */
export function isMatchingTaskLaunchError(
activeError: TaskLaunchErrorIdentity | null | undefined,
candidate: { sessionId?: string | null; errorStamp?: string | null },
): boolean {
if (!activeError?.stamp || !candidate.errorStamp) return false;
return (
(activeError.session_id ?? null) === (candidate.sessionId ?? null) &&
activeError.stamp === candidate.errorStamp
);
}
/** A task-wide launch error stays visible while any prior session is selected. */
export function isTaskLaunchErrorVisibleForSession(
activeError: TaskLaunchErrorIdentity | null | undefined,
sessionId: string | null | undefined,
): boolean {
if (!activeError?.stamp) return false;
if (sessionId === undefined || activeError.session_id == null) return true;
return activeError.session_id === sessionId;
}
/** Whether the active launch error owns the selected session's surfaces. */
export function isTaskLaunchErrorOwnedBySession(
activeError: TaskLaunchErrorIdentity | null | undefined,
sessionId: string | null | undefined,
): boolean {
if (!activeError?.stamp) return false;
if (activeError.session_id == null) return sessionId == null;
return sessionId != null && activeError.session_id === sessionId;
}
/** Messages that are only useful while a launch failure has no typed owner. */
export function isLaunchErrorSurfaceMessage(message: Message): boolean {
const metadata = message.metadata as Record<string, unknown> | undefined;
return metadata?.empty_turn === true || metadata?.failure_kind === "missing_pr_branch";
}
export type StatusMetadata = {
progress?: number;
status?: string;
@@ -16,6 +16,7 @@ type ComposerPropsArgs = {
handleSubmit: (payload: ChatSubmitPayload) => ChatSubmitResult;
handleCancelTurn: () => Promise<void>;
isSending: boolean;
launchErrorOwned?: boolean;
showRequestChangesTooltip: boolean;
onRequestChangesTooltipDismiss?: () => void;
onClarificationResolved: () => void;
@@ -41,6 +42,7 @@ export function useComposerProps(args: ComposerPropsArgs) {
handleSubmit,
handleCancelTurn,
isSending,
launchErrorOwned,
showRequestChangesTooltip,
onRequestChangesTooltipDismiss,
onClarificationResolved,
@@ -75,6 +77,7 @@ export function useComposerProps(args: ComposerPropsArgs) {
isPreparingEnvironment: panelState.isPreparingEnvironment,
isMoving,
isSending,
launchErrorOwned,
onCancel: handleCancelTurn,
placeholder,
pendingClarification: panelState.pendingClarification,
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildRunErrorsFromSessions,
filterVisibleRunErrors,
hasMatchingSessionLaunchError,
liveSessionMetadataFromStore,
mergeLiveSessionMetadata,
@@ -137,6 +138,32 @@ describe("hasMatchingSessionLaunchError", () => {
});
});
describe("filterVisibleRunErrors", () => {
it("keeps ordinary provider errors when the active summary is not a launch error", () => {
const errors = buildRunErrorsFromSessions([
session({
metadata: {
last_agent_error: {
message: "The provider rejected the request.",
code: "provider_auth_required",
stamp: "provider-stamp-1",
},
},
}),
]);
expect(
filterVisibleRunErrors(errors, {
session_id: "s1",
stamp: "provider-stamp-1",
occurred_at: "2026-08-02T15:00:01Z",
preview: "The provider rejected the request.",
category: "provider_auth_required",
}),
).toEqual(errors);
});
});
describe("liveSessionMetadataFromStore", () => {
const metadata = { last_agent_error: { message: "boom", remediation_url: URL } };
@@ -2,6 +2,8 @@ import type { CommentTurnContext } from "./turn-context";
import { groupSortKey, type SessionGroup } from "./session-groups";
import { normalizeRemediationUrl } from "@/lib/remediation-url";
import { lastAgentErrorStamp, readLastAgentError } from "@/lib/session-last-agent-error";
import { isTypedTaskLaunchError } from "./components/task-launch-error-entry";
import { isMatchingTaskLaunchError } from "@/components/task/chat/types";
import type {
RunError,
TaskComment,
@@ -9,6 +11,7 @@ import type {
TaskSession,
TimelineEvent,
} from "@/app/office/tasks/[id]/types";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
/**
* Discriminated union of every row that can appear in the task chat
@@ -85,9 +88,25 @@ export function hasMatchingSessionLaunchError(
runErrors: RunError[],
): boolean {
if (!summarySessionId) return false;
return runErrors.some(
return runErrors.some((error) =>
isMatchingTaskLaunchError(
{ session_id: summarySessionId, stamp: summaryStamp ?? "" },
{ sessionId: error.sessionId, errorStamp: error.errorStamp },
),
);
}
export function filterVisibleRunErrors(
runErrors: RunError[],
activeError: TaskStatusSummary["active_error"],
): RunError[] {
if (!isTypedTaskLaunchError(activeError)) return runErrors;
return runErrors.filter(
(error) =>
error.sessionId === summarySessionId && (!summaryStamp || error.errorStamp === summaryStamp),
!isMatchingTaskLaunchError(activeError, {
sessionId: error.sessionId,
errorStamp: error.errorStamp,
}),
);
}
@@ -54,6 +54,7 @@ function TypedRunLaunchErrorEntry({
stamp: error.errorStamp ?? "",
occurred_at: error.failedAt,
preview,
details: error.failureDetails,
category: error.failureCode,
recovery_actions: error.recoveryActions,
}}
@@ -1,17 +1,16 @@
"use client";
import { useMemo } from "react";
import type { TaskRepository } from "@/lib/types/http";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
import type { RunError } from "@/app/office/tasks/[id]/types";
import { hasMatchingSessionLaunchError } from "../chat-entries";
import { isTaskLaunchErrorVisibleForSession } from "@/components/task/chat/types";
import { isTypedTaskLaunchError, TaskLaunchErrorEntry } from "./task-launch-error-entry";
type TaskChatLaunchErrorProps = {
taskId: string;
workspaceId: string;
statusSummary?: TaskStatusSummary | null;
runErrors: RunError[];
/** When supplied, only render the error that belongs to this session. */
sessionId?: string | null;
repositories?: TaskRepository[];
};
@@ -19,17 +18,14 @@ export function TaskChatLaunchError({
taskId,
workspaceId,
statusSummary,
runErrors,
sessionId,
repositories,
}: TaskChatLaunchErrorProps) {
const error = useMemo(() => {
const candidate = statusSummary?.active_error;
if (!isTypedTaskLaunchError(candidate)) return null;
if (hasMatchingSessionLaunchError(candidate.session_id, candidate.stamp, runErrors)) {
return null;
}
return candidate;
}, [runErrors, statusSummary]);
const candidate = statusSummary?.active_error;
const error =
isTypedTaskLaunchError(candidate) && isTaskLaunchErrorVisibleForSession(candidate, sessionId)
? candidate
: null;
if (!error) return null;
return (
@@ -43,11 +43,15 @@ vi.mock("@/components/task/task-launch-branch-picker", () => ({
const TASK_ID = "task-1";
const TASK_REPOSITORY_ID = "task-repo-1";
const ERROR_STAMP = "launch-stamp-1";
const WORKSPACE_ID = "workspace-1";
const OCCURRED_AT = "2026-08-19T10:00:00Z";
const ERROR_ENTRY_TEST_ID = "task-launch-error-entry";
const RETRY_LAUNCH_BUTTON_TEST_ID = "task-launch-retry_launch-button";
const error: TaskStatusSummaryActiveError = {
task_repository_id: TASK_REPOSITORY_ID,
stamp: ERROR_STAMP,
occurred_at: "2026-08-19T10:00:00Z",
occurred_at: OCCURRED_AT,
preview: "The selected base branch is not available.",
category: "base_branch_missing",
recovery_actions: ["retry_default", "pick_base_branch", "mark_review_done"],
@@ -78,6 +82,7 @@ describe("TaskLaunchErrorEntry", () => {
"base_branch_missing",
"default_branch_unresolved",
"pr_already_closed",
"workspace_checkout_failed",
"generic_launch_failure",
])("classifies launch category %s as a typed launch error", (category) => {
expect(
@@ -88,30 +93,121 @@ describe("TaskLaunchErrorEntry", () => {
).toBe(true);
});
it("renders checkout details and retries the launch without branch settings", async () => {
const checkoutError: TaskStatusSummaryActiveError = {
session_id: "session-1",
stamp: "checkout-stamp-1",
occurred_at: OCCURRED_AT,
preview: "The workspace could not be prepared for this launch.",
details: "The pull request head was fetched into its isolated ref.",
category: "workspace_checkout_failed",
recovery_actions: ["retry_launch"],
};
render(
<TaskLaunchErrorEntry taskId={TASK_ID} workspaceId={WORKSPACE_ID} error={checkoutError} />,
);
expect(screen.getByText(checkoutError.preview)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /show details/i }));
expect(screen.getByText(checkoutError.details!)).toBeTruthy();
expect(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID)).toBeTruthy();
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
task_id: TASK_ID,
session_id: "session-1",
action: "retry_launch",
error_stamp: "checkout-stamp-1",
});
});
it("keeps recovery failures inside the launch card", async () => {
requestMock.mockRejectedValueOnce(new Error("recovery failed"));
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{ ...error, recovery_actions: ["retry_launch"] }}
/>,
);
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(screen.getByTestId("task-launch-recovery-error")).toBeTruthy());
expect(toastMock).not.toHaveBeenCalled();
});
it("clears a recovery failure when a newer launch error arrives", async () => {
requestMock.mockRejectedValueOnce(new Error("recovery failed"));
const { rerender } = render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{ ...error, recovery_actions: ["retry_launch"] }}
/>,
);
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(screen.getByTestId("task-launch-recovery-error")).toBeTruthy());
rerender(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{
...error,
stamp: "launch-stamp-2",
recovery_actions: ["retry_launch"],
}}
/>,
);
await waitFor(() => expect(screen.queryByTestId("task-launch-recovery-error")).toBeNull());
});
it("renders a typed summary error without recovery actions", () => {
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
statusSummary={{
revision: 1,
updated_at: "2026-08-19T10:00:00Z",
updated_at: OCCURRED_AT,
active_error: { ...error, recovery_actions: [] },
}}
runErrors={[]}
/>,
);
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
expect(screen.queryByTestId("task-launch-retry_default-button")).toBeNull();
});
it("renders a task-wide launch error while a prior session is selected", () => {
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
sessionId="prior-session"
statusSummary={{
revision: 1,
updated_at: OCCURRED_AT,
active_error: { ...error, recovery_actions: ["mark_review_done"] },
}}
/>,
);
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByTestId("task-launch-mark_review_done-button")).toBeTruthy();
});
it("renders the bounded preview and sends the exact task recovery payload", async () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[
{
id: TASK_REPOSITORY_ID,
@@ -127,7 +223,7 @@ describe("TaskLaunchErrorEntry", () => {
/>,
);
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
fireEvent.click(screen.getByTestId("task-launch-retry_default-button"));
@@ -144,7 +240,7 @@ describe("TaskLaunchErrorEntry", () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>,
@@ -165,7 +261,7 @@ describe("TaskLaunchErrorEntry", () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>,
@@ -187,13 +283,13 @@ describe("TaskLaunchErrorEntry", () => {
<>
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>
@@ -210,6 +306,6 @@ describe("TaskLaunchErrorEntry", () => {
expect(buttons[1].getAttribute("disabled")).not.toBeNull();
});
resolveRequest?.({ ok: true });
await waitFor(() => expect(screen.queryAllByTestId("task-launch-error-entry")).toHaveLength(2));
await waitFor(() => expect(screen.queryAllByTestId(ERROR_ENTRY_TEST_ID)).toHaveLength(2));
});
});
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
IconAlertTriangle,
IconCheck,
@@ -9,7 +9,7 @@ import {
IconRefresh,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useToast } from "@/components/toast-provider";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { getWebSocketClient } from "@/lib/ws/connection";
import type { TaskRepository } from "@/lib/types/http";
import type { TaskLaunchRecoveryAction } from "@/lib/types/task-launch-error";
@@ -26,10 +26,64 @@ type TaskLaunchErrorEntryProps = {
const pendingRecoveryRequests = new Map<string, Promise<unknown>>();
function useTaskLaunchRecovery({
taskId,
error,
}: Pick<TaskLaunchErrorEntryProps, "taskId" | "error">) {
const [pendingAction, setPendingAction] = useState<TaskLaunchRecoveryAction | null>(null);
const [recoveryError, setRecoveryError] = useState(false);
useEffect(() => {
setRecoveryError(false);
}, [error.stamp]);
const sendRecovery = async (action: TaskLaunchRecoveryAction, baseBranch?: string) => {
const client = getWebSocketClient();
if (!client || pendingAction) return;
setRecoveryError(false);
const requestKey = JSON.stringify({ taskId, stamp: error.stamp, action, baseBranch });
const existingRequest = pendingRecoveryRequests.get(requestKey);
if (existingRequest) {
setPendingAction(action);
try {
await existingRequest;
} catch {
// The request owner reports the recovery error to avoid duplicate toasts.
} finally {
setPendingAction(null);
}
return;
}
setPendingAction(action);
const request = Promise.resolve().then(() =>
client.request("task.launch.recover", {
task_id: taskId,
...(error.session_id ? { session_id: error.session_id } : {}),
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
action,
...(baseBranch ? { base_branch: baseBranch } : {}),
error_stamp: error.stamp,
}),
);
pendingRecoveryRequests.set(requestKey, request);
try {
await request;
} catch {
setRecoveryError(true);
} finally {
pendingRecoveryRequests.delete(requestKey);
setPendingAction(null);
}
};
return { pendingAction, recoveryError, sendRecovery };
}
const TASK_LAUNCH_ERROR_CATEGORIES = new Set([
"base_branch_missing",
"default_branch_unresolved",
"pr_already_closed",
"workspace_checkout_failed",
"generic_launch_failure",
]);
@@ -51,6 +105,8 @@ function categoryTitle(category: string | undefined, t: (key: string) => string)
return t("task:launchErrorDefaultBranchUnresolved");
case "pr_already_closed":
return t("task:launchErrorPrAlreadyClosed");
case "workspace_checkout_failed":
return t("task:launchErrorWorkspaceCheckoutFailed");
default:
return t("task:launchErrorGeneric");
}
@@ -64,6 +120,8 @@ function actionLabel(action: TaskLaunchRecoveryAction, t: (key: string) => strin
return t("task:pickBaseBranch");
case "mark_review_done":
return t("task:markReviewDone");
case "retry_launch":
return t("task:retryLaunch");
}
}
@@ -76,6 +134,7 @@ function RecoveryActionIcon({
}) {
if (pending) return <IconLoader2 className="h-3 w-3 animate-spin" />;
if (action === "retry_default") return <IconRefresh className="h-3 w-3" />;
if (action === "retry_launch") return <IconRefresh className="h-3 w-3" />;
if (action === "pick_base_branch") return <IconGitBranch className="h-3 w-3" />;
return <IconCheck className="h-3 w-3" />;
}
@@ -99,14 +158,14 @@ function TaskLaunchRecoveryActions({
}) {
const { t } = useTranslation();
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2">
<div className="mt-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{actions.map((action) => {
const opensBranchPicker = action === "pick_base_branch";
const button = (
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 max-w-full cursor-pointer gap-1.5 text-xs sm:min-h-8"
className="h-auto min-h-11 w-full cursor-pointer justify-start gap-1.5 text-xs sm:w-auto sm:min-h-8"
disabled={pendingAction !== null}
onClick={opensBranchPicker ? undefined : () => void onRecover(action)}
data-testid={`task-launch-${action}-button`}
@@ -139,58 +198,16 @@ export function TaskLaunchErrorEntry({
repositories,
}: TaskLaunchErrorEntryProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [pendingAction, setPendingAction] = useState<TaskLaunchRecoveryAction | null>(null);
const { pendingAction, recoveryError, sendRecovery } = useTaskLaunchRecovery({ taskId, error });
const [showDetails, setShowDetails] = useState(false);
const taskRepository = repositories?.find(
(repository) => repository.id === error.task_repository_id,
);
const currentBase = taskRepository?.base_branch ?? t("task:baseBranchNotSet");
const sendRecovery = async (action: TaskLaunchRecoveryAction, baseBranch?: string) => {
const client = getWebSocketClient();
if (!client || pendingAction) return;
const requestKey = JSON.stringify({ taskId, stamp: error.stamp, action, baseBranch });
const existingRequest = pendingRecoveryRequests.get(requestKey);
if (existingRequest) {
setPendingAction(action);
try {
await existingRequest;
} catch {
// The request owner reports the recovery error to avoid duplicate toasts.
} finally {
setPendingAction(null);
}
return;
}
setPendingAction(action);
const request = Promise.resolve().then(() =>
client.request("task.launch.recover", {
task_id: taskId,
...(error.session_id ? { session_id: error.session_id } : {}),
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
action,
...(baseBranch ? { base_branch: baseBranch } : {}),
error_stamp: error.stamp,
}),
);
pendingRecoveryRequests.set(requestKey, request);
try {
await request;
} catch {
toast({
title: t("task:launchRecoveryFailed"),
description: t("task:tryRecoveryAgain"),
variant: "error",
});
} finally {
pendingRecoveryRequests.delete(requestKey);
setPendingAction(null);
}
};
return (
<div
className="flex min-w-0 gap-3 border-b border-border/50 py-3"
className="flex min-w-0 gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 sm:p-4"
data-testid="task-launch-error-entry"
role="alert"
>
@@ -205,6 +222,27 @@ export function TaskLaunchErrorEntry({
<p className="mt-1 max-w-prose break-words text-sm text-muted-foreground">
{error.preview}
</p>
<p className="mt-2 text-xs text-muted-foreground" data-testid="task-launch-no-change">
{t("task:launchErrorNoChanges")}
</p>
{error.details && (
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="mt-3">
<CollapsibleTrigger className="flex min-h-11 items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground cursor-pointer sm:min-h-8">
<IconRefresh
className={`h-3.5 w-3.5 transition-transform ${showDetails ? "rotate-90" : ""}`}
/>
{t("task:showDetails")}
</CollapsibleTrigger>
<CollapsibleContent>
<pre
className="mt-2 max-w-prose whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed text-muted-foreground"
data-testid="task-launch-error-details"
>
{error.details}
</pre>
</CollapsibleContent>
</Collapsible>
)}
<TaskLaunchRecoveryActions
actions={error.recovery_actions ?? []}
workspaceId={workspaceId}
@@ -214,6 +252,15 @@ export function TaskLaunchErrorEntry({
pendingAction={pendingAction}
onRecover={sendRecovery}
/>
{recoveryError && (
<p
className="mt-2 text-xs text-destructive"
data-testid="task-launch-recovery-error"
role="alert"
>
{t("task:launchRecoveryFailed")} {t("task:tryRecoveryAgain")}
</p>
)}
</div>
</div>
);
@@ -35,6 +35,7 @@ import type {
import {
buildLaterAgentReplyMap,
buildRunErrorsFromSessions,
filterVisibleRunErrors,
mergeChatEntries,
type ChatEntry,
} from "./chat-entries";
@@ -615,6 +616,10 @@ export function TaskChat({
);
const turnCtx = useMemo(() => buildCommentTurnContext(comments, sessions), [comments, sessions]);
const runErrors = useMemo(() => buildRunErrorsFromSessions(sessions), [sessions]);
const visibleRunErrors = useMemo(
() => filterVisibleRunErrors(runErrors, statusSummary?.active_error),
[runErrors, statusSummary?.active_error],
);
const laterAgentReplyMap = useMemo(() => buildLaterAgentReplyMap(comments), [comments]);
const entries = useMemo(
() =>
@@ -624,10 +629,10 @@ export function TaskChat({
groups: renderedGroups,
decisions,
turnCtx,
runErrors,
runErrors: visibleRunErrors,
laterAgentReplyMap,
}),
[comments, timeline, renderedGroups, decisions, turnCtx, runErrors, laterAgentReplyMap],
[comments, timeline, renderedGroups, decisions, turnCtx, visibleRunErrors, laterAgentReplyMap],
);
useChatAutoScroll(scrollParent ?? null, sessions, taskId);
@@ -652,7 +657,6 @@ export function TaskChat({
taskId={taskId}
workspaceId={workspaceId}
statusSummary={statusSummary}
runErrors={runErrors}
repositories={repositories}
/>
{isEmpty ? (
@@ -0,0 +1,213 @@
import type { ReactNode } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Message } from "@/lib/types/http";
const launchError = {
stamp: "task-wide-launch-error",
occurred_at: "2026-08-20T10:00:00Z",
preview: "The workspace could not be prepared.",
category: "workspace_checkout_failed",
};
const priorTranscriptMessage = {
id: "prior-transcript",
author_type: "agent",
content: "The prior session stopped after its checkout failed.",
type: "status",
created_at: "2026-08-19T10:00:00Z",
} as unknown as Message;
const appStoreState = {
userSettings: {
showAnchoredPromptBar: false,
showScrollToLastPrompt: false,
showScrollToStart: false,
},
taskSessions: {
items: {
"prior-session": { name: null, agent_profile_id: null, last_read_message_id: null },
},
},
agentProfiles: { items: [] },
messages: { bySession: { "prior-session": [priorTranscriptMessage] } },
};
const panelState = {
resolvedSessionId: "prior-session",
session: { state: "FAILED", error_message: "The prior session stopped." },
taskId: "task-1",
isWorking: false,
messagesLoading: false,
historyRefreshPending: false,
isInitialMessagesLoading: false,
groupedItems: [],
allMessages: [priorTranscriptMessage],
footerActionMessages: [],
permissionsByToolCallId: new Map(),
childrenByParentToolCallId: new Map(),
agentMessageCount: 0,
pendingClarification: null,
pendingClarificationGroup: null,
};
vi.mock("./panel-primitives", () => ({
PanelRoot: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PanelBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof appStoreState) => unknown) => selector(appStoreState),
useAppStoreApi: () => ({
getState: () => appStoreState,
}),
}));
vi.mock("@/hooks/domains/settings/use-settings-data", () => ({
useSettingsData: () => undefined,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: false }),
}));
vi.mock("./task-archived-context", () => ({
useIsTaskArchived: () => false,
}));
vi.mock("./task-launch-error-context", () => ({
useTaskLaunchErrorContext: () => ({
taskId: "task-1",
workspaceId: "workspace-1",
statusSummary: { active_error: launchError },
repositories: [],
}),
}));
vi.mock("@/hooks/domains/task/use-task-status-summary", () => ({
useTaskStatusSummary: () => ({ active_error: launchError }),
}));
vi.mock("./chat/use-chat-panel-state", () => ({
useChatPanelState: () => panelState,
}));
vi.mock("./chat/chat-input-area", () => ({
ChatInputArea: ({
launchErrorOwned,
panelState: state,
}: {
launchErrorOwned?: boolean;
panelState: typeof panelState;
}) => (
<div data-testid="chat-footer">
{!launchErrorOwned && state.session?.state === "FAILED" && (
<div data-testid="session-stopped-banner">prior session recovery</div>
)}
</div>
),
useSubmitHandler: () => ({ isSending: false, handleSubmit: vi.fn() }),
useChatPanelHandlers: () => ({ handleCancelTurn: vi.fn() }),
}));
vi.mock("@/components/task/chat/message-list", () => ({
MessageList: ({
messages,
launchErrorOwned,
}: {
messages: Message[];
launchErrorOwned?: boolean;
}) => (
<div data-testid="message-list">
{!launchErrorOwned &&
messages.map((message) => <div key={message.id}>{message.content}</div>)}
</div>
),
}));
vi.mock("./simple/components/task-chat-launch-error", () => ({
TaskChatLaunchError: ({
statusSummary,
}: {
statusSummary?: { active_error?: typeof launchError };
}) =>
statusSummary?.active_error ? (
<div data-testid="task-launch-error-entry">{statusSummary.active_error.preview}</div>
) : null,
}));
vi.mock("@/components/shared/task-markdown-file-link-provider", () => ({
TaskMarkdownFileLinkProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("./chat/clarification-panel-section", () => ({
ClarificationPanelSection: () => null,
}));
vi.mock("./chat/use-composer-agent-start-hint", () => ({
useComposerAgentStartHint: () => false,
}));
vi.mock("@/hooks/use-panel-search", () => ({
usePanelSearch: () => undefined,
}));
vi.mock("@/hooks/domains/session/use-session-search", () => ({
useSessionSearch: () => ({
isOpen: false,
query: "",
hits: [],
activeHitId: null,
isSearching: false,
setQuery: vi.fn(),
setActiveHit: vi.fn(),
open: vi.fn(),
close: vi.fn(),
}),
}));
vi.mock("@/hooks/use-lazy-load-messages", () => ({
useLazyLoadMessages: () => ({ loadMoreRaw: vi.fn(), hasMore: false }),
}));
vi.mock("@/components/task/chat/use-drain-older-messages", () => ({
useDrainOlderMessages: () => undefined,
}));
vi.mock("./chat/use-session-read-tracking", () => ({
useSessionReadTracking: () => null,
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: { scrollTarget: null }) => unknown) =>
selector({ scrollTarget: null }),
getState: () => ({ scrollTarget: null }),
}));
vi.mock("@/hooks/domains/session/load-message-window", () => ({
loadMessageWindowAround: vi.fn(),
}));
vi.mock("@/lib/session-workspace-path", () => ({
getSessionWorkspacePath: () => undefined,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { TaskChatPanel } from "./task-chat-panel";
afterEach(() => cleanup());
describe("TaskChatPanel launch-error ownership", () => {
it("keeps a task-wide card and prior failed-session surfaces together", () => {
render(<TaskChatPanel sessionId="prior-session" taskId="task-1" />);
expect(screen.getByTestId("task-launch-error-entry").textContent).toContain(
launchError.preview,
);
expect(screen.getByTestId("session-stopped-banner")).toBeTruthy();
expect(screen.getByText(priorTranscriptMessage.content)).toBeTruthy();
});
});
+15 -1
View File
@@ -52,8 +52,10 @@ import { useTranslation } from "react-i18next";
import { loadMessageWindowAround } from "@/hooks/domains/session/load-message-window";
import { TaskChatLaunchError } from "./simple/components/task-chat-launch-error";
import { isTypedTaskLaunchError } from "./simple/components/task-launch-error-entry";
import { useTaskLaunchErrorContext } from "./task-launch-error-context";
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
import { isTaskLaunchErrorOwnedBySession } from "@/components/task/chat/types";
import { TaskMarkdownFileLinkProvider } from "@/components/shared/task-markdown-file-link-provider";
/** Returns a `clarificationKey` that increments each time a pending
@@ -1024,6 +1026,11 @@ export const TaskChatPanel = memo(function TaskChatPanel({
pendingClarification,
pendingClarificationGroup,
} = panelState;
const activeLaunchError = launchStatusSummary?.active_error;
const launchErrorOwned = Boolean(
isTypedTaskLaunchError(activeLaunchError) &&
isTaskLaunchErrorOwnedBySession(activeLaunchError, resolvedSessionId),
);
const showAgentStartHint = useComposerAgentStartHint(
resolvedSessionId,
session?.state,
@@ -1165,7 +1172,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
taskId={launchErrorContext.taskId}
workspaceId={launchErrorContext.workspaceId}
statusSummary={launchStatusSummary}
runErrors={[]}
sessionId={resolvedSessionId}
repositories={launchErrorContext.repositories}
/>
)}
@@ -1197,6 +1204,9 @@ export const TaskChatPanel = memo(function TaskChatPanel({
onFirstMessageHiddenChange={setIsFirstMessageHidden}
anchoredBarHeight={showAnchoredBar && lastPromptMessage ? anchoredBarHeight : 0}
isVisible={transcriptIsVisible}
launchErrorOwned={launchErrorOwned}
launchErrorStamp={launchErrorOwned ? activeLaunchError?.stamp : undefined}
launchErrorOccurredAt={launchErrorOwned ? activeLaunchError?.occurred_at : undefined}
stickyPromptBar={
showAnchoredBar && lastPromptMessage ? (
<AnchoredLastPromptBar
@@ -1251,6 +1261,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
onScrollToStart={scrollToStart}
statusTaskId={statusTaskId ?? taskIdHint}
showAgentStartHint={showAgentStartHint}
launchErrorOwned={launchErrorOwned}
/>
</PanelRoot>
);
@@ -1279,6 +1290,7 @@ type ChatFooterProps = {
statusTaskId: string | null;
/** Recovered-idle sessions render the composer hint (see ChatInputArea). */
showAgentStartHint: boolean;
launchErrorOwned: boolean;
};
/**
@@ -1306,6 +1318,7 @@ function ChatFooter({
onScrollToStart,
statusTaskId,
showAgentStartHint,
launchErrorOwned,
}: ChatFooterProps) {
const { t } = useTranslation();
if (isArchived) {
@@ -1335,6 +1348,7 @@ function ChatFooter({
onScrollToStart={onScrollToStart}
statusTaskId={statusTaskId}
showAgentStartHint={showAgentStartHint}
launchErrorOwned={launchErrorOwned}
/>
);
}
@@ -49,6 +49,12 @@ beforeEach(() => {
});
describe("TaskLaunchErrorProvider", () => {
it("does not toast for a typed launch error while its card is visible", () => {
renderProvider(initialSummary);
expect(toastMock).not.toHaveBeenCalled();
});
it.each(["provider_auth_required", "model_capacity"])(
"does not toast for ordinary active error category %s",
(category) => {
@@ -1,16 +1,9 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
import { useToast } from "@/components/toast-provider";
import { t } from "@/lib/i18n";
import { getSessionStorage, setSessionStorage } from "@/lib/local-storage";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { TaskRepository } from "@/lib/types/http";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
import { useTaskStatusSummary } from "@/hooks/domains/task/use-task-status-summary";
import { isTypedTaskLaunchError } from "./simple/components/task-launch-error-entry";
// i18n-exempt: persisted browser storage key, not user-facing copy.
const LAUNCH_ERROR_TOASTS_KEY = "kandev.task-launch-error-toasts";
export type TaskLaunchErrorContextValue = {
taskId: string;
@@ -28,39 +21,8 @@ export function TaskLaunchErrorProvider({
value: TaskLaunchErrorContextValue;
children: ReactNode;
}) {
const { toast } = useToast();
const shownToastStampsRef = useRef(new Set<string>());
const statusSummary = useTaskStatusSummary(value.taskId, value.statusSummary);
useEffect(() => {
const activeError = statusSummary?.active_error;
if (!isTypedTaskLaunchError(activeError)) return;
const localStamp = `${value.taskId}:${activeError.stamp}`;
if (shownToastStampsRef.current.has(localStamp)) return;
const shownByTask = getSessionStorage<Record<string, string[]>>(LAUNCH_ERROR_TOASTS_KEY, {});
if ((shownByTask[value.taskId] ?? []).includes(activeError.stamp)) {
shownToastStampsRef.current.add(localStamp);
return;
}
shownToastStampsRef.current.add(localStamp);
setSessionStorage(LAUNCH_ERROR_TOASTS_KEY, {
...shownByTask,
[value.taskId]: [...(shownByTask[value.taskId] ?? []), activeError.stamp].slice(-20),
});
toast({
title: t("task:taskFailedToStart"),
description: t("task:launchFailedSeeDetails"),
variant: "error",
});
}, [
statusSummary?.active_error?.category,
statusSummary?.active_error?.stamp,
toast,
value.taskId,
]);
const contextValue = useMemo(() => ({ ...value, statusSummary }), [statusSummary, value]);
return (
<TaskLaunchErrorContext.Provider value={contextValue}>
-1
View File
@@ -93,7 +93,6 @@ export const TaskLayout = memo(function TaskLayout({
taskId={launchErrorContext.taskId}
workspaceId={launchErrorContext.workspaceId}
statusSummary={launchErrorContext.statusSummary}
runErrors={[]}
repositories={launchErrorContext.repositories}
/>
</div>