Merge branch 'main' into fix/task-log-reassign-incomplete-110

This commit is contained in:
Wendong-Fan 2025-09-16 15:33:35 +08:00 committed by GitHub
commit 4dc76a0ed9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 521 additions and 36 deletions

View file

@ -5,10 +5,36 @@ from fastapi import APIRouter, FastAPI
from dotenv import load_dotenv
import importlib
from typing import Any, overload
import threading
# Thread-local storage for user-specific environment
_thread_local = threading.local()
# Default global environment path
default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
load_dotenv(dotenv_path=default_env_path)
env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
load_dotenv(dotenv_path=env_path)
def set_user_env_path(env_path: str | None = None):
"""
Set user-specific environment path for current thread.
If env_path is None, uses default global environment.
"""
if env_path and os.path.exists(env_path):
_thread_local.env_path = env_path
# Load user-specific environment variables
load_dotenv(dotenv_path=env_path, override=True)
else:
# Clear thread-local env_path to fall back to global
if hasattr(_thread_local, 'env_path'):
delattr(_thread_local, 'env_path')
def get_current_env_path() -> str:
"""
Get current environment path (either user-specific or default).
"""
return getattr(_thread_local, 'env_path', default_env_path)
@overload
@ -24,6 +50,20 @@ def env(key: str, default: Any) -> Any: ...
def env(key: str, default=None):
"""
Get environment variable.
First checks thread-local user-specific environment,
then falls back to global environment.
"""
# If we have a user-specific environment path, try to reload it to get latest values
if hasattr(_thread_local, 'env_path') and os.path.exists(_thread_local.env_path):
# Temporarily load user-specific env to get the latest value
from dotenv import dotenv_values
user_env_values = dotenv_values(_thread_local.env_path)
if key in user_env_values:
return user_env_values[key] or default
# Fall back to global environment
return os.getenv(key, default)

View file

@ -20,6 +20,7 @@ from app.service.task import (
create_task_lock,
get_task_lock,
)
from app.component.environment import set_user_env_path
router = APIRouter(tags=["chat"])
@ -33,6 +34,9 @@ chat_logger = traceroot.get_logger('chat_controller')
async def post(data: Chat, request: Request):
chat_logger.info(f"Starting new chat session for task_id: {data.task_id}, user: {data.email}")
task_lock = create_task_lock(data.task_id)
# Set user-specific environment path for this thread
set_user_env_path(data.env_path)
load_dotenv(dotenv_path=data.env_path)
# logger.debug(f"start chat: {data.model_dump_json()}")

View file

@ -15,6 +15,7 @@ from app.service.task import (
task_locks,
)
import asyncio
from app.component.environment import set_user_env_path
router = APIRouter(tags=["task"])
@ -49,6 +50,8 @@ def take_control(id: str, data: TakeControl):
@router.post("/task/{id}/add-agent", name="add new agent")
def add_agent(id: str, data: NewAgent):
# Set user-specific environment path for this thread
set_user_env_path(data.env_path)
load_dotenv(dotenv_path=data.env_path)
asyncio.run(get_task_lock(id).put_queue(ActionNewAgent(**data.model_dump())))
return Response(status_code=204)

View file

@ -1,5 +1,6 @@
import asyncio
import json
import os
import platform
from threading import Event
import traceback
@ -1438,7 +1439,17 @@ async def get_mcp_tools(mcp_server: McpServers):
traceroot_logger.info(f"Getting MCP tools for {len(mcp_server['mcpServers'])} servers")
if len(mcp_server["mcpServers"]) == 0:
return []
mcp_toolkit = MCPToolkit(config_dict={**mcp_server}, timeout=180)
# Ensure unified auth directory for all mcp-remote servers to avoid re-authentication on each task
config_dict = {**mcp_server}
for server_config in config_dict["mcpServers"].values():
if "env" not in server_config:
server_config["env"] = {}
# Set global auth directory to persist authentication across tasks
if "MCP_REMOTE_CONFIG_DIR" not in server_config["env"]:
server_config["env"]["MCP_REMOTE_CONFIG_DIR"] = env("MCP_REMOTE_CONFIG_DIR", os.path.expanduser("~/.mcp-auth"))
mcp_toolkit = MCPToolkit(config_dict=config_dict, timeout=20)
try:
await mcp_toolkit.connect()
traceroot_logger.info(f"Successfully connected to MCP toolkit with {len(mcp_server['mcpServers'])} servers")

View file

