mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
fix(core): notify reloads immediately and debounce at bursty sources (#46843)
This commit is contained in:
committed by
GitHub
Unverified
parent
bbc44310dc
commit
b45882ec87
@@ -103,7 +103,11 @@ export const Plugin = define({
|
||||
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
// Editor saves arrive as bursts of watcher events; settle before rescanning once. Subscribe
|
||||
// before debouncing so no update slips through while the debounce starts its pull.
|
||||
const updates = yield* PubSub.subscribe(changes)
|
||||
yield* Stream.fromSubscription(updates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -171,7 +171,11 @@ export const Plugin = define({
|
||||
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
// Editor saves arrive as bursts of watcher events; settle before rescanning once. Subscribe
|
||||
// before debouncing so no update slips through while the debounce starts its pull.
|
||||
const updates = yield* PubSub.subscribe(changes)
|
||||
yield* Stream.fromSubscription(updates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
+23
-50
@@ -1,6 +1,6 @@
|
||||
export * as State from "./state.js"
|
||||
|
||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Fiber, Scope } from "effect"
|
||||
|
||||
/**
|
||||
* A synchronous, replayable edit to the current domain state.
|
||||
@@ -23,7 +23,7 @@ export type Transform<DraftApi> = (
|
||||
transform: TransformCallback<DraftApi>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
/** Invalidates the current value after captured inputs change and coalesces notifications. */
|
||||
/** Invalidates the current value after captured inputs change and notifies like a registration would. */
|
||||
export type Reload = () => Effect.Effect<void>
|
||||
|
||||
export interface Transformable<DraftApi> {
|
||||
@@ -40,17 +40,12 @@ type Batch = {
|
||||
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
const reloadDebounce = 500
|
||||
|
||||
/** Coalesces notifications until the effect completes. Reads inside stay fresh; nothing is rolled back. */
|
||||
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return run(effect, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the effect as shutdown: States changed inside it close permanently and never notify again,
|
||||
* including debounced reloads already waiting.
|
||||
*/
|
||||
/** Runs the effect as shutdown: States changed inside it close permanently and never notify again. */
|
||||
export function shutdown<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return run(effect, true)
|
||||
}
|
||||
@@ -100,9 +95,9 @@ export interface Options<State, DraftApi> {
|
||||
/** Wraps mutable state in a domain-specific draft API. */
|
||||
readonly draft: MakeDraft<State, DraftApi>
|
||||
/**
|
||||
* Observes the freshly rebuilt value outside the read path. Batched changes
|
||||
* notify at batch completion; reloads debounce notifications. Resource
|
||||
* reconciliation owns its execution scope and coordination.
|
||||
* Observes the freshly rebuilt value outside the read path. Every registration, disposal, or
|
||||
* reload notifies once it is applied; a batch coalesces them into one notification at its end.
|
||||
* Resource reconciliation owns its execution scope and coordination.
|
||||
*/
|
||||
readonly notify?: (state: State) => Effect.Effect<void>
|
||||
}
|
||||
@@ -119,9 +114,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
let state = options.initial()
|
||||
const transforms: { run: TransformCallback<DraftApi> }[] = []
|
||||
let dirty = false
|
||||
let requestedAt = 0
|
||||
let closed = false
|
||||
let pending: Deferred.Deferred<void> | undefined
|
||||
|
||||
const get = () => {
|
||||
if (closed || !dirty) return state
|
||||
@@ -141,42 +134,22 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
if (options.notify) yield* options.notify(value)
|
||||
}).pipe(Effect.withSpan("State.notify"))
|
||||
|
||||
const changed = (debounce: boolean) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
dirty = true
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
if (batch.shutdown) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
batch.notifications.add(notify)
|
||||
const changed = Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
dirty = true
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
if (batch.shutdown) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
if (!debounce) {
|
||||
yield* restore(notify)
|
||||
return
|
||||
}
|
||||
|
||||
const clock = yield* Clock.Clock
|
||||
requestedAt = clock.currentTimeMillisUnsafe()
|
||||
if (pending) return yield* restore(Deferred.await(pending))
|
||||
const done = Deferred.makeUnsafe<void>()
|
||||
pending = done
|
||||
yield* Effect.gen(function* () {
|
||||
do {
|
||||
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
|
||||
if (remaining > 0) yield* Effect.sleep(remaining)
|
||||
} while (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce)
|
||||
// Observers can request and await another reload without joining their own notification.
|
||||
pending = undefined
|
||||
yield* notify.pipe(Deferred.into(done))
|
||||
}).pipe(Effect.forkDetach)
|
||||
yield* restore(Deferred.await(done))
|
||||
}),
|
||||
)
|
||||
batch.notifications.add(notify)
|
||||
return
|
||||
}
|
||||
yield* restore(notify)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
get,
|
||||
@@ -191,16 +164,16 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
||||
const index = transforms.indexOf(transform)
|
||||
if (index < 0) return Effect.void
|
||||
transforms.splice(index, 1)
|
||||
return changed(false)
|
||||
return changed
|
||||
}),
|
||||
)
|
||||
transforms.push(transform)
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
yield* changed(false)
|
||||
yield* changed
|
||||
return { dispose }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
reload: () => changed(true),
|
||||
reload: () => changed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, Fiber, type JsonSchema, Layer, PubSub, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
|
||||
@@ -123,9 +123,17 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
// Servers announce tools in bursts and each read loads the whole catalog, so settle and refresh
|
||||
// once. The bus subscription stays eager; only the already-open sliding subscription is debounced.
|
||||
const changes = yield* PubSub.sliding<void>(1)
|
||||
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
// Each read loads the whole catalog, so queued notifications need only one refresh.
|
||||
Stream.runForEachArray(() => reconcile),
|
||||
Stream.runForEach(() => PubSub.publish(changes, undefined)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const updates = yield* PubSub.subscribe(changes)
|
||||
yield* Stream.fromSubscription(updates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -102,9 +101,7 @@ describe("Agent", () => {
|
||||
)
|
||||
description = "New description"
|
||||
hidden = false
|
||||
const reload = yield* agent.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
yield* agent.reload()
|
||||
|
||||
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
@@ -342,9 +341,7 @@ describe("Catalog", () => {
|
||||
expect((yield* catalog.model.default())?.id).toBe(old)
|
||||
|
||||
configured = false
|
||||
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
yield* catalog.reload()
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -12,11 +12,10 @@ const tick = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
/**
|
||||
* Drives every pending TestClock timer to completion: stream debounces and
|
||||
* State's 500ms reload debounce register their sleeps from separate fiber hops
|
||||
* that a single adjust can miss, so the loop alternates real-macrotask settles
|
||||
* with adjusts until the condition holds. Extra adjusts are harmless when
|
||||
* nothing is pending.
|
||||
* Drives every pending TestClock timer to completion: stream debounces register
|
||||
* their sleeps from separate fiber hops that a single adjust can miss, so the
|
||||
* loop alternates real-macrotask settles with adjusts until the condition
|
||||
* holds. Extra adjusts are harmless when nothing is pending.
|
||||
*/
|
||||
export const advance = Effect.fnUntraced(function* (condition: () => boolean) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
|
||||
@@ -56,6 +56,7 @@ import { TestClock } from "effect/testing"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { advance, drain } from "./lib/clock"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
@@ -1635,10 +1636,7 @@ shutdownIt.effect("discards in-flight and queued MCP notifications after its lay
|
||||
const release = yield* Deferred.make<void>()
|
||||
const root = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(State.shutdown(Scope.close(root, Exit.void))),
|
||||
Effect.andThen(TestClock.adjust("500 millis")),
|
||||
),
|
||||
Deferred.succeed(release, undefined).pipe(Effect.andThen(State.shutdown(Scope.close(root, Exit.void)))),
|
||||
)
|
||||
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
|
||||
const service = Context.get(context, Mcp.Service)
|
||||
@@ -1669,11 +1667,9 @@ shutdownIt.effect("discards in-flight and queued MCP notifications after its lay
|
||||
source.url = "https://example.com/first"
|
||||
source.added = true
|
||||
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered)
|
||||
source.url = "https://example.com/second"
|
||||
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
|
||||
const shutdown = yield* State.shutdown(Scope.close(root, Exit.void)).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
@@ -1899,9 +1895,11 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_1", "execute"])
|
||||
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* TestClock.adjust("250 millis")
|
||||
yield* advance(() => reads >= 2)
|
||||
expect(reads).toBe(2)
|
||||
yield* Effect.forEach(Array.from({ length: 20 }), () => bus.publish(McpEvent.ToolsChanged, { server: "demo" }))
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* advance(() => reads >= 3)
|
||||
yield* drain
|
||||
expect(reads).toBe(3)
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_3", "execute"])
|
||||
}).pipe(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("State", () => {
|
||||
@@ -67,9 +66,7 @@ describe("State", () => {
|
||||
expect(state.get().values).toEqual(["first"])
|
||||
|
||||
value = "second"
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
yield* state.reload()
|
||||
expect(state.get().values).toEqual(["second"])
|
||||
}),
|
||||
)
|
||||
@@ -131,7 +128,7 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
|
||||
it.effect("discards reloads and disposals after shutdown while still running cleanup", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
let disposed = 0
|
||||
@@ -148,14 +145,10 @@ describe("State", () => {
|
||||
const registration = yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
|
||||
expect(finalized).toBe(1)
|
||||
|
||||
const pending = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("250 millis")
|
||||
yield* State.shutdown(Scope.close(scope, Exit.void))
|
||||
expect(disposed).toBe(1)
|
||||
expect(finalized).toBe(1)
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(pending)
|
||||
yield* registration.dispose
|
||||
yield* state.reload()
|
||||
expect(finalized).toBe(1)
|
||||
@@ -189,7 +182,7 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("debounces reload bursts", () =>
|
||||
it.effect("notifies once per reload without waiting", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
const state = State.create({
|
||||
@@ -202,16 +195,11 @@ describe("State", () => {
|
||||
})
|
||||
finalized = 0
|
||||
|
||||
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("250 millis")
|
||||
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("499 millis")
|
||||
expect(finalized).toBe(0)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
|
||||
// Under TestClock nothing time-based can complete, so returning proves no debounce is involved.
|
||||
yield* state.reload()
|
||||
expect(finalized).toBe(1)
|
||||
yield* state.reload()
|
||||
expect(finalized).toBe(2)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -412,7 +400,7 @@ describe("State rebuild", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("invalidates on reload and reads new inputs before notification", () =>
|
||||
it.effect("invalidates on reload and reads new inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
let source = 1
|
||||
let calls = 0
|
||||
@@ -428,13 +416,9 @@ describe("State rebuild", () => {
|
||||
})
|
||||
notifications = 0
|
||||
source = 2
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* state.reload()
|
||||
expect(state.get().value).toBe(2)
|
||||
expect(calls).toBe(2)
|
||||
expect(notifications).toBe(0)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(calls).toBe(2)
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
yield* State.batch(
|
||||
@@ -622,7 +606,7 @@ describe("State notification boundaries", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows a debounced observer to await another reload", () =>
|
||||
it.effect("allows an observer to await a nested reload", () =>
|
||||
Effect.gen(function* () {
|
||||
let source = 1
|
||||
let reloadAgain = false
|
||||
@@ -642,9 +626,7 @@ describe("State notification boundaries", () => {
|
||||
yield* state.transform((draft) => (draft.value = source))
|
||||
source = 2
|
||||
reloadAgain = true
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Fiber.join(reload)
|
||||
yield* state.reload()
|
||||
expect(observed).toEqual([1, 2, 3])
|
||||
}),
|
||||
)
|
||||
@@ -680,7 +662,7 @@ describe("State notification boundaries", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps cancelled reload callers independent and shares notification results", () =>
|
||||
it.effect("surfaces a failed reload notification to its caller and recovers on the next reload", () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = false
|
||||
let notifications = 0
|
||||
@@ -696,18 +678,10 @@ describe("State notification boundaries", () => {
|
||||
yield* state.transform(() => {})
|
||||
notifications = 0
|
||||
fail = true
|
||||
const cancelled = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Fiber.interrupt(cancelled)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
const exits = yield* Fiber.awaitAll([first, second])
|
||||
fail = false
|
||||
expect(exits.every(Exit.isFailure)).toBe(true)
|
||||
expect(Exit.isFailure(yield* state.reload().pipe(Effect.exit))).toBe(true)
|
||||
expect(notifications).toBe(1)
|
||||
const recovered = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(recovered)
|
||||
fail = false
|
||||
yield* state.reload()
|
||||
expect(notifications).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { z } from "zod"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -116,9 +115,7 @@ describe("Tool", () => {
|
||||
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "original updated" })
|
||||
|
||||
text = "refreshed"
|
||||
const reload = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
yield* service.reload()
|
||||
const refreshed = yield* service.snapshot()
|
||||
expect(refreshed.definitions[0]?.description).toBe("Updated")
|
||||
expect(refreshed.codeModeCatalog?.tools).toEqual([])
|
||||
@@ -204,24 +201,18 @@ describe("Tool", () => {
|
||||
|
||||
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
|
||||
source = [tool]
|
||||
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(first)
|
||||
yield* service.reload()
|
||||
const advertised = yield* service.snapshot()
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
|
||||
tool.execute = constant("second").execute
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(second)
|
||||
yield* service.reload()
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
|
||||
source = []
|
||||
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(removed)
|
||||
yield* service.reload()
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
}),
|
||||
@@ -292,9 +283,7 @@ describe("Tool", () => {
|
||||
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "overlay" })
|
||||
|
||||
source = [...source, { ...constant("collision"), name: "echo_tool", options: { codemode: false } }]
|
||||
const collision = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(collision)
|
||||
yield* service.reload()
|
||||
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "collision" })
|
||||
|
||||
yield* registration.dispose
|
||||
@@ -302,9 +291,7 @@ describe("Tool", () => {
|
||||
yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
|
||||
|
||||
source = [{ ...constant("invalid"), name: "", options: { codemode: false } }]
|
||||
const invalid = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(invalid)
|
||||
yield* service.reload()
|
||||
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "base" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -296,10 +296,7 @@ describe("Vcs", () => {
|
||||
const release = yield* Deferred.make<void>()
|
||||
const root = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(State.shutdown(Scope.close(root, Exit.void))),
|
||||
Effect.andThen(TestClock.adjust("500 millis")),
|
||||
),
|
||||
Deferred.succeed(release, undefined).pipe(Effect.andThen(State.shutdown(Scope.close(root, Exit.void)))),
|
||||
)
|
||||
const context = yield* Layer.buildWithScope(
|
||||
LayerNode.compile(Vcs.node, { replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus)), here] }),
|
||||
@@ -343,11 +340,9 @@ describe("Vcs", () => {
|
||||
|
||||
block = true
|
||||
const first = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered)
|
||||
branch = "late"
|
||||
const second = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
expect(reads).toEqual(["initial", "initial"])
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -108,9 +107,7 @@ describe("WebSearch", () => {
|
||||
|
||||
expect((yield* websearch.default())?.id).toBe(exa.providerID)
|
||||
source.providerID = parallel.providerID
|
||||
const reload = yield* websearch.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
yield* websearch.reload()
|
||||
expect((yield* websearch.default())?.id).toBe(parallel.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user