fix(opencode): preserve untouched extra-option value types on edit (#7841)

The extra-options row editor keeps every value as display text, and any
row change re-parsed all rows with JSON.parse. Untouched string values
such as "123", "false" or "null" were silently turned into numbers,
booleans or null on save, in both the OpenCode and MiniMax Code forms.

Merge row edits into the stored options instead: a row whose text still
matches the stored value's display form keeps the original value and
type, and a renamed key inherits its old value. Renaming an option onto
a key that already exists is rejected, matching the model field editor.

Fixes #7833
This commit is contained in:
小七 authored and GitHub committed 2026-10-10 12:05:05 +08:00
1 parent a0be6456cf
commit 2625bcf192
7 files changed
+339 -40

No files matched your search

+160
View File
@@ -144,4 +144,164 @@ describe("useOpencodeFormState", () => {
timeout: 200,
});
});
it("keeps untouched extra option values verbatim when another option is edited", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai",
options: {
name: "123",
flag: "false",
missing: "null",
list: "[]",
map: "{}",
timeout: 100,
},
models: { fixture: { name: "Fixture model" } },
});
act(() => {
result.current.handleOpencodeExtraOptionsChange({
...result.current.opencodeExtraOptions,
timeout: "200",
});
});
expect(JSON.parse(getSettingsConfig()).options).toEqual({
name: "123",
flag: "false",
missing: "null",
list: "[]",
map: "{}",
timeout: 200,
});
});
it("keeps untouched extra option values verbatim when an option is added", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai",
options: { name: "123", timeout: 100 },
models: {},
});
act(() => {
// Mirrors handleAddExtraOption: a draft row is appended to the map.
result.current.handleOpencodeExtraOptionsChange({
...result.current.opencodeExtraOptions,
"draft-option:123": "",
});
});
expect(JSON.parse(getSettingsConfig()).options).toEqual({
name: "123",
timeout: 100,
});
});
it("keeps untouched extra option values verbatim when a different option is removed", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai",
options: { name: "123", timeout: 100, legacy: "false" },
models: {},
});
const rows = { ...result.current.opencodeExtraOptions };
delete rows.legacy;
act(() => {
result.current.handleOpencodeExtraOptionsChange(rows);
});
expect(JSON.parse(getSettingsConfig()).options).toEqual({
name: "123",
timeout: 100,
});
});
it("removes extra options whose keys collide with Object.prototype properties", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai",
options: {
constructor: "custom-value",
toString: "false",
timeout: 100,
},
models: {},
});
const rows = { ...result.current.opencodeExtraOptions };
// Mirrors handleRemoveExtraOption, which deletes by a runtime key.
for (const key of ["constructor", "toString"]) delete rows[key];
act(() => {
result.current.handleOpencodeExtraOptionsChange(rows);
});
expect(JSON.parse(getSettingsConfig()).options).toEqual({
timeout: 100,
});
});
it("drops the old key when an extra option is renamed away from a prototype-named key", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai",
options: { valueOf: "3", timeout: 100 },
models: {},
});
// Mirrors handleExtraOptionKeyChange: rebuild the row map with the new key.
const rows: Record<string, string> = {};
for (const [k, v] of Object.entries(result.current.opencodeExtraOptions)) {
rows[k === "valueOf" ? "count" : k] = v;
}
act(() => {
result.current.handleOpencodeExtraOptionsChange(rows);
});
expect(JSON.parse(getSettingsConfig()).options).toEqual({
count: "3",
timeout: 100,
});
});
it("keeps a renamed extra option's stored value type when only the key changes", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai",
options: { name: "123", timeout: 100 },
models: {},
});
// Mirrors handleExtraOptionKeyChange: the row's display text moves to
// the new key untouched.
const rows: Record<string, string> = {};
for (const [k, v] of Object.entries(result.current.opencodeExtraOptions)) {
rows[k === "name" ? "label" : k] = v;
}
act(() => {
result.current.handleOpencodeExtraOptionsChange(rows);
});
const options = JSON.parse(getSettingsConfig()).options;
expect(options.label).toBe("123");
expect(options).not.toHaveProperty("name");
});
it("does not mistake a kept key with the same display text for a rename source", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai",
options: { name: "100", timeout: 100 },
models: {},
});
// Rename name -> label while timeout (also displayed as "100") stays.
const rows: Record<string, string> = {};
for (const [k, v] of Object.entries(result.current.opencodeExtraOptions)) {
rows[k === "name" ? "label" : k] = v;
}
act(() => {
result.current.handleOpencodeExtraOptionsChange(rows);
});
expect(JSON.parse(getSettingsConfig()).options).toEqual({
label: "100",
timeout: 100,
});
});
});