authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 02:08:36-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log4ec7ae33c5e47dc638bbfb80bd792724f5d7f1ce
tree835ee0fe61711bc0a458c74e2b9edeff946af325
parent317fcc659b80ebd651179db3eb8071c0c788a8b7

Add Claude integration coverage across orchestration flows

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 524 insertions(+), 43 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+14-4
......@@ -185,9 +185,19 @@ export interface OrchestrationIntegrationHarness {
185185 readonly dispose: Effect.Effect<void, never>;
186186}
187187
188export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
188interface MakeOrchestrationIntegrationHarnessOptions {
189 readonly provider?: "codex" | "claudeCode";
190}
191
192export const makeOrchestrationIntegrationHarness = (
193 options?: MakeOrchestrationIntegrationHarnessOptions,
194) =>
195 Effect.gen(function* () {
189196 const sleep = (ms: number) => Effect.sleep(ms);
190 const adapterHarness = yield* makeTestProviderAdapterHarness;
197 const provider = options?.provider ?? "codex";
198 const adapterHarness = yield* makeTestProviderAdapterHarness({
199 provider,
200 });
191201
192202 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));
193203 const workspaceDir = path.join(rootDir, "workspace");
......@@ -199,10 +209,10 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
199209
200210 const registry: typeof ProviderAdapterRegistry.Service = {
201211 getByProvider: (provider) =>
202 provider === "codex"
212 provider === adapterHarness.provider
203213 ? Effect.succeed(adapterHarness.adapter)
204214 : Effect.fail(new ProviderUnsupportedError({ provider })),
205 listProviders: () => Effect.succeed(["codex"]),
215 listProviders: () => Effect.succeed([adapterHarness.provider]),
206216 };
207217
208218 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
apps/server/integration/TestProviderAdapter.integration.ts+74-34
......@@ -42,6 +42,7 @@ interface SessionState {
4242
4343export interface TestProviderAdapterHarness {
4444 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
45 readonly provider: "codex" | "claudeCode";
4546 readonly queueTurnResponse: (
4647 sessionId: string,
4748 response: TestTurnResponse,
......@@ -49,7 +50,10 @@ export interface TestProviderAdapterHarness {
4950 readonly queueTurnResponseForNextSession: (
5051 response: TestTurnResponse,
5152 ) => Effect.Effect<void, never>;
53 readonly getStartCount: () => number;
5254 readonly getRollbackCalls: (sessionId: string) => ReadonlyArray<number>;
55 readonly getInterruptCalls: (sessionId: string) => ReadonlyArray<ProviderTurnId | undefined>;
56 readonly listActiveSessionIds: () => ReadonlyArray<ProviderSessionId>;
5357 readonly getApprovalResponses: (sessionId: string) => ReadonlyArray<{
5458 readonly sessionId: ProviderSessionId;
5559 readonly requestId: ApprovalRequestId;
......@@ -57,46 +61,57 @@ export interface TestProviderAdapterHarness {
5761 }>;
5862}
5963
60const PROVIDER = "codex" as const;
64interface MakeTestProviderAdapterHarnessOptions {
65 readonly provider?: "codex" | "claudeCode";
66}
6167
6268function nowIso(): string {
6369 return new Date().toISOString();
6470}
6571
66function sessionNotFound(sessionId: string): ProviderAdapterSessionNotFoundError {
72function sessionNotFound(
73 provider: "codex" | "claudeCode",
74 sessionId: string,
75): ProviderAdapterSessionNotFoundError {
6776 return new ProviderAdapterSessionNotFoundError({
68 provider: PROVIDER,
77 provider,
6978 sessionId,
7079 });
7180}
7281
73function missingSessionEffect(sessionId: string): Effect.Effect<never, ProviderAdapterError> {
74 return Effect.fail(sessionNotFound(sessionId));
82function missingSessionEffect(
83 provider: "codex" | "claudeCode",
84 sessionId: string,
85): Effect.Effect<never, ProviderAdapterError> {
86 return Effect.fail(sessionNotFound(provider, sessionId));
7587}
7688
77export const makeTestProviderAdapterHarness = Effect.gen(function* () {
78 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
79 let sessionCount = 0;
80 const sessions = new Map<string, SessionState>();
81 const queuedResponsesForNextSession: TestTurnResponse[] = [];
82 const approvalResponsesBySession = new Map<
83 string,
84 Array<{
85 readonly sessionId: ProviderSessionId;
86 readonly requestId: ApprovalRequestId;
87 readonly decision: ProviderApprovalDecision;
88 }>
89 >();
89export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) =>
90 Effect.gen(function* () {
91 const provider = options?.provider ?? "codex";
92 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
93 let sessionCount = 0;
94 const sessions = new Map<string, SessionState>();
95 const queuedResponsesForNextSession: TestTurnResponse[] = [];
96 const interruptCallsBySession = new Map<string, Array<ProviderTurnId | undefined>>();
97 const approvalResponsesBySession = new Map<
98 string,
99 Array<{
100 readonly sessionId: ProviderSessionId;
101 readonly requestId: ApprovalRequestId;
102 readonly decision: ProviderApprovalDecision;
103 }>
104 >();
90105
91106 const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);
92107
93108 const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) =>
94109 Effect.gen(function* () {
95 if (input.provider !== undefined && input.provider !== PROVIDER) {
110 if (input.provider !== undefined && input.provider !== provider) {
96111 return yield* new ProviderAdapterValidationError({
97 provider: PROVIDER,
112 provider,
98113 operation: "startSession",
99 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
114 issue: `Expected provider '${provider}' but received '${input.provider}'.`,
100115 });
101116 }
102117
......@@ -107,10 +122,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
107122
108123 const session: ProviderSession = {
109124 sessionId,
110 provider: PROVIDER,
125 provider,
111126 status: "ready",
112127 threadId,
113128 cwd: input.cwd,
129 resumeCursor: input.resumeCursor ?? { sessionId },
114130 createdAt,
115131 updatedAt: createdAt,
116132 };
......@@ -133,7 +149,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
133149 Effect.gen(function* () {
134150 const state = sessions.get(input.sessionId);
135151 if (!state) {
136 return yield* missingSessionEffect(input.sessionId);
152 return yield* missingSessionEffect(provider, input.sessionId);
137153 }
138154
139155 state.turnCount += 1;
......@@ -143,7 +159,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
143159 const response = state.queuedResponses.shift();
144160 if (!response) {
145161 return yield* new ProviderAdapterValidationError({
146 provider: PROVIDER,
162 provider,
147163 operation: "sendTurn",
148164 issue: `No queued turn response for session ${input.sessionId}.`,
149165 });
......@@ -155,7 +171,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
155171 const rawEvent: Record<string, unknown> = {
156172 ...(fixtureEvent as Record<string, unknown>),
157173 eventId: randomUUID(),
158 provider: PROVIDER,
174 provider,
159175 sessionId: input.sessionId,
160176 createdAt: nowIso(),
161177 };
......@@ -206,7 +222,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
206222 yield* emit({
207223 type: "turn.completed",
208224 eventId: EventId.makeUnsafe(randomUUID()),
209 provider: PROVIDER,
225 provider,
210226 sessionId: input.sessionId,
211227 createdAt: nowIso(),
212228 threadId: state.snapshot.threadId,
......@@ -227,8 +243,15 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
227243
228244 const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = (
229245 sessionId,
230 _turnId,
231 ) => (sessions.has(sessionId) ? Effect.void : missingSessionEffect(sessionId));
246 turnId,
247 ) =>
248 sessions.has(sessionId)
249 ? Effect.sync(() => {
250 const existing = interruptCallsBySession.get(sessionId) ?? [];
251 existing.push(turnId);
252 interruptCallsBySession.set(sessionId, existing);
253 })
254 : missingSessionEffect(provider, sessionId);
232255
233256 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (
234257 sessionId,
......@@ -245,7 +268,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
245268 });
246269 approvalResponsesBySession.set(sessionId, existing);
247270 })
248 : missingSessionEffect(sessionId);
271 : missingSessionEffect(provider, sessionId);
249272
250273 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) =>
251274 Effect.sync(() => {
......@@ -261,7 +284,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
261284 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (sessionId) => {
262285 const state = sessions.get(sessionId);
263286 if (!state) {
264 return missingSessionEffect(sessionId);
287 return missingSessionEffect(provider, sessionId);
265288 }
266289 return Effect.succeed(state.snapshot);
267290 };
......@@ -272,12 +295,12 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
272295 ) => {
273296 const state = sessions.get(sessionId);
274297 if (!state) {
275 return missingSessionEffect(sessionId);
298 return missingSessionEffect(provider, sessionId);
276299 }
277300 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) {
278301 return Effect.fail(
279302 new ProviderAdapterValidationError({
280 provider: PROVIDER,
303 provider,
281304 operation: "rollbackThread",
282305 issue: "numTurns must be an integer between 0 and current turn count.",
283306 }),
......@@ -301,7 +324,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
301324 });
302325
303326 const adapter: ProviderAdapterShape<ProviderAdapterError> = {
304 provider: PROVIDER,
327 provider,
305328 startSession,
306329 sendTurn,
307330 interruptTurn,
......@@ -325,7 +348,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
325348 ? Effect.sync(() => {
326349 state.queuedResponses.push(response);
327350 })
328 : Effect.fail(sessionNotFound(sessionId)),
351 : Effect.fail(sessionNotFound(provider, sessionId)),
329352 ),
330353 );
331354
......@@ -344,6 +367,19 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
344367 return [...state.rollbackCalls];
345368 };
346369
370 const getStartCount = (): number => sessionCount;
371
372 const getInterruptCalls = (sessionId: string): ReadonlyArray<ProviderTurnId | undefined> => {
373 const calls = interruptCallsBySession.get(sessionId);
374 if (!calls) {
375 return [];
376 }
377 return [...calls];
378 };
379
380 const listActiveSessionIds = (): ReadonlyArray<ProviderSessionId> =>
381 Array.from(sessions.values(), (state) => state.session.sessionId);
382
347383 const getApprovalResponses = (
348384 sessionId: string,
349385 ): ReadonlyArray<{
......@@ -360,9 +396,13 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
360396
361397 return {
362398 adapter,
399 provider,
363400 queueTurnResponse,
364401 queueTurnResponseForNextSession,
402 getStartCount,
365403 getRollbackCalls,
404 getInterruptCalls,
405 listActiveSessionIds,
366406 getApprovalResponses,
367407 } satisfies TestProviderAdapterHarness;
368408});
apps/server/integration/orchestrationEngine.integration.test.ts+435-4
......@@ -40,6 +40,7 @@ const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session");
4040const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread");
4141const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn");
4242const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
43type IntegrationProvider = "codex" | "claudeCode";
4344
4445function nowIso() {
4546 return new Date().toISOString();
......@@ -76,18 +77,21 @@ function waitForSync<A>(
7677 });
7778}
7879
79function runtimeBase(eventId: string, createdAt: string) {
80function runtimeBase(eventId: string, createdAt: string, provider: IntegrationProvider = "codex") {
8081 return {
8182 eventId: asEventId(eventId),
82 provider: "codex" as const,
83 provider,
8384 sessionId: FIXTURE_SESSION_ID,
8485 createdAt,
8586 };
8687}
8788
88function withHarness<A, E>(use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>) {
89function withHarness<A, E>(
90 use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>,
91 provider: IntegrationProvider = "codex",
92) {
8993 return Effect.acquireUseRelease(
90 makeOrchestrationIntegrationHarness,
94 makeOrchestrationIntegrationHarness({ provider }),
9195 use,
9296 (harness) => harness.dispose,
9397 );
......@@ -125,6 +129,7 @@ const startTurn = (input: {
125129 readonly commandId: string;
126130 readonly messageId: string;
127131 readonly text: string;
132 readonly provider?: IntegrationProvider;
128133}) =>
129134 input.harness.engine.dispatch({
130135 type: "thread.turn.start",
......@@ -136,6 +141,7 @@ const startTurn = (input: {
136141 text: input.text,
137142 attachments: [],
138143 },
144 ...(input.provider !== undefined ? { provider: input.provider } : {}),
139145 approvalPolicy: "on-request",
140146 sandboxMode: "workspace-write",
141147 createdAt: nowIso(),
......@@ -748,3 +754,428 @@ it.live(
748754 }),
749755 ),
750756);
757
758it.live("starts a claudeCode session on first turn when provider is requested", () =>
759 withHarness(
760 (harness) =>
761 Effect.gen(function* () {
762 yield* seedProjectAndThread(harness);
763
764 yield* harness.adapterHarness.queueTurnResponseForNextSession({
765 events: [
766 {
767 type: "turn.started",
768 ...runtimeBase("evt-claude-start-1", "2026-02-24T10:10:00.000Z", "claudeCode"),
769 threadId: FIXTURE_THREAD_ID,
770 turnId: FIXTURE_TURN_ID,
771 },
772 {
773 type: "message.delta",
774 ...runtimeBase("evt-claude-start-2", "2026-02-24T10:10:00.050Z", "claudeCode"),
775 threadId: FIXTURE_THREAD_ID,
776 turnId: FIXTURE_TURN_ID,
777 delta: "Claude first turn.\n",
778 },
779 {
780 type: "turn.completed",
781 ...runtimeBase("evt-claude-start-3", "2026-02-24T10:10:00.100Z", "claudeCode"),
782 threadId: FIXTURE_THREAD_ID,
783 turnId: FIXTURE_TURN_ID,
784 status: "completed",
785 },
786 ],
787 });
788
789 yield* startTurn({
790 harness,
791 commandId: "cmd-turn-start-claude-initial",
792 messageId: "msg-user-claude-initial",
793 text: "Use Claude",
794 provider: "claudeCode",
795 });
796
797 const thread = yield* harness.waitForThread(
798 THREAD_ID,
799 (entry) =>
800 entry.session?.providerName === "claudeCode" &&
801 entry.session.status === "ready" &&
802 entry.messages.some(
803 (message) => message.role === "assistant" && message.text === "Claude first turn.\n",
804 ),
805 );
806 assert.equal(thread.session?.providerName, "claudeCode");
807 }),
808 "claudeCode",
809 ),
810);
811
812it.live("recovers claudeCode sessions after provider stopAll using persisted resume state", () =>
813 withHarness(
814 (harness) =>
815 Effect.gen(function* () {
816 yield* seedProjectAndThread(harness);
817
818 yield* harness.adapterHarness.queueTurnResponseForNextSession({
819 events: [
820 {
821 type: "turn.started",
822 ...runtimeBase("evt-claude-recover-1", "2026-02-24T10:11:00.000Z", "claudeCode"),
823 threadId: FIXTURE_THREAD_ID,
824 turnId: FIXTURE_TURN_ID,
825 },
826 {
827 type: "message.delta",
828 ...runtimeBase("evt-claude-recover-2", "2026-02-24T10:11:00.050Z", "claudeCode"),
829 threadId: FIXTURE_THREAD_ID,
830 turnId: FIXTURE_TURN_ID,
831 delta: "Turn before restart.\n",
832 },
833 {
834 type: "turn.completed",
835 ...runtimeBase("evt-claude-recover-3", "2026-02-24T10:11:00.100Z", "claudeCode"),
836 threadId: FIXTURE_THREAD_ID,
837 turnId: FIXTURE_TURN_ID,
838 status: "completed",
839 },
840 ],
841 });
842
843 yield* startTurn({
844 harness,
845 commandId: "cmd-turn-start-claude-recover-1",
846 messageId: "msg-user-claude-recover-1",
847 text: "Before restart",
848 provider: "claudeCode",
849 });
850
851 const firstThread = yield* harness.waitForThread(
852 THREAD_ID,
853 (entry) =>
854 entry.latestTurnId === "turn-1" && entry.session?.providerSessionId !== null,
855 );
856 const staleSessionId = firstThread.session?.providerSessionId;
857 assert.equal(staleSessionId !== null, true);
858 if (!staleSessionId) {
859 return;
860 }
861
862 yield* harness.providerService.stopAll();
863 yield* waitForSync(
864 () => harness.adapterHarness.listActiveSessionIds(),
865 (sessionIds) => sessionIds.length === 0,
866 "provider stopAll",
867 );
868
869 yield* harness.adapterHarness.queueTurnResponseForNextSession({
870 events: [
871 {
872 type: "turn.started",
873 ...runtimeBase("evt-claude-recover-4", "2026-02-24T10:11:01.000Z", "claudeCode"),
874 threadId: FIXTURE_THREAD_ID,
875 turnId: FIXTURE_TURN_ID,
876 },
877 {
878 type: "message.delta",
879 ...runtimeBase("evt-claude-recover-5", "2026-02-24T10:11:01.050Z", "claudeCode"),
880 threadId: FIXTURE_THREAD_ID,
881 turnId: FIXTURE_TURN_ID,
882 delta: "Turn after restart.\n",
883 },
884 {
885 type: "turn.completed",
886 ...runtimeBase("evt-claude-recover-6", "2026-02-24T10:11:01.100Z", "claudeCode"),
887 threadId: FIXTURE_THREAD_ID,
888 turnId: FIXTURE_TURN_ID,
889 status: "completed",
890 },
891 ],
892 });
893
894 yield* startTurn({
895 harness,
896 commandId: "cmd-turn-start-claude-recover-2",
897 messageId: "msg-user-claude-recover-2",
898 text: "After restart",
899 });
900 yield* waitForSync(
901 () => harness.adapterHarness.getStartCount(),
902 (count) => count === 2,
903 "claude provider recovery start",
904 );
905
906 const recoveredThread = yield* harness.waitForThread(
907 THREAD_ID,
908 (entry) =>
909 entry.session?.providerName === "claudeCode" &&
910 entry.messages.some(
911 (message) => message.role === "user" && message.text === "After restart",
912 ) &&
913 !entry.activities.some((activity) => activity.kind === "provider.turn.start.failed"),
914 );
915 assert.equal(recoveredThread.session?.providerName, "claudeCode");
916 assert.equal(recoveredThread.session?.providerSessionId, staleSessionId);
917 }),
918 "claudeCode",
919 ),
920);
921
922it.live("forwards claudeCode approval responses to the provider session", () =>
923 withHarness(
924 (harness) =>
925 Effect.gen(function* () {
926 yield* seedProjectAndThread(harness);
927
928 yield* harness.adapterHarness.queueTurnResponseForNextSession({
929 events: [
930 {
931 type: "turn.started",
932 ...runtimeBase("evt-claude-approval-1", "2026-02-24T10:12:00.000Z", "claudeCode"),
933 threadId: FIXTURE_THREAD_ID,
934 turnId: FIXTURE_TURN_ID,
935 },
936 {
937 type: "approval.requested",
938 ...runtimeBase("evt-claude-approval-2", "2026-02-24T10:12:00.050Z", "claudeCode"),
939 threadId: FIXTURE_THREAD_ID,
940 turnId: FIXTURE_TURN_ID,
941 requestId: APPROVAL_REQUEST_ID,
942 requestKind: "command",
943 detail: "Approve Claude tool call",
944 },
945 {
946 type: "turn.completed",
947 ...runtimeBase("evt-claude-approval-3", "2026-02-24T10:12:00.100Z", "claudeCode"),
948 threadId: FIXTURE_THREAD_ID,
949 turnId: FIXTURE_TURN_ID,
950 status: "completed",
951 },
952 ],
953 });
954
955 yield* startTurn({
956 harness,
957 commandId: "cmd-turn-start-claude-approval",
958 messageId: "msg-user-claude-approval",
959 text: "Need approval",
960 provider: "claudeCode",
961 });
962
963 const thread = yield* harness.waitForThread(THREAD_ID, (entry) =>
964 entry.activities.some((activity) => activity.kind === "approval.requested"),
965 );
966 const providerSessionId = thread.session?.providerSessionId;
967 assert.equal(providerSessionId !== null, true);
968 if (!providerSessionId) {
969 return;
970 }
971
972 yield* harness.engine.dispatch({
973 type: "thread.approval.respond",
974 commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"),
975 threadId: THREAD_ID,
976 requestId: APPROVAL_REQUEST_ID,
977 decision: "accept",
978 createdAt: nowIso(),
979 });
980
981 yield* harness.waitForPendingApproval(
982 "req-approval-1",
983 (row) => row.status === "resolved" && row.decision === "accept",
984 );
985
986 const approvalResponses = yield* waitForSync(
987 () => harness.adapterHarness.getApprovalResponses(providerSessionId),
988 (responses) => responses.length === 1,
989 "claude provider approval response",
990 );
991 assert.equal(approvalResponses[0]?.decision, "accept");
992 }),
993 "claudeCode",
994 ),
995);
996
997it.live("forwards thread.turn.interrupt to claudeCode provider sessions", () =>
998 withHarness(
999 (harness) =>
1000 Effect.gen(function* () {
1001 yield* seedProjectAndThread(harness);
1002
1003 yield* harness.adapterHarness.queueTurnResponseForNextSession({
1004 events: [
1005 {
1006 type: "turn.started",
1007 ...runtimeBase("evt-claude-interrupt-1", "2026-02-24T10:13:00.000Z", "claudeCode"),
1008 threadId: FIXTURE_THREAD_ID,
1009 turnId: FIXTURE_TURN_ID,
1010 },
1011 {
1012 type: "message.delta",
1013 ...runtimeBase("evt-claude-interrupt-2", "2026-02-24T10:13:00.050Z", "claudeCode"),
1014 threadId: FIXTURE_THREAD_ID,
1015 turnId: FIXTURE_TURN_ID,
1016 delta: "Long running output.\n",
1017 },
1018 {
1019 type: "turn.completed",
1020 ...runtimeBase("evt-claude-interrupt-3", "2026-02-24T10:13:00.100Z", "claudeCode"),
1021 threadId: FIXTURE_THREAD_ID,
1022 turnId: FIXTURE_TURN_ID,
1023 status: "completed",
1024 },
1025 ],
1026 });
1027
1028 yield* startTurn({
1029 harness,
1030 commandId: "cmd-turn-start-claude-interrupt",
1031 messageId: "msg-user-claude-interrupt",
1032 text: "Start long turn",
1033 provider: "claudeCode",
1034 });
1035
1036 const thread = yield* harness.waitForThread(
1037 THREAD_ID,
1038 (entry) => entry.session?.providerSessionId !== null,
1039 );
1040 const providerSessionId = thread.session?.providerSessionId;
1041 assert.equal(providerSessionId !== null, true);
1042 if (!providerSessionId) {
1043 return;
1044 }
1045
1046 yield* harness.engine.dispatch({
1047 type: "thread.turn.interrupt",
1048 commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"),
1049 threadId: THREAD_ID,
1050 createdAt: nowIso(),
1051 });
1052 yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-interrupt-requested");
1053
1054 const interruptCalls = yield* waitForSync(
1055 () => harness.adapterHarness.getInterruptCalls(providerSessionId),
1056 (calls) => calls.length === 1,
1057 "claude provider interrupt call",
1058 );
1059 assert.equal(interruptCalls.length, 1);
1060 }),
1061 "claudeCode",
1062 ),
1063);
1064
1065it.live("reverts claudeCode turns and rolls back provider conversation state", () =>
1066 withHarness(
1067 (harness) =>
1068 Effect.gen(function* () {
1069 yield* seedProjectAndThread(harness);
1070
1071 yield* harness.adapterHarness.queueTurnResponseForNextSession({
1072 events: [
1073 {
1074 type: "turn.started",
1075 ...runtimeBase("evt-claude-revert-1", "2026-02-24T10:14:00.000Z", "claudeCode"),
1076 threadId: FIXTURE_THREAD_ID,
1077 turnId: FIXTURE_TURN_ID,
1078 },
1079 {
1080 type: "message.delta",
1081 ...runtimeBase("evt-claude-revert-2", "2026-02-24T10:14:00.050Z", "claudeCode"),
1082 threadId: FIXTURE_THREAD_ID,
1083 turnId: FIXTURE_TURN_ID,
1084 delta: "README -> v2\n",
1085 },
1086 {
1087 type: "turn.completed",
1088 ...runtimeBase("evt-claude-revert-3", "2026-02-24T10:14:00.100Z", "claudeCode"),
1089 threadId: FIXTURE_THREAD_ID,
1090 turnId: FIXTURE_TURN_ID,
1091 status: "completed",
1092 },
1093 ],
1094 mutateWorkspace: ({ cwd }) =>
1095 Effect.sync(() => {
1096 fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8");
1097 }),
1098 });
1099
1100 yield* startTurn({
1101 harness,
1102 commandId: "cmd-turn-start-claude-revert-1",
1103 messageId: "msg-user-claude-revert-1",
1104 text: "First Claude edit",
1105 provider: "claudeCode",
1106 });
1107
1108 const firstThread = yield* harness.waitForThread(
1109 THREAD_ID,
1110 (entry) => entry.latestTurnId === "turn-1" && entry.session?.providerSessionId !== null,
1111 );
1112 const sessionId = firstThread.session?.providerSessionId;
1113 assert.equal(sessionId !== null, true);
1114 if (!sessionId) {
1115 return;
1116 }
1117
1118 yield* harness.adapterHarness.queueTurnResponse(sessionId, {
1119 events: [
1120 {
1121 type: "turn.started",
1122 ...runtimeBase("evt-claude-revert-4", "2026-02-24T10:14:01.000Z", "claudeCode"),
1123 threadId: FIXTURE_THREAD_ID,
1124 turnId: FIXTURE_TURN_ID,
1125 },
1126 {
1127 type: "message.delta",
1128 ...runtimeBase("evt-claude-revert-5", "2026-02-24T10:14:01.050Z", "claudeCode"),
1129 threadId: FIXTURE_THREAD_ID,
1130 turnId: FIXTURE_TURN_ID,
1131 delta: "README -> v3\n",
1132 },
1133 {
1134 type: "turn.completed",
1135 ...runtimeBase("evt-claude-revert-6", "2026-02-24T10:14:01.100Z", "claudeCode"),
1136 threadId: FIXTURE_THREAD_ID,
1137 turnId: FIXTURE_TURN_ID,
1138 status: "completed",
1139 },
1140 ],
1141 mutateWorkspace: ({ cwd }) =>
1142 Effect.sync(() => {
1143 fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8");
1144 }),
1145 });
1146
1147 yield* startTurn({
1148 harness,
1149 commandId: "cmd-turn-start-claude-revert-2",
1150 messageId: "msg-user-claude-revert-2",
1151 text: "Second Claude edit",
1152 });
1153
1154 yield* harness.waitForThread(
1155 THREAD_ID,
1156 (entry) =>
1157 entry.latestTurnId === "turn-2" &&
1158 entry.checkpoints.length === 2 &&
1159 entry.session?.providerName === "claudeCode",
1160 );
1161
1162 yield* harness.engine.dispatch({
1163 type: "thread.checkpoint.revert",
1164 commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"),
1165 threadId: THREAD_ID,
1166 turnCount: 1,
1167 createdAt: nowIso(),
1168 });
1169
1170 const revertedThread = yield* harness.waitForThread(
1171 THREAD_ID,
1172 (entry) =>
1173 entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1,
1174 );
1175 assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1);
1176 assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n");
1177 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);
1178 }),
1179 "claudeCode",
1180 ),
1181);
apps/server/integration/providerService.integration.test.ts+1-1
......@@ -42,7 +42,7 @@ interface IntegrationFixture {
4242
4343const makeIntegrationFixture = Effect.gen(function* () {
4444 const cwd = yield* makeWorkspaceDirectory;
45 const harness = yield* makeTestProviderAdapterHarness;
45 const harness = yield* makeTestProviderAdapterHarness();
4646
4747 const registry: typeof ProviderAdapterRegistry.Service = {
4848 getByProvider: (provider) =>