feat(coding-agent): restored cancelled or preflight-denied prompts to editor

- Add `setPromptDropped` hook and `DroppedPrompt` type to return prompts that were cancelled during turn setup before dispatch.
- Restore dropped prompt text and image attachments to the interactive mode editor and remove the optimistic transcript row.
This commit is contained in:
can1357
2026-08-20 00:02:41 +02:00
parent 12238f55ca
commit 37fd2dbbe1
4 changed files with 76 additions and 8 deletions
+4
View File
@@ -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
@@ -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;
@@ -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. */
@@ -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<boolean> {
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<void> {
): Promise<boolean> {
// 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.
*