Compare commits

..

1 Commits

Author SHA1 Message Date
Ryan Vogel 7bc92ae0c2 feat(core): add session rename tool 2026-08-01 10:54:45 -04:00
9 changed files with 250 additions and 130 deletions
+2
View File
@@ -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,
@@ -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
View File
@@ -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",
@@ -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
}),
)
})
-5
View File
@@ -77,11 +77,6 @@
"bun": "./src/plugin/runtime-plugin-support.bun.ts",
"node": "./src/plugin/runtime-plugin-support.node.ts",
"default": "./src/plugin/runtime-plugin-support.node.ts"
},
"#plugin-loader": {
"bun": "./src/plugin/loader.bun.ts",
"node": "./src/plugin/loader.node.ts",
"default": "./src/plugin/loader.node.ts"
}
},
"dependencies": {
+2 -4
View File
@@ -7,7 +7,6 @@ import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
import { preparePlugin } from "#plugin-loader"
import { useConfig } from "../config"
import { useTuiLifecycle } from "../context/runtime"
import { errorMessage } from "../util/error"
@@ -216,7 +215,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages, host.paths.state).catch((error) => ({
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
@@ -440,7 +439,6 @@ async function resolvePlugin(
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageResolver,
state: string,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
@@ -453,7 +451,7 @@ async function resolvePlugin(
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(await preparePlugin(entrypoint, version, state))
const mod: { readonly default?: unknown } = await import(version)
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
}
-44
View File
@@ -1,44 +0,0 @@
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import { isCoreRuntimeModuleSpecifier, runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const runtime = new Set([
"@opentui/solid",
"@opentui/solid/components",
"@opentui/solid/jsx-runtime",
"@opentui/solid/jsx-dev-runtime",
"solid-js",
"solid-js/store",
])
export async function preparePlugin(entrypoint: string, version: string, state: string) {
const source = fileURLToPath(entrypoint)
if (!source.endsWith(".tsx") && !source.endsWith(".jsx")) return version
const result = await Bun.build({
entrypoints: [source],
target: "bun",
format: "esm",
sourcemap: "inline",
plugins: [
createSolidTransformPlugin({
moduleName: runtimeModuleIdForSpecifier("@opentui/solid"),
resolvePath(specifier) {
if (!runtime.has(specifier) && !isCoreRuntimeModuleSpecifier(specifier)) return null
return runtimeModuleIdForSpecifier(specifier)
},
}),
],
external: [...runtime, "@opentui/core", "@opentui/core/testing"].flatMap((specifier) => [
specifier,
runtimeModuleIdForSpecifier(specifier),
]),
})
if (!result.success) throw new Error(result.logs.join("\n"))
const directory = path.join(state, "tui-plugin-cache")
const output = path.join(directory, `${Bun.hash(version).toString(16)}.mjs`)
await mkdir(directory, { recursive: true })
await Bun.write(output, result.outputs[0]!)
return output
}
-3
View File
@@ -1,3 +0,0 @@
export async function preparePlugin(_entrypoint: string, version: string, _state: string) {
return version
}
@@ -1,72 +0,0 @@
import { expect, test } from "bun:test"
import { createComponent, createSignal, type JSX } from "solid-js"
import { testRender } from "@opentui/solid"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { preparePlugin } from "../src/plugin/loader.bun"
import "../src/plugin/runtime-plugin-support.bun"
import { tmpdir } from "./fixture/fixture"
test("an external TSX plugin uses the host Solid runtime", async () => {
await using tmp = await tmpdir()
const root = path.join(tmp.path, ".opencode")
const source = path.join(root, "plugins", "tui", "reactive.tsx")
const helper = path.join(root, "plugins", "tui", "signal.ts")
const localSolid = path.join(root, "node_modules", "solid-js")
const localOpenTui = path.join(root, "node_modules", "@opentui", "solid")
await Promise.all([
mkdir(path.dirname(source), { recursive: true }),
mkdir(localSolid, { recursive: true }),
mkdir(localOpenTui, { recursive: true }),
])
await Promise.all([
writeFile(
path.join(localSolid, "package.json"),
JSON.stringify({ name: "solid-js", type: "module", main: "index.js" }),
),
writeFile(
path.join(localSolid, "index.js"),
"export const createSignal = () => { throw new Error('local Solid used') }\n",
),
writeFile(
path.join(localOpenTui, "package.json"),
JSON.stringify({ name: "@opentui/solid", type: "module", main: "index.js" }),
),
writeFile(path.join(localOpenTui, "index.js"), "throw new Error('local OpenTUI used')\n"),
writeFile(helper, 'export { createSignal, onCleanup } from "solid-js"\n'),
writeFile(
source,
`
import { createSignal, onCleanup } from "./signal"
export const signal = createSignal
export default {
id: "test.reactive",
setup(context: any) {
context.ui.slot("home.footer", () => {
const [count, setCount] = createSignal(0)
const timer = setTimeout(() => setCount(1), 10)
onCleanup(() => clearTimeout(timer))
return <box><text>count:{count()}</text></box>
})
},
}
`,
),
])
const plugin = await import(await preparePlugin(new URL(`file://${source}`).href, `${source}?mtime=1`, tmp.path))
expect(plugin.signal).toBe(createSignal)
let slot: ((input: object) => JSX.Element) | undefined
await plugin.default.setup({ ui: { slot: (_name: string, render: typeof slot) => (slot = render) } })
if (!slot) throw new Error("Plugin did not register its slot")
const setup = await testRender(() => createComponent(slot!, {}), { width: 20, height: 2 })
try {
expect(await setup.waitForFrame((frame) => frame.includes("count:0"))).toContain("count:0")
expect(await setup.waitForFrame((frame) => frame.includes("count:1"))).toContain("count:1")
} finally {
setup.renderer.destroy()
}
})