Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
736 changes: 588 additions & 148 deletions packages/ai/src/models.generated.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Added correlated ACP terminal-quiescence metadata, resident session settlement, and fail-closed daemon input fencing; prevented recovery state from persisting runtime credentials or model configuration.
- Fixed explicit RLM child deletion leaving hidden unsettled work after runtime teardown, including reporting cleanup failures and notifying the parent when deletion completes.

Expand Down
20 changes: 20 additions & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4387,12 +4387,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);
}
}
}

Expand Down
16 changes: 14 additions & 2 deletions packages/coding-agent/src/modes/acp/acp-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -825,7 +828,16 @@ export async function runAcpModeWithConnection(
await entry.producer.drain();
return { stopReason: "cancelled" satisfies AcpStopReason };
}
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,
});
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (abort.signal.aborted) {
await entry.producer.drain();
return { stopReason: "cancelled" satisfies AcpStopReason };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,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 {
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/modes/daemon/daemon-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4059,6 +4059,7 @@ export class AgentDaemon {
});
}
if (admission.status === "owned") {
Comment thread
parkerpettit marked this conversation as resolved.
if (command.cancelOwned) admission.controller?.abort();
return success(command.id, command.type, {
status: "owned" as const,
});
Expand Down
20 changes: 17 additions & 3 deletions packages/coding-agent/src/modes/daemon/daemon-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7;
// Revision 16 adds the "stopping" workerState and stops reporting disconnected workers as "ready".
// Revision 17 gates authoritative child rosters and transient owned-session recovery context.
// Revision 18 adds the opt-in RLM quiescence barrier to headless completion.
export const DAEMON_SCHEMA_REVISION = 19;
export const DAEMON_SCHEMA_ID = "protocol-7-schema-19-29b4f87f83e7";
// 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-ed994cc39507";

export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME;
export type DaemonProtocolVersion = number;
Expand Down Expand Up @@ -106,7 +108,8 @@ export type DaemonServerCapability =
| "authoritative_child_roster"
| "owned_session_recovery_context"
| "rlm_quiescence_barrier"
| "session_input_pause";
| "session_input_pause"
| "owned_prompt_cancellation";

export type DaemonReplayStatus = "complete" | "partial" | "unavailable";

Expand Down Expand Up @@ -144,6 +147,7 @@ export const DAEMON_DEFAULT_SERVER_CAPABILITIES: readonly DaemonServerCapability
"transient_bash",
"session_input_admission",
"prompt_admission_cancellation",
"owned_prompt_cancellation",
"queue_message_mutation",
"authoritative_child_roster",
"owned_session_recovery_context",
Expand Down Expand Up @@ -426,6 +430,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;
Expand Down Expand Up @@ -659,6 +665,11 @@ const PROMPT_ADMISSION_CANCELLATION_COMMAND = {
minSchemaRevision: 8,
capability: "prompt_admission_cancellation",
} as const;
const OWNED_PROMPT_CANCELLATION_COMMAND = {
minProtocol: 7,
minSchemaRevision: 20,
capability: "owned_prompt_cancellation",
} as const;
const CLIENT_OWNED_DAEMON_COMMAND = {
minProtocol: 7,
capability: "client_owned_sessions",
Expand Down Expand Up @@ -809,6 +820,9 @@ export function getDaemonCommandCompatibilities(command: DaemonCommand): readonl
if (command.type === "wait_for_headless_completion" && command.waitForRlmQuiescence === true) {
requirements.push(RLM_QUIESCENCE_BARRIER_COMMAND);
}
if (command.type === "cancel_prompt_admission" && command.cancelOwned === true) {
requirements.push(OWNED_PROMPT_CANCELLATION_COMMAND);
}
return [...requirements, DAEMON_COMMAND_COMPATIBILITY[command.type]];
}

Expand Down
25 changes: 25 additions & 0 deletions packages/coding-agent/test/agent-connection-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,11 +874,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<void>((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 = () => {};
Expand Down
17 changes: 15 additions & 2 deletions packages/coding-agent/test/daemon-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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({
Expand All @@ -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"));
});

Expand Down
10 changes: 10 additions & 0 deletions packages/coding-agent/test/daemon-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,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: 20, 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
Expand Down
Loading
Loading