From 5ae45c8c8e9748294e9d35003797718320c59e96 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 01:29:25 +0900 Subject: [PATCH] fix: use correct project directory for Windows subagents (#1718) --- .../spawner/parent-directory-resolver.test.ts | 33 ++++++++ .../spawner/parent-directory-resolver.ts | 7 +- src/shared/index.ts | 1 + src/shared/session-directory-resolver.test.ts | 79 +++++++++++++++++++ src/shared/session-directory-resolver.ts | 39 +++++++++ .../subagent-session-creator.test.ts | 72 +++++++++++++---- .../subagent-session-creator.ts | 8 +- 7 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 src/features/background-agent/spawner/parent-directory-resolver.test.ts create mode 100644 src/shared/session-directory-resolver.test.ts create mode 100644 src/shared/session-directory-resolver.ts diff --git a/src/features/background-agent/spawner/parent-directory-resolver.test.ts b/src/features/background-agent/spawner/parent-directory-resolver.test.ts new file mode 100644 index 000000000..2fcae8255 --- /dev/null +++ b/src/features/background-agent/spawner/parent-directory-resolver.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" + +import { resolveParentDirectory } from "./parent-directory-resolver" + +describe("background-agent parent-directory-resolver", () => { + const originalPlatform = process.platform + + test("uses current working directory on Windows when parent session directory is AppData", async () => { + //#given + Object.defineProperty(process, "platform", { value: "win32" }) + try { + const client = { + session: { + get: async () => ({ + data: { directory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop" }, + }), + }, + } + + //#when + const result = await resolveParentDirectory({ + client: client as Parameters[0]["client"], + parentSessionID: "ses_parent", + defaultDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode", + }) + + //#then + expect(result).toBe(process.cwd()) + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }) + } + }) +}) diff --git a/src/features/background-agent/spawner/parent-directory-resolver.ts b/src/features/background-agent/spawner/parent-directory-resolver.ts index 7e527551f..48b1cee53 100644 --- a/src/features/background-agent/spawner/parent-directory-resolver.ts +++ b/src/features/background-agent/spawner/parent-directory-resolver.ts @@ -1,5 +1,5 @@ import type { OpencodeClient } from "../constants" -import { log } from "../../../shared" +import { log, resolveSessionDirectory } from "../../../shared" export async function resolveParentDirectory(options: { client: OpencodeClient @@ -15,7 +15,10 @@ export async function resolveParentDirectory(options: { return null }) - const parentDirectory = parentSession?.data?.directory ?? defaultDirectory + const parentDirectory = resolveSessionDirectory({ + parentDirectory: parentSession?.data?.directory, + fallbackDirectory: defaultDirectory, + }) log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) return parentDirectory } diff --git a/src/shared/index.ts b/src/shared/index.ts index 85a62b831..f9b58f41c 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -54,3 +54,4 @@ export * from "./truncate-description" export * from "./opencode-storage-paths" export * from "./opencode-message-dir" export * from "./normalize-sdk-response" +export * from "./session-directory-resolver" diff --git a/src/shared/session-directory-resolver.test.ts b/src/shared/session-directory-resolver.test.ts new file mode 100644 index 000000000..717d9f902 --- /dev/null +++ b/src/shared/session-directory-resolver.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" + +import { isWindowsAppDataDirectory, resolveSessionDirectory } from "./session-directory-resolver" + +describe("session-directory-resolver", () => { + describe("isWindowsAppDataDirectory", () => { + test("returns true when path is under AppData Local", () => { + //#given + const directory = "C:/Users/test/AppData/Local/opencode" + + //#when + const result = isWindowsAppDataDirectory(directory) + + //#then + expect(result).toBe(true) + }) + + test("returns false when path is outside AppData", () => { + //#given + const directory = "D:/projects/oh-my-opencode" + + //#when + const result = isWindowsAppDataDirectory(directory) + + //#then + expect(result).toBe(false) + }) + }) + + describe("resolveSessionDirectory", () => { + test("uses process working directory on Windows when parent directory drifts to AppData", () => { + //#given + const options = { + parentDirectory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop", + fallbackDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode", + platform: "win32" as const, + currentWorkingDirectory: "D:\\projects\\oh-my-opencode", + } + + //#when + const result = resolveSessionDirectory(options) + + //#then + expect(result).toBe("D:\\projects\\oh-my-opencode") + }) + + test("keeps AppData directory when current working directory is also AppData", () => { + //#given + const options = { + parentDirectory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop", + fallbackDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode", + platform: "win32" as const, + currentWorkingDirectory: "C:\\Users\\test\\AppData\\Local\\Temp", + } + + //#when + const result = resolveSessionDirectory(options) + + //#then + expect(result).toBe("C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop") + }) + + test("keeps original directory outside Windows", () => { + //#given + const options = { + parentDirectory: "/tmp/opencode", + fallbackDirectory: "/workspace/project", + platform: "darwin" as const, + currentWorkingDirectory: "/workspace/project", + } + + //#when + const result = resolveSessionDirectory(options) + + //#then + expect(result).toBe("/tmp/opencode") + }) + }) +}) diff --git a/src/shared/session-directory-resolver.ts b/src/shared/session-directory-resolver.ts new file mode 100644 index 000000000..bc76f1a51 --- /dev/null +++ b/src/shared/session-directory-resolver.ts @@ -0,0 +1,39 @@ +const WINDOWS_APPDATA_SEGMENTS = ["\\appdata\\local\\", "\\appdata\\roaming\\", "\\appdata\\locallow\\"] + +function normalizeWindowsPath(directory: string): string { + return directory.replaceAll("/", "\\").toLowerCase() +} + +export function isWindowsAppDataDirectory(directory: string): boolean { + const normalizedDirectory = normalizeWindowsPath(directory) + return WINDOWS_APPDATA_SEGMENTS.some((segment) => normalizedDirectory.includes(segment)) +} + +export function resolveSessionDirectory(options: { + parentDirectory: string | null | undefined + fallbackDirectory: string + platform?: NodeJS.Platform + currentWorkingDirectory?: string +}): string { + const { + parentDirectory, + fallbackDirectory, + platform = process.platform, + currentWorkingDirectory = process.cwd(), + } = options + + const sessionDirectory = parentDirectory ?? fallbackDirectory + if (platform !== "win32") { + return sessionDirectory + } + + if (!isWindowsAppDataDirectory(sessionDirectory)) { + return sessionDirectory + } + + if (isWindowsAppDataDirectory(currentWorkingDirectory)) { + return sessionDirectory + } + + return currentWorkingDirectory +} diff --git a/src/tools/call-omo-agent/subagent-session-creator.test.ts b/src/tools/call-omo-agent/subagent-session-creator.test.ts index bacf60f46..dea60d524 100644 --- a/src/tools/call-omo-agent/subagent-session-creator.test.ts +++ b/src/tools/call-omo-agent/subagent-session-creator.test.ts @@ -4,44 +4,88 @@ import { resolveOrCreateSessionId } from "./subagent-session-creator" import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state" describe("call-omo-agent resolveOrCreateSessionId", () => { - test("tracks newly created child session as subagent session", async () => { - // given - _resetForTesting() + const originalPlatform = process.platform + + function buildInput(options: { + parentDirectory?: string + contextDirectory: string + }): { + ctx: Parameters[0] + args: Parameters[1] + toolContext: Parameters[2] + createCalls: Array<{ query?: { directory?: string } }> + } { + const createCalls: Array<{ query?: { directory?: string } }> = [] + const { parentDirectory, contextDirectory } = options + const parentSessionData = parentDirectory ? { data: { directory: parentDirectory } } : { data: {} } - const createCalls: Array = [] const ctx = { - directory: "/project", + directory: contextDirectory, client: { session: { - get: async () => ({ data: { directory: "/parent" } }), - create: async (args: unknown) => { - createCalls.push(args) + get: async () => parentSessionData, + create: async (createInput: unknown) => { + const payload = createInput as { query?: { directory?: string } } + createCalls.push(payload) return { data: { id: "ses_child_sync" } } }, }, }, - } + } as unknown as Parameters[0] const args = { description: "sync test", prompt: "hello", subagent_type: "explore", run_in_background: false, - } + } satisfies Parameters[1] const toolContext = { sessionID: "ses_parent", messageID: "msg_parent", agent: "sisyphus", abort: new AbortController().signal, - } + } satisfies Parameters[2] - // when - const result = await resolveOrCreateSessionId(ctx as any, args as any, toolContext as any) + return { ctx, args, toolContext, createCalls } + } - // then + test("tracks newly created child session as subagent session", async () => { + //#given + _resetForTesting() + + const { ctx, args, toolContext, createCalls } = buildInput({ + parentDirectory: "/parent", + contextDirectory: "/project", + }) + + //#when + const result = await resolveOrCreateSessionId(ctx, args, toolContext) + + //#then expect(result).toEqual({ ok: true, sessionID: "ses_child_sync" }) expect(createCalls).toHaveLength(1) expect(subagentSessions.has("ses_child_sync")).toBe(true) }) + + test("uses current working directory on Windows when parent directory is under AppData", async () => { + //#given + _resetForTesting() + Object.defineProperty(process, "platform", { value: "win32" }) + try { + const { ctx, args, toolContext, createCalls } = buildInput({ + parentDirectory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop", + contextDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode", + }) + + //#when + await resolveOrCreateSessionId(ctx, args, toolContext) + + //#then + expect(createCalls).toHaveLength(1) + expect(createCalls[0]?.query?.directory).toBe(process.cwd()) + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }) + } + }) }) diff --git a/src/tools/call-omo-agent/subagent-session-creator.ts b/src/tools/call-omo-agent/subagent-session-creator.ts index cd637d236..383cae638 100644 --- a/src/tools/call-omo-agent/subagent-session-creator.ts +++ b/src/tools/call-omo-agent/subagent-session-creator.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared" +import { resolveSessionDirectory } from "../../shared" import { subagentSessions } from "../../features/claude-code-session-state" import type { CallOmoAgentArgs } from "./types" import type { ToolContextWithMetadata } from "./tool-context-with-metadata" @@ -27,11 +28,14 @@ export async function resolveOrCreateSessionId( log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`) const parentSession = await ctx.client.session .get({ path: { id: toolContext.sessionID } }) - .catch((err) => { + .catch((err: unknown) => { log("[call_omo_agent] Failed to get parent session", { error: String(err) }) return null }) - const parentDirectory = parentSession?.data?.directory ?? ctx.directory + const parentDirectory = resolveSessionDirectory({ + parentDirectory: parentSession?.data?.directory, + fallbackDirectory: ctx.directory, + }) const body = { parentID: toolContext.sessionID,