Răsfoiți Sursa

refactor: break SessionPrompt/TaskTool cycle via ctx injection (#21948)

Kit Langton 6 zile în urmă
părinte
comite
d9d5a0615e

+ 9 - 3
packages/opencode/src/session/prompt.ts

@@ -46,7 +46,7 @@ import { Process } from "@/util/process"
 import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
-import { TaskTool } from "@/tool/task"
+import { TaskTool, type TaskPromptOps } from "@/tool/task"
 import { SessionRunState } from "./run-state"
 
 // @ts-ignore
@@ -356,7 +356,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
           abort: options.abortSignal!,
           messageID: input.processor.message.id,
           callID: options.toolCallId,
-          extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
+          extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
           agent: input.agent.name,
           messages: input.messages,
           metadata: (val) =>
@@ -586,7 +586,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
               sessionID,
               abort: signal,
               callID: part.callID,
-              extra: { bypassAgentCheck: true },
+              extra: { bypassAgentCheck: true, promptOps },
               messages: msgs,
               metadata(val: { title?: string; metadata?: Record<string, any> }) {
                 return Effect.runPromise(
@@ -1655,6 +1655,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
         return result
       })
 
+      const promptOps: TaskPromptOps = {
+        cancel: (sessionID) => Effect.runFork(cancel(sessionID)),
+        resolvePromptParts: (template) => Effect.runPromise(resolvePromptParts(template)),
+        prompt: (input) => Effect.runPromise(prompt(input)),
+      }
+
       return Service.of({
         cancel,
         prompt,

+ 13 - 4
packages/opencode/src/tool/task.ts

@@ -5,11 +5,17 @@ import { Session } from "../session"
 import { SessionID, MessageID } from "../session/schema"
 import { MessageV2 } from "../session/message-v2"
 import { Agent } from "../agent/agent"
-import { SessionPrompt } from "../session/prompt"
+import type { SessionPrompt } from "../session/prompt"
 import { Config } from "../config/config"
 import { Effect } from "effect"
 import { Log } from "@/util/log"
 
+export interface TaskPromptOps {
+  cancel(sessionID: SessionID): void
+  resolvePromptParts(template: string): Promise<SessionPrompt.PromptInput["parts"]>
+  prompt(input: SessionPrompt.PromptInput): Promise<MessageV2.WithParts>
+}
+
 const id = "task"
 
 const parameters = z.object({
@@ -113,10 +119,13 @@ export const TaskTool = Tool.defineEffect(
         },
       })
 
+      const ops = ctx.extra?.promptOps as TaskPromptOps
+      if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
+
       const messageID = MessageID.ascending()
 
       function cancel() {
-        SessionPrompt.cancel(nextSession.id)
+        ops.cancel(nextSession.id)
       }
 
       return yield* Effect.acquireUseRelease(
@@ -125,9 +134,9 @@ export const TaskTool = Tool.defineEffect(
         }),
         () =>
           Effect.gen(function* () {
-            const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
+            const parts = yield* Effect.promise(() => ops.resolvePromptParts(params.prompt))
             const result = yield* Effect.promise(() =>
-              SessionPrompt.prompt({
+              ops.prompt({
                 messageID,
                 sessionID: nextSession.id,
                 model: {

+ 25 - 60
packages/opencode/test/tool/task.test.ts

@@ -6,10 +6,10 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { Instance } from "../../src/project/instance"
 import { Session } from "../../src/session"
 import { MessageV2 } from "../../src/session/message-v2"
-import { SessionPrompt } from "../../src/session/prompt"
+import type { SessionPrompt } from "../../src/session/prompt"
 import { MessageID, PartID } from "../../src/session/schema"
 import { ModelID, ProviderID } from "../../src/provider/schema"
-import { TaskTool } from "../../src/tool/task"
+import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
 import { ToolRegistry } from "../../src/tool/registry"
 import { provideTmpdirInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
@@ -62,6 +62,17 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
   return { chat, assistant }
 })
 
+function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
+  return {
+    cancel() {},
+    resolvePromptParts: async (template) => [{ type: "text", text: template }],
+    prompt: async (input) => {
+      opts?.onPrompt?.(input)
+      return reply(input, opts?.text ?? "done")
+    },
+  }
+}
+
 function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
   const id = MessageID.ascending()
   return {
@@ -180,21 +191,8 @@ describe("tool.task", () => {
         const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
         const tool = yield* TaskTool
         const def = yield* Effect.promise(() => tool.init())
-        const resolve = SessionPrompt.resolvePromptParts
-        const prompt = SessionPrompt.prompt
-        let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
-
-        SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
-        SessionPrompt.prompt = async (input) => {
-          seen = input
-          return reply(input, "resumed")
-        }
-        yield* Effect.addFinalizer(() =>
-          Effect.sync(() => {
-            SessionPrompt.resolvePromptParts = resolve
-            SessionPrompt.prompt = prompt
-          }),
-        )
+        let seen: SessionPrompt.PromptInput | undefined
+        const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
 
         const result = yield* Effect.promise(() =>
           def.execute(
@@ -209,6 +207,7 @@ describe("tool.task", () => {
               messageID: assistant.id,
               agent: "build",
               abort: new AbortController().signal,
+              extra: { promptOps },
               messages: [],
               metadata() {},
               ask: async () => {},
@@ -232,20 +231,10 @@ describe("tool.task", () => {
         const { chat, assistant } = yield* seed()
         const tool = yield* TaskTool
         const def = yield* Effect.promise(() => tool.init())
-        const resolve = SessionPrompt.resolvePromptParts
-        const prompt = SessionPrompt.prompt
         const calls: unknown[] = []
+        const promptOps = stubOps()
 
-        SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
-        SessionPrompt.prompt = async (input) => reply(input, "done")
-        yield* Effect.addFinalizer(() =>
-          Effect.sync(() => {
-            SessionPrompt.resolvePromptParts = resolve
-            SessionPrompt.prompt = prompt
-          }),
-        )
-
-        const exec = (extra?: { bypassAgentCheck?: boolean }) =>
+        const exec = (extra?: Record<string, any>) =>
           Effect.promise(() =>
             def.execute(
               {
@@ -258,7 +247,7 @@ describe("tool.task", () => {
                 messageID: assistant.id,
                 agent: "build",
                 abort: new AbortController().signal,
-                extra,
+                extra: { promptOps, ...extra },
                 messages: [],
                 metadata() {},
                 ask: async (input) => {
@@ -292,21 +281,8 @@ describe("tool.task", () => {
         const { chat, assistant } = yield* seed()
         const tool = yield* TaskTool
         const def = yield* Effect.promise(() => tool.init())
-        const resolve = SessionPrompt.resolvePromptParts
-        const prompt = SessionPrompt.prompt
-        let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
-
-        SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
-        SessionPrompt.prompt = async (input) => {
-          seen = input
-          return reply(input, "created")
-        }
-        yield* Effect.addFinalizer(() =>
-          Effect.sync(() => {
-            SessionPrompt.resolvePromptParts = resolve
-            SessionPrompt.prompt = prompt
-          }),
-        )
+        let seen: SessionPrompt.PromptInput | undefined
+        const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
 
         const result = yield* Effect.promise(() =>
           def.execute(
@@ -321,6 +297,7 @@ describe("tool.task", () => {
               messageID: assistant.id,
               agent: "build",
               abort: new AbortController().signal,
+              extra: { promptOps },
               messages: [],
               metadata() {},
               ask: async () => {},
@@ -346,21 +323,8 @@ describe("tool.task", () => {
           const { chat, assistant } = yield* seed()
           const tool = yield* TaskTool
           const def = yield* Effect.promise(() => tool.init())
-          const resolve = SessionPrompt.resolvePromptParts
-          const prompt = SessionPrompt.prompt
-          let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
-
-          SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
-          SessionPrompt.prompt = async (input) => {
-            seen = input
-            return reply(input, "done")
-          }
-          yield* Effect.addFinalizer(() =>
-            Effect.sync(() => {
-              SessionPrompt.resolvePromptParts = resolve
-              SessionPrompt.prompt = prompt
-            }),
-          )
+          let seen: SessionPrompt.PromptInput | undefined
+          const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
 
           const result = yield* Effect.promise(() =>
             def.execute(
@@ -374,6 +338,7 @@ describe("tool.task", () => {
                 messageID: assistant.id,
                 agent: "build",
                 abort: new AbortController().signal,
+                extra: { promptOps },
                 messages: [],
                 metadata() {},
                 ask: async () => {},