diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 04d7d4456..430820e97 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -13681,6 +13681,125 @@ describe("createAgentChatService", () => { })); }); + it("emits one Codex failure when error precedes turn/completed with the same payload", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const turn = service.runSessionTurn({ + sessionId: session.id, + text: "Continue shipping the fix.", + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/started", + params: { turn: { id: "turn-capacity", status: "inProgress" } }, + }); + const error = { + message: "Selected model is at capacity. Please try a different model.", + codexErrorInfo: "serverOverloaded", + }; + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "error", + params: { turnId: "turn-capacity", error, willRetry: false }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + turn: { + id: "turn-capacity", + status: "failed", + error, + }, + }, + }); + + await expect(turn).resolves.toEqual(expect.objectContaining({ turnId: "turn-capacity" })); + expect(events.filter((event) => event.event.type === "error")).toHaveLength(1); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "status", + turnStatus: "failed", + turnId: "turn-capacity", + }), + }), + expect.objectContaining({ + event: expect.objectContaining({ + type: "done", + status: "failed", + turnId: "turn-capacity", + }), + }), + ])); + await expect(service.getSessionSummary(session.id)).resolves.toEqual( + expect.objectContaining({ status: "idle" }), + ); + }); + + it("keeps Codex willRetry errors non-terminal until turn/completed", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const turn = service.runSessionTurn({ sessionId: session.id, text: "Retry transiently." }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/started", + params: { turn: { id: "turn-retry", status: "inProgress" } }, + }); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "error", + params: { + turnId: "turn-retry", + willRetry: true, + error: { message: "Temporary upstream failure.", codexErrorInfo: "serverOverloaded" }, + }, + }); + + expect(events.filter((event) => event.event.type === "error")).toHaveLength(0); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "system_notice", + noticeKind: "provider_health", + turnId: "turn-retry", + }), + }), + ])); + await expect(service.getSessionSummary(session.id)).resolves.toEqual( + expect.objectContaining({ status: "active" }), + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "turn/completed", + params: { turn: { id: "turn-retry", status: "completed" } }, + }); + await expect(turn).resolves.toEqual(expect.objectContaining({ turnId: "turn-retry" })); + }); + it("ignores stale Codex lifecycle notifications from a foreign turn", async () => { const events: Array<{ type: string; turnId?: string; text?: string }> = []; const { service } = createService({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 76fdd811a..ba0bdea0e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -704,6 +704,7 @@ type CodexRuntime = { agentMessageScopeByTurn: Map; agentMessageTextByTurn: Map; recentNotificationKeys: Set; + emittedErrorKeys: Set; reconciledItemSignaturesByTurn: Map>; noFirstEventWatchdog: { turnId: string; @@ -20105,6 +20106,38 @@ export function createAgentChatService(args: { persistChatState(managed); } + function emitCodexErrorOnce( + managed: ManagedChatSession, + runtime: CodexRuntime, + input: { + turnId?: string | null; + message: unknown; + errorInfo?: unknown; + detail?: unknown; + }, + ): void { + const turnId = typeof input.turnId === "string" && input.turnId.trim().length + ? input.turnId.trim() + : null; + const message = String(input.message ?? "Codex app-server error."); + const errorInfo = formatCodexErrorInfo(input.errorInfo); + const detail = typeof input.detail === "string" && input.detail.trim().length + ? input.detail.trim() + : undefined; + if (turnId) { + const semanticKey = JSON.stringify([turnId, message.trim(), errorInfo ?? null, detail ?? null]); + if (runtime.emittedErrorKeys.has(semanticKey)) return; + rememberBoundedId(runtime.emittedErrorKeys, semanticKey, 512); + } + emitChatEvent(managed, { + type: "error", + message, + ...(turnId ? { turnId } : {}), + ...(errorInfo ? { errorInfo } : {}), + ...(detail ? { detail } : {}), + }); + } + async function finishCodexTurnFromReconciledState( managed: ManagedChatSession, runtime: CodexRuntime, @@ -20148,11 +20181,11 @@ export function createAgentChatService(args: { const error = asRecord(turn.error); const errorMessage = stringOrNull(error?.message); if (status === "failed" && errorMessage) { - emitChatEvent(managed, { - type: "error", - message: errorMessage, + emitCodexErrorOnce(managed, runtime, { turnId, - errorInfo: formatCodexErrorInfo(error?.codexErrorInfo), + message: errorMessage, + errorInfo: error?.codexErrorInfo, + detail: error?.additionalDetails, }); } @@ -20599,11 +20632,11 @@ export function createAgentChatService(args: { } if (status === "failed" && turn?.error?.message) { - emitChatEvent(managed, { - type: "error", - message: String(turn.error.message), + emitCodexErrorOnce(managed, runtime, { turnId, - errorInfo: formatCodexErrorInfo(turn.error.codexErrorInfo) + message: turn.error.message, + errorInfo: turn.error.codexErrorInfo, + detail: (turn.error as { additionalDetails?: unknown }).additionalDetails, }); } @@ -20997,12 +21030,28 @@ export function createAgentChatService(args: { } if (method === "error") { - const error = (params.error as { message?: unknown; codexErrorInfo?: unknown } | null) ?? null; - emitChatEvent(managed, { - type: "error", - message: String(error?.message ?? "Codex app-server error."), - turnId: typeof params.turnId === "string" ? params.turnId : undefined, - errorInfo: formatCodexErrorInfo(error?.codexErrorInfo) + const error = (params.error as { + message?: unknown; + codexErrorInfo?: unknown; + additionalDetails?: unknown; + } | null) ?? null; + const turnId = typeof params.turnId === "string" ? params.turnId : runtime.activeTurnId ?? undefined; + if (params.willRetry === true) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "provider_health", + severity: "warning", + message: "Codex hit a provider error and is retrying automatically.", + detail: String(error?.message ?? "The provider request failed."), + ...(turnId ? { turnId } : {}), + }); + return; + } + emitCodexErrorOnce(managed, runtime, { + turnId, + message: error?.message ?? "Codex app-server error.", + errorInfo: error?.codexErrorInfo, + detail: error?.additionalDetails, }); return; } @@ -21224,6 +21273,7 @@ export function createAgentChatService(args: { agentMessageScopeByTurn: new Map(), agentMessageTextByTurn: new Map(), recentNotificationKeys: new Set(), + emittedErrorKeys: new Set(), reconciledItemSignaturesByTurn: new Map>(), noFirstEventWatchdog: null, stalledTurnIds: new Set(), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index ac6b5e1c7..9a31a2733 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1051,6 +1051,8 @@ export function AgentChatComposer({ appControlContextItems = [], builtInBrowserContextItems = [], modelSelectionLocked = false, + modelPickerOpenRequestKey, + onModelPickerOpenRequestHandled, permissionModeLocked = false, hideNativeControls = false, orchestrationRole = null, @@ -1183,6 +1185,8 @@ export function AgentChatComposer({ builtInBrowserContextItems?: BuiltInBrowserContextItem[]; executionModeOptions?: ExecutionModeOption[]; modelSelectionLocked?: boolean; + modelPickerOpenRequestKey?: number; + onModelPickerOpenRequestHandled?: () => void; permissionModeLocked?: boolean; hideNativeControls?: boolean; /** @@ -3849,6 +3853,8 @@ export function AgentChatComposer({ value={modelId} onChange={onModelChange} surfaceKey="chat-composer" + openRequestKey={modelPickerOpenRequestKey} + onOpenRequestHandled={onModelPickerOpenRequestHandled} {...(availableModelIds ? { availableModelIds } : {})} constrainToAvailableModelIds={constrainModelSelection} {...(providerAuthStatus ? { providerAuthStatus } : {})} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 65ca71ee6..f9a45e778 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -91,6 +91,7 @@ import { import { BackgroundFinishChip, SubagentResultCard, SubagentSpawnCard } from "./SubagentActivityCards"; import { ChatUserMinimap } from "./ChatUserMinimap"; import { AgentCliAuthCard, type AgentCliAuthCardInfo } from "./AgentCliAuthCard"; +import { classifyProviderFailure, ProviderFailureRecoveryCard } from "./ProviderFailureRecoveryCard"; import { HighlightedCode } from "./CodeHighlighter"; import { MosaicCard } from "./MosaicCard"; import { MOSAIC_FENCE_LANGUAGE } from "../../../shared/chatMosaic"; @@ -2635,11 +2636,14 @@ function renderEvent( options?: { onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; onCodexRecovery?: (args: AgentChatRecoverCodexTurnArgs) => Promise; + onRetryProviderFailure?: (turnId: string | null) => Promise; + onChooseProviderFailureModel?: () => void; turnModel?: { label: string; modelId?: string; model?: string } | null; surfaceMode?: ChatSurfaceMode; surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; turnActive?: boolean; + sessionTurnActive?: boolean; sessionEnded?: boolean; onOpenWorkspacePath?: (path: string | WorkspacePathLocation) => void; respondingApprovalIds?: Set; @@ -3617,6 +3621,7 @@ function renderEvent( const errorCopyValue = event.detail?.trim().length ? `${event.message}\n\n${event.detail}` : event.message; + const recovery = classifyProviderFailure(event); const renderAgentCliAuthCard = () => agentCliInfo ? ( ) : null} + {recovery ? ( + options.onRetryProviderFailure!(event.turnId ?? null) + : undefined} + onChooseModel={options?.onChooseProviderFailureModel} + /> + ) : null} {renderAgentCliAuthCard()} {event.errorInfo && !agentCliInfo ? ( -
- {typeof event.errorInfo === "string" ? event.errorInfo : `${event.errorInfo.provider ? `${event.errorInfo.provider}` : ""}${event.errorInfo.model ? ` / ${event.errorInfo.model}` : ""}`} +
+ {recovery?.label + ?? (typeof event.errorInfo === "string" ? event.errorInfo : `${event.errorInfo.provider ? `${event.errorInfo.provider}` : ""}${event.errorInfo.model ? ` / ${event.errorInfo.model}` : ""}`)}
) : null}
@@ -4143,10 +4162,13 @@ type EventRowProps = { turnEndDurationMs?: number | null; onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; onCodexRecovery?: (args: AgentChatRecoverCodexTurnArgs) => Promise; + onRetryProviderFailure?: (turnId: string | null) => Promise; + onChooseProviderFailureModel?: () => void; surfaceMode?: ChatSurfaceMode; surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; turnActive?: boolean; + sessionTurnActive?: boolean; sessionEnded?: boolean; isLatestWorkLog?: boolean; onOpenWorkspacePath?: (path: string | WorkspacePathLocation) => void; @@ -4176,10 +4198,13 @@ const EventRow = React.memo(function EventRow({ turnEndDurationMs, onApproval, onCodexRecovery, + onRetryProviderFailure, + onChooseProviderFailureModel, surfaceMode = "standard", surfaceProfile = "standard", assistantLabel, turnActive, + sessionTurnActive, sessionEnded, isLatestWorkLog, onOpenWorkspacePath, @@ -4243,11 +4268,14 @@ const EventRow = React.memo(function EventRow({ : renderEvent(envelope as RenderEnvelope, { onApproval, onCodexRecovery, + onRetryProviderFailure, + onChooseProviderFailureModel, turnModel, surfaceMode, surfaceProfile, assistantLabel, turnActive, + sessionTurnActive, sessionEnded, onOpenWorkspacePath, respondingApprovalIds, @@ -4571,9 +4599,12 @@ function AgentChatMessageListMain({ className, onApproval, onCodexRecovery, + onRetryProviderFailure, + onChooseProviderFailureModel, surfaceMode = "standard", surfaceProfile = "standard", assistantLabel, + sessionTurnActive = false, onOpenWorkspacePath, respondingApprovalIds, pendingApprovalIds, @@ -4595,9 +4626,12 @@ function AgentChatMessageListMain({ className?: string; onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; onCodexRecovery?: (args: AgentChatRecoverCodexTurnArgs) => Promise; + onRetryProviderFailure?: (turnId: string | null) => Promise; + onChooseProviderFailureModel?: () => void; surfaceMode?: ChatSurfaceMode; surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; + sessionTurnActive?: boolean; onOpenWorkspacePath?: (path: string, laneId?: string | null) => void; onInsertDraft?: (text: string) => void; onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; @@ -5397,10 +5431,13 @@ function AgentChatMessageListMain({ turnEndDurationMs={turnEndDurationMs} onApproval={handleApproval} onCodexRecovery={onCodexRecovery} + onRetryProviderFailure={onRetryProviderFailure} + onChooseProviderFailureModel={onChooseProviderFailureModel} surfaceMode={surfaceMode} surfaceProfile={surfaceProfile} assistantLabel={assistantLabel} turnActive={rowTurnActive} + sessionTurnActive={sessionTurnActive} sessionEnded={sessionEnded} isLatestWorkLog={isLatestWorkLog} onOpenWorkspacePath={openWorkspacePath} @@ -5433,10 +5470,13 @@ function AgentChatMessageListMain({ turnModel={turnModel} onApproval={handleApproval} onCodexRecovery={onCodexRecovery} + onRetryProviderFailure={onRetryProviderFailure} + onChooseProviderFailureModel={onChooseProviderFailureModel} surfaceMode={surfaceMode} surfaceProfile={surfaceProfile} assistantLabel={assistantLabel} turnActive={rowTurnActive} + sessionTurnActive={sessionTurnActive} sessionEnded={sessionEnded} isLatestWorkLog={isLatestWorkLog} onOpenWorkspacePath={openWorkspacePath} @@ -5458,7 +5498,7 @@ function AgentChatMessageListMain({ assistantTurnCopy={assistantTurnCopy} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic, scrollToRowKey]); + }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRetryProviderFailure, onChooseProviderFailureModel, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 3ef633e33..749d27eaa 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -2593,6 +2593,309 @@ describe("AgentChatPane submit recovery", () => { expect(screen.getByLabelText("Stop active turn")).toBeTruthy(); }); + it("lets terminal history beat a stale active summary and send the next message", async () => { + const session = buildSession("session-1", { status: "active", awaitingInput: false }); + const terminalEvents: AgentChatEventEnvelope[] = [ + { + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:52.000Z", + sequence: 1, + // ADE persists Codex user input before turn/started supplies the + // provider turn id. Recovery must associate this by turn boundaries. + event: { type: "user_message", text: "Keep shipping the fix." }, + }, + ...[2, 3].map((sequence) => ({ + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:53.000Z", + sequence, + event: { + type: "error" as const, + message: "Selected model is at capacity. Please try a different model.", + turnId: "turn-capacity", + errorInfo: "serverOverloaded", + }, + })), + { + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:53.050Z", + sequence: 4, + event: { + type: "status", + turnStatus: "failed", + turnId: "turn-capacity", + message: "Selected model is at capacity. Please try a different model.", + }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:53.066Z", + sequence: 5, + event: { type: "done", status: "failed", turnId: "turn-capacity", model: "gpt-5.4" }, + }, + ]; + const { emitChatEvent, send } = installAdeMocks({ + sessions: [session], + eventHistory: { + sessionId: session.sessionId, + events: terminalEvents, + truncated: false, + sessionFound: true, + }, + }); + + renderPane(session); + + expect(await screen.findByText("Provider capacity")).toBeTruthy(); + expect(screen.getAllByText("Error")).toHaveLength(1); + expect(screen.getByText("Selected model is at capacity. Please try a different model.")).toBeTruthy(); + expect(screen.queryByPlaceholderText("Steer the active turn...")).toBeNull(); + expect(screen.getByRole("button", { name: "Send" })).toBeTruthy(); + + const modelTrigger = screen.getByRole("button", { name: /^Select model/ }); + fireEvent.click(screen.getByRole("button", { name: "Choose model" })); + await waitFor(() => expect(modelTrigger.getAttribute("aria-expanded")).toBe("true")); + fireEvent.click(modelTrigger); + + const retryButton = screen.getByRole("button", { name: "Retry turn" }) as HTMLButtonElement; + const chooseModelButton = screen.getByRole("button", { name: "Choose model" }) as HTMLButtonElement; + act(() => { + emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:54.000Z", + sequence: 6, + event: { type: "status", turnStatus: "started", turnId: "turn-next" }, + }); + }); + await waitFor(() => { + expect(retryButton.disabled).toBe(true); + expect(chooseModelButton.disabled).toBe(true); + }); + fireEvent.click(retryButton); + expect(send).not.toHaveBeenCalled(); + + act(() => { + emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:55.000Z", + sequence: 7, + event: { type: "status", turnStatus: "completed", turnId: "turn-next" }, + }); + emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:55.001Z", + sequence: 8, + event: { type: "done", status: "completed", turnId: "turn-next", model: "gpt-5.4" }, + }); + }); + await waitFor(() => { + expect(retryButton.disabled).toBe(false); + expect(chooseModelButton.disabled).toBe(false); + }); + + fireEvent.click(retryButton); + await waitFor(() => { + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.sessionId, + text: "Keep shipping the fix.", + })); + }); + send.mockClear(); + + // A later non-terminal live event must use the same invariant as snapshot + // hydration and cannot revive the already-failed turn from the stale summary. + act(() => { + emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:56.000Z", + sequence: 9, + event: { type: "system_notice", noticeKind: "info", message: "Session metadata refreshed." }, + }); + }); + await waitFor(() => { + expect(screen.queryByPlaceholderText("Steer the active turn...")).toBeNull(); + }); + + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Continue in a new turn." } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + await waitFor(() => { + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.sessionId, + text: "Continue in a new turn.", + })); + }); + }); + + it("skips steer messages during provider-failure retry and surfaces a rejected resend", async () => { + const session = buildSession("session-1", { status: "idle", awaitingInput: false }); + const { send } = installAdeMocks({ + sessions: [session], + sendError: new Error("turn is already active"), + eventHistory: { + sessionId: session.sessionId, + truncated: false, + sessionFound: true, + events: [ + { + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:52.000Z", + sequence: 1, + event: { type: "user_message", text: "Retry the original prompt." }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:52.500Z", + sequence: 2, + event: { type: "user_message", text: "Do not resend this steer.", steerId: "steer-1" }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:53.000Z", + sequence: 3, + event: { + type: "error", + message: "Selected model is at capacity. Please try a different model.", + errorInfo: "serverOverloaded", + }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:53.050Z", + sequence: 4, + event: { type: "status", turnStatus: "failed", turnId: "turn-capacity" }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:53.066Z", + sequence: 5, + event: { type: "done", status: "failed", turnId: "turn-capacity", model: "gpt-5.4" }, + }, + ], + }, + }); + + renderPane(session); + + fireEvent.click(await screen.findByRole("button", { name: "Retry turn" })); + await waitFor(() => { + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.sessionId, + text: "Retry the original prompt.", + })); + }); + expect((await screen.findByRole("alert")).textContent).toContain( + "A turn is already active in this thread. Wait for it to finish before retrying.", + ); + }); + + it("does not carry a pending provider-failure model request into another session", async () => { + const failedSession = buildSession("session-failed", { status: "idle", awaitingInput: false }); + const nextSession = buildSession("session-next", { status: "idle", awaitingInput: false }); + installAdeMocks({ + sessions: [failedSession, nextSession], + eventHistory: ({ sessionId }) => ({ + sessionId, + truncated: false, + sessionFound: true, + events: sessionId === failedSession.sessionId + ? [ + { + sessionId, + timestamp: "2026-07-10T18:18:53.000Z", + sequence: 1, + event: { + type: "error", + message: "Selected model is at capacity. Please try a different model.", + turnId: "turn-capacity", + errorInfo: "serverOverloaded", + }, + }, + { + sessionId, + timestamp: "2026-07-10T18:18:53.066Z", + sequence: 2, + event: { type: "done", status: "failed", turnId: "turn-capacity", model: "gpt-5.4" }, + }, + ] + : [], + }), + }); + + const view = render( + + + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "Choose model" })); + expect(screen.getByRole("button", { name: /^Select model/ }).getAttribute("aria-expanded")).toBe("false"); + + view.rerender( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /^Select model/ }).getAttribute("aria-expanded")).toBe("false"); + }); + }); + + it.each([ + ["claude", "claude-sonnet-5", "anthropic/claude-sonnet-5"], + ["cursor", "composer", "cursor/composer"], + ["droid", "claude-sonnet-4-5", "droid/claude-sonnet-4-5"], + ["opencode", "gpt-5.4-mini", "opencode/openai/gpt-5.4-mini"], + ] as const)("keeps %s idle when terminal transcript evidence conflicts with an active summary", async (provider, model, modelId) => { + const session = buildSession(`session-${provider}`, { + provider, + model, + modelId, + status: "active", + awaitingInput: false, + }); + installAdeMocks({ + sessions: [session], + includeClaudeModel: true, + cursorModels: [{ id: "composer" }], + eventHistory: { + sessionId: session.sessionId, + truncated: false, + sessionFound: true, + events: [ + { + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:53.050Z", + sequence: 1, + event: { type: "status", turnStatus: "failed", turnId: `turn-${provider}`, message: "Provider failed." }, + }, + { + sessionId: session.sessionId, + timestamp: "2026-07-10T18:18:53.066Z", + sequence: 2, + event: { type: "done", status: "failed", turnId: `turn-${provider}`, model }, + }, + ], + }, + }); + + renderPane(session); + + expect(await screen.findByRole("button", { name: "Send" })).toBeTruthy(); + expect(screen.queryByPlaceholderText("Steer the active turn...")).toBeNull(); + expect(screen.queryByLabelText("Stop active turn")).toBeNull(); + }); + it("stops active-turn controls immediately when a terminal event streams", async () => { const session = buildSession("session-1", { status: "active", awaitingInput: false }); const { emitChatEvent } = installAdeMocks({ @@ -2623,6 +2926,12 @@ describe("AgentChatPane submit recovery", () => { expect(screen.queryByPlaceholderText("Steer the active turn...")).toBeNull(); expect(screen.getByRole("button", { name: "Send" })).toBeTruthy(); }); + + // The terminal event schedules a locked-session summary refresh. Its stale + // active snapshot must not revive the turn after that refresh lands. + await new Promise((resolve) => window.setTimeout(resolve, 250)); + expect(screen.queryByPlaceholderText("Steer the active turn...")).toBeNull(); + expect(screen.getByRole("button", { name: "Send" })).toBeTruthy(); }); it("falls back to a normal send when the active-turn marker is stale", async () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index a8ac12b6a..8a57e549f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -91,7 +91,10 @@ import { resolveModelDescriptorWithRuntimeCatalog, descriptorsFromAgentChatModel import { toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel"; import { getSharedRuntimeCatalog } from "../shared/ModelPicker/runtimeCatalogCache"; import { familiesFromStatus } from "../shared/ModelPicker/useProviderAuthStatus"; -import { AgentChatMessageList, type MosaicRenderContext } from "./AgentChatMessageList"; +import { + AgentChatMessageList, + type MosaicRenderContext, +} from "./AgentChatMessageList"; import { ChatStatusGlyph } from "./chatStatusVisuals"; import { isChatToolType } from "../../lib/sessions"; import { ToolLogo } from "../terminals/ToolLogos"; @@ -129,6 +132,7 @@ import { deriveChatSubagentSnapshots, deriveScheduledWorkSnapshots, deriveTodoIt import { deriveMissionSnapshot } from "./chatMission"; import { MissionControlPanel } from "./MissionControlPanel"; import { derivePendingInputRequests, type DerivedPendingInput } from "./pendingInput"; +import { findUserMessageForTurn, resolveTurnActive } from "./chatTurnState"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; @@ -982,14 +986,6 @@ export function deriveRuntimeState(events: AgentChatEventEnvelope[]): { }; } -function chatSummaryIndicatesActiveTurn(summary: AgentChatSessionSummary | null | undefined): boolean { - return summary?.status === "active" && summary.awaitingInput !== true; -} - -function chatEventEndsTurn(event: AgentChatEventEnvelope["event"]): boolean { - return event.type === "done" || (event.type === "status" && event.turnStatus !== "started"); -} - type AgentChatSessionViewCache = { events: AgentChatEventEnvelope[]; turnActive: boolean; @@ -3044,6 +3040,19 @@ export function AgentChatPane({ const [respondingApprovalIds, setRespondingApprovalIds] = useState>(new Set()); const [pendingSteersBySession, setPendingSteersBySession] = useState>({}); const [modelId, setModelId] = useState(""); + const [modelPickerOpenRequest, setModelPickerOpenRequest] = useState<{ + key: number; + sessionId: string | null; + laneId: string | null; + } | undefined>(); + const modelPickerOpenRequestSessionId = lockSessionId ?? selectedSessionId; + const modelPickerOpenRequestKey = modelPickerOpenRequest?.sessionId === modelPickerOpenRequestSessionId + && modelPickerOpenRequest.laneId === laneId + ? modelPickerOpenRequest.key + : undefined; + const handleModelPickerOpenRequestHandled = useCallback(() => { + setModelPickerOpenRequest(undefined); + }, []); const [runtimeCatalogVersion, setRuntimeCatalogVersion] = useState(0); const [reasoningEffort, setReasoningEffort] = useState(null); const [fastMode, setFastMode] = useState(false); @@ -5146,7 +5155,10 @@ export function AgentChatPane({ setSessions(summary ? [summary] : []); setTurnActiveBySession((prev) => { - const nextRunning = Boolean(summary && summary.status === "active" && summary.awaitingInput !== true); + const residentEvents = eventsBySessionRef.current[lockSessionId] ?? []; + const nextRunning = residentEvents.length > 0 + ? resolveTurnActive(residentEvents, deriveRuntimeState(residentEvents).turnActive, summary) + : Boolean(summary?.status === "active" && summary.awaitingInput !== true); return prev[lockSessionId] === nextRunning ? prev : { ...prev, [lockSessionId]: nextRunning }; @@ -5369,7 +5381,7 @@ export function AgentChatPane({ ?? (initialSessionSummary?.sessionId === sessionId ? initialSessionSummary : null); setTurnActiveBySession((prev) => ({ ...prev, - [sessionId]: cached.turnActive || (cached.events.length > 0 && chatSummaryIndicatesActiveTurn(sessionSummary)), + [sessionId]: resolveTurnActive(cached.events, cached.turnActive, sessionSummary), })); setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: cached.pendingInputs })); setPendingSteersBySession((prev) => ({ ...prev, [sessionId]: cached.pendingSteers })); @@ -5463,7 +5475,6 @@ export function AgentChatPane({ const derived = deriveRuntimeState(merged); const sessionSummary = sessionsRef.current.find((entry) => entry.sessionId === sessionId) ?? (initialSessionSummary?.sessionId === sessionId ? initialSessionSummary : null); - const allowRunningFromSummary = sessionSummary?.status === "active" && sessionSummary.awaitingInput !== true; const historyCursor = usedSnapshotPath ? snapshotTailStartOffset : null; writeAgentChatSessionViewCache(sessionId, merged, derived, historyCursor); eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: merged }; @@ -5471,7 +5482,7 @@ export function AgentChatPane({ setEventsBySession((prev) => ({ ...prev, [sessionId]: merged })); setTurnActiveBySession((prev) => ({ ...prev, - [sessionId]: derived.turnActive || (merged.length > 0 && allowRunningFromSummary), + [sessionId]: resolveTurnActive(merged, derived.turnActive, sessionSummary), })); setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: derived.pendingInputs })); setPendingSteersBySession((prev) => ({ ...prev, [sessionId]: derived.pendingSteers })); @@ -6013,6 +6024,7 @@ export function AgentChatPane({ useEffect(() => { setChatActionsOpen(false); setHandoffBusy(false); + setModelPickerOpenRequest(undefined); optimisticOutgoingMessageRef.current = null; setOptimisticOutgoingMessage(null); // The full composer bucket effect above owns draft/context hydration for @@ -6110,13 +6122,9 @@ export function AgentChatPane({ // after a "done" event. let next = eventsBySessionRef.current; const touchedSessionIds = new Set(); - const endingEventSessionIds = new Set(); for (const envelope of queued) { const sessionId = envelope.sessionId; - if (chatEventEndsTurn(envelope.event)) { - endingEventSessionIds.add(sessionId); - } const sessionEvents = next === eventsBySessionRef.current ? (eventsBySessionRef.current[sessionId] ?? []) : (next[sessionId] ?? []); @@ -6150,10 +6158,6 @@ export function AgentChatPane({ const derived = deriveRuntimeState(next[sessionId] ?? []); const sessionSummary = sessionsRef.current.find((entry) => entry.sessionId === sessionId) ?? (initialSessionSummary?.sessionId === sessionId ? initialSessionSummary : null); - const keepActiveFromSummary = - chatSummaryIndicatesActiveTurn(sessionSummary) - && (next[sessionId]?.length ?? 0) > 0 - && !endingEventSessionIds.has(sessionId); const maxEvents = sessionId === selectedSessionIdRef.current || sessionId === lockSessionId ? MAX_SELECTED_CHAT_SESSION_RESIDENT_EVENTS : MAX_BACKGROUND_CHAT_SESSION_EVENTS; @@ -6164,7 +6168,7 @@ export function AgentChatPane({ olderHistoryCursorRef.current[sessionId] ?? null, maxEvents, ); - activePatch[sessionId] = derived.turnActive || keepActiveFromSummary; + activePatch[sessionId] = resolveTurnActive(next[sessionId] ?? [], derived.turnActive, sessionSummary); pendingInputPatch[sessionId] = derived.pendingInputs; pendingSteerPatch[sessionId] = derived.pendingSteers; } @@ -6709,32 +6713,32 @@ export function AgentChatPane({ insertComposerDraft, ]); - // Resend the most recent user message for a session that fast-failed on a - // Claude logout — fired by the inline re-login card's "Retry turn" button. A - // forced provider refresh clears cached auth-failed runtime health first; if - // Claude is still logged out the new turn fast-fails again and a fresh card - // appears. + // Resend the most recent user message after a recoverable provider failure. + // A forced provider refresh clears stale auth/capacity health before the new + // turn starts in the same durable thread. const rejectAuthRetry = useCallback((sessionId: string) => { window.dispatchEvent(new CustomEvent(CHAT_AUTH_RETRY_REJECTED_EVENT, { detail: { sessionId } })); }, []); - const resendLastUserMessageForAuthRetry = useCallback(async (sessionId: string) => { + const resendLastUserMessage = useCallback(async (sessionId: string, failedTurnId?: string | null) => { if (submitInFlightRef.current) { rejectAuthRetry(sessionId); - return; + return "Another message is already being sent. Wait for it to finish before retrying."; } const events = selectedEventsForDisplayRef.current; - let userEvent: Extract | null = null; - for (let index = events.length - 1; index >= 0; index -= 1) { - const evt = events[index]?.event; - if (evt?.type === "user_message" && typeof evt.text === "string" && evt.text.trim().length > 0) { - userEvent = evt; - break; + let userEvent = failedTurnId ? findUserMessageForTurn(events, failedTurnId) : null; + if (!failedTurnId) { + for (let index = events.length - 1; index >= 0; index -= 1) { + const evt = events[index]?.event; + if (evt?.type === "user_message" && !evt.steerId && typeof evt.text === "string" && evt.text.trim().length > 0) { + userEvent = evt; + break; + } } } if (!userEvent) { rejectAuthRetry(sessionId); - return; + return "ADE could not find the original message for this failed turn."; } const text = userEvent.text; const displayText = typeof userEvent.displayText === "string" ? userEvent.displayText : text; @@ -6757,11 +6761,14 @@ export function AgentChatPane({ } catch (sendError) { if (!isTurnAlreadyActiveError(sendError)) throw sendError; rejectAuthRetry(sessionId); - return; + return "A turn is already active in this thread. Wait for it to finish before retrying."; } void refreshSessions().catch(() => {}); + return null; } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + setError(message); + return message; } finally { submitInFlightRef.current = false; setBusy(false); @@ -6806,11 +6813,11 @@ export function AgentChatPane({ const detail = (event as CustomEvent<{ sessionId?: string | null }>).detail; const sessionId = detail?.sessionId; if (typeof sessionId !== "string" || sessionId !== selectedSessionIdRef.current) return; - void resendLastUserMessageForAuthRetry(sessionId); + void resendLastUserMessage(sessionId); }; window.addEventListener(CHAT_RETRY_AUTH_TURN_EVENT, handler); return () => window.removeEventListener(CHAT_RETRY_AUTH_TURN_EVENT, handler); - }, [resendLastUserMessageForAuthRetry]); + }, [resendLastUserMessage]); // When a turn succeeds after a logout, tell visible re-login cards for this // session to collapse into a quiet "Reconnected" confirmation. @@ -10239,6 +10246,8 @@ export function AgentChatPane({ shouldAutofocus={layoutVariant === "grid-tile" ? shouldAutofocusComposer : false} sdkSlashCommands={sdkSlashCommands} modelId={modelId} + modelPickerOpenRequestKey={modelPickerOpenRequestKey} + onModelPickerOpenRequestHandled={handleModelPickerOpenRequestHandled} availableModelIds={effectiveAvailableModelIds} constrainModelSelection={modelSelectionConstrained} modelUnavailableMessage={constrainedModelSelectionError ?? undefined} @@ -11113,6 +11122,7 @@ export function AgentChatPane({ : subagentView ? subagentTranscriptLoading || subagentViewSnapshot?.status === "running" : turnActive && selectedSession?.status !== "ended"} + sessionTurnActive={turnActive} sessionEnded={selectedSession?.status === "ended"} className="min-h-0 border-0" surfaceMode={surfaceMode} @@ -11146,6 +11156,19 @@ export function AgentChatPane({ }} onCodexRecovery={(args: AgentChatRecoverCodexTurnArgs) => window.ade.agentChat.recoverCodexTurn(args)} + onRetryProviderFailure={async (failedTurnId) => { + if (!selectedSessionId) return "This chat is no longer selected."; + if (turnActive) return "A turn is already active in this thread. Wait for it to finish before retrying."; + return resendLastUserMessage(selectedSessionId, failedTurnId); + }} + onChooseProviderFailureModel={() => { + if (turnActive) return; + setModelPickerOpenRequest((request) => ({ + key: (request?.key ?? 0) + 1, + sessionId: modelPickerOpenRequestSessionId, + laneId, + })); + }} mosaic={subagentView || mainTranscriptView ? undefined : mosaicContext} scrollToRowKeyRequest={subagentView || mainTranscriptView ? null : wakeJumpRequest} /> diff --git a/apps/desktop/src/renderer/components/chat/ProviderFailureRecoveryCard.tsx b/apps/desktop/src/renderer/components/chat/ProviderFailureRecoveryCard.tsx new file mode 100644 index 000000000..bad56ac7c --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ProviderFailureRecoveryCard.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import type { AgentChatEvent } from "../../../shared/types"; + +export type ProviderFailureRecovery = { + kind: "capacity" | "rate_limit"; + label: string; + guidance: string; +}; + +export function classifyProviderFailure( + event: Extract, +): ProviderFailureRecovery | null { + const identity = `${ + typeof event.errorInfo === "string" ? event.errorInfo : event.errorInfo?.category ?? "" + } ${event.message}`.toLowerCase().replace(/[^a-z0-9]+/g, ""); + if (identity.includes("serveroverloaded") || identity.includes("modelisatcapacity")) { + return { + kind: "capacity", + label: "Provider capacity", + guidance: "The provider ended this turn because the selected model is at capacity. This thread is still safe to continue.", + }; + } + if (identity.includes("usagelimitexceeded") || identity.includes("ratelimit")) { + return { + kind: "rate_limit", + label: "Usage limit", + guidance: "The provider ended this turn at a usage limit. Retry after the limit resets or choose another available model.", + }; + } + return null; +} + +export function ProviderFailureRecoveryCard({ + recovery, + disabled, + onRetry, + onChooseModel, +}: { + recovery: ProviderFailureRecovery; + disabled: boolean; + onRetry?: () => Promise; + onChooseModel?: () => void; +}) { + const [retryPending, setRetryPending] = useState(false); + const [retryError, setRetryError] = useState(null); + + const retry = async () => { + if (!onRetry || retryPending) return; + setRetryPending(true); + setRetryError(null); + try { + setRetryError(await onRetry()); + } catch (error) { + setRetryError(error instanceof Error ? error.message : String(error)); + } finally { + setRetryPending(false); + } + }; + + return ( +
+
+ {recovery.guidance} +
+
+ + +
+ {retryError ? ( +
+ {retryError} +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index 2524399cd..f2b6a6c30 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -23,6 +23,43 @@ function groupEvents(events: AgentChatEventEnvelope[]) { } describe("chatTranscriptRows", () => { + it("collapses duplicate semantic failures for the same turn without hiding distinct errors", () => { + const base = { + sessionId: "session-1", + timestamp: "2026-07-10T18:18:53.000Z", + }; + const duplicateFailure = { + type: "error" as const, + message: "Selected model is at capacity. Please try a different model.", + turnId: "turn-capacity", + errorInfo: "serverOverloaded", + }; + const rows = collapseChatTranscriptEvents([ + { ...base, sequence: 1, event: duplicateFailure }, + { ...base, sequence: 2, event: { ...duplicateFailure } }, + { + ...base, + sequence: 3, + event: { + type: "error", + message: "A separate transport failure occurred.", + turnId: "turn-capacity", + errorInfo: "responseStreamDisconnected", + }, + }, + ]); + + expect(rows.filter((row) => row.event.type === "error")).toHaveLength(2); + expect(rows.map((row) => row.event.type === "error" ? row.event.message : null)).toEqual([ + duplicateFailure.message, + "A separate transport failure occurred.", + ]); + expect(rows[1]?.event).toEqual(expect.objectContaining({ + type: "error", + errorInfo: "responseStreamDisconnected", + })); + }); + it("extracts and normalizes localhost URLs from tool output text", () => { expect( extractLocalhostUrlsFromText("Local: http://localhost:5173/\nNetwork: http://0.0.0.0:5173/"), diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index 7f6b92990..a623a242b 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -246,10 +246,12 @@ type CollapseTranscriptContext = { latestTodoItemsByTurn: Map; /** Subagent lifecycle state keyed by agentKey (agentId ?? taskId). */ subagentAnchors: Map; + /** Semantic provider failures already rendered for a specific turn. */ + errorKeysByTurn: Set; }; export function createCollapseTranscriptContext(): CollapseTranscriptContext { - return { latestTodoItemsByTurn: new Map(), subagentAnchors: new Map() }; + return { latestTodoItemsByTurn: new Map(), subagentAnchors: new Map(), errorKeysByTurn: new Set() }; } function todoSnapshotKey(turnId: string | null): string { @@ -1128,6 +1130,17 @@ export function appendCollapsedChatTranscriptEvent( } } + if (event.type === "error" && event.turnId?.trim()) { + const semanticKey = JSON.stringify([ + event.turnId.trim(), + event.message.trim(), + event.detail?.trim() ?? null, + event.errorInfo ?? null, + ]); + if (context?.errorKeysByTurn.has(semanticKey)) return; + context?.errorKeysByTurn.add(semanticKey); + } + if (event.type === "system_notice") { if (event.noticeKind === "info" && event.message.trim().toLowerCase() === "session ready") { return; diff --git a/apps/desktop/src/renderer/components/chat/chatTurnState.ts b/apps/desktop/src/renderer/components/chat/chatTurnState.ts new file mode 100644 index 000000000..09c4148a7 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatTurnState.ts @@ -0,0 +1,80 @@ +import type { + AgentChatEvent, + AgentChatEventEnvelope, + AgentChatSessionSummary, +} from "../../../shared/types"; + +function chatEventEndsTurn(event: AgentChatEventEnvelope["event"]): boolean { + return event.type === "done" || (event.type === "status" && event.turnStatus !== "started"); +} + +export function findUserMessageForTurn( + events: AgentChatEventEnvelope[], + turnId: string, +): Extract | null { + let turnAnchor = -1; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]!.event; + if ("turnId" in event && event.turnId === turnId) { + turnAnchor = index; + break; + } + } + if (turnAnchor < 0) return null; + + for (let index = turnAnchor; index >= 0; index -= 1) { + const event = events[index]!.event; + if ( + event.type === "user_message" + && !event.steerId + && typeof event.text === "string" + && event.text.trim().length > 0 + && (!event.turnId || event.turnId === turnId) + ) { + return event; + } + if ( + index < turnAnchor + && chatEventEndsTurn(event) + && (!("turnId" in event) || event.turnId !== turnId) + ) { + break; + } + } + return null; +} + +/** Terminal transcript evidence outranks an eventually-consistent summary. */ +export function transcriptLatestTurnIsTerminal(events: AgentChatEventEnvelope[]): boolean { + let terminalIndex = -1; + let terminalTurnId: string | null = null; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]!.event; + if (!chatEventEndsTurn(event)) continue; + terminalIndex = index; + terminalTurnId = "turnId" in event && typeof event.turnId === "string" ? event.turnId : null; + break; + } + if (terminalIndex < 0) return false; + + for (let index = terminalIndex + 1; index < events.length; index += 1) { + const event = events[index]!.event; + if (event.type === "status" && event.turnStatus === "started") return false; + if (event.type === "user_message") return false; + const eventTurnId = "turnId" in event && typeof event.turnId === "string" ? event.turnId : null; + if (eventTurnId && eventTurnId !== terminalTurnId) return false; + } + return true; +} + +export function resolveTurnActive( + events: AgentChatEventEnvelope[], + derivedTurnActive: boolean, + summary: AgentChatSessionSummary | null | undefined, +): boolean { + if (derivedTurnActive) return true; + return events.length > 0 + && summary?.status === "active" + && summary.awaitingInput !== true + && !transcriptLatestTurnIsTerminal(events); +} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index d213a2e45..c764f7874 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -283,6 +283,34 @@ describe("ModelPicker", () => { expect(screen.getAllByRole("option").length).toBeGreaterThan(0); }); + it("consumes an open request while disabled without reopening after re-enable", async () => { + const onOpenRequestHandled = vi.fn(); + const { rerender } = renderPicker({ + disabled: true, + openRequestKey: 1, + onOpenRequestHandled, + }); + + await waitFor(() => expect(onOpenRequestHandled).toHaveBeenCalledTimes(1)); + expect(screen.getByRole("button", { name: /Select model/i }).getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByRole("listbox", { name: /models/i })).toBeNull(); + + rerender( + , + ); + + expect(screen.getByRole("button", { name: /Select model/i }).getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByRole("listbox", { name: /models/i })).toBeNull(); + expect(onOpenRequestHandled).toHaveBeenCalledTimes(1); + }); + it("respects hidePermissionRail when forwarded", async () => { const user = userEvent.setup(); renderPicker({ hidePermissionRail: true }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index 5cd67096d..61eaebb88 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -57,6 +57,8 @@ export type ModelPickerProps = { hidePermissionRail?: boolean; className?: string; triggerClassName?: string; + openRequestKey?: number; + onOpenRequestHandled?: () => void; }; export const ModelPicker = memo(function ModelPicker({ @@ -81,12 +83,22 @@ export const ModelPicker = memo(function ModelPicker({ hidePermissionRail = false, className, triggerClassName, + openRequestKey, + onOpenRequestHandled, }: ModelPickerProps) { const [open, setOpen] = useState(false); const [runtimeCatalog, setRuntimeCatalog] = useState(() => getSharedRuntimeCatalog()); const [refreshingProvider, setRefreshingProvider] = useState(null); const { recents } = useModelRecents({ hydrate: open }); + useEffect(() => { + if (openRequestKey == null) return; + if (!disabled) { + setOpen(true); + } + onOpenRequestHandled?.(); + }, [disabled, onOpenRequestHandled, openRequestKey]); + // Which cursor discovery source this picker surface needs synchronously: // chat surfaces run models through the SDK, CLI lane drafts through the // cursor-agent CLI. The host probes only this source and lets the other diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index aabe73122..be51ac97e 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -19,9 +19,9 @@ for its separate RPC, sync, storage, and UI contracts. | Path | Role | |---|---| -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns call `stopTask` for active subagents before emitting stopped subagent results. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Large service file. | | `apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx` | **Send to machine** workflow in the Handoff tab: source Git readiness, eligible connected-machine selection, optional continuation note, destination project matching or confirmed clone, storage/auth/model/commit/lane checks, transport disclosure, route-pinned final send, and recoverable source-marker completion. See [Cross-machine session handoff](../sync-and-multi-device/cross-machine-session-handoff.md). | | `apps/desktop/src/shared/crossMachineHandoff.ts` and `apps/desktop/src/shared/types/chat.ts` | Renderer-safe Git-origin normalization, portable remote sanitization, untrusted remote-response decoders, and the versioned capsule/preflight/accept DTOs shared across renderer, preload, Electron main, and the ADE runtime. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns call `stopTask` for active subagents before emitting stopped subagent results. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Large service file. | | `apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts` | Runtime-owned durable scheduler for Claude `ScheduleWakeup`, `CronCreate`, and `/loop`. Persists versioned schedule records and per-chat pause state in the project SQLite `kv` store, restores and re-arms them on service start, coalesces overdue work to one late fire, advances recurring cron work to its next normal occurrence, cancels schedules whose session is missing or archived, and reports transitions back to `agentChatService`. Uses injected time/timer/persistence adapters so restart, pause, collision, and catch-up behavior can be tested without Electron. | | `apps/desktop/src/main/services/chat/externalChatHistoryImport.ts` | Converts external Claude JSONL and Codex thread-turn history into ADE `AgentChatEventEnvelope` rows. It reads at most the last 32 MB of source transcript bytes, keeps the newest 2,000 imported content events, emits system notices for provenance/truncation, drops metadata-only/provider-wrapper user rows without stripping user-authored JSX/XML, preserves failed Claude tool-result status, maps user/assistant text plus tool calls/results/file changes/commands/search/image events where available, and derives a fallback imported-chat title from the first user or assistant text. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | @@ -88,6 +88,8 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx` | Renderer panel for the in-app browser. Renders the address bar, tabs strip, navigation controls, an inspect/select toolbar, and a `BuiltInBrowserStatus`-derived empty/error state, then asks the main process to position the underlying `WebContentsView` over the panel's bounding rect through `ade.builtInBrowser.setBounds`. Because native `WebContentsView` content sits above the renderer, the panel hides it while ADE overlays, dialogs, menus, or popovers overlap the browser surface so ADE chrome remains reachable. Mounted by `WorkSidebar` under the `browser` tab and (indirectly) by any renderer code that calls `openUrlInAdeBrowser()` — the helper opens the sidebar Browser tab and dispatches the URL into a fresh tab. Selections committed through inspect-mode hit-testing fan out via the `onAddContext` callback as `BuiltInBrowserContextItem` payloads. | | `apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx`, `ClaudeLoginPromptButton.tsx` | Shared Work surface header chrome for chat and CLI surfaces: title, lane chip, Claude cache badge, git toolbar, caller-provided trailing actions, and the dismissible Claude login CTA that starts `claude auth login` in a tracked PTY. The `WorkSurfaceTitle` sub-component plays a one-time CSS shimmer when the title transitions from a provider default (`Claude Chat`, `Codex Chat`, …) to a real auto-generated title while the surface stays mounted, and respects `prefers-reduced-motion`. `AgentChatPane` also reuses `ClaudeLoginPromptButton` as a sticky bar above the composer (keyed `composer-auth:`) while a Claude session is logged out, but only when the chat header pill is absent so the two never double up. | | `apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx` | Inline install / re-login card for missing or unauthenticated agent CLIs, rendered in the transcript from a decorated `error` event's `errorInfo.agentCli` payload. Copy chips + a tracked-PTY Run button (`window.ade.pty.create`) for the install / auth command. The logged-out (`category: "unauthenticated"`) variant is terracotta-toned for Claude (amber for other agents), retitles to "<Provider> is logged out", and adds an always-on **Retry turn** button that resends the last user message via the `CHAT_RETRY_AUTH_TURN_EVENT` (`ade:chat:retry-auth-turn`) window event; it collapses to a "Reconnected" confirmation when `AgentChatPane` fires `CHAT_AUTH_RECOVERED_EVENT` (`ade:chat:auth-recovered`) after a later turn succeeds. The "missing CLI" variant keeps the red-free amber install card. | +| `apps/desktop/src/renderer/components/chat/ProviderFailureRecoveryCard.tsx` | Classifies terminal provider capacity and usage-limit errors into actionable transcript cards. The card explains that the thread remains safe, offers an explicit same-thread **Retry turn**, and opens the composer model picker through a one-shot request for **Choose model**; neither action is enabled while another turn is active. | +| `apps/desktop/src/renderer/components/chat/chatTurnState.ts` | Shared renderer turn-state invariant used by cache hydration, history snapshots, live event flushes, and locked-session summary refreshes. A terminal `status`/`done` at the end of the transcript outranks an eventually consistent `status: "active"` session summary, so failed/interrupted turns restore an idle composer. Also resolves the user message associated with a failed turn, including Codex optimistic user rows that predate assignment of a provider `turnId`. | | `apps/desktop/src/renderer/lib/claudeAuthPrompt.ts` | Renderer-side classifier for Claude logged-out / `/login`-required error text. Drives the header and sticky login CTAs; matches both Claude-first wording and ADE's own "Authentication failed for <model>" classified message. | | `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. | | `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. Permission popover rows keep only the mode title in the visible row (the explanation remains in the tooltip/title) for every provider-backed picker. Codex MCP elicitations show Allow once / Deny, conditionally show Always allow, and expose safe URL authorization through ADE's browser. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images read bytes through `ade.app.readClipboardImage` then write them through `ade.agentChat.saveTempAttachment` so remote-bound chats receive a runtime-readable attachment path. The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. | @@ -540,7 +542,9 @@ happen to begin with `User request:`. steer, interrupt, or retry the same thread. 5. On completion the service emits `status: "completed" | "failed" | "interrupted"`, optionally emits a `turn_diff_summary`, flushes - buffered text, and pulls the next queued steer. + buffered text, marks the session idle, and pulls the next queued steer. + A terminal failure also stops still-active child subagents before the + parent goes idle; that child closeout does not keep the parent active. 6. `dispose({ sessionId })` deliberately ends the runtime, persists the final state as `disposed`, and cancels the chat's durable scheduled work. Project close and graceful app quit use the lifecycle variant: live rows become @@ -700,6 +704,22 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. guard: when `getRecentEntries` is called, the service flushes pending buffered text first so transcript reads always reflect the latest streamed content. +- **Provider failures are terminal once, but retry notices are not failures.** + Codex app-server may send an `error` notification before a failed + `turn/completed` carrying the same message. ADE emits one visible `error` + per turn + semantic error identity, then still emits the terminal failed + `status` and `done` markers that release the composer. Distinct errors in + the same turn must remain visible. An app-server error with + `willRetry: true` is instead a `provider_health` notice and must not end or + duplicate the active turn. +- **Terminal transcript evidence outranks a stale active summary.** + `chatTurnState.resolveTurnActive` is the single invariant for snapshot + hydration, resident cache hydration, live flushes, and locked-session + summary refreshes. If the latest transcript turn ends in terminal + `status`/`done` and no later turn starts, an eventually consistent active + summary cannot put the UI back into running/Stop state. Keep every + renderer rehydration path on this helper so failures always restore a + sendable composer. - **Codex runtime recovery events.** MCP startup status notifications are warnings, not model progress. Do not let them clear the no-first-output watchdog. If app-server state can be read, recovered turn items are diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index fa48bc231..68770c393 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -16,8 +16,10 @@ subagents, computer use). The pane derives all visible state from the | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal/stale-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. Also owns the durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout` (fails a step whose runtime call never settles; the underlying IPC is not cancellable, so it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Pure helper for handoff placeholder DTOs, scope keys, stable placeholder ids, status labels, and search matching. `AgentChatPane` writes these jobs into the root store while `TerminalsPage` passes matching jobs into the Work session sidebar. The handoff drawer can offer a brief summarized handoff or a full-history fork when source and target stay within Claude or within Codex; Codex forks use the app-server thread id returned by `thread/fork`. | | `CrossMachineHandoffModal.tsx` | Modal state and user flow for **Send to machine**. It verifies a local source lane, follows live remote connection snapshots, handles existing-project versus confirmed-clone setup, decodes destination responses at the renderer boundary, pins acceptance to the reviewed route kind, and exposes retryable source-marker failures after destination success. | -| `AgentChatMessageList.tsx` | Virtualized message list (`@tanstack/react-virtual`). Renders transcript rows and turn dividers, including a `Woke on schedule` divider before every synthetic scheduled turn and inline `SubagentSpawnCard` / `SubagentResultCard` / `BackgroundFinishChip` rows (from `SubagentActivityCards.tsx`) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the while-you-were-away strip and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details. | -| `AgentChatComposer.tsx` | Text input, attachments, model selector, compact title-only permission controls, slash commands, pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. | +| `AgentChatMessageList.tsx` | Virtualized message list (`@tanstack/react-virtual`). Renders transcript rows and turn dividers, including a `Woke on schedule` divider before every synthetic scheduled turn and inline `SubagentSpawnCard` / `SubagentResultCard` / `BackgroundFinishChip` rows (from `SubagentActivityCards.tsx`) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the while-you-were-away strip and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`; terminal provider capacity/usage-limit errors render `ProviderFailureRecoveryCard` with same-thread retry and model-selection actions. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details. | +| `AgentChatComposer.tsx` | Text input, attachments, model selector, compact title-only permission controls, slash commands, pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. It forwards one-shot open requests to the shared ModelPicker so transcript recovery cards can open model selection without synthetic DOM events; the picker acknowledges each request so remounts do not reopen it. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. | +| `ProviderFailureRecoveryCard.tsx` | Friendly recovery surface for terminal provider capacity and usage-limit failures. Shows human-readable error identity and guidance, then offers **Retry turn** and **Choose model** only after the failed turn has released the composer. | +| `chatTurnState.ts` | Pure turn-state helpers shared by live and hydration paths. Terminal transcript evidence beats a stale active session summary, and failed-turn retry resolves the associated non-steer user message even when the optimistic row has no provider turn id. | | `ChatActionsDrawerPanel.tsx`, `ChatSourcesPanel.tsx`, `chatSources.ts` | Chat Actions tab shell plus Codex Sources view. The source derivation deduplicates files, web queries/results, MCP apps/tools, and external resource URLs from transcript events; safe web rows open in ADE's browser. | | `VoiceDictationButton.tsx`, `apps/desktop/src/renderer/services/globalVoiceRecorder.ts`, `apps/desktop/src/renderer/components/voice/*` | Desktop dictation UI and recorder. The module-level recorder owns mic capture across navigation, writes live state to the root app store, transcribes via `window.ade.transcription`, inserts cleaned text into the registered composer, and always copies the cleaned transcript to the clipboard. The header indicator and composer pill render the same recording state. | | `apps/desktop/src/main/services/transcription/*` | Electron main-process transcription service. Writes captured 16 kHz mono PCM to WAV, runs bundled whisper.cpp `base.en`, parses the JSON sidecar, and applies deterministic glossary cleanup. | @@ -60,6 +62,8 @@ subagents, computer use). The pane derives all visible state from the accumulates envelopes into local state. 2. Derives: - Message rows via `chatTranscriptRows.ts`. + - Active/idle turn state via `chatTurnState.ts`; terminal transcript + evidence takes precedence over an eventually consistent active summary. - Pending inputs via `pendingInput.ts`. - Todo items via `deriveTodoItems()` in `chatExecutionSummary.ts`. - Scheduled/background work via `deriveScheduledWorkSnapshots()`. @@ -88,6 +92,13 @@ and a footer that contains the composer. `AgentChatComposer` supports: +- **Post-failure recovery.** A failed/interrupted turn releases the input and + send controls. Capacity and usage-limit cards can resend the original user + prompt (with its attachments, context items, and metadata) in the same + durable provider thread, or explicitly open the model picker before the + user starts the next turn. Recovery never starts automatically and is + disabled while another turn is active. + - **Text input** with auto-grow up to `composerMaxHeightPx`. Grid tiles pass a fixed 144 px ceiling (computed statically from `layoutVariant`) rather than the old `ResizeObserver`-based 28 %-of-height formula; diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index 85dc7c9c0..3ae9ea059 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -84,6 +84,7 @@ Two helpers summarise a parsed stream: | `pending_input_resolved` | Hidden row; consumed by pending-input derivation to clear UI state. | | `status` | Turn-level lifecycle: `started`, `completed`, `interrupted`, `failed`. | | `done` | Final turn marker with model, model id, usage, cost. Also clears non-question pending inputs when status is not `completed`. | +| `error` | Provider/runtime failure with message, detail, and semantic `errorInfo`. Codex can report the same terminal failure first as an app-server `error` notification and again on failed `turn/completed`; ADE keeps one visible row for the same turn/error identity while preserving distinct failures. | | `activity` | Ephemeral UI hint (thinking, searching, running_command). Hidden from the transcript. | | `todo_update` | Task-list snapshot; consumed by `ChatTasksPanel`. | | `subagent_started` / `subagent_progress` / `subagent_result` | Legacy Claude background subagent lifecycle. Each envelope carries `taskId`, `parentToolUseId`, `description`, and optional `agentId`, `parentAgentId`, and `agentType`: for Claude / ade-code `agentType` is the Task tool's `subagent_type` (stashed at the `tool_use` boundary and joined on `parentToolUseId`); for Codex parallel agents it is a per-turn `Agent #N` label assigned at first announcement and the raw threadId is mirrored as `agentId`; for OpenCode subagents `agentType` is omitted so the row falls back to the `description` (taken from `session.title`). Codex app-server `subAgentActivity` items also flow into these rows and may carry `label`, `model`, and `reasoningEffort` for richer roster labels. Claude SDK runs also stash `taskType` (`subagent` / `background` / `local_workflow` / `cron` / `other`) and `workflowName` at spawn so the renderer can label rows by workflow without re-deriving them per event; ambient/housekeeping tasks (the SDK's `skip_transcript=true` flag — e.g. session-title generation) are filtered out symmetrically across spawn, progress, and completion notifications so the subagent panel never flashes them. The service also emits canonical `subagent.started` / `subagent.progress` / `subagent.completed` rows from `runtimeEvents.ts` so all runtimes can converge on the same envelope. Two additional producers fan into the same three event types: **Claude Workflow runs** — the SDK's undocumented `workflow_progress` snapshot on `system:task_progress` is normalized by `claudeWorkflowProgress.ts` (defensive: malformed entries dropped, previews clipped, counts capped, unknown states degrade to queued/running; an unparseable snapshot leaves the generic task rendering untouched) and diffed per tick into started/progress/result transitions under a stable `::a` / latched-agentId identity, so each workflow agent renders as its own row with phase, tokens, and duration, reconnects upsert instead of duplicating, and agents left running when the workflow ends are closed out as `stopped`; and **child chat spawns** — a session created with `orchestrationParentSessionId` outside an orchestration run (e.g. `ade chat create` from a tracked agent shell) emits synthetic `subagent_started`/`subagent_result` events keyed `chat:` into the parent so the child lists in the parent's subagents panel, its first finished turn reporting completed/failed/stopped. | @@ -170,6 +171,10 @@ implements a two-layer transform: - `transcript_retraction` is also hidden, but mutates the accumulated rows by removing prior assistant `text` rows whose provider `messageId` was retracted or superseded. + - Exact duplicate `error` rows are collapsed by turn id, message, detail, + and semantic `errorInfo`. This replay guard handles historical + transcripts written before provider-side dedupe without hiding distinct + failures from the same turn. 2. **Grouped envelopes.** Adjacent work-log render events in the same turn merge into `work_log_group` blocks. When a `tool_use_summary` @@ -321,8 +326,12 @@ Persisted-history consumers see the stored preview on replay. can use the same session instead of creating a new one. Codex adapters deduplicate repeated lifecycle notifications before -converting them to envelope events, so a restart does not yield -duplicate rows. +converting them to envelope events. Terminal app-server failures use a +bounded semantic key (turn id + message + detail + error identity) shared by +the early notification and failed completion path; retrying notifications stay +non-terminal provider-health notices. The renderer applies the same exact +identity rule while replaying persisted history, so older transcripts do not +regain duplicate visible failures after restart. ## Gotchas