-
Notifications
You must be signed in to change notification settings - Fork 51
🤖 fix: gate context switch warnings to explicit model switches #2117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
e2823fa
🤖 tests: avoid mock.module in context switch warning test
ethanndickson 304b386
fix: warn on 1M context limit toggles
ethanndickson 1f29dad
fix: preserve same-model warnings on refresh
ethanndickson 9f65520
fix: clear warnings on zero-token switch
ethanndickson 71d2218
Refactor context switch warning state
ethanndickson ffdf8c5
fix: simplify model change warnings
ethanndickson 28ca169
fix: clear deferred switches and restore story
ethanndickson b2bf811
chore: trigger ci
ethanndickson 8ce74d7
fix: normalize gateway model changes
ethanndickson 65bd41e
fix: guard policy warning refresh
ethanndickson a9e02e8
fix: stabilize policy refresh and warnings
ethanndickson 72d85f9
fix: avoid recording sync model updates as agent switches
ethanndickson 5cb2f29
test: use idiomatic API mock pattern in PolicyContext tests
ethanndickson cae318a
🤖 fix: context-switch warning follow-ups
ethanndickson 7bf1af7
🤖 merge: sync with origin/main
ethanndickson a93da57
🤖 fix: clear stale explicit model-change entries
ethanndickson b398264
Merge remote-tracking branch 'origin/main' into workspace-da0t
ethanndickson e5e361f
fix: add source field to PolicyContext test responses
ethanndickson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.