FN-8869: inherit project model overrides for role agents

Role-based permanent agents now consistently use their effective project model and thinking settings.

- Resolve permanent-agent model and thinking inheritance by workflow role in core.
- Apply inherited settings to Agent views, Chat sessions, routes, and heartbeat sessions.
- Cover role-specific inheritance and preserve the lifecycle-column ratchet classification.

Files changed:
 .changeset/fn-8869-role-agent-project-model-override.md   |   7 ++
 docs/agents.md                                     |   9 +-
 docs/settings-reference.md                         |   4 +-
 packages/core/src/__tests__/agent-effective-model.test.ts    |  97 +++++++++++++++++++
 packages/core/src/__tests__/no-hardcoded-lifecycle-columns.test.ts |   2 +
 packages/core/src/ai/agent-effective-model.ts      |  82 +++++++++++++++++
 packages/core/src/ai/model-resolution.ts           |  14 +++
 packages/core/src/index.gate.ts                    |   7 ++
 packages/core/src/index.ts                         |   7 ++
 packages/core/src/types.ts                         |   7 ++
 packages/dashboard/app/components/AgentDetailView.tsx |  49 ++++++---
 packages/dashboard/app/components/AgentsView.tsx   |  11 ++-
 packages/dashboard/app/components/__tests__/AgentDetailView.effective-model.test.tsx |  55 +++++++++++
 packages/dashboard/app/components/__tests__/AgentDetailView.settings.test.tsx |  22 +++++
 packages/dashboard/app/components/__tests__/AgentsView.test.tsx |  25 +++++
 packages/dashboard/src/__tests__/chat-manager.test.ts |  45 +++++++++
 packages/dashboard/src/__tests__/routes-chat-sessions-project-model.test.ts | 103 +++++++++++++++++++++
 packages/dashboard/src/chat.ts                     |  65 +++++--------
 packages/dashboard/src/routes/register-chat-routes.ts |  37 +++++---
 packages/engine/src/__tests__/agent-session-helpers-test-mode.test.ts |  13 +++
 packages/engine/src/__tests__/agent-session-helpers.test.ts |  54 +++++++++++
 packages/engine/src/agent-heartbeat.ts             |  10 +-
 packages/engine/src/agents/agent-session-helpers.ts |   6 +-
 23 files changed, 648 insertions(+), 83 deletions(-)

Fusion-Task-Id: FN-8869

Fusion-Task-Lineage: 00227aaf-b33b-43f1-bcbb-2203c9930dbc

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-10 02:25:03 -07:00
parent 51437558ac
commit 5b9e6643d6
23 changed files with 648 additions and 83 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Role-based permanent agents inherit project model override and thinking in Agents and Chat.
category: fix
dev: Shared resolvePermanentAgentEffectiveModel/Thinking in @fusion/core wires role inheritance into Chat, Agents, and model-less heartbeats.

View File

@@ -366,14 +366,13 @@ Executor precedence for task runs:
If the assigned agent runtime model is missing or incomplete, Fusion continues to automatic provider/model resolution without mixing partial runtime fields into the selected pair. If the assigned agent runtime model is missing or incomplete, Fusion continues to automatic provider/model resolution without mixing partial runtime fields into the selected pair.
### Durable-agent heartbeat model precedence and unavailable-provider behavior ### Permanent role-agent identity, chat, and heartbeat model inheritance
Heartbeat sessions for durable agents resolve models with the same fresh-settings-first rule: For permanent role agents, the Agents page, Agent Detail, Chat session creation, direct chat, room responders, and model-less heartbeats share one identity resolution chain. Explicit session or room thinking wins, then an agent's explicit thinking, then its role lane, project default override, and global default. A complete per-agent runtime model (`runtimeConfig.model` or `modelProvider` + `modelId`) wins for agent-bound interactive sessions and heartbeats; incomplete pairs are ignored.
1. Execution-lane settings fallback (`executionProvider`/`executionModelId` → `executionGlobalProvider`/`executionGlobalModelId` → project/global defaults) Built-in workflow roles select their own lanes: Planner uses planning, Reviewer uses validator, Merger uses merger, and Executor/other permanent agents use execution. Thus an unset role lane inherits `defaultProviderOverride`/`defaultModelIdOverride` before global defaults. The Agent Detail picker labels an empty stored model as **Inherit project/role default**; saving inherit leaves the agent row empty rather than materialising the selected project model.
2. Agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) only when both provider and model ID are present and no execution/default pair is configured
Heartbeat no longer passes a stale runtime model ahead of a saved execution lane or project default override. Model-less durable-agent heartbeats use the same role-lane chain. Calling the low-level heartbeat resolver without role context deliberately retains its historical execution-lane behavior for compatibility. Task execution remains settings-first as described above and is not changed by identity inheritance.
Task-scoped heartbeat runs for durable agents execute inside the task's git worktree (same as ephemeral task execution), while no-task heartbeat runs continue to execute from the project root. Task-scoped heartbeat runs for durable agents execute inside the task's git worktree (same as ephemeral task execution), while no-task heartbeat runs continue to execute from the project root.
Heartbeat and executor system prompts share the same active-goal context injector (`buildGoalContextSection`), so both lanes receive identical goal preambles when active goals exist. Heartbeat and executor system prompts share the same active-goal context injector (`buildGoalContextSection`), so both lanes receive identical goal preambles when active goals exist.

View File

@@ -354,7 +354,9 @@ project workflow-lane baseline stored on the active default workflow → global
→ selected-workflow lane → project default override → global default. The → selected-workflow lane → project default override → global default. The
task-detail Workflow, Chat, and Agent Log displays use this same effective model task-detail Workflow, Chat, and Agent Log displays use this same effective model
resolution, so their Plan/Triage, Executor, Reviewer, and fallback lanes match the resolution, so their Plan/Triage, Executor, Reviewer, and fallback lanes match the
sessions that actually run. sessions that actually run. Permanent role-agent identity surfaces (Agents and Chat)
also inherit their matching role lane; when it is unset, the project default override
feeds that inheritance before the global default.
**Built-in prompt overrides.** Built-in workflow prompt/gate node text has a similar project-scoped persistence model, but it is separate from workflow settings: prompt overrides are stored per `(workflowId, nodeId, projectId)` and resolve as `stored prompt ?? shipped prompt`. Resetting a prompt deletes the stored node override and restores the built-in IR text; graph structure and setting declarations remain read-only for built-ins. See [Workflow Steps → Overriding built-in workflow prompts](./workflow-steps.md#overriding-built-in-workflow-prompts). **Built-in prompt overrides.** Built-in workflow prompt/gate node text has a similar project-scoped persistence model, but it is separate from workflow settings: prompt overrides are stored per `(workflowId, nodeId, projectId)` and resolve as `stored prompt ?? shipped prompt`. Resetting a prompt deletes the stored node override and restores the built-in IR text; graph structure and setting declarations remain read-only for built-ins. See [Workflow Steps → Overriding built-in workflow prompts](./workflow-steps.md#overriding-built-in-workflow-prompts).

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import {
getPrimaryWorkflowRole,
resolvePermanentAgentEffectiveModel,
resolvePermanentAgentEffectiveThinkingLevel,
} from "../ai/agent-effective-model.js";
const builtIn = (role: "triage" | "executor" | "reviewer" | "merger", runtimeConfig?: Record<string, unknown>) => ({
role,
roles: [role],
metadata: { builtInWorkflowRole: true, workflowRole: role },
runtimeConfig,
});
describe("permanent agent effective model inheritance", () => {
it("uses the project override for every built-in workflow role when lanes are empty", () => {
const settings = {
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-project",
defaultProvider: "openai",
defaultModelId: "gpt-global",
};
for (const role of ["triage", "executor", "reviewer", "merger"] as const) {
expect(resolvePermanentAgentEffectiveModel(builtIn(role, { enabled: false }), settings)).toEqual({
provider: "anthropic",
modelId: "claude-project",
});
}
});
it("selects role lanes before project defaults and retains legacy role discovery", () => {
const settings = {
planningProvider: "planning-provider",
planningModelId: "planning-model",
executionProvider: "execution-provider",
executionModelId: "execution-model",
validatorProvider: "validator-provider",
validatorModelId: "validator-model",
mergerProvider: "merger-provider",
mergerModelId: "merger-model",
defaultProviderOverride: "project-provider",
defaultModelIdOverride: "project-model",
};
const expected = {
triage: ["planning-provider", "planning-model"],
executor: ["execution-provider", "execution-model"],
reviewer: ["validator-provider", "validator-model"],
merger: ["merger-provider", "merger-model"],
} as const;
for (const role of ["triage", "executor", "reviewer", "merger"] as const) {
expect(resolvePermanentAgentEffectiveModel({ role, runtimeConfig: {} }, settings)).toEqual({
provider: expected[role][0],
modelId: expected[role][1],
});
}
expect(getPrimaryWorkflowRole({ roles: ["executor", "merger"] })).toBe("executor");
});
it("keeps complete agent model overrides but ignores incomplete pairs", () => {
const settings = { defaultProviderOverride: "project-provider", defaultModelIdOverride: "project-model" };
expect(resolvePermanentAgentEffectiveModel(builtIn("merger", {
modelProvider: "agent-provider",
modelId: "agent-model",
}), settings)).toEqual({ provider: "agent-provider", modelId: "agent-model" });
expect(resolvePermanentAgentEffectiveModel(builtIn("merger", { modelProvider: "partial-provider" }), settings)).toEqual({
provider: "project-provider",
modelId: "project-model",
});
});
it("preserves the mock test-mode resolver result", () => {
expect(resolvePermanentAgentEffectiveModel(builtIn("merger", { enabled: false }), {
testMode: true,
mergerProvider: "real-provider",
mergerModelId: "real-model",
})).toEqual({ provider: "mock", modelId: "scripted" });
});
it("resolves thinking from explicit session, agent, role lane, project, then global defaults", () => {
const settings = {
planningThinkingLevel: "low",
executionThinkingLevel: "medium",
validatorThinkingLevel: "high",
mergerThinkingLevel: "xhigh",
defaultThinkingLevelOverride: "minimal",
defaultThinkingLevel: "off",
};
expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("triage", {}), settings)).toBe("low");
expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("executor", {}), settings)).toBe("medium");
expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("reviewer", {}), settings)).toBe("high");
expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("merger", {}), settings)).toBe("xhigh");
expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("merger", { thinkingLevel: "minimal" }), settings)).toBe("minimal");
expect(resolvePermanentAgentEffectiveThinkingLevel(builtIn("merger", { thinkingLevel: "minimal" }), settings, "off")).toBe("off");
});
});

