mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9640058a8 |
@@ -139,7 +139,6 @@ test("resolves directory autocomplete from the current browser root", async () =
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({ data: [] })
|
||||
},
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
@@ -153,67 +152,6 @@ test("resolves directory autocomplete from the current browser root", async () =
|
||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||
})
|
||||
|
||||
test("keeps indexed directory results for servers that support empty search", async () => {
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
|
||||
list: () => Promise.reject(new Error("listing should not run when search returns results")),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
|
||||
expect(await search("")).toEqual(["/home/luke/projects"])
|
||||
})
|
||||
|
||||
test("lists the default directory when empty search is unsupported", async () => {
|
||||
const calls: string[] = []
|
||||
const directories = Array.from({ length: 60 }, (_, index) => ({
|
||||
path: `project-${index}/`,
|
||||
type: "directory" as const,
|
||||
}))
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ data: [] }),
|
||||
list: (input: { location?: { directory?: string } }) => {
|
||||
calls.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({
|
||||
data: [...directories, { path: "README.md", type: "file" }],
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
|
||||
const results = await search("")
|
||||
expect(results).toHaveLength(60)
|
||||
expect(results.at(-1)).toBe("/home/luke/project-59")
|
||||
expect(calls).toEqual(["/home/luke"])
|
||||
})
|
||||
|
||||
test("matches the default directory listing when typed search is unsupported", async () => {
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ data: [] }),
|
||||
list: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{ path: "Documents/", type: "directory" },
|
||||
{ path: "Downloads/", type: "directory" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
|
||||
expect(await search("documents")).toEqual(["/home/luke/Documents"])
|
||||
})
|
||||
|
||||
test("searches from an absolute root without a default base", async () => {
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
|
||||
@@ -379,14 +379,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
.then((result) => result.data.map((entry) => entry.path))
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
if (results.length) {
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
}
|
||||
const fallback = query
|
||||
? await match(input.directory, query, 50)
|
||||
: (await directories(input.directory)).map((item) => item.absolute)
|
||||
if (!active()) return []
|
||||
return fallback
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
}
|
||||
const segments = query.replace(/^\/+/, "").split("/")
|
||||
const head = segments.slice(0, -1).filter((part) => part && part !== ".")
|
||||
|
||||
@@ -131,8 +131,6 @@ export const Provider = Schema.Struct({
|
||||
|
||||
export type Provider = Schema.Schema.Type<typeof Provider>
|
||||
|
||||
const Catalog = Schema.Record(Schema.String, Provider)
|
||||
|
||||
export const Event = ModelsDev.Event
|
||||
|
||||
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
|
||||
@@ -184,7 +182,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe(
|
||||
Effect.flatMap(Schema.decodeUnknownEffect(Catalog)),
|
||||
Effect.catch((error) => {
|
||||
if (
|
||||
Flag.OPENCODE_MODELS_PATH === undefined &&
|
||||
@@ -204,7 +201,6 @@ const layer = Layer.effect(
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Catalog))(text)
|
||||
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
|
||||
yield* fs.writeWithDirs(tempfile, text).pipe(
|
||||
Effect.andThen(fs.rename(tempfile, filepath)),
|
||||
@@ -215,7 +211,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
)
|
||||
return catalog
|
||||
return text
|
||||
})
|
||||
|
||||
const populate = Effect.gen(function* () {
|
||||
@@ -225,18 +221,14 @@ const layer = Layer.effect(
|
||||
if (snapshot) return snapshot
|
||||
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
|
||||
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
|
||||
return yield* Effect.scoped(
|
||||
const text = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Flock.effect(lockKey)
|
||||
return yield* fetchAndWrite()
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.withSpan("ModelsDev.populate"),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("Failed to fetch models.dev", { error }).pipe(Effect.as({} as Record<string, Provider>)),
|
||||
),
|
||||
)
|
||||
return JSON.parse(text) as Record<string, Provider>
|
||||
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
|
||||
|
||||
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
|
||||
|
||||
|
||||
@@ -112,18 +112,6 @@ const writeCache = (data: object, mtimeMs?: number) => writeCacheText(JSON.strin
|
||||
const provided = <A, E>(state: Ref.Ref<MockState>, eff: Effect.Effect<A, E, ModelsDev.Service>) =>
|
||||
eff.pipe(Effect.provide(buildLayer(state)))
|
||||
|
||||
const withFetch = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
|
||||
}),
|
||||
() => effect,
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
|
||||
}),
|
||||
)
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(cacheFile, { force: true })
|
||||
})
|
||||
@@ -171,7 +159,16 @@ describe("ModelsDev Service", () => {
|
||||
yield* writeCacheText("{")
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const context = yield* Layer.build(buildLayer(state))
|
||||
const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)))
|
||||
const result = yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
|
||||
}),
|
||||
() => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
|
||||
}),
|
||||
)
|
||||
expect(result).toEqual(fixture2)
|
||||
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
|
||||
const final = yield* Ref.get(state)
|
||||
@@ -179,38 +176,6 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns an empty catalog when the initial fetch fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* Ref.make({ ...initialState, status: 503 })
|
||||
const context = yield* Layer.build(buildLayer(state))
|
||||
const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)))
|
||||
expect(result).toEqual({})
|
||||
expect((yield* Ref.get(state)).calls.length).toBe(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns an empty catalog when the response is malformed JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* Ref.make({ ...initialState, body: "{" })
|
||||
const context = yield* Layer.build(buildLayer(state))
|
||||
const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)))
|
||||
expect(result).toEqual({})
|
||||
expect((yield* Ref.get(state)).calls.length).toBe(1)
|
||||
expect(yield* Effect.promise(() => Bun.file(cacheFile).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns an empty catalog when the response has an invalid shape", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify({ acme: {} }) })
|
||||
const context = yield* Layer.build(buildLayer(state))
|
||||
const result = yield* withFetch(ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)))
|
||||
expect(result).toEqual({})
|
||||
expect((yield* Ref.get(state)).calls.length).toBe(1)
|
||||
expect(yield* Effect.promise(() => Bun.file(cacheFile).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() is single-flight under concurrent calls", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* writeCache(fixture)
|
||||
|
||||
@@ -1769,6 +1769,62 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays interrupted provider-local tool call IDs uniquely", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Echo twice" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
const firstGate = yield* Deferred.make<void>()
|
||||
const secondGate = yield* Deferred.make<void>()
|
||||
toolExecutionGate = firstGate
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "first" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "second" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
while (executions.length < 1) yield* Effect.yieldNow
|
||||
toolExecutionGate = secondGate
|
||||
yield* Deferred.succeed(firstGate, undefined)
|
||||
while (executions.length < 2) yield* Effect.yieldNow
|
||||
yield* session.interrupt(sessionID)
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
toolExecutionGate = undefined
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Echo twice" },
|
||||
{ type: "assistant", content: [{ type: "tool", id: "tool_0", state: { status: "completed" } }] },
|
||||
{ type: "assistant", content: [{ type: "tool", id: "tool_0", state: { status: "error" } }] },
|
||||
])
|
||||
|
||||
requests.length = 0
|
||||
responses = undefined
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const callIDs = requests[0]!.messages.flatMap((message) =>
|
||||
message.role === "assistant"
|
||||
? message.content.filter((part) => part.type === "tool-call").map((part) => part.id)
|
||||
: [],
|
||||
)
|
||||
expect(callIDs).toHaveLength(2)
|
||||
expect(new Set(callIDs).size).toBe(callIDs.length)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("joins concurrent resume calls into one active provider run", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -37,11 +37,22 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
|
||||
data: unknown,
|
||||
source: string,
|
||||
): DeepMutable<S["Type"]> {
|
||||
const decoded = EffectSchema.decodeUnknownExit(schema)(data, {
|
||||
errors: "all",
|
||||
onExcessProperty: "ignore",
|
||||
propertyOrder: "original",
|
||||
})
|
||||
const extra = topLevelExtraKeys(schema, data)
|
||||
if (extra.length) {
|
||||
throw new InvalidError({
|
||||
path: source,
|
||||
issues: [
|
||||
{
|
||||
code: "unrecognized_keys",
|
||||
keys: extra,
|
||||
path: [],
|
||||
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
|
||||
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
|
||||
const error = Cause.squash(decoded.cause)
|
||||
|
||||
@@ -59,3 +70,10 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
|
||||
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) return []
|
||||
if (schema.ast._tag !== "Objects" || schema.ast.indexSignatures.length > 0) return []
|
||||
const known = new Set(schema.ast.propertySignatures.map((item) => String(item.name)))
|
||||
return Object.keys(data).filter((key) => !known.has(key))
|
||||
}
|
||||
|
||||
@@ -597,12 +597,12 @@ accountTokenIt.instance("resolves env templates in account config with account t
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("validates config schema and throws on invalid values", () =>
|
||||
it.instance("validates config schema and throws on invalid fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: 42,
|
||||
invalid_field: "should cause error",
|
||||
})
|
||||
const exit = yield* Config.use.get().pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
@@ -1331,7 +1331,7 @@ it.instance("permission config preserves user key order", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
test("config parser preserves permission order while ignoring unknown top-level keys", () => {
|
||||
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
|
||||
const config = ConfigParse.schema(
|
||||
ConfigV1.Info,
|
||||
{
|
||||
@@ -1340,13 +1340,18 @@ test("config parser preserves permission order while ignoring unknown top-level
|
||||
"*": "deny",
|
||||
edit: "ask",
|
||||
},
|
||||
plugins: ["example"],
|
||||
},
|
||||
"test",
|
||||
)
|
||||
|
||||
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
|
||||
expect(config).not.toHaveProperty("plugins")
|
||||
try {
|
||||
ConfigParse.schema(ConfigV1.Info, { invalid_field: true }, "test")
|
||||
throw new Error("expected config parse to fail")
|
||||
} catch (err) {
|
||||
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
|
||||
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
|
||||
}
|
||||
})
|
||||
|
||||
// MCP config merging tests
|
||||
|
||||
Reference in New Issue
Block a user