Skip to content

Commit cabe131

Browse files
committed
fix(coding-agent): stop general clears from reclaiming handed-off actions
Restores the pre-handoff clear boundary for general queue operations while preserving targeted accepted-agent cancellation with abort-backed late-event capture.
1 parent 9286b3c commit cabe131

3 files changed

Lines changed: 43 additions & 10 deletions

File tree

‎packages/coding-agent/src/core/agent-session.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,17 +1564,18 @@ export class AgentSession {
15641564
private _cancelSessionActions(
15651565
predicate: (action: QueuedSessionAction) => boolean,
15661566
error: Error,
1567+
candidates = this._actionStore.clearableActions(),
15671568
): QueuedSessionAction[] {
1568-
const clearable = this._actionStore.clearableActions().filter(predicate);
1569-
const previousStates = new Map(clearable.map((action) => [action.id, action.lifecycle.state]));
1569+
const matching = candidates.filter(predicate);
1570+
const previousStates = new Map(matching.map((action) => [action.id, action.lifecycle.state]));
15701571
const preparing = this._actionStore
15711572
.activeActions()
15721573
.filter(
15731574
(action): action is SessionAction<PreparedTurnPayload> =>
15741575
action.payload.kind === "turn" && action.lifecycle.state === "preparing",
15751576
);
15761577
const previousAnchor = preparing.at(-1);
1577-
const actions = this._actionStore.remove(predicate);
1578+
const actions = this._actionStore.remove(predicate, candidates);
15781579
const removed = new Set(actions);
15791580
if (previousAnchor && removed.has(previousAnchor)) {
15801581
for (const action of preparing) {
@@ -5644,10 +5645,16 @@ export class AgentSession {
56445645

56455646
clearQueuedUserMessagesMatching(predicate: (text: string) => boolean): { steering: string[]; followUp: string[] } {
56465647
const matching = this._actionStore
5647-
.clearableActions()
5648+
.ownedActions()
56485649
.filter(
56495650
(action) =>
5650-
action.payload.kind === "turn" && action.agentMessageId !== undefined && predicate(action.payload.text),
5651+
action.payload.kind === "turn" &&
5652+
action.agentMessageId !== undefined &&
5653+
predicate(action.payload.text) &&
5654+
(action.lifecycle.state === "queued" ||
5655+
action.lifecycle.state === "selected" ||
5656+
action.lifecycle.state === "preparing" ||
5657+
(action.lifecycle.state === "committing" && !primaryDeliveryRecord(action).started)),
56515658
);
56525659
if (matching.length === 0) return { steering: [], followUp: [] };
56535660
const removedTexts = (delivery: DeliveryPolicy) =>
@@ -5673,7 +5680,7 @@ export class AgentSession {
56735680
.filter((action) => action.payload.kind === "turn" && action.payload.acceptedAgentMessage === accepted)
56745681
.map((action) => action.id),
56755682
);
5676-
if (ids.size > 0) this._cancelSessionActions((action) => ids.has(action.id), error);
5683+
if (ids.size > 0) this._cancelSessionActions((action) => ids.has(action.id), error, matching);
56775684
}
56785685
if (
56795686
matching.some(

‎packages/coding-agent/src/core/session-action-store.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ const ACTIVE_STATES = new Set<ActionLifecycle["state"]>(["selected", "preparing"
6363
const CLEARABLE_STATES = new Set<ActionLifecycle["state"]>(["queued", "selected", "preparing"]);
6464

6565
function isClearable(action: SessionAction): boolean {
66-
if (CLEARABLE_STATES.has(action.lifecycle.state)) return true;
67-
return action.lifecycle.state === "committing" && primaryRecords(action).every((record) => !record.started);
66+
return CLEARABLE_STATES.has(action.lifecycle.state);
6867
}
6968

7069
const LEGAL_TRANSITIONS: Readonly<Record<ActionLifecycle["state"], ReadonlySet<ActionLifecycle["state"]>>> = {
@@ -210,8 +209,8 @@ export class ActionStore<TAction extends SessionAction = SessionAction> {
210209
return action;
211210
}
212211

213-
remove(predicate: (action: TAction) => boolean): TAction[] {
214-
const removed = this.clearableActions().filter(predicate);
212+
remove(predicate: (action: TAction) => boolean, candidates = this.clearableActions()): TAction[] {
213+
const removed = candidates.filter(predicate);
215214
for (const action of removed) transitionSessionAction(action, { state: "cancelled" });
216215
return removed;
217216
}

‎packages/coding-agent/test/suite/agent-session-queue.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,33 @@ describe("AgentSession queue characterization", () => {
14151415
expect(getUserTexts(harness)).toEqual(["first heartbeat", "second heartbeat"]);
14161416
});
14171417

1418+
it("does not reclaim a handed-off action before its delivery event", async () => {
1419+
const harness = await createHarness();
1420+
harnesses.push(harness);
1421+
harness.setResponses([fauxAssistantMessage("delivered")]);
1422+
const dispatchGate = createDeferred();
1423+
const promptCalled = createDeferred();
1424+
const originalPrompt = harness.session.agent.prompt.bind(harness.session.agent);
1425+
const promptSpy = vi
1426+
.spyOn(harness.session.agent, "prompt")
1427+
.mockImplementation(async (messages: Parameters<typeof originalPrompt>[0]) => {
1428+
promptSpy.mockRestore();
1429+
promptCalled.resolve();
1430+
await dispatchGate.promise;
1431+
return originalPrompt(messages);
1432+
});
1433+
const pause = harness.session.acquireQueuedWorkPause();
1434+
await harness.session.followUp("handed off", undefined, { resumeIfIdle: true });
1435+
pause.release();
1436+
await promptCalled.promise;
1437+
1438+
expect(harness.session.clearQueue()).toEqual({ steering: [], followUp: [] });
1439+
dispatchGate.resolve();
1440+
await harness.session.waitForIdle();
1441+
expect(getUserTexts(harness)).toEqual(["handed off"]);
1442+
expect(getAssistantTexts(harness)).toEqual(["delivered"]);
1443+
});
1444+
14181445
it("keeps cleared prompts out of the handoff snapshot during the refine wait", async () => {
14191446
let sessionInternals: { _refineInFlight?: Promise<void> };
14201447
let clearDuringRefineWait: (() => void) | undefined;

0 commit comments

Comments
 (0)