Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline de8a085e9d test(core): reproduce duplicate tool call IDs 2026-08-07 22:00:40 +00:00
5 changed files with 90 additions and 80 deletions
@@ -139,7 +139,6 @@ test("resolves directory autocomplete from the current browser root", async () =
directories.push(input.location?.directory ?? "") directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] }) return Promise.resolve({ data: [] })
}, },
list: () => Promise.resolve({ data: [] }),
}, },
}, },
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"] } 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"]) 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 () => { test("searches from an absolute root without a default base", async () => {
const directories: string[] = [] const directories: string[] = []
const sdk = { const sdk = {
@@ -379,14 +379,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
.then((result) => result.data.map((entry) => entry.path)) .then((result) => result.data.map((entry) => entry.path))
.catch(() => []) .catch(() => [])
if (!active()) return [] if (!active()) return []
if (results.length) { return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
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
} }
const segments = query.replace(/^\/+/, "").split("/") const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".") const head = segments.slice(0, -1).filter((part) => part && part !== ".")
@@ -498,4 +498,60 @@ Recent work
}, },
]) ])
}) })
test("does not lower duplicate tool call IDs from interrupted history", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("duplicate-tool-call"),
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [
SessionMessage.AssistantTool.make({
type: "tool",
id: "call_1",
name: "read",
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: { path: "README.md" },
content: [{ type: "text", text: "done" }],
structured: {},
}),
time: { created, completed: created },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "call_1",
name: "unknown",
state: SessionMessage.ToolStateError.make({
status: "error",
input: {},
content: [],
structured: {},
error: { type: "unknown", message: "Tool execution interrupted" },
}),
time: { created, completed: created },
}),
],
time: { created, completed: created },
}),
],
model,
)
const calls = messages.flatMap((message) =>
message.content.filter((part) => part.type === "tool-call" && part.id === "call_1"),
)
expect(calls).toEqual([
{
type: "tool-call",
id: "call_1",
name: "read",
input: { path: "README.md" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
})
}) })
+23 -5
View File
@@ -37,11 +37,22 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
data: unknown, data: unknown,
source: string, source: string,
): DeepMutable<S["Type"]> { ): DeepMutable<S["Type"]> {
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { const extra = topLevelExtraKeys(schema, data)
errors: "all", if (extra.length) {
onExcessProperty: "ignore", throw new InvalidError({
propertyOrder: "original", 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"]> if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
const error = Cause.squash(decoded.cause) const error = Cause.squash(decoded.cause)
@@ -59,3 +70,10 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
{ cause: error }, { 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* () { Effect.gen(function* () {
const test = yield* TestInstance const test = yield* TestInstance
yield* writeConfigEffect(test.directory, { yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
model: 42, invalid_field: "should cause error",
}) })
const exit = yield* Config.use.get().pipe(Effect.exit) const exit = yield* Config.use.get().pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true) 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( const config = ConfigParse.schema(
ConfigV1.Info, ConfigV1.Info,
{ {
@@ -1340,13 +1340,18 @@ test("config parser preserves permission order while ignoring unknown top-level
"*": "deny", "*": "deny",
edit: "ask", edit: "ask",
}, },
plugins: ["example"],
}, },
"test", "test",
) )
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"]) 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 // MCP config merging tests