FN-7370: show thinking effort in model log markers

Display configured thinking effort alongside runtime task-log model markers.

- Add a shared formatter for model marker annotations and use it for triage, executor, reviewer, and workflow-step logs.
- Teach dashboard model parsing to ignore parenthesized marker diagnostics while preserving provider/model resolution.
- Cover thinking-effort markers with engine and dashboard tests, docs, and a patch changeset.

Files changed:
 .changeset/fn-7370-thinking-effort-model-logs.md   |  7 +++
 docs/agents.md                                     |  2 +
 packages/dashboard/app/components/TaskChatTab.tsx  |  7 +--
 .../app/components/__tests__/TaskChatTab.test.tsx  |  6 +--
 ...skDetailModal.models-progress-workflow.test.tsx |  6 +--
 .../__tests__/WorkflowResultsTab.test.tsx          |  6 +--
 .../__tests__/effective-model-resolution.test.ts   | 17 ++++--
 .../app/components/effective-model-resolution.ts   | 23 +++++---
 .../src/__tests__/executor-model-marker.test.ts    | 62 ++++++++++++++++++++++
 .../engine/src/__tests__/executor-test-helpers.ts  |  4 ++
 .../__tests__/executor-workflow-step-model.test.ts | 23 +++++++-
 packages/engine/src/__tests__/pi.test.ts           | 12 ++++-
 packages/engine/src/__tests__/reviewer.test.ts     | 37 +++++++++++++
 packages/engine/src/__tests__/triage.test.ts       | 20 ++++++-
 packages/engine/src/executor.ts                    | 17 ++++--
 packages/engine/src/pi.ts                          | 19 +++++++
 packages/engine/src/reviewer.ts                    |  7 +--
 packages/engine/src/triage.ts                      |  4 +-
 18 files changed, 244 insertions(+), 35 deletions(-)

Fusion-Task-Id: FN-7370

Fusion-Task-Lineage: 2c94a20c-77e1-41db-8af0-76239b30e3c4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 09:30:00 -07:00
parent 4805182d0c
commit 67f93cedd9
18 changed files with 244 additions and 35 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show thinking effort on task log model rows when it is configured.
category: fix
dev: Runtime using-model markers append `(thinking effort: <level>)` while dashboard parsers strip suffix annotations for provider icons and effective-model displays.

View File

@@ -374,6 +374,8 @@ The Task Detail Activity → Raw Logs model header prefers runtime provenance ma
- `Reviewer using model: <provider>/<modelId>` - `Reviewer using model: <provider>/<modelId>`
- `Triage using model: <provider>/<modelId>` - `Triage using model: <provider>/<modelId>`
When the lane resolves a thinking level, the same row appends ` (thinking effort: <level>)`, for example `Executor using model: openai/gpt-4o (thinking effort: high)`. Dashboard parsers ignore parenthesized diagnostics for provider icons/effective-model headers while Raw Logs and Activity rows keep the full text visible.
This makes the header reflect the model that actually ran. For active runs with no runtime marker yet, the UI can use the currently assigned agent runtime model as a temporary fallback before falling back to task/settings resolution. This makes the header reflect the model that actually ran. For active runs with no runtime marker yet, the UI can use the currently assigned agent runtime model as a temporary fallback before falling back to task/settings resolution.
### Ephemeral agent terminal cleanup ### Ephemeral agent terminal cleanup

View File