View File

@@ -45,6 +45,8 @@ const GOVERNED_IDS = ["triage", "todo", "in-progress", "in-review"] as const;
*/ */
const NON_COLUMN_RECEIVERS: ReadonlySet<string> = new Set([ const NON_COLUMN_RECEIVERS: ReadonlySet<string> = new Set([
"role", "role",
// FNXC:AgentModelInheritance 2026-08-10-09:21: `metadataRole` is a provisioned workflow-agent role, not a task lifecycle column.
"metadataRole",
// FNXC:PrincipalHeldPlanning 2026-08-10-08:20: a workflow work item's `workflowRole` is the stage principal // FNXC:PrincipalHeldPlanning 2026-08-10-08:20: a workflow work item's `workflowRole` is the stage principal
// role ("triage"/"executor"/"reviewer"/"merger") — the same non-column meaning `role` is already exempt for. // role ("triage"/"executor"/"reviewer"/"merger") — the same non-column meaning `role` is already exempt for.
"workflowRole", "workflowRole",

View File

@@ -0,0 +1,82 @@
import type { Settings } from "../types.js";
import {
resolveExecutionSettingsModel,
resolveMergerPhaseThinkingLevel,
resolveMergerSettingsModel,
resolvePhaseThinkingLevel,
resolvePlanningSettingsModel,
resolveValidatorSettingsModel,
type ResolvedModelSelection,
} from "./model-resolution.js";
export type PermanentAgentModelLike = {
roles?: string[];
role?: string | null;
runtimeConfig?: Record<string, unknown> | null;
metadata?: Record<string, unknown> | null;
};
export type PrimaryWorkflowRole = "triage" | "executor" | "reviewer" | "merger";
function completeRuntimeModel(runtimeConfig?: Record<string, unknown> | null): ResolvedModelSelection | undefined {
if (!runtimeConfig) return undefined;
const provider = typeof runtimeConfig.modelProvider === "string" ? runtimeConfig.modelProvider.trim() : "";
const modelId = typeof runtimeConfig.modelId === "string" ? runtimeConfig.modelId.trim() : "";
if (provider && modelId) return { provider, modelId };
const legacy = typeof runtimeConfig.model === "string" ? runtimeConfig.model.trim() : "";
const slash = legacy.indexOf("/");
if (slash > 0 && slash < legacy.length - 1) {
return { provider: legacy.slice(0, slash), modelId: legacy.slice(slash + 1) };
}
return undefined;
}
/** Return the built-in workflow role, favoring provisioned role metadata when present. */
export function getPrimaryWorkflowRole(agent: PermanentAgentModelLike): PrimaryWorkflowRole | undefined {
const metadataRole = agent.metadata?.builtInWorkflowRole === true && typeof agent.metadata.workflowRole === "string"
? agent.metadata.workflowRole
: undefined;
if (metadataRole === "triage" || metadataRole === "executor" || metadataRole === "reviewer" || metadataRole === "merger") return metadataRole;
const roles = Array.isArray(agent.roles) ? agent.roles : [];
for (const role of ["triage", "executor", "reviewer", "merger"] as const) if (roles.includes(role)) return role;
return agent.role === "triage" || agent.role === "executor" || agent.role === "reviewer" || agent.role === "merger" ? agent.role : undefined;
}
/*
FNXC:AgentModelInheritance 2026-08-09-22:38:
Permanent agent identity sessions preserve a complete per-agent runtime model, but an empty or
partial runtime model inherits the matching workflow role lane. Each lane reaches the project
default override before globals, keeping Agents, Chat, and heartbeats consistent without writing
an inherited pair onto the agent row.
*/
export function resolvePermanentAgentEffectiveModel(
agent: PermanentAgentModelLike,
settings?: Partial<Settings>,
): ResolvedModelSelection {
const runtimeModel = completeRuntimeModel(agent.runtimeConfig);
if (runtimeModel) return runtimeModel;
switch (getPrimaryWorkflowRole(agent)) {
case "triage": return resolvePlanningSettingsModel(settings);
case "reviewer": return resolveValidatorSettingsModel(settings);
case "merger": return resolveMergerSettingsModel(settings);
default: return resolveExecutionSettingsModel(settings);
}
}
export function resolvePermanentAgentEffectiveThinkingLevel(
agent: PermanentAgentModelLike,
settings?: Partial<Settings>,
explicitThinkingLevel?: string,
): string | undefined {
const explicit = typeof explicitThinkingLevel === "string" && explicitThinkingLevel.trim() ? explicitThinkingLevel.trim() : undefined;
const runtime = typeof agent.runtimeConfig?.thinkingLevel === "string" && agent.runtimeConfig.thinkingLevel.trim()
? agent.runtimeConfig.thinkingLevel.trim()
: undefined;
if (explicit ?? runtime) return explicit ?? runtime;
switch (getPrimaryWorkflowRole(agent)) {
case "triage": return resolvePhaseThinkingLevel("planning", settings);
case "reviewer": return resolvePhaseThinkingLevel("validation", settings);
case "merger": return resolveMergerPhaseThinkingLevel(settings);
default: return resolvePhaseThinkingLevel("execution", settings);
}
}

View File

@@ -162,6 +162,20 @@ export function resolvePhaseThinkingLevel(
); );
} }
/*
FNXC:AgentModelInheritance 2026-08-09-22:38:
Permanent role-agent identity sessions need the merger thinking chain in core because dashboard
and engine share it; keep it aligned with the established merger lane precedence.
*/
export function resolveMergerPhaseThinkingLevel(settings?: Partial<Settings>): string | undefined {
return firstThinkingLevel(
settings?.mergerThinkingLevel,
settings?.mergerGlobalThinkingLevel,
settings?.defaultThinkingLevelOverride,
settings?.defaultThinkingLevel,
);
}
export function resolveProjectDefaultModel(settings?: Partial<Settings>): ResolvedModelSelection { export function resolveProjectDefaultModel(settings?: Partial<Settings>): ResolvedModelSelection {
return applyTestModeOverrides( return applyTestModeOverrides(
pickFirstModelPair( pickFirstModelPair(

View File

@@ -1490,6 +1490,7 @@ export {
resolveSelectedWorkflowModelLane, resolveSelectedWorkflowModelLane,
resolveMergerFallbackModel, resolveMergerFallbackModel,
resolveMergerSettingsModel, resolveMergerSettingsModel,
resolveMergerPhaseThinkingLevel,
resolvePlanningSettingsModel, resolvePlanningSettingsModel,
resolveProjectDefaultModel, resolveProjectDefaultModel,
resolveTaskExecutionModel, resolveTaskExecutionModel,
@@ -1505,6 +1506,12 @@ export {
routeTaskValidatorModel, routeTaskValidatorModel,
} from "./ai/model-resolution.js"; } from "./ai/model-resolution.js";
export type { ResolvedModelSelection, RouterLaneOptions } from "./ai/model-resolution.js"; export type { ResolvedModelSelection, RouterLaneOptions } from "./ai/model-resolution.js";
export {
getPrimaryWorkflowRole,
resolvePermanentAgentEffectiveModel,
resolvePermanentAgentEffectiveThinkingLevel,
} from "./ai/agent-effective-model.js";
export type { PermanentAgentModelLike, PrimaryWorkflowRole } from "./ai/agent-effective-model.js";
export { export {
routeModel, routeModel,
routeModelAndEmit, routeModelAndEmit,

View File

@@ -1664,6 +1664,7 @@ export {
resolveSelectedWorkflowModelLane, resolveSelectedWorkflowModelLane,
resolveMergerFallbackModel, resolveMergerFallbackModel,
resolveMergerSettingsModel, resolveMergerSettingsModel,
resolveMergerPhaseThinkingLevel,
resolvePhaseThinkingLevel, resolvePhaseThinkingLevel,
resolvePlanningSettingsModel, resolvePlanningSettingsModel,
resolveProjectDefaultModel, resolveProjectDefaultModel,
@@ -1681,6 +1682,12 @@ export {
routeTaskValidatorModel, routeTaskValidatorModel,
} from "./ai/model-resolution.js"; } from "./ai/model-resolution.js";
export type { ModelThinkingPhase, ResolvedModelSelection, RouterLaneOptions } from "./ai/model-resolution.js"; export type { ModelThinkingPhase, ResolvedModelSelection, RouterLaneOptions } from "./ai/model-resolution.js";
export {
getPrimaryWorkflowRole,
resolvePermanentAgentEffectiveModel,
resolvePermanentAgentEffectiveThinkingLevel,
} from "./ai/agent-effective-model.js";
export type { PermanentAgentModelLike, PrimaryWorkflowRole } from "./ai/agent-effective-model.js";
export { export {
routeModel, routeModel,
routeModelAndEmit, routeModelAndEmit,

View File

@@ -1586,4 +1586,11 @@ export type { DoneColumnSortMode, DisplayColumnSortOptions } from "./tasks/task-
export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types/agents/agents.js"; export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types/agents/agents.js";
export type { AgentActivityEventType, AgentActivityAttribution, AgentActivityIdProvenance, AgentActivityIdCandidate, AgentActivityAttributionClaim, AgentActivityMetadataValueSpec, AgentActivityEvent, AgentActivityEventInput, AgentActivityQuery } from "./types/agents/agents.js"; export type { AgentActivityEventType, AgentActivityAttribution, AgentActivityIdProvenance, AgentActivityIdCandidate, AgentActivityAttributionClaim, AgentActivityMetadataValueSpec, AgentActivityEvent, AgentActivityEventInput, AgentActivityQuery } from "./types/agents/agents.js";
/* FNXC:AgentModelInheritance 2026-08-09-22:38: The dashboard aliases @fusion/core to this browser-safe leaf, so expose pure agent identity model resolution here rather than importing the Node-heavy barrel. */
export {
getPrimaryWorkflowRole,
resolvePermanentAgentEffectiveModel,
resolvePermanentAgentEffectiveThinkingLevel,
} from "./ai/agent-effective-model.js";
export { AGENT_ACTIVITY_EVENT_TYPES, AGENT_ACTIVITY_ATTRIBUTIONS, AGENT_ACTIVITY_LANE_SENTINELS, AGENT_ACTIVITY_GENERATED_ID_PATTERNS, AGENT_ACTIVITY_HANDOFF_REASONS, AGENT_ACTIVITY_TOOL_NAMES, AGENT_ACTIVITY_WORKFLOW_STEP_IDS, AGENT_ACTIVITY_METADATA_SCHEMA, AGENT_ACTIVITY_METADATA_KEYS, isAgentActivityEventType } from "./types/agents/agents.js"; export { AGENT_ACTIVITY_EVENT_TYPES, AGENT_ACTIVITY_ATTRIBUTIONS, AGENT_ACTIVITY_LANE_SENTINELS, AGENT_ACTIVITY_GENERATED_ID_PATTERNS, AGENT_ACTIVITY_HANDOFF_REASONS, AGENT_ACTIVITY_TOOL_NAMES, AGENT_ACTIVITY_WORKFLOW_STEP_IDS, AGENT_ACTIVITY_METADATA_SCHEMA, AGENT_ACTIVITY_METADATA_KEYS, isAgentActivityEventType } from "./types/agents/agents.js";

View File

@@ -15,8 +15,14 @@ import remarkGfm from "remark-gfm";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api"; import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, isAgentHeartbeatEnabled, withAgentHeartbeatEnabled, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettings, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api"; import { fetchAgent, updateAgent, updateAgentState, deleteAgent, isAgentHeartbeatEnabled, withAgentHeartbeatEnabled, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettings, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api";
import type { Agent } from "../api"; import type { Agent } from "../api";
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel } from "@fusion/core"; import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel, Settings as CoreSettings } from "@fusion/core";
import { AGENT_PERMISSIONS, getErrorMessage, isEphemeralAgent } from "@fusion/core"; import {
AGENT_PERMISSIONS,
getErrorMessage,
isEphemeralAgent,
resolvePermanentAgentEffectiveModel,
resolvePermanentAgentEffectiveThinkingLevel,
} from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer"; import { AgentLogViewer } from "./AgentLogViewer";
import { LoadingSpinner } from "./LoadingSpinner"; import { LoadingSpinner } from "./LoadingSpinner";
import { AgentReflectionsTab } from "./AgentReflectionsTab"; import { AgentReflectionsTab } from "./AgentReflectionsTab";
@@ -261,6 +267,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const [agent, setAgent] = useState<AgentDetail | null>(null); const [agent, setAgent] = useState<AgentDetail | null>(null);
const [heartbeatMultiplier, setHeartbeatMultiplier] = useState(1); const [heartbeatMultiplier, setHeartbeatMultiplier] = useState(1);
const [agentModelSettings, setAgentModelSettings] = useState<Partial<CoreSettings>>({});
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const [logs, setLogs] = useState<AgentLogEntry[]>([]); const [logs, setLogs] = useState<AgentLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -310,7 +317,10 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
let cancelled = false; let cancelled = false;
void fetchSettings(projectId) void fetchSettings(projectId)
.then((settings) => { .then((settings) => {
if (!cancelled) setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1); if (!cancelled) {
setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1);
setAgentModelSettings(settings);
}
}) })
.catch(() => { .catch(() => {
if (!cancelled) setHeartbeatMultiplier(1); if (!cancelled) setHeartbeatMultiplier(1);
@@ -1187,6 +1197,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
health={health} health={health}
onChildClick={onChildClick} onChildClick={onChildClick}
projectId={projectId} projectId={projectId}
agentModelSettings={agentModelSettings}
/> />
)} )}
@@ -1288,6 +1299,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
key={agent.id} key={agent.id}
agent={agent} agent={agent}
projectId={projectId} projectId={projectId}
agentModelSettings={agentModelSettings}
addToast={addToast} addToast={addToast}
onSaved={handleSavedMutation} onSaved={handleSavedMutation}
onHasChangesChange={handleConfigChangesState} onHasChangesChange={handleConfigChangesState}
@@ -1364,11 +1376,13 @@ function DashboardTab({
health, health,
onChildClick, onChildClick,
projectId, projectId,
agentModelSettings,
}: { }: {
agent: AgentDetail; agent: AgentDetail;
health: AgentHealthStatus; health: AgentHealthStatus;
onChildClick?: (childId: string) => void; onChildClick?: (childId: string) => void;
projectId?: string; projectId?: string;
agentModelSettings: Partial<CoreSettings>;
}) { }) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const stateStyle = STATE_COLORS[agent.state]; const stateStyle = STATE_COLORS[agent.state];
@@ -1398,7 +1412,8 @@ function DashboardTab({
const slashIdx = rc.model.indexOf("/"); const slashIdx = rc.model.indexOf("/");
return rc.model.slice(slashIdx + 1); return rc.model.slice(slashIdx + 1);
} }
return null; const effective = resolvePermanentAgentEffectiveModel(agent, agentModelSettings);
return effective.provider && effective.modelId ? `${effective.provider}/${effective.modelId}` : null;
})(); })();
// Fetch budget status on mount // Fetch budget status on mount
@@ -4027,6 +4042,7 @@ function HeartbeatProcedureSection({
function ConfigTab({ function ConfigTab({
agent, agent,
projectId, projectId,
agentModelSettings,
addToast, addToast,
onSaved, onSaved,
onHasChangesChange, onHasChangesChange,
@@ -4035,6 +4051,7 @@ function ConfigTab({
}: { }: {
agent: AgentDetail; agent: AgentDetail;
projectId?: string; projectId?: string;
agentModelSettings: Partial<CoreSettings>;
addToast: (message: string, type?: "success" | "error") => void; addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>; onSaved: () => Promise<void>;
onHasChangesChange?: (hasChanges: boolean) => void; onHasChangesChange?: (hasChanges: boolean) => void;
@@ -4064,7 +4081,7 @@ function ConfigTab({
initial[field.key] = String(raw); initial[field.key] = String(raw);
} }
} }
initial.thinkingLevel = typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "off"; initial.thinkingLevel = typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "";
return initial; return initial;
}); });
@@ -4440,7 +4457,7 @@ function ConfigTab({
if (runtimeMode !== (initialRuntimeHint ? "runtime" : "model")) return true; if (runtimeMode !== (initialRuntimeHint ? "runtime" : "model")) return true;
if (modelValue !== initialModelValue) return true; if (modelValue !== initialModelValue) return true;
if (selectedRuntimeId !== initialRuntimeHint) return true; if (selectedRuntimeId !== initialRuntimeHint) return true;
if ((formValues.thinkingLevel || "off") !== (typeof rc.thinkingLevel === "string" ? rc.thinkingLevel : "off")) return true; if ((formValues.thinkingLevel ?? "") !== (typeof rc.thinkingLevel === "string" ? rc.thinkingLevel : "")) return true;
return false; return false;
})(); })();
@@ -4489,7 +4506,7 @@ function ConfigTab({
setBudgetValues(deriveBudgetValues(agent.runtimeConfig)); setBudgetValues(deriveBudgetValues(agent.runtimeConfig));
setFormValues((prev) => ({ setFormValues((prev) => ({
...prev, ...prev,
thinkingLevel: typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "off", thinkingLevel: typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "",
})); }));
setModelValue(initialModelValue); setModelValue(initialModelValue);
setSelectedRuntimeId(initialRuntimeHint); setSelectedRuntimeId(initialRuntimeHint);
@@ -4682,8 +4699,11 @@ function ConfigTab({
newRuntimeConfig.heartbeatPromptTemplate = heartbeatPromptTemplate; newRuntimeConfig.heartbeatPromptTemplate = heartbeatPromptTemplate;
} }
const selectedThinkingLevel = (formValues.thinkingLevel || "off") as ThinkingLevel; if (formValues.thinkingLevel) {
newRuntimeConfig.thinkingLevel = selectedThinkingLevel; newRuntimeConfig.thinkingLevel = formValues.thinkingLevel as ThinkingLevel;
} else {
delete newRuntimeConfig.thinkingLevel;
}
if (runtimeMode === "runtime") { if (runtimeMode === "runtime") {
if (selectedRuntimeId.trim()) { if (selectedRuntimeId.trim()) {
@@ -5115,8 +5135,10 @@ function ConfigTab({
{runtimeMode === "model" ? ( {runtimeMode === "model" ? (
<div className="config-field"> <div className="config-field">
{/* {/*
FNXC:Settings-ThinkingLevel 2026-07-12-00:00: FNXC:AgentModelInheritance 2026-08-09-23:10:
Agent Detail now lets operators change a built-in agent's persisted runtimeConfig.thinkingLevel after creation through the shared inline model-dropdown control, matching NewAgentDialog's concrete-only agent semantics. An empty agent thinking selection remains an inherit marker rather than persisting "off".
Display the resolved role/project thinking as the dropdown default so operators can inspect
the active value without materializing it onto the permanent agent runtime configuration.
*/} */}
<CustomModelDropdown <CustomModelDropdown
models={availableModels} models={availableModels}
@@ -5125,7 +5147,7 @@ function ConfigTab({
setModelValue(value); setModelValue(value);
void scheduleAutoSave(); void scheduleAutoSave();
}} }}
placeholder={t("agents.useGlobalDefault", "Use global default")} placeholder={t("agents.inheritProjectRoleDefault", "Inherit project/role default")}
label={t("agents.agentModelLabel", "Agent Model")} label={t("agents.agentModelLabel", "Agent Model")}
disabled={modelsLoading} disabled={modelsLoading}
favoriteProviders={favoriteProviders} favoriteProviders={favoriteProviders}
@@ -5133,6 +5155,9 @@ function ConfigTab({
favoriteModels={favoriteModels} favoriteModels={favoriteModels}
onToggleModelFavorite={toggleFavoriteModel} onToggleModelFavorite={toggleFavoriteModel}
thinkingLevel={formValues.thinkingLevel ?? ""} thinkingLevel={formValues.thinkingLevel ?? ""}
defaultThinkingLevel={formValues.thinkingLevel
? undefined
: resolvePermanentAgentEffectiveThinkingLevel(agent, agentModelSettings)}
onThinkingLevelChange={(level) => { onThinkingLevelChange={(level) => {
setFormValues((prev) => ({ ...prev, thinkingLevel: level as ThinkingLevel })); setFormValues((prev) => ({ ...prev, thinkingLevel: level as ThinkingLevel }));
void scheduleAutoSave(); void scheduleAutoSave();

View File

@@ -26,7 +26,7 @@ import {
MIN_HEARTBEAT_INTERVAL_MS, MIN_HEARTBEAT_INTERVAL_MS,
HEARTBEAT_INTERVAL_PRESETS, HEARTBEAT_INTERVAL_PRESETS,
} from "../utils/heartbeatIntervals"; } from "../utils/heartbeatIntervals";
import { isEphemeralAgent, getErrorMessage } from "@fusion/core"; import { isEphemeralAgent, getErrorMessage, resolvePermanentAgentEffectiveModel, type Settings } from "@fusion/core";
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills"; import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
import { import {
ORG_CHART_LAYOUT_STORAGE_KEY, ORG_CHART_LAYOUT_STORAGE_KEY,
@@ -139,7 +139,7 @@ FNXC:AgentsView 2026-06-23-04:00:
Agent list cards must expose the configured model or plugin runtime without requiring a detail-view open. Agent list cards must expose the configured model or plugin runtime without requiring a detail-view open.
Use the same runtimeHint/modelProvider+modelId/legacy model fallback order as the detail view and leave no-override agents as Auto at render time. Use the same runtimeHint/modelProvider+modelId/legacy model fallback order as the detail view and leave no-override agents as Auto at render time.
*/ */
function getAgentModelLabel(agent: Agent): AgentModelLabel { function getAgentModelLabel(agent: Agent, settings?: Partial<Settings>): AgentModelLabel {
const runtimeConfig = agent.runtimeConfig ?? {}; const runtimeConfig = agent.runtimeConfig ?? {};
const runtimeHint = typeof runtimeConfig.runtimeHint === "string" ? runtimeConfig.runtimeHint : ""; const runtimeHint = typeof runtimeConfig.runtimeHint === "string" ? runtimeConfig.runtimeHint : "";
if (runtimeHint) { if (runtimeHint) {
@@ -158,7 +158,8 @@ function getAgentModelLabel(agent: Agent): AgentModelLabel {
return { label: legacyModel.slice(slashIdx + 1), isRuntime: false }; return { label: legacyModel.slice(slashIdx + 1), isRuntime: false };
} }
return { label: null, isRuntime: false }; const effective = resolvePermanentAgentEffectiveModel(agent, settings);
return { label: effective.provider && effective.modelId ? `${effective.provider}/${effective.modelId}` : null, isRuntime: false };
} }
function getOrgChartLeafCount(node: OrgTreeNode): number { function getOrgChartLeafCount(node: OrgTreeNode): number {
@@ -640,6 +641,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const [customHeartbeatMinutes, setCustomHeartbeatMinutes] = useState<Record<string, string>>({}); const [customHeartbeatMinutes, setCustomHeartbeatMinutes] = useState<Record<string, string>>({});
/** Global heartbeat multiplier loaded from project settings */ /** Global heartbeat multiplier loaded from project settings */
const [heartbeatMultiplier, setHeartbeatMultiplier] = useState<number>(1); const [heartbeatMultiplier, setHeartbeatMultiplier] = useState<number>(1);
const [agentModelSettings, setAgentModelSettings] = useState<Partial<Settings>>({});
/** Whether the heartbeat multiplier is currently being saved */ /** Whether the heartbeat multiplier is currently being saved */
const [isSavingMultiplier, setIsSavingMultiplier] = useState(false); const [isSavingMultiplier, setIsSavingMultiplier] = useState(false);
/** Agent IDs with an in-flight state transition (for optimistic update guard) */ /** Agent IDs with an in-flight state transition (for optimistic update guard) */
@@ -661,6 +663,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
.then((settings) => { .then((settings) => {
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1); setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1);
setAgentModelSettings(settings);
}) })
.catch(() => { .catch(() => {
// Use default on error // Use default on error
@@ -2049,7 +2052,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const isHeartbeatDisabled = !isAgentHeartbeatEnabled(agent); const isHeartbeatDisabled = !isAgentHeartbeatEnabled(agent);
const heartbeatSelectValue = isHeartbeatDisabled ? HEARTBEAT_DISABLED_OPTION_VALUE : String(configuredIntervalMs); const heartbeatSelectValue = isHeartbeatDisabled ? HEARTBEAT_DISABLED_OPTION_VALUE : String(configuredIntervalMs);
const isUpdatingHeartbeat = isBulkHeartbeatMutationRunning || updatingHeartbeatAgentId === agent.id || heartbeatMutationAgentIds.has(agent.id); const isUpdatingHeartbeat = isBulkHeartbeatMutationRunning || updatingHeartbeatAgentId === agent.id || heartbeatMutationAgentIds.has(agent.id);
const modelLabel = getAgentModelLabel(agent); const modelLabel = getAgentModelLabel(agent, agentModelSettings);
return ( return (
<div <div
key={agent.id} key={agent.id}

View File

@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import {
createMockAgent,
mockFetchAgent,
mockFetchSettings,
setupAgentDetailMocks,
} from "./AgentDetailView.test-helpers";
import { AgentDetailView } from "../AgentDetailView";
describe("AgentDetailView effective role model", () => {
beforeEach(() => {
setupAgentDetailMocks();
});
it("shows a built-in merger's inherited project model", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
role: "merger" as any,
roles: ["merger"] as any,
metadata: { builtInWorkflowRole: true, workflowRole: "merger" },
runtimeConfig: { enabled: false },
}));
mockFetchSettings.mockResolvedValue({
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-project",
} as any);
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: "Dashboard" }));
expect(await screen.findByText("anthropic/claude-project")).toBeInTheDocument();
});
it("keeps a complete agent model ahead of inherited settings", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
role: "merger" as any,
roles: ["merger"] as any,
runtimeConfig: { modelProvider: "openai", modelId: "agent-model" },
}));
mockFetchSettings.mockResolvedValue({
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-project",
} as any);
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await user.click(await screen.findByRole("button", { name: "Dashboard" }));
expect(await screen.findByText("openai/agent-model")).toBeInTheDocument();
expect(screen.queryByText("anthropic/claude-project")).not.toBeInTheDocument();
});
});

View File

@@ -29,6 +29,7 @@ import {
mockFetchModels, mockFetchModels,
mockFetchPluginRuntimes, mockFetchPluginRuntimes,
mockFetchSkillContent, mockFetchSkillContent,
mockFetchSettings,
mockFetchWorkspaceFileContent, mockFetchWorkspaceFileContent,
mockMarkMessageRead, mockMarkMessageRead,
mockResetAgentBudget, mockResetAgentBudget,
@@ -191,6 +192,27 @@ describe("Budget Settings", () => {
}); });
}); });
it("shows inherited role thinking without materializing an agent override", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({
role: "merger" as AgentCapability,
roles: ["merger"] as AgentCapability[],
metadata: { builtInWorkflowRole: true, workflowRole: "merger" },
runtimeConfig: { enabled: false },
}));
mockFetchSettings.mockResolvedValue({
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-project",
defaultThinkingLevelOverride: "high",
} as any);
const user = userEvent.setup();
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await navigateToSettings(user);
await screen.findByLabelText("Agent Model thinking level");
expect(screen.getByTestId("custom-model-dropdown")).toHaveAttribute("data-default-thinking-level", "high");
});
it("calls updateAgent with correct budgetConfig in runtimeConfig on save", async () => { it("calls updateAgent with correct budgetConfig in runtimeConfig on save", async () => {
mockUpdateAgent.mockResolvedValue(createMockAgent() as any); mockUpdateAgent.mockResolvedValue(createMockAgent() as any);

View File

@@ -327,6 +327,31 @@ describe("AgentsView", () => {
expect(getCardModelRow("agent-auto").textContent).toMatch(/Model:\s*Auto/); expect(getCardModelRow("agent-auto").textContent).toMatch(/Model:\s*Auto/);
}); });
it("renders the inherited project model for a model-less built-in role agent", async () => {
mockFetchAgents.mockResolvedValueOnce([{
...mockAgents[0],
id: "agent-built-in-merger",
name: "Workflow Merger",
role: "merger",
roles: ["merger"],
metadata: { builtInWorkflowRole: true, workflowRole: "merger" },
runtimeConfig: { enabled: false },
}]);
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
mockFetchSettings.mockResolvedValueOnce({
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-project",
});
const { container } = renderView(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("Workflow Merger")).toBeInTheDocument();
});
await waitFor(() => {
expect(container.querySelector(".agent-model-runtime")?.textContent).toMatch(/anthropic\/claude-project/);
});
});
it("renders cross-pane overview above split layout", async () => { it("renders cross-pane overview above split layout", async () => {
const { container } = renderView(<AgentsView addToast={mockAddToast} />); const { container } = renderView(<AgentsView addToast={mockAddToast} />);

View File

@@ -4122,4 +4122,49 @@ describe("ChatManager generation isolation", () => {
}); });
}); });
it("forwards inherited project model and thinking for direct role-agent chat", async () => {
mockAgentStore.getAgent.mockResolvedValue({
id: "agent-001", name: "Workflow Merger", role: "merger", roles: ["merger"],
metadata: { builtInWorkflowRole: true, workflowRole: "merger" }, runtimeConfig: { enabled: false },
});
const createResolvedSession = vi.fn(async () => ({
session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { messages: [{ role: "assistant", content: "Done" }] } },
}));
__setCreateResolvedAgentSession(createResolvedSession as any);
const chatManager = createChatManagerWithSettings({
defaultProviderOverride: "anthropic", defaultModelIdOverride: "claude-project", defaultThinkingLevelOverride: "high",
} as any);
await chatManager.sendMessage("chat-001", "Hello");
expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({
defaultProvider: "anthropic", defaultModelId: "claude-project", defaultThinkingLevel: "high",
}));
});
it("forwards inherited role settings for room responders while room thinking wins", async () => {
(mockChatStore as any).getRoom = vi.fn().mockReturnValue({ id: "room-1", name: "team", thinkingLevel: "off" });
(mockChatStore as any).listRoomMembers = vi.fn().mockReturnValue([{ roomId: "room-1", agentId: "agent-001", role: "member" }]);
(mockChatStore as any).addRoomMessage = vi.fn().mockImplementation((_roomId: string, input: any) => ({ id: "room-message", ...input }));
const merger = {
id: "agent-001", name: "Workflow Merger", role: "merger", roles: ["merger"], state: "active",
metadata: { builtInWorkflowRole: true, workflowRole: "merger" }, runtimeConfig: { enabled: false },
};
mockAgentStore.listAgents.mockResolvedValue([merger]);
mockAgentStore.getAgent.mockResolvedValue(merger);
const createResolvedSession = vi.fn(async () => ({
session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), state: { messages: [{ role: "assistant", content: "Done" }] } },
}));
__setCreateResolvedAgentSession(createResolvedSession as any);
const chatManager = createChatManagerWithSettings({
defaultProviderOverride: "anthropic", defaultModelIdOverride: "claude-project", mergerThinkingLevel: "high",
} as any);
await chatManager.sendRoomMessage("room-1", "Hello @Workflow_Merger");
expect(createResolvedSession).toHaveBeenCalledWith(expect.objectContaining({
defaultProvider: "anthropic", defaultModelId: "claude-project", defaultThinkingLevel: "off",
}));
});
}); });

