diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index dd5a5adb..3756961c 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -277,6 +277,12 @@ function createCollabNotificationHarness() { pending: new Map(), pendingApprovals: new Map(), pendingUserInputs: new Map(), + sessionApprovalOverride: undefined as + | undefined + | { + approvalPolicy: "never"; + sandboxPolicy: { type: "dangerFullAccess" }; + }, collabReceiverTurns: new Map(), collabReceiverParents: new Map(), reviewTurnIds: new Set(), @@ -290,8 +296,41 @@ function createCollabNotificationHarness() { const updateSession = vi .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") .mockImplementation(() => {}); + const requireSession = vi + .spyOn( + manager as unknown as { requireSession: (threadId: ThreadId) => unknown }, + "requireSession", + ) + .mockReturnValue(context); + const writeMessage = vi + .spyOn(manager as unknown as { writeMessage: (...args: unknown[]) => void }, "writeMessage") + .mockImplementation(() => {}); + + return { manager, context, emitEvent, updateSession, requireSession, writeMessage }; +} + +function handleServerNotificationForTest( + manager: CodexAppServerManager, + context: unknown, + notification: Record, +): void { + ( + manager as unknown as { + handleServerNotification: (context: unknown, notification: Record) => void; + } + ).handleServerNotification(context, notification); +} - return { manager, context, emitEvent, updateSession }; +function handleServerRequestForTest( + manager: CodexAppServerManager, + context: unknown, + request: Record, +): void { + ( + manager as unknown as { + handleServerRequest: (context: unknown, request: Record) => void; + } + ).handleServerRequest(context, request); } function createProcessOutputHarness() { @@ -2565,6 +2604,130 @@ describe("collab child conversation routing", () => { ); }); + it("routes unmapped child events through the active provider thread", () => { + const { manager, context, emitEvent } = createCollabNotificationHarness(); + + handleServerNotificationForTest(manager, context, { + method: "item/agentMessage/delta", + params: { + threadId: "child_provider_unmapped", + turnId: "turn_child_unmapped", + itemId: "msg_child_unmapped", + delta: "working", + }, + }); + + expect(emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + method: "item/agentMessage/delta", + turnId: "turn_child_unmapped", + itemId: "msg_child_unmapped", + providerThreadId: "child_provider_unmapped", + providerParentThreadId: "provider_parent", + }), + ); + }); + + it("does not infer a provider parent for the active parent or an inactive session", () => { + const { manager, context, emitEvent } = createCollabNotificationHarness(); + + handleServerNotificationForTest(manager, context, { + method: "item/agentMessage/delta", + params: { + threadId: "provider_parent", + turnId: "turn_parent", + itemId: "msg_parent", + delta: "parent", + }, + }); + context.session.status = "ready"; + handleServerNotificationForTest(manager, context, { + method: "item/agentMessage/delta", + params: { + threadId: "another_provider_thread", + turnId: "turn_other", + itemId: "msg_other", + delta: "other", + }, + }); + + const activeParentEvent = emitEvent.mock.calls[0]?.[0] as Record; + const inactiveSessionEvent = emitEvent.mock.calls[1]?.[0] as Record; + expect(activeParentEvent.providerThreadId).toBe("provider_parent"); + expect(activeParentEvent).not.toHaveProperty("providerParentThreadId"); + expect(inactiveSessionEvent.providerThreadId).toBe("another_provider_thread"); + expect(inactiveSessionEvent).not.toHaveProperty("providerParentThreadId"); + }); + + it("preserves inferred child routing through approval decisions", async () => { + const { manager, context, emitEvent, writeMessage } = createCollabNotificationHarness(); + + handleServerRequestForTest(manager, context, { + id: 42, + method: "item/commandExecution/requestApproval", + params: { + threadId: "child_provider_unmapped", + turnId: "turn_child_unmapped", + itemId: "call_child_unmapped", + command: "bun install", + }, + }); + + const pendingRequest = Array.from(context.pendingApprovals.values())[0]; + expect(pendingRequest).toEqual( + expect.objectContaining({ + providerThreadId: "child_provider_unmapped", + providerParentThreadId: "provider_parent", + }), + ); + await manager.respondToRequest(asThreadId("thread_1"), pendingRequest.requestId, "accept"); + + expect(writeMessage).toHaveBeenCalledWith(context, { + id: 42, + result: { decision: "accept" }, + }); + expect(emitEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "item/requestApproval/decision", + turnId: "turn_child_unmapped", + providerThreadId: "child_provider_unmapped", + providerParentThreadId: "provider_parent", + }), + ); + }); + + it("preserves inferred child routing through user-input answers", async () => { + const { manager, context, emitEvent, writeMessage } = createCollabNotificationHarness(); + + handleServerRequestForTest(manager, context, { + id: 43, + method: "item/tool/requestUserInput", + params: { + threadId: "child_provider_unmapped", + turnId: "turn_child_unmapped", + itemId: "tool_child_unmapped", + questions: [], + }, + }); + + const pendingRequest = Array.from(context.pendingUserInputs.values())[0]; + await manager.respondToUserInput(asThreadId("thread_1"), pendingRequest.requestId, { + scope: "child", + }); + + expect(writeMessage).toHaveBeenCalledWith(context, { + id: 43, + result: { answers: { scope: { answers: ["child"] } } }, + }); + expect(emitEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "item/tool/requestUserInput/answered", + providerThreadId: "child_provider_unmapped", + providerParentThreadId: "provider_parent", + }), + ); + }); + it("suppresses child lifecycle notifications without mutating the parent session state", () => { const { manager, context, emitEvent, updateSession } = createCollabNotificationHarness(); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 69ed42d9..3eeb66dd 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -75,7 +75,10 @@ interface PendingApprovalRequest { requestKind: ProviderRequestKind; threadId: ThreadId; turnId?: TurnId; + parentTurnId?: TurnId; itemId?: ProviderItemId; + providerThreadId?: string; + providerParentThreadId?: string; } interface PendingUserInputRequest { @@ -83,7 +86,17 @@ interface PendingUserInputRequest { jsonRpcId: string | number; threadId: ThreadId; turnId?: TurnId; + parentTurnId?: TurnId; itemId?: ProviderItemId; + providerThreadId?: string; + providerParentThreadId?: string; +} + +interface ResolvedCollaborationRoute { + readonly parentTurnId?: TurnId; + readonly providerThreadId?: string; + readonly providerParentThreadId?: string; + readonly isChildConversation: boolean; } interface CodexUserInputAnswer { @@ -1681,7 +1694,10 @@ export class CodexAppServerManager extends EventEmitter { + const executablePath = resolveClaudeSdkExecutablePath(binaryPath, { cwd, env }); // Spawn a lightweight Claude Code process for native command discovery. // The SDK's supportedCommands() awaits an internal initialization promise // that only resolves when the async generator is iterated (driving the @@ -4277,7 +4283,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { prompt: neverResolvingUserMessageStream(), options: { cwd, - pathToClaudeCodeExecutable: binaryPath, + pathToClaudeCodeExecutable: executablePath, settingSources: [...CLAUDE_SETTING_SOURCES], permissionMode: "plan" as PermissionMode, persistSession: false, @@ -4307,11 +4313,12 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { env: NodeJS.ProcessEnv, binaryPath: string, ): Promise { + const executablePath = resolveClaudeSdkExecutablePath(binaryPath, { cwd, env }); const tempQuery = createQuery({ prompt: neverResolvingUserMessageStream(), options: { cwd, - pathToClaudeCodeExecutable: binaryPath, + pathToClaudeCodeExecutable: executablePath, settingSources: [...CLAUDE_SETTING_SOURCES], permissionMode: "plan" as PermissionMode, persistSession: false, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index cdf71db4..f8c194fd 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -476,6 +476,84 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("shows Codex web-search queries in canonical tool details", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-web-search-complete"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("web-search-1"), + payload: { + item: { + type: "webSearch", + id: "web-search-1", + action: { + queries: ["latest OpenAI API documentation"], + url: "https://platform.openai.com/docs", + }, + }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + assert.equal(firstEvent.value.payload.itemType, "web_search"); + assert.equal(firstEvent.value.payload.title, "Web search"); + assert.equal(firstEvent.value.payload.detail, "latest OpenAI API documentation"); + }), + ); + + it.effect( + "ignores malformed Codex web-search detail fields without breaking event ingestion", + () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-web-search-malformed"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("web-search-malformed"), + payload: { + item: { + type: "webSearch", + id: "web-search-malformed", + query: { text: "must not be coerced" }, + action: { + query: 42, + queries: [{ text: "must not be coerced" }, null, "safe query"], + pattern: false, + url: { href: "https://example.test" }, + }, + }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + assert.equal(firstEvent.value.payload.itemType, "web_search"); + assert.equal(firstEvent.value.payload.detail, "safe query"); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 36667675..bace7d51 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -327,11 +327,17 @@ function reasoningSummaryDetail(item: Record): string | undefin } function itemDetail( + itemType: CanonicalItemType, item: Record, payload: Record, ): string | undefined { const nestedResult = asObject(item.result); + const action = asObject(item.action); + const actionQueries = Array.isArray(action?.queries) ? action.queries : []; const candidates = [ + ...(itemType === "web_search" + ? [item.query, action?.query, ...actionQueries, action?.pattern, action?.url].map(asString) + : []), asString(item.command), asString(item.title), asString(item.summary), @@ -806,7 +812,9 @@ function mapItemLifecycle( // Only the provider-authored summary is user-visible reasoning. Raw content // may contain model trace data and must not leak into transcript activities. const detail = - itemType === "reasoning" ? reasoningSummaryDetail(source) : itemDetail(source, payload ?? {}); + itemType === "reasoning" + ? reasoningSummaryDetail(source) + : itemDetail(itemType, source, payload ?? {}); const status = itemStatus(lifecycle, source.status); return { @@ -1172,7 +1180,7 @@ function mapToRuntimeEvents( } const itemType = source ? toCanonicalItemType(source.type ?? source.kind) : "unknown"; if (itemType === "plan") { - const detail = itemDetail(source, payload ?? {}); + const detail = itemDetail(itemType, source, payload ?? {}); if (!detail) { return []; } diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 3adad2ee..d3379616 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -11,11 +11,14 @@ import { type OpenCodeInventory, type OpenCodeRuntimeShape, } from "../opencodeRuntime.ts"; +import { KiloAdapter } from "../Services/KiloAdapter.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; import { flattenOpenCodeCliModels, flattenOpenCodeModels, + makeKiloAdapterLive, makeOpenCodeAdapterLive, + isOpenCodeSessionNotFound, normalizeOpenCodeTokenUsage, resolvePreferredOpenCodeModelProviders, } from "./OpenCodeAdapter.ts"; @@ -120,13 +123,21 @@ function createMockOpenCodeRuntime(options?: { data: Array<{ info: Record; parts: Part[] }>; }>; readonly session?: Record; + readonly forkedSession?: Record; + readonly sessionGet?: (input: { + readonly sessionID: string; + }) => Promise<{ readonly data?: Record }>; + readonly sessionGetError?: unknown; + readonly createdSessionId?: string; }) { const abortCalls: Array<{ sessionID: string }> = []; const cliModelCalls: Array[0]> = []; const connectCalls: Array[0]> = []; const createCalls: Array> = []; + const deleteCalls: Array<{ sessionID: string; directory?: string }> = []; + const getCalls: Array<{ sessionID: string }> = []; const updateCalls: Array> = []; - const forkCalls: Array<{ sessionID: string }> = []; + const forkCalls: Array<{ sessionID: string; directory?: string }> = []; const permissionReplyCalls: Array> = []; const promptCalls: Array> = []; const emptySubscription = { @@ -141,7 +152,11 @@ function createMockOpenCodeRuntime(options?: { session: { create: async (input: Record) => { createCalls.push(input); - return { data: { id: "opencode-session-1" } }; + return { data: { id: options?.createdSessionId ?? "opencode-session-1" } }; + }, + delete: async (input: { sessionID: string; directory?: string }) => { + deleteCalls.push(input); + return { data: true }; }, update: async (input: Record) => { updateCalls.push(input); @@ -166,12 +181,27 @@ function createMockOpenCodeRuntime(options?: { return { data: null }; }, messages: options?.messages ?? (async () => ({ data: [] })), - get: async () => ({ data: { directory: process.cwd(), ...(options?.session ?? {}) } }), + get: async (input: { sessionID: string }) => { + getCalls.push(input); + if (options?.sessionGet) { + return options.sessionGet(input); + } + if (options?.sessionGetError !== undefined) { + throw options.sessionGetError; + } + return { data: { id: input.sessionID, ...options?.session } }; + }, revert: async () => ({ data: null }), summarize: async () => ({ data: null }), - fork: async (input: { sessionID: string }) => { + fork: async (input: { sessionID: string; directory?: string }) => { forkCalls.push(input); - return { data: { id: "forked-session-1" } }; + return { + data: { + id: "forked-session-1", + ...(input.directory ? { directory: input.directory } : {}), + ...options?.forkedSession, + }, + }; }, }, permission: { @@ -253,6 +283,8 @@ function createMockOpenCodeRuntime(options?: { cliModelCalls, connectCalls, createCalls, + deleteCalls, + getCalls, updateCalls, forkCalls, permissionReplyCalls, @@ -1045,6 +1077,217 @@ describe("flattenOpenCodeModels", () => { }); }); +describe("isOpenCodeSessionNotFound", () => { + it("recognizes structured 404 errors through bounded wrappers", () => { + expect( + isOpenCodeSessionNotFound( + new OpenCodeRuntimeError({ + operation: "session.get", + detail: "missing", + cause: new Error("missing", { cause: { status: 404 } }), + }), + ), + ).toBe(true); + }); + + it("does not classify transport or non-404 server failures as missing", () => { + expect(isOpenCodeSessionNotFound(new Error("network unavailable"))).toBe(false); + expect( + isOpenCodeSessionNotFound({ + status: 500, + cause: { name: "NotFoundError" }, + }), + ).toBe(false); + }); +}); + +describe("KiloAdapter runtime lifecycle", () => { + it("validates and resumes an existing Kilo session without creating a replacement", async () => { + const runtime = createMockOpenCodeRuntime({ + session: { directory: "/repo/kilo-resume" }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* KiloAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + provider: "kilo", + threadId: asThreadId("thread-kilo-resume"), + runtimeMode: "full-access", + resumeCursor: { + openCodeSessionId: "ses_kilo_existing", + cwd: "/repo/kilo-resume", + }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + return { events, session }; + }).pipe( + Effect.provide( + makeKiloAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.getCalls).toEqual([{ sessionID: "ses_kilo_existing" }]); + expect(runtime.createCalls).toEqual([]); + expect(runtime.updateCalls).toEqual([ + { + sessionID: "ses_kilo_existing", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }, + ]); + expect(runtime.connectCalls).toHaveLength(1); + expect(runtime.connectCalls[0]).toMatchObject({ + binaryPath: "kilo", + cwd: "/repo/kilo-resume", + cliSpec: { + defaultBinaryPath: "kilo", + displayName: "Kilo", + serverReadyPrefix: "kilo server listening", + configContentEnvVar: "KILO_CONFIG_CONTENT", + dataDirectoryName: "kilo", + serverAuthUsername: "kilo", + }, + }); + expect(result.session).toMatchObject({ + provider: "kilo", + cwd: "/repo/kilo-resume", + resumeCursor: { + openCodeSessionId: "ses_kilo_existing", + cwd: "/repo/kilo-resume", + }, + }); + expect(result.events[0]).toMatchObject({ + provider: "kilo", + type: "session.started", + payload: { message: "Kilo session resumed" }, + }); + }); + + it("forks an explicit cwd move when a legacy Kilo cursor has no source directory", async () => { + const runtime = createMockOpenCodeRuntime(); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* KiloAdapter; + return yield* adapter.startSession({ + provider: "kilo", + threadId: asThreadId("thread-kilo-legacy-cursor-move"), + runtimeMode: "full-access", + cwd: "/repo/kilo-target", + resumeCursor: "ses_kilo_legacy", + }); + }).pipe( + Effect.provide( + makeKiloAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.getCalls).toEqual([{ sessionID: "ses_kilo_legacy" }]); + expect(runtime.createCalls).toEqual([]); + expect(runtime.forkCalls).toEqual([ + { sessionID: "ses_kilo_legacy", directory: "/repo/kilo-target" }, + ]); + expect(runtime.updateCalls).toEqual([ + { + sessionID: "forked-session-1", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }, + ]); + expect(result.resumeCursor).toMatchObject({ + openCodeSessionId: "forked-session-1", + cwd: "/repo/kilo-target", + }); + }); + + it("starts a fresh Kilo session only after a structured missing-session response", async () => { + const runtime = createMockOpenCodeRuntime({ + createdSessionId: "ses_kilo_fresh", + sessionGetError: new OpenCodeRuntimeError({ + operation: "session.get", + detail: "Kilo session was not found.", + cause: { response: { status: 404 }, name: "NotFoundError" }, + }), + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* KiloAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + provider: "kilo", + threadId: asThreadId("thread-kilo-stale-resume"), + runtimeMode: "approval-required", + resumeCursor: { + openCodeSessionId: "ses_kilo_missing", + cwd: "/repo/kilo-stale-resume", + }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + return { events, session }; + }).pipe( + Effect.provide( + makeKiloAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "kilo-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.getCalls).toEqual([{ sessionID: "ses_kilo_missing" }]); + expect(runtime.createCalls).toHaveLength(1); + expect(runtime.createCalls[0]?.permission).toEqual( + expect.arrayContaining([ + { permission: "*", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "ask" }, + ]), + ); + expect(runtime.connectCalls).toHaveLength(1); + expect(runtime.connectCalls[0]).toMatchObject({ + binaryPath: "kilo", + cwd: "/repo/kilo-stale-resume", + cliSpec: { + defaultBinaryPath: "kilo", + displayName: "Kilo", + configContentEnvVar: "KILO_CONFIG_CONTENT", + }, + }); + expect(result.session).toMatchObject({ + provider: "kilo", + resumeCursor: { + openCodeSessionId: "ses_kilo_fresh", + cwd: "/repo/kilo-stale-resume", + }, + }); + expect(result.events[0]).toMatchObject({ + provider: "kilo", + type: "session.started", + payload: { + message: "Kilo previous session unavailable; new session started", + }, + }); + }); +}); + describe("OpenCodeAdapter runtime lifecycle", () => { it("lists OpenCode models from the CLI before falling back to server inventory", async () => { const runtime = createMockOpenCodeRuntime({ @@ -1337,6 +1580,7 @@ describe("OpenCodeAdapter runtime lifecycle", () => { ); expect(runtime.createCalls).toEqual([]); + expect(runtime.getCalls).toEqual([{ sessionID: "existing-session-1" }]); expect(runtime.connectCalls).toHaveLength(1); expect(runtime.connectCalls[0]).toMatchObject({ cwd: "/repo/resume" }); expect(result.cwd).toBe("/repo/resume"); @@ -1371,6 +1615,7 @@ describe("OpenCodeAdapter runtime lifecycle", () => { ); expect(runtime.createCalls).toEqual([]); + expect(runtime.getCalls).toEqual([{ sessionID: "existing-session-1" }]); expect(runtime.updateCalls).toEqual([ { sessionID: "existing-session-1", @@ -1379,6 +1624,301 @@ describe("OpenCodeAdapter runtime lifecycle", () => { ]); }); + it("does not abort a durable resume cursor when concurrent starts race for one thread", async () => { + let enteredValidationCount = 0; + let signalBothValidationsEntered!: () => void; + let releaseValidations!: () => void; + const bothValidationsEntered = new Promise((resolve) => { + signalBothValidationsEntered = resolve; + }); + const validationGate = new Promise((resolve) => { + releaseValidations = resolve; + }); + const runtime = createMockOpenCodeRuntime({ + sessionGet: async (input) => { + enteredValidationCount += 1; + if (enteredValidationCount === 2) { + signalBothValidationsEntered(); + } + await validationGate; + return { + data: { + id: input.sessionID, + directory: "/repo/concurrent-resume", + }, + }; + }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const input = { + provider: "opencode" as const, + threadId: asThreadId("thread-concurrent-durable-resume"), + runtimeMode: "full-access" as const, + resumeCursor: { + openCodeSessionId: "ses_concurrent_durable", + cwd: "/repo/concurrent-resume", + }, + }; + const firstStart = yield* adapter.startSession(input).pipe(Effect.forkChild); + const secondStart = yield* adapter.startSession(input).pipe(Effect.forkChild); + + yield* Effect.promise(() => bothValidationsEntered); + yield* Effect.sync(releaseValidations); + + const [first, second] = yield* Effect.all( + [Fiber.join(firstStart), Fiber.join(secondStart)], + { concurrency: "unbounded" }, + ); + const sessions = yield* adapter.listSessions(); + return { + first, + second, + sessions, + abortCallsBeforeLayerClose: [...runtime.abortCalls], + }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.getCalls).toEqual([ + { sessionID: "ses_concurrent_durable" }, + { sessionID: "ses_concurrent_durable" }, + ]); + expect(runtime.createCalls).toEqual([]); + expect(result.abortCallsBeforeLayerClose).toEqual([]); + // Closing the test layer retires the single winning live session. A second + // abort here would show that the resumed race loser was aborted too. + expect(runtime.abortCalls).toEqual([{ sessionID: "ses_concurrent_durable" }]); + expect(runtime.updateCalls).toHaveLength(2); + expect(runtime.updateCalls.every((call) => call.sessionID === "ses_concurrent_durable")).toBe( + true, + ); + expect(result.first.resumeCursor).toMatchObject({ + openCodeSessionId: "ses_concurrent_durable", + }); + expect(result.second.resumeCursor).toMatchObject({ + openCodeSessionId: "ses_concurrent_durable", + }); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]?.resumeCursor).toMatchObject({ + openCodeSessionId: "ses_concurrent_durable", + }); + }); + + it("starts fresh only when the provider confirms a stale resume session", async () => { + const runtime = createMockOpenCodeRuntime({ + sessionGetError: new Error("session missing", { cause: { status: 404 } }), + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-stale-resume"), + runtimeMode: "full-access", + resumeCursor: { openCodeSessionId: "stale-session", cwd: "/repo/resume" }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + return { events, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.getCalls).toEqual([{ sessionID: "stale-session" }]); + expect(runtime.createCalls).toHaveLength(1); + expect(result.session.resumeCursor).toMatchObject({ + openCodeSessionId: "opencode-session-1", + }); + expect(result.events[0]).toMatchObject({ + type: "session.started", + payload: { + message: "OpenCode previous session unavailable; new session started", + }, + }); + }); + + it("does not erase resume context on a transient session probe failure", async () => { + const runtime = createMockOpenCodeRuntime({ + sessionGetError: new Error("provider unavailable", { cause: { status: 503 } }), + }); + + await expect( + Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + return yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-transient-resume"), + runtimeMode: "full-access", + resumeCursor: { openCodeSessionId: "live-session", cwd: "/repo/resume" }, + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ), + ).rejects.toThrow(); + + expect(runtime.getCalls).toEqual([{ sessionID: "live-session" }]); + expect(runtime.createCalls).toEqual([]); + }); + + it("forks a resumed session when its provider directory changed", async () => { + const runtime = createMockOpenCodeRuntime({ session: { directory: "/repo/old" } }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + return yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-resume-new-worktree"), + runtimeMode: "full-access", + cwd: "/repo/new", + resumeCursor: { openCodeSessionId: "existing-session-1", cwd: "/repo/old" }, + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.createCalls).toEqual([]); + expect(runtime.forkCalls).toEqual([ + { sessionID: "existing-session-1", directory: "/repo/new" }, + ]); + expect(result.resumeCursor).toMatchObject({ + openCodeSessionId: "forked-session-1", + cwd: "/repo/new", + }); + }); + + it("starts fresh when OpenCode ignores the requested fork directory", async () => { + const runtime = createMockOpenCodeRuntime({ + session: { directory: "/repo/old" }, + forkedSession: { directory: "/repo/old" }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-resume-unsupported-worktree-fork"), + runtimeMode: "full-access", + cwd: "/repo/new", + resumeCursor: { openCodeSessionId: "existing-session-1", cwd: "/repo/old" }, + }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + return { events, session }; + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.forkCalls).toEqual([ + { sessionID: "existing-session-1", directory: "/repo/new" }, + ]); + expect(runtime.deleteCalls).toEqual([ + { sessionID: "forked-session-1", directory: "/repo/old" }, + ]); + expect(runtime.createCalls).toHaveLength(1); + expect(result.session.resumeCursor).toMatchObject({ + openCodeSessionId: "opencode-session-1", + cwd: "/repo/new", + }); + expect(result.events[0]).toMatchObject({ + type: "session.started", + payload: { + message: + "OpenCode could not move the previous session to the requested directory; new session started", + }, + }); + }); + + it("never deletes the durable source session when a failed fork returns the same id", async () => { + const runtime = createMockOpenCodeRuntime({ + session: { directory: "/repo/old" }, + forkedSession: { id: "existing-session-1", directory: "/repo/old" }, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + return yield* adapter.startSession({ + provider: "opencode", + threadId: asThreadId("thread-resume-same-id-fork"), + runtimeMode: "full-access", + cwd: "/repo/new", + resumeCursor: { openCodeSessionId: "existing-session-1", cwd: "/repo/old" }, + }); + }).pipe( + Effect.provide( + makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + expect(runtime.forkCalls).toEqual([ + { sessionID: "existing-session-1", directory: "/repo/new" }, + ]); + expect(runtime.deleteCalls).toEqual([]); + expect(runtime.createCalls).toHaveLength(1); + expect(result.resumeCursor).toMatchObject({ + openCodeSessionId: "opencode-session-1", + cwd: "/repo/new", + }); + }); + it("declines inactive OpenCode native fork when source and target cwd differ", async () => { const runtime = createMockOpenCodeRuntime(); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index f55ef4c7..496d91fb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -18,7 +18,19 @@ import { TurnId, type UserInputQuestion, } from "@synara/contracts"; -import { Cause, Deferred, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect"; +import { + Cause, + Deferred, + Effect, + Exit, + FileSystem, + Layer, + Path, + Queue, + Ref, + Scope, + Stream, +} from "effect"; import type { Agent, AssistantMessage, @@ -113,6 +125,62 @@ const OPENCODE_PROMPT_ACCEPTED_ACTIVITY_TIMEOUT_MS = 60_000; const OPENCODE_PROMPT_ACCEPTED_RECOVERY_DELAYS_MS = [2_000, 5_000, 10_000, 20_000] as const; const OPENCODE_PROMPT_SUBMISSION_INLINE_WAIT_MS = 500; +/** Only a confirmed missing-session response may discard a durable cursor. */ +export function isOpenCodeSessionNotFound(cause: unknown): boolean { + const seen = new Set(); + const queue: unknown[] = [cause]; + for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { + const node = queue.shift(); + if (node === null || typeof node !== "object" || seen.has(node)) { + continue; + } + seen.add(node); + const record = node as Record; + const response = + record.response !== null && typeof record.response === "object" + ? (record.response as Record) + : undefined; + const statuses = [record.status, record.statusCode, response?.status].filter( + (status): status is number => typeof status === "number", + ); + if (statuses.includes(404)) { + return true; + } + if (statuses.length > 0) { + continue; + } + if (typeof record.name === "string" && record.name.toLowerCase() === "notfounderror") { + return true; + } + for (const key of ["cause", "body", "error", "data"] as const) { + if (record[key] !== undefined) { + queue.push(record[key]); + } + } + } + return false; +} + +export function isSameOpenCodeDirectory( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + left: string, + right: string, +): Effect.Effect { + const lexicalLeft = path.resolve(left); + const lexicalRight = path.resolve(right); + if (lexicalLeft === lexicalRight) { + return Effect.succeed(true); + } + const canonicalize = (value: string) => + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); + return Effect.zipWith( + canonicalize(lexicalLeft), + canonicalize(lexicalRight), + (canonicalLeft, canonicalRight) => canonicalLeft === canonicalRight, + ); +} + type OpenCodeSubscribedEvent = Awaited> extends { readonly stream: AsyncIterable; @@ -1717,6 +1785,10 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { Effect.gen(function* () { const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sameDirectory = (left: string, right: string) => + isSameOpenCodeDirectory(fileSystem, path, left, right); const provider = adapterConfig.provider; const buildEventBase = ( input: Omit< @@ -3655,67 +3727,172 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { cliSpec: adapterConfig.cliSpec, ...(server.external && serverPassword ? { serverPassword } : {}), }); - const createSessionId = resumedSessionId - ? // Resumed sessions skip session.create, so re-apply the runtime-mode - // permission ruleset explicitly. Non-fatal: older servers may reject the - // field, and full-access asks are still auto-approved in the event pump. - runOpenCodeSdk("session.update", () => - client.session.update({ - sessionID: resumedSessionId, - permission: buildOpenCodePermissionRules(input.runtimeMode), + const reapplyPermissions = (sessionId: string) => + runOpenCodeSdk("session.update", () => + client.session.update({ + sessionID: sessionId, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ).pipe( + // Older compatible servers may not accept the permission field. Full-access + // requests remain auto-approved by the event pump, so keep this non-fatal. + Effect.catchCause((cause) => + Effect.logWarning( + `${adapterConfig.displayName} failed to apply permission ruleset on resume`, + Cause.squash(cause), + ), + ), + Effect.asVoid, + ); + const createSession = ( + resolution: "fresh" | "fresh-after-stale" | "fresh-after-cwd" = "fresh", + ) => + runOpenCodeSdk("session.create", () => { + const sessionCreateInput = { + ...(initialParsedModel + ? { + model: { + providerID: initialParsedModel.providerID, + id: initialParsedModel.modelID, + ...(initialVariant ? { variant: initialVariant } : {}), + }, + } + : {}), + ...(initialAgent ? { agent: initialAgent } : {}), + permission: buildOpenCodePermissionRules(input.runtimeMode), + title: `Scient ${input.threadId}`, + }; + return client.session.create( + sessionCreateInput as unknown as Parameters[0], + ); + }).pipe( + Effect.flatMap((sessionResult) => + sessionResult.data?.id + ? Effect.succeed({ + openCodeSessionId: sessionResult.data.id, + created: true as const, + resolution, + }) + : Effect.fail( + new OpenCodeRuntimeError({ + operation: "session.create", + detail: `${adapterConfig.displayName} session.create returned no session payload.`, + }), + ), + ), + ); + const resolveSession = Effect.gen(function* () { + if (!resumedSessionId) { + return yield* createSession(); + } + + // A durable cursor is authoritative unless the provider confirms a 404. + // Transport/auth/server failures must propagate instead of silently replacing + // a live conversation with an empty session. The separate changed-cwd path + // below may fall back only after the provider proves it cannot move a fork. + const adopted = yield* runOpenCodeSdk("session.get", () => + client.session.get({ sessionID: resumedSessionId }), + ).pipe( + Effect.map((response) => response.data), + Effect.catchIf( + (cause) => isOpenCodeSessionNotFound(cause), + () => Effect.succeed(undefined), + ), + ); + if (!adopted) { + yield* Effect.logWarning( + `${adapterConfig.displayName} session '${resumedSessionId}' no longer exists; starting a fresh session.`, + ); + return yield* createSession("fresh-after-stale"); + } + + const adoptedSessionId = adopted.id || resumedSessionId; + const sourceDirectory = adopted.directory?.trim() || resumeDirectory; + // Older provider responses and legacy string cursors can both omit the + // source cwd. Reuse is safe when the caller did not request a move; an + // explicit cwd with an unknown source must take the guarded fork path so + // Scient never persists a cursor that only claims to have changed cwd. + const canReuse = sourceDirectory + ? yield* sameDirectory(sourceDirectory, directory) + : input.cwd === undefined; + if (canReuse) { + yield* reapplyPermissions(adoptedSessionId); + return { + openCodeSessionId: adoptedSessionId, + created: false as const, + resolution: "resumed" as const, + }; + } + + // A changed worktree/cwd needs a provider fork so the new session retains + // history without mutating the original session's directory. + const forkedSession = yield* runOpenCodeSdk("session.fork", () => + client.session.fork({ sessionID: adoptedSessionId, directory }), + ); + const forkedSessionId = forkedSession.data?.id; + if (!forkedSessionId) { + return yield* Effect.fail( + new OpenCodeRuntimeError({ + operation: "session.fork", + detail: `${adapterConfig.displayName} session.fork returned no session payload.`, }), - ).pipe( - Effect.catchCause((cause) => - Effect.logWarning( - `${adapterConfig.displayName} failed to apply permission ruleset on resume`, - Cause.squash(cause), + ); + } + const forkedDirectory = forkedSession.data?.directory?.trim(); + const forkCreatedDistinctSession = forkedSessionId !== adoptedSessionId; + const forkUsesRequestedDirectory = forkedDirectory + ? yield* sameDirectory(forkedDirectory, directory) + : false; + if (!forkCreatedDistinctSession || !forkUsesRequestedDirectory) { + // OpenCode 1.18.4 accepts the directory argument but can still bind the + // fork to the source session's cwd. Never persist a cursor that claims a + // different worktree than the provider will actually use. Retire the + // unusable fork and preserve the requested cwd with a fresh session. + yield* Effect.logWarning( + `${adapterConfig.displayName} session fork remained bound to '${forkedDirectory ?? "an unknown directory"}'; starting a fresh session in '${directory}' instead.`, + ); + if (forkCreatedDistinctSession) { + yield* runOpenCodeSdk("session.delete", () => + client.session.delete({ + sessionID: forkedSessionId, + ...(forkedDirectory ? { directory: forkedDirectory } : {}), + }), + ).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + `${adapterConfig.displayName} failed to delete a fork created in the wrong directory`, + Cause.squash(cause), + ), ), - ), - Effect.as(resumedSessionId), - ) - : runOpenCodeSdk("session.create", () => { - const sessionCreateInput = { - ...(initialParsedModel - ? { - model: { - providerID: initialParsedModel.providerID, - id: initialParsedModel.modelID, - ...(initialVariant ? { variant: initialVariant } : {}), - }, - } - : {}), - ...(initialAgent ? { agent: initialAgent } : {}), - permission: buildOpenCodePermissionRules(input.runtimeMode), - title: `Scient ${input.threadId}`, - }; - return client.session.create( - sessionCreateInput as unknown as Parameters< - typeof client.session.create - >[0], + Effect.asVoid, ); - }).pipe( - Effect.flatMap((sessionResult) => - sessionResult.data?.id - ? Effect.succeed(sessionResult.data.id) - : Effect.fail( - new OpenCodeRuntimeError({ - operation: "session.create", - detail: `${adapterConfig.displayName} session.create returned no session payload.`, - }), - ), - ), - ); + } + return yield* createSession("fresh-after-cwd"); + } + yield* reapplyPermissions(forkedSessionId); + return { + openCodeSessionId: forkedSessionId, + created: true as const, + resolution: "forked" as const, + }; + }); const loadModelContextLimits = openCodeRuntime.loadOpenCodeInventory(client).pipe( Effect.map(buildOpenCodeModelContextLimitMap), Effect.catchCause(() => Effect.succeed(new Map())), ); // Session creation and metadata discovery are independent once the server is up. - const [openCodeSessionId, modelContextLimitBySlug] = yield* Effect.all( - [createSessionId, loadModelContextLimits], + const [resolvedSession, modelContextLimitBySlug] = yield* Effect.all( + [resolveSession, loadModelContextLimits], { concurrency: "unbounded" }, ); - return { sessionScope, server, client, openCodeSessionId, modelContextLimitBySlug }; + return { + sessionScope, + server, + client, + ...resolvedSession, + modelContextLimitBySlug, + }; }).pipe(Effect.provideService(Scope.Scope, sessionScope)), ); if (Exit.isFailure(startedExit)) { @@ -3727,11 +3904,14 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const raceWinner = sessions.get(input.threadId); if (raceWinner) { - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSessionId, - }), - ).pipe(Effect.ignore); + // Never abort provider state merely adopted from a durable cursor. + if (started.created) { + yield* runOpenCodeSdk("session.abort", () => + started.client.session.abort({ + sessionID: started.openCodeSessionId, + }), + ).pipe(Effect.ignore); + } yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); return raceWinner.session; } @@ -3787,13 +3967,22 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { sessions.set(input.threadId, context); yield* startEventPump(context); + const sessionStartedMessage = + started.resolution === "resumed" + ? `${adapterConfig.displayName} session resumed` + : started.resolution === "forked" + ? `${adapterConfig.displayName} session resumed in the requested directory` + : started.resolution === "fresh-after-stale" + ? `${adapterConfig.displayName} previous session unavailable; new session started` + : started.resolution === "fresh-after-cwd" + ? `${adapterConfig.displayName} could not move the previous session to the requested directory; new session started` + : `${adapterConfig.displayName} session started`; + yield* emit({ ...buildEventBase({ threadId: input.threadId }), type: "session.started", payload: { - message: resumedSessionId - ? `${adapterConfig.displayName} session resumed` - : `${adapterConfig.displayName} session started`, + message: sessionStartedMessage, resume: { openCodeSessionId: started.openCodeSessionId }, }, }); diff --git a/apps/server/src/provider/claudeCapabilities.ts b/apps/server/src/provider/claudeCapabilities.ts index 86d5536b..f5757a38 100644 --- a/apps/server/src/provider/claudeCapabilities.ts +++ b/apps/server/src/provider/claudeCapabilities.ts @@ -8,6 +8,8 @@ import { type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import { resolveClaudeSdkExecutablePath } from "./claudeExecutable"; + export interface ClaudeAccountCapabilities { readonly email?: string; readonly organization?: string; @@ -112,10 +114,14 @@ export async function probeClaudeAccountCapabilities( let timeoutId: ReturnType | undefined; try { + const executable = resolveClaudeSdkExecutablePath(input.executable, { + env: input.env, + ...(input.cwd ? { cwd: input.cwd } : {}), + }); runtime = createQuery({ prompt: neverSendingPrompt(abortController.signal), options: { - pathToClaudeCodeExecutable: input.executable, + pathToClaudeCodeExecutable: executable, env: input.env, persistSession: false, settingSources: ["user", "project", "local"], diff --git a/apps/server/src/provider/claudeExecutable.test.ts b/apps/server/src/provider/claudeExecutable.test.ts new file mode 100644 index 00000000..b14cfb64 --- /dev/null +++ b/apps/server/src/provider/claudeExecutable.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; + +import { resolveClaudeSdkExecutablePath } from "./claudeExecutable"; + +const NPM_DIRECTORY = "C:\\Users\\dev\\AppData\\Roaming\\npm"; +const NPM_SHIM = `${NPM_DIRECTORY}\\claude.cmd`; +const NPM_NATIVE_ENTRY = `${NPM_DIRECTORY}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; +const NPM_SCRIPT_ENTRY = `${NPM_DIRECTORY}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`; +const PROJECT_DIRECTORY = "C:\\repo"; +const PROJECT_SHIM = `${PROJECT_DIRECTORY}\\node_modules\\.bin\\claude.cmd`; +const PROJECT_NATIVE_ENTRY = `${PROJECT_DIRECTORY}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; + +describe("resolveClaudeSdkExecutablePath", () => { + it("leaves non-Windows executables unchanged", () => { + const resolveCommandPath = vi.fn(() => "must-not-be-used"); + expect( + resolveClaudeSdkExecutablePath("/custom/claude", { + platform: "darwin", + resolveCommandPath, + }), + ).toBe("/custom/claude"); + expect(resolveCommandPath).not.toHaveBeenCalled(); + }); + + it("returns a resolved native Windows executable", () => { + const nativeExecutable = "C:\\Users\\dev\\bin\\claude.exe"; + expect( + resolveClaudeSdkExecutablePath("claude", { + platform: "win32", + resolveCommandPath: () => nativeExecutable, + }), + ).toBe(nativeExecutable); + }); + + it.each([".cmd", ".bat", ".ps1", ".CMD"])( + "follows an npm %s launcher to the packaged native executable", + (extension) => { + const shim = `${NPM_DIRECTORY}\\claude${extension}`; + expect( + resolveClaudeSdkExecutablePath("claude", { + platform: "win32", + resolveCommandPath: () => shim, + isFile: (candidate) => candidate === NPM_NATIVE_ENTRY, + }), + ).toBe(NPM_NATIVE_ENTRY); + }, + ); + + it("falls back to cli.js for older npm packages", () => { + expect( + resolveClaudeSdkExecutablePath("claude", { + platform: "win32", + resolveCommandPath: () => NPM_SHIM, + isFile: (candidate) => candidate === NPM_SCRIPT_ENTRY, + }), + ).toBe(NPM_SCRIPT_ENTRY); + }); + + it("follows a project-local node_modules shim to its sibling package", () => { + expect( + resolveClaudeSdkExecutablePath("claude", { + platform: "win32", + resolveCommandPath: () => PROJECT_SHIM, + isFile: (candidate) => candidate === PROJECT_NATIVE_ENTRY, + }), + ).toBe(PROJECT_NATIVE_ENTRY); + }); + + it("keeps the configured path when a launcher has no known package entry", () => { + expect( + resolveClaudeSdkExecutablePath("claude", { + platform: "win32", + resolveCommandPath: () => NPM_SHIM, + isFile: () => false, + }), + ).toBe("claude"); + }); +}); diff --git a/apps/server/src/provider/claudeExecutable.ts b/apps/server/src/provider/claudeExecutable.ts new file mode 100644 index 00000000..db6cbdee --- /dev/null +++ b/apps/server/src/provider/claudeExecutable.ts @@ -0,0 +1,69 @@ +// FILE: claudeExecutable.ts +// Purpose: Resolves Claude launchers into paths the Claude Agent SDK can spawn directly. +// Layer: Provider utility. + +import { statSync } from "node:fs"; +import * as Path from "node:path"; + +import { + resolveWindowsCommandPath, + type WindowsSafeProcessInput, +} from "@synara/shared/windowsProcess"; + +const WINDOWS_LAUNCHER_EXTENSIONS = new Set([".cmd", ".bat", ".ps1"]); +const NPM_PACKAGE_ENTRY_CANDIDATES = [ + ["@anthropic-ai", "claude-code", "bin", "claude.exe"], + ["@anthropic-ai", "claude-code", "cli.js"], +] as const; + +type ResolveCommandPath = (command: string, input: WindowsSafeProcessInput) => string; + +export interface ClaudeSdkExecutableResolutionInput extends WindowsSafeProcessInput { + readonly isFile?: ((filePath: string) => boolean) | undefined; + readonly resolveCommandPath?: ResolveCommandPath | undefined; +} + +function isExistingFile(filePath: string): boolean { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** + * The Claude Agent SDK does not launch Windows npm shims through a shell. + * Resolve a bare command against PATH/PATHEXT and follow known npm shims to + * the package executable (or the older cli.js entry point) when possible. + */ +export function resolveClaudeSdkExecutablePath( + binaryPath: string, + input: ClaudeSdkExecutableResolutionInput = {}, +): string { + const platform = input.platform ?? process.platform; + if (platform !== "win32") { + return binaryPath; + } + + const resolved = (input.resolveCommandPath ?? resolveWindowsCommandPath)(binaryPath, input); + if (!WINDOWS_LAUNCHER_EXTENSIONS.has(Path.win32.extname(resolved).toLowerCase())) { + return resolved; + } + + const isFile = input.isFile ?? isExistingFile; + const launcherDirectory = Path.win32.dirname(resolved); + const packageRoots = [Path.win32.join(launcherDirectory, "node_modules")]; + if (Path.win32.basename(launcherDirectory).toLowerCase() === ".bin") { + packageRoots.unshift(Path.win32.dirname(launcherDirectory)); + } + for (const packageRoot of packageRoots) { + for (const entrySegments of NPM_PACKAGE_ENTRY_CANDIDATES) { + const candidate = Path.win32.join(packageRoot, ...entrySegments); + if (isFile(candidate)) { + return candidate; + } + } + } + + return binaryPath; +} diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index fa93b2a1..695c9e29 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -42,7 +42,7 @@ import { NetService } from "@synara/shared/Net"; import { prepareWindowsSafeProcess } from "@synara/shared/windowsProcess"; import { isWindowsShellCommandMissingResult } from "../shell-command-detection.ts"; -const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 20_000; +const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; const DEFAULT_HOSTNAME = "127.0.0.1"; export const OPENCODE_LOCAL_SERVER_IDLE_TTL_MS = 5 * 60_000; const OPENCODE_STARTUP_OUTPUT_MAX_CHARS = 4_000; diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 10f598ac..5e119778 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -30,6 +30,7 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, getScrollContainerDistanceFromBottom, } from "../chat-scroll"; +import { useLatestProjectStore } from "../latestProjectStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, @@ -601,6 +602,16 @@ function withHomeChatProject(snapshot: OrchestrationReadModel): OrchestrationRea }; } +function withActiveHomeChatThread(snapshot: OrchestrationReadModel): OrchestrationReadModel { + const snapshotWithHomeProject = withHomeChatProject(snapshot); + return { + ...snapshotWithHomeProject, + threads: snapshotWithHomeProject.threads.map((thread) => + thread.id === THREAD_ID ? Object.assign({}, thread, { projectId: HOME_PROJECT_ID }) : thread, + ), + }; +} + function withStudioProject(snapshot: OrchestrationReadModel): OrchestrationReadModel { return { ...snapshot, @@ -1753,6 +1764,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await setViewport(DEFAULT_VIEWPORT); attachmentResponseDelayMs = 0; localStorage.clear(); + useLatestProjectStore.setState({ latestProjectId: null }); document.body.innerHTML = ""; wsRequests.length = 0; useComposerDraftStore.setState({ @@ -3547,6 +3559,139 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("uses the latest ordinary project from Home for the global New thread button", async () => { + useLatestProjectStore.setState({ latestProjectId: PROJECT_ID }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withActiveHomeChatThread( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-global-new-thread-latest-project" as MessageId, + targetText: "global new thread latest project", + }), + ), + }); + + try { + const newThreadButton = page.getByRole("button", { name: "New thread", exact: true }); + await expect.element(newThreadButton).toBeInTheDocument(); + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Global New thread should create a draft in the latest ordinary project.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + expect(useComposerDraftStore.getState().getDraftThread(newThreadId)?.projectId).toBe( + PROJECT_ID, + ); + await expect.element(page.getByText("Type path", { exact: true })).not.toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + + it("uses the latest ordinary project from Home for command-palette New thread", async () => { + useLatestProjectStore.setState({ latestProjectId: PROJECT_ID }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withActiveHomeChatThread( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-palette-new-thread-latest-project" as MessageId, + targetText: "palette new thread latest project", + }), + ), + }); + + try { + const searchButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.trim().startsWith("Search"), + ) ?? null, + "Unable to find the global Search button.", + ); + searchButton.click(); + const paletteNewThreadAction = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-slot="command-item"]')).find( + (item) => item.textContent?.trim().startsWith("New thread"), + ) ?? null, + "Unable to find the command-palette New thread action.", + ); + paletteNewThreadAction.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Command-palette New thread should create a draft in the latest ordinary project.", + ); + const newThreadId = newThreadPath.slice(1) as ThreadId; + expect(useComposerDraftStore.getState().getDraftThread(newThreadId)?.projectId).toBe( + PROJECT_ID, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("opens Add project when global New thread has no usable project target", async () => { + useLatestProjectStore.setState({ latestProjectId: PROJECT_ID }); + const snapshot = withActiveHomeChatThread( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-global-new-thread-no-project" as MessageId, + targetText: "global new thread no project", + }), + ); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + projects: snapshot.projects.filter((project) => project.kind !== "project"), + }, + }); + + try { + const initialPath = mounted.router.state.location.pathname; + const newThreadButton = page.getByRole("button", { name: "New thread", exact: true }); + await expect.element(newThreadButton).toBeInTheDocument(); + await newThreadButton.click(); + + await expect.element(page.getByText("Type path", { exact: true })).toBeInTheDocument(); + expect(mounted.router.state.location.pathname).toBe(initialPath); + } finally { + await mounted.cleanup(); + } + }); + + it("does not open Add project before project hydration completes", async () => { + useLatestProjectStore.setState({ latestProjectId: PROJECT_ID }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withActiveHomeChatThread( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-global-new-thread-before-hydration" as MessageId, + targetText: "global new thread before hydration", + }), + ), + }); + + try { + useStore.setState({ projects: [], threadsHydrated: false }); + await waitForLayout(); + const initialPath = mounted.router.state.location.pathname; + const newThreadButton = page.getByRole("button", { name: "New thread", exact: true }); + await expect.element(newThreadButton).toBeInTheDocument(); + await newThreadButton.click(); + await waitForLayout(); + + await expect.element(page.getByText("Type path", { exact: true })).not.toBeInTheDocument(); + expect(mounted.router.state.location.pathname).toBe(initialPath); + } finally { + await mounted.cleanup(); + } + }); + it("lets an empty project draft switch to another open project", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d02525b5..db5cf58e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -138,7 +138,11 @@ import { providerComposerCapabilitiesQueryOptions, supportsThreadImport, } from "../lib/providerDiscoveryReactQuery"; -import { resolveCurrentProjectTargetId } from "../lib/projectShortcutTargets"; +import { + resolveCurrentProjectTargetId, + resolveLatestProjectTargetId, + resolveNewThreadTarget, +} from "../lib/projectShortcutTargets"; import { projectDiscoverScriptsQueryOptions } from "../lib/projectReactQuery"; import { pullRequestQueryKeys, @@ -167,6 +171,7 @@ import { prewarmStudioProject, } from "../lib/studioProjects"; import { useComposerDraftStore } from "../composerDraftStore"; +import { useLatestProjectStore } from "../latestProjectStore"; import { resolveThreadEnvironmentPresentation } from "../lib/threadEnvironment"; import { dispatchThreadRename } from "../lib/threadRename"; import { quotePosixShellArgument } from "../lib/shellQuote"; @@ -1383,6 +1388,7 @@ export default function Sidebar() { ); const projects = useStore((store) => store.projects); const threadsHydrated = useStore((store) => store.threadsHydrated); + const latestProjectId = useLatestProjectStore((state) => state.latestProjectId); const sidebarThreadSummaryById = useStore((store) => store.sidebarThreadSummaryById); const sidebarThreadSummaryByIdRef = useRef(sidebarThreadSummaryById); const syncServerShellSnapshot = useStore((store) => store.syncServerShellSnapshot); @@ -2883,6 +2889,18 @@ export default function Sidebar() { () => resolveCurrentProjectTargetId(projects, focusedProjectId), [focusedProjectId, projects], ); + const latestUsableProjectId = useMemo( + () => resolveLatestProjectTargetId(projects, latestProjectId), + [latestProjectId, projects], + ); + const primaryNewThreadTarget = useMemo( + () => + resolveNewThreadTarget({ + currentProjectId: currentProjectShortcutTargetId, + latestUsableProjectId, + }), + [currentProjectShortcutTargetId, latestUsableProjectId], + ); // Warm model discovery before ChatView mounts so new-thread composers skip // the "Loading models" skeleton when React Query already has a fresh cache hit. @@ -2925,30 +2943,32 @@ export default function Sidebar() { ); const prefetchModelsForPrimaryNewThread = useCallback(() => { - if (!currentProjectShortcutTargetId) { + if (!primaryNewThreadTarget) { return; } - prefetchModelsForProjectNewThread(currentProjectShortcutTargetId); - }, [currentProjectShortcutTargetId, prefetchModelsForProjectNewThread]); + prefetchModelsForProjectNewThread(primaryNewThreadTarget.projectId); + }, [prefetchModelsForProjectNewThread, primaryNewThreadTarget]); const prefetchModelsForPrimaryNewThreadWithDroid = useCallback(() => { - if (!currentProjectShortcutTargetId) { + if (!primaryNewThreadTarget) { return; } - prefetchModelsForProjectNewThread(currentProjectShortcutTargetId, { includeDroid: true }); - }, [currentProjectShortcutTargetId, prefetchModelsForProjectNewThread]); + prefetchModelsForProjectNewThread(primaryNewThreadTarget.projectId, { includeDroid: true }); + }, [prefetchModelsForProjectNewThread, primaryNewThreadTarget]); useEffect(() => { - if (!currentProjectShortcutTargetId) { + if (!primaryNewThreadTarget) { return; } - prefetchModelsForProjectNewThread(currentProjectShortcutTargetId); - }, [currentProjectShortcutTargetId, prefetchModelsForProjectNewThread]); + prefetchModelsForProjectNewThread(primaryNewThreadTarget.projectId); + }, [prefetchModelsForProjectNewThread, primaryNewThreadTarget]); const handlePrimaryNewThread = useCallback(() => { - if (currentProjectShortcutTargetId) { - prefetchModelsForProjectNewThread(currentProjectShortcutTargetId, { includeDroid: true }); - void handleNewThread(currentProjectShortcutTargetId, { + if (primaryNewThreadTarget) { + prefetchModelsForProjectNewThread(primaryNewThreadTarget.projectId, { + includeDroid: true, + }); + void handleNewThread(primaryNewThreadTarget.projectId, { envMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, }), @@ -2956,13 +2976,18 @@ export default function Sidebar() { return; } - handleStartAddProject(); + // Project state is briefly empty while the persisted shell hydrates. Do + // not turn a startup click into an unrelated "Add project" interaction. + if (threadsHydrated) { + handleStartAddProject(); + } }, [ appSettings.defaultThreadEnvMode, - currentProjectShortcutTargetId, handleNewThread, handleStartAddProject, prefetchModelsForProjectNewThread, + primaryNewThreadTarget, + threadsHydrated, ]); const handleImportThread = useCallback( @@ -6602,7 +6627,7 @@ export default function Sidebar() { { id: "new-thread", label: "New thread", - description: "Start a fresh thread in the current project.", + description: "Start a fresh thread in the current or most recently used project.", keywords: ["thread", "new", "project"], shortcutLabel: newThreadShortcutLabel, }, diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 1e998837..2fd8a5ae 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,11 +1,32 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + executeTerminalSelectionAction, + normalizeTerminalClipboardText, resolveTerminalSelectionActionPosition, shouldHandleTerminalSelectionMouseUp, terminalSelectionActionDelayForClickCount, } from "./terminal/terminalSelectionActions"; +describe("normalizeTerminalClipboardText", () => { + it("removes per-line terminal padding while preserving CRLF line endings", () => { + expect(normalizeTerminalClipboardText("first \r\nsecond\t \r\nthird ")).toBe( + "first\r\nsecond\r\nthird", + ); + }); + + it("preserves leading indentation and internal whitespace", () => { + expect(normalizeTerminalClipboardText(" indented value \t kept \n\tnext line\t")).toBe( + " indented value \t kept\n\tnext line", + ); + }); + + it("returns already-normalized selections unchanged", () => { + const selection = " leading indentation\r\ninternal spaces and\ttabs"; + expect(normalizeTerminalClipboardText(selection)).toBe(selection); + }); +}); + describe("resolveTerminalSelectionActionPosition", () => { it("prefers the selection rect over the last pointer position", () => { expect( @@ -73,3 +94,97 @@ describe("resolveTerminalSelectionActionPosition", () => { expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); }); }); + +describe("executeTerminalSelectionAction", () => { + it("copies normalized terminal text, preserves the selection, and restores focus", async () => { + const copyText = vi.fn(async () => undefined); + const addToChat = vi.fn(); + const clearSelection = vi.fn(); + const focusTerminal = vi.fn(); + const reportCopyError = vi.fn(); + + await executeTerminalSelectionAction({ + action: "copy", + clipboardText: "raw\r\nselection ", + selection: { text: "normalized\nselection" }, + copyText, + addToChat, + clearSelection, + focusTerminal, + reportCopyError, + isCurrent: () => true, + }); + + expect(copyText).toHaveBeenCalledWith("raw\r\nselection"); + expect(addToChat).not.toHaveBeenCalled(); + expect(clearSelection).not.toHaveBeenCalled(); + expect(reportCopyError).not.toHaveBeenCalled(); + expect(focusTerminal).toHaveBeenCalledOnce(); + }); + + it("keeps Add to chat normalization and selection lifecycle unchanged", async () => { + const selection = { text: "normalized\nselection" }; + const addToChat = vi.fn(); + const clearSelection = vi.fn(); + const focusTerminal = vi.fn(); + + await executeTerminalSelectionAction({ + action: "add-to-chat", + clipboardText: "raw selection ", + selection, + copyText: vi.fn(async () => undefined), + addToChat, + clearSelection, + focusTerminal, + reportCopyError: vi.fn(), + isCurrent: () => true, + }); + + expect(addToChat).toHaveBeenCalledWith(selection); + expect(clearSelection).toHaveBeenCalledOnce(); + expect(focusTerminal).toHaveBeenCalledOnce(); + }); + + it("reports clipboard rejection and does not apply stale async completions", async () => { + let current = true; + const error = new Error("clipboard unavailable"); + const reportCopyError = vi.fn(); + const focusTerminal = vi.fn(); + + await executeTerminalSelectionAction({ + action: "copy", + clipboardText: "selection", + selection: { text: "selection" }, + copyText: async () => { + current = false; + throw error; + }, + addToChat: vi.fn(), + clearSelection: vi.fn(), + focusTerminal, + reportCopyError, + isCurrent: () => current, + }); + + expect(reportCopyError).not.toHaveBeenCalled(); + expect(focusTerminal).not.toHaveBeenCalled(); + + current = true; + await executeTerminalSelectionAction({ + action: "copy", + clipboardText: "selection", + selection: { text: "selection" }, + copyText: async () => { + throw error; + }, + addToChat: vi.fn(), + clearSelection: vi.fn(), + focusTerminal, + reportCopyError, + isCurrent: () => current, + }); + + expect(reportCopyError).toHaveBeenCalledWith(error); + expect(focusTerminal).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index fb4ee596..476ab7a2 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -18,6 +18,7 @@ import { Terminal } from "@xterm/xterm"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { readNativeApi } from "~/nativeApi"; +import { copyTextToClipboard } from "~/hooks/useCopyToClipboard"; import { MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, @@ -31,6 +32,7 @@ import { } from "./terminal/TerminalChrome"; import { resolveThreadTerminalLayout } from "./terminal/TerminalLayout"; import { + executeTerminalSelectionAction, resolveTerminalSelectionActionPosition, shouldHandleTerminalSelectionMouseUp, terminalSelectionActionDelayForClickCount, @@ -39,6 +41,7 @@ import { buildTerminalRuntimeKey, terminalRuntimeRegistry, } from "./terminal/terminalRuntimeRegistry"; +import { writeSystemMessage } from "./terminal/terminalRuntimeAppearance"; import type { TerminalRuntimeConfig, TerminalRuntimeStatus, @@ -312,6 +315,7 @@ function TerminalViewport({ const readSelectionAction = useCallback((): { position: { x: number; y: number }; + clipboardText: string; selection: TerminalContextSelection; } | null => { const activeTerminal = terminalRef.current; @@ -340,6 +344,7 @@ function TerminalViewport({ }); return { position, + clipboardText: selectionText, selection: { terminalId, terminalLabel: terminalLabelRef.current, @@ -365,15 +370,34 @@ function TerminalViewport({ selectionActionOpenRef.current = true; try { const clicked = await api.contextMenu.show( - [{ id: "add-to-chat", label: "Add to chat" }], + [ + { id: "add-to-chat", label: "Add to chat" }, + { id: "copy", label: "Copy" }, + ], nextAction.position, ); - if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") { + if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } - onAddTerminalContextRef.current(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRuntimeRegistry.focus(runtimeKey); + await executeTerminalSelectionAction({ + action: clicked, + clipboardText: nextAction.clipboardText, + selection: nextAction.selection, + copyText: copyTextToClipboard, + addToChat: (selection) => onAddTerminalContextRef.current(selection), + clearSelection: () => terminalRef.current?.clearSelection(), + focusTerminal: () => terminalRuntimeRegistry.focus(runtimeKey), + reportCopyError: (error) => { + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to copy terminal selection", + ); + } + }, + isCurrent: () => requestId === selectionActionRequestIdRef.current, + }); } finally { selectionActionOpenRef.current = false; } diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index d39c4954..2bf245aa 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -648,7 +648,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker( {selectedModelLabel} )} - + { const selection = terminal.getSelection(); if (!selection) return; - const trimmed = selection.replace(/[^\S\n]+$/gm, ""); - if (trimmed === selection) return; + const normalizedSelection = normalizeTerminalClipboardText(selection); + if (normalizedSelection === selection) return; if (event.clipboardData) { event.preventDefault(); - event.clipboardData.setData("text/plain", trimmed); + event.clipboardData.setData("text/plain", normalizedSelection); return; } - void navigator.clipboard?.writeText(trimmed).catch(() => undefined); + void navigator.clipboard?.writeText(normalizedSelection).catch(() => undefined); }; wrapper.addEventListener("copy", handleCopy); entry.persistentDisposables.push(() => { diff --git a/apps/web/src/components/terminal/terminalSelectionActions.ts b/apps/web/src/components/terminal/terminalSelectionActions.ts index 0009488a..fa837d92 100644 --- a/apps/web/src/components/terminal/terminalSelectionActions.ts +++ b/apps/web/src/components/terminal/terminalSelectionActions.ts @@ -4,6 +4,13 @@ const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; +// Xterm selections can include visual cell padding at the end of each line. +// Keep meaningful leading/internal whitespace and the original line endings, +// while matching the terminal runtime's established keyboard-copy behavior. +export function normalizeTerminalClipboardText(selection: string): string { + return selection.replace(/[^\S\r\n]+(?=\r?$)/gm, ""); +} + export function resolveTerminalSelectionActionPosition(options: { bounds: { left: number; top: number; width: number; height: number }; selectionRect: { right: number; bottom: number } | null; @@ -49,3 +56,33 @@ export function shouldHandleTerminalSelectionMouseUp( ): boolean { return selectionGestureActive && button === 0; } + +export async function executeTerminalSelectionAction(input: { + action: "add-to-chat" | "copy"; + clipboardText: string; + selection: T; + copyText: (text: string) => Promise; + addToChat: (selection: T) => void; + clearSelection: () => void; + focusTerminal: () => void; + reportCopyError: (error: unknown) => void; + isCurrent: () => boolean; +}): Promise { + if (!input.isCurrent()) return; + if (input.action === "add-to-chat") { + input.addToChat(input.selection); + input.clearSelection(); + input.focusTerminal(); + return; + } + + try { + await input.copyText(normalizeTerminalClipboardText(input.clipboardText)); + } catch (error) { + if (!input.isCurrent()) return; + input.reportCopyError(error); + } + if (input.isCurrent()) { + input.focusTerminal(); + } +} diff --git a/apps/web/src/lib/projectShortcutTargets.test.ts b/apps/web/src/lib/projectShortcutTargets.test.ts new file mode 100644 index 00000000..ea5ee3c0 --- /dev/null +++ b/apps/web/src/lib/projectShortcutTargets.test.ts @@ -0,0 +1,83 @@ +import type { ProjectId } from "@synara/contracts"; +import { describe, expect, it } from "vitest"; + +import type { Project } from "../types"; +import { + resolveCurrentProjectTargetId, + resolveLatestProjectTargetId, + resolveNewThreadTarget, +} from "./projectShortcutTargets"; + +const CURRENT_PROJECT_ID = "project-current" as ProjectId; +const LATEST_PROJECT_ID = "project-latest" as ProjectId; +const HOME_PROJECT_ID = "project-home" as ProjectId; +const STUDIO_PROJECT_ID = "project-studio" as ProjectId; + +function makeProject(id: ProjectId, kind: Project["kind"] = "project"): Project { + return { + id, + kind, + name: id, + remoteName: id, + folderName: id, + localName: null, + cwd: `/workspace/${id}`, + defaultModelSelection: null, + expanded: false, + scripts: [], + }; +} + +describe("project shortcut targets", () => { + const projects = [ + makeProject(CURRENT_PROJECT_ID), + makeProject(LATEST_PROJECT_ID), + makeProject(HOME_PROJECT_ID, "chat"), + makeProject(STUDIO_PROJECT_ID, "studio"), + ]; + + it("prefers the focused ordinary project over the latest project", () => { + expect( + resolveNewThreadTarget({ + currentProjectId: resolveCurrentProjectTargetId(projects, CURRENT_PROJECT_ID), + latestUsableProjectId: resolveLatestProjectTargetId(projects, LATEST_PROJECT_ID), + }), + ).toEqual({ projectId: CURRENT_PROJECT_ID, inheritContext: true }); + }); + + it("falls back to the latest ordinary project from Home or an unfocused shell", () => { + for (const focusedProjectId of [HOME_PROJECT_ID, null]) { + expect( + resolveNewThreadTarget({ + currentProjectId: resolveCurrentProjectTargetId(projects, focusedProjectId), + latestUsableProjectId: resolveLatestProjectTargetId(projects, LATEST_PROJECT_ID), + }), + ).toEqual({ projectId: LATEST_PROJECT_ID, inheritContext: false }); + } + }); + + it.each([HOME_PROJECT_ID, STUDIO_PROJECT_ID])( + "rejects a non-ordinary latest project target (%s)", + (projectId) => { + expect(resolveLatestProjectTargetId(projects, projectId)).toBeNull(); + }, + ); + + it("returns no target for stale or absent projects", () => { + expect( + resolveNewThreadTarget({ + currentProjectId: null, + latestUsableProjectId: resolveLatestProjectTargetId( + projects, + "project-deleted" as ProjectId, + ), + }), + ).toBeNull(); + expect( + resolveNewThreadTarget({ + currentProjectId: resolveCurrentProjectTargetId([], null), + latestUsableProjectId: resolveLatestProjectTargetId([], null), + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/projectShortcutTargets.ts b/apps/web/src/lib/projectShortcutTargets.ts index ab4c75fe..36c36f07 100644 --- a/apps/web/src/lib/projectShortcutTargets.ts +++ b/apps/web/src/lib/projectShortcutTargets.ts @@ -40,9 +40,9 @@ export interface NewThreadTarget { readonly inheritContext: boolean; } -// Single rule for which project a "new thread" chord targets: the focused project when -// one is usable, otherwise the most recently used project. Shared by every create chord -// (new thread, new terminal, provider-specific) so they never disagree on the fallback. +// Single rule for which project a global "new thread" action targets: the focused project +// when one is usable, otherwise the most recently used project. Shared by click, palette, +// and keyboard entry points so they never disagree on the fallback. export function resolveNewThreadTarget(input: { currentProjectId: ProjectId | null; latestUsableProjectId: ProjectId | null; diff --git a/upstream-state.json b/upstream-state.json index 503a5dfc..fff64d7c 100644 --- a/upstream-state.json +++ b/upstream-state.json @@ -5,8 +5,8 @@ "officialRepository": "Emanuele-web04/synara", "officialDefaultBranch": "main", "updateMode": "divergent-cherry-pick", - "reviewedThrough": "3a5720bdd0ae4ace444379cabf0a634941d232fd", - "reviewedAt": "2026-07-18", + "reviewedThrough": "d3b9c66d97781170a826f10f0fba9eeb42294383", + "reviewedAt": "2026-07-21", "integrationBase": "9be46c3ce6a7521b64436b7334bc6fce16e3cac4", - "reviewRecord": "ScientFactory/Scient:lab/external/upstream-reviews/2026-07-18-scient-desktop.md" + "reviewRecord": "ScientFactory/Scient:lab/external/upstream-reviews/2026-07-21-scient-desktop.md" }