Skip to content

Commit a9a3959

Browse files
committed
fix: anchor settings TUI to global resume value
1 parent a853920 commit a9a3959

2 files changed

Lines changed: 72 additions & 4 deletions

File tree

agenticoding.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { STATUS_KEY_HANDOFF, WIDGET_KEY_WARNING, updateIndicators } from "./tui.
2525
import {
2626
MANUAL_AGENTICODING_SETTINGS_INSTRUCTIONS,
2727
buildAgenticodingSettingsModel,
28+
createAgenticodingSettingsComponent,
2829
getAgenticodingSettingsDisplayLines,
2930
readHandoffSettingsState,
3031
resolveHandoffResumeBehavior,
@@ -625,6 +626,40 @@ test("agenticoding settings TUI warns when project override masks global setting
625626
});
626627
});
627628

629+
test("agenticoding settings TUI editable control anchors and refreshes to global value when project override masks it", async () => {
630+
await withIsolatedSettings(async ({ home, cwd }) => {
631+
await writeSettingsFile(join(home, ".pi", "agent", "settings.json"), { handoff: { resumeBehavior: "proceed" } });
632+
await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { resumeBehavior: "wait" } });
633+
const ctx = { cwd, hasUI: true, ui: { notify: () => {} } } as any;
634+
const model = await buildAgenticodingSettingsModel(ctx);
635+
const component = createAgenticodingSettingsComponent(model, ctx, { requestRender: () => {} }, theme, () => {});
636+
637+
const rendered = stripAnsi(component.render(120).join("\n"));
638+
assert.match(rendered, /Resolved handoff\.resumeBehavior: wait \(project\)/);
639+
assert.match(rendered, /Global settings: .*"proceed"/);
640+
assert.match(rendered, /Handoff resume behavior \(global save\)\s+proceed/);
641+
});
642+
643+
await withIsolatedSettings(async ({ home, cwd }) => {
644+
const globalPath = join(home, ".pi", "agent", "settings.json");
645+
await writeSettingsFile(globalPath, { handoff: { resumeBehavior: "wait" } });
646+
await writeSettingsFile(join(cwd, ".pi", "settings.json"), { handoff: { resumeBehavior: "wait" } });
647+
const ctx = { cwd, hasUI: true, ui: { notify: () => {} } } as any;
648+
const model = await buildAgenticodingSettingsModel(ctx);
649+
const component = createAgenticodingSettingsComponent(model, ctx, { requestRender: () => {} }, theme, () => {});
650+
651+
component.handleInput("\r");
652+
await new Promise(resolve => setTimeout(resolve, 50));
653+
654+
const saved = JSON.parse(await readFile(globalPath, "utf8"));
655+
assert.equal(saved.handoff.resumeBehavior, "proceed");
656+
const rendered = stripAnsi(component.render(120).join("\n"));
657+
assert.match(rendered, /Resolved handoff\.resumeBehavior: wait \(project\)/);
658+
assert.match(rendered, /Global settings: .*"proceed"/);
659+
assert.match(rendered, /Handoff resume behavior \(global save\)\s+proceed/);
660+
});
661+
});
662+
628663
test("agenticoding settings TUI handles invalid JSON policies", async () => {
629664
await withIsolatedSettings(async ({ home, cwd }) => {
630665
const globalPath = join(home, ".pi", "agent", "settings.json");
@@ -644,6 +679,28 @@ test("agenticoding settings TUI handles invalid JSON policies", async () => {
644679
assert.match(notifications.at(-1)?.message ?? "", /Invalid global settings JSON/);
645680
});
646681

682+
for (const nonObjectRoot of ["[]", "\"x\"", "42"]) {
683+
await withIsolatedSettings(async ({ home, cwd }) => {
684+
const globalPath = join(home, ".pi", "agent", "settings.json");
685+
await writeSettingsFile(globalPath, nonObjectRoot);
686+
const notifications: Array<{ message: string; level: string }> = [];
687+
const ctx = {
688+
cwd,
689+
hasUI: true,
690+
ui: { notify: (message: string, level: string) => notifications.push({ message, level }) },
691+
} as any;
692+
693+
const state = await readHandoffSettingsState(cwd);
694+
assert.equal(state.global.invalid, true);
695+
const invalidGlobal = await buildAgenticodingSettingsModel(ctx);
696+
assert.equal(invalidGlobal.globalWriteBlocked, true);
697+
assert.equal(await invalidGlobal.save("proceed", ctx), false);
698+
assert.equal(await readFile(globalPath, "utf8"), nonObjectRoot);
699+
assert.equal(notifications.at(-1)?.level, "error");
700+
assert.match(notifications.at(-1)?.message ?? "", /root must be an object/);
701+
});
702+
}
703+
647704
await withIsolatedSettings(async ({ home, cwd }) => {
648705
const globalPath = join(home, ".pi", "agent", "settings.json");
649706
await writeSettingsFile(join(cwd, ".pi", "settings.json"), "{");

settings.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ async function readSettingsSource(label: SettingsSourceLabel, path: string): Pro
141141

142142
try {
143143
const parsed = JSON.parse(raw);
144-
const settings = isPlainObject(parsed) ? cloneSettingsObject(parsed) : createSettingsObject();
144+
if (!isPlainObject(parsed)) {
145+
return { label, path, exists: true, invalid: true, settings: createSettingsObject(), resumeBehavior: undefined };
146+
}
147+
const settings = cloneSettingsObject(parsed);
145148
return { label, path, exists: true, invalid: false, settings, resumeBehavior: extractResumeBehavior(settings) };
146149
} catch {
147150
return { label, path, exists: true, invalid: true, settings: createSettingsObject(), resumeBehavior: undefined };
@@ -208,7 +211,11 @@ export async function writeGlobalHandoffResumeBehavior(
208211
if (raw !== undefined) {
209212
try {
210213
const parsed = JSON.parse(raw);
211-
settings = isPlainObject(parsed) ? cloneSettingsObject(parsed) : createSettingsObject();
214+
if (!isPlainObject(parsed)) {
215+
notify(ctx, `Invalid global settings JSON at ${path}; root must be an object, not writing handoff.resumeBehavior to avoid clobbering it.`, "error");
216+
return false;
217+
}
218+
settings = cloneSettingsObject(parsed);
212219
} catch {
213220
notify(ctx, `Invalid global settings JSON at ${path}; not writing handoff.resumeBehavior to avoid clobbering it.`, "error");
214221
return false;
@@ -273,6 +280,10 @@ function describeValue(value: unknown): string {
273280
return value === undefined ? "unset" : formatSettingValue(value);
274281
}
275282

283+
function getGlobalEditableHandoffResumeBehavior(model: AgenticodingSettingsModel): HandoffResumeBehavior {
284+
return isHandoffResumeBehavior(model.state.global.resumeBehavior) ? model.state.global.resumeBehavior : "wait";
285+
}
286+
276287
export function getAgenticodingSettingsDisplayLines(model: AgenticodingSettingsModel): string[] {
277288
const lines = [
278289
`Resolved handoff.resumeBehavior: ${model.effectiveBehavior} (${model.effectiveSource})`,
@@ -315,7 +326,7 @@ export function createAgenticodingSettingsComponent(
315326
const items: SettingItem[] = [{
316327
id: "handoff.resumeBehavior",
317328
label: "Handoff resume behavior (global save)",
318-
currentValue: model.effectiveBehavior,
329+
currentValue: getGlobalEditableHandoffResumeBehavior(model),
319330
values: SUPPORTED_HANDOFF_RESUME_BEHAVIORS,
320331
}];
321332

@@ -343,7 +354,7 @@ export function createAgenticodingSettingsComponent(
343354
try {
344355
const saved = await model.save(newValue, ctx);
345356
model = await buildAgenticodingSettingsModel(ctx);
346-
settingsList.updateValue("handoff.resumeBehavior", model.effectiveBehavior);
357+
settingsList.updateValue("handoff.resumeBehavior", getGlobalEditableHandoffResumeBehavior(model));
347358
if (saved && model.projectOverrideWarning) {
348359
notify(ctx, model.projectOverrideWarning, "warning");
349360
}

0 commit comments

Comments
 (0)