diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c9d0ebce73..11339a729c 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -957,6 +957,64 @@ test("steers a send that arrives while the previous turn is still starting", asy } }); +test("starts its own turn when the previous turn fails to start under a steer send", async () => { + const startHeld = deferred(); + class FailingFirstStartSession extends SteeringTestSession { + override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + if (prompt === "FIRST") { + this.startPrompts.push(prompt); + await startHeld.promise; + throw new Error("first start failed"); + } + return await super.startTurn(prompt); + } + } + const session = new FailingFirstStartSession({ + provider: "codex", + cwd: process.cwd(), + }); + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-")); + const manager = new AgentManager({ + clients: { + codex: new (class extends TestAgentClient { + override async createSession(): Promise { + return session; + } + })(), + }, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + const steerOptions = (clientMessageId: string) => ({ + replaceRunning: true, + activeTurnBehavior: "steer" as const, + runOptions: { clientMessageId }, + }); + await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client")); + const second = startAgentRun( + manager, + agent.id, + "SECOND", + logger, + steerOptions("second-client"), + ); + + startHeld.resolve(); + await expect(second).resolves.toEqual({ disposition: "turn_started" }); + await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"])); + expect(session.interruptCount).toBe(0); + } finally { + startHeld.resolve(); + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("orders an accepted steer before output emitted while acknowledgement is pending", async () => { const entered = deferred(); const release = deferred(); diff --git a/packages/server/src/server/agent/agent-run-state.ts b/packages/server/src/server/agent/agent-run-state.ts index f2f4ee3d16..0ea85f6d81 100644 --- a/packages/server/src/server/agent/agent-run-state.ts +++ b/packages/server/src/server/agent/agent-run-state.ts @@ -20,8 +20,8 @@ export interface PendingForegroundRun { kind: "foreground"; stagedEvents: AgentStreamEvent[]; start: ForegroundRunStart; - startSettledPromise: Promise; - resolveStartSettled: () => void; + turnStartedPromise: Promise; + resolveTurnStarted: () => void; settled: boolean; settledPromise: Promise; resolveSettled: () => void; @@ -63,16 +63,21 @@ export class AgentRunState { start: Exclude, ): void { run.start = start; - run.resolveStartSettled(); + if (start.status === "started") { + run.resolveTurnStarted(); + } } - /** Resolves once the agent's foreground run has a turn id, failed to start, or ended. */ + /** + * Resolves once the agent's foreground run has a turn id or has ended. A failed start + * resolves only once its run is cleared, so callers never see a failed run still tracked. + */ async waitForForegroundStart(agentId: string): Promise { const run = this.getPendingRun(agentId); if (run?.start.status !== "pending") { return; } - await Promise.race([run.startSettledPromise, run.settledPromise]); + await Promise.race([run.turnStartedPromise, run.settledPromise]); } hasPendingRun(agentId: string): boolean { @@ -298,16 +303,16 @@ export class ForegroundTurnStream { } function createPendingForegroundRun(): PendingForegroundRun { - let resolveStartSettled!: () => void; - const startSettledPromise = new Promise((resolvePromise) => { - resolveStartSettled = resolvePromise; + let resolveTurnStarted!: () => void; + const turnStartedPromise = new Promise((resolvePromise) => { + resolveTurnStarted = resolvePromise; }); return { ...createTrackedRunState(), kind: "foreground", start: { status: "pending" }, - startSettledPromise, - resolveStartSettled, + turnStartedPromise, + resolveTurnStarted, stagedEvents: [], }; }