Files
multica/packages/views/agents/components/agent-creation-studio.execution.test.tsx
Multica Eve 81797b5a03 feat(agents): thinking level + Codex speed in the create flow (MUL-5390) (#6027)
* feat(agents): expose thinking level and Codex speed in the create flow (MUL-5390)

Creating an agent could only set runtime + model, so a Codex agent that
needed Fast had to be created and then fixed in settings — and duplicating
one silently dropped both overrides. The create API and the TS contract
already carried `thinking_level` / `service_tier`; only the creation
surface did not.

- AgentDraft carries thinkingLevel / serviceTier, rendered in the
  Execution section through the same ThinkingSettingField /
  ServiceTierSettingField the settings page uses. They stay fail-closed:
  a field appears only when the exact selected model's live catalog on an
  online runtime advertises the capability, so no value can be sent that
  the daemon would refuse.
- Payload and duplicate-draft assembly move into pure functions
  (buildCreateAgentRequest / buildDuplicateDraft); empty overrides are
  omitted instead of sent as "".
- Dependency cleanup: a runtime change clears model + thinking + speed, a
  model change clears the two per-model overrides. Applies to the plain
  form, the AI-builder setup screen, a committed builder runtime rebind,
  and a builder draft that moves the model.
- Duplicate now follows the rule `multica agent copy` already enforces:
  same runtime copies model / thinking / speed, a forced fallback to
  another runtime clears all three (it previously kept a model the target
  runtime may not serve) and says so. custom_args and concurrency are
  runtime-independent and still copied.
- Settings page: changing the model no longer leaves an orphan override.
  Only what the authoritative catalog says the new model does not support
  is cleared, and it travels with the model in one request. An unknown
  catalog (offline runtime, discovery in flight or failed), an empty
  "runtime default" model or a model missing from the catalog leaves the
  stored values untouched instead of deleting a value the daemon would
  have honoured.
- Restores the 255-rune description limit plus counter that the studio
  lost relative to the old dialog, and gates create on it since a
  duplicate or builder draft can seed a longer value programmatically.
- zh-Hans / en / ja / ko strings, including a fuller duplicate notice
  (MCP servers and machine-local runtime config are not copied either).

Out of scope, tracked separately: max_concurrent_tasks bounds on the API,
from-template parity (its UI is unreachable on main), and copying skill
enabled state / runtime-skill overrides.

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): do not seed a duplicate draft before runtimes resolve (MUL-5390)

On a cold start — a direct ?duplicate=<id> link or a refresh — the agent list
can resolve while the runtime list is still pending. The one-shot seeding
effect ran anyway, so buildDuplicateDraft judged the source runtime
unavailable against an empty list and permanently cleared the model /
thinking_level / service_tier a same-runtime copy must keep, plus showed the
fallback notice. Re-running on every runtime change would instead overwrite
edits the user already made, so the gate is on the query being decidable.

Seeding now lives in useDuplicateDraftSeed and waits for the runtime query to
resolve or fail; a failure still seeds, since an unconfirmable runtime makes
the fallback correct. Verified the new test fails without the gate.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-28 17:03:41 +08:00

