mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-01 16:03:19 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc92ae0c2 |
@@ -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)
|
||||
}),
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user