diff --git a/.changeset/fix-multi-project-session-scope.md b/.changeset/fix-multi-project-session-scope.md new file mode 100644 index 00000000000..d56c4c93018 --- /dev/null +++ b/.changeset/fix-multi-project-session-scope.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Scope Agent Manager session events and Git status to the active project, including edits inside nested repositories. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index be4377e46ff..c04d818b4b8 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -50,7 +50,6 @@ import { import { GitOps } from "./agent-manager/GitOps" import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller" import { diffSummary as localDiffSummary } from "./agent-manager/local-diff" -import { getWorkspaceRoot } from "./review-utils" import { createMarketplaceRemover, removeMcp } from "./kilo-provider/remove-config-item" import { AgentRequirementsController } from "./kilo-provider/agent-requirements-controller" import type { RemoteStatusService } from "./services/RemoteStatusService" @@ -441,6 +440,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private statsGitOps: GitOps | null = null private cachedStats: unknown = null private cachedGitRepo = false + private cachedGitDirectory: string | undefined + private gitStatusRevision = 0 private onBeforeMessage: ((msg: Record) => Promise | null>) | null = null @@ -1664,6 +1665,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Subscribe to SSE events for this webview (filtered by tracked sessions) this.unsubscribeEvent = this.connectionService.onEventFiltered( (payload, directory) => { + if (directory && !this.isCurrentProjectDirectory(directory)) return false + if (!directory && isEventFromForeignProject(payload, this.projectID)) return false const event = unwrapSyncEvent(payload) if (!event) return false @@ -1675,6 +1678,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // message.part.* events are always session-scoped; drop if session unknown. if (!sessionId) return !isSessionScopedPartEvent(event.type) + if (!directory && !this.isCurrentProjectSession(sessionId)) return false if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) { return true @@ -1821,15 +1825,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.memory.fetch(), this.seedSessionStatusMap(), ]) - this.cachedGitRepo = await hasGit(this.client!, this.getWorkspaceDirectory()) - this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo }) + await this.refreshGitStatus(this.getWorkspaceDirectory()) this.sendNotificationSettings() this.sendTimelineSetting() this.postMessage(buildThroughputSettingMessage()) this.postMessage({ type: "extensionDataReady" }) - if (this.cachedGitRepo) this.startStatsPolling() - console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully") } catch (error) { console.error("[Kilo New] KiloProvider: ❌ Failed to initialize connection:", error) @@ -1890,6 +1891,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Non-blocking: refresh session metadata + status for the webview after switching. */ private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void { if (!this.client) return + void this.refreshGitStatus(dir) const revision = this.revisions.get(sessionID) const refresh = (this.refreshes.get(sessionID) ?? 0) + 1 this.refreshes.set(sessionID, refresh) @@ -2105,10 +2107,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async flushPendingSessionRefresh(reason: string): Promise { if (!this.pendingSessionRefresh) return console.log("[Kilo New] KiloProvider: 🔄 Flushing deferred sessions refresh", { reason }) + const scope = this.opts.projectQualifier?.()?.projectId + if (scope !== undefined) this.projectID = undefined const ctx = this.sessionRefreshContext try { const resolved = await flushPendingSessionRefreshUtil(ctx) - if (resolved) this.projectID = resolved + if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved } catch (error) { console.error("[Kilo New] KiloProvider: Failed to flush session refresh:", error) } @@ -2119,10 +2123,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Handle loading all sessions. */ private async handleLoadSessions(): Promise { + const scope = this.opts.projectQualifier?.()?.projectId + if (scope !== undefined) this.projectID = undefined const ctx = this.sessionRefreshContext try { const resolved = await loadSessionsUtil(ctx) - if (resolved) this.projectID = resolved + if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved } catch (error) { console.error("[Kilo New] KiloProvider: Failed to load sessions:", error) this.postMessage({ @@ -4306,9 +4312,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Drop session events from other projects before any tracking logic. // This must come first: the trackedSessionIds guard below would otherwise // let a foreign session through if it was accidentally tracked. - if (!isLegacySyncEvent(event) && isEventFromForeignProject(event, this.projectID)) return + if (directory && !this.isCurrentProjectDirectory(directory)) return if ( this.projectID && + (!this.opts.projectQualifier || !directory) && (event.type === "session.created" || event.type === "session.updated") && event.properties.info.projectID !== undefined && event.properties.info.projectID !== null && @@ -4367,6 +4374,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) return + if (event.type === "message.part.updated") this.refreshGitStatusFromPart(event, sessionID) + if (event.type === "session.updated" && typeof event.properties.info.cost === "number") { const cost = this.costs.setSessionCost(event.properties.sessionID, event.properties.info.cost) this.requestCostAlert(event.properties.sessionID, cost) @@ -4737,6 +4746,79 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return undefined } + private isCurrentProjectDirectory(directory: string): boolean { + if (!this.opts.projectQualifier?.()) return true + const dirs = [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])] + return dirs.some((dir) => sameDirectory(dir, directory)) + } + + private isCurrentProjectSession(sessionID: string): boolean { + if (!this.opts.projectQualifier || !this.opts.routeService) return true + const directory = this.opts.routeService.trySessionDirectory(sessionID) + return !directory || this.isCurrentProjectDirectory(directory) + } + + private refreshGitStatusFromPart( + event: Extract, + sessionID?: string, + ) { + const part = event.properties.part as { + type?: string + metadata?: Record + state?: { status?: string; input?: Record; metadata?: Record } + } + if (part.type !== "tool" || part.state?.status !== "completed") return + const values = [part.metadata?.filepath, part.state?.metadata?.filepath, part.state?.input?.filePath] + const file = values.find((value): value is string => typeof value === "string" && value.length > 0) + if (!file) return + const base = this.getWorkspaceDirectory(sessionID) + const value = file.split(",")[0].trim() + const pathName = path.isAbsolute(value) ? value : path.resolve(base, value) + const directory = path.dirname(pathName) + if (!this.isCurrentProjectGitDirectory(directory, sessionID)) return + void this.refreshGitStatus(directory) + } + + private isCurrentProjectGitDirectory(directory: string, sessionID?: string): boolean { + const roots = this.opts.projectQualifier?.() + ? [this.getRootDirectory(), ...(this.opts.worktreeDirectories?.() ?? [])] + : [this.getWorkspaceDirectory(sessionID)] + return roots.some((root) => { + const rel = path.relative(canonicalizePath(root), canonicalizePath(directory)) + return rel === "" || (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${path.sep}`)) + }) + } + + public async refreshGitStatus(directory = this.getWorkspaceDirectory()): Promise { + const client = this.client + if (!client) return + const revision = ++this.gitStatusRevision + const repo = await hasGit(client, directory) + const root = await this.resolveGitRoot(directory) + if (revision !== this.gitStatusRevision) return + const found = repo || root !== undefined + const target = root ?? directory + if (!this.cachedGitDirectory || !sameDirectory(this.cachedGitDirectory, target)) this.cachedStats = null + this.cachedGitDirectory = target + this.cachedGitRepo = found + this.postMessage({ type: "gitStatus", repo: found }) + if (found) { + if (!this.statsPoller) this.startStatsPolling() + return + } + this.statsPoller?.stop() + this.statsGitOps?.dispose() + this.statsPoller = null + this.statsGitOps = null + } + + private async resolveGitRoot(directory: string): Promise { + const git = this.statsGitOps ?? new GitOps({ log: () => {} }) + const root = await git.root(directory) + if (!this.statsGitOps) git.dispose() + return root + } + private getContextDirectory(): string { return resolveContextDirectory({ currentSessionID: this.currentSession?.id, @@ -4846,7 +4928,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.statsGitOps = git this.statsPoller = new GitStatsPoller({ getWorktrees: () => [], - getWorkspaceRoot: () => getWorkspaceRoot(), + getWorkspaceRoot: () => this.cachedGitDirectory ?? this.getWorkspaceDirectory(this.currentSession?.id), localDiff: (dir, base) => localDiffSummary(git, dir, base), git, onStats: () => {}, diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 39b844f9894..5453637a159 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1555,7 +1555,11 @@ export class AgentManagerProvider implements Disposable { void this.sendRepoInfo() if (!reactivateProject(ctx, this.panel?.sessions, (c) => this.pushState(c))) this.stateReady = this.initializeState() - else this.projectPollers.sync(this.contexts) + else { + this.panel?.sessions.refreshSessions() + this.projectPollers.sync(this.contexts) + } + this.panel?.sessions.refreshGitStatus?.() } private onWorkspaceChanged(): void { if (this.contexts.syncPinned()) { diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 92a2984b5a9..ea26906b893 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -171,6 +171,10 @@ export class GitOps { return this.raw(["rev-parse", "--abbrev-ref", "HEAD"], cwd).catch(() => "") } + async root(cwd: string): Promise { + return this.raw(["rev-parse", "--show-toplevel"], cwd).catch(() => undefined) + } + /** * Resolve the remote name for a branch. Checks (in order): * 1. The configured upstream's remote (e.g. upstream from `upstream/main`) diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index f8e8cee3a64..296cb70caf3 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -66,6 +66,8 @@ export interface SessionProvider { isSessionRouteAmbiguous?(sessionId: string): boolean /** Exact directory for a project-qualified session ref, or undefined. */ routeSessionDirectoryFor?(ref: SessionRef): string | undefined + /** Re-check Git capability for the active project/session directory. */ + refreshGitStatus?(): void dispose(): void } diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 4e1528f8a9b..a98f679d89e 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -151,6 +151,7 @@ export class VscodeHost implements Host { unregisterSessionRoute: (ref) => provider.unregisterSessionRoute(ref), isSessionRouteAmbiguous: (sessionId) => provider.isSessionRouteAmbiguous(sessionId), routeSessionDirectoryFor: (ref) => provider.routeSessionDirectoryFor(ref), + refreshGitStatus: () => void provider.refreshGitStatus(), dispose: () => provider.dispose(), } diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index c9825cb7713..41ea85380de 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -661,7 +661,10 @@ export function mapCloudSessionMessageToWebviewMessage(message: CloudSessionMess * Returns true when the event carries a projectID that does not match the expected one. * When expectedProjectID is undefined (not yet resolved), nothing is filtered. */ -export function isEventFromForeignProject(event: StreamEvent, expectedProjectID: string | undefined): boolean { +export function isEventFromForeignProject( + event: StreamEvent | SyncPayload, + expectedProjectID: string | undefined, +): boolean { if (!expectedProjectID || event.type !== "sync") return false if (event.name === "session.created.1" || event.name === "session.deleted.1") { return event.data.info.projectID !== expectedProjectID diff --git a/packages/kilo-vscode/src/kilo-provider/git-status.ts b/packages/kilo-vscode/src/kilo-provider/git-status.ts index 12e324e529a..e4ef22b5e45 100644 --- a/packages/kilo-vscode/src/kilo-provider/git-status.ts +++ b/packages/kilo-vscode/src/kilo-provider/git-status.ts @@ -1,8 +1,8 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" export async function hasGit(client: KiloClient, directory: string): Promise { - return client.project - .current({ directory }) + return Promise.resolve() + .then(() => client.project.current({ directory })) .then((r) => r.data?.vcs === "git") .catch(() => false) } diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index bd56efa8274..563620705d5 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -58,6 +58,23 @@ describe("GitOps", () => { }) }) + describe("root", () => { + it("resolves the nearest enclosing repository", async () => { + const git = ops(async (args) => { + if (args[0] === "rev-parse" && args[1] === "--show-toplevel") return "/workspace/frontend" + return "" + }) + expect(await git.root("/workspace/frontend/src")).toBe("/workspace/frontend") + }) + + it("returns undefined outside a repository", async () => { + const git = ops(async () => { + throw new Error("not a git repo") + }) + expect(await git.root("/workspace")).toBeUndefined() + }) + }) + describe("resolveRemote", () => { it("uses upstream remote when upstream is configured", async () => { const git = ops(async (args) => { diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 1965da54521..29be0ad5b20 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -8,8 +8,12 @@ type Internals = { webview: { postMessage: (message: unknown) => Promise } | null trackedSessionIds: Set currentSession: Session | null + projectID: string | undefined + isWebviewReady: boolean pendingFollowup: { dir: string; time: number } | null handleLoadMessages: (sessionID: string) => Promise + handleEvent: (event: Event, directory?: string) => void + refreshGitStatus: (directory?: string) => Promise initializeConnection: () => Promise syncWebviewState: () => Promise flushPendingSessionRefresh: () => Promise @@ -43,6 +47,18 @@ function created(input: { id: string; directory: string; parentID?: string }): E } as Event } +function info(input: { id: string; projectID: string; directory: string }): Session { + return { + id: input.id, + slug: `${input.id}-slug`, + projectID: input.projectID, + directory: input.directory, + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + } +} + function connection() { let filter: ((event: Event) => boolean) | undefined let listener: ((event: Event) => void) | undefined @@ -80,6 +96,135 @@ function connection() { } describe("KiloProvider follow-up sessions", () => { + it("scopes shared session events to the active project directory", () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never, undefined, { + rootDirectory: () => "/repo/project-b", + projectQualifier: () => ({ projectId: "project-b" }), + }) + const internal = provider as unknown as Internals + const sent: unknown[] = [] + const sharedID = "ses-shared" + + internal.webview = { + postMessage: async (message: unknown) => { + sent.push(message) + return true + }, + } + internal.isWebviewReady = true + internal.currentSession = info({ id: sharedID, projectID: "backend-project-b", directory: "/repo/project-b" }) + internal.projectID = "backend-project-a" + internal.trackedSessionIds.add(sharedID) + + // A background project's event must not overwrite the active project's + // transcript when both instances expose the same raw session key. + internal.handleEvent( + { + type: "message.updated", + properties: { + sessionID: sharedID, + info: { + id: "msg-project-a", + sessionID: sharedID, + role: "assistant", + time: { created: 1 }, + }, + }, + } as Event, + "/repo/project-a", + ) + expect(sent).toEqual([]) + + // Switching projects can briefly leave the backend project identity stale; + // the active directory is the authoritative scope during that transition. + internal.handleEvent( + { + type: "session.created", + properties: { sessionID: sharedID, info: internal.currentSession }, + } as Event, + "/repo/project-b", + ) + expect(sent).toContainEqual({ + type: "sessionCreated", + session: { + id: sharedID, + title: "Session", + createdAt: new Date(1).toISOString(), + updatedAt: new Date(1).toISOString(), + parentID: null, + revert: null, + summary: null, + }, + }) + }) + + it("refreshes Git from the file path in a completed edit tool part", () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never, undefined, { + rootDirectory: () => "/workspace", + projectQualifier: () => ({ projectId: "workspace" }), + }) + const internal = provider as unknown as Internals + const dirs: string[] = [] + const sessionID = "ses-edit" + internal.currentSession = info({ id: sessionID, projectID: "backend-workspace", directory: "/workspace" }) + internal.trackedSessionIds.add(sessionID) + internal.refreshGitStatus = async (directory) => { + if (directory) dirs.push(directory) + } + + internal.handleEvent( + { + type: "message.part.updated", + properties: { + sessionID, + part: { + type: "tool", + state: { status: "completed" }, + metadata: { filepath: "/workspace/frontend/src/app.ts" }, + }, + }, + } as Event, + "/workspace", + ) + + expect(dirs).toEqual(["/workspace/frontend/src"]) + }) + + it("ignores completed tool paths outside the active project", () => { + const service = connection() + const provider = new KiloProvider({} as never, service as never, undefined, { + rootDirectory: () => "/workspace", + projectQualifier: () => ({ projectId: "workspace" }), + }) + const internal = provider as unknown as Internals + const dirs: string[] = [] + const sessionID = "ses-external-edit" + internal.currentSession = info({ id: sessionID, projectID: "backend-workspace", directory: "/workspace" }) + internal.trackedSessionIds.add(sessionID) + internal.refreshGitStatus = async (directory) => { + if (directory) dirs.push(directory) + } + + internal.handleEvent( + { + type: "message.part.updated", + properties: { + sessionID, + part: { + type: "tool", + state: { status: "completed" }, + metadata: { filepath: "/other-repo/src/app.ts" }, + }, + }, + } as Event, + "/workspace", + ) + + expect(dirs).toEqual([]) + }) + it("ignores subagents before adopting pending follow-up sessions", async () => { const service = connection() const provider = new KiloProvider({} as never, service as never) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts index b8053f7abe3..475607e5fdd 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-route-integration.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect } from "bun:test" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" import { ProjectRouteService } from "../../src/agent-manager/project/route" // vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) @@ -11,8 +14,9 @@ type SessionGetParams = { sessionID: string; directory: string } * session.get records every call so tests can assert which directory was * queried. Mirrors the shape used by kilo-provider-session-refresh.test.ts. */ -function mockConnection(getImpl?: (p: SessionGetParams) => Promise) { +function mockConnection(getImpl?: (p: SessionGetParams) => Promise, vcs = "git") { const calls: SessionGetParams[] = [] + const projectCalls: string[] = [] const client = { session: { get: async (p: SessionGetParams) => { @@ -32,6 +36,12 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise) { }, list: async () => ({ data: [] }), }, + project: { + current: async (p: { directory: string }) => { + projectCalls.push(p.directory) + return { data: { vcs } } + }, + }, provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) }, app: { agents: async () => ({ data: [] }), @@ -47,6 +57,7 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise) { let current: typeof client | null = client return { calls, + projectCalls, connection: { connect: async () => { current = client @@ -77,11 +88,27 @@ function mockConnection(getImpl?: (p: SessionGetParams) => Promise) { } } +async function withNestedRepo(run: (root: string) => Promise): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-nested-repo-")) + const root = path.join(base, "frontend") + await fs.mkdir(root) + const result = Bun.spawnSync({ cmd: ["git", "init"], cwd: root, stdout: "pipe", stderr: "pipe" }) + if (result.exitCode !== 0) throw new Error(Buffer.from(result.stderr).toString()) + try { + await run(root) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } +} + type ProviderInternals = { client: unknown connectionState: "connecting" | "connected" | "disconnected" | "error" initConnectionPromise: Promise | null + isWebviewReady: boolean webview: { postMessage: (message: unknown) => Promise } | null + startStatsPolling: () => void + refreshGitStatus: (directory?: string) => Promise handleSendCommand: ( command: string, args: string, @@ -111,6 +138,49 @@ function connect(internal: ProviderInternals): void { } describe("KiloProvider route integration", () => { + it("finds a nested Git root when the workspace parent is not a repo", async () => { + await withNestedRepo(async (root) => { + const source = path.join(root, "src") + await fs.mkdir(source) + const { connection, projectCalls } = mockConnection(undefined, "none") + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => source, + }) + const internal = provider as unknown as ProviderInternals + const sent: unknown[] = [] + internal.connectionState = "connected" + internal.initConnectionPromise = Promise.resolve() + internal.isWebviewReady = true + internal.startStatsPolling = () => {} + internal.webview = { postMessage: async (message) => sent.push(message) } + + await internal.refreshGitStatus(source) + + expect(projectCalls).toEqual([source]) + expect(sent).toContainEqual({ type: "gitStatus", repo: true }) + }) + }) + + it("checks Git capability in the active project directory", async () => { + const { connection, projectCalls } = mockConnection() + const provider = new KiloProvider({} as never, connection, undefined, { + rootDirectory: () => "/workspace/parent/project-b", + projectQualifier: () => ({ projectId: "project-b" }), + }) + const internal = provider as unknown as ProviderInternals + const sent: unknown[] = [] + internal.connectionState = "connected" + internal.initConnectionPromise = Promise.resolve() + internal.isWebviewReady = true + internal.startStatsPolling = () => {} + internal.webview = { postMessage: async (message) => sent.push(message) } + + await internal.refreshGitStatus() + + expect(projectCalls).toEqual(["/workspace/parent/project-b"]) + expect(sent).toContainEqual({ type: "gitStatus", repo: true }) + }) + it("resolves a unique Local session route to its exact project root", async () => { const routes = new ProjectRouteService() routes.registerProject("a", "/repo/a", 1) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index 3cb7d11ed69..e96a67d6b71 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -9,11 +9,20 @@ type State = "connecting" | "connected" | "disconnected" | "error" type ProviderInternals = { connectionState: State pendingSessionRefresh: boolean + projectID: string | undefined webview: { postMessage: (message: unknown) => Promise } | null initializeConnection: () => Promise handleLoadSessions: () => Promise } +function deferred() { + let resolve: (value: T) => void = () => {} + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + function createContext(overrides?: Partial): SessionRefreshContext & { sent: unknown[] } { const sent: unknown[] = [] return { @@ -100,6 +109,40 @@ function createConnection(client: ReturnType) { } describe("KiloProvider pending session refresh", () => { + it("does not let a late listing restore the previous project's identity", async () => { + const client = createClient() + const pending = new Map>>() + client.session.list = async (params: { directory: string }) => { + const next = deferred<{ data: unknown[] }>() + pending.set(params.directory, next) + return next.promise as never + } + const connection = createConnection(client) + await connection.connect() + let active = "a" + const provider = new KiloProvider({} as never, connection as never, undefined, { + rootDirectory: () => `/repo/${active}`, + projectQualifier: () => ({ projectId: active }), + }) + const internal = provider as unknown as ProviderInternals + internal.connectionState = "connected" + + const first = internal.handleLoadSessions() + active = "b" + const second = internal.handleLoadSessions() + + pending.get("/repo/b")!.resolve({ + data: [{ id: "ses-b", projectID: "backend-b", time: { created: 1, updated: 1 } }], + }) + await second + pending.get("/repo/a")!.resolve({ + data: [{ id: "ses-a", projectID: "backend-a", time: { created: 1, updated: 1 } }], + }) + await first + + expect(internal.projectID).toBe("backend-b") + }) + it("keeps worktree sessions with legacy project ids", async () => { const sent: unknown[] = [] const ctx = createContext({