authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-11 22:29:54-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 22:29:54-03:00
logfd5553f1afcef4f410a067687faa991743b5034c
tree7b423ec9547504ba93165f2b7175f0be0aef8d9a
parentcf1ba3d7da98357243ee056d2fd358eed8171981
signature Signed by PGP key B5690EEEBB952194

fix: restore provider history and prompts when rewinding (#11338)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

31 files changed, 1517 insertions(+), 190 deletions(-)

apps/server/integration/orchestrationEngine.integration.test.ts+2-2
......@@ -888,7 +888,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
888888);
889889
890890it.live(
891 "appends checkpoint.revert.failed activity when revert is requested without an active session",
891 "appends checkpoint.revert.failed activity when revert is requested without a provider binding",
892892 () =>
893893 withHarness((harness) =>
894894 Effect.gen(function* () {
......@@ -917,7 +917,7 @@ it.live(
917917 assert.equal(
918918 String(
919919 (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail,
920 ).includes("No active provider session"),
920 ).includes("no persisted provider binding exists"),
921921 true,
922922 );
923923 }),
apps/server/src/claudeHistoryWorker.ts created+25
......@@ -0,0 +1,25 @@
1import { forkSession, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
2import * as Schema from "effect/Schema";
3
4// A separate process gives SDK history helpers the provider's environment without
5// mutating the server's environment. This entry is bundled alongside the server.
6const [method, sessionId, rawOptions] = process.argv.slice(2);
7const options = Schema.decodeSync(
8 Schema.fromJsonString(
9 Schema.Struct({
10 dir: Schema.optionalKey(Schema.String),
11 includeSystemMessages: Schema.optionalKey(Schema.Boolean),
12 upToMessageId: Schema.optionalKey(Schema.String),
13 }),
14 ),
15)(rawOptions ?? "{}");
16if (!sessionId) throw new Error("Claude history session id is required.");
17const result =
18 method === "getSessionMessages"
19 ? await getSessionMessages(sessionId, options)
20 : method === "forkSession"
21 ? await forkSession(sessionId, options)
22 : (() => {
23 throw new Error("Unknown Claude history operation.");
24 })();
25process.stdout.write(JSON.stringify(result));
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+39-21
......@@ -1916,27 +1916,45 @@ describe("CheckpointReactor", () => {
19161916 });
19171917 });
19181918
1919 it("appends an error activity when revert is requested without an active session", async () => {
1920 const harness = await createHarness({ hasSession: false });
1921 const createdAt = "2026-01-01T00:00:00.000Z";
1922
1923 await Effect.runPromise(
1924 harness.engine.dispatch({
1925 type: "thread.checkpoint.revert",
1926 commandId: CommandId.make("cmd-revert-no-session"),
1927 threadId: ThreadId.make("thread-1"),
1928 turnCount: 1,
1929 createdAt,
1930 }),
1931 );
1919 it.each([false, true])(
1920 "reverts without an active session using project cwd fallback: %s",
1921 async (useProjectCwd) => {
1922 const harness = await createHarness({
1923 hasSession: false,
1924 ...(useProjectCwd ? { threadWorktreePath: null } : {}),
1925 });
1926 const createdAt = "2026-01-01T00:00:00.000Z";
19321927
1933 const thread = await waitForThread(harness.readModel, (entry) =>
1934 entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"),
1935 );
1928 await Effect.runPromise(
1929 harness.engine.dispatch({
1930 type: "thread.turn.diff.complete",
1931 commandId: CommandId.make("cmd-diff-before-session-recovery"),
1932 threadId: ThreadId.make("thread-1"),
1933 turnId: asTurnId("turn-1"),
1934 completedAt: createdAt,
1935 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
1936 status: "ready",
1937 files: [],
1938 checkpointTurnCount: 1,
1939 createdAt,
1940 }),
1941 );
1942 await Effect.runPromise(
1943 harness.engine.dispatch({
1944 type: "thread.checkpoint.revert",
1945 commandId: CommandId.make("cmd-revert-no-session"),
1946 threadId: ThreadId.make("thread-1"),
1947 turnCount: 0,
1948 createdAt,
1949 }),
1950 );
19361951
1937 expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe(
1938 true,
1939 );
1940 expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
1941 });
1952 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
1953 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
1954 threadId: ThreadId.make("thread-1"),
1955 numTurns: 1,
1956 });
1957 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n");
1958 },
1959 );
19421960});
apps/server/src/orchestration/Layers/CheckpointReactor.ts+12-16
......@@ -699,21 +699,17 @@ const make = Effect.gen(function* () {
699699 return;
700700 }
701701
702 const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId);
703 if (Option.isNone(sessionRuntime)) {
704 yield* appendRevertFailureActivity({
705 threadId: event.payload.threadId,
706 turnCount: event.payload.turnCount,
707 detail: "No active provider session with workspace cwd is bound to this thread.",
708 createdAt: now,
709 }).pipe(Effect.catch(() => Effect.void));
710 return;
711 }
712 if (!(yield* checkpointStore.isGitRepository(sessionRuntime.value.cwd))) {
702 const checkpointCwd = yield* resolveCheckpointCwd({
703 threadId: event.payload.threadId,
704 thread,
705 projects: yield* resolveThreadProjects(thread.projectId),
706 preferSessionRuntime: true,
707 });
708 if (!checkpointCwd) {
713709 yield* appendRevertFailureActivity({
714710 threadId: event.payload.threadId,
715711 turnCount: event.payload.turnCount,
716 detail: "Checkpoints are unavailable because this project is not a git repository.",
712 detail: "Checkpoint workspace is unavailable or is not a git repository.",
717713 createdAt: now,
718714 }).pipe(Effect.catch(() => Effect.void));
719715 return;
......@@ -754,7 +750,7 @@ const make = Effect.gen(function* () {
754750 yield* providerService.assertConversationRollbackSupported(event.payload.threadId);
755751
756752 const restored = yield* checkpointStore.restoreCheckpoint({
757 cwd: sessionRuntime.value.cwd,
753 cwd: checkpointCwd,
758754 checkpointRef: targetCheckpointRef,
759755 fallbackToHead: event.payload.turnCount === 0,
760756 });
......@@ -770,12 +766,12 @@ const make = Effect.gen(function* () {
770766
771767 // Refresh the workspace entry index so the @-mention file picker
772768 // reflects the reverted filesystem state.
773 yield* workspaceEntries.refresh(sessionRuntime.value.cwd);
769 yield* workspaceEntries.refresh(checkpointCwd);
774770
775771 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);
776772 if (rolledBackTurns > 0) {
777773 yield* providerService.rollbackConversation({
778 threadId: sessionRuntime.value.threadId,
774 threadId: event.payload.threadId,
779775 numTurns: rolledBackTurns,
780776 });
781777 }
......@@ -789,7 +785,7 @@ const make = Effect.gen(function* () {
789785
790786 if (staleCheckpointRefs.length > 0) {
791787 yield* checkpointStore.deleteCheckpointRefs({
792 cwd: sessionRuntime.value.cwd,
788 cwd: checkpointCwd,
793789 checkpointRefs: staleCheckpointRefs,
794790 });
795791 }
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+217-71
......@@ -170,8 +170,11 @@ function makeHarness(config?: {
170170 readonly instanceId?: ProviderInstanceId;
171171 readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"];
172172 readonly environment?: ClaudeAdapterLiveOptions["environment"];
173 readonly getSessionMessages?: ClaudeAdapterLiveOptions["getSessionMessages"];
174 readonly forkSession?: ClaudeAdapterLiveOptions["forkSession"];
173175}) {
174176 const query = new FakeClaudeQuery();
177 const queries = [query];
175178 let createInput:
176179 | {
177180 readonly prompt: AsyncIterable<SDKUserMessage>;
......@@ -184,9 +187,12 @@ function makeHarness(config?: {
184187 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),
185188 ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}),
186189 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),
190 ...(config?.getSessionMessages ? { getSessionMessages: config.getSessionMessages } : {}),
191 ...(config?.forkSession ? { forkSession: config.forkSession } : {}),
187192 createQuery: (input) => {
193 if (createInput && config?.getSessionMessages) queries.push(new FakeClaudeQuery());
188194 createInput = input;
189 return query;
195 return queries.at(-1)!;
190196 },
191197 ...(config?.nativeEventLogger
192198 ? {
......@@ -218,6 +224,7 @@ function makeHarness(config?: {
218224 Layer.provideMerge(NodeServices.layer),
219225 ),
220226 query,
227 queries,
221228 getLastCreateQueryInput: () => createInput,
222229 };
223230}
......@@ -6256,87 +6263,226 @@ describe("ClaudeAdapterLive", () => {
62566263 );
62576264 });
62586265
6259 it.effect(
6260 "supports rollbackThread by trimming in-memory turns and preserving earlier turns",
6261 () => {
6262 const harness = makeHarness();
6263 return Effect.gen(function* () {
6264 const adapter = yield* ClaudeAdapter;
6265
6266 const session = yield* adapter.startSession({
6267 threadId: THREAD_ID,
6268 provider: ProviderDriverKind.make("claudeAgent"),
6269 runtimeMode: "full-access",
6270 });
6266 it.effect("rewinds a steered Claude turn after recovery and preserves fork boundaries", () => {
6267 const forkCalls: Array<Parameters<NonNullable<ClaudeAdapterLiveOptions["forkSession"]>>> = [];
6268 let firstTurnId = "";
6269 let secondTurnId = "";
6270 let missingBoundary = false;
6271 let legacyHistory = false;
6272 const harness = makeHarness({
6273 forkSession: async (...args) => {
6274 forkCalls.push(args);
6275 return { sessionId: "550e8400-e29b-41d4-a716-446655440020" };
6276 },
6277 getSessionMessages: async (sessionId) => {
6278 const history: Awaited<
6279 ReturnType<NonNullable<ClaudeAdapterLiveOptions["getSessionMessages"]>>
6280 > = [
6281 {
6282 type: "user",
6283 uuid: firstTurnId,
6284 session_id: "550e8400-e29b-41d4-a716-446655440010",
6285 parent_tool_use_id: null,
6286 parent_agent_id: null,
6287 message: { content: "first" },
6288 },
6289 {
6290 type: "assistant",
6291 uuid: "assistant-1",
6292 session_id: "550e8400-e29b-41d4-a716-446655440010",
6293 parent_tool_use_id: null,
6294 parent_agent_id: null,
6295 message: { content: [] },
6296 },
6297 {
6298 type: "user",
6299 uuid: "tool-result-1",
6300 session_id: "550e8400-e29b-41d4-a716-446655440010",
6301 parent_tool_use_id: null,
6302 parent_agent_id: null,
6303 message: { content: [{ type: "tool_result" }] },
6304 },
6305 {
6306 type: "assistant",
6307 uuid: "assistant-1-final",
6308 session_id: "550e8400-e29b-41d4-a716-446655440010",
6309 parent_tool_use_id: null,
6310 parent_agent_id: null,
6311 message: { content: [] },
6312 },
6313 {
6314 type: "user",
6315 uuid: secondTurnId,
6316 session_id: "550e8400-e29b-41d4-a716-446655440010",
6317 parent_tool_use_id: null,
6318 parent_agent_id: null,
6319 message: { content: "second" },
6320 },
6321 {
6322 type: "assistant",
6323 uuid: "assistant-2",
6324 session_id: "550e8400-e29b-41d4-a716-446655440010",
6325 parent_tool_use_id: null,
6326 parent_agent_id: null,
6327 message: { content: [] },
6328 },
6329 {
6330 type: "user",
6331 uuid: "steer",
6332 session_id: sessionId,
6333 parent_tool_use_id: null,
6334 parent_agent_id: null,
6335 message: { content: "steer the second turn" },
6336 },
6337 {
6338 type: "assistant",
6339 uuid: "assistant-steer",
6340 session_id: sessionId,
6341 parent_tool_use_id: null,
6342 parent_agent_id: null,
6343 message: { content: [] },
6344 },
6345 ];
6346 return sessionId.endsWith("0020")
6347 ? history.slice(0, 4).map((message) => ({ ...message, uuid: `fork-${message.uuid}` }))
6348 : legacyHistory
6349 ? history.slice(0, 6)
6350 : missingBoundary
6351 ? history.filter((message) => message.uuid !== secondTurnId)
6352 : history;
6353 },
6354 });
6355 return Effect.gen(function* () {
6356 const adapter = yield* ClaudeAdapter;
62716357
6272 const firstTurn = yield* adapter.sendTurn({
6273 threadId: session.threadId,
6274 input: "first",
6275 attachments: [],
6276 });
6358 const session = yield* adapter.startSession({
6359 threadId: THREAD_ID,
6360 provider: ProviderDriverKind.make("claudeAgent"),
6361 runtimeMode: "full-access",
6362 });
62776363
6278 const firstCompletedFiber = yield* Stream.filter(
6279 adapter.streamEvents,
6280 (event) => event.type === "turn.completed",
6281 ).pipe(Stream.runHead, Effect.forkChild);
6364 const firstTurn = yield* adapter.sendTurn({
6365 threadId: session.threadId,
6366 input: "first",
6367 attachments: [],
6368 });
6369 firstTurnId = firstTurn.turnId;
62826370
6283 harness.query.emit({
6284 type: "result",
6285 subtype: "success",
6286 is_error: false,
6287 errors: [],
6288 session_id: "sdk-session-rollback",
6289 uuid: "result-first",
6290 } as unknown as SDKMessage);
6371 const firstCompletedFiber = yield* Stream.filter(
6372 adapter.streamEvents,
6373 (event) => event.type === "turn.completed",
6374 ).pipe(Stream.runHead, Effect.forkChild);
62916375
6292 const firstCompleted = yield* Fiber.join(firstCompletedFiber);
6293 assert.equal(firstCompleted._tag, "Some");
6294 if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") {
6295 assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId));
6296 }
6376 harness.query.emit({
6377 type: "result",
6378 subtype: "success",
6379 is_error: false,
6380 errors: [],
6381 session_id: "550e8400-e29b-41d4-a716-446655440010",
6382 uuid: "result-first",
6383 } as unknown as SDKMessage);
62976384
6298 const secondTurn = yield* adapter.sendTurn({
6299 threadId: session.threadId,
6300 input: "second",
6301 attachments: [],
6302 });
6385 const firstCompleted = yield* Fiber.join(firstCompletedFiber);
6386 assert.equal(firstCompleted._tag, "Some");
6387 if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") {
6388 assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId));
6389 }
63036390
6304 const secondCompletedFiber = yield* Stream.filter(
6305 adapter.streamEvents,
6306 (event) => event.type === "turn.completed",
6307 ).pipe(Stream.runHead, Effect.forkChild);
6391 const secondTurn = yield* adapter.sendTurn({
6392 threadId: session.threadId,
6393 input: "second",
6394 attachments: [],
6395 });
6396 secondTurnId = secondTurn.turnId;
6397 const steer = yield* adapter.sendTurn({
6398 threadId: session.threadId,
6399 input: "steer the second turn",
6400 attachments: [],
6401 });
6402 assert.equal(steer.turnId, secondTurn.turnId);
63086403
6309 harness.query.emit({
6310 type: "result",
6311 subtype: "success",
6312 is_error: false,
6313 errors: [],
6314 session_id: "sdk-session-rollback",
6315 uuid: "result-second",
6316 } as unknown as SDKMessage);
6404 const secondCompletedFiber = yield* Stream.filter(
6405 adapter.streamEvents,
6406 (event) => event.type === "turn.completed",
6407 ).pipe(Stream.runHead, Effect.forkChild);
63176408
6318 const secondCompleted = yield* Fiber.join(secondCompletedFiber);
6319 assert.equal(secondCompleted._tag, "Some");
6320 if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") {
6321 assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId));
6322 }
6409 harness.query.emit({
6410 type: "result",
6411 subtype: "success",
6412 is_error: false,
6413 errors: [],
6414 session_id: "550e8400-e29b-41d4-a716-446655440010",
6415 uuid: "result-second",
6416 } as unknown as SDKMessage);
63236417
6324 const threadBeforeRollback = yield* adapter.readThread(session.threadId);
6325 assert.equal(threadBeforeRollback.turns.length, 2);
6418 const secondCompleted = yield* Fiber.join(secondCompletedFiber);
6419 assert.equal(secondCompleted._tag, "Some");
6420 if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") {
6421 assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId));
6422 }
63266423
6327 const rolledBack = yield* adapter.rollbackThread(session.threadId, 1);
6328 assert.equal(rolledBack.turns.length, 1);
6329 assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId);
6424 const threadBeforeRollback = yield* adapter.readThread(session.threadId);
6425 assert.equal(threadBeforeRollback.turns.length, 2);
6426 const cursor = (yield* adapter.listSessions())[0]?.resumeCursor;
6427 yield* adapter.stopSession(session.threadId);
6428 legacyHistory = true;
6429 yield* adapter.startSession({
6430 threadId: session.threadId,
6431 runtimeMode: "full-access",
6432 resumeCursor: {
6433 threadId: session.threadId,
6434 resume: "550e8400-e29b-41d4-a716-446655440010",
6435 turnCount: 1,
6436 },
6437 });
6438 const legacyOptions = harness.getLastCreateQueryInput();
6439 const ambiguousLegacy = yield* adapter.rollbackThread(session.threadId, 1).pipe(Effect.flip);
6440 assert.match(ambiguousLegacy.message, /exact Claude turn boundary is unavailable/);
6441 assert.equal(forkCalls.length, 0);
6442 assert.equal(harness.getLastCreateQueryInput(), legacyOptions);
6443 assert.equal((yield* adapter.listSessions()).length, 1);
6444 yield* adapter.stopSession(session.threadId);
6445 legacyHistory = false;
6446 yield* adapter.startSession({
6447 threadId: session.threadId,
6448 runtimeMode: "full-access",
6449 resumeCursor: cursor,
6450 });
6451 missingBoundary = true;
6452 const unavailable = yield* adapter.rollbackThread(session.threadId, 1).pipe(Effect.flip);
6453 assert.match(unavailable.message, /exact Claude turn boundary is unavailable/);
6454 assert.equal(forkCalls.length, 0);
6455 missingBoundary = false;
6456
6457 const recoveredQuery = harness.queries.at(-1)!;
6458 assert.equal(recoveredQuery.closeCalls, 0);
6459 yield* adapter.rollbackThread(session.threadId, 1);
6460 assert.equal(recoveredQuery.closeCalls, 1);
6461 const forkOptions = harness.getLastCreateQueryInput()?.options;
6462 assert.deepEqual(forkCalls, [
6463 ["550e8400-e29b-41d4-a716-446655440010", { upToMessageId: "assistant-1-final" }],
6464 ]);
6465 assert.equal(forkOptions?.resume, "550e8400-e29b-41d4-a716-446655440020");
6466 assert.equal(forkOptions?.resumeSessionAt, undefined);
6467 assert.equal(forkOptions?.forkSession, undefined);
6468 assert.deepEqual((yield* adapter.listSessions())[0]?.resumeCursor, {
6469 threadId: session.threadId,
6470 resume: "550e8400-e29b-41d4-a716-446655440020",
6471 turnCount: 1,
6472 turnStartMessageIds: [`fork-${firstTurnId}`],
6473 });
63306474
6331 const threadAfterRollback = yield* adapter.readThread(session.threadId);
6332 assert.equal(threadAfterRollback.turns.length, 1);
6333 assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId);
6334 }).pipe(
6335 Effect.provideService(Random.Random, makeDeterministicRandomService()),
6336 Effect.provide(harness.layer),
6337 );
6338 },
6339 );
6475 yield* adapter.rollbackThread(session.threadId, 2);
6476 const resetOptions = harness.getLastCreateQueryInput()?.options;
6477 assert.equal(resetOptions?.resume, undefined);
6478 assert.equal(resetOptions?.resumeSessionAt, undefined);
6479 assert.equal(resetOptions?.forkSession, undefined);
6480 assert.ok(resetOptions?.sessionId);
6481 }).pipe(
6482 Effect.provideService(Random.Random, makeDeterministicRandomService()),
6483 Effect.provide(harness.layer),
6484 );
6485 });
63406486
63416487 it.effect("updates model on sendTurn when model override is provided", () => {
63426488 const harness = makeHarness();
apps/server/src/provider/Layers/ClaudeAdapter.ts+237-6
......@@ -9,6 +9,8 @@
99import {
1010 type CanUseTool,
1111 query,
12 getSessionMessages,
13 forkSession,
1214 type Options as ClaudeQueryOptions,
1315 type PermissionMode,
1416 type PermissionResult,
......@@ -78,6 +80,7 @@ import * as Queue from "effect/Queue";
7880import * as Ref from "effect/Ref";
7981import * as Schema from "effect/Schema";
8082import * as Stream from "effect/Stream";
83import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8184
8285import { resolveAttachmentPath } from "../../attachmentStore.ts";
8386import { ServerConfig } from "../../config.ts";
......@@ -108,9 +111,26 @@ import {
108111 type ProviderAdapterError,
109112} from "../Errors.ts";
110113import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
114import { spawnAndCollect } from "../providerSnapshot.ts";
111115import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
112116const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
113117const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
118const encodeHistoryArgs = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
119const decodeHistoryFork = Schema.decodeSync(
120 Schema.fromJsonString(Schema.Struct({ sessionId: Schema.String })),
121);
122const decodeSessionMessages = Schema.decodeSync(
123 Schema.fromJsonString(
124 Schema.Array(
125 Schema.Struct({
126 type: Schema.Literals(["user", "assistant", "system"]),
127 uuid: Schema.String,
128 parent_tool_use_id: Schema.NullOr(Schema.String),
129 message: Schema.Unknown,
130 }),
131 ),
132 ),
133);
114134
115135const PROVIDER = ProviderDriverKind.make("claudeAgent");
116136type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;
......@@ -139,6 +159,7 @@ interface ClaudeResumeState {
139159 readonly resume?: string;
140160 readonly resumeSessionAt?: string;
141161 readonly turnCount?: number;
162 readonly turnStartMessageIds?: ReadonlyArray<string | null>;
142163}
143164
144165interface ClaudeTurnState {
......@@ -290,6 +311,8 @@ function rememberPendingTaskModel(
290311
291312interface ClaudeSessionContext {
292313 session: ProviderSession;
314 startInput: Parameters<ClaudeAdapterShape["startSession"]>[0];
315 readonly turnStartMessageIds: Array<string | null>;
293316 readonly promptQueue: Queue.Queue<PromptQueueItem>;
294317 readonly query: ClaudeQueryRuntime;
295318 streamFiber: Fiber.Fiber<void, Error> | undefined;
......@@ -350,6 +373,8 @@ export interface ClaudeAdapterLiveOptions {
350373 readonly prompt: AsyncIterable<SDKUserMessage>;
351374 readonly options: ClaudeQueryOptions;
352375 }) => ClaudeQueryRuntime;
376 readonly getSessionMessages?: typeof getSessionMessages;
377 readonly forkSession?: typeof forkSession;
353378 readonly nativeEventLogPath?: string;
354379 readonly nativeEventLogger?: EventNdjsonLogger;
355380 readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;
......@@ -854,6 +879,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
854879 sessionId?: unknown;
855880 resumeSessionAt?: unknown;
856881 turnCount?: unknown;
882 turnStartMessageIds?: unknown;
857883 };
858884
859885 const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
......@@ -871,11 +897,17 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
871897 const resumeSessionAt =
872898 typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined;
873899 const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined;
900 const turnStartMessageIds =
901 Array.isArray(cursor.turnStartMessageIds) &&
902 cursor.turnStartMessageIds.every((id: unknown) => id === null || typeof id === "string")
903 ? (cursor.turnStartMessageIds as Array<string | null>)
904 : undefined;
874905
875906 return {
876907 ...(threadId ? { threadId } : {}),
877908 ...(resume ? { resume } : {}),
878909 ...(resumeSessionAt ? { resumeSessionAt } : {}),
910 ...(turnStartMessageIds ? { turnStartMessageIds } : {}),
879911 ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0
880912 ? { turnCount: turnCountValue }
881913 : {}),
......@@ -1927,6 +1959,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
19271959 const path = yield* Path.Path;
19281960 const serverConfig = yield* ServerConfig;
19291961 const crypto = yield* Crypto.Crypto;
1962 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
19301963 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe(
19311964 Effect.provideService(Path.Path, path),
19321965 );
......@@ -2043,7 +2076,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
20432076 threadId,
20442077 ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}),
20452078 ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}),
2046 turnCount: context.turns.length,
2079 turnCount: context.turnStartMessageIds.length,
2080 turnStartMessageIds: [...context.turnStartMessageIds],
20472081 };
20482082
20492083 context.session = {
......@@ -3155,6 +3189,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
31553189 if (!context.turnState) {
31563190 const turnId = TurnId.make(yield* randomUUIDv4);
31573191 const startedAt = yield* nowIso;
3192 context.turnStartMessageIds.push(message.uuid);
31583193 context.turnState = {
31593194 turnId,
31603195 startedAt,
......@@ -3177,6 +3212,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
31773212 activeTurnId: turnId,
31783213 updatedAt: startedAt,
31793214 };
3215 yield* updateResumeCursor(context);
31803216 const turnStartedStamp = yield* makeEventStamp();
31813217 yield* offerRuntimeEvent({
31823218 type: "turn.started",
......@@ -4779,6 +4815,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
47794815 ...(sessionId ? { resume: sessionId } : {}),
47804816 ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}),
47814817 turnCount: resumeState?.turnCount ?? 0,
4818 ...(resumeState?.turnStartMessageIds
4819 ? { turnStartMessageIds: resumeState.turnStartMessageIds }
4820 : {}),
47824821 },
47834822 createdAt: startedAt,
47844823 updatedAt: startedAt,
......@@ -4786,6 +4825,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
47864825
47874826 const context: ClaudeSessionContext = {
47884827 session,
4828 startInput: input,
4829 turnStartMessageIds: resumeState?.turnStartMessageIds
4830 ? [...resumeState.turnStartMessageIds]
4831 : Array.from({ length: resumeState?.turnCount ?? 0 }, () => null),
47894832 promptQueue,
47904833 query: queryRuntime,
47914834 streamFiber: undefined,
......@@ -4899,6 +4942,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
48994942 const modelSelection = selectedModel
49004943 ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) }
49014944 : undefined;
4945 if (modelSelection) {
4946 context.startInput = { ...context.startInput, modelSelection };
4947 }
49024948
49034949 // A sendTurn while a real turn is running is a steer: the message is
49044950 // queued into the live SDK agent loop and the work continues as the same
......@@ -5015,9 +5061,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
50155061 ),
50165062 });
50175063
5064 if (steeringTurnState === null) context.turnStartMessageIds.push(turnId);
5065 yield* updateResumeCursor(context);
50185066 yield* Queue.offer(context.promptQueue, {
50195067 type: "message",
5020 message,
5068 message:
5069 steeringTurnState === null
5070 ? { ...message, uuid: turnId as NonNullable<SDKUserMessage["uuid"]> }
5071 : message,
50215072 }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)));
50225073
50235074 return {
......@@ -5049,10 +5100,190 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
50495100 const rollbackThread: ClaudeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(
50505101 function* (threadId, numTurns) {
50515102 const context = yield* requireSession(threadId);
5052 const nextLength = Math.max(0, context.turns.length - numTurns);
5053 context.turns.splice(nextLength);
5054 yield* updateResumeCursor(context);
5055 return yield* snapshotThread(context);
5103 if (!Number.isInteger(numTurns) || numTurns < 1) {
5104 return yield* new ProviderAdapterValidationError({
5105 provider: PROVIDER,
5106 operation: "rollbackThread",
5107 issue: "numTurns must be an integer >= 1.",
5108 });
5109 }
5110 if (
5111 context.turnStartMessageIds.length > 0 &&
5112 context.turnStartMessageIds.every((id) => id !== null) &&
5113 numTurns >= context.turnStartMessageIds.length
5114 ) {
5115 yield* stopSessionInternal(context, { emitExitEvent: false });
5116 yield* startSession({
5117 ...context.startInput,
5118 runtimeMode: context.session.runtimeMode,
5119 resumeCursor: undefined,
5120 });
5121 return yield* snapshotThread(yield* requireSession(threadId));
5122 }
5123 const sessionId = context.resumeSessionId;
5124 if (!sessionId) {
5125 return yield* new ProviderAdapterRequestError({
5126 provider: PROVIDER,
5127 method: "thread/rollback",
5128 detail: "Claude session id is unavailable.",
5129 });
5130 }
5131 const historyWorkerPath = yield* path
5132 .fromFileUrl(
5133 new URL(
5134 import.meta.url.endsWith(".ts")
5135 ? "../../claudeHistoryWorker.ts"
5136 : "./claudeHistoryWorker.mjs",
5137 import.meta.url,
5138 ),
5139 )
5140 .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause)));
5141 const runScopedHistoryCommand = async (
5142 method: "getSessionMessages" | "forkSession",
5143 args: object,
5144 historySessionId = sessionId,
5145 ) => {
5146 // SDK history helpers read process.env. Isolate the provider's home instead
5147 // of changing the server's environment while other providers are running.
5148 const result = await Effect.runPromise(
5149 spawnAndCollect(
5150 process.execPath,
5151 ChildProcess.make(
5152 process.execPath,
5153 [historyWorkerPath, method, historySessionId, encodeHistoryArgs(args)],
5154 { env: { ...claudeEnvironment, ELECTRON_RUN_AS_NODE: "1" } },
5155 ),
5156 ).pipe(
5157 Effect.timeout("30 seconds"),
5158 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
5159 ),
5160 );
5161 if (result.code !== 0) throw new Error(result.stderr || "Claude history command failed.");
5162 return result.stdout;
5163 };
5164 const readHistory = (historySessionId: string) =>
5165 Effect.tryPromise({
5166 try: async () => {
5167 const readOptions = {
5168 ...(context.session.cwd ? { dir: context.session.cwd } : {}),
5169 includeSystemMessages: true,
5170 };
5171 if (options?.getSessionMessages)
5172 return options.getSessionMessages(historySessionId, readOptions);
5173 if (claudeEnvironment.CLAUDE_CONFIG_DIR === process.env.CLAUDE_CONFIG_DIR) {
5174 return getSessionMessages(historySessionId, readOptions);
5175 }
5176 return decodeSessionMessages(
5177 await runScopedHistoryCommand("getSessionMessages", readOptions, historySessionId),
5178 );
5179 },
5180 catch: (cause) => toRequestError(threadId, "thread/rollback", cause),
5181 });
5182 const messages = yield* readHistory(sessionId);
5183 // Tool results are user-role messages too. Only human prompts begin a turn.
5184 const turnStarts = messages.flatMap((message, index) => {
5185 if (message.type !== "user" || message.parent_tool_use_id !== null) return [];
5186 const body = message.message;
5187 if (typeof body !== "object" || body === null || !("content" in body)) return [];
5188 const content = body.content;
5189 return typeof content === "string" ||
5190 (Array.isArray(content) &&
5191 content.some(
5192 (part: unknown) =>
5193 typeof part === "object" &&
5194 part !== null &&
5195 "type" in part &&
5196 part.type !== "tool_result",
5197 ))
5198 ? [index]
5199 : [];
5200 });
5201 if (messages.length === 0) {
5202 return yield* new ProviderAdapterRequestError({
5203 provider: PROVIDER,
5204 method: "thread/rollback",
5205 detail: "Claude session history is unavailable.",
5206 });
5207 }
5208 const boundaries = [...context.turnStartMessageIds];
5209 // Older cursors did not record native boundaries. Infer them only when
5210 // their T3 turn count agrees; steers must never be treated as extra turns.
5211 if (
5212 boundaries.every((id): boolean => id === null) &&
5213 boundaries.length === turnStarts.length
5214 ) {
5215 boundaries.splice(
5216 0,
5217 boundaries.length,
5218 ...turnStarts.map((index) => messages[index]!.uuid),
5219 );
5220 }
5221 const retainedCount = Math.max(0, boundaries.length - numTurns);
5222 const firstRemovedId = boundaries[retainedCount];
5223 const firstRemoved = messages.findIndex((message) => message.uuid === firstRemovedId);
5224 if (
5225 boundaries.length === 0 ||
5226 boundaries.some((id) => id === null) ||
5227 (retainedCount > 0 && firstRemoved < 1)
5228 ) {
5229 return yield* new ProviderAdapterRequestError({
5230 provider: PROVIDER,
5231 method: "thread/rollback",
5232 detail:
5233 "The exact Claude turn boundary is unavailable, possibly after compaction or recovery of older history. Start a new thread instead.",
5234 });
5235 }
5236 const rollbackAt = retainedCount > 0 ? messages[firstRemoved - 1]?.uuid : undefined;
5237 const retainedTurns = context.turns.slice(0, Math.max(0, context.turns.length - numTurns));
5238 const fork = rollbackAt
5239 ? yield* Effect.tryPromise({
5240 try: async () => {
5241 const forkOptions = {
5242 ...(context.session.cwd ? { dir: context.session.cwd } : {}),
5243 upToMessageId: rollbackAt,
5244 };
5245 if (options?.forkSession) return options.forkSession(sessionId, forkOptions);
5246 if (claudeEnvironment.CLAUDE_CONFIG_DIR === process.env.CLAUDE_CONFIG_DIR) {
5247 return forkSession(sessionId, forkOptions);
5248 }
5249 return decodeHistoryFork(await runScopedHistoryCommand("forkSession", forkOptions));
5250 },
5251 catch: (cause) => toRequestError(threadId, "thread/rollback", cause),
5252 })
5253 : undefined;
5254 const retainedBoundaries = boundaries.slice(0, retainedCount);
5255 if (fork) {
5256 const forkMessages = yield* readHistory(fork.sessionId);
5257 if (forkMessages.length !== firstRemoved) {
5258 return yield* new ProviderAdapterRequestError({
5259 provider: PROVIDER,
5260 method: "thread/rollback",
5261 detail: "Claude fork history did not preserve the retained turn boundaries.",
5262 });
5263 }
5264 // Native forks replace every UUID while preserving transcript order.
5265 for (let index = 0; index < retainedBoundaries.length; index++) {
5266 const messageIndex = messages.findIndex(
5267 (message) => message.uuid === retainedBoundaries[index],
5268 );
5269 retainedBoundaries[index] = forkMessages[messageIndex]?.uuid ?? null;
5270 }
5271 }
5272 yield* stopSessionInternal(context, { emitExitEvent: false });
5273 yield* startSession({
5274 ...context.startInput,
5275 runtimeMode: context.session.runtimeMode,
5276 resumeCursor: fork
5277 ? {
5278 resume: fork.sessionId,
5279 turnCount: retainedCount,
5280 turnStartMessageIds: retainedBoundaries,
5281 }
5282 : undefined,
5283 });
5284 const restarted = yield* requireSession(threadId);
5285 restarted.turns.push(...retainedTurns);
5286 return yield* snapshotThread(restarted);
50565287 },
50575288 );
50585289
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+91
......@@ -18,10 +18,101 @@ import {
1818 isRecoverableThreadResumeError,
1919 makeMemoryConsolidationNotificationFilter,
2020 openCodexThread,
21 readCodexThread,
22 rollbackCodexThread,
2123 toMcpElicitationResponse,
2224} from "./CodexSessionRuntime.ts";
2325const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);
2426
27describe("Codex thread history", () => {
28 for (const numTurns of [1, 2, 3, 5]) {
29 it.effect(`reverts ${numTurns} paginated turns at the durable boundary`, () =>
30 Effect.gen(function* () {
31 let retained = ["turn-1", "turn-2", "turn-3"];
32 const client: Parameters<typeof rollbackCodexThread>[0] = {
33 request: () => Effect.die("Legacy history API must not be used for paginated threads"),
34 raw: {
35 request: (method, params) =>
36 Effect.sync(() => {
37 if (method === "thread/read") return { thread: { historyMode: "paginated" } };
38 if (method === "thread/turns/list") {
39 const { cursor } = params as { cursor: string | null };
40 const start = cursor === null ? 0 : Number(cursor);
41 const ids = retained.slice(start, start + 2);
42 return {
43 data: ids.map((id) => ({ id, items: [], status: "completed" })),
44 nextCursor: start + 2 < retained.length ? String(start + 2) : null,
45 };
46 }
47 NodeAssert.equal(method, "thread/revert");
48 const { beforeTurnId } = params as { beforeTurnId: string };
49 retained = retained.slice(0, retained.indexOf(beforeTurnId));
50 return { thread: { id: "thread-1", turns: [] } };
51 }),
52 },
53 };
54 const result = yield* rollbackCodexThread(client, "thread-1", numTurns);
55 const expected = ["turn-1", "turn-2", "turn-3"].slice(0, Math.max(0, 3 - numTurns));
56 NodeAssert.deepEqual(
57 result.turns.map((turn) => turn.id),
58 expected,
59 );
60 NodeAssert.deepEqual(
61 (yield* readCodexThread(client, "thread-1")).turns.map((turn) => turn.id),
62 expected,
63 );
64 }),
65 );
66 }
67
68 for (const cursors of [
69 ["next", "next"],
70 ["first", "second", "first"],
71 ]) {
72 it.effect(`rejects a pagination cursor cycle: ${cursors.join(", ")}`, () =>
73 Effect.gen(function* () {
74 let pageCount = 0;
75 const client: Parameters<typeof readCodexThread>[0] = {
76 request: () => Effect.die("Unexpected legacy request"),
77 raw: {
78 request: (method) =>
79 Effect.sync(() => {
80 if (method === "thread/read") return { thread: { historyMode: "paginated" } };
81 NodeAssert.ok(pageCount < cursors.length, "Repeated cursor was requested");
82 return { data: [], nextCursor: cursors[pageCount++] };
83 }),
84 },
85 };
86 const error = yield* Effect.flip(readCodexThread(client, "thread-1"));
87 NodeAssert.ok(isCodexAppServerRequestError(error));
88 NodeAssert.equal(pageCount, cursors.length);
89 }),
90 );
91 }
92
93 it.effect("keeps the count-based rollback API for older threads", () =>
94 Effect.gen(function* () {
95 const client: Parameters<typeof rollbackCodexThread>[0] = {
96 raw: { request: () => Effect.succeed({ thread: {} }) },
97 request: <M extends CodexRpc.ClientRequestMethod>(
98 method: M,
99 params: CodexRpc.ClientRequestParamsByMethod[M],
100 ) => {
101 NodeAssert.equal(method, "thread/rollback");
102 NodeAssert.deepEqual(params, { threadId: "legacy-thread", numTurns: 2 });
103 return Effect.succeed({
104 thread: { id: "legacy-thread", turns: [] },
105 } as unknown as CodexRpc.ClientRequestResponsesByMethod[M]);
106 },
107 };
108 NodeAssert.deepEqual(yield* rollbackCodexThread(client, "legacy-thread", 2), {
109 threadId: "legacy-thread",
110 turns: [],
111 });
112 }),
113 );
114});
115
25116describe("CodexSessionRuntimeIdentifierGenerationError", () => {
26117 it("retains identifier purpose and the random source failure", () => {
27118 const cause = new Error("random source unavailable");
apps/server/src/provider/Layers/CodexSessionRuntime.ts+94-10
......@@ -1192,6 +1192,97 @@ function parseThreadSnapshot(
11921192 };
11931193}
11941194
1195const CodexThreadHistoryMetadata = Schema.Struct({
1196 thread: Schema.Struct({
1197 historyMode: Schema.optionalKey(Schema.Literals(["legacy", "paginated"])),
1198 }),
1199});
1200const CodexTurnsPage = Schema.Struct({
1201 data: Schema.Array(EffectCodexSchema.V2ThreadReadResponse__Turn),
1202 nextCursor: Schema.NullOr(Schema.String),
1203});
1204const decodeCodexHistoryMetadata = Schema.decodeUnknownEffect(CodexThreadHistoryMetadata);
1205const decodeCodexTurnsPage = Schema.decodeUnknownEffect(CodexTurnsPage);
1206type CodexHistoryClient = {
1207 readonly raw: Pick<CodexClient.CodexAppServerClient["Service"]["raw"], "request">;
1208 readonly request: CodexClient.CodexAppServerClient["Service"]["request"];
1209};
1210
1211const readCodexHistoryMode = Effect.fn("readCodexHistoryMode")(function* (
1212 client: CodexHistoryClient,
1213 threadId: string,
1214) {
1215 const response = yield* client.raw.request("thread/read", { threadId, includeTurns: false });
1216 const metadata = yield* decodeCodexHistoryMetadata(response).pipe(
1217 Effect.mapError((error) =>
1218 CodexErrors.CodexAppServerRequestError.invalidPayload("thread/read", "decode-payload", error),
1219 ),
1220 );
1221 return metadata.thread.historyMode;
1222});
1223
1224export const readCodexThread = Effect.fn("readCodexThread")(function* (
1225 client: CodexHistoryClient,
1226 threadId: string,
1227): Effect.fn.Return<CodexThreadSnapshot, CodexErrors.CodexAppServerError> {
1228 if ((yield* readCodexHistoryMode(client, threadId)) !== "paginated") {
1229 return parseThreadSnapshot(
1230 yield* client.request("thread/read", { threadId, includeTurns: true }),
1231 );
1232 }
1233 const turns: Array<CodexThreadTurnSnapshot> = [];
1234 const requestedCursors = new Set<string | null>();
1235 let cursor: string | null = null;
1236 do {
1237 if (requestedCursors.has(cursor)) {
1238 return yield* CodexErrors.CodexAppServerRequestError.internalError(
1239 "Thread history pagination repeated a cursor.",
1240 undefined,
1241 { method: "thread/turns/list", operation: "decode-payload" },
1242 );
1243 }
1244 requestedCursors.add(cursor);
1245 const response: unknown = yield* client.raw.request("thread/turns/list", {
1246 threadId,
1247 cursor,
1248 limit: 100,
1249 sortDirection: "asc",
1250 itemsView: "full",
1251 });
1252 const page = yield* decodeCodexTurnsPage(response).pipe(
1253 Effect.mapError((error) =>
1254 CodexErrors.CodexAppServerRequestError.invalidPayload(
1255 "thread/turns/list",
1256 "decode-payload",
1257 error,
1258 ),
1259 ),
1260 );
1261 turns.push(...page.data.map((turn) => ({ id: TurnId.make(turn.id), items: turn.items })));
1262 cursor = page.nextCursor;
1263 } while (cursor !== null);
1264 return { threadId, turns };
1265});
1266
1267export const rollbackCodexThread = Effect.fn("rollbackCodexThread")(function* (
1268 client: CodexHistoryClient,
1269 threadId: string,
1270 numTurns: number,
1271): Effect.fn.Return<CodexThreadSnapshot, CodexErrors.CodexAppServerError> {
1272 if ((yield* readCodexHistoryMode(client, threadId)) !== "paginated") {
1273 return parseThreadSnapshot(yield* client.request("thread/rollback", { threadId, numTurns }));
1274 }
1275 // Paginated threads replace history at a turn boundary instead of supporting
1276 // the legacy count-based rollback endpoint.
1277 const snapshot = yield* readCodexThread(client, threadId);
1278 const retainedCount = Math.max(0, snapshot.turns.length - numTurns);
1279 const firstRemoved = snapshot.turns[retainedCount];
1280 if (firstRemoved) {
1281 yield* client.raw.request("thread/revert", { threadId, beforeTurnId: firstRemoved.id });
1282 }
1283 return { threadId, turns: snapshot.turns.slice(0, retainedCount) };
1284});
1285
11951286export const makeCodexSessionRuntime = (
11961287 options: CodexSessionRuntimeOptions,
11971288): Effect.Effect<
......@@ -2435,24 +2526,17 @@ export const makeCodexSessionRuntime = (
24352526 }),
24362527 readThread: Effect.gen(function* () {
24372528 const providerThreadId = yield* readProviderThreadId;
2438 const response = yield* client.request("thread/read", {
2439 threadId: providerThreadId,
2440 includeTurns: true,
2441 });
2442 return parseThreadSnapshot(response);
2529 return yield* readCodexThread(client, providerThreadId);
24432530 }),
24442531 rollbackThread: (numTurns) =>
24452532 Effect.gen(function* () {
24462533 const providerThreadId = yield* readProviderThreadId;
2447 const response = yield* client.request("thread/rollback", {
2448 threadId: providerThreadId,
2449 numTurns,
2450 });
2534 const snapshot = yield* rollbackCodexThread(client, providerThreadId, numTurns);
24512535 yield* updateSession(sessionRef, {
24522536 status: "ready",
24532537 activeTurnId: undefined,
24542538 });
2455 return parseThreadSnapshot(response);
2539 return snapshot;
24562540 }),
24572541 uploadFeedback: (reason) =>
24582542 Effect.gen(function* () {
apps/server/src/provider/Layers/CursorAdapter.test.ts+22
......@@ -162,6 +162,28 @@ const cursorAdapterTestLayer = it.layer(
162162);
163163
164164cursorAdapterTestLayer("CursorAdapterLive", (it) => {
165 it.effect("rejects rollback without discarding the provider conversation", () =>
166 Effect.gen(function* () {
167 const adapter = yield* CursorAdapter;
168 const settings = yield* ServerSettingsService;
169 const threadId = ThreadId.make("cursor-unsupported-rollback");
170 const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper());
171 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
172 yield* adapter.startSession({
173 threadId,
174 cwd: process.cwd(),
175 runtimeMode: "full-access",
176 });
177 yield* adapter.sendTurn({ threadId, input: "Remember this turn", attachments: [] });
178 const originalTurns = [...(yield* adapter.readThread(threadId)).turns];
179 assert.isFalse(adapter.capabilities.supportsConversationRollback);
180 const error = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip);
181 assert.equal(error._tag, "ProviderAdapterRequestError");
182 assert.deepStrictEqual((yield* adapter.readThread(threadId)).turns, originalTurns);
183 yield* adapter.stopSession(threadId);
184 }),
185 );
186
165187 it.effect("rejects a Cursor transport error returned as a successful assistant answer", () =>
166188 Effect.gen(function* () {
167189 const adapter = yield* CursorAdapter;
apps/server/src/provider/Layers/CursorAdapter.ts+7-5
......@@ -1187,7 +1187,7 @@ export function makeCursorAdapter(
11871187
11881188 const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) =>
11891189 Effect.gen(function* () {
1190 const ctx = yield* requireSession(threadId);
1190 yield* requireSession(threadId);
11911191 if (!Number.isInteger(numTurns) || numTurns < 1) {
11921192 return yield* new ProviderAdapterValidationError({
11931193 provider: PROVIDER,
......@@ -1195,9 +1195,11 @@ export function makeCursorAdapter(
11951195 issue: "numTurns must be an integer >= 1.",
11961196 });
11971197 }
1198 const nextLength = Math.max(0, ctx.turns.length - numTurns);
1199 ctx.turns.splice(nextLength);
1200 return { threadId, turns: ctx.turns };
1198 return yield* new ProviderAdapterRequestError({
1199 provider: PROVIDER,
1200 method: "thread/rollback",
1201 detail: "Cursor ACP sessions do not support provider-side rollback.",
1202 });
12011203 });
12021204
12031205 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>
......@@ -1235,7 +1237,7 @@ export function makeCursorAdapter(
12351237
12361238 return {
12371239 provider: PROVIDER,
1238 capabilities: { sessionModelSwitch: "in-session" },
1240 capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false },
12391241 compaction: { type: "slash-command", command: "/compress" },
12401242 startSession,
12411243 sendTurn,
apps/server/src/provider/Layers/CursorProvider.test.ts+1
......@@ -492,6 +492,7 @@ describe("buildCursorProviderSnapshot", () => {
492492 status: "warning",
493493 message: "Cursor ACP model discovery timed out after 15000ms.",
494494 models: [],
495 supportsConversationRollback: false,
495496 });
496497 });
497498
apps/server/src/provider/Layers/CursorProvider.ts+1
......@@ -55,6 +55,7 @@ const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect(
5555);
5656const CURSOR_PRESENTATION = {
5757 displayName: "Cursor",
58 supportsConversationRollback: false,
5859 badgeLabel: "Early Access",
5960 showInteractionModeToggle: true,
6061} as const;
apps/server/src/provider/Layers/GrokAdapter.test.ts+20
......@@ -212,6 +212,26 @@ it("requires a settlement to match the live Grok turn", () => {
212212});
213213
214214it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
215 it.effect("rejects rollback without discarding the provider conversation", () =>
216 Effect.gen(function* () {
217 const threadId = ThreadId.make("grok-unsupported-rollback");
218 const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper());
219 const adapter = yield* makeTestAdapter(wrapperPath);
220 yield* adapter.startSession({
221 threadId,
222 cwd: process.cwd(),
223 runtimeMode: "full-access",
224 });
225 yield* adapter.sendTurn({ threadId, input: "Remember this turn" });
226 const originalTurns = [...(yield* adapter.readThread(threadId)).turns];
227 assert.isFalse(adapter.capabilities.supportsConversationRollback);
228 const error = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip);
229 assert.equal(error._tag, "ProviderAdapterRequestError");
230 assert.deepStrictEqual((yield* adapter.readThread(threadId)).turns, originalTurns);
231 yield* adapter.stopSession(threadId);
232 }),
233 );
234
215235 it.effect("sends runtime context with the current model without changing saved prompts", () =>
216236 Effect.gen(function* () {
217237 const threadId = ThreadId.make("grok-runtime-context");
apps/server/src/provider/Layers/GrokAdapter.ts+1-1
......@@ -2134,7 +2134,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
21342134
21352135 return {
21362136 provider: PROVIDER,
2137 capabilities: { sessionModelSwitch: "in-session" },
2137 capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false },
21382138 compaction: { type: "slash-command", command: "/compact" },
21392139 startSession,
21402140 sendTurn,
apps/server/src/provider/Layers/GrokProvider.test.ts+1
......@@ -282,6 +282,7 @@ describe("buildInitialGrokProviderSnapshot", () => {
282282 expect(snapshot.version).toBeNull();
283283 expect(snapshot.message).toContain("Checking Grok");
284284 expect(snapshot.requiresNewThreadForModelChange).toBeUndefined();
285 expect(snapshot.supportsConversationRollback).toBe(false);
285286 }),
286287 );
287288});
apps/server/src/provider/Layers/GrokProvider.ts+1
......@@ -44,6 +44,7 @@ import { discoverGrokSkills } from "../Drivers/GrokSkills.ts";
4444
4545const GROK_PRESENTATION = {
4646 displayName: "Grok",
47 supportsConversationRollback: false,
4748 badgeLabel: "Early Access",
4849 showInteractionModeToggle: false,
4950} as const;
apps/server/src/provider/Layers/ProviderService.test.ts+97-1
......@@ -1654,6 +1654,9 @@ routing.layer("ProviderServiceLive routing", (it) => {
16541654 it.effect("routes provider operations and rollback conversation", () =>
16551655 Effect.gen(function* () {
16561656 const provider = yield* ProviderService.ProviderService;
1657 const modelSelection = createModelSelection(codexInstanceId, "gpt-5.6-sol", [
1658 { id: "reasoningEffort", value: "high" },
1659 ]);
16571660
16581661 const session = yield* provider.startSession(asThreadId("thread-1"), {
16591662 provider: ProviderDriverKind.make("codex"),
......@@ -1671,6 +1674,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
16711674 threadId: session.threadId,
16721675 input: "hello",
16731676 attachments: [],
1677 modelSelection,
16741678 });
16751679 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
16761680
......@@ -1708,6 +1712,21 @@ routing.layer("ProviderServiceLive routing", (it) => {
17081712 numTurns: 0,
17091713 });
17101714
1715 const rewindCursor = { threadId: "rewound-provider-thread" };
1716 routing.codex.updateSession(session.threadId, (session) => ({
1717 ...session,
1718 resumeCursor: rewindCursor,
1719 }));
1720 yield* provider.rollbackConversation({ threadId: session.threadId, numTurns: 1 });
1721 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
1722 const rewoundBinding = yield* directory.getBinding(session.threadId);
1723 assert(Option.isSome(rewoundBinding));
1724 assert.deepEqual(rewoundBinding.value.resumeCursor, rewindCursor);
1725 assert.deepEqual(
1726 (rewoundBinding.value.runtimePayload as { modelSelection?: unknown }).modelSelection,
1727 modelSelection,
1728 );
1729
17111730 yield* provider.stopSession({ threadId: session.threadId });
17121731 routing.codex.startSession.mockClear();
17131732 routing.codex.sendTurn.mockClear();
......@@ -1727,16 +1746,93 @@ routing.layer("ProviderServiceLive routing", (it) => {
17271746 cwd?: string;
17281747 resumeCursor?: unknown;
17291748 threadId?: string;
1749 modelSelection?: unknown;
17301750 };
17311751 assert.equal(startPayload.provider, "codex");
17321752 assert.equal(startPayload.cwd, fixtureCwd("project"));
1733 assert.deepEqual(startPayload.resumeCursor, session.resumeCursor);
1753 assert.deepEqual(startPayload.resumeCursor, rewindCursor);
1754 assert.deepEqual(startPayload.modelSelection, modelSelection);
17341755 assert.equal(startPayload.threadId, session.threadId);
17351756 }
17361757 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
17371758 }),
17381759 );
17391760
1761 it.effect("preserves background turn boundaries when stopping before rollback recovery", () =>
1762 Effect.gen(function* () {
1763 const provider = yield* ProviderService.ProviderService;
1764 const threadId = asThreadId("thread-background-rewind");
1765 const initial = yield* provider.startSession(threadId, {
1766 provider: CLAUDE_AGENT_DRIVER,
1767 providerInstanceId: claudeAgentInstanceId,
1768 threadId,
1769 runtimeMode: "full-access",
1770 });
1771 const cursor = {
1772 resume: "550e8400-e29b-41d4-a716-446655440010",
1773 turnCount: 2,
1774 turnStartMessageIds: ["user-prompt", "background-assistant"],
1775 };
1776 routing.claude.updateSession(threadId, (session) => ({ ...session, resumeCursor: cursor }));
1777 const completed = yield* provider.streamEvents.pipe(
1778 Stream.filter((event) => event.eventId === "evt-background-rewind"),
1779 Stream.take(1),
1780 Stream.runDrain,
1781 Effect.forkChild,
1782 );
1783 yield* Effect.yieldNow;
1784 routing.claude.emit({
1785 type: "turn.completed",
1786 eventId: asEventId("evt-background-rewind"),
1787 provider: CLAUDE_AGENT_DRIVER,
1788 createdAt: "2026-01-01T00:00:00.000Z",
1789 threadId,
1790 turnId: asTurnId("background-turn"),
1791 payload: { state: "completed" },
1792 });
1793 yield* Fiber.join(completed);
1794 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
1795 const binding = yield* directory.getBinding(threadId);
1796 assert(Option.isSome(binding));
1797 assert.deepEqual(binding.value.resumeCursor, cursor);
1798 yield* provider.stopSession({ threadId });
1799 routing.claude.startSession.mockClear();
1800 yield* provider.rollbackConversation({ threadId, numTurns: 1 });
1801 assert.deepEqual(routing.claude.startSession.mock.calls[0]?.[0].resumeCursor, cursor);
1802
1803 const replacement = yield* provider.startSession(threadId, {
1804 provider: CODEX_DRIVER,
1805 providerInstanceId: codexInstanceId,
1806 threadId,
1807 runtimeMode: "full-access",
1808 });
1809 routing.claude.listSessions.mockReturnValueOnce(
1810 Effect.succeed([{ ...initial, resumeCursor: cursor }]),
1811 );
1812 const staleCompleted = yield* provider.streamEvents.pipe(
1813 Stream.filter((event) => event.eventId === "evt-stale-background-rewind"),
1814 Stream.take(1),
1815 Stream.runDrain,
1816 Effect.forkChild,
1817 );
1818 yield* Effect.yieldNow;
1819 routing.claude.emit({
1820 type: "turn.completed",
1821 eventId: asEventId("evt-stale-background-rewind"),
1822 provider: CLAUDE_AGENT_DRIVER,
1823 createdAt: "2026-01-01T00:00:01.000Z",
1824 threadId,
1825 turnId: asTurnId("old-background-turn"),
1826 payload: { state: "completed" },
1827 });
1828 yield* Fiber.join(staleCompleted);
1829 const replacementBinding = yield* directory.getBinding(threadId);
1830 assert(Option.isSome(replacementBinding));
1831 assert.equal(replacementBinding.value.providerInstanceId, codexInstanceId);
1832 assert.deepEqual(replacementBinding.value.resumeCursor, replacement.resumeCursor);
1833 }),
1834 );
1835
17401836 it.effect("marks a successful fallback compaction as compacted", () =>
17411837 Effect.gen(function* () {
17421838 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+47
......@@ -1100,6 +1100,35 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
11001100 canonicalEvent.type === "turn.aborted"
11011101 ) {
11021102 yield* recordTurnCompletedAnalytics(source, canonicalEvent);
1103 if (source.provider === "claudeAgent") {
1104 // Background Claude turns have no sendTurn response to persist their
1105 // new native boundary. Save it before clients can checkpoint the turn.
1106 yield* Effect.gen(function* () {
1107 const adapter = yield* registry.getByInstance(source.instanceId);
1108 const session = (yield* adapter.listSessions()).find(
1109 (session) => session.threadId === canonicalEvent.threadId,
1110 );
1111 if (session?.resumeCursor !== undefined) {
1112 const binding = yield* directory.getBinding(session.threadId);
1113 if (
1114 Option.isNone(binding) ||
1115 binding.value.providerInstanceId !== source.instanceId
1116 ) {
1117 return;
1118 }
1119 yield* directory.upsert({
1120 threadId: session.threadId,
1121 provider: source.provider,
1122 providerInstanceId: source.instanceId,
1123 resumeCursor: session.resumeCursor,
1124 });
1125 }
1126 }).pipe(
1127 Effect.catch((cause) =>
1128 Effect.logWarning("failed to persist Claude turn resume state", { cause }),
1129 ),
1130 );
1131 }
11031132 } else if (canonicalEvent.type === "session.exited") {
11041133 yield* clearTurnAnalyticsSession(source.instanceId, canonicalEvent.threadId);
11051134 }
......@@ -2006,6 +2035,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
20062035 "provider.thread_id": input.threadId,
20072036 });
20082037 if (routed.isActive) {
2038 const session = (yield* routed.adapter.listSessions()).find(
2039 (session) => session.threadId === routed.threadId,
2040 );
2041 if (session) {
2042 yield* upsertSessionBinding(
2043 { ...session, providerInstanceId: routed.instanceId },
2044 input.threadId,
2045 );
2046 }
20092047 yield* routed.adapter.stopSession(routed.threadId);
20102048 }
20112049 const pendingCompaction = pendingCompactions.get(input.threadId);
......@@ -2176,6 +2214,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
21762214 "provider.rollback_turns": input.numTurns,
21772215 });
21782216 yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns);
2217 const session = (yield* routed.adapter.listSessions()).find(
2218 (session) => session.threadId === routed.threadId,
2219 );
2220 if (session) {
2221 yield* upsertSessionBinding(
2222 { ...session, providerInstanceId: routed.instanceId },
2223 input.threadId,
2224 );
2225 }
21792226 yield* analytics.record("provider.conversation.rolled_back", {
21802227 provider: routed.adapter.provider,
21812228 turns: input.numTurns,
apps/server/src/provider/providerSnapshot.ts+4
......@@ -66,6 +66,7 @@ export interface ServerProviderPresentation {
6666 readonly showInteractionModeToggle?: boolean;
6767 readonly reportsContextWindow?: boolean;
6868 readonly requiresNewThreadForModelChange?: boolean;
69 readonly supportsConversationRollback?: boolean;
6970}
7071
7172export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">;
......@@ -209,6 +210,9 @@ export function buildServerProvider(input: {
209210 : undefined;
210211 return {
211212 displayName: input.presentation.displayName,
213 ...(typeof input.presentation.supportsConversationRollback === "boolean"
214 ? { supportsConversationRollback: input.presentation.supportsConversationRollback }
215 : {}),
212216 ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),
213217 ...(typeof input.presentation.showInteractionModeToggle === "boolean"
214218 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }
apps/server/src/vcs/GitVcsDriver.test.ts+65-1
......@@ -7,7 +7,7 @@ import * as PlatformError from "effect/PlatformError";
77import { ChildProcessSpawner } from "effect/unstable/process";
88import { assert, it } from "@effect/vitest";
99
10import { GitCommandError } from "@t3tools/contracts";
10import { CheckpointRef, GitCommandError } from "@t3tools/contracts";
1111import * as ServerConfig from "../config.ts";
1212import * as GitVcsDriver from "./GitVcsDriver.ts";
1313import * as VcsProcess from "./VcsProcess.ts";
......@@ -65,6 +65,70 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({
6565 },
6666});
6767
68it.effect("restores empty checkpoints without changing paths outside the workspace", () =>
69 Effect.gen(function* () {
70 const fileSystem = yield* FileSystem.FileSystem;
71 const path = yield* Path.Path;
72 const driver = yield* GitVcsDriver.makeVcsDriverShape();
73 for (const nested of [false, true]) {
74 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-empty-checkpoint-" });
75 yield* runGit(root, ["init"]);
76 yield* runGit(root, ["config", "user.email", "test@test.com"]);
77 yield* runGit(root, ["config", "user.name", "Test"]);
78 if (nested) {
79 yield* fileSystem.writeFileString(path.join(root, "outside.txt"), "original\n");
80 yield* runGit(root, ["add", "."]);
81 }
82 yield* runGit(root, ["commit", "--allow-empty", "-m", "initial"]);
83 const cwd = nested ? path.join(root, "nested") : root;
84 yield* fileSystem.makeDirectory(cwd, { recursive: true });
85 const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/empty");
86 yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
87 if (nested) {
88 yield* fileSystem.writeFileString(path.join(root, "outside.txt"), "changed\n");
89 yield* runGit(root, ["add", "outside.txt"]);
90 }
91 for (const staged of [false, true]) {
92 const addedPath = path.join(cwd, "added.txt");
93 yield* fileSystem.writeFileString(addedPath, "new\n");
94 if (staged) yield* runGit(cwd, ["add", "added.txt"]);
95 assert.isTrue(
96 yield* driver.checkpoints.restoreCheckpoint({
97 cwd,
98 checkpointRef,
99 fallbackToHead: false,
100 }),
101 );
102 assert.isFalse(yield* fileSystem.exists(addedPath));
103 }
104 yield* fileSystem.writeFileString(
105 path.join(root, ".git", "info", "exclude"),
106 "ignored.txt\n",
107 );
108 yield* fileSystem.writeFileString(path.join(cwd, "ignored.txt"), "keep\n");
109 yield* fileSystem.makeDirectory(path.join(cwd, "untracked"));
110 yield* fileSystem.writeFileString(path.join(cwd, "untracked", "file.txt"), "remove\n");
111 assert.isTrue(
112 yield* driver.checkpoints.restoreCheckpoint({ cwd, checkpointRef, fallbackToHead: false }),
113 );
114 assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, "ignored.txt")), "keep\n");
115 assert.isFalse(yield* fileSystem.exists(path.join(cwd, "untracked")));
116 if (nested) {
117 assert.strictEqual(
118 yield* fileSystem.readFileString(path.join(root, "outside.txt")),
119 "changed\n",
120 );
121 const staged = yield* driver.execute({
122 operation: "test",
123 cwd: root,
124 args: ["diff", "--cached", "--name-only"],
125 });
126 assert.strictEqual(staged.stdout.trim(), "outside.txt");
127 }
128 }
129 }).pipe(Effect.scoped, Effect.provide(GitContractLayer)),
130);
131
68132it.effect("GitVcsDriver forwards execute env to the VCS process", () => {
69133 let observedEnv: NodeJS.ProcessEnv | undefined;
70134 let observedAppendTruncationMarker: boolean | undefined;
apps/server/src/vcs/GitVcsDriver.ts+43-3
......@@ -811,16 +811,56 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
811811 return false;
812812 }
813813
814 yield* execute({
814 const tracked = yield* execute({
815815 operation,
816816 cwd: input.cwd,
817 args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],
817 args: ["ls-files", "--cached", `--with-tree=${commitOid}`, "-z", "--", "."],
818818 });
819 yield* execute({
819 // An empty index and checkpoint have nothing for git restore's pathspec to match.
820 if (tracked.stdout.length > 0) {
821 yield* execute({
822 operation,
823 cwd: input.cwd,
824 args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],
825 });
826 }
827 // Restoring away the last tracked file can remove a nested workspace directory.
828 yield* fileSystem.makeDirectory(input.cwd, { recursive: true }).pipe(
829 Effect.mapError(
830 (cause) =>
831 new VcsProcessExitError({
832 operation,
833 command: "git restore",
834 cwd: input.cwd,
835 exitCode: 0,
836 detail: `Could not recreate the checkpoint workspace: ${cause.message}`,
837 }),
838 ),
839 );
840 const cleaned = yield* execute({
820841 operation,
821842 cwd: input.cwd,
822843 args: ["clean", "-fd", "--", "."],
844 allowNonZeroExit: true,
823845 });
846 if (cleaned.exitCode !== 0) {
847 // Git can remove every child, then fail trying to remove './' itself.
848 const emptiedWorkspace =
849 cleaned.exitCode === 1 &&
850 /^warning: failed to remove \.\/: [^\n]+$/.test(cleaned.stderr.trim()) &&
851 (yield* fileSystem.readDirectory(input.cwd).pipe(
852 Effect.map((entries) => entries.length === 0),
853 Effect.catch(() => Effect.succeed(false)),
854 ));
855 if (!emptiedWorkspace)
856 return yield* new VcsProcessExitError({
857 operation,
858 command: "git clean",
859 cwd: input.cwd,
860 exitCode: cleaned.exitCode,
861 detail: cleaned.stderr.trim() || "Could not clean the checkpoint workspace.",
862 });
863 }
824864
825865 const headExists = yield* hasHeadCommit(input.cwd);
826866 if (headExists) {
apps/server/vite.config.ts+1-1
......@@ -36,7 +36,7 @@ export default mergeConfig(
3636 },
3737 },
3838 pack: {
39 entry: ["src/bin.ts"],
39 entry: ["src/bin.ts", "src/claudeHistoryWorker.ts"],
4040 outDir: "dist",
4141 sourcemap: true,
4242 clean: true,
apps/web/src/components/ChatView.logic.test.ts+138
......@@ -1,6 +1,8 @@
11import {
22 ANTIGRAVITY_DEFAULT_MODEL,
3 CheckpointRef,
34 EnvironmentId,
5 EventId,
46 MessageId,
57 ProjectId,
68 ProviderDriverKind,
......@@ -10,6 +12,9 @@ import {
1012 TurnId,
1113} from "@t3tools/contracts";
1214import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
15import { Atom, AsyncResult } from "effect/unstable/reactivity";
16import { appAtomRegistry } from "../rpc/atomRegistry";
17import { environmentThreadDetails } from "../state/threads";
1318
1419import type { Thread, ThreadShell, TurnDiffSummary } from "../types";
1520import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
......@@ -80,6 +85,8 @@ import {
8085 shouldShowPlanFollowUpPrompt,
8186 shouldWriteThreadErrorToCurrentServerThread,
8287 toolGroupConsumesUpwardNavigation,
88 waitForRevertedMessage,
89 prepareRevertedMessageAttachments,
8390} from "./ChatView.logic";
8491
8592describe("agent browser close confirmation", () => {
......@@ -2242,3 +2249,134 @@ describe("threadShellHasStarted", () => {
22422249 expect(threadShellHasStarted(null)).toBe(false);
22432250 });
22442251});
2252
2253describe("rewind draft recovery", () => {
2254 const message = {
2255 id: MessageId.make("rewound-message"),
2256 role: "user" as const,
2257 text: "edit this question",
2258 turnId: TurnId.make("rewound-turn"),
2259 createdAt: now,
2260 updatedAt: now,
2261 streaming: false,
2262 };
2263
2264 afterEach(() => {
2265 vi.restoreAllMocks();
2266 vi.unstubAllGlobals();
2267 vi.useRealTimers();
2268 });
2269
2270 it("waits past command acceptance until the exact message disappears", async () => {
2271 const atom = Atom.make<Thread | null>(makeThread({ messages: [message] }));
2272 vi.spyOn(environmentThreadDetails, "detailAtom").mockReturnValue(atom);
2273 let accepted = false;
2274 const result = waitForRevertedMessage({ environmentId, threadId }, message.id, 0, async () => {
2275 accepted = true;
2276 });
2277 let completed = false;
2278 void result.then(() => {
2279 completed = true;
2280 });
2281 await Promise.resolve();
2282 await Promise.resolve();
2283 expect(accepted).toBe(true);
2284 expect(completed).toBe(false);
2285 appAtomRegistry.set(
2286 atom,
2287 makeThread({
2288 messages: [],
2289 latestTurn: completedTurn,
2290 checkpoints: [
2291 {
2292 turnId: completedTurn.turnId,
2293 checkpointTurnCount: 1,
2294 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/1"),
2295 status: "ready",
2296 files: [],
2297 assistantMessageId: null,
2298 completedAt: now,
2299 },
2300 ],
2301 }),
2302 );
2303 await Promise.resolve();
2304 expect(completed).toBe(false);
2305 appAtomRegistry.set(atom, makeThread({ messages: [] }));
2306 await result;
2307 });
2308
2309 it("rejects a new provider rewind failure without restoring a draft", async () => {
2310 const atom = Atom.make<Thread | null>(makeThread({ messages: [message] }));
2311 vi.spyOn(environmentThreadDetails, "detailAtom").mockReturnValue(atom);
2312 const result = waitForRevertedMessage({ environmentId, threadId }, message.id, 0, async () => {
2313 appAtomRegistry.set(
2314 atom,
2315 makeThread({
2316 messages: [message],
2317 activities: [
2318 {
2319 id: EventId.make("rewind-failed"),
2320 kind: "checkpoint.revert.failed",
2321 tone: "error",
2322 summary: "Checkpoint revert failed",
2323 payload: { detail: "Native history unavailable", turnCount: 0 },
2324 turnId: null,
2325 createdAt: now,
2326 },
2327 ],
2328 }),
2329 );
2330 });
2331 await expect(result).rejects.toThrow("Native history unavailable");
2332 });
2333
2334 it("bounds waits when a provider never finishes", async () => {
2335 vi.useFakeTimers();
2336 const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
2337 const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
2338 const atom = Atom.make<Thread | null>(makeThread({ messages: [message] }));
2339 vi.spyOn(environmentThreadDetails, "detailAtom").mockReturnValue(atom);
2340 const result = waitForRevertedMessage(
2341 { environmentId, threadId },
2342 message.id,
2343 0,
2344 async () => {},
2345 20,
2346 );
2347 const timeoutIndex = setTimeoutSpy.mock.calls.findIndex(([, delay]) => delay === 20);
2348 const rewindTimeout = setTimeoutSpy.mock.results[timeoutIndex]?.value;
2349 expect(rewindTimeout).toBeDefined();
2350 const rejection = expect(result).rejects.toThrow("Timed out waiting");
2351 await vi.advanceTimersByTimeAsync(20);
2352 await rejection;
2353 expect(clearTimeoutSpy).toHaveBeenCalledWith(rewindTimeout);
2354 });
2355
2356 it("copies attachment bytes before rewind into a fresh file", async () => {
2357 const fetchMock = vi.fn().mockResolvedValue(new Response("original bytes"));
2358 vi.stubGlobal("fetch", fetchMock);
2359 const files = await prepareRevertedMessageAttachments({
2360 message: {
2361 ...message,
2362 attachments: [
2363 {
2364 type: "file",
2365 id: "old-attachment",
2366 name: "notes.txt",
2367 mimeType: "text/plain",
2368 sizeBytes: 14,
2369 },
2370 ],
2371 },
2372 environmentId,
2373 httpBaseUrl: "https://server.test",
2374 createAssetUrl: async () =>
2375 AsyncResult.success({ relativeUrl: "/asset/signed", expiresAt: Date.now() + 60_000 }),
2376 });
2377 expect(files[0]).toBeInstanceOf(File);
2378 expect(files[0]?.name).toBe("notes.txt");
2379 expect(await files[0]?.text()).toBe("original bytes");
2380 expect(fetchMock.mock.calls[0]?.[0]).toBe("https://server.test/asset/signed");
2381 });
2382});
apps/web/src/components/ChatView.logic.ts+107
......@@ -737,6 +737,38 @@ export async function resolveFileAttachmentUrl(input: {
737737 return url;
738738}
739739
740export async function prepareRevertedMessageAttachments(input: {
741 message: ChatMessage;
742 environmentId: EnvironmentId;
743 httpBaseUrl: string;
744 createAssetUrl: Parameters<typeof resolveFileAttachmentUrl>[0]["createAssetUrl"];
745}): Promise<File[]> {
746 return Promise.all(
747 (input.message.attachments ?? []).map(async (attachment) => {
748 if (attachment.type !== "image" && attachment.type !== "file") {
749 throw new Error("This message has an attachment that cannot be restored.");
750 }
751 const result = await input.createAssetUrl({
752 environmentId: input.environmentId,
753 input: {
754 resource: {
755 _tag: "attachment",
756 attachmentId: attachment.id,
757 fileName: attachment.name,
758 mimeType: attachment.mimeType,
759 },
760 },
761 });
762 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
763 const url = resolveAssetUrl(input.httpBaseUrl, result.value.relativeUrl);
764 if (url === null) throw new Error("The environment returned an invalid attachment URL.");
765 const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
766 if (!response.ok) throw new Error(`Could not restore attachment: ${attachment.name}`);
767 return new File([await response.blob()], attachment.name, { type: attachment.mimeType });
768 }),
769 );
770}
771
740772export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
741773 if (message.role !== "user" || !message.attachments) {
742774 return;
......@@ -1105,6 +1137,81 @@ export async function waitForStartedServerThread(
11051137 });
11061138}
11071139
1140export async function waitForRevertedMessage(
1141 threadRef: ScopedThreadRef,
1142 messageId: MessageId,
1143 turnCount: number,
1144 revert: () => Promise<void>,
1145 timeoutMs = 120_000,
1146): Promise<void> {
1147 const threadAtom = environmentThreadDetails.detailAtom(threadRef);
1148 const initial = appAtomRegistry.get(threadAtom);
1149 if (!initial?.messages.some((message) => message.id === messageId)) {
1150 throw new Error("The message to rewind is no longer available.");
1151 }
1152 const previousFailures = new Set(
1153 initial.activities
1154 .filter((activity) => activity.kind === "checkpoint.revert.failed")
1155 .map((activity) => activity.id),
1156 );
1157 return new Promise<void>((resolve, reject) => {
1158 let settled = false;
1159 let accepted = false;
1160 let unsubscribe = () => {};
1161 let timeout: ReturnType<typeof globalThis.setTimeout> | undefined;
1162 const finish = (error?: unknown) => {
1163 if (settled) return;
1164 settled = true;
1165 if (timeout !== undefined) globalThis.clearTimeout(timeout);
1166 unsubscribe();
1167 if (error !== undefined) reject(error);
1168 else resolve();
1169 };
1170 const inspect = () => {
1171 const thread = appAtomRegistry.get(threadAtom);
1172 if (!thread) return;
1173 const failure = thread.activities.findLast(
1174 (activity) =>
1175 activity.kind === "checkpoint.revert.failed" && !previousFailures.has(activity.id),
1176 );
1177 if (failure) {
1178 const payload = failure.payload;
1179 finish(
1180 new Error(
1181 typeof payload === "object" &&
1182 payload !== null &&
1183 "detail" in payload &&
1184 typeof payload.detail === "string"
1185 ? payload.detail
1186 : failure.summary,
1187 ),
1188 );
1189 } else if (
1190 accepted &&
1191 !thread.messages.some((message) => message.id === messageId) &&
1192 thread.checkpoints.every((checkpoint) => checkpoint.checkpointTurnCount <= turnCount) &&
1193 (turnCount === 0
1194 ? thread.latestTurn === null
1195 : thread.checkpoints.some(
1196 (checkpoint) => checkpoint.turnId === thread.latestTurn?.turnId,
1197 ))
1198 ) {
1199 finish();
1200 }
1201 };
1202 unsubscribe = appAtomRegistry.subscribe(threadAtom, inspect);
1203 timeout = globalThis.setTimeout(() => {
1204 finish(new Error("Timed out waiting for the thread to rewind."));
1205 }, timeoutMs);
1206 Promise.resolve()
1207 .then(revert)
1208 .then(() => {
1209 accepted = true;
1210 inspect();
1211 }, finish);
1212 });
1213}
1214
11081215export interface LocalDispatchSnapshot {
11091216 startedAt: string;
11101217 preparingWorktree: boolean;
apps/web/src/components/ChatView.tsx+114-29
......@@ -228,7 +228,7 @@ import {
228228 PaperclipIcon,
229229 WifiOffIcon,
230230} from "lucide-react";
231import { cn, randomHex } from "~/lib/utils";
231import { cn, randomHex, randomUUID } from "~/lib/utils";
232232import { stackedThreadToast, toastManager } from "./ui/toast";
233233import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
234234import { type NewProjectScriptInput } from "./ProjectScriptsControl";
......@@ -410,6 +410,8 @@ import {
410410 deriveLockedProvider,
411411 readFileAsDataUrl,
412412 resolveFileAttachmentUrl,
413 prepareRevertedMessageAttachments,
414 waitForRevertedMessage,
413415 reconcileMountedTerminalThreadIds,
414416 recallCheckoutIsRepo,
415417 rememberCheckoutIsRepo,
......@@ -485,7 +487,10 @@ import {
485487 supportsServerUpdateThreadContinuation,
486488} from "../versionSkew";
487489import { useAssetUrls } from "../assets/assetUrls";
488import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory";
490import {
491 ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
492 recallableComposerPrompt,
493} from "./chat/composerPromptHistory";
489494
490495const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
491496const EMPTY_PROVIDERS: ServerProvider[] = [];
......@@ -1427,6 +1432,13 @@ export default function ChatView(props: ChatViewProps) {
14271432 [environmentId, threadId],
14281433 );
14291434 const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
1435 const currentRouteThreadKeyRef = useRef<string | null>(routeThreadKey);
1436 useLayoutEffect(() => {
1437 currentRouteThreadKeyRef.current = routeThreadKey;
1438 return () => {
1439 currentRouteThreadKeyRef.current = null;
1440 };
1441 }, [routeThreadKey]);
14301442 const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, {
14311443 reportFailure: false,
14321444 });
......@@ -1641,7 +1653,9 @@ export default function ChatView(props: ChatViewProps) {
16411653 Record<string, LocalThreadErrorEntry>
16421654 >({});
16431655 const [isConnecting, _setIsConnecting] = useState(false);
1644 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
1656 const isRevertingCheckpoint = useComposerDraftStore((store) =>
1657 store.rewindingThreadKeys.has(routeThreadKey),
1658 );
16451659 const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>(
16461660 null,
16471661 );
......@@ -5352,10 +5366,6 @@ export default function ChatView(props: ChatViewProps) {
53525366 // activeThreadRef resets transitively with the active thread.
53535367 }, [activeThread?.id]);
53545368
5355 useEffect(() => {
5356 setIsRevertingCheckpoint(false);
5357 }, [activeThread?.id]);
5358
53595369 useEffect(() => {
53605370 if (!activeThread?.id || terminalUiState.terminalOpen) return;
53615371 const frame = window.requestAnimationFrame(() => {
......@@ -6524,9 +6534,11 @@ export default function ChatView(props: ChatViewProps) {
65246534 }, [activeThreadId, composerRef]);
65256535
65266536 const onRevertToTurnCount = useCallback(
6527 async (turnCount: number) => {
6537 async (turnCount: number, messageId: MessageId) => {
65286538 const localApi = readLocalApi();
65296539 if (!localApi || !activeThread || isRevertingCheckpoint) return;
6540 const message = activeThread.messages.find((message) => message.id === messageId);
6541 if (!message || message.role !== "user") return;
65306542
65316543 if (!supportsConversationRollback) {
65326544 setThreadError(
......@@ -6548,9 +6560,9 @@ export default function ChatView(props: ChatViewProps) {
65486560 }
65496561 const confirmed = await localApi.dialogs.confirm(
65506562 [
6551 `Revert this thread to checkpoint ${turnCount}?`,
6552 "This will discard newer messages and turn diffs in this thread.",
6553 "This action cannot be undone.",
6563 "Edit from here?",
6564 "Rewind files and chat to before this message.",
6565 "Your prompt and attachments return to the composer.",
65546566 ].join("\n"),
65556567 { variant: "destructive" },
65566568 );
......@@ -6558,34 +6570,102 @@ export default function ChatView(props: ChatViewProps) {
65586570 return;
65596571 }
65606572
6561 setIsRevertingCheckpoint(true);
6573 useComposerDraftStore.setState((store) => ({
6574 rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
6575 }));
65626576 setThreadError(activeThread.id, null);
6563 const result = await revertThreadCheckpoint({
6564 environmentId,
6565 input: {
6566 threadId: activeThread.id,
6567 turnCount,
6568 },
6569 });
6570 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
6571 const error = squashAtomCommandFailure(result);
6577 try {
6578 if (composerRef.current?.hasPendingAttachments()) {
6579 throw new Error("Wait for attachments to finish preparing before rewinding.");
6580 }
6581 const connection = readPreparedConnection(environmentId);
6582 if (!connection) throw new Error("The environment is not connected.");
6583 const files = await prepareRevertedMessageAttachments({
6584 message,
6585 environmentId,
6586 httpBaseUrl: connection.httpBaseUrl,
6587 createAssetUrl: createAttachmentAssetUrl,
6588 });
6589 const store = useComposerDraftStore.getState();
6590 const draft = store.getComposerDraft(composerDraftTarget);
6591 if (
6592 (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length >
6593 PROVIDER_SEND_TURN_MAX_ATTACHMENTS
6594 ) {
6595 throw new Error(
6596 "Make room for this message's attachments in the composer before rewinding.",
6597 );
6598 }
6599 await waitForRevertedMessage(routeThreadRef, messageId, turnCount, async () => {
6600 const result = await revertThreadCheckpoint({
6601 environmentId,
6602 input: { threadId: activeThread.id, turnCount },
6603 });
6604 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
6605 });
6606 const currentPrompt = store.getComposerDraft(composerDraftTarget)?.prompt ?? "";
6607 const restoredPrompt = recallableComposerPrompt(message.text);
6608 const nextPrompt =
6609 restoredPrompt.length === 0
6610 ? currentPrompt
6611 : currentPrompt.length > 0
6612 ? `${currentPrompt}\n\n${restoredPrompt}`
6613 : restoredPrompt;
6614 store.setPrompt(composerDraftTarget, nextPrompt);
6615 const images: ComposerImageAttachment[] = [];
6616 const restoredFiles: ComposerFileAttachment[] = [];
6617 files.forEach((file, index) => {
6618 const attachment = {
6619 id: randomUUID(),
6620 name: file.name,
6621 mimeType: file.type,
6622 sizeBytes: file.size,
6623 file,
6624 };
6625 if (message.attachments?.[index]?.type === "image") {
6626 images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) });
6627 } else {
6628 restoredFiles.push({ ...attachment, type: "file" });
6629 }
6630 });
6631 store.addImages(composerDraftTarget, images, { allowDuplicates: true });
6632 store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true });
6633 if (currentRouteThreadKeyRef.current === routeThreadKey) {
6634 promptRef.current = nextPrompt;
6635 composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length });
6636 requestAnimationFrame(() => {
6637 if (currentRouteThreadKeyRef.current === routeThreadKey)
6638 composerRef.current?.focusAtEnd();
6639 });
6640 }
6641 } catch (error) {
65726642 setThreadError(
65736643 activeThread.id,
65746644 error instanceof Error ? error.message : "Failed to revert thread state.",
65756645 );
6646 } finally {
6647 useComposerDraftStore.setState((store) => {
6648 const remaining = new Set(store.rewindingThreadKeys);
6649 remaining.delete(routeThreadKey);
6650 return { rewindingThreadKeys: remaining };
6651 });
65766652 }
6577 setIsRevertingCheckpoint(false);
65786653 },
65796654 [
65806655 activeThread,
65816656 activeEnvironmentUnavailable,
65826657 activeEnvironmentUnavailableLabel,
6658 composerDraftTarget,
6659 composerRef,
6660 createAttachmentAssetUrl,
65836661 environmentId,
65846662 isConnecting,
65856663 isRevertingCheckpoint,
65866664 isSendBusy,
65876665 phase,
65886666 revertThreadCheckpoint,
6667 routeThreadKey,
6668 routeThreadRef,
65896669 setThreadError,
65906670 supportsConversationRollback,
65916671 ],
......@@ -6703,6 +6783,7 @@ export default function ChatView(props: ChatViewProps) {
67036783 !activeThread ||
67046784 isSendBusy ||
67056785 isConnecting ||
6786 isRevertingCheckpoint ||
67066787 !clientSettingsHydrated ||
67076788 threadDetailLoading ||
67086789 sendInFlightRef.current ||
......@@ -8155,8 +8236,8 @@ export default function ChatView(props: ChatViewProps) {
81558236 // reference is fully stable and never busts TimelineRowCtx identity.
81568237 const onRevertToTurnCountRef = useRef(onRevertToTurnCount);
81578238 onRevertToTurnCountRef.current = onRevertToTurnCount;
8158 const onRevertTimelineTurn = useCallback((targetTurnCount: number) => {
8159 void onRevertToTurnCountRef.current(targetTurnCount);
8239 const onRevertTimelineTurn = useCallback((targetTurnCount: number, messageId: MessageId) => {
8240 void onRevertToTurnCountRef.current(targetTurnCount, messageId);
81608241 }, []);
81618242
81628243 // Files dropped on a sidebar row land here once the dropped-on thread is
......@@ -8650,6 +8731,7 @@ export default function ChatView(props: ChatViewProps) {
86508731 {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
86518732 <div
86528733 ref={setComposerOverlayElement}
8734 inert={isRevertingCheckpoint}
86538735 data-chat-composer-overlay="true"
86548736 className={
86558737 isDraftHeroState
......@@ -8719,12 +8801,15 @@ export default function ChatView(props: ChatViewProps) {
87198801 phase={phase}
87208802 isConnecting={isConnecting}
87218803 isSendBusy={isSendBusy}
8804 isRevertingCheckpoint={isRevertingCheckpoint}
87228805 sendDisabledReason={
8723 feedbackUploading
8724 ? "Sending feedback"
8725 : threadDetailLoading
8726 ? "Messages loading"
8727 : null
8806 isRevertingCheckpoint
8807 ? "Rewinding conversation"
8808 : feedbackUploading
8809 ? "Sending feedback"
8810 : threadDetailLoading
8811 ? "Messages loading"
8812 : null
87288813 }
87298814 isPreparingWorktree={isPreparingWorktree}
87308815 bannerItems={composerBannerItems}
apps/web/src/components/chat/ChatComposer.tsx+14-3
......@@ -1157,6 +1157,7 @@ export interface ChatComposerHandle {
11571157 restoreAfterTimelineReachedEnd: () => void;
11581158 collapseForTimelineScrollKey: (key: string) => void;
11591159 addDroppedFiles: (files: File[]) => void;
1160 hasPendingAttachments: () => boolean;
11601161 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
11611162 citeAssistantText: (
11621163 citation: AssistantCitation,
......@@ -1235,6 +1236,7 @@ export interface ChatComposerProps {
12351236 phase: SessionPhase;
12361237 isConnecting: boolean;
12371238 isSendBusy: boolean;
1239 isRevertingCheckpoint?: boolean;
12381240 sendDisabledReason: string | null;
12391241 isPreparingWorktree: boolean;
12401242 bannerItems: readonly ComposerBannerStackItem[];
......@@ -1383,6 +1385,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13831385 phase,
13841386 isConnecting,
13851387 isSendBusy,
1388 isRevertingCheckpoint = false,
13861389 sendDisabledReason: externalSendDisabledReason,
13871390 isPreparingWorktree,
13881391 environmentUnavailable,
......@@ -1970,6 +1973,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19701973 * the next draft.
19711974 */
19721975 const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());
1976 const isRevertingCheckpointRef = useRef(isRevertingCheckpoint);
1977 isRevertingCheckpointRef.current = isRevertingCheckpoint;
19731978
19741979 // ------------------------------------------------------------------
19751980 // Derived: composer send state
......@@ -3293,7 +3298,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
32933298 // A thread switch during the verify await would mix the new thread's
32943299 // prompt with this invocation's captured target. Nothing was taken yet,
32953300 // so abort and leave the entry restorable where the user now is.
3296 if (composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current) {
3301 if (
3302 isRevertingCheckpointRef.current ||
3303 composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current
3304 ) {
32973305 return;
32983306 }
32993307
......@@ -4270,7 +4278,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42704278 // even when the composer is in a state that can't stash.
42714279 event.preventDefault();
42724280 event.stopPropagation();
4273 if (isCommandPaletteOpen()) {
4281 if (isCommandPaletteOpen() || isRevertingCheckpoint) {
42744282 return;
42754283 }
42764284 if (pendingUserInputs.length > 0 && !isComposerApprovalState) {
......@@ -4292,6 +4300,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42924300 pendingUserInputs.length,
42934301 projectSelectionRequired,
42944302 stashCurrentPrompt,
4303 isRevertingCheckpoint,
42954304 terminalOpen,
42964305 ]);
42974306
......@@ -4299,7 +4308,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42994308 // Callbacks: attachments
43004309 // ------------------------------------------------------------------
43014310 const addComposerAttachments = async (files: File[]) => {
4302 if (!activeThreadId || files.length === 0) return;
4311 if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return;
43034312 if (
43044313 pendingUserInputs.length > 0 &&
43054314 (!supportsQuestionAttachments ||
......@@ -4714,6 +4723,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
47144723 void addComposerAttachments(files);
47154724 focusComposer();
47164725 },
4726 hasPendingAttachments: () =>
4727 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0,
47174728 insertTextAtEnd: insertComposerTextAtEnd,
47184729 citeAssistantText: (citation, sourceAnchor) =>
47194730 insertComposerText(
apps/web/src/components/chat/MessagesTimeline.tsx+13-7
......@@ -221,7 +221,7 @@ interface TimelineRowSharedState {
221221 workspaceRoot: string | undefined;
222222 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
223223 activeThreadEnvironmentId: EnvironmentId;
224 onRevertToTurnCount: (targetTurnCount: number) => void;
224 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
225225 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
226226 onImageExpand: (preview: ExpandedImagePreview) => void;
227227 onFileOpen: (attachment: ChatFileAttachment) => void;
......@@ -339,7 +339,7 @@ interface MessagesTimelineProps {
339339 displayThreadKey?: string;
340340 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
341341 supportsConversationRollback: boolean;
342 onRevertToTurnCount: (targetTurnCount: number) => void;
342 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
343343 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
344344 isRevertingCheckpoint: boolean;
345345 onImageExpand: (preview: ExpandedImagePreview) => void;
......@@ -1580,7 +1580,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
15801580 </Tooltip>
15811581 <div className="flex items-center gap-0.5">
15821582 {typeof revertTurnCount === "number" && (
1583 <RevertUserMessageButton turnCount={revertTurnCount} />
1583 <RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} />
15841584 )}
15851585 {displayedUserMessage.copyText && (
15861586 <MessageCopyButton text={displayedUserMessage.copyText} variant="ghost" />
......@@ -1592,7 +1592,13 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
15921592 );
15931593}
15941594
1595function RevertUserMessageButton({ turnCount }: { turnCount: number }) {
1595function RevertUserMessageButton({
1596 turnCount,
1597 messageId,
1598}: {
1599 turnCount: number;
1600 messageId: MessageId;
1601}) {
15961602 const ctx = use(TimelineRowCtx);
15971603 const activity = use(TimelineRowActivityCtx);
15981604
......@@ -1605,14 +1611,14 @@ function RevertUserMessageButton({ turnCount }: { turnCount: number }) {
16051611 size="xs"
16061612 variant="ghost"
16071613 disabled={activity.isRevertingCheckpoint || activity.isWorking}
1608 onClick={() => ctx.onRevertToTurnCount(turnCount)}
1609 aria-label="Revert to this message"
1614 onClick={() => ctx.onRevertToTurnCount(turnCount, messageId)}
1615 aria-label="Edit from here"
16101616 />
16111617 }
16121618 >
16131619 <Undo2Icon className="size-3" />
16141620 </TooltipTrigger>
1615 <TooltipPopup side="top">Revert to this message</TooltipPopup>
1621 <TooltipPopup side="top">Edit from here</TooltipPopup>
16161622 </Tooltip>
16171623 );
16181624}
apps/web/src/composerDraftStore.test.ts+59
......@@ -145,6 +145,7 @@ function makeTerminalContext(input: {
145145
146146function resetComposerDraftStore() {
147147 useComposerDraftStore.setState({
148 rewindingThreadKeys: new Set(),
148149 draftsByThreadKey: {},
149150 draftThreadsByThreadKey: {},
150151 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
......@@ -275,6 +276,28 @@ describe("composerDraftStore addImages", () => {
275276 expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");
276277 });
277278
279 it("restores images with matching metadata without replacing unsent bytes", () => {
280 const store = useComposerDraftStore.getState();
281 const unsent = makeImage({ id: "unsent", previewUrl: "blob:unsent" });
282 const restored = {
283 ...unsent,
284 id: "restored",
285 previewUrl: "blob:restored",
286 file: new File([new Uint8Array(unsent.sizeBytes).fill(2)], unsent.name, {
287 type: unsent.mimeType,
288 lastModified: unsent.file.lastModified,
289 }),
290 };
291 store.addImages(threadRef, [unsent]);
292 store.addImages(threadRef, [restored, restored], { allowDuplicates: true });
293
294 const images = store.getComposerDraft(threadRef)?.images;
295 expect(images?.map((image) => image.id)).toEqual(["unsent", "restored"]);
296 expect(images?.[0]?.file).toBe(unsent.file);
297 expect(images?.[1]?.file).toBe(restored.file);
298 expect(revokeSpy).not.toHaveBeenCalled();
299 });
300
278301 it("deduplicates against existing images across calls by file signature", () => {
279302 const first = makeImage({
280303 id: "img-a",
......@@ -388,6 +411,16 @@ describe("composerDraftStore unsent draft marker", () => {
388411 useComposerDraftStore.getState().clearComposerContent(threadRef);
389412 expect(hasDraft()).toBe(false);
390413 });
414
415 it("does not persist active rewind locks", () => {
416 const threadKey = threadKeyFor(threadId, TEST_ENVIRONMENT_ID);
417 useComposerDraftStore.setState({ rewindingThreadKeys: new Set([threadKey]) });
418
419 expect(useComposerDraftStore.getState().rewindingThreadKeys.has(threadKey)).toBe(true);
420 expect(partializeComposerDraftStoreState(useComposerDraftStore.getState())).not.toHaveProperty(
421 "rewindingThreadKeys",
422 );
423 });
391424});
392425
393426describe("composerDraftStore file attachments", () => {
......@@ -664,6 +697,23 @@ describe("composerDraftStore file attachments", () => {
664697 ]);
665698 });
666699
700 it("restores files with matching metadata without replacing unsent bytes", () => {
701 const store = useComposerDraftStore.getState();
702 const unsent = makeFile("unsent");
703 const restored = {
704 ...unsent,
705 id: "restored",
706 file: new File(["edited"], unsent.name, { type: unsent.mimeType }),
707 };
708 store.addFiles(threadRef, [unsent]);
709 store.addFiles(threadRef, [restored, restored], { allowDuplicates: true });
710
711 const files = store.getComposerDraft(threadRef)?.files;
712 expect(files?.map((file) => file.id)).toEqual(["unsent", "restored"]);
713 expect(files?.[0]?.file).toBe(unsent.file);
714 expect(files?.[1]?.file).toBe(restored.file);
715 });
716
667717 it("keeps same-name videos with different MIME types", () => {
668718 const store = useComposerDraftStore.getState();
669719 const mp4 = new File(["report"], "clip", { type: "video/mp4" });
......@@ -1136,10 +1186,19 @@ describe("composerDraftStore project draft thread mapping", () => {
11361186 store.addImage(localDraftId, makeImage({ id: "img-local", previewUrl: "blob:local-draft" }));
11371187 store.setPrompt(localThreadRef, "local thread draft");
11381188 store.setPrompt(remoteThreadRef, "remote thread draft");
1189 useComposerDraftStore.setState({
1190 rewindingThreadKeys: new Set([
1191 threadKeyFor(threadId, TEST_ENVIRONMENT_ID),
1192 threadKeyFor(otherThreadId, OTHER_TEST_ENVIRONMENT_ID),
1193 ]),
1194 });
11391195
11401196 clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID);
11411197
11421198 const next = useComposerDraftStore.getState();
1199 expect([...next.rewindingThreadKeys]).toEqual([
1200 threadKeyFor(otherThreadId, OTHER_TEST_ENVIRONMENT_ID),
1201 ]);
11431202 expect(next.getDraftThreadByProjectRef(projectRef)).toBeNull();
11441203 expect(next.getDraftThreadByProjectRef(remoteProjectRef)).not.toBeNull();
11451204 expect(next.getComposerDraft(localDraftId)).toBeNull();
apps/web/src/composerDraftStore.ts+32-13
......@@ -475,6 +475,7 @@ interface ComposerDraftStoreState {
475475 draftThreadsByThreadKey: Record<string, DraftThreadState>;
476476 logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>;
477477 backgroundSubmissionThreadKeys: Record<string, true>;
478 rewindingThreadKeys: ReadonlySet<string>;
478479 stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
479480 stickyActiveProvider: ProviderInstanceId | null;
480481 /** Returns the editable composer content for a draft session or server thread. */
......@@ -602,9 +603,17 @@ interface ComposerDraftStoreState {
602603 interactionMode: ProviderInteractionMode | null | undefined,
603604 ) => void;
604605 addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => boolean;
605 addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void;
606 addImages: (
607 threadRef: ComposerThreadTarget,
608 images: ComposerImageAttachment[],
609 options?: { allowDuplicates?: boolean },
610 ) => void;
606611 removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void;
607 addFiles: (threadRef: ComposerThreadTarget, files: ComposerFileAttachment[]) => void;
612 addFiles: (
613 threadRef: ComposerThreadTarget,
614 files: ComposerFileAttachment[],
615 options?: { allowDuplicates?: boolean },
616 ) => void;
608617 removeFile: (threadRef: ComposerThreadTarget, fileId: string) => void;
609618 setFileUpload: (
610619 threadRef: ComposerThreadTarget,
......@@ -2522,6 +2531,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
25222531 draftThreadsByThreadKey: {},
25232532 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
25242533 backgroundSubmissionThreadKeys: {},
2534 rewindingThreadKeys: new Set<string>(),
25252535 stickyModelSelectionByProvider: {},
25262536 stickyActiveProvider: null,
25272537 getComposerDraft: (target) => getComposerDraftState(get(), target),
......@@ -3289,7 +3299,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
32893299 (get().draftsByThreadKey[threadKey]?.images.some(({ id }) => id === image.id) ?? false)
32903300 );
32913301 },
3292 addImages: (threadRef, images) => {
3302 addImages: (threadRef, images, options) => {
32933303 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
32943304 if (threadKey.length === 0 || images.length === 0) {
32953305 return;
......@@ -3304,7 +3314,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
33043314 const dedupedIncoming: ComposerImageAttachment[] = [];
33053315 for (const image of images) {
33063316 const dedupKey = composerImageDedupKey(image);
3307 if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) {
3317 if (
3318 existingIds.has(image.id) ||
3319 (!options?.allowDuplicates && existingDedupKeys.has(dedupKey))
3320 ) {
33083321 // Avoid revoking a blob URL that's still referenced by an accepted image.
33093322 if (!acceptedPreviewUrls.has(image.previewUrl)) {
33103323 revokeObjectPreviewUrl(image.previewUrl);
......@@ -3374,7 +3387,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
33743387 return { draftsByThreadKey: nextDraftsByThreadKey };
33753388 });
33763389 },
3377 addFiles: (threadRef, files) => {
3390 addFiles: (threadRef, files, options) => {
33783391 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
33793392 if (threadKey.length === 0 || files.length === 0) {
33803393 return;
......@@ -3394,14 +3407,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
33943407 if (knownIds.has(file.id)) {
33953408 continue;
33963409 }
3397 const duplicate =
3398 knownFiles.get(key) ??
3399 existing.files.find(
3400 (candidate) =>
3401 composerFileNeedsReattach(candidate) &&
3402 !replacements.has(candidate.id) &&
3403 composerFileMatchesReattachMarker(candidate, file),
3404 );
3410 const duplicate = options?.allowDuplicates
3411 ? undefined
3412 : (knownFiles.get(key) ??
3413 existing.files.find(
3414 (candidate) =>
3415 composerFileNeedsReattach(candidate) &&
3416 !replacements.has(candidate.id) &&
3417 composerFileMatchesReattachMarker(candidate, file),
3418 ));
34053419 if (duplicate) {
34063420 // A needs-reattach marker is not a usable duplicate. Replace
34073421 // it so the upload restarts.
......@@ -4105,6 +4119,11 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo
41054119 draftThreadsByThreadKey: nextDraftThreads,
41064120 logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings,
41074121 backgroundSubmissionThreadKeys: nextBackgroundSubmissionThreadKeys,
4122 rewindingThreadKeys: new Set(
4123 [...state.rewindingThreadKeys].filter(
4124 (threadKey) => parseScopedThreadKey(threadKey)?.environmentId !== environmentId,
4125 ),
4126 ),
41084127 };
41094128 });
41104129 composerDebouncedStorage.flush();
docs/user/composer.md+11
......@@ -69,6 +69,17 @@ the text is an unedited recalled prompt, with the caret on the first visual line
6969the last visual line for `ArrowDown`, counting wrapped lines. Editing a recalled prompt turns it
7070into a normal draft.
7171
72## Edit an earlier prompt
73
74On web and desktop, choose **Edit from here** beneath a sent message to rewind
75the conversation and workspace files to before that message. The selected prompt
76and its attachments return to the composer for editing and resending. Any unsent
77draft stays above the restored prompt.
78
79This removes the selected message and later conversation from the active thread
80and provider history. It does not undo external actions or separate provider
81memory. The action is available only when the provider supports rewind.
82
7283## Prompt stash
7384
7485On web and desktop, press `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux to save
knip.jsonc+1
......@@ -21,6 +21,7 @@
2121 "entry": [
2222 "src/bin.ts!",
2323 "src/service-launcher.ts!",
24 "src/claudeHistoryWorker.ts!",
2425 "scripts/cli.ts",
2526 "src/provider/testFixtures/*.mjs",
2627 ],