fix: retain session failures across recovery surfaces (#3669)
This commit is contained in:
@@ -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 } };
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user