Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- Resolve readonly target-directory settings snapshots with normalized values and fresh operation-local metadata reads, without changing parent settings or publishing global hooks.
1 change: 1 addition & 0 deletions packages/coding-agent/src/capability/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ export async function loadCapability<T>(capabilityId: string, options: LoadOptio
repoRoot,
isolatedHome: false,
settings: ordinaryOptions.settings,
bypassCache: ordinaryOptions.bypassCache,
};
const providers = filterProviders(capability, ordinaryOptions);

Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/src/capability/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface LoadContext {
repoRoot: string | null;
/** Whether discovery must remain isolated to the supplied home boundary. */
isolatedHome?: boolean;
/** Bypass lexical filesystem caches for this discovery operation. */
bypassCache?: boolean;
/** Owning session settings for provider policy decisions. */
settings?: Settings;
}
Expand Down Expand Up @@ -112,6 +114,8 @@ export interface LoadOptions {
includeDisabledProviders?: boolean;
/** Explicit disabled extension IDs to apply instead of settings. */
disabledExtensions?: string[];
/** Bypass lexical filesystem caches for this discovery operation. */
bypassCache?: boolean;
/** Session settings whose provider policy applies to this load. */
settings?: Settings;
/** Internal: prevent explicit-home loads from consulting process-global policy. */
Expand Down
48 changes: 44 additions & 4 deletions packages/coding-agent/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,41 @@ export class Settings implements NotificationSettingsReader {
if (saveError !== undefined) throw saveError;
}

/** Copy execution settings without reloading, persisting, or sharing mutable layers. */
snapshot(): Settings {
const cloned = new Settings({ cwd: this.#cwd, agentDir: this.#agentDir, inMemory: true });
cloned.#global = structuredClone(this.#global);
cloned.#project = structuredClone(this.#project);
cloned.#overrides = structuredClone(this.#overrides);
cloned.#merged = structuredClone(this.#merged);
cloned.#schemaReport = structuredClone(this.#schemaReport);
cloned.#autoroutingEffective = structuredClone(this.#autoroutingEffective);
cloned.#autoroutingLocalIssues = structuredClone(this.#autoroutingLocalIssues);
cloned.#schemaMigrationPending = this.#schemaMigrationPending;
cloned.#futureSchemaVersion = this.#futureSchemaVersion;
cloned.#hasMalformedConfigRoot = this.#hasMalformedConfigRoot;
cloned.#hasRecoveredConfigSyntax = this.#hasRecoveredConfigSyntax;
cloned.#hasInvalidNotificationGlobal = this.#hasInvalidNotificationGlobal;
cloned.#notificationValidationGeneration = this.#notificationValidationGeneration;
cloned.#rawNotificationConfig = structuredClone(this.#rawNotificationConfig);
cloned.#durableRawNotificationConfig = structuredClone(this.#durableRawNotificationConfig);
cloned.#durableNotificationFingerprint = this.#durableNotificationFingerprint;
return cloned;
}

/** Resolve a retained execution's project layer without persistence or global hooks. */
async snapshotForCwd(cwd: string): Promise<Settings> {
const cloned = this.snapshot();
if (path.resolve(cwd) === path.resolve(this.#cwd)) return cloned;
cloned.#cwd = path.resolve(cwd);
cloned.#readonly = true;
cloned.#project = {};
cloned.#rebuildMerged();
cloned.#project = await cloned.#loadProjectSettings(true);
await cloned.#normalizeAfterLoad(false);
return cloned;
}

async cloneForCwd(cwd: string): Promise<Settings> {
// A clone shares the same config queue. Settle an already-reserved local
// debounce before the clone can enqueue a durable selector, preventing it
Expand Down Expand Up @@ -1572,9 +1607,14 @@ export class Settings implements NotificationSettingsReader {
return reconciled.settings;
}

async #loadProjectSettings(): Promise<RawSettings> {
async #loadProjectSettings(bypassCache = false): Promise<RawSettings> {
try {
const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd, settings: this });
const result = await loadCapability(settingsCapability.id, {
cwd: this.#cwd,
agentDir: this.#agentDir,
settings: this,
bypassCache,
});
let merged: RawSettings = {};
for (const item of result.items as SettingsCapabilityItem[]) {
if (item.level !== "project") continue;
Expand Down Expand Up @@ -1637,7 +1677,7 @@ export class Settings implements NotificationSettingsReader {
return settings;
}

async #normalizeAfterLoad(): Promise<void> {
async #normalizeAfterLoad(publishHooks = true): Promise<void> {
this.#sanitizeModelSelectorRecords();
this.#rebuildMerged();
if (!this.#futureSchemaVersion) {
Expand All @@ -1653,7 +1693,7 @@ export class Settings implements NotificationSettingsReader {
await this.flush();
this.#sanitizeModelSelectorRecords();
this.#rebuildMerged();
this.#fireAllHooks();
if (publishHooks) this.#fireAllHooks();
}

#sanitizeModelSelectorRecords(): void {
Expand Down
12 changes: 9 additions & 3 deletions packages/coding-agent/src/discovery/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,13 @@ export function getProjectPath(ctx: LoadContext, source: SourceId, subpath: stri

/** Build the filesystem authority for a provider read. */
export function getReadOptions(
ctx: Pick<LoadContext, "home" | "isolatedHome" | "userAgentDir" | "homeIdentity" | "userAgentIdentity">,
ctx: Pick<
LoadContext,
"home" | "isolatedHome" | "userAgentDir" | "homeIdentity" | "userAgentIdentity" | "bypassCache"
>,
scope: ReadScope,
): ReadFileOptions | undefined {
if (!ctx.isolatedHome) return undefined;
if (!ctx.isolatedHome) return ctx.bypassCache ? { bypassCache: true } : undefined;
return {
isolatedHome: true,
home: ctx.home,
Expand All @@ -219,7 +222,10 @@ export function getReadOptions(
}

export async function getReadOptionsForContainment(
ctx: Pick<LoadContext, "home" | "isolatedHome" | "userAgentDir" | "homeIdentity" | "userAgentIdentity">,
ctx: Pick<
LoadContext,
"home" | "isolatedHome" | "userAgentDir" | "homeIdentity" | "userAgentIdentity" | "bypassCache"
>,
scope: ReadScope,
containmentRoot?: string,
): Promise<ReadFileOptions | undefined> {
Expand Down
164 changes: 163 additions & 1 deletion packages/coding-agent/test/settings-session-memory.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
import { describe, expect, it } from "bun:test";
import { afterEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { CONFIG_DIR_NAME, getDefaultTabWidth, onDefaultTabWidthChange, setDefaultTabWidth } from "@gajae-code/utils";
import { YAML } from "bun";
import type { Settings as SettingsCapabilityItem } from "../src/capability/settings";
import { Settings } from "../src/config/settings";
import {
getDefault,
getEnumValues,
reconcileSettingsSchema,
SETTINGS_SCHEMA,
type SettingValue,
} from "../src/config/settings-schema";
import { loadCapability } from "../src/discovery";

const temporaryRoots: string[] = [];

async function createTemporaryRoot(): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-settings-snapshot-"));
temporaryRoots.push(root);
return root;
}

function projectSettingsPath(cwd: string): string {
return path.join(cwd, CONFIG_DIR_NAME, "settings.json");
}

async function writeProjectSettings(cwd: string, data: Record<string, unknown>): Promise<void> {
const settingsPath = projectSettingsPath(cwd);
await Bun.write(settingsPath, JSON.stringify(data));
}

afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true })));
});

describe("sessionMemory settings", () => {
it("defines automatic size routing alongside explicit modes and defaults to auto", () => {
Expand Down Expand Up @@ -66,3 +95,136 @@ describe("sessionMemory settings", () => {
expect(reconciled.settings).toEqual({});
});
});

describe("Settings.snapshotForCwd", () => {
it("normalizes global and target project settings without mutating the parent or firing global hooks", async () => {
const originalTabWidth = getDefaultTabWidth();
const parentTabWidth = originalTabWidth === 4 ? 5 : 4;
const targetTabWidth = parentTabWidth === 4 ? 5 : 4;
const observedTabWidths: number[] = [];
const removeTabWidthListener = onDefaultTabWidthChange(width => observedTabWidths.push(width));
try {
const root = await createTemporaryRoot();
const parentCwd = path.join(root, "parent");
const targetCwd = path.join(root, "target");
const agentDir = path.join(root, "agent");
const globalConfigPath = path.join(agentDir, "config.yml");
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(parentCwd, { recursive: true });
await writeProjectSettings(parentCwd, { ask: { timeout: 15_000 } });
await writeProjectSettings(targetCwd, {
ask: { timeout: 60_000 },
display: { tabWidth: targetTabWidth },
});
const globalConfig = {
ask: { timeout: 30_000 },
display: { tabWidth: parentTabWidth },
theme: { dark: "red-claw", light: "blue-crab" },
};
await Bun.write(globalConfigPath, YAML.stringify(globalConfig, null, 2));
const globalConfigBefore = await Bun.file(globalConfigPath).text();
const parentSettingsPath = projectSettingsPath(parentCwd);
const parentSettingsBefore = await Bun.file(parentSettingsPath).text();
const targetSettingsPath = projectSettingsPath(targetCwd);
const targetSettingsBefore = await Bun.file(targetSettingsPath).text();

const parent = await Settings.loadReadonly({ cwd: parentCwd, agentDir });
expect(parent.getGlobal("ask.timeout")).toBe(30);
expect(parent.get("ask.timeout")).toBe(15);
expect(getDefaultTabWidth()).toBe(parentTabWidth);
observedTabWidths.length = 0;
const changedPaths: string[] = [];
const unsubscribe = parent.onChanged(settingPath => changedPaths.push(settingPath));
try {
const target = await parent.snapshotForCwd(targetCwd);
expect(target.getCwd()).toBe(targetCwd);
expect(target.getGlobal("ask.timeout")).toBe(30);
expect(target.get("ask.timeout")).toBe(60);
expect(target.get("display.tabWidth")).toBe(targetTabWidth);
expect(parent.getCwd()).toBe(parentCwd);
expect(parent.get("ask.timeout")).toBe(15);
expect(observedTabWidths).toEqual([]);
expect(getDefaultTabWidth()).toBe(parentTabWidth);
expect(changedPaths).toEqual([]);

parent.override("ask.timeout", 45);
const changeCount = changedPaths.length;
const overridden = await parent.snapshotForCwd(targetCwd);
expect(overridden.getGlobal("ask.timeout")).toBe(30);
expect(overridden.get("ask.timeout")).toBe(45);
overridden.clearOverride("ask.timeout");
expect(overridden.get("ask.timeout")).toBe(60);
expect(parent.get("ask.timeout")).toBe(45);
expect(parent.getCwd()).toBe(parentCwd);
expect(changedPaths).toHaveLength(changeCount);
expect(observedTabWidths).toEqual([]);
expect(getDefaultTabWidth()).toBe(parentTabWidth);
} finally {
unsubscribe();
}

expect(await Bun.file(globalConfigPath).text()).toBe(globalConfigBefore);
expect(await Bun.file(parentSettingsPath).text()).toBe(parentSettingsBefore);
expect(await Bun.file(targetSettingsPath).text()).toBe(targetSettingsBefore);
} finally {
removeTabWidthListener();
setDefaultTabWidth(originalTabWidth);
}
});

it("loads different target directories and refreshes an edited cached target file", async () => {
const root = await createTemporaryRoot();
const targetA = path.join(root, "target-a");
const targetB = path.join(root, "target-b");
const parent = Settings.isolated({ "ask.timeout": 5 });
await writeProjectSettings(targetA, { ask: { timeout: 30_000 } });
await writeProjectSettings(targetB, { ask: { timeout: 120_000 } });

const firstA = await loadCapability<SettingsCapabilityItem>("settings", {
cwd: targetA,
includeDisabledProviders: true,
});
const firstB = await loadCapability<SettingsCapabilityItem>("settings", {
cwd: targetB,
includeDisabledProviders: true,
});
const settingsAPath = projectSettingsPath(targetA);
const itemA = firstA.items.find(item => item.level === "project" && item.path === settingsAPath);
expect(itemA?.data.ask).toEqual({ timeout: 30_000 });
const itemB = firstB.items.find(item => item.level === "project" && item.path === projectSettingsPath(targetB));
expect(itemB?.data.ask).toEqual({ timeout: 120_000 });

await writeProjectSettings(targetA, { ask: { timeout: 60_000 } });
const ordinaryReload = await loadCapability<SettingsCapabilityItem>("settings", {
cwd: targetA,
includeDisabledProviders: true,
});
const cachedItemA = ordinaryReload.items.find(item => item.level === "project" && item.path === settingsAPath);
expect(cachedItemA?.data.ask).toEqual({ timeout: 30_000 });

const snapshotA = await parent.snapshotForCwd(targetA);
const snapshotB = await parent.snapshotForCwd(targetB);
const refreshedA = await parent.snapshotForCwd(targetA);
expect(snapshotA.get("ask.timeout")).toBe(60);
expect(snapshotB.get("ask.timeout")).toBe(120);
expect(refreshedA.get("ask.timeout")).toBe(60);
expect(parent.get("ask.timeout")).toBe(5);
});

it("tolerates absent and malformed target settings files", async () => {
const root = await createTemporaryRoot();
const absentCwd = path.join(root, "absent");
const malformedCwd = path.join(root, "malformed");
const parent = Settings.isolated({ "ask.timeout": 9 });
const absent = await parent.snapshotForCwd(absentCwd);

const malformedPath = projectSettingsPath(malformedCwd);
await Bun.write(malformedPath, '{"ask":');
const malformed = await parent.snapshotForCwd(malformedCwd);

expect(absent.getCwd()).toBe(absentCwd);
expect(absent.get("ask.timeout")).toBe(9);
expect(malformed.getCwd()).toBe(malformedCwd);
expect(malformed.get("ask.timeout")).toBe(9);
});
});
Loading