From a538cddd33d959151a72635fd4b32065842e6cc1 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 25 Jun 2026 18:52:09 +0000 Subject: [PATCH 1/4] fix(tui): guarded streaming escape cancel Added a two-step Esc guard for active streaming responses and deferred ordinary render scheduling behind queued input so Esc delivery stays responsive under streaming load. Fixes #3493 --- packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/controllers/input-controller.ts | 41 +++++++++- .../test/input-controller-escape.test.ts | 51 ++++++++++++- packages/tui/CHANGELOG.md | 4 + packages/tui/src/tui.ts | 2 +- .../tui/test/input-render-scheduling.test.ts | 74 +++++++++++++++++++ 6 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 packages/tui/test/input-render-scheduling.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d5afd8188..475beace5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed streaming Esc handling so the first Esc arms a 2s cancel hint and only a second Esc in that window aborts the active response ([#3493](https://github.com/can1357/oh-my-pi/issues/3493)). + ## [16.1.19] - 2026-06-25 ### Fixed diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 063fb0a1f..ec7774394 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -118,6 +118,7 @@ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000; // deliberate human double-tap is always tens of milliseconds apart. const LEFT_DOUBLE_TAP_MIN_GAP_MS = 40; const LEFT_DOUBLE_TAP_MAX_GAP_MS = 500; +const STREAMING_ESCAPE_CANCEL_WINDOW_MS = 2_000; export class InputController { constructor( @@ -137,6 +138,11 @@ export class InputController { // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See // #detectLeftDoubleTap. #leftTapCount = 0; + // Streaming turns use a two-step Esc: first press arms this token, second press + // within the window aborts the same live assistant message. + #streamingEscapeArmedToken: object | undefined; + #streamingEscapeArmedUntil = 0; + #streamingEscapeTimer: NodeJS.Timeout | undefined; // Sequential index for `local://attachment-N` references created by large-paste and // pasted-file attachments. Seeded from 0 and bumped past existing attachment files. #attachmentCounter = 0; @@ -186,6 +192,36 @@ export class InputController { const unsubscribe = tinyTitleClient.onProgress(update); } + #clearStreamingEscapeArm(): void { + this.#streamingEscapeArmedToken = undefined; + this.#streamingEscapeArmedUntil = 0; + if (this.#streamingEscapeTimer) { + clearTimeout(this.#streamingEscapeTimer); + this.#streamingEscapeTimer = undefined; + } + } + + #handleStreamingEscape(): void { + const token = this.ctx.streamingMessage ?? this.ctx.streamingComponent ?? this.ctx.session; + const now = Date.now(); + if (this.#streamingEscapeArmedToken === token && now <= this.#streamingEscapeArmedUntil) { + this.#clearStreamingEscapeArm(); + void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL }); + return; + } + + this.#clearStreamingEscapeArm(); + this.#streamingEscapeArmedToken = token; + this.#streamingEscapeArmedUntil = now + STREAMING_ESCAPE_CANCEL_WINDOW_MS; + this.#streamingEscapeTimer = setTimeout(() => { + if (this.#streamingEscapeArmedToken === token && Date.now() >= this.#streamingEscapeArmedUntil) { + this.#clearStreamingEscapeArm(); + } + }, STREAMING_ESCAPE_CANCEL_WINDOW_MS); + this.#streamingEscapeTimer.unref?.(); + this.ctx.showStatus("Press Esc again within 2s to cancel streaming."); + } + setupKeyHandlers(): void { this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt")); if (!this.#focusedLeftTapListenerInstalled) { @@ -263,7 +299,7 @@ export class InputController { if (this.ctx.loopModeEnabled) { this.ctx.pauseLoop(); if (this.ctx.session.isStreaming) { - void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL }); + this.#handleStreamingEscape(); } else { this.ctx.cancelPendingSubmission(); } @@ -314,12 +350,13 @@ export class InputController { this.ctx.isPythonMode = false; this.ctx.updateEditorBorderColor(); } else if (this.ctx.session.isStreaming) { - void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL }); + this.#handleStreamingEscape(); } else if (this.ctx.editor.getText().trim()) { // Esc with typed text clears the draft instead of (or before) any double-Esc action this.ctx.editor.setText(""); this.ctx.ui.requestRender(); this.ctx.lastEscapeTime = 0; + this.#clearStreamingEscapeArm(); } else { // Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting const action = settings.get("doubleEscapeAction"); diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index ac29fdb2b..a06274fa4 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -76,6 +76,7 @@ function createContext(): { requestRender: Spy; resetDisplay: Spy; shutdown: Spy; + showStatus: Spy; startPendingSubmission: StartPendingSubmissionSpy; updatePendingMessagesDisplay: Spy; }; @@ -93,6 +94,7 @@ function createContext(): { const onInputCallback = vi.fn(); const requestRender = vi.fn(); const resetDisplay = vi.fn(); + const showStatus = vi.fn(); const inputListeners: Array<(data: string) => { consume?: boolean; data?: string } | undefined> = []; const handleBtwCommand = vi.fn(async () => {}); const handleBtwEscape = vi.fn(() => true); @@ -205,6 +207,7 @@ function createContext(): { showSessionSelector: vi.fn(), shutdown: vi.fn(async () => {}), clearEditor: vi.fn(), + showStatus, } as unknown as InteractiveModeContext; return { @@ -231,6 +234,7 @@ function createContext(): { prompt, requestRender, resetDisplay, + showStatus, shutdown: ctx.shutdown as Spy, startPendingSubmission, updatePendingMessagesDisplay, @@ -407,7 +411,9 @@ describe("InputController escape behavior", () => { expect(spies.abort).not.toHaveBeenCalled(); }); - it("aborts streaming even when the working loader is no longer present", () => { + it("requires a second Esc within two seconds to abort streaming", () => { + const now = vi.spyOn(Date, "now"); + now.mockReturnValue(1_000); const { ctx, editor, spies } = createContext(); (ctx.session as { isStreaming: boolean }).isStreaming = true; const controller = new InputController(ctx); @@ -417,7 +423,50 @@ describe("InputController escape behavior", () => { expect(spies.cancelPendingSubmission).not.toHaveBeenCalled(); expect(spies.clearQueue).not.toHaveBeenCalled(); + expect(spies.abort).not.toHaveBeenCalled(); + expect(spies.showStatus).toHaveBeenCalledWith("Press Esc again within 2s to cancel streaming."); + + now.mockReturnValue(2_500); + editor.onEscape?.(); + expect(spies.abort).toHaveBeenCalledTimes(1); + expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL }); + }); + + it("expires the streaming Esc arm instead of aborting on a late second press", () => { + const now = vi.spyOn(Date, "now"); + now.mockReturnValue(1_000); + const { ctx, editor, spies } = createContext(); + (ctx.session as { isStreaming: boolean }).isStreaming = true; + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + editor.onEscape?.(); + now.mockReturnValue(3_001); + editor.onEscape?.(); + + expect(spies.abort).not.toHaveBeenCalled(); + expect(spies.showStatus).toHaveBeenCalledTimes(2); + }); + + it("does not let a prior streaming Esc arm abort a new assistant message", () => { + const now = vi.spyOn(Date, "now"); + now.mockReturnValue(1_000); + const { ctx, editor, spies } = createContext(); + const firstMessage = {}; + const secondMessage = {}; + (ctx.session as { isStreaming: boolean }).isStreaming = true; + (ctx as unknown as { streamingMessage: object }).streamingMessage = firstMessage; + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + editor.onEscape?.(); + (ctx as unknown as { streamingMessage: object }).streamingMessage = secondMessage; + now.mockReturnValue(1_500); + editor.onEscape?.(); + + expect(spies.abort).not.toHaveBeenCalled(); + expect(spies.showStatus).toHaveBeenCalledTimes(2); }); it("returns focused subagent view to main on Esc instead of aborting", () => { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index fa628a41c..1a01a42f1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed ordinary render scheduling to yield behind already-queued terminal input, preventing delayed Esc delivery during heavy streaming paints ([#3493](https://github.com/can1357/oh-my-pi/issues/3493)). + ## [16.1.19] - 2026-06-25 ### Fixed diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index ef778fe6e..7366bef53 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -110,7 +110,7 @@ export interface TUIStartOptions { const DEFAULT_RENDER_SCHEDULER: RenderScheduler = { now: () => performance.now(), scheduleImmediate: callback => { - process.nextTick(callback); + setImmediate(callback); }, scheduleRender: (callback, delayMs) => { const timer = setTimeout(callback, delayMs); diff --git a/packages/tui/test/input-render-scheduling.test.ts b/packages/tui/test/input-render-scheduling.test.ts new file mode 100644 index 000000000..430535089 --- /dev/null +++ b/packages/tui/test/input-render-scheduling.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import { type Component, type RenderTimer, TUI } from "@oh-my-pi/pi-tui"; +import { VirtualTerminal } from "./virtual-terminal"; + +class InputProbe implements Component { + constructor(private readonly events: string[]) {} + + invalidate(): void {} + + render(_width: number): readonly string[] { + this.events.push("render"); + return ["probe"]; + } + + handleInput(_data: string): void { + this.events.push("input"); + } +} + +class DeferredRenderScheduler { + nowMs = 0; + readonly immediates: Array<() => void> = []; + readonly timers: Array<{ callback: () => void; canceled: boolean }> = []; + + now(): number { + return this.nowMs; + } + + scheduleImmediate(callback: () => void): void { + this.immediates.push(callback); + } + + scheduleRender(callback: () => void, _delayMs: number): RenderTimer { + const timer = { callback, canceled: false }; + this.timers.push(timer); + return { + cancel: () => { + timer.canceled = true; + }, + }; + } +} + +describe("TUI input/render scheduling", () => { + it("can process terminal input before a deferred ordinary repaint", () => { + const term = new VirtualTerminal(20, 4); + const scheduler = new DeferredRenderScheduler(); + const events: string[] = []; + const probe = new InputProbe(events); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + tui.addChild(probe); + tui.setFocus(probe); + + try { + tui.start(); + scheduler.immediates.shift()?.(); + const initialTimer = scheduler.timers.shift(); + if (initialTimer && !initialTimer.canceled) initialTimer.callback(); + events.length = 0; + scheduler.nowMs = 100; + + tui.requestRender(); + term.sendInput("x"); + scheduler.immediates.shift()?.(); + const repaintTimer = scheduler.timers.shift(); + if (repaintTimer && !repaintTimer.canceled) repaintTimer.callback(); + + expect(events[0]).toBe("input"); + expect(events).toContain("render"); + } finally { + tui.stop(); + } + }); +}); From 3158435b8a77a39ad09ff86255b567c85a5141a6 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 25 Jun 2026 19:01:22 +0000 Subject: [PATCH 2/4] fix(agent): cleared streaming esc arm on turn boundary Subscribed the input controller to session lifecycle events so a fallback streaming Esc arm taken pre-message_start cannot carry across an agent_end/agent_start pair and abort a fresh turn within the 2s window. --- .../src/modes/controllers/input-controller.ts | 13 ++++++- .../test/input-controller-escape.test.ts | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index ec7774394..a3f9479dc 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -139,10 +139,13 @@ export class InputController { // #detectLeftDoubleTap. #leftTapCount = 0; // Streaming turns use a two-step Esc: first press arms this token, second press - // within the window aborts the same live assistant message. + // within the window aborts the same live assistant message. The arm is cleared on + // every session lifecycle transition (`agent_start`/`agent_end`) so a fallback + // arm taken pre-`message_start` cannot carry over and abort a fresh turn. #streamingEscapeArmedToken: object | undefined; #streamingEscapeArmedUntil = 0; #streamingEscapeTimer: NodeJS.Timeout | undefined; + #streamingEscapeSessionSubscribed = false; // Sequential index for `local://attachment-N` references created by large-paste and // pasted-file attachments. Seeded from 0 and bumped past existing attachment files. #attachmentCounter = 0; @@ -224,6 +227,14 @@ export class InputController { setupKeyHandlers(): void { this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt")); + if (!this.#streamingEscapeSessionSubscribed && typeof this.ctx.session.subscribe === "function") { + this.#streamingEscapeSessionSubscribed = true; + this.ctx.session.subscribe(event => { + if (event.type === "agent_start" || event.type === "agent_end") { + this.#clearStreamingEscapeArm(); + } + }); + } if (!this.#focusedLeftTapListenerInstalled) { this.#focusedLeftTapListenerInstalled = true; this.ctx.ui.addInputListener(data => { diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index a06274fa4..42678865e 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -81,6 +81,7 @@ function createContext(): { updatePendingMessagesDisplay: Spy; }; inputListeners: Array<(data: string) => { consume?: boolean; data?: string } | undefined>; + sessionListeners: Array<(event: { type: string }) => void>; } { let editorText = ""; const abort = vi.fn(); @@ -96,6 +97,7 @@ function createContext(): { const resetDisplay = vi.fn(); const showStatus = vi.fn(); const inputListeners: Array<(data: string) => { consume?: boolean; data?: string } | undefined> = []; + const sessionListeners: Array<(event: { type: string }) => void> = []; const handleBtwCommand = vi.fn(async () => {}); const handleBtwEscape = vi.fn(() => true); const hasActiveBtw = vi.fn(() => false); @@ -160,6 +162,13 @@ function createContext(): { clearQueue, getQueuedMessages, prompt, + subscribe: vi.fn((listener: (event: { type: string }) => void) => { + sessionListeners.push(listener); + return () => { + const index = sessionListeners.indexOf(listener); + if (index >= 0) sessionListeners.splice(index, 1); + }; + }), } as unknown as InteractiveModeContext["session"], viewSession: { isCompacting: false, @@ -240,6 +249,7 @@ function createContext(): { updatePendingMessagesDisplay, }, inputListeners, + sessionListeners, }; } beforeEach(async () => { @@ -469,6 +479,33 @@ describe("InputController escape behavior", () => { expect(spies.showStatus).toHaveBeenCalledTimes(2); }); + it("clears the streaming Esc arm when the current turn ends", () => { + const now = vi.spyOn(Date, "now"); + now.mockReturnValue(1_000); + const { ctx, editor, spies, sessionListeners } = createContext(); + (ctx.session as { isStreaming: boolean }).isStreaming = true; + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + // Fallback arm (no streamingMessage/streamingComponent yet — pre-message_start). + editor.onEscape?.(); + expect(sessionListeners).toHaveLength(1); + + // Turn 1 ends; a new turn starts. session.subscribe receives both transitions, + // either of which must invalidate the still-armed fallback token so it cannot + // fast-abort the new turn's first Esc. + for (const listener of sessionListeners) { + listener({ type: "agent_end" }); + listener({ type: "agent_start" }); + } + + now.mockReturnValue(1_500); + editor.onEscape?.(); + + expect(spies.abort).not.toHaveBeenCalled(); + expect(spies.showStatus).toHaveBeenCalledTimes(2); + }); + it("returns focused subagent view to main on Esc instead of aborting", () => { const { ctx, editor, spies } = createContext(); Object.defineProperty(ctx, "focusedAgentId", { value: "Worker", configurable: true }); From 4dc0c6a3b279d06c345c8a160e3898bda7787bdb Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 25 Jun 2026 19:06:34 +0000 Subject: [PATCH 3/4] fix(agent): keyed streaming esc arm on streamingComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventController replaces ctx.streamingMessage with a fresh immutable snapshot on every message_update, so keying the Esc arm on that reference invalidated it between presses and re-armed instead of cancelling. The arm now keys on ctx.streamingComponent — created once per message_start and stable across deltas — with ctx.session as the pre-message_start fallback, still cleared on agent_start/agent_end. --- .../src/modes/controllers/input-controller.ts | 10 +++++- .../test/input-controller-escape.test.ts | 34 ++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index a3f9479dc..d044d55bb 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -205,7 +205,15 @@ export class InputController { } #handleStreamingEscape(): void { - const token = this.ctx.streamingMessage ?? this.ctx.streamingComponent ?? this.ctx.session; + // `ctx.streamingMessage` is replaced on every `message_update` (agent-loop + // hands the EventController a fresh immutable snapshot per delta), so keying + // on it would invalidate the arm between the first and second Esc as soon as + // the next token arrived. `ctx.streamingComponent` is created once per + // `message_start` and survives every delta until the message ends; the + // session fallback covers the pre-`message_start` window and is cleared on + // every `agent_start`/`agent_end` (see setupKeyHandlers) so it cannot carry + // across turn boundaries. + const token = this.ctx.streamingComponent ?? this.ctx.session; const now = Date.now(); if (this.#streamingEscapeArmedToken === token && now <= this.#streamingEscapeArmedUntil) { this.#clearStreamingEscapeArm(); diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index 42678865e..e904ab260 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -459,19 +459,19 @@ describe("InputController escape behavior", () => { expect(spies.showStatus).toHaveBeenCalledTimes(2); }); - it("does not let a prior streaming Esc arm abort a new assistant message", () => { + it("re-arms when the streaming component is replaced between the two Esc presses", () => { const now = vi.spyOn(Date, "now"); now.mockReturnValue(1_000); const { ctx, editor, spies } = createContext(); - const firstMessage = {}; - const secondMessage = {}; + const firstComponent = {}; + const secondComponent = {}; (ctx.session as { isStreaming: boolean }).isStreaming = true; - (ctx as unknown as { streamingMessage: object }).streamingMessage = firstMessage; + (ctx as unknown as { streamingComponent: object }).streamingComponent = firstComponent; const controller = new InputController(ctx); controller.setupKeyHandlers(); editor.onEscape?.(); - (ctx as unknown as { streamingMessage: object }).streamingMessage = secondMessage; + (ctx as unknown as { streamingComponent: object }).streamingComponent = secondComponent; now.mockReturnValue(1_500); editor.onEscape?.(); @@ -479,6 +479,30 @@ describe("InputController escape behavior", () => { expect(spies.showStatus).toHaveBeenCalledTimes(2); }); + it("aborts on the second Esc even when ctx.streamingMessage was replaced by a delta in between", () => { + // `EventController` replaces `ctx.streamingMessage` with a fresh immutable + // snapshot on every `message_update`; only `streamingComponent` is stable + // across the streaming window, so swapping the message must not invalidate + // the armed token. + const now = vi.spyOn(Date, "now"); + now.mockReturnValue(1_000); + const { ctx, editor, spies } = createContext(); + const streamingComponent = {}; + (ctx.session as { isStreaming: boolean }).isStreaming = true; + (ctx as unknown as { streamingComponent: object }).streamingComponent = streamingComponent; + (ctx as unknown as { streamingMessage: object }).streamingMessage = { content: [] }; + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + editor.onEscape?.(); + (ctx as unknown as { streamingMessage: object }).streamingMessage = { content: ["delta"] }; + now.mockReturnValue(1_500); + editor.onEscape?.(); + + expect(spies.abort).toHaveBeenCalledTimes(1); + expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL }); + }); + it("clears the streaming Esc arm when the current turn ends", () => { const now = vi.spyOn(Date, "now"); now.mockReturnValue(1_000); From 99668abd4149425f0fc41a7f8ad61591079a5b55 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 25 Jun 2026 19:22:33 +0000 Subject: [PATCH 4/4] fix(agent): minted per-turn sentinel for streaming esc arm Replaced the streamingComponent/session token fallback with a per-turn sentinel that is reset on every agent_start/agent_end. The arm now survives the pre-message_start to post-message_start transition (and any later message_update churn) within a single turn instead of re-arming when streamingComponent first appears. --- .../src/modes/controllers/input-controller.ts | 22 +++++++++---------- .../test/input-controller-escape.test.ts | 22 +++++++++---------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index d044d55bb..aa552e78d 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -139,9 +139,11 @@ export class InputController { // #detectLeftDoubleTap. #leftTapCount = 0; // Streaming turns use a two-step Esc: first press arms this token, second press - // within the window aborts the same live assistant message. The arm is cleared on - // every session lifecycle transition (`agent_start`/`agent_end`) so a fallback - // arm taken pre-`message_start` cannot carry over and abort a fresh turn. + // within the window aborts the same live assistant turn. The token is a per-turn + // sentinel minted lazily on demand and reset on every `agent_start`/`agent_end` + // (see setupKeyHandlers), so it survives `message_start`/`message_update` + // transitions inside a single turn but cannot leak across turn boundaries. + #streamingEscapeTurnSentinel: object | undefined; #streamingEscapeArmedToken: object | undefined; #streamingEscapeArmedUntil = 0; #streamingEscapeTimer: NodeJS.Timeout | undefined; @@ -205,15 +207,10 @@ export class InputController { } #handleStreamingEscape(): void { - // `ctx.streamingMessage` is replaced on every `message_update` (agent-loop - // hands the EventController a fresh immutable snapshot per delta), so keying - // on it would invalidate the arm between the first and second Esc as soon as - // the next token arrived. `ctx.streamingComponent` is created once per - // `message_start` and survives every delta until the message ends; the - // session fallback covers the pre-`message_start` window and is cleared on - // every `agent_start`/`agent_end` (see setupKeyHandlers) so it cannot carry - // across turn boundaries. - const token = this.ctx.streamingComponent ?? this.ctx.session; + if (!this.#streamingEscapeTurnSentinel) { + this.#streamingEscapeTurnSentinel = {}; + } + const token = this.#streamingEscapeTurnSentinel; const now = Date.now(); if (this.#streamingEscapeArmedToken === token && now <= this.#streamingEscapeArmedUntil) { this.#clearStreamingEscapeArm(); @@ -239,6 +236,7 @@ export class InputController { this.#streamingEscapeSessionSubscribed = true; this.ctx.session.subscribe(event => { if (event.type === "agent_start" || event.type === "agent_end") { + this.#streamingEscapeTurnSentinel = undefined; this.#clearStreamingEscapeArm(); } }); diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index e904ab260..6783058f1 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -459,37 +459,35 @@ describe("InputController escape behavior", () => { expect(spies.showStatus).toHaveBeenCalledTimes(2); }); - it("re-arms when the streaming component is replaced between the two Esc presses", () => { + it("preserves the streaming Esc arm when streamingComponent appears between presses", () => { + // Pre-`message_start`: first Esc arms on the per-turn sentinel. `message_start` + // then publishes `ctx.streamingComponent`; the second Esc must still abort the + // same live turn instead of re-arming on the new component reference. const now = vi.spyOn(Date, "now"); now.mockReturnValue(1_000); const { ctx, editor, spies } = createContext(); - const firstComponent = {}; - const secondComponent = {}; (ctx.session as { isStreaming: boolean }).isStreaming = true; - (ctx as unknown as { streamingComponent: object }).streamingComponent = firstComponent; const controller = new InputController(ctx); controller.setupKeyHandlers(); editor.onEscape?.(); - (ctx as unknown as { streamingComponent: object }).streamingComponent = secondComponent; + (ctx as unknown as { streamingComponent: object }).streamingComponent = {}; now.mockReturnValue(1_500); editor.onEscape?.(); - expect(spies.abort).not.toHaveBeenCalled(); - expect(spies.showStatus).toHaveBeenCalledTimes(2); + expect(spies.abort).toHaveBeenCalledTimes(1); + expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL }); }); it("aborts on the second Esc even when ctx.streamingMessage was replaced by a delta in between", () => { // `EventController` replaces `ctx.streamingMessage` with a fresh immutable - // snapshot on every `message_update`; only `streamingComponent` is stable - // across the streaming window, so swapping the message must not invalidate - // the armed token. + // snapshot on every `message_update`; the per-turn sentinel is unaffected so + // swapping the message must not invalidate the armed token. const now = vi.spyOn(Date, "now"); now.mockReturnValue(1_000); const { ctx, editor, spies } = createContext(); - const streamingComponent = {}; (ctx.session as { isStreaming: boolean }).isStreaming = true; - (ctx as unknown as { streamingComponent: object }).streamingComponent = streamingComponent; + (ctx as unknown as { streamingComponent: object }).streamingComponent = {}; (ctx as unknown as { streamingMessage: object }).streamingMessage = { content: [] }; const controller = new InputController(ctx);