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...@@ -888,7 +888,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
888);888);
889889
890it.live(890it.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",
892 () =>892 () =>
893 withHarness((harness) =>893 withHarness((harness) =>
894 Effect.gen(function* () {894 Effect.gen(function* () {
...@@ -917,7 +917,7 @@ it.live(...@@ -917,7 +917,7 @@ it.live(
917 assert.equal(917 assert.equal(
918 String(918 String(
919 (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail,919 (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail,
920 ).includes("No active provider session"),920 ).includes("no persisted provider binding exists"),
921 true,921 true,
922 );922 );
923 }),923 }),
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", () => {...@@ -1916,27 +1916,45 @@ describe("CheckpointReactor", () => {
1916 });1916 });
1917 });1917 });
19181918
1919 it("appends an error activity when revert is requested without an active session", async () => {1919 it.each([false, true])(
1920 const harness = await createHarness({ hasSession: false });1920 "reverts without an active session using project cwd fallback: %s",
1921 const createdAt = "2026-01-01T00:00:00.000Z";1921 async (useProjectCwd) => {
19221922 const harness = await createHarness({
1923 await Effect.runPromise(1923 hasSession: false,
1924 harness.engine.dispatch({1924 ...(useProjectCwd ? { threadWorktreePath: null } : {}),
1925 type: "thread.checkpoint.revert",1925 });
1926 commandId: CommandId.make("cmd-revert-no-session"),1926 const createdAt = "2026-01-01T00:00:00.000Z";
1927 threadId: ThreadId.make("thread-1"),
1928 turnCount: 1,
1929 createdAt,
1930 }),
1931 );
19321927
1933 const thread = await waitForThread(harness.readModel, (entry) =>1928 await Effect.runPromise(
1934 entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"),1929 harness.engine.dispatch({
1935 );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(1952 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
1938 true,1953 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
1939 );1954 threadId: ThreadId.make("thread-1"),
1940 expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();1955 numTurns: 1,
1941 });1956 });
1957 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n");
1958 },
1959 );
1942});1960});
apps/server/src/orchestration/Layers/CheckpointReactor.ts+12-16
...@@ -699,21 +699,17 @@ const make = Effect.gen(function* () {...@@ -699,21 +699,17 @@ const make = Effect.gen(function* () {
699 return;699 return;
700 }700 }
701701
702 const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId);702 const checkpointCwd = yield* resolveCheckpointCwd({
703 if (Option.isNone(sessionRuntime)) {703 threadId: event.payload.threadId,
704 yield* appendRevertFailureActivity({704 thread,
705 threadId: event.payload.threadId,705 projects: yield* resolveThreadProjects(thread.projectId),
706 turnCount: event.payload.turnCount,706 preferSessionRuntime: true,
707 detail: "No active provider session with workspace cwd is bound to this thread.",707 });
708 createdAt: now,708 if (!checkpointCwd) {
709 }).pipe(Effect.catch(() => Effect.void));
710 return;
711 }
712 if (!(yield* checkpointStore.isGitRepository(sessionRuntime.value.cwd))) {
713 yield* appendRevertFailureActivity({709 yield* appendRevertFailureActivity({
714 threadId: event.payload.threadId,710 threadId: event.payload.threadId,
715 turnCount: event.payload.turnCount,711 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.",
717 createdAt: now,713 createdAt: now,
718 }).pipe(Effect.catch(() => Effect.void));714 }).pipe(Effect.catch(() => Effect.void));
719 return;715 return;
...@@ -754,7 +750,7 @@ const make = Effect.gen(function* () {...@@ -754,7 +750,7 @@ const make = Effect.gen(function* () {
754 yield* providerService.assertConversationRollbackSupported(event.payload.threadId);750 yield* providerService.assertConversationRollbackSupported(event.payload.threadId);
755751
756 const restored = yield* checkpointStore.restoreCheckpoint({752 const restored = yield* checkpointStore.restoreCheckpoint({
757 cwd: sessionRuntime.value.cwd,753 cwd: checkpointCwd,
758 checkpointRef: targetCheckpointRef,754 checkpointRef: targetCheckpointRef,
759 fallbackToHead: event.payload.turnCount === 0,755 fallbackToHead: event.payload.turnCount === 0,
760 });756 });
...@@ -770,12 +766,12 @@ const make = Effect.gen(function* () {...@@ -770,12 +766,12 @@ const make = Effect.gen(function* () {
770766
771 // Refresh the workspace entry index so the @-mention file picker767 // Refresh the workspace entry index so the @-mention file picker
772 // reflects the reverted filesystem state.768 // reflects the reverted filesystem state.
773 yield* workspaceEntries.refresh(sessionRuntime.value.cwd);769 yield* workspaceEntries.refresh(checkpointCwd);
774770
775 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);771 const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount);
776 if (rolledBackTurns > 0) {772 if (rolledBackTurns > 0) {
777 yield* providerService.rollbackConversation({773 yield* providerService.rollbackConversation({
778 threadId: sessionRuntime.value.threadId,774 threadId: event.payload.threadId,
779 numTurns: rolledBackTurns,775 numTurns: rolledBackTurns,
780 });776 });
781 }777 }
...@@ -789,7 +785,7 @@ const make = Effect.gen(function* () {...@@ -789,7 +785,7 @@ const make = Effect.gen(function* () {
789785
790 if (staleCheckpointRefs.length > 0) {786 if (staleCheckpointRefs.length > 0) {
791 yield* checkpointStore.deleteCheckpointRefs({787 yield* checkpointStore.deleteCheckpointRefs({
792 cwd: sessionRuntime.value.cwd,788 cwd: checkpointCwd,
793 checkpointRefs: staleCheckpointRefs,789 checkpointRefs: staleCheckpointRefs,
794 });790 });
795 }791 }
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+217-71
...@@ -170,8 +170,11 @@ function makeHarness(config?: {...@@ -170,8 +170,11 @@ function makeHarness(config?: {
170 readonly instanceId?: ProviderInstanceId;170 readonly instanceId?: ProviderInstanceId;
171 readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"];171 readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"];
172 readonly environment?: ClaudeAdapterLiveOptions["environment"];172 readonly environment?: ClaudeAdapterLiveOptions["environment"];
173 readonly getSessionMessages?: ClaudeAdapterLiveOptions["getSessionMessages"];
174 readonly forkSession?: ClaudeAdapterLiveOptions["forkSession"];
173}) {175}) {
174 const query = new FakeClaudeQuery();176 const query = new FakeClaudeQuery();
177 const queries = [query];
175 let createInput:178 let createInput:
176 | {179 | {
177 readonly prompt: AsyncIterable<SDKUserMessage>;180 readonly prompt: AsyncIterable<SDKUserMessage>;
...@@ -184,9 +187,12 @@ function makeHarness(config?: {...@@ -184,9 +187,12 @@ function makeHarness(config?: {
184 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),187 ...(config?.instanceId ? { instanceId: config.instanceId } : {}),
185 ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}),188 ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}),
186 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),189 modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG),
190 ...(config?.getSessionMessages ? { getSessionMessages: config.getSessionMessages } : {}),
191 ...(config?.forkSession ? { forkSession: config.forkSession } : {}),
187 createQuery: (input) => {192 createQuery: (input) => {
193 if (createInput && config?.getSessionMessages) queries.push(new FakeClaudeQuery());
188 createInput = input;194 createInput = input;
189 return query;195 return queries.at(-1)!;
190 },196 },
191 ...(config?.nativeEventLogger197 ...(config?.nativeEventLogger
192 ? {198 ? {
...@@ -218,6 +224,7 @@ function makeHarness(config?: {...@@ -218,6 +224,7 @@ function makeHarness(config?: {
218 Layer.provideMerge(NodeServices.layer),224 Layer.provideMerge(NodeServices.layer),
219 ),225 ),
220 query,226 query,
227 queries,
221 getLastCreateQueryInput: () => createInput,228 getLastCreateQueryInput: () => createInput,
222 };229 };
223}230}
...@@ -6256,87 +6263,226 @@ describe("ClaudeAdapterLive", () => {...@@ -6256,87 +6263,226 @@ describe("ClaudeAdapterLive", () => {
6256 );6263 );
6257 });6264 });
62586265
6259 it.effect(6266 it.effect("rewinds a steered Claude turn after recovery and preserves fork boundaries", () => {
6260 "supports rollbackThread by trimming in-memory turns and preserving earlier turns",6267 const forkCalls: Array<Parameters<NonNullable<ClaudeAdapterLiveOptions["forkSession"]>>> = [];
6261 () => {6268 let firstTurnId = "";
6262 const harness = makeHarness();6269 let secondTurnId = "";
6263 return Effect.gen(function* () {6270 let missingBoundary = false;
6264 const adapter = yield* ClaudeAdapter;6271 let legacyHistory = false;
62656272 const harness = makeHarness({
6266 const session = yield* adapter.startSession({6273 forkSession: async (...args) => {
6267 threadId: THREAD_ID,6274 forkCalls.push(args);
6268 provider: ProviderDriverKind.make("claudeAgent"),6275 return { sessionId: "550e8400-e29b-41d4-a716-446655440020" };
6269 runtimeMode: "full-access",6276 },
6270 });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({6358 const session = yield* adapter.startSession({
6273 threadId: session.threadId,6359 threadId: THREAD_ID,
6274 input: "first",6360 provider: ProviderDriverKind.make("claudeAgent"),
6275 attachments: [],6361 runtimeMode: "full-access",
6276 });6362 });
62776363
6278 const firstCompletedFiber = yield* Stream.filter(6364 const firstTurn = yield* adapter.sendTurn({
6279 adapter.streamEvents,6365 threadId: session.threadId,
6280 (event) => event.type === "turn.completed",6366 input: "first",
6281 ).pipe(Stream.runHead, Effect.forkChild);6367 attachments: [],
6368 });
6369 firstTurnId = firstTurn.turnId;
62826370
6283 harness.query.emit({6371 const firstCompletedFiber = yield* Stream.filter(
6284 type: "result",6372 adapter.streamEvents,
6285 subtype: "success",6373 (event) => event.type === "turn.completed",
6286 is_error: false,6374 ).pipe(Stream.runHead, Effect.forkChild);
6287 errors: [],
6288 session_id: "sdk-session-rollback",
6289 uuid: "result-first",
6290 } as unknown as SDKMessage);
62916375
6292 const firstCompleted = yield* Fiber.join(firstCompletedFiber);6376 harness.query.emit({
6293 assert.equal(firstCompleted._tag, "Some");6377 type: "result",
6294 if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") {6378 subtype: "success",
6295 assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId));6379 is_error: false,
6296 }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({6385 const firstCompleted = yield* Fiber.join(firstCompletedFiber);
6299 threadId: session.threadId,6386 assert.equal(firstCompleted._tag, "Some");
6300 input: "second",6387 if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") {
6301 attachments: [],6388 assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId));
6302 });6389 }
63036390
6304 const secondCompletedFiber = yield* Stream.filter(6391 const secondTurn = yield* adapter.sendTurn({
6305 adapter.streamEvents,6392 threadId: session.threadId,
6306 (event) => event.type === "turn.completed",6393 input: "second",
6307 ).pipe(Stream.runHead, Effect.forkChild);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({6404 const secondCompletedFiber = yield* Stream.filter(
6310 type: "result",6405 adapter.streamEvents,
6311 subtype: "success",6406 (event) => event.type === "turn.completed",
6312 is_error: false,6407 ).pipe(Stream.runHead, Effect.forkChild);
6313 errors: [],
6314 session_id: "sdk-session-rollback",
6315 uuid: "result-second",
6316 } as unknown as SDKMessage);
63176408
6318 const secondCompleted = yield* Fiber.join(secondCompletedFiber);6409 harness.query.emit({
6319 assert.equal(secondCompleted._tag, "Some");6410 type: "result",
6320 if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") {6411 subtype: "success",
6321 assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId));6412 is_error: false,
6322 }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);6418 const secondCompleted = yield* Fiber.join(secondCompletedFiber);
6325 assert.equal(threadBeforeRollback.turns.length, 2);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);6424 const threadBeforeRollback = yield* adapter.readThread(session.threadId);
6328 assert.equal(rolledBack.turns.length, 1);6425 assert.equal(threadBeforeRollback.turns.length, 2);
6329 assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId);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);6475 yield* adapter.rollbackThread(session.threadId, 2);
6332 assert.equal(threadAfterRollback.turns.length, 1);6476 const resetOptions = harness.getLastCreateQueryInput()?.options;
6333 assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId);6477 assert.equal(resetOptions?.resume, undefined);
6334 }).pipe(6478 assert.equal(resetOptions?.resumeSessionAt, undefined);
6335 Effect.provideService(Random.Random, makeDeterministicRandomService()),6479 assert.equal(resetOptions?.forkSession, undefined);
6336 Effect.provide(harness.layer),6480 assert.ok(resetOptions?.sessionId);
6337 );6481 }).pipe(
6338 },6482 Effect.provideService(Random.Random, makeDeterministicRandomService()),
6339 );6483 Effect.provide(harness.layer),
6484 );
6485 });
63406486
6341 it.effect("updates model on sendTurn when model override is provided", () => {6487 it.effect("updates model on sendTurn when model override is provided", () => {
6342 const harness = makeHarness();6488 const harness = makeHarness();
apps/server/src/provider/Layers/ClaudeAdapter.ts+237-6
...@@ -9,6 +9,8 @@...@@ -9,6 +9,8 @@
9import {9import {
10 type CanUseTool,10 type CanUseTool,
11 query,11 query,
12 getSessionMessages,
13 forkSession,
12 type Options as ClaudeQueryOptions,14 type Options as ClaudeQueryOptions,
13 type PermissionMode,15 type PermissionMode,
14 type PermissionResult,16 type PermissionResult,
...@@ -78,6 +80,7 @@ import * as Queue from "effect/Queue";...@@ -78,6 +80,7 @@ import * as Queue from "effect/Queue";
78import * as Ref from "effect/Ref";80import * as Ref from "effect/Ref";
79import * as Schema from "effect/Schema";81import * as Schema from "effect/Schema";
80import * as Stream from "effect/Stream";82import * as Stream from "effect/Stream";
83import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8184
82import { resolveAttachmentPath } from "../../attachmentStore.ts";85import { resolveAttachmentPath } from "../../attachmentStore.ts";
83import { ServerConfig } from "../../config.ts";86import { ServerConfig } from "../../config.ts";
...@@ -108,9 +111,26 @@ import {...@@ -108,9 +111,26 @@ import {
108 type ProviderAdapterError,111 type ProviderAdapterError,
109} from "../Errors.ts";112} from "../Errors.ts";
110import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";113import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
114import { spawnAndCollect } from "../providerSnapshot.ts";
111import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";115import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
112const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));116const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
113const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown));117const 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
115const PROVIDER = ProviderDriverKind.make("claudeAgent");135const PROVIDER = ProviderDriverKind.make("claudeAgent");
116type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;136type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">;
...@@ -139,6 +159,7 @@ interface ClaudeResumeState {...@@ -139,6 +159,7 @@ interface ClaudeResumeState {
139 readonly resume?: string;159 readonly resume?: string;
140 readonly resumeSessionAt?: string;160 readonly resumeSessionAt?: string;
141 readonly turnCount?: number;161 readonly turnCount?: number;
162 readonly turnStartMessageIds?: ReadonlyArray<string | null>;
142}163}
143164
144interface ClaudeTurnState {165interface ClaudeTurnState {
...@@ -290,6 +311,8 @@ function rememberPendingTaskModel(...@@ -290,6 +311,8 @@ function rememberPendingTaskModel(
290311
291interface ClaudeSessionContext {312interface ClaudeSessionContext {
292 session: ProviderSession;313 session: ProviderSession;
314 startInput: Parameters<ClaudeAdapterShape["startSession"]>[0];
315 readonly turnStartMessageIds: Array<string | null>;
293 readonly promptQueue: Queue.Queue<PromptQueueItem>;316 readonly promptQueue: Queue.Queue<PromptQueueItem>;
294 readonly query: ClaudeQueryRuntime;317 readonly query: ClaudeQueryRuntime;
295 streamFiber: Fiber.Fiber<void, Error> | undefined;318 streamFiber: Fiber.Fiber<void, Error> | undefined;
...@@ -350,6 +373,8 @@ export interface ClaudeAdapterLiveOptions {...@@ -350,6 +373,8 @@ export interface ClaudeAdapterLiveOptions {
350 readonly prompt: AsyncIterable<SDKUserMessage>;373 readonly prompt: AsyncIterable<SDKUserMessage>;
351 readonly options: ClaudeQueryOptions;374 readonly options: ClaudeQueryOptions;
352 }) => ClaudeQueryRuntime;375 }) => ClaudeQueryRuntime;
376 readonly getSessionMessages?: typeof getSessionMessages;
377 readonly forkSession?: typeof forkSession;
353 readonly nativeEventLogPath?: string;378 readonly nativeEventLogPath?: string;
354 readonly nativeEventLogger?: EventNdjsonLogger;379 readonly nativeEventLogger?: EventNdjsonLogger;
355 readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;380 readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>;
...@@ -854,6 +879,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef...@@ -854,6 +879,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
854 sessionId?: unknown;879 sessionId?: unknown;
855 resumeSessionAt?: unknown;880 resumeSessionAt?: unknown;
856 turnCount?: unknown;881 turnCount?: unknown;
882 turnStartMessageIds?: unknown;
857 };883 };
858884
859 const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined;885 const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined;
...@@ -871,11 +897,17 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef...@@ -871,11 +897,17 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef
871 const resumeSessionAt =897 const resumeSessionAt =
872 typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined;898 typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined;
873 const turnCountValue = typeof cursor.turnCount === "number" ? cursor.turnCount : undefined;899 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
875 return {906 return {
876 ...(threadId ? { threadId } : {}),907 ...(threadId ? { threadId } : {}),
877 ...(resume ? { resume } : {}),908 ...(resume ? { resume } : {}),
878 ...(resumeSessionAt ? { resumeSessionAt } : {}),909 ...(resumeSessionAt ? { resumeSessionAt } : {}),
910 ...(turnStartMessageIds ? { turnStartMessageIds } : {}),
879 ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0911 ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0
880 ? { turnCount: turnCountValue }912 ? { turnCount: turnCountValue }
881 : {}),913 : {}),
...@@ -1927,6 +1959,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -1927,6 +1959,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
1927 const path = yield* Path.Path;1959 const path = yield* Path.Path;
1928 const serverConfig = yield* ServerConfig;1960 const serverConfig = yield* ServerConfig;
1929 const crypto = yield* Crypto.Crypto;1961 const crypto = yield* Crypto.Crypto;
1962 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1930 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe(1963 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe(
1931 Effect.provideService(Path.Path, path),1964 Effect.provideService(Path.Path, path),
1932 );1965 );
...@@ -2043,7 +2076,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2043,7 +2076,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2043 threadId,2076 threadId,
2044 ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}),2077 ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}),
2045 ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}),2078 ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}),
2046 turnCount: context.turns.length,2079 turnCount: context.turnStartMessageIds.length,
2080 turnStartMessageIds: [...context.turnStartMessageIds],
2047 };2081 };
20482082
2049 context.session = {2083 context.session = {
...@@ -3155,6 +3189,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3155,6 +3189,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3155 if (!context.turnState) {3189 if (!context.turnState) {
3156 const turnId = TurnId.make(yield* randomUUIDv4);3190 const turnId = TurnId.make(yield* randomUUIDv4);
3157 const startedAt = yield* nowIso;3191 const startedAt = yield* nowIso;
3192 context.turnStartMessageIds.push(message.uuid);
3158 context.turnState = {3193 context.turnState = {
3159 turnId,3194 turnId,
3160 startedAt,3195 startedAt,
...@@ -3177,6 +3212,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3177,6 +3212,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3177 activeTurnId: turnId,3212 activeTurnId: turnId,
3178 updatedAt: startedAt,3213 updatedAt: startedAt,
3179 };3214 };
3215 yield* updateResumeCursor(context);
3180 const turnStartedStamp = yield* makeEventStamp();3216 const turnStartedStamp = yield* makeEventStamp();
3181 yield* offerRuntimeEvent({3217 yield* offerRuntimeEvent({
3182 type: "turn.started",3218 type: "turn.started",
...@@ -4779,6 +4815,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4779,6 +4815,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4779 ...(sessionId ? { resume: sessionId } : {}),4815 ...(sessionId ? { resume: sessionId } : {}),
4780 ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}),4816 ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}),
4781 turnCount: resumeState?.turnCount ?? 0,4817 turnCount: resumeState?.turnCount ?? 0,
4818 ...(resumeState?.turnStartMessageIds
4819 ? { turnStartMessageIds: resumeState.turnStartMessageIds }
4820 : {}),
4782 },4821 },
4783 createdAt: startedAt,4822 createdAt: startedAt,
4784 updatedAt: startedAt,4823 updatedAt: startedAt,
...@@ -4786,6 +4825,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4786,6 +4825,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
47864825
4787 const context: ClaudeSessionContext = {4826 const context: ClaudeSessionContext = {
4788 session,4827 session,
4828 startInput: input,
4829 turnStartMessageIds: resumeState?.turnStartMessageIds
4830 ? [...resumeState.turnStartMessageIds]
4831 : Array.from({ length: resumeState?.turnCount ?? 0 }, () => null),
4789 promptQueue,4832 promptQueue,
4790 query: queryRuntime,4833 query: queryRuntime,
4791 streamFiber: undefined,4834 streamFiber: undefined,
...@@ -4899,6 +4942,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4899,6 +4942,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4899 const modelSelection = selectedModel4942 const modelSelection = selectedModel
4900 ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) }4943 ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) }
4901 : undefined;4944 : undefined;
4945 if (modelSelection) {
4946 context.startInput = { ...context.startInput, modelSelection };
4947 }
49024948
4903 // A sendTurn while a real turn is running is a steer: the message is4949 // A sendTurn while a real turn is running is a steer: the message is
4904 // queued into the live SDK agent loop and the work continues as the same4950 // 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* (...@@ -5015,9 +5061,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
5015 ),5061 ),
5016 });5062 });
50175063
5064 if (steeringTurnState === null) context.turnStartMessageIds.push(turnId);
5065 yield* updateResumeCursor(context);
5018 yield* Queue.offer(context.promptQueue, {5066 yield* Queue.offer(context.promptQueue, {
5019 type: "message",5067 type: "message",
5020 message,5068 message:
5069 steeringTurnState === null
5070 ? { ...message, uuid: turnId as NonNullable<SDKUserMessage["uuid"]> }
5071 : message,
5021 }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)));5072 }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)));
50225073
5023 return {5074 return {
...@@ -5049,10 +5100,190 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -5049,10 +5100,190 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
5049 const rollbackThread: ClaudeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(5100 const rollbackThread: ClaudeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(
5050 function* (threadId, numTurns) {5101 function* (threadId, numTurns) {
5051 const context = yield* requireSession(threadId);5102 const context = yield* requireSession(threadId);
5052 const nextLength = Math.max(0, context.turns.length - numTurns);5103 if (!Number.isInteger(numTurns) || numTurns < 1) {
5053 context.turns.splice(nextLength);5104 return yield* new ProviderAdapterValidationError({
5054 yield* updateResumeCursor(context);5105 provider: PROVIDER,
5055 return yield* snapshotThread(context);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);
5056 },5287 },
5057 );5288 );
50585289
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+91
...@@ -18,10 +18,101 @@ import {...@@ -18,10 +18,101 @@ import {
18 isRecoverableThreadResumeError,18 isRecoverableThreadResumeError,
19 makeMemoryConsolidationNotificationFilter,19 makeMemoryConsolidationNotificationFilter,
20 openCodexThread,20 openCodexThread,
21 readCodexThread,
22 rollbackCodexThread,
21 toMcpElicitationResponse,23 toMcpElicitationResponse,
22} from "./CodexSessionRuntime.ts";24} from "./CodexSessionRuntime.ts";
23const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError);25const 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
25describe("CodexSessionRuntimeIdentifierGenerationError", () => {116describe("CodexSessionRuntimeIdentifierGenerationError", () => {
26 it("retains identifier purpose and the random source failure", () => {117 it("retains identifier purpose and the random source failure", () => {
27 const cause = new Error("random source unavailable");118 const cause = new Error("random source unavailable");
apps/server/src/provider/Layers/CodexSessionRuntime.ts+94-10
...@@ -1192,6 +1192,97 @@ function parseThreadSnapshot(...@@ -1192,6 +1192,97 @@ function parseThreadSnapshot(
1192 };1192 };
1193}1193}
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
1195export const makeCodexSessionRuntime = (1286export const makeCodexSessionRuntime = (
1196 options: CodexSessionRuntimeOptions,1287 options: CodexSessionRuntimeOptions,
1197): Effect.Effect<1288): Effect.Effect<
...@@ -2435,24 +2526,17 @@ export const makeCodexSessionRuntime = (...@@ -2435,24 +2526,17 @@ export const makeCodexSessionRuntime = (
2435 }),2526 }),
2436 readThread: Effect.gen(function* () {2527 readThread: Effect.gen(function* () {
2437 const providerThreadId = yield* readProviderThreadId;2528 const providerThreadId = yield* readProviderThreadId;
2438 const response = yield* client.request("thread/read", {2529 return yield* readCodexThread(client, providerThreadId);
2439 threadId: providerThreadId,
2440 includeTurns: true,
2441 });
2442 return parseThreadSnapshot(response);
2443 }),2530 }),
2444 rollbackThread: (numTurns) =>2531 rollbackThread: (numTurns) =>
2445 Effect.gen(function* () {2532 Effect.gen(function* () {
2446 const providerThreadId = yield* readProviderThreadId;2533 const providerThreadId = yield* readProviderThreadId;
2447 const response = yield* client.request("thread/rollback", {2534 const snapshot = yield* rollbackCodexThread(client, providerThreadId, numTurns);
2448 threadId: providerThreadId,
2449 numTurns,
2450 });
2451 yield* updateSession(sessionRef, {2535 yield* updateSession(sessionRef, {
2452 status: "ready",2536 status: "ready",
2453 activeTurnId: undefined,2537 activeTurnId: undefined,
2454 });2538 });
2455 return parseThreadSnapshot(response);2539 return snapshot;
2456 }),2540 }),
2457 uploadFeedback: (reason) =>2541 uploadFeedback: (reason) =>
2458 Effect.gen(function* () {2542 Effect.gen(function* () {
apps/server/src/provider/Layers/CursorAdapter.test.ts+22
...@@ -162,6 +162,28 @@ const cursorAdapterTestLayer = it.layer(...@@ -162,6 +162,28 @@ const cursorAdapterTestLayer = it.layer(
162);162);
163163
164cursorAdapterTestLayer("CursorAdapterLive", (it) => {164cursorAdapterTestLayer("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
165 it.effect("rejects a Cursor transport error returned as a successful assistant answer", () =>187 it.effect("rejects a Cursor transport error returned as a successful assistant answer", () =>
166 Effect.gen(function* () {188 Effect.gen(function* () {
167 const adapter = yield* CursorAdapter;189 const adapter = yield* CursorAdapter;
apps/server/src/provider/Layers/CursorAdapter.ts+7-5
...@@ -1187,7 +1187,7 @@ export function makeCursorAdapter(...@@ -1187,7 +1187,7 @@ export function makeCursorAdapter(
11871187
1188 const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) =>1188 const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) =>
1189 Effect.gen(function* () {1189 Effect.gen(function* () {
1190 const ctx = yield* requireSession(threadId);1190 yield* requireSession(threadId);
1191 if (!Number.isInteger(numTurns) || numTurns < 1) {1191 if (!Number.isInteger(numTurns) || numTurns < 1) {
1192 return yield* new ProviderAdapterValidationError({1192 return yield* new ProviderAdapterValidationError({
1193 provider: PROVIDER,1193 provider: PROVIDER,
...@@ -1195,9 +1195,11 @@ export function makeCursorAdapter(...@@ -1195,9 +1195,11 @@ export function makeCursorAdapter(
1195 issue: "numTurns must be an integer >= 1.",1195 issue: "numTurns must be an integer >= 1.",
1196 });1196 });
1197 }1197 }
1198 const nextLength = Math.max(0, ctx.turns.length - numTurns);1198 return yield* new ProviderAdapterRequestError({
1199 ctx.turns.splice(nextLength);1199 provider: PROVIDER,
1200 return { threadId, turns: ctx.turns };1200 method: "thread/rollback",
1201 detail: "Cursor ACP sessions do not support provider-side rollback.",
1202 });
1201 });1203 });
12021204
1203 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>1205 const stopSession: CursorAdapterShape["stopSession"] = (threadId) =>
...@@ -1235,7 +1237,7 @@ export function makeCursorAdapter(...@@ -1235,7 +1237,7 @@ export function makeCursorAdapter(
12351237
1236 return {1238 return {
1237 provider: PROVIDER,1239 provider: PROVIDER,
1238 capabilities: { sessionModelSwitch: "in-session" },1240 capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false },
1239 compaction: { type: "slash-command", command: "/compress" },1241 compaction: { type: "slash-command", command: "/compress" },
1240 startSession,1242 startSession,
1241 sendTurn,1243 sendTurn,
apps/server/src/provider/Layers/CursorProvider.test.ts+1
...@@ -492,6 +492,7 @@ describe("buildCursorProviderSnapshot", () => {...@@ -492,6 +492,7 @@ describe("buildCursorProviderSnapshot", () => {
492 status: "warning",492 status: "warning",
493 message: "Cursor ACP model discovery timed out after 15000ms.",493 message: "Cursor ACP model discovery timed out after 15000ms.",
494 models: [],494 models: [],
495 supportsConversationRollback: false,
495 });496 });
496 });497 });
497498
apps/server/src/provider/Layers/CursorProvider.ts+1
...@@ -55,6 +55,7 @@ const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect(...@@ -55,6 +55,7 @@ const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect(
55);55);
56const CURSOR_PRESENTATION = {56const CURSOR_PRESENTATION = {
57 displayName: "Cursor",57 displayName: "Cursor",
58 supportsConversationRollback: false,
58 badgeLabel: "Early Access",59 badgeLabel: "Early Access",
59 showInteractionModeToggle: true,60 showInteractionModeToggle: true,
60} as const;61} 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", () => {...@@ -212,6 +212,26 @@ it("requires a settlement to match the live Grok turn", () => {
212});212});
213213
214it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {214it.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
215 it.effect("sends runtime context with the current model without changing saved prompts", () =>235 it.effect("sends runtime context with the current model without changing saved prompts", () =>
216 Effect.gen(function* () {236 Effect.gen(function* () {
217 const threadId = ThreadId.make("grok-runtime-context");237 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...@@ -2134,7 +2134,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
21342134
2135 return {2135 return {
2136 provider: PROVIDER,2136 provider: PROVIDER,
2137 capabilities: { sessionModelSwitch: "in-session" },2137 capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false },
2138 compaction: { type: "slash-command", command: "/compact" },2138 compaction: { type: "slash-command", command: "/compact" },
2139 startSession,2139 startSession,
2140 sendTurn,2140 sendTurn,
apps/server/src/provider/Layers/GrokProvider.test.ts+1
...@@ -282,6 +282,7 @@ describe("buildInitialGrokProviderSnapshot", () => {...@@ -282,6 +282,7 @@ describe("buildInitialGrokProviderSnapshot", () => {
282 expect(snapshot.version).toBeNull();282 expect(snapshot.version).toBeNull();
283 expect(snapshot.message).toContain("Checking Grok");283 expect(snapshot.message).toContain("Checking Grok");
284 expect(snapshot.requiresNewThreadForModelChange).toBeUndefined();284 expect(snapshot.requiresNewThreadForModelChange).toBeUndefined();
285 expect(snapshot.supportsConversationRollback).toBe(false);
285 }),286 }),
286 );287 );
287});288});
apps/server/src/provider/Layers/GrokProvider.ts+1
...@@ -44,6 +44,7 @@ import { discoverGrokSkills } from "../Drivers/GrokSkills.ts";...@@ -44,6 +44,7 @@ import { discoverGrokSkills } from "../Drivers/GrokSkills.ts";
4444
45const GROK_PRESENTATION = {45const GROK_PRESENTATION = {
46 displayName: "Grok",46 displayName: "Grok",
47 supportsConversationRollback: false,
47 badgeLabel: "Early Access",48 badgeLabel: "Early Access",
48 showInteractionModeToggle: false,49 showInteractionModeToggle: false,
49} as const;50} as const;
apps/server/src/provider/Layers/ProviderService.test.ts+97-1
...@@ -1654,6 +1654,9 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1654,6 +1654,9 @@ routing.layer("ProviderServiceLive routing", (it) => {
1654 it.effect("routes provider operations and rollback conversation", () =>1654 it.effect("routes provider operations and rollback conversation", () =>
1655 Effect.gen(function* () {1655 Effect.gen(function* () {
1656 const provider = yield* ProviderService.ProviderService;1656 const provider = yield* ProviderService.ProviderService;
1657 const modelSelection = createModelSelection(codexInstanceId, "gpt-5.6-sol", [
1658 { id: "reasoningEffort", value: "high" },
1659 ]);
16571660
1658 const session = yield* provider.startSession(asThreadId("thread-1"), {1661 const session = yield* provider.startSession(asThreadId("thread-1"), {
1659 provider: ProviderDriverKind.make("codex"),1662 provider: ProviderDriverKind.make("codex"),
...@@ -1671,6 +1674,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1671,6 +1674,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
1671 threadId: session.threadId,1674 threadId: session.threadId,
1672 input: "hello",1675 input: "hello",
1673 attachments: [],1676 attachments: [],
1677 modelSelection,
1674 });1678 });
1675 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);1679 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
16761680
...@@ -1708,6 +1712,21 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1708,6 +1712,21 @@ routing.layer("ProviderServiceLive routing", (it) => {
1708 numTurns: 0,1712 numTurns: 0,
1709 });1713 });
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
1711 yield* provider.stopSession({ threadId: session.threadId });1730 yield* provider.stopSession({ threadId: session.threadId });
1712 routing.codex.startSession.mockClear();1731 routing.codex.startSession.mockClear();
1713 routing.codex.sendTurn.mockClear();1732 routing.codex.sendTurn.mockClear();
...@@ -1727,16 +1746,93 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1727,16 +1746,93 @@ routing.layer("ProviderServiceLive routing", (it) => {
1727 cwd?: string;1746 cwd?: string;
1728 resumeCursor?: unknown;1747 resumeCursor?: unknown;
1729 threadId?: string;1748 threadId?: string;
1749 modelSelection?: unknown;
1730 };1750 };
1731 assert.equal(startPayload.provider, "codex");1751 assert.equal(startPayload.provider, "codex");
1732 assert.equal(startPayload.cwd, fixtureCwd("project"));1752 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);
1734 assert.equal(startPayload.threadId, session.threadId);1755 assert.equal(startPayload.threadId, session.threadId);
1735 }1756 }
1736 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);1757 assert.equal(routing.codex.sendTurn.mock.calls.length, 1);
1737 }),1758 }),
1738 );1759 );
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
1740 it.effect("marks a successful fallback compaction as compacted", () =>1836 it.effect("marks a successful fallback compaction as compacted", () =>
1741 Effect.gen(function* () {1837 Effect.gen(function* () {
1742 const provider = yield* ProviderService.ProviderService;1838 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+47
...@@ -1100,6 +1100,35 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1100,6 +1100,35 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1100 canonicalEvent.type === "turn.aborted"1100 canonicalEvent.type === "turn.aborted"
1101 ) {1101 ) {
1102 yield* recordTurnCompletedAnalytics(source, canonicalEvent);1102 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 }
1103 } else if (canonicalEvent.type === "session.exited") {1132 } else if (canonicalEvent.type === "session.exited") {
1104 yield* clearTurnAnalyticsSession(source.instanceId, canonicalEvent.threadId);1133 yield* clearTurnAnalyticsSession(source.instanceId, canonicalEvent.threadId);
1105 }1134 }
...@@ -2006,6 +2035,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -2006,6 +2035,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
2006 "provider.thread_id": input.threadId,2035 "provider.thread_id": input.threadId,
2007 });2036 });
2008 if (routed.isActive) {2037 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 }
2009 yield* routed.adapter.stopSession(routed.threadId);2047 yield* routed.adapter.stopSession(routed.threadId);
2010 }2048 }
2011 const pendingCompaction = pendingCompactions.get(input.threadId);2049 const pendingCompaction = pendingCompactions.get(input.threadId);
...@@ -2176,6 +2214,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -2176,6 +2214,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
2176 "provider.rollback_turns": input.numTurns,2214 "provider.rollback_turns": input.numTurns,
2177 });2215 });
2178 yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns);2216 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 }
2179 yield* analytics.record("provider.conversation.rolled_back", {2226 yield* analytics.record("provider.conversation.rolled_back", {
2180 provider: routed.adapter.provider,2227 provider: routed.adapter.provider,
2181 turns: input.numTurns,2228 turns: input.numTurns,
apps/server/src/provider/providerSnapshot.ts+4
...@@ -66,6 +66,7 @@ export interface ServerProviderPresentation {...@@ -66,6 +66,7 @@ export interface ServerProviderPresentation {
66 readonly showInteractionModeToggle?: boolean;66 readonly showInteractionModeToggle?: boolean;
67 readonly reportsContextWindow?: boolean;67 readonly reportsContextWindow?: boolean;
68 readonly requiresNewThreadForModelChange?: boolean;68 readonly requiresNewThreadForModelChange?: boolean;
69 readonly supportsConversationRollback?: boolean;
69}70}
7071
71export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">;72export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">;
...@@ -209,6 +210,9 @@ export function buildServerProvider(input: {...@@ -209,6 +210,9 @@ export function buildServerProvider(input: {
209 : undefined;210 : undefined;
210 return {211 return {
211 displayName: input.presentation.displayName,212 displayName: input.presentation.displayName,
213 ...(typeof input.presentation.supportsConversationRollback === "boolean"
214 ? { supportsConversationRollback: input.presentation.supportsConversationRollback }
215 : {}),
212 ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),216 ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}),
213 ...(typeof input.presentation.showInteractionModeToggle === "boolean"217 ...(typeof input.presentation.showInteractionModeToggle === "boolean"
214 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }218 ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle }
apps/server/src/vcs/GitVcsDriver.test.ts+65-1
...@@ -7,7 +7,7 @@ import * as PlatformError from "effect/PlatformError";...@@ -7,7 +7,7 @@ import * as PlatformError from "effect/PlatformError";
7import { ChildProcessSpawner } from "effect/unstable/process";7import { ChildProcessSpawner } from "effect/unstable/process";
8import { assert, it } from "@effect/vitest";8import { assert, it } from "@effect/vitest";
99
10import { GitCommandError } from "@t3tools/contracts";10import { CheckpointRef, GitCommandError } from "@t3tools/contracts";
11import * as ServerConfig from "../config.ts";11import * as ServerConfig from "../config.ts";
12import * as GitVcsDriver from "./GitVcsDriver.ts";12import * as GitVcsDriver from "./GitVcsDriver.ts";
13import * as VcsProcess from "./VcsProcess.ts";13import * as VcsProcess from "./VcsProcess.ts";
...@@ -65,6 +65,70 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({...@@ -65,6 +65,70 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({
65 },65 },
66});66});
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
68it.effect("GitVcsDriver forwards execute env to the VCS process", () => {132it.effect("GitVcsDriver forwards execute env to the VCS process", () => {
69 let observedEnv: NodeJS.ProcessEnv | undefined;133 let observedEnv: NodeJS.ProcessEnv | undefined;
70 let observedAppendTruncationMarker: boolean | undefined;134 let observedAppendTruncationMarker: boolean | undefined;
apps/server/src/vcs/GitVcsDriver.ts+43-3
...@@ -811,16 +811,56 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -811,16 +811,56 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
811 return false;811 return false;
812 }812 }
813813
814 yield* execute({814 const tracked = yield* execute({
815 operation,815 operation,
816 cwd: input.cwd,816 cwd: input.cwd,
817 args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],817 args: ["ls-files", "--cached", `--with-tree=${commitOid}`, "-z", "--", "."],
818 });818 });
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({
820 operation,841 operation,
821 cwd: input.cwd,842 cwd: input.cwd,
822 args: ["clean", "-fd", "--", "."],843 args: ["clean", "-fd", "--", "."],
844 allowNonZeroExit: true,
823 });845 });
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
825 const headExists = yield* hasHeadCommit(input.cwd);865 const headExists = yield* hasHeadCommit(input.cwd);
826 if (headExists) {866 if (headExists) {
apps/server/vite.config.ts+1-1
...@@ -36,7 +36,7 @@ export default mergeConfig(...@@ -36,7 +36,7 @@ export default mergeConfig(
36 },36 },
37 },37 },
38 pack: {38 pack: {
39 entry: ["src/bin.ts"],39 entry: ["src/bin.ts", "src/claudeHistoryWorker.ts"],
40 outDir: "dist",40 outDir: "dist",
41 sourcemap: true,41 sourcemap: true,
42 clean: true,42 clean: true,
apps/web/src/components/ChatView.logic.test.ts+138
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import {1import {
2 ANTIGRAVITY_DEFAULT_MODEL,2 ANTIGRAVITY_DEFAULT_MODEL,
3 CheckpointRef,
3 EnvironmentId,4 EnvironmentId,
5 EventId,
4 MessageId,6 MessageId,
5 ProjectId,7 ProjectId,
6 ProviderDriverKind,8 ProviderDriverKind,
...@@ -10,6 +12,9 @@ import {...@@ -10,6 +12,9 @@ import {
10 TurnId,12 TurnId,
11} from "@t3tools/contracts";13} from "@t3tools/contracts";
12import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";14import { 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
14import type { Thread, ThreadShell, TurnDiffSummary } from "../types";19import type { Thread, ThreadShell, TurnDiffSummary } from "../types";
15import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";20import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
...@@ -80,6 +85,8 @@ import {...@@ -80,6 +85,8 @@ import {
80 shouldShowPlanFollowUpPrompt,85 shouldShowPlanFollowUpPrompt,
81 shouldWriteThreadErrorToCurrentServerThread,86 shouldWriteThreadErrorToCurrentServerThread,
82 toolGroupConsumesUpwardNavigation,87 toolGroupConsumesUpwardNavigation,
88 waitForRevertedMessage,
89 prepareRevertedMessageAttachments,
83} from "./ChatView.logic";90} from "./ChatView.logic";
8491
85describe("agent browser close confirmation", () => {92describe("agent browser close confirmation", () => {
...@@ -2242,3 +2249,134 @@ describe("threadShellHasStarted", () => {...@@ -2242,3 +2249,134 @@ describe("threadShellHasStarted", () => {
2242 expect(threadShellHasStarted(null)).toBe(false);2249 expect(threadShellHasStarted(null)).toBe(false);
2243 });2250 });
2244});2251});
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: {...@@ -737,6 +737,38 @@ export async function resolveFileAttachmentUrl(input: {
737 return url;737 return url;
738}738}
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
740export function revokeUserMessagePreviewUrls(message: ChatMessage): void {772export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
741 if (message.role !== "user" || !message.attachments) {773 if (message.role !== "user" || !message.attachments) {
742 return;774 return;
...@@ -1105,6 +1137,81 @@ export async function waitForStartedServerThread(...@@ -1105,6 +1137,81 @@ export async function waitForStartedServerThread(
1105 });1137 });
1106}1138}
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
1108export interface LocalDispatchSnapshot {1215export interface LocalDispatchSnapshot {
1109 startedAt: string;1216 startedAt: string;
1110 preparingWorktree: boolean;1217 preparingWorktree: boolean;
apps/web/src/components/ChatView.tsx+114-29
...@@ -228,7 +228,7 @@ import {...@@ -228,7 +228,7 @@ import {
228 PaperclipIcon,228 PaperclipIcon,
229 WifiOffIcon,229 WifiOffIcon,
230} from "lucide-react";230} from "lucide-react";
231import { cn, randomHex } from "~/lib/utils";231import { cn, randomHex, randomUUID } from "~/lib/utils";
232import { stackedThreadToast, toastManager } from "./ui/toast";232import { stackedThreadToast, toastManager } from "./ui/toast";
233import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";233import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings";
234import { type NewProjectScriptInput } from "./ProjectScriptsControl";234import { type NewProjectScriptInput } from "./ProjectScriptsControl";
...@@ -410,6 +410,8 @@ import {...@@ -410,6 +410,8 @@ import {
410 deriveLockedProvider,410 deriveLockedProvider,
411 readFileAsDataUrl,411 readFileAsDataUrl,
412 resolveFileAttachmentUrl,412 resolveFileAttachmentUrl,
413 prepareRevertedMessageAttachments,
414 waitForRevertedMessage,
413 reconcileMountedTerminalThreadIds,415 reconcileMountedTerminalThreadIds,
414 recallCheckoutIsRepo,416 recallCheckoutIsRepo,
415 rememberCheckoutIsRepo,417 rememberCheckoutIsRepo,
...@@ -485,7 +487,10 @@ import {...@@ -485,7 +487,10 @@ import {
485 supportsServerUpdateThreadContinuation,487 supportsServerUpdateThreadContinuation,
486} from "../versionSkew";488} from "../versionSkew";
487import { useAssetUrls } from "../assets/assetUrls";489import { 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
490const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];495const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
491const EMPTY_PROVIDERS: ServerProvider[] = [];496const EMPTY_PROVIDERS: ServerProvider[] = [];
...@@ -1427,6 +1432,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -1427,6 +1432,13 @@ export default function ChatView(props: ChatViewProps) {
1427 [environmentId, threadId],1432 [environmentId, threadId],
1428 );1433 );
1429 const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);1434 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]);
1430 const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, {1442 const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, {
1431 reportFailure: false,1443 reportFailure: false,
1432 });1444 });
...@@ -1641,7 +1653,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -1641,7 +1653,9 @@ export default function ChatView(props: ChatViewProps) {
1641 Record<string, LocalThreadErrorEntry>1653 Record<string, LocalThreadErrorEntry>
1642 >({});1654 >({});
1643 const [isConnecting, _setIsConnecting] = useState(false);1655 const [isConnecting, _setIsConnecting] = useState(false);
1644 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);1656 const isRevertingCheckpoint = useComposerDraftStore((store) =>
1657 store.rewindingThreadKeys.has(routeThreadKey),
1658 );
1645 const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>(1659 const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>(
1646 null,1660 null,
1647 );1661 );
...@@ -5352,10 +5366,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -5352,10 +5366,6 @@ export default function ChatView(props: ChatViewProps) {
5352 // activeThreadRef resets transitively with the active thread.5366 // activeThreadRef resets transitively with the active thread.
5353 }, [activeThread?.id]);5367 }, [activeThread?.id]);
53545368
5355 useEffect(() => {
5356 setIsRevertingCheckpoint(false);
5357 }, [activeThread?.id]);
5358
5359 useEffect(() => {5369 useEffect(() => {
5360 if (!activeThread?.id || terminalUiState.terminalOpen) return;5370 if (!activeThread?.id || terminalUiState.terminalOpen) return;
5361 const frame = window.requestAnimationFrame(() => {5371 const frame = window.requestAnimationFrame(() => {
...@@ -6524,9 +6534,11 @@ export default function ChatView(props: ChatViewProps) {...@@ -6524,9 +6534,11 @@ export default function ChatView(props: ChatViewProps) {
6524 }, [activeThreadId, composerRef]);6534 }, [activeThreadId, composerRef]);
65256535
6526 const onRevertToTurnCount = useCallback(6536 const onRevertToTurnCount = useCallback(
6527 async (turnCount: number) => {6537 async (turnCount: number, messageId: MessageId) => {
6528 const localApi = readLocalApi();6538 const localApi = readLocalApi();
6529 if (!localApi || !activeThread || isRevertingCheckpoint) return;6539 if (!localApi || !activeThread || isRevertingCheckpoint) return;
6540 const message = activeThread.messages.find((message) => message.id === messageId);
6541 if (!message || message.role !== "user") return;
65306542
6531 if (!supportsConversationRollback) {6543 if (!supportsConversationRollback) {
6532 setThreadError(6544 setThreadError(
...@@ -6548,9 +6560,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -6548,9 +6560,9 @@ export default function ChatView(props: ChatViewProps) {
6548 }6560 }
6549 const confirmed = await localApi.dialogs.confirm(6561 const confirmed = await localApi.dialogs.confirm(
6550 [6562 [
6551 `Revert this thread to checkpoint ${turnCount}?`,6563 "Edit from here?",
6552 "This will discard newer messages and turn diffs in this thread.",6564 "Rewind files and chat to before this message.",
6553 "This action cannot be undone.",6565 "Your prompt and attachments return to the composer.",
6554 ].join("\n"),6566 ].join("\n"),
6555 { variant: "destructive" },6567 { variant: "destructive" },
6556 );6568 );
...@@ -6558,34 +6570,102 @@ export default function ChatView(props: ChatViewProps) {...@@ -6558,34 +6570,102 @@ export default function ChatView(props: ChatViewProps) {
6558 return;6570 return;
6559 }6571 }
65606572
6561 setIsRevertingCheckpoint(true);6573 useComposerDraftStore.setState((store) => ({
6574 rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
6575 }));
6562 setThreadError(activeThread.id, null);6576 setThreadError(activeThread.id, null);
6563 const result = await revertThreadCheckpoint({6577 try {
6564 environmentId,6578 if (composerRef.current?.hasPendingAttachments()) {
6565 input: {6579 throw new Error("Wait for attachments to finish preparing before rewinding.");
6566 threadId: activeThread.id,6580 }
6567 turnCount,6581 const connection = readPreparedConnection(environmentId);
6568 },6582 if (!connection) throw new Error("The environment is not connected.");
6569 });6583 const files = await prepareRevertedMessageAttachments({
6570 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {6584 message,
6571 const error = squashAtomCommandFailure(result);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) {
6572 setThreadError(6642 setThreadError(
6573 activeThread.id,6643 activeThread.id,
6574 error instanceof Error ? error.message : "Failed to revert thread state.",6644 error instanceof Error ? error.message : "Failed to revert thread state.",
6575 );6645 );
6646 } finally {
6647 useComposerDraftStore.setState((store) => {
6648 const remaining = new Set(store.rewindingThreadKeys);
6649 remaining.delete(routeThreadKey);
6650 return { rewindingThreadKeys: remaining };
6651 });
6576 }6652 }
6577 setIsRevertingCheckpoint(false);
6578 },6653 },
6579 [6654 [
6580 activeThread,6655 activeThread,
6581 activeEnvironmentUnavailable,6656 activeEnvironmentUnavailable,
6582 activeEnvironmentUnavailableLabel,6657 activeEnvironmentUnavailableLabel,
6658 composerDraftTarget,
6659 composerRef,
6660 createAttachmentAssetUrl,
6583 environmentId,6661 environmentId,
6584 isConnecting,6662 isConnecting,
6585 isRevertingCheckpoint,6663 isRevertingCheckpoint,
6586 isSendBusy,6664 isSendBusy,
6587 phase,6665 phase,
6588 revertThreadCheckpoint,6666 revertThreadCheckpoint,
6667 routeThreadKey,
6668 routeThreadRef,
6589 setThreadError,6669 setThreadError,
6590 supportsConversationRollback,6670 supportsConversationRollback,
6591 ],6671 ],
...@@ -6703,6 +6783,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6703,6 +6783,7 @@ export default function ChatView(props: ChatViewProps) {
6703 !activeThread ||6783 !activeThread ||
6704 isSendBusy ||6784 isSendBusy ||
6705 isConnecting ||6785 isConnecting ||
6786 isRevertingCheckpoint ||
6706 !clientSettingsHydrated ||6787 !clientSettingsHydrated ||
6707 threadDetailLoading ||6788 threadDetailLoading ||
6708 sendInFlightRef.current ||6789 sendInFlightRef.current ||
...@@ -8155,8 +8236,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -8155,8 +8236,8 @@ export default function ChatView(props: ChatViewProps) {
8155 // reference is fully stable and never busts TimelineRowCtx identity.8236 // reference is fully stable and never busts TimelineRowCtx identity.
8156 const onRevertToTurnCountRef = useRef(onRevertToTurnCount);8237 const onRevertToTurnCountRef = useRef(onRevertToTurnCount);
8157 onRevertToTurnCountRef.current = onRevertToTurnCount;8238 onRevertToTurnCountRef.current = onRevertToTurnCount;
8158 const onRevertTimelineTurn = useCallback((targetTurnCount: number) => {8239 const onRevertTimelineTurn = useCallback((targetTurnCount: number, messageId: MessageId) => {
8159 void onRevertToTurnCountRef.current(targetTurnCount);8240 void onRevertToTurnCountRef.current(targetTurnCount, messageId);
8160 }, []);8241 }, []);
81618242
8162 // Files dropped on a sidebar row land here once the dropped-on thread is8243 // Files dropped on a sidebar row land here once the dropped-on thread is
...@@ -8650,6 +8731,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -8650,6 +8731,7 @@ export default function ChatView(props: ChatViewProps) {
8650 {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}8731 {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
8651 <div8732 <div
8652 ref={setComposerOverlayElement}8733 ref={setComposerOverlayElement}
8734 inert={isRevertingCheckpoint}
8653 data-chat-composer-overlay="true"8735 data-chat-composer-overlay="true"
8654 className={8736 className={
8655 isDraftHeroState8737 isDraftHeroState
...@@ -8719,12 +8801,15 @@ export default function ChatView(props: ChatViewProps) {...@@ -8719,12 +8801,15 @@ export default function ChatView(props: ChatViewProps) {
8719 phase={phase}8801 phase={phase}
8720 isConnecting={isConnecting}8802 isConnecting={isConnecting}
8721 isSendBusy={isSendBusy}8803 isSendBusy={isSendBusy}
8804 isRevertingCheckpoint={isRevertingCheckpoint}
8722 sendDisabledReason={8805 sendDisabledReason={
8723 feedbackUploading8806 isRevertingCheckpoint
8724 ? "Sending feedback"8807 ? "Rewinding conversation"
8725 : threadDetailLoading8808 : feedbackUploading
8726 ? "Messages loading"8809 ? "Sending feedback"
8727 : null8810 : threadDetailLoading
8811 ? "Messages loading"
8812 : null
8728 }8813 }
8729 isPreparingWorktree={isPreparingWorktree}8814 isPreparingWorktree={isPreparingWorktree}
8730 bannerItems={composerBannerItems}8815 bannerItems={composerBannerItems}
apps/web/src/components/chat/ChatComposer.tsx+14-3
...@@ -1157,6 +1157,7 @@ export interface ChatComposerHandle {...@@ -1157,6 +1157,7 @@ export interface ChatComposerHandle {
1157 restoreAfterTimelineReachedEnd: () => void;1157 restoreAfterTimelineReachedEnd: () => void;
1158 collapseForTimelineScrollKey: (key: string) => void;1158 collapseForTimelineScrollKey: (key: string) => void;
1159 addDroppedFiles: (files: File[]) => void;1159 addDroppedFiles: (files: File[]) => void;
1160 hasPendingAttachments: () => boolean;
1160 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;1161 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
1161 citeAssistantText: (1162 citeAssistantText: (
1162 citation: AssistantCitation,1163 citation: AssistantCitation,
...@@ -1235,6 +1236,7 @@ export interface ChatComposerProps {...@@ -1235,6 +1236,7 @@ export interface ChatComposerProps {
1235 phase: SessionPhase;1236 phase: SessionPhase;
1236 isConnecting: boolean;1237 isConnecting: boolean;
1237 isSendBusy: boolean;1238 isSendBusy: boolean;
1239 isRevertingCheckpoint?: boolean;
1238 sendDisabledReason: string | null;1240 sendDisabledReason: string | null;
1239 isPreparingWorktree: boolean;1241 isPreparingWorktree: boolean;
1240 bannerItems: readonly ComposerBannerStackItem[];1242 bannerItems: readonly ComposerBannerStackItem[];
...@@ -1383,6 +1385,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1383,6 +1385,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1383 phase,1385 phase,
1384 isConnecting,1386 isConnecting,
1385 isSendBusy,1387 isSendBusy,
1388 isRevertingCheckpoint = false,
1386 sendDisabledReason: externalSendDisabledReason,1389 sendDisabledReason: externalSendDisabledReason,
1387 isPreparingWorktree,1390 isPreparingWorktree,
1388 environmentUnavailable,1391 environmentUnavailable,
...@@ -1970,6 +1973,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1970,6 +1973,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1970 * the next draft.1973 * the next draft.
1971 */1974 */
1972 const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());1975 const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map());
1976 const isRevertingCheckpointRef = useRef(isRevertingCheckpoint);
1977 isRevertingCheckpointRef.current = isRevertingCheckpoint;
19731978
1974 // ------------------------------------------------------------------1979 // ------------------------------------------------------------------
1975 // Derived: composer send state1980 // Derived: composer send state
...@@ -3293,7 +3298,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3293,7 +3298,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3293 // A thread switch during the verify await would mix the new thread's3298 // A thread switch during the verify await would mix the new thread's
3294 // prompt with this invocation's captured target. Nothing was taken yet,3299 // prompt with this invocation's captured target. Nothing was taken yet,
3295 // so abort and leave the entry restorable where the user now is.3300 // 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 ) {
3297 return;3305 return;
3298 }3306 }
32993307
...@@ -4270,7 +4278,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4270,7 +4278,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4270 // even when the composer is in a state that can't stash.4278 // even when the composer is in a state that can't stash.
4271 event.preventDefault();4279 event.preventDefault();
4272 event.stopPropagation();4280 event.stopPropagation();
4273 if (isCommandPaletteOpen()) {4281 if (isCommandPaletteOpen() || isRevertingCheckpoint) {
4274 return;4282 return;
4275 }4283 }
4276 if (pendingUserInputs.length > 0 && !isComposerApprovalState) {4284 if (pendingUserInputs.length > 0 && !isComposerApprovalState) {
...@@ -4292,6 +4300,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4292,6 +4300,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4292 pendingUserInputs.length,4300 pendingUserInputs.length,
4293 projectSelectionRequired,4301 projectSelectionRequired,
4294 stashCurrentPrompt,4302 stashCurrentPrompt,
4303 isRevertingCheckpoint,
4295 terminalOpen,4304 terminalOpen,
4296 ]);4305 ]);
42974306
...@@ -4299,7 +4308,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4299,7 +4308,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4299 // Callbacks: attachments4308 // Callbacks: attachments
4300 // ------------------------------------------------------------------4309 // ------------------------------------------------------------------
4301 const addComposerAttachments = async (files: File[]) => {4310 const addComposerAttachments = async (files: File[]) => {
4302 if (!activeThreadId || files.length === 0) return;4311 if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return;
4303 if (4312 if (
4304 pendingUserInputs.length > 0 &&4313 pendingUserInputs.length > 0 &&
4305 (!supportsQuestionAttachments ||4314 (!supportsQuestionAttachments ||
...@@ -4714,6 +4723,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4714,6 +4723,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4714 void addComposerAttachments(files);4723 void addComposerAttachments(files);
4715 focusComposer();4724 focusComposer();
4716 },4725 },
4726 hasPendingAttachments: () =>
4727 (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0,
4717 insertTextAtEnd: insertComposerTextAtEnd,4728 insertTextAtEnd: insertComposerTextAtEnd,
4718 citeAssistantText: (citation, sourceAnchor) =>4729 citeAssistantText: (citation, sourceAnchor) =>
4719 insertComposerText(4730 insertComposerText(
apps/web/src/components/chat/MessagesTimeline.tsx+13-7
...@@ -221,7 +221,7 @@ interface TimelineRowSharedState {...@@ -221,7 +221,7 @@ interface TimelineRowSharedState {
221 workspaceRoot: string | undefined;221 workspaceRoot: string | undefined;
222 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;222 skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
223 activeThreadEnvironmentId: EnvironmentId;223 activeThreadEnvironmentId: EnvironmentId;
224 onRevertToTurnCount: (targetTurnCount: number) => void;224 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
225 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;225 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
226 onImageExpand: (preview: ExpandedImagePreview) => void;226 onImageExpand: (preview: ExpandedImagePreview) => void;
227 onFileOpen: (attachment: ChatFileAttachment) => void;227 onFileOpen: (attachment: ChatFileAttachment) => void;
...@@ -339,7 +339,7 @@ interface MessagesTimelineProps {...@@ -339,7 +339,7 @@ interface MessagesTimelineProps {
339 displayThreadKey?: string;339 displayThreadKey?: string;
340 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;340 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
341 supportsConversationRollback: boolean;341 supportsConversationRollback: boolean;
342 onRevertToTurnCount: (targetTurnCount: number) => void;342 onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void;
343 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;343 onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
344 isRevertingCheckpoint: boolean;344 isRevertingCheckpoint: boolean;
345 onImageExpand: (preview: ExpandedImagePreview) => void;345 onImageExpand: (preview: ExpandedImagePreview) => void;
...@@ -1580,7 +1580,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1580,7 +1580,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1580 </Tooltip>1580 </Tooltip>
1581 <div className="flex items-center gap-0.5">1581 <div className="flex items-center gap-0.5">
1582 {typeof revertTurnCount === "number" && (1582 {typeof revertTurnCount === "number" && (
1583 <RevertUserMessageButton turnCount={revertTurnCount} />1583 <RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} />
1584 )}1584 )}
1585 {displayedUserMessage.copyText && (1585 {displayedUserMessage.copyText && (
1586 <MessageCopyButton text={displayedUserMessage.copyText} variant="ghost" />1586 <MessageCopyButton text={displayedUserMessage.copyText} variant="ghost" />
...@@ -1592,7 +1592,13 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1592,7 +1592,13 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1592 );1592 );
1593}1593}
15941594
1595function RevertUserMessageButton({ turnCount }: { turnCount: number }) {1595function RevertUserMessageButton({
1596 turnCount,
1597 messageId,
1598}: {
1599 turnCount: number;
1600 messageId: MessageId;
1601}) {
1596 const ctx = use(TimelineRowCtx);1602 const ctx = use(TimelineRowCtx);
1597 const activity = use(TimelineRowActivityCtx);1603 const activity = use(TimelineRowActivityCtx);
15981604
...@@ -1605,14 +1611,14 @@ function RevertUserMessageButton({ turnCount }: { turnCount: number }) {...@@ -1605,14 +1611,14 @@ function RevertUserMessageButton({ turnCount }: { turnCount: number }) {
1605 size="xs"1611 size="xs"
1606 variant="ghost"1612 variant="ghost"
1607 disabled={activity.isRevertingCheckpoint || activity.isWorking}1613 disabled={activity.isRevertingCheckpoint || activity.isWorking}
1608 onClick={() => ctx.onRevertToTurnCount(turnCount)}1614 onClick={() => ctx.onRevertToTurnCount(turnCount, messageId)}
1609 aria-label="Revert to this message"1615 aria-label="Edit from here"
1610 />1616 />
1611 }1617 }
1612 >1618 >
1613 <Undo2Icon className="size-3" />1619 <Undo2Icon className="size-3" />
1614 </TooltipTrigger>1620 </TooltipTrigger>
1615 <TooltipPopup side="top">Revert to this message</TooltipPopup>1621 <TooltipPopup side="top">Edit from here</TooltipPopup>
1616 </Tooltip>1622 </Tooltip>
1617 );1623 );
1618}1624}
apps/web/src/composerDraftStore.test.ts+59
...@@ -145,6 +145,7 @@ function makeTerminalContext(input: {...@@ -145,6 +145,7 @@ function makeTerminalContext(input: {
145145
146function resetComposerDraftStore() {146function resetComposerDraftStore() {
147 useComposerDraftStore.setState({147 useComposerDraftStore.setState({
148 rewindingThreadKeys: new Set(),
148 draftsByThreadKey: {},149 draftsByThreadKey: {},
149 draftThreadsByThreadKey: {},150 draftThreadsByThreadKey: {},
150 logicalProjectDraftThreadKeyByLogicalProjectKey: {},151 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
...@@ -275,6 +276,28 @@ describe("composerDraftStore addImages", () => {...@@ -275,6 +276,28 @@ describe("composerDraftStore addImages", () => {
275 expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");276 expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate");
276 });277 });
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
278 it("deduplicates against existing images across calls by file signature", () => {301 it("deduplicates against existing images across calls by file signature", () => {
279 const first = makeImage({302 const first = makeImage({
280 id: "img-a",303 id: "img-a",
...@@ -388,6 +411,16 @@ describe("composerDraftStore unsent draft marker", () => {...@@ -388,6 +411,16 @@ describe("composerDraftStore unsent draft marker", () => {
388 useComposerDraftStore.getState().clearComposerContent(threadRef);411 useComposerDraftStore.getState().clearComposerContent(threadRef);
389 expect(hasDraft()).toBe(false);412 expect(hasDraft()).toBe(false);
390 });413 });
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 });
391});424});
392425
393describe("composerDraftStore file attachments", () => {426describe("composerDraftStore file attachments", () => {
...@@ -664,6 +697,23 @@ describe("composerDraftStore file attachments", () => {...@@ -664,6 +697,23 @@ describe("composerDraftStore file attachments", () => {
664 ]);697 ]);
665 });698 });
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
667 it("keeps same-name videos with different MIME types", () => {717 it("keeps same-name videos with different MIME types", () => {
668 const store = useComposerDraftStore.getState();718 const store = useComposerDraftStore.getState();
669 const mp4 = new File(["report"], "clip", { type: "video/mp4" });719 const mp4 = new File(["report"], "clip", { type: "video/mp4" });
...@@ -1136,10 +1186,19 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -1136,10 +1186,19 @@ describe("composerDraftStore project draft thread mapping", () => {
1136 store.addImage(localDraftId, makeImage({ id: "img-local", previewUrl: "blob:local-draft" }));1186 store.addImage(localDraftId, makeImage({ id: "img-local", previewUrl: "blob:local-draft" }));
1137 store.setPrompt(localThreadRef, "local thread draft");1187 store.setPrompt(localThreadRef, "local thread draft");
1138 store.setPrompt(remoteThreadRef, "remote thread draft");1188 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
1140 clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID);1196 clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID);
11411197
1142 const next = useComposerDraftStore.getState();1198 const next = useComposerDraftStore.getState();
1199 expect([...next.rewindingThreadKeys]).toEqual([
1200 threadKeyFor(otherThreadId, OTHER_TEST_ENVIRONMENT_ID),
1201 ]);
1143 expect(next.getDraftThreadByProjectRef(projectRef)).toBeNull();1202 expect(next.getDraftThreadByProjectRef(projectRef)).toBeNull();
1144 expect(next.getDraftThreadByProjectRef(remoteProjectRef)).not.toBeNull();1203 expect(next.getDraftThreadByProjectRef(remoteProjectRef)).not.toBeNull();
1145 expect(next.getComposerDraft(localDraftId)).toBeNull();1204 expect(next.getComposerDraft(localDraftId)).toBeNull();
apps/web/src/composerDraftStore.ts+32-13
...@@ -475,6 +475,7 @@ interface ComposerDraftStoreState {...@@ -475,6 +475,7 @@ interface ComposerDraftStoreState {
475 draftThreadsByThreadKey: Record<string, DraftThreadState>;475 draftThreadsByThreadKey: Record<string, DraftThreadState>;
476 logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>;476 logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>;
477 backgroundSubmissionThreadKeys: Record<string, true>;477 backgroundSubmissionThreadKeys: Record<string, true>;
478 rewindingThreadKeys: ReadonlySet<string>;
478 stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;479 stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
479 stickyActiveProvider: ProviderInstanceId | null;480 stickyActiveProvider: ProviderInstanceId | null;
480 /** Returns the editable composer content for a draft session or server thread. */481 /** Returns the editable composer content for a draft session or server thread. */
...@@ -602,9 +603,17 @@ interface ComposerDraftStoreState {...@@ -602,9 +603,17 @@ interface ComposerDraftStoreState {
602 interactionMode: ProviderInteractionMode | null | undefined,603 interactionMode: ProviderInteractionMode | null | undefined,
603 ) => void;604 ) => void;
604 addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => boolean;605 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;
606 removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void;611 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;
608 removeFile: (threadRef: ComposerThreadTarget, fileId: string) => void;617 removeFile: (threadRef: ComposerThreadTarget, fileId: string) => void;
609 setFileUpload: (618 setFileUpload: (
610 threadRef: ComposerThreadTarget,619 threadRef: ComposerThreadTarget,
...@@ -2522,6 +2531,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -2522,6 +2531,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
2522 draftThreadsByThreadKey: {},2531 draftThreadsByThreadKey: {},
2523 logicalProjectDraftThreadKeyByLogicalProjectKey: {},2532 logicalProjectDraftThreadKeyByLogicalProjectKey: {},
2524 backgroundSubmissionThreadKeys: {},2533 backgroundSubmissionThreadKeys: {},
2534 rewindingThreadKeys: new Set<string>(),
2525 stickyModelSelectionByProvider: {},2535 stickyModelSelectionByProvider: {},
2526 stickyActiveProvider: null,2536 stickyActiveProvider: null,
2527 getComposerDraft: (target) => getComposerDraftState(get(), target),2537 getComposerDraft: (target) => getComposerDraftState(get(), target),
...@@ -3289,7 +3299,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3289,7 +3299,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3289 (get().draftsByThreadKey[threadKey]?.images.some(({ id }) => id === image.id) ?? false)3299 (get().draftsByThreadKey[threadKey]?.images.some(({ id }) => id === image.id) ?? false)
3290 );3300 );
3291 },3301 },
3292 addImages: (threadRef, images) => {3302 addImages: (threadRef, images, options) => {
3293 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";3303 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3294 if (threadKey.length === 0 || images.length === 0) {3304 if (threadKey.length === 0 || images.length === 0) {
3295 return;3305 return;
...@@ -3304,7 +3314,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3304,7 +3314,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3304 const dedupedIncoming: ComposerImageAttachment[] = [];3314 const dedupedIncoming: ComposerImageAttachment[] = [];
3305 for (const image of images) {3315 for (const image of images) {
3306 const dedupKey = composerImageDedupKey(image);3316 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 ) {
3308 // Avoid revoking a blob URL that's still referenced by an accepted image.3321 // Avoid revoking a blob URL that's still referenced by an accepted image.
3309 if (!acceptedPreviewUrls.has(image.previewUrl)) {3322 if (!acceptedPreviewUrls.has(image.previewUrl)) {
3310 revokeObjectPreviewUrl(image.previewUrl);3323 revokeObjectPreviewUrl(image.previewUrl);
...@@ -3374,7 +3387,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3374,7 +3387,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3374 return { draftsByThreadKey: nextDraftsByThreadKey };3387 return { draftsByThreadKey: nextDraftsByThreadKey };
3375 });3388 });
3376 },3389 },
3377 addFiles: (threadRef, files) => {3390 addFiles: (threadRef, files, options) => {
3378 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";3391 const threadKey = resolveComposerDraftKey(get(), threadRef) ?? "";
3379 if (threadKey.length === 0 || files.length === 0) {3392 if (threadKey.length === 0 || files.length === 0) {
3380 return;3393 return;
...@@ -3394,14 +3407,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()(...@@ -3394,14 +3407,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()(
3394 if (knownIds.has(file.id)) {3407 if (knownIds.has(file.id)) {
3395 continue;3408 continue;
3396 }3409 }
3397 const duplicate =3410 const duplicate = options?.allowDuplicates
3398 knownFiles.get(key) ??3411 ? undefined
3399 existing.files.find(3412 : (knownFiles.get(key) ??
3400 (candidate) =>3413 existing.files.find(
3401 composerFileNeedsReattach(candidate) &&3414 (candidate) =>
3402 !replacements.has(candidate.id) &&3415 composerFileNeedsReattach(candidate) &&
3403 composerFileMatchesReattachMarker(candidate, file),3416 !replacements.has(candidate.id) &&
3404 );3417 composerFileMatchesReattachMarker(candidate, file),
3418 ));
3405 if (duplicate) {3419 if (duplicate) {
3406 // A needs-reattach marker is not a usable duplicate. Replace3420 // A needs-reattach marker is not a usable duplicate. Replace
3407 // it so the upload restarts.3421 // it so the upload restarts.
...@@ -4105,6 +4119,11 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo...@@ -4105,6 +4119,11 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo
4105 draftThreadsByThreadKey: nextDraftThreads,4119 draftThreadsByThreadKey: nextDraftThreads,
4106 logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings,4120 logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings,
4107 backgroundSubmissionThreadKeys: nextBackgroundSubmissionThreadKeys,4121 backgroundSubmissionThreadKeys: nextBackgroundSubmissionThreadKeys,
4122 rewindingThreadKeys: new Set(
4123 [...state.rewindingThreadKeys].filter(
4124 (threadKey) => parseScopedThreadKey(threadKey)?.environmentId !== environmentId,
4125 ),
4126 ),
4108 };4127 };
4109 });4128 });
4110 composerDebouncedStorage.flush();4129 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...@@ -69,6 +69,17 @@ the text is an unedited recalled prompt, with the caret on the first visual line
69the last visual line for `ArrowDown`, counting wrapped lines. Editing a recalled prompt turns it69the last visual line for `ArrowDown`, counting wrapped lines. Editing a recalled prompt turns it
70into a normal draft.70into 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
72## Prompt stash83## Prompt stash
7384
74On web and desktop, press `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux to save85On web and desktop, press `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux to save
knip.jsonc+1
...@@ -21,6 +21,7 @@...@@ -21,6 +21,7 @@
21 "entry": [21 "entry": [
22 "src/bin.ts!",22 "src/bin.ts!",
23 "src/service-launcher.ts!",23 "src/service-launcher.ts!",
24 "src/claudeHistoryWorker.ts!",
24 "scripts/cli.ts",25 "scripts/cli.ts",
25 "src/provider/testFixtures/*.mjs",26 "src/provider/testFixtures/*.mjs",
26 ],27 ],