@ -312,6 +312,24 @@ function registerIpcHandlers() {
});
ipcMain.handle('get-app-version', () => app.getVersion());
ipcMain.handle('get-backend-port', () => backendPort);
ipcMain.handle('restart-backend', async () => {
try {
if (backendPort) {
log.info('Restarting backend service...');
await cleanupPythonProcess();
await checkAndStartBackend();
log.info('Backend restart completed successfully');
return { success: true };
} else {
log.warn('No backend port found, starting fresh backend');
await checkAndStartBackend();
return { success: true };
}
} catch (error) {
log.error('Failed to restart backend:', error);
return { success: false, error: String(error) };
}
});
ipcMain.handle('get-system-language', getSystemLanguage);
ipcMain.handle('is-fullscreen', () => win?.isFullScreen() || false);
ipcMain.handle('get-home-dir', () => {
@ -516,6 +534,15 @@ function registerIpcHandlers() {
// ==================== MCP manage handler ====================
ipcMain.handle('mcp-install', async (event, name, mcp) => {
// Convert args from JSON string to array if needed
if (mcp.args && typeof mcp.args === 'string') {
try {
mcp.args = JSON.parse(mcp.args);
} catch (e) {
// If parsing fails, split by comma as fallback
mcp.args = mcp.args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
addMcp(name, mcp);
return { success: true };
});
@ -526,6 +553,15 @@ function registerIpcHandlers() {
});
ipcMain.handle('mcp-update', async (event, name, mcp) => {
// Convert args from JSON string to array if needed
if (mcp.args && typeof mcp.args === 'string') {
try {
mcp.args = JSON.parse(mcp.args);
} catch (e) {
// If parsing fails, split by comma as fallback
mcp.args = mcp.args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
updateMcp(name, mcp);
return { success: true };
});

View file

@ -8,6 +8,7 @@ const MCP_CONFIG_PATH = path.join(MCP_CONFIG_DIR, 'mcp.json');
type McpServerConfig = {
command: string;
args: string[];
description?: string;
env?: Record<string, string>;
} | {
url: string;
@ -17,7 +18,7 @@ type McpServersConfig = {
[name: string]: McpServerConfig;
};
type ConfigFile = {
export type ConfigFile = {
mcpServers: McpServersConfig;
};
@ -42,6 +43,28 @@ export function readMcpConfig(): ConfigFile {
if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
return getDefaultConfig();
}
// Normalize args field - ensure it's always an array
Object.keys(parsed.mcpServers).forEach(serverName => {
const server = parsed.mcpServers[serverName];
if (server.args) {
const args = server.args as any;
if (typeof args === 'string') {
try {
// Try to parse as JSON string first
server.args = JSON.parse(args);
} catch (e) {
// If parsing fails, split by comma as fallback
server.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
// Ensure it's always an array of strings
if (Array.isArray(server.args)) {
server.args = server.args.map((arg: any) => String(arg));
}
}
});
return parsed;
} catch (e) {
return getDefaultConfig();
@ -58,7 +81,22 @@ export function writeMcpConfig(config: ConfigFile): void {
export function addMcp(name: string, mcp: McpServerConfig): void {
const config = readMcpConfig();
if (!config.mcpServers[name]) {
config.mcpServers[name] = mcp;
// Ensure args is an array before adding
const normalizedMcp = { ...mcp };
if ('args' in normalizedMcp && normalizedMcp.args) {
const args = normalizedMcp.args as any;
if (typeof args === 'string') {
try {
normalizedMcp.args = JSON.parse(args);
} catch (e) {
normalizedMcp.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
if (Array.isArray(normalizedMcp.args)) {
normalizedMcp.args = normalizedMcp.args.map((arg: any) => String(arg));
}
}
config.mcpServers[name] = normalizedMcp;
writeMcpConfig(config);
}
}
@ -74,6 +112,21 @@ export function removeMcp(name: string): void {
export function updateMcp(name: string, mcp: McpServerConfig): void {
const config = readMcpConfig();
config.mcpServers[name] = mcp;
// Ensure args is an array before updating
const normalizedMcp = { ...mcp };
if ('args' in normalizedMcp && normalizedMcp.args) {
const args = normalizedMcp.args as any;
if (typeof args === 'string') {
try {
normalizedMcp.args = JSON.parse(args);
} catch (e) {
normalizedMcp.args = args.split(',').map((arg: string) => arg.trim()).filter((arg: string) => arg !== '');
}
}
if (Array.isArray(normalizedMcp.args)) {
normalizedMcp.args = normalizedMcp.args.map((arg: any) => String(arg));
}
}
config.mcpServers[name] = normalizedMcp;
writeMcpConfig(config);
}

View file

@ -112,18 +112,23 @@ export class WebViewManager {
}
console.log(`Webview ${id} navigated to: ${navigationUrl}`)
if (webViewInfo.isActive && webViewInfo.isShow && navigationUrl !== 'about:blank?use=0' && navigationUrl !== 'about:blank') {
console.log("did-navigate", id, url)
this.win?.webContents.send("url-updated", url);
console.log("did-navigate", id, navigationUrl)
this.win?.webContents.send("url-updated", navigationUrl);
return
}
webViewInfo.view.setBounds({ x: -1919, y: -1079, width: 1920, height: 1080 })
const activeSize = this.getActiveWebview().length
const allSize = Array.from(this.webViews.values()).length
if (allSize - activeSize <= 3) {
const newId = Array.from(this.webViews.keys()).length + 2
this.createWebview(newId.toString(), 'about:blank?use=0')
this.createWebview((newId + 1).toString(), 'about:blank?use=0')
this.createWebview((newId + 2).toString(), 'about:blank?use=0')
const existingKeys = Array.from(this.webViews.keys()).map(Number).filter(n => !isNaN(n))
const maxId = existingKeys.length > 0 ? Math.max(...existingKeys) : 0
const startId = maxId + 1
// Create webviews sequentially to avoid race conditions
for (let i = 0; i < 3; i++) {
const nextId = (startId + i).toString()
this.createWebview(nextId, 'about:blank?use=0')
}
}
// setTimeout(() => {
@ -242,8 +247,12 @@ export class WebViewManager {
}
}
public distroy() {
// TODO: Destroy all webviews
public destroy() {
// Destroy all webviews
Array.from(this.webViews.keys()).forEach(id => {
this.destroyWebview(id)
})
this.webViews.clear()
}
}

View file

@ -0,0 +1,39 @@
import { useCallback } from "react";
import { Button } from "../ui/button";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger?: React.ReactNode;
}
export default function CloseNoticeDialog({open, onOpenChange, trigger}: Props) {
const onSubmit = useCallback(() => {
window.electronAPI.closeWindow(true)
}, [])
return <Dialog open={open} onOpenChange={onOpenChange}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="sm:max-w-[600px] p-0 !bg-popup-surface gap-0 !rounded-xl border border-zinc-300 shadow-sm">
<DialogHeader className="!bg-popup-surface !rounded-t-xl p-md">
<DialogTitle className="m-0">
Close notice
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-md bg-popup-bg p-md">
A task is currently running. Exiting will terminate it. Are you sure you want to exit?
</div>
<DialogFooter className="bg-white-100% !rounded-b-xl p-md">
<DialogClose asChild>
<Button variant="ghost" size="md">
Cancel
</Button>
</DialogClose>
<Button size="md" onClick={onSubmit} variant="primary">
Yes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
}

View file

@ -6,10 +6,32 @@ import { useAuthStore } from "@/store/authStore";
import { useEffect, useState } from "react";
import { AnimationJson } from "@/components/AnimationJson";
import animationData from "@/assets/animation/onboarding_success.json";
import CloseNoticeDialog from "../Dialog/CloseNotice";
import { useChatStore } from "@/store/chatStore";
const Layout = () => {
const { initState, setInitState, isFirstLaunch, setIsFirstLaunch } =
useAuthStore();
const [isInstalling, setIsInstalling] = useState(false);
const [noticeOpen, setNoticeOpen] = useState(false);
const chatStore = useChatStore();
useEffect(() => {
const handleBeforeClose = () => {
const currentStatus = chatStore.tasks[chatStore.activeTaskId as string]?.status;
if(["pending", "running", "pause"].includes(currentStatus)) {
setNoticeOpen(true);
} else {
window.electronAPI.closeWindow(true);
}
};
window.ipcRenderer.on("before-close", handleBeforeClose);
return () => {
window.ipcRenderer.removeAllListeners("before-close");
};
}, [chatStore.tasks, chatStore.activeTaskId]);
useEffect(() => {
const checkToolInstalled = async () => {
// in render process
@ -25,6 +47,7 @@ const Layout = () => {
};
checkToolInstalled();
}, []);
return (
<div className="h-full flex flex-col">
@ -46,6 +69,10 @@ const Layout = () => {
)}
<Outlet />
<HistorySidebar />
<CloseNoticeDialog
onOpenChange={setNoticeOpen}
open={noticeOpen}
/>
</div>
</div>
);

View file

@ -20,6 +20,7 @@ import { getProxyBaseURL } from "@/lib";
import { useAuthStore } from "@/store/authStore";
import { toast } from "sonner";
import { ConfigFile } from "electron/main/utils/mcpConfig";
export default function SettingMCP() {
const navigate = useNavigate();
@ -195,13 +196,28 @@ export default function SettingMCP() {
setSaving(true);
setErrorMsg(null);
try {
await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, {
const mcpData = {
mcp_name: configForm.mcp_name,
mcp_desc: configForm.mcp_desc,
command: configForm.command,
args: arrayToArgsJson(configForm.argsArr),
env: configForm.env,
});
}
await proxyFetchPut(`/api/mcp/users/${showConfig.id}`, mcpData);
if (window.ipcRenderer) {
//Partial payload to empty env {}
const payload: any = {
description: configForm.mcp_desc,
command: configForm.command,
args: arrayToArgsJson(configForm.argsArr),
};
if (configForm.env && Object.keys(configForm.env).length > 0) {
payload.env = configForm.env;
}
window.ipcRenderer.invoke("mcp-update", mcpData.mcp_name, payload);
}
setShowConfig(null);
fetchList();
} catch (err: any) {
@ -236,9 +252,27 @@ export default function SettingMCP() {
setInstalling(true);
try {
if (addType === "local") {
let data;
let data:ConfigFile;
try {
data = JSON.parse(localJson);
// validate mcpServers structure
if (!data.mcpServers || typeof data.mcpServers !== "object") {
throw new Error("Invalid mcpServers");
}
// check for name conflicts with existing items
const serverNames = Object.keys(data.mcpServers);
const conflict = serverNames.find((name) =>
items.some((d) => d.mcp_name === name)
);
if (conflict) {
toast.error(`MCP server "${conflict}" already exists`, {
closeButton: true,
});
setInstalling(false);
return;
}
} catch (e) {
toast.error("Invalid JSON", { closeButton: true });
setInstalling(false);
@ -252,19 +286,14 @@ export default function SettingMCP() {
}
if (window.ipcRenderer) {
const mcpServers = data["mcpServers"];
Object.entries(mcpServers).forEach(async ([key, value]) => {
for (const [key, value] of Object.entries(mcpServers)) {
await window.ipcRenderer.invoke("mcp-install", key, value);
});
}
}
}
setShowAdd(false);
setLocalJson(`{
"mcp_id": 0,
"mcp_name": "",
"mcp_desc": "",
"command": "",
"args": "",
"env": {}
"mcpServers": {}
}`);
setRemoteName("");
setRemoteUrl("");
@ -335,13 +364,13 @@ export default function SettingMCP() {
{!isLoading && !error && items.length === 0 && (
<div className="text-center py-8 text-gray-400">No MCP servers</div>
)}
<MCPList
{!isLoading && <MCPList
items={items}
onSetting={setShowConfig}
onDelete={setDeleteTarget}
onSwitch={handleSwitch}
switchLoading={switchLoading}
/>
/>}
<MCPConfigDialog
open={!!showConfig}
form={configForm}

View file

@ -682,9 +682,6 @@ export default function SettingModels() {
<SelectItem value="gpt-5">GPT-5</SelectItem>
<SelectItem value="gpt-5-mini">GPT-5 mini</SelectItem>
<SelectItem value="gpt-5-nano">GPT-5 nano</SelectItem>
<SelectItem value="claude-opus-4-1-20250805">
Claude Opus 4.1
</SelectItem>
<SelectItem value="claude-sonnet-4-20250514">
Claude Sonnet 4
</SelectItem>

View file

@ -34,7 +34,7 @@ export default function MCPConfigDialog({ open, form, mcp, onChange, onSave, onC
<form onSubmit={onSave} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input autoComplete="off" className="w-full border rounded px-3 py-2 text-sm" value={form.mcp_name} onChange={e => onChange({ ...form, mcp_name: e.target.value })} disabled={loading} />
<input autoComplete="off" className="w-full border rounded px-3 py-2 text-sm" value={form.mcp_name} onChange={e => onChange({ ...form, mcp_name: e.target.value })} disabled readOnly />
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
@ -45,7 +45,7 @@ export default function MCPConfigDialog({ open, form, mcp, onChange, onSave, onC
<input autoComplete="off" className="w-full border rounded px-3 py-2 text-sm" value={form.command} onChange={e => onChange({ ...form, command: e.target.value })} disabled={loading} />
</div>
<div>
<label className="block text-sm font-medium mb-1">Args (one per line)</label>
<label className="block text-sm font-medium mb-1">Args (one per line, no quotes or commas)</label>
<textarea
autoComplete="off"
className="w-full border rounded px-3 py-2 text-sm"

View file

@ -12,9 +12,9 @@ interface MCPListProps {
export default function MCPList({ items, onSetting, onDelete, onSwitch, switchLoading }: MCPListProps) {
return (
<div className='pt-4'>
{items.map(item => (
{items.map((item) => (
<MCPListItem
key={item.mcp_id}
key={item.id}
item={item}
onSetting={onSetting}
onDelete={onDelete}

View file

@ -1,11 +1,37 @@
export function parseArgsToArray(args: string): string[] {
try {
// Try parsing as JSON array first
const arr = JSON.parse(args);
if (Array.isArray(arr)) return arr.map(String);
} catch { }
// Handle malformed JSON by manually trimming { } and trying again
if (args.trim().startsWith('{') && args.trim().endsWith('}')) {
const trimmed = args.trim().slice(1, -1); // Remove { }
try {
// Try parsing the trimmed version as JSON array
const arr = JSON.parse(`[${trimmed}]`);
if (Array.isArray(arr)) return arr.map(String);
} catch { }
// If still fails, treat as comma-separated
if (trimmed.trim()) {
return trimmed.split(',').map(arg => arg.trim()).filter(arg => arg !== '');
}
}
// If not JSON, treat as comma-separated string
if (args.trim()) {
return args.split(',').map(arg => arg.trim()).filter(arg => arg !== '');
}
return [];
}
export function arrayToArgsJson(arr: string[]): string {
return JSON.stringify(arr.filter(v => v.trim() !== ''));
const filtered = arr.filter(v => v.trim() !== '');
if (filtered.length === 0) return '';
// Return as JSON stringified array
return JSON.stringify(filtered);
}

View file

@ -1658,7 +1658,17 @@ const chatStore = create<ChatStore>()(
clearTasks: () => {
const { create } = get()
console.log('clearTasks')
fetchDelete('/task/stop-all')
window.ipcRenderer.invoke('restart-backend')
.then((res) => {
console.log('restart-backend', res)
})
.catch((error) => {
console.error('Error in clearTasks cleanup:', error)
})
// Immediately create new task to maintain UI responsiveness
const newTaskId = create()
set((state) => ({
...state,

View file

@ -0,0 +1,201 @@
import { describe, it, expect } from 'vitest';
import { parseArgsToArray, arrayToArgsJson } from '../../../../src/pages/Setting/components/utils';
describe('parseArgsToArray', () => {
it('should parse JSON array string to array', () => {
const input = '["arg1", "arg2", "arg3"]';
const expected = ['arg1', 'arg2', 'arg3'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse JSON array string with special characters', () => {
const input = '["-y", "@modelcontextprotocol/server-sequential-thinking"]';
const expected = ['-y', '@modelcontextprotocol/server-sequential-thinking'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse JSON array string with file paths containing backslashes', () => {
const input = '["--directory", "C:\\\\Users\\\\ASUS\\\\Desktop\\\\project", "run", "main.py"]';
const expected = ['--directory', 'C:\\Users\\ASUS\\Desktop\\project', 'run', 'main.py'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse JSON array string with file paths containing forward slashes', () => {
const input = '["--directory", "C:/Users/ASUS/Desktop/project", "run", "main.py"]';
const expected = ['--directory', 'C:/Users/ASUS/Desktop/project', 'run', 'main.py'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse comma-separated string to array', () => {
const input = '-y,@modelcontextprotocol/server-filesystem,.';
const expected = ['-y', '@modelcontextprotocol/server-filesystem', '.'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse comma-separated string with spaces', () => {
const input = '-y, @modelcontextprotocol/server-filesystem, .';
const expected = ['-y', '@modelcontextprotocol/server-filesystem', '.'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should parse comma-separated string with file paths containing slashes', () => {
const input = '--directory,C:/Users/ASUS/Desktop/project,run,main.py';
const expected = ['--directory', 'C:/Users/ASUS/Desktop/project', 'run', 'main.py'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle empty string', () => {
const input = '';
const expected: string[] = [];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle whitespace-only string', () => {
const input = ' ';
const expected: string[] = [];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should filter out empty args from comma-separated string', () => {
const input = '-y,,@modelcontextprotocol/server-filesystem,.';
const expected = ['-y', '@modelcontextprotocol/server-filesystem', '.'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle invalid JSON gracefully by treating as comma-separated', () => {
const input = '[invalid json';
const expected: string[] = ['[invalid json'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should handle non-array JSON by treating as comma-separated', () => {
const input = '{"key": "value"}';
//Trim the curly braces
const expected: string[] = ['"key": "value"'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
it('should convert array elements to strings', () => {
const input = '[123, true, "string", null]';
const expected = ['123', 'true', 'string', 'null'];
const result = parseArgsToArray(input);
expect(result).toEqual(expected);
});
});
describe('arrayToArgsJson', () => {
it('should convert array to JSON string', () => {
const input = ['arg1', 'arg2', 'arg3'];
const expected = '["arg1","arg2","arg3"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should convert array with special characters to JSON string', () => {
const input = ['-y', '@modelcontextprotocol/server-sequential-thinking'];
const expected = '["-y","@modelcontextprotocol/server-sequential-thinking"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should convert array with file paths containing backslashes', () => {
const input = ['--directory', 'C:\\Users\\ASUS\\Desktop\\project', 'run', 'main.py'];
const expected = '["--directory","C:\\\\Users\\\\ASUS\\\\Desktop\\\\project","run","main.py"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should convert array with file paths containing forward slashes', () => {
const input = ['--directory', 'C:/Users/ASUS/Desktop/project', 'run', 'main.py'];
const expected = '["--directory","C:/Users/ASUS/Desktop/project","run","main.py"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should handle empty array', () => {
const input: string[] = [];
const expected = '';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should filter out empty strings and whitespace-only strings', () => {
const input = ['arg1', '', ' ', 'arg2'];
const expected = '["arg1","arg2"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should return empty string for array with only empty/whitespace strings', () => {
const input = ['', ' ', '\t', '\n'];
const expected = '';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
it('should preserve strings with meaningful whitespace', () => {
const input = ['arg with spaces', 'another arg'];
const expected = '["arg with spaces","another arg"]';
const result = arrayToArgsJson(input);
expect(result).toBe(expected);
});
});
describe('bidirectional conversion', () => {
it('should correctly convert from comma-separated string to JSON and back', () => {
const original = '-y,@modelcontextprotocol/server-filesystem,.';
const array = parseArgsToArray(original);
const jsonString = arrayToArgsJson(array);
const finalArray = parseArgsToArray(jsonString);
expect(array).toEqual(['-y', '@modelcontextprotocol/server-filesystem', '.']);
expect(jsonString).toBe('["-y","@modelcontextprotocol/server-filesystem","."]');
expect(finalArray).toEqual(array);
});
it('should correctly convert from JSON string to array and back', () => {
const original = '["-y","@modelcontextprotocol/server-sequential-thinking"]';
const array = parseArgsToArray(original);
const jsonString = arrayToArgsJson(array);
expect(array).toEqual(['-y', '@modelcontextprotocol/server-sequential-thinking']);
expect(jsonString).toBe(original);
});
it('should handle file paths with various slash types bidirectionally', () => {
const windowsPath = '["--directory","C:\\\\Users\\\\ASUS\\\\Desktop\\\\project","run"]';
const unixPath = '["--directory","/home/user/project","run"]';
// Test Windows paths
const windowsArray = parseArgsToArray(windowsPath);
const windowsJson = arrayToArgsJson(windowsArray);
expect(parseArgsToArray(windowsJson)).toEqual(windowsArray);
// Test Unix paths
const unixArray = parseArgsToArray(unixPath);
const unixJson = arrayToArgsJson(unixArray);
expect(parseArgsToArray(unixJson)).toEqual(unixArray);
});
it('should handle mixed path separators in comma-separated format', () => {
const mixed = '--directory,C:/Users/ASUS\\Desktop/project,run,main.py';
const array = parseArgsToArray(mixed);
const jsonString = arrayToArgsJson(array);
const finalArray = parseArgsToArray(jsonString);
expect(array).toEqual(['--directory', 'C:/Users/ASUS\\Desktop/project', 'run', 'main.py']);
expect(finalArray).toEqual(array);
});
});