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:
@@ -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];
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user