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:
7
.changeset/fn-8869-role-agent-project-model-override.md
Normal file
7
.changeset/fn-8869-role-agent-project-model-override.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
### 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)
|
||||
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
|
||||
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.
|
||||
|
||||
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.
|
||||
Heartbeat and executor system prompts share the same active-goal context injector (`buildGoalContextSection`), so both lanes receive identical goal preambles when active goals exist.
|
||||
|
||||
@@ -354,7 +354,9 @@ project workflow-lane baseline stored on the active default workflow → global
|
||||
→ selected-workflow lane → project default override → global default. The
|
||||
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
|
||||
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).
|
||||
|
||||
|
||||
97
packages/core/src/__tests__/agent-effective-model.test.ts
Normal file
97
packages/core/src/__tests__/agent-effective-model.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,8 @@ const GOVERNED_IDS = ["triage", "todo", "in-progress", "in-review"] as const;
|
||||
*/
|
||||
const NON_COLUMN_RECEIVERS: ReadonlySet<string> = new Set([
|
||||
"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
|
||||
// role ("triage"/"executor"/"reviewer"/"merger") — the same non-column meaning `role` is already exempt for.
|
||||
"workflowRole",
|
||||
|
||||
82
packages/core/src/ai/agent-effective-model.ts
Normal file
82
packages/core/src/ai/agent-effective-model.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
return applyTestModeOverrides(
|
||||
pickFirstModelPair(
|
||||
|
||||
@@ -1490,6 +1490,7 @@ export {
|
||||
resolveSelectedWorkflowModelLane,
|
||||
resolveMergerFallbackModel,
|
||||
resolveMergerSettingsModel,
|
||||
resolveMergerPhaseThinkingLevel,
|
||||
resolvePlanningSettingsModel,
|
||||
resolveProjectDefaultModel,
|
||||
resolveTaskExecutionModel,
|
||||
@@ -1505,6 +1506,12 @@ export {
|
||||
routeTaskValidatorModel,
|
||||
} 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 {
|
||||
routeModel,
|
||||
routeModelAndEmit,
|
||||
|
||||
@@ -1664,6 +1664,7 @@ export {
|
||||
resolveSelectedWorkflowModelLane,
|
||||
resolveMergerFallbackModel,
|
||||
resolveMergerSettingsModel,
|
||||
resolveMergerPhaseThinkingLevel,
|
||||
resolvePhaseThinkingLevel,
|
||||
resolvePlanningSettingsModel,
|
||||
resolveProjectDefaultModel,
|
||||
@@ -1681,6 +1682,12 @@ export {
|
||||
routeTaskValidatorModel,
|
||||
} 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 {
|
||||
routeModel,
|
||||
routeModelAndEmit,
|
||||
|
||||
@@ -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 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";
|
||||
|
||||
@@ -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 { 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 { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel } from "@fusion/core";
|
||||
import { AGENT_PERMISSIONS, getErrorMessage, isEphemeralAgent } from "@fusion/core";
|
||||
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel, Settings as CoreSettings } from "@fusion/core";
|
||||
import {
|
||||
AGENT_PERMISSIONS,
|
||||
getErrorMessage,
|
||||
isEphemeralAgent,
|
||||
resolvePermanentAgentEffectiveModel,
|
||||
resolvePermanentAgentEffectiveThinkingLevel,
|
||||
} from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
||||
@@ -261,6 +267,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
const { t } = useTranslation("app");
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
||||
const [heartbeatMultiplier, setHeartbeatMultiplier] = useState(1);
|
||||
const [agentModelSettings, setAgentModelSettings] = useState<Partial<CoreSettings>>({});
|
||||
const { confirm } = useConfirm();
|
||||
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -310,7 +317,10 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
let cancelled = false;
|
||||
void fetchSettings(projectId)
|
||||
.then((settings) => {
|
||||
if (!cancelled) setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1);
|
||||
if (!cancelled) {
|
||||
setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1);
|
||||
setAgentModelSettings(settings);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHeartbeatMultiplier(1);
|
||||
@@ -1187,6 +1197,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
health={health}
|
||||
onChildClick={onChildClick}
|
||||
projectId={projectId}
|
||||
agentModelSettings={agentModelSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1288,6 +1299,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
agentModelSettings={agentModelSettings}
|
||||
addToast={addToast}
|
||||
onSaved={handleSavedMutation}
|
||||
onHasChangesChange={handleConfigChangesState}
|
||||
@@ -1364,11 +1376,13 @@ function DashboardTab({
|
||||
health,
|
||||
onChildClick,
|
||||
projectId,
|
||||
agentModelSettings,
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
health: AgentHealthStatus;
|
||||
onChildClick?: (childId: string) => void;
|
||||
projectId?: string;
|
||||
agentModelSettings: Partial<CoreSettings>;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const stateStyle = STATE_COLORS[agent.state];
|
||||
@@ -1398,7 +1412,8 @@ function DashboardTab({
|
||||
const slashIdx = rc.model.indexOf("/");
|
||||
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
|
||||
@@ -4027,6 +4042,7 @@ function HeartbeatProcedureSection({
|
||||
function ConfigTab({
|
||||
agent,
|
||||
projectId,
|
||||
agentModelSettings,
|
||||
addToast,
|
||||
onSaved,
|
||||
onHasChangesChange,
|
||||
@@ -4035,6 +4051,7 @@ function ConfigTab({
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
projectId?: string;
|
||||
agentModelSettings: Partial<CoreSettings>;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
onSaved: () => Promise<void>;
|
||||
onHasChangesChange?: (hasChanges: boolean) => void;
|
||||
@@ -4064,7 +4081,7 @@ function ConfigTab({
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -4440,7 +4457,7 @@ function ConfigTab({
|
||||
if (runtimeMode !== (initialRuntimeHint ? "runtime" : "model")) return true;
|
||||
if (modelValue !== initialModelValue) 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;
|
||||
})();
|
||||
@@ -4489,7 +4506,7 @@ function ConfigTab({
|
||||
setBudgetValues(deriveBudgetValues(agent.runtimeConfig));
|
||||
setFormValues((prev) => ({
|
||||
...prev,
|
||||
thinkingLevel: typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "off",
|
||||
thinkingLevel: typeof agent.runtimeConfig?.thinkingLevel === "string" ? agent.runtimeConfig.thinkingLevel : "",
|
||||
}));
|
||||
setModelValue(initialModelValue);
|
||||
setSelectedRuntimeId(initialRuntimeHint);
|
||||
@@ -4682,8 +4699,11 @@ function ConfigTab({
|
||||
newRuntimeConfig.heartbeatPromptTemplate = heartbeatPromptTemplate;
|
||||
}
|
||||
|
||||
const selectedThinkingLevel = (formValues.thinkingLevel || "off") as ThinkingLevel;
|
||||
newRuntimeConfig.thinkingLevel = selectedThinkingLevel;
|
||||
if (formValues.thinkingLevel) {
|
||||
newRuntimeConfig.thinkingLevel = formValues.thinkingLevel as ThinkingLevel;
|
||||
} else {
|
||||
delete newRuntimeConfig.thinkingLevel;
|
||||
}
|
||||
|
||||
if (runtimeMode === "runtime") {
|
||||
if (selectedRuntimeId.trim()) {
|
||||
@@ -5115,8 +5135,10 @@ function ConfigTab({
|
||||
{runtimeMode === "model" ? (
|
||||
<div className="config-field">
|
||||
{/*
|
||||
FNXC:Settings-ThinkingLevel 2026-07-12-00:00:
|
||||
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.
|
||||
FNXC:AgentModelInheritance 2026-08-09-23:10:
|
||||
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
|
||||
models={availableModels}
|
||||
@@ -5125,7 +5147,7 @@ function ConfigTab({
|
||||
setModelValue(value);
|
||||
void scheduleAutoSave();
|
||||
}}
|
||||
placeholder={t("agents.useGlobalDefault", "Use global default")}
|
||||
placeholder={t("agents.inheritProjectRoleDefault", "Inherit project/role default")}
|
||||
label={t("agents.agentModelLabel", "Agent Model")}
|
||||
disabled={modelsLoading}
|
||||
favoriteProviders={favoriteProviders}
|
||||
@@ -5133,6 +5155,9 @@ function ConfigTab({
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={toggleFavoriteModel}
|
||||
thinkingLevel={formValues.thinkingLevel ?? ""}
|
||||
defaultThinkingLevel={formValues.thinkingLevel
|
||||
? undefined
|
||||
: resolvePermanentAgentEffectiveThinkingLevel(agent, agentModelSettings)}
|
||||
onThinkingLevelChange={(level) => {
|
||||
setFormValues((prev) => ({ ...prev, thinkingLevel: level as ThinkingLevel }));
|
||||
void scheduleAutoSave();
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
MIN_HEARTBEAT_INTERVAL_MS,
|
||||
HEARTBEAT_INTERVAL_PRESETS,
|
||||
} from "../utils/heartbeatIntervals";
|
||||
import { isEphemeralAgent, getErrorMessage } from "@fusion/core";
|
||||
import { isEphemeralAgent, getErrorMessage, resolvePermanentAgentEffectiveModel, type Settings } from "@fusion/core";
|
||||
import { formatAgentSkillBadgeLabel } from "../utils/agentSkills";
|
||||
import {
|
||||
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.
|
||||
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 runtimeHint = typeof runtimeConfig.runtimeHint === "string" ? runtimeConfig.runtimeHint : "";
|
||||
if (runtimeHint) {
|
||||
@@ -158,7 +158,8 @@ function getAgentModelLabel(agent: Agent): AgentModelLabel {
|
||||
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 {
|
||||
@@ -640,6 +641,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const [customHeartbeatMinutes, setCustomHeartbeatMinutes] = useState<Record<string, string>>({});
|
||||
/** Global heartbeat multiplier loaded from project settings */
|
||||
const [heartbeatMultiplier, setHeartbeatMultiplier] = useState<number>(1);
|
||||
const [agentModelSettings, setAgentModelSettings] = useState<Partial<Settings>>({});
|
||||
/** Whether the heartbeat multiplier is currently being saved */
|
||||
const [isSavingMultiplier, setIsSavingMultiplier] = useState(false);
|
||||
/** 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) => {
|
||||
if (!isMountedRef.current) return;
|
||||
setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1);
|
||||
setAgentModelSettings(settings);
|
||||
})
|
||||
.catch(() => {
|
||||
// Use default on error
|
||||
@@ -2049,7 +2052,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const isHeartbeatDisabled = !isAgentHeartbeatEnabled(agent);
|
||||
const heartbeatSelectValue = isHeartbeatDisabled ? HEARTBEAT_DISABLED_OPTION_VALUE : String(configuredIntervalMs);
|
||||
const isUpdatingHeartbeat = isBulkHeartbeatMutationRunning || updatingHeartbeatAgentId === agent.id || heartbeatMutationAgentIds.has(agent.id);
|
||||
const modelLabel = getAgentModelLabel(agent);
|
||||
const modelLabel = getAgentModelLabel(agent, agentModelSettings);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
mockFetchModels,
|
||||
mockFetchPluginRuntimes,
|
||||
mockFetchSkillContent,
|
||||
mockFetchSettings,
|
||||
mockFetchWorkspaceFileContent,
|
||||
mockMarkMessageRead,
|
||||
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 () => {
|
||||
mockUpdateAgent.mockResolvedValue(createMockAgent() as any);
|
||||
|
||||
|
||||
@@ -327,6 +327,31 @@ describe("AgentsView", () => {
|
||||
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 () => {
|
||||
const { container } = renderView(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
summarizeTitle,
|
||||
FUSION_RUNTIME_SELF_AWARENESS,
|
||||
createLogger,
|
||||
resolvePermanentAgentEffectiveModel,
|
||||
resolvePermanentAgentEffectiveThinkingLevel,
|
||||
} from "@fusion/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
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[] } }>;
|
||||
},
|
||||
private getSettings?: () => Promise<Pick<Settings,
|
||||
| "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 getSettings?: () => Promise<Partial<Settings> | undefined> | Partial<Settings> | undefined,
|
||||
private messageStore?: MessageStore,
|
||||
// Scoped task store for the chat's project — enables workflow-authoring
|
||||
// tools (fn_workflow_*) and explicit-task document tools. Optional so
|
||||
@@ -1566,23 +1542,14 @@ export class ChatManager {
|
||||
defaultThinkingLevelOverride?: Settings["defaultThinkingLevelOverride"];
|
||||
executionThinkingLevel?: Settings["executionThinkingLevel"];
|
||||
executionGlobalThinkingLevel?: Settings["executionGlobalThinkingLevel"];
|
||||
}> {
|
||||
} & Partial<Settings>> {
|
||||
if (!this.getSettings) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await this.getSettings();
|
||||
return {
|
||||
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,
|
||||
};
|
||||
return settings ?? {};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
diagnostics.warn(`Failed to load chat fallback settings: ${message}`);
|
||||
@@ -2138,13 +2105,19 @@ export class ChatManager {
|
||||
* 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.
|
||||
*/
|
||||
const effectiveModelProvider = input.modelProvider ?? responderRuntimeModel.provider ?? chatModelSettings.defaultProvider;
|
||||
const effectiveModelId = input.modelId ?? responderRuntimeModel.modelId ?? chatModelSettings.defaultModelId;
|
||||
const inheritedResponderModel = resolvePermanentAgentEffectiveModel(input.responder, chatModelSettings);
|
||||
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:
|
||||
* 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:
|
||||
* 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) {
|
||||
hasExplicitAgentRuntimeModel = true;
|
||||
}
|
||||
effectiveModelProvider ??= runtimeModel.provider;
|
||||
effectiveModelId ??= runtimeModel.modelId;
|
||||
const inheritedAgentModel = resolvePermanentAgentEffectiveModel(agent, await this.getChatModelSettings());
|
||||
const hasCompleteRuntimeModel = !!runtimeModel.provider && !!runtimeModel.modelId;
|
||||
effectiveModelProvider ??= hasCompleteRuntimeModel ? runtimeModel.provider : inheritedAgentModel.provider;
|
||||
effectiveModelId ??= hasCompleteRuntimeModel ? runtimeModel.modelId : inheritedAgentModel.modelId;
|
||||
failureContextProvider = effectiveModelProvider;
|
||||
failureContextModelId = effectiveModelId;
|
||||
}
|
||||
@@ -2688,7 +2663,9 @@ export class ChatManager {
|
||||
* 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.
|
||||
*/
|
||||
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) {
|
||||
const warning = {
|
||||
|
||||
@@ -3,7 +3,13 @@ import { archivedColumnsForTask } from "../task-lifecycle-lanes.js";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
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";
|
||||
/*
|
||||
FNXC:GrokAcp 2026-07-11-18:30:
|
||||
@@ -445,8 +451,8 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
* POST /api/chat/sessions
|
||||
* Create a new chat session.
|
||||
* Body: { agentId: string, title?: string, modelProvider?: string, modelId?: string, thinkingLevel?: string }
|
||||
* If modelProvider and modelId are provided, those are used. Otherwise the model is
|
||||
* resolved from the agent's runtimeConfig.model setting.
|
||||
* If modelProvider and modelId are provided, those are used. Otherwise the model and
|
||||
* 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.
|
||||
*/
|
||||
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");
|
||||
}
|
||||
|
||||
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)
|
||||
let resolvedProvider: string | null = null;
|
||||
let resolvedModelId: string | null = null;
|
||||
let inheritedThinkingLevel: string | undefined;
|
||||
|
||||
if (hasClientModelProvider && hasClientModelId) {
|
||||
// Use client-provided model
|
||||
@@ -489,18 +502,12 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
resolvedModelId = modelId!.trim();
|
||||
} else {
|
||||
// Resolve from agent's runtimeConfig.model
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw notFound(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
const resolved = resolvePermanentAgentEffectiveModel(agent, settings);
|
||||
resolvedProvider = resolved.provider ?? null;
|
||||
resolvedModelId = resolved.modelId ?? null;
|
||||
inheritedThinkingLevel = resolvePermanentAgentEffectiveThinkingLevel(agent, settings);
|
||||
}
|
||||
inheritedThinkingLevel ??= resolvePermanentAgentEffectiveThinkingLevel(agent, settings);
|
||||
|
||||
// Create the chat session with projectId for multi-project scoping
|
||||
const session = await chatStore.createSession({
|
||||
@@ -509,7 +516,7 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps):
|
||||
projectId: projectId ?? null,
|
||||
modelProvider: resolvedProvider,
|
||||
modelId: resolvedModelId,
|
||||
...(thinkingLevel ? { thinkingLevel } : {}),
|
||||
...((thinkingLevel ?? inheritedThinkingLevel) ? { thinkingLevel: thinkingLevel ?? inheritedThinkingLevel } : {}),
|
||||
});
|
||||
|
||||
res.status(201).json({ session });
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
evaluateImplementationTaskBind,
|
||||
resolvePersistAgentThinkingLog,
|
||||
resolveAgentMemoryInclusionMode,
|
||||
resolvePermanentAgentEffectiveThinkingLevel,
|
||||
AWAITING_APPROVAL_PAUSE_REASON,
|
||||
rankAssignedTasksForWakeDelta,
|
||||
formatAssignedTasksWakeDeltaSection,
|
||||
@@ -3066,7 +3067,13 @@ export class HeartbeatMonitor {
|
||||
heartbeatModelSettings = taskDetail
|
||||
? await mergeEffectiveSettings(taskStore, taskDetail, 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
|
||||
// logger construction, so refresh telemetry before the session boundary and tool callbacks.
|
||||
attachAgentUsageTelemetry(agentLogger, {
|
||||
@@ -3106,6 +3113,7 @@ export class HeartbeatMonitor {
|
||||
fallbackProvider: heartbeatSessionModels.fallbackProvider,
|
||||
fallbackModelId: heartbeatSessionModels.fallbackModelId,
|
||||
fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(undefined, heartbeatModelSettings),
|
||||
...(effectiveHeartbeatThinkingLevel ? { defaultThinkingLevel: effectiveHeartbeatThinkingLevel } : {}),
|
||||
runAuditor: audit,
|
||||
settings: heartbeatModelSettings,
|
||||
mcpServers: heartbeatMcp.servers,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isTestModeActive,
|
||||
resolveAgentToolOutputMaxChars,
|
||||
resolveExecutionSettingsModel,
|
||||
resolvePermanentAgentEffectiveModel,
|
||||
resolveExecutorFallbackModel,
|
||||
resolveMergerSettingsModel,
|
||||
resolvePhaseThinkingLevel,
|
||||
@@ -799,6 +800,7 @@ export function resolveValidatorSessionModel(
|
||||
export function resolveHeartbeatSessionModels(
|
||||
settings: Partial<Settings> | undefined,
|
||||
assignedAgentRuntimeConfig?: Record<string, unknown>,
|
||||
agent?: { roles?: string[]; role?: string; metadata?: Record<string, unknown> | null },
|
||||
): {
|
||||
defaultProvider: 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 assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user