mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 02:51:19 +02:00
Compare commits
1 Commits
main
...
agent/walt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d871c4165 |
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)!);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user