Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/restore-past-session-mentions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---

Search the latest 5,000 chats across the worktree family and display the best 50 matches in the sidebar and Agent Manager. Skip inaccessible folders from unrelated projects when finding past chats.
2 changes: 1 addition & 1 deletion packages/kilo-vscode/src/kilo-provider/session-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: 5_000 },
{ throwOnError: true },
)
const sessions: Item[] = res.data
Expand Down
34 changes: 34 additions & 0 deletions packages/kilo-vscode/tests/unit/file-mention-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
buildMentionResults,
buildSessionAttachments,
filterMentionResults,
filterSessions,
getMentionRemovalRange,
getPastChatsMentionResult,
isCursorAtMentionEnd,
Expand Down Expand Up @@ -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"])
Expand Down
20 changes: 19 additions & 1 deletion packages/kilo-vscode/tests/unit/session-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ 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: 5_000 }])
expect(posted).toEqual([
{
type: "sessionSearchResult",
Expand Down Expand Up @@ -89,6 +89,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: 5_000 }])
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[] = []

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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()
Expand Down
21 changes: 17 additions & 4 deletions packages/opencode/src/kilocode/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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])]
Expand Down
192 changes: 192 additions & 0 deletions packages/opencode/test/kilocode/server/session-mentions.test.ts
Original file line number Diff line number Diff line change
@@ -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(5_000),
})
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 },
)
})
Loading