mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix: scope recall sessions to project worktrees
Keep recall and the all-sessions picker focused on the current repo while still surfacing sessions from sibling worktrees.
This commit is contained in:
@@ -3,7 +3,7 @@ import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import path from "path"
|
||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||
import { createMemo, createSignal, createResource, onMount } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { useTheme } from "../context/theme"
|
||||
@@ -11,7 +11,6 @@ import { useSDK } from "../context/sdk"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { createDebouncedSignal } from "../util/signal"
|
||||
import { Spinner } from "./spinner"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -20,26 +19,30 @@ export function DialogSessionList() {
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [global, setGlobal] = createSignal(false) // kilocode_change
|
||||
|
||||
// kilocode_change start
|
||||
const [searchResults] = createResource(
|
||||
const [searchResults, searchActions] = createResource(
|
||||
() => ({ query: search(), global: global() }),
|
||||
async ({ query, global: all }) => {
|
||||
if (!query && !all) return undefined
|
||||
if (all) {
|
||||
const result = await sdk.client.experimental.session.list({
|
||||
search: query || undefined,
|
||||
roots: true,
|
||||
limit: 30,
|
||||
})
|
||||
const project = await sdk.client.project.current({}, { throwOnError: true })
|
||||
const result = await sdk.client.experimental.session.list(
|
||||
{
|
||||
projectID: project.data?.id,
|
||||
search: query || undefined,
|
||||
roots: true,
|
||||
limit: 30,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
return result.data ?? []
|
||||
}
|
||||
const result = await sdk.client.session.list({ search: query, limit: 30 })
|
||||
const result = await sdk.client.session.list({ search: query || undefined, limit: 30 }, { throwOnError: true })
|
||||
return result.data ?? []
|
||||
},
|
||||
)
|
||||
@@ -47,7 +50,10 @@ export function DialogSessionList() {
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
||||
const sessions = createMemo(() => {
|
||||
if (global() || search()) return searchResults() ?? [] // kilocode_change
|
||||
return searchResults() ?? sync.data.session
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
@@ -64,12 +70,8 @@ export function DialogSessionList() {
|
||||
const isDeleting = toDelete() === x.id
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy"
|
||||
// kilocode_change start
|
||||
const project =
|
||||
all && "project" in x ? (x as { project?: { name?: string; worktree: string } | null }).project : undefined
|
||||
const root = project?.name ?? (project ? path.basename(project.worktree) || project.worktree : "")
|
||||
const root = all ? path.basename(x.directory) || x.directory : "" // kilocode_change
|
||||
const suffix = root ? ` [${root}]` : ""
|
||||
// kilocode_change end
|
||||
return {
|
||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title + suffix, // kilocode_change
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
@@ -87,7 +89,7 @@ export function DialogSessionList() {
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={global() ? "Sessions (all projects)" : "Sessions"}
|
||||
title={global() ? "Sessions (all worktrees)" : "Sessions"} // kilocode_change
|
||||
options={options()}
|
||||
skipFilter={true}
|
||||
current={currentSessionID()}
|
||||
@@ -96,20 +98,6 @@ export function DialogSessionList() {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
const item = sessions().find((x) => x.id === option.value)
|
||||
const project =
|
||||
global() && item && "project" in item
|
||||
? (item as { project?: { worktree?: string } | null }).project
|
||||
: undefined
|
||||
const cross =
|
||||
global() && item && project?.worktree !== undefined && project.worktree !== sync.data.path.worktree
|
||||
if (cross) {
|
||||
toast.show({
|
||||
message: "Open this session from its own project",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
@@ -120,16 +108,13 @@ export function DialogSessionList() {
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
// kilocode_change start
|
||||
disabled: global(),
|
||||
// kilocode_change end
|
||||
onTrigger: async (option) => {
|
||||
if (global()) return // kilocode_change
|
||||
if (toDelete() === option.value) {
|
||||
sdk.client.session.delete({
|
||||
await sdk.client.session.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
setToDelete(undefined)
|
||||
if (global() || search()) void searchActions.refetch() // kilocode_change
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
@@ -138,19 +123,25 @@ export function DialogSessionList() {
|
||||
{
|
||||
keybind: keybind.all.session_rename?.[0],
|
||||
title: "rename",
|
||||
// kilocode_change start
|
||||
disabled: global(),
|
||||
// kilocode_change end
|
||||
onTrigger: async (option) => {
|
||||
if (global()) return // kilocode_change
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
const item = sessions().find((x) => x.id === option.value)
|
||||
dialog.replace(() => (
|
||||
<DialogSessionRename
|
||||
session={option.value}
|
||||
title={item?.title}
|
||||
onConfirm={() => {
|
||||
if (global() || search()) void searchActions.refetch() // kilocode_change
|
||||
}}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
// kilocode_change start
|
||||
{
|
||||
keybind: { name: "a", ctrl: true, meta: false, shift: false, leader: false },
|
||||
title: global() ? "project" : "all",
|
||||
title: global() ? "recent" : "all",
|
||||
onTrigger: async () => {
|
||||
setToDelete(undefined)
|
||||
setGlobal((v) => !v)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useSDK } from "../context/sdk"
|
||||
|
||||
interface DialogSessionRenameProps {
|
||||
session: string
|
||||
title?: string // kilocode_change
|
||||
onConfirm?: () => void // kilocode_change
|
||||
}
|
||||
|
||||
export function DialogSessionRename(props: DialogSessionRenameProps) {
|
||||
@@ -17,12 +19,14 @@ export function DialogSessionRename(props: DialogSessionRenameProps) {
|
||||
return (
|
||||
<DialogPrompt
|
||||
title="Rename Session"
|
||||
value={session()?.title}
|
||||
value={session()?.title ?? props.title} // kilocode_change
|
||||
onConfirm={(value) => {
|
||||
sdk.client.session.update({
|
||||
sessionID: props.session,
|
||||
title: value,
|
||||
})
|
||||
sdk.client.session
|
||||
.update({
|
||||
sessionID: props.session,
|
||||
title: value,
|
||||
})
|
||||
.then(() => props.onConfirm?.()) // kilocode_change
|
||||
dialog.clear()
|
||||
}}
|
||||
onCancel={() => dialog.clear()}
|
||||
|
||||
@@ -326,6 +326,8 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
// kilocode_change
|
||||
projectID: z.string().optional().meta({ description: "Filter sessions by project ID" }),
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
@@ -346,6 +348,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
const limit = query.limit ?? 100
|
||||
const sessions: Session.GlobalInfo[] = []
|
||||
for await (const session of Session.listGlobal({
|
||||
projectID: query.projectID, // kilocode_change
|
||||
directory: query.directory,
|
||||
roots: query.roots,
|
||||
start: query.start,
|
||||
|
||||
@@ -622,6 +622,7 @@ export namespace Session {
|
||||
}
|
||||
|
||||
export function* listGlobal(input?: {
|
||||
projectID?: string
|
||||
directory?: string
|
||||
roots?: boolean
|
||||
start?: number
|
||||
@@ -632,6 +633,12 @@ export namespace Session {
|
||||
}) {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
// kilocode_change start
|
||||
if (input?.projectID) {
|
||||
conditions.push(eq(SessionTable.project_id, input.projectID))
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
if (input?.directory) {
|
||||
// kilocode_change start: vscode uri.fsPath gives lowercase drive letter on Windows; resolve() canonicalises to match stored path
|
||||
conditions.push(eq(SessionTable.directory, Filesystem.resolve(input.directory)))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// kilocode_change - new file
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { Session } from "../session"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Locale } from "../util/locale"
|
||||
import DESCRIPTION from "./recall.txt"
|
||||
@@ -39,17 +38,17 @@ async function search(params: { query?: string; limit?: number }, ctx: Tool.Cont
|
||||
|
||||
const limit = Math.min(params.limit ?? 20, 50)
|
||||
const current = Instance.project.id
|
||||
const { Session } = await import("../session/index") // kilocode_change
|
||||
|
||||
const results: Array<{
|
||||
id: string
|
||||
title: string
|
||||
project: string
|
||||
directory: string
|
||||
updated: string
|
||||
current: boolean
|
||||
}> = []
|
||||
|
||||
for (const session of Session.listGlobal({
|
||||
projectID: current, // kilocode_change
|
||||
search: params.query,
|
||||
roots: true,
|
||||
limit,
|
||||
@@ -57,10 +56,8 @@ async function search(params: { query?: string; limit?: number }, ctx: Tool.Cont
|
||||
results.push({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
project: session.project?.name ?? session.project?.worktree ?? "unknown",
|
||||
directory: session.directory,
|
||||
updated: Locale.todayTimeOrDateTime(session.time.updated),
|
||||
current: session.projectID === current,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,10 +69,7 @@ async function search(params: { query?: string; limit?: number }, ctx: Tool.Cont
|
||||
}
|
||||
}
|
||||
|
||||
const lines = results.map(
|
||||
(r) =>
|
||||
`- **${r.title}** (${r.current ? "current project" : r.project})\n ID: ${r.id} | Updated: ${r.updated} | Dir: ${r.directory}`,
|
||||
)
|
||||
const lines = results.map((r) => `- **${r.title}**\n ID: ${r.id} | Updated: ${r.updated} | Dir: ${r.directory}`)
|
||||
|
||||
return {
|
||||
title: `Search: "${params.query}" (${results.length} results)`,
|
||||
@@ -89,9 +83,17 @@ async function read(params: { sessionID?: string }, ctx: Tool.Context) {
|
||||
throw new Error("The 'sessionID' parameter is required when mode is 'read'")
|
||||
}
|
||||
|
||||
const { Session } = await import("../session/index") // kilocode_change
|
||||
const session = await Session.get(params.sessionID).catch(() => {
|
||||
throw new Error(`Session "${params.sessionID}" not found. Use search mode first to find valid session IDs.`)
|
||||
})
|
||||
// kilocode_change start
|
||||
if (session.projectID !== Instance.project.id) {
|
||||
throw new Error(
|
||||
`Session "${params.sessionID}" belongs to a different project and cannot be read from this workspace.`,
|
||||
)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
await ctx.ask({
|
||||
permission: "recall",
|
||||
@@ -107,7 +109,7 @@ async function read(params: { sessionID?: string }, ctx: Tool.Context) {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const lines: string[] = [
|
||||
`# Session: ${session.title}`,
|
||||
`Project: ${session.directory}`,
|
||||
`Directory: ${session.directory}`,
|
||||
`Created: ${Locale.todayTimeOrDateTime(session.time.created)}`,
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
Search and read past conversations from any project on this machine. Use this to recall previous work, find how something was implemented before, or retrieve context from other projects.
|
||||
Search and read past conversations from the current project on this machine, including its git worktrees. Use this to recall previous work, find how something was implemented before, or retrieve context from another worktree in the same repo.
|
||||
|
||||
Two modes:
|
||||
1. **Search** - Find sessions by title keyword across all projects. Returns a list of matching sessions with their project, title, and last updated time. Use this first to locate relevant conversations.
|
||||
1. **Search** - Find sessions by title keyword in the current project and its worktrees. Returns a list of matching sessions with their title, directory, and last updated time. Use this first to locate relevant conversations.
|
||||
2. **Read** - Retrieve the full transcript of a specific session by ID. Returns the conversation messages (user prompts and assistant responses) so you can understand what was discussed and done.
|
||||
|
||||
Usage notes:
|
||||
- Search matches against session titles using case-insensitive substring matching
|
||||
- Results include sessions from ALL projects on this machine, not just the current one
|
||||
- Reading a session from a different project requires permission
|
||||
- Results are limited to the current project/worktree family
|
||||
- Reading a session from a different project is rejected
|
||||
- Use search mode first to find session IDs, then read mode to get the full conversation
|
||||
- Session transcripts can be large; prefer searching first to narrow down which session to read
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
mock.module("@/kilo-sessions/remote-sender", () => ({
|
||||
RemoteSender: {
|
||||
create() {
|
||||
return {
|
||||
handle() {},
|
||||
dispose() {},
|
||||
}
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("experimental.session.list", () => {
|
||||
test("filters sessions by project ID across worktrees", async () => {
|
||||
await using first = await tmpdir({ git: true })
|
||||
await using second = await tmpdir({ git: true })
|
||||
const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree")
|
||||
|
||||
try {
|
||||
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
|
||||
|
||||
const share = Config.get
|
||||
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
|
||||
|
||||
try {
|
||||
const { Server } = await import("../../src/server/server")
|
||||
const { Session } = await import("../../src/session/index")
|
||||
const root = await Instance.provide({
|
||||
directory: first.path,
|
||||
fn: async () => ({
|
||||
app: Server.App(),
|
||||
project: await Server.App().request("/project/current", {
|
||||
headers: { "x-kilo-directory": first.path },
|
||||
}),
|
||||
session: await Session.create({ title: "root-session" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const branch = await Instance.provide({
|
||||
directory: worktree,
|
||||
fn: async () => Session.create({ title: "worktree-session" }),
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: second.path,
|
||||
fn: async () => Session.create({ title: "other-project-session" }),
|
||||
})
|
||||
|
||||
const app = root.app
|
||||
const project = await root.project.json()
|
||||
const response = await app.request(
|
||||
`/experimental/session?projectID=${encodeURIComponent(project.id)}&roots=true`,
|
||||
{
|
||||
headers: { "x-kilo-directory": first.path },
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
const ids = body.map((item: { id: string }) => item.id)
|
||||
const dirs = body.map((item: { directory: string }) => item.directory)
|
||||
|
||||
expect(ids).toContain(root.session.id)
|
||||
expect(ids).toContain(branch.id)
|
||||
expect(dirs).toContain(worktree)
|
||||
expect(body.some((item: { title: string }) => item.title === "other-project-session")).toBe(false)
|
||||
} finally {
|
||||
Config.get = share
|
||||
}
|
||||
} finally {
|
||||
await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
mock.module("@/kilo-sessions/remote-sender", () => ({
|
||||
RemoteSender: {
|
||||
create() {
|
||||
return {
|
||||
handle() {},
|
||||
dispose() {},
|
||||
}
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("Session.listGlobal", () => {
|
||||
test("lists sessions across projects with project metadata", async () => {
|
||||
const { Session } = await import("../../src/session/index")
|
||||
await using first = await tmpdir({ git: true })
|
||||
await using second = await tmpdir({ git: true })
|
||||
|
||||
@@ -40,6 +58,7 @@ describe("Session.listGlobal", () => {
|
||||
})
|
||||
|
||||
test("excludes archived sessions by default", async () => {
|
||||
const { Session } = await import("../../src/session/index")
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const archived = await Instance.provide({
|
||||
@@ -64,6 +83,7 @@ describe("Session.listGlobal", () => {
|
||||
})
|
||||
|
||||
test("supports cursor pagination", async () => {
|
||||
const { Session } = await import("../../src/session/index")
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const first = await Instance.provide({
|
||||
@@ -78,12 +98,46 @@ describe("Session.listGlobal", () => {
|
||||
|
||||
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
|
||||
expect(page.length).toBe(1)
|
||||
expect(page[0].id).toBe(second.id)
|
||||
expect(page[0]!.id).toBe(second.id)
|
||||
|
||||
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
|
||||
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0]!.time.updated })]
|
||||
const ids = next.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(first.id)
|
||||
expect(ids).not.toContain(second.id)
|
||||
})
|
||||
|
||||
test("filters by project ID across worktrees", async () => {
|
||||
const { Session } = await import("../../src/session/index")
|
||||
await using first = await tmpdir({ git: true })
|
||||
await using second = await tmpdir({ git: true })
|
||||
const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree")
|
||||
|
||||
try {
|
||||
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
|
||||
|
||||
const root = await Instance.provide({
|
||||
directory: first.path,
|
||||
fn: async () => Session.create({ title: "root-session" }),
|
||||
})
|
||||
const branch = await Instance.provide({
|
||||
directory: worktree,
|
||||
fn: async () => Session.create({ title: "worktree-session" }),
|
||||
})
|
||||
const other = await Instance.provide({
|
||||
directory: second.path,
|
||||
fn: async () => Session.create({ title: "other-session" }),
|
||||
})
|
||||
|
||||
const sessions = [...Session.listGlobal({ projectID: root.projectID, roots: true, limit: 200 })]
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(root.id)
|
||||
expect(ids).toContain(branch.id)
|
||||
expect(ids).not.toContain(other.id)
|
||||
expect(sessions.find((session) => session.id === branch.id)?.directory).toBe(worktree)
|
||||
} finally {
|
||||
await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { RecallTool } from "../../src/tool/recall"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { Tool } from "../../src/tool/tool"
|
||||
|
||||
mock.module("@/kilo-sessions/remote-sender", () => ({
|
||||
RemoteSender: {
|
||||
create() {
|
||||
return {
|
||||
handle() {},
|
||||
dispose() {},
|
||||
}
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const ctx: Tool.Context = {
|
||||
sessionID: "ses_test",
|
||||
messageID: "msg_test",
|
||||
callID: "call_test",
|
||||
agent: "code",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("tool.recall", () => {
|
||||
test("search is limited to the current project worktrees", async () => {
|
||||
await using first = await tmpdir({ git: true })
|
||||
await using second = await tmpdir({ git: true })
|
||||
const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree")
|
||||
|
||||
try {
|
||||
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
|
||||
|
||||
const share = Config.get
|
||||
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
|
||||
|
||||
try {
|
||||
const { Session } = await import("../../src/session/index")
|
||||
await Instance.provide({
|
||||
directory: first.path,
|
||||
fn: async () => Session.create({ title: "search-target root" }),
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: worktree,
|
||||
fn: async () => Session.create({ title: "search-target worktree" }),
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: second.path,
|
||||
fn: async () => Session.create({ title: "search-target other" }),
|
||||
})
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: first.path,
|
||||
fn: async () => {
|
||||
const tool = await RecallTool.init()
|
||||
return tool.execute({ mode: "search", query: "search-target" }, ctx)
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.output).toContain("search-target root")
|
||||
expect(result.output).toContain("search-target worktree")
|
||||
expect(result.output).not.toContain("search-target other")
|
||||
} finally {
|
||||
Config.get = share
|
||||
}
|
||||
} finally {
|
||||
await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow()
|
||||
}
|
||||
})
|
||||
|
||||
test("read rejects sessions from another project", async () => {
|
||||
await using first = await tmpdir({ git: true })
|
||||
await using second = await tmpdir({ git: true })
|
||||
|
||||
const share = Config.get
|
||||
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
|
||||
|
||||
try {
|
||||
const { Session } = await import("../../src/session/index")
|
||||
const session = await Instance.provide({
|
||||
directory: second.path,
|
||||
fn: async () => Session.create({ title: "other-project-session" }),
|
||||
})
|
||||
|
||||
const err = await Instance.provide({
|
||||
directory: first.path,
|
||||
fn: async () => {
|
||||
const tool = await RecallTool.init()
|
||||
return tool.execute({ mode: "read", sessionID: session.id }, ctx).catch((error) => error as Error)
|
||||
},
|
||||
})
|
||||
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect((err as Error).message).toContain("belongs to a different project")
|
||||
} finally {
|
||||
Config.get = share
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1065,6 +1065,7 @@ export class Session extends HeyApiClient {
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
projectID?: string
|
||||
roots?: boolean
|
||||
start?: number
|
||||
cursor?: number
|
||||
@@ -1081,6 +1082,7 @@ export class Session extends HeyApiClient {
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "query", key: "projectID" },
|
||||
{ in: "query", key: "roots" },
|
||||
{ in: "query", key: "start" },
|
||||
{ in: "query", key: "cursor" },
|
||||
|
||||
@@ -2843,6 +2843,10 @@ export type ExperimentalSessionListData = {
|
||||
*/
|
||||
directory?: string
|
||||
workspace?: string
|
||||
/**
|
||||
* Filter sessions by project ID
|
||||
*/
|
||||
projectID?: string
|
||||
/**
|
||||
* Only return root sessions (no parentID)
|
||||
*/
|
||||
|
||||
@@ -1790,6 +1790,14 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "projectID",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Filter sessions by project ID"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "roots",
|
||||
|
||||
Reference in New Issue
Block a user