diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 1fe52d47a0a6..bb91badd2b8b 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -198,6 +198,26 @@ describe("layout workspace helpers", () => { expect(result?.id).toBe("root") }) + test("includes root sessions from subdirectories of the same workspace", () => { + const result = latestRootSession( + [ + { + path: { directory: "/workspace" }, + session: [ + session({ + id: "subdir", + directory: "/workspace/src", + time: { created: 30, updated: 30, archived: undefined }, + }), + ], + }, + ], + 120_000, + ) + + expect(result?.id).toBe("subdir") + }) + test("formats fallback project display name", () => { expect(displayName({ worktree: "/tmp/app" })).toBe("app") expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 226098c1cd66..03248f37d619 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -14,6 +14,15 @@ export const workspaceKey = (directory: string) => { return value.replace(/\/+$/, "") } +const within = (dir: string, root: string) => { + const a = workspaceKey(dir) + const b = workspaceKey(root) + if (a === b) return true + if (b === "/") return a.startsWith("/") + if (/^[A-Za-z]:\/$/i.test(b)) return a.startsWith(b) + return a.startsWith(`${b}/`) +} + function sortSessions(now: number) { const oneMinuteAgo = now - 60 * 1000 return (a: Session, b: Session) => { @@ -29,7 +38,7 @@ function sortSessions(now: number) { } const isRootVisibleSession = (session: Session, directory: string) => - workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + within(session.directory, directory) && !session.parentID && !session.time?.archived const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0aeb864e8679..932897e7b24d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -27,6 +27,11 @@ import { SkillTool } from "../../tool/skill" import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" +import { Session } from "../../session" + +export function continueID(input: { search?: string }) { + return [...Session.discover({ roots: true, search: input.search, limit: 1 })][0]?.id +} type ToolProps = { input: Tool.InferParameters @@ -238,6 +243,10 @@ export const RunCommand = cmd({ describe: "continue the last session", type: "boolean", }) + .option("continue-search", { + describe: "filter continued sessions by title", + type: "string", + }) .option("session", { alias: ["s"], describe: "session id to continue", @@ -379,7 +388,7 @@ export const RunCommand = cmd({ } async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + const baseID = args.session ?? (args.continue ? continueID({ search: args.continueSearch }) : undefined) if (baseID && args.fork) { const forked = await sdk.session.fork({ sessionID: baseID }) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 8acd7480c941..3db651b1e4d5 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -12,6 +12,11 @@ import { EOL } from "os" import path from "path" import { which } from "../../util/which" +type Row = Pick & { + project?: { worktree: string; name?: string } | null + db?: string +} + function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] if (process.platform !== "win32") { @@ -87,10 +92,19 @@ export const SessionListCommand = cmd({ choices: ["table", "json"], default: "table", }) + .option("global", { + alias: ["all"], + describe: "list sessions across discovered local databases", + type: "boolean", + }) + .option("search", { + describe: "filter sessions by title", + type: "string", + }) }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const sessions = [...Session.list({ roots: true, limit: args.maxCount })] + const sessions = listItems({ global: args.global, search: args.search, limit: args.maxCount }) if (sessions.length === 0) { return @@ -127,26 +141,36 @@ export const SessionListCommand = cmd({ }, }) -function formatSessionTable(sessions: Session.Info[]): string { +export function listItems(input: { global?: boolean; search?: string; limit?: number }) { + if (input.global) return [...Session.discover({ roots: true, search: input.search, limit: input.limit })] + return [...Session.list({ roots: true, search: input.search, limit: input.limit })] +} + +function formatSessionTable(sessions: Row[]): string { const lines: string[] = [] const maxIdWidth = Math.max(20, ...sessions.map((s) => s.id.length)) const maxTitleWidth = Math.max(25, ...sessions.map((s) => s.title.length)) + const hasProject = sessions.some((s) => s.project?.worktree) - const header = `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated` + const header = hasProject + ? `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated Project` + : `Session ID${" ".repeat(maxIdWidth - 10)} Title${" ".repeat(maxTitleWidth - 5)} Updated` lines.push(header) lines.push("─".repeat(header.length)) for (const session of sessions) { const truncatedTitle = Locale.truncate(session.title, maxTitleWidth) const timeStr = Locale.todayTimeOrDateTime(session.time.updated) - const line = `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}` + const line = hasProject + ? `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr} ${session.project?.name ?? session.project?.worktree ?? ""}` + : `${session.id.padEnd(maxIdWidth)} ${truncatedTitle.padEnd(maxTitleWidth)} ${timeStr}` lines.push(line) } return lines.join(EOL) } -function formatSessionJSON(sessions: Session.Info[]): string { +function formatSessionJSON(sessions: Row[]): string { const jsonData = sessions.map((session) => ({ id: session.id, title: session.title, @@ -154,6 +178,8 @@ function formatSessionJSON(sessions: Session.Info[]): string { created: session.time.created, projectId: session.projectID, directory: session.directory, + project: session.project, + db: session.db, })) return JSON.stringify(jsonData, null, 2) } diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 74506c31da65..03268d0320ae 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,5 +1,6 @@ import { Slug } from "@opencode-ai/util/slug" import path from "path" +import { readdirSync } from "fs" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" @@ -34,6 +35,7 @@ import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" import { Effect, Layer, Scope, ServiceMap } from "effect" import { makeRuntime } from "@/effect/run-service" +import { Database as Sqlite } from "bun:sqlite" export namespace Session { const log = Log.create({ service: "session" }) @@ -183,6 +185,11 @@ export namespace Session { }) export type GlobalInfo = z.output + export const DiscoverInfo = GlobalInfo.extend({ + db: z.string(), + }) + export type DiscoverInfo = z.output + export const Event = { Created: SyncEvent.define({ type: "session.created", @@ -241,6 +248,160 @@ export namespace Session { return path.join(base, [input.time.created, input.slug].join("-") + ".md") } + function files() { + try { + return [ + ...new Set( + readdirSync(Global.Path.data) + .filter((x) => /^opencode.*\.db$/i.test(x)) + .map((x) => path.join(Global.Path.data, x)), + ), + ] + } catch { + return [] + } + } + + function sandboxes(input: unknown) { + if (typeof input !== "string") return [] + try { + const value = JSON.parse(input) + return Array.isArray(value) ? value : [] + } catch { + return [] + } + } + + function match( + row: Record, + input?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + archived?: boolean + }, + ) { + if (input?.directory) { + const dir = row.directory + const root = row.project_worktree + const boxes = sandboxes(row.project_sandboxes) + if (dir !== input.directory && root !== input.directory && !boxes.includes(input.directory)) return false + } + if (input?.roots && row.parent_id != null) return false + if (input?.start && Number(row.time_updated) < input.start) return false + if (input?.cursor && Number(row.time_updated) >= input.cursor) return false + if ( + input?.search && + !String(row.title ?? "") + .toLowerCase() + .includes(input.search.toLowerCase()) + ) + return false + if (!input?.archived && row.time_archived != null) return false + return true + } + + function* scan(input?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean + }) { + const seen = new Set() + const rows = files() + .flatMap((file) => { + try { + const db = new Sqlite(file, { readonly: true }) + const rows = db + .query( + `SELECT session.*, project.id AS project_summary_id, project.name AS project_name, project.worktree AS project_worktree, project.sandboxes AS project_sandboxes + FROM session + LEFT JOIN project ON project.id = session.project_id`, + ) + .all() as Record[] + db.close() + return rows + .filter((row) => match(row, input)) + .map((row) => ({ + ...fromRow(row as SessionRow), + project: row.project_summary_id + ? { + id: row.project_summary_id as string, + name: (row.project_name as string | null) ?? undefined, + worktree: row.project_worktree as string, + } + : null, + db: file, + })) + } catch { + return [] + } + }) + .sort((a, b) => b.time.updated - a.time.updated || b.id.localeCompare(a.id)) + .filter((item) => { + if (seen.has(item.id)) return false + seen.add(item.id) + return true + }) + + for (const row of rows.slice(0, input?.limit ?? 100)) { + yield row + } + } + + export function discover(input?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean + }) { + return scan(input) + } + + export function info(id: SessionID | string) { + const item = [...scan({ limit: Number.MAX_SAFE_INTEGER, archived: true })].find((x) => x.id === id) + if (!item) throw new NotFoundError({ message: `Session not found: ${id}` }) + return item + } + + export function read(input: { sessionID: SessionID | string; limit?: number }) { + const session = info(input.sessionID) + const db = new Sqlite(session.db, { readonly: true }) + const rows = db + .query(`SELECT * FROM message WHERE session_id = ? ORDER BY time_created DESC, id DESC LIMIT ?`) + .all(String(input.sessionID), input.limit ?? 100) as Record[] + const result = rows.toReversed().map((row) => { + const parts = db.query(`SELECT * FROM part WHERE message_id = ? ORDER BY id ASC`).all(String(row.id)) as Record< + string, + unknown + >[] + return { + info: { + ...(JSON.parse(String(row.data)) as object), + id: row.id, + sessionID: row.session_id, + time: { created: Number(row.time_created) }, + }, + parts: parts.map((part) => ({ + ...(JSON.parse(String(part.data)) as object), + id: part.id, + sessionID: part.session_id, + messageID: part.message_id, + })), + } + }) + db.close() + return result as MessageV2.WithParts[] + } + export const getUsage = (input: { model: Provider.Model usage: LanguageModelV2Usage diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ec5a0649cdd8..fe5b9c9baccb 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -35,6 +35,10 @@ import { makeRuntime } from "@/effect/run-service" import { DesktopTool } from "./desktop" import { BrowserTool } from "./browser" import { SwarmTool } from "./swarm" +import { SessionListTool } from "./session_list" +import { SessionSearchTool } from "./session_search" +import { SessionInfoTool } from "./session_info" +import { SessionReadTool } from "./session_read" import { EnterWorktreeTool, ExitWorktreeTool } from "./worktree" import { BackgroundStartTool, BackgroundOutputTool, BackgroundCancelTool } from "./background" @@ -137,6 +141,10 @@ export namespace ToolRegistry { DesktopTool, BrowserTool, SwarmTool, + SessionListTool, + SessionSearchTool, + SessionInfoTool, + SessionReadTool, BackgroundStartTool, BackgroundOutputTool, BackgroundCancelTool, diff --git a/packages/opencode/src/tool/session_info.ts b/packages/opencode/src/tool/session_info.ts new file mode 100644 index 000000000000..40f093abac87 --- /dev/null +++ b/packages/opencode/src/tool/session_info.ts @@ -0,0 +1,18 @@ +import z from "zod" +import { Tool } from "./tool" +import { Session } from "../session" + +export const SessionInfoTool = Tool.define("session_info", { + description: "Get metadata for a local OpenCode session from discovered databases.", + parameters: z.object({ + sessionID: z.string(), + }), + async execute(input) { + const item = Session.info(input.sessionID) + return { + title: input.sessionID, + output: JSON.stringify(item, null, 2), + metadata: {}, + } + }, +}) diff --git a/packages/opencode/src/tool/session_list.ts b/packages/opencode/src/tool/session_list.ts new file mode 100644 index 000000000000..e23da5eae286 --- /dev/null +++ b/packages/opencode/src/tool/session_list.ts @@ -0,0 +1,30 @@ +import z from "zod" +import { Tool } from "./tool" +import { Session } from "../session" + +export const SessionListTool = Tool.define("session_list", { + description: "List local OpenCode sessions across discovered databases.", + parameters: z.object({ + directory: z.string().optional(), + query: z.string().optional(), + roots: z.coerce.boolean().optional(), + limit: z.coerce.number().optional(), + archived: z.coerce.boolean().optional(), + }), + async execute(input) { + const items = [ + ...Session.discover({ + directory: input.directory, + search: input.query, + roots: input.roots, + limit: input.limit, + archived: input.archived, + }), + ] + return { + title: "Sessions", + output: JSON.stringify(items, null, 2), + metadata: {}, + } + }, +}) diff --git a/packages/opencode/src/tool/session_read.ts b/packages/opencode/src/tool/session_read.ts new file mode 100644 index 000000000000..200c9daedc8a --- /dev/null +++ b/packages/opencode/src/tool/session_read.ts @@ -0,0 +1,32 @@ +import z from "zod" +import { Tool } from "./tool" +import { Session } from "../session" + +export const SessionReadTool = Tool.define("session_read", { + description: "Read messages from a local OpenCode session in discovered databases.", + parameters: z.object({ + sessionID: z.string(), + limit: z.coerce.number().optional(), + }), + async execute(input) { + const session = Session.info(input.sessionID) + const msgs = Session.read({ sessionID: input.sessionID, limit: input.limit }) + const output = [ + `Session: ${session.id}`, + `Title: ${session.title}`, + ...msgs.flatMap((msg) => { + const role = typeof msg.info.role === "string" ? msg.info.role : "unknown" + const text = msg.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + return [`[${role}]`, text] + }), + ].join("\n") + return { + title: input.sessionID, + output, + metadata: {}, + } + }, +}) diff --git a/packages/opencode/src/tool/session_search.ts b/packages/opencode/src/tool/session_search.ts new file mode 100644 index 000000000000..1500ea62d6a1 --- /dev/null +++ b/packages/opencode/src/tool/session_search.ts @@ -0,0 +1,28 @@ +import z from "zod" +import { Tool } from "./tool" +import { Session } from "../session" + +export const SessionSearchTool = Tool.define("session_search", { + description: "Search local OpenCode sessions by title across discovered databases.", + parameters: z.object({ + query: z.string(), + limit: z.coerce.number().optional(), + directory: z.string().optional(), + archived: z.coerce.boolean().optional(), + }), + async execute(input) { + const items = [ + ...Session.discover({ + directory: input.directory, + search: input.query, + limit: input.limit, + archived: input.archived, + }), + ] + return { + title: "Session search", + output: JSON.stringify(items, null, 2), + metadata: {}, + } + }, +}) diff --git a/packages/opencode/test/cli/session.test.ts b/packages/opencode/test/cli/session.test.ts new file mode 100644 index 000000000000..3e7af5a5717e --- /dev/null +++ b/packages/opencode/test/cli/session.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir, readdir, rm } from "fs/promises" +import path from "path" +import { Database as Sqlite } from "bun:sqlite" +import { Global } from "../../src/global" +import { Instance } from "../../src/project/instance" +import { Database } from "../../src/storage/db" +import { listItems } from "../../src/cli/cmd/session" +import { continueID } from "../../src/cli/cmd/run" +import { tmpdir } from "../fixture/fixture" + +function seed( + file: string, + input: { + project: { id: string; worktree: string; name?: string } + session: { id: string; title: string; directory: string; updated: number } + }, +) { + const db = new Sqlite(file, { create: true }) + db.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT NOT NULL, + vcs TEXT, + name TEXT, + icon_url TEXT, + icon_color TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_initialized INTEGER, + sandboxes TEXT NOT NULL, + commands TEXT + ); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + workspace_id TEXT, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + share_url TEXT, + summary_additions INTEGER, + summary_deletions INTEGER, + summary_files INTEGER, + summary_diffs TEXT, + revert TEXT, + permission TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_compacting INTEGER, + time_archived INTEGER, + FOREIGN KEY(project_id) REFERENCES project(id) + ); + `) + db.prepare( + `INSERT INTO project (id, worktree, vcs, name, icon_url, icon_color, time_created, time_updated, time_initialized, sandboxes, commands) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.project.id, + input.project.worktree, + "git", + input.project.name ?? null, + null, + null, + input.session.updated, + input.session.updated, + null, + JSON.stringify([]), + null, + ) + db.prepare( + `INSERT INTO session (id, project_id, workspace_id, parent_id, slug, directory, title, version, share_url, summary_additions, summary_deletions, summary_files, summary_diffs, revert, permission, time_created, time_updated, time_compacting, time_archived) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.session.id, + input.project.id, + null, + null, + input.session.id, + input.session.directory, + input.session.title, + "v2", + null, + null, + null, + null, + null, + null, + null, + input.session.updated, + input.session.updated, + null, + null, + ) + db.close() +} + +afterEach(async () => { + await Instance.disposeAll() + Database.close() + const items = await readdir(Global.Path.data).catch(() => []) + await Promise.all( + items + .filter((item) => item.startsWith("opencode") && item.endsWith(".db")) + .map((item) => rm(path.join(Global.Path.data, item), { force: true })), + ) +}) + +describe("cli session helpers", () => { + test("listItems returns discovered sessions for global search", async () => { + await mkdir(Global.Path.data, { recursive: true }) + seed(path.join(Global.Path.data, "opencode.db"), { + project: { id: "proj-root", worktree: "/tmp/root" }, + session: { id: "ses-root", title: "root session", directory: "/tmp/root", updated: 10 }, + }) + seed(path.join(Global.Path.data, "opencode-dev.db"), { + project: { id: "proj-dev", worktree: "/tmp/dev" }, + session: { id: "ses-dev", title: "memory improvement", directory: "/tmp/dev", updated: 20 }, + }) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const sessions = listItems({ global: true, search: "memory", limit: 10 }) + expect(sessions.map((x) => String(x.id))).toEqual(["ses-dev"]) + }, + }) + }) + + test("continueID picks newest discovered root session matching search", async () => { + await mkdir(Global.Path.data, { recursive: true }) + seed(path.join(Global.Path.data, "opencode.db"), { + project: { id: "proj-root", worktree: "/tmp/root" }, + session: { id: "ses-root", title: "memory old", directory: "/tmp/root", updated: 10 }, + }) + seed(path.join(Global.Path.data, "opencode-dev.db"), { + project: { id: "proj-dev", worktree: "/tmp/dev" }, + session: { id: "ses-dev", title: "memory latest", directory: "/tmp/dev", updated: 20 }, + }) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const id = continueID({ search: "memory" }) + expect(String(id)).toBe("ses-dev") + }, + }) + }) +}) diff --git a/packages/opencode/test/server/session-discovery.test.ts b/packages/opencode/test/server/session-discovery.test.ts new file mode 100644 index 000000000000..f5ed96277250 --- /dev/null +++ b/packages/opencode/test/server/session-discovery.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir, readdir, rm } from "fs/promises" +import path from "path" +import { Database as Sqlite } from "bun:sqlite" +import { Global } from "../../src/global" +import { Session } from "../../src/session" +import { Database } from "../../src/storage/db" + +function seed( + file: string, + input: { + project: { id: string; worktree: string; name?: string } + session: { id: string; title: string; directory: string; updated: number; archived?: number } + }, +) { + const db = new Sqlite(file, { create: true }) + db.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT NOT NULL, + vcs TEXT, + name TEXT, + icon_url TEXT, + icon_color TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_initialized INTEGER, + sandboxes TEXT NOT NULL, + commands TEXT + ); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + workspace_id TEXT, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + share_url TEXT, + summary_additions INTEGER, + summary_deletions INTEGER, + summary_files INTEGER, + summary_diffs TEXT, + revert TEXT, + permission TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_compacting INTEGER, + time_archived INTEGER, + FOREIGN KEY(project_id) REFERENCES project(id) + ); + `) + db.prepare( + `INSERT INTO project (id, worktree, vcs, name, icon_url, icon_color, time_created, time_updated, time_initialized, sandboxes, commands) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.project.id, + input.project.worktree, + "git", + input.project.name ?? null, + null, + null, + input.session.updated, + input.session.updated, + null, + JSON.stringify([]), + null, + ) + db.prepare( + `INSERT INTO session (id, project_id, workspace_id, parent_id, slug, directory, title, version, share_url, summary_additions, summary_deletions, summary_files, summary_diffs, revert, permission, time_created, time_updated, time_compacting, time_archived) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.session.id, + input.project.id, + null, + null, + input.session.id, + input.session.directory, + input.session.title, + "v2", + null, + null, + null, + null, + null, + null, + null, + input.session.updated, + input.session.updated, + null, + input.session.archived ?? null, + ) + db.close() +} + +afterEach(() => { + Database.close() +}) + +afterEach(async () => { + const items = await readdir(Global.Path.data).catch(() => []) + await Promise.all( + items + .filter((item) => item.startsWith("opencode") && item.endsWith(".db")) + .map((item) => rm(path.join(Global.Path.data, item), { force: true })), + ) +}) + +describe("Session.discover", () => { + test("lists sessions across multiple local opencode db files", async () => { + await mkdir(Global.Path.data, { recursive: true }) + + seed(path.join(Global.Path.data, "opencode.db"), { + project: { id: "proj-root", worktree: "/tmp/root", name: "root" }, + session: { id: "ses-root", title: "root session", directory: "/tmp/root", updated: 10 }, + }) + seed(path.join(Global.Path.data, "opencode-dev.db"), { + project: { id: "proj-dev", worktree: "/tmp/dev", name: "dev" }, + session: { id: "ses-dev", title: "dev session", directory: "/tmp/dev", updated: 20 }, + }) + + const sessions = [...Session.discover({ limit: 10 })] + + expect(sessions.map((x) => String(x.id))).toEqual(["ses-dev", "ses-root"]) + expect(sessions[0]?.project?.worktree).toBe("/tmp/dev") + expect(sessions[1]?.project?.worktree).toBe("/tmp/root") + }) + + test("ignores broken db files while returning valid sessions", async () => { + await mkdir(Global.Path.data, { recursive: true }) + + seed(path.join(Global.Path.data, "opencode.db"), { + project: { id: "proj-root", worktree: "/tmp/root" }, + session: { id: "ses-root", title: "root session", directory: "/tmp/root", updated: 10 }, + }) + Bun.write(path.join(Global.Path.data, "opencode-bad.db"), "not sqlite") + + const sessions = [...Session.discover({ limit: 10 })] + + expect(sessions.map((x) => String(x.id))).toEqual(["ses-root"]) + }) +}) diff --git a/packages/opencode/test/tool/session.test.ts b/packages/opencode/test/tool/session.test.ts new file mode 100644 index 000000000000..a6606bc8c479 --- /dev/null +++ b/packages/opencode/test/tool/session.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir, readdir, rm } from "fs/promises" +import path from "path" +import { Database as Sqlite } from "bun:sqlite" +import { Global } from "../../src/global" +import { Instance } from "../../src/project/instance" +import { Database } from "../../src/storage/db" +import { MessageID, SessionID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" +import { SessionInfoTool } from "../../src/tool/session_info" +import { SessionListTool } from "../../src/tool/session_list" +import { SessionReadTool } from "../../src/tool/session_read" +import { SessionSearchTool } from "../../src/tool/session_search" + +function seed( + file: string, + input: { + project: { id: string; worktree: string; name?: string } + session: { id: string; title: string; directory: string; updated: number } + text: string + }, +) { + const db = new Sqlite(file, { create: true }) + db.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE project ( + id TEXT PRIMARY KEY, + worktree TEXT NOT NULL, + vcs TEXT, + name TEXT, + icon_url TEXT, + icon_color TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_initialized INTEGER, + sandboxes TEXT NOT NULL, + commands TEXT + ); + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + workspace_id TEXT, + parent_id TEXT, + slug TEXT NOT NULL, + directory TEXT NOT NULL, + title TEXT NOT NULL, + version TEXT NOT NULL, + share_url TEXT, + summary_additions INTEGER, + summary_deletions INTEGER, + summary_files INTEGER, + summary_diffs TEXT, + revert TEXT, + permission TEXT, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_compacting INTEGER, + time_archived INTEGER, + FOREIGN KEY(project_id) REFERENCES project(id) + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL, + FOREIGN KEY(session_id) REFERENCES session(id) + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL, + FOREIGN KEY(message_id) REFERENCES message(id) + ); + `) + db.prepare( + `INSERT INTO project (id, worktree, vcs, name, icon_url, icon_color, time_created, time_updated, time_initialized, sandboxes, commands) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.project.id, + input.project.worktree, + "git", + input.project.name ?? null, + null, + null, + input.session.updated, + input.session.updated, + null, + JSON.stringify([]), + null, + ) + db.prepare( + `INSERT INTO session (id, project_id, workspace_id, parent_id, slug, directory, title, version, share_url, summary_additions, summary_deletions, summary_files, summary_diffs, revert, permission, time_created, time_updated, time_compacting, time_archived) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.session.id, + input.project.id, + null, + null, + input.session.id, + input.session.directory, + input.session.title, + "v2", + null, + null, + null, + null, + null, + null, + null, + input.session.updated, + input.session.updated, + null, + null, + ) + db.prepare(`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)`).run( + `msg-${input.session.id}`, + input.session.id, + input.session.updated, + input.session.updated, + JSON.stringify({ role: "user", sessionID: input.session.id, time: { created: input.session.updated } }), + ) + db.prepare( + `INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + `part-${input.session.id}`, + `msg-${input.session.id}`, + input.session.id, + input.session.updated, + input.session.updated, + JSON.stringify({ type: "text", text: input.text }), + ) + db.close() +} + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +afterEach(async () => { + await Instance.disposeAll() + Database.close() + const items = await readdir(Global.Path.data).catch(() => []) + await Promise.all( + items + .filter((item) => item.startsWith("opencode") && item.endsWith(".db")) + .map((item) => rm(path.join(Global.Path.data, item), { force: true })), + ) +}) + +describe("session tools", () => { + test("session_list returns sessions across local db files", async () => { + await mkdir(Global.Path.data, { recursive: true }) + seed(path.join(Global.Path.data, "opencode.db"), { + project: { id: "proj-root", worktree: "/tmp/root", name: "root" }, + session: { id: "ses-root", title: "root session", directory: "/tmp/root", updated: 10 }, + text: "root text", + }) + seed(path.join(Global.Path.data, "opencode-dev.db"), { + project: { id: "proj-dev", worktree: "/tmp/dev", name: "dev" }, + session: { id: "ses-dev", title: "dev session", directory: "/tmp/dev", updated: 20 }, + text: "dev text", + }) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await SessionListTool.init() + const result = await tool.execute({ limit: 10 }, ctx) + expect(result.output).toContain("ses-dev") + expect(result.output).toContain("ses-root") + }, + }) + }) + + test("session_search filters discovered sessions by query", async () => { + await mkdir(Global.Path.data, { recursive: true }) + seed(path.join(Global.Path.data, "opencode.db"), { + project: { id: "proj-root", worktree: "/tmp/root" }, + session: { id: "ses-root", title: "root session", directory: "/tmp/root", updated: 10 }, + text: "root text", + }) + seed(path.join(Global.Path.data, "opencode-dev.db"), { + project: { id: "proj-dev", worktree: "/tmp/dev" }, + session: { id: "ses-dev", title: "memory improvement", directory: "/tmp/dev", updated: 20 }, + text: "dev text", + }) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await SessionSearchTool.init() + const result = await tool.execute({ query: "memory", limit: 10 }, ctx) + expect(result.output).toContain("ses-dev") + expect(result.output).not.toContain("ses-root") + }, + }) + }) + + test("session_info returns metadata for one discovered session", async () => { + await mkdir(Global.Path.data, { recursive: true }) + seed(path.join(Global.Path.data, "opencode-dev.db"), { + project: { id: "proj-dev", worktree: "/tmp/dev", name: "dev" }, + session: { id: "ses-dev", title: "memory improvement", directory: "/tmp/dev", updated: 20 }, + text: "dev text", + }) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await SessionInfoTool.init() + const result = await tool.execute({ sessionID: "ses-dev" }, ctx) + expect(result.output).toContain("memory improvement") + expect(result.output).toContain("/tmp/dev") + }, + }) + }) + + test("session_read returns text parts for one discovered session", async () => { + await mkdir(Global.Path.data, { recursive: true }) + seed(path.join(Global.Path.data, "opencode-dev.db"), { + project: { id: "proj-dev", worktree: "/tmp/dev", name: "dev" }, + session: { id: "ses-dev", title: "memory improvement", directory: "/tmp/dev", updated: 20 }, + text: "please improve memory recall", + }) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await SessionReadTool.init() + const result = await tool.execute({ sessionID: "ses-dev", limit: 20 }, ctx) + expect(result.output).toContain("please improve memory recall") + }, + }) + }) +})