From 7bc92ae0c2946efd448a995ba6552f846955cbb4 Mon Sep 17 00:00:00 2001 From: Ryan Vogel <89211796+R44VC0RP@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:40:43 -0400 Subject: [PATCH] feat(core): add session rename tool --- packages/core/src/plugin/internal.ts | 2 + .../core/src/tool/plugin/session-rename.ts | 77 ++++++++ packages/core/test/location-layer.test.ts | 7 +- .../core/test/tool-session-rename.test.ts | 164 ++++++++++++++++++ 4 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/tool/plugin/session-rename.ts create mode 100644 packages/core/test/tool-session-rename.test.ts diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 3327bef396..952451c8e0 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -43,6 +43,7 @@ import { QuestionTool } from "../tool/plugin/question" import { ReadToolFileSystem } from "../tool/read-filesystem" import { ReadTool } from "../tool/plugin/read" import { ShellTool } from "../tool/plugin/shell" +import { SessionRenameTool } from "../tool/plugin/session-rename" import { SkillTool } from "../tool/plugin/skill" import { SubagentTool } from "../tool/plugin/subagent" import { Tool } from "../tool" @@ -149,6 +150,7 @@ const pre = [ QuestionTool.Plugin, ReadTool.Plugin, ShellTool.Plugin, + SessionRenameTool.Plugin, SkillTool.Plugin, SubagentTool.Plugin, WebFetchTool.Plugin, diff --git a/packages/core/src/tool/plugin/session-rename.ts b/packages/core/src/tool/plugin/session-rename.ts new file mode 100644 index 0000000000..d82f3c20f1 --- /dev/null +++ b/packages/core/src/tool/plugin/session-rename.ts @@ -0,0 +1,77 @@ +export * as SessionRenameTool from "./session-rename" + +import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin" +import { ToolFailure } from "@opencode-ai/ai" +import { Effect, Schema } from "effect" +import { Permission } from "../../permission" +import { PluginRuntime } from "../../plugin/runtime" + +export const name = "sessionRename" + +export const description = + "Rename the current session. Use a short, specific title that summarizes the work being done. This tool can only rename the session you are currently working in." + +export const Input = Schema.Struct({ + title: Schema.String.check( + Schema.isMinLength(1, { message: "Title must not be empty" }), + Schema.isMaxLength(100, { message: "Title must be 100 characters or fewer" }), + ).annotate({ description: "New title for the current session" }), +}) + +export const Output = Schema.Struct({ + title: Schema.String, +}) + +export const Plugin = { + id: "opencode.tool.session-rename", + effect: Effect.fn("SessionRenameTool.Plugin")(function* (ctx: PluginContext) { + const runtime = yield* PluginRuntime.Service + const permission = yield* Permission.Service + + yield* ctx.tool + .transform((draft) => + draft.add( + ({ + name, + options: { codemode: false }, + description, + input: Input, + output: Output, + execute: (input, context) => + Effect.gen(function* () { + const title = input.title.replace(/\s+/g, " ").trim() + if (!title) return yield* new ToolFailure({ message: "Session title must not be empty" }) + yield* permission + .assert({ + action: name, + resources: ["*"], + save: ["*"], + metadata: { title }, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.messageID, callID: context.callID }, + }) + .pipe( + Effect.mapError( + (error) => new ToolFailure({ message: "Permission denied: sessionRename", error }), + ), + ) + yield* runtime.session + .rename({ sessionID: context.sessionID, title }) + .pipe( + Effect.mapError( + (error) => new ToolFailure({ message: "Unable to rename the current session", error }), + ), + ) + return { + output: { title }, + content: `Renamed the current session to: ${title}`, + metadata: { title }, + } + }), + }), + ), + ) + .pipe(Effect.orDie) + }), +} diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index bf897a6b1a..c7ea33c49f 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -552,8 +552,9 @@ describe("LocationServiceMap", () => { "grep", "question", "read", - "shell", - "skill", + "shell", + "sessionRename", + "skill", "subagent", "webfetch", "websearch", @@ -585,6 +586,7 @@ describe("LocationServiceMap", () => { "patch", "question", "read", + "sessionRename", "shell", "skill", "subagent", @@ -604,6 +606,7 @@ describe("LocationServiceMap", () => { "patch", "question", "read", + "sessionRename", "shell", "skill", "subagent", diff --git a/packages/core/test/tool-session-rename.test.ts b/packages/core/test/tool-session-rename.test.ts new file mode 100644 index 0000000000..2f67f0ecd2 --- /dev/null +++ b/packages/core/test/tool-session-rename.test.ts @@ -0,0 +1,164 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Agent } from "@opencode-ai/core/agent" +import { Image } from "@opencode-ai/core/image" +import { Permission } from "@opencode-ai/core/permission" +import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" +import { Session } from "@opencode-ai/core/session" +import { Tool } from "@opencode-ai/core/tool" +import { SessionRenameTool } from "@opencode-ai/core/tool/plugin/session-rename" +import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { imagePassthrough } from "./lib/image" +import { testEffect } from "./lib/effect" +import { executeTool, registerToolPlugin, toolDefinitions, toolIdentity } from "./lib/tool" + +const currentSessionID = Session.ID.make("ses_session_rename_current") +const otherSessionID = Session.ID.make("ses_session_rename_other") +const renamed: Array<{ sessionID: Session.ID; title: string }> = [] +const assertions: Permission.AssertInput[] = [] +let deny = false + +const runtime = Layer.succeed( + PluginRuntime.Service, + PluginRuntime.Service.of({ + session: { + get: () => Effect.die("unused"), + create: () => Effect.die("unused"), + messages: () => Effect.die("unused"), + prompt: () => Effect.die("unused"), + generate: () => Effect.die("unused"), + command: () => Effect.die("unused"), + resume: () => Effect.die("unused"), + interrupt: () => Effect.die("unused"), + synthetic: () => Effect.die("unused"), + rename: (input) => Effect.sync(() => void renamed.push(input)), + wait: () => Effect.die("unused"), + }, + job: { + start: () => Effect.die("unused"), + wait: () => Effect.die("unused"), + block: () => Effect.die("unused"), + background: () => Effect.die("unused"), + cancel: () => Effect.die("unused"), + }, + location: { + agent: { + list: () => Effect.die("unused"), + }, + }, + }), +) + +const permission = Layer.succeed( + Permission.Service, + Permission.Service.of({ + assert: (input) => + Effect.sync(() => assertions.push(input)).pipe( + Effect.andThen( + deny + ? Effect.fail( + new Permission.BlockedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), + ), + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), +) + +const sessionRenameToolNode = makeLocationNode({ + name: "test/session-rename-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(SessionRenameTool.Plugin)), + deps: [Tool.node, Permission.node, PluginRuntime.node], +}) + +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Tool.node, sessionRenameToolNode]), [ + [Permission.node, permission], + [PluginRuntime.node, runtime], + [Image.node, imagePassthrough], + ]), +) + +describe("SessionRenameTool", () => { + it.effect("registers directly and confines rename to the invocation session", () => + Effect.gen(function* () { + renamed.length = 0 + assertions.length = 0 + deny = false + const registry = yield* Tool.Service + const snapshot = yield* registry.snapshot() + + expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["sessionRename", "execute"]) + expect(snapshot.codeModeCatalog?.some((tool) => tool.path === "sessionRename")).toBe(false) + expect( + yield* executeTool(registry, { + sessionID: currentSessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-session-rename", + name: "sessionRename", + input: { title: "\n Focused\n title\t", sessionID: otherSessionID }, + }, + }), + ).toEqual({ + status: "completed", + output: { title: "Focused title" }, + content: [{ type: "text", text: "Renamed the current session to: Focused title" }], + metadata: { title: "Focused title" }, + }) + expect(renamed).toEqual([{ sessionID: currentSessionID, title: "Focused title" }]) + expect(assertions).toMatchObject([ + { + action: "sessionRename", + resources: ["*"], + sessionID: currentSessionID, + agent: Agent.ID.make("build"), + source: { type: "tool", messageID: toolIdentity.messageID, callID: "call-session-rename" }, + }, + ]) + }), + ) + + it.effect("filters catalog denial and enforces leaf permission", () => + Effect.gen(function* () { + renamed.length = 0 + deny = true + const registry = yield* Tool.Service + + expect( + (yield* toolDefinitions(registry, [{ action: "sessionRename", resource: "*", effect: "deny" }])).map( + (tool) => tool.name, + ), + ).toEqual(["execute"]) + expect( + yield* executeTool(registry, { + sessionID: currentSessionID, + ...toolIdentity, + call: { + type: "tool-call", + id: "call-session-rename-denied", + name: "sessionRename", + input: { title: "Denied title" }, + }, + }), + ).toEqual({ + status: "error", + error: { type: "permission.rejected", message: "Permission denied: sessionRename" }, + }) + expect(renamed).toEqual([]) + deny = false + }), + ) +})