From 67f93cedd90f16761141f51ee8d8b8d812a8607f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 09:30:00 -0700 Subject: [PATCH] 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) --- .../fn-7370-thinking-effort-model-logs.md | 7 +++ docs/agents.md | 2 + .../dashboard/app/components/TaskChatTab.tsx | 7 ++- .../components/__tests__/TaskChatTab.test.tsx | 6 +- ...ailModal.models-progress-workflow.test.tsx | 6 +- .../__tests__/WorkflowResultsTab.test.tsx | 6 +- .../effective-model-resolution.test.ts | 17 +++-- .../components/effective-model-resolution.ts | 23 +++++-- .../__tests__/executor-model-marker.test.ts | 62 +++++++++++++++++++ .../src/__tests__/executor-test-helpers.ts | 4 ++ .../executor-workflow-step-model.test.ts | 23 ++++++- packages/engine/src/__tests__/pi.test.ts | 12 +++- .../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(-) create mode 100644 .changeset/fn-7370-thinking-effort-model-logs.md create mode 100644 packages/engine/src/__tests__/executor-model-marker.test.ts diff --git a/.changeset/fn-7370-thinking-effort-model-logs.md b/.changeset/fn-7370-thinking-effort-model-logs.md new file mode 100644 index 0000000000..97bedc863a --- /dev/null +++ b/.changeset/fn-7370-thinking-effort-model-logs.md @@ -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: )` while dashboard parsers strip suffix annotations for provider icons and effective-model displays. diff --git a/docs/agents.md b/docs/agents.md index ad9acff543..2cc2d20c6e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -374,6 +374,8 @@ The Task Detail Activity → Raw Logs model header prefers runtime provenance ma - `Reviewer using model: /` - `Triage using model: /` +When the lane resolves a thinking level, the same row appends ` (thinking effort: )`, 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. ### Ephemeral agent terminal cleanup diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index cd41fa2211..d5d9f2b8af 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -14,6 +14,7 @@ import { formatRelativeTimeAgo } from "../utils/relativeTimeAgo"; import { ProviderIcon } from "./ProviderIcon"; import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize"; import { markdownComponents } from "./AgentLogViewer"; +import { parseRuntimeModelMarker } from "./effective-model-resolution"; import "./TaskChatTab.css"; interface TaskChatTabProps { @@ -74,9 +75,9 @@ function getRoleLabel(role: AgentLogRole, t: TFunction<"app">): string { function parseModelMarker(entry: AgentLogEntry): TaskChatModelInfo | null { if (entry.type !== "text") return null; - const match = entry.text.match(/^(?:Triage|Executor|Reviewer) using model: (.+?)\/(.+)$/); - if (!match) return null; - return { provider: match[1], modelId: match[2] }; + const role = entry.agent === "triage" ? "Triage" : entry.agent === "executor" ? "Executor" : entry.agent === "reviewer" ? "Reviewer" : null; + if (!role) return null; + return parseRuntimeModelMarker(entry.text, role); } function makeModelInfo(provider: string | undefined, modelId: string | undefined): TaskChatModelInfo | null { diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 8e8e520593..ba16d833a2 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -508,10 +508,10 @@ describe("TaskChatTab", () => { 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([ - makeEntry({ agent: "triage", text: "Triage using model: google/gemini-pro" }), - makeEntry({ agent: "executor", text: "Executor using model: openai/gpt-4o" }), + makeEntry({ agent: "triage", text: "Triage using model: google/gemini-pro (thinking effort: low)" }), + 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" }), ]); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx index 9dea9feb96..b95b86cd80 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx @@ -345,7 +345,7 @@ describe("TaskDetailModal", () => { 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" }, + { 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, clear: vi.fn(), @@ -565,8 +565,8 @@ describe("TaskDetailModal", () => { vi.mocked(useAgentLogs).mockReturnValue({ 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:02Z", taskId: "FN-099", text: "Reviewer using model: google/gemini-2.5-pro", type: "text" as const, agent: "reviewer" }, + { 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 (thinking effort: medium)", type: "text" as const, agent: "reviewer" }, ], loading: false, clear: vi.fn(), diff --git a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx index 07b3c806fa..8319a4769d 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx @@ -533,21 +533,21 @@ describe("WorkflowResultsTab", () => { taskId: "FN-001", agent: "executor", 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", taskId: "FN-001", agent: "reviewer", 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", taskId: "FN-001", agent: "triage", 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 = { diff --git a/packages/dashboard/app/components/__tests__/effective-model-resolution.test.ts b/packages/dashboard/app/components/__tests__/effective-model-resolution.test.ts index 6ec7d6f4a4..4cad069adf 100644 --- a/packages/dashboard/app/components/__tests__/effective-model-resolution.test.ts +++ b/packages/dashboard/app/components/__tests__/effective-model-resolution.test.ts @@ -5,6 +5,7 @@ import { extractExecutorModelFromLog, extractPlanningModelFromLog, extractReviewerModelFromLog, + parseRuntimeModelMarker, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator, @@ -63,12 +64,12 @@ function runtimeAgent(runtimeConfig?: Record): Agent { } 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 = [ log("executor", "Executor using model: old-provider/old-model"), - log("reviewer", "Reviewer using model: reviewer-provider/reviewer-model"), - log("triage", "Triage using model: triage-provider/triage-model"), - log("executor", "Executor using model: new-provider/new-model"), + log("reviewer", "Reviewer using model: reviewer-provider/reviewer-model (thinking effort: high)"), + log("triage", "Triage using model: triage-provider/triage-model (thinking effort: low)"), + log("executor", "Executor using model: new-provider/new-model (thinking effort: high)"), ]; 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" }); }); + 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", () => { 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" }); diff --git a/packages/dashboard/app/components/effective-model-resolution.ts b/packages/dashboard/app/components/effective-model-resolution.ts index 53510c6b1a..7b8592104c 100644 --- a/packages/dashboard/app/components/effective-model-resolution.ts +++ b/packages/dashboard/app/components/effective-model-resolution.ts @@ -14,14 +14,25 @@ function isStringValue(value: unknown): value is string { /* 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. + +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 { let result: { provider: string; modelId: string } | null = null; entries.forEach((entry) => { if (entry.agent !== "executor" || entry.type !== "text") return; - const match = entry.text.match(/^Executor using model: (.+?)\/(.+)$/); + const match = parseRuntimeModelMarker(entry.text, "Executor"); if (match) { - result = { provider: match[1], modelId: match[2] }; + result = match; } }); return result; @@ -31,9 +42,9 @@ export function extractReviewerModelFromLog(entries: AgentLogEntry[]): { provide let result: { provider: string; modelId: string } | null = null; entries.forEach((entry) => { if (entry.agent !== "reviewer" || entry.type !== "text") return; - const match = entry.text.match(/^Reviewer using model: (.+?)\/(.+)$/); + const match = parseRuntimeModelMarker(entry.text, "Reviewer"); if (match) { - result = { provider: match[1], modelId: match[2] }; + result = match; } }); return result; @@ -119,9 +130,9 @@ export function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provide let result: { provider: string; modelId: string } | null = null; entries.forEach((entry) => { if (entry.agent !== "triage" || entry.type !== "text") return; - const match = entry.text.match(/^Triage using model: (.+?)\/(.+)$/); + const match = parseRuntimeModelMarker(entry.text, "Triage"); if (match) { - result = { provider: match[1], modelId: match[2] }; + result = match; } }); return result; diff --git a/packages/engine/src/__tests__/executor-model-marker.test.ts b/packages/engine/src/__tests__/executor-model-marker.test.ts new file mode 100644 index 0000000000..1f9e63b72f --- /dev/null +++ b/packages/engine/src/__tests__/executor-model-marker.test.ts @@ -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", + ); + }); +}); diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 62f38e0bca..0e1a4a7bfe 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -6,6 +6,10 @@ import { installTaskWorktreeIdentityGuard } from "../worktree-hooks.js"; vi.mock("../pi.js", () => ({ createFnAgent: vi.fn(), 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) => { if (typeof (session as any).compact === "function") { return (session as any).compact(instructions); diff --git a/packages/engine/src/__tests__/executor-workflow-step-model.test.ts b/packages/engine/src/__tests__/executor-workflow-step-model.test.ts index 93b66376ef..f8e627d115 100644 --- a/packages/engine/src/__tests__/executor-workflow-step-model.test.ts +++ b/packages/engine/src/__tests__/executor-workflow-step-model.test.ts @@ -114,7 +114,7 @@ async function runStepWithSettings( undefined, ); - return captured.last; + return { ...captured.last, logCalls: store.logEntry.mock.calls }; } describe("executor workflow-step model resolution", () => { @@ -231,4 +231,25 @@ describe("executor workflow-step model resolution", () => { 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)", + ]); + }); }); diff --git a/packages/engine/src/__tests__/pi.test.ts b/packages/engine/src/__tests__/pi.test.ts index 26da29a3d7..577b260d08 100644 --- a/packages/engine/src/__tests__/pi.test.ts +++ b/packages/engine/src/__tests__/pi.test.ts @@ -1,5 +1,5 @@ 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 { 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", () => { it("is a non-empty string", () => { expect(COMPACTION_FALLBACK_INSTRUCTIONS).toBeTruthy(); diff --git a/packages/engine/src/__tests__/reviewer.test.ts b/packages/engine/src/__tests__/reviewer.test.ts index 5b42e943c8..7604589463 100644 --- a/packages/engine/src/__tests__/reviewer.test.ts +++ b/packages/engine/src/__tests__/reviewer.test.ts @@ -3,6 +3,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("../pi.js", () => ({ createFnAgent: vi.fn(), 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) => { if (typeof session.prompt === "function") { if (options === undefined) { @@ -205,6 +209,39 @@ describe("reviewStep — model settings threading", () => { 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 () => { mockedCreateFnAgent.mockResolvedValue( createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."), diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 49e4b8bcc1..f38b18823a 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -32,6 +32,10 @@ vi.mock("../reviewer.js", () => ({ vi.mock("../pi.js", () => ({ createFnAgent: mockCreateFnAgent, 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"), })); @@ -3638,6 +3642,14 @@ describe("taskCreate tool model inheritance", () => { const store = createMockStore({ 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 @@ -3667,10 +3679,14 @@ describe("taskCreate tool model inheritance", () => { 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( "FN-300", - "Triage using model: mock-model", + "Triage using model: mock-model (thinking effort: high)", "text", undefined, "triage", diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 615ca36c12..331620dd14 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -76,7 +76,7 @@ import { import { canonicalFusionBranchName, canonicalStepInstanceBranchName, generateWorktreeName, resolveTaskWorkingBranch } from "./worktree-names.js"; import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js"; 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 { createResolvedAgentSession, @@ -10128,7 +10128,8 @@ export class TaskExecutor { } const executorModelDesc = describeModel(session); - const executorModelMarker = `Executor using model: ${executorModelDesc}`; + const executorModelDetails = formatModelMarkerDetails(executorModelDesc, executorThinkingLevel); + const executorModelMarker = `Executor using model: ${executorModelDetails}`; if (isResuming) { 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)); @@ -14516,10 +14517,18 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB ...(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( 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)); diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index d0a3f2f1e7..1b4b5d272d 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -517,6 +517,25 @@ export function describeModel(session: AgentSession): string { 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. * These guide the compaction summary to preserve essential context while diff --git a/packages/engine/src/reviewer.ts b/packages/engine/src/reviewer.ts index a31226fbba..43956f33de 100644 --- a/packages/engine/src/reviewer.ts +++ b/packages/engine/src/reviewer.ts @@ -19,7 +19,7 @@ import { } from "@fusion/core"; import { recordRetry } from "./retry-burned-logger.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 { createResolvedAgentSession, extractRuntimeHint, resolveValidatorSessionModel } from "./agent-session-helpers.js"; import { buildSessionSkillContext } from "./session-skill-context.js"; @@ -446,8 +446,9 @@ export async function reviewStep( }); const reviewerModelDesc = describeModel(session); - const reviewerModelMarker = `Reviewer using model: ${reviewerModelDesc}`; - reviewerLog.log(`${taskId}: reviewer using model ${reviewerModelDesc}`); + const reviewerModelDetails = formatModelMarkerDetails(reviewerModelDesc, options.defaultThinkingLevel); + const reviewerModelMarker = `Reviewer using model: ${reviewerModelDetails}`; + reviewerLog.log(`${taskId}: reviewer using model ${reviewerModelDetails}`); if (options.store && options.taskId) { await options.store.logEntry(options.taskId, reviewerModelMarker); await options.store.appendAgentLog(options.taskId, reviewerModelMarker, "text", undefined, "reviewer").catch(() => undefined); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 2f684901a0..e2979440d3 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -80,7 +80,7 @@ import type { ToolDefinition, AgentSession, } from "@earendil-works/pi-coding-agent"; -import { describeModel, promptWithFallback } from "./pi.js"; +import { describeModel, formatModelMarkerDetails, promptWithFallback } from "./pi.js"; import { createResolvedAgentSession, 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}`); await this.store.logEntry(task.id, `Triage using model: ${modelDesc}`); await this.store.appendAgentLog(