diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a52ed5856e8..80f571285dd 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -138,6 +138,12 @@ describe("ProviderCommandReactor", () => { (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access") ? input.runtimeMode : "full-access", + ...(typeof input === "object" && + input !== null && + "cwd" in input && + typeof input.cwd === "string" + ? { cwd: input.cwd } + : {}), ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}), threadId, resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, @@ -864,6 +870,76 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("restarts the provider session when the thread workspace changes", async () => { + const harness = await createHarness({ + threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-workspace-1"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-workspace-1"), + role: "user", + text: "first in project root", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + cwd: "/tmp/provider-project", + }); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-worktree-change"), + threadId: ThreadId.make("thread-1"), + worktreePath: "/tmp/provider-project-worktree", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-workspace-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-workspace-2"), + role: "user", + text: "second in worktree", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.stopSession.mock.calls.length).toBe(0); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project-worktree", + resumeCursor: { opaque: "resume-1" }, + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, + runtimeMode: "approval-required", + }); + }); + it("restarts claude sessions when claude effort changes", async () => { const harness = await createHarness({ threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 8b3321ec3ea..f7ae38b2d2a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -334,6 +334,7 @@ const make = Effect.gen(function* () { thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; if (existingSessionThreadId) { const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; + const cwdChanged = effectiveCwd !== activeSession?.cwd; const sessionModelSwitch = currentProvider === undefined ? "in-session" @@ -350,6 +351,7 @@ const make = Effect.gen(function* () { if ( !runtimeModeChanged && + !cwdChanged && !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { @@ -367,6 +369,9 @@ const make = Effect.gen(function* () { currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, runtimeModeChanged, + previousCwd: activeSession?.cwd, + desiredCwd: effectiveCwd, + cwdChanged, modelChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, @@ -381,6 +386,7 @@ const make = Effect.gen(function* () { restartedSessionThreadId: restartedSession.threadId, provider: restartedSession.provider, runtimeMode: restartedSession.runtimeMode, + cwd: restartedSession.cwd, }); yield* bindSessionToThread(restartedSession); return restartedSession.threadId; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 07ea11f0d63..9c83bce8584 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2561,6 +2561,96 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves durable resume ids across Claude resume hooks", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const durableSessionId = "550e8400-e29b-41d4-a716-446655440000"; + const transientHookSessionId = "7368d0c7-40a3-4d8a-bcc1-ac80c49f2719"; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: "claudeAgent", + resumeCursor: { + threadId: RESUME_THREAD_ID, + resume: durableSessionId, + resumeSessionAt: "assistant-99", + turnCount: 3, + }, + runtimeMode: "full-access", + }); + + harness.query.emit({ + type: "system", + subtype: "hook_started", + hook_id: "resume-hook-1", + hook_name: "SessionStart:resume", + hook_event: "SessionStart", + session_id: transientHookSessionId, + uuid: "resume-hook-started", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "hook_response", + hook_id: "resume-hook-1", + hook_name: "SessionStart:resume", + hook_event: "SessionStart", + output: "", + stdout: "", + stderr: "", + outcome: "success", + session_id: transientHookSessionId, + uuid: "resume-hook-response", + } as unknown as SDKMessage); + + harness.query.emit({ + type: "system", + subtype: "init", + apiKeySource: "none", + claude_code_version: "test", + cwd: "/tmp/claude-adapter-test", + tools: [], + mcp_servers: [], + model: "claude-sonnet-4-5", + permissionMode: "bypassPermissions", + slash_commands: [], + output_style: "default", + skills: [], + plugins: [], + session_id: durableSessionId, + uuid: "resume-init", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const threadStartedEvents = runtimeEvents.filter((event) => event.type === "thread.started"); + assert.equal(threadStartedEvents.length, 1); + const threadStarted = threadStartedEvents[0]; + assert.equal(threadStarted?.type, "thread.started"); + if (threadStarted?.type === "thread.started") { + assert.deepEqual(threadStarted.payload, { + providerThreadId: durableSessionId, + }); + } + + const activeSessions = yield* adapter.listSessions(); + const resumeCursor = activeSessions[0]?.resumeCursor as + | { + readonly resume?: string; + } + | undefined; + assert.equal(resumeCursor?.resume, durableSessionId); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("uses an app-generated Claude session id for fresh sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 03dfffa0426..35f326af07b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -198,6 +198,18 @@ function isSyntheticClaudeThreadId(value: string): boolean { return value.startsWith("claude-thread-"); } +function hasDurableClaudeSessionId(message: SDKMessage): boolean { + if (message.type !== "system") { + return true; + } + + return ( + message.subtype !== "hook_started" && + message.subtype !== "hook_progress" && + message.subtype !== "hook_response" + ); +} + function toMessage(cause: unknown, fallback: string): string { if (cause instanceof Error && cause.message.length > 0) { return cause.message; @@ -1249,6 +1261,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (typeof message.session_id !== "string" || message.session_id.length === 0) { return; } + if (!hasDurableClaudeSessionId(message)) { + return; + } const nextThreadId = message.session_id; context.resumeSessionId = message.session_id; yield* updateResumeCursor(context); @@ -2875,6 +2890,31 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), }; + yield* Effect.annotateCurrentSpan({ + "provider.kind": PROVIDER, + "provider.thread_id": threadId, + "provider.runtime_mode": input.runtimeMode, + "claude.resume.source": + existingResumeSessionId !== undefined ? "resume-session" : "generated-session", + "claude.resume.thread_id": resumeState?.threadId ?? "", + "claude.resume.session_id": existingResumeSessionId ?? "", + "claude.resume.session_at": resumeState?.resumeSessionAt ?? "", + "claude.resume.turn_count": resumeState?.turnCount ?? -1, + "claude.query.cwd": input.cwd ?? "", + "claude.query.model": apiModelId ?? "", + "claude.query.effort": effectiveEffort ?? "", + "claude.query.permission_mode": permissionMode ?? "", + "claude.query.allow_dangerously_skip_permissions": permissionMode === "bypassPermissions", + "claude.query.resume": existingResumeSessionId ?? "", + "claude.query.session_id": newSessionId ?? "", + "claude.query.include_partial_messages": true, + "claude.query.additional_directories": input.cwd ? [input.cwd] : [], + "claude.query.setting_sources": [...CLAUDE_SETTING_SOURCES], + "claude.query.settings_json": JSON.stringify(settings), + "claude.query.extra_args_json": JSON.stringify(extraArgs), + "claude.query.path_to_executable": claudeBinaryPath, + }); + const queryRuntime = yield* Effect.try({ try: () => createQuery({ diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index d7b8c1cb38a..09606ba35a7 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -929,9 +929,10 @@ routing.layer("ProviderServiceLive routing", (it) => { const provider = yield* ProviderService; const runtimeRepository = yield* ProviderSessionRuntimeRepository; - const session = yield* provider.startSession(asThreadId("thread-1"), { + const threadId = asThreadId("thread-runtime-status"); + const session = yield* provider.startSession(threadId, { provider: "codex", - threadId: asThreadId("thread-1"), + threadId, runtimeMode: "full-access", }); yield* provider.sendTurn({ @@ -957,7 +958,7 @@ routing.layer("ProviderServiceLive routing", (it) => { lastError: string | null; lastRuntimeEvent: string | null; }; - assert.equal(runtimePayload.cwd, process.cwd()); + assert.equal(runtimePayload.cwd, session.cwd); assert.equal(runtimePayload.model, null); assert.equal(runtimePayload.activeTurnId, `turn-${String(session.threadId)}`); assert.equal(runtimePayload.lastError, null); @@ -1058,6 +1059,94 @@ routing.layer("ProviderServiceLive routing", (it) => { fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect( + "reuses persisted cwd when startSession resumes a claude session without cwd input", + () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cwd-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + + const firstClaude = makeFakeCodexAdapter("claudeAgent"); + const firstRegistry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "claudeAgent" + ? Effect.succeed(firstClaude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["claudeAgent"]), + }; + const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide(firstDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + const initial = yield* Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(asThreadId("thread-claude-cwd"), { + provider: "claudeAgent", + threadId: asThreadId("thread-claude-cwd"), + cwd: "/tmp/project-claude-cwd", + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(firstProviderLayer)); + + const secondClaude = makeFakeCodexAdapter("claudeAgent"); + const secondRegistry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "claudeAgent" + ? Effect.succeed(secondClaude.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["claudeAgent"]), + }; + const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide(secondDirectoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + secondClaude.startSession.mockClear(); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(initial.threadId, { + provider: "claudeAgent", + threadId: initial.threadId, + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(secondProviderLayer)); + + assert.equal(secondClaude.startSession.mock.calls.length, 1); + const resumedStartInput = secondClaude.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "claudeAgent"); + assert.equal(startPayload.cwd, "/tmp/project-claude-cwd"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.equal(startPayload.threadId, initial.threadId); + } + + fs.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); const fanout = makeProviderServiceLayer(); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index a38a24655fe..94630d3bca9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -374,9 +374,31 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( (persistedBinding?.provider === input.provider ? persistedBinding.resumeCursor : undefined); + const effectiveCwd = + input.cwd ?? + (persistedBinding?.provider === input.provider + ? readPersistedCwd(persistedBinding.runtimePayload) + : undefined); + yield* Effect.annotateCurrentSpan({ + "provider.resume_cursor.source": + input.resumeCursor !== undefined + ? "request" + : effectiveResumeCursor !== undefined && persistedBinding?.provider === input.provider + ? "persisted" + : "none", + "provider.resume_cursor.present": effectiveResumeCursor !== undefined, + "provider.cwd.source": + input.cwd !== undefined + ? "request" + : effectiveCwd !== undefined && persistedBinding?.provider === input.provider + ? "persisted" + : "none", + "provider.cwd.effective": effectiveCwd ?? "", + }); const adapter = yield* registry.getByProvider(input.provider); const session = yield* adapter.startSession({ ...input, + ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), }); @@ -398,7 +420,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( provider: session.provider, runtimeMode: input.runtimeMode, hasResumeCursor: session.resumeCursor !== undefined, - hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0, + hasCwd: typeof effectiveCwd === "string" && effectiveCwd.trim().length > 0, hasModel: typeof input.modelSelection?.model === "string" && input.modelSelection.model.trim().length > 0,