Преглед изворни кода

fix(core): isolate tool hook outcomes (#38571)

Kit Langton пре 3 недеља
родитељ
комит
e7ecee5df2

+ 1 - 1
packages/codemode/src/tool.ts

@@ -29,7 +29,7 @@ export type JsonSchema = {
 /** Either a validating Effect Schema or a render-only JSON Schema document. */
 export type SchemaType = Schema.Decoder<unknown> | JsonSchema
 
-/** Executable tool tool exposed through CodeMode's `tools` object. */
+/** Executable tool exposed through CodeMode's `tools` object. */
 export type Tool<R = never> = {
   readonly _tag: "CodeModeTool"
   readonly description: string

+ 10 - 14
packages/core/src/plugin/host.ts

@@ -394,19 +394,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
           })
         }
         return toolHooks.hook.after((event) => {
-          // JS plugin boundary: marshal the canonical outcome out, copy mutations back.
-          const output: Record<string, unknown> = {
+          // Decode first so plugin mutations cannot alias the canonical outcome.
+          const output = {
             tool: event.tool,
             sessionID: event.sessionID,
             agent: event.agent,
             messageID: event.messageID,
             callID: event.callID,
             input: event.input,
-            status: event.status,
-            content: event.content,
-            metadata: event.metadata,
-            outputPaths: event.outputPaths,
-            ...(event.status === "error" ? { error: event.error } : {}),
+            ...Schema.decodeUnknownSync(Tool.ExecuteAfterOutcome)(event),
           }
           return Reflect.apply(callback, undefined, [output]).pipe(
             Effect.tap(() => {
@@ -417,16 +413,16 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
                 return Effect.logWarning("ignoring execute.after tool status change", { tool: event.tool })
               return Effect.sync(() => {
                 if (event.status === "completed" && decoded.value.status === "completed") {
-                  if (output.content !== event.content) event.content = decoded.value.content
-                  if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
-                  if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
+                  event.content = decoded.value.content
+                  event.metadata = decoded.value.metadata
+                  event.outputPaths = decoded.value.outputPaths
                   return
                 }
                 if (event.status === "error" && decoded.value.status === "error") {
-                  if (output.error !== event.error) event.error = decoded.value.error
-                  if (output.content !== event.content) event.content = decoded.value.content
-                  if (output.metadata !== event.metadata) event.metadata = decoded.value.metadata
-                  if (output.outputPaths !== event.outputPaths) event.outputPaths = decoded.value.outputPaths
+                  event.error = decoded.value.error
+                  event.content = decoded.value.content
+                  event.metadata = decoded.value.metadata
+                  event.outputPaths = decoded.value.outputPaths
                 }
               })
             }),

+ 1 - 1
packages/core/src/session/runner/llm.ts

@@ -130,7 +130,7 @@ const layer = Layer.effect(
       // Durable publishes are serialized so tool fibers and step settlement never interleave
       // mid-event.
       const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
-      const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
+      const publish = (event: LLMEvent) => serialized(publisher.publish(event))
       let overflowFailure: ProviderErrorEvent | undefined
       const providerStream = llm.stream(prepared.request).pipe(
         Stream.runForEach((event) =>

+ 14 - 17
packages/core/src/session/runner/publish-llm-event.ts

@@ -1,5 +1,5 @@
-import { type LLMEvent, type ProviderMetadata, type ToolContent, type ToolResultValue } from "@opencode-ai/ai"
-import { Effect, Schema } from "effect"
+import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
+import { Effect } from "effect"
 import { EventV2 } from "../../event"
 import { ModelV2 } from "../../model"
 import { SessionEvent } from "../event"
@@ -12,7 +12,6 @@ import { Snapshot } from "../../snapshot"
 import { RelativePath } from "../../schema"
 import { SessionUsage } from "../usage"
 import { Tool } from "../../tool/tool"
-import { MAX_BYTES } from "../../tool-output-store"
 import type { ToolRegistry } from "../../tool/registry"
 
 type Input = {
@@ -28,9 +27,11 @@ const record = (value: unknown): Record<string, unknown> =>
   typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : { value }
 
 /** Derives canonical model content from a provider-hosted tool result. */
-const hostedContent = (result: ToolResultValue): readonly [ToolContent, ...ToolContent[]] => {
-  if (result.type === "content" && result.value.length > 0)
-    return result.value as unknown as readonly [ToolContent, ...ToolContent[]]
+const hostedContent = (result: ToolResultValue): Tool.NonEmptyContent => {
+  if (result.type === "content") {
+    const content = Tool.nonEmpty(result.value)
+    if (content !== undefined) return content
+  }
   return [{ type: "text", text: Tool.stringify(result.value) }]
 }
 
@@ -47,11 +48,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
       progress?: ToolRegistry.Progress
     }
   >()
-  const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) => {
-    if (!tool.progress) return {}
-    const metadata = Tool.jsonMetadata(tool.progress, MAX_BYTES)
-    return metadata === undefined ? {} : { metadata }
-  }
+  const failureSnapshot = (tool: { readonly progress?: ToolRegistry.Progress }) =>
+    tool.progress === undefined ? {} : { metadata: tool.progress }
   let assistantMessageID = input.assistantMessageID
   let stepStarted = false
   let stepFailed = false
@@ -292,7 +290,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
     return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
   }
 
-  const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
+  const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
     switch (event.type) {
       case "step-start":
         yield* startAssistant()
@@ -405,12 +403,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
         tool.settled = true
         const executed = event.providerExecuted === true || tool.providerExecuted
         const resultState = providerState(event.providerMetadata)
-        if (error !== undefined || event.result.type === "error") {
+        if (event.result.type === "error") {
           yield* events.publish(SessionEvent.Tool.Failed, {
             sessionID: input.sessionID,
             assistantMessageID: tool.assistantMessageID,
             callID: event.id,
-            error: error ?? { type: "tool.execution", message: Tool.stringify(event.result.value) },
+            error: { type: "tool.execution", message: Tool.stringify(event.result.value) },
             ...failureSnapshot(tool),
             executed,
             resultState,
@@ -471,13 +469,12 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
     const tool = tools.get(callID)
     if (!tool?.called || tool.settled)
       return yield* Effect.die(new Error(`Tool progress outside running call: ${callID}`))
-    const current = { ...update }
-    tool.progress = current
+    tool.progress = update
     yield* events.publish(SessionEvent.Tool.Progress, {
       sessionID: input.sessionID,
       assistantMessageID: tool.assistantMessageID,
       callID,
-      metadata: current,
+      metadata: update,
     })
   })
 

+ 1 - 1
packages/core/test/plugin.test.ts

@@ -365,7 +365,7 @@ describe("PluginV2", () => {
             yield* ctx.tool
               .hook("execute.after", (event) =>
                 Effect.sync(() => {
-                  if (event.status === "completed") event.content = [] as never
+                  if (event.status === "completed") (event.content as unknown as unknown[]).splice(0)
                 }),
               )
               .pipe(Effect.asVoid)

+ 13 - 3
packages/core/test/session-runner-tool-events.test.ts

@@ -118,9 +118,7 @@ test("provider-executed success derives content and retains provider result stat
 test("interrupted progress metadata remains in the terminal failure snapshot", async () => {
   const { published, publisher } = capture("anthropic", { interruptProgress: true })
   await Effect.runPromise(publisher.publish(call))
-  const exit = await Effect.runPromiseExit(
-    publisher.progress(call.id, { phase: "visible" }),
-  )
+  const exit = await Effect.runPromiseExit(publisher.progress(call.id, { phase: "visible" }))
   expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
   await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
 
@@ -129,6 +127,18 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
   })
 })
 
+test("failure snapshot retains canonical progress above the default byte limit", async () => {
+  const { published, publisher } = capture("anthropic", { interruptProgress: true })
+  await Effect.runPromise(publisher.publish(call))
+  const detail = "x".repeat(60 * 1024)
+  await Effect.runPromiseExit(publisher.progress(call.id, { detail }))
+  await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "interrupted" }))
+
+  expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
+    metadata: { detail },
+  })
+})
+
 test("failure before progress omits partial output fields", async () => {
   const { published, publisher } = capture()
   await Effect.runPromise(publisher.publish(call))