fix: retain session failures across recovery surfaces (#3669)

This commit is contained in:
Carlos Florêncio
2026-09-15 09:36:47 +01:00
committed by GitHub
parent 54e2e21f32
commit eeeb6f5586
101 changed files with 4057 additions and 1704 deletions
@@ -24,6 +24,8 @@ import type {
TimelineEvent,
} from "@/app/office/tasks/[id]/types";
import { useTranslation } from "react-i18next";
import { TaskLaunchErrorProvider } from "@/components/task/task-launch-error-context";
import { TaskSharedError } from "@/components/task/task-shared-error";
// TAB_TRIGGER_BASE adds a 1px ring to the active tab on top of
// shadcn's default bg/text active styling, so the selected tab is
@@ -137,44 +139,54 @@ export function ChatActivityTabs({
);
return (
<Tabs defaultValue="chat" className="mt-6">
<TabsList>
<TabsTrigger value="chat" className={TAB_TRIGGER_BASE}>
{t("task:chat")}
</TabsTrigger>
<TabsTrigger value="activity" className={TAB_TRIGGER_BASE}>
{t("task:activity")}
</TabsTrigger>
<TaskLaunchErrorProvider
value={{
taskId: task.id,
workspaceId: task.workspaceId,
statusSummary: task.statusSummary,
repositories: task.repositories,
}}
>
<TaskSharedError />
<Tabs defaultValue="chat" className="mt-6">
<TabsList>
<TabsTrigger value="chat" className={TAB_TRIGGER_BASE}>
{t("task:chat")}
</TabsTrigger>
<TabsTrigger value="activity" className={TAB_TRIGGER_BASE}>
{t("task:activity")}
</TabsTrigger>
{officeGroups.map((g) => (
<AgentTabTrigger key={g.id} group={g} />
))}
</TabsList>
<TabsContent value="chat">
<ApprovalActionBar task={task} />
<TaskChat
taskId={task.id}
workspaceId={task.workspaceId}
statusSummary={task.statusSummary}
repositories={task.repositories}
comments={comments}
timeline={timeline}
sessions={sessions}
decisions={task.decisions}
reviewers={task.reviewers}
approvers={task.approvers}
scrollParent={scrollParent}
readOnly={readOnly}
onCommentsChanged={onCommentsChanged}
taskTitle={task.title}
taskDescription={task.description}
/>
</TabsContent>
<TabsContent value="activity">
<TaskActivity taskId={task.id} entries={activity} />
</TabsContent>
{officeGroups.map((g) => (
<AgentTabTrigger key={g.id} group={g} />
<AgentTabContent key={g.id} taskId={task.id} group={g} />
))}
</TabsList>
<TabsContent value="chat">
<ApprovalActionBar task={task} />
<TaskChat
taskId={task.id}
workspaceId={task.workspaceId}
statusSummary={task.statusSummary}
repositories={task.repositories}
comments={comments}
timeline={timeline}
sessions={sessions}
decisions={task.decisions}
reviewers={task.reviewers}
approvers={task.approvers}
scrollParent={scrollParent}
readOnly={readOnly}
onCommentsChanged={onCommentsChanged}
taskTitle={task.title}
taskDescription={task.description}
/>
</TabsContent>
<TabsContent value="activity">
<TaskActivity taskId={task.id} entries={activity} />
</TabsContent>
{officeGroups.map((g) => (
<AgentTabContent key={g.id} taskId={task.id} group={g} />
))}
</Tabs>
</Tabs>
</TaskLaunchErrorProvider>
);
}
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildRunErrorsFromSessions,
filterVisibleRunErrors,
hasMatchingSessionLaunchError,
liveSessionMetadataFromStore,
mergeLiveSessionMetadata,
@@ -9,6 +8,7 @@ import {
import type { TaskSession } from "@/app/office/tasks/[id]/types";
const URL = "https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go";
const SESSION_STARTED_AT = "2026-08-02T15:00:00Z";
function session(overrides: Partial<TaskSession>): TaskSession {
return {
@@ -17,7 +17,7 @@ function session(overrides: Partial<TaskSession>): TaskSession {
agentRole: "agent",
state: "FAILED",
isPrimary: false,
startedAt: "2026-08-02T15:00:00Z",
startedAt: SESSION_STARTED_AT,
errorMessage: "usage limit reached",
...overrides,
};
@@ -64,12 +64,16 @@ describe("buildRunErrorsFromSessions", () => {
});
it("omits the URL when metadata or the field is absent", () => {
const legacyError = buildRunErrorsFromSessions([session({ errorMessage: "boom" })]);
expect(legacyError).toHaveLength(1);
expect(legacyError[0].remediationUrl).toBeUndefined();
expect(legacyError[0].isActive).toBe(true);
for (const s of [
session({ errorMessage: "boom" }),
session({ metadata: { last_agent_error: { message: "boom" } } }),
session({ metadata: { last_agent_error: { message: "boom", remediation_url: "" } } }),
]) {
const errors = buildRunErrorsFromSessions([s]);
expect(errors).toHaveLength(1);
expect(errors[0].remediationUrl).toBeUndefined();
}
});
@@ -88,11 +92,38 @@ describe("buildRunErrorsFromSessions", () => {
expect(errors[0].remediationUrl).toBeUndefined();
});
it("skips non-FAILED sessions", () => {
it("preserves an active error on a non-FAILED session", () => {
const errors = buildRunErrorsFromSessions([
session({ state: "RUNNING", metadata: { last_agent_error: { remediation_url: URL } } }),
session({
state: "RUNNING",
metadata: { last_agent_error: { message: "still starting", stamp: "failure-1" } },
}),
]);
expect(errors).toHaveLength(0);
expect(errors).toHaveLength(1);
expect(errors[0].isActive).toBe(true);
});
it("preserves a dismissed error as history after the session resumes", () => {
const errors = buildRunErrorsFromSessions([
session({
state: "IDLE",
metadata: {
last_agent_error: {
message: "The agent could not start.",
occurred_at: SESSION_STARTED_AT,
dismissed_at: "2026-08-02T15:05:00Z",
stamp: "failure-1",
recovery_actions: ["retry_default"],
},
},
}),
]);
expect(errors).toHaveLength(1);
expect(errors[0].failedAt).toBe(SESSION_STARTED_AT);
expect(errors[0].errorStamp).toBe("failure-1");
expect(errors[0].isActive).toBe(false);
expect(errors[0].recoveryActions).toBeUndefined();
});
});
@@ -118,7 +149,7 @@ describe("hasMatchingSessionLaunchError", () => {
it("does not match an unstamped run error to a stamped summary", () => {
expect(
hasMatchingSessionLaunchError("s1", "new-stamp", [
{ id: "e1", sessionId: "s1", rawPayload: "boom", failedAt: "2026-08-02T15:00:00Z" },
{ id: "e1", sessionId: "s1", rawPayload: "boom", failedAt: SESSION_STARTED_AT },
]),
).toBe(false);
});
@@ -130,7 +161,7 @@ describe("hasMatchingSessionLaunchError", () => {
id: "e1",
sessionId: "s1",
rawPayload: "boom",
failedAt: "2026-08-02T15:00:00Z",
failedAt: SESSION_STARTED_AT,
errorStamp: "new-stamp",
},
]),
@@ -138,32 +169,6 @@ 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 } };
+42 -64
View File
@@ -1,8 +1,11 @@
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 {
lastAgentErrorStamp,
readLastAgentError,
readLastAgentErrorIncludingDismissed,
} from "@/lib/session-last-agent-error";
import { isMatchingTaskLaunchError } from "@/components/task/chat/types";
import type {
RunError,
@@ -11,7 +14,6 @@ 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
@@ -50,38 +52,51 @@ export function buildLaterAgentReplyMap(comments: TaskComment[]): Map<string, bo
}
/**
* Pull RunError entries out of failed sessions. One entry per office
* session in FAILED state — the session row's error_message becomes
* the rawPayload for the chat entry's Show details. The remediation URL is
* copied from the persisted last_agent_error metadata and passed through the
* browser-edge allowlist, so RunError never carries an unvalidated string.
* Pull retained session failures into the Office chat timeline. The session
* metadata remains the source of the chronological breadcrumb after recovery;
* only an undismissed record carries recovery actions. The remediation URL is
* copied from the persisted metadata and passed through the browser-edge
* allowlist, so RunError never carries an unvalidated string.
*/
export function buildRunErrorsFromSessions(sessions: TaskSession[]): RunError[] {
const errors: RunError[] = [];
for (const s of sessions) {
if (s.state !== "FAILED") continue;
const failedAt = s.completedAt ?? s.updatedAt ?? s.startedAt ?? "";
const lastError = lastAgentErrorFromSessionMetadata(s.metadata);
const parsedLastError = readLastAgentError(s.metadata);
errors.push({
id: `re-${s.id}`,
sessionId: s.id,
agentProfileId: s.agentProfileId,
rawPayload: s.errorMessage ?? "",
failedAt,
failureCode: lastError?.code,
failureDetails: lastError?.details,
message: parsedLastError?.message,
recoveryActions: parsedLastError?.recoveryActions,
taskRepositoryId: parsedLastError?.taskRepositoryId,
errorStamp: parsedLastError ? lastAgentErrorStamp(parsedLastError) : undefined,
remediationUrl:
normalizeRemediationUrl(remediationUrlFromSessionMetadata(s.metadata)) ?? undefined,
});
const error = buildRunErrorFromSession(s);
if (error) errors.push(error);
}
return errors;
}
function buildRunErrorFromSession(s: TaskSession): RunError | null {
const parsedLastError = readLastAgentErrorIncludingDismissed(s.metadata);
const isLegacy = parsedLastError === null;
// Older Office sessions only stored error_message on the session row. Keep
// those FAILED rows in the chronological timeline while using metadata as
// the richer identity for resumed and dismissed failures.
if (isLegacy && s.state !== "FAILED") return null;
const activeLastError = readLastAgentError(s.metadata);
const error: RunError = {
id: `re-${s.id}`,
sessionId: s.id,
agentProfileId: s.agentProfileId,
rawPayload: s.errorMessage ?? "",
failedAt: parsedLastError?.occurredAt ?? s.completedAt ?? s.updatedAt ?? s.startedAt ?? "",
recoveryActions: activeLastError?.recoveryActions,
isActive: activeLastError !== null || isLegacy,
};
if (isLegacy) return error;
error.failureCode = parsedLastError.code;
error.failureDetails = parsedLastError.details;
error.message = parsedLastError.message;
error.taskRepositoryId = parsedLastError.taskRepositoryId;
error.errorStamp = lastAgentErrorStamp(parsedLastError);
const remediationUrl = normalizeRemediationUrl(parsedLastError.remediationUrl);
if (remediationUrl) error.remediationUrl = remediationUrl;
return error;
}
export function hasMatchingSessionLaunchError(
summarySessionId: string | undefined,
summaryStamp: string | undefined,
@@ -96,20 +111,6 @@ export function hasMatchingSessionLaunchError(
);
}
export function filterVisibleRunErrors(
runErrors: RunError[],
activeError: TaskStatusSummary["active_error"],
): RunError[] {
if (!isTypedTaskLaunchError(activeError)) return runErrors;
return runErrors.filter(
(error) =>
!isMatchingTaskLaunchError(activeError, {
sessionId: error.sessionId,
errorStamp: error.errorStamp,
}),
);
}
export function mergeLiveSessionMetadata(
base: Record<string, unknown> | null | undefined,
live: Record<string, unknown> | null | undefined,
@@ -138,29 +139,6 @@ export function liveSessionMetadataFromStore(
return row.metadata;
}
function remediationUrlFromSessionMetadata(metadata: Record<string, unknown> | null | undefined) {
const lastError = metadata?.last_agent_error;
if (!lastError || typeof lastError !== "object") return undefined;
const record = lastError as Record<string, unknown>;
const raw = record.remediation_url ?? record.remediationUrl;
return typeof raw === "string" && raw !== "" ? raw : undefined;
}
function lastAgentErrorFromSessionMetadata(
metadata: Record<string, unknown> | null | undefined,
): { code?: string; details?: string } | undefined {
const lastError = metadata?.last_agent_error;
if (!lastError || typeof lastError !== "object") return undefined;
const record = lastError as Record<string, unknown>;
const code = record.code ?? record.failure_code ?? record.failureCode;
const details =
record.details ?? record.failure_details ?? record.failureDetails ?? record.error_output;
return {
code: typeof code === "string" && code !== "" ? code : undefined,
details: typeof details === "string" && details !== "" ? details : undefined,
};
}
export type MergeChatEntriesArgs = {
comments: TaskComment[];
timeline: TimelineEvent[];
@@ -16,7 +16,7 @@ export function ManagedRuntimeNpmRunError({
}: {
error: RunError;
agentName: string;
onRetry: () => void;
onRetry?: () => void;
}) {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
@@ -56,18 +56,20 @@ export function ManagedRuntimeNpmRunError({
</CollapsibleContent>
</Collapsible>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
onClick={onRetry}
data-testid="run-error-managed-runtime-retry-button"
>
<IconRefresh className="h-3 w-3" />
{t("chat:managedRuntimeRetry")}
</Button>
</div>
{onRetry && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
onClick={onRetry}
data-testid="run-error-managed-runtime-retry-button"
>
<IconRefresh className="h-3 w-3" />
{t("chat:managedRuntimeRetry")}
</Button>
</div>
)}
</div>
</div>
);
@@ -72,4 +72,17 @@ describe("RunErrorEntry", () => {
expect(screen.getByTestId("run-error-continue-new-branch-button")).toBeTruthy();
expect(screen.getByTestId("run-error-restore-workspace-button")).toBeTruthy();
});
it("renders recovered session history without stale recovery controls", () => {
render(
<RunErrorEntry
taskId="task-1"
workspaceId="workspace-1"
error={{ ...runError("provider_auth_required"), isActive: false }}
/>,
);
expect(screen.queryByTestId("run-error-resume-button")).toBeNull();
expect(screen.queryByTestId("run-error-fresh-button")).toBeNull();
});
});
@@ -48,6 +48,7 @@ function TypedRunLaunchErrorEntry({
taskId={taskId}
workspaceId={workspaceId ?? ""}
repositories={repositories}
isActive={error.isActive !== false}
error={{
session_id: error.sessionId,
task_repository_id: error.taskRepositoryId,
@@ -125,9 +126,55 @@ function RunErrorRecoveryFeedback({
);
}
function LegacyRunErrorActions({
error,
isActive,
busyAction,
onRecover,
}: {
error: RunError;
isActive: boolean;
busyAction: SessionRecoveryAction | "restore" | null;
onRecover: (action: SessionRecoveryAction) => Promise<boolean>;
}) {
const { t } = useTranslation();
return (
<div className="mt-2 flex items-center gap-2 flex-wrap">
<RemediationLink url={error.remediationUrl} />
{isActive && (
<>
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
onClick={() => onRecover("resume")}
disabled={busyAction !== null}
data-testid="run-error-resume-button"
>
<IconRefresh className="h-3 w-3" />
{t("task:resumeSession")}
</Button>
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
onClick={() => onRecover("fresh_start")}
disabled={busyAction !== null}
data-testid="run-error-fresh-button"
>
<IconPlayerPlay className="h-3 w-3" />
{t("task:startFreshSession")}
</Button>
</>
)}
</div>
);
}
function LegacyRunErrorEntry({
agentName,
error,
isActive,
onRecover,
onRetry,
onRestore,
@@ -140,6 +187,7 @@ function LegacyRunErrorEntry({
}: {
agentName: string;
error: RunError;
isActive: boolean;
onRecover: (action: SessionRecoveryAction) => Promise<boolean>;
onRetry: () => void;
onRestore: () => void;
@@ -168,16 +216,18 @@ function LegacyRunErrorEntry({
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{t("task:theAgentStoppedWithAnError")}</p>
<RunErrorRecoveryFeedback
workspaceId={workspaceId}
recoveryError={recoveryError}
recoveryNotice={recoveryNotice}
branchDetails={branchDetails}
busyAction={busyAction}
onRetry={onRetry}
onRestore={onRestore}
onNewBranch={onNewBranch}
/>
{isActive && (
<RunErrorRecoveryFeedback
workspaceId={workspaceId}
recoveryError={recoveryError}
recoveryNotice={recoveryNotice}
branchDetails={branchDetails}
busyAction={busyAction}
onRetry={onRetry}
onRestore={onRestore}
onNewBranch={onNewBranch}
/>
)}
{error.rawPayload && (
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="mt-2">
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors">
@@ -196,31 +246,12 @@ function LegacyRunErrorEntry({
</CollapsibleContent>
</Collapsible>
)}
<div className="mt-2 flex items-center gap-2 flex-wrap">
<RemediationLink url={error.remediationUrl} />
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
onClick={() => onRecover("resume")}
disabled={busyAction !== null}
data-testid="run-error-resume-button"
>
<IconRefresh className="h-3 w-3" />
{t("task:resumeSession")}
</Button>
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 cursor-pointer gap-1.5 text-xs sm:min-h-8"
onClick={() => onRecover("fresh_start")}
disabled={busyAction !== null}
data-testid="run-error-fresh-button"
>
<IconPlayerPlay className="h-3 w-3" />
{t("task:startFreshSession")}
</Button>
</div>
<LegacyRunErrorActions
error={error}
isActive={isActive}
busyAction={busyAction}
onRecover={onRecover}
/>
</div>
</div>
);
@@ -274,7 +305,7 @@ export function RunErrorEntry({
<ManagedRuntimeNpmRunError
error={error}
agentName={agentName}
onRetry={() => void handleRecover("runtime_retry")}
onRetry={error.isActive === false ? undefined : () => void handleRecover("runtime_retry")}
/>
);
}
@@ -283,6 +314,7 @@ export function RunErrorEntry({
<LegacyRunErrorEntry
agentName={agentName}
error={error}
isActive={error.isActive !== false}
onRecover={handleRecover}
onRetry={handleRetry}
onRestore={() => void handleRestore()}
@@ -62,11 +62,14 @@ vi.mock("@/components/task/task-launch-error-context", () => ({
const TASK_ID = "task-1";
const TASK_REPOSITORY_ID = "task-repo-1";
const SESSION_ID = "session-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 RETRY_DEFAULT_BUTTON_TEST_ID = "task-launch-retry_default-button";
const TASK_LAUNCH_RECOVER_METHOD = "task.launch.recover";
const error: TaskStatusSummaryActiveError = {
task_repository_id: TASK_REPOSITORY_ID,
@@ -118,7 +121,7 @@ describe("TaskLaunchErrorEntry", () => {
it("renders checkout details and retries the launch without branch settings", async () => {
const checkoutError: TaskStatusSummaryActiveError = {
session_id: "session-1",
session_id: SESSION_ID,
stamp: "checkout-stamp-1",
occurred_at: OCCURRED_AT,
preview: "The workspace could not be prepared for this launch.",
@@ -138,9 +141,9 @@ describe("TaskLaunchErrorEntry", () => {
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
session_id: "session-1",
session_id: SESSION_ID,
action: "retry_launch",
error_stamp: "checkout-stamp-1",
});
@@ -205,7 +208,21 @@ describe("TaskLaunchErrorEntry", () => {
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
expect(screen.queryByTestId("task-launch-retry_default-button")).toBeNull();
expect(screen.queryByTestId(RETRY_DEFAULT_BUTTON_TEST_ID)).toBeNull();
});
it("hides recovery actions for a retained session history entry", () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
isActive={false}
error={{ ...error, recovery_actions: ["retry_default"] }}
/>,
);
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(RETRY_DEFAULT_BUTTON_TEST_ID)).toBeNull();
});
it("routes a session-owned bootstrap error to the recovery card", () => {
@@ -213,13 +230,13 @@ describe("TaskLaunchErrorEntry", () => {
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
sessionId="session-1"
sessionId={SESSION_ID}
statusSummary={{
revision: 2,
updated_at: OCCURRED_AT,
active_error: {
...error,
session_id: "session-1",
session_id: SESSION_ID,
phase: "bootstrap",
category: "generic_launch_failure",
recovery_actions: ["retry_launch"],
@@ -249,13 +266,13 @@ describe("TaskLaunchErrorEntry", () => {
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
sessionId="session-1"
sessionId={SESSION_ID}
statusSummary={{
revision: 3,
updated_at: OCCURRED_AT,
active_error: {
...error,
session_id: "session-1",
session_id: SESSION_ID,
phase: "bootstrap",
category: "generic_launch_failure",
recovery_actions: ["retry_launch"],
@@ -309,10 +326,10 @@ describe("TaskLaunchErrorEntry", () => {
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
fireEvent.click(screen.getByTestId("task-launch-retry_default-button"));
fireEvent.click(screen.getByTestId(RETRY_DEFAULT_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
task_repository_id: TASK_REPOSITORY_ID,
action: "retry_default",
@@ -332,7 +349,7 @@ describe("TaskLaunchErrorEntry", () => {
fireEvent.click(screen.getByTestId("mock-branch-option"));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
task_repository_id: TASK_REPOSITORY_ID,
action: "pick_base_branch",
@@ -341,6 +358,26 @@ describe("TaskLaunchErrorEntry", () => {
});
});
it("keeps task-scoped recovery on the task source when it has a session correlation", async () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
repositories={[]}
error={{ ...error, scope: "task", session_id: "retired-session-1" }}
/>,
);
fireEvent.click(screen.getByTestId(RETRY_DEFAULT_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith(TASK_LAUNCH_RECOVER_METHOD, {
task_id: TASK_ID,
task_repository_id: TASK_REPOSITORY_ID,
action: "retry_default",
error_stamp: ERROR_STAMP,
});
});
it("waits for a branch selection before sending the picker action", async () => {
render(
<TaskLaunchErrorEntry
@@ -380,7 +417,7 @@ describe("TaskLaunchErrorEntry", () => {
</>,
);
const buttons = screen.getAllByTestId("task-launch-retry_default-button");
const buttons = screen.getAllByTestId(RETRY_DEFAULT_BUTTON_TEST_ID);
fireEvent.click(buttons[0]);
fireEvent.click(buttons[1]);
@@ -22,6 +22,8 @@ type TaskLaunchErrorEntryProps = {
workspaceId: string;
error: TaskStatusSummaryActiveError;
repositories?: TaskRepository[];
/** Retained session history has no live recovery controls. */
isActive?: boolean;
};
const pendingRecoveryRequests = new Map<string, Promise<unknown>>();
@@ -58,7 +60,7 @@ function useTaskLaunchRecovery({
const request = Promise.resolve().then(() =>
client.request("task.launch.recover", {
task_id: taskId,
...(error.session_id ? { session_id: error.session_id } : {}),
...(error.scope !== "task" && error.session_id ? { session_id: error.session_id } : {}),
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
action,
...(baseBranch ? { base_branch: baseBranch } : {}),
@@ -196,6 +198,7 @@ export function TaskLaunchErrorEntry({
workspaceId,
error,
repositories,
isActive = true,
}: TaskLaunchErrorEntryProps) {
const { t } = useTranslation();
const { pendingAction, recoveryError, sendRecovery } = useTaskLaunchRecovery({ taskId, error });
@@ -243,16 +246,18 @@ export function TaskLaunchErrorEntry({
</CollapsibleContent>
</Collapsible>
)}
<TaskLaunchRecoveryActions
actions={error.recovery_actions ?? []}
workspaceId={workspaceId}
repositories={repositories}
taskRepositoryId={error.task_repository_id}
currentBase={currentBase}
pendingAction={pendingAction}
onRecover={sendRecovery}
/>
{recoveryError && (
{isActive && (
<TaskLaunchRecoveryActions
actions={error.recovery_actions ?? []}
workspaceId={workspaceId}
repositories={repositories}
taskRepositoryId={error.task_repository_id}
currentBase={currentBase}
pendingAction={pendingAction}
onRecover={sendRecovery}
/>
)}
{isActive && recoveryError && (
<p
className="mt-2 text-xs text-destructive"
data-testid="task-launch-recovery-error"
@@ -635,7 +635,7 @@ describe("TaskChat run error entries", () => {
expect(screen.queryByTestId("run-error-raw-payload")).toBeNull();
});
it("renders a task-owned launch error before the empty state", () => {
it("leaves task-owned launch errors to the task shell", () => {
const statusSummary: TaskStatusSummary = {
revision: 3,
updated_at: T_11,
@@ -658,8 +658,8 @@ describe("TaskChat run error entries", () => {
/>,
),
);
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
expect(screen.getByText(/choose a base branch/i)).toBeTruthy();
expect(screen.queryByTestId("task-launch-error-entry")).toBeNull();
expect(screen.queryByText(/choose a base branch/i)).toBeNull();
expect(screen.getByText(/no comments yet/i)).toBeTruthy();
});
+2 -15
View File
@@ -21,7 +21,6 @@ import { formatRelativeTime } from "@/lib/utils";
import { MarkdownComment } from "./markdown-comment";
import { AgentTurnPanel } from "./components/agent-turn-panel";
import { RunErrorEntry } from "./components/run-error-entry";
import { TaskChatLaunchError } from "./components/task-chat-launch-error";
import { UserCommentRunBadge } from "./components/user-comment-run-badge";
import { buildCommentTurnContext, type CommentTurnContext } from "./turn-context";
import { groupSessionsForTimeline, groupSortKey, type SessionGroup } from "./session-groups";
@@ -35,7 +34,6 @@ import type {
import {
buildLaterAgentReplyMap,
buildRunErrorsFromSessions,
filterVisibleRunErrors,
mergeChatEntries,
type ChatEntry,
} from "./chat-entries";
@@ -591,7 +589,6 @@ export function TaskChat({
onCommentsChanged,
taskTitle,
taskDescription,
statusSummary,
repositories,
}: TaskChatProps) {
const { t } = useTranslation();
@@ -616,10 +613,6 @@ 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(
() =>
@@ -629,10 +622,10 @@ export function TaskChat({
groups: renderedGroups,
decisions,
turnCtx,
runErrors: visibleRunErrors,
runErrors,
laterAgentReplyMap,
}),
[comments, timeline, renderedGroups, decisions, turnCtx, visibleRunErrors, laterAgentReplyMap],
[comments, timeline, renderedGroups, decisions, turnCtx, runErrors, laterAgentReplyMap],
);
useChatAutoScroll(scrollParent ?? null, sessions, taskId);
@@ -653,12 +646,6 @@ export function TaskChat({
{t("task:showOlderSessions", { count: olderGroups.length })}
</button>
)}
<TaskChatLaunchError
taskId={taskId}
workspaceId={workspaceId}
statusSummary={statusSummary}
repositories={repositories}
/>
{isEmpty ? (
<p className="text-sm text-muted-foreground py-4">{t("task:noCommentsYet")}</p>
) : (