Compare commits

..

3 Commits

Author SHA1 Message Date
Jiayuan
61684767c0 fix(agents): drop duplicate template thinking_level on runtime fallback (MUL-3772)
Remaining blocker on #4638: in duplicate mode the dialog initialized
`thinkingLevel` from the template even when the template runtime was
locked/missing and it fell back to a different-provider runtime. The
provider-change effect skips the initial establishment (to preserve genuine
pre-fill) and the catalog-based clear can't run when the fallback runtime is
offline / its catalog hasn't resolved, so the stale per-provider token could
be submitted and 400 (e.g. a Claude `max` on an offline Codex fallback).

Fix: only clone the template's `thinking_level` when the clone actually lands
on the template's own runtime — the same usability condition that governs the
runtime pre-fill, now factored into a shared `landsOnTemplateRuntime` helper.
On fallback to any other runtime the level starts blank, so it can never be
carried over regardless of provider, online state, or catalog timing.

Tests: locked template runtime → offline different-provider fallback drops the
level and omits it from the payload; landing on the template runtime (offline,
same provider) still preserves it.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-27 02:54:27 +08:00
Jiayuan
791c99b2b5 fix(agents): prevent stale/provider-invalid reasoning level on create (MUL-3772)
Addresses the blocking review on #4638: the create form held the chosen
`thinking_level` across runtime/model changes and could submit a token the
new provider rejects (the backend validates effort per provider and 400s an
invalid one, e.g. a Codex `none` on a Claude runtime).

Two guards:
- create-agent-dialog: clear the override synchronously when the selected
  runtime's provider changes (the backend validation axis), so a stale token
  can never be submitted — race-free, covering the window before the new
  runtime's model catalog has loaded. The initial provider establishment is
  skipped so duplicate-mode pre-fill survives until the user changes it.
- reasoning-picker: once the catalog for the current (runtime, model) has
  loaded, drop a value the model no longer advertises (also hides the row for
  non-reasoning models). Gated on loaded data so a duplicate pre-fill isn't
  wiped before its catalog arrives. Stricter than the inspector picker, which
  keeps orphans visible as a clear-only affordance.

Tests: switching the agent runtime to another provider clears the stale level
and omits it from the payload; a duplicate-mode orphan the model doesn't
advertise is dropped and not submitted. Per-runtime model-catalog test mock.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-27 02:45:46 +08:00
Jiayuan
6039b451ab feat(agents): split create-agent runtime into Machine + Agent runtime and add reasoning (MUL-3772)
REQ-1: the single "Runtime" dropdown (e.g. "Claude (host.local)") is split
into two cascading selectors — Machine (the physical/cloud host) and Agent
runtime (the CLI backend on that host). A RuntimeDevice is already a
(machine × CLI) pair, so this is a pure frontend regrouping: the flat
`runtimes` array is grouped by daemon (cloud/daemon-less hosts fall back to
owner+device, then runtime id) and the resolved (machine, runtime) collapses
back to one `runtime_id`. The submit payload is unchanged. Single-runtime
machines render the agent-runtime selector read-only for a stable layout.
The Mine/All filter, private-runtime lock + Create gate, duplicate-mode
pre-fill, and late-WS seeding are all preserved on the new Machine selector.

REQ-2: models that expose reasoning levels now get a Reasoning (effort)
picker in the create form, gated identically to the inspector — hidden until
the selected model advertises `supported_levels`, "" = "Follow CLI config"
(the effort flag is omitted). Level resolution is shared with the inspector
via a new `thinking-levels` helper (no logic fork). `thinking_level` is added
to the create payload and pre-filled in duplicate mode.

No API/DB change. New i18n keys added across en/ja/ko/zh-Hans.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-27 02:31:56 +08:00
22 changed files with 1285 additions and 390 deletions

View File

