fix(vscode): restore past session references

This commit is contained in:
marius-kilocode
2026-08-28 08:44:07 +02:00
parent 7b8895a0e3
commit 5d1313f8a2
8 changed files with 282 additions and 8 deletions
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---
Restore past-chat references across large histories and skip inaccessible folders from unrelated projects. Keep sidebar and Agent Manager search responsive by showing the best 50 matches.
@@ -44,7 +44,7 @@ export async function handleSessionSearch(input: Input): Promise<void> {
try {
const res = await client.experimental.session.list(
{ worktrees: true, roots: true, directory: dir, limit: 50 },
{ worktrees: true, roots: true, directory: dir, limit: Number.MAX_SAFE_INTEGER },
{ throwOnError: true },
)
const sessions: Item[] = res.data
@@ -7,6 +7,7 @@ import {
buildMentionResults,
buildSessionAttachments,
filterMentionResults,
filterSessions,
getMentionRemovalRange,
getPastChatsMentionResult,
isCursorAtMentionEnd,
@@ -119,6 +120,39 @@ describe("filterMentionResults", () => {
})
})
describe("filterSessions", () => {
const sessions = Array.from({ length: 60 }, (_, index) => ({
id: `ses_${index}`,
title: `Recent session ${index}`,
updated: 60 - index,
worktreeName: "branch",
}))
it("shows the first 50 sessions in their existing order without a query", () => {
expect(filterSessions(sessions, "")).toEqual(sessions.slice(0, 50))
})
it("limits broad search results to 50 matches", () => {
expect(filterSessions(sessions, "recent")).toHaveLength(50)
})
it.each(["ORCHID", "old-branch"])("finds older sessions beyond the display limit by %s", (query) => {
const source = { id: "ses_old", title: "Orchid reference source", updated: 0, worktreeName: "old-branch" }
expect(filterSessions([...sessions, source], query)).toEqual([source])
})
it("ranks an older exact match before newer partial matches", () => {
const source = { id: "ses_old", title: "Recent", updated: 0 }
const matches = filterSessions([...sessions, source], "recent")
expect(matches).toHaveLength(50)
expect(matches[0]).toBe(source)
})
it("returns no results when nothing matches", () => {
expect(filterSessions(sessions, "zzzz")).toEqual([])
})
})
describe("syncMentionedPaths", () => {
it("keeps paths still referenced in text", () => {
const paths = new Set(["foo.ts", "bar.ts"])
@@ -35,7 +35,9 @@ describe("handleSessionSearch", () => {
post: (msg) => posted.push(msg),
})
expect(calls).toEqual([{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: 50 }])
expect(calls).toEqual([
{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: Number.MAX_SAFE_INTEGER },
])
expect(posted).toEqual([
{
type: "sessionSearchResult",
@@ -89,6 +91,24 @@ describe("handleSessionSearch", () => {
expect(posted[0]?.sessions.map((s) => s.id)).toEqual(["ses_keep"])
})
it.each(["/repo", "/repo/.kilo/worktrees/branch"])("loads older chats in one request from %s", async (dir) => {
const recent = Array.from({ length: 60 }, (_, index) => session(`ses_${index}`, `Recent ${index}`, 2, "branch"))
const source = session("ses_old", "Older chat", 1, "main")
const api = stub([...recent, source])
const posted: Array<{ sessions: Array<{ id: string }> }> = []
await handleSessionSearch({
client: api.client as never,
message: { requestId: "all", sessionID: "ses_current" },
dir: (id) => (id === "ses_current" ? dir : "/wrong-project"),
post: (msg) => posted.push(msg as never),
})
expect(api.calls).toEqual([{ worktrees: true, roots: true, directory: dir, limit: Number.MAX_SAFE_INTEGER }])
expect(posted).toHaveLength(1)
expect(posted[0]?.sessions.map((item) => item.id)).toEqual([...recent.map((item) => item.id), source.id])
})
it("posts an empty result when the client is missing or the list fails", async () => {
const posted: unknown[] = []
@@ -3,6 +3,7 @@
import { onMount } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { List } from "@kilocode/kilo-ui/list"
import { filterSessions } from "../../hooks/file-mention-utils"
import type { SessionSearchItem } from "../../types/messages"
import { formatRelativeDate } from "../../utils/date"
@@ -40,9 +41,9 @@ export function SessionMentionPicker(props: Props) {
}}
>
<List<SessionSearchItem>
items={props.sessions}
items={(query) => filterSessions(props.sessions, query)}
key={(item) => item.id}
filterKeys={["title", "worktreeName"]}
skipFilter={() => true}
search={{ placeholder: "Search sessions", autofocus: true }}
onSelect={(item) => {
if (item) props.onSelect(item)
@@ -1,3 +1,4 @@
import fuzzysort from "fuzzysort"
import type { FileAttachment, FileSearchItem, SessionSearchItem } from "../types/messages"
import { GIT_CHANGES_MENTION } from "./git-changes-context-utils"
import { TERMINAL_MENTION } from "./terminal-context-utils"
@@ -79,6 +80,13 @@ export function buildMentionResults(query: string, items: Array<FileSearchItem |
]
}
export function filterSessions(sessions: SessionSearchItem[], query: string) {
if (!query) return sessions.slice(0, 50)
return fuzzysort
.go(query.toLowerCase(), sessions, { keys: ["title", "worktreeName"], limit: 50 })
.map((item) => item.obj)
}
/** Single-line, safe display/filename forms for a session mention. */
export function sessionMentionText(title: string) {
return title.replace(/\s+/g, " ").trim()
@@ -58,7 +58,7 @@ export namespace KiloSession {
}
})
if (!ctx) return
Bus.publish(ctx, Event.QueueChanged, input).catch(err => log.warn("queue changed publish failed", { err }))
Bus.publish(ctx, Event.QueueChanged, input).catch((err) => log.warn("queue changed publish failed", { err }))
}
// ---------------------------------------------------------------------------
@@ -137,16 +137,29 @@ export namespace KiloSession {
rows: Array<Pick<typeof ProjectTable.$inferSelect, "id" | "worktree" | "sandboxes">>,
directories: string[] = [],
): string[] {
const resolve = (dir: string) => {
try {
return Filesystem.resolve(dir)
} catch (err) {
const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined
if (code !== "EPERM" && code !== "EACCES") throw err
log.warn("Ignoring inaccessible saved project directory", { dir, code })
return undefined
}
}
const current = rows.find((row) => row.id === id)
const root = current?.worktree ? Filesystem.resolve(current.worktree) : undefined
const root = current?.worktree ? resolve(current.worktree) : undefined
// Combine the stored root with Git's current sibling worktrees.
const roots = new Set([...(root && root !== "/" ? [root] : []), ...directories.map(Filesystem.resolve)])
if (roots.size === 0) return [id]
// Match both each project's recorded root and its saved worktrees.
const ids = rows.flatMap((row) => {
const dirs = [row.worktree, ...row.sandboxes].map(Filesystem.resolve)
return dirs.some((dir) => roots.has(dir)) ? [row.id] : []
const match = [row.worktree, ...row.sandboxes].some((dir) => {
const value = resolve(dir)
return value !== undefined && roots.has(value)
})
return match ? [row.id] : []
})
// Always keep the requested ID and remove duplicates.
return [...new Set([id, ...ids])]
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, spyOn } from "bun:test"
import path from "path"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Effect, Layer, Schema } from "effect"
import { eq, inArray } from "drizzle-orm"
import { Session } from "../../../src/session/session"
import { Filesystem } from "../../../src/util/filesystem"
import { resetDatabase } from "../../fixture/db"
import { disposeAllInstances, provideInstance, TestInstance, tmpdir } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
import { httpApiLayer, requestInDirectory } from "../../server/httpapi-layer"
import { HttpClientResponse } from "effect/unstable/http"
const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer))
function request(directory: string) {
const query = new URLSearchParams({
directory,
worktrees: "true",
roots: "true",
limit: String(Number.MAX_SAFE_INTEGER),
})
return requestInDirectory(`/experimental/session?${query}`, directory)
}
function json(response: HttpClientResponse.HttpClientResponse) {
return Effect.gen(function* () {
const body: unknown = yield* response.json
if (!Schema.is(Schema.Array(Session.GlobalInfo))(body)) {
return yield* Effect.fail(new Error("Invalid session metadata"))
}
return body
})
}
function updated(ids: Array<Session.Info["id"]>, time: number) {
return Database.Service.use(({ db }) =>
db.update(SessionTable).set({ time_updated: time }).where(inArray(SessionTable.id, ids)).run().pipe(Effect.orDie),
)
}
function repo() {
return Effect.acquireRelease(
Effect.promise(() => tmpdir({ git: true })),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()).pipe(Effect.ignore),
)
}
function deny(dir: string, code: "EACCES" | "EPERM") {
const real = Filesystem.resolve
const spy = spyOn(Filesystem, "resolve").mockImplementation((input) => {
if (input === dir) throw Object.assign(new Error(`cannot resolve ${dir}`), { code })
return real(input)
})
return Effect.addFinalizer(() => Effect.sync(() => spy.mockRestore()))
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe.serial("Kilo session mentions", () => {
it.instance(
"lists more than 50 root sessions without a cursor",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessions = yield* Effect.forEach(Array.from({ length: 51 }), (_, index) =>
Session.use.create({ title: `session ${index}` }),
)
yield* updated(
sessions.map((session) => session.id),
1,
)
const response = yield* request(test.directory)
expect(response.status).toBe(200)
expect(response.headers["x-next-cursor"]).toBeUndefined()
const body = yield* json(response)
expect(new Set(body.map((item) => item.id))).toEqual(new Set(sessions.map((session) => session.id)))
}),
{ git: true },
30_000,
)
it.instance(
"ignores an inaccessible worktree from an unrelated project",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const other = yield* repo()
const current = yield* Session.use.create({ title: "current project" })
const unrelated = yield* Session.use.create({ title: "unrelated project" }).pipe(provideInstance(other.path))
const denied = path.join(other.path, "denied-worktree")
const { db } = yield* Database.Service
yield* db
.update(ProjectTable)
.set({ worktree: AbsolutePath.make(denied) })
.where(eq(ProjectTable.id, unrelated.projectID))
.run()
.pipe(Effect.orDie)
yield* deny(denied, "EPERM")
const response = yield* request(test.directory)
expect(response.status).toBe(200)
const body = yield* json(response)
const ids = body.map((item) => item.id)
expect(ids).toContain(current.id)
expect(ids).not.toContain(unrelated.id)
}),
{ git: true },
)
it.instance(
"ignores an inaccessible saved sandbox",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const other = yield* repo()
const current = yield* Session.use.create({ title: "current project" })
const unrelated = yield* Session.use.create({ title: "unrelated project" }).pipe(provideInstance(other.path))
const denied = path.join(test.directory, "denied-sandbox")
const { db } = yield* Database.Service
yield* db
.update(ProjectTable)
.set({ sandboxes: [AbsolutePath.make(denied)] })
.where(eq(ProjectTable.id, unrelated.projectID))
.run()
.pipe(Effect.orDie)
yield* deny(denied, "EACCES")
const response = yield* request(test.directory)
expect(response.status).toBe(200)
const body = yield* json(response)
expect(body.map((item) => item.id)).toEqual([current.id])
}),
{ git: true },
)
it.instance(
"keeps a legacy root chat when a denied sandbox precedes a current-worktree alias",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const legacy = yield* repo()
const other = yield* repo()
const current = yield* Session.use.create({ title: "current project" })
const root = yield* Session.use.create({ title: "legacy root" })
const unrelated = yield* Session.use.create({ title: "unrelated project" }).pipe(provideInstance(other.path))
const denied = path.join(legacy.path, "denied-sandbox")
const project = ProjectV2.ID.make("legacy-mentions-project")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({
id: project,
worktree: AbsolutePath.make(legacy.path),
vcs: "git",
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [AbsolutePath.make(denied), AbsolutePath.make(test.directory)],
})
.run()
.pipe(Effect.orDie)
yield* db
.update(SessionTable)
.set({ project_id: project })
.where(eq(SessionTable.id, root.id))
.run()
.pipe(Effect.orDie)
yield* deny(denied, "EPERM")
const response = yield* request(test.directory)
expect(response.status).toBe(200)
const body = yield* json(response)
const ids = body.map((item) => item.id)
expect(ids).toContain(current.id)
expect(ids).toContain(root.id)
expect(ids).not.toContain(unrelated.id)
}),
{ git: true },
)
})