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
39 changes: 31 additions & 8 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useAPI } from "@/browser/contexts/API";
import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel";
import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange";
import {
clearPendingWorkspaceAiSettings,
markPendingWorkspaceAiSettings,
Expand Down Expand Up @@ -499,11 +500,18 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

const canonicalModel = migrateGatewayModel(model);
ensureModelInSettings(canonicalModel); // Ensure model exists in Settings
updatePersistedState(storageKeys.modelKey, canonicalModel); // Update workspace or project-specific

// Notify parent of model change (for context switch warning)
// Called before early returns so warning works even offline or with custom agents
onModelChange?.(canonicalModel);
if (onModelChange) {
// Notify parent of model change (for context switch warning + persisted model metadata).
// Called before early returns so warnings work even offline or with custom agents.
onModelChange(canonicalModel);
} else {
const scopeId =
variant === "creation" ? getProjectScopeId(creationProjectPath) : workspaceId;
if (scopeId) {
setWorkspaceModelWithOrigin(scopeId, canonicalModel, "user");
}
}

if (variant !== "workspace" || !workspaceId) {
return;
Expand Down Expand Up @@ -556,12 +564,12 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
[
api,
agentId,
storageKeys.modelKey,
creationProjectPath,
ensureModelInSettings,
onModelChange,
thinkingLevel,
variant,
workspaceId,
onModelChange,
]
);

Expand Down Expand Up @@ -797,10 +805,17 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
!coderPresetsLoading &&
!policyBlocksCreateSend;

// User request: this sync effect runs on mount and when defaults/config change.
// Only treat *real* agent changes as explicit (origin "agent"); everything else is "sync".
const prevCreationAgentIdRef = useRef<string | null>(null);
const prevCreationScopeIdRef = useRef<string | null>(null);
// Creation variant: keep the project-scoped model/thinking in sync with global agent defaults
// so switching agents uses the configured defaults (and respects "inherit" semantics).
useEffect(() => {
if (variant !== "creation") {
// Reset tracking on variant transitions so creation entry never counts as an explicit switch.
prevCreationAgentIdRef.current = null;
prevCreationScopeIdRef.current = null;
return;
}

Expand All @@ -815,6 +830,15 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
? agentId.trim().toLowerCase()
: "exec";

const isExplicitAgentSwitch =
prevCreationAgentIdRef.current !== null &&
prevCreationScopeIdRef.current === scopeId &&
prevCreationAgentIdRef.current !== normalizedAgentId;

// Update refs for the next run (even if no model changes).
prevCreationAgentIdRef.current = normalizedAgentId;
prevCreationScopeIdRef.current = scopeId;

const existingModel = readPersistedState<string>(modelKey, fallbackModel);
const candidateModel = agentAiDefaults[normalizedAgentId]?.modelString ?? existingModel;
const resolvedModel =
Expand All @@ -828,7 +852,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off";

if (existingModel !== resolvedModel) {
updatePersistedState(modelKey, resolvedModel);
setWorkspaceModelWithOrigin(scopeId, resolvedModel, isExplicitAgentSwitch ? "agent" : "sync");
}

if (existingThinking !== resolvedThinking) {
Expand Down Expand Up @@ -1485,7 +1509,6 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
setAttachments,
setSendingState: (increment: boolean) => setSendingCount((c) => c + (increment ? 1 : -1)),
setToast,
onModelChange: props.onModelChange,
setPreferredModel,
setVimEnabled,
onTruncateHistory: variant === "workspace" ? props.onTruncateHistory : undefined,
Expand Down
3 changes: 2 additions & 1 deletion src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { RuntimeChoice } from "@/browser/utils/runtimeUi";
import { buildRuntimeConfig, RUNTIME_MODE } from "@/common/types/runtime";
import type { ThinkingLevel } from "@/common/types/thinking";
import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings";
import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions";
import {
Expand Down Expand Up @@ -70,7 +71,7 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void
// This ensures the model used for creation is persisted for future resumes
const projectModel = readPersistedState<string | null>(getModelKey(projectScopeId), null);
if (projectModel) {
updatePersistedState(getModelKey(workspaceId), projectModel);
setWorkspaceModelWithOrigin(workspaceId, projectModel, "sync");
}

const projectAgentId = readPersistedState<string | null>(getAgentIdKey(projectScopeId), null);
Expand Down
8 changes: 6 additions & 2 deletions src/browser/components/ContextSwitchWarning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export const ContextSwitchWarning: React.FC<Props> = (props) => {
: null;

return (
<div className="bg-plan-mode/10 border-plan-mode/30 mx-4 my-2 rounded-md border px-4 py-3">
<div
data-testid="context-switch-warning"
className="bg-plan-mode/10 border-plan-mode/30 mx-4 my-2 rounded-md border px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="text-plan-mode mb-1 flex items-center gap-2 text-[13px] font-medium">
Expand All @@ -43,8 +46,9 @@ export const ContextSwitchWarning: React.FC<Props> = (props) => {
onClick={props.onDismiss}
className="text-muted hover:text-foreground -mt-1 -mr-1 cursor-pointer p-1"
title="Dismiss"
aria-label="Dismiss context limit warning"
>
<X size={14} />
<X size={14} aria-hidden="true" />
</button>
</div>
<div className="mt-2.5 flex items-center gap-3">
Expand Down
90 changes: 90 additions & 0 deletions src/browser/components/WorkspaceModeAISync.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from "react";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import { cleanup, render, waitFor } from "@testing-library/react";

import { AgentProvider } from "@/browser/contexts/AgentContext";
import { consumeWorkspaceModelChange } from "@/browser/utils/modelChange";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { AGENT_AI_DEFAULTS_KEY, getModelKey } from "@/common/constants/storage";

import { WorkspaceModeAISync } from "./WorkspaceModeAISync";

let workspaceCounter = 0;

function nextWorkspaceId(): string {
workspaceCounter += 1;
return `workspace-mode-ai-sync-test-${workspaceCounter}`;
}

const noop = () => {
// intentional noop for tests
};

describe("WorkspaceModeAISync", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
globalThis.localStorage = globalThis.window.localStorage;
globalThis.localStorage.clear();
});

afterEach(() => {
cleanup();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
globalThis.localStorage = undefined as unknown as Storage;
});

test("only records explicit model changes when agentId changes", async () => {
const workspaceId = nextWorkspaceId();

const execModel = "openai:gpt-4o-mini";
const planModel = "anthropic:claude-3-5-sonnet-latest";

updatePersistedState(AGENT_AI_DEFAULTS_KEY, {
exec: { modelString: execModel },
plan: { modelString: planModel },
});

// Start with a different model so the mount sync performs an update.
updatePersistedState(getModelKey(workspaceId), "some-legacy-model");

function Harness(props: { agentId: string }) {
return (
<AgentProvider
value={{
agentId: props.agentId,
setAgentId: noop,
currentAgent: undefined,
agents: [],
loaded: true,
loadFailed: false,
refresh: () => Promise.resolve(),
refreshing: false,
disableWorkspaceAgents: false,
setDisableWorkspaceAgents: noop,
}}
>
<WorkspaceModeAISync workspaceId={workspaceId} />
</AgentProvider>
);
}

const { rerender } = render(<Harness agentId="exec" />);

// Mount sync should update the model but NOT record an explicit change entry.
await waitFor(() => {
expect(readPersistedState(getModelKey(workspaceId), "")).toBe(execModel);
});
expect(consumeWorkspaceModelChange(workspaceId, execModel)).toBeNull();

// Switching agents (within the same workspace) should be treated as explicit.
rerender(<Harness agentId="plan" />);

await waitFor(() => {
expect(readPersistedState(getModelKey(workspaceId), "")).toBe(planModel);
});
expect(consumeWorkspaceModelChange(workspaceId, planModel)).toBe("agent");
});
});
24 changes: 22 additions & 2 deletions src/browser/components/WorkspaceModeAISync.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useAgent } from "@/browser/contexts/AgentContext";
import {
readPersistedState,
Expand All @@ -12,6 +12,7 @@ import {
AGENT_AI_DEFAULTS_KEY,
} from "@/common/constants/storage";
import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings";
import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange";
import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking";
import type { AgentAiDefaults } from "@/common/types/agentAiDefaults";

Expand All @@ -34,6 +35,12 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null {
{ listener: true }
);

// User request: this effect runs on mount and during background sync (defaults/config).
// Only treat *real* agentId changes as explicit (origin "agent"); everything else is "sync"
// so we don't show context-switch warnings on workspace entry.
const prevAgentIdRef = useRef<string | null>(null);
const prevWorkspaceIdRef = useRef<string | null>(null);

useEffect(() => {
const fallbackModel = getDefaultModel();
const modelKey = getModelKey(workspaceId);
Expand All @@ -44,6 +51,15 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null {
? agentId.trim().toLowerCase()
: "exec";

const isExplicitAgentSwitch =
prevAgentIdRef.current !== null &&
prevWorkspaceIdRef.current === workspaceId &&
prevAgentIdRef.current !== normalizedAgentId;

// Update refs for the next run (even if no model changes).
prevAgentIdRef.current = normalizedAgentId;
prevWorkspaceIdRef.current = workspaceId;

const activeDescriptor = agents.find((entry) => entry.id === normalizedAgentId);
const fallbackAgentId =
activeDescriptor?.base ?? (normalizedAgentId === "plan" ? "plan" : "exec");
Expand Down Expand Up @@ -85,7 +101,11 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null {
const resolvedThinking = coerceThinkingLevel(candidateThinking) ?? "off";

if (existingModel !== resolvedModel) {
updatePersistedState(modelKey, resolvedModel);
setWorkspaceModelWithOrigin(
workspaceId,
resolvedModel,
isExplicitAgentSwitch ? "agent" : "sync"
);
}

if (existingThinking !== resolvedThinking) {
Expand Down
110 changes: 110 additions & 0 deletions src/browser/contexts/PolicyContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import React from "react";
import type { APIClient } from "@/browser/contexts/API";
import type { PolicyGetResponse } from "@/common/orpc/types";

// Idiomatic pattern: mock @/browser/contexts/API at the top of the file
// before importing PolicyProvider. This ensures our mock takes precedence
// even when other test files have already mocked the same module (bun module
// mocks leak between files: https://github.com/oven-sh/bun/issues/12823).

async function* emptyStream() {
// no-op
}

let mockGet: () => Promise<PolicyGetResponse>;

void mock.module("@/browser/contexts/API", () => ({
useAPI: () => ({
api: {
policy: {
get: () => mockGet(),
onChanged: () => Promise.resolve(emptyStream()),
},
} as unknown as APIClient,
status: "connected" as const,
error: null,
}),
APIProvider: ({ children }: { children: React.ReactNode }) => children,
}));

// Import AFTER the mock is registered
import { PolicyProvider, usePolicy } from "./PolicyContext";

const buildBlockedResponse = (reason: string): PolicyGetResponse => ({
source: "governor",
status: { state: "blocked", reason },
policy: null,
});

const buildEnforcedResponse = (): PolicyGetResponse => ({
source: "governor",
status: { state: "enforced" },
policy: {
policyFormatVersion: "0.1",
providerAccess: null,
mcp: { allowUserDefined: { stdio: true, remote: true } },
runtimes: null,
},
});

const Wrapper = (props: { children: React.ReactNode }) =>
React.createElement(PolicyProvider, null, props.children);
Wrapper.displayName = "PolicyContextTestWrapper";

describe("PolicyContext", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
globalThis.localStorage = globalThis.window.localStorage;
globalThis.localStorage.clear();
});

afterEach(() => {
cleanup();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
globalThis.localStorage = undefined as unknown as Storage;
});

test("updates when blocked reason changes", async () => {
// Keep this mock resilient to multiple mount refreshes (e.g. StrictMode).
let current = buildBlockedResponse("Reason A");
mockGet = () => Promise.resolve(current);

const { result } = renderHook(() => usePolicy(), { wrapper: Wrapper });

await waitFor(() => expect(result.current.status.reason).toBe("Reason A"), {
timeout: 3000,
});

current = buildBlockedResponse("Reason B");
await act(async () => {
await result.current.refresh();
});

await waitFor(() => expect(result.current.status.reason).toBe("Reason B"), {
timeout: 3000,
});
});

test("keeps identical policy responses stable", async () => {
mockGet = () => Promise.resolve(buildEnforcedResponse());

const { result } = renderHook(() => usePolicy(), { wrapper: Wrapper });

await waitFor(() => expect(result.current.policy).not.toBeNull(), { timeout: 3000 });

const firstPolicy = result.current.policy;
const firstStatus = result.current.status;

await act(async () => {
await result.current.refresh();
});

expect(result.current.policy).toBe(firstPolicy);
expect(result.current.status).toBe(firstStatus);
});
});
Loading
Loading