mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-06 10:50:54 +02:00
On Windows the daemon passes the full multi-line prompt as `-p <prompt>` but spawns npm's `copilot.cmd`, which we already rewrite to `powershell -File copilot.ps1`. Neither launcher can carry that argument: - `copilot.cmd` forwards with `%*`, which cmd.exe expands by re-tokenising the raw command line. - `copilot.ps1` ends in `& node.exe npm-loader.js $args`, and PowerShell re-serialises `$args` onto node's command line. Under Windows PowerShell 5.1 (and pwsh <= 7.2, which default to Legacy native argument passing) embedded double quotes are not re-escaped, so the prompt is re-tokenised. Copilot then sees several argv tokens where one was intended and refuses the run with "It looks like your prompt was not quoted, so the extra words were treated as separate arguments" — the same defect class already fixed for cursor-agent in #5649, except Copilot has no stdin prompt channel to escape through, so the prompt must stay on the command line and the launchers have to go. Copilot CLI ships a native per-platform binary and `npm-loader.js` does nothing but `spawnSync` it with argv untouched, so resolve `copilot-win32-{x64,arm64}\copilot.exe` out of the npm layout and spawn it directly. That leaves exactly one hop, Go -> native binary, and Go's syscall.EscapeArg is the exact inverse of the CRT parsing that binary uses. This mirrors resolveOpenCodeNativeFromShim / resolveDevecoNativeFromShim. Both the nested (current npm) and hoisted (older npm) platform-package locations are probed; when neither resolves, we keep falling back to the PowerShell launcher, which is still better than cmd.exe. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
155 lines
5.8 KiB
Go
155 lines
5.8 KiB
Go
package agent
|
|
|
|
import (
|
|
"io"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
// TestChooseCopilotInvocation_PassthroughForNonLauncher verifies that when
|
|
// the resolved executable is not a Windows .cmd/.bat launcher, both argv[0]
|
|
// and the argv list are returned unchanged on every platform. This guards
|
|
// against accidental rewriting on macOS/Linux and for direct binary launches
|
|
// on Windows.
|
|
func TestChooseCopilotInvocation_PassthroughForNonLauncher(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
|
|
execName := "copilot"
|
|
lookedUp := filepath.Join(t.TempDir(), "copilot") // no .cmd / .bat
|
|
args := []string{
|
|
"-p", "You are running as a local coding agent.\n\nDo something.",
|
|
"--output-format", "json",
|
|
"--allow-all",
|
|
"--no-ask-user",
|
|
}
|
|
|
|
gotExec, gotArgs := chooseCopilotInvocation(execName, lookedUp, args, logger)
|
|
|
|
if gotExec != execName {
|
|
t.Errorf("argv0 changed unexpectedly: got %q want %q", gotExec, execName)
|
|
}
|
|
if !reflect.DeepEqual(gotArgs, args) {
|
|
t.Errorf("argv changed unexpectedly:\n got %#v\n want %#v", gotArgs, args)
|
|
}
|
|
}
|
|
|
|
// ── Windows native-binary resolution tests ──
|
|
//
|
|
// These run on every platform: resolveCopilotNativeFromShim takes statFn as a
|
|
// parameter precisely so the Windows layout can be asserted from macOS/Linux CI.
|
|
|
|
// TestResolveCopilotNativeFromShim_NestedNpmLayout covers the layout npm
|
|
// actually produces today (verified against @github/copilot 1.0.77): the
|
|
// platform package stays nested under the parent package rather than being
|
|
// hoisted.
|
|
func TestResolveCopilotNativeFromShim_NestedNpmLayout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prefix := filepath.Join("C:\\Users", "dev", "AppData", "Roaming", "npm")
|
|
shim := filepath.Join(prefix, "copilot.cmd")
|
|
native := filepath.Join(prefix, "node_modules", "@github", "copilot", "node_modules", "@github", "copilot-win32-x64", "copilot.exe")
|
|
|
|
if got := resolveCopilotNativeFromShim(shim, fakeStat(native)); got != native {
|
|
t.Errorf("got %q, want %q", got, native)
|
|
}
|
|
}
|
|
|
|
// TestResolveCopilotNativeFromShim_HoistedNpmLayout covers older npm versions
|
|
// and other installers that hoist the optional platform dep to the top-level
|
|
// node_modules.
|
|
func TestResolveCopilotNativeFromShim_HoistedNpmLayout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prefix := filepath.Join("C:\\Users", "dev", "AppData", "Roaming", "npm")
|
|
shim := filepath.Join(prefix, "copilot.cmd")
|
|
native := filepath.Join(prefix, "node_modules", "@github", "copilot-win32-x64", "copilot.exe")
|
|
|
|
if got := resolveCopilotNativeFromShim(shim, fakeStat(native)); got != native {
|
|
t.Errorf("got %q, want %q", got, native)
|
|
}
|
|
}
|
|
|
|
// TestResolveCopilotNativeFromShim_FindsArm64Package covers Windows-on-ARM
|
|
// hosts, where npm installs @github/copilot-win32-arm64 instead. The resolver
|
|
// must find it regardless of which arch this test binary was built for, since
|
|
// the arch list only controls probe order, not membership.
|
|
func TestResolveCopilotNativeFromShim_FindsArm64Package(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prefix := filepath.Join("C:\\Users", "dev", "AppData", "Roaming", "npm")
|
|
shim := filepath.Join(prefix, "copilot.cmd")
|
|
native := filepath.Join(prefix, "node_modules", "@github", "copilot", "node_modules", "@github", "copilot-win32-arm64", "copilot.exe")
|
|
|
|
if got := resolveCopilotNativeFromShim(shim, fakeStat(native)); got != native {
|
|
t.Errorf("got %q, want %q", got, native)
|
|
}
|
|
}
|
|
|
|
// TestResolveCopilotNativeFromShim_ReturnsEmptyWhenNativeMissing covers a
|
|
// partial install or a Copilot build predating the platform packages. The
|
|
// caller must fall back to the PowerShell launcher rather than spawn a path
|
|
// that doesn't exist.
|
|
func TestResolveCopilotNativeFromShim_ReturnsEmptyWhenNativeMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
shim := filepath.Join("C:\\Users", "dev", "AppData", "Roaming", "npm", "copilot.cmd")
|
|
|
|
if got := resolveCopilotNativeFromShim(shim, fakeStat()); got != "" {
|
|
t.Errorf("got %q, want empty (missing native binary)", got)
|
|
}
|
|
}
|
|
|
|
// TestResolveCopilotNativeFromShim_SkipsNonCmdPath keeps macOS/Linux and
|
|
// direct-binary Windows launches on the untouched passthrough path.
|
|
func TestResolveCopilotNativeFromShim_SkipsNonCmdPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, p := range []string{
|
|
"/usr/local/bin/copilot",
|
|
"C:\\Users\\dev\\AppData\\Roaming\\npm\\copilot.exe",
|
|
"C:\\Users\\dev\\AppData\\Roaming\\npm\\copilot.ps1",
|
|
"",
|
|
} {
|
|
if got := resolveCopilotNativeFromShim(p, fakeStat("anything")); got != "" {
|
|
t.Errorf("path %q: got %q, want empty", p, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestResolveCopilotNativeFromShim_AcceptsUppercaseExtension guards the
|
|
// PATHEXT case: Windows filesystems are case-insensitive and exec.LookPath
|
|
// can return either case.
|
|
func TestResolveCopilotNativeFromShim_AcceptsUppercaseExtension(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prefix := filepath.Join("C:\\Users", "dev", "AppData", "Roaming", "npm")
|
|
shim := filepath.Join(prefix, "copilot.CMD")
|
|
native := filepath.Join(prefix, "node_modules", "@github", "copilot", "node_modules", "@github", "copilot-win32-x64", "copilot.exe")
|
|
|
|
if got := resolveCopilotNativeFromShim(shim, fakeStat(native)); got != native {
|
|
t.Errorf("got %q, want %q", got, native)
|
|
}
|
|
}
|
|
|
|
// TestCopilotWindowsPackageCandidates_ArchOrdering pins the probe order: the
|
|
// host's own arch first, but never at the cost of dropping the other one — an
|
|
// x64 Node running under emulation on an ARM64 host installs the x64 package.
|
|
func TestCopilotWindowsPackageCandidates_ArchOrdering(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
goarch string
|
|
want []string
|
|
}{
|
|
{"arm64", []string{"copilot-win32-arm64", "copilot-win32-x64"}},
|
|
{"amd64", []string{"copilot-win32-x64", "copilot-win32-arm64"}},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := copilotWindowsPackageCandidates(tc.goarch); !reflect.DeepEqual(got, tc.want) {
|
|
t.Errorf("goarch %q: got %#v, want %#v", tc.goarch, got, tc.want)
|
|
}
|
|
}
|
|
}
|