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:
can1357
2026-08-20 08:13:15 +02:00
parent b9cfff73ec
commit 993032fd81
13 changed files with 215 additions and 21 deletions
@@ -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
+60 -1
View File
@@ -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",