Skip to content
Draft
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
3 changes: 2 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1786,7 +1786,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (!cleaned) return

const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
draft.title = title
// Only set if title is still default (wasn't changed by tool during LLM call)
if (Session.isDefaultTitle(draft.title)) draft.title = title
})
}
}
2 changes: 2 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { PlanExitTool, PlanEnterTool } from "./plan"
import { SetCurrentSessionTitleTool } from "./set-current-session-title"

export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
Expand Down Expand Up @@ -105,6 +106,7 @@ export namespace ToolRegistry {
WebFetchTool,
TodoWriteTool,
TodoReadTool,
SetCurrentSessionTitleTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/src/tool/set-current-session-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import z from "zod"
import { Tool } from "./tool"
import { Session } from "../session"
import DESCRIPTION from "./set-current-session-title.txt"

export const SetCurrentSessionTitleTool = Tool.define("set_current_session_title", {
description: DESCRIPTION,
parameters: z.object({
title: z
.string()
.min(1, "Title must be at least 1 character")
.max(255, "Title must be at most 255 characters")
.describe("The new title for the current session"),
}),
async execute(params, ctx) {
await ctx.ask({
permission: "set_current_session_title",
patterns: ["*"],
always: ["*"],
metadata: {},
})

const session = await Session.update(ctx.sessionID, (draft) => {
draft.title = params.title
})

return {
title: params.title,
output: `Session title updated to: ${session.title}`,
metadata: {
sessionID: ctx.sessionID,
title: session.title,
},
}
},
})
8 changes: 8 additions & 0 deletions packages/opencode/src/tool/set-current-session-title.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Sets the title of the current session.

Use this tool to give the session a descriptive title that reflects its purpose or content, or when the user asks you to set the session's title. The title will be immediately visible in the UI.

Usage notes:
- The title must be between 1 and 255 characters
- Choose a concise, descriptive title that helps identify the session's purpose
- This is useful in custom slash commands to automatically title sessions based on their context
161 changes: 161 additions & 0 deletions packages/opencode/test/tool/set-current-session-title.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, test } from "bun:test"
import { SetCurrentSessionTitleTool } from "../../src/tool/set-current-session-title"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { tmpdir } from "../fixture/fixture"

describe("tool.set_current_session_title", () => {
test("updates session title", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await SetCurrentSessionTitleTool.init()
const ctx = {
sessionID: session.id,
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}

const result = await tool.execute({ title: "My Test Session" }, ctx)

expect(result.title).toBe("My Test Session")
expect(result.output).toContain("My Test Session")

const updated = await Session.get(session.id)
expect(updated.title).toBe("My Test Session")
},
})
})

test("rejects empty title", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await SetCurrentSessionTitleTool.init()
const ctx = {
sessionID: session.id,
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}

await expect(tool.execute({ title: "" }, ctx)).rejects.toThrow()
},
})
})

test("rejects title exceeding 255 characters", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await SetCurrentSessionTitleTool.init()
const ctx = {
sessionID: session.id,
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}

const longTitle = "a".repeat(256)
await expect(tool.execute({ title: longTitle }, ctx)).rejects.toThrow()
},
})
})

test("accepts title with exactly 255 characters", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await SetCurrentSessionTitleTool.init()
const ctx = {
sessionID: session.id,
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}

const maxTitle = "a".repeat(255)
const result = await tool.execute({ title: maxTitle }, ctx)

expect(result.title).toBe(maxTitle)
const updated = await Session.get(session.id)
expect(updated.title).toBe(maxTitle)
},
})
})

test("accepts single character title", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await SetCurrentSessionTitleTool.init()
const ctx = {
sessionID: session.id,
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async () => {},
}

const result = await tool.execute({ title: "X" }, ctx)

expect(result.title).toBe("X")
const updated = await Session.get(session.id)
expect(updated.title).toBe("X")
},
})
})

test("asks for permission", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await SetCurrentSessionTitleTool.init()
const requests: Array<{ permission: string }> = []
const ctx = {
sessionID: session.id,
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
metadata: () => {},
ask: async (req: { permission: string }) => {
requests.push(req)
},
}

await tool.execute({ title: "Test Title" }, ctx)

expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("set_current_session_title")
},
})
})
})