From 9484bf26c1ef8128cab0c249a943a84bcc5cecb5 Mon Sep 17 00:00:00 2001 From: Nadia Rodionova <273119990+sosidudku1@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:44:29 +0300 Subject: [PATCH 1/3] fix(http): a failed question on screen never takes back a stored answer After a cleared transcript the client counts the question's position from what it shows, so a failed "continue" the agent never stored could line up with an earlier "continue" and remove its real answer. expectUnanswered: true says the client saw no answer, and a stored reply then refuses with turn_mismatch (ATO-1420). --- src/http/README.md | 2 +- src/http/route-rollback-last-turn.test.ts | 17 ++++++++++++ src/http/route-rollback-last-turn.ts | 21 ++++++++++---- src/session/session-truncate.test.ts | 34 +++++++++++++++++++++++ src/session/session-truncate.ts | 25 ++++++++++++++++- 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/http/README.md b/src/http/README.md index 017e9f4d5..cc590fcab 100644 --- a/src/http/README.md +++ b/src/http/README.md @@ -55,6 +55,6 @@ Auth on the network (ATO-1126): the bearer token is compared in constant time ([ Per-chat folders (ATO-731, [session-working-dir.ts](session-working-dir.ts)): every tool step reads its session's `workingDir`, so `POST /v1/chat/completions` takes `working_dir` (absolute, existing) for a NEW session, `POST /api/sessions/{id}/working-dir` `{ workingDir }` moves one chat (`409 turn_in_progress` only while that chat runs a turn), and `GET /api/sessions?scope=all` lists chats from every folder. None of it restarts `serve`. -Taking a turn back (ATO-1298, [route-rollback-last-turn.ts](route-rollback-last-turn.ts)): `POST /api/sessions/{id}/rollback-last-turn` removes the chat's last question and its answer and returns `200 { id, text }`, the question's text. The body may carry `{ expectText, expectPriorQuestions }`, the question the client means by its text and by how many questions come before it (steers not counted); if the stored last question differs in either (a failed turn the agent never recorded), it answers `409 turn_mismatch` and removes nothing. It only removes: the client sends `text` again through `POST /v1/chat/completions`, so a retry runs the same path as a first try and the old answer is no longer in the chat the model reads. It does not reach outside the transcript: what the turn's reflection already wrote to long-term memory stays, and a chat cut back to nothing keeps its title and leaves `GET /api/sessions` (no question in it) until the question is sent again. Only a turn that changed nothing outside the transcript qualifies: a question answered by one reply, or by nothing (a provider error). A stopped turn does not: a stop can cut a tool batch short, and a local turn stores no trace of that step. Otherwise `409` with `code` `turn_has_tools`, `turn_steered`, `turn_compacted`, `turn_stopped`, `turn_in_progress`, `nothing_to_undo`, `turn_unrecognized` or `turn_mismatch`, and the chat is left as it was. `404 session_not_found` is an unknown chat; an agent without the route answers 404 without that code. The cut itself is [truncateSessionAt](../session/session-truncate.ts). +Taking a turn back (ATO-1298, [route-rollback-last-turn.ts](route-rollback-last-turn.ts)): `POST /api/sessions/{id}/rollback-last-turn` removes the chat's last question and its answer and returns `200 { id, text }`, the question's text. The body may carry `{ expectText, expectPriorQuestions, expectUnanswered }`, the question the client means by its text, by how many questions come before it (steers not counted, questions a cleared transcript hides counted) and, with `expectUnanswered: true`, by the client having seen no answer to it; if the stored last question differs in any (a failed turn the agent never recorded), it answers `409 turn_mismatch` and removes nothing. `expectUnanswered` (ATO-1420) keeps a wrong count from removing a real answer: a failed question on screen never matches a stored one that has a reply. It only removes: the client sends `text` again through `POST /v1/chat/completions`, so a retry runs the same path as a first try and the old answer is no longer in the chat the model reads. It does not reach outside the transcript: what the turn's reflection already wrote to long-term memory stays, and a chat cut back to nothing keeps its title and leaves `GET /api/sessions` (no question in it) until the question is sent again. Only a turn that changed nothing outside the transcript qualifies: a question answered by one reply, or by nothing (a provider error). A stopped turn does not: a stop can cut a tool batch short, and a local turn stores no trace of that step. Otherwise `409` with `code` `turn_has_tools`, `turn_steered`, `turn_compacted`, `turn_stopped`, `turn_in_progress`, `nothing_to_undo`, `turn_unrecognized` or `turn_mismatch`, and the chat is left as it was. `404 session_not_found` is an unknown chat; an agent without the route answers 404 without that code. The cut itself is [truncateSessionAt](../session/session-truncate.ts). [Model tiers route](route-model-tiers.ts): `GET /api/model-tiers` returns the Cheap / Balanced / Best picks per provider key (`openrouter`, `aimlapi`, `gemini`, `claude-cli`) from the one data file, [model-tiers.ts](../llm/provider/presets/model-tiers.ts) (ATO-32). The TUI pickers list those models first under the same labels. diff --git a/src/http/route-rollback-last-turn.test.ts b/src/http/route-rollback-last-turn.test.ts index 37132591e..0dac6c9b5 100644 --- a/src/http/route-rollback-last-turn.test.ts +++ b/src/http/route-rollback-last-turn.test.ts @@ -98,6 +98,23 @@ describe("POST /api/sessions/{id}/rollback-last-turn (ATO-1298)", () => { expect((await post("rb-6", { expectText: "continue", expectPriorQuestions: 0 })).status).toBe(200); }); + it("keeps a real answer when the failed question on screen lines up with it after a clear (ATO-1420)", async () => { + // "first" (cleared from the window), "continue" answered, a second + // "continue" failed and never stored: the window counts 1 question before it. + seed("rb-8", [ + { kind: "user", text: "first", at: 1 }, + { kind: "assistant_reply", text: "one", at: 2 }, + { kind: "user", text: "continue", at: 3 }, + { kind: "assistant_reply", text: "real answer", at: 4 }, + ], [2, 4]); + const res = await post("rb-8", { expectText: "continue", expectPriorQuestions: 1, expectUnanswered: true }); + expect(res.status).toBe(409); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("turn_mismatch"); + expect(harness.runtime.sessionStore.load("rb-8")?.turns).toHaveLength(4); + expect((await post("rb-8", { expectUnanswered: "yes" })).status).toBe(400); + expect(harness.runtime.sessionStore.load("rb-8")?.turns).toHaveLength(4); + }); + it("refuses a stopped turn", async () => { seed("rb-7", [ { kind: "user", text: "delete the build folder", at: 1 }, diff --git a/src/http/route-rollback-last-turn.ts b/src/http/route-rollback-last-turn.ts index f67cdd192..ad6d5d685 100644 --- a/src/http/route-rollback-last-turn.ts +++ b/src/http/route-rollback-last-turn.ts @@ -11,17 +11,20 @@ const REFUSAL_MESSAGES: Record = { turn_compacted: "the last turn is already part of a compacted summary, so it cannot be taken back", turn_stopped: "the last turn was stopped, and a stop can cut a tool call short without a trace, so it cannot be taken back; send the question again instead", turn_unrecognized: "the last turn is not a single question and answer, so it cannot be taken back", - turn_mismatch: "the chat's last question is not the one given (expectText, expectPriorQuestions), so nothing was taken back", + turn_mismatch: "the chat's last question is not the one given (expectText, expectPriorQuestions, expectUnanswered), so nothing was taken back", }; /** * `POST /api/sessions/{id}/rollback-last-turn` (ATO-1298): remove the * chat's last question and its answer so a client can ask it again * without the model reading the old answer. Body `{}` or - * `{ expectText?, expectPriorQuestions? }`: the question the client means, - * by its text and by how many questions come before it (steers not - * counted). When the stored last question differs in either, `409 - * turn_mismatch` and nothing is removed. + * `{ expectText?, expectPriorQuestions?, expectUnanswered? }`: the + * question the client means, by its text, by how many questions come + * before it (steers not counted) and, with `expectUnanswered: true`, by + * the client having seen no answer to it (ATO-1420: a stored reply then + * means a different question, however the count lines up). When the + * stored last question differs in any, `409 turn_mismatch` and nothing + * is removed. * * Only removes. The answer is `200 { id, text }` with the question's * text; the client sends it again through `/v1/chat/completions` like @@ -41,7 +44,7 @@ export function createRollbackLastTurnHandler(): HttpHandler { const options: LastTurnRollbackOptions = {}; try { const body = (await readJsonBody(req)) as - | { expectText?: unknown; expectPriorQuestions?: unknown } + | { expectText?: unknown; expectPriorQuestions?: unknown; expectUnanswered?: unknown } | null; const text = body?.expectText; if (text !== undefined && typeof text !== "string") { @@ -55,6 +58,12 @@ export function createRollbackLastTurnHandler(): HttpHandler { return; } if (prior !== undefined) options.expectPriorQuestions = prior; + const unanswered = body?.expectUnanswered; + if (unanswered !== undefined && typeof unanswered !== "boolean") { + sendError(res, 400, openaiError("expectUnanswered must be a boolean", "invalid_request_error", "expectUnanswered")); + return; + } + if (unanswered !== undefined) options.expectUnanswered = unanswered; } catch (err) { sendError(res, 400, openaiError(`Invalid JSON: ${err instanceof Error ? err.message : String(err)}`)); return; diff --git a/src/session/session-truncate.test.ts b/src/session/session-truncate.test.ts index 165640a18..63c22f9db 100644 --- a/src/session/session-truncate.test.ts +++ b/src/session/session-truncate.test.ts @@ -306,6 +306,40 @@ describe("planLastTurnRollback", () => { expect(planLastTurnRollback(state, { expectPriorQuestions: 1 }).ok).toBe(true); }); + it("never removes a real answer for a failed question on screen, after a cleared transcript (ATO-1420)", () => { + // "first" answered, then the window was cleared (the agent keeps it); + // "continue" answered; a second "continue" failed and was never stored. + let state = turn(fresh(), user("first", 1), reply("one", 2)); + state = turn(state, user("continue", 3), reply("real answer", 4)); + // The window counts one question before the failed one (the cleared + // "first" is not on screen), which is the stored "continue"'s position. + const counted = { expectText: "continue", expectPriorQuestions: 1 }; + expect(planLastTurnRollback(state, counted).ok).toBe(true); + // The window saw no answer to the failed one: the stored "continue" is not it. + expect(planLastTurnRollback(state, { ...counted, expectUnanswered: true })) + .toEqual({ ok: false, code: "turn_mismatch" }); + expect(state.turns.map((t) => t.kind === "assistant_reply" && t.text)).toContain("real answer"); + }); + + it("still takes back a failed question that has no answer when expectUnanswered is set", () => { + let state = turn(fresh(), user("first", 1), reply("one", 2)); + state = { ...turn(state, user("continue", 3)), status: "failed" as const, lastError: "402" }; + const plan = planLastTurnRollback(state, { + expectText: "continue", + expectPriorQuestions: 1, + expectUnanswered: true, + }); + expect(plan.ok).toBe(true); + if (!plan.ok) return; + expect(plan.state.turns).toHaveLength(2); + }); + + it("keeps the stop refusal for a stopped turn when expectUnanswered is set", () => { + const state = turn(fresh(), user("delete the build folder", 1), stoppedTurnMarker(2)); + expect(planLastTurnRollback(state, { expectUnanswered: true })) + .toEqual({ ok: false, code: "turn_stopped" }); + }); + it("refuses two answers after one prompt", () => { const state = turn(fresh(), user("a", 1), reply("b", 2), reply("c", 3)); expect(planLastTurnRollback(state)).toEqual({ ok: false, code: "turn_unrecognized" }); diff --git a/src/session/session-truncate.ts b/src/session/session-truncate.ts index 46035e285..02f55f394 100644 --- a/src/session/session-truncate.ts +++ b/src/session/session-truncate.ts @@ -121,7 +121,7 @@ export type LastTurnRollbackRefusal = | "turn_unrecognized" /** * The last question is not the one the caller meant (`expectText`, - * `expectPriorQuestions`). + * `expectPriorQuestions`, `expectUnanswered`). */ | "turn_mismatch"; @@ -154,6 +154,18 @@ export interface LastTurnRollbackOptions { * `turn_mismatch`. */ expectPriorQuestions?: number; + /** + * The caller saw no answer to the question it means (its turn failed + * on screen). A stored last question that has a reply is then not that + * question, whatever its text and position say, and it refuses with + * `turn_mismatch` (ATO-1420). The position is counted by the client + * from what it shows, and a client whose transcript was cleared, or + * that counts wrong for any other reason, could otherwise line a failed + * "continue" up with an earlier "continue" and remove its real answer. + * Taking back a question that has no answer loses no answer, so the + * opposite case (a reply on screen, none stored) is not checked. + */ + expectUnanswered?: boolean; } /** @@ -201,6 +213,17 @@ export function planLastTurnRollback( } const after = turns.slice(start + 1); + if ( + options.expectUnanswered === true && + after.some( + (turn) => + turn.kind === "assistant_reply" && + turn.progressNote !== true && + turn.stopped !== true, + ) + ) { + return { ok: false, code: "turn_mismatch" }; + } if ( after.some( (turn) => From a702b30301258d795d75827f38ef1565c4230987 Mon Sep 17 00:00:00 2001 From: Nadia Rodionova <273119990+sosidudku1@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:46:03 +0300 Subject: [PATCH 2/3] fix(tui): count the questions /clear hides when try again takes a turn back After /clear the screen no longer shows the earlier questions the session still holds, so try again handed the rollback a position counted from the screen. A failed "continue" then lined up with an earlier "continue" and its real answer was removed. The reducer now keeps how many questions a clear hid, try again adds them to the position, and a question shown without an answer is sent with expectUnanswered (ATO-1420). --- src/tui/chat/chat-try-again-button.test.tsx | 126 +++++++++++++++++++- src/tui/chat/chat-try-again-button.tsx | 29 ++++- src/tui/reduce-ui-actions.test.ts | 28 +++++ src/tui/reduce-ui-actions.ts | 9 ++ src/tui/tui-state.ts | 11 ++ 5 files changed, 197 insertions(+), 6 deletions(-) diff --git a/src/tui/chat/chat-try-again-button.test.tsx b/src/tui/chat/chat-try-again-button.test.tsx index f32027d59..2f038d9a5 100644 --- a/src/tui/chat/chat-try-again-button.test.tsx +++ b/src/tui/chat/chat-try-again-button.test.tsx @@ -8,13 +8,23 @@ import { MouseProvider } from "../mouse/mouse-context.js"; import { MouseTargetRegistry } from "../mouse/mouse-registry.js"; import type { TuiAction } from "../tui-action.js"; import type { LastTurnRollbackOutcome, TuiAppCallbacks } from "../tui-app.js"; +import { createEmptySessionState, type SessionState } from "../../session/index.js"; +import { + planLastTurnRollback, + type LastTurnRollbackOptions, +} from "../../session/session-truncate.js"; import { createInitialTuiState, type ChatMessage, type TuiSessionInfo, type TuiState, } from "../tui-state.js"; -import { ChatTryAgainButton, isLastQuestion, priorQuestions } from "./chat-try-again-button.js"; +import { + ChatTryAgainButton, + isLastQuestion, + priorQuestions, + shownUnanswered, +} from "./chat-try-again-button.js"; const SESSION: TuiSessionInfo = fakeSession({ sessionId: "again", @@ -98,7 +108,7 @@ function mount( }: { withMouse?: boolean; initial?: TuiState; - rollback?: () => LastTurnRollbackOutcome; + rollback?: (expect?: LastTurnRollbackOptions) => LastTurnRollbackOutcome; } = {}, ): Harness { const registry = new MouseTargetRegistry(); @@ -117,9 +127,9 @@ function mount( const callbacks: TuiAppCallbacks = { ...(rollback ? { - onLastTurnRollbackRequested: () => { + onLastTurnRollbackRequested: (expect) => { rollbackCalls += 1; - return rollback(); + return rollback(expect); }, } : {}), @@ -348,6 +358,97 @@ describe("ChatTryAgainButton", () => { app.unmount(); }); + describe("after /clear (ATO-1420)", () => { + // The session the orchestrator holds: "first" was answered before + // `/clear`, then "continue" was answered for real. + const stored = (): SessionState => ({ + ...createEmptySessionState({ id: "again", workingDir: "/tmp/again" }), + status: "pending", + turns: [ + { kind: "user", text: "first", at: 1 }, + { kind: "assistant_reply", text: "one", at: 2 }, + { kind: "user", text: "continue", at: 3 }, + { kind: "assistant_reply", text: "real answer", at: 4 }, + ], + turnCount: 2, + macroTurnStarts: [2, 4], + }); + // The orchestrator's rollback, on that session (`rollbackLastTurn`). + const orchestrator = (session: { current: SessionState }) => + (expect?: LastTurnRollbackOptions): LastTurnRollbackOutcome => { + const plan = planLastTurnRollback(session.current, expect); + if (!plan.ok) return { ok: false, code: plan.code }; + session.current = plan.state; + return { ok: true, text: plan.text }; + }; + const cleared = (messages: ChatMessage[]): TuiState => ({ + ...reduceTuiState( + { + ...createInitialTuiState(SESSION), + messages: [ + { id: "u0", role: "user", text: "first" }, + { id: "a0", role: "assistant", text: "one" }, + ], + }, + { type: "chat_cleared" }, + ), + messages, + }); + + it("a failed repeat of a question never takes back the earlier real answer", async () => { + // On screen after /clear: "continue" answered, then a second + // "continue" that failed and never reached the session. + const session = { current: stored() }; + const asked: (LastTurnRollbackOptions | undefined)[] = []; + const rollback = orchestrator(session); + const app = mount( + , + { + initial: cleared([ + { id: "u1", role: "user", text: "continue" }, + { id: "a1", role: "assistant", text: "real answer" }, + { id: "u2", role: "user", text: "continue" }, + { id: "f2", role: "system", text: "Turn failed", variant: "warn", retryText: "continue" }, + ]), + rollback: (expect) => { + asked.push(expect); + return rollback(expect); + }, + }, + ); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + expect(asked).toEqual([ + { expectText: "continue", expectPriorQuestions: 2, expectUnanswered: true }, + ]); + // Nothing was taken back; the question goes out again as a new turn. + expect(session.current.turns).toHaveLength(4); + expect(session.current.turns[3]).toMatchObject({ text: "real answer" }); + expect(app.submitted).toEqual(["continue"]); + expect(app.actions.map((a) => a.type)).not.toContain("chat_messages_truncated"); + app.unmount(); + }); + + it("still takes back the last answered question, counting the cleared ones", async () => { + const session = { current: stored() }; + const app = mount( + , + { + initial: cleared([ + { id: "u1", role: "user", text: "continue" }, + { id: "a1", role: "assistant", text: "real answer" }, + ]), + rollback: orchestrator(session), + }, + ); + await app.clickUntil("[try again]", () => app.submitted.length > 0); + // One question before it in the session ("first"), none on screen. + expect(session.current.turns.map((t) => t.kind)).toEqual(["user", "assistant_reply"]); + expect(app.submitted).toEqual(["continue"]); + expect(app.state().messages).toEqual([]); + app.unmount(); + }); + }); + it("a notice's button (no message id) always resends", async () => { const app = mount(, { initial: withMessages(), @@ -373,6 +474,23 @@ describe("isLastQuestion", () => { }); }); +describe("shownUnanswered", () => { + it("is true only when no reply follows the question before the next one", () => { + const messages: ChatMessage[] = [ + { id: "u0", role: "user", text: "a" }, + { id: "p0", role: "assistant", text: "working on it", progressNote: true }, + { id: "u1", role: "user", text: "b" }, + { id: "a1", role: "assistant", text: "reply" }, + { id: "u2", role: "user", text: "c" }, + { id: "f2", role: "system", text: "Turn failed", variant: "warn" }, + ]; + expect(shownUnanswered(messages, "u0")).toBe(true); + expect(shownUnanswered(messages, "u1")).toBe(false); + expect(shownUnanswered(messages, "u2")).toBe(true); + expect(shownUnanswered(messages, "missing")).toBe(false); + }); +}); + describe("priorQuestions", () => { it("counts the turn-opening prompts before a message, steers left out", () => { const messages: ChatMessage[] = [ diff --git a/src/tui/chat/chat-try-again-button.tsx b/src/tui/chat/chat-try-again-button.tsx index 46a2d964f..80ee52dc4 100644 --- a/src/tui/chat/chat-try-again-button.tsx +++ b/src/tui/chat/chat-try-again-button.tsx @@ -105,6 +105,24 @@ export function priorQuestions( return count; } +/** + * Whether the screen shows no answer to `messageId`: no reply (progress + * notes aside) between it and the next question. A failed turn's + * question looks like this. + */ +export function shownUnanswered( + messages: readonly ChatMessage[], + messageId: string, +): boolean { + const at = messages.findIndex((message) => message.id === messageId); + if (at < 0) return false; + for (const message of messages.slice(at + 1)) { + if (message.role === "user" && message.steered !== true) break; + if (message.role === "assistant" && message.progressNote !== true) return false; + } + return true; +} + /** Nothing running, waiting, or parked that the cut could race with. */ function chatIsIdle(state: TuiState): boolean { return ( @@ -147,10 +165,17 @@ export function retryChatMessage( ) { // The clicked question by text and by how many questions precede it: // a failed turn that never reached the store must not match an - // earlier question with the same text. + // earlier question with the same text. The questions `/clear` took + // off the screen are still in the session and count (ATO-1420); and + // a question the screen shows no answer to must never take back a + // stored one that has an answer, however the count lines up. const outcome = rollback({ expectText: text, - expectPriorQuestions: priorQuestions(state.messages, messageId), + expectPriorQuestions: + (state.clearedQuestions ?? 0) + priorQuestions(state.messages, messageId), + ...(shownUnanswered(state.messages, messageId) + ? { expectUnanswered: true } + : {}), }); if (outcome.ok) { mouse.dispatch({ type: "chat_messages_truncated", fromMessageId: messageId }); diff --git a/src/tui/reduce-ui-actions.test.ts b/src/tui/reduce-ui-actions.test.ts index 14ec57317..ac4f1ac20 100644 --- a/src/tui/reduce-ui-actions.test.ts +++ b/src/tui/reduce-ui-actions.test.ts @@ -490,6 +490,34 @@ describe("reduceUiAction finished-turn folds (ATO-963)", () => { }); }); +describe("reduceUiAction clearedQuestions (ATO-1420)", () => { + it("counts the questions /clear hides, and forgets them on a switch", () => { + const state = { + ...createInitialTuiState(SESSION), + messages: [ + { id: "u1", role: "user" as const, text: "q1" }, + { id: "s1", role: "user" as const, text: "also", steered: true }, + { id: "a1", role: "assistant" as const, text: "r1" }, + { id: "u2", role: "user" as const, text: "q2" }, + ], + }; + const once = reduceUiAction(state, { type: "chat_cleared" }); + expect(once?.clearedQuestions).toBe(2); + const twice = reduceUiAction( + { ...once!, messages: [{ id: "u3", role: "user" as const, text: "q3" }] }, + { type: "chat_cleared" }, + ); + expect(twice?.clearedQuestions).toBe(3); + const switched = reduceUiAction(twice!, { + type: "session_switched", + sessionId: "s2", + workingDir: "/tmp", + messages: [], + }); + expect(switched?.clearedQuestions).toBe(0); + }); +}); + describe("reduceUiAction chat_messages_truncated (ATO-1302)", () => { const withPairs = () => ({ ...createInitialTuiState(SESSION), diff --git a/src/tui/reduce-ui-actions.ts b/src/tui/reduce-ui-actions.ts index 8e897989a..45c987747 100644 --- a/src/tui/reduce-ui-actions.ts +++ b/src/tui/reduce-ui-actions.ts @@ -319,6 +319,13 @@ export function reduceUiAction( return { ...state, messages: [], + // The session keeps what the screen drops: `[try again]` still + // has to count those questions (ATO-1420). + clearedQuestions: + (state.clearedQuestions ?? 0) + + state.messages.filter( + (message) => message.role === "user" && message.steered !== true, + ).length, pendingSteers: [], reasoning: [], feed: [], @@ -521,6 +528,8 @@ export function reduceUiAction( plan: action.plan ?? null, sessionUsage: action.sessionUsage ?? null, messages: [...action.messages], + // Rebuilt from the session: nothing of it is hidden any more. + clearedQuestions: 0, reasoning: [], feed: [], logs: [], diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index 738a93c78..3f30efc07 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -385,6 +385,17 @@ export interface TuiState { feed: FeedEntry[]; /** Chat transcript: human-friendly view onto the session turn list. */ messages: ChatMessage[]; + /** + * Questions (steers not counted) that `/clear` took off the screen + * while the session still holds them (ATO-1420). `[try again]` adds it + * to the questions it sees before the one clicked, so the position it + * hands the rollback is the session's, not the screen's. A failed + * question that never reached the session is counted here too: a count + * too high only makes the rollback refuse (and the button resend), + * never take back a different question. Reset when the transcript is + * rebuilt from the session (`session_switched`). Absent means 0. + */ + clearedQuestions?: number; /** Counts tool steps inside the currently running turn. */ currentTurnToolSteps: number; /** From 75351fd17b4d611d50012914ed4afcf4ac27c289 Mon Sep 17 00:00:00 2001 From: Nadia Rodionova <273119990+sosidudku1@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:10:40 +0300 Subject: [PATCH 3/3] test(tui): add timestamp to the cleared-chat message fixtures --- src/tui/chat/chat-try-again-button.test.tsx | 28 ++++++++++----------- src/tui/reduce-ui-actions.test.ts | 10 ++++---- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/tui/chat/chat-try-again-button.test.tsx b/src/tui/chat/chat-try-again-button.test.tsx index 25363e418..c56bf61cc 100644 --- a/src/tui/chat/chat-try-again-button.test.tsx +++ b/src/tui/chat/chat-try-again-button.test.tsx @@ -386,8 +386,8 @@ describe("ChatTryAgainButton", () => { { ...createInitialTuiState(SESSION), messages: [ - { id: "u0", role: "user", text: "first" }, - { id: "a0", role: "assistant", text: "one" }, + { id: "u0", role: "user", text: "first", timestamp: 0 }, + { id: "a0", role: "assistant", text: "one", timestamp: 0 }, ], }, { type: "chat_cleared" }, @@ -405,10 +405,10 @@ describe("ChatTryAgainButton", () => { , { initial: cleared([ - { id: "u1", role: "user", text: "continue" }, - { id: "a1", role: "assistant", text: "real answer" }, - { id: "u2", role: "user", text: "continue" }, - { id: "f2", role: "system", text: "Turn failed", variant: "warn", retryText: "continue" }, + { id: "u1", role: "user", text: "continue", timestamp: 0 }, + { id: "a1", role: "assistant", text: "real answer", timestamp: 0 }, + { id: "u2", role: "user", text: "continue", timestamp: 0 }, + { id: "f2", role: "system", text: "Turn failed", variant: "warn", retryText: "continue", timestamp: 0 }, ]), rollback: (expect) => { asked.push(expect); @@ -434,8 +434,8 @@ describe("ChatTryAgainButton", () => { , { initial: cleared([ - { id: "u1", role: "user", text: "continue" }, - { id: "a1", role: "assistant", text: "real answer" }, + { id: "u1", role: "user", text: "continue", timestamp: 0 }, + { id: "a1", role: "assistant", text: "real answer", timestamp: 0 }, ]), rollback: orchestrator(session), }, @@ -477,12 +477,12 @@ describe("isLastQuestion", () => { describe("shownUnanswered", () => { it("is true only when no reply follows the question before the next one", () => { const messages: ChatMessage[] = [ - { id: "u0", role: "user", text: "a" }, - { id: "p0", role: "assistant", text: "working on it", progressNote: true }, - { id: "u1", role: "user", text: "b" }, - { id: "a1", role: "assistant", text: "reply" }, - { id: "u2", role: "user", text: "c" }, - { id: "f2", role: "system", text: "Turn failed", variant: "warn" }, + { id: "u0", role: "user", text: "a", timestamp: 0 }, + { id: "p0", role: "assistant", text: "working on it", progressNote: true, timestamp: 0 }, + { id: "u1", role: "user", text: "b", timestamp: 0 }, + { id: "a1", role: "assistant", text: "reply", timestamp: 0 }, + { id: "u2", role: "user", text: "c", timestamp: 0 }, + { id: "f2", role: "system", text: "Turn failed", variant: "warn", timestamp: 0 }, ]; expect(shownUnanswered(messages, "u0")).toBe(true); expect(shownUnanswered(messages, "u1")).toBe(false); diff --git a/src/tui/reduce-ui-actions.test.ts b/src/tui/reduce-ui-actions.test.ts index 8464dfd96..1e565184c 100644 --- a/src/tui/reduce-ui-actions.test.ts +++ b/src/tui/reduce-ui-actions.test.ts @@ -495,16 +495,16 @@ describe("reduceUiAction clearedQuestions (ATO-1420)", () => { const state = { ...createInitialTuiState(SESSION), messages: [ - { id: "u1", role: "user" as const, text: "q1" }, - { id: "s1", role: "user" as const, text: "also", steered: true }, - { id: "a1", role: "assistant" as const, text: "r1" }, - { id: "u2", role: "user" as const, text: "q2" }, + { id: "u1", role: "user" as const, text: "q1", timestamp: 0 }, + { id: "s1", role: "user" as const, text: "also", steered: true, timestamp: 0 }, + { id: "a1", role: "assistant" as const, text: "r1", timestamp: 0 }, + { id: "u2", role: "user" as const, text: "q2", timestamp: 0 }, ], }; const once = reduceUiAction(state, { type: "chat_cleared" }); expect(once?.clearedQuestions).toBe(2); const twice = reduceUiAction( - { ...once!, messages: [{ id: "u3", role: "user" as const, text: "q3" }] }, + { ...once!, messages: [{ id: "u3", role: "user" as const, text: "q3", timestamp: 0 }] }, { type: "chat_cleared" }, ); expect(twice?.clearedQuestions).toBe(3);