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 {...@@ -185,9 +185,19 @@ export interface OrchestrationIntegrationHarness {
185 readonly dispose: Effect.Effect<void, never>;185 readonly dispose: Effect.Effect<void, never>;
186}186}
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* () {
189 const sleep = (ms: number) => Effect.sleep(ms);196 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
192 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));202 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));
193 const workspaceDir = path.join(rootDir, "workspace");203 const workspaceDir = path.join(rootDir, "workspace");
...@@ -199,10 +209,10 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {...@@ -199,10 +209,10 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () {
199209
200 const registry: typeof ProviderAdapterRegistry.Service = {210 const registry: typeof ProviderAdapterRegistry.Service = {
201 getByProvider: (provider) =>211 getByProvider: (provider) =>
202 provider === "codex"212 provider === adapterHarness.provider
203 ? Effect.succeed(adapterHarness.adapter)213 ? Effect.succeed(adapterHarness.adapter)
204 : Effect.fail(new ProviderUnsupportedError({ provider })),214 : Effect.fail(new ProviderUnsupportedError({ provider })),
205 listProviders: () => Effect.succeed(["codex"]),215 listProviders: () => Effect.succeed([adapterHarness.provider]),
206 };216 };
207217
208 const persistenceLayer = makeSqlitePersistenceLive(dbPath);218 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
apps/server/integration/TestProviderAdapter.integration.ts+74-34
...@@ -42,6 +42,7 @@ interface SessionState {...@@ -42,6 +42,7 @@ interface SessionState {
4242
43export interface TestProviderAdapterHarness {43export interface TestProviderAdapterHarness {
44 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;44 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
45 readonly provider: "codex" | "claudeCode";
45 readonly queueTurnResponse: (46 readonly queueTurnResponse: (
46 sessionId: string,47 sessionId: string,
47 response: TestTurnResponse,48 response: TestTurnResponse,
...@@ -49,7 +50,10 @@ export interface TestProviderAdapterHarness {...@@ -49,7 +50,10 @@ export interface TestProviderAdapterHarness {
49 readonly queueTurnResponseForNextSession: (50 readonly queueTurnResponseForNextSession: (
50 response: TestTurnResponse,51 response: TestTurnResponse,
51 ) => Effect.Effect<void, never>;52 ) => Effect.Effect<void, never>;
53 readonly getStartCount: () => number;
52 readonly getRollbackCalls: (sessionId: string) => ReadonlyArray<number>;54 readonly getRollbackCalls: (sessionId: string) => ReadonlyArray<number>;
55 readonly getInterruptCalls: (sessionId: string) => ReadonlyArray<ProviderTurnId | undefined>;
56 readonly listActiveSessionIds: () => ReadonlyArray<ProviderSessionId>;
53 readonly getApprovalResponses: (sessionId: string) => ReadonlyArray<{57 readonly getApprovalResponses: (sessionId: string) => ReadonlyArray<{
54 readonly sessionId: ProviderSessionId;58 readonly sessionId: ProviderSessionId;
55 readonly requestId: ApprovalRequestId;59 readonly requestId: ApprovalRequestId;
...@@ -57,46 +61,57 @@ export interface TestProviderAdapterHarness {...@@ -57,46 +61,57 @@ export interface TestProviderAdapterHarness {
57 }>;61 }>;
58}62}
5963
60const PROVIDER = "codex" as const;64interface MakeTestProviderAdapterHarnessOptions {
65 readonly provider?: "codex" | "claudeCode";
66}
6167
62function nowIso(): string {68function nowIso(): string {
63 return new Date().toISOString();69 return new Date().toISOString();
64}70}
6571
66function sessionNotFound(sessionId: string): ProviderAdapterSessionNotFoundError {72function sessionNotFound(
73 provider: "codex" | "claudeCode",
74 sessionId: string,
75): ProviderAdapterSessionNotFoundError {
67 return new ProviderAdapterSessionNotFoundError({76 return new ProviderAdapterSessionNotFoundError({
68 provider: PROVIDER,77 provider,
69 sessionId,78 sessionId,
70 });79 });
71}80}
7281
73function missingSessionEffect(sessionId: string): Effect.Effect<never, ProviderAdapterError> {82function missingSessionEffect(
74 return Effect.fail(sessionNotFound(sessionId));83 provider: "codex" | "claudeCode",
84 sessionId: string,
85): Effect.Effect<never, ProviderAdapterError> {
86 return Effect.fail(sessionNotFound(provider, sessionId));
75}87}
7688
77export const makeTestProviderAdapterHarness = Effect.gen(function* () {89export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) =>
78 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();90 Effect.gen(function* () {
79 let sessionCount = 0;91 const provider = options?.provider ?? "codex";
80 const sessions = new Map<string, SessionState>();92 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
81 const queuedResponsesForNextSession: TestTurnResponse[] = [];93 let sessionCount = 0;
82 const approvalResponsesBySession = new Map<94 const sessions = new Map<string, SessionState>();
83 string,95 const queuedResponsesForNextSession: TestTurnResponse[] = [];
84 Array<{96 const interruptCallsBySession = new Map<string, Array<ProviderTurnId | undefined>>();
85 readonly sessionId: ProviderSessionId;97 const approvalResponsesBySession = new Map<
86 readonly requestId: ApprovalRequestId;98 string,
87 readonly decision: ProviderApprovalDecision;99 Array<{
88 }>100 readonly sessionId: ProviderSessionId;
89 >();101 readonly requestId: ApprovalRequestId;
102 readonly decision: ProviderApprovalDecision;
103 }>
104 >();
90105
91 const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);106 const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);
92107
93 const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) =>108 const startSession: ProviderAdapterShape<ProviderAdapterError>["startSession"] = (input) =>
94 Effect.gen(function* () {109 Effect.gen(function* () {
95 if (input.provider !== undefined && input.provider !== PROVIDER) {110 if (input.provider !== undefined && input.provider !== provider) {
96 return yield* new ProviderAdapterValidationError({111 return yield* new ProviderAdapterValidationError({
97 provider: PROVIDER,112 provider,
98 operation: "startSession",113 operation: "startSession",
99 issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,114 issue: `Expected provider '${provider}' but received '${input.provider}'.`,
100 });115 });
101 }116 }
102117
...@@ -107,10 +122,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -107,10 +122,11 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
107122
108 const session: ProviderSession = {123 const session: ProviderSession = {
109 sessionId,124 sessionId,
110 provider: PROVIDER,125 provider,
111 status: "ready",126 status: "ready",
112 threadId,127 threadId,
113 cwd: input.cwd,128 cwd: input.cwd,
129 resumeCursor: input.resumeCursor ?? { sessionId },
114 createdAt,130 createdAt,
115 updatedAt: createdAt,131 updatedAt: createdAt,
116 };132 };
...@@ -133,7 +149,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -133,7 +149,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
133 Effect.gen(function* () {149 Effect.gen(function* () {
134 const state = sessions.get(input.sessionId);150 const state = sessions.get(input.sessionId);
135 if (!state) {151 if (!state) {
136 return yield* missingSessionEffect(input.sessionId);152 return yield* missingSessionEffect(provider, input.sessionId);
137 }153 }
138154
139 state.turnCount += 1;155 state.turnCount += 1;
...@@ -143,7 +159,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -143,7 +159,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
143 const response = state.queuedResponses.shift();159 const response = state.queuedResponses.shift();
144 if (!response) {160 if (!response) {
145 return yield* new ProviderAdapterValidationError({161 return yield* new ProviderAdapterValidationError({
146 provider: PROVIDER,162 provider,
147 operation: "sendTurn",163 operation: "sendTurn",
148 issue: `No queued turn response for session ${input.sessionId}.`,164 issue: `No queued turn response for session ${input.sessionId}.`,
149 });165 });
...@@ -155,7 +171,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -155,7 +171,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
155 const rawEvent: Record<string, unknown> = {171 const rawEvent: Record<string, unknown> = {
156 ...(fixtureEvent as Record<string, unknown>),172 ...(fixtureEvent as Record<string, unknown>),
157 eventId: randomUUID(),173 eventId: randomUUID(),
158 provider: PROVIDER,174 provider,
159 sessionId: input.sessionId,175 sessionId: input.sessionId,
160 createdAt: nowIso(),176 createdAt: nowIso(),
161 };177 };
...@@ -206,7 +222,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -206,7 +222,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
206 yield* emit({222 yield* emit({
207 type: "turn.completed",223 type: "turn.completed",
208 eventId: EventId.makeUnsafe(randomUUID()),224 eventId: EventId.makeUnsafe(randomUUID()),
209 provider: PROVIDER,225 provider,
210 sessionId: input.sessionId,226 sessionId: input.sessionId,
211 createdAt: nowIso(),227 createdAt: nowIso(),
212 threadId: state.snapshot.threadId,228 threadId: state.snapshot.threadId,
...@@ -227,8 +243,15 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -227,8 +243,15 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
227243
228 const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = (244 const interruptTurn: ProviderAdapterShape<ProviderAdapterError>["interruptTurn"] = (
229 sessionId,245 sessionId,
230 _turnId,246 turnId,
231 ) => (sessions.has(sessionId) ? Effect.void : missingSessionEffect(sessionId));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
233 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (256 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (
234 sessionId,257 sessionId,
...@@ -245,7 +268,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -245,7 +268,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
245 });268 });
246 approvalResponsesBySession.set(sessionId, existing);269 approvalResponsesBySession.set(sessionId, existing);
247 })270 })
248 : missingSessionEffect(sessionId);271 : missingSessionEffect(provider, sessionId);
249272
250 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) =>273 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) =>
251 Effect.sync(() => {274 Effect.sync(() => {
...@@ -261,7 +284,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -261,7 +284,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
261 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (sessionId) => {284 const readThread: ProviderAdapterShape<ProviderAdapterError>["readThread"] = (sessionId) => {
262 const state = sessions.get(sessionId);285 const state = sessions.get(sessionId);
263 if (!state) {286 if (!state) {
264 return missingSessionEffect(sessionId);287 return missingSessionEffect(provider, sessionId);
265 }288 }
266 return Effect.succeed(state.snapshot);289 return Effect.succeed(state.snapshot);
267 };290 };
...@@ -272,12 +295,12 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -272,12 +295,12 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
272 ) => {295 ) => {
273 const state = sessions.get(sessionId);296 const state = sessions.get(sessionId);
274 if (!state) {297 if (!state) {
275 return missingSessionEffect(sessionId);298 return missingSessionEffect(provider, sessionId);
276 }299 }
277 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) {300 if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) {
278 return Effect.fail(301 return Effect.fail(
279 new ProviderAdapterValidationError({302 new ProviderAdapterValidationError({
280 provider: PROVIDER,303 provider,
281 operation: "rollbackThread",304 operation: "rollbackThread",
282 issue: "numTurns must be an integer between 0 and current turn count.",305 issue: "numTurns must be an integer between 0 and current turn count.",
283 }),306 }),
...@@ -301,7 +324,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -301,7 +324,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
301 });324 });
302325
303 const adapter: ProviderAdapterShape<ProviderAdapterError> = {326 const adapter: ProviderAdapterShape<ProviderAdapterError> = {
304 provider: PROVIDER,327 provider,
305 startSession,328 startSession,
306 sendTurn,329 sendTurn,
307 interruptTurn,330 interruptTurn,
...@@ -325,7 +348,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -325,7 +348,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
325 ? Effect.sync(() => {348 ? Effect.sync(() => {
326 state.queuedResponses.push(response);349 state.queuedResponses.push(response);
327 })350 })
328 : Effect.fail(sessionNotFound(sessionId)),351 : Effect.fail(sessionNotFound(provider, sessionId)),
329 ),352 ),
330 );353 );
331354
...@@ -344,6 +367,19 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -344,6 +367,19 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
344 return [...state.rollbackCalls];367 return [...state.rollbackCalls];
345 };368 };
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
347 const getApprovalResponses = (383 const getApprovalResponses = (
348 sessionId: string,384 sessionId: string,
349 ): ReadonlyArray<{385 ): ReadonlyArray<{
...@@ -360,9 +396,13 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -360,9 +396,13 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
360396
361 return {397 return {
362 adapter,398 adapter,
399 provider,
363 queueTurnResponse,400 queueTurnResponse,
364 queueTurnResponseForNextSession,401 queueTurnResponseForNextSession,
402 getStartCount,
365 getRollbackCalls,403 getRollbackCalls,
404 getInterruptCalls,
405 listActiveSessionIds,
366 getApprovalResponses,406 getApprovalResponses,
367 } satisfies TestProviderAdapterHarness;407 } satisfies TestProviderAdapterHarness;
368});408});
apps/server/integration/orchestrationEngine.integration.test.ts+435-4
...@@ -40,6 +40,7 @@ const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session");...@@ -40,6 +40,7 @@ const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session");
40const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread");40const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread");
41const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn");41const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn");
42const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");42const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
43type IntegrationProvider = "codex" | "claudeCode";
4344
44function nowIso() {45function nowIso() {
45 return new Date().toISOString();46 return new Date().toISOString();
...@@ -76,18 +77,21 @@ function waitForSync<A>(...@@ -76,18 +77,21 @@ function waitForSync<A>(
76 });77 });
77}78}
7879
79function runtimeBase(eventId: string, createdAt: string) {80function runtimeBase(eventId: string, createdAt: string, provider: IntegrationProvider = "codex") {
80 return {81 return {
81 eventId: asEventId(eventId),82 eventId: asEventId(eventId),
82 provider: "codex" as const,83 provider,
83 sessionId: FIXTURE_SESSION_ID,84 sessionId: FIXTURE_SESSION_ID,
84 createdAt,85 createdAt,
85 };86 };
86}87}
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) {
89 return Effect.acquireUseRelease(93 return Effect.acquireUseRelease(
90 makeOrchestrationIntegrationHarness,94 makeOrchestrationIntegrationHarness({ provider }),
91 use,95 use,
92 (harness) => harness.dispose,96 (harness) => harness.dispose,
93 );97 );
...@@ -125,6 +129,7 @@ const startTurn = (input: {...@@ -125,6 +129,7 @@ const startTurn = (input: {
125 readonly commandId: string;129 readonly commandId: string;
126 readonly messageId: string;130 readonly messageId: string;
127 readonly text: string;131 readonly text: string;
132 readonly provider?: IntegrationProvider;
128}) =>133}) =>
129 input.harness.engine.dispatch({134 input.harness.engine.dispatch({
130 type: "thread.turn.start",135 type: "thread.turn.start",
...@@ -136,6 +141,7 @@ const startTurn = (input: {...@@ -136,6 +141,7 @@ const startTurn = (input: {
136 text: input.text,141 text: input.text,
137 attachments: [],142 attachments: [],
138 },143 },
144 ...(input.provider !== undefined ? { provider: input.provider } : {}),
139 approvalPolicy: "on-request",145 approvalPolicy: "on-request",
140 sandboxMode: "workspace-write",146 sandboxMode: "workspace-write",
141 createdAt: nowIso(),147 createdAt: nowIso(),
...@@ -748,3 +754,428 @@ it.live(...@@ -748,3 +754,428 @@ it.live(
748 }),754 }),
749 ),755 ),
750);756);
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 {...@@ -42,7 +42,7 @@ interface IntegrationFixture {
4242
43const makeIntegrationFixture = Effect.gen(function* () {43const makeIntegrationFixture = Effect.gen(function* () {
44 const cwd = yield* makeWorkspaceDirectory;44 const cwd = yield* makeWorkspaceDirectory;
45 const harness = yield* makeTestProviderAdapterHarness;45 const harness = yield* makeTestProviderAdapterHarness();
4646
47 const registry: typeof ProviderAdapterRegistry.Service = {47 const registry: typeof ProviderAdapterRegistry.Service = {
48 getByProvider: (provider) =>48 getByProvider: (provider) =>