fix(sessions): route new sessions to the correct project when server omits directory (#1637, #2270) (#2272)

* fix(sessions): route new sessions to the correct project when server omits directory

createSession() passed the server response's `directory` field to
setCurrentSession as `directoryHint`. When the server response omitted
the field (a common path), `directoryHint` was `null` and
setCurrentSession fell back to `opencodeClient.getDirectory()`, which
could still hold a stale directory from a different project.

That fallback caused:
- #1637: clicking `+` on one project while browsing another created
  a session grouped under the wrong project.
- #2270: in a parent Git repo with multiple child projects (some
  without their own Git), sessions from a child with its own Git were
  grouped under a sibling child without Git.

Capture the effective directory passed to the SDK and reuse it as the
fallback for the server response. This guarantees `setCurrentSession`
and `registerSessionDirectory` always see the directory the user
explicitly selected for the new session.

Add regression tests covering both reported topologies and the
no-override / no-server-directory preservation path.

* fix(sessions): prefer registered project paths

---------

Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Leonid
2026-07-17 13:14:22 +03:00
committed by GitHub
co-authored by bashrusakh Bohdan Triapitsyn
parent 0202e88eb4
commit f5b4a267c0
3 changed files with 312 additions and 4 deletions
+2 -2
View File
@@ -59,5 +59,5 @@ export const resolveProjectForSessionDirectory = (
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
directory: string | null,
): ProjectEntry | null =>
resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory) ??
resolveProjectForDirectory(projects, directory);
resolveProjectForDirectory(projects, directory) ??
resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory);
@@ -0,0 +1,302 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2/client"
import type { ProjectEntry } from "@/lib/api/types"
import type { WorktreeMetadata } from "@/types/worktree"
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"
// Recorded call info
const setCurrentSessionCalls: Array<{ id: string | null; directoryHint: string | null | undefined }> = []
const registerSessionDirectoryCalls: Array<{ sessionID: string; directory: string }> = []
const upsertSessionCalls: Session[] = []
const markSessionAsOpenChamberCreatedCalls: string[] = []
// Configurable opencodeClient.createSession — set per test
let nextCreateSessionResponse: Session = { id: "ses_default", time: { created: 1 } } as Session
let nextCreateSessionCalls: Array<{ params: unknown; directory: string | null | undefined }> = []
// Configurable current directory (used as fallback when no directoryOverride is set)
let currentDirectory: string | null = null
mock.module("@/lib/opencode/client", () => ({
opencodeClient: {
getDirectory: () => currentDirectory,
setDirectory: mock(() => undefined),
createSession: mock(async (params: unknown, directory?: string | null) => {
nextCreateSessionCalls.push({ params, directory })
return nextCreateSessionResponse
}),
},
}))
mock.module("../session-ui-store", () => ({
useSessionUIStore: {
getState: () => ({
setCurrentSession: (id: string | null, directoryHint?: string | null) => {
setCurrentSessionCalls.push({ id, directoryHint })
},
markSessionAsOpenChamberCreated: (sessionId: string) => {
markSessionAsOpenChamberCreatedCalls.push(sessionId)
},
}),
},
}))
mock.module("../sync-refs", () => ({
registerSessionDirectory: (sessionID: string, directory: string) => {
registerSessionDirectoryCalls.push({ sessionID, directory })
},
}))
mock.module("@/stores/useGlobalSessionsStore", () => ({
useGlobalSessionsStore: {
getState: () => ({
upsertSession: (session: Session) => {
upsertSessionCalls.push(session)
},
}),
},
mergeSessionDirectoryMetadata: (incoming: Session) => incoming,
mergeLiveSessionWithGlobalSession: (incoming: Session) => incoming,
resolveGlobalSessionDirectory: () => null,
}))
mock.module("@/stores/useConfigStore", () => ({
useConfigStore: {
getState: () => ({
isConnected: true,
hasEverConnected: true,
}),
},
}))
mock.module("../input-store", () => ({
useInputStore: {
getState: () => ({
clearAttachedFiles: () => undefined,
addRestoredAttachment: () => undefined,
}),
},
}))
const { createSession, setActionRefs } = await import("../session-actions")
beforeEach(() => {
setCurrentSessionCalls.length = 0
registerSessionDirectoryCalls.length = 0
upsertSessionCalls.length = 0
markSessionAsOpenChamberCreatedCalls.length = 0
nextCreateSessionCalls = []
nextCreateSessionResponse = { id: "ses_default", time: { created: 1 } } as Session
currentDirectory = null
// Initialize action refs. The first two args (sdk, childStores) are not
// exercised by `createSession` itself, only the directory getter is.
setActionRefs(
{} as never,
{ children: new Map(), ensureChild: () => ({}), getChild: () => undefined } as never,
() => currentDirectory ?? "",
)
})
describe("issue #1637 — server omits directory, falls back to directoryOverride", () => {
test("when server response omits directory and directoryOverride is set, setCurrentSession receives the directoryOverride (not null)", async () => {
nextCreateSessionResponse = { id: "ses_1637_a", time: { created: 1 } } as Session
const result = await createSession("test title", "/projects/alpha", null)
expect(result?.id).toBe("ses_1637_a")
expect(nextCreateSessionCalls).toHaveLength(1)
expect(nextCreateSessionCalls[0].directory).toBe("/projects/alpha")
expect(setCurrentSessionCalls).toHaveLength(1)
expect(setCurrentSessionCalls[0]).toEqual({
id: "ses_1637_a",
directoryHint: "/projects/alpha",
})
// registerSessionDirectory is also called with the effective directory
expect(registerSessionDirectoryCalls).toEqual([{ sessionID: "ses_1637_a", directory: "/projects/alpha" }])
})
})
describe("issue #1637 — server returns directory, server value wins", () => {
test("when server response includes directory and directoryOverride is set, setCurrentSession receives the server directory", async () => {
nextCreateSessionResponse = {
id: "ses_1637_b",
time: { created: 1 },
directory: "/projects/gamma",
} as Session
const result = await createSession("test title", "/projects/alpha", null)
expect(result?.id).toBe("ses_1637_b")
expect(setCurrentSessionCalls).toHaveLength(1)
// Server's directory should take precedence over the override
expect(setCurrentSessionCalls[0]).toEqual({
id: "ses_1637_b",
directoryHint: "/projects/gamma",
})
expect(registerSessionDirectoryCalls).toEqual([{ sessionID: "ses_1637_b", directory: "/projects/gamma" }])
})
})
describe("issue #1637 — no directoryOverride, no server directory", () => {
test("when no directoryOverride and server omits directory, setCurrentSession receives null", async () => {
currentDirectory = null
nextCreateSessionResponse = { id: "ses_1637_c", time: { created: 1 } } as Session
const result = await createSession("test title", null, null)
expect(result?.id).toBe("ses_1637_c")
// Without any directory source, the call to opencodeClient.createSession
// is made with undefined (dir() returned undefined).
// dir() returns undefined when no current directory is set and no override is provided
expect(nextCreateSessionCalls[0].directory == null).toBe(true)
expect(setCurrentSessionCalls).toHaveLength(1)
// Existing behavior preserved when no directory info is available anywhere
expect(setCurrentSessionCalls[0]).toEqual({
id: "ses_1637_c",
directoryHint: null,
})
// registerSessionDirectory is not called when sessionDirectory is null
expect(registerSessionDirectoryCalls).toHaveLength(0)
})
})
describe("issue #1637 — no directoryOverride, server returns directory", () => {
test("when no directoryOverride but server returns directory, setCurrentSession receives the server directory", async () => {
currentDirectory = null
nextCreateSessionResponse = {
id: "ses_1637_d",
time: { created: 1 },
directory: "/projects/server-side",
} as Session
const result = await createSession("test title", null, null)
expect(result?.id).toBe("ses_1637_d")
expect(setCurrentSessionCalls).toHaveLength(1)
expect(setCurrentSessionCalls[0]).toEqual({
id: "ses_1637_d",
directoryHint: "/projects/server-side",
})
expect(registerSessionDirectoryCalls).toEqual([
{ sessionID: "ses_1637_d", directory: "/projects/server-side" },
])
})
})
describe("issue #2270 — nested Git projects: child directory wins when override is provided", () => {
test("when creating a session with a directoryOverride pointing to a child project, the child project directory is used even when the server omits directory", async () => {
// Simulate the situation where multiple child projects are registered
// under a parent Git repo. The user is on a child project and clicks "+".
// The server response omits the `directory` field.
const childProjectDir = "/work/parent-git-repo/child-project-a"
nextCreateSessionResponse = { id: "ses_2270_child", time: { created: 1 } } as Session
const result = await createSession(undefined, childProjectDir, null)
expect(result?.id).toBe("ses_2270_child")
// The SDK should be called with the child project directory
expect(nextCreateSessionCalls).toHaveLength(1)
expect(nextCreateSessionCalls[0].directory).toBe(childProjectDir)
// setCurrentSession receives the child project directory, not null
expect(setCurrentSessionCalls).toHaveLength(1)
expect(setCurrentSessionCalls[0]).toEqual({
id: "ses_2270_child",
directoryHint: childProjectDir,
})
// And it is NOT a different child project
expect(setCurrentSessionCalls[0].directoryHint).not.toBe("/work/parent-git-repo/child-project-b")
// The session is registered in the routing index under the correct directory
expect(registerSessionDirectoryCalls).toEqual([
{ sessionID: "ses_2270_child", directory: childProjectDir },
])
})
})
describe("issue #2270 — registered child project wins over a leaked ancestor worktree", () => {
test("resolves a nested child repository directly instead of through a sibling project's ancestor worktree", () => {
const projects = [
{ id: "project-a", path: "/workspace/project-a" },
{ id: "project-b", path: "/workspace/project-b" },
{ id: "project-c", path: "/workspace/project-c" },
] as ProjectEntry[]
const leakedParentWorktree = {
path: "/workspace",
projectDirectory: "/workspace",
} as WorktreeMetadata
const availableWorktreesByProject = new Map<string, WorktreeMetadata[]>([
["/workspace/project-a", [leakedParentWorktree]],
])
const resolved = resolveProjectForSessionDirectory(
projects,
availableWorktreesByProject,
"/workspace/project-b/src",
)
expect(resolved?.id).toBe("project-b")
})
test("still resolves an external worktree through its owning project", () => {
const projects = [
{ id: "project-a", path: "/workspace/project-a" },
{ id: "project-b", path: "/workspace/project-b" },
] as ProjectEntry[]
const externalWorktree = {
path: "/worktrees/project-b-feature",
projectDirectory: "/workspace/project-b",
} as WorktreeMetadata
const availableWorktreesByProject = new Map<string, WorktreeMetadata[]>([
["/workspace/project-b", [externalWorktree]],
])
const resolved = resolveProjectForSessionDirectory(
projects,
availableWorktreesByProject,
"/worktrees/project-b-feature/src",
)
expect(resolved?.id).toBe("project-b")
})
})
describe("issue #2270 — registerSessionDirectory called with effective directory", () => {
test("registerSessionDirectory is called with the effective directory (not null or a stale value) when directoryOverride is set", async () => {
// Server response omits directory; effectiveDirectory is the directoryOverride
const effectiveDir = "/projects/alpha/subdir"
nextCreateSessionResponse = { id: "ses_2270_reg", time: { created: 1 } } as Session
await createSession("title", effectiveDir, null)
expect(registerSessionDirectoryCalls).toHaveLength(1)
expect(registerSessionDirectoryCalls[0]).toEqual({
sessionID: "ses_2270_reg",
directory: effectiveDir,
})
// Sanity: not null and not a stale value
expect(registerSessionDirectoryCalls[0].directory).not.toBeNull()
expect(registerSessionDirectoryCalls[0].directory).not.toBe("")
// setCurrentSession and registerSessionDirectory must agree
expect(setCurrentSessionCalls[0].directoryHint).toBe(effectiveDir)
})
test("registerSessionDirectory is called with the server's directory when the server returns one (server takes precedence over override)", async () => {
const overrideDir = "/projects/alpha"
const serverDir = "/projects/echoed-by-server"
nextCreateSessionResponse = {
id: "ses_2270_server",
time: { created: 1 },
directory: serverDir,
} as Session
await createSession("title", overrideDir, null)
expect(registerSessionDirectoryCalls).toHaveLength(1)
expect(registerSessionDirectoryCalls[0]).toEqual({
sessionID: "ses_2270_server",
directory: serverDir,
})
// Not the override (the server has authoritative info)
expect(registerSessionDirectoryCalls[0].directory).not.toBe(overrideDir)
})
})
+8 -2
View File
@@ -436,13 +436,19 @@ export async function createSession(
metadata?: Record<string, unknown>,
): Promise<Session | null> {
try {
// Capture the effective directory used for session creation so we can fall
// back to it when the server response omits the `directory` field.
// Without this, setCurrentSession would fall through to a stale
// opencodeClient.getDirectory() value and group the session under the
// wrong project (closes #1637, #2270).
const effectiveDirectory = directoryOverride ?? dir()
const session = await opencodeClient.createSession({
title,
parentID: parentID ?? undefined,
metadata,
}, directoryOverride ?? dir())
}, effectiveDirectory)
const sessionDirectory = (session as { directory?: string | null }).directory ?? null
const sessionDirectory = (session as { directory?: string | null }).directory ?? effectiveDirectory ?? null
// Pre-populate routing index so SSE events arriving before session.created
// can be routed to the correct child store
if (sessionDirectory) {