mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-01 16:03:19 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc92ae0c2 | |||
| 85ea15e56d |
@@ -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
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,10 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../s
|
||||
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
|
||||
const prompts = new Set<(input: RunPrompt) => void>()
|
||||
const closes = new Set<() => void>()
|
||||
let ready!: () => void
|
||||
const promptReady = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
const events = input.events ?? []
|
||||
const commits = input.commits ?? []
|
||||
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
|
||||
@@ -14,6 +18,7 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
},
|
||||
onPrompt(fn) {
|
||||
prompts.add(fn)
|
||||
ready()
|
||||
return () => prompts.delete(fn)
|
||||
},
|
||||
onClose(fn) {
|
||||
@@ -50,9 +55,12 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
events,
|
||||
commits,
|
||||
calls,
|
||||
promptReady,
|
||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||
if (prompts.size === 0) return false
|
||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
||||
for (const fn of [...prompts]) fn(prompt)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ describe("run interactive runtime", () => {
|
||||
const api = ui.api
|
||||
const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
|
||||
const catalogLoaded = defer<void>()
|
||||
const defaultModelReloaded = defer<void>()
|
||||
const modelShown = defer<void>()
|
||||
const turnStarted = defer<void>()
|
||||
const model = catalogModel({
|
||||
id: "resolved",
|
||||
providerID: "test",
|
||||
@@ -69,7 +72,17 @@ describe("run interactive runtime", () => {
|
||||
providers: [catalogProvider("test", "Test Provider")],
|
||||
models: [model],
|
||||
})
|
||||
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise)
|
||||
let defaultModelCalls = 0
|
||||
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => {
|
||||
defaultModelCalls++
|
||||
if (defaultModelCalls === 2) defaultModelReloaded.resolve()
|
||||
return selected.promise
|
||||
})
|
||||
const emit = api.event.bind(api)
|
||||
api.event = (event) => {
|
||||
emit(event)
|
||||
if (event.type === "model") modelShown.resolve()
|
||||
}
|
||||
|
||||
const task = runInteractiveDeferredMode(
|
||||
{
|
||||
@@ -110,6 +123,7 @@ describe("run interactive runtime", () => {
|
||||
runPromptTurn: async (input) => {
|
||||
turnAgent = input.agent
|
||||
turnModel = input.model
|
||||
turnStarted.resolve()
|
||||
api.close()
|
||||
},
|
||||
queuePromptTurn: async () => {},
|
||||
@@ -133,8 +147,8 @@ describe("run interactive runtime", () => {
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
data: model,
|
||||
})
|
||||
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
|
||||
while (!events.some((event) => event.type === "model")) await Bun.sleep(0)
|
||||
await defaultModelReloaded.promise
|
||||
await modelShown.promise
|
||||
expect(events).toContainEqual({
|
||||
type: "model",
|
||||
model: "Resolved Model · Test Provider",
|
||||
@@ -142,8 +156,9 @@ describe("run interactive runtime", () => {
|
||||
})
|
||||
expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
|
||||
lifecycle.onAgentSelect?.("review")
|
||||
ui.submit("hello")
|
||||
while (!turnModel) await Bun.sleep(0)
|
||||
await ui.promptReady
|
||||
expect(ui.submit("hello")).toBe(true)
|
||||
await turnStarted.promise
|
||||
expect(turnAgent).toBe("review")
|
||||
expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
|
||||
await task
|
||||
|
||||
Reference in New Issue
Block a user