fix(coding-agent): allow ask re-answer when targeting the active leaf

navigateTree()'s targetId === oldLeafId no-op short-circuit ran before
the ask re-answer probe/completion block, so selecting an ask
toolResult that is already the current leaf silently reported success
without returning reopenAsk or branching a new answer. This happens
when the user interrupts right after answering ask (before a follow-up
assistant message lands) or another caller navigates straight onto the
ask result. Exempt allowAskReopen probes/completions targeting an ask
toolResult from the short-circuit so the two-phase re-answer protocol
still runs.
This commit is contained in:
Mathews-Tom
2026-07-18 03:16:56 +05:30
parent 5633a64213
commit 743c8ab7de
2 changed files with 71 additions and 7 deletions
@@ -16776,8 +16776,23 @@ export class AgentSession {
await this.#flushPendingBashMessages();
const oldLeafId = this.sessionManager.getLeafId();
// No-op if already at target
if (targetId === oldLeafId) {
const targetEntry = this.sessionManager.getEntry(targetId);
if (!targetEntry) {
throw new Error(`Entry ${targetId} not found`);
}
const targetIsAskResult =
targetEntry.type === "message" &&
targetEntry.message.role === "toolResult" &&
targetEntry.message.toolName === "ask";
// No-op if already at target — except mid-flight through the `ask`
// re-answer protocol (issue #5642): a probe or completion call can
// legitimately target the *current* leaf (e.g. the user interrupted
// right after answering `ask`, before a follow-up assistant message
// landed, or another caller navigated straight onto the ask result),
// and must still return `reopenAsk` / branch the new answer instead of
// silently reporting a no-op (chatgpt-codex review on #5895).
if (targetId === oldLeafId && !(options.allowAskReopen && targetIsAskResult)) {
return { cancelled: false };
}
@@ -16786,11 +16801,6 @@ export class AgentSession {
throw new Error("No model available for summarization");
}
const targetEntry = this.sessionManager.getEntry(targetId);
if (!targetEntry) {
throw new Error(`Entry ${targetId} not found`);
}
// `ask` toolResult, first pass: hand control back to the caller to
// re-open the picker instead of landing on the stale answer in place.
// Nothing is mutated here — see the `reanswerAskResult` branch below for
@@ -404,6 +404,60 @@ describe("AgentSession tree navigation onto an ask toolResult", () => {
await ctx.cleanup();
}
});
it("(j) allows probing and completing an ask re-answer when the ask toolResult is already the current leaf", async () => {
// If the user interrupts right after answering `ask` (before a
// follow-up assistant message is appended), or another caller navigates
// straight onto the ask result, the ask toolResult itself is the
// current leaf. The `targetId === oldLeafId` no-op short-circuit must
// not swallow the re-answer protocol in that case — a probe still
// needs to return `reopenAsk`, and a completion still needs to branch
// a new sibling (chatgpt-codex review on #5895).
const ctx = await createTestSession({ inMemory: true });
try {
const { session, sessionManager } = ctx;
sessionManager.appendMessage(userMsg("please deploy"));
const askCallId = "ask-call-1";
const askCallEntryId = sessionManager.appendMessage(
toolCallMsg(askCallId, "ask", { questions: ORIGINAL_QUESTIONS }),
);
const tr1Id = sessionManager.appendMessage(
toolResultMsg(askCallId, "ask", "User selected: staging", staleAnswerResult().details),
);
// No follow-up assistant message: tr1 is the current leaf.
expect(sessionManager.getLeafId()).toBe(tr1Id);
const probe = await session.navigateTree(tr1Id, { allowAskReopen: true });
expect(probe.cancelled).toBe(false);
expect(probe.reopenAsk).toBeDefined();
expect(probe.reopenAsk?.toolCallId).toBe(askCallId);
expect(probe.reopenAsk?.questions).toEqual(ORIGINAL_QUESTIONS);
// The probe must not mutate anything.
expect(sessionManager.getLeafId()).toBe(tr1Id);
const result = await session.navigateTree(tr1Id, {
allowAskReopen: true,
reanswerAskResult: newAnswerResult(),
});
expect(result.cancelled).toBe(false);
const newLeafId = sessionManager.getLeafId();
expect(newLeafId).not.toBe(tr1Id);
const newEntry = sessionManager.getEntry(newLeafId!);
expect(newEntry?.parentId).toBe(askCallEntryId);
if (newEntry?.type === "message" && newEntry.message.role === "toolResult") {
expect(newEntry.message.details).toEqual(newAnswerResult().details);
} else {
throw new Error("expected the new leaf to be a toolResult entry");
}
// The original (stale) answer's branch is still reachable.
const originalEntry = sessionManager.getEntry(tr1Id);
expect(originalEntry?.parentId).toBe(askCallEntryId);
} finally {
await ctx.cleanup();
}
});
});
describe("AgentSession.buildAskReanswerContext", () => {