diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index de62788200b..781e4bd6b5b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1806,10 +1806,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the .find((line) => line.length > 0) if (!cleaned) return - const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - draft.title = title - }, - { touch: false }, - ) + const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned + // Only set if title is still default (wasn't changed by tool during LLM call) + if (Session.isDefaultTitle(draft.title)) draft.title = title + }, { touch: false }) } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index faa5f72bcce..008cb26285a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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 { @@ -106,6 +107,7 @@ export namespace ToolRegistry { WebFetchTool, TodoWriteTool, TodoReadTool, + SetCurrentSessionTitleTool, WebSearchTool, CodeSearchTool, SkillTool, diff --git a/packages/opencode/src/tool/set-current-session-title.ts b/packages/opencode/src/tool/set-current-session-title.ts new file mode 100644 index 00000000000..f555853c2e6 --- /dev/null +++ b/packages/opencode/src/tool/set-current-session-title.ts @@ -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, + }, + } + }, +}) diff --git a/packages/opencode/src/tool/set-current-session-title.txt b/packages/opencode/src/tool/set-current-session-title.txt new file mode 100644 index 00000000000..687ee0fc7d3 --- /dev/null +++ b/packages/opencode/src/tool/set-current-session-title.txt @@ -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 diff --git a/packages/opencode/test/tool/set-current-session-title.test.ts b/packages/opencode/test/tool/set-current-session-title.test.ts new file mode 100644 index 00000000000..ecd38051f26 --- /dev/null +++ b/packages/opencode/test/tool/set-current-session-title.test.ts @@ -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") + }, + }) + }) +})