From 15c8d6c417387584839dc3a1e3e222590c7a2968 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:26:44 -0400 Subject: [PATCH 1/3] feat(chat): wire Claude SDK tier 3 stop, context, and auto-continue Default Stop now clears the queue without tearing down background Claude jobs, per-task stop is public, /context classifies by kind, and chats auto-continue at the plan limit unless the user opts out. Co-authored-by: Cursor --- apps/ade-cli/src/adeRpcServer.ts | 1 + apps/ade-cli/src/cli.test.ts | 33 +- apps/ade-cli/src/cli.ts | 73 ++- .../personalChats/personalChatScope.ts | 4 + .../sync/syncRemoteCommandService.test.ts | 2 +- .../services/sync/syncRemoteCommandService.ts | 18 +- apps/ade-cli/src/tuiClient/app.tsx | 33 +- apps/ade-cli/src/tuiClient/commands.ts | 2 +- apps/desktop/src/main/main.ts | 17 + .../main/services/adeActions/registry.test.ts | 3 +- .../src/main/services/adeActions/registry.ts | 1 + .../services/chat/agentChatService.test.ts | 528 +++++++++++++++++- .../main/services/chat/agentChatService.ts | 463 ++++++++++++--- .../src/main/services/ipc/registerIpc.ts | 21 +- .../sessions/chatSessionProjection.ts | 1 + .../sessions/settleTeardownWiring.test.ts | 5 +- .../services/sessions/settleTeardownWiring.ts | 15 +- apps/desktop/src/preload/global.d.ts | 6 + apps/desktop/src/preload/preload.ts | 18 + apps/desktop/src/renderer/browserMock.ts | 1 + .../components/chat/AgentChatComposer.tsx | 38 +- .../chat/AgentChatMessageList.test.tsx | 28 +- .../components/chat/AgentChatMessageList.tsx | 80 ++- .../components/chat/AgentChatPane.test.tsx | 26 +- .../components/chat/AgentChatPane.tsx | 103 +++- .../chat/ChatSubagentsPanel.test.tsx | 39 ++ .../components/chat/ChatSubagentsPanel.tsx | 46 +- .../chat/ProviderFailureRecoveryCard.tsx | 34 +- .../chat/SubagentActivityCards.test.tsx | 24 + .../components/chat/SubagentActivityCards.tsx | 20 +- .../chat/chatTranscriptRows.test.ts | 1 + .../components/chat/chatTranscriptRows.ts | 12 + .../renderer/webclient/adapter/agentChat.ts | 9 + .../desktop/src/shared/chatAutoResume.test.ts | 25 + apps/desktop/src/shared/chatAutoResume.ts | 35 ++ apps/desktop/src/shared/chatStopModes.test.ts | 84 +++ apps/desktop/src/shared/chatStopModes.ts | 136 +++++ .../shared/claudeClassifierContext.test.ts | 55 ++ .../src/shared/claudeClassifierContext.ts | 84 +++ .../src/shared/claudeContextUsage.test.ts | 73 +++ apps/desktop/src/shared/claudeContextUsage.ts | 142 +++++ .../src/shared/claudeModelSwitch.test.ts | 38 ++ apps/desktop/src/shared/claudeModelSwitch.ts | 55 ++ apps/desktop/src/shared/ipc.ts | 1 + .../shared/sessionStatusPresentation.test.ts | 23 +- .../src/shared/sessionStatusPresentation.ts | 15 + .../src/shared/syncMobileCompatibility.ts | 1 + apps/desktop/src/shared/types/chat.ts | 32 +- .../desktop/src/shared/types/personalChats.ts | 3 + apps/desktop/src/shared/types/sessions.ts | 5 + apps/desktop/src/shared/types/sync.ts | 1 + apps/ios/ADE/Models/RemoteModels.swift | 25 +- apps/ios/ADE/Services/SyncService.swift | 18 +- .../Views/Work/WorkChatRichCardViews.swift | 73 ++- .../Work/WorkChatSessionView+Timeline.swift | 8 +- .../ADE/Views/Work/WorkChatSessionView.swift | 140 ++++- .../Work/WorkErrorAndMessageHelpers.swift | 2 +- apps/ios/ADE/Views/Work/WorkModels.swift | 110 ++++ .../Work/WorkSessionCanonicalState.swift | 25 +- .../WorkSessionDestinationView+Actions.swift | 31 + .../Work/WorkSessionDestinationView.swift | 14 +- .../Work/WorkStatusAndFormattingHelpers.swift | 16 + apps/ios/ADETests/ADETests.swift | 140 +++++ docs/features/chat/README.md | 59 +- docs/features/chat/composer-and-ui.md | 34 +- docs/features/chat/transcript-and-turns.md | 12 +- .../sync-and-multi-device/ios-companion.md | 15 +- .../sync-and-multi-device/remote-commands.md | 18 +- 68 files changed, 2944 insertions(+), 309 deletions(-) create mode 100644 apps/desktop/src/shared/chatStopModes.test.ts create mode 100644 apps/desktop/src/shared/chatStopModes.ts create mode 100644 apps/desktop/src/shared/claudeClassifierContext.test.ts create mode 100644 apps/desktop/src/shared/claudeClassifierContext.ts create mode 100644 apps/desktop/src/shared/claudeContextUsage.test.ts create mode 100644 apps/desktop/src/shared/claudeContextUsage.ts create mode 100644 apps/desktop/src/shared/claudeModelSwitch.test.ts create mode 100644 apps/desktop/src/shared/claudeModelSwitch.ts diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 63ed6f230..c11cc8c21 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2683,6 +2683,7 @@ const SCOPED_CHAT_ACTIONS = new Set([ // all CTO-only and refuse a session-bound agent outright — no scoping needed. "interrupt", "interruptWithQueueMode", + "stopTask", "restoreCancelledQueue", "setSpawnKind", "dismissSubagentTakeoverPrompt", diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 9db72c240..ac2eba937 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -4535,19 +4535,48 @@ describe("ADE CLI", () => { "chat-1", "--keep-queue", "--clear-queue", - ])).toThrow(/only one of --mode, --keep-queue, or --clear-queue/); + ])).toThrow(/Use only one of --keep-queue or --clear-queue/); expect(() => buildCliPlan([ "chat", "interrupt", "chat-1", "--arg", "mode=discard_everything", - ])).toThrow(/stop_and_clear or stop_only/); + ])).toThrow(/stop_and_clear, stop_only, stop_and_background, or stop_and_clear_and_background/); expect(() => buildCliPlan([ "chat", "restore-queue", "chat-1", ])).toThrow(/recoveryId/); + + const stopTask = expectExecutePlan(buildCliPlan([ + "chat", + "stop-task", + "chat-1", + "task-A", + ])); + expect(stopTask.label).toBe("chat stop task"); + expect(stopTask.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "stopTask", + args: { sessionId: "chat-1", taskId: "task-A" }, + }, + }); + + const personalStopTask = expectExecutePlan(buildCliPlan([ + "chat", + "stop-task", + "personal-1", + "--personal", + "--task", + "task-B", + ])); + expect(personalStopTask.machineOnly).toBe(true); + expect(personalStopTask.steps[0]?.params).toEqual({ + action: "stopTask", + args: { sessionId: "personal-1", taskId: "task-B" }, + }); }); it("routes chat demote, promote, and keep-reporting to spawn-kind actions", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 85b70abb7..004b907a9 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -78,6 +78,10 @@ import { type AgentChatDroidPermissionMode, } from "../../desktop/src/shared/types/chat"; import type { AgentChatDispatchSteerMode } from "../../desktop/src/shared/types/chat"; +import { + isAgentChatStopMode, + type AgentChatStopMode, +} from "../../desktop/src/shared/chatStopModes"; import { chatTurnStatusExitCode, formatChatTurnStatus, @@ -2175,10 +2179,14 @@ const HELP_BY_COMMAND: Record = { Detach one issue (or all) from a session $ ade chat linear-issues --text List issues attached to a session $ ade chat interrupt Stop an active turn and clear its queued messages + $ ade chat interrupt --keep-queue Stop the turn but preserve queued messages + $ ade chat interrupt --stop-background + Also stop background jobs; combine with --keep-queue + $ ade chat interrupt --mode stop_and_clear | stop_only | stop_and_background | stop_and_clear_and_background + $ ade chat stop-task Stop one Claude background task; siblings keep running $ ade chat demote Take over a subagent: it becomes a peer and reports stop $ ade chat promote Restore a peer as a subagent so it reports to its parent again $ ade chat keep-reporting Dismiss the takeover prompt without changing the report channel - $ ade chat interrupt --keep-queue Stop the turn but preserve queued messages $ ade chat restore-queue Restore a recently cleared queue during its undo window $ ade chat slash --text List slash commands for a session $ ade new chat --mode cli --lane --parent --type subagent --prompt "fix" @@ -3219,21 +3227,28 @@ function readLaneId(args: string[]): string | null { return readValue(args, ["--lane", "--lane-id"]) ?? null; } -function normalizeChatStopMode(value: unknown): "stop_and_clear" | "stop_only" { - if (value === "stop_and_clear" || value === "stop_only") return value; - throw new CliUsageError("chat interrupt --mode must be stop_and_clear or stop_only."); +function normalizeChatStopMode(value: unknown): AgentChatStopMode { + if (isAgentChatStopMode(value)) return value; + throw new CliUsageError("chat interrupt --mode must be stop_and_clear, stop_only, stop_and_background, or stop_and_clear_and_background."); } -function readChatStopMode(args: string[]): "stop_and_clear" | "stop_only" { +function readChatStopMode(args: string[]): AgentChatStopMode { const explicitMode = readValue(args, ["--mode"]); const keepQueue = readFlag(args, ["--keep-queue", "--stop-only"]); const clearQueue = readFlag(args, ["--clear-queue", "--stop-and-clear"]); - const selected = [explicitMode, keepQueue ? "stop_only" : null, clearQueue ? "stop_and_clear" : null] - .filter((value): value is string => value !== null); - if (selected.length > 1) { - throw new CliUsageError("Use only one of --mode, --keep-queue, or --clear-queue."); + const stopBackground = readFlag(args, ["--stop-background"]); + if (explicitMode && (keepQueue || clearQueue || stopBackground)) { + throw new CliUsageError("Use --mode or the queue/background flags, not both."); + } + if (keepQueue && clearQueue) { + throw new CliUsageError("Use only one of --keep-queue or --clear-queue."); } - return normalizeChatStopMode(selected[0] ?? "stop_and_clear"); + if (explicitMode) return normalizeChatStopMode(explicitMode); + if (keepQueue && stopBackground) return "stop_and_background"; + if (clearQueue && stopBackground) return "stop_and_clear_and_background"; + if (stopBackground) return "stop_and_background"; + if (keepQueue) return "stop_only"; + return "stop_and_clear"; } /** @@ -8074,6 +8089,27 @@ function buildChatPlan(args: string[]): CliPlan { ], }; } + if (sub === "stop-task") { + const taskId = requireValue( + readValue(args, ["--task", "--task-id"]) ?? firstStandalonePositional(args), + "taskId", + ); + return { + kind: "execute", + label: "chat stop task", + steps: [ + actionStep( + "result", + "chat", + "stopTask", + withSession({ + sessionId: requireValue(sessionId, "sessionId"), + taskId, + }), + ), + ], + }; + } if ( sub === "restore-queue" || sub === "restore-cancelled-queue" @@ -8664,6 +8700,7 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan { "configure", "interrupt", "stop", + "stop-task", "restore-queue", "restore-cancelled-queue", "undo-stop", @@ -8680,7 +8717,7 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan { "status", ]); if (!sessionSubcommands.has(sub)) { - throw new CliUsageError(`Personal chats support actions, action, list, create, show, read, send, steer, update, models, model-catalog, interrupt, restore-queue, recover, resolve-unprocessed, archive, unarchive, or delete; got '${sub}'.`); + throw new CliUsageError(`Personal chats support actions, action, list, create, show, read, send, steer, update, models, model-catalog, interrupt, stop-task, restore-queue, recover, resolve-unprocessed, archive, unarchive, or delete; got '${sub}'.`); } const sessionId = requireValue( @@ -8770,6 +8807,20 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan { steps: [personalChatStep("interrupt", interruptArgs)], }; } + if (sub === "stop-task") { + const taskId = requireValue( + readValue(args, ["--task", "--task-id"]) ?? firstStandalonePositional(args), + "taskId", + ); + return { + ...base, + label: "personal chat stop task", + steps: [personalChatStep("stopTask", collectGenericObjectArgs(args, { + sessionId, + taskId, + }))], + }; + } if ( sub === "restore-queue" || sub === "restore-cancelled-queue" diff --git a/apps/ade-cli/src/services/personalChats/personalChatScope.ts b/apps/ade-cli/src/services/personalChats/personalChatScope.ts index fceb5affe..24d158282 100644 --- a/apps/ade-cli/src/services/personalChats/personalChatScope.ts +++ b/apps/ade-cli/src/services/personalChats/personalChatScope.ts @@ -461,6 +461,10 @@ export class PersonalChatScope { await this.requirePersonalSession(service, readSessionId(args)); result = await service.interrupt(args as never); break; + case "stopTask": + await this.requirePersonalSession(service, readSessionId(args)); + result = await service.stopTask(args as never); + break; case "restoreCancelledQueue": await this.requirePersonalSession(service, readSessionId(args)); result = await service.restoreCancelledQueue(args as never); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 52e9d3da5..74f9b1a17 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -1095,7 +1095,7 @@ describe("createSyncRemoteCommandService", () => { await expect(service.execute(makePayload("chat.interrupt", { sessionId: "chat-1", mode: "discard_everything", - }))).rejects.toThrow(/stop_and_clear.*stop_only/); + }))).rejects.toThrow(/known stop mode/); await expect(service.execute(makePayload("chat.restoreCancelledQueue", { sessionId: "chat-1", }))).rejects.toThrow(/requires recoveryId/); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 4f803d1d7..cb1f84506 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -9,6 +9,7 @@ import { isAgentChatTurnRecoveryAction, normalizeAgentChatSessionMetadataFields, } from "../../../../desktop/src/shared/types/chat"; +import { isAgentChatStopMode } from "../../../../desktop/src/shared/chatStopModes"; import { runWithAbortSignal } from "./abortSignal"; import { projectAttachmentsDir } from "../../../../desktop/src/shared/chatAttachmentStagingFs"; import { assertCursorCloudRenameAllowed } from "../../../../desktop/src/shared/cursorCloudNaming"; @@ -66,6 +67,7 @@ import type { AgentChatDispatchSteerArgs, AgentChatCancelDispatchedSteerArgs, AgentChatInterruptArgs, + AgentChatStopTaskArgs, AgentChatRestoreCancelledQueueArgs, AgentChatRecoverCodexTurnArgs, AgentChatRecoverTurnArgs, @@ -2605,8 +2607,8 @@ function parseAgentChatCancelDispatchedSteerArgs(value: Record) function parseAgentChatInterruptArgs(value: Record): AgentChatInterruptArgs { const mode = value.mode; - if (mode !== undefined && mode !== "stop_and_clear" && mode !== "stop_only") { - throw new Error("chat.interrupt mode must be 'stop_and_clear' or 'stop_only'."); + if (mode !== undefined && !isAgentChatStopMode(mode)) { + throw new Error("chat.interrupt mode must be a known stop mode."); } return { sessionId: requireString(value.sessionId, "chat.interrupt requires sessionId."), @@ -2614,6 +2616,13 @@ function parseAgentChatInterruptArgs(value: Record): AgentChatI }; } +function parseAgentChatStopTaskArgs(value: Record): AgentChatStopTaskArgs { + return { + sessionId: requireString(value.sessionId, "chat.stopTask requires sessionId."), + taskId: requireString(value.taskId, "chat.stopTask requires taskId."), + }; +} + function parseAgentChatRestoreCancelledQueueArgs(value: Record): AgentChatRestoreCancelledQueueArgs { return { sessionId: requireString(value.sessionId, "chat.restoreCancelledQueue requires sessionId."), @@ -4777,6 +4786,11 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio const result = await requireService(args.agentChatService, "Agent chat service not available.").interrupt(parseAgentChatInterruptArgs(payload)); return { ...result, ok: true }; }); + register("chat.stopTask", { viewerAllowed: true, queueable: false }, async (payload) => { + const result = await requireService(args.agentChatService, "Agent chat service not available.") + .stopTask(parseAgentChatStopTaskArgs(payload)); + return { ...result, ok: true }; + }); register("chat.restoreCancelledQueue", { viewerAllowed: true, queueable: false }, async (payload) => { const result = await requireService(args.agentChatService, "Agent chat service not available.") .restoreCancelledQueue(parseAgentChatRestoreCancelledQueueArgs(payload)); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 20880a716..dfffe349d 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -20,6 +20,12 @@ import { unsupportedActiveTurnDispatchModeMessage, } from "../../../desktop/src/shared/types/chat"; import { providerDisplayLabel } from "../../../desktop/src/shared/pendingInputLabels"; +import { + parseAgentChatStopMode, + resolveAgentChatStopModeAlias, + stopModeClearsQueue, + stopModeStopsBackground, +} from "../../../desktop/src/shared/chatStopModes"; import { composerFileSearchQuery, composerTriggerForSelection, @@ -1329,14 +1335,16 @@ function formatTokenSummary(stats: ReturnType): string } function chatInterruptNotice(result: Awaited>): string { - if (result.mode === "stop_only") { - return "Stopped. Queued messages are preserved."; + const stoppedBackground = stopModeStopsBackground(parseAgentChatStopMode(result.mode)); + const backgroundNote = stoppedBackground ? " Background jobs were stopped." : ""; + if (!stopModeClearsQueue(parseAgentChatStopMode(result.mode))) { + return `Stopped. Queued messages are preserved.${backgroundNote}`; } if (result.cancelledQueuedCount > 0 && result.recoveryId) { const noun = result.cancelledQueuedCount === 1 ? "message" : "messages"; - return `Stopped and cleared ${result.cancelledQueuedCount} queued ${noun}. Undo: /restore-queue ${result.recoveryId}`; + return `Stopped and cleared ${result.cancelledQueuedCount} queued ${noun}. Undo: /restore-queue ${result.recoveryId}${backgroundNote}`; } - return "Stopped."; + return `Stopped.${backgroundNote}`; } export function formatGoalBannerLine(goal: CodexThreadGoal | ClaudeActiveGoal | null): string | null { @@ -12321,18 +12329,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } const normalizedMode = args.trim().toLowerCase(); - let mode: AgentChatStopMode; - if (!normalizedMode || normalizedMode === "clear-queue" || normalizedMode === "--clear-queue") { - mode = "stop_and_clear"; - } else if ( - normalizedMode === "keep-queue" - || normalizedMode === "--keep-queue" - || normalizedMode === "stop-only" - || normalizedMode === "--stop-only" - ) { - mode = "stop_only"; - } else { - addNotice("Usage: /stop [keep-queue|clear-queue]", "error"); + const mode = normalizedMode + ? resolveAgentChatStopModeAlias(normalizedMode) + : "stop_and_clear"; + if (!mode) { + addNotice("Usage: /stop [keep-queue|clear-queue|background|clear-and-background]", "error"); return; } setStreaming(false); diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index e633bedb5..f12b901b7 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -68,7 +68,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/redo", description: "Redo the most recently undone HEAD change on the active lane", placement: "inline", category: "Lanes" }, { name: "/stage all", description: "Stage all changes in the active lane", placement: "inline", category: "Lanes" }, { name: "/clear", description: "Clear the local terminal transcript view", placement: "inline", category: "Chats" }, - { name: "/stop", description: "Stop the active turn and clear or preserve its queue", placement: "inline", argumentHint: "[keep-queue|clear-queue]", category: "Chats" }, + { name: "/stop", description: "Stop the active turn; optionally keep the queue or stop background jobs", placement: "inline", argumentHint: "[keep-queue|clear-queue|background|clear-and-background]", category: "Chats" }, { name: "/restore-queue", description: "Undo a recent Stop & clear queue", placement: "inline", argumentHint: "", category: "Chats" }, { name: "/login", description: "Sign in to the active CLI-backed provider from this terminal", placement: "inline", category: "Nav" }, // The way back from an account-side machine removal — the same repair diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index e14f3fca2..dad5e02ed 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3910,6 +3910,23 @@ app.whenReady().then(async () => { analytics: productAnalyticsService, properties, }), + onUsageLimitAutoResumed: ({ sessionId, title }) => { + if (!Notification.isSupported()) return; + try { + const notification = new Notification({ + title: "Chat resumed", + body: title?.trim() + ? `"${title.trim()}" continued after its usage limit reset.` + : "A chat continued after its usage limit reset.", + }); + notification.show(); + } catch (error) { + logger.warn("agent_chat.usage_limit_resume_notification_failed", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + }, onSessionEnded: onTrackedSessionEnded, getDirtyFileTextForPath: async (absPath: string) => { const trimmed = absPath.trim(); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 1805c16d6..fc8e0634f 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -154,6 +154,7 @@ describe("isAllowedAdeAction", () => { expect(isAllowedAdeAction("chat", "getCodexGoal")).toBe(true); expect(isAllowedAdeAction("chat", "resetCodexMemory")).toBe(true); expect(isAllowedAdeAction("chat", "terminateCodexBackgroundTerminal")).toBe(true); + expect(isAllowedAdeAction("chat", "stopTask")).toBe(true); expect(isAllowedAdeAction("git", "getCommit")).toBe(true); }); @@ -212,7 +213,7 @@ describe("isAllowedAdeAction", () => { "resolveSmartLinkPreview", "resolveUnprocessedMessage", "respondToInput", "restoreCancelledQueue", "rewindFiles", "saveTempAttachment", "sendMessage", "setClaudeOutputStyle", "setCodexGoal", "setCodexGoalStatus", "setParallelLaunchState", - "setScheduledWorkPaused", "steer", "suggestLaneNameFromPrompt", + "setScheduledWorkPaused", "steer", "stopTask", "suggestLaneNameFromPrompt", "terminateCodexBackgroundTerminal", "unarchiveSession", "updateSession", "validateCrossMachineSource", "warmupModel", // src/main/services/remoteRuntime/remoteConnectionService.ts diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index bdab4e958..05e8cca08 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -609,6 +609,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { includeHookEvents?: boolean; promptSuggestions?: boolean; + perTaskStopAffordance?: boolean; + autoContinueAtUsageLimit?: boolean; settingSources?: string[]; settings?: { enabledPlugins?: Record; @@ -3756,6 +3758,8 @@ describe("createAgentChatService", () => { ])); expect(opts?.includeHookEvents).toBe(true); expect(opts?.promptSuggestions).toBe(true); + expect(opts?.perTaskStopAffordance).toBe(true); + expect(opts?.autoContinueAtUsageLimit).toBe(true); // No settings file names a style here, so ADE must not name one either: // its settings land at flag tier, above every file the SDK reads, so an // "outputStyle" key would override the user's global selection. @@ -10849,6 +10853,187 @@ describe("createAgentChatService", () => { expect(close.mock.calls.length).toBeGreaterThan(0); }); + it("parks the session while Claude waits on a usage-limit reset and clears park on opt-out", async () => { + const resetsAt = Math.floor((Date.now() + 3_600_000) / 1000); + const send = vi.fn().mockResolvedValue(undefined); + const close = vi.fn(); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "rate_limit_event", + session_id: "sdk-session-park", + rate_limit_info: { + status: "rejected", + utilization: 1, + resetsAt, + }, + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, stream, close, sessionId: "sdk-session-park", + } as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue({ + send, stream, close, sessionId: "sdk-session-park", + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "hit the limit", + timeoutMs: 15_000, + }); + const parked = await service.getSessionSummary(session.id); + expect(parked?.usageLimitParkedUntil).toEqual(expect.any(String)); + expect(Date.parse(parked!.usageLimitParkedUntil!)).toBeGreaterThan(Date.now()); + + await service.updateSession({ + sessionId: session.id, + autoContinueAtUsageLimit: false, + }); + const optedOut = await service.getSessionSummary(session.id); + expect(optedOut?.autoContinueAtUsageLimit).toBe(false); + expect(optedOut?.usageLimitParkedUntil ?? null).toBeNull(); + }); + + it("Don't continue interrupts a busy Claude query without closing the session", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let hangResolve: (() => void) | null = null; + const hangPromise = new Promise((resolve) => { hangResolve = resolve; }); + const close = vi.fn(); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-opt-out-busy", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "still working" }, + }, + }; + await hangPromise; + return; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close, + sessionId: "sdk-opt-out-busy", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await vi.waitFor(() => { + expect(warmupComplete).toBe(true); + }); + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Please keep working", + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => event.event.type === "text", + ); + + await service.updateSession({ + sessionId: session.id, + autoContinueAtUsageLimit: false, + }); + + const optedOut = await service.getSessionSummary(session.id); + expect(optedOut?.autoContinueAtUsageLimit).toBe(false); + const interruptedStatuses = events.filter( + (event) => event.event.type === "status" && event.event.turnStatus === "interrupted", + ); + const interruptedDone = events.filter( + (event) => event.event.type === "done" && event.event.status === "interrupted", + ); + expect(interruptedStatuses.length).toBeGreaterThan(0); + expect(interruptedDone.length).toBeGreaterThan(0); + expect(close).not.toHaveBeenCalled(); + + hangResolve!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); + + it("clears usage-limit park on a user send without notifying", async () => { + const onUsageLimitAutoResumed = vi.fn(); + const resetsAt = Math.floor((Date.now() + 3_600_000) / 1000); + const send = vi.fn().mockResolvedValue(undefined); + const close = vi.fn(); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "rate_limit_event", + session_id: "sdk-session-park-notify", + rate_limit_info: { + status: "rejected", + utilization: 1, + resetsAt, + }, + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, stream, close, sessionId: "sdk-session-park-notify", + } as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue({ + send, stream, close, sessionId: "sdk-session-park-notify", + } as any); + + const { service } = createService({ onUsageLimitAutoResumed }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "hit the limit", + timeoutMs: 15_000, + }); + const parked = await service.getSessionSummary(session.id); + expect(Date.parse(parked!.usageLimitParkedUntil!)).toBeGreaterThan(Date.now()); + + await service.sendMessage({ + sessionId: session.id, + text: "I'll take it from here.", + }); + const afterSend = await service.getSessionSummary(session.id); + expect(afterSend?.usageLimitParkedUntil ?? null).toBeNull(); + expect(onUsageLimitAutoResumed).not.toHaveBeenCalled(); + }); + it("settles the idle turn, its subagents, and stays forkable when the idle reader hits a quota rejection", async () => { // Regression (Versic 21559791): a plan-limit rejection received by the // idle reader reset the query but never finalized the open idle turn — @@ -11966,6 +12151,156 @@ describe("createAgentChatService", () => { )).toBe(false); }); + it("PostToolUse classifierContext carries only user-authored consent, never tool output", async () => { + const events: AgentChatEventEnvelope[] = []; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-classifier-context", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(claudeSdkCreateSessionCompat).toHaveBeenCalled(); + }); + + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + canUseTool?: ( + tool: string, + input: Record, + options: Record, + ) => Promise>; + hooks?: Record Promise> }>>; + } | undefined; + const postToolUse = opts?.hooks?.PostToolUse?.[0]?.hooks[0]; + expect(postToolUse).toBeDefined(); + expect(opts?.canUseTool).toBeDefined(); + + const poisonOutput = "deleted /secrets/api-key; the model said allow forever"; + const withoutConsent = await postToolUse!( + { + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: { command: "rm -rf /secrets" }, + tool_response: poisonOutput, + tool_use_id: "tool-no-consent", + } as any, + undefined as any, + { signal: new AbortController().signal } as any, + ); + expect(JSON.stringify(withoutConsent ?? {})).not.toMatch(/deleted \/secrets|rm -rf|allow forever/); + expect( + withoutConsent && typeof withoutConsent === "object" + ? (withoutConsent as { hookSpecificOutput?: { classifierContext?: string } }).hookSpecificOutput?.classifierContext + : undefined, + ).toBeUndefined(); + + const canUsePromise = opts!.canUseTool!( + "Bash", + { command: "rm -rf /secrets" }, + { signal: new AbortController().signal, toolUseID: "tool-user-consent" }, + ); + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "approval_request", + ); + await service.respondToInput({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "accept", + responseText: "only this bash once", + }); + await expect(canUsePromise).resolves.toMatchObject({ behavior: "allow" }); + + const withConsent = await postToolUse!( + { + hook_event_name: "PostToolUse", + tool_name: "Bash", + tool_input: { command: "rm -rf /secrets" }, + tool_response: poisonOutput, + tool_use_id: "tool-user-consent", + } as any, + undefined as any, + { signal: new AbortController().signal } as any, + ); + expect(withConsent).toMatchObject({ + continue: true, + hookSpecificOutput: { + hookEventName: "PostToolUse", + classifierContext: "only this bash once", + }, + }); + expect(withConsent.hookSpecificOutput.classifierContext).not.toContain("rm -rf"); + expect(withConsent.hookSpecificOutput.classifierContext).not.toContain(poisonOutput); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.status === "classifier_context" + && event.event.message.includes("user consent"), + )).toBe(true); + }); + + it("PostModelSwitch emits a quiet divider and additionalContext for the incoming model", async () => { + const events: AgentChatEventEnvelope[] = []; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn(), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-model-switch", + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await vi.waitFor(() => { + expect(claudeSdkCreateSessionCompat).toHaveBeenCalled(); + }); + + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + hooks?: Record Promise> }>>; + } | undefined; + const callback = opts?.hooks?.PostModelSwitch?.[0]?.hooks[0]; + expect(callback).toBeDefined(); + const result = await callback!( + { + hook_event_name: "PostModelSwitch", + from_model: "claude-opus-4-6", + to_model: "Sonnet 5", + requested_model: "sonnet", + } as any, + undefined as any, + { signal: new AbortController().signal } as any, + ); + expect(result.hookSpecificOutput.additionalContext).toContain("claude-opus-4-6"); + expect(result.hookSpecificOutput.additionalContext).toContain("sonnet"); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.status === "model_switched" + && event.event.message.includes("switched to Sonnet 5") + && event.event.message.includes("requested \"sonnet\""), + )).toBe(true); + }); + it("emits failed tool results from PostToolUseFailure hooks", async () => { const events: AgentChatEventEnvelope[] = []; vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ @@ -14703,7 +15038,7 @@ describe("createAgentChatService", () => { && (e.event as any).id === "background:bg-int" && (e.event as any).status === "running"); - await service.interrupt({ sessionId: session.id }); + await service.interrupt({ sessionId: session.id, mode: "stop_and_clear_and_background" }); // Interrupt is a genuine teardown — the query is gone, so settle stopped. await waitForEvent(events, (e): e is AgentChatEventEnvelope => @@ -14719,6 +15054,112 @@ describe("createAgentChatService", () => { await expect(sendPromise).resolves.toBeUndefined(); }); + it("default interrupt leaves background tasks running once per-task stop exists", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let hangResolve: (() => void) | null = null; + const hangPromise = new Promise((resolve) => { hangResolve = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stopTask = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-bg-spare", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "system", + subtype: "task_started", + task_id: "bg-spare", + description: "long lived background", + command: "tail -f log", + task_type: "background", + }; + await hangPromise; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, stream, close: vi.fn(), sessionId: "sdk-bg-spare", setPermissionMode, stopTask, + } as any); + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + await vi.waitFor(() => { expect(warmupComplete).toBe(true); }); + const sendPromise = service.sendMessage({ sessionId: session.id, text: "start bg" }); + + await waitForEvent(events, (e): e is AgentChatEventEnvelope => + e.event.type === "scheduled_work_update" + && (e.event as any).id === "background:bg-spare" + && (e.event as any).status === "running"); + + await service.interrupt({ sessionId: session.id }); + + expect(stopTask).not.toHaveBeenCalled(); + expect(events.some((e) => + e.event.type === "scheduled_work_update" + && (e.event as any).id === "background:bg-spare" + && (e.event as any).status === "stopped")).toBe(false); + + hangResolve!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); + + it("stops one running subagent and leaves siblings running", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let hangResolve: (() => void) | null = null; + const hangPromise = new Promise((resolve) => { hangResolve = resolve; }); + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const stopTask = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-stop-one", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { type: "assistant", message: { id: "m-stop-one", content: [ + { type: "tool_use", id: "toolu_A", name: "Task", input: { subagent_type: "Explore", description: "review A", prompt: "a" } }, + { type: "tool_use", id: "toolu_B", name: "Task", input: { subagent_type: "Explore", description: "review B", prompt: "b" } }, + ], usage: { input_tokens: 1, output_tokens: 1 } } }; + yield { type: "system", subtype: "task_started", task_id: "task-A", agent_id: "agent-A", parent_tool_use_id: "toolu_A", description: "review A" }; + yield { type: "system", subtype: "task_started", task_id: "task-B", agent_id: "agent-B", parent_tool_use_id: "toolu_B", description: "review B" }; + await hangPromise; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, stream, close: vi.fn(), sessionId: "sdk-stop-one", setPermissionMode, stopTask, + } as any); + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + await vi.waitFor(() => { expect(warmupComplete).toBe(true); }); + const sendPromise = service.sendMessage({ sessionId: session.id, text: "spawn A and B" }); + + await waitForEvent(events, (e): e is AgentChatEventEnvelope => + e.event.type === "subagent_started" && (e.event as any).taskId === "task-B"); + + await expect(service.stopTask({ sessionId: session.id, taskId: "task-A" })) + .resolves.toMatchObject({ stopped: true, taskId: "task-A" }); + expect(stopTask).toHaveBeenCalledTimes(1); + expect(stopTask).toHaveBeenCalledWith("task-A"); + + await waitForEvent(events, (e): e is AgentChatEventEnvelope => + e.event.type === "subagent_result" + && (e.event as any).taskId === "task-A" + && (e.event as any).status === "stopped"); + expect(events.some((e) => + e.event.type === "subagent_result" && (e.event as any).taskId === "task-B")).toBe(false); + + hangResolve!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); + it("splits the background level into working and monitoring counts on the session summary", async () => { // The classifier is a DENYLIST: only a type whose whole job is to watch // (`monitor`) reads as monitoring. A generic backgrounded shell, a real @@ -39327,8 +39768,10 @@ describe("createAgentChatService", () => { e.event.type === "subagent_started" && (e.event as any).taskId === "sub-task-2", ); - // Now interrupt — should emit subagent_result "stopped" for both - await service.interrupt({ sessionId: session.id }); + // Now interrupt on the background-killing axis — should emit + // subagent_result "stopped" for both. Default Stop (stop_and_clear) + // spares them once per-task stop exists. + await service.interrupt({ sessionId: session.id, mode: "stop_and_clear_and_background" }); const stoppedEvents = events.filter( (e) => e.event.type === "subagent_result" && (e.event as any).status === "stopped", @@ -39495,7 +39938,7 @@ describe("createAgentChatService", () => { await expect(sendPromise).resolves.toBeUndefined(); }); - it("emits a single interrupted status and done event and closes the Claude session", async () => { + it("emits a single interrupted status and done event without closing the Claude session", async () => { const events: AgentChatEventEnvelope[] = []; let streamCall = 0; let warmupComplete = false; @@ -39566,7 +40009,8 @@ describe("createAgentChatService", () => { ); expect(interruptedStatuses).toHaveLength(1); expect(interruptedDone).toHaveLength(1); - expect(close).toHaveBeenCalledTimes(1); + // Default Stop spares background work, so the query stays alive. + expect(close).not.toHaveBeenCalled(); hangResolve!(); await expect(sendPromise).resolves.toBeUndefined(); @@ -39579,6 +40023,68 @@ describe("createAgentChatService", () => { )).toHaveLength(1); }); + it("closes the Claude session when Stop also kills background tasks", async () => { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let warmupComplete = false; + let hangResolve: (() => void) | null = null; + const hangPromise = new Promise((resolve) => { hangResolve = resolve; }); + const close = vi.fn(); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-bg-kill-interrupt", slash_commands: [] }; + warmupComplete = true; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "stream_event", + event: { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "still working" }, + }, + }; + await hangPromise; + return; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close, + sessionId: "sdk-bg-kill-interrupt", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await vi.waitFor(() => { expect(warmupComplete).toBe(true); }); + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Please keep working", + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => event.event.type === "text", + ); + + await service.interrupt({ + sessionId: session.id, + mode: "stop_and_clear_and_background", + }); + expect(close).toHaveBeenCalledTimes(1); + + hangResolve!(); + await expect(sendPromise).resolves.toBeUndefined(); + }); + it("bounds hung Claude interrupt and subagent stop calls below the desktop action timeout", async () => { try { const events: AgentChatEventEnvelope[] = []; @@ -39623,7 +40129,10 @@ describe("createAgentChatService", () => { vi.useFakeTimers(); let interruptSettled = false; - const interruptPromise = service.interrupt({ sessionId: session.id }).then(() => { + const interruptPromise = service.interrupt({ + sessionId: session.id, + mode: "stop_and_clear_and_background", + }).then(() => { interruptSettled = true; }); await vi.advanceTimersByTimeAsync(1_999); @@ -39701,7 +40210,7 @@ describe("createAgentChatService", () => { }); }); - it("resumes through a fresh SDK session after interrupt so stale stream text is not replayed", async () => { + it("resumes through a fresh SDK session after a background-killing interrupt so stale stream text is not replayed", async () => { const events: AgentChatEventEnvelope[] = []; let primaryStreamCall = 0; let releaseInterruptedStream = false; @@ -39792,7 +40301,10 @@ describe("createAgentChatService", () => { event.event.type === "text" && event.event.text.includes("partial first answer"), ); - await service.interrupt({ sessionId: session.id }); + await service.interrupt({ + sessionId: session.id, + mode: "stop_and_clear_and_background", + }); releaseInterruptedStream = true; await firstTurn; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 162add9e8..658b043b5 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -302,6 +302,8 @@ import type { AgentChatInteractionMode, AgentChatInterruptArgs, AgentChatInterruptResult, + AgentChatStopTaskArgs, + AgentChatStopTaskResult, AgentChatRestoreCancelledQueueArgs, AgentChatRestoreCancelledQueueResult, AgentChatStopMode, @@ -576,7 +578,32 @@ import { snapshotFromClaudeSessionQuotaText, type ClaudeSessionQuotaSnapshot, } from "../../../shared/claudeSessionQuota"; -import { isAutoResumeScheduledWork } from "../../../shared/chatAutoResume"; +import { + autoResumeFireAtMs, + isAutoResumeScheduledWork, + isPendingAutoResumeScheduledWork, + isUsageLimitChatError, + sessionAutoContinueAtUsageLimit, +} from "../../../shared/chatAutoResume"; +import { + CLAUDE_PER_TASK_STOP_CONTROLS_REACHABLE, + DEFAULT_AGENT_CHAT_STOP_MODE, + parseAgentChatStopMode, + shouldDeclarePerTaskStopAffordance, + stopModeClearsQueue, + stopModeStopsBackground, +} from "../../../shared/chatStopModes"; +import { + buildClassifierContext, + classifierContextAuditMessage, + type UserAuthoredClassifierInput, +} from "../../../shared/claudeClassifierContext"; +import { normalizeClaudeContextUsage as normalizeStructuredClaudeContextUsage } from "../../../shared/claudeContextUsage"; +import { + claudeModelSwitchAdditionalContext, + claudeModelSwitchDividerMessage, + parseClaudeModelSwitchArgs, +} from "../../../shared/claudeModelSwitch"; import { createChatAutoResumeCoordinator } from "./chatAutoResumeCoordinator"; import type { ChatAutoResumeAnalyticsProperties } from "./chatAutoResumeCoordinator"; import { buildAdeCliAgentGuidance } from "../../../shared/adeCliGuidance"; @@ -1250,6 +1277,9 @@ type PersistedChatState = { sessionProfile?: "light" | "workflow"; reasoningEffort?: string | null; fastMode?: boolean; + /** Explicit `false` is the per-chat opt-out; absent means on. */ + autoContinueAtUsageLimit?: boolean; + usageLimitParkedUntil?: string | null; codexServiceTier?: string | null; executionMode?: AgentChatExecutionMode | null; interactionMode?: AgentChatInteractionMode | null; @@ -1974,6 +2004,12 @@ type ClaudeRuntime = { interrupted: boolean; /** Set when early interrupt events have been emitted to avoid duplicate emission later. */ interruptEventsEmitted: boolean; + /** + * User Stop chose a mode that spares background tasks (`stop_only` / + * `stop_and_clear`). The query must stay alive so those tasks can finish; + * settling them here would undo `perTaskStopAffordance`. + */ + spareBackgroundOnInterrupt?: boolean; /** Set when a reasoning effort change is requested mid-turn; flushed when idle. */ pendingSessionReset?: boolean; /** Clear Claude SDK continuity on the deferred reset; used after mode changes the old session cannot apply. */ @@ -1988,6 +2024,12 @@ type ClaudeRuntime = { resumeIdleWatchdog?: (() => void) | null; /** Set after we've emitted the once-per-session "Approaching plan limit" notice. */ rateLimitWarningEmitted: boolean; + /** + * User-authored classifierContext keyed by tool_use_id, captured at the + * approval waiter and consumed by PostToolUse. Never holds tool output or + * model text. + */ + pendingClassifierContextByToolUseId: Map; }; /** @@ -3533,6 +3575,11 @@ type ManagedChatSession = { runtimeInvalidated: boolean; /** Set after we've emitted the once-per-session Claude plan-limit notice. */ claudeRateLimitWarningEmitted: boolean; + /** + * ISO instant this chat is parked waiting for a usage-limit reset. Used when + * Claude's SDK-native auto-continue is waiting (no ADE scheduled-work row). + */ + usageLimitParkedUntil?: string | null; /** * In-memory only: this chat's Claude session UUID hit a hard plan/session * quota. ADE restart clears it. The next send reaps (already done) and @@ -8334,6 +8381,7 @@ export function createAgentChatService(args: { * `ChatAutoResumeAnalyticsProperties`. */ onAutoResumeOutcome?: (properties: ChatAutoResumeAnalyticsProperties) => void; + onUsageLimitAutoResumed?: (args: { sessionId: string; title?: string | null }) => void; onSessionEnded?: (args: { laneId: string; sessionId: string; exitCode: number | null }) => void; onLinearIssueChatLinked?: (args: { laneId: string; @@ -8404,6 +8452,7 @@ export function createAgentChatService(args: { onChatMentionsExpanded, onSessionMetadataRegenerated, onAutoResumeOutcome, + onUsageLimitAutoResumed, onSessionEnded, onLinearIssueChatLinked, getDirtyFileTextForPath, @@ -9213,6 +9262,19 @@ export function createAgentChatService(args: { }; }; + const rememberUserAuthoredClassifierContext = ( + runtime: ClaudeRuntime, + toolUseId: unknown, + input: UserAuthoredClassifierInput, + ): void => { + const id = typeof toolUseId === "string" ? toolUseId.trim() : ""; + if (!id) return; + // Session-override and policy auto-allows never call this. If ADE cannot + // attribute a statement to the user, buildClassifierContext returns null. + if (!buildClassifierContext(input)) return; + runtime.pendingClassifierContextByToolUseId.set(id, input); + }; + const buildClaudeCanUseTool = ( runtime: ClaudeRuntime, managed: ManagedChatSession, @@ -9411,6 +9473,10 @@ export function createAgentChatService(args: { } if (approved) { + rememberUserAuthoredClassifierContext(runtime, sdkOptions?.toolUseID, { + typedText: response.responseText, + explicitApproval: true, + }); // Switch session out of plan mode so the UI reflects the transition. if (managed.session.permissionMode === "plan" || managed.session.interactionMode === "plan") { applyClaudePlanModeTransition(managed.session, "default"); @@ -9630,6 +9696,10 @@ export function createAgentChatService(args: { runtime.approvalOverrides.add(normalizedToolName); } if (approved) { + rememberUserAuthoredClassifierContext(runtime, sdkOptions?.toolUseID, { + typedText: response.responseText, + explicitApproval: true, + }); return { behavior: "allow", ...(response.decision === "accept_for_session" && sdkOptions?.suggestions?.length @@ -14034,6 +14104,8 @@ export function createAgentChatService(args: { ...(managed.session.sessionProfile ? { sessionProfile: managed.session.sessionProfile } : {}), ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), ...(managed.session.fastMode === true ? { fastMode: true } : {}), + ...(managed.session.autoContinueAtUsageLimit === false ? { autoContinueAtUsageLimit: false } : {}), + ...(managed.usageLimitParkedUntil ? { usageLimitParkedUntil: managed.usageLimitParkedUntil } : {}), ...(managed.session.codexServiceTier !== undefined ? { codexServiceTier: managed.session.codexServiceTier } : {}), ...(managed.session.executionMode ? { executionMode: managed.session.executionMode } : {}), ...(managed.session.interactionMode ? { interactionMode: managed.session.interactionMode } : {}), @@ -14367,6 +14439,11 @@ export function createAgentChatService(args: { const sessionProfile = normalizeSessionProfile(record.sessionProfile); const reasoningEffort = normalizeReasoningEffort(record.reasoningEffort); const fastMode = readLegacyFastMode(record as Record); + const autoContinueAtUsageLimit = record.autoContinueAtUsageLimit === false ? false : undefined; + const usageLimitParkedUntil = typeof record.usageLimitParkedUntil === "string" + && record.usageLimitParkedUntil.trim().length + ? record.usageLimitParkedUntil.trim() + : undefined; const hasCodexServiceTier = Object.prototype.hasOwnProperty.call(record, "codexServiceTier"); const codexServiceTier = hasCodexServiceTier ? normalizeCodexServiceTier(record.codexServiceTier) : undefined; const executionMode = normalizePersistedExecutionMode(record.executionMode); @@ -14591,6 +14668,8 @@ export function createAgentChatService(args: { ...(sessionProfile ? { sessionProfile } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), ...(fastMode ? { fastMode: true } : {}), + ...(autoContinueAtUsageLimit === false ? { autoContinueAtUsageLimit: false } : {}), + ...(usageLimitParkedUntil ? { usageLimitParkedUntil } : {}), ...(hasCodexServiceTier ? { codexServiceTier } : {}), ...(executionMode ? { executionMode } : {}), ...(interactionMode ? { interactionMode } : {}), @@ -15354,6 +15433,14 @@ export function createAgentChatService(args: { return null; }; + const parkUsageLimitIfAutoContinuing = (managed: ManagedChatSession): void => { + if (!sessionAutoContinueAtUsageLimit(managed.session)) return; + const fireAt = autoResumeFireAtMs(usageLimitResetAtMs(managed), Date.now()); + if (fireAt == null) return; + managed.usageLimitParkedUntil = new Date(fireAt).toISOString(); + persistChatState(managed); + }; + type CommitChatEventOptions = { liveEvent?: AgentChatEvent; }; @@ -15365,12 +15452,18 @@ export function createAgentChatService(args: { ): void => { const decoratedEvent = event.type === "error" ? decorateAgentCliError(managed, event) : event; if (decoratedEvent.type === "error") { - autoResume.maybeArmAfterUsageLimit({ - sessionId: managed.session.id, - provider: managed.session.provider, - resetAtMs: usageLimitResetAtMs(managed), - error: decoratedEvent, - }); + const autoContinue = sessionAutoContinueAtUsageLimit(managed.session); + if (autoContinue && managed.session.provider !== "claude") { + autoResume.maybeArmAfterUsageLimit({ + sessionId: managed.session.id, + provider: managed.session.provider, + resetAtMs: usageLimitResetAtMs(managed), + error: decoratedEvent, + }); + } + if (autoContinue && isUsageLimitChatError(decoratedEvent)) { + parkUsageLimitIfAutoContinuing(managed); + } } const liveEvent = options.liveEvent ?? decoratedEvent; const storedEvent = compactChatEventForStorage(decoratedEvent); @@ -15743,6 +15836,35 @@ export function createAgentChatService(args: { managed.todoItems = normalizedEvent.items; } + if ( + managed.usageLimitParkedUntil + && normalizedEvent.type === "user_message" + && normalizedEvent.deliveryState !== "queued" + ) { + const autoResumeTurn = isAutoResumeScheduledWork({ + id: normalizedEvent.metadata?.scheduledWake?.scheduleId, + }); + managed.usageLimitParkedUntil = null; + persistChatState(managed); + if (autoResumeTurn) { + onUsageLimitAutoResumed?.({ + sessionId: managed.session.id, + title: sessionService.get(managed.session.id)?.title ?? null, + }); + } + } else if ( + managed.usageLimitParkedUntil + && normalizedEvent.type === "status" + && normalizedEvent.turnStatus === "started" + ) { + managed.usageLimitParkedUntil = null; + persistChatState(managed); + onUsageLimitAutoResumed?.({ + sessionId: managed.session.id, + title: sessionService.get(managed.session.id)?.title ?? null, + }); + } + if ( normalizedEvent.type === "user_message" && normalizedEvent.deliveryState !== "queued" @@ -19291,6 +19413,7 @@ export function createAgentChatService(args: { ...(rowGoal ? { goal: rowGoal } : {}), reasoningEffort: persisted?.reasoningEffort ?? null, fastMode: persisted?.fastMode === true, + ...(persisted?.autoContinueAtUsageLimit === false ? { autoContinueAtUsageLimit: false } : {}), executionMode: persisted?.executionMode ?? null, interactionMode: persisted?.interactionMode ?? null, ...(persisted?.claudePermissionMode ? { claudePermissionMode: persisted.claudePermissionMode } : {}), @@ -19382,6 +19505,7 @@ export function createAgentChatService(args: { lastLaneDirectiveKey: persisted?.lastLaneDirectiveKey ?? null, runtimeInvalidated: false, claudeRateLimitWarningEmitted: false, + ...(persisted?.usageLimitParkedUntil ? { usageLimitParkedUntil: persisted.usageLimitParkedUntil } : {}), claudeSessionQuotaSnapshot: null, codexTerminalTurnIds: new Set(persisted?.codexTerminalTurnIds ?? []), codexAutomaticRecoveryAttempted: persisted?.codexAutomaticRecoveryAttempted === true, @@ -20616,6 +20740,7 @@ export function createAgentChatService(args: { captureTurnBeforeSha(managed); runtime.interrupted = false; runtime.interruptEventsEmitted = false; + runtime.spareBackgroundOnInterrupt = false; runtime.busy = true; runtime.activeTurnId = turnId; setSessionActive(managed); @@ -21976,6 +22101,7 @@ export function createAgentChatService(args: { }; runtime.interrupted = false; runtime.interruptEventsEmitted = false; + runtime.spareBackgroundOnInterrupt = false; runtime.resolvedToolUseIds.clear(); setSessionActive(managed); @@ -24055,7 +24181,7 @@ export function createAgentChatService(args: { clearClaudeTurnTimers(); runtime.pauseIdleWatchdog = null; runtime.resumeIdleWatchdog = null; - if (runtime.interrupted) { + if (runtime.interrupted && !runtime.spareBackgroundOnInterrupt) { await stopActiveClaudeSubagents(managed, runtime, turnId, "Interrupted"); } const finalStatus: ClaudeTerminalStatus = runtime.interrupted @@ -24095,9 +24221,9 @@ export function createAgentChatService(args: { // A background shell survives the turn boundary as a "running" row on // purpose: the query is NOT closed here (see above), so its real // completion still arrives on a later turn via system:task_notification. - // Only true teardown paths (interrupt — handled above via - // stopActiveClaudeSubagents — reset/dispose, host-restart rebind) settle - // them as stopped. + // Only true teardown paths (background-killing interrupt, reset/dispose, + // host-restart rebind) settle them as stopped. Default Stop spares them + // once per-task stop exists. if (!runtime.interruptEventsEmitted) { emitChatEvent(managed, { type: "status", turnStatus: finalStatus, turnId }); void emitTurnDiffSummaryIfChanged(managed, turnId); @@ -24176,9 +24302,11 @@ export function createAgentChatService(args: { flushOpenClaudeToolUses(finalToolStatus); flushClaudeStructuredActivities(finalToolStatus); - // Only close the query on genuine errors. User interrupts close and - // clear the session immediately in interrupt() so the next turn cannot - // consume buffered events from the abandoned stream. + // Only close the query on genuine errors. User interrupts that spare + // background work leave the query open so background completions can + // still arrive; production interrupt keeps the stream open and the + // success path starts the idle reader. Abort throws still land here — + // do not close() or an idle-reader terminate would reap those jobs. if (!runtime.interrupted) { try { runtime.query?.close(); } catch { /* ignore */ } runtime.inputPump?.close(); @@ -24192,14 +24320,16 @@ export function createAgentChatService(args: { runtime.warmupDone = null; claudeSubprocessReaper.reapForSession(managed.session.id, "claude_turn_failed"); } - await stopActiveClaudeSubagents( - managed, - runtime, - turnId, - finalToolStatus === "interrupted" - ? "Interrupted" - : "Claude's query ended before this task reported completion.", - ); + if (!(runtime.interrupted && runtime.spareBackgroundOnInterrupt)) { + await stopActiveClaudeSubagents( + managed, + runtime, + turnId, + finalToolStatus === "interrupted" + ? "Interrupted" + : "Claude's query ended before this task reported completion.", + ); + } const doneModel = buildDoneModelPayload(); void emitTurnDiffSummaryIfChanged(managed, turnId); @@ -27939,6 +28069,97 @@ export function createAgentChatService(args: { })); }; + const stopTask = async ( + { sessionId, taskId }: AgentChatStopTaskArgs, + ): Promise => { + const managed = ensureManagedSession(sessionId); + const id = taskId.trim(); + if (!id) { + return { sessionId, taskId, stopped: false, reason: "A task id is required." }; + } + if (managed.runtime?.kind !== "claude") { + return { + sessionId, + taskId: id, + stopped: false, + reason: "Per-task stop is only available for Claude chats.", + }; + } + const runtime = managed.runtime; + const existing = runtime.activeSubagents.get(id); + if (!existing && !runtime.liveBackgroundTaskIds.has(id)) { + return { sessionId, taskId: id, stopped: false, reason: "That task is not running." }; + } + const turnId = runtime.activeTurnId ?? undefined; + const control = getClaudeQueryControl(runtime.query); + if (typeof control.stopTask !== "function") { + return { + sessionId, + taskId: id, + stopped: false, + reason: "The Claude query did not expose a task stop control.", + }; + } + try { + await awaitClaudeControlCall( + `Stopping Claude task '${id}'`, + CLAUDE_STOP_TASK_TIMEOUT_MS, + () => control.stopTask!(id), + ); + } catch (error) { + logger.warn("agent_chat.claude_stop_task_failed", { + sessionId: managed.session.id, + taskId: id, + error: error instanceof Error ? error.message : String(error), + }); + return { + sessionId, + taskId: id, + stopped: false, + reason: error instanceof Error ? error.message : String(error), + }; + } + const tracked = runtime.activeSubagents.get(id) ?? existing; + if (tracked && isBackgroundShellCommand({ + taskType: tracked.taskType, + agentType: tracked.agentType, + command: tracked.command, + description: tracked.description, + })) { + emitClaudeBackgroundTaskUpdate(managed, runtime, { + taskId: id, + status: "stopped", + title: tracked.description, + summary: "Stopped by user", + command: tracked.command, + ...(turnId ? { turnId } : {}), + }); + } else if (tracked && !tracked.skipTranscript && !tracked.nonAgentTaskRun) { + emitClaudeSubagentResult(managed, runtime, { + type: "subagent_result", + taskId: id, + ...(tracked.agentId ? { agentId: tracked.agentId } : {}), + ...(tracked.agentType ? { agentType: tracked.agentType } : {}), + parentToolUseId: tracked.parentToolUseId ?? undefined, + status: "stopped", + summary: "Stopped by user", + finalSummary: "Stopped by user", + ...optionalSubagentModelFields(tracked.model), + turnId, + }); + } + runtime.activeSubagents.delete(id); + runtime.liveBackgroundTaskIds.delete(id); + runtime.seenBackgroundTaskIds.delete(id); + runtime.backgroundTaskTypeById.delete(id); + if (tracked?.parentToolUseId) { + runtime.taskToolInputByToolUseId.delete(tracked.parentToolUseId); + } + syncClaudeBackgroundWorkAnchor(runtime); + persistChatState(managed); + return { sessionId, taskId: id, stopped: true }; + }; + type CodexCollabAgentState = { threadId: string; status: string; @@ -31937,20 +32158,42 @@ export function createAgentChatService(args: { { hooks: [ async (input: HookInput) => { + // Classifier context is computed first and returned on this same + // PostToolUse completion. A late `{ async: true }` value is ignored. + const toolUseId = input.hook_event_name === "PostToolUse" ? input.tool_use_id : undefined; + const pending = toolUseId + ? runtime.pendingClassifierContextByToolUseId.get(toolUseId) ?? null + : null; + if (toolUseId) runtime.pendingClassifierContextByToolUseId.delete(toolUseId); + const relay = pending ? buildClassifierContext(pending) : null; + if (relay) { + const audit = classifierContextAuditMessage(relay); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + status: "classifier_context", + message: audit.message, + ...(audit.detail ? { detail: audit.detail } : {}), + turnId: runtime.activeTurnId ?? undefined, + }); + } await handleClaudeScheduledWorkPostToolUse(managed, runtime, input); const trimmed = buildClaudeTrimmedToolOutput(input); - if (!trimmed) return { continue: true }; - logger.info("agent_chat.claude_post_tool_use_trimmed", { - sessionId: managed.session.id, - toolName: input.hook_event_name === "PostToolUse" ? input.tool_name : undefined, - originalBytes: trimmed.originalBytes, - trimmedBytes: trimmed.trimmedBytes, - }); + if (trimmed) { + logger.info("agent_chat.claude_post_tool_use_trimmed", { + sessionId: managed.session.id, + toolName: input.hook_event_name === "PostToolUse" ? input.tool_name : undefined, + originalBytes: trimmed.originalBytes, + trimmedBytes: trimmed.trimmedBytes, + }); + } + if (!trimmed && !relay) return { continue: true }; return { continue: true, hookSpecificOutput: { hookEventName: "PostToolUse" as const, - updatedToolOutput: trimmed.updatedToolOutput, + ...(trimmed ? { updatedToolOutput: trimmed.updatedToolOutput } : {}), + ...(relay ? { classifierContext: relay.classifierContext } : {}), }, }; }, @@ -32024,7 +32267,37 @@ export function createAgentChatService(args: { ], }, ], - }); + PreModelSwitch: [ + { + hooks: [ + async () => ({ continue: true }), + ], + }, + ], + PostModelSwitch: [ + { + hooks: [ + async (input: HookInput) => { + const switchArgs = parseClaudeModelSwitchArgs(input); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + status: "model_switched", + message: claudeModelSwitchDividerMessage(switchArgs), + turnId: runtime.activeTurnId ?? undefined, + }); + return { + continue: true, + hookSpecificOutput: { + hookEventName: "PostModelSwitch" as const, + additionalContext: claudeModelSwitchAdditionalContext(switchArgs), + }, + }; + }, + ], + }, + ], + }) as NonNullable; /** * Build stable Agent SDK query options from the managed session state. @@ -32135,7 +32408,14 @@ export function createAgentChatService(args: { laneId: managed.session.laneId, cwd: managed.laneWorktreePath, }), - }; + autoContinueAtUsageLimit: sessionAutoContinueAtUsageLimit(managed.session), + ...(shouldDeclarePerTaskStopAffordance({ + stopTaskExposed: true, + stopControlsReachable: CLAUDE_PER_TASK_STOP_CONTROLS_REACHABLE, + }) + ? { perTaskStopAffordance: true } + : {}), + } as ClaudeSDKOptions; if (isOrchestrationLeadSession(managed.session)) { opts.disallowedTools = Array.from(new Set([ ...(opts.disallowedTools ?? []), @@ -33495,8 +33775,10 @@ export function createAgentChatService(args: { approvals: new Map(), interrupted: false, interruptEventsEmitted: false, + spareBackgroundOnInterrupt: false, approvalOverrides: new Set(persisted?.approvalOverrides ?? []), resolvedToolUseIds: new Set(), + pendingClassifierContextByToolUseId: new Map(), rateLimitWarningEmitted: managed.claudeRateLimitWarningEmitted, }; for (const steer of runtime.pendingSteers) { @@ -36805,6 +37087,7 @@ export function createAgentChatService(args: { ); managed.claudeQuotaCardWasLive = true; managed.claudeQuotaCardLiveChecked = true; + parkUsageLimitIfAutoContinuing(managed); void emitAdeCard({ sessionId: managed.session.id, card: buildClaudeSessionQuotaCard({ @@ -44043,9 +44326,10 @@ export function createAgentChatService(args: { }; const interrupt = async ( - { sessionId, mode = "stop_and_clear" }: AgentChatInterruptArgs, + { sessionId, mode: rawMode = "stop_and_clear" }: AgentChatInterruptArgs, internalOptions: { requireClaudeProviderInterrupt?: boolean } = {}, ): Promise => { + const mode = parseAgentChatStopMode(rawMode); const managed = ensureManagedSession(sessionId); const result: AgentChatInterruptResult = { mode, @@ -44072,7 +44356,7 @@ export function createAgentChatService(args: { // unrecoverable, and the opposite of the rule that losing a settle costs // one click while losing the user's work does not. Default is // `stop_and_clear`, so the Stop button is unaffected. - if (mode === "stop_and_clear") cancelQueuedSteers(managed, managed.runtime, "interrupted"); + if (stopModeClearsQueue(mode)) cancelQueuedSteers(managed, managed.runtime, "interrupted"); persistChatState(managed); for (const pending of managed.runtime.pendingApprovals.values()) { rejectOpenCodePendingApproval(managed.runtime.handle, pending).catch(() => {}); @@ -44118,7 +44402,7 @@ export function createAgentChatService(args: { cancelCursorPermissionWaiter(w, "Cursor tool approval was cancelled because the turn was interrupted."); } rt.permissionWaiters.clear(); - if (mode === "stop_and_clear") cancelQueuedSteers(managed, rt, "interrupted"); + if (stopModeClearsQueue(mode)) cancelQueuedSteers(managed, rt, "interrupted"); return result; } @@ -44131,7 +44415,7 @@ export function createAgentChatService(args: { } catch { // ignore } - if (mode === "stop_and_clear") cancelQueuedSteers(managed, rt, "interrupted"); + if (stopModeClearsQueue(mode)) cancelQueuedSteers(managed, rt, "interrupted"); cancelPendingInputsFrom(managed, "pi", "ade"); persistChatState(managed); return result; @@ -44139,7 +44423,7 @@ export function createAgentChatService(args: { if (managed.session.provider === "pi") { piRuntimeSetupInterruptRequested.set(managed, true); - if (mode === "stop_and_clear") { + if (stopModeClearsQueue(mode)) { cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted"); } setSessionIdle(managed); @@ -44167,7 +44451,7 @@ export function createAgentChatService(args: { error: error instanceof Error ? error.message : String(error), }); } - if (mode === "stop_and_clear") cancelQueuedSteers(managed, rt, "interrupted"); + if (stopModeClearsQueue(mode)) cancelQueuedSteers(managed, rt, "interrupted"); persistChatState(managed); return result; } @@ -44176,7 +44460,7 @@ export function createAgentChatService(args: { // The stop landed while the session was still opening, so there is no // runtime to cancel. Clear the queue and idle the row, the same way the // Pi no-runtime arm does. - if (mode === "stop_and_clear") { + if (stopModeClearsQueue(mode)) { cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted"); } cancelPendingInputsFrom(managed, "acp", "ade"); @@ -44197,13 +44481,13 @@ export function createAgentChatService(args: { cancelDroidPermissionWaiter(w, "Droid tool approval was cancelled because the turn was interrupted."); } rt.permissionWaiters.clear(); - if (mode === "stop_and_clear") cancelQueuedSteers(managed, rt, "interrupted"); + if (stopModeClearsQueue(mode)) cancelQueuedSteers(managed, rt, "interrupted"); return result; } if (managed.session.provider === "droid") { droidRuntimeSetupInterruptRequested.set(managed, true); - if (mode === "stop_and_clear") { + if (stopModeClearsQueue(mode)) { cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted"); } persistChatState(managed); @@ -44212,7 +44496,7 @@ export function createAgentChatService(args: { if (managed.session.provider === "cursor") { cursorRuntimeSetupInterruptRequested.set(managed, true); - if (mode === "stop_and_clear") { + if (stopModeClearsQueue(mode)) { cancelQueuedSteers(managed, { pendingSteers: [], activeTurnId: null }, "interrupted"); } persistChatState(managed); @@ -44236,7 +44520,7 @@ export function createAgentChatService(args: { // to a turn that IS running still goes when the app-server aborts it, // because the request behind the card dies with the turn either way. const settleCardsIfClearing = (): void => { - if (mode !== "stop_and_clear") return; + if (!stopModeClearsQueue(mode)) return; settleCodexPendingInputs(managed, runtime); }; if (!managed.session.threadId) { @@ -44348,7 +44632,7 @@ export function createAgentChatService(args: { }); const claudeControl = getClaudeQueryControl(runtime.query); let interruptResponse: SDKControlInterruptResponse | undefined; - const localQueuedForRecovery = mode === "stop_and_clear" + const localQueuedForRecovery = stopModeClearsQueue(mode) ? [...runtime.pendingSteers] : []; // Remove ADE-local staged messages before awaiting the provider interrupt. @@ -44361,7 +44645,7 @@ export function createAgentChatService(args: { const knownQueuedMessagesAtInterrupt = new Map(runtime.knownQueuedMessages); const cancelQueuedCapability = managed.session.protocolCapabilities?.includes("interrupt_cancel_queued_v1") === true; const requestClaudeInterrupt = async (): Promise => { - if (mode === "stop_and_clear" && cancelQueuedCapability && claudeControl.interruptWithOptions) { + if (stopModeClearsQueue(mode) && cancelQueuedCapability && claudeControl.interruptWithOptions) { return await awaitClaudeControlCall( "Claude interrupt and queue cancellation", CLAUDE_INTERRUPT_REQUEST_TIMEOUT_MS, @@ -44374,7 +44658,7 @@ export function createAgentChatService(args: { CLAUDE_INTERRUPT_REQUEST_TIMEOUT_MS, () => claudeControl.interrupt!(), ); - if (mode !== "stop_and_clear" || !response?.still_queued?.length || !claudeControl.cancelAsyncMessage) { + if (!stopModeClearsQueue(mode) || !response?.still_queued?.length || !claudeControl.cancelAsyncMessage) { return response; } const cancelled: string[] = []; @@ -44401,6 +44685,7 @@ export function createAgentChatService(args: { // Set interrupted before touching the runtime so the streaming loop can // break cleanly while the underlying SDK stream is aborted below. runtime.interrupted = true; + runtime.spareBackgroundOnInterrupt = !stopModeStopsBackground(mode); const interruptedTurnId = runtime.activeTurnId; if (runtime.busy && interruptedTurnId) { runtime.interruptEventsEmitted = true; @@ -44415,7 +44700,9 @@ export function createAgentChatService(args: { } cancelClaudeWarmup(managed, runtime, "interrupt"); settleClaudeInitialInputDispatch(runtime, new Error("Claude turn was interrupted before its input was dispatched.")); - await stopActiveClaudeSubagents(managed, runtime, interruptedTurnId ?? undefined, "Interrupted by user"); + if (stopModeStopsBackground(mode)) { + await stopActiveClaudeSubagents(managed, runtime, interruptedTurnId ?? undefined, "Interrupted by user"); + } if (!internalOptions.requireClaudeProviderInterrupt) { try { if (claudeControl.interrupt || claudeControl.interruptWithOptions) { @@ -44432,13 +44719,16 @@ export function createAgentChatService(args: { const normalizedInterrupt = normalizeClaudeInterruptReceipt(interruptResponse); const preserveQueryForQueuedMessages = normalizedInterrupt.stillQueuedUuids.length > 0; const providerCancelledUuids = normalizedInterrupt.cancelledUuids; - if (!preserveQueryForQueuedMessages) { + // Resetting the query reaps the Claude process and orphans every still-open + // task. That is correct for background-killing Stop; it is the opposite of + // `perTaskStopAffordance` for Turn-only / Turn+queue. + if (!preserveQueryForQueuedMessages && stopModeStopsBackground(mode)) { // Invalidate the idle reader and any already-issued `next()` promise as // part of the same reset. Clearing only query/inputPump lets that stale // promise consume the next turn after an interrupt. await resetClaudeQuerySession(managed, runtime, "interrupt"); } - if (mode === "stop_and_clear") { + if (stopModeClearsQueue(mode)) { const localQueuedCount = localQueuedForRecovery.length; result.cancelledQueuedCount = localQueuedCount + providerCancelledUuids.length; const providerCancelledSteers = providerCancelledUuids.flatMap((uuid) => { @@ -45574,6 +45864,11 @@ export function createAgentChatService(args: { error: error instanceof Error ? error.message : String(error), }); } + const pendingAutoResume = scheduledWork.find((item) => isPendingAutoResumeScheduledWork(item)); + const usageLimitParkedUntil = pendingAutoResume?.nextRunAt + ?? liveManaged?.usageLimitParkedUntil + ?? persisted?.usageLimitParkedUntil + ?? null; // Preserve an explicit null from the live session instead of falling back // to an older persisted native mode. The distinction matters to clients: // Cursor's mode is nullable because null means the user cleared the native @@ -45702,6 +45997,8 @@ export function createAgentChatService(args: { summary: row.summary ?? null, ...(provider === "claude" ? { claudeTag } : {}), nextWakeAt, + ...(usageLimitParkedUntil ? { usageLimitParkedUntil } : {}), + autoContinueAtUsageLimit: sessionAutoContinueAtUsageLimit(liveSession ?? persisted), activeBackgroundTaskCount, // Omitted when nothing is live, like every other optional field here: a // zero record carries no information and would ride along on every @@ -48349,6 +48646,7 @@ export function createAgentChatService(args: { permissionMode, spawnKind: requestedSpawnKind, subagentTakeoverPromptShown, + autoContinueAtUsageLimit: requestedAutoContinueAtUsageLimit, }: AgentChatUpdateSessionArgs): Promise => { const fastMode = requestedFastModeArg ?? requestedLegacyFastModeArg; const managed = ensureManagedSession(sessionId); @@ -48758,6 +49056,29 @@ export function createAgentChatService(args: { delete managed.session.fastMode; } } + if (requestedAutoContinueAtUsageLimit !== undefined) { + if (requestedAutoContinueAtUsageLimit === false) { + managed.session.autoContinueAtUsageLimit = false; + if (managed.usageLimitParkedUntil) { + managed.usageLimitParkedUntil = null; + } + autoResume.cancelForSession(sessionId, "opted_out_of_auto_continue"); + const runtime = managed.runtime; + if ( + runtime?.kind === "claude" + && runtime.query + && (runtime.busy || runtime.activeTurnId) + ) { + try { + await interrupt({ sessionId, mode: DEFAULT_AGENT_CHAT_STOP_MODE }); + } catch { + // Opt-out is persisted even if the live query cannot be interrupted. + } + } + } else { + delete managed.session.autoContinueAtUsageLimit; + } + } const nextClaudeFastModeSetting = managed.session.provider === "claude" ? sessionEffectiveFastMode(managed.session) : false; @@ -50132,47 +50453,7 @@ export function createAgentChatService(args: { const normalizeClaudeContextUsage = ( usage: SDKControlGetContextUsageResponse, - ): AgentChatContextUsage => { - const totalTokens = Number.isFinite(usage.totalTokens) ? Math.max(0, usage.totalTokens) : 0; - const maxTokens = Number.isFinite(usage.maxTokens) ? Math.max(0, usage.maxTokens) : 0; - const denominator = maxTokens > 0 ? maxTokens : totalTokens > 0 ? totalTokens : 1; - const categories = (Array.isArray(usage.categories) ? usage.categories : []) - .map((category): AgentChatContextUsage["categories"][number] | null => { - const name = typeof category.name === "string" ? category.name.trim() : ""; - const tokens = Number.isFinite(category.tokens) ? Math.max(0, category.tokens) : 0; - if (!name.length && tokens === 0) return null; - return { - name: name || "Other", - tokens, - percentage: tokens > 0 ? (tokens / denominator) * 100 : 0, - ...(typeof category.color === "string" && category.color.trim().length ? { color: category.color.trim() } : {}), - ...(category.isDeferred === true ? { isDeferred: true } : {}), - }; - }) - .filter((category): category is AgentChatContextUsage["categories"][number] => Boolean(category)); - - if (maxTokens > totalTokens && !categories.some((category) => category.name.trim().toLowerCase() === "free")) { - const freeTokens = maxTokens - totalTokens; - categories.push({ - name: "Free", - tokens: freeTokens, - percentage: maxTokens > 0 ? (freeTokens / maxTokens) * 100 : 0, - }); - } - - return { - categories, - totalTokens, - maxTokens, - rawMaxTokens: Number.isFinite(usage.rawMaxTokens) ? Math.max(0, usage.rawMaxTokens) : maxTokens, - percentage: Number.isFinite(usage.percentage) - ? Math.max(0, Math.min(100, usage.percentage)) - : maxTokens > 0 - ? (totalTokens / maxTokens) * 100 - : 0, - ...(typeof usage.model === "string" && usage.model.trim().length ? { model: usage.model.trim() } : {}), - }; - }; + ): AgentChatContextUsage => normalizeStructuredClaudeContextUsage(usage); const normalizeClaudeRewindFilesResult = ( result: ClaudeRewindFilesResult, @@ -51599,6 +51880,8 @@ export function createAgentChatService(args: { dispatchSteer, cancelDispatchedSteer, interrupt, + interruptWithQueueMode: interrupt, + stopTask, /** * Is a persisted Claude `--bg` job actually still running? * diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 883a7886e..a7f6901fb 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -63,6 +63,7 @@ import { normalizeAppPackageChannel } from "../../../shared/packageChannel"; import { findRecentProjectForRepo } from "../projects/repoProjectResolver"; import { getModelById } from "../../../shared/modelRegistry"; import { isAgentChatTurnRecoveryAction } from "../../../shared/types/chat"; +import { isAgentChatStopMode } from "../../../shared/chatStopModes"; import { convertHeicBufferToJpeg, HeicAttachmentConversionError, @@ -433,6 +434,8 @@ import type { AgentChatValidateCrossMachineSourceArgs, AgentChatInterruptArgs, AgentChatInterruptResult, + AgentChatStopTaskArgs, + AgentChatStopTaskResult, AgentChatRestoreCancelledQueueArgs, AgentChatRestoreCancelledQueueResult, AgentChatRecoverTurnArgs, @@ -8031,7 +8034,7 @@ export function registerIpc({ throw new Error("A chat session id is required."); } const rawMode = (arg as { mode?: unknown }).mode; - if (rawMode !== undefined && rawMode !== "stop_and_clear" && rawMode !== "stop_only") { + if (rawMode !== undefined && !isAgentChatStopMode(rawMode)) { throw new Error("Invalid chat stop mode."); } const request: AgentChatInterruptArgs = { @@ -8041,6 +8044,22 @@ export function registerIpc({ return await ctx.agentChatService.interrupt(request); }); + ipcMain.handle(IPC.agentChatStopTask, async (_event, arg: unknown): Promise => { + const ctx = ensureAgentChatContext(); + if ( + !arg + || typeof arg !== "object" + || typeof (arg as { sessionId?: unknown }).sessionId !== "string" + || typeof (arg as { taskId?: unknown }).taskId !== "string" + ) { + throw new Error("A chat session id and task id are required."); + } + return await ctx.agentChatService.stopTask({ + sessionId: (arg as { sessionId: string }).sessionId, + taskId: (arg as { taskId: string }).taskId, + }); + }); + ipcMain.handle(IPC.agentChatRestoreCancelledQueue, async ( _event, arg: unknown, diff --git a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts index 1532dfdd8..4ffe87692 100644 --- a/apps/desktop/src/main/services/sessions/chatSessionProjection.ts +++ b/apps/desktop/src/main/services/sessions/chatSessionProjection.ts @@ -75,6 +75,7 @@ export function projectChatOntoSession( ...session, currentTurnStartedAt: chat.currentTurnStartedAt ?? null, nextWakeAt: chat.nextWakeAt, + ...(chat.usageLimitParkedUntil ? { usageLimitParkedUntil: chat.usageLimitParkedUntil } : {}), chatActivityMode: chat.interactionMode === "plan" ? "planning" : null, activeBackgroundTaskCount: chat.activeBackgroundTaskCount ?? 0, ...(chat.backgroundWork ? { backgroundWork: chat.backgroundWork } : {}), diff --git a/apps/desktop/src/main/services/sessions/settleTeardownWiring.test.ts b/apps/desktop/src/main/services/sessions/settleTeardownWiring.test.ts index 07cd5af83..347eebea8 100644 --- a/apps/desktop/src/main/services/sessions/settleTeardownWiring.test.ts +++ b/apps/desktop/src/main/services/sessions/settleTeardownWiring.test.ts @@ -33,8 +33,9 @@ describe("settle teardown wiring", () => { await wiring.runSettleTeardown("session-1", neverAborted); // `stop_and_clear` cancels queued follow-ups. Losing a settle costs one - // click; losing prompts the user already typed is unrecoverable. - expect(interrupt).toHaveBeenCalledWith({ sessionId: "session-1", mode: "stop_only" }); + // click; losing prompts the user already typed is unrecoverable. After + // the stop matrix, settle still stops background work. + expect(interrupt).toHaveBeenCalledWith({ sessionId: "session-1", mode: "stop_and_background" }); }); it("treats a persisted Claude background job as work, even when the runtime is gone", async () => { diff --git a/apps/desktop/src/main/services/sessions/settleTeardownWiring.ts b/apps/desktop/src/main/services/sessions/settleTeardownWiring.ts index c0dbb4d51..27a4f1fd9 100644 --- a/apps/desktop/src/main/services/sessions/settleTeardownWiring.ts +++ b/apps/desktop/src/main/services/sessions/settleTeardownWiring.ts @@ -1,3 +1,4 @@ +import { SETTLE_TEARDOWN_STOP_MODE, type AgentChatStopMode } from "../../../shared/chatStopModes"; import { createSessionSettleTeardown, residueCountBucket } from "./sessionSettleTeardown"; import type { SettleResidueItem, SettleTeardownContext, SettleTeardownOutcome } from "./sessionSettleTeardown"; import type { ProductAnalyticsCapture } from "../../../shared/types/productAnalytics"; @@ -17,7 +18,7 @@ import type { ProductAnalyticsCapture } from "../../../shared/types/productAnaly /** Only what teardown needs, so neither caller has to hand over a whole service. */ export type SettleTeardownChatService = { - interrupt: (args: { sessionId: string; mode: "stop_only" | "stop_and_clear" }) => Promise; + interrupt: (args: { sessionId: string; mode: AgentChatStopMode }) => Promise; getSessionSummary: (sessionId: string) => Promise<{ status: string; activeBackgroundTaskCount?: number | null; @@ -69,12 +70,12 @@ export function createSettleTeardownWiring(deps: SettleTeardownWiringDeps): Sett }; const runSettleTeardown = createSessionSettleTeardown({ interrupt: async (sessionId) => { - // `stop_only`, never `stop_and_clear`: the latter also cancels the user's - // QUEUED turns. Design 3c's rule is that losing a settle costs one click - // while losing the user's work is unrecoverable, and a queued prompt is - // the user's work. If a queued turn then starts, C3 clears the settle — - // which is R1, and already the accepted trade. - await deps.agentChatService.interrupt({ sessionId, mode: "stop_only" }); + // `stop_and_background`, never `stop_and_clear`: the latter also cancels + // the user's QUEUED turns. Design 3c's rule is that losing a settle costs + // one click while losing the user's work is unrecoverable, and a queued + // prompt is the user's work. After perTaskStopAffordance, `stop_only` + // would also spare background work — settle still has to stop that. + await deps.agentChatService.interrupt({ sessionId, mode: SETTLE_TEARDOWN_STOP_MODE }); }, readActiveWork: async (sessionId) => { const summary = await deps.agentChatService.getSessionSummary(sessionId); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 35830b573..aa08deebf 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -139,6 +139,8 @@ import type { AgentChatValidateCrossMachineSourceArgs, AgentChatInterruptArgs, AgentChatInterruptResult, + AgentChatStopTaskArgs, + AgentChatStopTaskResult, AgentChatRestoreCancelledQueueArgs, AgentChatRestoreCancelledQueueResult, AgentChatRecoverTurnArgs, @@ -1864,6 +1866,10 @@ declare global { args: AgentChatInterruptArgs, pin?: OpenProjectBinding | null, ) => Promise; + stopTask: ( + args: AgentChatStopTaskArgs, + pin?: OpenProjectBinding | null, + ) => Promise; restoreCancelledQueue: ( args: AgentChatRestoreCancelledQueueArgs, pin?: OpenProjectBinding | null, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index cfdcfcb12..b09e296dc 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -405,6 +405,8 @@ import type { AgentChatValidateCrossMachineSourceArgs, AgentChatInterruptArgs, AgentChatInterruptResult, + AgentChatStopTaskArgs, + AgentChatStopTaskResult, AgentChatRestoreCancelledQueueArgs, AgentChatRestoreCancelledQueueResult, AgentChatRecoverTurnArgs, @@ -1428,6 +1430,7 @@ const MUTATING_CHAT_ACTIONS = new Set([ "respondToInput", "approveToolUse", "interrupt", + "stopTask", "restoreCancelledQueue", "recoverTurn", "recoverCodexTurn", @@ -6676,6 +6679,21 @@ const adeBridge = { agentChatSummaryCache.clear(); return result; }, + stopTask: async ( + args: AgentChatStopTaskArgs, + pin?: OpenProjectBinding | null, + ): Promise => { + agentChatSummaryCache.clear(); + const result = await callPinnedOrBoundRuntimeActionOr( + pin, + "chat", + "stopTask", + { args }, + () => ipcRenderer.invoke(IPC.agentChatStopTask, args), + ); + agentChatSummaryCache.clear(); + return result; + }, restoreCancelledQueue: async ( args: AgentChatRestoreCancelledQueueArgs, pin?: OpenProjectBinding | null, diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 783b04367..840313a0d 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4952,6 +4952,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { mode: "stop_and_clear", cancelledQueuedCount: 0, }), + stopTask: resolvedArg({ sessionId: "", taskId: "", stopped: false }), restoreCancelledQueue: resolvedArg({ restored: false, restoredCount: 0, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 955d30d33..baddb8010 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -31,6 +31,12 @@ import { type AgentChatModelCatalogRefreshProvider, type PromptStashEntry, } from "../../../shared/types"; +import { + AGENT_CHAT_STOP_MODES, + chatStopModeCopy, + parseAgentChatStopMode, + stopModeClearsQueue, +} from "../../../shared/chatStopModes"; import { buildChatContextAttachmentPrompt, chatContextAttachmentKey, @@ -1485,30 +1491,23 @@ function ActiveTurnSendButton({ ); } -const ACTIVE_TURN_STOP_COPY: Record = { - stop_and_clear: { - label: "Stop & clear queue", - description: "Stop the active turn and cancel messages already queued for Claude.", - }, - stop_only: { - label: "Stop only", - description: "Stop the active turn but keep queued messages ready for Claude.", - }, -}; +const ACTIVE_TURN_STOP_MODES = AGENT_CHAT_STOP_MODES; function ActiveTurnStopButton({ mode, allowQueueChoice, + backgroundJobCount, onModeChange, onStop, }: { mode: AgentChatStopMode; allowQueueChoice: boolean; + backgroundJobCount: number; onModeChange: (mode: AgentChatStopMode) => void; onStop: () => void; }) { const { caretRef, menuOpen, setMenuOpen } = useComposerSplitMenu("[data-active-stop-menu]"); - const selectedCopy = ACTIVE_TURN_STOP_COPY[mode]; + const selectedCopy = chatStopModeCopy(mode, backgroundJobCount); if (!allowQueueChoice) { return ( @@ -1535,10 +1534,10 @@ function ActiveTurnStopButton({ aria-label={selectedCopy.label} onClick={onStop} > - {mode === "stop_and_clear" ? : } + {stopModeClearsQueue(mode) ? : } - + ) : null} + {onStop && isActive && snapshot.sourceTaskId ? ( + + ) : null} ); @@ -814,6 +827,7 @@ function SubagentRow({ onToggleCollapsedSubtree, spawnedChatTitle = null, sessionModelLabel = null, + onStop, }: { snapshot: ChatSubagentSnapshot; selected: boolean; @@ -836,6 +850,7 @@ function SubagentRow({ /** Parent session model label used only when the envelope has no model. */ sessionModelLabel?: string | null; onClick: () => void; + onStop?: (snapshot: ChatSubagentSnapshot) => void; }) { const isSpawnedChat = snapshot.childSessionId != null; // Spawned-chat rows read as the chat they open: prefer the resolved live @@ -988,6 +1003,21 @@ function SubagentRow({ {time ? {time} : null} + {isRunning && onStop && !isSpawnedChat ? ( + + ) : null} {filePaths.length > 0 ? ( @@ -1102,6 +1132,7 @@ export function ChatSubagentsPanel({ onToggleSchedulesPaused, onCancelScheduledWork, onStopBackgroundTask, + onStopSubagent, sessionModelLabel = null, }: { sessionId?: string | null; @@ -1145,6 +1176,7 @@ export function ChatSubagentsPanel({ onCancelScheduledWork?: (snapshot: ChatScheduledWorkSnapshot) => void; /** Stop a running Codex background terminal via `thread/backgroundTerminals/terminate`. */ onStopBackgroundTask?: (snapshot: ChatScheduledWorkSnapshot) => void; + onStopSubagent?: (snapshot: ChatSubagentSnapshot) => void; sessionModelLabel?: string | null; }) { const [expanded, setExpanded] = useState(false); @@ -1532,6 +1564,7 @@ export function ChatSubagentsPanel({ : null } sessionModelLabel={sessionModelLabel} + onStop={onStopSubagent} onClick={() => handleRowClick(snap)} /> ); @@ -1674,7 +1707,18 @@ export function ChatSubagentsPanel({ ) : null}} idOf={(item) => item.id} - renderActiveRow={(item) => onCancelScheduledWork(item) : undefined} />} + renderActiveRow={(item) => ( + onCancelScheduledWork(item) : undefined} + onStop={ + item.sourceTaskId && onStopBackgroundTask + ? () => onStopBackgroundTask(item) + : undefined + } + /> + )} renderEarlierRow={(item) => isFiredOneShotWakeup(item) ? : } diff --git a/apps/desktop/src/renderer/components/chat/ProviderFailureRecoveryCard.tsx b/apps/desktop/src/renderer/components/chat/ProviderFailureRecoveryCard.tsx index 1a8db28a1..bfaee0f3d 100644 --- a/apps/desktop/src/renderer/components/chat/ProviderFailureRecoveryCard.tsx +++ b/apps/desktop/src/renderer/components/chat/ProviderFailureRecoveryCard.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import type { AgentChatEvent } from "../../../shared/types"; -import { formatAutoResumeTime, isUsageLimitChatError } from "../../../shared/chatAutoResume"; +import { formatUsageLimitResetLabel, isUsageLimitChatError } from "../../../shared/chatAutoResume"; export type ProviderFailureRecovery = { kind: "capacity" | "rate_limit"; @@ -14,8 +14,9 @@ export type ProviderFailureRecovery = { * only this card re-renders when the schedule appears or is cancelled. */ export type ChatAutoResumeState = { - scheduleId: string; - /** ISO fire time of the durable scheduled-work row. */ + /** ADE scheduled-work id, or null when the SDK is waiting natively. */ + scheduleId: string | null; + /** ISO fire time of the durable scheduled-work row or SDK parked-until instant. */ nextRunAt: string | null; /** * {@link providerFailureEventId} of the newest usage-limit failure in this @@ -131,19 +132,23 @@ export function ProviderFailureRecoveryCard({ {showAutoResume ? (
- {`Auto-resume scheduled for ${formatAutoResumeTime(autoResumeAt)}`} - +
Usage limit reached
+
{formatUsageLimitResetLabel(autoResumeAt)}
+
Continue automatically
+
+ +
- ) : null} + ) : (
+ )} {retryError ? (
{retryError} diff --git a/apps/desktop/src/renderer/components/chat/SubagentActivityCards.test.tsx b/apps/desktop/src/renderer/components/chat/SubagentActivityCards.test.tsx index c55c666f0..21c4438f8 100644 --- a/apps/desktop/src/renderer/components/chat/SubagentActivityCards.test.tsx +++ b/apps/desktop/src/renderer/components/chat/SubagentActivityCards.test.tsx @@ -24,6 +24,7 @@ function spawnEvent(overrides: Partial = {}): Su childSessionId: "child-abc", spawnKind: "subagent", resultSummary: null, + taskId: null, ...overrides, }; } @@ -74,6 +75,29 @@ describe("SubagentSpawnCard", () => { expect(navEvent).toBeUndefined(); expect(screen.queryByText("SUBAGENT")).toBeNull(); }); + + it("stops a running native subagent without navigating", () => { + const onStop = vi.fn(); + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Stop Wave 2 UI" })); + expect(onStop).toHaveBeenCalledWith("task-explore-1"); + const navEvent = dispatchSpy.mock.calls + .map(([evt]) => evt) + .find((evt): evt is CustomEvent => evt instanceof CustomEvent && evt.type === "ade:work:select-session"); + expect(navEvent).toBeUndefined(); + }); }); describe("BackgroundJobLine", () => { diff --git a/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx b/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx index dbb12715e..8f90b3682 100644 --- a/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx +++ b/apps/desktop/src/renderer/components/chat/SubagentActivityCards.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { ArrowDown, ArrowUp, CaretDown, CaretRight, Check, Gear, Stop, X } from "@phosphor-icons/react"; +import { ArrowDown, ArrowUp, CaretDown, CaretRight, Check, Gear, Square, Stop, X } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; import { formatSubagentDurationMs } from "../../lib/format"; import { ChatSubagentGlyph, chatSubagentColor } from "./chatSubagentIdentity"; @@ -102,10 +102,12 @@ function glyphStatusFor(status: SubagentSpawnAnchorRenderEvent["status"]): ChatS export function SubagentSpawnCard({ event, onJumpToResult, + onStop, laneId, }: { event: SubagentSpawnAnchorRenderEvent; onJumpToResult?: () => void; + onStop?: (taskId: string) => void; /** Lane of the spawner, forwarded to the navigation event when known. */ laneId?: string | null; }) { @@ -219,6 +221,22 @@ export function SubagentSpawnCard({ open + ) : isRunning && onStop && event.taskId ? ( + ) : !isRunning && onJumpToResult ? ( + ) : null} {onOpenBackgroundJobs ? (