187 lines
5.4 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type {
RuntimeDevice,
RuntimeModel,
RuntimeModelListRequest,
} from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enAgents from "../../locales/en/agents.json";
import enCommon from "../../locales/en/common.json";
import enIssues from "../../locales/en/issues.json";
const mockInitiateListModels = vi.hoisted(() => vi.fn());
const mockGetListModelsResult = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/api", () => ({
api: {
initiateListModels: (...args: unknown[]) => mockInitiateListModels(...args),
getListModelsResult: (...args: unknown[]) =>
mockGetListModelsResult(...args),
},
ApiError: class ApiError extends Error {},
}));
import {
AgentExecutionOverrides,
type AgentDraft,
} from "./agent-creation-studio";
const FAST_MODEL: RuntimeModel = {
id: "gpt-5.6-sol",
label: "GPT-5.6 Sol",
thinking: {
supported_levels: [
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
},
service_tiers: [{ id: "priority", name: "Fast", description: "1.5x speed" }],
};
const PLAIN_MODEL: RuntimeModel = { id: "gpt-5.4-mini", label: "GPT-5.4 mini" };
const ONLINE_RUNTIME = {
id: "runtime-1",
name: "Codex laptop",
provider: "codex",
status: "online",
} as RuntimeDevice;
function listResult(models: RuntimeModel[]): RuntimeModelListRequest {
return {
id: "request-1",
runtime_id: "runtime-1",
status: "completed",
models,
supported: true,
created_at: "2026-07-28T00:00:00Z",
updated_at: "2026-07-28T00:00:00Z",
};
}
const baseDraft: AgentDraft = {
name: "Fast Codex",
description: "",
instructions: "",
avatarUrl: null,
runtimeId: "runtime-1",
model: "gpt-5.6-sol",
thinkingLevel: "",
serviceTier: "",
skillIds: new Set(),
permissionScope: "private",
memberIds: new Set(),
teamIds: new Set(),
};
function renderOverrides(
props: Partial<React.ComponentProps<typeof AgentExecutionOverrides>> = {},
) {
const onChange = vi.fn();
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<I18nProvider
locale="en"
resources={{
en: { common: enCommon, agents: enAgents, issues: enIssues },
}}
>
<QueryClientProvider client={queryClient}>
<AgentExecutionOverrides
draft={baseDraft}
runtime={ONLINE_RUNTIME}
onChange={onChange}
{...props}
/>
</QueryClientProvider>
</I18nProvider>,
);
return { onChange };
}
// MUL-5390: the create flow never offered these two, so a Fast Codex agent had
// to be created first and fixed afterwards in settings.
describe("AgentExecutionOverrides", () => {
beforeEach(() => {
vi.clearAllMocks();
mockInitiateListModels.mockResolvedValue(listResult([FAST_MODEL]));
mockGetListModelsResult.mockResolvedValue(listResult([FAST_MODEL]));
});
afterEach(cleanup);
it("offers Speed for a model whose catalog advertises a tier", async () => {
const { onChange } = renderOverrides();
await screen.findByText("Speed");
fireEvent.click(screen.getByRole("button", { name: /speed/i }));
fireEvent.click(await screen.findByText("Fast"));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ serviceTier: "priority" }),
);
});
it("offers the exact model's thinking levels", async () => {
const { onChange } = renderOverrides();
await screen.findByText("Thinking");
fireEvent.click(screen.getByRole("button", { name: /thinking/i }));
fireEvent.click(await screen.findByText("High"));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ thinkingLevel: "high" }),
);
});
it("hides both fields when the model advertises neither capability", async () => {
mockInitiateListModels.mockResolvedValue(listResult([PLAIN_MODEL]));
mockGetListModelsResult.mockResolvedValue(listResult([PLAIN_MODEL]));
renderOverrides({ draft: { ...baseDraft, model: "gpt-5.4-mini" } });
await waitFor(() => expect(mockInitiateListModels).toHaveBeenCalled());
expect(screen.queryByText("Speed")).toBeNull();
expect(screen.queryByText("Thinking")).toBeNull();
});
it("stays hidden and asks the daemon nothing while the runtime is offline", async () => {
renderOverrides({
runtime: { ...ONLINE_RUNTIME, status: "offline" } as RuntimeDevice,
});
await waitFor(() => {
expect(screen.queryByText("Speed")).toBeNull();
});
expect(mockInitiateListModels).not.toHaveBeenCalled();
});
it("stays hidden when model discovery fails", async () => {
mockInitiateListModels.mockRejectedValue(new Error("discovery failed"));
renderOverrides();
await waitFor(() => expect(mockInitiateListModels).toHaveBeenCalled());
expect(screen.queryByText("Speed")).toBeNull();
expect(screen.queryByText("Thinking")).toBeNull();
});
it("fails closed for an empty model that follows the runtime default", async () => {
renderOverrides({ draft: { ...baseDraft, model: "" } });
await waitFor(() => expect(mockInitiateListModels).toHaveBeenCalled());
expect(screen.queryByText("Speed")).toBeNull();
expect(screen.queryByText("Thinking")).toBeNull();
});
});