@@ -159,14 +159,14 @@ Agentic coding CLI using the ACP protocol over stdio (shares the transport with
### Antigravity (Google)
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Multica launches it with `agy -p`, the daemon-compatible non-interactive mode; current Antigravity CLI releases can execute tools from that mode, while `agy -i` requires an attached TTY. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
| | |
|---|---|
| Daemon looks for | `agy` |
| Install | Follow the official guide at [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview). The CLI ships pre-built — run `agy install` once to wire up PATH and shell aliases. |
| Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. |
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text, and per-tool telemetry is not available today. |
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text. |
## After installing

View File

@@ -159,14 +159,14 @@ ACP 协议 agent和 Kimi 共享传输层。会话续接可用MCP 配置
### AntigravityGoogle
Google 的 Antigravity CLI`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动它,这是适合 daemon 后台任务的一次性非交互模式;当前 Antigravity CLI 在这个模式下仍可执行工具,而 `agy -i` 需要连接 TTY不适合 daemon 驱动。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
Google 的 Antigravity CLI`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
| | |
|---|---|
| 守护进程扫描 | `agy` |
| 安装 | 看官方指引 [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview)。CLI 是预编译的,跑一次 `agy install` 配好 PATH 和 shell 别名即可。 |
| 认证 | 交互式跑一次 `agy` 走 Google 账号登录流程;或者通过 Antigravity 桌面端登录——CLI 会复用 GUI 写入 keyring 的凭据。 |
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 过程和最终回复都会作为 text 消息送回 Multica,目前无法展示 Antigravity 的逐工具 telemetry。 |
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 思考过程和最终回复都会作为 text 消息送回 Multica。 |
## 装完之后

View File

@@ -31,7 +31,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
### Antigravity
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. Multica launches Antigravity with `agy -p` because that is the daemon-compatible non-interactive mode; `agy -i` needs an attached TTY and is not suitable for background task execution. Current Antigravity CLI releases can still execute tools from this mode, but stdout is plain assistant text rather than a structured event stream, so Multica relays the transcript as text and cannot show per-tool telemetry for Antigravity today. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
### Claude Code

View File

@@ -31,7 +31,7 @@ Multica 内置支持 **13 款 AI 编程工具**。它们都实现了同一套接
### Antigravity
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动 Antigravity因为这是适合 daemon 后台任务的一次性非交互模式;`agy -i` 需要连接 TTY不适合后台执行。当前 Antigravity CLI 在 `agy -p` 下仍可执行工具,但 stdout 是纯文本而非结构化事件流,所以 Multica 会把 transcript 作为 text 转发,暂时无法展示逐工具 telemetry。**会话恢复真用**——通过 `--conversation <id>`,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flagagy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug而且 agy 遇到无法识别的值会静默空跑所以优先从发现列表里挑选不要手填。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flagagy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug而且 agy 遇到无法识别的值会静默空跑所以优先从发现列表里挑选不要手填。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
### Claude Code

View File

@@ -3,7 +3,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { Agent, MemberWithUser, RuntimeDevice } from "@multica/core/types";
import type {
Agent,
MemberWithUser,
RuntimeDevice,
RuntimeModel,
} from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { NavigationProvider, type NavigationAdapter } from "../../navigation";
@@ -26,11 +31,30 @@ vi.mock("@multica/core/hooks", () => ({
}));
// ModelDropdown talks to the api; the create dialog only needs it as a
// stand-in here, so swap it out.
// stand-in here, so swap it out. The Reasoning picker, however, IS under
// test, so we mock the runtime-models query layer it depends on with a
// controllable catalog instead of mocking the component itself.
vi.mock("./model-dropdown", () => ({
ModelDropdown: () => null,
}));
// Per-runtime model catalogs so a runtime/provider switch resolves a
// different reasoning vocabulary, exercising the stale-token clearing.
const modelsByRuntime: Record<
string,
{ models: RuntimeModel[]; supported: boolean }
> = {};
vi.mock("@multica/core/runtimes", () => ({
runtimeModelsOptions: (runtimeId: string | null | undefined) => ({
queryKey: ["runtime-models", runtimeId ?? "none"],
queryFn: async () =>
modelsByRuntime[runtimeId ?? ""] ?? { models: [], supported: true },
enabled: Boolean(runtimeId),
retry: false,
}),
}));
// Provider logos don't matter for these assertions but they pull in SVGs.
vi.mock("../../runtimes/components/provider-logo", () => ({
ProviderLogo: () => null,
@@ -42,7 +66,7 @@ vi.mock("../../common/actor-avatar", () => ({
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
}));
import { CreateAgentDialog } from "./create-agent-dialog";
@@ -78,10 +102,10 @@ function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
id: "rt",
workspace_id: "ws-1",
daemon_id: null,
name: "Test Runtime",
name: "Claude (host.local)",
runtime_mode: "local",
provider: "claude",
launch_header: "",
launch_header: "claude (stream-json)",
status: "online",
device_info: "host.local",
metadata: {},
@@ -94,11 +118,11 @@ function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
};
}
function makeTemplate(runtimeId: string): Agent {
function makeTemplate(overrides: Partial<Agent>): Agent {
return {
id: "agent-template",
workspace_id: "ws-1",
runtime_id: runtimeId,
runtime_id: "rt",
name: "Template Agent",
description: "",
instructions: "",
@@ -116,6 +140,7 @@ function makeTemplate(runtimeId: string): Agent {
updated_at: "2026-04-01T00:00:00Z",
archived_at: null,
archived_by: null,
...overrides,
};
}
@@ -129,16 +154,16 @@ function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<WorkspaceSlugProvider slug="test-ws">
<NavigationProvider value={navigationStub}>
<CreateAgentDialog
runtimes={runtimes}
members={members}
currentUserId={ME}
template={template}
onClose={onClose}
onCreate={onCreate}
/>
</NavigationProvider>
<NavigationProvider value={navigationStub}>
<CreateAgentDialog
runtimes={runtimes}
members={members}
currentUserId={ME}
template={template}
onClose={onClose}
onCreate={onCreate}
/>
</NavigationProvider>
</WorkspaceSlugProvider>
</QueryClientProvider>
</I18nProvider>,
@@ -146,138 +171,218 @@ function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
return { onCreate, onClose };
}
describe("CreateAgentDialog runtime visibility gate", () => {
const tick = () => new Promise((r) => setTimeout(r, 0));
// A name is required before Create will submit; manual-create starts blank.
function typeName(value = "My Agent") {
fireEvent.change(screen.getByPlaceholderText("e.g. Deep Research Agent"), {
target: { value },
});
}
const openMachinePicker = () =>
fireEvent.click(screen.getByTestId("machine-picker-trigger"));
const openAgentRuntimePicker = () =>
fireEvent.click(screen.getByTestId("agent-runtime-trigger"));
afterEach(() => {
cleanup();
document.body.innerHTML = "";
for (const key of Object.keys(modelsByRuntime)) delete modelsByRuntime[key];
});
describe("CreateAgentDialog machine + agent-runtime cascade (MUL-3772)", () => {
beforeEach(() => vi.clearAllMocks());
// Base UI Dialog renders into a portal on document.body and leaves
// focus-guard / inert wrapper divs around after the React tree unmounts.
// The auto-cleanup from @testing-library/react drops the container but
// not the portal residue, so two-tests-in-a-row queries see double
// matches ("All", "My Runtime"). Force cleanup + wipe body between tests.
afterEach(() => {
cleanup();
document.body.innerHTML = "";
it("groups two CLIs on one daemon into a machine and cascades the runtime picker", async () => {
const claude = makeRuntime({
id: "rt-claude",
daemon_id: "d1",
name: "Claude (Workstation)",
device_info: "Workstation",
provider: "claude",
});
const codex = makeRuntime({
id: "rt-codex",
daemon_id: "d1",
name: "Codex (Workstation)",
device_info: "Workstation",
provider: "codex",
launch_header: "codex app-server",
});
const { onCreate } = renderDialog([claude, codex]);
// Machine box shows the host label; agent-runtime box seeds to the
// provider-sorted first runtime (claude < codex).
expect(
screen.getByText("Workstation", { selector: "span.truncate" }),
).toBeInTheDocument();
expect(
screen.getByText("Claude", { selector: "span.truncate" }),
).toBeInTheDocument();
// Cascade: open the Agent runtime picker and switch to Codex.
openAgentRuntimePicker();
fireEvent.click(screen.getByText("Codex", { selector: "span.truncate" }));
typeName();
fireEvent.click(screen.getByText("Create"));
await tick();
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-codex");
});
it("disables another member's private runtime in the picker", () => {
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
const othersPrivate = makeRuntime({
id: "rt-others-private",
name: "Others Private",
it("renders the agent-runtime selector read-only for a single-runtime machine", () => {
const only = makeRuntime({
id: "rt-solo",
daemon_id: "d-solo",
name: "Claude (Solo)",
device_info: "Solo",
});
renderDialog([only]);
// The agent-runtime title is present but it is NOT inside a button
// (no popover trigger) — single-runtime machines have nothing to pick.
const title = screen.getByText("Claude", { selector: "span.truncate" });
expect(title.closest("button")).toBeNull();
});
const mineAndOthersPrivate = (): RuntimeDevice[] => [
makeRuntime({
id: "rt-mine",
daemon_id: "d-mine",
name: "Claude (Mine)",
device_info: "Mine",
owner_id: ME,
visibility: "private",
}),
makeRuntime({
id: "rt-others",
daemon_id: "d-other",
name: "Claude (Theirs)",
device_info: "Theirs",
owner_id: OTHER,
visibility: "private",
});
renderDialog([mine, othersPrivate]);
}),
];
// Flip to "All" so other-owned runtimes show.
fireEvent.click(screen.getByText("All"));
// Open the picker.
fireEvent.click(
screen.getByText("My Runtime", { selector: "span.truncate" }),
);
const disabledRow = screen
.getByText("Others Private")
.closest("button") as HTMLButtonElement;
expect(disabledRow).not.toBeNull();
expect(disabledRow.disabled).toBe(true);
expect(disabledRow.title).toMatch(/Private runtime/i);
it("filters another member's machine out of the picker under Mine", () => {
renderDialog(mineAndOthersPrivate());
openMachinePicker();
expect(screen.queryByText("Theirs")).toBeNull();
});
it("lets a plain member pick another member's public runtime", () => {
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
const othersPublic = makeRuntime({
id: "rt-others-public",
name: "Others Public",
owner_id: OTHER,
visibility: "public",
});
renderDialog([mine, othersPublic]);
it("shows another member's private machine locked under All", () => {
renderDialog(mineAndOthersPrivate());
// hasOtherMachines surfaces the Mine/All toggle; flip to All before
// opening so the other-owned machine is in scope.
fireEvent.click(screen.getByText("All"));
fireEvent.click(
screen.getByText("My Runtime", { selector: "span.truncate" }),
);
openMachinePicker();
const publicRow = screen
.getByText("Others Public")
.closest("button") as HTMLButtonElement;
expect(publicRow).not.toBeNull();
expect(publicRow.disabled).toBe(false);
const lockedRow = screen.getByText("Theirs").closest("button");
expect(lockedRow).not.toBeNull();
expect((lockedRow as HTMLButtonElement).disabled).toBe(true);
expect((lockedRow as HTMLButtonElement).title).toMatch(/Private runtime/i);
});
it("defaults the selected runtime to a usable one, not a locked private", () => {
it("seeds to a usable machine, not a locked private one that sorts first", () => {
const othersPrivate = makeRuntime({
id: "rt-others-private",
name: "Others Private",
id: "rt-others",
daemon_id: "d-other",
name: "Claude (Theirs)",
device_info: "Theirs",
owner_id: OTHER,
visibility: "private",
});
const mine = makeRuntime({
id: "rt-mine",
name: "My Runtime",
daemon_id: "d-mine",
name: "Claude (Mine)",
device_info: "Mine",
owner_id: ME,
visibility: "private",
});
renderDialog([othersPrivate, mine]);
// The trigger label shows the selected runtime name. The picker must
// not seed with the other-owned private runtime even if it sorted
// first in the input list.
expect(screen.queryByText("Others Private", { selector: "span.truncate" })).toBeNull();
expect(screen.getByText("My Runtime", { selector: "span.truncate" })).toBeInTheDocument();
expect(
screen.getByText("Mine", { selector: "span.truncate" }),
).toBeInTheDocument();
expect(screen.queryByText("Theirs", { selector: "span.truncate" })).toBeNull();
});
it("in duplicate mode, does not pre-fill the template's runtime when it's now locked", async () => {
// Template runtime is owned by someone else and now private — the
// duplicate flow used to seed with it anyway, leaving the user with
// a Create button that 403s server-side. Now we fall back to the
// first usable runtime instead.
it("treats a cloud runtime (no daemon) as its own machine with a Cloud badge", () => {
const cloud = makeRuntime({
id: "rt-cloud",
daemon_id: null,
runtime_mode: "cloud",
name: "Codex cloud",
device_info: "Cloud · us-west",
provider: "codex",
owner_id: null,
visibility: "public",
});
renderDialog([cloud]);
// A workspace cloud runtime is owned by nobody, so it lives under "All",
// not "Mine" — flip the filter to bring it into scope.
fireEvent.click(screen.getByText("All"));
expect(
screen.getByText("Cloud · us-west", { selector: "span.truncate" }),
).toBeInTheDocument();
expect(
screen.getByText("Codex cloud", { selector: "span.truncate" }),
).toBeInTheDocument();
// Cloud badge is rendered in the machine trigger.
expect(screen.getAllByText("Cloud").length).toBeGreaterThan(0);
});
});
describe("CreateAgentDialog Create gate (MUL-3772)", () => {
beforeEach(() => vi.clearAllMocks());
it("in duplicate mode, falls back off a now-locked template runtime", async () => {
const othersPrivate = makeRuntime({
id: "rt-others-private",
name: "Others Private",
id: "rt-others",
daemon_id: "d-other",
name: "Claude (Theirs)",
device_info: "Theirs",
owner_id: OTHER,
visibility: "private",
});
const mine = makeRuntime({
id: "rt-mine",
name: "My Runtime",
daemon_id: "d-mine",
name: "Claude (Mine)",
device_info: "Mine",
owner_id: ME,
visibility: "private",
});
const template = makeTemplate("rt-others-private");
const template = makeTemplate({ runtime_id: "rt-others" });
const { onCreate } = renderDialog([othersPrivate, mine], template);
expect(
screen.getByText("My Runtime", { selector: "span.truncate" }),
screen.getByText("Mine", { selector: "span.truncate" }),
).toBeInTheDocument();
expect(
screen.queryByText("Others Private", { selector: "span.truncate" }),
).toBeNull();
// Sanity check: with a usable selection seeded, Create should submit.
fireEvent.click(screen.getByText("Create"));
await new Promise((r) => setTimeout(r, 0));
await tick();
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-mine");
});
it("disables Create when the selected runtime is locked (template + no usable fallback)", () => {
// Edge case: template points at a locked runtime AND the workspace
// has no usable alternatives in scope. The defense-in-depth gate on
// the Create button must keep the user from submitting a 403.
it("disables Create when the only runtime is locked", () => {
const onlyOthersPrivate = makeRuntime({
id: "rt-only-others-private",
name: "Only Others Private",
id: "rt-locked",
daemon_id: "d-other",
name: "Claude (Theirs)",
device_info: "Theirs",
owner_id: OTHER,
visibility: "private",
});
// Flip the picker to "All" so the locked runtime is at least
// visible — that's the scope where the selected-but-locked state
// can persist after the initial seed search returns nothing.
const template = makeTemplate("rt-only-others-private");
const template = makeTemplate({ runtime_id: "rt-locked" });
renderDialog([onlyOthersPrivate], template);
// The Create button is rendered by lucide-free CTA text "Create".
const createBtn = screen
.getAllByRole("button")
.find((b) => b.textContent === "Create");
@@ -285,3 +390,226 @@ describe("CreateAgentDialog runtime visibility gate", () => {
expect((createBtn as HTMLButtonElement).disabled).toBe(true);
});
});
function reasoningModel(
id: string,
levels: { value: string; label: string }[],
): RuntimeModel {
return { id, label: id, default: true, thinking: { supported_levels: levels } };
}
describe("CreateAgentDialog reasoning picker (MUL-3772 REQ-2)", () => {
beforeEach(() => vi.clearAllMocks());
it("hides the reasoning row when the model exposes no levels", async () => {
modelsByRuntime["rt-1"] = {
models: [{ id: "haiku", label: "Haiku", default: true }],
supported: true,
};
renderDialog([makeRuntime({ id: "rt-1", daemon_id: "d1" })]);
await tick();
expect(screen.queryByText("Reasoning")).toBeNull();
});
it("shows the row for a reasoning-capable model and submits the chosen level", async () => {
modelsByRuntime["rt-1"] = {
models: [
reasoningModel("opus", [
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
]),
],
supported: true,
};
const { onCreate } = renderDialog([
makeRuntime({ id: "rt-1", daemon_id: "d1" }),
]);
// Row appears once the catalog query settles.
await screen.findByText("Reasoning");
// Open the reasoning popover (trigger shows "Follow CLI config") and
// pick "High".
fireEvent.click(screen.getByText("Follow CLI config"));
fireEvent.click(screen.getByText("High"));
typeName();
fireEvent.click(screen.getByText("Create"));
await tick();
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBe("high");
});
it("omits thinking_level when left on follow-CLI-config", async () => {
modelsByRuntime["rt-1"] = {
models: [reasoningModel("opus", [{ value: "high", label: "High" }])],
supported: true,
};
const { onCreate } = renderDialog([
makeRuntime({ id: "rt-1", daemon_id: "d1" }),
]);
await screen.findByText("Reasoning");
typeName();
fireEvent.click(screen.getByText("Create"));
await tick();
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBeUndefined();
});
it("clears a stale level when switching the agent runtime to another provider", async () => {
// Two CLIs on one machine with disjoint reasoning vocabularies. Picking a
// Claude-only level then switching to the Codex runtime must not carry the
// now provider-invalid token into the payload (the backend 400s on it).
modelsByRuntime["rt-claude"] = {
models: [
reasoningModel("opus", [
{ value: "high", label: "High" },
{ value: "max", label: "Max" },
]),
],
supported: true,
};
modelsByRuntime["rt-codex"] = {
models: [
reasoningModel("gpt5", [
{ value: "none", label: "None" },
{ value: "low", label: "Low" },
]),
],
supported: true,
};
const { onCreate } = renderDialog([
makeRuntime({
id: "rt-claude",
daemon_id: "d1",
provider: "claude",
name: "Claude (Mac)",
device_info: "Mac",
}),
makeRuntime({
id: "rt-codex",
daemon_id: "d1",
provider: "codex",
name: "Codex (Mac)",
device_info: "Mac",
launch_header: "codex app-server",
}),
]);
// Seeds to Claude (provider-sorted); pick the Claude-only "Max".
await screen.findByText("Reasoning");
fireEvent.click(screen.getByText("Follow CLI config"));
fireEvent.click(screen.getByText("Max"));
// Switch the agent runtime to Codex — a different provider.
openAgentRuntimePicker();
fireEvent.click(screen.getByText("Codex", { selector: "span.truncate" }));
await tick();
// The stale "Max" is gone; the row reseeds to follow-CLI-config and the
// payload omits the token entirely.
expect(screen.queryByText("Max")).toBeNull();
typeName();
fireEvent.click(screen.getByText("Create"));
await tick();
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-codex");
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBeUndefined();
});
it("drops a duplicate-mode orphan level the current model does not advertise", async () => {
// Duplicate clones thinking_level "high", but the target runtime's model
// has no reasoning catalog — the orphan must not be submittable.
modelsByRuntime["rt-1"] = {
models: [{ id: "haiku", label: "Haiku", default: true }],
supported: true,
};
const template = makeTemplate({
runtime_id: "rt-1",
thinking_level: "high",
});
const { onCreate } = renderDialog(
[makeRuntime({ id: "rt-1", daemon_id: "d1" })],
template,
);
// Once the catalog loads, the orphan clears and the row disappears.
await vi.waitFor(() =>
expect(screen.queryByText("Reasoning")).toBeNull(),
);
fireEvent.click(screen.getByText("Create"));
await tick();
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBeUndefined();
});
it("drops the template level when duplicate falls back off a locked template runtime to an offline different-provider runtime", async () => {
// Template is a Claude agent with a Claude-only level, but its runtime is
// now locked. The dialog falls back to the user's offline Codex runtime,
// which has no model catalog — so neither the provider-change effect (it
// skips the initial establishment) nor the catalog-based clear can run.
// The template level must already be blank at init so it can't be sent.
const lockedClaude = makeRuntime({
id: "rt-claude-locked",
daemon_id: "d-claude",
provider: "claude",
name: "Claude (Theirs)",
device_info: "Theirs",
owner_id: OTHER,
visibility: "private",
});
const offlineCodex = makeRuntime({
id: "rt-codex",
daemon_id: "d-codex",
provider: "codex",
name: "Codex (Mine)",
device_info: "Mine",
owner_id: ME,
status: "offline",
launch_header: "codex app-server",
});
// Intentionally no catalog entry for rt-codex: offline → never queried.
const template = makeTemplate({
runtime_id: "rt-claude-locked",
thinking_level: "max",
model: "",
});
const { onCreate } = renderDialog([lockedClaude, offlineCodex], template);
// Fallback seeds the usable Codex runtime.
await tick();
fireEvent.click(screen.getByText("Create"));
await tick();
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-codex");
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBeUndefined();
});
it("keeps the template level when duplicate lands on the template runtime (offline, same provider)", async () => {
// The complement of the regression above: when the clone DOES land on the
// template's own runtime, the per-provider level is valid and must survive
// even offline (where no catalog is available to re-confirm it).
const claudeOffline = makeRuntime({
id: "rt-claude",
daemon_id: "d1",
provider: "claude",
owner_id: ME,
status: "offline",
});
const template = makeTemplate({
runtime_id: "rt-claude",
thinking_level: "max",
model: "",
});
const { onCreate } = renderDialog([claudeOffline], template);
await tick();
fireEvent.click(screen.getByText("Create"));
await tick();
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-claude");
expect(onCreate.mock.calls[0]?.[0].thinking_level).toBe("max");
});
});

View File

@@ -1,10 +1,11 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Globe, Lock } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { ModelDropdown } from "./model-dropdown";
import { RuntimePicker, isRuntimeUsableForUser } from "./runtime-picker";
import { ReasoningPicker } from "./reasoning-picker";
import { InstructionsEditor } from "./instructions-editor";
import { SkillMultiSelect } from "./skill-multi-select";
import { AvatarPicker } from "./avatar-picker";
@@ -38,6 +39,24 @@ import {
import { CharCounter } from "./char-counter";
import { useT } from "../../i18n";
// True when a duplicate would land on the template agent's own runtime — i.e.
// that runtime still exists and is usable by the caller. When false the dialog
// falls back to a different runtime, so template-runtime-scoped pre-fills (the
// runtime itself and its per-provider `thinking_level`) must not be carried
// over.
function landsOnTemplateRuntime(
template: Agent | null | undefined,
runtimes: RuntimeDevice[],
currentUserId: string | null,
): boolean {
if (!template?.runtime_id) return false;
const templateRuntime = runtimes.find((r) => r.id === template.runtime_id);
return (
templateRuntime != null &&
isRuntimeUsableForUser(templateRuntime, currentUserId)
);
}
export function CreateAgentDialog({
runtimes,
runtimesLoading,
@@ -87,6 +106,22 @@ export function CreateAgentDialog({
template?.visibility ?? "workspace",
);
const [model, setModel] = useState(template?.model ?? "");
// Reasoning/effort override (MUL-3772). "" = follow the local CLI config.
// Duplicate mode clones the source agent's level — but ONLY when the clone
// actually lands on the template's own runtime (same condition as the
// runtime pre-fill below). The level is a per-provider token; if the
// template runtime is locked/missing the dialog falls back to a different,
// possibly different-provider runtime, where that token would be
// provider-invalid. Starting blank there avoids carrying a value the
// fallback runtime would reject — which the provider-change effect can't
// catch (it skips the initial establishment) and the catalog-based clear
// can't catch when the fallback runtime is offline / its catalog hasn't
// resolved before submit.
const [thinkingLevel, setThinkingLevel] = useState(() =>
template?.thinking_level && landsOnTemplateRuntime(template, runtimes, currentUserId)
? template.thinking_level
: "",
);
const [instructions, setInstructions] = useState(template?.instructions ?? "");
const [avatarUrl, setAvatarUrl] = useState<string | null>(template?.avatar_url ?? null);
const [selectedSkillIds, setSelectedSkillIds] = useState<Set<string>>(
@@ -96,18 +131,14 @@ export function CreateAgentDialog({
// Duplicate-mode pre-fill: clone lands on the source agent's runtime so
// the user doesn't have to re-pick. Skipped when that runtime is now
// locked for the caller (Create would 403). Empty fallback hands the
// job to RuntimePicker — it owns filter state, so it's the only place
// locked/missing for the caller (Create would 403). Empty fallback hands
// the job to RuntimePicker — it owns filter state, so it's the only place
// that knows which runtimes are visible right now.
const [selectedRuntimeId, setSelectedRuntimeId] = useState(() => {
const templateRuntime = template?.runtime_id
? runtimes.find((r) => r.id === template.runtime_id)
: undefined;
if (templateRuntime && isRuntimeUsableForUser(templateRuntime, currentUserId)) {
return templateRuntime.id;
}
return "";
});
const [selectedRuntimeId, setSelectedRuntimeId] = useState(() =>
landsOnTemplateRuntime(template, runtimes, currentUserId)
? (template?.runtime_id ?? "")
: "",
);
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
// Defense-in-depth: even if a locked runtime somehow ends up selected
@@ -118,6 +149,27 @@ export function CreateAgentDialog({
selectedRuntime != null &&
!isRuntimeUsableForUser(selectedRuntime, currentUserId);
// Reasoning/effort tokens are validated per provider on the backend, so a
// level chosen for one provider is invalid the moment the user switches the
// selected runtime to a different provider (e.g. a Codex `none` would 400 on
// a Claude runtime). Clear the override synchronously on a provider change so
// a stale token can never be submitted — this also covers the brief window
// before ReasoningPicker's catalog-based clear can run for a not-yet-cached
// runtime. The initial establishment (undefined → first provider) is skipped
// so a duplicate-mode pre-fill survives until the user actually changes it.
const prevProviderRef = useRef<string | undefined>(undefined);
useEffect(() => {
const provider = selectedRuntime?.provider;
if (provider === undefined) return;
if (
prevProviderRef.current !== undefined &&
prevProviderRef.current !== provider
) {
setThinkingLevel("");
}
prevProviderRef.current = provider;
}, [selectedRuntime?.provider]);
// Shared squad-join follow-up. Returns nothing — the caller has
// already shown its create-success toast; we only need to surface a
// warning when the agent landed but the squad-join failed. Cache
@@ -160,6 +212,9 @@ export function CreateAgentDialog({
runtime_id: selectedRuntime.id,
visibility,
model: model.trim() || undefined,
// Omit when "" so the backend leaves out the effort flag and the
// local CLI config decides — matches the "follow CLI config" sentinel.
thinking_level: thinkingLevel || undefined,
instructions: trimmedInstructions || undefined,
avatar_url: avatarUrl ?? undefined,
};
@@ -342,6 +397,14 @@ export function CreateAgentDialog({
disabled={!selectedRuntime}
/>
<ReasoningPicker
runtimeId={selectedRuntime?.id ?? null}
runtimeOnline={selectedRuntime?.status === "online"}
model={model}
value={thinkingLevel}
onChange={setThinkingLevel}
/>
{/* --- Optional sections (instructions / skills) ---
Collapsed by default so quick-create stays fast.
Duplicate pre-fills everything from the source agent. */}

View File

@@ -0,0 +1,35 @@
import type {
RuntimeModel,
RuntimeModelThinkingLevel,
} from "@multica/core/types";
// Shared, framework-free reasoning-level resolution. Both the inspector's
// ThinkingPropRow and the create-dialog ReasoningPicker derive the level set
// from the discovered model catalog the same way, so the logic lives here
// once (no fork). MUL-2339 / MUL-3772.
/**
* Resolve the catalog entry for the active model. When `model` is empty
* (the agent runs the runtime's default), fall back to the catalog's
* `default` flag, then the first discovered model — matching how the model
* picker presents the implicit default.
*/
export function pickModelEntry(
models: RuntimeModel[],
model: string,
): RuntimeModel | undefined {
if (model) return models.find((m) => m.id === model);
return models.find((m) => m.default) ?? models[0];
}
/**
* The reasoning/effort levels the active (runtime, model) pair exposes.
* Empty when the model advertises no `thinking` catalog — the caller treats
* that as "no reasoning picker for this model".
*/
export function resolveThinkingLevels(
models: RuntimeModel[],
model: string,
): RuntimeModelThinkingLevel[] {
return pickModelEntry(models, model)?.thinking?.supported_levels ?? [];
}

View File

@@ -1,11 +1,11 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import type { RuntimeModel } from "@multica/core/types";
import { runtimeModelsOptions } from "@multica/core/runtimes";
import { PropRow } from "../../../common/prop-row";
import { useT } from "../../../i18n";
import { ThinkingPicker } from "./thinking-picker";
import { resolveThinkingLevels } from "./thinking-levels";
/**
* Thinking row for the agent inspector. Hidden when the active model has
@@ -46,8 +46,7 @@ export function ThinkingPropRow({
);
const models = modelsQuery.data?.models ?? [];
const entry = pickModelEntry(models, model);
const levels = entry?.thinking?.supported_levels ?? [];
const levels = resolveThinkingLevels(models, model);
if (levels.length === 0 && !value) return null;
return (
@@ -61,11 +60,3 @@ export function ThinkingPropRow({
</PropRow>
);
}
function pickModelEntry(
models: RuntimeModel[],
model: string,
): RuntimeModel | undefined {
if (model) return models.find((m) => m.id === model);
return models.find((m) => m.default) ?? models[0];
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Check, Sparkles } from "lucide-react";
import { runtimeModelsOptions } from "@multica/core/runtimes";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Label } from "@multica/ui/components/ui/label";
import { useT } from "../../i18n";
import { resolveThinkingLevels } from "./inspector/thinking-levels";
// ReasoningPicker — the create-dialog counterpart of the inspector's
// ThinkingPropRow (MUL-3772 / REQ-2). It shares the level-resolution logic
// (`resolveThinkingLevels`) and the empty-string "follow CLI config" sentinel
// with the inspector, but wears the full-width form chrome the other create
// fields use instead of the inspector's inline chip.
//
// Mounted unconditionally by the dialog; renders nothing until the selected
// model advertises a reasoning catalog, so providers without reasoning never
// show an empty row. An already-set value (duplicate pre-fill) also forces the
// row so a stale token stays visible and clearable.
export function ReasoningPicker({
runtimeId,
runtimeOnline,
model,
value,
onChange,
}: {
runtimeId: string | null;
runtimeOnline: boolean;
model: string;
value: string;
onChange: (next: string) => void;
}) {
const { t } = useT("agents");
const [open, setOpen] = useState(false);
const modelsQuery = useQuery(
runtimeModelsOptions(runtimeOnline ? runtimeId : null),
);
const models = modelsQuery.data?.models ?? [];
const levels = resolveThinkingLevels(models, model);
// Create-flow safety net: once the catalog for the current (runtime, model)
// has loaded, drop a non-empty value the model no longer advertises so it
// can never be submitted. Without this, picking a level on one runtime and
// switching to a model/provider that lacks it would carry the stale token
// into the create payload — the backend validates effort tokens per provider
// and 400s an invalid one (e.g. a Codex-only `none` on a Claude runtime).
//
// Gated on loaded data (not just the empty-while-loading default) so a
// duplicate-mode pre-fill is never wiped before its catalog arrives. This is
// deliberately stricter than the inspector's ThinkingPicker, which keeps an
// orphan visible as a clear-only affordance: a saved agent legitimately owns
// its token, but the create form must not forward an unsubmittable one.
useEffect(() => {
const data = modelsQuery.data;
if (!data || !value) return;
if (resolveThinkingLevels(data.models, model).some((l) => l.value === value))
return;
onChange("");
}, [modelsQuery.data, model, value, onChange]);
// Hidden until the model exposes reasoning levels (or a value is already
// persisted) — mirrors ThinkingPropRow's gate so behavior matches the
// inspector exactly.
if (levels.length === 0 && !value) return null;
const selected = value ? levels.find((l) => l.value === value) : undefined;
// Unknown-but-set value (model swap that dropped the option): show the raw
// token so the user can see and clear what is actually persisted.
const triggerLabel = selected
? selected.label
: value || t(($) => $.pickers.thinking_default);
const select = (next: string) => {
setOpen(false);
if (next !== value) onChange(next);
};
return (
<div className="flex flex-col min-w-0">
<div className="flex h-6 items-center">
<Label className="text-xs text-muted-foreground">
{t(($) => $.create_dialog.reasoning_label)}
</Label>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50">
<Sparkles className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{triggerLabel}</span>
</div>
<div className="truncate text-xs text-muted-foreground">
{t(($) => $.create_dialog.reasoning_hint)}
</div>
</div>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--anchor-width)] p-1 max-h-72 overflow-y-auto"
>
{/* "Follow CLI config" (value "") is a first-class, selectable
option — Multica omits the effort flag and the local CLI config
decides. Mirrors the inspector picker's empty-sentinel meaning. */}
<ReasoningOption
label={t(($) => $.pickers.thinking_default)}
selected={value === ""}
onClick={() => select("")}
/>
{levels.map((level) => (
<ReasoningOption
key={level.value}
label={level.label}
description={level.description}
selected={level.value === value}
onClick={() => select(level.value)}
/>
))}
</PopoverContent>
</Popover>
</div>
);
}
function ReasoningOption({
label,
description,
selected,
onClick,
}: {
label: string;
description?: string;
selected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{label}</div>
{description && (
<div className="mt-0.5 text-xs leading-snug text-muted-foreground">
{description}
</div>
)}
</div>
{selected && <Check className="h-4 w-4 shrink-0 text-primary" />}
</button>
);
}

View File

@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import type { RuntimeDevice } from "@multica/core/types";
import {
buildAgentRuntimeMachines,
isRuntimeUsableForUser,
} from "./runtime-picker";
function rt(overrides: Partial<RuntimeDevice>): RuntimeDevice {
return {
id: "rt",
workspace_id: "ws-1",
daemon_id: null,
name: "Claude (host.local)",
runtime_mode: "local",
provider: "claude",
launch_header: "claude (stream-json)",
status: "online",
device_info: "host.local",
metadata: {},
owner_id: "u1",
visibility: "private",
last_seen_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("buildAgentRuntimeMachines", () => {
it("groups CLIs on the same daemon into one machine, sorted by provider", () => {
const machines = buildAgentRuntimeMachines([
rt({ id: "a", daemon_id: "d1", provider: "codex", name: "Codex (Mac)" }),
rt({ id: "b", daemon_id: "d1", provider: "claude", name: "Claude (Mac)" }),
]);
expect(machines).toHaveLength(1);
expect(machines[0]!.runtimes.map((r) => r.provider)).toEqual([
"claude",
"codex",
]);
expect(machines[0]!.label).toBe("Mac");
});
it("keeps two members' identically-named daemon-less hosts separate", () => {
const machines = buildAgentRuntimeMachines([
rt({ id: "a", daemon_id: null, owner_id: "u1", name: "Claude (box)" }),
rt({ id: "b", daemon_id: null, owner_id: "u2", name: "Claude (box)" }),
]);
expect(machines).toHaveLength(2);
});
it("treats a cloud runtime as its own machine", () => {
const machines = buildAgentRuntimeMachines([
rt({
id: "c",
daemon_id: null,
runtime_mode: "cloud",
name: "Codex cloud",
device_info: "Cloud · us-west",
owner_id: null,
}),
]);
expect(machines).toHaveLength(1);
expect(machines[0]!.cloud).toBe(true);
expect(machines[0]!.label).toBe("Cloud · us-west");
expect(machines[0]!.ownerId).toBeNull();
});
it("marks a machine online when any of its runtimes is online", () => {
const machines = buildAgentRuntimeMachines([
rt({ id: "a", daemon_id: "d1", provider: "claude", status: "offline" }),
rt({ id: "b", daemon_id: "d1", provider: "codex", status: "online" }),
]);
expect(machines[0]!.online).toBe(true);
});
it("falls back to the runtime id when daemon and device are both missing", () => {
const machines = buildAgentRuntimeMachines([
rt({ id: "x", daemon_id: null, name: "Claude", device_info: "" }),
]);
expect(machines).toHaveLength(1);
expect(machines[0]!.label).toBe("Claude");
});
});
describe("isRuntimeUsableForUser", () => {
it("allows the owner, allows public, blocks another member's private", () => {
expect(isRuntimeUsableForUser(rt({ owner_id: "u1" }), "u1")).toBe(true);
expect(
isRuntimeUsableForUser(rt({ owner_id: "u2", visibility: "public" }), "u1"),
).toBe(true);
expect(
isRuntimeUsableForUser(rt({ owner_id: "u2", visibility: "private" }), "u1"),
).toBe(false);
});
});

View File

@@ -1,9 +1,10 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Cloud, Loader2, Lock } from "lucide-react";
import { ChevronDown, Cloud, Loader2, Lock, Monitor } from "lucide-react";
import { ProviderLogo } from "../../runtimes/components/provider-logo";
import { ActorAvatar } from "../../common/actor-avatar";
import { splitRuntimeName } from "../../runtimes/components/runtime-machines";
import type { MemberWithUser, RuntimeDevice } from "@multica/core/types";
import {
Popover,
@@ -15,6 +16,30 @@ import { useT } from "../../i18n";
export type RuntimeFilter = "mine" | "all";
// ---------------------------------------------------------------------------
// MUL-3772: the single "Runtime" dropdown is split into two cascading
// selectors — Machine (the physical/cloud host) and Agent runtime (the CLI
// backend on that host). A RuntimeDevice is already a (machine × CLI) pair;
// the label the daemon builds is `<provider> (<deviceName>)`, so the split is
// purely a frontend regrouping. The submit payload is unchanged: a resolved
// (machine, runtime) collapses back to one `runtime_id`.
// ---------------------------------------------------------------------------
/** A host grouping one-or-more agent-runtime (CLI) instances. */
export interface AgentRuntimeMachine {
/** Stable group key — daemon id, else owner+device, else runtime id. */
key: string;
/** Display label: hostname/device name. */
label: string;
/** Runtime owner (shared across a daemon's runtimes); null for cloud. */
ownerId: string | null;
/** True when any runtime on this machine is online. */
online: boolean;
/** True when this is a cloud-mode machine. */
cloud: boolean;
runtimes: RuntimeDevice[];
}
export function RuntimePicker({
runtimes,
runtimesLoading,
@@ -30,62 +55,136 @@ export function RuntimePicker({
selectedRuntimeId: string;
onSelect: (id: string) => void;
}) {
const { t } = useT("agents");
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState<RuntimeFilter>("mine");
const getOwnerMember = (ownerId: string | null) => {
if (!ownerId) return null;
return members.find((m) => m.user_id === ownerId) ?? null;
};
const machines = useMemo(
() => buildAgentRuntimeMachines(runtimes),
[runtimes],
);
const hasOtherMachines = machines.some((m) => m.ownerId !== currentUserId);
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
const filteredRuntimes = useMemo(
() => computeFilteredRuntimes(runtimes, filter, currentUserId),
[runtimes, filter, currentUserId],
const filteredMachines = useMemo(
() => computeFilteredMachines(machines, filter, currentUserId),
[machines, filter, currentUserId],
);
const selectedRuntime =
runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
const selectedMachine =
machines.find((m) =>
m.runtimes.some((r) => r.id === selectedRuntimeId),
) ?? null;
// Sole source of truth for seeding the parent's selection when it's empty
// — first mount with no template runtime, runtimes arriving later over
// WS, or filter toggle clearing to a set with no usable item. Only fires
// — first mount with no template runtime, runtimes arriving later over WS,
// or a filter toggle clearing to a set with no usable item. Only fires
// when `selectedRuntimeId === ""` so a duplicate-mode pre-fill (template
// runtime) is never silently overwritten.
useEffect(() => {
if (selectedRuntimeId !== "") return;
const firstUsable = filteredRuntimes.find((r) =>
isRuntimeUsableForUser(r, currentUserId),
);
const firstUsable = firstUsableRuntime(filteredMachines, currentUserId);
if (firstUsable) onSelect(firstUsable.id);
}, [filteredRuntimes, selectedRuntimeId, currentUserId, onSelect]);
}, [filteredMachines, selectedRuntimeId, currentUserId, onSelect]);
// On filter toggle, recompute the picker's selection to a usable item
// in the new filter set. Pushes `""` when nothing matches; the seeding
// effect above is a no-op in that case (correct: no usable item to pick).
// On filter toggle, recompute the selection to a usable runtime in the new
// machine set. Pushes `""` when nothing matches; the seeding effect above
// is a no-op in that case (correct: no usable item to pick).
const handleFilterChange = (next: RuntimeFilter) => {
if (next === filter) return;
setFilter(next);
const nextList = computeFilteredRuntimes(runtimes, next, currentUserId);
const firstUsable = nextList.find((r) =>
const nextMachines = computeFilteredMachines(machines, next, currentUserId);
const firstUsable = firstUsableRuntime(nextMachines, currentUserId);
onSelect(firstUsable?.id ?? "");
};
// Switching machine moves the selection to that machine's first usable
// runtime (or its first runtime if all are locked — the Create gate still
// blocks submit, but the user can see what's there).
const handleMachineSelect = (machine: AgentRuntimeMachine) => {
const usable = machine.runtimes.find((r) =>
isRuntimeUsableForUser(r, currentUserId),
);
onSelect(firstUsable?.id ?? "");
onSelect((usable ?? machine.runtimes[0])?.id ?? "");
};
return (
<>
<MachinePicker
machines={filteredMachines}
selectedMachine={selectedMachine}
members={members}
currentUserId={currentUserId}
runtimesLoading={runtimesLoading}
runtimesEmpty={runtimes.length === 0}
filter={filter}
hasOtherMachines={hasOtherMachines}
onFilterChange={handleFilterChange}
onSelect={handleMachineSelect}
/>
<AgentRuntimePicker
machine={selectedMachine}
selectedRuntimeId={selectedRuntimeId}
currentUserId={currentUserId}
runtimesLoading={runtimesLoading}
onSelect={onSelect}
/>
</>
);
}
// ---------------------------------------------------------------------------
// Machine selector — owns the Mine/All filter (relabelled but functionally
// the runtime filter it replaced) and the online/owner/Cloud chrome.
// ---------------------------------------------------------------------------
function MachinePicker({
machines,
selectedMachine,
members,
currentUserId,
runtimesLoading,
runtimesEmpty,
filter,
hasOtherMachines,
onFilterChange,
onSelect,
}: {
machines: AgentRuntimeMachine[];
selectedMachine: AgentRuntimeMachine | null;
members: MemberWithUser[];
currentUserId: string | null;
runtimesLoading?: boolean;
runtimesEmpty: boolean;
filter: RuntimeFilter;
hasOtherMachines: boolean;
onFilterChange: (next: RuntimeFilter) => void;
onSelect: (machine: AgentRuntimeMachine) => void;
}) {
const { t } = useT("agents");
const [open, setOpen] = useState(false);
const ownerName = (machine: AgentRuntimeMachine): string | null => {
if (!machine.ownerId) return null;
return members.find((m) => m.user_id === machine.ownerId)?.name ?? null;
};
const machineSubtitle = (machine: AgentRuntimeMachine): string => {
const owner = ownerName(machine);
const count = t(($) => $.create_dialog.machine_runtime_count, {
count: machine.runtimes.length,
});
return owner ? `${owner} · ${count}` : count;
};
return (
<div className="flex flex-col min-w-0">
<div className="flex h-6 items-center justify-between">
<Label className="text-xs text-muted-foreground">
{t(($) => $.create_dialog.runtime_label)}
{t(($) => $.create_dialog.machine_label)}
</Label>
{hasOtherRuntimes && (
{hasOtherMachines && (
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
<button
type="button"
onClick={() => handleFilterChange("mine")}
onClick={() => onFilterChange("mine")}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
filter === "mine"
? "bg-background text-foreground shadow-sm"
@@ -96,7 +195,7 @@ export function RuntimePicker({
</button>
<button
type="button"
onClick={() => handleFilterChange("all")}
onClick={() => onFilterChange("all")}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
filter === "all"
? "bg-background text-foreground shadow-sm"
@@ -110,40 +209,44 @@ export function RuntimePicker({
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
disabled={runtimes.length === 0 && !runtimesLoading}
data-testid="machine-picker-trigger"
disabled={runtimesEmpty && !runtimesLoading}
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
>
{runtimesLoading ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
) : selectedRuntime ? (
<ProviderLogo
provider={selectedRuntime.provider}
className="h-4 w-4 shrink-0"
/>
) : (
) : selectedMachine?.cloud ? (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{runtimesLoading
? t(($) => $.create_dialog.runtime_loading)
: (selectedRuntime?.name ??
: (selectedMachine?.label ??
t(($) => $.create_dialog.runtime_none))}
</span>
{selectedRuntime?.runtime_mode === "cloud" && (
{selectedMachine?.cloud && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
{t(($) => $.create_dialog.runtime_cloud_badge)}
</span>
)}
</div>
{selectedRuntime && (
{selectedMachine && (
<div className="truncate text-xs text-muted-foreground">
{getOwnerMember(selectedRuntime.owner_id)?.name ??
selectedRuntime.device_info}
{machineSubtitle(selectedMachine)}
</div>
)}
</div>
{selectedMachine && (
<span
className={`h-2 w-2 shrink-0 rounded-full ${
selectedMachine.online ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
)}
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
open ? "rotate-180" : ""
@@ -154,39 +257,42 @@ export function RuntimePicker({
align="start"
className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto"
>
{filteredRuntimes.map((device) => {
const ownerMember = getOwnerMember(device.owner_id);
const disabled = !isRuntimeUsableForUser(device, currentUserId);
{machines.map((machine) => {
const disabled = !isMachineUsableForUser(machine, currentUserId);
const disabledTitle = disabled
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
: undefined;
const owner = ownerName(machine);
return (
<button
key={device.id}
key={machine.key}
type="button"
disabled={disabled}
title={disabledTitle}
onClick={() => {
if (disabled) return;
onSelect(device.id);
onSelect(machine);
setOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
disabled
? "cursor-not-allowed opacity-50"
: device.id === selectedRuntimeId
: machine.key === selectedMachine?.key
? "bg-accent"
: "hover:bg-accent/50"
}`}
>
<ProviderLogo
provider={device.provider}
className="h-4 w-4 shrink-0"
/>
{machine.cloud ? (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="truncate font-medium">
{machine.label}
</span>
{machine.cloud && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
{t(($) => $.create_dialog.runtime_cloud_badge)}
</span>
@@ -199,25 +305,27 @@ export function RuntimePicker({
)}
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
{ownerMember ? (
{owner && machine.ownerId ? (
<>
<ActorAvatar
actorType="member"
actorId={ownerMember.user_id}
actorId={machine.ownerId}
size={14}
/>
<span className="truncate">{ownerMember.name}</span>
<span className="truncate">
{machineSubtitle(machine)}
</span>
</>
) : (
<span className="truncate">{device.device_info}</span>
<span className="truncate">
{machineSubtitle(machine)}
</span>
)}
</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online"
? "bg-success"
: "bg-muted-foreground/40"
machine.online ? "bg-success" : "bg-muted-foreground/40"
}`}
/>
</button>
@@ -229,6 +337,167 @@ export function RuntimePicker({
);
}
// ---------------------------------------------------------------------------
// Agent-runtime selector — cascades off the selected machine. A machine with
// a single runtime renders the selector read-only (stable layout, nothing to
// choose) per the approved RFC default.
// ---------------------------------------------------------------------------
function AgentRuntimePicker({
machine,
selectedRuntimeId,
currentUserId,
runtimesLoading,
onSelect,
}: {
machine: AgentRuntimeMachine | null;
selectedRuntimeId: string;
currentUserId: string | null;
runtimesLoading?: boolean;
onSelect: (id: string) => void;
}) {
const { t } = useT("agents");
const [open, setOpen] = useState(false);
const runtimes = machine?.runtimes ?? [];
const selectedRuntime =
runtimes.find((r) => r.id === selectedRuntimeId) ?? null;
const single = runtimes.length === 1;
const runtimeTitle = (runtime: RuntimeDevice): string =>
splitRuntimeName(runtime.name).base;
const runtimeSubtitle = (runtime: RuntimeDevice): string => {
const kind = runtime.profile_id
? t(($) => $.create_dialog.runtime_kind_custom)
: t(($) => $.create_dialog.runtime_kind_builtin);
const header = runtime.launch_header?.trim();
return header ? `${header} · ${kind}` : kind;
};
const triggerContent = (
<>
{selectedRuntime ? (
<ProviderLogo
provider={selectedRuntime.provider}
className="h-4 w-4 shrink-0"
/>
) : (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{selectedRuntime
? runtimeTitle(selectedRuntime)
: t(($) => $.create_dialog.agent_runtime_none)}
</span>
</div>
{selectedRuntime && (
<div className="truncate text-xs text-muted-foreground">
{runtimeSubtitle(selectedRuntime)}
</div>
)}
</div>
</>
);
return (
<div className="flex flex-col min-w-0">
<div className="flex h-6 items-center">
<Label className="text-xs text-muted-foreground">
{t(($) => $.create_dialog.agent_runtime_label)}
</Label>
</div>
{single ? (
// Read-only: a single-runtime machine has nothing to pick. Render the
// same chrome (minus the chevron / hover) so the layout doesn't jump
// when switching to a multi-runtime machine.
<div
data-testid="agent-runtime-readonly"
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2.5 mt-1.5 text-left text-sm"
>
{triggerContent}
</div>
) : (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
data-testid="agent-runtime-trigger"
disabled={!machine || runtimesLoading}
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
>
{triggerContent}
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto"
>
{runtimes.map((runtime) => {
const disabled = !isRuntimeUsableForUser(runtime, currentUserId);
const disabledTitle = disabled
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
: undefined;
return (
<button
key={runtime.id}
type="button"
disabled={disabled}
title={disabledTitle}
onClick={() => {
if (disabled) return;
onSelect(runtime.id);
setOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
disabled
? "cursor-not-allowed opacity-50"
: runtime.id === selectedRuntimeId
? "bg-accent"
: "hover:bg-accent/50"
}`}
>
<ProviderLogo
provider={runtime.provider}
className="h-4 w-4 shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{runtimeTitle(runtime)}
</span>
{disabled && (
<span className="shrink-0 inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
<Lock className="h-3 w-3" />
{t(($) => $.create_dialog.runtime_private_badge)}
</span>
)}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{runtimeSubtitle(runtime)}
</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
runtime.status === "online"
? "bg-success"
: "bg-muted-foreground/40"
}`}
/>
</button>
);
})}
</PopoverContent>
</Popover>
)}
</div>
);
}
// Visibility gate exposed so the parent can defend Create against a locked
// selection (e.g. duplicate of an agent whose runtime is now private).
export function isRuntimeUsableForUser(
@@ -240,24 +509,101 @@ export function isRuntimeUsableForUser(
return r.visibility === "public";
}
function computeFilteredRuntimes(
runtimes: RuntimeDevice[],
filter: RuntimeFilter,
function isMachineUsableForUser(
machine: AgentRuntimeMachine,
currentUserId: string | null,
): RuntimeDevice[] {
const filtered =
filter === "mine" && currentUserId
? runtimes.filter((r) => r.owner_id === currentUserId)
: runtimes;
return filtered.toSorted((a, b) => {
const aMine = a.owner_id === currentUserId;
const bMine = b.owner_id === currentUserId;
if (aMine && !bMine) return -1;
if (!aMine && bMine) return 1;
const aUsable = isRuntimeUsableForUser(a, currentUserId);
const bUsable = isRuntimeUsableForUser(b, currentUserId);
if (aUsable && !bUsable) return -1;
if (!aUsable && bUsable) return 1;
return 0;
): boolean {
return machine.runtimes.some((r) => isRuntimeUsableForUser(r, currentUserId));
}
// Group the flat runtime list into machines. A daemon-backed runtime groups
// by `daemon_id`; cloud / daemon-less runtimes (daemon_id: null) fall back to
// owner+device so two members' identically-named hosts never collapse into
// one row, then to the runtime id when even the device name is missing.
export function buildAgentRuntimeMachines(
runtimes: RuntimeDevice[],
): AgentRuntimeMachine[] {
const groups = new Map<string, RuntimeDevice[]>();
const order: string[] = [];
for (const runtime of runtimes) {
const key = machineKey(runtime);
const existing = groups.get(key);
if (existing) {
existing.push(runtime);
} else {
groups.set(key, [runtime]);
order.push(key);
}
}
return order.map((key) => {
const group = groups
.get(key)!
.toSorted((a, b) => a.provider.localeCompare(b.provider));
const first = group[0]!;
return {
key,
label: machineLabel(group),
ownerId: first.owner_id,
online: group.some((r) => r.status === "online"),
cloud: group.some((r) => r.runtime_mode === "cloud"),
runtimes: group,
} satisfies AgentRuntimeMachine;
});
}
function machineKey(runtime: RuntimeDevice): string {
if (runtime.daemon_id) return `daemon:${runtime.daemon_id}`;
const device =
splitRuntimeName(runtime.name).hostname ??
(runtime.device_info?.trim() || null);
if (device) return `device:${runtime.owner_id ?? "_"}:${device}`;
return `runtime:${runtime.id}`;
}
function machineLabel(runtimes: RuntimeDevice[]): string {
const first = runtimes[0]!;
const host = splitRuntimeName(first.name).hostname;
if (host) return host;
const device = first.device_info?.trim();
if (device) return device;
return first.name;
}
function computeFilteredMachines(
machines: AgentRuntimeMachine[],
filter: RuntimeFilter,
currentUserId: string | null,
): AgentRuntimeMachine[] {
const filtered =
filter === "mine" && currentUserId
? machines.filter((m) => m.ownerId === currentUserId)
: machines;
return filtered.toSorted((a, b) => {
const aMine = a.ownerId === currentUserId;
const bMine = b.ownerId === currentUserId;
if (aMine && !bMine) return -1;
if (!aMine && bMine) return 1;
const aUsable = isMachineUsableForUser(a, currentUserId);
const bUsable = isMachineUsableForUser(b, currentUserId);
if (aUsable && !bUsable) return -1;
if (!aUsable && bUsable) return 1;
return a.label.localeCompare(b.label);
});
}
// First selectable runtime across the (already sorted) machine set — used to
// seed and to re-seed on filter change. Honors the per-runtime visibility
// gate so a locked private runtime is never auto-selected.
function firstUsableRuntime(
machines: AgentRuntimeMachine[],
currentUserId: string | null,
): RuntimeDevice | undefined {
for (const machine of machines) {
const usable = machine.runtimes.find((r) =>
isRuntimeUsableForUser(r, currentUserId),
);
if (usable) return usable;
}
return undefined;
}

View File

@@ -46,6 +46,8 @@ import { deriveThreadResolution } from "./thread-utils";
const highlightedCommentBackgroundClass =
"bg-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
const highlightedCommentFadeClass =
"after:from-[color-mix(in_srgb,var(--card)_95%,var(--brand)_5%)]";
function StickyHeaderShell({
className,
@@ -65,8 +67,9 @@ function StickyHeaderShell({
return (
<div
className={cn(
"sticky top-0 z-10 transition-colors duration-700 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-1 after:bg-[inherit] after:[mask-image:linear-gradient(to_bottom,#000,transparent)] after:[-webkit-mask-image:linear-gradient(to_bottom,#000,transparent)]",
"sticky top-0 z-10 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-1 after:bg-gradient-to-b after:to-transparent",
highlighted ? highlightedCommentBackgroundClass : "bg-card",
highlighted ? highlightedCommentFadeClass : "after:from-card",
)}
>
<div className={className}>

View File

@@ -241,6 +241,15 @@
"runtime_cloud_badge": "Cloud",
"runtime_private_badge": "Private",
"runtime_private_locked_tooltip": "Private runtime — only its owner or a workspace admin can create agents on it. Ask the owner to switch it to Public to share.",
"machine_label": "Machine",
"machine_runtime_count_one": "{{count}} runtime",
"machine_runtime_count_other": "{{count}} runtimes",
"agent_runtime_label": "Agent runtime",
"agent_runtime_none": "Select a machine first",
"runtime_kind_builtin": "Built-in",
"runtime_kind_custom": "Custom",
"reasoning_label": "Reasoning",
"reasoning_hint": "Effort level — discovered from the runtime",
"duplicate_copy_suffix": " (Copy)",
"create": "Create",
"creating": "Creating...",

View File

@@ -228,6 +228,14 @@
"runtime_cloud_badge": "クラウド",
"runtime_private_badge": "非公開",
"runtime_private_locked_tooltip": "非公開ランタイムです。オーナーまたはワークスペースの admin のみがこのランタイム上にエージェントを作成できます。共有するには、オーナーに公開へ切り替えるよう依頼してください。",
"machine_label": "マシン",
"machine_runtime_count_other": "ランタイム {{count}} 件",
"agent_runtime_label": "エージェントランタイム",
"agent_runtime_none": "先にマシンを選択してください",
"runtime_kind_builtin": "組み込み",
"runtime_kind_custom": "カスタム",
"reasoning_label": "思考",
"reasoning_hint": "努力レベル — ランタイムから検出",
"duplicate_copy_suffix": " (コピー)",
"create": "作成",
"creating": "作成中...",

View File

@@ -241,6 +241,15 @@
"runtime_cloud_badge": "클라우드",
"runtime_private_badge": "비공개",
"runtime_private_locked_tooltip": "비공개 런타임입니다. 소유자 또는 워크스페이스 관리자만 이 런타임에 에이전트를 만들 수 있습니다. 공유하려면 소유자에게 공개로 전환해 달라고 요청하세요.",
"machine_label": "머신",
"machine_runtime_count_one": "런타임 {{count}}개",
"machine_runtime_count_other": "런타임 {{count}}개",
"agent_runtime_label": "에이전트 런타임",
"agent_runtime_none": "먼저 머신을 선택하세요",
"runtime_kind_builtin": "기본 제공",
"runtime_kind_custom": "사용자 지정",
"reasoning_label": "추론",
"reasoning_hint": "강도 수준 — 런타임에서 자동 감지",
"duplicate_copy_suffix": " (사본)",
"create": "만들기",
"creating": "만드는 중...",

View File

@@ -236,6 +236,14 @@
"runtime_cloud_badge": "云端",
"runtime_private_badge": "私有",
"runtime_private_locked_tooltip": "私有运行时——只有 runtime 所有者或工作区管理员可在其上创建智能体。如果需要共享,请联系所有者切换为「公开」。",
"machine_label": "机器",
"machine_runtime_count_other": "{{count}} 个运行时",
"agent_runtime_label": "Agent 运行时",
"agent_runtime_none": "请先选择机器",
"runtime_kind_builtin": "内置",
"runtime_kind_custom": "自定义",
"reasoning_label": "思考",
"reasoning_hint": "强度等级——由运行时自动发现",
"duplicate_copy_suffix": "(副本)",
"create": "创建",
"creating": "创建中...",

View File

@@ -1833,23 +1833,15 @@ func (s *TaskService) HandleFailedTasks(ctx context.Context, tasks []db.AgentTas
"error", checkErr,
)
} else if !hasActive {
updatedIssue, updateErr := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
if _, updateErr := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
ID: t.IssueID,
Status: "todo",
WorkspaceID: issue.WorkspaceID,
})
if updateErr != nil {
}); updateErr != nil {
slog.Warn("handle failed tasks: reset stuck issue failed",
"issue_id", issueKey,
"error", updateErr,
)
} else {
// This direct reset bypasses the HTTP UpdateIssue
// handler that normally emits issue:updated, so emit
// it here too. Without it the board / status-filter
// caches keep showing the issue as in_progress until
// the next write touches it (#4648 / MUL-3782).
s.broadcastIssueUpdated(updatedIssue, issue.Status)
}
}
}
@@ -2269,32 +2261,14 @@ func (s *TaskService) broadcastChatDone(ctx context.Context, task db.AgentTaskQu
})
}
// broadcastIssueUpdated publishes the issue:updated event the frontend's
// realtime reconcile (onIssueUpdated) relies on to move an issue between status
// columns / status filters and reconcile their bucket counts. prevStatus is the
// issue's status before the write so the client can gate that reconcile on
// status_changed.
//
// The `issue` payload is a map (issueToMap), which the workspace WS fanout
// (listeners.go SubscribeAll) marshals and broadcasts as-is — that is what
// drives the UI reconcile. Note this does NOT cover the full HTTP UpdateIssue
// side effects: the activity-log and inbox listeners type-assert `issue` to a
// handler.IssueResponse and skip a map, so a background status reset does not
// emit status-change activity / notifications. That is intentional for the
// realtime-staleness fix (#4648 / MUL-3782); folding those side effects in
// would mean unifying the payload type and is left as a follow-up.
func (s *TaskService) broadcastIssueUpdated(issue db.Issue, prevStatus string) {
func (s *TaskService) broadcastIssueUpdated(issue db.Issue) {
prefix := s.getIssuePrefix(issue.WorkspaceID)
s.Bus.Publish(events.Event{
Type: protocol.EventIssueUpdated,
WorkspaceID: util.UUIDToString(issue.WorkspaceID),
ActorType: "system",
ActorID: "",
Payload: map[string]any{
"issue": issueToMap(issue, prefix),
"status_changed": prevStatus != issue.Status,
"prev_status": prevStatus,
},
Payload: map[string]any{"issue": issueToMap(issue, prefix)},
})
}

View File

@@ -1,119 +0,0 @@
package service
import (
"context"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// noRowsDBTX makes every read return pgx.ErrNoRows so getIssuePrefix's
// GetWorkspace lookup falls back to an empty prefix without needing a DB. The
// helper under test still publishes regardless of the prefix result.
type noRowsDBTX struct{}
func (noRowsDBTX) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
return pgconn.NewCommandTag(""), nil
}
func (noRowsDBTX) Query(context.Context, string, ...any) (pgx.Rows, error) {
return nil, pgx.ErrNoRows
}
func (noRowsDBTX) QueryRow(context.Context, string, ...any) pgx.Row { return noRow{} }
type noRow struct{}
func (noRow) Scan(...any) error { return pgx.ErrNoRows }
// TestBroadcastIssueUpdated_EmitsStatusChange pins the realtime contract behind
// #4648 / MUL-3782: when a background path resets an issue's status (e.g. the
// failed-task handler flipping a stuck in_progress issue back to todo), it must
// publish issue:updated with status_changed=true and the new status so the
// frontend's onIssueUpdated reconcile moves the card between status columns /
// filters instead of leaving it stale until the next unrelated write.
func TestBroadcastIssueUpdated_EmitsStatusChange(t *testing.T) {
bus := events.New()
var got []events.Event
bus.SubscribeAll(func(e events.Event) { got = append(got, e) })
svc := &TaskService{
Queries: db.New(noRowsDBTX{}),
Bus: bus,
}
issue := db.Issue{
ID: testUUID(1),
WorkspaceID: testUUID(2),
Number: 7,
Status: "todo",
}
svc.broadcastIssueUpdated(issue, "in_progress")
if len(got) != 1 {
t.Fatalf("expected exactly 1 published event, got %d", len(got))
}
e := got[0]
if e.Type != protocol.EventIssueUpdated {
t.Fatalf("expected event type %q, got %q", protocol.EventIssueUpdated, e.Type)
}
if e.WorkspaceID != util.UUIDToString(issue.WorkspaceID) {
t.Fatalf("workspace mismatch: got %q want %q", e.WorkspaceID, util.UUIDToString(issue.WorkspaceID))
}
payload, ok := e.Payload.(map[string]any)
if !ok {
t.Fatalf("payload is not map[string]any: %T", e.Payload)
}
if payload["status_changed"] != true {
t.Errorf("expected status_changed=true, got %v", payload["status_changed"])
}
if payload["prev_status"] != "in_progress" {
t.Errorf("expected prev_status=in_progress, got %v", payload["prev_status"])
}
issueMap, ok := payload["issue"].(map[string]any)
if !ok {
t.Fatalf("issue payload is not map[string]any: %T", payload["issue"])
}
if issueMap["status"] != "todo" {
t.Errorf("expected issue.status=todo, got %v", issueMap["status"])
}
if issueMap["id"] != util.UUIDToString(issue.ID) {
t.Errorf("issue.id mismatch: got %v want %q", issueMap["id"], util.UUIDToString(issue.ID))
}
}
// TestBroadcastIssueUpdated_NoStatusChange guards the gate: a same-status
// broadcast reports status_changed=false so the client skips the status-bucket
// reconcile for non-status field updates.
func TestBroadcastIssueUpdated_NoStatusChange(t *testing.T) {
bus := events.New()
var got []events.Event
bus.SubscribeAll(func(e events.Event) { got = append(got, e) })
svc := &TaskService{
Queries: db.New(noRowsDBTX{}),
Bus: bus,
}
issue := db.Issue{
ID: testUUID(1),
WorkspaceID: testUUID(2),
Status: "todo",
}
svc.broadcastIssueUpdated(issue, "todo")
if len(got) != 1 {
t.Fatalf("expected exactly 1 published event, got %d", len(got))
}
payload, ok := got[0].Payload.(map[string]any)
if !ok {
t.Fatalf("payload is not map[string]any: %T", got[0].Payload)
}
if payload["status_changed"] != false {
t.Errorf("expected status_changed=false, got %v", payload["status_changed"])
}
}

View File

@@ -217,7 +217,7 @@ func DetectVersion(ctx context.Context, executablePath string) (string, error) {
// environment variables are deliberately omitted so the string is a hint
// about *what* users are extending, not a dump of the full command line.
var launchHeaders = map[string]string{
"antigravity": "agy -p (non-interactive)",
"antigravity": "agy -p (print mode)",
"claude": "claude (stream-json)",
"codebuddy": "codebuddy (stream-json)",
"codex": "codex app-server",

View File

@@ -2,7 +2,6 @@ package agent
import (
"context"
"strings"
"testing"
"time"
)
@@ -116,18 +115,6 @@ func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
}
}
func TestLaunchHeaderAntigravityAvoidsTextOnlyPrintModeLabel(t *testing.T) {
t.Parallel()
header := LaunchHeader("antigravity")
if header != "agy -p (non-interactive)" {
t.Fatalf("unexpected Antigravity launch header: %q", header)
}
if strings.Contains(header, "print mode") {
t.Fatalf("Antigravity launch header must not imply a text-only mode: %q", header)
}
}
func TestLaunchHeaderReturnsEmptyForUnknownType(t *testing.T) {
t.Parallel()
if header := LaunchHeader("made-up-agent"); header != "" {

View File

@@ -14,14 +14,12 @@ import (
)
// antigravityBackend implements Backend by spawning Google's Antigravity CLI
// with a one-shot prompt (`agy -p <prompt>`). Despite the upstream flag name,
// current agy print mode is still capable of running Antigravity tools; it is
// the daemon-compatible mode because `agy -i` requires an attached TTY. Unlike
// Claude / Codex / Cursor / Gemini, the Antigravity CLI does not expose a
// structured event stream — stdout is plain assistant text (intermediate "I
// will run X" lines and the final reply, all interleaved). The backend
// therefore streams stdout line-by-line as `MessageText` events and accumulates
// the same text as the final `Result.Output`.
// (`agy -p <prompt>`) in non-interactive print mode. Unlike Claude / Codex /
// Cursor / Gemini, the Antigravity CLI does not expose a structured event
// stream — stdout is plain assistant text (intermediate "I will run X" lines
// and the final reply, all interleaved). The backend therefore streams stdout
// line-by-line as `MessageText` events and accumulates the same text as the
// final `Result.Output`.
//
// Session resumption uses `--conversation <id>`. The conversation id is not
// emitted on stdout; we capture it by routing `--log-file` to a temp file and
@@ -156,7 +154,7 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex
// success the user can't distinguish from a finished task (MUL-3570).
finalStatus = "timeout"
finalError = fmt.Sprintf(
"agy --print-timeout elapsed after %s waiting for the agent response; a long-running command likely outlived the print timeout",
"agy print mode timed out after %s waiting for the agent response; a long-running command likely outlived --print-timeout",
antigravityPrintTimeout(timeout),
)
} else if providerErr := antigravityProviderError(logPath); finalStatus == "completed" && providerErr != "" {
@@ -272,7 +270,7 @@ var antigravityBlockedArgs = map[string]blockedArgMode{
"-p": blockedWithValue,
"--print": blockedWithValue,
"--prompt": blockedWithValue,
"-i": blockedStandalone, // interactive mode requires a TTY and cannot run under the daemon
"-i": blockedStandalone, // interactive mode would block the daemon
"--prompt-interactive": blockedStandalone,
"-c": blockedStandalone, // resume via --conversation, not --continue
"--continue": blockedStandalone,
@@ -283,8 +281,7 @@ var antigravityBlockedArgs = map[string]blockedArgMode{
"--log-file": blockedWithValue, // daemon needs it for session capture
}
// buildAntigravityArgs assembles the argv for a daemon-compatible one-shot agy
// invocation.
// buildAntigravityArgs assembles the argv for a one-shot agy invocation.
//
// agy -p <prompt> --dangerously-skip-permissions [--model <display name>]
// --print-timeout <duration> --log-file <tmp>

View File

@@ -219,8 +219,6 @@ func TestBuildAntigravityArgsFiltersBlockedCustomArgs(t *testing.T) {
// resume-aware operation.
CustomArgs: []string{
"-p", "hijacked-prompt",
"-i",
"--prompt-interactive",
"--continue",
"-c",
"--conversation", "bad-id",
@@ -249,9 +247,6 @@ func TestBuildAntigravityArgsFiltersBlockedCustomArgs(t *testing.T) {
if strings.Contains(joined, "hijacked-prompt") {
t.Errorf("custom -p value leaked through filter: %v", args)
}
if strings.Contains(joined, "-i") || strings.Contains(joined, "--prompt-interactive") {
t.Errorf("interactive-mode flags leaked through filter: %v", args)
}
if strings.Contains(joined, "bad-id") {
t.Errorf("custom --conversation value leaked through filter: %v", args)
}
@@ -394,8 +389,8 @@ func TestAntigravityBackendPrintTimeoutSurfacesAsTimeout(t *testing.T) {
if result.Status != "timeout" {
t.Fatalf("expected status=timeout, got %q (error=%q)", result.Status, result.Error)
}
if !strings.Contains(result.Error, "agy --print-timeout elapsed") {
t.Errorf("expected error to explain the agy print timeout, got %q", result.Error)
if !strings.Contains(result.Error, "print mode timed out") {
t.Errorf("expected error to explain the print-mode timeout, got %q", result.Error)
}
// Narration streamed before the cut-off must still reach the result so
// the user sees how far the turn got.