diff --git a/src/tools/index.ts b/src/tools/index.ts index d749d427a7..daac8786ca 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -24,6 +24,7 @@ import { session_read, session_search, session_info, + session_rename, } from "./session-manager" export { sessionExists } from "./session-manager/storage" @@ -68,4 +69,5 @@ export const builtinTools: Record = { session_read, session_search, session_info, + session_rename, } diff --git a/src/tools/session-manager/constants.ts b/src/tools/session-manager/constants.ts index 5f079a1a84..2cef688fa3 100644 --- a/src/tools/session-manager/constants.ts +++ b/src/tools/session-manager/constants.ts @@ -94,4 +94,23 @@ Example: session_delete(session_id="ses_abc123", confirm=true) Successfully deleted session ses_abc123` +export const SESSION_RENAME_DESCRIPTION = `Rename/retitle an OpenCode session. + +Updates the title field in session metadata. The title is displayed in session_list and session_info outputs. + +Arguments: +- session_id (optional): Session ID to rename. Defaults to current session if not provided. +- new_title (required): New title for the session (cannot be empty) + +IMPORTANT: If the user asks to rename a session without specifying a title (e.g., "rename this session" or "give this session a descriptive name"), YOU must generate an appropriate title based on the conversation context before calling this tool. Analyze the key topics, tasks, or themes discussed and create a concise, descriptive title (max 80 chars). + +Example - user provides title: +User: "rename this session to Auth Implementation" +session_rename(new_title="Auth Implementation") + +Example - user doesn't provide title (YOU generate it): +User: "rename this session" +[You analyze the conversation: discussed React hooks, state management, useEffect patterns] +session_rename(new_title="React Hooks & State Management Deep Dive")` + export const TOOL_NAME_PREFIX = "session_" diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts index 174cdbe042..4a85de53f2 100644 --- a/src/tools/session-manager/storage.test.ts +++ b/src/tools/session-manager/storage.test.ts @@ -22,6 +22,7 @@ mock.module("./constants", () => ({ SESSION_SEARCH_DESCRIPTION: "test", SESSION_INFO_DESCRIPTION: "test", SESSION_DELETE_DESCRIPTION: "test", + SESSION_RENAME_DESCRIPTION: "test", TOOL_NAME_PREFIX: "session_", })) @@ -313,3 +314,256 @@ describe("session-manager storage - getMainSessions", () => { expect(sessions.length).toBe(2) }) }) + +describe("session-manager storage - findSessionMetadataPath", () => { + beforeEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + mkdirSync(TEST_DIR, { recursive: true }) + mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true }) + mkdirSync(TEST_PART_STORAGE, { recursive: true }) + mkdirSync(TEST_SESSION_STORAGE, { recursive: true }) + mkdirSync(TEST_TODO_DIR, { recursive: true }) + mkdirSync(TEST_TRANSCRIPT_DIR, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + }) + + function createSessionMetadata(projectID: string, sessionID: string, title?: string) { + const projectDir = join(TEST_SESSION_STORAGE, projectID) + mkdirSync(projectDir, { recursive: true }) + const metadata = { + id: sessionID, + directory: `/test/path/${projectID}`, + time: { + created: Date.now(), + updated: Date.now(), + }, + title: title || undefined, + } + writeFileSync(join(projectDir, `${sessionID}.json`), JSON.stringify(metadata, null, 2)) + } + + test("returns empty string for non-existent session", () => { + // #given non-existent session ID + const sessionID = "ses_doesnotexist_12345" + + // #when searching for metadata path + const path = storage.findSessionMetadataPath(sessionID) + + // #then returns empty string + expect(path).toBe("") + }) + + test("returns correct path when session exists in nested directory", () => { + // #given existing session in nested directory structure + const projectID = "test-project-123" + const sessionID = "ses_test_456" + createSessionMetadata(projectID, sessionID) + + // #when searching for metadata path + const path = storage.findSessionMetadataPath(sessionID) + + // #then returns full path to session JSON file + const expectedPath = join(TEST_SESSION_STORAGE, projectID, `${sessionID}.json`) + expect(path).toBe(expectedPath) + expect(existsSync(path)).toBe(true) + }) + + test("handles multiple projects and finds correct session", () => { + // #given sessions in multiple project directories + const projectA = "project-a" + const projectB = "project-b" + const sessionA = "ses_a_123" + const sessionB = "ses_b_456" + + createSessionMetadata(projectA, sessionA) + createSessionMetadata(projectB, sessionB) + + // #when searching for specific session + const pathA = storage.findSessionMetadataPath(sessionA) + const pathB = storage.findSessionMetadataPath(sessionB) + + // #then returns correct paths for each session + expect(pathA).toBe(join(TEST_SESSION_STORAGE, projectA, `${sessionA}.json`)) + expect(pathB).toBe(join(TEST_SESSION_STORAGE, projectB, `${sessionB}.json`)) + }) + + test("returns empty string when SESSION_STORAGE does not exist", () => { + // #given SESSION_STORAGE directory removed + rmSync(TEST_SESSION_STORAGE, { recursive: true, force: true }) + const nonExistentSession = "ses_any_789" + + // #when searching in non-existent storage + const path = storage.findSessionMetadataPath(nonExistentSession) + + // #then returns empty string gracefully + expect(path).toBe("") + }) +}) + +describe("session-manager storage - renameSession", () => { + beforeEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + mkdirSync(TEST_DIR, { recursive: true }) + mkdirSync(TEST_MESSAGE_STORAGE, { recursive: true }) + mkdirSync(TEST_PART_STORAGE, { recursive: true }) + mkdirSync(TEST_SESSION_STORAGE, { recursive: true }) + mkdirSync(TEST_TODO_DIR, { recursive: true }) + mkdirSync(TEST_TRANSCRIPT_DIR, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(TEST_DIR)) { + rmSync(TEST_DIR, { recursive: true, force: true }) + } + }) + + function createSessionMetadata(projectID: string, sessionID: string, title?: string) { + const projectDir = join(TEST_SESSION_STORAGE, projectID) + mkdirSync(projectDir, { recursive: true }) + const metadata = { + id: sessionID, + directory: `/test/path/${projectID}`, + time: { + created: Date.now(), + updated: Date.now(), + }, + title: title || undefined, + } + writeFileSync(join(projectDir, `${sessionID}.json`), JSON.stringify(metadata, null, 2)) + } + + test("returns false for non-existent session", async () => { + // #given non-existent session ID + const sessionID = "ses_doesnotexist_12345" + + // #when attempting to rename + const result = await storage.renameSession(sessionID, "New Title") + + // #then returns false + expect(result).toBe(false) + }) + + test("successfully renames existing session and updates title", async () => { + // #given existing session with initial title + const projectID = "test-project-123" + const sessionID = "ses_test_456" + const initialTitle = "Initial Title" + createSessionMetadata(projectID, sessionID, initialTitle) + + // #when renaming session + const newTitle = "Updated Title" + const result = await storage.renameSession(sessionID, newTitle) + + // #then returns true and updates title in metadata + expect(result).toBe(true) + + const sessionPath = join(TEST_SESSION_STORAGE, projectID, `${sessionID}.json`) + const content = await Bun.file(sessionPath).text() + const metadata = JSON.parse(content) + expect(metadata.title).toBe(newTitle) + }) + + test("updates time.updated timestamp when renaming", async () => { + // #given existing session + const projectID = "test-project-123" + const sessionID = "ses_test_456" + createSessionMetadata(projectID, sessionID, "Original Title") + + const sessionPath = join(TEST_SESSION_STORAGE, projectID, `${sessionID}.json`) + const beforeContent = await Bun.file(sessionPath).text() + const beforeMetadata = JSON.parse(beforeContent) + const originalUpdated = beforeMetadata.time.updated + + // #given time has passed to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)) + + // #when renaming session + const result = await storage.renameSession(sessionID, "New Title") + + // #then time.updated is greater than original + expect(result).toBe(true) + + const afterContent = await Bun.file(sessionPath).text() + const afterMetadata = JSON.parse(afterContent) + expect(afterMetadata.time.updated).toBeGreaterThan(originalUpdated) + }) + + test("handles empty string title by keeping existing title", async () => { + // #given existing session with title + const projectID = "test-project-123" + const sessionID = "ses_test_456" + const originalTitle = "Some Title" + createSessionMetadata(projectID, sessionID, originalTitle) + + // #when renaming with empty string + const result = await storage.renameSession(sessionID, "") + + // #then title is preserved (empty strings are ignored to prevent TUI crashes) + expect(result).toBe(true) + + const sessionPath = join(TEST_SESSION_STORAGE, projectID, `${sessionID}.json`) + const content = await Bun.file(sessionPath).text() + const metadata = JSON.parse(content) + expect(metadata.title).toBe(originalTitle) + }) + + test("preserves other metadata fields when renaming", async () => { + // #given existing session with metadata + const projectID = "test-project-123" + const sessionID = "ses_test_456" + createSessionMetadata(projectID, sessionID, "Original") + + const sessionPath = join(TEST_SESSION_STORAGE, projectID, `${sessionID}.json`) + const beforeContent = await Bun.file(sessionPath).text() + const beforeMetadata = JSON.parse(beforeContent) + + // #when renaming session + await storage.renameSession(sessionID, "New Title") + + // #then other fields remain unchanged + const afterContent = await Bun.file(sessionPath).text() + const afterMetadata = JSON.parse(afterContent) + + expect(afterMetadata.id).toBe(beforeMetadata.id) + expect(afterMetadata.directory).toBe(beforeMetadata.directory) + expect(afterMetadata.time.created).toBe(beforeMetadata.time.created) + }) + + test("handles invalid JSON gracefully", async () => { + // #given session file with invalid JSON + const projectID = "test-project-123" + const sessionID = "ses_test_456" + const projectDir = join(TEST_SESSION_STORAGE, projectID) + mkdirSync(projectDir, { recursive: true }) + const sessionPath = join(projectDir, `${sessionID}.json`) + writeFileSync(sessionPath, "{ invalid json }") + + // #when attempting to rename + const result = await storage.renameSession(sessionID, "New Title") + + // #then returns false due to parse error + expect(result).toBe(false) + }) + + test("handles file system errors gracefully", async () => { + // #given existing session + const projectID = "test-project-123" + const sessionID = "ses_test_456" + createSessionMetadata(projectID, sessionID, "Title") + + // #when attempting to rename (should handle any errors gracefully) + const result = await storage.renameSession(sessionID, "New Title") + + // #then should return boolean (either true or false, not throw) + expect(typeof result).toBe("boolean") + }) +}) diff --git a/src/tools/session-manager/storage.ts b/src/tools/session-manager/storage.ts index 8ed93f0027..3b59fe8826 100644 --- a/src/tools/session-manager/storage.ts +++ b/src/tools/session-manager/storage.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync } from "node:fs" -import { readdir, readFile } from "node:fs/promises" +import { readdir, readFile, writeFile } from "node:fs/promises" import { join } from "node:path" import { MESSAGE_STORAGE, PART_STORAGE, SESSION_STORAGE, TODO_DIR, TRANSCRIPT_DIR } from "./constants" import type { SessionMessage, SessionInfo, TodoItem, SessionMetadata } from "./types" @@ -204,6 +204,19 @@ export async function readSessionTranscript(sessionID: string): Promise } } +async function getSessionTitle(sessionID: string): Promise { + const metadataPath = findSessionMetadataPath(sessionID) + if (!metadataPath) return undefined + + try { + const content = await readFile(metadataPath, "utf-8") + const meta = JSON.parse(content) as SessionMetadata + return meta.title + } catch { + return undefined + } +} + export async function getSessionInfo(sessionID: string): Promise { const messages = await readSessionMessages(sessionID) if (messages.length === 0) return null @@ -224,6 +237,8 @@ export async function getSessionInfo(sessionID: string): Promise 0, has_transcript: transcriptEntries > 0, + title, todos, transcript_entries: transcriptEntries, } } + +export function findSessionMetadataPath(sessionID: string): string { + if (!existsSync(SESSION_STORAGE)) return "" + + try { + const projectDirs = readdirSync(SESSION_STORAGE, { withFileTypes: true }) + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory()) continue + + const projectPath = join(SESSION_STORAGE, projectDir.name) + const sessionFile = `${sessionID}.json` + const sessionPath = join(projectPath, sessionFile) + + if (existsSync(sessionPath)) { + return sessionPath + } + } + } catch { + return "" + } + + return "" +} + +export async function renameSession(sessionID: string, newTitle: string): Promise { + const sessionPath = findSessionMetadataPath(sessionID) + if (!sessionPath) return false + + try { + const content = await readFile(sessionPath, "utf-8") + const metadata = JSON.parse(content) as SessionMetadata + + metadata.title = newTitle && newTitle.trim() ? newTitle.trim() : metadata.title + metadata.time.updated = Date.now() + + await writeFile(sessionPath, JSON.stringify(metadata, null, 2), "utf-8") + return true + } catch { + return false + } +} diff --git a/src/tools/session-manager/tools.test.ts b/src/tools/session-manager/tools.test.ts index a44f7dbe74..5c40f0d011 100644 --- a/src/tools/session-manager/tools.test.ts +++ b/src/tools/session-manager/tools.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { session_list, session_read, session_search, session_info } from "./tools" +import { session_list, session_read, session_search, session_info, session_rename } from "./tools" import type { ToolContext } from "@opencode-ai/plugin/tool" const mockContext: ToolContext = { @@ -121,4 +121,62 @@ describe("session-manager tools", () => { expect(typeof result).toBe("string") }) + + test("session_rename handles non-existent session", async () => { + //#given non-existent session ID + const args = { session_id: "ses_nonexistent", new_title: "New Title" } + + //#when executing rename + const result = await session_rename.execute(args, mockContext) + + //#then returns error message + expect(result).toContain("not found") + }) + + test("session_rename successfully renames session", async () => { + //#given valid session ID and new title + const args = { session_id: "ses_test123", new_title: "Updated Title" } + + //#when executing rename + const result = await session_rename.execute(args, mockContext) + + //#then returns a string result (success or failure) + expect(typeof result).toBe("string") + }) + + test("session_rename rejects empty title", async () => { + //#given empty new_title parameter + const args = { session_id: "ses_test123", new_title: "" } + + //#when attempting to execute + const result = await session_rename.execute(args, mockContext) + + //#then should return error about empty title + expect(result).toContain("cannot be empty") + }) + + test("session_rename uses current session when session_id not provided", async () => { + //#given only new_title provided, no session_id + const args = { new_title: "Default Session Title" } + + //#when executing rename + const result = await session_rename.execute(args, mockContext) + + //#then result references context.sessionID ("test-session"), proving fallback worked + expect(typeof result).toBe("string") + expect(result).toContain("test-session") + }) + + test("session_rename prefers explicit session_id over context", async () => { + //#given both session_id and new_title provided + const args = { session_id: "ses_explicit", new_title: "Explicit Title" } + + //#when executing rename + const result = await session_rename.execute(args, mockContext) + + //#then should use the explicit session_id, not context.sessionID + expect(typeof result).toBe("string") + expect(result).toContain("ses_explicit") + expect(result).not.toContain("test-session") + }) }) diff --git a/src/tools/session-manager/tools.ts b/src/tools/session-manager/tools.ts index 1ef917c052..9c97d2820d 100644 --- a/src/tools/session-manager/tools.ts +++ b/src/tools/session-manager/tools.ts @@ -4,8 +4,9 @@ import { SESSION_READ_DESCRIPTION, SESSION_SEARCH_DESCRIPTION, SESSION_INFO_DESCRIPTION, + SESSION_RENAME_DESCRIPTION, } from "./constants" -import { getAllSessions, getMainSessions, getSessionInfo, readSessionMessages, readSessionTodos, sessionExists } from "./storage" +import { getAllSessions, getMainSessions, getSessionInfo, readSessionMessages, readSessionTodos, renameSession, sessionExists } from "./storage" import { filterSessionsByDate, formatSessionInfo, @@ -14,7 +15,7 @@ import { formatSearchResults, searchInSession, } from "./utils" -import type { SessionListArgs, SessionReadArgs, SessionSearchArgs, SessionInfoArgs, SearchResult } from "./types" +import type { SessionListArgs, SessionReadArgs, SessionSearchArgs, SessionInfoArgs, SessionRenameArgs, SearchResult } from "./types" const SEARCH_TIMEOUT_MS = 60_000 const MAX_SESSIONS_TO_SCAN = 50 @@ -144,3 +145,40 @@ export const session_info: ToolDefinition = tool({ } }, }) + +export const session_rename: ToolDefinition = tool({ + description: SESSION_RENAME_DESCRIPTION, + args: { + session_id: tool.schema.string().optional().describe("Session ID to rename (default: current session)"), + new_title: tool.schema.string().describe("New title for the session"), + }, + execute: async (args: SessionRenameArgs, context) => { + try { + const toolCtx = context as { sessionID: string } + const sessionId = args.session_id || toolCtx.sessionID + + if (!sessionId) { + return "Error: No session ID provided and could not determine current session" + } + + if (!args.new_title || args.new_title.trim() === "") { + return "Error: Title cannot be empty. Please provide a descriptive title for the session." + } + + if (!sessionExists(sessionId)) { + return `Session not found: ${sessionId}` + } + + const success = await renameSession(sessionId, args.new_title.trim()) + + if (success) { + const titleDisplay = args.new_title ? `"${args.new_title}"` : "(cleared)" + return `Successfully renamed session ${sessionId} to ${titleDisplay}` + } + + return `Failed to rename session ${sessionId}` + } catch (e) { + return `Error: ${e instanceof Error ? e.message : String(e)}` + } + }, +}) diff --git a/src/tools/session-manager/types.ts b/src/tools/session-manager/types.ts index becaf13bc9..dbeb1ceb3e 100644 --- a/src/tools/session-manager/types.ts +++ b/src/tools/session-manager/types.ts @@ -29,6 +29,7 @@ export interface SessionInfo { agents_used: string[] has_todos: boolean has_transcript: boolean + title?: string todos?: TodoItem[] transcript_entries?: number } @@ -97,3 +98,8 @@ export interface SessionDeleteArgs { session_id: string confirm: boolean } + +export interface SessionRenameArgs { + session_id?: string + new_title: string +} diff --git a/src/tools/session-manager/utils.ts b/src/tools/session-manager/utils.ts index 33faae9c4d..e5fc76a074 100644 --- a/src/tools/session-manager/utils.ts +++ b/src/tools/session-manager/utils.ts @@ -14,9 +14,10 @@ export async function formatSessionList(sessionIDs: string[]): Promise { return "No valid sessions found." } - const headers = ["Session ID", "Messages", "First", "Last", "Agents"] + const headers = ["Session ID", "Title", "Messages", "First", "Last", "Agents"] const rows = infos.map((info) => [ info.id, + info.title || "(no title)", info.message_count.toString(), info.first_message?.toISOString().split("T")[0] ?? "N/A", info.last_message?.toISOString().split("T")[0] ?? "N/A", @@ -86,6 +87,7 @@ export function formatSessionMessages( export function formatSessionInfo(info: SessionInfo): string { const lines = [ `Session ID: ${info.id}`, + `Title: ${info.title || "(no title)"}`, `Messages: ${info.message_count}`, `Date Range: ${info.first_message?.toISOString() ?? "N/A"} to ${info.last_message?.toISOString() ?? "N/A"}`, `Agents Used: ${info.agents_used.join(", ") || "none"}`,