fix: recover failed task launches safely (#3437)
This commit is contained in:
@@ -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)));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -93,7 +93,6 @@ export const TaskLayout = memo(function TaskLayout({
|
||||
taskId={launchErrorContext.taskId}
|
||||
workspaceId={launchErrorContext.workspaceId}
|
||||
statusSummary={launchErrorContext.statusSummary}
|
||||
runErrors={[]}
|
||||
repositories={launchErrorContext.repositories}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user