fix(core): preserve admitted tool generations (#36177)
This commit is contained in:
parent
4a006b1210
commit
3785eddfa0
5 changed files with 127 additions and 51 deletions
|
|
@ -97,12 +97,7 @@ describe("ToolRegistry", () => {
|
|||
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
|
||||
|
||||
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"])
|
||||
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([
|
||||
"read",
|
||||
"edit",
|
||||
"write",
|
||||
"patch",
|
||||
])
|
||||
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write", "patch"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -355,7 +350,7 @@ describe("ToolRegistry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a call when its advertised registration was removed", () =>
|
||||
it.effect("executes the advertised registration after it is removed", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
|
|
@ -363,29 +358,23 @@ describe("ToolRegistry", () => {
|
|||
const materialized = yield* service.materialize({ model: testModel })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: echo",
|
||||
})
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects only the replaced name from a multi-tool provider turn", () =>
|
||||
it.effect("executes each registration advertised for a provider turn after replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ first: make(), second: make() })
|
||||
const materialized = yield* service.materialize({ model: testModel })
|
||||
yield* service.register({ first: make() })
|
||||
|
||||
expect((yield* materialized.settle(call("first"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: first",
|
||||
})
|
||||
expect((yield* materialized.settle(call("first"))).result).toEqual({ type: "text", value: "first" })
|
||||
expect((yield* materialized.settle(call("second"))).result).toEqual({ type: "text", value: "second" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats revealing a previous overlay as stale", () =>
|
||||
it.effect("executes an advertised overlay after the previous registration is revealed", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
yield* service.register({ echo: make() })
|
||||
|
|
@ -394,10 +383,54 @@ describe("ToolRegistry", () => {
|
|||
const materialized = yield* service.materialize({ model: testModel })
|
||||
yield* Scope.close(overlay, Exit.void)
|
||||
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({
|
||||
type: "error",
|
||||
value: "Stale tool call: echo",
|
||||
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("executes deferred registrations from the advertised generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ToolRegistry.Service
|
||||
const executed: string[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* service
|
||||
.register(
|
||||
{
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`old:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
},
|
||||
{ deferred: true },
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize({ model: testModel })
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register(
|
||||
{
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ text })),
|
||||
}),
|
||||
},
|
||||
{ deferred: true },
|
||||
)
|
||||
|
||||
const settlement = yield* materialized.settle({
|
||||
...call("execute"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-execute",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.echo({ text: "admitted" })' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(settlement.result).toMatchObject({ type: "text" })
|
||||
expect(executed).toEqual(["old:admitted"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
|
@ -788,6 +788,73 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("executes parallel tool calls against the generation admitted before a registry reload", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
const generation: string[] = []
|
||||
yield* registry
|
||||
.register({
|
||||
reloaded: Tool.make({
|
||||
description: "Record the admitted generation",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ generation: Schema.String }),
|
||||
execute: () => Effect.sync(() => generation.push("admitted")).pipe(Effect.as({ generation: "admitted" })),
|
||||
}),
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
yield* admit(session, "Use the reloaded tool")
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-reloaded-1", name: "reloaded", input: {} }),
|
||||
LLMEvent.toolCall({ id: "call-reloaded-2", name: "reloaded", input: {} }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* registry.register({
|
||||
reloaded: Tool.make({
|
||||
description: "Record the replacement generation",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ generation: Schema.String }),
|
||||
execute: () =>
|
||||
Effect.sync(() => generation.push("replacement")).pipe(Effect.as({ generation: "replacement" })),
|
||||
}),
|
||||
})
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(generation).toEqual(["admitted", "admitted"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Use the reloaded tool" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-reloaded-1",
|
||||
state: { status: "completed", structured: { generation: "admitted" } },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-reloaded-2",
|
||||
state: { status: "completed", structured: { generation: "admitted" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts a real runner turn after default prompt recording", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
|
@ -2659,7 +2726,7 @@ describe("SessionRunnerLLM", () => {
|
|||
id: "call-interrupted",
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "tool.stale", message: "Tool execution interrupted: echo" },
|
||||
error: { type: "aborted", message: "Tool execution interrupted: echo" },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue