mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 01:19:26 +02:00
Compare commits
1 Commits
codex/agen
...
agent/walt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b91587ae |
231
packages/views/agents/components/agent-profile-card.test.tsx
Normal file
231
packages/views/agents/components/agent-profile-card.test.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enAgents from "../../locales/en/agents.json";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
// Preserve the real paths module (sub-components import other helpers from it)
|
||||
// and only stub the one path the card actually calls.
|
||||
vi.mock("@multica/core/paths", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@multica/core/paths")>(
|
||||
"@multica/core/paths",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useWorkspacePaths: () => ({
|
||||
agentDetail: (id: string) => `/test/agents/${id}`,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/workspace/avatar-url", () => ({
|
||||
resolvePublicFileUrl: (url: string | null) => url,
|
||||
}));
|
||||
|
||||
vi.mock("../../navigation", () => ({
|
||||
AppLink: ({
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
[k: string]: unknown;
|
||||
}) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockAgents = vi.hoisted(() => ({ current: [] as unknown[] }));
|
||||
const mockRuntimes = vi.hoisted(() => ({ current: [] as unknown[] }));
|
||||
const mockModels = vi.hoisted(() => ({ current: undefined as unknown }));
|
||||
const mockPresence = vi.hoisted(() => ({ current: "loading" as unknown }));
|
||||
|
||||
// Distinguish the card's four queries by their key shape:
|
||||
// ["workspaces", wsId, "agents"] — agent list
|
||||
// ["workspaces", wsId, "members"] — member list
|
||||
// ["runtimes", wsId, "list"] — runtime list
|
||||
// ["runtimes", "models", rtId] — runtime model catalog (gated by enabled)
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useQuery: (opts: { queryKey: readonly unknown[]; enabled?: boolean }) => {
|
||||
const key = opts.queryKey;
|
||||
const root = key[0];
|
||||
const marker = key[2];
|
||||
if (root === "workspaces" && marker === "agents") {
|
||||
return { data: mockAgents.current, isLoading: false };
|
||||
}
|
||||
if (root === "workspaces" && marker === "members") {
|
||||
return { data: [], isLoading: false };
|
||||
}
|
||||
if (root === "runtimes" && key[1] === "models") {
|
||||
// Mirrors the real `enabled: Boolean(runtimeId)` gate: a disabled
|
||||
// catalog query (offline / no override) exposes no data.
|
||||
return { data: opts.enabled ? mockModels.current : undefined, isLoading: false };
|
||||
}
|
||||
if (root === "runtimes" && marker === "list") {
|
||||
return { data: mockRuntimes.current, isLoading: false };
|
||||
}
|
||||
return { data: undefined, isLoading: false };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/agents", async () => {
|
||||
const actual = await vi.importActual<typeof import("@multica/core/agents")>(
|
||||
"@multica/core/agents",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAgentPresenceDetail: () => mockPresence.current,
|
||||
};
|
||||
});
|
||||
|
||||
import { AgentProfileCard } from "./agent-profile-card";
|
||||
|
||||
function makeAgent(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "agent-1",
|
||||
workspace_id: "ws-1",
|
||||
runtime_id: "rt-1",
|
||||
name: "Walt",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local" as const,
|
||||
runtime_config: {},
|
||||
custom_args: [],
|
||||
visibility: "workspace" as const,
|
||||
status: "idle" as const,
|
||||
max_concurrent_tasks: 1,
|
||||
model: "claude-opus-4-8",
|
||||
thinking_level: "",
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntime(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "rt-1",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: null,
|
||||
name: "Mac-Studio",
|
||||
runtime_mode: "local" as const,
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online" as const,
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private" as const,
|
||||
last_seen_at: new Date().toISOString(),
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Catalog with the friendly label for the persisted token ("xhigh" → "Extra high").
|
||||
const CATALOG = {
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
label: "Opus 4.8",
|
||||
default: true,
|
||||
thinking: {
|
||||
supported_levels: [
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "xhigh", label: "Extra high" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
supported: true,
|
||||
};
|
||||
|
||||
function renderCard() {
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<AgentProfileCard agentId="agent-1" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockAgents.current = [makeAgent()];
|
||||
mockRuntimes.current = [makeRuntime()];
|
||||
mockModels.current = CATALOG;
|
||||
mockPresence.current = {
|
||||
availability: "online",
|
||||
workload: "idle",
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
capacity: 1,
|
||||
};
|
||||
});
|
||||
|
||||
describe("AgentProfileCard model + thinking level", () => {
|
||||
it("shows the model id and no thinking chip when no override is set", () => {
|
||||
mockAgents.current = [makeAgent({ model: "claude-opus-4-8", thinking_level: "" })];
|
||||
|
||||
renderCard();
|
||||
|
||||
expect(screen.getByText(enAgents.profile_card.model_label)).toBeInTheDocument();
|
||||
expect(screen.getByText("claude-opus-4-8")).toBeInTheDocument();
|
||||
// An empty override renders no chip — neither a label nor a raw token.
|
||||
expect(screen.queryByText("Extra high")).toBeNull();
|
||||
expect(screen.queryByText("xhigh")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the thinking token to its catalog label when the runtime is online", () => {
|
||||
mockAgents.current = [makeAgent({ thinking_level: "xhigh" })];
|
||||
mockRuntimes.current = [makeRuntime({ status: "online" })];
|
||||
mockModels.current = CATALOG;
|
||||
|
||||
renderCard();
|
||||
|
||||
expect(screen.getByText("Extra high")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the raw token when the catalog is unavailable (runtime offline)", () => {
|
||||
mockAgents.current = [makeAgent({ thinking_level: "xhigh" })];
|
||||
mockRuntimes.current = [makeRuntime({ status: "offline" })];
|
||||
|
||||
renderCard();
|
||||
|
||||
// Catalog query is disabled while offline → the raw token shows verbatim.
|
||||
expect(screen.getByText("xhigh")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Extra high")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the default label when the agent has no explicit model", () => {
|
||||
mockAgents.current = [makeAgent({ model: "", thinking_level: "" })];
|
||||
|
||||
renderCard();
|
||||
|
||||
expect(screen.getByText(enAgents.pickers.model_default)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Agent, AgentRuntime } from "@multica/core/types";
|
||||
import type { Agent, AgentRuntime, RuntimeModel } from "@multica/core/types";
|
||||
import { useAgentPresenceDetail } from "@multica/core/agents";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
deriveRuntimeHealth,
|
||||
runtimeModelsOptions,
|
||||
type RuntimeHealth,
|
||||
} from "@multica/core/runtimes";
|
||||
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
|
||||
@@ -33,6 +34,22 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
const runtime = agent
|
||||
? runtimes.find((r) => r.id === agent.runtime_id) ?? null
|
||||
: null;
|
||||
// Friendly thinking-level label comes from the runtime model catalog
|
||||
// (RuntimeModel.thinking.supported_levels[].label) — the same source the
|
||||
// inspector's ThinkingPicker uses. Only fetch it when the agent actually has
|
||||
// a thinking override AND its runtime is online: agents with no override (the
|
||||
// common case, where no chip renders) never trigger daemon model discovery
|
||||
// from this passive hover surface. Cold cache / offline → raw-token fallback.
|
||||
const runtimeOnline = runtime?.status === "online";
|
||||
const thinkingLevel = agent?.thinking_level ?? "";
|
||||
const modelsQuery = useQuery(
|
||||
runtimeModelsOptions(
|
||||
agent && thinkingLevel && runtimeOnline ? agent.runtime_id : null,
|
||||
),
|
||||
);
|
||||
|
||||
if (agentsLoading && !agent) {
|
||||
return (
|
||||
@@ -55,8 +72,11 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
const owner = agent.owner_id
|
||||
? members.find((m) => m.user_id === agent.owner_id) ?? null
|
||||
: null;
|
||||
const runtime = runtimes.find((r) => r.id === agent.runtime_id) ?? null;
|
||||
const isArchived = !!agent.archived_at;
|
||||
const modelValue = agent.model ?? "";
|
||||
const thinkingChipLabel = thinkingLevel
|
||||
? resolveThinkingLabel(modelsQuery.data?.models ?? [], modelValue, thinkingLevel)
|
||||
: null;
|
||||
const initials = agent.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
@@ -113,11 +133,19 @@ export function AgentProfileCard({ agentId }: AgentProfileCardProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta rows — minimal set: runtime (where it lives), skills (what
|
||||
it knows), owner (who manages it). Model is intentionally
|
||||
omitted — power-user detail lives on the detail page. */}
|
||||
{/* Meta rows — runtime (where it lives), model + thinking level (what it
|
||||
runs), skills (what it knows), owner (who manages it). The thinking
|
||||
chip renders only when the agent has a non-empty override. */}
|
||||
<div className="flex flex-col gap-1.5 text-xs">
|
||||
<RuntimeRow agent={agent} runtime={runtime} />
|
||||
<ModelRow
|
||||
model={modelValue || t(($) => $.pickers.model_default)}
|
||||
isDefault={!modelValue}
|
||||
thinkingLabel={thinkingChipLabel}
|
||||
thinkingTitle={t(($) => $.pickers.thinking_tooltip, {
|
||||
value: thinkingChipLabel ?? "",
|
||||
})}
|
||||
/>
|
||||
{agent.skills.length > 0 && (
|
||||
<SkillsRow skills={agent.skills.map((s) => s.name)} />
|
||||
)}
|
||||
@@ -189,6 +217,67 @@ function RuntimeRow({
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve a persisted thinking_level token to its friendly catalog label
|
||||
// (e.g. "xhigh" → "Extra high"), mirroring ThinkingPicker's lookup. Falls back
|
||||
// to the raw token when the catalog is unavailable (runtime offline, cold
|
||||
// cache, or the model/level dropped from the catalog) — an accepted
|
||||
// degradation for this glance surface.
|
||||
function resolveThinkingLabel(
|
||||
models: RuntimeModel[],
|
||||
model: string,
|
||||
value: string,
|
||||
): string {
|
||||
const entry = model
|
||||
? models.find((m) => m.id === model)
|
||||
: models.find((m) => m.default) ?? models[0];
|
||||
return (
|
||||
entry?.thinking?.supported_levels.find((l) => l.value === value)?.label ??
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
// Model row + optional thinking-level chip. Model is shown as the raw id
|
||||
// (matching the inspector, which never resolves it to a friendly label); the
|
||||
// thinking chip reuses the skills-chip visual and is rendered only when the
|
||||
// agent has a non-empty thinking override. The model value gets a `title` so
|
||||
// long ids that truncate stay readable on hover.
|
||||
function ModelRow({
|
||||
model,
|
||||
isDefault,
|
||||
thinkingLabel,
|
||||
thinkingTitle,
|
||||
}: {
|
||||
model: string;
|
||||
isDefault: boolean;
|
||||
thinkingLabel: string | null;
|
||||
thinkingTitle: string;
|
||||
}) {
|
||||
const { t } = useT("agents");
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-12 shrink-0 text-muted-foreground">
|
||||
{t(($) => $.profile_card.model_label)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={`min-w-0 truncate ${isDefault ? "" : "font-mono text-[11px]"}`}
|
||||
title={model}
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
{thinkingLabel && (
|
||||
<span
|
||||
className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
|
||||
title={thinkingTitle}
|
||||
>
|
||||
{thinkingLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -406,6 +406,7 @@
|
||||
"unavailable": "Agent unavailable",
|
||||
"detail_link": "Detail →",
|
||||
"runtime_label": "Runtime",
|
||||
"model_label": "Model",
|
||||
"skills_label": "Skills",
|
||||
"owner_label": "Owner",
|
||||
"unknown_runtime": "Unknown runtime"
|
||||
|
||||
@@ -388,6 +388,7 @@
|
||||
"unavailable": "エージェントを利用できません",
|
||||
"detail_link": "詳細 →",
|
||||
"runtime_label": "ランタイム",
|
||||
"model_label": "モデル",
|
||||
"skills_label": "スキル",
|
||||
"owner_label": "オーナー",
|
||||
"unknown_runtime": "不明なランタイム"
|
||||
|
||||
@@ -406,6 +406,7 @@
|
||||
"unavailable": "에이전트를 사용할 수 없습니다",
|
||||
"detail_link": "자세히 →",
|
||||
"runtime_label": "런타임",
|
||||
"model_label": "모델",
|
||||
"skills_label": "스킬",
|
||||
"owner_label": "소유자",
|
||||
"unknown_runtime": "알 수 없는 런타임"
|
||||
|
||||
@@ -396,6 +396,7 @@
|
||||
"unavailable": "智能体不可用",
|
||||
"detail_link": "详情 →",
|
||||
"runtime_label": "运行时",
|
||||
"model_label": "模型",
|
||||
"skills_label": "Skills",
|
||||
"owner_label": "所有者",
|
||||
"unknown_runtime": "未知运行时"
|
||||
|
||||
Reference in New Issue
Block a user