@@ -14,6 +14,7 @@ import { formatRelativeTimeAgo } from "../utils/relativeTimeAgo";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize"; import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize";
import { markdownComponents } from "./AgentLogViewer"; import { markdownComponents } from "./AgentLogViewer";
import { parseRuntimeModelMarker } from "./effective-model-resolution";
import "./TaskChatTab.css"; import "./TaskChatTab.css";
interface TaskChatTabProps { interface TaskChatTabProps {
@@ -74,9 +75,9 @@ function getRoleLabel(role: AgentLogRole, t: TFunction<"app">): string {
function parseModelMarker(entry: AgentLogEntry): TaskChatModelInfo | null { function parseModelMarker(entry: AgentLogEntry): TaskChatModelInfo | null {
if (entry.type !== "text") return null; if (entry.type !== "text") return null;
const match = entry.text.match(/^(?:Triage|Executor|Reviewer) using model: (.+?)\/(.+)$/); const role = entry.agent === "triage" ? "Triage" : entry.agent === "executor" ? "Executor" : entry.agent === "reviewer" ? "Reviewer" : null;
if (!match) return null; if (!role) return null;
return { provider: match[1], modelId: match[2] }; return parseRuntimeModelMarker(entry.text, role);
} }
function makeModelInfo(provider: string | undefined, modelId: string | undefined): TaskChatModelInfo | null { function makeModelInfo(provider: string | undefined, modelId: string | undefined): TaskChatModelInfo | null {

View File

@@ -508,10 +508,10 @@ describe("TaskChatTab", () => {
expect(document.querySelectorAll(".task-chat-provider-icon [data-provider='anthropic']")).toHaveLength(2); expect(document.querySelectorAll(".task-chat-provider-icon [data-provider='anthropic']")).toHaveLength(2);
}); });
it("renders provider icons for task chat roles from runtime model markers", () => { it("renders provider icons for task chat roles from legacy and suffixed runtime model markers", () => {
mockLogs([ mockLogs([
makeEntry({ agent: "triage", text: "Triage using model: google/gemini-pro" }), makeEntry({ agent: "triage", text: "Triage using model: google/gemini-pro (thinking effort: low)" }),
makeEntry({ agent: "executor", text: "Executor using model: openai/gpt-4o" }), makeEntry({ agent: "executor", text: "Executor using model: openai/gpt-4o (thinking effort: high)" }),
makeEntry({ agent: "reviewer", text: "Reviewer using model: anthropic/claude-sonnet-4-5" }), makeEntry({ agent: "reviewer", text: "Reviewer using model: anthropic/claude-sonnet-4-5" }),
]); ]);

View File

@@ -345,7 +345,7 @@ describe("TaskDetailModal", () => {
vi.mocked(useAgentLogs).mockReturnValue({ vi.mocked(useAgentLogs).mockReturnValue({
entries: [ entries: [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-099", text: "hello", type: "text" as const }, { 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" }, { timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Triage using model: google/gemini-pro (thinking effort: high)", type: "text" as const, agent: "triage" },
], ],
loading: false, loading: false,
clear: vi.fn(), clear: vi.fn(),
@@ -565,8 +565,8 @@ describe("TaskDetailModal", () => {
vi.mocked(useAgentLogs).mockReturnValue({ vi.mocked(useAgentLogs).mockReturnValue({
entries: [ entries: [
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Executor using model: openai/gpt-4o", type: "text" as const, agent: "executor" }, { timestamp: "2026-01-01T00:00:01Z", taskId: "FN-099", text: "Executor using model: openai/gpt-4o (thinking effort: high)", type: "text" as const, agent: "executor" },
{ timestamp: "2026-01-01T00:00:02Z", taskId: "FN-099", text: "Reviewer using model: google/gemini-2.5-pro", type: "text" as const, agent: "reviewer" }, { timestamp: "2026-01-01T00:00:02Z", taskId: "FN-099", text: "Reviewer using model: google/gemini-2.5-pro (thinking effort: medium)", type: "text" as const, agent: "reviewer" },
], ],
loading: false, loading: false,
clear: vi.fn(), clear: vi.fn(),

View File

@@ -533,21 +533,21 @@ describe("WorkflowResultsTab", () => {
taskId: "FN-001", taskId: "FN-001",
agent: "executor", agent: "executor",
type: "text", type: "text",
text: "Executor using model: runtime-executor/runtime-executor-model", text: "Executor using model: runtime-executor/runtime-executor-model (thinking effort: high)",
}, },
{ {
timestamp: "2026-06-25T00:00:01Z", timestamp: "2026-06-25T00:00:01Z",
taskId: "FN-001", taskId: "FN-001",
agent: "reviewer", agent: "reviewer",
type: "text", type: "text",
text: "Reviewer using model: runtime-reviewer/runtime-reviewer-model", text: "Reviewer using model: runtime-reviewer/runtime-reviewer-model (thinking effort: medium)",
}, },
{ {
timestamp: "2026-06-25T00:00:02Z", timestamp: "2026-06-25T00:00:02Z",
taskId: "FN-001", taskId: "FN-001",
agent: "triage", agent: "triage",
type: "text", type: "text",
text: "Triage using model: runtime-planning/runtime-planning-model", text: "Triage using model: runtime-planning/runtime-planning-model (thinking effort: low)",
}, },
]; ];
const assignedAgent = { const assignedAgent = {

View File

@@ -5,6 +5,7 @@ import {
extractExecutorModelFromLog, extractExecutorModelFromLog,
extractPlanningModelFromLog, extractPlanningModelFromLog,
extractReviewerModelFromLog, extractReviewerModelFromLog,
parseRuntimeModelMarker,
resolveEffectiveExecutor, resolveEffectiveExecutor,
resolveEffectivePlanning, resolveEffectivePlanning,
resolveEffectiveValidator, resolveEffectiveValidator,
@@ -63,12 +64,12 @@ function runtimeAgent(runtimeConfig?: Record<string, unknown>): Agent {
} }
describe("effective model resolution", () => { describe("effective model resolution", () => {
it("extracts the latest role-specific model marker from agent logs", () => { it("extracts the latest role-specific model marker from legacy and suffixed agent logs", () => {
const entries = [ const entries = [
log("executor", "Executor using model: old-provider/old-model"), log("executor", "Executor using model: old-provider/old-model"),
log("reviewer", "Reviewer using model: reviewer-provider/reviewer-model"), log("reviewer", "Reviewer using model: reviewer-provider/reviewer-model (thinking effort: high)"),
log("triage", "Triage using model: triage-provider/triage-model"), log("triage", "Triage using model: triage-provider/triage-model (thinking effort: low)"),
log("executor", "Executor using model: new-provider/new-model"), log("executor", "Executor using model: new-provider/new-model (thinking effort: high)"),
]; ];
expect(extractExecutorModelFromLog(entries)).toEqual({ provider: "new-provider", modelId: "new-model" }); expect(extractExecutorModelFromLog(entries)).toEqual({ provider: "new-provider", modelId: "new-model" });
@@ -76,6 +77,14 @@ describe("effective model resolution", () => {
expect(extractPlanningModelFromLog(entries)).toEqual({ provider: "triage-provider", modelId: "triage-model" }); expect(extractPlanningModelFromLog(entries)).toEqual({ provider: "triage-provider", modelId: "triage-model" });
}); });
it("parses runtime model markers for all roles while ignoring parenthesized diagnostics", () => {
expect(parseRuntimeModelMarker("Triage using model: google/gemini-pro", "Triage")).toEqual({ provider: "google", modelId: "gemini-pro" });
expect(parseRuntimeModelMarker("Executor using model: openai/gpt-4o (thinking effort: high)", "Executor")).toEqual({ provider: "openai", modelId: "gpt-4o" });
expect(parseRuntimeModelMarker("Reviewer using model: anthropic/claude-sonnet-4-5 (thinking effort: high) (fallback after timeout)", "Reviewer")).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" });
expect(parseRuntimeModelMarker("Executor using model: openai/gpt-4o (thinking effort: high)", "Reviewer")).toBeNull();
expect(parseRuntimeModelMarker("Executor using model: unknown model", "Executor")).toBeNull();
});
it("parses assigned-agent runtime models from combined or split fields", () => { it("parses assigned-agent runtime models from combined or split fields", () => {
expect(extractAssignedRuntimeModel(runtimeAgent({ model: "runtime-provider/runtime-model" }))).toEqual({ provider: "runtime-provider", modelId: "runtime-model" }); expect(extractAssignedRuntimeModel(runtimeAgent({ model: "runtime-provider/runtime-model" }))).toEqual({ provider: "runtime-provider", modelId: "runtime-model" });
expect(extractAssignedRuntimeModel(runtimeAgent({ modelProvider: "split-provider", modelId: "split-model" }))).toEqual({ provider: "split-provider", modelId: "split-model" }); expect(extractAssignedRuntimeModel(runtimeAgent({ modelProvider: "split-provider", modelId: "split-model" }))).toEqual({ provider: "split-provider", modelId: "split-model" });

View File

@@ -14,14 +14,25 @@ function isStringValue(value: unknown): value is string {
/* /*
FNXC:ModelResolution 2026-06-25-00:00: FNXC:ModelResolution 2026-06-25-00:00:
FN-7040 requires the Chat tab, Agent Log header, and Workflow tab Model settings to share one effective model resolver so runtime log markers, active assigned-agent runtime models, task overrides, and settings fallbacks never diverge between task-detail surfaces. FN-7040 requires the Chat tab, Agent Log header, and Workflow tab Model settings to share one effective model resolver so runtime log markers, active assigned-agent runtime models, task overrides, and settings fallbacks never diverge between task-detail surfaces.
FNXC:TaskLogModelThinking 2026-07-01-00:00:
Runtime "using model" markers may append parenthesized diagnostics such as thinking effort, workflow-step overrides, or fallback reasons. Dashboard model resolution strips those suffix annotations while preserving legacy exact markers so provider icons and effective-model headers continue to resolve from the same row operators read in Activity and Raw Logs.
*/ */
const MODEL_MARKER_PATTERN = /^(Triage|Executor|Reviewer) using model: ([^/\s]+)\/(.+?)(?:\s+\([^)]*\))*$/;
export function parseRuntimeModelMarker(text: string, role: "Triage" | "Executor" | "Reviewer"): { provider: string; modelId: string } | null {
const match = text.match(MODEL_MARKER_PATTERN);
if (!match || match[1] !== role) return null;
return { provider: match[2], modelId: match[3] };
}
export function extractExecutorModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null { export function extractExecutorModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
let result: { provider: string; modelId: string } | null = null; let result: { provider: string; modelId: string } | null = null;
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.agent !== "executor" || entry.type !== "text") return; if (entry.agent !== "executor" || entry.type !== "text") return;
const match = entry.text.match(/^Executor using model: (.+?)\/(.+)$/); const match = parseRuntimeModelMarker(entry.text, "Executor");
if (match) { if (match) {
result = { provider: match[1], modelId: match[2] }; result = match;
} }
}); });
return result; return result;
@@ -31,9 +42,9 @@ export function extractReviewerModelFromLog(entries: AgentLogEntry[]): { provide
let result: { provider: string; modelId: string } | null = null; let result: { provider: string; modelId: string } | null = null;
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.agent !== "reviewer" || entry.type !== "text") return; if (entry.agent !== "reviewer" || entry.type !== "text") return;
const match = entry.text.match(/^Reviewer using model: (.+?)\/(.+)$/); const match = parseRuntimeModelMarker(entry.text, "Reviewer");
if (match) { if (match) {
result = { provider: match[1], modelId: match[2] }; result = match;
} }
}); });
return result; return result;
@@ -119,9 +130,9 @@ export function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provide
let result: { provider: string; modelId: string } | null = null; let result: { provider: string; modelId: string } | null = null;
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.agent !== "triage" || entry.type !== "text") return; if (entry.agent !== "triage" || entry.type !== "text") return;
const match = entry.text.match(/^Triage using model: (.+?)\/(.+)$/); const match = parseRuntimeModelMarker(entry.text, "Triage");
if (match) { if (match) {
result = { provider: match[1], modelId: match[2] }; result = match;
} }
}); });
return result; return result;

View File

@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { createMockStore, mockedCreateFnAgent, resetExecutorMocks } from "./executor-test-helpers.js";
describe("TaskExecutor model marker logging", () => {
beforeEach(() => {
resetExecutorMocks();
});
it("logs executor model rows with the resolved task thinking effort", async () => {
const store = createMockStore();
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
defaultThinkingLevel: "medium",
});
store.getTask.mockResolvedValue({
id: "FN-7370",
title: "Thinking marker",
description: "Verify executor marker",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
thinkingLevel: "high",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn(async () => {
throw new Error("stop after model marker");
}),
dispose: vi.fn(),
},
} as any);
const executor = new TaskExecutor(store as any, "/tmp/test");
await executor.execute(await store.getTask("FN-7370") as any);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-7370",
"Executor using model: mock-provider/mock-model (thinking effort: high)",
undefined,
expect.any(Object),
);
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-7370",
"Executor using model: mock-provider/mock-model (thinking effort: high)",
"text",
undefined,
"executor",
);
});
});

View File

@@ -6,6 +6,10 @@ import { installTaskWorktreeIdentityGuard } from "../worktree-hooks.js";
vi.mock("../pi.js", () => ({ vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(), createFnAgent: vi.fn(),
describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"), describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"),
formatModelMarkerDetails: vi.fn((model: string, thinking?: string | null, annotations: string[] = []) => {
const suffixes = [thinking ? `thinking effort: ${thinking}` : "", ...annotations].filter(Boolean);
return suffixes.length ? `${model} ${suffixes.map((suffix) => `(${suffix})`).join(" ")}` : model;
}),
compactSessionContext: vi.fn(async (session, instructions) => { compactSessionContext: vi.fn(async (session, instructions) => {
if (typeof (session as any).compact === "function") { if (typeof (session as any).compact === "function") {
return (session as any).compact(instructions); return (session as any).compact(instructions);

View File

@@ -114,7 +114,7 @@ async function runStepWithSettings(
undefined, undefined,
); );
return captured.last; return { ...captured.last, logCalls: store.logEntry.mock.calls };
} }
describe("executor workflow-step model resolution", () => { describe("executor workflow-step model resolution", () => {
@@ -231,4 +231,25 @@ describe("executor workflow-step model resolution", () => {
defaultModelId: "scripted", defaultModelId: "scripted",
}); });
}); });
it("logs workflow-step model rows with thinking effort before override annotations", async () => {
const primary = await runStepWithSettings(
{
defaultThinkingLevel: "high",
executionProvider: "project-exec-provider",
executionModelId: "project-exec-model",
},
{
step: {
modelProvider: "step-provider",
modelId: "step-model",
},
},
);
expect(primary.logCalls).toContainEqual([
"FN-MODEL-1",
"Workflow step 'Model Step' using model: mock-provider/mock-model (thinking effort: high) (workflow step override)",
]);
});
}); });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describeModel, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS, createFnAgent, getProjectRootFromWorktree, isModelAuthTierIncompatibilityError, isRetryableModelSelectionError, promptWithFallback, type AgentOptions } from "../pi.js"; import { describeModel, formatModelMarkerDetails, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS, createFnAgent, getProjectRootFromWorktree, isModelAuthTierIncompatibilityError, isRetryableModelSelectionError, promptWithFallback, type AgentOptions } from "../pi.js";
import { createAgentSession, ModelRegistry, type AgentSession } from "@earendil-works/pi-coding-agent"; import { createAgentSession, ModelRegistry, type AgentSession } from "@earendil-works/pi-coding-agent";
import { piLog } from "../logger.js"; import { piLog } from "../logger.js";
@@ -147,6 +147,16 @@ describe("describeModel", () => {
}); });
}); });
describe("formatModelMarkerDetails", () => {
it("adds thinking effort before workflow annotations and omits empty values", () => {
expect(formatModelMarkerDetails("openai/gpt-4o", "high", ["workflow step override", "fallback after timeout"])).toBe(
"openai/gpt-4o (thinking effort: high) (workflow step override) (fallback after timeout)",
);
expect(formatModelMarkerDetails("openai/gpt-4o", undefined, [""])).toBe("openai/gpt-4o");
expect(formatModelMarkerDetails("openai/gpt-4o", "off")).toBe("openai/gpt-4o (thinking effort: off)");
});
});
describe("COMPACTION_FALLBACK_INSTRUCTIONS", () => { describe("COMPACTION_FALLBACK_INSTRUCTIONS", () => {
it("is a non-empty string", () => { it("is a non-empty string", () => {
expect(COMPACTION_FALLBACK_INSTRUCTIONS).toBeTruthy(); expect(COMPACTION_FALLBACK_INSTRUCTIONS).toBeTruthy();

View File

@@ -3,6 +3,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../pi.js", () => ({ vi.mock("../pi.js", () => ({
createFnAgent: vi.fn(), createFnAgent: vi.fn(),
describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"), describeModel: vi.fn().mockReturnValue("mock-provider/mock-model"),
formatModelMarkerDetails: vi.fn((model: string, thinking?: string | null, annotations: string[] = []) => {
const suffixes = [thinking ? `thinking effort: ${thinking}` : "", ...annotations].filter(Boolean);
return suffixes.length ? `${model} ${suffixes.map((suffix) => `(${suffix})`).join(" ")}` : model;
}),
promptWithFallback: vi.fn(async (session, prompt, options) => { promptWithFallback: vi.fn(async (session, prompt, options) => {
if (typeof session.prompt === "function") { if (typeof session.prompt === "function") {
if (options === undefined) { if (options === undefined) {
@@ -205,6 +209,39 @@ describe("reviewStep — model settings threading", () => {
expect(result.verdict).toBe("APPROVE"); expect(result.verdict).toBe("APPROVE");
}); });
it("logs reviewer model rows with default thinking effort", async () => {
mockedCreateFnAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
);
const store = {
getSettings: vi.fn().mockResolvedValue({}),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
};
await reviewStep(
"/tmp/worktree", "FN-100", 1, "Test Step", "plan", "# prompt",
undefined,
{
store: store as any,
taskId: "FN-100",
defaultThinkingLevel: "high",
},
);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-100",
"Reviewer using model: mock-provider/mock-model (thinking effort: high)",
);
expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-100",
"Reviewer using model: mock-provider/mock-model (thinking effort: high)",
"text",
undefined,
"reviewer",
);
});
it("extracts APPROVE verdict correctly", async () => { it("extracts APPROVE verdict correctly", async () => {
mockedCreateFnAgent.mockResolvedValue( mockedCreateFnAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."), createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),

View File

@@ -32,6 +32,10 @@ vi.mock("../reviewer.js", () => ({
vi.mock("../pi.js", () => ({ vi.mock("../pi.js", () => ({
createFnAgent: mockCreateFnAgent, createFnAgent: mockCreateFnAgent,
describeModel: vi.fn().mockReturnValue("mock-model"), describeModel: vi.fn().mockReturnValue("mock-model"),
formatModelMarkerDetails: vi.fn((model: string, thinking?: string | null, annotations: string[] = []) => {
const suffixes = [thinking ? `thinking effort: ${thinking}` : "", ...annotations].filter(Boolean);
return suffixes.length ? `${model} ${suffixes.map((suffix) => `(${suffix})`).join(" ")}` : model;
}),
promptWithFallback: vi.fn().mockReturnValue("mock-prompt"), promptWithFallback: vi.fn().mockReturnValue("mock-prompt"),
})); }));
@@ -3638,6 +3642,14 @@ describe("taskCreate tool model inheritance", () => {
const store = createMockStore({ const store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }), getTask: vi.fn().mockResolvedValue({ ...task, attachments: [] }),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultThinkingLevel: "high",
} as Settings),
}); });
// Set up createFnAgent to return a session that immediately throws // Set up createFnAgent to return a session that immediately throws
@@ -3667,10 +3679,14 @@ describe("taskCreate tool model inheritance", () => {
await processor.specifyTask(task); await processor.specifyTask(task);
// Verify appendAgentLog was called with model info and triage role // Verify appendAgentLog was called with model and thinking effort info on the same triage row.
expect(store.logEntry).toHaveBeenCalledWith(
"FN-300",
"Triage using model: mock-model (thinking effort: high)",
);
expect(store.appendAgentLog).toHaveBeenCalledWith( expect(store.appendAgentLog).toHaveBeenCalledWith(
"FN-300", "FN-300",
"Triage using model: mock-model", "Triage using model: mock-model (thinking effort: high)",
"text", "text",
undefined, undefined,
"triage", "triage",

View File

@@ -76,7 +76,7 @@ import {
import { canonicalFusionBranchName, canonicalStepInstanceBranchName, generateWorktreeName, resolveTaskWorkingBranch } from "./worktree-names.js"; import { canonicalFusionBranchName, canonicalStepInstanceBranchName, generateWorktreeName, resolveTaskWorkingBranch } from "./worktree-names.js";
import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js"; import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js";
import { Type, type Static } from "@earendil-works/pi-ai"; import { Type, type Static } from "@earendil-works/pi-ai";
import { describeModel, promptWithFallback, compactSessionContext } from "./pi.js"; import { describeModel, formatModelMarkerDetails, promptWithFallback, compactSessionContext } from "./pi.js";
import { accumulateSessionTokenUsage, mergeTokenUsagePerModel } from "./session-token-usage.js"; import { accumulateSessionTokenUsage, mergeTokenUsagePerModel } from "./session-token-usage.js";
import { import {
createResolvedAgentSession, createResolvedAgentSession,
@@ -10128,7 +10128,8 @@ export class TaskExecutor {
} }
const executorModelDesc = describeModel(session); const executorModelDesc = describeModel(session);
const executorModelMarker = `Executor using model: ${executorModelDesc}`; const executorModelDetails = formatModelMarkerDetails(executorModelDesc, executorThinkingLevel);
const executorModelMarker = `Executor using model: ${executorModelDetails}`;
if (isResuming) { if (isResuming) {
executorLog.log(`${task.id}: resumed session from ${task.sessionFile}`); executorLog.log(`${task.id}: resumed session from ${task.sessionFile}`);
await this.store.logEntry(task.id, `Resumed agent session after unpause (model: ${executorModelDesc})`, undefined, this.getRunContextFor(task.id)); await this.store.logEntry(task.id, `Resumed agent session after unpause (model: ${executorModelDesc})`, undefined, this.getRunContextFor(task.id));
@@ -14516,10 +14517,18 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
...(readonlyCustomTools.allowed.length > 0 ? { customTools: readonlyCustomTools.allowed } : {}), ...(readonlyCustomTools.allowed.length > 0 ? { customTools: readonlyCustomTools.allowed } : {}),
}); });
executorLog.log(`${task.id}: workflow step '${workflowStep.name}' using model ${describeModel(session)}${useOverride && attemptLabel === "primary" ? " (workflow step override)" : ""}${attemptLabel === "fallback" ? " (fallback after timeout)" : ""}`); const workflowModelDetails = formatModelMarkerDetails(
describeModel(session),
settings.defaultThinkingLevel,
[
useOverride && attemptLabel === "primary" ? "workflow step override" : "",
attemptLabel === "fallback" ? "fallback after timeout" : "",
],
);
executorLog.log(`${task.id}: workflow step '${workflowStep.name}' using model ${workflowModelDetails}`);
await this.store.logEntry( await this.store.logEntry(
task.id, task.id,
`Workflow step '${workflowStep.name}' using model: ${describeModel(session)}${useOverride && attemptLabel === "primary" ? " (workflow step override)" : ""}${attemptLabel === "fallback" ? " (fallback after timeout)" : ""}`, `Workflow step '${workflowStep.name}' using model: ${workflowModelDetails}`,
); );
this.setActiveWorkflowStepSession(task.id, session, worktreePath, this.createSeenSteeringIds(task)); this.setActiveWorkflowStepSession(task.id, session, worktreePath, this.createSeenSteeringIds(task));

View File

@@ -517,6 +517,25 @@ export function describeModel(session: AgentSession): string {
return `${model.provider}/${model.id}`; return `${model.provider}/${model.id}`;
} }
/**
* FNXC:TaskLogModelThinking 2026-07-01-00:00:
* Task-log model markers must keep the historical provider/model prefix stable while appending resolved thinking effort on the same row for operator diagnostics. Extra annotations stay parenthesized after the thinking-effort annotation so dashboard parsers can strip all suffix metadata deterministically.
*/
export function formatModelMarkerDetails(
modelDescription: string,
thinkingLevel?: string | null,
annotations: string[] = [],
): string {
const suffixes: string[] = [];
const normalizedThinkingLevel = typeof thinkingLevel === "string" ? thinkingLevel.trim() : "";
if (normalizedThinkingLevel) {
suffixes.push(`thinking effort: ${normalizedThinkingLevel}`);
}
suffixes.push(...annotations.map((annotation) => annotation.trim()).filter(Boolean));
if (suffixes.length === 0) return modelDescription;
return `${modelDescription} ${suffixes.map((suffix) => `(${suffix})`).join(" ")}`;
}
/** /**
* Default instructions used when calling `session.compact()` for loop recovery. * Default instructions used when calling `session.compact()` for loop recovery.
* These guide the compaction summary to preserve essential context while * These guide the compaction summary to preserve essential context while

View File

@@ -19,7 +19,7 @@ import {
} from "@fusion/core"; } from "@fusion/core";
import { recordRetry } from "./retry-burned-logger.js"; import { recordRetry } from "./retry-burned-logger.js";
import { mergeEffectiveSettings } from "./effective-settings.js"; import { mergeEffectiveSettings } from "./effective-settings.js";
import { describeModel, promptWithFallback } from "./pi.js"; import { describeModel, formatModelMarkerDetails, promptWithFallback } from "./pi.js";
import { isContextLimitError } from "./context-limit-detector.js"; import { isContextLimitError } from "./context-limit-detector.js";
import { createResolvedAgentSession, extractRuntimeHint, resolveValidatorSessionModel } from "./agent-session-helpers.js"; import { createResolvedAgentSession, extractRuntimeHint, resolveValidatorSessionModel } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js"; import { buildSessionSkillContext } from "./session-skill-context.js";
@@ -446,8 +446,9 @@ export async function reviewStep(
}); });
const reviewerModelDesc = describeModel(session); const reviewerModelDesc = describeModel(session);
const reviewerModelMarker = `Reviewer using model: ${reviewerModelDesc}`; const reviewerModelDetails = formatModelMarkerDetails(reviewerModelDesc, options.defaultThinkingLevel);
reviewerLog.log(`${taskId}: reviewer using model ${reviewerModelDesc}`); const reviewerModelMarker = `Reviewer using model: ${reviewerModelDetails}`;
reviewerLog.log(`${taskId}: reviewer using model ${reviewerModelDetails}`);
if (options.store && options.taskId) { if (options.store && options.taskId) {
await options.store.logEntry(options.taskId, reviewerModelMarker); await options.store.logEntry(options.taskId, reviewerModelMarker);
await options.store.appendAgentLog(options.taskId, reviewerModelMarker, "text", undefined, "reviewer").catch(() => undefined); await options.store.appendAgentLog(options.taskId, reviewerModelMarker, "text", undefined, "reviewer").catch(() => undefined);

View File

@@ -80,7 +80,7 @@ import type {
ToolDefinition, ToolDefinition,
AgentSession, AgentSession,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { describeModel, promptWithFallback } from "./pi.js"; import { describeModel, formatModelMarkerDetails, promptWithFallback } from "./pi.js";
import { import {
createResolvedAgentSession, createResolvedAgentSession,
extractRuntimeHint, extractRuntimeHint,
@@ -1123,7 +1123,7 @@ export class TriageProcessor {
}), }),
}); });
const modelDesc = describeModel(session); const modelDesc = formatModelMarkerDetails(describeModel(session), settings.defaultThinkingLevel);
planLog.log(`${task.id}: using model ${modelDesc}`); planLog.log(`${task.id}: using model ${modelDesc}`);
await this.store.logEntry(task.id, `Triage using model: ${modelDesc}`); await this.store.logEntry(task.id, `Triage using model: ${modelDesc}`);
await this.store.appendAgentLog( await this.store.appendAgentLog(