From 5d7d4cf180418f394559832c5e424745e9532daf Mon Sep 17 00:00:00 2001 From: Parker Pettit Date: Wed, 19 Aug 2026 20:22:50 +0000 Subject: [PATCH 1/2] fix(coding-agent): queue ACP prompts behind in-flight work --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 20 ++ .../coding-agent/src/modes/acp/acp-mode.ts | 11 +- .../daemon-agent-connection.ts | 1 + .../src/modes/daemon/daemon-mode.ts | 1 + .../src/modes/daemon/daemon-protocol.ts | 17 +- .../test/agent-connection-daemon.test.ts | 25 +++ .../coding-agent/test/daemon-mode.test.ts | 17 +- .../coding-agent/test/daemon-protocol.test.ts | 10 + .../coding-agent/test/suite/acp-mode.test.ts | 191 +++++++++++++++++- 10 files changed, 288 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 032ea08b84..86ddcbd652 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Fixed ACP rejecting an immediate follow-up prompt when injected work restarted the session; follow-ups now queue behind in-flight work, and cancellation drops queued follow-ups before they start. - Fixed large IPython variables repeatedly slowing later turns by excluding them from persistent snapshots and removing them when context is compacted. - Fixed daemon socket paths being used verbatim in identity derivations: on supported platforms, `--daemon-socket` spellings differing only by duplicate or trailing slashes now normalize to one canonical path, so worker-descriptor namespaces, daemon log files, and persisted descriptors agree. - Added a `thinking` option to `rlm.run` for spawning subagents with an explicit reasoning level; invalid levels for the resolved child model fail spawn. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e50cc72768..4c916b1323 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -4343,12 +4343,32 @@ export class AgentSession { const outcome = this._agentMessageOutcome(agentMessageId); outcome.completion = createAgentMessageDeferred(); const completion = outcome.completion.promise; + const signal = options?.signal; + let cancelQueuedPrompt: (() => void) | undefined; try { await this.promptUntilAccepted(text, { ...options, agentMessageId }); + if (signal) { + cancelQueuedPrompt = () => { + const error = new Error("Prompt was cancelled before it started."); + const cancelled = this._cancelSessionActions( + (action) => action.agentMessageId === agentMessageId && action.payload.kind === "turn", + error, + ); + if (cancelled.length > 0) { + this._settleAgentMessage(agentMessageId, "completion", error); + } + }; + signal.addEventListener("abort", cancelQueuedPrompt, { once: true }); + if (signal.aborted) cancelQueuedPrompt(); + } await completion; } catch (error) { this._settleAgentMessage(agentMessageId, "completion", this._asError(error)); throw error; + } finally { + if (signal && cancelQueuedPrompt) { + signal.removeEventListener("abort", cancelQueuedPrompt); + } } } diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index 4228c5ed8e..1385877e12 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -336,7 +336,16 @@ 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". + await connection.promptAndWait(text, { + ...(images.length > 0 ? { images } : {}), + streamingBehavior: "followUp", + queueIfBusy: true, + signal: abort.signal, + }); // Autonomous gates continue inside this same prompt turn: the turn is // only over once the gate loop settles. const status = await connection.waitForHeadlessCompletion(); diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 016a053279..d8e2dd37a5 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -834,6 +834,7 @@ export class DaemonAgentConnection implements AgentConnection { type: "cancel_prompt_admission", activeSessionId: this.activeSessionId, admissionId, + ...(this.client.supportsServerCapability("owned_prompt_cancellation") ? { cancelOwned: true } : {}), }); status = result.status; } catch { diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 05a7632a25..2d0cae958c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -4037,6 +4037,7 @@ export class AgentDaemon { }); } if (admission.status === "owned") { + if (command.cancelOwned) admission.controller?.abort(); return success(command.id, command.type, { status: "owned" as const, }); diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index b26eb1bdc2..6999722506 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -60,8 +60,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 14 carries the client's monotonic telemetry opt-out on attach and reattach. // Revision 15 adds the mutate_queued_message command and queue_message_mutation capability. // Revision 16 adds the "stopping" workerState and stops reporting disconnected workers as "ready". -export const DAEMON_SCHEMA_REVISION = 16; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-16-1bcb9e7f1a49"; +// Revision 17 lets cancellation target a prompt the session owns but has not started. +export const DAEMON_SCHEMA_REVISION = 17; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-17-78200ccd9972"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -100,6 +101,7 @@ export type DaemonServerCapability = | "transient_bash" | "session_input_admission" | "prompt_admission_cancellation" + | "owned_prompt_cancellation" | "queue_message_mutation"; export type DaemonReplayStatus = "complete" | "partial" | "unavailable"; @@ -138,6 +140,7 @@ export const DAEMON_DEFAULT_SERVER_CAPABILITIES: readonly DaemonServerCapability "transient_bash", "session_input_admission", "prompt_admission_cancellation", + "owned_prompt_cancellation", "queue_message_mutation", ]; @@ -414,6 +417,8 @@ export type DaemonCommand = type: "cancel_prompt_admission"; activeSessionId: string; admissionId: string; + /** Cancel session-owned work too when it has not started delivery. */ + cancelOwned?: boolean; } | { id?: string; @@ -639,6 +644,11 @@ const PROMPT_ADMISSION_CANCELLATION_COMMAND = { minSchemaRevision: 8, capability: "prompt_admission_cancellation", } as const; +const OWNED_PROMPT_CANCELLATION_COMMAND = { + minProtocol: 7, + minSchemaRevision: 17, + capability: "owned_prompt_cancellation", +} as const; const CLIENT_OWNED_DAEMON_COMMAND = { minProtocol: 7, capability: "client_owned_sessions", @@ -762,6 +772,9 @@ export function getDaemonCommandCompatibilities(command: DaemonCommand): readonl if ((command.type === "prompt" || command.type === "prompt_and_wait") && command.admissionId !== undefined) { return [PROMPT_ADMISSION_CANCELLATION_COMMAND, compatibility]; } + if (command.type === "cancel_prompt_admission" && command.cancelOwned === true) { + return [OWNED_PROMPT_CANCELLATION_COMMAND, compatibility]; + } return [compatibility]; } diff --git a/packages/coding-agent/test/agent-connection-daemon.test.ts b/packages/coding-agent/test/agent-connection-daemon.test.ts index 7ff21e88c4..b17242bcad 100644 --- a/packages/coding-agent/test/agent-connection-daemon.test.ts +++ b/packages/coding-agent/test/agent-connection-daemon.test.ts @@ -799,11 +799,36 @@ describe("DaemonAgentConnection", () => { await vi.waitFor(() => expect(fakeClient.requests.map((request) => request.type)).toContain("cancel_prompt_admission"), ); + expect(fakeClient.requests.find((request) => request.type === "cancel_prompt_admission")).not.toHaveProperty( + "cancelOwned", + ); releasePrompt(); await expect(prompt).resolves.toBeUndefined(); }); + it("requests owned prompt cancellation when the daemon advertises it", async () => { + const fakeClient = new FakeDaemonClient(); + fakeClient.serverCapabilities.add("owned_prompt_cancellation"); + let releasePrompt = () => {}; + fakeClient.promptGate = new Promise((resolve) => { + releasePrompt = resolve; + }); + const connection = new DaemonAgentConnection(asDaemonClient(fakeClient), "active-1"); + const abort = new AbortController(); + + const prompt = connection.prompt("startup", { signal: abort.signal }); + abort.abort(); + await vi.waitFor(() => + expect(fakeClient.requests.map((request) => request.type)).toContain("cancel_prompt_admission"), + ); + expect(fakeClient.requests.find((request) => request.type === "cancel_prompt_admission")).toMatchObject({ + cancelOwned: true, + }); + releasePrompt(); + await expect(prompt).resolves.toBeUndefined(); + }); + it("preserves a definitive prompt rejection when cancellation reports owned", async () => { const fakeClient = new FakeDaemonClient(); let releasePrompt = () => {}; diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 000443c740..58facb9cf4 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -9053,7 +9053,7 @@ describe("daemon mode helpers", () => { }, ); - it("cancels only pre-ownership prompt admission and cleans up its controller", async () => { + it("capability-gates cancellation after prompt ownership", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-test.sock", { defaultSessionConfig: { agentDir: "/tmp/prime-agent-test-agent", cwd: "/tmp" }, createRuntime: async () => { @@ -9112,7 +9112,7 @@ describe("daemon mode helpers", () => { ).resolves.toMatchObject({ success: true, data: { status: "cancelled" } }); await vi.waitFor(() => expect(internals.promptAdmissions.size).toBe(0)); - // Once ownership commits the same cancellation is a no-op. + // Old clients retain the pre-ownership-only behavior. internals.parseCommandAndRegisterPromptAdmission( client, JSON.stringify({ @@ -9139,6 +9139,19 @@ describe("daemon mode helpers", () => { admissionId: "admission-2", }), ).resolves.toMatchObject({ success: true, data: { status: "owned" } }); + expect(promptOptions?.signal?.aborted).toBe(false); + + // New clients request the capability-gated session-owned cancellation. + await expect( + internals.handleCommand(client, { + id: "cancel-3", + type: "cancel_prompt_admission", + activeSessionId: state.activeSessionId, + admissionId: "admission-2", + cancelOwned: true, + }), + ).resolves.toMatchObject({ success: true, data: { status: "owned" } }); + expect(promptOptions?.signal?.aborted).toBe(true); rejectPrompt?.(new Error("test cleanup")); }); diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index d7eb8acf4a..2429e53558 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -130,6 +130,16 @@ describe("daemon protocol helpers", () => { expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("prompt_admission_cancellation"); }); + it("capability-gates cancellation after prompt ownership", () => { + const legacy = { type: "cancel_prompt_admission", activeSessionId: "active-1", admissionId: "a-1" } as const; + expect(getDaemonCommandCompatibilities(legacy)).toEqual([DAEMON_COMMAND_COMPATIBILITY.cancel_prompt_admission]); + expect(getDaemonCommandCompatibilities({ ...legacy, cancelOwned: true })).toEqual([ + { minProtocol: 7, minSchemaRevision: 17, capability: "owned_prompt_cancellation" }, + DAEMON_COMMAND_COMPATIBILITY.cancel_prompt_admission, + ]); + expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("owned_prompt_cancellation"); + }); + it("gates honest worker-state reporting at its introducing schema revision", () => { // Revision 16 adds the "stopping" workerState and stops reporting // disconnected workers as "ready". The field is optional and old clients diff --git a/packages/coding-agent/test/suite/acp-mode.test.ts b/packages/coding-agent/test/suite/acp-mode.test.ts index 61b1c6252c..b28fa3276d 100644 --- a/packages/coding-agent/test/suite/acp-mode.test.ts +++ b/packages/coding-agent/test/suite/acp-mode.test.ts @@ -1,6 +1,7 @@ import * as acp from "@agentclientprotocol/sdk"; -import { fauxAssistantMessage } from "@earendil-works/pi-ai"; +import { type AssistantMessage, fauxAssistantMessage } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; +import type { AgentSession } from "../../src/core/agent-session.js"; import type { AgentSessionRuntime } from "../../src/core/agent-session-runtime.js"; import { PRIME_AGENT_META_NAMESPACE } from "../../src/modes/acp/acp-meta.js"; import { runAcpModeWithConnection } from "../../src/modes/acp/index.js"; @@ -29,6 +30,29 @@ interface ClientHarness { close: () => void; } +function injectWorkAfterHeadlessCompletion( + connection: InProcessAgentConnection, + session: AgentSession, + 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(); @@ -84,4 +108,169 @@ describe("ACP mode end to end", () => { harness.cleanup(); }, 30_000); + + it("queues a follow-up prompt behind injected work instead of rejecting it", 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("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); + expect(harness.session.isStreaming).toBe(true); + + let secondSettled = false; + const second = client + .request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Second turn" }], + }) + .finally(() => { + secondSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(secondSettled).toBe(false); + releaseInjected(); + await expect(second).resolves.toMatchObject({ stopReason: "end_turn" }); + + const text = updates + .filter((update) => update.update?.sessionUpdate === "agent_message_chunk") + .map((update) => update.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", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "First turn" }], + }); + expect(first.stopReason).toBe("end_turn"); + expect(harness.session.getAutonomousStatus().turnsUsed).toBe(1); + + const second = client.request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Second turn" }], + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + releaseInjected(); + 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); + 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(); + }, 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)); + 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: [] }); + await client.request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "First turn" }], + }); + + const queued = client.request("session/prompt", { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Second turn" }], + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + void client.notify("session/cancel", { sessionId: session.sessionId }); + await expect(queued).resolves.toMatchObject({ stopReason: "cancelled" }); + releaseInjected(); + await new Promise((resolve) => setTimeout(resolve, 300)); + const assistantText = harness.session.messages + .filter((message) => message.role === "assistant") + .map((message) => JSON.stringify(message.content)) + .join("|"); + expect(assistantText).not.toContain("queued turn done"); + harness.cleanup(); + }, 5_000); }); From 7ce56098e5b2eb933e65c4875a64f8b07ec94a34 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 20 Aug 2026 11:01:59 +0200 Subject: [PATCH 2/2] fix(acp): keep queued-prompt semantics through the resident lifecycle merge Renumber owned prompt cancellation to schema revision 20 (17-19 landed on main), adapt compatibility gating to the requirements-array form, and return stopReason "cancelled" for a prompt parked behind the terminal lifecycle when a cancellation drops it before it starts. --- packages/coding-agent/src/modes/acp/acp-mode.ts | 5 ++++- packages/coding-agent/src/modes/daemon/daemon-protocol.ts | 2 +- packages/coding-agent/test/daemon-protocol.test.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/acp/acp-mode.ts b/packages/coding-agent/src/modes/acp/acp-mode.ts index 562e506e6c..988dac3228 100644 --- a/packages/coding-agent/src/modes/acp/acp-mode.ts +++ b/packages/coding-agent/src/modes/acp/acp-mode.ts @@ -799,7 +799,10 @@ export async function runAcpModeWithConnection( await entry.pendingTerminal?.task; if (session !== entry) throw new Error(`Unknown ACP session: ${params.sessionId}`); if (sessionCloseInFlight) throw new Error(`ACP session is closing: ${params.sessionId}`); - if (entry.cancelling) throw new Error(`ACP session is cancelling: ${params.sessionId}`); + // This prompt was admitted before the cancellation started; it is dropped + // by the cancel rather than malformed, so report the protocol stop reason + // instead of a request error. + if (entry.cancelling) return { stopReason: "cancelled" satisfies AcpStopReason }; if (entry.stopFailure) throw new Error(`ACP session stop failed: ${entry.stopFailure}`); if (entry.pendingTerminal?.failure) { throw new Error(`ACP lifecycle reconciliation failed: ${entry.pendingTerminal.failure}`); diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index 00b12c6a64..6f77aeb040 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -65,7 +65,7 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 19 adds daemon-held session input pauses. // Revision 20 lets cancellation target a prompt the session owns but has not started. export const DAEMON_SCHEMA_REVISION = 20; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-20-4c1f0e2a8d51"; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-20-ed994cc39507"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index ce340fac92..615008c90f 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -238,7 +238,7 @@ describe("daemon protocol helpers", () => { const legacy = { type: "cancel_prompt_admission", activeSessionId: "active-1", admissionId: "a-1" } as const; expect(getDaemonCommandCompatibilities(legacy)).toEqual([DAEMON_COMMAND_COMPATIBILITY.cancel_prompt_admission]); expect(getDaemonCommandCompatibilities({ ...legacy, cancelOwned: true })).toEqual([ - { minProtocol: 7, minSchemaRevision: 17, capability: "owned_prompt_cancellation" }, + { minProtocol: 7, minSchemaRevision: 20, capability: "owned_prompt_cancellation" }, DAEMON_COMMAND_COMPATIBILITY.cancel_prompt_admission, ]); expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("owned_prompt_cancellation");