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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user