Selaa lähdekoodia

feat(core): add session rename tool

Ryan Vogel 1 viikko sitten
vanhempi
sitoutus
7bc92ae0c2

+ 2 - 0
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,

+ 77 - 0
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)
+  }),
+}

+ 5 - 2
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",

+ 164 - 0
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
+    }),
+  )
+})