fix: use correct project directory for Windows subagents (#1718)

This commit is contained in:
YeonGyu-Kim 2026-02-17 01:29:25 +09:00
parent fcf26d9898
commit 5ae45c8c8e
7 changed files with 221 additions and 18 deletions

View File

@ -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<typeof resolveParentDirectory>[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 })
}
})
})

View File

@ -1,5 +1,5 @@
import type { OpencodeClient } from "../constants" import type { OpencodeClient } from "../constants"
import { log } from "../../../shared" import { log, resolveSessionDirectory } from "../../../shared"
export async function resolveParentDirectory(options: { export async function resolveParentDirectory(options: {
client: OpencodeClient client: OpencodeClient
@ -15,7 +15,10 @@ export async function resolveParentDirectory(options: {
return null 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}`) log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
return parentDirectory return parentDirectory
} }

View File

@ -54,3 +54,4 @@ export * from "./truncate-description"
export * from "./opencode-storage-paths" export * from "./opencode-storage-paths"
export * from "./opencode-message-dir" export * from "./opencode-message-dir"
export * from "./normalize-sdk-response" export * from "./normalize-sdk-response"
export * from "./session-directory-resolver"

View File

@ -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")
})
})
})

View File

@ -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
}

View File

@ -4,44 +4,88 @@ import { resolveOrCreateSessionId } from "./subagent-session-creator"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state" import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
describe("call-omo-agent resolveOrCreateSessionId", () => { describe("call-omo-agent resolveOrCreateSessionId", () => {
test("tracks newly created child session as subagent session", async () => { const originalPlatform = process.platform
// given
_resetForTesting() function buildInput(options: {
parentDirectory?: string
contextDirectory: string
}): {
ctx: Parameters<typeof resolveOrCreateSessionId>[0]
args: Parameters<typeof resolveOrCreateSessionId>[1]
toolContext: Parameters<typeof resolveOrCreateSessionId>[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<unknown> = []
const ctx = { const ctx = {
directory: "/project", directory: contextDirectory,
client: { client: {
session: { session: {
get: async () => ({ data: { directory: "/parent" } }), get: async () => parentSessionData,
create: async (args: unknown) => { create: async (createInput: unknown) => {
createCalls.push(args) const payload = createInput as { query?: { directory?: string } }
createCalls.push(payload)
return { data: { id: "ses_child_sync" } } return { data: { id: "ses_child_sync" } }
}, },
}, },
}, },
} } as unknown as Parameters<typeof resolveOrCreateSessionId>[0]
const args = { const args = {
description: "sync test", description: "sync test",
prompt: "hello", prompt: "hello",
subagent_type: "explore", subagent_type: "explore",
run_in_background: false, run_in_background: false,
} } satisfies Parameters<typeof resolveOrCreateSessionId>[1]
const toolContext = { const toolContext = {
sessionID: "ses_parent", sessionID: "ses_parent",
messageID: "msg_parent", messageID: "msg_parent",
agent: "sisyphus", agent: "sisyphus",
abort: new AbortController().signal, abort: new AbortController().signal,
} } satisfies Parameters<typeof resolveOrCreateSessionId>[2]
// when return { ctx, args, toolContext, createCalls }
const result = await resolveOrCreateSessionId(ctx as any, args as any, toolContext as any) }
// 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(result).toEqual({ ok: true, sessionID: "ses_child_sync" })
expect(createCalls).toHaveLength(1) expect(createCalls).toHaveLength(1)
expect(subagentSessions.has("ses_child_sync")).toBe(true) 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 })
}
})
}) })

View File

@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared" import { log } from "../../shared"
import { resolveSessionDirectory } from "../../shared"
import { subagentSessions } from "../../features/claude-code-session-state" import { subagentSessions } from "../../features/claude-code-session-state"
import type { CallOmoAgentArgs } from "./types" import type { CallOmoAgentArgs } from "./types"
import type { ToolContextWithMetadata } from "./tool-context-with-metadata" 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}`) log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`)
const parentSession = await ctx.client.session const parentSession = await ctx.client.session
.get({ path: { id: toolContext.sessionID } }) .get({ path: { id: toolContext.sessionID } })
.catch((err) => { .catch((err: unknown) => {
log("[call_omo_agent] Failed to get parent session", { error: String(err) }) log("[call_omo_agent] Failed to get parent session", { error: String(err) })
return null return null
}) })
const parentDirectory = parentSession?.data?.directory ?? ctx.directory const parentDirectory = resolveSessionDirectory({
parentDirectory: parentSession?.data?.directory,
fallbackDirectory: ctx.directory,
})
const body = { const body = {
parentID: toolContext.sessionID, parentID: toolContext.sessionID,