fix: stop AgentBusyError on plan approval and loop/goal continuations

Plan approval dispatched the executor's first synthetic prompt without
checking whether the agent was still streaming the post-resolve
continuation (or a turn started by the approve-time compaction/clear),
surfacing "Failed to finalize approved plan: ... Agent is already
processing". Loop auto-submit and goal continuations hit the same throw
via submitInteractiveInput, which always called prompt/promptCustomMessage
without a streamingBehavior.

#approvePlan now aborts any in-flight turn before the synthetic prompt,
and submitInteractiveInput routes submissions through the steer/follow-up
queue (streamingBehavior: "followUp") when the session is streaming.
Non-streaming call shapes are unchanged. Extends the manual-/goal fix
(#2454) to the continuation and plan-approval paths.
This commit is contained in:
metaphorics
2026-06-14 07:37:14 +09:00
parent 11622e6190
commit 34746ceaf5
5 changed files with 128 additions and 6 deletions
@@ -46,6 +46,7 @@ describe("submitInteractiveInput", () => {
const session = {
prompt: vi.fn(async () => true),
promptCustomMessage: vi.fn(async () => {}),
isStreaming: false,
};
const input = createInput({ text: "resume now", started: true, synthetic: true });
@@ -67,6 +68,7 @@ describe("submitInteractiveInput", () => {
const session = {
prompt: vi.fn(async () => true),
promptCustomMessage: vi.fn(async () => {}),
isStreaming: false,
};
const input = createInput();
@@ -88,6 +90,7 @@ describe("submitInteractiveInput", () => {
const session = {
prompt: vi.fn(async () => true),
promptCustomMessage: vi.fn(async () => {}),
isStreaming: false,
};
const input = createInput({ text: "continue goal", customType: "goal-continuation" });
@@ -103,4 +106,56 @@ describe("submitInteractiveInput", () => {
expect(mode.finishPendingSubmission).toHaveBeenCalledWith(input);
expect(mode.showError).not.toHaveBeenCalled();
});
it("queues goal-continuation as followUp when streaming", async () => {
const mode = {
markPendingSubmissionStarted: vi.fn(() => true),
finishPendingSubmission: vi.fn(),
showError: vi.fn(),
checkShutdownRequested: vi.fn(async () => {}),
};
const session = {
prompt: vi.fn(async () => true),
promptCustomMessage: vi.fn(async () => {}),
isStreaming: true,
};
const input = createInput({ text: "continue goal", customType: "goal-continuation" });
await submitInteractiveInput(mode, session, input);
expect(session.prompt).not.toHaveBeenCalled();
expect(session.promptCustomMessage).toHaveBeenCalledWith(
{
customType: "goal-continuation",
content: "continue goal",
display: false,
attribution: "agent",
},
{ streamingBehavior: "followUp" },
);
expect(mode.finishPendingSubmission).toHaveBeenCalledWith(input);
expect(mode.showError).not.toHaveBeenCalled();
});
it("queues a plain submission as followUp when streaming", async () => {
const mode = {
markPendingSubmissionStarted: vi.fn(() => true),
finishPendingSubmission: vi.fn(),
showError: vi.fn(),
checkShutdownRequested: vi.fn(async () => {}),
};
const session = {
prompt: vi.fn(async () => true),
promptCustomMessage: vi.fn(async () => {}),
isStreaming: true,
};
const input = createInput({ text: "loop prompt" });
await submitInteractiveInput(mode, session, input);
expect(session.prompt).toHaveBeenCalledWith("loop prompt", { images: undefined, streamingBehavior: "followUp" });
expect(session.promptCustomMessage).not.toHaveBeenCalled();
expect(mode.finishPendingSubmission).toHaveBeenCalledWith(input);
expect(mode.showError).not.toHaveBeenCalled();
});
});