fix(web): preserve mcp args and instance labels (#1299)

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
This commit is contained in:
Duc Nguyenandntduc authored and GitHub committed 2026-06-29 02:54:49 +07:00
1 parent 64596bf0c1
commit 79822410e8
6 files changed
+345 -25

No files matched your search

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { ChannelContact } from "@/types/contact";
import { buildAgentInstanceDisplay, getAgentInstanceResolveIds } from "./agent-instance-display-utils";
function resolver(contacts: Record<string, Partial<ChannelContact>>) {
return (id: string): ChannelContact | null => (contacts[id] as ChannelContact | undefined) ?? null;
}
describe("agent instance display utils", () => {
it("formats group instances as channel, chat name, and raw instance id", () => {
const display = buildAgentInstanceDisplay(
{
user_id: "group:lark-cppai-pm:oc_123",
metadata: { chat_title: "LVC Online" },
},
resolver({}),
);
expect(display.label).toBe("Lark - LVC Online - oc_123");
expect(display.instanceId).toBe("oc_123");
});
it("uses raw group contact names when profile metadata has no title", () => {
const display = buildAgentInstanceDisplay(
{ user_id: "group:lark-cppai-pm:oc_456" },
resolver({
oc_456: {
sender_id: "oc_456",
channel_type: "feishu",
display_name: "Peacefullfie",
},
}),
);
expect(display.label).toBe("Lark - Peacefullfie - oc_456");
});
it("does not use sender display names as group names", () => {
const display = buildAgentInstanceDisplay(
{
user_id: "group:lark-cppai-pm:oc_456",
metadata: { display_name: "Last Sender" },
},
resolver({}),
);
expect(display.label).toBe("Lark - oc_456");
});
it("formats direct channel contacts when contact metadata is available", () => {
const display = buildAgentInstanceDisplay(
{ user_id: "ou_789" },
resolver({
ou_789: {
sender_id: "ou_789",
channel_type: "feishu",
channel_instance: "lark-cppai-pm",
display_name: "Duc Nguyen",
},
}),
);
expect(display.label).toBe("Lark - Duc Nguyen - ou_789");
});
it("falls back to a clean raw id when no channel or name is known", () => {
const display = buildAgentInstanceDisplay({ user_id: "system" }, resolver({}));
expect(display.label).toBe("system");
});
it("detects underscore channel prefixes", () => {
const display = buildAgentInstanceDisplay({ user_id: "group:zalo_ol:chat4878" }, resolver({}));
expect(display.label).toBe("Zalo - chat4878");
});
it("uses raw group ids for contact resolver inputs", () => {
expect(
getAgentInstanceResolveIds([
{ user_id: "group:telegram:-100123" },
{ user_id: "group:telegram:-100123" },
{ user_id: "ou_abc" },
]),
).toEqual(["-100123", "ou_abc"]);
});
it("maps tele group instance prefixes to Telegram", () => {
const display = buildAgentInstanceDisplay({ user_id: "group:tele-itsdd-local-goclaw:-5104" }, resolver({}));
expect(display.label).toBe("Telegram - -5104");
});
});
@@ -0,0 +1,136 @@
import type { ChannelContact } from "@/types/contact";
interface AgentInstanceLike {
user_id: string;
metadata?: Record<string, unknown>;
}
export interface AgentInstanceDisplay {
label: string;
instanceId: string;
channelLabel: string | null;
chatName: string | null;
rawUserId: string;
}
type ContactResolver = (id: string) => ChannelContact | null;
const CHANNEL_PREFIX_LABELS: Array<[string, string]> = [
["zalo_personal", "Zalo Personal"],
["zalo-personal", "Zalo Personal"],
["zalo_oa", "Zalo OA"],
["zalo-oa", "Zalo OA"],
["bitrix24", "Bitrix24"],
["bitrix", "Bitrix24"],
["telegram", "Telegram"],
["tele", "Telegram"],
["tg", "Telegram"],
["discord", "Discord"],
["whatsapp", "WhatsApp"],
["facebook", "Facebook"],
["pancake", "Pancake"],
["feishu", "Feishu"],
["lark", "Lark"],
["slack", "Slack"],
["zalo", "Zalo"],
];
export function getAgentInstanceResolveIds(instances: AgentInstanceLike[]): string[] {
const ids = new Set<string>();
for (const instance of instances) {
const groupTarget = parseGroupTarget(instance.user_id);
ids.add(groupTarget?.instanceId || instance.user_id);
}
return [...ids];
}
export function buildAgentInstanceDisplay(
instance: AgentInstanceLike,
resolve: ContactResolver,
): AgentInstanceDisplay {
const groupTarget = parseGroupTarget(instance.user_id);
const isGroupInstance = Boolean(groupTarget);
const contact = resolve(instance.user_id);
const rawContact = groupTarget ? resolve(groupTarget.instanceId) : null;
const metadata = instance.metadata ?? {};
const instanceId = groupTarget?.instanceId || contact?.sender_id || instance.user_id;
const channelLabel =
channelLabelFromSource(groupTarget?.channelSource) ??
channelLabelFromSource(metadata.channel_instance) ??
channelLabelFromSource(rawContact?.channel_instance) ??
channelLabelFromSource(contact?.channel_instance) ??
channelLabelFromSource(metadata.channel_type) ??
channelLabelFromSource(metadata.channel) ??
channelLabelFromSource(metadata.platform) ??
channelLabelFromSource(rawContact?.channel_type) ??
channelLabelFromSource(contact?.channel_type);
const metadataTitle =
clean(metadata.chat_title) ??
clean(metadata.group_title) ??
clean(metadata.chat_name);
const chatName = isGroupInstance
? metadataTitle ?? clean(rawContact?.display_name) ?? clean(rawContact?.username)
: metadataTitle ??
clean(metadata.display_name) ??
clean(rawContact?.display_name) ??
clean(rawContact?.username) ??
clean(contact?.display_name) ??
clean(contact?.username);
return {
label: formatInstanceLabel(channelLabel, chatName, instanceId),
instanceId,
channelLabel,
chatName,
rawUserId: instance.user_id,
};
}
function parseGroupTarget(userID: string): { channelSource: string; instanceId: string } | null {
if (!userID.startsWith("group:")) return null;
const rest = userID.slice("group:".length);
const separatorIndex = rest.indexOf(":");
if (separatorIndex < 0) return null;
const channelSource = rest.slice(0, separatorIndex);
const instanceId = rest.slice(separatorIndex + 1);
if (!channelSource || !instanceId) return null;
return { channelSource, instanceId };
}
function channelLabelFromSource(source?: unknown): string | null {
const value = clean(source)?.toLowerCase();
if (!value) return null;
for (const [prefix, label] of CHANNEL_PREFIX_LABELS) {
if (
value === prefix ||
value.startsWith(`${prefix}-`) ||
value.startsWith(`${prefix}_`) ||
value.startsWith(`${prefix}:`)
) {
return label;
}
}
return null;
}
function formatInstanceLabel(channelLabel: string | null, chatName: string | null, instanceId: string): string {
if (channelLabel && chatName) return `${channelLabel} - ${chatName} - ${instanceId}`;
if (channelLabel) return `${channelLabel} - ${instanceId}`;
if (chatName) return chatName;
return instanceId;
}
function clean(value?: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { useAgentInstances, type UserInstance } from "../hooks/use-agent-instances";
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
import { buildAgentInstanceDisplay, getAgentInstanceResolveIds } from "./agent-instance-display-utils";
interface AgentInstancesTabProps {
agentId: string;
@@ -53,13 +54,18 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
const isDirty = content !== originalContent;
// Resolve user_ids to contact names for instances without metadata
const instanceUserIDs = useMemo(() => instances.map((i) => i.user_id), [instances]);
const instanceUserIDs = useMemo(() => getAgentInstanceResolveIds(instances), [instances]);
const { resolve } = useContactResolver(instanceUserIDs);
// Existing instance user_ids for deduplication
const existingIDs = useMemo(() => new Set(instances.map((i) => i.user_id)), [instances]);
const [addUserId, setAddUserId] = useState("");
const selectedInstanceDisplay = useMemo(() => {
if (!selected) return null;
const selectedInstance = instances.find((inst) => inst.user_id === selected) ?? { user_id: selected };
return buildAgentInstanceDisplay(selectedInstance, resolve);
}, [instances, resolve, selected]);
const handleAddUser = (val: string) => {
setAddUserId(val);
@@ -75,7 +81,7 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
return (
<div className="flex gap-4" style={{ minHeight: 400 }}>
{/* Instance list */}
<div className="w-64 shrink-0 space-y-1 overflow-y-auto rounded-md border p-2">
<div className="w-80 shrink-0 space-y-1 overflow-y-auto rounded-md border p-2">
<div className="px-1 pb-2">
<UserPickerCombobox
value={addUserId}
@@ -121,7 +127,9 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">USER.md</span>
<span className="text-xs text-muted-foreground">— {selected}</span>
<span className="truncate text-xs text-muted-foreground" title={selectedInstanceDisplay?.rawUserId}>
— {selectedInstanceDisplay?.label ?? selected}
</span>
</div>
<Textarea
className="flex-1 font-mono text-sm"
@@ -146,8 +154,7 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
function InstanceRow({ instance, isSelected, onClick, resolve }: { instance: UserInstance; isSelected: boolean; onClick: () => void; resolve: (id: string) => import("@/types/contact").ChannelContact | null }) {
const lastSeen = instance.last_seen_at ? formatRelative(instance.last_seen_at) : null;
const contact = resolve(instance.user_id);
const displayName = instance.metadata?.display_name || instance.metadata?.chat_title || contact?.display_name || null;
const display = buildAgentInstanceDisplay(instance, resolve);
return (
<button
@@ -157,12 +164,9 @@ function InstanceRow({ instance, isSelected, onClick, resolve }: { instance: Use
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
}`}
>
<span className="truncate text-xs font-medium">
{displayName || instance.user_id}
<span className="whitespace-normal break-words text-xs font-medium leading-snug" title={display.label}>
{display.label}
</span>
{displayName && (
<span className="truncate font-mono text-2xs text-muted-foreground">{instance.user_id}</span>
)}
<div className="flex items-center gap-2">
{instance.file_count > 0 && (
<Badge variant="outline" className="text-2xs">
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { formatShellArgs, parseShellArgs } from "./mcp-args";
describe("MCP argv formatting", () => {
it("preserves comma-separated values as one argument", () => {
const input = "-t preset.default,preset.base.batch,task.v2.task.list";
expect(parseShellArgs(input)).toEqual([
"-t",
"preset.default,preset.base.batch,task.v2.task.list",
]);
});
it("renders saved args with spaces between argv entries, not commas", () => {
const args = [
"/path/to/cli.js",
"mcp",
"-t",
"preset.default,preset.base.batch,task.v2.task.list",
];
expect(formatShellArgs(args)).toBe(
"/path/to/cli.js mcp -t preset.default,preset.base.batch,task.v2.task.list",
);
});
it("round trips quoted arguments containing spaces", () => {
const args = ["--flag", "value with spaces", "--json", "{\"ok\":true}", ""];
expect(parseShellArgs(formatShellArgs(args))).toEqual(args);
});
});
+65
View File
@@ -0,0 +1,65 @@
/** Split argv text using shell-like whitespace rules. Commas are literal. */
export function parseShellArgs(input: string): string[] {
const tokens: string[] = [];
let token = "";
let tokenStarted = false;
let quote: "'" | "\"" | null = null;
let escaping = false;
const pushToken = () => {
if (tokenStarted) {
tokens.push(token);
token = "";
tokenStarted = false;
}
};
for (const char of input.trim()) {
if (escaping) {
token += char;
tokenStarted = true;
escaping = false;
continue;
}
if (char === "\\" && quote !== "'") {
tokenStarted = true;
escaping = true;
continue;
}
if ((char === "'" || char === "\"") && !quote) {
tokenStarted = true;
quote = char;
continue;
}
if (quote === char) {
quote = null;
continue;
}
if (!quote && /\s/.test(char)) {
pushToken();
continue;
}
token += char;
tokenStarted = true;
}
if (escaping) token += "\\";
pushToken();
return tokens;
}
/** Render argv text without inventing comma separators between arguments. */
export function formatShellArgs(args: string[]): string {
return args.map(formatShellArg).join(" ");
}
function formatShellArg(arg: string): string {
if (arg === "") return "\"\"";
if (!/[\s"\\]/.test(arg)) return arg;
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
}
+5 -15
View File
@@ -16,17 +16,7 @@ import { isValidSlug } from "@/lib/slug";
import { mcpFormSchema, type MCPFormData } from "@/schemas/mcp.schema";
import { McpConnectionFields } from "./mcp-connection-fields";
import { McpSettingsFields } from "./mcp-settings-fields";
/** Split a string into shell-like tokens, treating commas and spaces outside quotes as delimiters. */
function splitShellTokens(input: string): string[] {
const tokens: string[] = [];
const re = /"([^"]*)"|'([^']*)'|[^\s,]+/g;
let m;
while ((m = re.exec(input)) !== null) {
tokens.push(m[1] ?? m[2] ?? m[0]);
}
return tokens.filter(Boolean);
}
import { formatShellArgs, parseShellArgs } from "./mcp-args";
interface MCPFormDialogProps {
open: boolean;
@@ -102,7 +92,7 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest, on
displayName: server?.display_name ?? "",
transport: (server?.transport as MCPFormData["transport"]) ?? "stdio",
command: server?.command ?? "",
args: Array.isArray(server?.args) ? server.args.join(", ") : "",
args: Array.isArray(server?.args) ? formatShellArgs(server.args) : "",
url: server?.url ?? "",
headers: server?.headers ?? {},
env: server?.env ?? {},
@@ -132,14 +122,14 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest, on
let resolvedCommand = command.trim();
if (isStdio) {
const cmdTokens = splitShellTokens(resolvedCommand);
const cmdTokens = parseShellArgs(resolvedCommand);
if (cmdTokens.length > 1) {
resolvedCommand = cmdTokens[0]!;
const extraArgs = cmdTokens.slice(1);
const userArgs = args.trim() ? splitShellTokens(args) : [];
const userArgs = args.trim() ? parseShellArgs(args) : [];
parsedArgs = [...extraArgs, ...userArgs];
} else if (args.trim()) {
parsedArgs = splitShellTokens(args);
parsedArgs = parseShellArgs(args);
}
}