Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 5ee94e2

Browse files
authored
fix(agent): serialize non-steering follow-ups (#3963)
1 parent 92c2a07 commit 5ee94e2

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

packages/agent/src/server/agent-server.test.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2640,6 +2640,59 @@ describe("AgentServer HTTP Mode", () => {
26402640
expect(resetTurnMessages).not.toHaveBeenCalled();
26412641
}, 20000);
26422642

2643+
it("does not queue steering behind an active non-steering turn", async () => {
2644+
const s = createServer();
2645+
await s.start();
2646+
let finishTurn!: (result: { stopReason: "end_turn" }) => void;
2647+
const prompt = vi.fn((params: { _meta?: { steer?: boolean } }) =>
2648+
params._meta?.steer === true
2649+
? Promise.resolve({
2650+
stopReason: "end_turn" as const,
2651+
_meta: { steer: true },
2652+
})
2653+
: new Promise<{ stopReason: "end_turn" }>((resolve) => {
2654+
finishTurn = resolve;
2655+
}),
2656+
);
2657+
const serverInternals = s as unknown as {
2658+
session: { clientConnection: { prompt: typeof prompt } };
2659+
};
2660+
serverInternals.session.clientConnection.prompt = prompt;
2661+
2662+
const token = createToken();
2663+
const send = (id: string, steer = false) =>
2664+
fetch(`http://localhost:${port}/command`, {
2665+
method: "POST",
2666+
headers: {
2667+
Authorization: `Bearer ${token}`,
2668+
"Content-Type": "application/json",
2669+
},
2670+
body: JSON.stringify({
2671+
jsonrpc: "2.0",
2672+
id,
2673+
method: "user_message",
2674+
params: { content: id, messageId: id, ...(steer && { steer }) },
2675+
}),
2676+
});
2677+
2678+
const activeTurn = send("normal-turn");
2679+
await vi.waitFor(() => expect(prompt).toHaveBeenCalledTimes(1));
2680+
2681+
const steerResponse = await send("steer-turn", true);
2682+
await expect(steerResponse.json()).resolves.toMatchObject({
2683+
result: { stopReason: "steered", steered: true },
2684+
});
2685+
expect(prompt).toHaveBeenCalledTimes(2);
2686+
expect(prompt.mock.calls[1]?.[0]).toEqual(
2687+
expect.objectContaining({
2688+
_meta: expect.objectContaining({ steer: true }),
2689+
}),
2690+
);
2691+
2692+
finishTurn({ stopReason: "end_turn" });
2693+
await activeTurn;
2694+
}, 20000);
2695+
26432696
it("declines steering without blocking on a fallback normal turn", async () => {
26442697
const s = createServer();
26452698
await s.start();
@@ -2951,18 +3004,21 @@ describe("AgentServer HTTP Mode", () => {
29513004
);
29523005
const { relaySpy, send } = await setupRelayEchoServer(prompt);
29533006

2954-
// The second message lands while the first turn is still in flight;
2955-
// each relay carries its own sender's id, not the first turn's.
3007+
// The second non-steering message lands while the first turn is still in
3008+
// flight. It waits for exclusive turn ownership, and each relay carries
3009+
// its own sender's id.
29563010
const first = send("m-first");
29573011
await vi.waitFor(() => expect(prompt).toHaveBeenCalledTimes(1));
29583012
const second = send("m-second");
2959-
await vi.waitFor(() => expect(prompt).toHaveBeenCalledTimes(2));
3013+
await new Promise((resolve) => setTimeout(resolve, 25));
3014+
expect(prompt).toHaveBeenCalledTimes(1);
29603015

29613016
pendingTurns[0]({ stopReason: "end_turn" });
29623017
await first;
29633018
await vi.waitFor(() => expect(relaySpy).toHaveBeenCalledTimes(1));
29643019
expect(relaySpy.mock.calls[0][4]).toBe("m-first");
29653020

3021+
await vi.waitFor(() => expect(prompt).toHaveBeenCalledTimes(2));
29663022
pendingTurns[1]({ stopReason: "end_turn" });
29673023
await second;
29683024
await vi.waitFor(() => expect(relaySpy).toHaveBeenCalledTimes(2));

packages/agent/src/server/agent-server.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,9 @@ export class AgentServer {
407407
private pendingCompactContinuationMessageIds = new Set<string>();
408408
private inFlightMessageDeliveries = new Map<string, Promise<unknown>>();
409409
private activeOwnedTurnCount = 0;
410+
// Normal follow-ups own turns in arrival order. Explicit steering bypasses
411+
// this tail so it can still reach the active adapter turn immediately.
412+
private nonSteerDeliveryTail: Promise<void> = Promise.resolve();
410413
private pendingPermissions = new Map<
411414
string,
412415
{
@@ -1091,6 +1094,7 @@ export class AgentServer {
10911094
this.inFlightMessageDeliveries.set(messageId, deliveryOutcome);
10921095
}
10931096
let deliveryCommitted = retryCompactContinuation;
1097+
let releaseNonSteerDelivery: (() => void) | undefined;
10941098
const commitDelivery = (): void => {
10951099
deliveryCommitted = true;
10961100
if (!messageId) return;
@@ -1105,6 +1109,9 @@ export class AgentServer {
11051109
};
11061110

11071111
try {
1112+
if (params.steer !== true) {
1113+
releaseNonSteerDelivery = await this.acquireNonSteerDeliveryTurn();
1114+
}
11081115
this.logger.debug("Received user_message command", {
11091116
hasContent:
11101117
typeof params.content === "string"
@@ -1302,6 +1309,7 @@ export class AgentServer {
13021309
rejectDelivery(error);
13031310
throw error;
13041311
} finally {
1312+
releaseNonSteerDelivery?.();
13051313
if (
13061314
messageId &&
13071315
this.inFlightMessageDeliveries.get(messageId) === deliveryOutcome
@@ -1930,6 +1938,16 @@ export class AgentServer {
19301938
}
19311939
}
19321940

1941+
private async acquireNonSteerDeliveryTurn(): Promise<() => void> {
1942+
const previous = this.nonSteerDeliveryTail;
1943+
let release!: () => void;
1944+
this.nonSteerDeliveryTail = new Promise<void>((resolve) => {
1945+
release = resolve;
1946+
});
1947+
await previous;
1948+
return release;
1949+
}
1950+
19331951
/**
19341952
* Send an initial/resume turn prompt, absorbing transient upstream
19351953
* failures with a bounded number of retries. These turns run unattended

0 commit comments

Comments
 (0)