diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 45eb883c6e4b903c6055201523ed6fd187240b2a..bec539b2661804954976dd2a09567de382a32a27 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -185,9 +185,19 @@ export interface OrchestrationIntegrationHarness { readonly dispose: Effect.Effect; } -export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { +interface MakeOrchestrationIntegrationHarnessOptions { + readonly provider?: "codex" | "claudeCode"; +} + +export const makeOrchestrationIntegrationHarness = ( + options?: MakeOrchestrationIntegrationHarnessOptions, +) => + Effect.gen(function* () { const sleep = (ms: number) => Effect.sleep(ms); - const adapterHarness = yield* makeTestProviderAdapterHarness; + const provider = options?.provider ?? "codex"; + const adapterHarness = yield* makeTestProviderAdapterHarness({ + provider, + }); const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); const workspaceDir = path.join(rootDir, "workspace"); @@ -199,10 +209,10 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => - provider === "codex" + provider === adapterHarness.provider ? Effect.succeed(adapterHarness.adapter) : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), + listProviders: () => Effect.succeed([adapterHarness.provider]), }; const persistenceLayer = makeSqlitePersistenceLive(dbPath); diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 4b18819f3702bc7c6fc52de7beb0d22d057e7548..db4f9c683554413cb8a51d3c9b9f0b39f4b45b89 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -42,6 +42,7 @@ interface SessionState { export interface TestProviderAdapterHarness { readonly adapter: ProviderAdapterShape; + readonly provider: "codex" | "claudeCode"; readonly queueTurnResponse: ( sessionId: string, response: TestTurnResponse, @@ -49,7 +50,10 @@ export interface TestProviderAdapterHarness { readonly queueTurnResponseForNextSession: ( response: TestTurnResponse, ) => Effect.Effect; + readonly getStartCount: () => number; readonly getRollbackCalls: (sessionId: string) => ReadonlyArray; + readonly getInterruptCalls: (sessionId: string) => ReadonlyArray; + readonly listActiveSessionIds: () => ReadonlyArray; readonly getApprovalResponses: (sessionId: string) => ReadonlyArray<{ readonly sessionId: ProviderSessionId; readonly requestId: ApprovalRequestId; @@ -57,46 +61,57 @@ export interface TestProviderAdapterHarness { }>; } -const PROVIDER = "codex" as const; +interface MakeTestProviderAdapterHarnessOptions { + readonly provider?: "codex" | "claudeCode"; +} function nowIso(): string { return new Date().toISOString(); } -function sessionNotFound(sessionId: string): ProviderAdapterSessionNotFoundError { +function sessionNotFound( + provider: "codex" | "claudeCode", + sessionId: string, +): ProviderAdapterSessionNotFoundError { return new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, + provider, sessionId, }); } -function missingSessionEffect(sessionId: string): Effect.Effect { - return Effect.fail(sessionNotFound(sessionId)); +function missingSessionEffect( + provider: "codex" | "claudeCode", + sessionId: string, +): Effect.Effect { + return Effect.fail(sessionNotFound(provider, sessionId)); } -export const makeTestProviderAdapterHarness = Effect.gen(function* () { - const runtimeEvents = yield* Queue.unbounded(); - let sessionCount = 0; - const sessions = new Map(); - const queuedResponsesForNextSession: TestTurnResponse[] = []; - const approvalResponsesBySession = new Map< - string, - Array<{ - readonly sessionId: ProviderSessionId; - readonly requestId: ApprovalRequestId; - readonly decision: ProviderApprovalDecision; - }> - >(); +export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => + Effect.gen(function* () { + const provider = options?.provider ?? "codex"; + const runtimeEvents = yield* Queue.unbounded(); + let sessionCount = 0; + const sessions = new Map(); + const queuedResponsesForNextSession: TestTurnResponse[] = []; + const interruptCallsBySession = new Map>(); + const approvalResponsesBySession = new Map< + string, + Array<{ + readonly sessionId: ProviderSessionId; + readonly requestId: ApprovalRequestId; + readonly decision: ProviderApprovalDecision; + }> + >(); const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); const startSession: ProviderAdapterShape["startSession"] = (input) => Effect.gen(function* () { - if (input.provider !== undefined && input.provider !== PROVIDER) { + if (input.provider !== undefined && input.provider !== provider) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider, operation: "startSession", - issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + issue: `Expected provider '${provider}' but received '${input.provider}'.`, }); } @@ -107,10 +122,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { const session: ProviderSession = { sessionId, - provider: PROVIDER, + provider, status: "ready", threadId, cwd: input.cwd, + resumeCursor: input.resumeCursor ?? { sessionId }, createdAt, updatedAt: createdAt, }; @@ -133,7 +149,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { Effect.gen(function* () { const state = sessions.get(input.sessionId); if (!state) { - return yield* missingSessionEffect(input.sessionId); + return yield* missingSessionEffect(provider, input.sessionId); } state.turnCount += 1; @@ -143,7 +159,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { const response = state.queuedResponses.shift(); if (!response) { return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, + provider, operation: "sendTurn", issue: `No queued turn response for session ${input.sessionId}.`, }); @@ -155,7 +171,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { const rawEvent: Record = { ...(fixtureEvent as Record), eventId: randomUUID(), - provider: PROVIDER, + provider, sessionId: input.sessionId, createdAt: nowIso(), }; @@ -206,7 +222,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { yield* emit({ type: "turn.completed", eventId: EventId.makeUnsafe(randomUUID()), - provider: PROVIDER, + provider, sessionId: input.sessionId, createdAt: nowIso(), threadId: state.snapshot.threadId, @@ -227,8 +243,15 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { const interruptTurn: ProviderAdapterShape["interruptTurn"] = ( sessionId, - _turnId, - ) => (sessions.has(sessionId) ? Effect.void : missingSessionEffect(sessionId)); + turnId, + ) => + sessions.has(sessionId) + ? Effect.sync(() => { + const existing = interruptCallsBySession.get(sessionId) ?? []; + existing.push(turnId); + interruptCallsBySession.set(sessionId, existing); + }) + : missingSessionEffect(provider, sessionId); const respondToRequest: ProviderAdapterShape["respondToRequest"] = ( sessionId, @@ -245,7 +268,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { }); approvalResponsesBySession.set(sessionId, existing); }) - : missingSessionEffect(sessionId); + : missingSessionEffect(provider, sessionId); const stopSession: ProviderAdapterShape["stopSession"] = (sessionId) => Effect.sync(() => { @@ -261,7 +284,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { const readThread: ProviderAdapterShape["readThread"] = (sessionId) => { const state = sessions.get(sessionId); if (!state) { - return missingSessionEffect(sessionId); + return missingSessionEffect(provider, sessionId); } return Effect.succeed(state.snapshot); }; @@ -272,12 +295,12 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { ) => { const state = sessions.get(sessionId); if (!state) { - return missingSessionEffect(sessionId); + return missingSessionEffect(provider, sessionId); } if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) { return Effect.fail( new ProviderAdapterValidationError({ - provider: PROVIDER, + provider, operation: "rollbackThread", issue: "numTurns must be an integer between 0 and current turn count.", }), @@ -301,7 +324,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { }); const adapter: ProviderAdapterShape = { - provider: PROVIDER, + provider, startSession, sendTurn, interruptTurn, @@ -325,7 +348,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { ? Effect.sync(() => { state.queuedResponses.push(response); }) - : Effect.fail(sessionNotFound(sessionId)), + : Effect.fail(sessionNotFound(provider, sessionId)), ), ); @@ -344,6 +367,19 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { return [...state.rollbackCalls]; }; + const getStartCount = (): number => sessionCount; + + const getInterruptCalls = (sessionId: string): ReadonlyArray => { + const calls = interruptCallsBySession.get(sessionId); + if (!calls) { + return []; + } + return [...calls]; + }; + + const listActiveSessionIds = (): ReadonlyArray => + Array.from(sessions.values(), (state) => state.session.sessionId); + const getApprovalResponses = ( sessionId: string, ): ReadonlyArray<{ @@ -360,9 +396,13 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { return { adapter, + provider, queueTurnResponse, queueTurnResponseForNextSession, + getStartCount, getRollbackCalls, + getInterruptCalls, + listActiveSessionIds, getApprovalResponses, } satisfies TestProviderAdapterHarness; }); diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index e12b8d2bd1828b39dafcbfb743aced79138f87f6..438c565b2f47a31b329226ba722049501a05cf97 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -40,6 +40,7 @@ const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session"); const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread"); const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn"); const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); +type IntegrationProvider = "codex" | "claudeCode"; function nowIso() { return new Date().toISOString(); @@ -76,18 +77,21 @@ function waitForSync( }); } -function runtimeBase(eventId: string, createdAt: string) { +function runtimeBase(eventId: string, createdAt: string, provider: IntegrationProvider = "codex") { return { eventId: asEventId(eventId), - provider: "codex" as const, + provider, sessionId: FIXTURE_SESSION_ID, createdAt, }; } -function withHarness(use: (harness: OrchestrationIntegrationHarness) => Effect.Effect) { +function withHarness( + use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, + provider: IntegrationProvider = "codex", +) { return Effect.acquireUseRelease( - makeOrchestrationIntegrationHarness, + makeOrchestrationIntegrationHarness({ provider }), use, (harness) => harness.dispose, ); @@ -125,6 +129,7 @@ const startTurn = (input: { readonly commandId: string; readonly messageId: string; readonly text: string; + readonly provider?: IntegrationProvider; }) => input.harness.engine.dispatch({ type: "thread.turn.start", @@ -136,6 +141,7 @@ const startTurn = (input: { text: input.text, attachments: [], }, + ...(input.provider !== undefined ? { provider: input.provider } : {}), approvalPolicy: "on-request", sandboxMode: "workspace-write", createdAt: nowIso(), @@ -748,3 +754,428 @@ it.live( }), ), ); + +it.live("starts a claudeCode session on first turn when provider is requested", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Claude first turn.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-initial", + messageId: "msg-user-claude-initial", + text: "Use Claude", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.session.status === "ready" && + entry.messages.some( + (message) => message.role === "assistant" && message.text === "Claude first turn.\n", + ), + ); + assert.equal(thread.session?.providerName, "claudeCode"); + }), + "claudeCode", + ), +); + +it.live("recovers claudeCode sessions after provider stopAll using persisted resume state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn before restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-1", + messageId: "msg-user-claude-recover-1", + text: "Before restart", + provider: "claudeCode", + }); + + const firstThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurnId === "turn-1" && entry.session?.providerSessionId !== null, + ); + const staleSessionId = firstThread.session?.providerSessionId; + assert.equal(staleSessionId !== null, true); + if (!staleSessionId) { + return; + } + + yield* harness.providerService.stopAll(); + yield* waitForSync( + () => harness.adapterHarness.listActiveSessionIds(), + (sessionIds) => sessionIds.length === 0, + "provider stopAll", + ); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Turn after restart.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-recover-2", + messageId: "msg-user-claude-recover-2", + text: "After restart", + }); + yield* waitForSync( + () => harness.adapterHarness.getStartCount(), + (count) => count === 2, + "claude provider recovery start", + ); + + const recoveredThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.providerName === "claudeCode" && + entry.messages.some( + (message) => message.role === "user" && message.text === "After restart", + ) && + !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"), + ); + assert.equal(recoveredThread.session?.providerName, "claudeCode"); + assert.equal(recoveredThread.session?.providerSessionId, staleSessionId); + }), + "claudeCode", + ), +); + +it.live("forwards claudeCode approval responses to the provider session", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "approval.requested", + ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + requestId: APPROVAL_REQUEST_ID, + requestKind: "command", + detail: "Approve Claude tool call", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-approval", + messageId: "msg-user-claude-approval", + text: "Need approval", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some((activity) => activity.kind === "approval.requested"), + ); + const providerSessionId = thread.session?.providerSessionId; + assert.equal(providerSessionId !== null, true); + if (!providerSessionId) { + return; + } + + yield* harness.engine.dispatch({ + type: "thread.approval.respond", + commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"), + threadId: THREAD_ID, + requestId: APPROVAL_REQUEST_ID, + decision: "accept", + createdAt: nowIso(), + }); + + yield* harness.waitForPendingApproval( + "req-approval-1", + (row) => row.status === "resolved" && row.decision === "accept", + ); + + const approvalResponses = yield* waitForSync( + () => harness.adapterHarness.getApprovalResponses(providerSessionId), + (responses) => responses.length === 1, + "claude provider approval response", + ); + assert.equal(approvalResponses[0]?.decision, "accept"); + }), + "claudeCode", + ), +); + +it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Long running output.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-interrupt", + messageId: "msg-user-claude-interrupt", + text: "Start long turn", + provider: "claudeCode", + }); + + const thread = yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.session?.providerSessionId !== null, + ); + const providerSessionId = thread.session?.providerSessionId; + assert.equal(providerSessionId !== null, true); + if (!providerSessionId) { + return; + } + + yield* harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"), + threadId: THREAD_ID, + createdAt: nowIso(), + }); + yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-interrupt-requested"); + + const interruptCalls = yield* waitForSync( + () => harness.adapterHarness.getInterruptCalls(providerSessionId), + (calls) => calls.length === 1, + "claude provider interrupt call", + ); + assert.equal(interruptCalls.length, 1); + }), + "claudeCode", + ), +); + +it.live("reverts claudeCode turns and rolls back provider conversation state", () => + withHarness( + (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.adapterHarness.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v2\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-1", + messageId: "msg-user-claude-revert-1", + text: "First Claude edit", + provider: "claudeCode", + }); + + const firstThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => entry.latestTurnId === "turn-1" && entry.session?.providerSessionId !== null, + ); + const sessionId = firstThread.session?.providerSessionId; + assert.equal(sessionId !== null, true); + if (!sessionId) { + return; + } + + yield* harness.adapterHarness.queueTurnResponse(sessionId, { + events: [ + { + type: "turn.started", + ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "README -> v3\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"), + threadId: FIXTURE_THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + mutateWorkspace: ({ cwd }) => + Effect.sync(() => { + fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); + }), + }); + + yield* startTurn({ + harness, + commandId: "cmd-turn-start-claude-revert-2", + messageId: "msg-user-claude-revert-2", + text: "Second Claude edit", + }); + + yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.latestTurnId === "turn-2" && + entry.checkpoints.length === 2 && + entry.session?.providerName === "claudeCode", + ); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"), + threadId: THREAD_ID, + turnCount: 1, + createdAt: nowIso(), + }); + + const revertedThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, + ); + assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); + assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); + assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]); + }), + "claudeCode", + ), +); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 2e9ae06b6143bae0095a975aec1af9ce328a9578..be623e7da35c63f367e8b6cf094d5cb1fb617224 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -42,7 +42,7 @@ interface IntegrationFixture { const makeIntegrationFixture = Effect.gen(function* () { const cwd = yield* makeWorkspaceDirectory; - const harness = yield* makeTestProviderAdapterHarness; + const harness = yield* makeTestProviderAdapterHarness(); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) =>