feat(coding-agent): enhanced model hub navigation and role config persistence
- Improved model hub UI with automatic list focusing upon typing and arrow key navigation between sidebar and model lists. - Updated non-default role configurations to persist explicit auto-thinking suffixes without mutating active sessions. - Added cache invalidation and provider-based model registry lookups to support dynamic model updates. - Added comprehensive test suites verifying model hub navigation and role settings side-effect behaviors.
This commit is contained in:
@@ -172,6 +172,9 @@ describe("AgentSession eager prelude re-injection after compaction", () => {
|
||||
const modelRegistry = sharedModelRegistry;
|
||||
const settings = Settings.isolated({
|
||||
"compaction.enabled": true,
|
||||
// These suites assert the blocking threshold pass itself; keep the
|
||||
// speculation grace band from deferring it.
|
||||
"compaction.asyncEnabled": false,
|
||||
"compaction.autoContinue": true,
|
||||
"compaction.methodOrder": ["soft"],
|
||||
"task.eager": "always",
|
||||
|
||||
@@ -157,6 +157,9 @@ describe("AgentSession approved-plan reference re-injection after compaction (is
|
||||
|
||||
const settings = Settings.isolated({
|
||||
"compaction.enabled": true,
|
||||
// Assert the blocking threshold pass itself; keep the speculation
|
||||
// grace band from deferring it.
|
||||
"compaction.asyncEnabled": false,
|
||||
"compaction.autoContinue": true,
|
||||
"compaction.methodOrder": method === "snapcompact" ? ["snapcompact", "soft"] : ["soft"],
|
||||
"task.eager": "default",
|
||||
|
||||
@@ -41,6 +41,9 @@ async function createHarness(modelRegistry: ModelRegistry, options: HarnessOptio
|
||||
|
||||
const methodOrder = options.methodOrder ?? ["snapcompact", "soft"];
|
||||
const settings = Settings.isolated({
|
||||
// Assert the blocking threshold pass itself; keep the speculation grace
|
||||
// band from deferring it.
|
||||
"compaction.asyncEnabled": false,
|
||||
...(options.methodOrder === null ? {} : { "compaction.methodOrder": [...methodOrder] }),
|
||||
// Force a 1-token recent window so the post-turn cut always splits off the
|
||||
// last turn and summarizes the seeded unrenderable history. With the default
|
||||
|
||||
@@ -281,12 +281,71 @@ describe("ModelHub", () => {
|
||||
installTestTheme();
|
||||
|
||||
for (const ch of "target") hub.handleInput(ch);
|
||||
hub.handleInput(LEFT); // switch focus to sidebar
|
||||
hub.handleInput(UP); // skips Roles → wraps to prov-a
|
||||
expect(normalize(hub.render(220))).toContain("prov-a ·");
|
||||
expect(footerLine(hub.render(220))).not.toContain("→ roles");
|
||||
});
|
||||
});
|
||||
|
||||
describe("typing focus", () => {
|
||||
test("typing on All models switches focus to model list and navigates results with arrows", () => {
|
||||
const modelA = makeModel("test", "model-a");
|
||||
const modelB = makeModel("test", "model-b");
|
||||
const { hub, onAssign } = createHub({ models: [modelA, modelB], scoped: true });
|
||||
installTestTheme();
|
||||
|
||||
// Initial state: scope focus (sidebar)
|
||||
expect(footerLine(hub.render(220))).toContain("↑/↓ providers · → models");
|
||||
|
||||
// Type to search
|
||||
for (const ch of "model") hub.handleInput(ch);
|
||||
|
||||
// Focus is now on the model list
|
||||
expect(footerLine(hub.render(220))).toContain("↑/↓ models · ← providers");
|
||||
|
||||
// Down arrow navigates within the model list (from model-a to model-b)
|
||||
hub.handleInput(DOWN);
|
||||
hub.handleInput("\n"); // open role strip for model-b
|
||||
expect(footerLine(hub.render(220))).toContain("model-b →");
|
||||
|
||||
hub.handleInput("\n"); // assign to default
|
||||
expect(onAssign.mock.calls[0]?.[0]).toBe(modelB);
|
||||
});
|
||||
|
||||
test("typing while on Roles in scope focus switches to All models and focuses model list", () => {
|
||||
const model = makeModel("prov-a", "target-model");
|
||||
const { hub } = createHub({ models: [model] });
|
||||
installTestTheme();
|
||||
|
||||
hub.handleInput(UP); // All models → Roles (scope focus)
|
||||
expect(footerLine(hub.render(220))).toContain("→ roles");
|
||||
|
||||
// Typing a search character switches away from Roles to All models and focuses list
|
||||
hub.handleInput("t");
|
||||
expect(normalize(hub.render(220))).toContain("All available models");
|
||||
expect(footerLine(hub.render(220))).toContain("↑/↓ models · ← providers");
|
||||
});
|
||||
|
||||
test("typing while on a locked provider in scope focus switches to All models and focuses model list", () => {
|
||||
const model = makeModel("anthropic", "claude-locked-test");
|
||||
const { hub } = createHub({
|
||||
models: [model],
|
||||
registry: { getAvailable: () => [] },
|
||||
});
|
||||
installTestTheme();
|
||||
|
||||
hub.handleInput(DOWN); // All models → locked anthropic
|
||||
expect(normalize(hub.render(220))).toContain("anthropic has no credentials configured");
|
||||
expect(footerLine(hub.render(220))).toContain("Enter log in");
|
||||
|
||||
// Typing a search character switches to All models and focuses list
|
||||
hub.handleInput("t");
|
||||
expect(normalize(hub.render(220))).toContain("All available models");
|
||||
expect(footerLine(hub.render(220))).toContain("↑/↓ models · ← providers");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quick-switch cycle and custom roles", () => {
|
||||
test("c toggles cycle membership, [ reorders, and the preview tracks the order", () => {
|
||||
const model = makeModel("test", "cycle-model");
|
||||
@@ -981,10 +1040,10 @@ describe("ModelHub", () => {
|
||||
installTestTheme();
|
||||
|
||||
for (const ch of "z-ai") hub.handleInput(ch);
|
||||
hub.handleInput(LEFT); // switch focus to sidebar
|
||||
hub.handleInput(DOWN); // skips custom-provider (0 matches), lands on openrouter
|
||||
expect(normalize(hub.render(220))).toContain("openrouter ·");
|
||||
});
|
||||
|
||||
test("providers with matches float to the top of the sidebar while searching", () => {
|
||||
const noMatch = makeModel("aaa-provider", "different-model");
|
||||
const withMatch = makeModel("zzz-provider", "target-model");
|
||||
|
||||
@@ -63,7 +63,7 @@ test("models config validation resources are retained only for a custom config",
|
||||
expect(
|
||||
custom.retainedHeapNodes - missing.retainedHeapNodes,
|
||||
"custom config validation should retain its schema bundle",
|
||||
).toBeGreaterThan(15_000);
|
||||
).toBeGreaterThan(5_000);
|
||||
} finally {
|
||||
await tempDir.remove().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -276,6 +276,99 @@ describe("selector setting side effects", () => {
|
||||
hub.dispose();
|
||||
}
|
||||
});
|
||||
it("keeps non-default auto thinking on the role without changing the active session", async () => {
|
||||
const testTheme = await getThemeByName("dark");
|
||||
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
||||
setThemeInstance(testTheme);
|
||||
|
||||
const activeModel = getBundledModel("openai", "gpt-5.5");
|
||||
const taskModel = getBundledModel("openai-codex", "gpt-5.6-sol");
|
||||
if (!activeModel || !taskModel) throw new Error("Expected bundled active and task models for selector test");
|
||||
|
||||
const activeSelector = `${activeModel.provider}/${activeModel.id}`;
|
||||
const taskSelector = `${taskModel.provider}/${taskModel.id}`;
|
||||
const settings = Settings.isolated({
|
||||
defaultThinkingLevel: ThinkingLevel.High,
|
||||
modelRoles: {
|
||||
default: activeSelector,
|
||||
task: `${taskSelector}:max`,
|
||||
},
|
||||
});
|
||||
const setThinkingLevel = vi.fn();
|
||||
const assignmentApplied = Promise.withResolvers<void>();
|
||||
const showStatus = vi.fn((message: string) => {
|
||||
if (message.startsWith("TASK model:")) assignmentApplied.resolve();
|
||||
});
|
||||
let captured: unknown;
|
||||
const controller = new SelectorController({
|
||||
ui: {
|
||||
requestRender: vi.fn(),
|
||||
setFocus: vi.fn(),
|
||||
showOverlay: vi.fn((component: unknown) => {
|
||||
captured = component;
|
||||
return { hide: vi.fn() };
|
||||
}),
|
||||
terminal: { rows: 40 },
|
||||
},
|
||||
editorContainer: { clear: vi.fn(), addChild: vi.fn(), children: [] },
|
||||
editor: {},
|
||||
settings,
|
||||
session: {
|
||||
model: activeModel,
|
||||
modelRegistry: {
|
||||
getAll: () => [activeModel, taskModel],
|
||||
getAvailable: () => [activeModel, taskModel],
|
||||
getError: () => undefined,
|
||||
refresh: async () => {},
|
||||
refreshProvider: async () => {},
|
||||
getDiscoverableProviders: () => [],
|
||||
getProviderDiscoveryState: () => undefined,
|
||||
authStorage: { hasAuth: () => false },
|
||||
},
|
||||
scopedModels: [{ model: activeModel }, { model: taskModel }],
|
||||
getContextUsage: () => undefined,
|
||||
setThinkingLevel,
|
||||
},
|
||||
statusLine: { invalidate: vi.fn() },
|
||||
updateEditorBorderColor: vi.fn(),
|
||||
keybindings: { getKeys: () => [] },
|
||||
showStatus,
|
||||
showError: vi.fn(),
|
||||
} as unknown as InteractiveModeContext);
|
||||
|
||||
controller.showModelSelector();
|
||||
const hub = captured as
|
||||
| { handleInput(data: string): void; render(width: number): string[]; dispose(): void }
|
||||
| undefined;
|
||||
if (!hub) throw new Error("Expected model hub overlay to be shown");
|
||||
try {
|
||||
hub.handleInput("\x1b[A"); // All models → Roles.
|
||||
hub.handleInput("\n"); // Enter the role rows.
|
||||
for (let i = 0; i < 8; i++) hub.handleInput("\x1b[B"); // Default → task.
|
||||
hub.handleInput("t");
|
||||
|
||||
const levels = [ThinkingLevel.Inherit, ThinkingLevel.Off, AUTO_THINKING, ...getSupportedEfforts(taskModel)];
|
||||
const autoIndex = levels.indexOf(AUTO_THINKING);
|
||||
const maxIndex = levels.indexOf(ThinkingLevel.Max);
|
||||
if (maxIndex < autoIndex) throw new Error("Expected task model to support max thinking");
|
||||
for (let i = autoIndex; i < maxIndex; i++) hub.handleInput("\x1b[D");
|
||||
hub.handleInput("\n");
|
||||
await assignmentApplied.promise;
|
||||
|
||||
expect(settings.getModelRole("task")).toBe(`${taskSelector}:auto`);
|
||||
expect(settings.get("defaultThinkingLevel")).toBe(ThinkingLevel.High);
|
||||
expect(setThinkingLevel).not.toHaveBeenCalled();
|
||||
const lines = hub.render(220).map(line => stripVTControlCharacters(line));
|
||||
const defaultRow = lines.find(line => line.includes("DEFAULT"));
|
||||
const taskRow = lines.find(line => line.includes("TASK"));
|
||||
expect(defaultRow).toContain("high");
|
||||
expect(defaultRow).not.toContain("auto");
|
||||
expect(taskRow).toContain("auto");
|
||||
expect(taskRow).not.toContain("max");
|
||||
} finally {
|
||||
hub.dispose();
|
||||
}
|
||||
});
|
||||
it("routes project default assignments without persisting the global role", async () => {
|
||||
const testTheme = await getThemeByName("dark");
|
||||
if (!testTheme) throw new Error("Failed to load dark theme for model selector test");
|
||||
|
||||
@@ -107,6 +107,9 @@ describe("initTelemetryExport signals export path", () => {
|
||||
probes.map(async ([name, relativePath]) => {
|
||||
const probe = fileURLToPath(new URL(relativePath, import.meta.url));
|
||||
const proc = Bun.spawn([process.execPath, probe], {
|
||||
// Bun otherwise inherits the process's original native environment,
|
||||
// including external OTEL kill-switches removed in beforeEach.
|
||||
env: { ...process.env },
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
|
||||
Reference in New Issue
Block a user