Files
multica/packages/views/agents/components/runtime-picker.test.tsx
Bohan Jiang 168620fc15 MUL-5163 fix(agents): rebind the Agent Builder carrier when the runtime is switched (#5780)
Switching the runtime mid-conversation in Build with AI only updated local React state, so the picker could show runtime B while every subsequent message still executed on the runtime frozen at session create time.

- Add PATCH /api/agent-builder/sessions/{id}/runtime to rebind the hidden builder carrier (runtime_id/runtime_mode, model cleared since model ids are per-runtime). Creator-only, builder carriers only, target must be in-workspace, usable by the member, and online; a reply in flight returns 409.
- Serialise rebind against send: both take LockChatSessionForRuntimeBind on the chat_session row and SendDirectChatMessage re-reads the agent inside that transaction, so a send blocked behind a rebind cannot resume and stamp its task with the runtime the switch moved away from.
- Leave chat_session.runtime_id stale on purpose so the daemon starts a fresh provider session on the new runtime while Multica-side history and the draft survive.
- Frontend updates the draft only after the server reports the bound runtime, blocks sending during a rebind, disables the Mine/All filter alongside the trigger, and explains why the picker is locked during a pending reply.

Closes #5773
2026-07-23 10:56:53 +08:00

124 lines
4.2 KiB
TypeScript

// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup } from "@testing-library/react";
import type { MemberWithUser, RuntimeDevice } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enAgents from "../../locales/en/agents.json";
import enIssues from "../../locales/en/issues.json";
// ActorAvatar pulls workspace context this unit test doesn't provide.
vi.mock("../../common/actor-avatar", () => ({
ActorAvatar: () => null,
}));
// Provider logos are inline SVGs with no behavior under test.
vi.mock("../../runtimes/components/provider-logo", () => ({
ProviderLogo: () => null,
}));
import { RuntimePicker } from "./runtime-picker";
const TEST_RESOURCES = {
en: { common: enCommon, agents: enAgents, issues: enIssues },
};
const ME = "user-me";
const MEMBERS = [{ user_id: ME, name: "Me", role: "member" }] as unknown as MemberWithUser[];
function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
return {
id: "rt",
workspace_id: "ws-1",
daemon_id: "daemon-1",
name: "Claude (host.local)",
runtime_mode: "local",
provider: "claude",
launch_header: "",
status: "online",
device_info: "host.local · macOS (arm64)",
metadata: {},
owner_id: ME,
visibility: "private",
last_seen_at: "2026-07-11T00:00:00Z",
created_at: "2026-07-01T00:00:00Z",
updated_at: "2026-07-01T00:00:00Z",
...overrides,
} as RuntimeDevice;
}
const RUNTIMES = [
makeRuntime({ id: "rt-a", name: "Claude (a.local)" }),
makeRuntime({ id: "rt-b", name: "Claude (b.local)", provider: "codex" }),
];
function renderPicker(props: Partial<React.ComponentProps<typeof RuntimePicker>> = {}) {
const onSelect = vi.fn();
const utils = render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<RuntimePicker
runtimes={RUNTIMES}
members={MEMBERS}
currentUserId={ME}
selectedRuntimeId="rt-a"
onSelect={onSelect}
{...props}
/>
</I18nProvider>,
);
return { ...utils, onSelect };
}
function trigger(container: HTMLElement): HTMLButtonElement {
const element = container.querySelector<HTMLButtonElement>('[data-slot="popover-trigger"]');
if (!element) throw new Error("runtime picker trigger not rendered");
return element;
}
describe("RuntimePicker (creation studio)", () => {
beforeEach(() => cleanup());
afterEach(() => cleanup());
it("opens the runtime list on click", () => {
const { container } = renderPicker();
fireEvent.click(trigger(container));
expect(trigger(container).getAttribute("aria-expanded")).toBe("true");
});
// A builder session rebinds its execution runtime on the server. While that
// request is in flight the selection cannot be honoured yet, so the picker
// must refuse to open rather than let the user queue a second, conflicting
// choice (MUL-5163).
it("cannot be opened while disabled", () => {
const { container, onSelect } = renderPicker({ disabled: true });
expect(trigger(container).disabled).toBe(true);
fireEvent.click(trigger(container));
expect(trigger(container).getAttribute("aria-expanded")).not.toBe("true");
expect(onSelect).not.toHaveBeenCalled();
});
// Switching the Mine/All tab re-selects the first usable runtime in the new
// list, so it is a second path into onSelect. Leaving it live while disabled
// would let a locked picker start a switch the server then has to refuse.
it("does not select through the Mine/All filter while disabled", () => {
const { container, onSelect } = renderPicker({
runtimes: [
...RUNTIMES,
makeRuntime({ id: "rt-other", name: "Claude (other.local)", owner_id: "user-other", visibility: "public" }),
],
disabled: true,
});
const filters = [...container.querySelectorAll("button")].filter(
(button) => button.getAttribute("data-slot") !== "popover-trigger",
);
expect(filters.length).toBeGreaterThan(0);
for (const button of filters) {
expect(button.disabled).toBe(true);
fireEvent.click(button);
}
expect(onSelect).not.toHaveBeenCalled();
});
});