mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
MUL-2708: fix(agent): preserve multi-line Pi prompt on Windows by bypassing the .cmd shim (#3417)
Pi is installed on Windows via npm, which lays down `pi.cmd` → `pi.ps1`
→ `node_modules/@mariozechner/pi-coding-agent/dist/cli.js`. The daemon
spawns Pi with `exec.Command("pi", ...)`; PATHEXT resolves that to
`pi.cmd`, and cmd.exe expands `%*` in the shim by re-tokenising the
original command line, which truncates any argv containing newlines.
buildPiArgs passes the full prompt as the last positional argv, so the
multi-line system+user prompt is silently cut at the first newline
before it reaches the JS entrypoint. The session JSONL then records
only the first line ("You are running as a chat assistant for a Multica
workspace.") and Pi replies as if the user message were missing
(GitHub multica-ai/multica#3306).
Mirror the existing cursor-agent fix: when LookPath resolves Pi to a
.cmd/.bat launcher and a sibling pi.ps1 exists, invoke PowerShell with
`-File <ps1>` directly and forward each arg as a discrete token. This
keeps us on the official launch path while skipping the cmd.exe %*
re-expansion. Falls back to the original launcher when pi.ps1 or
PowerShell can't be located.
The Windows test asserts the rewrite produces the expected argv and
that the multi-line positional prompt survives unchanged.
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -174,12 +174,13 @@ func isPiToolNameByte(b byte) bool {
|
||||
}
|
||||
|
||||
func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
||||
execPath := b.cfg.ExecutablePath
|
||||
if execPath == "" {
|
||||
execPath = "pi"
|
||||
execName := b.cfg.ExecutablePath
|
||||
if execName == "" {
|
||||
execName = "pi"
|
||||
}
|
||||
if _, err := exec.LookPath(execPath); err != nil {
|
||||
return nil, fmt.Errorf("pi executable not found at %q: %w", execPath, err)
|
||||
lookedUp, err := exec.LookPath(execName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pi executable not found at %q: %w", execName, err)
|
||||
}
|
||||
|
||||
timeout := opts.Timeout
|
||||
@@ -205,10 +206,11 @@ func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
args := buildPiArgs(prompt, sessionPath, opts, b.cfg.Logger)
|
||||
argv0, cmdArgs := choosePiInvocation(execName, lookedUp, args, b.cfg.Logger)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
cmd := exec.CommandContext(runCtx, argv0, cmdArgs...)
|
||||
hideAgentWindow(cmd)
|
||||
b.cfg.Logger.Info("agent command", "exec", execPath, "args", args)
|
||||
b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs)
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
|
||||
32
server/pkg/agent/pi_invocation.go
Normal file
32
server/pkg/agent/pi_invocation.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package agent
|
||||
|
||||
import "log/slog"
|
||||
|
||||
// choosePiInvocation selects the actual program (argv[0]) and the full
|
||||
// argv to spawn a Pi run.
|
||||
//
|
||||
// Background:
|
||||
// - On macOS/Linux, the npm-installed `pi` binstub is a shebang script
|
||||
// that execs node directly with the JS entrypoint, so argv passes
|
||||
// through unchanged.
|
||||
// - On Windows, the npm installer ships `pi.cmd` whose body is
|
||||
// "powershell ... -File pi.ps1 %*". CreateProcess for a .cmd file
|
||||
// goes through cmd.exe, and %* in a .cmd batch file is expanded by
|
||||
// re-tokenising the original command line, which mangles arguments
|
||||
// containing newlines or other whitespace — for Pi, that's the
|
||||
// multi-line positional prompt passed by buildPiArgs. Symptom: the
|
||||
// Pi session JSONL records only the first line of the prompt
|
||||
// (#3306). To stay on the official launch path while avoiding that
|
||||
// re-tokenisation, we resolve pi.ps1 next to the .cmd and invoke
|
||||
// PowerShell with `-File <ps1>` directly, letting Go pass each argv
|
||||
// as a separate token.
|
||||
//
|
||||
// The Windows-specific behaviour is implemented in
|
||||
// pi_invocation_windows.go; on other platforms we fall through to a
|
||||
// passthrough.
|
||||
func choosePiInvocation(execName, lookedUp string, args []string, logger *slog.Logger) (string, []string) {
|
||||
if argv0, full, ok := platformPiInvocation(lookedUp, args, logger); ok {
|
||||
return argv0, full
|
||||
}
|
||||
return execName, args
|
||||
}
|
||||
12
server/pkg/agent/pi_invocation_other.go
Normal file
12
server/pkg/agent/pi_invocation_other.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package agent
|
||||
|
||||
import "log/slog"
|
||||
|
||||
// platformPiInvocation is a no-op on non-Windows platforms: Pi's binstub
|
||||
// invokes node directly via shebang and Go's os/exec can pass argv
|
||||
// unchanged.
|
||||
func platformPiInvocation(_ string, _ []string, _ *slog.Logger) (string, []string, bool) {
|
||||
return "", nil, false
|
||||
}
|
||||
36
server/pkg/agent/pi_invocation_test.go
Normal file
36
server/pkg/agent/pi_invocation_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestChoosePiInvocation_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 TestChoosePiInvocation_PassthroughForNonLauncher(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
execName := "pi"
|
||||
lookedUp := filepath.Join(t.TempDir(), "pi") // no .cmd / .bat
|
||||
args := []string{
|
||||
"-p",
|
||||
"--mode", "json",
|
||||
"--session", "/tmp/pi-session.jsonl",
|
||||
"You are running as a chat assistant for a Multica workspace.\n\nUser message:\n我需要创建一个issue\n",
|
||||
}
|
||||
|
||||
gotExec, gotArgs := choosePiInvocation(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)
|
||||
}
|
||||
}
|
||||
60
server/pkg/agent/pi_invocation_windows.go
Normal file
60
server/pkg/agent/pi_invocation_windows.go
Normal file
@@ -0,0 +1,60 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// platformPiInvocation rewrites the pi invocation on Windows when the
|
||||
// resolved executable is the npm-installed pi.cmd launcher (or a .bat
|
||||
// alias) that delegates to pi.ps1.
|
||||
//
|
||||
// We replace
|
||||
//
|
||||
// pi.cmd <args...>
|
||||
//
|
||||
// with
|
||||
//
|
||||
// powershell.exe -NoProfile -ExecutionPolicy Bypass -File pi.ps1 <args...>
|
||||
//
|
||||
// which is what the .cmd does internally, but lets Go pass each arg as
|
||||
// a discrete token instead of routing through cmd.exe's %* re-expansion
|
||||
// (which mangles the multi-line positional prompt the daemon builds in
|
||||
// buildPiArgs — see #3306).
|
||||
//
|
||||
// powerShellLookup is shared with the cursor backend: both npm shims
|
||||
// have the same launcher shape and need the same PowerShell host on the
|
||||
// same Windows installation.
|
||||
func platformPiInvocation(lookedUp string, args []string, logger *slog.Logger) (string, []string, bool) {
|
||||
ext := strings.ToLower(filepath.Ext(lookedUp))
|
||||
if ext != ".cmd" && ext != ".bat" {
|
||||
return "", nil, false
|
||||
}
|
||||
dir := filepath.Dir(lookedUp)
|
||||
ps1 := filepath.Join(dir, "pi.ps1")
|
||||
if st, err := os.Stat(ps1); err != nil || st.IsDir() {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
psExe, ok := powerShellLookup()
|
||||
if !ok {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
full := make([]string, 0, 5+len(args))
|
||||
full = append(full, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1)
|
||||
full = append(full, args...)
|
||||
|
||||
if logger != nil {
|
||||
logger.Info("pi: routing through powershell -File to preserve argv tokens",
|
||||
"powershell", psExe,
|
||||
"ps1", ps1,
|
||||
"original", lookedUp,
|
||||
)
|
||||
}
|
||||
return psExe, full, true
|
||||
}
|
||||
118
server/pkg/agent/pi_invocation_windows_test.go
Normal file
118
server/pkg/agent/pi_invocation_windows_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPlatformPiInvocation_RewritesCmdLauncherToPowerShellFile is the core
|
||||
// Windows test: when LookPath resolves pi to the npm-installed .cmd
|
||||
// launcher and a sibling pi.ps1 exists, we should invoke PowerShell with
|
||||
// -File <ps1> and forward every original arg unchanged — including the
|
||||
// multi-line positional prompt that would otherwise be mangled by the
|
||||
// cmd.exe %* re-expansion inside pi.cmd. This is the regression test for
|
||||
// #3306: daemon argv carried the full prompt, but Pi's session JSONL only
|
||||
// recorded the first line.
|
||||
func TestPlatformPiInvocation_RewritesCmdLauncherToPowerShellFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdPath := filepath.Join(dir, "pi.cmd")
|
||||
ps1Path := filepath.Join(dir, "pi.ps1")
|
||||
writeFile(t, cmdPath, "@echo off\r\npowershell -NoProfile -ExecutionPolicy Bypass -File \"%~dp0pi.ps1\" %*\r\n")
|
||||
writeFile(t, ps1Path, "# fake pi.ps1\r\n")
|
||||
|
||||
fakePS := filepath.Join(dir, "powershell.exe")
|
||||
writeFile(t, fakePS, "")
|
||||
stubPowerShell(t, fakePS, true)
|
||||
|
||||
multiLinePrompt := "You are running as a chat assistant for a Multica workspace.\n\nUser message:\n我需要创建一个issue\n"
|
||||
args := []string{
|
||||
"-p",
|
||||
"--mode", "json",
|
||||
"--session", `C:\Users\X\.multica\pi-sessions\20260528T040000.jsonl`,
|
||||
multiLinePrompt,
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
gotExec, gotArgs, ok := platformPiInvocation(cmdPath, args, logger)
|
||||
if !ok {
|
||||
t.Fatalf("expected platform rewrite to be applied, got ok=false")
|
||||
}
|
||||
if gotExec != fakePS {
|
||||
t.Errorf("argv0: got %q want %q", gotExec, fakePS)
|
||||
}
|
||||
|
||||
wantArgs := append([]string{
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", ps1Path,
|
||||
}, args...)
|
||||
if !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Errorf("argv mismatch:\n got %#v\n want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
|
||||
// Explicit check: the last argv (the positional prompt) must still
|
||||
// contain every line of the original multi-line prompt. This is the
|
||||
// concrete property #3306 violates when cmd.exe re-tokenises %*.
|
||||
if gotArgs[len(gotArgs)-1] != multiLinePrompt {
|
||||
t.Errorf("multi-line prompt was mangled:\n got %q\n want %q", gotArgs[len(gotArgs)-1], multiLinePrompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformPiInvocation_SkipsWhenNotCmdOrBat ensures we leave argv alone
|
||||
// when the user explicitly resolved pi to something that isn't a batch
|
||||
// launcher (e.g. a real binary or a node script via shebang shim).
|
||||
func TestPlatformPiInvocation_SkipsWhenNotCmdOrBat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exePath := filepath.Join(dir, "pi.exe")
|
||||
writeFile(t, exePath, "")
|
||||
// A sibling .ps1 must not trick us into rewriting a non-launcher exec.
|
||||
writeFile(t, filepath.Join(dir, "pi.ps1"), "")
|
||||
|
||||
stubPowerShell(t, filepath.Join(dir, "powershell.exe"), true)
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, _, ok := platformPiInvocation(exePath, []string{"-p", "hello"}, logger); ok {
|
||||
t.Fatalf("expected ok=false for non-.cmd/.bat launcher")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformPiInvocation_SkipsWhenPS1Missing covers the rare case where a
|
||||
// .cmd was found but its companion .ps1 is missing (e.g. a partial install
|
||||
// or a third-party shim that wraps Pi differently). We must fall back to
|
||||
// the original launcher rather than synthesising an invalid powershell
|
||||
// -File invocation against a non-existent script.
|
||||
func TestPlatformPiInvocation_SkipsWhenPS1Missing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdPath := filepath.Join(dir, "pi.cmd")
|
||||
writeFile(t, cmdPath, "@echo off\r\n")
|
||||
|
||||
stubPowerShell(t, filepath.Join(dir, "powershell.exe"), true)
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, _, ok := platformPiInvocation(cmdPath, []string{"-p", "hello"}, logger); ok {
|
||||
t.Fatalf("expected ok=false when pi.ps1 is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformPiInvocation_SkipsWhenPowerShellMissing covers a stripped
|
||||
// down environment in which neither pwsh.exe nor powershell.exe can be
|
||||
// resolved. We must not fabricate an empty-string argv[0].
|
||||
func TestPlatformPiInvocation_SkipsWhenPowerShellMissing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdPath := filepath.Join(dir, "pi.cmd")
|
||||
ps1Path := filepath.Join(dir, "pi.ps1")
|
||||
writeFile(t, cmdPath, "@echo off\r\n")
|
||||
writeFile(t, ps1Path, "# fake\r\n")
|
||||
|
||||
stubPowerShell(t, "", false)
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, _, ok := platformPiInvocation(cmdPath, []string{"-p", "hello"}, logger); ok {
|
||||
t.Fatalf("expected ok=false when no powershell host is available")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user