| author | |
| committer | |
| log | fd5553f1afcef4f410a067687faa991743b5034c |
| tree | 7b423ec9547504ba93165f2b7175f0be0aef8d9a |
| parent | cf1ba3d7da98357243ee056d2fd358eed8171981 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 888 | ); |
| 889 | 889 | |
| 890 | 890 | it.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 | 893 | withHarness((harness) => |
| 894 | 894 | Effect.gen(function* () { |
| ... | ... | @@ -917,7 +917,7 @@ it.live( |
| 917 | 917 | assert.equal( |
| 918 | 918 | String( |
| 919 | 919 | (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail, |
| 920 | ).includes("No active provider session"), | |
| 920 | ).includes("no persisted provider binding exists"), | |
| 921 | 921 | true, |
| 922 | 922 | ); |
| 923 | 923 | }), |
apps/server/src/claudeHistoryWorker.ts created+25| ... | ... | @@ -0,0 +1,25 @@ |
| 1 | import { forkSession, getSessionMessages } from "@anthropic-ai/claude-agent-sdk"; | |
| 2 | import * 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. | |
| 6 | const [method, sessionId, rawOptions] = process.argv.slice(2); | |
| 7 | const 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 ?? "{}"); | |
| 16 | if (!sessionId) throw new Error("Claude history session id is required."); | |
| 17 | const 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 | })(); | |
| 25 | process.stdout.write(JSON.stringify(result)); |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+39-21| ... | ... | @@ -1916,27 +1916,45 @@ describe("CheckpointReactor", () => { |
| 1916 | 1916 | }); |
| 1917 | 1917 | }); |
| 1918 | 1918 | |
| 1919 | it("appends an error activity when revert is requested without an active session", async () => { | |
| 1920 | const harness = await createHarness({ hasSession: false }); | |
| 1921 | const createdAt = "2026-01-01T00:00:00.000Z"; | |
| 1922 | ||
| 1923 | await Effect.runPromise( | |
| 1924 | harness.engine.dispatch({ | |
| 1925 | type: "thread.checkpoint.revert", | |
| 1926 | commandId: CommandId.make("cmd-revert-no-session"), | |
| 1927 | threadId: ThreadId.make("thread-1"), | |
| 1928 | turnCount: 1, | |
| 1929 | createdAt, | |
| 1930 | }), | |
| 1931 | ); | |
| 1919 | it.each([false, true])( | |
| 1920 | "reverts without an active session using project cwd fallback: %s", | |
| 1921 | async (useProjectCwd) => { | |
| 1922 | const harness = await createHarness({ | |
| 1923 | hasSession: false, | |
| 1924 | ...(useProjectCwd ? { threadWorktreePath: null } : {}), | |
| 1925 | }); | |
| 1926 | const createdAt = "2026-01-01T00:00:00.000Z"; | |
| 1932 | 1927 | |
| 1933 | const thread = await waitForThread(harness.readModel, (entry) => | |
| 1934 | entry.activities.some((activity) => activity.kind === "checkpoint.revert.failed"), | |
| 1935 | ); | |
| 1928 | await Effect.runPromise( | |
| 1929 | harness.engine.dispatch({ | |
| 1930 | type: "thread.turn.diff.complete", | |
| 1931 | commandId: CommandId.make("cmd-diff-before-session-recovery"), | |
| 1932 | threadId: ThreadId.make("thread-1"), | |
| 1933 | turnId: asTurnId("turn-1"), | |
| 1934 | completedAt: createdAt, | |
| 1935 | checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), | |
| 1936 | status: "ready", | |
| 1937 | files: [], | |
| 1938 | checkpointTurnCount: 1, | |
| 1939 | createdAt, | |
| 1940 | }), | |
| 1941 | ); | |
| 1942 | await Effect.runPromise( | |
| 1943 | harness.engine.dispatch({ | |
| 1944 | type: "thread.checkpoint.revert", | |
| 1945 | commandId: CommandId.make("cmd-revert-no-session"), | |
| 1946 | threadId: ThreadId.make("thread-1"), | |
| 1947 | turnCount: 0, | |
| 1948 | createdAt, | |
| 1949 | }), | |
| 1950 | ); | |
| 1936 | 1951 | |
| 1937 | expect(thread.activities.some((activity) => activity.kind === "checkpoint.revert.failed")).toBe( | |
| 1938 | true, | |
| 1939 | ); | |
| 1940 | expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); | |
| 1941 | }); | |
| 1952 | await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); | |
| 1953 | expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ | |
| 1954 | threadId: ThreadId.make("thread-1"), | |
| 1955 | numTurns: 1, | |
| 1956 | }); | |
| 1957 | expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v1\n"); | |
| 1958 | }, | |
| 1959 | ); | |
| 1942 | 1960 | }); |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+12-16| ... | ... | @@ -699,21 +699,17 @@ const make = Effect.gen(function* () { |
| 699 | 699 | return; |
| 700 | 700 | } |
| 701 | 701 | |
| 702 | const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId); | |
| 703 | if (Option.isNone(sessionRuntime)) { | |
| 704 | yield* appendRevertFailureActivity({ | |
| 705 | threadId: event.payload.threadId, | |
| 706 | turnCount: event.payload.turnCount, | |
| 707 | detail: "No active provider session with workspace cwd is bound to this thread.", | |
| 708 | createdAt: now, | |
| 709 | }).pipe(Effect.catch(() => Effect.void)); | |
| 710 | return; | |
| 711 | } | |
| 712 | if (!(yield* checkpointStore.isGitRepository(sessionRuntime.value.cwd))) { | |
| 702 | const checkpointCwd = yield* resolveCheckpointCwd({ | |
| 703 | threadId: event.payload.threadId, | |
| 704 | thread, | |
| 705 | projects: yield* resolveThreadProjects(thread.projectId), | |
| 706 | preferSessionRuntime: true, | |
| 707 | }); | |
| 708 | if (!checkpointCwd) { | |
| 713 | 709 | yield* appendRevertFailureActivity({ |
| 714 | 710 | threadId: event.payload.threadId, |
| 715 | 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 | 713 | createdAt: now, |
| 718 | 714 | }).pipe(Effect.catch(() => Effect.void)); |
| 719 | 715 | return; |
| ... | ... | @@ -754,7 +750,7 @@ const make = Effect.gen(function* () { |
| 754 | 750 | yield* providerService.assertConversationRollbackSupported(event.payload.threadId); |
| 755 | 751 | |
| 756 | 752 | const restored = yield* checkpointStore.restoreCheckpoint({ |
| 757 | cwd: sessionRuntime.value.cwd, | |
| 753 | cwd: checkpointCwd, | |
| 758 | 754 | checkpointRef: targetCheckpointRef, |
| 759 | 755 | fallbackToHead: event.payload.turnCount === 0, |
| 760 | 756 | }); |
| ... | ... | @@ -770,12 +766,12 @@ const make = Effect.gen(function* () { |
| 770 | 766 | |
| 771 | 767 | // Refresh the workspace entry index so the @-mention file picker |
| 772 | 768 | // reflects the reverted filesystem state. |
| 773 | yield* workspaceEntries.refresh(sessionRuntime.value.cwd); | |
| 769 | yield* workspaceEntries.refresh(checkpointCwd); | |
| 774 | 770 | |
| 775 | 771 | const rolledBackTurns = Math.max(0, currentTurnCount - event.payload.turnCount); |
| 776 | 772 | if (rolledBackTurns > 0) { |
| 777 | 773 | yield* providerService.rollbackConversation({ |
| 778 | threadId: sessionRuntime.value.threadId, | |
| 774 | threadId: event.payload.threadId, | |
| 779 | 775 | numTurns: rolledBackTurns, |
| 780 | 776 | }); |
| 781 | 777 | } |
| ... | ... | @@ -789,7 +785,7 @@ const make = Effect.gen(function* () { |
| 789 | 785 | |
| 790 | 786 | if (staleCheckpointRefs.length > 0) { |
| 791 | 787 | yield* checkpointStore.deleteCheckpointRefs({ |
| 792 | cwd: sessionRuntime.value.cwd, | |
| 788 | cwd: checkpointCwd, | |
| 793 | 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 | 170 | readonly instanceId?: ProviderInstanceId; |
| 171 | 171 | readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"]; |
| 172 | 172 | readonly environment?: ClaudeAdapterLiveOptions["environment"]; |
| 173 | readonly getSessionMessages?: ClaudeAdapterLiveOptions["getSessionMessages"]; | |
| 174 | readonly forkSession?: ClaudeAdapterLiveOptions["forkSession"]; | |
| 173 | 175 | }) { |
| 174 | 176 | const query = new FakeClaudeQuery(); |
| 177 | const queries = [query]; | |
| 175 | 178 | let createInput: |
| 176 | 179 | | { |
| 177 | 180 | readonly prompt: AsyncIterable<SDKUserMessage>; |
| ... | ... | @@ -184,9 +187,12 @@ function makeHarness(config?: { |
| 184 | 187 | ...(config?.instanceId ? { instanceId: config.instanceId } : {}), |
| 185 | 188 | ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}), |
| 186 | 189 | modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), |
| 190 | ...(config?.getSessionMessages ? { getSessionMessages: config.getSessionMessages } : {}), | |
| 191 | ...(config?.forkSession ? { forkSession: config.forkSession } : {}), | |
| 187 | 192 | createQuery: (input) => { |
| 193 | if (createInput && config?.getSessionMessages) queries.push(new FakeClaudeQuery()); | |
| 188 | 194 | createInput = input; |
| 189 | return query; | |
| 195 | return queries.at(-1)!; | |
| 190 | 196 | }, |
| 191 | 197 | ...(config?.nativeEventLogger |
| 192 | 198 | ? { |
| ... | ... | @@ -218,6 +224,7 @@ function makeHarness(config?: { |
| 218 | 224 | Layer.provideMerge(NodeServices.layer), |
| 219 | 225 | ), |
| 220 | 226 | query, |
| 227 | queries, | |
| 221 | 228 | getLastCreateQueryInput: () => createInput, |
| 222 | 229 | }; |
| 223 | 230 | } |
| ... | ... | @@ -6256,87 +6263,226 @@ describe("ClaudeAdapterLive", () => { |
| 6256 | 6263 | ); |
| 6257 | 6264 | }); |
| 6258 | 6265 | |
| 6259 | it.effect( | |
| 6260 | "supports rollbackThread by trimming in-memory turns and preserving earlier turns", | |
| 6261 | () => { | |
| 6262 | const harness = makeHarness(); | |
| 6263 | return Effect.gen(function* () { | |
| 6264 | const adapter = yield* ClaudeAdapter; | |
| 6265 | ||
| 6266 | const session = yield* adapter.startSession({ | |
| 6267 | threadId: THREAD_ID, | |
| 6268 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 6269 | runtimeMode: "full-access", | |
| 6270 | }); | |
| 6266 | it.effect("rewinds a steered Claude turn after recovery and preserves fork boundaries", () => { | |
| 6267 | const forkCalls: Array<Parameters<NonNullable<ClaudeAdapterLiveOptions["forkSession"]>>> = []; | |
| 6268 | let firstTurnId = ""; | |
| 6269 | let secondTurnId = ""; | |
| 6270 | let missingBoundary = false; | |
| 6271 | let legacyHistory = false; | |
| 6272 | const harness = makeHarness({ | |
| 6273 | forkSession: async (...args) => { | |
| 6274 | forkCalls.push(args); | |
| 6275 | return { sessionId: "550e8400-e29b-41d4-a716-446655440020" }; | |
| 6276 | }, | |
| 6277 | getSessionMessages: async (sessionId) => { | |
| 6278 | const history: Awaited< | |
| 6279 | ReturnType<NonNullable<ClaudeAdapterLiveOptions["getSessionMessages"]>> | |
| 6280 | > = [ | |
| 6281 | { | |
| 6282 | type: "user", | |
| 6283 | uuid: firstTurnId, | |
| 6284 | session_id: "550e8400-e29b-41d4-a716-446655440010", | |
| 6285 | parent_tool_use_id: null, | |
| 6286 | parent_agent_id: null, | |
| 6287 | message: { content: "first" }, | |
| 6288 | }, | |
| 6289 | { | |
| 6290 | type: "assistant", | |
| 6291 | uuid: "assistant-1", | |
| 6292 | session_id: "550e8400-e29b-41d4-a716-446655440010", | |
| 6293 | parent_tool_use_id: null, | |
| 6294 | parent_agent_id: null, | |
| 6295 | message: { content: [] }, | |
| 6296 | }, | |
| 6297 | { | |
| 6298 | type: "user", | |
| 6299 | uuid: "tool-result-1", | |
| 6300 | session_id: "550e8400-e29b-41d4-a716-446655440010", | |
| 6301 | parent_tool_use_id: null, | |
| 6302 | parent_agent_id: null, | |
| 6303 | message: { content: [{ type: "tool_result" }] }, | |
| 6304 | }, | |
| 6305 | { | |
| 6306 | type: "assistant", | |
| 6307 | uuid: "assistant-1-final", | |
| 6308 | session_id: "550e8400-e29b-41d4-a716-446655440010", | |
| 6309 | parent_tool_use_id: null, | |
| 6310 | parent_agent_id: null, | |
| 6311 | message: { content: [] }, | |
| 6312 | }, | |
| 6313 | { | |
| 6314 | type: "user", | |
| 6315 | uuid: secondTurnId, | |
| 6316 | session_id: "550e8400-e29b-41d4-a716-446655440010", | |
| 6317 | parent_tool_use_id: null, | |
| 6318 | parent_agent_id: null, | |
| 6319 | message: { content: "second" }, | |
| 6320 | }, | |
| 6321 | { | |
| 6322 | type: "assistant", | |
| 6323 | uuid: "assistant-2", | |
| 6324 | session_id: "550e8400-e29b-41d4-a716-446655440010", | |
| 6325 | parent_tool_use_id: null, | |
| 6326 | parent_agent_id: null, | |
| 6327 | message: { content: [] }, | |
| 6328 | }, | |
| 6329 | { | |
| 6330 | type: "user", | |
| 6331 | uuid: "steer", | |
| 6332 | session_id: sessionId, | |
| 6333 | parent_tool_use_id: null, | |
| 6334 | parent_agent_id: null, | |
| 6335 | message: { content: "steer the second turn" }, | |
| 6336 | }, | |
| 6337 | { | |
| 6338 | type: "assistant", | |
| 6339 | uuid: "assistant-steer", | |
| 6340 | session_id: sessionId, | |
| 6341 | parent_tool_use_id: null, | |
| 6342 | parent_agent_id: null, | |
| 6343 | message: { content: [] }, | |
| 6344 | }, | |
| 6345 | ]; | |
| 6346 | return sessionId.endsWith("0020") | |
| 6347 | ? history.slice(0, 4).map((message) => ({ ...message, uuid: `fork-${message.uuid}` })) | |
| 6348 | : legacyHistory | |
| 6349 | ? history.slice(0, 6) | |
| 6350 | : missingBoundary | |
| 6351 | ? history.filter((message) => message.uuid !== secondTurnId) | |
| 6352 | : history; | |
| 6353 | }, | |
| 6354 | }); | |
| 6355 | return Effect.gen(function* () { | |
| 6356 | const adapter = yield* ClaudeAdapter; | |
| 6271 | 6357 | |
| 6272 | const firstTurn = yield* adapter.sendTurn({ | |
| 6273 | threadId: session.threadId, | |
| 6274 | input: "first", | |
| 6275 | attachments: [], | |
| 6276 | }); | |
| 6358 | const session = yield* adapter.startSession({ | |
| 6359 | threadId: THREAD_ID, | |
| 6360 | provider: ProviderDriverKind.make("claudeAgent"), | |
| 6361 | runtimeMode: "full-access", | |
| 6362 | }); | |
| 6277 | 6363 | |
| 6278 | const firstCompletedFiber = yield* Stream.filter( | |
| 6279 | adapter.streamEvents, | |
| 6280 | (event) => event.type === "turn.completed", | |
| 6281 | ).pipe(Stream.runHead, Effect.forkChild); | |
| 6364 | const firstTurn = yield* adapter.sendTurn({ | |
| 6365 | threadId: session.threadId, | |
| 6366 | input: "first", | |
| 6367 | attachments: [], | |
| 6368 | }); | |
| 6369 | firstTurnId = firstTurn.turnId; | |
| 6282 | 6370 | |
| 6283 | harness.query.emit({ | |
| 6284 | type: "result", | |
| 6285 | subtype: "success", | |
| 6286 | is_error: false, | |
| 6287 | errors: [], | |
| 6288 | session_id: "sdk-session-rollback", | |
| 6289 | uuid: "result-first", | |
| 6290 | } as unknown as SDKMessage); | |
| 6371 | const firstCompletedFiber = yield* Stream.filter( | |
| 6372 | adapter.streamEvents, | |
| 6373 | (event) => event.type === "turn.completed", | |
| 6374 | ).pipe(Stream.runHead, Effect.forkChild); | |
| 6291 | 6375 | |
| 6292 | const firstCompleted = yield* Fiber.join(firstCompletedFiber); | |
| 6293 | assert.equal(firstCompleted._tag, "Some"); | |
| 6294 | if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") { | |
| 6295 | assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId)); | |
| 6296 | } | |
| 6376 | harness.query.emit({ | |
| 6377 | type: "result", | |
| 6378 | subtype: "success", | |
| 6379 | is_error: false, | |
| 6380 | errors: [], | |
| 6381 | session_id: "550e8400-e29b-41d4-a716-446655440010", | |
| 6382 | uuid: "result-first", | |
| 6383 | } as unknown as SDKMessage); | |
| 6297 | 6384 | |
| 6298 | const secondTurn = yield* adapter.sendTurn({ | |
| 6299 | threadId: session.threadId, | |
| 6300 | input: "second", | |
| 6301 | attachments: [], | |
| 6302 | }); | |
| 6385 | const firstCompleted = yield* Fiber.join(firstCompletedFiber); | |
| 6386 | assert.equal(firstCompleted._tag, "Some"); | |
| 6387 | if (firstCompleted._tag === "Some" && firstCompleted.value.type === "turn.completed") { | |
| 6388 | assert.equal(String(firstCompleted.value.turnId), String(firstTurn.turnId)); | |
| 6389 | } | |
| 6303 | 6390 | |
| 6304 | const secondCompletedFiber = yield* Stream.filter( | |
| 6305 | adapter.streamEvents, | |
| 6306 | (event) => event.type === "turn.completed", | |
| 6307 | ).pipe(Stream.runHead, Effect.forkChild); | |
| 6391 | const secondTurn = yield* adapter.sendTurn({ | |
| 6392 | threadId: session.threadId, | |
| 6393 | input: "second", | |
| 6394 | attachments: [], | |
| 6395 | }); | |
| 6396 | secondTurnId = secondTurn.turnId; | |
| 6397 | const steer = yield* adapter.sendTurn({ | |
| 6398 | threadId: session.threadId, | |
| 6399 | input: "steer the second turn", | |
| 6400 | attachments: [], | |
| 6401 | }); | |
| 6402 | assert.equal(steer.turnId, secondTurn.turnId); | |
| 6308 | 6403 | |
| 6309 | harness.query.emit({ | |
| 6310 | type: "result", | |
| 6311 | subtype: "success", | |
| 6312 | is_error: false, | |
| 6313 | errors: [], | |
| 6314 | session_id: "sdk-session-rollback", | |
| 6315 | uuid: "result-second", | |
| 6316 | } as unknown as SDKMessage); | |
| 6404 | const secondCompletedFiber = yield* Stream.filter( | |
| 6405 | adapter.streamEvents, | |
| 6406 | (event) => event.type === "turn.completed", | |
| 6407 | ).pipe(Stream.runHead, Effect.forkChild); | |
| 6317 | 6408 | |
| 6318 | const secondCompleted = yield* Fiber.join(secondCompletedFiber); | |
| 6319 | assert.equal(secondCompleted._tag, "Some"); | |
| 6320 | if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") { | |
| 6321 | assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); | |
| 6322 | } | |
| 6409 | harness.query.emit({ | |
| 6410 | type: "result", | |
| 6411 | subtype: "success", | |
| 6412 | is_error: false, | |
| 6413 | errors: [], | |
| 6414 | session_id: "550e8400-e29b-41d4-a716-446655440010", | |
| 6415 | uuid: "result-second", | |
| 6416 | } as unknown as SDKMessage); | |
| 6323 | 6417 | |
| 6324 | const threadBeforeRollback = yield* adapter.readThread(session.threadId); | |
| 6325 | assert.equal(threadBeforeRollback.turns.length, 2); | |
| 6418 | const secondCompleted = yield* Fiber.join(secondCompletedFiber); | |
| 6419 | assert.equal(secondCompleted._tag, "Some"); | |
| 6420 | if (secondCompleted._tag === "Some" && secondCompleted.value.type === "turn.completed") { | |
| 6421 | assert.equal(String(secondCompleted.value.turnId), String(secondTurn.turnId)); | |
| 6422 | } | |
| 6326 | 6423 | |
| 6327 | const rolledBack = yield* adapter.rollbackThread(session.threadId, 1); | |
| 6328 | assert.equal(rolledBack.turns.length, 1); | |
| 6329 | assert.equal(rolledBack.turns[0]?.id, firstTurn.turnId); | |
| 6424 | const threadBeforeRollback = yield* adapter.readThread(session.threadId); | |
| 6425 | assert.equal(threadBeforeRollback.turns.length, 2); | |
| 6426 | const cursor = (yield* adapter.listSessions())[0]?.resumeCursor; | |
| 6427 | yield* adapter.stopSession(session.threadId); | |
| 6428 | legacyHistory = true; | |
| 6429 | yield* adapter.startSession({ | |
| 6430 | threadId: session.threadId, | |
| 6431 | runtimeMode: "full-access", | |
| 6432 | resumeCursor: { | |
| 6433 | threadId: session.threadId, | |
| 6434 | resume: "550e8400-e29b-41d4-a716-446655440010", | |
| 6435 | turnCount: 1, | |
| 6436 | }, | |
| 6437 | }); | |
| 6438 | const legacyOptions = harness.getLastCreateQueryInput(); | |
| 6439 | const ambiguousLegacy = yield* adapter.rollbackThread(session.threadId, 1).pipe(Effect.flip); | |
| 6440 | assert.match(ambiguousLegacy.message, /exact Claude turn boundary is unavailable/); | |
| 6441 | assert.equal(forkCalls.length, 0); | |
| 6442 | assert.equal(harness.getLastCreateQueryInput(), legacyOptions); | |
| 6443 | assert.equal((yield* adapter.listSessions()).length, 1); | |
| 6444 | yield* adapter.stopSession(session.threadId); | |
| 6445 | legacyHistory = false; | |
| 6446 | yield* adapter.startSession({ | |
| 6447 | threadId: session.threadId, | |
| 6448 | runtimeMode: "full-access", | |
| 6449 | resumeCursor: cursor, | |
| 6450 | }); | |
| 6451 | missingBoundary = true; | |
| 6452 | const unavailable = yield* adapter.rollbackThread(session.threadId, 1).pipe(Effect.flip); | |
| 6453 | assert.match(unavailable.message, /exact Claude turn boundary is unavailable/); | |
| 6454 | assert.equal(forkCalls.length, 0); | |
| 6455 | missingBoundary = false; | |
| 6456 | ||
| 6457 | const recoveredQuery = harness.queries.at(-1)!; | |
| 6458 | assert.equal(recoveredQuery.closeCalls, 0); | |
| 6459 | yield* adapter.rollbackThread(session.threadId, 1); | |
| 6460 | assert.equal(recoveredQuery.closeCalls, 1); | |
| 6461 | const forkOptions = harness.getLastCreateQueryInput()?.options; | |
| 6462 | assert.deepEqual(forkCalls, [ | |
| 6463 | ["550e8400-e29b-41d4-a716-446655440010", { upToMessageId: "assistant-1-final" }], | |
| 6464 | ]); | |
| 6465 | assert.equal(forkOptions?.resume, "550e8400-e29b-41d4-a716-446655440020"); | |
| 6466 | assert.equal(forkOptions?.resumeSessionAt, undefined); | |
| 6467 | assert.equal(forkOptions?.forkSession, undefined); | |
| 6468 | assert.deepEqual((yield* adapter.listSessions())[0]?.resumeCursor, { | |
| 6469 | threadId: session.threadId, | |
| 6470 | resume: "550e8400-e29b-41d4-a716-446655440020", | |
| 6471 | turnCount: 1, | |
| 6472 | turnStartMessageIds: [`fork-${firstTurnId}`], | |
| 6473 | }); | |
| 6330 | 6474 | |
| 6331 | const threadAfterRollback = yield* adapter.readThread(session.threadId); | |
| 6332 | assert.equal(threadAfterRollback.turns.length, 1); | |
| 6333 | assert.equal(threadAfterRollback.turns[0]?.id, firstTurn.turnId); | |
| 6334 | }).pipe( | |
| 6335 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 6336 | Effect.provide(harness.layer), | |
| 6337 | ); | |
| 6338 | }, | |
| 6339 | ); | |
| 6475 | yield* adapter.rollbackThread(session.threadId, 2); | |
| 6476 | const resetOptions = harness.getLastCreateQueryInput()?.options; | |
| 6477 | assert.equal(resetOptions?.resume, undefined); | |
| 6478 | assert.equal(resetOptions?.resumeSessionAt, undefined); | |
| 6479 | assert.equal(resetOptions?.forkSession, undefined); | |
| 6480 | assert.ok(resetOptions?.sessionId); | |
| 6481 | }).pipe( | |
| 6482 | Effect.provideService(Random.Random, makeDeterministicRandomService()), | |
| 6483 | Effect.provide(harness.layer), | |
| 6484 | ); | |
| 6485 | }); | |
| 6340 | 6486 | |
| 6341 | 6487 | it.effect("updates model on sendTurn when model override is provided", () => { |
| 6342 | 6488 | const harness = makeHarness(); |
apps/server/src/provider/Layers/ClaudeAdapter.ts+237-6| ... | ... | @@ -9,6 +9,8 @@ |
| 9 | 9 | import { |
| 10 | 10 | type CanUseTool, |
| 11 | 11 | query, |
| 12 | getSessionMessages, | |
| 13 | forkSession, | |
| 12 | 14 | type Options as ClaudeQueryOptions, |
| 13 | 15 | type PermissionMode, |
| 14 | 16 | type PermissionResult, |
| ... | ... | @@ -78,6 +80,7 @@ import * as Queue from "effect/Queue"; |
| 78 | 80 | import * as Ref from "effect/Ref"; |
| 79 | 81 | import * as Schema from "effect/Schema"; |
| 80 | 82 | import * as Stream from "effect/Stream"; |
| 83 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 81 | 84 | |
| 82 | 85 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 83 | 86 | import { ServerConfig } from "../../config.ts"; |
| ... | ... | @@ -108,9 +111,26 @@ import { |
| 108 | 111 | type ProviderAdapterError, |
| 109 | 112 | } from "../Errors.ts"; |
| 110 | 113 | import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; |
| 114 | import { spawnAndCollect } from "../providerSnapshot.ts"; | |
| 111 | 115 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 112 | 116 | const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); |
| 113 | 117 | const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); |
| 118 | const encodeHistoryArgs = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 119 | const decodeHistoryFork = Schema.decodeSync( | |
| 120 | Schema.fromJsonString(Schema.Struct({ sessionId: Schema.String })), | |
| 121 | ); | |
| 122 | const 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 | ); | |
| 114 | 134 | |
| 115 | 135 | const PROVIDER = ProviderDriverKind.make("claudeAgent"); |
| 116 | 136 | type ClaudeTextStreamKind = Extract<RuntimeContentStreamKind, "assistant_text" | "reasoning_text">; |
| ... | ... | @@ -139,6 +159,7 @@ interface ClaudeResumeState { |
| 139 | 159 | readonly resume?: string; |
| 140 | 160 | readonly resumeSessionAt?: string; |
| 141 | 161 | readonly turnCount?: number; |
| 162 | readonly turnStartMessageIds?: ReadonlyArray<string | null>; | |
| 142 | 163 | } |
| 143 | 164 | |
| 144 | 165 | interface ClaudeTurnState { |
| ... | ... | @@ -290,6 +311,8 @@ function rememberPendingTaskModel( |
| 290 | 311 | |
| 291 | 312 | interface ClaudeSessionContext { |
| 292 | 313 | session: ProviderSession; |
| 314 | startInput: Parameters<ClaudeAdapterShape["startSession"]>[0]; | |
| 315 | readonly turnStartMessageIds: Array<string | null>; | |
| 293 | 316 | readonly promptQueue: Queue.Queue<PromptQueueItem>; |
| 294 | 317 | readonly query: ClaudeQueryRuntime; |
| 295 | 318 | streamFiber: Fiber.Fiber<void, Error> | undefined; |
| ... | ... | @@ -350,6 +373,8 @@ export interface ClaudeAdapterLiveOptions { |
| 350 | 373 | readonly prompt: AsyncIterable<SDKUserMessage>; |
| 351 | 374 | readonly options: ClaudeQueryOptions; |
| 352 | 375 | }) => ClaudeQueryRuntime; |
| 376 | readonly getSessionMessages?: typeof getSessionMessages; | |
| 377 | readonly forkSession?: typeof forkSession; | |
| 353 | 378 | readonly nativeEventLogPath?: string; |
| 354 | 379 | readonly nativeEventLogger?: EventNdjsonLogger; |
| 355 | 380 | readonly modelCatalog?: Effect.Effect<ClaudeModelCatalog>; |
| ... | ... | @@ -854,6 +879,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef |
| 854 | 879 | sessionId?: unknown; |
| 855 | 880 | resumeSessionAt?: unknown; |
| 856 | 881 | turnCount?: unknown; |
| 882 | turnStartMessageIds?: unknown; | |
| 857 | 883 | }; |
| 858 | 884 | |
| 859 | 885 | const threadIdCandidate = typeof cursor.threadId === "string" ? cursor.threadId : undefined; |
| ... | ... | @@ -871,11 +897,17 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef |
| 871 | 897 | const resumeSessionAt = |
| 872 | 898 | typeof cursor.resumeSessionAt === "string" ? cursor.resumeSessionAt : undefined; |
| 873 | 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; | |
| 874 | 905 | |
| 875 | 906 | return { |
| 876 | 907 | ...(threadId ? { threadId } : {}), |
| 877 | 908 | ...(resume ? { resume } : {}), |
| 878 | 909 | ...(resumeSessionAt ? { resumeSessionAt } : {}), |
| 910 | ...(turnStartMessageIds ? { turnStartMessageIds } : {}), | |
| 879 | 911 | ...(turnCountValue !== undefined && Number.isInteger(turnCountValue) && turnCountValue >= 0 |
| 880 | 912 | ? { turnCount: turnCountValue } |
| 881 | 913 | : {}), |
| ... | ... | @@ -1927,6 +1959,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1927 | 1959 | const path = yield* Path.Path; |
| 1928 | 1960 | const serverConfig = yield* ServerConfig; |
| 1929 | 1961 | const crypto = yield* Crypto.Crypto; |
| 1962 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 1930 | 1963 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( |
| 1931 | 1964 | Effect.provideService(Path.Path, path), |
| 1932 | 1965 | ); |
| ... | ... | @@ -2043,7 +2076,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 2043 | 2076 | threadId, |
| 2044 | 2077 | ...(context.resumeSessionId ? { resume: context.resumeSessionId } : {}), |
| 2045 | 2078 | ...(context.lastAssistantUuid ? { resumeSessionAt: context.lastAssistantUuid } : {}), |
| 2046 | turnCount: context.turns.length, | |
| 2079 | turnCount: context.turnStartMessageIds.length, | |
| 2080 | turnStartMessageIds: [...context.turnStartMessageIds], | |
| 2047 | 2081 | }; |
| 2048 | 2082 | |
| 2049 | 2083 | context.session = { |
| ... | ... | @@ -3155,6 +3189,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3155 | 3189 | if (!context.turnState) { |
| 3156 | 3190 | const turnId = TurnId.make(yield* randomUUIDv4); |
| 3157 | 3191 | const startedAt = yield* nowIso; |
| 3192 | context.turnStartMessageIds.push(message.uuid); | |
| 3158 | 3193 | context.turnState = { |
| 3159 | 3194 | turnId, |
| 3160 | 3195 | startedAt, |
| ... | ... | @@ -3177,6 +3212,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3177 | 3212 | activeTurnId: turnId, |
| 3178 | 3213 | updatedAt: startedAt, |
| 3179 | 3214 | }; |
| 3215 | yield* updateResumeCursor(context); | |
| 3180 | 3216 | const turnStartedStamp = yield* makeEventStamp(); |
| 3181 | 3217 | yield* offerRuntimeEvent({ |
| 3182 | 3218 | type: "turn.started", |
| ... | ... | @@ -4779,6 +4815,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4779 | 4815 | ...(sessionId ? { resume: sessionId } : {}), |
| 4780 | 4816 | ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), |
| 4781 | 4817 | turnCount: resumeState?.turnCount ?? 0, |
| 4818 | ...(resumeState?.turnStartMessageIds | |
| 4819 | ? { turnStartMessageIds: resumeState.turnStartMessageIds } | |
| 4820 | : {}), | |
| 4782 | 4821 | }, |
| 4783 | 4822 | createdAt: startedAt, |
| 4784 | 4823 | updatedAt: startedAt, |
| ... | ... | @@ -4786,6 +4825,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4786 | 4825 | |
| 4787 | 4826 | const context: ClaudeSessionContext = { |
| 4788 | 4827 | session, |
| 4828 | startInput: input, | |
| 4829 | turnStartMessageIds: resumeState?.turnStartMessageIds | |
| 4830 | ? [...resumeState.turnStartMessageIds] | |
| 4831 | : Array.from({ length: resumeState?.turnCount ?? 0 }, () => null), | |
| 4789 | 4832 | promptQueue, |
| 4790 | 4833 | query: queryRuntime, |
| 4791 | 4834 | streamFiber: undefined, |
| ... | ... | @@ -4899,6 +4942,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 4899 | 4942 | const modelSelection = selectedModel |
| 4900 | 4943 | ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) } |
| 4901 | 4944 | : undefined; |
| 4945 | if (modelSelection) { | |
| 4946 | context.startInput = { ...context.startInput, modelSelection }; | |
| 4947 | } | |
| 4902 | 4948 | |
| 4903 | 4949 | // A sendTurn while a real turn is running is a steer: the message is |
| 4904 | 4950 | // 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 | 5061 | ), |
| 5016 | 5062 | }); |
| 5017 | 5063 | |
| 5064 | if (steeringTurnState === null) context.turnStartMessageIds.push(turnId); | |
| 5065 | yield* updateResumeCursor(context); | |
| 5018 | 5066 | yield* Queue.offer(context.promptQueue, { |
| 5019 | 5067 | type: "message", |
| 5020 | message, | |
| 5068 | message: | |
| 5069 | steeringTurnState === null | |
| 5070 | ? { ...message, uuid: turnId as NonNullable<SDKUserMessage["uuid"]> } | |
| 5071 | : message, | |
| 5021 | 5072 | }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause))); |
| 5022 | 5073 | |
| 5023 | 5074 | return { |
| ... | ... | @@ -5049,10 +5100,190 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 5049 | 5100 | const rollbackThread: ClaudeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( |
| 5050 | 5101 | function* (threadId, numTurns) { |
| 5051 | 5102 | const context = yield* requireSession(threadId); |
| 5052 | const nextLength = Math.max(0, context.turns.length - numTurns); | |
| 5053 | context.turns.splice(nextLength); | |
| 5054 | yield* updateResumeCursor(context); | |
| 5055 | return yield* snapshotThread(context); | |
| 5103 | if (!Number.isInteger(numTurns) || numTurns < 1) { | |
| 5104 | return yield* new ProviderAdapterValidationError({ | |
| 5105 | provider: PROVIDER, | |
| 5106 | operation: "rollbackThread", | |
| 5107 | issue: "numTurns must be an integer >= 1.", | |
| 5108 | }); | |
| 5109 | } | |
| 5110 | if ( | |
| 5111 | context.turnStartMessageIds.length > 0 && | |
| 5112 | context.turnStartMessageIds.every((id) => id !== null) && | |
| 5113 | numTurns >= context.turnStartMessageIds.length | |
| 5114 | ) { | |
| 5115 | yield* stopSessionInternal(context, { emitExitEvent: false }); | |
| 5116 | yield* startSession({ | |
| 5117 | ...context.startInput, | |
| 5118 | runtimeMode: context.session.runtimeMode, | |
| 5119 | resumeCursor: undefined, | |
| 5120 | }); | |
| 5121 | return yield* snapshotThread(yield* requireSession(threadId)); | |
| 5122 | } | |
| 5123 | const sessionId = context.resumeSessionId; | |
| 5124 | if (!sessionId) { | |
| 5125 | return yield* new ProviderAdapterRequestError({ | |
| 5126 | provider: PROVIDER, | |
| 5127 | method: "thread/rollback", | |
| 5128 | detail: "Claude session id is unavailable.", | |
| 5129 | }); | |
| 5130 | } | |
| 5131 | const historyWorkerPath = yield* path | |
| 5132 | .fromFileUrl( | |
| 5133 | new URL( | |
| 5134 | import.meta.url.endsWith(".ts") | |
| 5135 | ? "../../claudeHistoryWorker.ts" | |
| 5136 | : "./claudeHistoryWorker.mjs", | |
| 5137 | import.meta.url, | |
| 5138 | ), | |
| 5139 | ) | |
| 5140 | .pipe(Effect.mapError((cause) => toRequestError(threadId, "thread/rollback", cause))); | |
| 5141 | const runScopedHistoryCommand = async ( | |
| 5142 | method: "getSessionMessages" | "forkSession", | |
| 5143 | args: object, | |
| 5144 | historySessionId = sessionId, | |
| 5145 | ) => { | |
| 5146 | // SDK history helpers read process.env. Isolate the provider's home instead | |
| 5147 | // of changing the server's environment while other providers are running. | |
| 5148 | const result = await Effect.runPromise( | |
| 5149 | spawnAndCollect( | |
| 5150 | process.execPath, | |
| 5151 | ChildProcess.make( | |
| 5152 | process.execPath, | |
| 5153 | [historyWorkerPath, method, historySessionId, encodeHistoryArgs(args)], | |
| 5154 | { env: { ...claudeEnvironment, ELECTRON_RUN_AS_NODE: "1" } }, | |
| 5155 | ), | |
| 5156 | ).pipe( | |
| 5157 | Effect.timeout("30 seconds"), | |
| 5158 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | |
| 5159 | ), | |
| 5160 | ); | |
| 5161 | if (result.code !== 0) throw new Error(result.stderr || "Claude history command failed."); | |
| 5162 | return result.stdout; | |
| 5163 | }; | |
| 5164 | const readHistory = (historySessionId: string) => | |
| 5165 | Effect.tryPromise({ | |
| 5166 | try: async () => { | |
| 5167 | const readOptions = { | |
| 5168 | ...(context.session.cwd ? { dir: context.session.cwd } : {}), | |
| 5169 | includeSystemMessages: true, | |
| 5170 | }; | |
| 5171 | if (options?.getSessionMessages) | |
| 5172 | return options.getSessionMessages(historySessionId, readOptions); | |
| 5173 | if (claudeEnvironment.CLAUDE_CONFIG_DIR === process.env.CLAUDE_CONFIG_DIR) { | |
| 5174 | return getSessionMessages(historySessionId, readOptions); | |
| 5175 | } | |
| 5176 | return decodeSessionMessages( | |
| 5177 | await runScopedHistoryCommand("getSessionMessages", readOptions, historySessionId), | |
| 5178 | ); | |
| 5179 | }, | |
| 5180 | catch: (cause) => toRequestError(threadId, "thread/rollback", cause), | |
| 5181 | }); | |
| 5182 | const messages = yield* readHistory(sessionId); | |
| 5183 | // Tool results are user-role messages too. Only human prompts begin a turn. | |
| 5184 | const turnStarts = messages.flatMap((message, index) => { | |
| 5185 | if (message.type !== "user" || message.parent_tool_use_id !== null) return []; | |
| 5186 | const body = message.message; | |
| 5187 | if (typeof body !== "object" || body === null || !("content" in body)) return []; | |
| 5188 | const content = body.content; | |
| 5189 | return typeof content === "string" || | |
| 5190 | (Array.isArray(content) && | |
| 5191 | content.some( | |
| 5192 | (part: unknown) => | |
| 5193 | typeof part === "object" && | |
| 5194 | part !== null && | |
| 5195 | "type" in part && | |
| 5196 | part.type !== "tool_result", | |
| 5197 | )) | |
| 5198 | ? [index] | |
| 5199 | : []; | |
| 5200 | }); | |
| 5201 | if (messages.length === 0) { | |
| 5202 | return yield* new ProviderAdapterRequestError({ | |
| 5203 | provider: PROVIDER, | |
| 5204 | method: "thread/rollback", | |
| 5205 | detail: "Claude session history is unavailable.", | |
| 5206 | }); | |
| 5207 | } | |
| 5208 | const boundaries = [...context.turnStartMessageIds]; | |
| 5209 | // Older cursors did not record native boundaries. Infer them only when | |
| 5210 | // their T3 turn count agrees; steers must never be treated as extra turns. | |
| 5211 | if ( | |
| 5212 | boundaries.every((id): boolean => id === null) && | |
| 5213 | boundaries.length === turnStarts.length | |
| 5214 | ) { | |
| 5215 | boundaries.splice( | |
| 5216 | 0, | |
| 5217 | boundaries.length, | |
| 5218 | ...turnStarts.map((index) => messages[index]!.uuid), | |
| 5219 | ); | |
| 5220 | } | |
| 5221 | const retainedCount = Math.max(0, boundaries.length - numTurns); | |
| 5222 | const firstRemovedId = boundaries[retainedCount]; | |
| 5223 | const firstRemoved = messages.findIndex((message) => message.uuid === firstRemovedId); | |
| 5224 | if ( | |
| 5225 | boundaries.length === 0 || | |
| 5226 | boundaries.some((id) => id === null) || | |
| 5227 | (retainedCount > 0 && firstRemoved < 1) | |
| 5228 | ) { | |
| 5229 | return yield* new ProviderAdapterRequestError({ | |
| 5230 | provider: PROVIDER, | |
| 5231 | method: "thread/rollback", | |
| 5232 | detail: | |
| 5233 | "The exact Claude turn boundary is unavailable, possibly after compaction or recovery of older history. Start a new thread instead.", | |
| 5234 | }); | |
| 5235 | } | |
| 5236 | const rollbackAt = retainedCount > 0 ? messages[firstRemoved - 1]?.uuid : undefined; | |
| 5237 | const retainedTurns = context.turns.slice(0, Math.max(0, context.turns.length - numTurns)); | |
| 5238 | const fork = rollbackAt | |
| 5239 | ? yield* Effect.tryPromise({ | |
| 5240 | try: async () => { | |
| 5241 | const forkOptions = { | |
| 5242 | ...(context.session.cwd ? { dir: context.session.cwd } : {}), | |
| 5243 | upToMessageId: rollbackAt, | |
| 5244 | }; | |
| 5245 | if (options?.forkSession) return options.forkSession(sessionId, forkOptions); | |
| 5246 | if (claudeEnvironment.CLAUDE_CONFIG_DIR === process.env.CLAUDE_CONFIG_DIR) { | |
| 5247 | return forkSession(sessionId, forkOptions); | |
| 5248 | } | |
| 5249 | return decodeHistoryFork(await runScopedHistoryCommand("forkSession", forkOptions)); | |
| 5250 | }, | |
| 5251 | catch: (cause) => toRequestError(threadId, "thread/rollback", cause), | |
| 5252 | }) | |
| 5253 | : undefined; | |
| 5254 | const retainedBoundaries = boundaries.slice(0, retainedCount); | |
| 5255 | if (fork) { | |
| 5256 | const forkMessages = yield* readHistory(fork.sessionId); | |
| 5257 | if (forkMessages.length !== firstRemoved) { | |
| 5258 | return yield* new ProviderAdapterRequestError({ | |
| 5259 | provider: PROVIDER, | |
| 5260 | method: "thread/rollback", | |
| 5261 | detail: "Claude fork history did not preserve the retained turn boundaries.", | |
| 5262 | }); | |
| 5263 | } | |
| 5264 | // Native forks replace every UUID while preserving transcript order. | |
| 5265 | for (let index = 0; index < retainedBoundaries.length; index++) { | |
| 5266 | const messageIndex = messages.findIndex( | |
| 5267 | (message) => message.uuid === retainedBoundaries[index], | |
| 5268 | ); | |
| 5269 | retainedBoundaries[index] = forkMessages[messageIndex]?.uuid ?? null; | |
| 5270 | } | |
| 5271 | } | |
| 5272 | yield* stopSessionInternal(context, { emitExitEvent: false }); | |
| 5273 | yield* startSession({ | |
| 5274 | ...context.startInput, | |
| 5275 | runtimeMode: context.session.runtimeMode, | |
| 5276 | resumeCursor: fork | |
| 5277 | ? { | |
| 5278 | resume: fork.sessionId, | |
| 5279 | turnCount: retainedCount, | |
| 5280 | turnStartMessageIds: retainedBoundaries, | |
| 5281 | } | |
| 5282 | : undefined, | |
| 5283 | }); | |
| 5284 | const restarted = yield* requireSession(threadId); | |
| 5285 | restarted.turns.push(...retainedTurns); | |
| 5286 | return yield* snapshotThread(restarted); | |
| 5056 | 5287 | }, |
| 5057 | 5288 | ); |
| 5058 | 5289 |
apps/server/src/provider/Layers/CodexSessionRuntime.test.ts+91| ... | ... | @@ -18,10 +18,101 @@ import { |
| 18 | 18 | isRecoverableThreadResumeError, |
| 19 | 19 | makeMemoryConsolidationNotificationFilter, |
| 20 | 20 | openCodexThread, |
| 21 | readCodexThread, | |
| 22 | rollbackCodexThread, | |
| 21 | 23 | toMcpElicitationResponse, |
| 22 | 24 | } from "./CodexSessionRuntime.ts"; |
| 23 | 25 | const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); |
| 24 | 26 | |
| 27 | describe("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 | ||
| 25 | 116 | describe("CodexSessionRuntimeIdentifierGenerationError", () => { |
| 26 | 117 | it("retains identifier purpose and the random source failure", () => { |
| 27 | 118 | const cause = new Error("random source unavailable"); |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+94-10| ... | ... | @@ -1192,6 +1192,97 @@ function parseThreadSnapshot( |
| 1192 | 1192 | }; |
| 1193 | 1193 | } |
| 1194 | 1194 | |
| 1195 | const CodexThreadHistoryMetadata = Schema.Struct({ | |
| 1196 | thread: Schema.Struct({ | |
| 1197 | historyMode: Schema.optionalKey(Schema.Literals(["legacy", "paginated"])), | |
| 1198 | }), | |
| 1199 | }); | |
| 1200 | const CodexTurnsPage = Schema.Struct({ | |
| 1201 | data: Schema.Array(EffectCodexSchema.V2ThreadReadResponse__Turn), | |
| 1202 | nextCursor: Schema.NullOr(Schema.String), | |
| 1203 | }); | |
| 1204 | const decodeCodexHistoryMetadata = Schema.decodeUnknownEffect(CodexThreadHistoryMetadata); | |
| 1205 | const decodeCodexTurnsPage = Schema.decodeUnknownEffect(CodexTurnsPage); | |
| 1206 | type CodexHistoryClient = { | |
| 1207 | readonly raw: Pick<CodexClient.CodexAppServerClient["Service"]["raw"], "request">; | |
| 1208 | readonly request: CodexClient.CodexAppServerClient["Service"]["request"]; | |
| 1209 | }; | |
| 1210 | ||
| 1211 | const 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 | ||
| 1224 | export 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 | ||
| 1267 | export 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 | ||
| 1195 | 1286 | export const makeCodexSessionRuntime = ( |
| 1196 | 1287 | options: CodexSessionRuntimeOptions, |
| 1197 | 1288 | ): Effect.Effect< |
| ... | ... | @@ -2435,24 +2526,17 @@ export const makeCodexSessionRuntime = ( |
| 2435 | 2526 | }), |
| 2436 | 2527 | readThread: Effect.gen(function* () { |
| 2437 | 2528 | const providerThreadId = yield* readProviderThreadId; |
| 2438 | const response = yield* client.request("thread/read", { | |
| 2439 | threadId: providerThreadId, | |
| 2440 | includeTurns: true, | |
| 2441 | }); | |
| 2442 | return parseThreadSnapshot(response); | |
| 2529 | return yield* readCodexThread(client, providerThreadId); | |
| 2443 | 2530 | }), |
| 2444 | 2531 | rollbackThread: (numTurns) => |
| 2445 | 2532 | Effect.gen(function* () { |
| 2446 | 2533 | const providerThreadId = yield* readProviderThreadId; |
| 2447 | const response = yield* client.request("thread/rollback", { | |
| 2448 | threadId: providerThreadId, | |
| 2449 | numTurns, | |
| 2450 | }); | |
| 2534 | const snapshot = yield* rollbackCodexThread(client, providerThreadId, numTurns); | |
| 2451 | 2535 | yield* updateSession(sessionRef, { |
| 2452 | 2536 | status: "ready", |
| 2453 | 2537 | activeTurnId: undefined, |
| 2454 | 2538 | }); |
| 2455 | return parseThreadSnapshot(response); | |
| 2539 | return snapshot; | |
| 2456 | 2540 | }), |
| 2457 | 2541 | uploadFeedback: (reason) => |
| 2458 | 2542 | Effect.gen(function* () { |
apps/server/src/provider/Layers/CursorAdapter.test.ts+22| ... | ... | @@ -162,6 +162,28 @@ const cursorAdapterTestLayer = it.layer( |
| 162 | 162 | ); |
| 163 | 163 | |
| 164 | 164 | cursorAdapterTestLayer("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 | 187 | it.effect("rejects a Cursor transport error returned as a successful assistant answer", () => |
| 166 | 188 | Effect.gen(function* () { |
| 167 | 189 | const adapter = yield* CursorAdapter; |
apps/server/src/provider/Layers/CursorAdapter.ts+7-5| ... | ... | @@ -1187,7 +1187,7 @@ export function makeCursorAdapter( |
| 1187 | 1187 | |
| 1188 | 1188 | const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => |
| 1189 | 1189 | Effect.gen(function* () { |
| 1190 | const ctx = yield* requireSession(threadId); | |
| 1190 | yield* requireSession(threadId); | |
| 1191 | 1191 | if (!Number.isInteger(numTurns) || numTurns < 1) { |
| 1192 | 1192 | return yield* new ProviderAdapterValidationError({ |
| 1193 | 1193 | provider: PROVIDER, |
| ... | ... | @@ -1195,9 +1195,11 @@ export function makeCursorAdapter( |
| 1195 | 1195 | issue: "numTurns must be an integer >= 1.", |
| 1196 | 1196 | }); |
| 1197 | 1197 | } |
| 1198 | const nextLength = Math.max(0, ctx.turns.length - numTurns); | |
| 1199 | ctx.turns.splice(nextLength); | |
| 1200 | return { threadId, turns: ctx.turns }; | |
| 1198 | return yield* new ProviderAdapterRequestError({ | |
| 1199 | provider: PROVIDER, | |
| 1200 | method: "thread/rollback", | |
| 1201 | detail: "Cursor ACP sessions do not support provider-side rollback.", | |
| 1202 | }); | |
| 1201 | 1203 | }); |
| 1202 | 1204 | |
| 1203 | 1205 | const stopSession: CursorAdapterShape["stopSession"] = (threadId) => |
| ... | ... | @@ -1235,7 +1237,7 @@ export function makeCursorAdapter( |
| 1235 | 1237 | |
| 1236 | 1238 | return { |
| 1237 | 1239 | provider: PROVIDER, |
| 1238 | capabilities: { sessionModelSwitch: "in-session" }, | |
| 1240 | capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, | |
| 1239 | 1241 | compaction: { type: "slash-command", command: "/compress" }, |
| 1240 | 1242 | startSession, |
| 1241 | 1243 | sendTurn, |
apps/server/src/provider/Layers/CursorProvider.test.ts+1| ... | ... | @@ -492,6 +492,7 @@ describe("buildCursorProviderSnapshot", () => { |
| 492 | 492 | status: "warning", |
| 493 | 493 | message: "Cursor ACP model discovery timed out after 15000ms.", |
| 494 | 494 | models: [], |
| 495 | supportsConversationRollback: false, | |
| 495 | 496 | }); |
| 496 | 497 | }); |
| 497 | 498 |
apps/server/src/provider/Layers/CursorProvider.ts+1| ... | ... | @@ -55,6 +55,7 @@ const decodeCursorListAvailableModelsResponse = Schema.decodeUnknownEffect( |
| 55 | 55 | ); |
| 56 | 56 | const CURSOR_PRESENTATION = { |
| 57 | 57 | displayName: "Cursor", |
| 58 | supportsConversationRollback: false, | |
| 58 | 59 | badgeLabel: "Early Access", |
| 59 | 60 | showInteractionModeToggle: true, |
| 60 | 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 | 212 | }); |
| 213 | 213 | |
| 214 | 214 | it.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 | 235 | it.effect("sends runtime context with the current model without changing saved prompts", () => |
| 216 | 236 | Effect.gen(function* () { |
| 217 | 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 | 2134 | |
| 2135 | 2135 | return { |
| 2136 | 2136 | provider: PROVIDER, |
| 2137 | capabilities: { sessionModelSwitch: "in-session" }, | |
| 2137 | capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, | |
| 2138 | 2138 | compaction: { type: "slash-command", command: "/compact" }, |
| 2139 | 2139 | startSession, |
| 2140 | 2140 | sendTurn, |
apps/server/src/provider/Layers/GrokProvider.test.ts+1| ... | ... | @@ -282,6 +282,7 @@ describe("buildInitialGrokProviderSnapshot", () => { |
| 282 | 282 | expect(snapshot.version).toBeNull(); |
| 283 | 283 | expect(snapshot.message).toContain("Checking Grok"); |
| 284 | 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 | 44 | |
| 45 | 45 | const GROK_PRESENTATION = { |
| 46 | 46 | displayName: "Grok", |
| 47 | supportsConversationRollback: false, | |
| 47 | 48 | badgeLabel: "Early Access", |
| 48 | 49 | showInteractionModeToggle: false, |
| 49 | 50 | } as const; |
apps/server/src/provider/Layers/ProviderService.test.ts+97-1| ... | ... | @@ -1654,6 +1654,9 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1654 | 1654 | it.effect("routes provider operations and rollback conversation", () => |
| 1655 | 1655 | Effect.gen(function* () { |
| 1656 | 1656 | const provider = yield* ProviderService.ProviderService; |
| 1657 | const modelSelection = createModelSelection(codexInstanceId, "gpt-5.6-sol", [ | |
| 1658 | { id: "reasoningEffort", value: "high" }, | |
| 1659 | ]); | |
| 1657 | 1660 | |
| 1658 | 1661 | const session = yield* provider.startSession(asThreadId("thread-1"), { |
| 1659 | 1662 | provider: ProviderDriverKind.make("codex"), |
| ... | ... | @@ -1671,6 +1674,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1671 | 1674 | threadId: session.threadId, |
| 1672 | 1675 | input: "hello", |
| 1673 | 1676 | attachments: [], |
| 1677 | modelSelection, | |
| 1674 | 1678 | }); |
| 1675 | 1679 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); |
| 1676 | 1680 | |
| ... | ... | @@ -1708,6 +1712,21 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1708 | 1712 | numTurns: 0, |
| 1709 | 1713 | }); |
| 1710 | 1714 | |
| 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 | 1730 | yield* provider.stopSession({ threadId: session.threadId }); |
| 1712 | 1731 | routing.codex.startSession.mockClear(); |
| 1713 | 1732 | routing.codex.sendTurn.mockClear(); |
| ... | ... | @@ -1727,16 +1746,93 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 1727 | 1746 | cwd?: string; |
| 1728 | 1747 | resumeCursor?: unknown; |
| 1729 | 1748 | threadId?: string; |
| 1749 | modelSelection?: unknown; | |
| 1730 | 1750 | }; |
| 1731 | 1751 | assert.equal(startPayload.provider, "codex"); |
| 1732 | 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 | 1755 | assert.equal(startPayload.threadId, session.threadId); |
| 1735 | 1756 | } |
| 1736 | 1757 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); |
| 1737 | 1758 | }), |
| 1738 | 1759 | ); |
| 1739 | 1760 | |
| 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 | 1836 | it.effect("marks a successful fallback compaction as compacted", () => |
| 1741 | 1837 | Effect.gen(function* () { |
| 1742 | 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 | 1100 | canonicalEvent.type === "turn.aborted" |
| 1101 | 1101 | ) { |
| 1102 | 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 | 1132 | } else if (canonicalEvent.type === "session.exited") { |
| 1104 | 1133 | yield* clearTurnAnalyticsSession(source.instanceId, canonicalEvent.threadId); |
| 1105 | 1134 | } |
| ... | ... | @@ -2006,6 +2035,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 2006 | 2035 | "provider.thread_id": input.threadId, |
| 2007 | 2036 | }); |
| 2008 | 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 | 2047 | yield* routed.adapter.stopSession(routed.threadId); |
| 2010 | 2048 | } |
| 2011 | 2049 | const pendingCompaction = pendingCompactions.get(input.threadId); |
| ... | ... | @@ -2176,6 +2214,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 2176 | 2214 | "provider.rollback_turns": input.numTurns, |
| 2177 | 2215 | }); |
| 2178 | 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 | 2226 | yield* analytics.record("provider.conversation.rolled_back", { |
| 2180 | 2227 | provider: routed.adapter.provider, |
| 2181 | 2228 | turns: input.numTurns, |
apps/server/src/provider/providerSnapshot.ts+4| ... | ... | @@ -66,6 +66,7 @@ export interface ServerProviderPresentation { |
| 66 | 66 | readonly showInteractionModeToggle?: boolean; |
| 67 | 67 | readonly reportsContextWindow?: boolean; |
| 68 | 68 | readonly requiresNewThreadForModelChange?: boolean; |
| 69 | readonly supportsConversationRollback?: boolean; | |
| 69 | 70 | } |
| 70 | 71 | |
| 71 | 72 | export type ServerProviderDraft = Omit<ServerProvider, "instanceId" | "driver">; |
| ... | ... | @@ -209,6 +210,9 @@ export function buildServerProvider(input: { |
| 209 | 210 | : undefined; |
| 210 | 211 | return { |
| 211 | 212 | displayName: input.presentation.displayName, |
| 213 | ...(typeof input.presentation.supportsConversationRollback === "boolean" | |
| 214 | ? { supportsConversationRollback: input.presentation.supportsConversationRollback } | |
| 215 | : {}), | |
| 212 | 216 | ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), |
| 213 | 217 | ...(typeof input.presentation.showInteractionModeToggle === "boolean" |
| 214 | 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 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 8 | 8 | import { assert, it } from "@effect/vitest"; |
| 9 | 9 | |
| 10 | import { GitCommandError } from "@t3tools/contracts"; | |
| 10 | import { CheckpointRef, GitCommandError } from "@t3tools/contracts"; | |
| 11 | 11 | import * as ServerConfig from "../config.ts"; |
| 12 | 12 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 13 | 13 | import * as VcsProcess from "./VcsProcess.ts"; |
| ... | ... | @@ -65,6 +65,70 @@ runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({ |
| 65 | 65 | }, |
| 66 | 66 | }); |
| 67 | 67 | |
| 68 | it.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 | ||
| 68 | 132 | it.effect("GitVcsDriver forwards execute env to the VCS process", () => { |
| 69 | 133 | let observedEnv: NodeJS.ProcessEnv | undefined; |
| 70 | 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 | 811 | return false; |
| 812 | 812 | } |
| 813 | 813 | |
| 814 | yield* execute({ | |
| 814 | const tracked = yield* execute({ | |
| 815 | 815 | operation, |
| 816 | 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 | 841 | operation, |
| 821 | 842 | cwd: input.cwd, |
| 822 | 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 | } | |
| 824 | 864 | |
| 825 | 865 | const headExists = yield* hasHeadCommit(input.cwd); |
| 826 | 866 | if (headExists) { |
apps/server/vite.config.ts+1-1| ... | ... | @@ -36,7 +36,7 @@ export default mergeConfig( |
| 36 | 36 | }, |
| 37 | 37 | }, |
| 38 | 38 | pack: { |
| 39 | entry: ["src/bin.ts"], | |
| 39 | entry: ["src/bin.ts", "src/claudeHistoryWorker.ts"], | |
| 40 | 40 | outDir: "dist", |
| 41 | 41 | sourcemap: true, |
| 42 | 42 | clean: true, |
apps/web/src/components/ChatView.logic.test.ts+138| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { |
| 2 | 2 | ANTIGRAVITY_DEFAULT_MODEL, |
| 3 | CheckpointRef, | |
| 3 | 4 | EnvironmentId, |
| 5 | EventId, | |
| 4 | 6 | MessageId, |
| 5 | 7 | ProjectId, |
| 6 | 8 | ProviderDriverKind, |
| ... | ... | @@ -10,6 +12,9 @@ import { |
| 10 | 12 | TurnId, |
| 11 | 13 | } from "@t3tools/contracts"; |
| 12 | 14 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 15 | import { Atom, AsyncResult } from "effect/unstable/reactivity"; | |
| 16 | import { appAtomRegistry } from "../rpc/atomRegistry"; | |
| 17 | import { environmentThreadDetails } from "../state/threads"; | |
| 13 | 18 | |
| 14 | 19 | import type { Thread, ThreadShell, TurnDiffSummary } from "../types"; |
| 15 | 20 | import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; |
| ... | ... | @@ -80,6 +85,8 @@ import { |
| 80 | 85 | shouldShowPlanFollowUpPrompt, |
| 81 | 86 | shouldWriteThreadErrorToCurrentServerThread, |
| 82 | 87 | toolGroupConsumesUpwardNavigation, |
| 88 | waitForRevertedMessage, | |
| 89 | prepareRevertedMessageAttachments, | |
| 83 | 90 | } from "./ChatView.logic"; |
| 84 | 91 | |
| 85 | 92 | describe("agent browser close confirmation", () => { |
| ... | ... | @@ -2242,3 +2249,134 @@ describe("threadShellHasStarted", () => { |
| 2242 | 2249 | expect(threadShellHasStarted(null)).toBe(false); |
| 2243 | 2250 | }); |
| 2244 | 2251 | }); |
| 2252 | ||
| 2253 | describe("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 | 737 | return url; |
| 738 | 738 | } |
| 739 | 739 | |
| 740 | export 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 | ||
| 740 | 772 | export function revokeUserMessagePreviewUrls(message: ChatMessage): void { |
| 741 | 773 | if (message.role !== "user" || !message.attachments) { |
| 742 | 774 | return; |
| ... | ... | @@ -1105,6 +1137,81 @@ export async function waitForStartedServerThread( |
| 1105 | 1137 | }); |
| 1106 | 1138 | } |
| 1107 | 1139 | |
| 1140 | export 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 | ||
| 1108 | 1215 | export interface LocalDispatchSnapshot { |
| 1109 | 1216 | startedAt: string; |
| 1110 | 1217 | preparingWorktree: boolean; |
apps/web/src/components/ChatView.tsx+114-29| ... | ... | @@ -228,7 +228,7 @@ import { |
| 228 | 228 | PaperclipIcon, |
| 229 | 229 | WifiOffIcon, |
| 230 | 230 | } from "lucide-react"; |
| 231 | import { cn, randomHex } from "~/lib/utils"; | |
| 231 | import { cn, randomHex, randomUUID } from "~/lib/utils"; | |
| 232 | 232 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 233 | 233 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 234 | 234 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| ... | ... | @@ -410,6 +410,8 @@ import { |
| 410 | 410 | deriveLockedProvider, |
| 411 | 411 | readFileAsDataUrl, |
| 412 | 412 | resolveFileAttachmentUrl, |
| 413 | prepareRevertedMessageAttachments, | |
| 414 | waitForRevertedMessage, | |
| 413 | 415 | reconcileMountedTerminalThreadIds, |
| 414 | 416 | recallCheckoutIsRepo, |
| 415 | 417 | rememberCheckoutIsRepo, |
| ... | ... | @@ -485,7 +487,10 @@ import { |
| 485 | 487 | supportsServerUpdateThreadContinuation, |
| 486 | 488 | } from "../versionSkew"; |
| 487 | 489 | import { useAssetUrls } from "../assets/assetUrls"; |
| 488 | import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; | |
| 490 | import { | |
| 491 | ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, | |
| 492 | recallableComposerPrompt, | |
| 493 | } from "./chat/composerPromptHistory"; | |
| 489 | 494 | |
| 490 | 495 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 491 | 496 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| ... | ... | @@ -1427,6 +1432,13 @@ export default function ChatView(props: ChatViewProps) { |
| 1427 | 1432 | [environmentId, threadId], |
| 1428 | 1433 | ); |
| 1429 | 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 | 1442 | const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, { |
| 1431 | 1443 | reportFailure: false, |
| 1432 | 1444 | }); |
| ... | ... | @@ -1641,7 +1653,9 @@ export default function ChatView(props: ChatViewProps) { |
| 1641 | 1653 | Record<string, LocalThreadErrorEntry> |
| 1642 | 1654 | >({}); |
| 1643 | 1655 | const [isConnecting, _setIsConnecting] = useState(false); |
| 1644 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); | |
| 1656 | const isRevertingCheckpoint = useComposerDraftStore((store) => | |
| 1657 | store.rewindingThreadKeys.has(routeThreadKey), | |
| 1658 | ); | |
| 1645 | 1659 | const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState<string | null>( |
| 1646 | 1660 | null, |
| 1647 | 1661 | ); |
| ... | ... | @@ -5352,10 +5366,6 @@ export default function ChatView(props: ChatViewProps) { |
| 5352 | 5366 | // activeThreadRef resets transitively with the active thread. |
| 5353 | 5367 | }, [activeThread?.id]); |
| 5354 | 5368 | |
| 5355 | useEffect(() => { | |
| 5356 | setIsRevertingCheckpoint(false); | |
| 5357 | }, [activeThread?.id]); | |
| 5358 | ||
| 5359 | 5369 | useEffect(() => { |
| 5360 | 5370 | if (!activeThread?.id || terminalUiState.terminalOpen) return; |
| 5361 | 5371 | const frame = window.requestAnimationFrame(() => { |
| ... | ... | @@ -6524,9 +6534,11 @@ export default function ChatView(props: ChatViewProps) { |
| 6524 | 6534 | }, [activeThreadId, composerRef]); |
| 6525 | 6535 | |
| 6526 | 6536 | const onRevertToTurnCount = useCallback( |
| 6527 | async (turnCount: number) => { | |
| 6537 | async (turnCount: number, messageId: MessageId) => { | |
| 6528 | 6538 | const localApi = readLocalApi(); |
| 6529 | 6539 | if (!localApi || !activeThread || isRevertingCheckpoint) return; |
| 6540 | const message = activeThread.messages.find((message) => message.id === messageId); | |
| 6541 | if (!message || message.role !== "user") return; | |
| 6530 | 6542 | |
| 6531 | 6543 | if (!supportsConversationRollback) { |
| 6532 | 6544 | setThreadError( |
| ... | ... | @@ -6548,9 +6560,9 @@ export default function ChatView(props: ChatViewProps) { |
| 6548 | 6560 | } |
| 6549 | 6561 | const confirmed = await localApi.dialogs.confirm( |
| 6550 | 6562 | [ |
| 6551 | `Revert this thread to checkpoint ${turnCount}?`, | |
| 6552 | "This will discard newer messages and turn diffs in this thread.", | |
| 6553 | "This action cannot be undone.", | |
| 6563 | "Edit from here?", | |
| 6564 | "Rewind files and chat to before this message.", | |
| 6565 | "Your prompt and attachments return to the composer.", | |
| 6554 | 6566 | ].join("\n"), |
| 6555 | 6567 | { variant: "destructive" }, |
| 6556 | 6568 | ); |
| ... | ... | @@ -6558,34 +6570,102 @@ export default function ChatView(props: ChatViewProps) { |
| 6558 | 6570 | return; |
| 6559 | 6571 | } |
| 6560 | 6572 | |
| 6561 | setIsRevertingCheckpoint(true); | |
| 6573 | useComposerDraftStore.setState((store) => ({ | |
| 6574 | rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey), | |
| 6575 | })); | |
| 6562 | 6576 | setThreadError(activeThread.id, null); |
| 6563 | const result = await revertThreadCheckpoint({ | |
| 6564 | environmentId, | |
| 6565 | input: { | |
| 6566 | threadId: activeThread.id, | |
| 6567 | turnCount, | |
| 6568 | }, | |
| 6569 | }); | |
| 6570 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 6571 | const error = squashAtomCommandFailure(result); | |
| 6577 | try { | |
| 6578 | if (composerRef.current?.hasPendingAttachments()) { | |
| 6579 | throw new Error("Wait for attachments to finish preparing before rewinding."); | |
| 6580 | } | |
| 6581 | const connection = readPreparedConnection(environmentId); | |
| 6582 | if (!connection) throw new Error("The environment is not connected."); | |
| 6583 | const files = await prepareRevertedMessageAttachments({ | |
| 6584 | message, | |
| 6585 | environmentId, | |
| 6586 | httpBaseUrl: connection.httpBaseUrl, | |
| 6587 | createAssetUrl: createAttachmentAssetUrl, | |
| 6588 | }); | |
| 6589 | const store = useComposerDraftStore.getState(); | |
| 6590 | const draft = store.getComposerDraft(composerDraftTarget); | |
| 6591 | if ( | |
| 6592 | (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length > | |
| 6593 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS | |
| 6594 | ) { | |
| 6595 | throw new Error( | |
| 6596 | "Make room for this message's attachments in the composer before rewinding.", | |
| 6597 | ); | |
| 6598 | } | |
| 6599 | await waitForRevertedMessage(routeThreadRef, messageId, turnCount, async () => { | |
| 6600 | const result = await revertThreadCheckpoint({ | |
| 6601 | environmentId, | |
| 6602 | input: { threadId: activeThread.id, turnCount }, | |
| 6603 | }); | |
| 6604 | if (result._tag === "Failure") throw squashAtomCommandFailure(result); | |
| 6605 | }); | |
| 6606 | const currentPrompt = store.getComposerDraft(composerDraftTarget)?.prompt ?? ""; | |
| 6607 | const restoredPrompt = recallableComposerPrompt(message.text); | |
| 6608 | const nextPrompt = | |
| 6609 | restoredPrompt.length === 0 | |
| 6610 | ? currentPrompt | |
| 6611 | : currentPrompt.length > 0 | |
| 6612 | ? `${currentPrompt}\n\n${restoredPrompt}` | |
| 6613 | : restoredPrompt; | |
| 6614 | store.setPrompt(composerDraftTarget, nextPrompt); | |
| 6615 | const images: ComposerImageAttachment[] = []; | |
| 6616 | const restoredFiles: ComposerFileAttachment[] = []; | |
| 6617 | files.forEach((file, index) => { | |
| 6618 | const attachment = { | |
| 6619 | id: randomUUID(), | |
| 6620 | name: file.name, | |
| 6621 | mimeType: file.type, | |
| 6622 | sizeBytes: file.size, | |
| 6623 | file, | |
| 6624 | }; | |
| 6625 | if (message.attachments?.[index]?.type === "image") { | |
| 6626 | images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); | |
| 6627 | } else { | |
| 6628 | restoredFiles.push({ ...attachment, type: "file" }); | |
| 6629 | } | |
| 6630 | }); | |
| 6631 | store.addImages(composerDraftTarget, images, { allowDuplicates: true }); | |
| 6632 | store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true }); | |
| 6633 | if (currentRouteThreadKeyRef.current === routeThreadKey) { | |
| 6634 | promptRef.current = nextPrompt; | |
| 6635 | composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length }); | |
| 6636 | requestAnimationFrame(() => { | |
| 6637 | if (currentRouteThreadKeyRef.current === routeThreadKey) | |
| 6638 | composerRef.current?.focusAtEnd(); | |
| 6639 | }); | |
| 6640 | } | |
| 6641 | } catch (error) { | |
| 6572 | 6642 | setThreadError( |
| 6573 | 6643 | activeThread.id, |
| 6574 | 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 | 6655 | activeThread, |
| 6581 | 6656 | activeEnvironmentUnavailable, |
| 6582 | 6657 | activeEnvironmentUnavailableLabel, |
| 6658 | composerDraftTarget, | |
| 6659 | composerRef, | |
| 6660 | createAttachmentAssetUrl, | |
| 6583 | 6661 | environmentId, |
| 6584 | 6662 | isConnecting, |
| 6585 | 6663 | isRevertingCheckpoint, |
| 6586 | 6664 | isSendBusy, |
| 6587 | 6665 | phase, |
| 6588 | 6666 | revertThreadCheckpoint, |
| 6667 | routeThreadKey, | |
| 6668 | routeThreadRef, | |
| 6589 | 6669 | setThreadError, |
| 6590 | 6670 | supportsConversationRollback, |
| 6591 | 6671 | ], |
| ... | ... | @@ -6703,6 +6783,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6703 | 6783 | !activeThread || |
| 6704 | 6784 | isSendBusy || |
| 6705 | 6785 | isConnecting || |
| 6786 | isRevertingCheckpoint || | |
| 6706 | 6787 | !clientSettingsHydrated || |
| 6707 | 6788 | threadDetailLoading || |
| 6708 | 6789 | sendInFlightRef.current || |
| ... | ... | @@ -8155,8 +8236,8 @@ export default function ChatView(props: ChatViewProps) { |
| 8155 | 8236 | // reference is fully stable and never busts TimelineRowCtx identity. |
| 8156 | 8237 | const onRevertToTurnCountRef = useRef(onRevertToTurnCount); |
| 8157 | 8238 | onRevertToTurnCountRef.current = onRevertToTurnCount; |
| 8158 | const onRevertTimelineTurn = useCallback((targetTurnCount: number) => { | |
| 8159 | void onRevertToTurnCountRef.current(targetTurnCount); | |
| 8239 | const onRevertTimelineTurn = useCallback((targetTurnCount: number, messageId: MessageId) => { | |
| 8240 | void onRevertToTurnCountRef.current(targetTurnCount, messageId); | |
| 8160 | 8241 | }, []); |
| 8161 | 8242 | |
| 8162 | 8243 | // 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 | 8731 | {/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */} |
| 8651 | 8732 | <div |
| 8652 | 8733 | ref={setComposerOverlayElement} |
| 8734 | inert={isRevertingCheckpoint} | |
| 8653 | 8735 | data-chat-composer-overlay="true" |
| 8654 | 8736 | className={ |
| 8655 | 8737 | isDraftHeroState |
| ... | ... | @@ -8719,12 +8801,15 @@ export default function ChatView(props: ChatViewProps) { |
| 8719 | 8801 | phase={phase} |
| 8720 | 8802 | isConnecting={isConnecting} |
| 8721 | 8803 | isSendBusy={isSendBusy} |
| 8804 | isRevertingCheckpoint={isRevertingCheckpoint} | |
| 8722 | 8805 | sendDisabledReason={ |
| 8723 | feedbackUploading | |
| 8724 | ? "Sending feedback" | |
| 8725 | : threadDetailLoading | |
| 8726 | ? "Messages loading" | |
| 8727 | : null | |
| 8806 | isRevertingCheckpoint | |
| 8807 | ? "Rewinding conversation" | |
| 8808 | : feedbackUploading | |
| 8809 | ? "Sending feedback" | |
| 8810 | : threadDetailLoading | |
| 8811 | ? "Messages loading" | |
| 8812 | : null | |
| 8728 | 8813 | } |
| 8729 | 8814 | isPreparingWorktree={isPreparingWorktree} |
| 8730 | 8815 | bannerItems={composerBannerItems} |
apps/web/src/components/chat/ChatComposer.tsx+14-3| ... | ... | @@ -1157,6 +1157,7 @@ export interface ChatComposerHandle { |
| 1157 | 1157 | restoreAfterTimelineReachedEnd: () => void; |
| 1158 | 1158 | collapseForTimelineScrollKey: (key: string) => void; |
| 1159 | 1159 | addDroppedFiles: (files: File[]) => void; |
| 1160 | hasPendingAttachments: () => boolean; | |
| 1160 | 1161 | insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; |
| 1161 | 1162 | citeAssistantText: ( |
| 1162 | 1163 | citation: AssistantCitation, |
| ... | ... | @@ -1235,6 +1236,7 @@ export interface ChatComposerProps { |
| 1235 | 1236 | phase: SessionPhase; |
| 1236 | 1237 | isConnecting: boolean; |
| 1237 | 1238 | isSendBusy: boolean; |
| 1239 | isRevertingCheckpoint?: boolean; | |
| 1238 | 1240 | sendDisabledReason: string | null; |
| 1239 | 1241 | isPreparingWorktree: boolean; |
| 1240 | 1242 | bannerItems: readonly ComposerBannerStackItem[]; |
| ... | ... | @@ -1383,6 +1385,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1383 | 1385 | phase, |
| 1384 | 1386 | isConnecting, |
| 1385 | 1387 | isSendBusy, |
| 1388 | isRevertingCheckpoint = false, | |
| 1386 | 1389 | sendDisabledReason: externalSendDisabledReason, |
| 1387 | 1390 | isPreparingWorktree, |
| 1388 | 1391 | environmentUnavailable, |
| ... | ... | @@ -1970,6 +1973,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 1970 | 1973 | * the next draft. |
| 1971 | 1974 | */ |
| 1972 | 1975 | const pendingImageCompressionsRef = useRef<Map<string, number>>(new Map()); |
| 1976 | const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); | |
| 1977 | isRevertingCheckpointRef.current = isRevertingCheckpoint; | |
| 1973 | 1978 | |
| 1974 | 1979 | // ------------------------------------------------------------------ |
| 1975 | 1980 | // Derived: composer send state |
| ... | ... | @@ -3293,7 +3298,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 3293 | 3298 | // A thread switch during the verify await would mix the new thread's |
| 3294 | 3299 | // prompt with this invocation's captured target. Nothing was taken yet, |
| 3295 | 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 | 3305 | return; |
| 3298 | 3306 | } |
| 3299 | 3307 | |
| ... | ... | @@ -4270,7 +4278,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4270 | 4278 | // even when the composer is in a state that can't stash. |
| 4271 | 4279 | event.preventDefault(); |
| 4272 | 4280 | event.stopPropagation(); |
| 4273 | if (isCommandPaletteOpen()) { | |
| 4281 | if (isCommandPaletteOpen() || isRevertingCheckpoint) { | |
| 4274 | 4282 | return; |
| 4275 | 4283 | } |
| 4276 | 4284 | if (pendingUserInputs.length > 0 && !isComposerApprovalState) { |
| ... | ... | @@ -4292,6 +4300,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4292 | 4300 | pendingUserInputs.length, |
| 4293 | 4301 | projectSelectionRequired, |
| 4294 | 4302 | stashCurrentPrompt, |
| 4303 | isRevertingCheckpoint, | |
| 4295 | 4304 | terminalOpen, |
| 4296 | 4305 | ]); |
| 4297 | 4306 | |
| ... | ... | @@ -4299,7 +4308,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4299 | 4308 | // Callbacks: attachments |
| 4300 | 4309 | // ------------------------------------------------------------------ |
| 4301 | 4310 | const addComposerAttachments = async (files: File[]) => { |
| 4302 | if (!activeThreadId || files.length === 0) return; | |
| 4311 | if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return; | |
| 4303 | 4312 | if ( |
| 4304 | 4313 | pendingUserInputs.length > 0 && |
| 4305 | 4314 | (!supportsQuestionAttachments || |
| ... | ... | @@ -4714,6 +4723,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4714 | 4723 | void addComposerAttachments(files); |
| 4715 | 4724 | focusComposer(); |
| 4716 | 4725 | }, |
| 4726 | hasPendingAttachments: () => | |
| 4727 | (pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0, | |
| 4717 | 4728 | insertTextAtEnd: insertComposerTextAtEnd, |
| 4718 | 4729 | citeAssistantText: (citation, sourceAnchor) => |
| 4719 | 4730 | insertComposerText( |
apps/web/src/components/chat/MessagesTimeline.tsx+13-7| ... | ... | @@ -221,7 +221,7 @@ interface TimelineRowSharedState { |
| 221 | 221 | workspaceRoot: string | undefined; |
| 222 | 222 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 223 | 223 | activeThreadEnvironmentId: EnvironmentId; |
| 224 | onRevertToTurnCount: (targetTurnCount: number) => void; | |
| 224 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; | |
| 225 | 225 | onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; |
| 226 | 226 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 227 | 227 | onFileOpen: (attachment: ChatFileAttachment) => void; |
| ... | ... | @@ -339,7 +339,7 @@ interface MessagesTimelineProps { |
| 339 | 339 | displayThreadKey?: string; |
| 340 | 340 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 341 | 341 | supportsConversationRollback: boolean; |
| 342 | onRevertToTurnCount: (targetTurnCount: number) => void; | |
| 342 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; | |
| 343 | 343 | onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; |
| 344 | 344 | isRevertingCheckpoint: boolean; |
| 345 | 345 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| ... | ... | @@ -1580,7 +1580,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1580 | 1580 | </Tooltip> |
| 1581 | 1581 | <div className="flex items-center gap-0.5"> |
| 1582 | 1582 | {typeof revertTurnCount === "number" && ( |
| 1583 | <RevertUserMessageButton turnCount={revertTurnCount} /> | |
| 1583 | <RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} /> | |
| 1584 | 1584 | )} |
| 1585 | 1585 | {displayedUserMessage.copyText && ( |
| 1586 | 1586 | <MessageCopyButton text={displayedUserMessage.copyText} variant="ghost" /> |
| ... | ... | @@ -1592,7 +1592,13 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 1592 | 1592 | ); |
| 1593 | 1593 | } |
| 1594 | 1594 | |
| 1595 | function RevertUserMessageButton({ turnCount }: { turnCount: number }) { | |
| 1595 | function RevertUserMessageButton({ | |
| 1596 | turnCount, | |
| 1597 | messageId, | |
| 1598 | }: { | |
| 1599 | turnCount: number; | |
| 1600 | messageId: MessageId; | |
| 1601 | }) { | |
| 1596 | 1602 | const ctx = use(TimelineRowCtx); |
| 1597 | 1603 | const activity = use(TimelineRowActivityCtx); |
| 1598 | 1604 | |
| ... | ... | @@ -1605,14 +1611,14 @@ function RevertUserMessageButton({ turnCount }: { turnCount: number }) { |
| 1605 | 1611 | size="xs" |
| 1606 | 1612 | variant="ghost" |
| 1607 | 1613 | disabled={activity.isRevertingCheckpoint || activity.isWorking} |
| 1608 | onClick={() => ctx.onRevertToTurnCount(turnCount)} | |
| 1609 | aria-label="Revert to this message" | |
| 1614 | onClick={() => ctx.onRevertToTurnCount(turnCount, messageId)} | |
| 1615 | aria-label="Edit from here" | |
| 1610 | 1616 | /> |
| 1611 | 1617 | } |
| 1612 | 1618 | > |
| 1613 | 1619 | <Undo2Icon className="size-3" /> |
| 1614 | 1620 | </TooltipTrigger> |
| 1615 | <TooltipPopup side="top">Revert to this message</TooltipPopup> | |
| 1621 | <TooltipPopup side="top">Edit from here</TooltipPopup> | |
| 1616 | 1622 | </Tooltip> |
| 1617 | 1623 | ); |
| 1618 | 1624 | } |
apps/web/src/composerDraftStore.test.ts+59| ... | ... | @@ -145,6 +145,7 @@ function makeTerminalContext(input: { |
| 145 | 145 | |
| 146 | 146 | function resetComposerDraftStore() { |
| 147 | 147 | useComposerDraftStore.setState({ |
| 148 | rewindingThreadKeys: new Set(), | |
| 148 | 149 | draftsByThreadKey: {}, |
| 149 | 150 | draftThreadsByThreadKey: {}, |
| 150 | 151 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| ... | ... | @@ -275,6 +276,28 @@ describe("composerDraftStore addImages", () => { |
| 275 | 276 | expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); |
| 276 | 277 | }); |
| 277 | 278 | |
| 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 | 301 | it("deduplicates against existing images across calls by file signature", () => { |
| 279 | 302 | const first = makeImage({ |
| 280 | 303 | id: "img-a", |
| ... | ... | @@ -388,6 +411,16 @@ describe("composerDraftStore unsent draft marker", () => { |
| 388 | 411 | useComposerDraftStore.getState().clearComposerContent(threadRef); |
| 389 | 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 | }); |
| 392 | 425 | |
| 393 | 426 | describe("composerDraftStore file attachments", () => { |
| ... | ... | @@ -664,6 +697,23 @@ describe("composerDraftStore file attachments", () => { |
| 664 | 697 | ]); |
| 665 | 698 | }); |
| 666 | 699 | |
| 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 | 717 | it("keeps same-name videos with different MIME types", () => { |
| 668 | 718 | const store = useComposerDraftStore.getState(); |
| 669 | 719 | const mp4 = new File(["report"], "clip", { type: "video/mp4" }); |
| ... | ... | @@ -1136,10 +1186,19 @@ describe("composerDraftStore project draft thread mapping", () => { |
| 1136 | 1186 | store.addImage(localDraftId, makeImage({ id: "img-local", previewUrl: "blob:local-draft" })); |
| 1137 | 1187 | store.setPrompt(localThreadRef, "local thread draft"); |
| 1138 | 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 | }); | |
| 1139 | 1195 | |
| 1140 | 1196 | clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID); |
| 1141 | 1197 | |
| 1142 | 1198 | const next = useComposerDraftStore.getState(); |
| 1199 | expect([...next.rewindingThreadKeys]).toEqual([ | |
| 1200 | threadKeyFor(otherThreadId, OTHER_TEST_ENVIRONMENT_ID), | |
| 1201 | ]); | |
| 1143 | 1202 | expect(next.getDraftThreadByProjectRef(projectRef)).toBeNull(); |
| 1144 | 1203 | expect(next.getDraftThreadByProjectRef(remoteProjectRef)).not.toBeNull(); |
| 1145 | 1204 | expect(next.getComposerDraft(localDraftId)).toBeNull(); |
apps/web/src/composerDraftStore.ts+32-13| ... | ... | @@ -475,6 +475,7 @@ interface ComposerDraftStoreState { |
| 475 | 475 | draftThreadsByThreadKey: Record<string, DraftThreadState>; |
| 476 | 476 | logicalProjectDraftThreadKeyByLogicalProjectKey: Record<string, string>; |
| 477 | 477 | backgroundSubmissionThreadKeys: Record<string, true>; |
| 478 | rewindingThreadKeys: ReadonlySet<string>; | |
| 478 | 479 | stickyModelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>; |
| 479 | 480 | stickyActiveProvider: ProviderInstanceId | null; |
| 480 | 481 | /** Returns the editable composer content for a draft session or server thread. */ |
| ... | ... | @@ -602,9 +603,17 @@ interface ComposerDraftStoreState { |
| 602 | 603 | interactionMode: ProviderInteractionMode | null | undefined, |
| 603 | 604 | ) => void; |
| 604 | 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 | 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 | 617 | removeFile: (threadRef: ComposerThreadTarget, fileId: string) => void; |
| 609 | 618 | setFileUpload: ( |
| 610 | 619 | threadRef: ComposerThreadTarget, |
| ... | ... | @@ -2522,6 +2531,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 2522 | 2531 | draftThreadsByThreadKey: {}, |
| 2523 | 2532 | logicalProjectDraftThreadKeyByLogicalProjectKey: {}, |
| 2524 | 2533 | backgroundSubmissionThreadKeys: {}, |
| 2534 | rewindingThreadKeys: new Set<string>(), | |
| 2525 | 2535 | stickyModelSelectionByProvider: {}, |
| 2526 | 2536 | stickyActiveProvider: null, |
| 2527 | 2537 | getComposerDraft: (target) => getComposerDraftState(get(), target), |
| ... | ... | @@ -3289,7 +3299,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3289 | 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 | 3303 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; |
| 3294 | 3304 | if (threadKey.length === 0 || images.length === 0) { |
| 3295 | 3305 | return; |
| ... | ... | @@ -3304,7 +3314,10 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3304 | 3314 | const dedupedIncoming: ComposerImageAttachment[] = []; |
| 3305 | 3315 | for (const image of images) { |
| 3306 | 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 | 3321 | // Avoid revoking a blob URL that's still referenced by an accepted image. |
| 3309 | 3322 | if (!acceptedPreviewUrls.has(image.previewUrl)) { |
| 3310 | 3323 | revokeObjectPreviewUrl(image.previewUrl); |
| ... | ... | @@ -3374,7 +3387,7 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3374 | 3387 | return { draftsByThreadKey: nextDraftsByThreadKey }; |
| 3375 | 3388 | }); |
| 3376 | 3389 | }, |
| 3377 | addFiles: (threadRef, files) => { | |
| 3390 | addFiles: (threadRef, files, options) => { | |
| 3378 | 3391 | const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; |
| 3379 | 3392 | if (threadKey.length === 0 || files.length === 0) { |
| 3380 | 3393 | return; |
| ... | ... | @@ -3394,14 +3407,15 @@ const composerDraftStore = create<ComposerDraftStoreState>()( |
| 3394 | 3407 | if (knownIds.has(file.id)) { |
| 3395 | 3408 | continue; |
| 3396 | 3409 | } |
| 3397 | const duplicate = | |
| 3398 | knownFiles.get(key) ?? | |
| 3399 | existing.files.find( | |
| 3400 | (candidate) => | |
| 3401 | composerFileNeedsReattach(candidate) && | |
| 3402 | !replacements.has(candidate.id) && | |
| 3403 | composerFileMatchesReattachMarker(candidate, file), | |
| 3404 | ); | |
| 3410 | const duplicate = options?.allowDuplicates | |
| 3411 | ? undefined | |
| 3412 | : (knownFiles.get(key) ?? | |
| 3413 | existing.files.find( | |
| 3414 | (candidate) => | |
| 3415 | composerFileNeedsReattach(candidate) && | |
| 3416 | !replacements.has(candidate.id) && | |
| 3417 | composerFileMatchesReattachMarker(candidate, file), | |
| 3418 | )); | |
| 3405 | 3419 | if (duplicate) { |
| 3406 | 3420 | // A needs-reattach marker is not a usable duplicate. Replace |
| 3407 | 3421 | // it so the upload restarts. |
| ... | ... | @@ -4105,6 +4119,11 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo |
| 4105 | 4119 | draftThreadsByThreadKey: nextDraftThreads, |
| 4106 | 4120 | logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, |
| 4107 | 4121 | backgroundSubmissionThreadKeys: nextBackgroundSubmissionThreadKeys, |
| 4122 | rewindingThreadKeys: new Set( | |
| 4123 | [...state.rewindingThreadKeys].filter( | |
| 4124 | (threadKey) => parseScopedThreadKey(threadKey)?.environmentId !== environmentId, | |
| 4125 | ), | |
| 4126 | ), | |
| 4108 | 4127 | }; |
| 4109 | 4128 | }); |
| 4110 | 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 | 69 | the last visual line for `ArrowDown`, counting wrapped lines. Editing a recalled prompt turns it |
| 70 | 70 | into a normal draft. |
| 71 | 71 | |
| 72 | ## Edit an earlier prompt | |
| 73 | ||
| 74 | On web and desktop, choose **Edit from here** beneath a sent message to rewind | |
| 75 | the conversation and workspace files to before that message. The selected prompt | |
| 76 | and its attachments return to the composer for editing and resending. Any unsent | |
| 77 | draft stays above the restored prompt. | |
| 78 | ||
| 79 | This removes the selected message and later conversation from the active thread | |
| 80 | and provider history. It does not undo external actions or separate provider | |
| 81 | memory. The action is available only when the provider supports rewind. | |
| 82 | ||
| 72 | 83 | ## Prompt stash |
| 73 | 84 | |
| 74 | 85 | On 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 | 21 | "entry": [ |
| 22 | 22 | "src/bin.ts!", |
| 23 | 23 | "src/service-launcher.ts!", |
| 24 | "src/claudeHistoryWorker.ts!", | |
| 24 | 25 | "scripts/cli.ts", |
| 25 | 26 | "src/provider/testFixtures/*.mjs", |
| 26 | 27 | ], |