mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 07:19:49 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37f67daf83 | |||
| 642772e2a5 | |||
| 595e4c8c96 | |||
| 20929b3081 |
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { FileSystem } from "../filesystem.js"
|
||||
@@ -22,38 +22,64 @@ export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
|
||||
|
||||
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
|
||||
|
||||
export const ripgrepLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const files: string[] = []
|
||||
const directories = new Set<string>()
|
||||
const clock = yield* Clock.Clock
|
||||
const home = Protected.isHome(location.directory)
|
||||
yield* ripgrep
|
||||
.find({
|
||||
let index = { files: [] as string[], directories: new Set<string>() }
|
||||
let initialized = false
|
||||
let settledAt = Number.NEGATIVE_INFINITY
|
||||
let refreshing = false
|
||||
const scan = Effect.gen(function* () {
|
||||
const next = { files: [] as string[], directories: new Set<string>() }
|
||||
if (!initialized) index = next
|
||||
yield* ripgrep.find({
|
||||
cwd: location.directory,
|
||||
pattern: "*",
|
||||
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||
onEntry: (entry) =>
|
||||
Effect.sync(() => {
|
||||
files.push(entry.path)
|
||||
next.files.push(entry.path)
|
||||
const parts = entry.path.split("/")
|
||||
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
||||
parts
|
||||
.slice(0, -1)
|
||||
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
||||
index = next
|
||||
initialized = true
|
||||
}).pipe(
|
||||
Effect.orDie,
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
settledAt = clock.currentTimeMillisUnsafe()
|
||||
refreshing = false
|
||||
}),
|
||||
),
|
||||
)
|
||||
const refresh = Effect.sync(() => {
|
||||
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
|
||||
refreshing = true
|
||||
return scan
|
||||
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
|
||||
yield* refresh
|
||||
return Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* refresh
|
||||
const items =
|
||||
input.type === "file"
|
||||
? files
|
||||
? index.files
|
||||
: input.type === "directory"
|
||||
? Array.from(directories)
|
||||
: [...files, ...directories]
|
||||
? Array.from(index.directories)
|
||||
: [...index.files, ...index.directories]
|
||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
|
||||
@@ -55,7 +55,7 @@ export const Plugin = {
|
||||
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
|
||||
websearch.default().pipe(
|
||||
Effect.flatMap((provider) => {
|
||||
if (!provider || provider === WebSearch.AUTO) return ctx.websearch.query(input)
|
||||
if (!provider) return ctx.websearch.query(input)
|
||||
return context
|
||||
.progress({ provider: provider.id })
|
||||
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id })))
|
||||
@@ -67,7 +67,8 @@ export const Plugin = {
|
||||
Effect.gen(function* () {
|
||||
if (yield* websearch.default()) return yield* Effect.void
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
if (!providers.length) return yield* new WebSearch.ProviderRequiredError()
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
@@ -82,11 +83,11 @@ export const Plugin = {
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: "Allow web search",
|
||||
label: `Allow web search via ${defaultProvider.name}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose a specific provider",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
@@ -122,10 +123,10 @@ export const Plugin = {
|
||||
: undefined
|
||||
if (selection?.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? WebSearch.AUTO
|
||||
const providerID = selection?.answer.provider ?? defaultProvider.id
|
||||
if (
|
||||
typeof providerID !== "string" ||
|
||||
(providerID !== WebSearch.AUTO && !providers.some((provider) => provider.id === providerID))
|
||||
!providers.some((provider) => provider.id === providerID)
|
||||
)
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
return yield* kv.set("websearch:provider", providerID)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as WebSearch from "./websearch.js"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Context, Effect, Layer, Random, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus.js"
|
||||
import { KV } from "./kv.js"
|
||||
@@ -10,8 +10,6 @@ import { State } from "./state.js"
|
||||
export const ID = WebSearch.ID
|
||||
export type ID = WebSearch.ID
|
||||
|
||||
export const AUTO = WebSearch.AUTO
|
||||
|
||||
export const Provider = WebSearch.Provider
|
||||
export type Provider = WebSearch.Provider
|
||||
|
||||
@@ -54,7 +52,7 @@ export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledErro
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly providers: () => Effect.Effect<readonly Provider[]>
|
||||
readonly default: () => Effect.Effect<Provider | typeof AUTO | undefined, DisabledError>
|
||||
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
|
||||
readonly query: (input: Input) => Effect.Effect<Response, Error>
|
||||
}
|
||||
|
||||
@@ -62,14 +60,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
|
||||
|
||||
type Data = {
|
||||
readonly providers: Map<ID, ProviderImplementation>
|
||||
defaultProviderID?: WebSearch.Selection
|
||||
defaultProviderID?: ID
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
add: (provider: ProviderImplementation) => void
|
||||
default: {
|
||||
get: () => WebSearch.Selection | undefined
|
||||
set: (providerID: WebSearch.Selection) => void
|
||||
get: () => ID | undefined
|
||||
set: (providerID: ID) => void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,13 +96,11 @@ const layer = Layer.effect(
|
||||
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
||||
const data = state.get()
|
||||
if (data.defaultProviderID === AUTO) return AUTO
|
||||
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
|
||||
if (configured) return configured
|
||||
const stored = yield* kv.get("websearch:provider")
|
||||
if (stored === false) return yield* new DisabledError()
|
||||
if (typeof stored !== "string") return
|
||||
if (stored === AUTO) return AUTO
|
||||
return data.providers.get(ID.make(stored))
|
||||
})
|
||||
|
||||
@@ -113,21 +109,9 @@ const layer = Layer.effect(
|
||||
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||
const provider = yield* defaultProvider()
|
||||
if (!provider) return yield* new ProviderRequiredError()
|
||||
if (provider === AUTO) {
|
||||
if (!providers.size) return yield* new ProviderRequiredError()
|
||||
return yield* Random.shuffle(providers.values())
|
||||
}
|
||||
return provider
|
||||
})
|
||||
|
||||
const execute = Effect.fn("WebSearch.execute")(function* (provider: ProviderImplementation, input: Input) {
|
||||
const results = yield* provider.execute({ query: input.query }).pipe(
|
||||
Effect.flatMap(decodeResults),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
|
||||
)
|
||||
return new Response({ providerID: provider.id, results })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
@@ -139,13 +123,15 @@ const layer = Layer.effect(
|
||||
}),
|
||||
default: Effect.fn("WebSearch.defaultInfo")(function* () {
|
||||
const provider = yield* defaultProvider()
|
||||
if (provider === AUTO) return AUTO
|
||||
return provider && { id: provider.id, name: provider.name }
|
||||
}),
|
||||
query: Effect.fn("WebSearch.query")(function* (input) {
|
||||
const route = yield* resolve(input)
|
||||
if (Array.isArray(route)) return yield* Effect.firstSuccessOf(route.map((provider) => execute(provider, input)))
|
||||
return yield* execute(route, input)
|
||||
const provider = yield* resolve(input)
|
||||
const results = yield* provider.execute({ query: input.query }).pipe(
|
||||
Effect.flatMap(decodeResults),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
|
||||
)
|
||||
return new Response({ providerID: provider.id, results })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Protected } from "@opencode-ai/core/filesystem/protected"
|
||||
@@ -56,4 +57,70 @@ describe("FileSystemSearch", () => {
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
|
||||
let scans = 0
|
||||
const initial = Effect.runSync(Deferred.make<void>())
|
||||
const started = Effect.runSync(Deferred.make<void>())
|
||||
const release = Effect.runSync(Deferred.make<void>())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
if (scans > 1) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
const entry = FileSystem.Entry.make({
|
||||
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
|
||||
type: "file",
|
||||
})
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
if (scans === 1) yield* Deferred.succeed(initial, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Deferred.await(initial)
|
||||
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||
expect(scans).toBe(1)
|
||||
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
yield* search.find({ query: "old", type: "file" })
|
||||
yield* Deferred.await(started)
|
||||
|
||||
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||
expect(scans).toBe(2)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const refreshed = yield* Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
return yield* search.find({ query: "new", type: "file" })
|
||||
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
|
||||
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
|
||||
expect(scans).toBe(2)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,7 +80,6 @@ const websearch = Layer.succeed(
|
||||
Effect.gen(function* () {
|
||||
const stored = values.get("websearch:provider")
|
||||
if (stored === false) return yield* new WebSearch.DisabledError()
|
||||
if (stored === WebSearch.AUTO) return WebSearch.AUTO
|
||||
return typeof stored === "string" ? providers.find((provider) => provider.id === stored) : undefined
|
||||
}),
|
||||
query: (input) =>
|
||||
@@ -94,7 +93,6 @@ const websearch = Layer.succeed(
|
||||
}
|
||||
if (queryError) return yield* queryError
|
||||
if (providerRequired && typeof stored !== "string") return yield* new WebSearch.ProviderRequiredError()
|
||||
if (stored === WebSearch.AUTO) return result
|
||||
if (typeof stored === "string")
|
||||
return new WebSearch.Response({ providerID: WebSearch.ID.make(stored), results: result.results })
|
||||
return result
|
||||
@@ -236,7 +234,7 @@ describe("WebSearchTool registration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("asks once and enables automatic provider selection", () =>
|
||||
it.effect("asks once and uses the default provider when web search is first enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
providerRequired = true
|
||||
formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
@@ -249,7 +247,7 @@ describe("WebSearchTool registration", () => {
|
||||
call: { type: "tool-call", id: "call-enable", name: "websearch", input: { query: "effect" } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed", metadata: { provider: "exa" } })
|
||||
expect(values.get("websearch:provider")).toBe(WebSearch.AUTO)
|
||||
expect(values.get("websearch:provider")).toBe("exa")
|
||||
expect(queries).toHaveLength(2)
|
||||
expect(formRequests).toEqual([
|
||||
{
|
||||
@@ -266,11 +264,11 @@ describe("WebSearchTool registration", () => {
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: "Allow web search",
|
||||
label: "Allow web search via Exa",
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose a specific provider",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
@@ -355,7 +353,7 @@ describe("WebSearchTool registration", () => {
|
||||
|
||||
expect(results.every((item) => item.status === "completed")).toBe(true)
|
||||
expect(formRequests).toHaveLength(1)
|
||||
expect(values.get("websearch:provider")).toBe(WebSearch.AUTO)
|
||||
expect(values.get("websearch:provider")).toBe("exa")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Random, Scope } from "effect"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -9,7 +9,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
|
||||
|
||||
const register = (id: string, behavior: "results" | "empty" | "fail" = "results") =>
|
||||
const register = (id: string) =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const providerID = WebSearch.ID.make(id)
|
||||
@@ -19,24 +19,17 @@ const register = (id: string, behavior: "results" | "empty" | "fail" = "results"
|
||||
id: providerID,
|
||||
name: id.toUpperCase(),
|
||||
execute: (input) =>
|
||||
Effect.sync(() => calls.push(input)).pipe(
|
||||
Effect.andThen(
|
||||
behavior === "fail"
|
||||
? Effect.fail(new Error(`${id} failed`))
|
||||
: Effect.succeed(
|
||||
behavior === "empty"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
url: `https://${id}.example.com`,
|
||||
title: input.query,
|
||||
content: `${id}: ${input.query}`,
|
||||
time: {},
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
return [
|
||||
{
|
||||
url: `https://${id}.example.com`,
|
||||
title: input.query,
|
||||
content: `${id}: ${input.query}`,
|
||||
time: {},
|
||||
},
|
||||
]
|
||||
}),
|
||||
})
|
||||
})
|
||||
return { providerID, calls }
|
||||
@@ -67,21 +60,6 @@ describe("WebSearch", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps explicit providers strict when automatic selection is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa", "fail")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
|
||||
|
||||
const error = yield* websearch.query({ query: "strict", providerID: exa.providerID }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "WebSearch.Request", providerID: exa.providerID })
|
||||
expect(exa.calls).toEqual([{ query: "strict" }])
|
||||
expect(parallel.calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires a provider when no default is set", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
@@ -103,20 +81,6 @@ describe("WebSearch", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps fixed configured providers strict", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa", "fail")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(exa.providerID))
|
||||
|
||||
const error = yield* websearch.query({ query: "configured" }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "WebSearch.Request", providerID: exa.providerID })
|
||||
expect(parallel.calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the provider stored in KV", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
@@ -130,118 +94,6 @@ describe("WebSearch", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps fixed KV providers strict", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa", "fail")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set("websearch:provider", exa.providerID)
|
||||
|
||||
const error = yield* websearch.query({ query: "fixed" }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "WebSearch.Request", providerID: exa.providerID })
|
||||
expect(parallel.calls).toEqual([])
|
||||
yield* kv.remove("websearch:provider")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("automatically tries each provider at most once until one succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
const order = yield* Random.shuffle(["exa", "parallel", "firecrawl"]).pipe(Random.withSeed("fallback"))
|
||||
const registered = yield* Effect.forEach(["exa", "parallel", "firecrawl"], (id) =>
|
||||
register(id, id === order.at(-1) ? "results" : "fail"),
|
||||
)
|
||||
const websearch = yield* WebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set("websearch:provider", WebSearch.AUTO)
|
||||
|
||||
const response = yield* websearch.query({ query: "automatic" }).pipe(Random.withSeed("fallback"))
|
||||
|
||||
expect(response.providerID).toBe(WebSearch.ID.make(order.at(-1)!))
|
||||
expect(registered.flatMap((provider) => provider.calls)).toHaveLength(3)
|
||||
expect(registered.every((provider) => provider.calls.length === 1)).toBe(true)
|
||||
yield* kv.remove("websearch:provider")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops automatic fallback on empty results", () =>
|
||||
Effect.gen(function* () {
|
||||
const empty = yield* register("empty", "empty")
|
||||
const fallback = yield* register("fallback")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
|
||||
|
||||
const response = yield* websearch.query({ query: "empty" }).pipe(Random.withSeed("empty-first"))
|
||||
|
||||
expect(response.results).toEqual([])
|
||||
expect(empty.calls).toHaveLength(1)
|
||||
expect(fallback.calls).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns the final request error when every automatic provider fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa", "fail")
|
||||
const parallel = yield* register("parallel", "fail")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
|
||||
const expected = yield* Random.shuffle([exa.providerID, parallel.providerID]).pipe(Random.withSeed("all-fail"))
|
||||
|
||||
const error = yield* websearch.query({ query: "failure" }).pipe(Random.withSeed("all-fail"), Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ _tag: "WebSearch.Request", providerID: expected.at(-1) })
|
||||
expect(exa.calls).toHaveLength(1)
|
||||
expect(parallel.calls).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("supports zero and one registered provider in automatic mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
|
||||
expect((yield* websearch.query({ query: "zero" }).pipe(Effect.flip))._tag).toBe("WebSearch.ProviderRequired")
|
||||
|
||||
const only = yield* register("only")
|
||||
expect((yield* websearch.query({ query: "one" })).providerID).toBe(only.providerID)
|
||||
expect(only.calls).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets an automatic configured default override a fixed KV provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixed = yield* register("fixed", "fail")
|
||||
const fallback = yield* register("fallback")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set("websearch:provider", fixed.providerID)
|
||||
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
|
||||
|
||||
expect((yield* websearch.query({ query: "config" })).providerID).toBe(fallback.providerID)
|
||||
expect(fixed.calls.length).toBeLessThanOrEqual(1)
|
||||
expect(fallback.calls).toHaveLength(1)
|
||||
yield* kv.remove("websearch:provider")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("can start automatic selection with any registered provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((draft) => draft.default.set(WebSearch.AUTO))
|
||||
|
||||
const selected = yield* Effect.forEach(["a", "b", "c", "d", "e", "f"], (seed) =>
|
||||
websearch.query({ query: seed }).pipe(
|
||||
Random.withSeed(seed),
|
||||
Effect.map((response) => response.providerID),
|
||||
),
|
||||
)
|
||||
|
||||
expect(new Set(selected)).toEqual(new Set([exa.providerID, parallel.providerID]))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails when web search is explicitly disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveRendererDevUrl } from "./renderer-url"
|
||||
|
||||
describe("renderer development URL", () => {
|
||||
test("allows a valid URL in development", () => {
|
||||
expect(resolveRendererDevUrl(false, "http://localhost:5173")?.origin).toBe("http://localhost:5173")
|
||||
})
|
||||
|
||||
test("ignores the override in packaged applications", () => {
|
||||
expect(resolveRendererDevUrl(true, "https://example.com")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores invalid URLs", () => {
|
||||
expect(resolveRendererDevUrl(false, "not a url")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export function resolveRendererDevUrl(packaged: boolean, value?: string) {
|
||||
if (packaged || !value || !URL.canParse(value)) return undefined
|
||||
return new URL(value)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { createUnresponsiveSampler } from "./unresponsive"
|
||||
import { nativeT } from "./native-translations"
|
||||
import { createWindowRegistry } from "./window-registry"
|
||||
import { safeWindowURL } from "./window-state"
|
||||
import { resolveRendererDevUrl } from "./renderer-url"
|
||||
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -332,7 +333,7 @@ export function registerRendererProtocol() {
|
||||
}
|
||||
|
||||
function loadWindow(win: BrowserWindow, html: string) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
const devUrl = resolveRendererDevUrl(app.isPackaged, process.env.ELECTRON_RENDERER_URL)
|
||||
if (devUrl) {
|
||||
const url = new URL(html, devUrl)
|
||||
void win.loadURL(url.toString())
|
||||
@@ -510,9 +511,8 @@ function isRendererUrl(value?: string, html = false) {
|
||||
const url = new URL(value)
|
||||
if (html && !url.pathname.endsWith(".html")) return false
|
||||
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
const devUrl = resolveRendererDevUrl(app.isPackaged, process.env.ELECTRON_RENDERER_URL)
|
||||
return devUrl ? url.origin === devUrl.origin : false
|
||||
}
|
||||
|
||||
function wireZoom(win: BrowserWindow) {
|
||||
|
||||
@@ -4,5 +4,5 @@ import { Schema } from "effect"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
|
||||
provider: WebSearch.Selection,
|
||||
provider: WebSearch.ID,
|
||||
}) {}
|
||||
|
||||
@@ -7,11 +7,6 @@ import { optional } from "./schema.js"
|
||||
export const ID = Schema.String.pipe(Schema.brand("WebSearch.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const AUTO = "auto" as const
|
||||
|
||||
export const Selection = Schema.Union([Schema.Literal(AUTO), ID])
|
||||
export type Selection = typeof Selection.Type
|
||||
|
||||
export interface Provider extends Schema.Schema.Type<typeof Provider> {}
|
||||
export const Provider = Schema.Struct({
|
||||
id: ID,
|
||||
|
||||
@@ -6,14 +6,8 @@ import { ConfigMCP } from "../src/config/mcp.js"
|
||||
import { ConfigProvider } from "../src/config/provider.js"
|
||||
import { Mcp } from "../src/mcp.js"
|
||||
import { AbsolutePath } from "../src/schema.js"
|
||||
import { WebSearch } from "../src/websearch.js"
|
||||
|
||||
describe("Config.Entry", () => {
|
||||
test("accepts automatic web search provider selection", () => {
|
||||
const config = new Config.Info({ websearch: { provider: WebSearch.AUTO } })
|
||||
expect(config.websearch?.provider).toBe(WebSearch.AUTO)
|
||||
})
|
||||
|
||||
test("round-trips every configuration entry type", () => {
|
||||
const entries = [
|
||||
new Config.Document({
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Experiment = {
|
||||
id: "tab_drafts"
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
@@ -12,36 +13,30 @@ type Experiment = {
|
||||
// In-flight features anyone can opt into. Each entry is temporary: an
|
||||
// experiment either graduates (delete the entry, make the behavior
|
||||
// unconditional) or dies (delete the entry and the branch it gated).
|
||||
export const experiments: Experiment[] = [
|
||||
{
|
||||
id: "tab_drafts",
|
||||
title: "Per-tab prompt drafts",
|
||||
description: "Keep unsent prompt drafts on the tab where they were written. New sessions start blank.",
|
||||
},
|
||||
]
|
||||
export const experiments: Experiment[] = []
|
||||
|
||||
export function DialogExperiments() {
|
||||
const config = useConfig()
|
||||
const theme = useTheme()
|
||||
const toast = useToast()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [selected, setSelected] = createSignal<Experiment>()
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
|
||||
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
|
||||
|
||||
const options = createMemo(() =>
|
||||
experiments.map((experiment, index) => ({
|
||||
experiments.map((experiment) => ({
|
||||
title: experiment.title,
|
||||
category: "Experiments",
|
||||
searchText: experiment.description,
|
||||
footer: enabled(experiment) ? "on" : "off",
|
||||
value: index,
|
||||
value: experiment,
|
||||
})),
|
||||
)
|
||||
|
||||
// All experiments are booleans, so either direction toggles.
|
||||
async function change(index = selected()) {
|
||||
async function change(experiment = selected()) {
|
||||
if (saving()) return
|
||||
const experiment = experiments[index]
|
||||
if (!experiment) return
|
||||
const next = !enabled(experiment)
|
||||
setSaving(true)
|
||||
@@ -58,23 +53,33 @@ export function DialogExperiments() {
|
||||
<DialogSelect
|
||||
title="Experiments"
|
||||
options={options()}
|
||||
renderFilter={experiments.length > 0}
|
||||
onMove={(option) => setSelected(option.value)}
|
||||
onSelect={(option) => void change(option.value)}
|
||||
footerHints={[{ title: "←/→", label: "change" }]}
|
||||
bindings={[
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
]}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No experiments available</text>
|
||||
</box>
|
||||
}
|
||||
footerHints={experiments.length > 0 ? [{ title: "←/→", label: "change" }] : []}
|
||||
bindings={
|
||||
experiments.length > 0
|
||||
? [
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
|
||||
// Holds one in-progress draft per slot across Prompt remounts. The undefined
|
||||
// key is the default single global slot that follows focus across tabs; the
|
||||
// tab_drafts experiment keys drafts by the tab (sessionID or "home") they
|
||||
// were written in. A draft is consumed on take: restoring it moves it out of
|
||||
// the stash, so a stale copy never shadows newer input.
|
||||
// Holds one in-progress draft per tab across Prompt remounts. A draft is
|
||||
// consumed on take: restoring it moves it out of the stash, so a stale copy
|
||||
// never shadows newer input.
|
||||
export type DraftEntry = { prompt: PromptInfo; cursor: number }
|
||||
|
||||
let global: DraftEntry | undefined
|
||||
const byTab = new Map<string, DraftEntry>()
|
||||
const byTab = new Map<string | undefined, DraftEntry>()
|
||||
|
||||
export function takeDraft(key: string | undefined) {
|
||||
if (key === undefined) {
|
||||
const entry = global
|
||||
global = undefined
|
||||
return entry
|
||||
}
|
||||
const entry = byTab.get(key)
|
||||
byTab.delete(key)
|
||||
export function takeDraft(sessionID: string | undefined) {
|
||||
const entry = byTab.get(sessionID)
|
||||
byTab.delete(sessionID)
|
||||
return entry
|
||||
}
|
||||
|
||||
export function saveDraft(key: string | undefined, entry: DraftEntry) {
|
||||
if (key === undefined) {
|
||||
global = entry
|
||||
return
|
||||
}
|
||||
byTab.set(key, entry)
|
||||
export function saveDraft(sessionID: string | undefined, entry: DraftEntry) {
|
||||
byTab.set(sessionID, entry)
|
||||
}
|
||||
|
||||
@@ -678,10 +678,9 @@ export function Prompt(props: PromptProps) {
|
||||
// instance belongs to exactly one tab. Reading props.sessionID lazily would
|
||||
// observe the *next* route during onCleanup and stash under the wrong tab.
|
||||
const stashSessionID = props.sessionID
|
||||
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
|
||||
|
||||
onMount(() => {
|
||||
const saved = takeDraft(stashKey())
|
||||
const saved = takeDraft(stashSessionID)
|
||||
if (store.prompt.text) return
|
||||
if (saved && saved.prompt.text) {
|
||||
input.setText(saved.prompt.text)
|
||||
@@ -694,7 +693,7 @@ export function Prompt(props: PromptProps) {
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
if (store.prompt.text) {
|
||||
saveDraft(stashKey(), { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
|
||||
saveDraft(stashSessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
|
||||
}
|
||||
setInputTarget(undefined)
|
||||
props.ref?.(undefined)
|
||||
|
||||
@@ -192,13 +192,9 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Debugging settings" }),
|
||||
experimental: Schema.optional(
|
||||
Schema.Struct({
|
||||
tab_drafts: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Keep unsent prompt drafts on the tab where they were written",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Experimental features that may change or be removed at any time" }),
|
||||
experimental: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
||||
description: "Experimental features that may change or be removed at any time",
|
||||
}),
|
||||
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
|
||||
cursor: Schema.optional(Cursor),
|
||||
|
||||
@@ -3,23 +3,13 @@ import { saveDraft, takeDraft } from "../../src/component/prompt/draft-stash"
|
||||
import { emptyPrompt } from "../../src/prompt/history"
|
||||
|
||||
// The Prompt component stashes an unsent draft in onCleanup and takes it back
|
||||
// in onMount across route remounts. The key it uses is undefined by default
|
||||
// (one global slot that follows focus across tabs) and the tab identity
|
||||
// (sessionID, or "home") when the tab_drafts experiment is on.
|
||||
// in onMount across route remounts, keyed by sessionID or undefined for home.
|
||||
|
||||
function draft(text: string, cursor = text.length) {
|
||||
return { prompt: { ...emptyPrompt(), text }, cursor }
|
||||
}
|
||||
|
||||
describe("prompt draft stash", () => {
|
||||
test("global slot follows focus: any tab takes the last stashed draft", () => {
|
||||
const entry = draft("follow me")
|
||||
saveDraft(undefined, entry)
|
||||
expect(takeDraft(undefined)).toBe(entry)
|
||||
// Consumed on take, so a remount never restores a stale copy.
|
||||
expect(takeDraft(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("tab-keyed drafts stay on the tab they were written in", () => {
|
||||
const two = draft("notes for session two")
|
||||
saveDraft("ses_two", two)
|
||||
@@ -37,25 +27,12 @@ describe("prompt draft stash", () => {
|
||||
const one = draft("DRAFT-ONE")
|
||||
const home = draft("draft on home")
|
||||
saveDraft("ses_one", one)
|
||||
saveDraft("home", home)
|
||||
saveDraft(undefined, home)
|
||||
|
||||
expect(takeDraft("home")).toBe(home)
|
||||
expect(takeDraft(undefined)).toBe(home)
|
||||
expect(takeDraft("ses_one")).toBe(one)
|
||||
})
|
||||
|
||||
test("global and tab slots never leak into each other when the experiment toggles mid-draft", () => {
|
||||
const global = draft("stashed before enabling tab_drafts")
|
||||
const keyed = draft("stashed after enabling tab_drafts")
|
||||
saveDraft(undefined, global)
|
||||
saveDraft("ses_a", keyed)
|
||||
|
||||
// A keyed lookup must not surface the global draft on the wrong tab...
|
||||
expect(takeDraft("ses_b")).toBeUndefined()
|
||||
// ...and the global slot must not surface a tab's draft.
|
||||
expect(takeDraft(undefined)).toBe(global)
|
||||
expect(takeDraft("ses_a")).toBe(keyed)
|
||||
})
|
||||
|
||||
test("a newer draft for the same slot replaces the older one", () => {
|
||||
saveDraft("ses_a", draft("first"))
|
||||
const second = draft("second")
|
||||
|
||||
Reference in New Issue
Block a user