Compare commits

..
Author SHA1 Message Date
Aiden Clineandopencode-agent[bot] c9640058a8 test(core): reproduce interrupted tool ID reuse 2026-08-07 22:09:56 +00:00
7 changed files with 94 additions and 94 deletions
@@ -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 !== ".")
+56
View File
@@ -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
+23 -5
View File
@@ -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))
}
+10 -5
View File
@@ -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
+1 -1
View File
@@ -9,7 +9,7 @@ import { Effect, Layer } from "effect"
import * as Context from "effect/Context"
import { Resource } from "sst/resource"
const ATHENA_MAX_POLL_ATTEMPTS = 900
const ATHENA_MAX_POLL_ATTEMPTS = 300
const ATHENA_PAGE_SIZE = 1000
export type AthenaData = Record<string, string>
+3 -13
View File
@@ -19,19 +19,9 @@ const daemon = Effect.gen(function* () {
let lastFullDay = ""
const pass = Effect.gen(function* () {
const today = new Date().toISOString().slice(0, 10)
if (lastFullDay !== today) {
const completed = yield* syncStats({ full: true }).pipe(
Effect.as(true),
Effect.catchCause((cause) =>
Effect.logWarning(`full stats sync failed; falling back to incremental sync ${Cause.pretty(cause)}`).pipe(
Effect.as(false),
),
),
)
lastFullDay = today
if (completed) return
}
yield* syncStats({ full: false })
const full = lastFullDay !== today
yield* syncStats({ full })
if (full) lastFullDay = today
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning(`stats sync failed ${JSON.stringify({ cause: Cause.pretty(cause) })}`),