View File

@@ -0,0 +1,103 @@
// @vitest-environment node
import express from "express";
import multer from "multer";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AgentStore } from "@fusion/core";
import { request } from "../test-request.js";
import { registerChatRoutes } from "../routes/register-chat-routes.js";
function postJson(app: express.Express, body: unknown) {
return request(app, "POST", "/api/chat/sessions", JSON.stringify(body), {
"content-type": "application/json",
});
}
/**
* FNXC:AgentModelInheritance 2026-08-10-08:51:
* Chat-session creation must persist the permanent role agent's inherited model and thinking
* when callers leave both fields empty. This route-level fixture prevents a future route refactor
* from bypassing the shared resolver after project settings have been loaded.
*/
describe("POST /api/chat/sessions permanent role model inheritance", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses project inheritance while preserving client and complete agent overrides", async () => {
const createSession = vi.fn(async (input: Record<string, unknown>) => ({ id: `session-${createSession.mock.calls.length}`, ...input }));
const chatStore = { createSession };
const mergerAgent = {
id: "workflow-merger",
roles: ["merger"],
metadata: { builtInWorkflowRole: true, workflowRole: "merger" },
runtimeConfig: { enabled: false },
};
const explicitAgent = {
...mergerAgent,
id: "explicit-agent",
runtimeConfig: { modelProvider: "agent-provider", modelId: "agent-model" },
};
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "getAgent").mockImplementation(async (id) => (
id === explicitAgent.id ? explicitAgent as never : mergerAgent as never
));
const scopedStore = {
getFusionDir: () => "/route-project/.fusion",
getAsyncLayer: () => undefined,
getSettings: async () => ({
defaultProvider: "global-provider",
defaultModelId: "global-model",
defaultProviderOverride: "project-provider",
defaultModelIdOverride: "project-model",
defaultThinkingLevelOverride: "high",
}),
};
const app = express();
app.use(express.json());
const router = express.Router();
registerChatRoutes({
router,
store: scopedStore,
options: { chatStore },
getProjectContext: async () => ({ store: scopedStore, projectId: "project-1", engine: undefined }),
rethrowAsApiError: (error: unknown) => { throw error; },
} as never, {
parseLastEventId: () => undefined,
replayBufferedSSE: () => false,
validateOptionalModelField: () => undefined,
upload: multer(),
});
app.use("/api", router);
const inherited = await postJson(app, { agentId: mergerAgent.id });
expect(inherited.status).toBe(201);
expect(inherited.body.session).toMatchObject({
modelProvider: "project-provider",
modelId: "project-model",
thinkingLevel: "high",
});
const clientOverride = await postJson(app, {
agentId: mergerAgent.id,
modelProvider: "client-provider",
modelId: "client-model",
});
expect(clientOverride.status).toBe(201);
expect(clientOverride.body.session).toMatchObject({
modelProvider: "client-provider",
modelId: "client-model",
thinkingLevel: "high",
});
const agentOverride = await postJson(app, { agentId: explicitAgent.id });
expect(agentOverride.status).toBe(201);
expect(agentOverride.body.session).toMatchObject({
modelProvider: "agent-provider",
modelId: "agent-model",
thinkingLevel: "high",
});
});
});

