Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
3d871c4165 fix(views): resolve handoff-note version gate locally for direct agent assigns
The run-confirm interception box gated its handoff-note field on the
preview round-trip's `handoff_supported`, so every open showed a
"checking…" wait before the note box could even be used — to learn
something the client already holds. For a concrete agent assignee the
target runtime is exactly that agent's, and its CLI version is already
warm in the prefetched agent + runtime caches, so the box can settle
synchronously, the same way the quick-create version gate does.

Add a frontend `handoffSupported` mirror of the server's
MinHandoffCLIVersion gate, resolve the agent → runtime → cli_version
locally, and drive the note box from that verdict without waiting on
loading. Squad / batch-status / unresolved-agent paths — whose resolved
trigger set is only known server-side — keep falling back to the
preview's `handoff_supported`, unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-26 09:08:05 +08:00
4 changed files with 187 additions and 8 deletions

View File

@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { checkQuickCreateCliVersion } from "./cli-version";
import {
checkQuickCreateCliVersion,
handoffSupported,
MIN_HANDOFF_CLI_VERSION,
} from "./cli-version";
describe("checkQuickCreateCliVersion", () => {
it("returns ok for a tagged release at or above the minimum", () => {
@@ -24,3 +28,30 @@ describe("checkQuickCreateCliVersion", () => {
expect(checkQuickCreateCliVersion("0.1.0-1-gabc1234").state).toBe("ok");
});
});
// Mirrors server/pkg/agent/handoff_version_test.go so the frontend soft-gate
// signal and the server's authoritative one agree by construction.
describe("handoffSupported", () => {
it("supports a tagged release at or above the minimum", () => {
expect(handoffSupported(MIN_HANDOFF_CLI_VERSION)).toBe(true);
expect(handoffSupported("0.4.0")).toBe(true);
expect(handoffSupported("v0.3.28")).toBe(true);
});
it("does not support a tagged release below the minimum", () => {
expect(handoffSupported("0.3.26")).toBe(false);
expect(handoffSupported("0.2.21")).toBe(false);
});
it("fails closed on empty or unparsable input", () => {
expect(handoffSupported("")).toBe(false);
expect(handoffSupported(undefined)).toBe(false);
expect(handoffSupported(null)).toBe(false);
expect(handoffSupported("garbage")).toBe(false);
});
it("treats git-describe dev builds as supported regardless of base tag", () => {
expect(handoffSupported("v0.3.0-5-gabc1234")).toBe(true);
expect(handoffSupported("v0.1.0-235-gdaf0e935-dirty")).toBe(true);
});
});

View File

@@ -72,3 +72,33 @@ export function readRuntimeCliVersion(metadata: Record<string, unknown> | undefi
const v = metadata?.cli_version;
return typeof v === "string" ? v : "";
}
/**
* Frontend mirror of the server's `MinHandoffCLIVersion` soft gate
* (`server/pkg/agent/version.go`). The assignment handoff note is only rendered
* into the run's opening prompt by daemons at or above this multica CLI version
* (MUL-3375); older daemons silently drop it. Unlike the quick-create gate this
* never blocks the assignment — the UI just grays out the note box and warns.
*
* Keep in lockstep with the server constant; the two are enforced independently
* (the server is authoritative) but must agree so the warning matches reality.
*/
export const MIN_HANDOFF_CLI_VERSION = "0.3.28";
/**
* Whether a daemon-reported CLI version is new enough to render a handoff note.
* Mirrors server `agent.HandoffSupported`: missing / unparsable / below-minimum
* all degrade to `false`, and dev-built daemons (git-describe shape) always
* pass — the version string is the shared signal, so frontend and server agree
* by construction. Pure and synchronous, so the note box can settle from the
* already-warm runtime cache instead of waiting on the trigger-preview
* round-trip, exactly like the quick-create version gate.
*/
export function handoffSupported(detected: string | undefined | null): boolean {
const current = (detected ?? "").trim();
if (!current) return false;
if (DEV_DESCRIBE_RE.test(current)) return true;
const parsed = parseSemver(current);
if (!parsed) return false;
return !lessThan(parsed, parseSemver(MIN_HANDOFF_CLI_VERSION)!);
}

View File

@@ -13,6 +13,46 @@ vi.mock("../issues/hooks/use-issue-trigger-preview", () => ({
useIssueTriggerPreview: () => previewState,
}));
// --- Warm agent + runtime caches (prefetched in the real app) ----------------
// The modal resolves a concrete agent assignee → its runtime → cli_version
// locally, exactly like the quick-create version gate, so the note box never
// waits on the preview round-trip. Tests drive the local verdict by swapping
// the runtime's reported cli_version here.
const cache = {
agents: [{ id: "agent-1", runtime_id: "runtime-1" }] as Array<{ id: string; runtime_id: string }>,
runtimes: [{ id: "runtime-1", metadata: { cli_version: "0.4.0" } }] as Array<{
id: string;
metadata: Record<string, unknown>;
}>,
};
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: string[] }) => {
if (queryKey[0] === "runtimes") return { data: cache.runtimes };
if (queryKey[0] === "workspaces" && queryKey[2] === "agents") return { data: cache.agents };
return { data: [] };
},
}));
vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test" }));
vi.mock("@multica/core/workspace/queries", () => ({
agentListOptions: (wsId: string) => ({ queryKey: ["workspaces", wsId, "agents"] }),
}));
// Stub the runtimes barrel: the query-options builder would otherwise drag the
// network layer in, and the deep cli-version module isn't an exported subpath.
// `handoffSupported`'s real semver/dev-build logic is exhaustively covered in
// packages/core/runtimes/cli-version.test.ts; here we only need a faithful
// stand-in for the >= 0.3.28 threshold so the cache → version → verdict wiring
// is exercised end to end.
vi.mock("@multica/core/runtimes", () => ({
runtimeListOptions: (wsId: string) => ({ queryKey: ["runtimes", wsId, "list"] }),
readRuntimeCliVersion: (m?: { cli_version?: unknown }) =>
typeof m?.cli_version === "string" ? m.cli_version : "",
handoffSupported: (v?: string | null) => {
const m = /(\d+)\.(\d+)\.(\d+)/.exec((v ?? "").trim());
if (!m) return false;
return Number(m[1]) * 1e6 + Number(m[2]) * 1e3 + Number(m[3]) >= 3028; // 0.3.28
},
}));
const mockUpdate = vi.fn().mockResolvedValue(undefined);
const mockBatch = vi.fn().mockResolvedValue(undefined);
vi.mock("@multica/core/issues/mutations", () => ({
@@ -32,9 +72,12 @@ vi.mock("../i18n", () => ({
title_assign: "Assign and start?",
title_status: "Start working now?",
will_start_named: "start Walt",
will_start_named_squad: "start squad Walt",
will_start: "start many",
will_start_squad: "start squad many",
nothing_assign: "no run (backlog)",
nothing_status: "no runs",
checking: "Checking…",
note_label: "Handoff note",
note_placeholder: "scope...",
note_unsupported: "runtime too old",
@@ -67,6 +110,9 @@ vi.mock("@multica/ui/components/ui/button", () => ({
vi.mock("@multica/ui/components/ui/textarea", () => ({
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}));
vi.mock("@multica/ui/components/ui/spinner", () => ({
Spinner: () => <span data-testid="spinner" />,
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
beforeEach(() => {
@@ -74,7 +120,10 @@ beforeEach(() => {
mockBatch.mockClear();
previewState.triggers = [{ issue_id: "issue-1", agent_id: "agent-1", source: "assign", handoff_supported: true }];
previewState.totalCount = 1;
previewState.isLoading = false;
previewState.handoffSupported = true;
cache.agents = [{ id: "agent-1", runtime_id: "runtime-1" }];
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.4.0" } }];
});
describe("RunConfirmModal", () => {
@@ -112,8 +161,13 @@ describe("RunConfirmModal", () => {
expect(payload.handoff_note).toBeUndefined();
});
it("disables the note box when the runtime can't render a handoff", () => {
previewState.handoffSupported = false;
it("disables the note box from the local runtime version, before the preview resolves", () => {
// Old daemon that can't render handoff notes, and the predicate is still in
// flight. The box must already be disabled + warned from the warm runtime
// cache — no "checking…" wait, no reliance on the server verdict.
previewState.isLoading = true;
previewState.totalCount = 0;
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.2.21" } }];
render(
<RunConfirmModal
onClose={vi.fn()}
@@ -124,6 +178,37 @@ describe("RunConfirmModal", () => {
expect(screen.getByText("runtime too old")).toBeInTheDocument();
});
it("keeps the note box usable while the preview is still loading for a supported agent", () => {
// The core of MUL-3706: a concrete agent on a current runtime should never
// see a "checking…" gate on the note box — the version is known locally.
previewState.isLoading = true;
previewState.totalCount = 0;
cache.runtimes = [{ id: "runtime-1", metadata: { cli_version: "0.4.0" } }];
render(
<RunConfirmModal
onClose={vi.fn()}
data={{ issueIds: ["issue-1"], mode: "assign", assigneeType: "agent", assigneeId: "agent-1" }}
/>,
);
expect(screen.getByPlaceholderText("scope...")).not.toBeDisabled();
expect(screen.queryByText("runtime too old")).not.toBeInTheDocument();
});
it("squad assignee defers to the server handoff verdict (not locally resolvable)", () => {
// A squad routes to its leader agent, picked server-side — the target
// runtime isn't knowable client-side, so the box must follow the preview's
// handoff_supported, exactly as before.
previewState.handoffSupported = false;
render(
<RunConfirmModal
onClose={vi.fn()}
data={{ issueIds: ["issue-1"], mode: "assign", assigneeType: "squad", assigneeId: "squad-1" }}
/>,
);
expect(screen.getByPlaceholderText("scope...")).toBeDisabled();
expect(screen.getByText("runtime too old")).toBeInTheDocument();
});
it("batch (N ids) applies via batchUpdate", async () => {
previewState.triggers = [
{ issue_id: "i1", agent_id: "a1", source: "status", handoff_supported: true },

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, type ReactNode } from "react";
import { useMemo, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Dialog,
@@ -16,6 +17,9 @@ import { Spinner } from "@multica/ui/components/ui/spinner";
import type { IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
import { useUpdateIssue, useBatchUpdateIssues } from "@multica/core/issues/mutations";
import { useActorName } from "@multica/core/workspace/hooks";
import { useWorkspaceId } from "@multica/core/hooks";
import { agentListOptions } from "@multica/core/workspace/queries";
import { runtimeListOptions, readRuntimeCliVersion, handoffSupported } from "@multica/core/runtimes";
import { useIssueTriggerPreview } from "../issues/hooks/use-issue-trigger-preview";
import { useT } from "../i18n";
@@ -90,9 +94,34 @@ export function RunConfirmModal({
const loading = preview.isLoading;
const willStart = preview.totalCount > 0;
const canNote = mode === "assign" && willStart;
// Local-first handoff-support verdict. For a concrete agent assignee the
// target runtime is exactly that agent's, and its CLI version is already warm
// in the prefetched agent + runtime caches (useWorkspacePresencePrefetch) —
// so we can decide whether the note box is usable synchronously, the same way
// the quick-create version gate does, instead of waiting on the preview
// round-trip just to learn something the client already holds. Squad / status
// / unresolved-agent stay `null` and fall through to the server's verdict,
// because their resolved trigger set (hence runtime versions) is only known
// after the backend predicate lands.
const wsId = useWorkspaceId();
const { data: agents = [] } = useQuery({ ...agentListOptions(wsId), enabled: !!wsId });
const { data: runtimes = [] } = useQuery({ ...runtimeListOptions(wsId), enabled: !!wsId });
const localHandoff = useMemo<boolean | null>(() => {
if (mode !== "assign" || d.assigneeType !== "agent" || !d.assigneeId) return null;
const agent = agents.find((a) => a.id === d.assigneeId);
if (!agent?.runtime_id) return null;
const runtime = runtimes.find((r) => r.id === agent.runtime_id);
if (!runtime) return null;
return handoffSupported(readRuntimeCliVersion(runtime.metadata));
}, [mode, d.assigneeType, d.assigneeId, agents, runtimes]);
// Soft gate: an old runtime can't render the note. Disable the box but let
// the assignment proceed (MUL-3375 §6.3).
const noteDisabled = canNote && !preview.handoffSupported;
// the assignment proceed (MUL-3375 §6.3). The local verdict resolves it
// instantly when available; otherwise we use the server's preview value once
// it lands (and only then, since `canNote` is false while loading).
const noteDisabled =
localHandoff !== null ? localHandoff === false : canNote && !preview.handoffSupported;
const applyTo = (extra: Partial<UpdateIssueRequest>) => {
const base: UpdateIssueRequest =
@@ -184,12 +213,16 @@ export function RunConfirmModal({
id="handoff-note"
value={note}
maxLength={MAX_HANDOFF_NOTE}
disabled={loading || noteDisabled || submitting}
// Only block on the preview round-trip when we have no local
// verdict (squad / status). With a local verdict the box is
// usable the instant it opens — supported → editable, old runtime
// → disabled — never a "checking…" wait.
disabled={submitting || noteDisabled || (localHandoff === null && loading)}
placeholder={t(($) => $.run_confirm.note_placeholder)}
onChange={(e) => setNote(e.target.value)}
rows={3}
/>
{!loading && noteDisabled ? (
{noteDisabled && (localHandoff !== null || !loading) ? (
<p className="text-xs text-muted-foreground">{t(($) => $.run_confirm.note_unsupported)}</p>
) : null}
</div>