Skip to content
Closed
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
84 changes: 77 additions & 7 deletions src/browser/contexts/AgentContext.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { GlobalWindow } from "happy-dom";

import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
Expand All @@ -15,10 +15,17 @@ import { APIProvider, type APIClient } from "./API";
import { ProjectProvider } from "./ProjectContext";
import { RouterProvider } from "./RouterContext";
import { WorkspaceProvider } from "./WorkspaceContext";
import { createTestApiClient, createTestPreferencesConfig } from "@/browser/testUtils";
import {
createTestApiClient,
createTestConfig,
createTestPreferencesConfig,
} from "@/browser/testUtils";
import type { AgentAiDefaults } from "@/common/types/agentAiDefaults";
import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync";

let mockAgentDefinitions: AgentDefinitionDescriptor[] = [];
let mockWorkspaceMetadata = new Map<string, { parentWorkspaceId?: string; agentId?: string }>();
let mockAgentListFails = false;

const EXEC_AGENT: AgentDefinitionDescriptor = {
id: "exec",
Expand Down Expand Up @@ -100,16 +107,32 @@ function createEmptyAsyncIterable<T>(): AsyncIterable<T> {
};
}

function createApiClient(preferences?: UserPreferences): APIClient {
function createApiClient(
preferences?: UserPreferences,
agentAiDefaults?: AgentAiDefaults
): APIClient {
const workspaceMetadata = Array.from(
mockWorkspaceMetadata.entries(),
([workspaceId, overrides]) => createWorkspaceMetadata(workspaceId, overrides)
);

return createTestApiClient({
config: createTestPreferencesConfig(preferences),
config: {
...createTestPreferencesConfig(preferences),
...(agentAiDefaults != null
? {
getConfig: () =>
Promise.resolve(
createTestConfig({ userPreferences: preferences ?? {}, agentAiDefaults })
),
}
: {}),
},
agents: {
list: () => Promise.resolve(mockAgentDefinitions),
list: () =>
mockAgentListFails
? Promise.reject(new Error("agents unavailable"))
: Promise.resolve(mockAgentDefinitions),
},
workspace: {
list: () => Promise.resolve(workspaceMetadata),
Expand Down Expand Up @@ -151,10 +174,11 @@ function renderAgentHarness(props: {
projectPath: string;
workspaceId?: string;
preferences?: UserPreferences;
agentAiDefaults?: AgentAiDefaults;
onChange: (value: AgentContextValue) => void;
}) {
return render(
<APIProvider client={createApiClient(props.preferences)}>
<APIProvider client={createApiClient(props.preferences, props.agentAiDefaults)}>
<RouterProvider>
<ProjectProvider>
<WorkspaceProvider>
Expand All @@ -176,6 +200,7 @@ describe("AgentContext", () => {
beforeEach(() => {
mockAgentDefinitions = [];
mockWorkspaceMetadata = new Map();
mockAgentListFails = false;

originalWindow = globalThis.window;
originalDocument = globalThis.document;
Expand All @@ -195,7 +220,10 @@ describe("AgentContext", () => {

afterEach(() => {
cleanup();
getAppConfigStore().updateOptimistically({ userPreferences: undefined });
getAppConfigStore().updateOptimistically({
userPreferences: undefined,
agentAiDefaults: undefined,
});
getWorkspaceStoreRaw().dispose();
mock.restore();
globalThis.window = originalWindow;
Expand All @@ -220,6 +248,48 @@ describe("AgentContext", () => {
expect(getUserPreferences().ai?.projectDefaults).toBeUndefined();
});

test("readers outside the agent context resolve a custom agent's inherited defaults", async () => {
const workspaceId = "ws-custom-agent";
mockAgentDefinitions = [EXEC_AGENT, { ...REVIEW_PROJECT_AGENT, base: "exec" }];
mockWorkspaceMetadata = new Map([[workspaceId, { agentId: "review" }]]);

renderAgentHarness({
projectPath: "/tmp/project",
workspaceId,
agentAiDefaults: { exec: { modelString: "openai:gpt-5.2" } },
onChange: () => undefined,
});

await waitFor(() => {
expect(getWorkspaceAiSelection(workspaceId, "review").model).toBe("openai:gpt-5.2");
});
});

test("a failed agent refresh drops the inherited defaults it loaded before", async () => {
const workspaceId = "ws-custom-agent";
mockAgentDefinitions = [EXEC_AGENT, { ...REVIEW_PROJECT_AGENT, base: "exec" }];
mockWorkspaceMetadata = new Map([[workspaceId, { agentId: "review" }]]);
let contextValue: AgentContextValue | undefined;

renderAgentHarness({
projectPath: "/tmp/project",
workspaceId,
agentAiDefaults: { exec: { modelString: "openai:gpt-5.2" } },
onChange: (value) => (contextValue = value),
});
await waitFor(() => {
expect(getWorkspaceAiSelection(workspaceId, "review").model).toBe("openai:gpt-5.2");
});

mockAgentListFails = true;
await act(async () => {
await contextValue?.refresh();
});

expect(contextValue?.loadFailed).toBe(true);
expect(getWorkspaceAiSelection(workspaceId, "review").model).not.toBe("openai:gpt-5.2");
});

test("project-scoped preference takes precedence over global default", async () => {
const projectPath = "/tmp/project";

Expand Down
3 changes: 3 additions & 0 deletions src/browser/contexts/AgentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
useScopedAiDefault,
writeScopedAiDefault,
} from "@/browser/utils/scopedAiDefaults";
import { setAgentBases } from "@/browser/utils/aiSelectionIntent";
import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
import { sortAgentsStable } from "@/browser/utils/agents";
import { normalizeAgentId, resolveRemovedBuiltinAgentId } from "@/common/utils/agentIds";
Expand Down Expand Up @@ -198,6 +199,7 @@ function AgentProviderWithState(props: {
isMountedRef.current
) {
setAgents(result);
setAgentBases(getScopeId(workspaceId, projectPath), result);
Comment thread
ibetitsmike marked this conversation as resolved.
setLoadFailed(false);
setLoaded(true);
}
Expand All @@ -210,6 +212,7 @@ function AgentProviderWithState(props: {
isMountedRef.current
) {
setAgents([]);
setAgentBases(getScopeId(workspaceId, projectPath), []);
setLoadFailed(true);
setLoaded(true);
}
Expand Down
23 changes: 23 additions & 0 deletions src/browser/utils/aiSelectionIntent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type WorkspaceAiMetadata = Pick<

const pendingByScope = new Map<string, Partial<Record<AiSelectionField, PendingSelection>>>();
const metadataByWorkspace = new Map<string, WorkspaceAiMetadata>();
const agentBasesByScope = new Map<string, ReadonlyMap<string, string | undefined>>();
const listeners = new Set<() => void>();
let nextToken = 1;
let version = 0;
Expand Down Expand Up @@ -63,6 +64,27 @@ export function getWorkspaceAiMetadata(workspaceId: string): WorkspaceAiMetadata
return metadataByWorkspace.get(workspaceId);
}

/**
* Agent id -> base id of the agents loaded for a workspace or creation scope, so readers outside
* the agent context still resolve a custom agent's inherited defaults.
*/
export function setAgentBases(
scopeId: string,
agents: ReadonlyArray<{ id: string; base?: string }>
): void {
const bases = new Map(agents.map((agent) => [agent.id, agent.base]));
Comment thread
ibetitsmike marked this conversation as resolved.
const previous = agentBasesByScope.get(scopeId);
if (previous != null && JSON.stringify([...previous]) === JSON.stringify([...bases])) return;
agentBasesByScope.set(scopeId, bases);
notify();
}

export function getAgentBases(
scopeId: string
): ReadonlyMap<string, string | undefined> | undefined {
return agentBasesByScope.get(scopeId);
}

function normalizeAgent(agentId: string): string {
return agentId.trim().toLowerCase() || WORKSPACE_DEFAULTS.agentId;
}
Expand Down Expand Up @@ -215,5 +237,6 @@ export function dropPendingModelPicks(shouldDrop: (model: string) => boolean): v
export function resetAiSelectionIntentForTests(): void {
pendingByScope.clear();
metadataByWorkspace.clear();
agentBasesByScope.clear();
notify();
}
19 changes: 19 additions & 0 deletions src/browser/utils/workspaceAiSettingsSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getAppConfigStore } from "@/browser/stores/AppConfigStore";
import {
markAiSelectionIntent,
resetAiSelectionIntentForTests,
setAgentBases,
setWorkspaceAiMetadata,
} from "@/browser/utils/aiSelectionIntent";
import {
Expand Down Expand Up @@ -126,6 +127,24 @@ describe("getWorkspaceAiSelection", () => {
}
});

test("a custom agent resolves its base agent's defaults from the loaded agent list", () => {
getAppConfigStore().updateOptimistically({
defaultModel: "openai:global",
agentAiDefaults: { exec: { modelString: "openai:configured", thinkingLevel: "high" } },
});
setWorkspaceAiMetadata(WS, {
projectPath: PROJECT,
aiSettings: undefined,
aiSettingsByAgent: undefined,
});
setAgentBases(WS, [{ id: "exec" }, { id: "reviewer", base: "exec" }]);

expect(getWorkspaceAiSelection(WS, "reviewer")).toMatchObject({
model: "openai:configured",
thinkingLevel: "high",
});
});

test("the hook follows a default model that loads after it rendered", () => {
setWorkspaceAiMetadata(WS, {
projectPath: PROJECT,
Expand Down
3 changes: 2 additions & 1 deletion src/browser/utils/workspaceAiSettingsSync.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useSyncExternalStore } from "react";
import { normalizeModelPreference } from "@/browser/utils/messages/buildSendMessageOptions";
import {
getAgentBases,
getAiSelectionVersion,
getPendingAiSelection,
getWorkspaceAiMetadata,
Expand Down Expand Up @@ -59,7 +60,7 @@ function resolveWorkspaceAiSelection(input: WorkspaceAiSelectionInput): Workspac
const configured = resolveConfiguredAiDefaults(
agentId,
input.agentAiDefaults,
input.agentBaseById
input.agentBaseById ?? getAgentBases(input.workspaceId)
Comment thread
ibetitsmike marked this conversation as resolved.
);
const project =
metadata?.projectPath != null ? input.ai?.projectDefaults?.[metadata.projectPath] : undefined;
Expand Down
Loading