Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
session_read,
session_search,
session_info,
session_rename,
} from "./session-manager"

export { sessionExists } from "./session-manager/storage"
Expand Down Expand Up @@ -68,4 +69,5 @@ export const builtinTools: Record<string, ToolDefinition> = {
session_read,
session_search,
session_info,
session_rename,
}
19 changes: 19 additions & 0 deletions src/tools/session-manager/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_"
254 changes: 254 additions & 0 deletions src/tools/session-manager/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_",
}))

Expand Down Expand Up @@ -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")
})
})
59 changes: 58 additions & 1 deletion src/tools/session-manager/storage.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -204,6 +204,19 @@ export async function readSessionTranscript(sessionID: string): Promise<number>
}
}

async function getSessionTitle(sessionID: string): Promise<string | undefined> {
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<SessionInfo | null> {
const messages = await readSessionMessages(sessionID)
if (messages.length === 0) return null
Expand All @@ -224,6 +237,8 @@ export async function getSessionInfo(sessionID: string): Promise<SessionInfo | n
const todos = await readSessionTodos(sessionID)
const transcriptEntries = await readSessionTranscript(sessionID)

const title = await getSessionTitle(sessionID)

return {
id: sessionID,
message_count: messages.length,
Expand All @@ -232,7 +247,49 @@ export async function getSessionInfo(sessionID: string): Promise<SessionInfo | n
agents_used: Array.from(agentsUsed),
has_todos: todos.length > 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<boolean> {
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
}
}
Loading