From 48e58f117f0bc4a02cdb38a2fca5b44e8c4688c1 Mon Sep 17 00:00:00 2001 From: Parker Pettit Date: Thu, 6 Aug 2026 16:51:04 +0000 Subject: [PATCH 1/4] fix(coding-agent): answer ACP prompts only once the session admits the next turn session/prompt returned after waitForHeadlessCompletion while the agent session could still be streaming the turn's residue. A client that prompts right after end_turn then gets rejected with "Agent is already processing. Specify streamingBehavior" - advice it cannot follow, since the ACP surface never reads that option. The response is the client's admission signal, so hold it until the session's own admission gate (waitForIdle) clears. Seen in production by the verifiers ACP harness driving multi-turn panel seats: 16 rejected turns in one overnight campaign. --- .../coding-agent/src/modes/acp/acp-mode.ts | 3 ++ .../coding-agent/test/suite/acp-mode.test.ts | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index 2f075b024..fc9221e35 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -408,6 +408,9 @@ export async function runAcpModeWithConnection( }) .catch(() => undefined); + // The response is the client's signal that the next prompt is admissible. + // Work may have restarted the session after headless completion observed idle. + await connection.waitForIdle(); // A turn that failed (provider error, auth, no usable model) must not be // reported as a clean end_turn. Print mode surfaces // `stopReason: "error"` with its errorMessage; ACP previously dropped diff --git a/packages/coding-agent/test/suite/acp-mode.test.ts b/packages/coding-agent/test/suite/acp-mode.test.ts index 596d0dd13..c8d7a510e 100644 --- a/packages/coding-agent/test/suite/acp-mode.test.ts +++ b/packages/coding-agent/test/suite/acp-mode.test.ts @@ -62,6 +62,7 @@ function fakeAcpConnection( return snapshot; }, promptAndWait: async () => {}, + waitForIdle: async () => {}, waitForHeadlessCompletion: async () => ({ enabled: false, continuationsUsed: 0, @@ -294,4 +295,42 @@ describe("ACP mode end to end", () => { expect(quiescence.update._meta[PRIME_AGENT_META_NAMESPACE].quiescence.outstandingSubagents).toBe(1); close(); }); + it("answers a prompt only once the session admits the next turn", async () => { + const harness = await createHarness(); + harness.setResponses([fauxAssistantMessage("First reply."), fauxAssistantMessage("Second reply.")]); + const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); + + // The prompt response is the client's signal that the next prompt is + // admissible, so it must be ordered after the session's admission gate. + let idleAwaited = false; + const waitForIdle = connection.waitForIdle.bind(connection); + connection.waitForIdle = async () => { + await waitForIdle(); + idleAwaited = true; + }; + + const { client } = connectAcpClient(connection); + await client.request("initialize", { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const session = await client.request("session/new", { cwd: harness.tempDir, mcpServers: [] }); + + const first = await client.request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "First turn" }], + }); + expect(first.stopReason).toBe("end_turn"); + expect(idleAwaited).toBe(true); + + // A client that prompts immediately after end_turn must never be refused + // as "Agent is already processing". + const second = await client.request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Second turn" }], + }); + expect(second.stopReason).toBe("end_turn"); + + harness.cleanup(); + }, 30_000); }); From ef2ee9383eff775c5687f4329730b679b7c9958b Mon Sep 17 00:00:00 2001 From: Parker Pettit Date: Mon, 10 Aug 2026 19:05:36 +0000 Subject: [PATCH 2/4] fix(coding-agent): queue ACP prompts behind in-flight work instead of waiting for idle The final waitForIdle() before the ACP response was whole-session quiescence, not an admission gate: it held the session/prompt response open for any work injected after the first idle observation (subagent replies, heartbeats), could defer it indefinitely behind a chain of detached-child turns, and blocked the same boundary #806 deliberately kept non-blocking. The stop reason was also computed from the status snapshot captured before that wait, so a turn that exhausted an autonomous limit while the response waited still reported end_turn. Queue the host turn instead: pass streamingBehavior followUp with queueIfBusy so a prompt that arrives while injected work keeps the session busy runs after it rather than being rejected with "Agent is already processing". promptAndWait resolves once the queued turn has run, so waitForHeadlessCompletion captures the status the response actually gates and the stop reason stays fresh. Regression tests inject real work after the first headless-completion idle observation and drive a real ACP client: the follow-up prompt is queued (never rejected, never resolved early), the stop reason reflects the queued turn's fresh autonomous status, and the response is not held open for detached work. --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/modes/acp/acp-mode.ts | 16 +- .../coding-agent/test/suite/acp-mode.test.ts | 173 ++++++++++++++++-- 3 files changed, 166 insertions(+), 24 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1716ecd6e..dac5b6058 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Added ACP `_meta.quiescence` to report outstanding subagents and remaining autonomous continuations; `session/prompt` rejects if its live child-roster read fails. +- Fixed ACP rejecting an immediate follow-up prompt with "Agent is already processing" when injected work (subagent replies, heartbeats) restarted the session after the previous turn; the follow-up now queues behind the in-flight work instead of failing, and `session/cancel` drops a queued follow-up that has not started. - Fixed resident daemon workers retaining their permitted launch environment across supervisor restarts while keeping client-owned credentials out of descriptor files. - Added a configurable copy action to login dialogs so raw sign-in URLs can be copied without selecting wrapped text ([#643](https://github.com/PrimeIntellect-ai/prime-agent/issues/643)). - Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)). diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index fc9221e35..52928ce9c 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -387,7 +387,18 @@ export async function runAcpModeWithConnection( // rebuild the transcript mid-turn, so record the pre-turn messages // themselves rather than how many there were. const priorMessages = turnBoundary(await connection.getMessages()); - await connection.promptAndWait(text, images.length > 0 ? { images } : undefined); + // A follow-up prompt can arrive while injected work (subagent replies, + // heartbeats) keeps the resident session busy. ACP has no native queue + // field, so queue the host turn behind that work with follow-up + // semantics instead of rejecting it as "Agent is already processing". + // promptAndWait then resolves once the queued turn has actually run, + // which is also what keeps the stop reason below attributed to the + // turn this prompt gates. + await connection.promptAndWait(text, { + ...(images.length > 0 ? { images } : {}), + streamingBehavior: "followUp", + queueIfBusy: true, + }); // Autonomous gates continue inside this same prompt turn: the turn is // only over once the gate loop settles. const status = await connection.waitForHeadlessCompletion(); @@ -408,9 +419,6 @@ export async function runAcpModeWithConnection( }) .catch(() => undefined); - // The response is the client's signal that the next prompt is admissible. - // Work may have restarted the session after headless completion observed idle. - await connection.waitForIdle(); // A turn that failed (provider error, auth, no usable model) must not be // reported as a clean end_turn. Print mode surfaces // `stopReason: "error"` with its errorMessage; ACP previously dropped diff --git a/packages/coding-agent/test/suite/acp-mode.test.ts b/packages/coding-agent/test/suite/acp-mode.test.ts index c8d7a510e..1af4e44bc 100644 --- a/packages/coding-agent/test/suite/acp-mode.test.ts +++ b/packages/coding-agent/test/suite/acp-mode.test.ts @@ -76,6 +76,31 @@ function fakeAcpConnection( }; } +/** + * Real injected work with production timing: after the first headless-completion + * idle observation, this starts a genuine session turn (a subagent reply has the + * same shape) and blocks until the session is streaming it. Returns a checker + * for whether the injection has happened. + */ +function injectWorkAfterHeadlessCompletion(connection: any, session: any, text: string): () => boolean { + const waitForHeadlessCompletion = connection.waitForHeadlessCompletion.bind(connection); + let injected = false; + connection.waitForHeadlessCompletion = async () => { + const status = await waitForHeadlessCompletion(); + if (!injected) { + injected = true; + void session.prompt(text); + const deadline = Date.now() + 5_000; + while (!session.isStreaming && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + expect(session.isStreaming).toBe(true); + } + return status; + }; + return () => injected; +} + function connectAcpClient(connection: any): ClientHarness { // Two web streams crossed over: agent's stdout is the client's stdin. const toAgent = new TransformStream(); @@ -295,25 +320,89 @@ describe("ACP mode end to end", () => { expect(quiescence.update._meta[PRIME_AGENT_META_NAMESPACE].quiescence.outstandingSubagents).toBe(1); close(); }); - it("answers a prompt only once the session admits the next turn", async () => { + // Production race: after ACP reported a completed turn, injected work (a + // subagent reply, heartbeat, goal message) restarted the resident session, so + // the client's next prompt was rejected with "Agent is already processing". + // Each regression injects real work after the first idle observation and + // drives a real ACP client, rather than asserting call ordering. + + it("queues a follow-up prompt behind injected work instead of rejecting it", async () => { const harness = await createHarness(); - harness.setResponses([fauxAssistantMessage("First reply."), fauxAssistantMessage("Second reply.")]); + let releaseInjected!: () => void; + const injectedHeld = new Promise((resolve) => { + releaseInjected = () => resolve(fauxAssistantMessage("injected work done")); + }); + harness.setResponses([ + fauxAssistantMessage("turn one done"), + () => injectedHeld, + fauxAssistantMessage("turn two done"), + ]); const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); + const injected = injectWorkAfterHeadlessCompletion(connection, harness.session, "injected work"); + const { client, updates } = connectAcpClient(connection); + await client.request("initialize", { protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} }); + const session = await client.request("session/new", { cwd: harness.tempDir, mcpServers: [] }); + + const first = await client.request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "First turn" }], + }); + expect(first.stopReason).toBe("end_turn"); + expect(injected()).toBe(true); + // The injected turn is still streaming, so the session is busy when the + // follow-up prompt arrives, exactly as in the production campaign. + expect(harness.session.isStreaming).toBe(true); - // The prompt response is the client's signal that the next prompt is - // admissible, so it must be ordered after the session's admission gate. - let idleAwaited = false; - const waitForIdle = connection.waitForIdle.bind(connection); - connection.waitForIdle = async () => { - await waitForIdle(); - idleAwaited = true; - }; + let secondSettled = false; + const second = client + .request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Second turn" }], + }) + .finally(() => { + secondSettled = true; + }); + // The queued turn must neither resolve early nor reject with + // "Agent is already processing". + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(secondSettled).toBe(false); + expect(harness.session.isStreaming).toBe(true); + releaseInjected(); + await expect(second).resolves.toMatchObject({ stopReason: "end_turn" }); + expect(harness.session.isStreaming).toBe(false); - const { client } = connectAcpClient(connection); - await client.request("initialize", { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, + const text = updates + .filter((u) => u.update?.sessionUpdate === "agent_message_chunk") + .map((u) => u.update.content.text) + .join(""); + expect(text).toContain("turn two done"); + + harness.cleanup(); + }, 5_000); + + it("reports the queued turn's stop reason from a fresh autonomous status", async () => { + const harness = await createHarness({ + autonomous: { + enabled: true, + maxTurns: 2, + maxContinuations: 3, + maxTokens: 80_000, + gates: { commands: ["true"], maxRetries: 3 }, + }, + }); + let releaseInjected!: () => void; + const injectedHeld = new Promise((resolve) => { + releaseInjected = () => resolve(fauxAssistantMessage("injected work done")); }); + harness.setResponses([ + fauxAssistantMessage("turn one done"), + () => injectedHeld, + fauxAssistantMessage("turn two done"), + ]); + const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); + injectWorkAfterHeadlessCompletion(connection, harness.session, "injected work"); + const { client } = connectAcpClient(connection); + await client.request("initialize", { protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} }); const session = await client.request("session/new", { cwd: harness.tempDir, mcpServers: [] }); const first = await client.request("session/prompt", { @@ -321,16 +410,60 @@ describe("ACP mode end to end", () => { prompt: [{ type: "text", text: "First turn" }], }); expect(first.stopReason).toBe("end_turn"); - expect(idleAwaited).toBe(true); + expect(harness.session.getAutonomousStatus().turnsUsed).toBe(1); - // A client that prompts immediately after end_turn must never be refused - // as "Agent is already processing". - const second = await client.request("session/prompt", { + const second = client.request("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "Second turn" }], }); - expect(second.stopReason).toBe("end_turn"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(harness.session.isStreaming).toBe(true); + releaseInjected(); + // The second prompt's response gates its own queued turn, so the stop + // reason comes from the status captured after that turn ran, not from a + // snapshot taken before the injected work consumed the autonomous budget. + await expect(second).resolves.toMatchObject({ stopReason: "max_turn_requests" }); + expect(harness.session.getAutonomousStatus().turnsUsed).toBeGreaterThanOrEqual( + harness.session.getAutonomousStatus().limits.maxTurns, + ); + harness.cleanup(); + }, 5_000); + it("does not hold the prompt response open for detached work", async () => { + const harness = await createHarness(); + let releaseInjected!: () => void; + const injectedHeld = new Promise((resolve) => { + releaseInjected = () => resolve(fauxAssistantMessage("injected work done")); + }); + harness.setResponses([fauxAssistantMessage("turn one done"), () => injectedHeld]); + const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); + const injected = injectWorkAfterHeadlessCompletion(connection, harness.session, "injected work"); + const { client } = connectAcpClient(connection); + await client.request("initialize", { protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} }); + const session = await client.request("session/new", { cwd: harness.tempDir, mcpServers: [] }); + + let settled = false; + const prompt = client + .request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "First turn" }], + }) + .finally(() => { + settled = true; + }); + const deadline = Date.now() + 5_000; + while (!injected() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 1)); + } + expect(injected()).toBe(true); + // The response boundary reports quiescence rather than waiting for it: + // detached work must not hold the session/prompt response open, which is + // exactly the quiescence-blocking #806 rejected. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(settled).toBe(true); + expect(harness.session.isStreaming).toBe(true); + releaseInjected(); + await expect(prompt).resolves.toMatchObject({ stopReason: "end_turn" }); harness.cleanup(); - }, 30_000); + }, 5_000); }); From e5bdb44ced40ab22ef8fc38d8f45f886571e1b0d Mon Sep 17 00:00:00 2001 From: Parker Pettit Date: Mon, 10 Aug 2026 20:13:01 +0000 Subject: [PATCH 3/4] fix(coding-agent): cancel an ACP prompt still queued behind busy work session/prompt passes abort.signal to promptAndWait, and AgentSession promptAndWait now cancels a not-yet-started followUp action when that signal fires instead of leaving it to run after the busy turn drains. session/cancel on the daemon path propagates its admission-controller abort to the queued prompt (cancel_prompt_admission aborts the owned controller), so a cancelled prompt neither runs later nor leaves the session/prompt request hanging. --- .../coding-agent/src/core/agent-session.ts | 29 +++++++++++ .../coding-agent/src/modes/acp/acp-mode.ts | 3 ++ .../src/modes/daemon/daemon-mode.ts | 3 ++ .../coding-agent/test/suite/acp-mode.test.ts | 52 +++++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1ddc8761b..ed30690e4 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4464,12 +4464,41 @@ export class AgentSession { const outcome = this._agentMessageOutcome(agentMessageId); outcome.completion = createAgentMessageDeferred(); const completion = outcome.completion.promise; + // Admission-time signal checks cannot see an abort that lands once a + // followUp prompt is already queued behind busy work. Cancel the not-yet- + // started action here so promptAndWait rejects and the caller can report + // the cancellation instead of leaving the queued prompt to run later. + const signal = options?.signal; + let cancelQueuedPrompt: (() => void) | undefined; + const detachCancelQueuedPrompt = () => { + if (signal && cancelQueuedPrompt) { + signal.removeEventListener("abort", cancelQueuedPrompt); + } + }; try { await this.promptUntilAccepted(text, { ...options, agentMessageId }); + if (signal && !signal.aborted) { + cancelQueuedPrompt = () => { + const error = new Error("Prompt was cancelled before it started."); + const cancelled = this._cancelSessionActions( + (action) => + action.agentMessageId === agentMessageId && + action.payload.kind === "turn" && + (action.lifecycle.state === "queued" || action.lifecycle.state === "selected"), + error, + ); + if (cancelled.length > 0) { + this._settleAgentMessage(agentMessageId, "completion", error); + } + }; + signal.addEventListener("abort", cancelQueuedPrompt, { once: true }); + } await completion; } catch (error) { this._settleAgentMessage(agentMessageId, "completion", this._asError(error)); throw error; + } finally { + detachCancelQueuedPrompt(); } } diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index 52928ce9c..ad76c960d 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -398,6 +398,9 @@ export async function runAcpModeWithConnection( ...(images.length > 0 ? { images } : {}), streamingBehavior: "followUp", queueIfBusy: true, + // session/cancel must drop a prompt that is still waiting in the + // queue, not just the turn currently streaming. + signal: abort.signal, }); // Autonomous gates continue inside this same prompt turn: the turn is // only over once the gate loop settles. diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index dfebcdf61..3d7813c0c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -3742,6 +3742,9 @@ export class AgentDaemon { }); } if (admission.status === "owned") { + // The prompt is session-owned; still propagate the cancel so a + // queued not-yet-started prompt is dropped rather than run later. + admission.controller?.abort(); return success(command.id, command.type, { status: "owned" as const, }); diff --git a/packages/coding-agent/test/suite/acp-mode.test.ts b/packages/coding-agent/test/suite/acp-mode.test.ts index 1af4e44bc..aa71b42e2 100644 --- a/packages/coding-agent/test/suite/acp-mode.test.ts +++ b/packages/coding-agent/test/suite/acp-mode.test.ts @@ -466,4 +466,56 @@ describe("ACP mode end to end", () => { await expect(prompt).resolves.toMatchObject({ stopReason: "end_turn" }); harness.cleanup(); }, 5_000); + it("cancels a prompt that is still queued behind busy work", async () => { + const harness = await createHarness(); + let releaseInjected!: () => void; + const injectedHeld = new Promise((resolve) => { + releaseInjected = () => resolve(fauxAssistantMessage("injected work done")); + }); + harness.setResponses([ + fauxAssistantMessage("turn one done"), + () => injectedHeld, + fauxAssistantMessage("queued turn done"), + ]); + const connection = new InProcessAgentConnection(runtimeHostFor(harness.session)); + const injected = injectWorkAfterHeadlessCompletion(connection, harness.session, "injected work"); + const { client } = connectAcpClient(connection); + await client.request("initialize", { protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} }); + const session = await client.request("session/new", { cwd: harness.tempDir, mcpServers: [] }); + + const first = await client.request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "First turn" }], + }); + expect(first.stopReason).toBe("end_turn"); + expect(injected()).toBe(true); + expect(harness.session.isStreaming).toBe(true); + + const queued = client.request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Second turn" }], + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(harness.session.isStreaming).toBe(true); + + // session/cancel must drop the queued prompt, not leave it to run once + // the busy turn finishes. The notification handler may wait on the + // in-flight turn, so do not await it here. + void client.notify("session/cancel", { sessionId: session.sessionId }); + await expect(queued).resolves.toMatchObject({ stopReason: "cancelled" }); + const texts = () => + harness.session.messages + .filter((m) => m.role === "assistant") + .map((m: any) => + Array.isArray(m.content) + ? m.content.map((p: any) => (p.type === "text" ? p.text : p.type)).join(" ") + : String(m.content), + ); + expect(texts().join("|")).not.toContain("queued turn done"); + // Releasing the busy turn must not let the cancelled prompt execute. + releaseInjected(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(texts().join("|")).not.toContain("queued turn done"); + harness.cleanup(); + }, 5_000); }); From bf2c00e708cfc75dd2b68ec3313c9f096c4efcd4 Mon Sep 17 00:00:00 2001 From: Parker Pettit Date: Mon, 10 Aug 2026 21:27:01 +0000 Subject: [PATCH 4/4] fix(coding-agent): close the abort-listener registration race Register the queued-prompt cancel listener before re-checking signal aborted so an abort landing between the check and the registration still cancels the action. --- packages/coding-agent/src/core/agent-session.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index ed30690e4..95c2fe853 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4477,7 +4477,7 @@ export class AgentSession { }; try { await this.promptUntilAccepted(text, { ...options, agentMessageId }); - if (signal && !signal.aborted) { + if (signal) { cancelQueuedPrompt = () => { const error = new Error("Prompt was cancelled before it started."); const cancelled = this._cancelSessionActions( @@ -4491,7 +4491,10 @@ export class AgentSession { this._settleAgentMessage(agentMessageId, "completion", error); } }; + // Register before re-checking aborted: an abort between the check + // and registration would otherwise never invoke the listener. signal.addEventListener("abort", cancelQueuedPrompt, { once: true }); + if (signal.aborted) cancelQueuedPrompt(); } await completion; } catch (error) {