View File

@@ -38,6 +38,8 @@ import {
summarizeTitle, summarizeTitle,
FUSION_RUNTIME_SELF_AWARENESS, FUSION_RUNTIME_SELF_AWARENESS,
createLogger, createLogger,
resolvePermanentAgentEffectiveModel,
resolvePermanentAgentEffectiveThinkingLevel,
} from "@fusion/core"; } from "@fusion/core";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -1425,33 +1427,7 @@ export class ChatManager {
*/ */
getPluginSkills?(): Array<{ pluginId: string; pluginRoot?: string; skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] } }>; getPluginSkills?(): Array<{ pluginId: string; pluginRoot?: string; skill: { skillId?: string; name: string; description?: string; enabled?: boolean; skillFiles?: string[] } }>;
}, },
private getSettings?: () => Promise<Pick<Settings, private getSettings?: () => Promise<Partial<Settings> | undefined> | Partial<Settings> | undefined,
| "fallbackProvider"
| "fallbackModelId"
| "defaultProvider"
| "defaultModelId"
| "defaultThinkingLevel"
| "defaultThinkingLevelOverride"
| "executionThinkingLevel"
| "executionGlobalThinkingLevel"
| "chatRoomRecentVerbatimMessages"
| "chatRoomCompactionFetchLimit"
| "chatRoomSummaryMaxChars"
| "modelPricingOverrides"
> | undefined> | Pick<Settings,
| "fallbackProvider"
| "fallbackModelId"
| "defaultProvider"
| "defaultModelId"
| "defaultThinkingLevel"
| "defaultThinkingLevelOverride"
| "executionThinkingLevel"
| "executionGlobalThinkingLevel"
| "chatRoomRecentVerbatimMessages"
| "chatRoomCompactionFetchLimit"
| "chatRoomSummaryMaxChars"
| "modelPricingOverrides"
> | undefined,
private messageStore?: MessageStore, private messageStore?: MessageStore,
// Scoped task store for the chat's project — enables workflow-authoring // Scoped task store for the chat's project — enables workflow-authoring
// tools (fn_workflow_*) and explicit-task document tools. Optional so // tools (fn_workflow_*) and explicit-task document tools. Optional so
@@ -1566,23 +1542,14 @@ export class ChatManager {
defaultThinkingLevelOverride?: Settings["defaultThinkingLevelOverride"]; defaultThinkingLevelOverride?: Settings["defaultThinkingLevelOverride"];
executionThinkingLevel?: Settings["executionThinkingLevel"]; executionThinkingLevel?: Settings["executionThinkingLevel"];
executionGlobalThinkingLevel?: Settings["executionGlobalThinkingLevel"]; executionGlobalThinkingLevel?: Settings["executionGlobalThinkingLevel"];
}> { } & Partial<Settings>> {
if (!this.getSettings) { if (!this.getSettings) {
return {}; return {};
} }
try { try {
const settings = await this.getSettings(); const settings = await this.getSettings();
return { return settings ?? {};
fallbackProvider: settings?.fallbackProvider ?? undefined,
fallbackModelId: settings?.fallbackModelId ?? undefined,
defaultProvider: settings?.defaultProvider ?? undefined,
defaultModelId: settings?.defaultModelId ?? undefined,
defaultThinkingLevel: settings?.defaultThinkingLevel ?? undefined,
defaultThinkingLevelOverride: settings?.defaultThinkingLevelOverride ?? undefined,
executionThinkingLevel: settings?.executionThinkingLevel ?? undefined,
executionGlobalThinkingLevel: settings?.executionGlobalThinkingLevel ?? undefined,
};
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
diagnostics.warn(`Failed to load chat fallback settings: ${message}`); diagnostics.warn(`Failed to load chat fallback settings: ${message}`);
@@ -2138,13 +2105,19 @@ export class ChatManager {
* FNXC:GrokCliRouting 2026-07-09-22:10: * FNXC:GrokCliRouting 2026-07-09-22:10:
* Room responders with no explicit send-time or responder runtime model still need the configured chat/project default to reach createResolvedAgentSession. Without forwarding a defaultProvider of grok-cli, the no-visible-key auto-derive seam cannot route to the Grok CLI runtime and pi can surface the direct xAI missing-key error. * Room responders with no explicit send-time or responder runtime model still need the configured chat/project default to reach createResolvedAgentSession. Without forwarding a defaultProvider of grok-cli, the no-visible-key auto-derive seam cannot route to the Grok CLI runtime and pi can surface the direct xAI missing-key error.
*/ */
const effectiveModelProvider = input.modelProvider ?? responderRuntimeModel.provider ?? chatModelSettings.defaultProvider; const inheritedResponderModel = resolvePermanentAgentEffectiveModel(input.responder, chatModelSettings);
const effectiveModelId = input.modelId ?? responderRuntimeModel.modelId ?? chatModelSettings.defaultModelId; const hasCompleteResponderRuntimeModel = !!responderRuntimeModel.provider && !!responderRuntimeModel.modelId;
const effectiveModelProvider = input.modelProvider ?? (hasCompleteResponderRuntimeModel ? responderRuntimeModel.provider : inheritedResponderModel.provider);
const effectiveModelId = input.modelId ?? (hasCompleteResponderRuntimeModel ? responderRuntimeModel.modelId : inheritedResponderModel.modelId);
/* /*
* FNXC:Chat-ThinkingLevel 2026-07-12-00:00: * FNXC:Chat-ThinkingLevel 2026-07-12-00:00:
* Room responders apply the room-level reasoning-effort default through the engine `defaultThinkingLevel` session option. An unset room value inherits the resolved project/global chat default and every direct or ambient responder in the room receives the same effective level. * Room responders apply the room-level reasoning-effort default through the engine `defaultThinkingLevel` session option. An unset room value inherits the resolved project/global chat default and every direct or ambient responder in the room receives the same effective level.
*/ */
const effectiveThinkingLevel = resolveExecutorThinkingLevel(input.roomThinkingLevel ?? undefined, chatModelSettings); const effectiveThinkingLevel = resolvePermanentAgentEffectiveThinkingLevel(
input.responder,
chatModelSettings,
input.roomThinkingLevel ?? undefined,
);
/* /*
* FNXC:ChatModels 2026-07-01-16:42: * FNXC:ChatModels 2026-07-01-16:42:
* Room responders should pass configured fallback models even when the room send chose an explicit model. The engine still swaps only for retryable provider/model-selection failures, so an unavailable Sonnet 5 can recover without making ordinary prompt errors ambiguous. * Room responders should pass configured fallback models even when the room send chose an explicit model. The engine still swaps only for retryable provider/model-selection failures, so an unavailable Sonnet 5 can recover without making ordinary prompt errors ambiguous.
@@ -2567,8 +2540,10 @@ export class ChatManager {
if (runtimeModel.provider && runtimeModel.modelId) { if (runtimeModel.provider && runtimeModel.modelId) {
hasExplicitAgentRuntimeModel = true; hasExplicitAgentRuntimeModel = true;
} }
effectiveModelProvider ??= runtimeModel.provider; const inheritedAgentModel = resolvePermanentAgentEffectiveModel(agent, await this.getChatModelSettings());
effectiveModelId ??= runtimeModel.modelId; const hasCompleteRuntimeModel = !!runtimeModel.provider && !!runtimeModel.modelId;
effectiveModelProvider ??= hasCompleteRuntimeModel ? runtimeModel.provider : inheritedAgentModel.provider;
effectiveModelId ??= hasCompleteRuntimeModel ? runtimeModel.modelId : inheritedAgentModel.modelId;
failureContextProvider = effectiveModelProvider; failureContextProvider = effectiveModelProvider;
failureContextModelId = effectiveModelId; failureContextModelId = effectiveModelId;
} }
@@ -2688,7 +2663,9 @@ export class ChatManager {
* FNXC:Chat-ThinkingLevel 2026-07-10-00:00: * FNXC:Chat-ThinkingLevel 2026-07-10-00:00:
* Model-loop chat sessions apply the per-session thinking level through the engine `defaultThinkingLevel` session option; an empty session value inherits the project/global execution default resolved by resolveExecutorThinkingLevel. * Model-loop chat sessions apply the per-session thinking level through the engine `defaultThinkingLevel` session option; an empty session value inherits the project/global execution default resolved by resolveExecutorThinkingLevel.
*/ */
const effectiveThinkingLevel = resolveExecutorThinkingLevel(session.thinkingLevel ?? undefined, chatModelSettings); const effectiveThinkingLevel = agent
? resolvePermanentAgentEffectiveThinkingLevel(agent, chatModelSettings, session.thinkingLevel ?? undefined)
: resolveExecutorThinkingLevel(session.thinkingLevel ?? undefined, chatModelSettings);
if (agent?.id && !this.messageStore) { if (agent?.id && !this.messageStore) {
const warning = { const warning = {

View File

@@ -3,7 +3,13 @@ import { archivedColumnsForTask } from "../task-lifecycle-lanes.js";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises"; import { mkdir, rm, writeFile } from "node:fs/promises";
import { basename, join, resolve } from "node:path"; import { basename, join, resolve } from "node:path";
import { THINKING_LEVELS, type EnrichedChatSession, type ChatAttachment } from "@fusion/core"; import {
THINKING_LEVELS,
resolvePermanentAgentEffectiveModel,
resolvePermanentAgentEffectiveThinkingLevel,
type EnrichedChatSession,
type ChatAttachment,
} from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js"; import { ApiError, badRequest, notFound } from "../api-error.js";
/* /*
FNXC:GrokAcp 2026-07-11-18:30: FNXC:GrokAcp 2026-07-11-18:30:
@@ -445,8 +451,8 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
* POST /api/chat/sessions * POST /api/chat/sessions
* Create a new chat session. * Create a new chat session.
* Body: { agentId: string, title?: string, modelProvider?: string, modelId?: string, thinkingLevel?: string } * Body: { agentId: string, title?: string, modelProvider?: string, modelId?: string, thinkingLevel?: string }
* If modelProvider and modelId are provided, those are used. Otherwise the model is * If modelProvider and modelId are provided, those are used. Otherwise the model and
* resolved from the agent's runtimeConfig.model setting. * thinking level resolve through the agent's permanent-role inheritance chain.
* The session is scoped to the project identified by projectId query param or header. * The session is scoped to the project identified by projectId query param or header.
*/ */
router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => { router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
@@ -479,9 +485,16 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
throw badRequest("Both modelProvider and modelId must be provided together, or neither should be provided"); throw badRequest("Both modelProvider and modelId must be provided together, or neither should be provided");
} }
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw notFound(`Agent ${agentId} not found`);
}
const settings = await scopedStore.getSettings();
// Fetch the agent to resolve model configuration (only if client didn't provide model) // Fetch the agent to resolve model configuration (only if client didn't provide model)
let resolvedProvider: string | null = null; let resolvedProvider: string | null = null;
let resolvedModelId: string | null = null; let resolvedModelId: string | null = null;
let inheritedThinkingLevel: string | undefined;
if (hasClientModelProvider && hasClientModelId) { if (hasClientModelProvider && hasClientModelId) {
// Use client-provided model // Use client-provided model
@@ -489,18 +502,12 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
resolvedModelId = modelId!.trim(); resolvedModelId = modelId!.trim();
} else { } else {
// Resolve from agent's runtimeConfig.model // Resolve from agent's runtimeConfig.model
const agent = await agentStore.getAgent(agentId); const resolved = resolvePermanentAgentEffectiveModel(agent, settings);
if (!agent) { resolvedProvider = resolved.provider ?? null;
throw notFound(`Agent ${agentId} not found`); resolvedModelId = resolved.modelId ?? null;
} inheritedThinkingLevel = resolvePermanentAgentEffectiveThinkingLevel(agent, settings);
// Parse the agent's model config from runtimeConfig.model
// Format: "provider/modelId" (e.g., "anthropic/claude-sonnet-4-5")
const runtimeModel = typeof agent.runtimeConfig?.model === "string" ? agent.runtimeConfig.model : "";
const slashIdx = runtimeModel.indexOf("/");
resolvedProvider = slashIdx > 0 ? runtimeModel.slice(0, slashIdx) : null;
resolvedModelId = slashIdx > 0 ? runtimeModel.slice(slashIdx + 1) : null;
} }
inheritedThinkingLevel ??= resolvePermanentAgentEffectiveThinkingLevel(agent, settings);
// Create the chat session with projectId for multi-project scoping // Create the chat session with projectId for multi-project scoping
const session = await chatStore.createSession({ const session = await chatStore.createSession({
@@ -509,7 +516,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
projectId: projectId ?? null, projectId: projectId ?? null,
modelProvider: resolvedProvider, modelProvider: resolvedProvider,
modelId: resolvedModelId, modelId: resolvedModelId,
...(thinkingLevel ? { thinkingLevel } : {}), ...((thinkingLevel ?? inheritedThinkingLevel) ? { thinkingLevel: thinkingLevel ?? inheritedThinkingLevel } : {}),
}); });
res.status(201).json({ session }); res.status(201).json({ session });

View File

@@ -126,3 +126,16 @@ describe("agent-session-helpers test mode overrides", () => {
}); });
}); });
}); });
describe("role-aware heartbeat test mode", () => {
it("still forces mock for a supplied permanent workflow role", () => {
expect(resolveHeartbeatSessionModels({
testMode: true,
mergerProvider: "real-provider",
mergerModelId: "real-model",
}, { enabled: false }, { roles: ["merger"] })).toMatchObject({
defaultProvider: "mock",
defaultModelId: "scripted",
});
});
});

