tool-session-rename.test.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  4. import { Agent } from "@opencode-ai/core/agent"
  5. import { Image } from "@opencode-ai/core/image"
  6. import { Permission } from "@opencode-ai/core/permission"
  7. import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
  8. import { Session } from "@opencode-ai/core/session"
  9. import { Tool } from "@opencode-ai/core/tool"
  10. import { SessionRenameTool } from "@opencode-ai/core/tool/plugin/session-rename"
  11. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  12. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  13. import { imagePassthrough } from "./lib/image"
  14. import { testEffect } from "./lib/effect"
  15. import { executeTool, registerToolPlugin, toolDefinitions, toolIdentity } from "./lib/tool"
  16. const currentSessionID = Session.ID.make("ses_session_rename_current")
  17. const otherSessionID = Session.ID.make("ses_session_rename_other")
  18. const renamed: Array<{ sessionID: Session.ID; title: string }> = []
  19. const assertions: Permission.AssertInput[] = []
  20. let deny = false
  21. const runtime = Layer.succeed(
  22. PluginRuntime.Service,
  23. PluginRuntime.Service.of({
  24. session: {
  25. get: () => Effect.die("unused"),
  26. create: () => Effect.die("unused"),
  27. messages: () => Effect.die("unused"),
  28. prompt: () => Effect.die("unused"),
  29. generate: () => Effect.die("unused"),
  30. command: () => Effect.die("unused"),
  31. resume: () => Effect.die("unused"),
  32. interrupt: () => Effect.die("unused"),
  33. synthetic: () => Effect.die("unused"),
  34. rename: (input) => Effect.sync(() => void renamed.push(input)),
  35. wait: () => Effect.die("unused"),
  36. },
  37. job: {
  38. start: () => Effect.die("unused"),
  39. wait: () => Effect.die("unused"),
  40. block: () => Effect.die("unused"),
  41. background: () => Effect.die("unused"),
  42. cancel: () => Effect.die("unused"),
  43. },
  44. location: {
  45. agent: {
  46. list: () => Effect.die("unused"),
  47. },
  48. },
  49. }),
  50. )
  51. const permission = Layer.succeed(
  52. Permission.Service,
  53. Permission.Service.of({
  54. assert: (input) =>
  55. Effect.sync(() => assertions.push(input)).pipe(
  56. Effect.andThen(
  57. deny
  58. ? Effect.fail(
  59. new Permission.BlockedError({
  60. rules: [],
  61. permission: input.action,
  62. resources: input.resources,
  63. }),
  64. )
  65. : Effect.void,
  66. ),
  67. ),
  68. ask: () => Effect.die("unused"),
  69. reply: () => Effect.die("unused"),
  70. get: () => Effect.die("unused"),
  71. forSession: () => Effect.die("unused"),
  72. list: () => Effect.die("unused"),
  73. }),
  74. )
  75. const sessionRenameToolNode = makeLocationNode({
  76. name: "test/session-rename-tool-plugin",
  77. layer: Layer.effectDiscard(registerToolPlugin(SessionRenameTool.Plugin)),
  78. deps: [Tool.node, Permission.node, PluginRuntime.node],
  79. })
  80. const it = testEffect(
  81. AppNodeBuilder.build(LayerNode.group([Tool.node, sessionRenameToolNode]), [
  82. [Permission.node, permission],
  83. [PluginRuntime.node, runtime],
  84. [Image.node, imagePassthrough],
  85. ]),
  86. )
  87. describe("SessionRenameTool", () => {
  88. it.effect("registers directly and confines rename to the invocation session", () =>
  89. Effect.gen(function* () {
  90. renamed.length = 0
  91. assertions.length = 0
  92. deny = false
  93. const registry = yield* Tool.Service
  94. const snapshot = yield* registry.snapshot()
  95. expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["sessionRename", "execute"])
  96. expect(snapshot.codeModeCatalog?.some((tool) => tool.path === "sessionRename")).toBe(false)
  97. expect(
  98. yield* executeTool(registry, {
  99. sessionID: currentSessionID,
  100. ...toolIdentity,
  101. call: {
  102. type: "tool-call",
  103. id: "call-session-rename",
  104. name: "sessionRename",
  105. input: { title: "\n Focused\n title\t", sessionID: otherSessionID },
  106. },
  107. }),
  108. ).toEqual({
  109. status: "completed",
  110. output: { title: "Focused title" },
  111. content: [{ type: "text", text: "Renamed the current session to: Focused title" }],
  112. metadata: { title: "Focused title" },
  113. })
  114. expect(renamed).toEqual([{ sessionID: currentSessionID, title: "Focused title" }])
  115. expect(assertions).toMatchObject([
  116. {
  117. action: "sessionRename",
  118. resources: ["*"],
  119. sessionID: currentSessionID,
  120. agent: Agent.ID.make("build"),
  121. source: { type: "tool", messageID: toolIdentity.messageID, callID: "call-session-rename" },
  122. },
  123. ])
  124. }),
  125. )
  126. it.effect("filters catalog denial and enforces leaf permission", () =>
  127. Effect.gen(function* () {
  128. renamed.length = 0
  129. deny = true
  130. const registry = yield* Tool.Service
  131. expect(
  132. (yield* toolDefinitions(registry, [{ action: "sessionRename", resource: "*", effect: "deny" }])).map(
  133. (tool) => tool.name,
  134. ),
  135. ).toEqual(["execute"])
  136. expect(
  137. yield* executeTool(registry, {
  138. sessionID: currentSessionID,
  139. ...toolIdentity,
  140. call: {
  141. type: "tool-call",
  142. id: "call-session-rename-denied",
  143. name: "sessionRename",
  144. input: { title: "Denied title" },
  145. },
  146. }),
  147. ).toEqual({
  148. status: "error",
  149. error: { type: "permission.rejected", message: "Permission denied: sessionRename" },
  150. })
  151. expect(renamed).toEqual([])
  152. deny = false
  153. }),
  154. )
  155. })