Skip to content
Closed
19 changes: 17 additions & 2 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ type ActiveRun = {
promise: Promise<void>;
resolve: () => void;
abortController: AbortController;
suppressQueuedMessageDrain: boolean;
};

/**
Expand Down Expand Up @@ -324,6 +325,16 @@ export class Agent {
this.activeRun?.abortController.abort();
}

/**
* Keep queued steering and follow-up messages for an external owner after
* this run reaches agent_end, without changing the active abort signal.
*/
suppressQueuedMessageDrain(): void {
if (this.activeRun) {
this.activeRun.suppressQueuedMessageDrain = true;
}
}

/**
* Resolve when the current run and all awaited event listeners have finished.
*
Expand Down Expand Up @@ -505,15 +516,19 @@ export class Agent {
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
this.activeRun = { promise, resolve: resolvePromise, abortController };
this.activeRun = { promise, resolve: resolvePromise, abortController, suppressQueuedMessageDrain: false };

this._state.isStreaming = true;
this._state.streamingMessage = undefined;
this._state.errorMessage = undefined;

try {
await executor(abortController.signal);
while (!abortController.signal.aborted && this.hasQueuedMessages()) {
while (
!abortController.signal.aborted &&
!this.activeRun?.suppressQueuedMessageDrain &&
this.hasQueuedMessages()
) {
await this.runQueuedMessagesAfterAgentEnd(abortController.signal);
}
} catch (error) {
Expand Down
22 changes: 22 additions & 0 deletions packages/agent/src/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,27 @@
# Changes

## 2026-07-23 - Session-owned post-agent_end queue drain suppression

### What changed and why

- `Agent` now exposes `suppressQueuedMessageDrain()` for the active run. It stops only the lifecycle-owned
post-`agent_end` steering/follow-up drain, retaining both queues without aborting the run signal.
- The coding-agent compaction admission gate uses this ownership transfer for required recovery. Real user aborts
continue to abort the active signal and retain the normal terminal semantics.

### Files modified

- `agent.ts`
- `../test/agent.test.ts`

### Why the extension system could not handle this

- Native queue draining and active-run signal ownership occur inside `Agent` after event subscribers return.

### Expected merge conflict zones on next upstream sync

- MEDIUM: `agent.ts` active-run lifecycle and post-`agent_end` queue draining.

## 2026-07-23 - uuidv7 concurrency refutation + immutable launch profile

### What changed and why
Expand Down
36 changes: 36 additions & 0 deletions packages/agent/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,42 @@ describe("Agent", () => {
expect(() => agent.abort()).not.toThrow();
});

it("retains agent_end queues without aborting when drain suppression is requested", async () => {
let providerCalls = 0;
let agentEndSignal: AbortSignal | undefined;
const agent = new Agent({
streamFn: () => {
providerCalls++;
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message: createAssistantMessage(`response ${providerCalls}`),
});
});
return stream;
},
});
agent.subscribe((event, signal) => {
if (event.type !== "agent_end" || providerCalls !== 1) return;
agentEndSignal = signal;
agent.followUp({ role: "user", content: "deferred follow-up", timestamp: Date.now() });
agent.suppressQueuedMessageDrain();
});

await agent.prompt("first prompt");

expect(agentEndSignal?.aborted).toBe(false);
expect(agent.hasQueuedMessages()).toBe(true);
expect(providerCalls).toBe(1);

await agent.continue();

expect(agent.hasQueuedMessages()).toBe(false);
expect(providerCalls).toBe(2);
});

it("should throw when prompt() called while streaming", async () => {
let abortSignal: AbortSignal | undefined;
const agent = new Agent({
Expand Down
Loading