feat(KB-148): display per-task model overrides in agent log viewer

- Add model info header to AgentLogViewer with executor/validator display

- Show provider icons and model IDs when overrides are set

- Display 'Using default' badge when no override is configured

- Update TaskDetailModal to extract and pass model selections

- Add comprehensive tests for model display functionality
This commit is contained in:
gsxdsm
2026-03-30 08:17:21 -07:00
parent 82d33cefde
commit ca162abd16
3 changed files with 220 additions and 2 deletions

View File

@@ -1,15 +1,23 @@
import type { AgentLogEntry } from "@kb/core";
import { ProviderIcon } from "./ProviderIcon";
interface ModelInfo {
provider?: string;
modelId?: string;
}
interface AgentLogViewerProps {
entries: AgentLogEntry[];
loading: boolean;
executorModel?: ModelInfo | null;
validatorModel?: ModelInfo | null;
}
/**
* Renders agent log entries in a scrollable, monospace container.
* Displays entries in reverse chronological order (newest first).
*/
export function AgentLogViewer({ entries, loading }: AgentLogViewerProps) {
export function AgentLogViewer({ entries, loading, executorModel, validatorModel }: AgentLogViewerProps) {
if (loading && entries.length === 0) {
return (
<div className="agent-log-viewer" data-testid="agent-log-viewer">
@@ -29,6 +37,9 @@ export function AgentLogViewer({ entries, loading }: AgentLogViewerProps) {
// Reverse entries so newest appear first
const reversedEntries = [...entries].reverse();
const hasExecutorOverride = executorModel?.provider && executorModel?.modelId;
const hasValidatorOverride = validatorModel?.provider && validatorModel?.modelId;
return (
<div
className="agent-log-viewer"
@@ -46,6 +57,48 @@ export function AgentLogViewer({ entries, loading }: AgentLogViewerProps) {
wordBreak: "break-word",
}}
>
{/* Model info header */}
<div
className="agent-log-model-header"
style={{
display: "flex",
gap: "16px",
padding: "8px 12px",
marginBottom: "12px",
background: "var(--bg-tertiary, #252536)",
borderRadius: "4px",
fontSize: "12px",
color: "var(--text-muted, #888)",
}}
data-testid="agent-log-model-header"
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ fontWeight: 600 }}>Executor:</span>
{hasExecutorOverride ? (
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<ProviderIcon provider={executorModel.provider!} size="sm" />
<span style={{ color: "var(--text-secondary, #aaa)" }}>
{executorModel.provider}/{executorModel.modelId}
</span>
</span>
) : (
<span className="model-badge-default">Using default</span>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ fontWeight: 600 }}>Validator:</span>
{hasValidatorOverride ? (
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<ProviderIcon provider={validatorModel.provider!} size="sm" />
<span style={{ color: "var(--text-secondary, #aaa)" }}>
{validatorModel.provider}/{validatorModel.modelId}
</span>
</span>
) : (
<span className="model-badge-default">Using default</span>
)}
</div>
</div>
{reversedEntries.map((entry, i) => {
// Look at previous entry in reversed array (= next chronologically) for deduplication
const prev = reversedEntries[i - 1];

View File

@@ -14,6 +14,29 @@ import { PrSection } from "./PrSection";
import { SpecEditor } from "./SpecEditor";
import { FileBrowserModal } from "./FileBrowserModal";
interface ModelSelection {
provider?: string;
modelId?: string;
}
function normalizeModelField(value: string | null | undefined): string | undefined {
return value ?? undefined;
}
function getExecutorSelection(task: Task | TaskDetail): ModelSelection {
return {
provider: normalizeModelField(task.modelProvider),
modelId: normalizeModelField(task.modelId),
};
}
function getValidatorSelection(task: Task | TaskDetail): ModelSelection {
return {
provider: normalizeModelField(task.validatorModelProvider),
modelId: normalizeModelField(task.validatorModelId),
};
}
function getStepStatusColor(status: string): string {
switch (status) {
case "done":
@@ -642,7 +665,12 @@ export function TaskDetailModal({
</div>
) : activeTab === "agent-log" ? (
<div className="detail-section">
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />
<AgentLogViewer
entries={agentLogEntries}
loading={agentLogLoading}
executorModel={getExecutorSelection(task)}
validatorModel={getValidatorSelection(task)}
/>
</div>
) : activeTab === "steering" ? (
<SteeringTab task={task} addToast={addToast} />

View File

@@ -193,4 +193,141 @@ describe("AgentLogViewer", () => {
expect(badges).toHaveLength(0);
});
});
describe("model info header", () => {
it("renders model info header with executor model when set", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer
entries={entries}
loading={false}
executorModel={{ provider: "anthropic", modelId: "claude-sonnet-4-5" }}
/>
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Executor:");
expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5");
expect(header!.textContent).toContain("Validator:");
expect(header!.textContent).toContain("Using default");
// Verify ProviderIcon is rendered for executor
expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy();
});
it("renders 'Using default' when no executor model override is set", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer entries={entries} loading={false} executorModel={null} />
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Executor:");
expect(header!.textContent).toContain("Using default");
});
it("renders 'Using default' when executorModel is undefined", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer entries={entries} loading={false} />
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Executor:");
expect(header!.textContent).toContain("Using default");
});
it("renders model info header with validator model when set", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer
entries={entries}
loading={false}
validatorModel={{ provider: "openai", modelId: "gpt-4o" }}
/>
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Validator:");
expect(header!.textContent).toContain("openai/gpt-4o");
expect(header!.textContent).toContain("Executor:");
expect(header!.textContent).toContain("Using default");
// Verify ProviderIcon is rendered for validator
expect(container.querySelector('[data-provider="openai"]')).toBeTruthy();
});
it("renders 'Using default' when no validator model override is set", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer entries={entries} loading={false} validatorModel={null} />
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Validator:");
expect(header!.textContent).toContain("Using default");
});
it("renders both models when both are configured", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer
entries={entries}
loading={false}
executorModel={{ provider: "anthropic", modelId: "claude-opus-4" }}
validatorModel={{ provider: "openai", modelId: "gpt-4o" }}
/>
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Executor:");
expect(header!.textContent).toContain("anthropic/claude-opus-4");
expect(header!.textContent).toContain("Validator:");
expect(header!.textContent).toContain("openai/gpt-4o");
// Verify both ProviderIcons are rendered
expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy();
expect(container.querySelector('[data-provider="openai"]')).toBeTruthy();
});
it("renders header with 'Using default' for both models when both are null/undefined", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer entries={entries} loading={false} />
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Executor:");
expect(header!.textContent).toContain("Using default");
expect(header!.textContent).toContain("Validator:");
expect(header!.textContent).toContain("Using default");
});
it("shows 'Using default' when executorModel has only provider but no modelId", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer
entries={entries}
loading={false}
executorModel={{ provider: "anthropic" }}
/>
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Executor:");
expect(header!.textContent).toContain("Using default");
});
it("shows 'Using default' when executorModel has only modelId but no provider", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer
entries={entries}
loading={false}
executorModel={{ modelId: "claude-sonnet-4-5" }}
/>
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Executor:");
expect(header!.textContent).toContain("Using default");
});
});
});