refactor(effect): resolve built tools through the registry (#20787)

This commit is contained in:
Kit Langton
2026-04-03 10:31:00 -04:00
committed by GitHub
parent fbfa148e4e
commit 7994dce0f2
10 changed files with 224 additions and 147 deletions

View File

@@ -15,6 +15,7 @@ import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import type { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question"
import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
@@ -160,7 +161,8 @@ function makeHttp() {
AppFileSystem.defaultLayer,
status,
).pipe(Layer.provideMerge(infra))
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
const question = Question.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(question), Layer.provideMerge(deps))
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))

View File

@@ -38,6 +38,7 @@ import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider"
import { Question } from "../../src/question"
import { SessionCompaction } from "../../src/session/compaction"
import { Instruction } from "../../src/session/instruction"
import { SessionProcessor } from "../../src/session/processor"
@@ -124,7 +125,8 @@ function makeHttp() {
AppFileSystem.defaultLayer,
status,
).pipe(Layer.provideMerge(infra))
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(deps))
const question = Question.layer.pipe(Layer.provideMerge(deps))
const registry = ToolRegistry.layer.pipe(Layer.provideMerge(question), Layer.provideMerge(deps))
const trunc = Truncate.layer.pipe(Layer.provideMerge(deps))
const proc = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps))

View File

@@ -1,8 +1,12 @@
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
import { z } from "zod"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer } from "effect"
import { Tool } from "../../src/tool/tool"
import { QuestionTool } from "../../src/tool/question"
import * as QuestionModule from "../../src/question"
import { Question } from "../../src/question"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const ctx = {
sessionID: SessionID.make("ses_test-session"),
@@ -15,55 +19,69 @@ const ctx = {
ask: async () => {},
}
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
for (;;) {
const items = yield* question.list()
const item = items[0]
if (item) return item
yield* Effect.sleep("10 millis")
}
})
describe("tool.question", () => {
let askSpy: any
it.live("should successfully execute with valid question parameters", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const question = yield* Question.Service
const toolInfo = yield* QuestionTool
const tool = yield* Effect.promise(() => toolInfo.init())
const questions = [
{
question: "What is your favorite color?",
header: "Color",
options: [
{ label: "Red", description: "The color of passion" },
{ label: "Blue", description: "The color of sky" },
],
multiple: false,
},
]
beforeEach(() => {
askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => {
return []
})
})
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Red"]] })
afterEach(() => {
askSpy.mockRestore()
})
const result = yield* Fiber.join(fiber)
expect(result.title).toBe("Asked 1 question")
}),
),
)
test("should successfully execute with valid question parameters", async () => {
const tool = await QuestionTool.init()
const questions = [
{
question: "What is your favorite color?",
header: "Color",
options: [
{ label: "Red", description: "The color of passion" },
{ label: "Blue", description: "The color of sky" },
],
multiple: false,
},
]
it.live("should now pass with a header longer than 12 but less than 30 chars", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const question = yield* Question.Service
const toolInfo = yield* QuestionTool
const tool = yield* Effect.promise(() => toolInfo.init())
const questions = [
{
question: "What is your favorite animal?",
header: "This Header is Over 12",
options: [{ label: "Dog", description: "Man's best friend" }],
},
]
askSpy.mockResolvedValueOnce([["Red"]])
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
const result = await tool.execute({ questions }, ctx)
expect(askSpy).toHaveBeenCalledTimes(1)
expect(result.title).toBe("Asked 1 question")
})
test("should now pass with a header longer than 12 but less than 30 chars", async () => {
const tool = await QuestionTool.init()
const questions = [
{
question: "What is your favorite animal?",
header: "This Header is Over 12",
options: [{ label: "Dog", description: "Man's best friend" }],
},
]
askSpy.mockResolvedValueOnce([["Dog"]])
const result = await tool.execute({ questions }, ctx)
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
})
const result = yield* Fiber.join(fiber)
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
}),
),
)
// intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough
// test("should throw an Error for header exceeding 30 characters", async () => {