Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 17 additions & 0 deletions src/http/route-rollback-last-turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
21 changes: 15 additions & 6 deletions src/http/route-rollback-last-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@ const REFUSAL_MESSAGES: Record<string, string> = {
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
Expand All @@ -41,7 +44,7 @@ export function createRollbackLastTurnHandler(): HttpHandler {
const options: LastTurnRollbackOptions = {};
try {
const body = (await readJsonBody<unknown>(req)) as
| { expectText?: unknown; expectPriorQuestions?: unknown }
| { expectText?: unknown; expectPriorQuestions?: unknown; expectUnanswered?: unknown }
| null;
const text = body?.expectText;
if (text !== undefined && typeof text !== "string") {
Expand All @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions src/session/session-truncate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
25 changes: 24 additions & 1 deletion src/session/session-truncate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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) =>
Expand Down
126 changes: 122 additions & 4 deletions src/tui/chat/chat-try-again-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -98,7 +108,7 @@ function mount(
}: {
withMouse?: boolean;
initial?: TuiState;
rollback?: () => LastTurnRollbackOutcome;
rollback?: (expect?: LastTurnRollbackOptions) => LastTurnRollbackOutcome;
} = {},
): Harness {
const registry = new MouseTargetRegistry();
Expand All @@ -117,9 +127,9 @@ function mount(
const callbacks: TuiAppCallbacks = {
...(rollback
? {
onLastTurnRollbackRequested: () => {
onLastTurnRollbackRequested: (expect) => {
rollbackCalls += 1;
return rollback();
return rollback(expect);
},
}
: {}),
Expand Down Expand Up @@ -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", timestamp: 0 },
{ id: "a0", role: "assistant", text: "one", timestamp: 0 },
],
},
{ 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(
<ChatTryAgainButton text="continue" messageId="u2" />,
{
initial: cleared([
{ 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);
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(
<ChatTryAgainButton text="continue" messageId="u1" />,
{
initial: cleared([
{ id: "u1", role: "user", text: "continue", timestamp: 0 },
{ id: "a1", role: "assistant", text: "real answer", timestamp: 0 },
]),
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(<ChatTryAgainButton text="second question" />, {
initial: withMessages(),
Expand All @@ -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", 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);
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[] = [
Expand Down
Loading