Skip to content
3 changes: 2 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1799,7 +1799,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"
import { ApplyPatchTool } from "./apply_patch"

export namespace ToolRegistry {
Expand Down Expand Up @@ -106,6 +107,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")
},
})
})
})