View File

@@ -1260,3 +1260,57 @@ describe("resolveImplicitPlanningFallbackModel (FN-7719)", () => {
}); });
}); });
}); });
describe("role-aware heartbeat model inheritance", () => {
const projectOverride = {
defaultProviderOverride: "project-provider",
defaultModelIdOverride: "project-model",
defaultProvider: "global-provider",
defaultModelId: "global-model",
};
it("uses each supplied workflow role lane and leaves no-role calls on execution", () => {
const settings = {
...projectOverride,
planningProvider: "planning-provider",
planningModelId: "planning-model",
executionProvider: "execution-provider",
executionModelId: "execution-model",
validatorProvider: "validator-provider",
validatorModelId: "validator-model",
mergerProvider: "merger-provider",
mergerModelId: "merger-model",
};
const expected = {
triage: ["planning-provider", "planning-model"],
executor: ["execution-provider", "execution-model"],
reviewer: ["validator-provider", "validator-model"],
merger: ["merger-provider", "merger-model"],
} as const;
for (const role of ["triage", "executor", "reviewer", "merger"] as const) {
expect(resolveHeartbeatSessionModels(settings, { enabled: false }, { roles: [role] })).toMatchObject({
defaultProvider: expected[role][0],
defaultModelId: expected[role][1],
});
}
expect(resolveHeartbeatSessionModels(settings, { enabled: false })).toMatchObject({
defaultProvider: "execution-provider",
defaultModelId: "execution-model",
});
});
it("uses project override for model-less roles and preserves complete runtime assignments", () => {
expect(resolveHeartbeatSessionModels(projectOverride, { enabled: false }, { roles: ["merger"] })).toMatchObject({
defaultProvider: "project-provider",
defaultModelId: "project-model",
});
expect(resolveHeartbeatSessionModels(projectOverride, {
modelProvider: "agent-provider",
modelId: "agent-model",
}, { roles: ["merger"] })).toMatchObject({
defaultProvider: "agent-provider",
defaultModelId: "agent-model",
});
});
});

