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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user