From 1fe62b7efb6c4df5ed37b294a4814db0e9b860a2 Mon Sep 17 00:00:00 2001 From: Bas Milius Date: Thu, 23 Apr 2026 06:40:27 +0200 Subject: [PATCH 1/9] Add IntelliJ project icon to the list of possible favicon paths (#1651) (cherry picked from commit fd3b96b487a9aac8a011c27f977067d63d942ac1) --- apps/server/src/project/Layers/ProjectFaviconResolver.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index 3004a7a45cfd..f2d44a76096a 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -27,6 +27,7 @@ const FAVICON_CANDIDATES = [ "assets/icon.png", "assets/logo.svg", "assets/logo.png", + ".idea/icon.svg", ] as const; // Files that may contain a or icon metadata declaration. From 19c4c50c56f255801a232ea837b5d1f40730f23b Mon Sep 17 00:00:00 2001 From: Ashvin Nihalani Date: Mon, 13 Apr 2026 12:14:27 -0700 Subject: [PATCH 2/9] docs: Document environment prep before local development (#1975) (cherry picked from commit 9b29be916b2c5e3736fa685d30c417f4fddcce9c) --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index dcb6452a29ee..5893a4987305 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,14 @@ Observability guide: [docs/observability.md](./docs/observability.md) ## If you REALLY want to contribute still.... read this first +Before local development, prepare the environment and install dependencies: + +```bash +# Optional: only needed if you use mise for dev tool management. +mise install +bun install . +``` + Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). From dd7ddce109388c646e43d8cf326e54c297ae1210 Mon Sep 17 00:00:00 2001 From: Raul Pereira da Silva <38929917+raulpesilva@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:42:19 -0300 Subject: [PATCH 3/9] fix(server): detect localized Windows command errors (#2152) Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> (cherry picked from commit b0b7b38da1dc4b19833d13f84eb907b1e2adfb63) --- apps/server/src/processRunner.test.ts | 20 +++++++++++++++++++- apps/server/src/processRunner.ts | 15 ++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index 15ad4daf09bb..af4ff528ee00 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { runProcess } from "./processRunner.ts"; +import { isWindowsCommandNotFound, runProcess } from "./processRunner.ts"; describe("runProcess", () => { it("fails when output exceeds max buffer in default mode", async () => { @@ -21,3 +21,21 @@ describe("runProcess", () => { expect(result.stderrTruncated).toBe(false); }); }); + +describe("isWindowsCommandNotFound", () => { + it("matches the localized German cmd.exe error text", () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + + try { + expect( + isWindowsCommandNotFound( + 1, + "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt", + ), + ).toBe(true); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); +}); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 5402612887d5..03b164fc241f 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -37,10 +37,23 @@ function normalizeSpawnError(command: string, args: readonly string[], error: un return new Error(`Failed to run ${commandLabel(command, args)}: ${error.message}`); } +const WINDOWS_COMMAND_NOT_FOUND_PATTERNS = [ + /is not recognized as an internal or external command/i, + /n.o . reconhecido como um comando interno/i, + /non . riconosciuto come comando interno o esterno/i, + /n.est pas reconnu en tant que commande interne/i, + /no se reconoce como un comando interno o externo/i, + /wird nicht als interner oder externer befehl/i, +] as const; + +function hasWindowsCommandNotFoundMessage(output: string): boolean { + return WINDOWS_COMMAND_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(output)); +} + export function isWindowsCommandNotFound(code: number | null, stderr: string): boolean { if (process.platform !== "win32") return false; if (code === 9009) return true; - return /is not recognized as an internal or external command/i.test(stderr); + return hasWindowsCommandNotFoundMessage(stderr); } function normalizeExitError( From 4a621a587794078861282f1d2f49591c6c8c98f5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Apr 2026 13:29:54 -0400 Subject: [PATCH 4/9] Fix Claude session cwd resume drift (#2292) Co-authored-by: Cursor Agent Co-authored-by: Julius Marminge Co-authored-by: codex (cherry picked from commit 188df6da074bf60af765881bdfd6e886ef83e6ee) --- .../Layers/ProviderCommandReactor.test.ts | 76 +++++++++++++++ .../Layers/ProviderCommandReactor.ts | 6 ++ .../src/provider/Layers/ClaudeAdapter.test.ts | 90 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 40 ++++++++ .../provider/Layers/ProviderService.test.ts | 95 ++++++++++++++++++- .../src/provider/Layers/ProviderService.ts | 22 +++++ 6 files changed, 326 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4781964d765f..3af835c425d0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -137,6 +137,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}` }, @@ -900,6 +906,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 b1993b0b82bc..48399a5fe16c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -363,6 +363,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" @@ -383,6 +384,7 @@ const make = Effect.gen(function* () { if ( !runtimeModeChanged && + !cwdChanged && !shouldRestartForModelChange && !shouldRestartForModelSelectionChange && !shouldRestartForAdditionalDirs @@ -401,6 +403,9 @@ const make = Effect.gen(function* () { currentRuntimeMode: thread.session?.runtimeMode, desiredRuntimeMode: thread.runtimeMode, runtimeModeChanged, + previousCwd: activeSession?.cwd, + desiredCwd: effectiveCwd, + cwdChanged, modelChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, @@ -415,6 +420,7 @@ const make = Effect.gen(function* () { restartedSessionThreadId: restartedSession.threadId, provider: restartedSession.provider, runtimeMode: restartedSession.runtimeMode, + cwd: restartedSession.cwd, }); yield* bindSessionToThread(restartedSession); threadSessionStartDirectories.set(threadId, [...effectiveAdditionalDirs]); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 95741d564d6d..06524176b08a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2914,6 +2914,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 e1cc19e66f77..ebbd29581d4d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -211,6 +211,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; @@ -1356,6 +1368,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); @@ -3086,6 +3101,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 f61f09bb7d34..bcfb5eff0dfe 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -964,9 +964,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({ @@ -992,7 +993,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); @@ -1091,6 +1092,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 03885e354d74..ad89bcb699c7 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -363,9 +363,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 } : {}), }); From 5d2f1604d242b1927ee94d56bc33612636d1a373 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Thu, 23 Apr 2026 16:52:02 +0100 Subject: [PATCH 5/9] fix(web): ignore stale runtime projection snapshots (#2301) (cherry picked from commit 0d55a42840231122f8d7414b35f155343031f82d) --- .../src/environments/runtime/service.test.ts | 109 +++++++++++++++- apps/web/src/environments/runtime/service.ts | 121 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) diff --git a/apps/web/src/environments/runtime/service.test.ts b/apps/web/src/environments/runtime/service.test.ts index 7a4af4049808..40ec455adc13 100644 --- a/apps/web/src/environments/runtime/service.test.ts +++ b/apps/web/src/environments/runtime/service.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { shouldApplyTerminalEvent } from "./service"; +import { + shouldApplyProjectionEvent, + shouldApplyProjectionSnapshot, + shouldApplyTerminalEvent, +} from "./service"; describe("shouldApplyTerminalEvent", () => { it("applies terminal events for draft-only threads", () => { @@ -39,3 +43,106 @@ describe("shouldApplyTerminalEvent", () => { ).toBe(true); }); }); + +describe("shouldApplyProjectionSnapshot", () => { + it("accepts the first snapshot for an environment", () => { + expect( + shouldApplyProjectionSnapshot({ + current: null, + next: { + snapshotSequence: 1, + updatedAt: "2026-04-22T10:00:00.000Z", + }, + }), + ).toBe(true); + }); + + it("drops snapshots with an older sequence", () => { + expect( + shouldApplyProjectionSnapshot({ + current: { + sequence: 5, + updatedAt: "2026-04-22T10:05:00.000Z", + }, + next: { + snapshotSequence: 4, + updatedAt: "2026-04-22T10:06:00.000Z", + }, + }), + ).toBe(false); + }); + + it("drops snapshots with the same sequence and older timestamp", () => { + expect( + shouldApplyProjectionSnapshot({ + current: { + sequence: 5, + updatedAt: "2026-04-22T10:05:00.000Z", + }, + next: { + snapshotSequence: 5, + updatedAt: "2026-04-22T10:04:59.000Z", + }, + }), + ).toBe(false); + }); + + it("accepts snapshots with the same sequence and a newer timestamp", () => { + expect( + shouldApplyProjectionSnapshot({ + current: { + sequence: 5, + updatedAt: "2026-04-22T10:05:00.000Z", + }, + next: { + snapshotSequence: 5, + updatedAt: "2026-04-22T10:05:01.000Z", + }, + }), + ).toBe(true); + }); +}); + +describe("shouldApplyProjectionEvent", () => { + it("accepts the first event for an environment", () => { + expect( + shouldApplyProjectionEvent({ + current: null, + sequence: 1, + }), + ).toBe(true); + }); + + it("drops stale or duplicate events", () => { + expect( + shouldApplyProjectionEvent({ + current: { + sequence: 5, + updatedAt: "2026-04-22T10:05:00.000Z", + }, + sequence: 5, + }), + ).toBe(false); + expect( + shouldApplyProjectionEvent({ + current: { + sequence: 5, + updatedAt: "2026-04-22T10:05:00.000Z", + }, + sequence: 4, + }), + ).toBe(false); + }); + + it("accepts newer events", () => { + expect( + shouldApplyProjectionEvent({ + current: { + sequence: 5, + updatedAt: "2026-04-22T10:05:00.000Z", + }, + sequence: 6, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 385a87947177..2dfa4b96a4b3 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -95,6 +95,13 @@ type ThreadDetailSubscriptionEntry = { const environmentConnections = new Map(); const environmentConnectionListeners = new Set<() => void>(); const threadDetailSubscriptions = new Map(); +const lastAppliedProjectionVersionByEnvironment = new Map< + EnvironmentId, + { + readonly sequence: number; + readonly updatedAt: string | null; + } +>(); let activeService: EnvironmentServiceState | null = null; let needsProviderInvalidation = false; @@ -110,6 +117,98 @@ const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 2 * 60 * 1000; const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; const NOOP = () => undefined; +function compareAppliedProjectionVersion( + left: { readonly sequence: number; readonly updatedAt: string | null }, + right: { readonly sequence: number; readonly updatedAt: string | null }, +): number { + if (left.sequence !== right.sequence) { + return left.sequence - right.sequence; + } + + const leftUpdatedAt = left.updatedAt ?? ""; + const rightUpdatedAt = right.updatedAt ?? ""; + if (leftUpdatedAt === rightUpdatedAt) { + return 0; + } + + return leftUpdatedAt < rightUpdatedAt ? -1 : 1; +} + +function toAppliedProjectionVersion( + snapshot: Pick, +): { + readonly sequence: number; + readonly updatedAt: string; +} { + return { + sequence: snapshot.snapshotSequence, + updatedAt: snapshot.updatedAt, + }; +} + +export function shouldApplyProjectionSnapshot(input: { + readonly current: { + readonly sequence: number; + readonly updatedAt: string | null; + } | null; + readonly next: Pick; +}): boolean { + if (input.current === null) { + return true; + } + + return compareAppliedProjectionVersion(input.current, toAppliedProjectionVersion(input.next)) < 0; +} + +export function shouldApplyProjectionEvent(input: { + readonly current: { + readonly sequence: number; + readonly updatedAt: string | null; + } | null; + readonly sequence: number; +}): boolean { + if (input.current === null) { + return true; + } + + return input.sequence > input.current.sequence; +} + +function readLastAppliedProjectionVersion(environmentId: EnvironmentId): { + readonly sequence: number; + readonly updatedAt: string | null; +} | null { + return lastAppliedProjectionVersionByEnvironment.get(environmentId) ?? null; +} + +function markAppliedProjectionSnapshot( + environmentId: EnvironmentId, + snapshot: Pick, +): void { + const nextVersion = toAppliedProjectionVersion(snapshot); + const currentVersion = readLastAppliedProjectionVersion(environmentId); + if ( + currentVersion !== null && + compareAppliedProjectionVersion(currentVersion, nextVersion) >= 0 + ) { + return; + } + + lastAppliedProjectionVersionByEnvironment.set(environmentId, nextVersion); +} + +function markAppliedProjectionEvent(environmentId: EnvironmentId, sequence: number): void { + const currentVersion = readLastAppliedProjectionVersion(environmentId); + if (currentVersion !== null && sequence <= currentVersion.sequence) { + return; + } + + lastAppliedProjectionVersionByEnvironment.set(environmentId, { + sequence, + updatedAt: currentVersion?.updatedAt ?? null, + }); +} + function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { return scopedThreadKey(scopeThreadRef(environmentId, threadId)); } @@ -618,6 +717,15 @@ export function applyEnvironmentThreadDetailEvent( } function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) { + if ( + !shouldApplyProjectionEvent({ + current: readLastAppliedProjectionVersion(environmentId), + sequence: event.sequence, + }) + ) { + return; + } + const threadId = event.kind === "thread-upserted" ? event.thread.id @@ -628,6 +736,7 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; useStore.getState().applyShellEvent(event, environmentId); + markAppliedProjectionEvent(environmentId, event.sequence); switch (event.kind) { case "project-upserted": @@ -661,7 +770,17 @@ function createEnvironmentConnectionHandlers() { return { applyShellEvent, syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { + if ( + !shouldApplyProjectionSnapshot({ + current: readLastAppliedProjectionVersion(environmentId), + next: snapshot, + }) + ) { + return; + } + useStore.getState().syncServerShellSnapshot(snapshot, environmentId); + markAppliedProjectionSnapshot(environmentId, snapshot); reconcileThreadDetailSubscriptionsForEnvironment( environmentId, snapshot.threads.map((thread) => thread.id), @@ -776,6 +895,7 @@ async function removeConnection(environmentId: EnvironmentId): Promise } disposeThreadDetailSubscriptionsForEnvironment(environmentId); + lastAppliedProjectionVersionByEnvironment.delete(environmentId); environmentConnections.delete(environmentId); emitEnvironmentConnectionRegistryChange(); await connection.dispose(); @@ -1104,6 +1224,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () export async function resetEnvironmentServiceForTests(): Promise { stopActiveService(); + lastAppliedProjectionVersionByEnvironment.clear(); for (const key of Array.from(threadDetailSubscriptions.keys())) { disposeThreadDetailSubscriptionByKey(key); } From 7dd31b6cb9b6a161ebf7843cf040f32d7a59f84c Mon Sep 17 00:00:00 2001 From: Thimo Sietsma Date: Thu, 23 Apr 2026 17:42:35 +0200 Subject: [PATCH 6/9] fix(request-permission): add `dynamic_tool_call` to command request (#2311) (cherry picked from commit 0ee302e24de2ff0a0f88b3478a3274560e28f3c9) --- apps/web/src/session-logic.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d75da30ea938..d5dc70fa1103 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -213,6 +213,7 @@ function requestKindFromRequestType(requestType: unknown): PendingApproval["requ switch (requestType) { case "command_execution_approval": case "exec_command_approval": + case "dynamic_tool_call": return "command"; case "file_read_approval": return "file-read"; From af5012a925de79a4c77fc3dd135010d7b6e7219b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 23 Apr 2026 11:24:06 -0400 Subject: [PATCH 7/9] Exclude subscribe RPCs from latency tracking (#2313) (cherry picked from commit d5b7690f95504a04c12d10acfefd59f32515dbf2) --- apps/web/src/rpc/requestLatencyState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index d21e37b5298d..ecc3b88275ce 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -36,7 +36,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { } function shouldTrackRpcAck(tag: string): boolean { - return !tag.startsWith("subscribe"); + return !tag.includes("subscribe"); } export function getSlowRpcAckRequests(): ReadonlyArray { From 43f6dd36204f64d9ecc281805314fd8c9ca16860 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 24 Apr 2026 13:25:19 +0300 Subject: [PATCH 8/9] fix(test): use AnalyticsServiceNoopLive in resume-drift tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream #2292 added cwd/resume-drift regression tests that wired AnalyticsService.layerTest. MarCode removed PostHog/analytics per FEATURES.md §"Telemetry Removal" and exposes only AnalyticsServiceNoopLive (already imported in this file at line 45 and used at line 369). Switch the two resume-drift test layers to the noop so typecheck passes. --- apps/server/src/provider/Layers/ProviderService.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index bcfb5eff0dfe..cb030adbf411 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1119,7 +1119,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), Layer.provide(defaultServerSettingsLayer), - Layer.provide(AnalyticsService.layerTest), + Layer.provide(AnalyticsServiceNoopLive), ); const initial = yield* Effect.gen(function* () { @@ -1147,7 +1147,7 @@ routing.layer("ProviderServiceLive routing", (it) => { Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), Layer.provide(defaultServerSettingsLayer), - Layer.provide(AnalyticsService.layerTest), + Layer.provide(AnalyticsServiceNoopLive), ); secondClaude.startSession.mockClear(); From 580bc10fb6bbfc52dc3d205e6b4fd21a5e9ff3ba Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 24 Apr 2026 13:36:59 +0300 Subject: [PATCH 9/9] fix(test): create real worktree dir for workspace-change reactor test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream #2292 added the "restarts the provider session when the thread workspace changes" test using the hardcoded path "/tmp/provider-project-worktree". MarCode's ProviderCommandReactor (line 302) auto-archives threads whose worktree directory is missing on disk — so the non-existent path short-circuits the session restart and the test times out waiting for startSession call #2. Use mkdtempSync for a real dir that exists during the test, clean up in finally. This preserves MarCode's missing-worktree auto-archive behavior (MEMORY.md: "Handle missing worktree directories gracefully #54e6ddc2") while still exercising the workspace-change restart path. --- .../Layers/ProviderCommandReactor.test.ts | 120 ++++++++++-------- 1 file changed, 64 insertions(+), 56 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 3af835c425d0..8c97dcfbb060 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -910,70 +910,78 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness({ threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, }); + // MarCode auto-archives threads whose worktree path is missing on disk + // (ProviderCommandReactor.ts:302). Create a real temp dir so the workspace + // change triggers a session restart instead of an auto-archive. + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), "marcode-reactor-worktree-")); 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, - }), - ); + try { + 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 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.meta.update", + commandId: CommandId.make("cmd-thread-worktree-change"), + threadId: ThreadId.make("thread-1"), + worktreePath, + }), + ); - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-workspace-2"), + 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"), - message: { - messageId: asMessageId("user-message-workspace-2"), - role: "user", - text: "second in worktree", - attachments: [], + cwd: worktreePath, + resumeCursor: { opaque: "resume-1" }, + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", }, - 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", - }); + }); + } finally { + fs.rmSync(worktreePath, { recursive: true, force: true }); + } }); it("restarts claude sessions when claude effort changes", async () => {