View File

@@ -29,6 +29,7 @@ import {
evaluateImplementationTaskBind, evaluateImplementationTaskBind,
resolvePersistAgentThinkingLog, resolvePersistAgentThinkingLog,
resolveAgentMemoryInclusionMode, resolveAgentMemoryInclusionMode,
resolvePermanentAgentEffectiveThinkingLevel,
AWAITING_APPROVAL_PAUSE_REASON, AWAITING_APPROVAL_PAUSE_REASON,
rankAssignedTasksForWakeDelta, rankAssignedTasksForWakeDelta,
formatAssignedTasksWakeDeltaSection, formatAssignedTasksWakeDeltaSection,
@@ -3066,7 +3067,13 @@ export class HeartbeatMonitor {
heartbeatModelSettings = taskDetail heartbeatModelSettings = taskDetail
? await mergeEffectiveSettings(taskStore, taskDetail, heartbeatBaseSettings) ? await mergeEffectiveSettings(taskStore, taskDetail, heartbeatBaseSettings)
: await mergeProjectWorkflowModelLaneBaseline(taskStore, heartbeatBaseSettings); : await mergeProjectWorkflowModelLaneBaseline(taskStore, heartbeatBaseSettings);
const heartbeatSessionModels = resolveHeartbeatSessionModels(heartbeatModelSettings, agent.runtimeConfig); /*
FNXC:AgentModelInheritance 2026-08-09-22:38:
A model-less durable workflow role agent inherits its own role lane rather than always
taking the execution lane; complete runtime models remain authoritative in the helper.
*/
const heartbeatSessionModels = resolveHeartbeatSessionModels(heartbeatModelSettings, agent.runtimeConfig, agent);
const effectiveHeartbeatThinkingLevel = resolvePermanentAgentEffectiveThinkingLevel(agent, heartbeatModelSettings);
// FNXC:CommandCenterActivity 2026-08-09-11:12: Heartbeat model selection happens after // FNXC:CommandCenterActivity 2026-08-09-11:12: Heartbeat model selection happens after
// logger construction, so refresh telemetry before the session boundary and tool callbacks. // logger construction, so refresh telemetry before the session boundary and tool callbacks.
attachAgentUsageTelemetry(agentLogger, { attachAgentUsageTelemetry(agentLogger, {
@@ -3106,6 +3113,7 @@ export class HeartbeatMonitor {
fallbackProvider: heartbeatSessionModels.fallbackProvider, fallbackProvider: heartbeatSessionModels.fallbackProvider,
fallbackModelId: heartbeatSessionModels.fallbackModelId, fallbackModelId: heartbeatSessionModels.fallbackModelId,
fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(undefined, heartbeatModelSettings), fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(undefined, heartbeatModelSettings),
...(effectiveHeartbeatThinkingLevel ? { defaultThinkingLevel: effectiveHeartbeatThinkingLevel } : {}),
runAuditor: audit, runAuditor: audit,
settings: heartbeatModelSettings, settings: heartbeatModelSettings,
mcpServers: heartbeatMcp.servers, mcpServers: heartbeatMcp.servers,

View File

@@ -17,6 +17,7 @@ import {
isTestModeActive, isTestModeActive,
resolveAgentToolOutputMaxChars, resolveAgentToolOutputMaxChars,
resolveExecutionSettingsModel, resolveExecutionSettingsModel,
resolvePermanentAgentEffectiveModel,
resolveExecutorFallbackModel, resolveExecutorFallbackModel,
resolveMergerSettingsModel, resolveMergerSettingsModel,
resolvePhaseThinkingLevel, resolvePhaseThinkingLevel,
@@ -799,6 +800,7 @@ export function resolveValidatorSessionModel(
export function resolveHeartbeatSessionModels( export function resolveHeartbeatSessionModels(
settings: Partial<Settings> | undefined, settings: Partial<Settings> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>, assignedAgentRuntimeConfig?: Record<string, unknown>,
agent?: { roles?: string[]; role?: string; metadata?: Record<string, unknown> | null },
): { ): {
defaultProvider: string | undefined; defaultProvider: string | undefined;
defaultModelId: string | undefined; defaultModelId: string | undefined;
@@ -815,7 +817,9 @@ export function resolveHeartbeatSessionModels(
}; };
} }
const executionSettingsModel = resolveExecutionSettingsModel(settings); const executionSettingsModel = agent
? resolvePermanentAgentEffectiveModel(agent, settings)
: resolveExecutionSettingsModel(settings);
const executorFallbackModel = resolveExecutorFallbackModel(settings); const executorFallbackModel = resolveExecutorFallbackModel(settings);
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
/* /*