feat(FN-860): display planning/triage model in Agent Log and Task Detail

- Add Planning/Triage model resolution and display in AgentLogViewer header
- Wire planning model resolution into TaskDetailModal with model info tab
- Add comprehensive tests for AgentLogViewer and TaskDetailModal model display
- Document planning/triage model display feature in README
This commit is contained in:
gsxdsm
2026-04-04 08:52:42 -07:00
parent 28fc9787d2
commit 6842b593c8
5 changed files with 349 additions and 3 deletions

View File

@@ -43,6 +43,7 @@ interface AgentLogViewerProps {
loading: boolean;
executorModel?: ModelInfo | null;
validatorModel?: ModelInfo | null;
planningModel?: ModelInfo | null;
}
/**
@@ -51,7 +52,7 @@ interface AgentLogViewerProps {
* Auto-scrolls to keep latest entries visible when streaming.
* Supports toggling between markdown-formatted and plain-text rendering.
*/
export function AgentLogViewer({ entries, loading, executorModel, validatorModel }: AgentLogViewerProps) {
export function AgentLogViewer({ entries, loading, executorModel, validatorModel, planningModel }: AgentLogViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const previousEntryCountRef = useRef<number>(0);
const [renderMarkdown, setRenderMarkdown] = useState(true);
@@ -98,6 +99,7 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
const hasExecutorOverride = executorModel?.provider && executorModel?.modelId;
const hasValidatorOverride = validatorModel?.provider && validatorModel?.modelId;
const hasPlanningOverride = planningModel?.provider && planningModel?.modelId;
return (
<div
@@ -163,6 +165,19 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
<span className="model-badge-default">Using default</span>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ fontWeight: 600 }}>Planning/Triage:</span>
{hasPlanningOverride ? (
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<ProviderIcon provider={planningModel.provider!} size="sm" />
<span style={{ color: "var(--text-secondary, #aaa)" }}>
{planningModel.provider}/{planningModel.modelId}
</span>
</span>
) : (
<span className="model-badge-default">Using default</span>
)}
</div>
{/* Markdown render toggle */}
<div style={{ marginLeft: "auto" }}>
<button

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo, Settings } from "@fusion/core";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo, Settings, AgentLogEntry } from "@fusion/core";
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@fusion/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, requestSpecRevision, approvePlan, rejectPlan, refineTask, fetchWorkflowResults } from "../api";
import type { WorkflowStepResult } from "@fusion/core";
@@ -81,6 +81,48 @@ function resolveEffectiveValidator(
return {};
}
/**
* Extract planning/triage model from agent log entries.
* Looks for text entries with agent role "triage" matching the pattern:
* "Triage using model: <provider>/<modelId>"
* Returns the latest match, or null if none found.
*/
function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
// Iterate in chronological order; last match wins
let result: { provider: string; modelId: string } | null = null;
for (const entry of entries) {
if (entry.agent !== "triage" || entry.type !== "text") continue;
const match = entry.text.match(/^Triage using model: (.+?)\/(.+)$/);
if (match) {
result = { provider: match[1], modelId: match[2] };
}
}
return result;
}
/**
* Resolve the effective planning/triage model following the resolution order:
* 1. Runtime triage model from agent log marker (if present)
* 2. Project settings planningProvider/planningModelId
* 3. Global settings defaultProvider/defaultModelId
*/
function resolveEffectivePlanning(
logEntries: AgentLogEntry[],
settings?: Settings,
): ModelSelection {
const fromLog = extractPlanningModelFromLog(logEntries);
if (fromLog) {
return fromLog;
}
if (settings?.planningProvider && settings.planningModelId) {
return { provider: settings.planningProvider, modelId: settings.planningModelId };
}
if (settings?.defaultProvider && settings.defaultModelId) {
return { provider: settings.defaultProvider, modelId: settings.defaultModelId };
}
return {};
}
function getStepStatusColor(status: string): string {
switch (status) {
case "done":
@@ -837,6 +879,7 @@ export function TaskDetailModal({
loading={agentLogLoading}
executorModel={resolveEffectiveExecutor(task, settings)}
validatorModel={resolveEffectiveValidator(task, settings)}
planningModel={resolveEffectivePlanning(agentLogEntries, settings)}
/>
) : (
<div className="detail-activity">

View File

@@ -329,6 +329,85 @@ describe("AgentLogViewer", () => {
expect(header!.textContent).toContain("Executor:");
expect(header!.textContent).toContain("Using default");
});
it("renders model info header with planning/triage model when set", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer
entries={entries}
loading={false}
planningModel={{ provider: "anthropic", modelId: "claude-opus-4" }}
/>
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Planning/Triage:");
expect(header!.textContent).toContain("anthropic/claude-opus-4");
// Verify ProviderIcon is rendered for planning
expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy();
});
it("renders 'Using default' for planning/triage when no planning model is set", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer entries={entries} loading={false} planningModel={null} />
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Planning/Triage:");
expect(header!.textContent).toContain("Using default");
});
it("renders 'Using default' for planning/triage when planningModel 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("Planning/Triage:");
expect(header!.textContent).toContain("Using default");
});
it("renders all three models when all 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" }}
planningModel={{ provider: "google", modelId: "gemini-pro" }}
/>
);
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");
expect(header!.textContent).toContain("Planning/Triage:");
expect(header!.textContent).toContain("google/gemini-pro");
// Verify all three ProviderIcons are rendered
expect(container.querySelector('[data-provider="anthropic"]')).toBeTruthy();
expect(container.querySelector('[data-provider="openai"]')).toBeTruthy();
expect(container.querySelector('[data-provider="google"]')).toBeTruthy();
});
it("shows 'Using default' for planning when planningModel has only provider but no modelId", () => {
const entries = [makeEntry()];
const { container } = render(
<AgentLogViewer
entries={entries}
loading={false}
planningModel={{ provider: "anthropic" }}
/>
);
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Planning/Triage:");
expect(header!.textContent).toContain("Using default");
});
});
describe("horizontal overflow prevention", () => {

View File

@@ -1241,6 +1241,205 @@ describe("TaskDetailModal", () => {
// Validator uses settings-specific validator
expect(header.textContent).toContain("openai/gpt-4o");
});
// Planning/Triage model resolution tests
describe("Planning/Triage model resolution", () => {
it("shows planning model from runtime triage log marker", async () => {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Triage using model: google/gemini-pro", type: "text" as const, agent: "triage" },
],
loading: false,
clear: vi.fn(),
});
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Navigate to Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
// Planning should show the runtime triage marker, not settings default
expect(header!.textContent).toContain("Planning/Triage:");
expect(header!.textContent).toContain("google/gemini-pro");
});
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
// Executor/Validator should still show settings default
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
});
it("shows planning model from settings planningProvider when no runtime marker", async () => {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
planningProvider: "openai",
planningModelId: "gpt-4o",
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [mockLogEntry],
loading: false,
clear: vi.fn(),
});
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Navigate to Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
// Planning should use planningProvider/planningModelId from settings
expect(header!.textContent).toContain("Planning/Triage:");
expect(header!.textContent).toContain("openai/gpt-4o");
});
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
// Executor/Validator should show default
expect(header.textContent).toContain("anthropic/claude-sonnet-4-5");
// Planning should NOT show the default
expect(header.textContent).toContain("openai/gpt-4o");
});
it("falls back to default settings for planning when no planning-specific setting exists", async () => {
const { container } = await setupModelTest({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
// Navigate to Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
expect(header!.textContent).toContain("Planning/Triage:");
expect(header!.textContent).toContain("anthropic/claude-sonnet-4-5");
});
// Planning falls back to default - same as executor/validator
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
const matches = header.textContent!.match(/anthropic\/claude-sonnet-4-5/g);
expect(matches).toHaveLength(3); // executor, validator, planning
});
it("shows 'Using default' for planning when no models can be resolved", async () => {
const { container } = await setupModelTest({
// No defaultProvider/defaultModelId
});
// Navigate to Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
});
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
expect(header.textContent).toContain("Planning/Triage:");
const defaultBadges = header.querySelectorAll(".model-badge-default");
// 3 default badges: executor, validator, planning
expect(defaultBadges).toHaveLength(3);
});
it("runtime triage marker takes precedence over planningProvider settings", async () => {
const { fetchSettings } = await import("../../api");
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
planningProvider: "openai",
planningModelId: "gpt-4o",
} as any);
vi.mocked(useAgentLogs).mockReturnValue({
entries: [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Triage using model: google/gemini-pro", type: "text" as const, agent: "triage" },
],
loading: false,
clear: vi.fn(),
});
const { container } = render(
<TaskDetailModal
task={makeTask({ prompt: "# Hello\n\nContent" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Navigate to Agent Log subview
fireEvent.click(screen.getByText("Logs"));
fireEvent.click(screen.getByText("Agent Log"));
await waitFor(() => {
const header = container.querySelector("[data-testid='agent-log-model-header']");
expect(header).toBeTruthy();
// Runtime marker should win over planning settings
expect(header!.textContent).toContain("google/gemini-pro");
});
const header = container.querySelector("[data-testid='agent-log-model-header']")!;
// Should NOT show the planning settings model
expect(header.textContent).not.toContain("openai/gpt-4o");
});
});
});
describe("step progress", () => {