diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index db40072b8..1634a9501 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,10 @@ - Token counting is now scoped to the model being billed rather than to a process-global tokenizer: session maintenance, stats, advisors, `/context`, snapcompact inline imaging, and `compress` each count through the owning agent's `Tokenizer` (`agent.tokenizer`). Message counting is `Tokenizer.countMessage`/`countMessages` (replacing the free `estimateTokens(message, tokenizer)` helper; the legacy shim keeps a compat `estimateTokens` export for legacy pi extensions). `estimateToolSchemaTokens`, `estimateSkillsTokens`, `computeNonMessageTokens`, and `computeNonMessageBreakdown` take an explicit tokenizer; `scripts/measure-prompt-tokens.ts` accepts an optional model id (argv) so its numbers match what that model is charged. - The advisor runtime's `maintainContext` hook now receives the pending update as a message instead of a pre-computed token count — sizing it needs the advisor model's tokenizer, which the host owns. +### Fixed + +- Fixed a prompt cancelled during turn setup (Esc while the pre-stream spinner is up, after dispatch had started) vanishing entirely: it was never persisted to the session — so the `/tree` and `/branch` selectors had nothing to rewind to — and was not returned to the editor either, while its optimistic transcript row kept lingering. A prompt dropped before reaching the agent (abort or usage-preflight denial racing setup) is now handed back: the stale transcript row is removed and the typed text and image attachments are restored to the editor for editing. + ## [17.3.8] - 2026-08-19 ### Added diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index a114559a2..5c2545181 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -103,6 +103,7 @@ import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; import { type AgentSession, type AgentSessionEvent, + type DroppedPrompt, type ResolvedRoleModel, SHUTDOWN_CONSOLIDATE_BUDGET_MS, } from "../session/agent-session"; @@ -912,6 +913,7 @@ export class InteractiveMode implements InteractiveModeContext { this.session.setTitleGenerationStart?.(() => { this.#inputController.notifyTitleGenerationStart(); }); + this.session.setPromptDropped?.(prompt => this.#restoreDroppedPrompt(prompt)); this.#observerRegistry = new SessionObserverRegistry(); } @@ -1821,6 +1823,30 @@ export class InteractiveMode implements InteractiveModeContext { return true; } + /** + * Hands back a prompt the session dropped before dispatch (an Esc abort or + * usage preflight denial raced turn setup). The message was never persisted, + * so the tree/branch selectors cannot offer it — remove the optimistic + * transcript row and put the typed text back in the editor for editing. + */ + #restoreDroppedPrompt(prompt: DroppedPrompt): void { + this.clearOptimisticUserMessage(); + this.#pendingWorkingMessage = undefined; + if (this.loadingAnimation) { + this.#stopLoadingAnimation(true); + } + this.rebuildChatFromMessages(); + // The drop arrives asynchronously (after the abort settles); never clobber + // a draft the user has already started typing in the meantime. + if (!this.editor.getText().trim()) { + this.editor.pendingImages = prompt.images ? [...prompt.images] : []; + this.editor.pendingImageLinks = prompt.images ? prompt.images.map(() => undefined) : []; + this.editor.imageLinks = this.editor.pendingImageLinks; + this.editor.setText(prompt.text); + } + this.ui.requestRender(); + } + markPendingSubmissionStarted(input: SubmittedUserInput): boolean { if (this.#pendingSubmittedInput !== input || input.cancelled) { return false; diff --git a/packages/coding-agent/src/session/agent-session-types.ts b/packages/coding-agent/src/session/agent-session-types.ts index 50f3be5b8..2d3ffb019 100644 --- a/packages/coding-agent/src/session/agent-session-types.ts +++ b/packages/coding-agent/src/session/agent-session-types.ts @@ -306,6 +306,16 @@ export interface PromptOptions { skipCompactionCheck?: boolean; } +/** Payload for {@link AgentSession.setPromptDropped}: a user prompt cancelled + * before it reached the agent (an abort or usage preflight denial raced turn + * setup), so it was never persisted to the session. */ +export interface DroppedPrompt { + /** The prompt exactly as typed, before template/command expansion. */ + text: string; + /** Image attachments submitted with the prompt. */ + images?: ImageContent[]; +} + /** Options for AgentSession.followUp(). */ export interface FollowUpOptions { /** Enqueue as a hidden developer message instead of a user follow-up. */ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index aa13bb6be..26ad0a36d 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -219,6 +219,7 @@ import type { AsyncJobSnapshot, CommandMetadataChangedListener, ContextUsageBreakdown, + DroppedPrompt, FollowUpOptions, FreshSessionResult, HandoffResult, @@ -535,6 +536,9 @@ export class AgentSession { #titleSystemPrompt: string | undefined; #titleGenerationStart: (() => void) | undefined; #titleGenerationInFlightFor: string | undefined; + /** Host hook invoked when a typed user prompt is dropped before dispatch; + * see {@link setPromptDropped}. */ + #promptDropped: ((prompt: DroppedPrompt) => void) | undefined; #titleGenerationAbortController = new AbortController(); #toolChoiceQueue = new ToolChoiceQueue(); @@ -5326,6 +5330,9 @@ export class AgentSession { */ async prompt(text: string, options?: PromptOptions): Promise { const expandPromptTemplates = options?.expandPromptTemplates ?? true; + // Slash/custom-command handling below rewrites `text`; keep the original + // so a dropped prompt is handed back exactly as the user typed it. + const typedText = text; // Handle extension commands first (execute immediately, even during streaming) if (expandPromptTemplates && text.startsWith("/")) { @@ -5439,8 +5446,9 @@ export class AgentSession { preludeMessages.push(eagerTaskPrelude); } + let dispatched = false; try { - await this.#promptWithMessage(message, expandedText, { + dispatched = await this.#promptWithMessage(message, expandedText, { ...options, images: normalizedImages, prependMessages: @@ -5454,6 +5462,13 @@ export class AgentSession { this.#toolChoiceQueue.removeByLabel("eager-todo"); this.#toolChoiceQueue.removeByLabel("external-thinking"); } + if (!dispatched && message.role === "user") { + // An abort (Esc) or preflight denial raced turn setup: the prompt never + // reached the agent or the session file. Hand it back to the host so the + // user can edit/resubmit instead of losing it (tree/branch can't offer + // a message that was never persisted). + this.#promptDropped?.({ text: typedText, images: options?.images }); + } return true; } @@ -5536,12 +5551,16 @@ export class AgentSession { skipPostPromptRecoveryWait?: boolean; acceptTerminalEmptyStop?: boolean; }, - ): Promise { + ): Promise { + // Returns false when the prompt was dropped before reaching the agent — + // every pre-dispatch bail (generation bump from abort, disposal, usage + // preflight denial) exits silently, and prompt() uses the outcome to hand + // the typed text back to the host instead of losing it. this.#beginInFlight(); const generation = this.#promptGeneration; try { await this.#recovery.maybeRestoreRetryFallbackPrimary(); - if (!(await this.#runUsageAwarePreflightForNextModelCall())) return; + if (!(await this.#runUsageAwarePreflightForNextModelCall())) return false; // Flush any pending bash messages before the new prompt await this.#bash.flushPending(); this.#eval.flushPending(); @@ -5609,7 +5628,7 @@ export class AgentSession { // Early bail-out: if a newer abort/prompt cycle started during setup, // return before mutating shared state (nextTurn messages, system prompt). if (this.#promptGeneration !== generation) { - return; + return false; } // A pending xd:// delta accompanies the next user-authored prompt, @@ -5644,7 +5663,7 @@ export class AgentSession { // resuming would start a turn on a torn-down session. const disposingBeforeTransition = this.#isDisposed; await this.#memory.transition; - if ((this.#isDisposed && !disposingBeforeTransition) || this.#promptGeneration !== generation) return; + if ((this.#isDisposed && !disposingBeforeTransition) || this.#promptGeneration !== generation) return false; const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText); let baseXdevCatalogDelivered = true; @@ -5695,7 +5714,7 @@ export class AgentSession { // Bail out if a newer abort/prompt cycle has started since we began setup if (this.#promptGeneration !== generation) { - return; + return false; } // Auto thinking: classify this real user turn and set the effective level @@ -5709,7 +5728,7 @@ export class AgentSession { if (this.isAutoThinking && isUserTurn) { await this.#models.applyAutoThinkingLevel(expandedText, generation); if (this.#promptGeneration !== generation) { - return; + return false; } } const xdevMountNotice = isUserQueuedMessage(message) @@ -5721,7 +5740,7 @@ export class AgentSession { await this.#maintenance.runPrePromptCompactionIfNeeded(messages); if (this.#promptGeneration !== generation) { - return; + return false; } const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined; @@ -5757,6 +5776,7 @@ export class AgentSession { if (!options?.skipPostPromptRecoveryWait) { await this.#waitForPostPromptRecovery(generation); } + return true; } finally { // The per-turn before_agent_start override lives only for this turn. this.#tools.clearTurnSystemPromptOverride(); @@ -6648,6 +6668,14 @@ export class AgentSession { this.#titleGenerationStart = handler; } + /** Install the host hook that receives a typed user prompt dropped before + * dispatch (an Esc abort or usage preflight denial raced turn setup). The + * prompt never reached the agent or the session file, so without this hook + * it would vanish; interactive mode restores it to the editor for editing. */ + setPromptDropped(handler: ((prompt: DroppedPrompt) => void) | undefined): void { + this.#promptDropped = handler; + } + /** * Abort current operation and wait for agent to become idle. *