From 0babeec0ef017b0b6ee7d557fe22fef2eb9ea20c Mon Sep 17 00:00:00 2001 From: KamijoToma Date: Thu, 18 Jun 2026 02:45:03 +0800 Subject: [PATCH] fix(coding-agent): tighten btw branchability --- .../src/modes/controllers/btw-controller.ts | 1 + .../test/agent-session-btw-branch.test.ts | 2 +- .../modes/controllers/btw-controller.test.ts | 29 ++++++++++++++----- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/modes/controllers/btw-controller.ts b/packages/coding-agent/src/modes/controllers/btw-controller.ts index bdc534e46..11ac6e615 100644 --- a/packages/coding-agent/src/modes/controllers/btw-controller.ts +++ b/packages/coding-agent/src/modes/controllers/btw-controller.ts @@ -53,6 +53,7 @@ export class BtwController { this.#lastQuestion !== undefined && this.#lastReplyText !== undefined && this.#lastAssistantMessage !== undefined && + this.#lastLeafId !== null && this.#lastLeafId === this.ctx.sessionManager.getLeafId() ); } diff --git a/packages/coding-agent/test/agent-session-btw-branch.test.ts b/packages/coding-agent/test/agent-session-btw-branch.test.ts index 335171c1e..5b7147884 100644 --- a/packages/coding-agent/test/agent-session-btw-branch.test.ts +++ b/packages/coding-agent/test/agent-session-btw-branch.test.ts @@ -226,7 +226,7 @@ describe("AgentSession.branchFromBtw", () => { activeSession.sessionManager.appendMessage({ role: "user", content: "seed", timestamp: Date.now() }); await activeSession.sessionManager.flush(); const abortController = new AbortController(); - const execution = new Promise(() => {}); + const execution = Promise.withResolvers().promise; activeSession.trackEvalExecution(execution, abortController).catch(() => undefined); expect(activeSession.isEvalRunning).toBe(true); diff --git a/packages/coding-agent/test/modes/controllers/btw-controller.test.ts b/packages/coding-agent/test/modes/controllers/btw-controller.test.ts index 241498323..5e1194e1e 100644 --- a/packages/coding-agent/test/modes/controllers/btw-controller.test.ts +++ b/packages/coding-agent/test/modes/controllers/btw-controller.test.ts @@ -112,10 +112,8 @@ describe("BtwController", () => { it("replaces a previous request by aborting it before issuing the next runEphemeralTurn", async () => { const signals: AbortSignal[] = []; - let firstRelease!: () => void; - const firstPromise = new Promise(resolve => { - firstRelease = () => resolve({ replyText: "first", assistantMessage: createAssistantMessage("first") }); - }); + const first = Promise.withResolvers(); + const firstPromise = first.promise; const runEphemeralTurn = vi .fn<(args: RunEphemeralTurnArgs) => Promise>() .mockImplementationOnce(async args => { @@ -141,11 +139,11 @@ describe("BtwController", () => { expect(signals[1]?.aborted).toBe(false); expect(btwContainer.children).toHaveLength(1); // Allow the orphaned first request to finish to keep the test clean. - firstRelease(); + first.resolve({ replyText: "first", assistantMessage: createAssistantMessage("first") }); }); it("clears the panel when the active request is dismissed via Escape", async () => { - const runEphemeralTurn = vi.fn(async () => new Promise(() => {})); + const runEphemeralTurn = vi.fn(async () => Promise.withResolvers().promise); const btwContainer = new Container(); const ctx = makeCtx(makeFakeSession(runEphemeralTurn), btwContainer); const controller = new BtwController(ctx); @@ -185,7 +183,7 @@ describe("BtwController", () => { }); it("does not allow branch while /btw is still running", async () => { - const runEphemeralTurn = vi.fn(async () => new Promise(() => {})); + const runEphemeralTurn = vi.fn(async () => Promise.withResolvers().promise); const ctx = makeCtx(makeFakeSession(runEphemeralTurn)); const controller = new BtwController(ctx); @@ -194,6 +192,21 @@ describe("BtwController", () => { expect(controller.canBranch()).toBe(false); }); + it("does not allow branch when the completed answer has no originating leaf", async () => { + const assistantMessage = createAssistantMessage("Answer"); + const runEphemeralTurn = vi.fn(async () => ({ replyText: "Answer", assistantMessage })); + const ctx = makeCtx(makeFakeSession(runEphemeralTurn)) as InteractiveModeContext & { + setTestLeafId(nextLeafId: string | null): void; + }; + ctx.setTestLeafId(null); + const controller = new BtwController(ctx); + + await controller.start("Question?"); + await drainBtwRequest(); + + expect(controller.canBranch()).toBe(false); + }); + it("allows branch after a complete non-empty reply", async () => { const assistantMessage = createAssistantMessage("Answer"); const runEphemeralTurn = vi.fn(async () => ({ replyText: "Answer", assistantMessage })); @@ -221,7 +234,7 @@ describe("BtwController", () => { }); it("does not allow branch after aborted or errored requests", async () => { - const abortedRun = vi.fn(async () => new Promise(() => {})); + const abortedRun = vi.fn(async () => Promise.withResolvers().promise); const abortedController = new BtwController(makeCtx(makeFakeSession(abortedRun))); await abortedController.start("Question?"); expect(abortedController.handleEscape()).toBe(true);