mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 20:29:10 +02:00
Three user reports converge on the same Windows-shell encoding bug: - #2198 / #2236 — Chinese, Codex on Win11. Comments / descriptions generated by the agent arrive as `?`. - #2376 — Cyrillic, non-Codex agent ("Ops Lead") on Win11 Desktop. Title preserved (argv → CreateProcessW UTF-16), description / agent reply garbled (stdin → shell-codepage re-encoding). woodcoal's independent diagnosis on #2198 confirms the root cause: Windows PowerShell 5.1's `$OutputEncoding` defaults to ASCIIEncoding when piping to a native command, so non-ASCII bytes are silently replaced with `?` before they reach `multica.exe`. The CLI's stdin parsing is fine; the bytes are corrupted upstream, in the agent's shell layer. This PR ships the fix that supersedes the codex-only attempt in PR #2265 (which is closed in favour of this one): ## CLI Add `--content-file <path>` to `multica issue comment add` and `--description-file <path>` to `multica issue {create,update}`. The CLI reads bytes off disk via `os.ReadFile` and skips the shell entirely; UTF-8 survives end-to-end regardless of `$OutputEncoding` or `chcp`. The three input modes (`--content`, `--content-stdin`, `--content-file`) are mutually exclusive. ## Runtime config `buildMetaSkillContent`'s Available Commands section is rewritten as a neutral three-mode menu. The previous unconditional "MUST pipe via stdin" / `--description-stdin` mandate (over-spread from #1795 / #1851's Codex-multi-line fix) is gone for non-Codex providers; the strong directive now lives only in the Codex-Specific section, which branches on host: - Codex / Linux+macOS: `--content-stdin` + HEREDOC (preserves MUL-1467 fix against codex's literal `\n` habit). - Codex / Windows: `--content-file` (PowerShell ASCII pipe is the exact bug we're patching). ## Per-turn reply template `BuildCommentReplyInstructions` now takes a provider arg and branches provider × OS: - Windows + any provider → `--content-file` (the bug is shell-layer, not provider-layer; #2376 shows non-Codex agents on Windows also hit it). All providers write a UTF-8 file with their file-write tool and post via `--content-file ./reply.md`. - Linux/macOS + Codex → stdin/HEREDOC (MUL-1467 protection). - Linux/macOS + non-Codex → lightweight pre-#1795 inline `--content "..."`. The CLI server-side decodes `\n`, so escaped multi-line works; the agent retains stdin / file as escape hatches for richer formatting. `BuildPrompt` and `buildCommentPrompt` gain a `provider` arg; `daemon.runTask` already has it in scope. ## Tests - `TestResolveTextFlag` — file-source verbatim with non-ASCII (`标题 / Заголовок / 中文段落`), missing-file error, empty-file rejection, three-way mutual exclusion. - `TestInjectRuntimeConfigAvailableCommandsIsNeutral` — every non-Codex provider × {linux, darwin, windows} pins the three-mode menu present + over-spread "MUST stdin" substrings absent. - `TestInjectRuntimeConfigCodexLinuxEmphasizesStdin` + `TestInjectRuntimeConfigCodexWindowsUsesContentFile` — Codex section's per-OS branch. - `TestBuildCommentReplyInstructionsCodexLinux` + `TestBuildCommentReplyInstructionsNonCodexLinux` + `TestBuildCommentReplyInstructionsWindowsUsesContentFile` — the reply-template provider × OS matrix. - `TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin` — end-to-end AGENTS.md / CLAUDE.md on Windows has no prescriptive stdin directive, for claude / codex / opencode. `go test ./...` and `go vet ./...` clean. Closes #2198, #2236, #2376. Co-authored-by: multica-agent <github@multica.ai>
253 lines
8.8 KiB
Go
253 lines
8.8 KiB
Go
package execenv
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestBuildCommentReplyInstructionsCodexLinux pins that the strong
|
|
// "MUST use --content-stdin + HEREDOC" mandate stays alive for Codex on
|
|
// non-Windows hosts. Codex's habit of emitting literal `\n` inside
|
|
// `--content "..."` is the original reason this mandate exists
|
|
// (#1795 / #1851); on Linux/macOS stdin is the right answer.
|
|
//
|
|
// Not parallel: mutates the package-level runtimeGOOS.
|
|
func TestBuildCommentReplyInstructionsCodexLinux(t *testing.T) {
|
|
saved := runtimeGOOS
|
|
t.Cleanup(func() { runtimeGOOS = saved })
|
|
runtimeGOOS = "linux"
|
|
|
|
issueID := "11111111-1111-1111-1111-111111111111"
|
|
triggerID := "22222222-2222-2222-2222-222222222222"
|
|
|
|
got := BuildCommentReplyInstructions("codex", issueID, triggerID)
|
|
|
|
for _, want := range []string{
|
|
"multica issue comment add " + issueID + " --parent " + triggerID + " --content-stdin",
|
|
"Always use `--content-stdin`",
|
|
"even when the reply is a single line",
|
|
"<<'COMMENT'",
|
|
"Do NOT write literal `\\n` escapes to simulate line breaks",
|
|
"do NOT reuse --parent values from previous turns",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("codex/linux reply instructions missing %q\n---\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
if strings.Contains(got, "--content \"...\"") {
|
|
t.Fatalf("codex reply instructions should not offer inline --content form\n---\n%s", got)
|
|
}
|
|
}
|
|
|
|
// TestBuildCommentReplyInstructionsNonCodexLinux pins that every non-Codex
|
|
// provider on Linux/macOS gets the lightweight pre-#1795 inline template.
|
|
// The "MUST stdin" mandate was originally a Codex-specific fix that
|
|
// #1795 / #1851 accidentally spread to every provider, breaking Windows
|
|
// non-ASCII for all of them (#2198 / #2236 / #2376). Non-Codex providers
|
|
// handle inline escaping correctly and the CLI server-decodes `\n` etc.,
|
|
// so the inline template works on every non-Windows platform.
|
|
//
|
|
// Not parallel: mutates the package-level runtimeGOOS.
|
|
func TestBuildCommentReplyInstructionsNonCodexLinux(t *testing.T) {
|
|
saved := runtimeGOOS
|
|
t.Cleanup(func() { runtimeGOOS = saved })
|
|
|
|
issueID := "11111111-1111-1111-1111-111111111111"
|
|
triggerID := "22222222-2222-2222-2222-222222222222"
|
|
|
|
for _, host := range []string{"linux", "darwin"} {
|
|
for _, provider := range []string{"claude", "opencode", "openclaw", "hermes", "kimi", "kiro", "cursor", "gemini"} {
|
|
name := provider + "/" + host
|
|
t.Run(name, func(t *testing.T) {
|
|
runtimeGOOS = host
|
|
got := BuildCommentReplyInstructions(provider, issueID, triggerID)
|
|
|
|
for _, want := range []string{
|
|
"multica issue comment add " + issueID + " --parent " + triggerID + " --content \"...\"",
|
|
"do NOT reuse --parent values from previous turns",
|
|
"If you decide to reply",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("%s reply instructions missing %q\n---\n%s", name, want, got)
|
|
}
|
|
}
|
|
|
|
// Non-Codex / non-Windows providers must NOT receive the
|
|
// Codex-specific "MUST stdin" mandate or its HEREDOC
|
|
// template — that was the over-spread of #1795 / #1851.
|
|
for _, banned := range []string{
|
|
"Always use `--content-stdin`",
|
|
"<<'COMMENT'",
|
|
"--parent " + triggerID + " --content-stdin",
|
|
"--parent " + triggerID + " --content-file",
|
|
} {
|
|
if strings.Contains(got, banned) {
|
|
t.Errorf("%s reply instructions still steers at codex template: %q\n---\n%s", name, banned, got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildCommentReplyInstructionsWindowsUsesContentFile pins that on
|
|
// Windows every provider — Codex AND non-Codex — gets the
|
|
// `--content-file` template. The bug is shell-layer, not provider-layer:
|
|
// any agent on Windows piping HEREDOC through PowerShell loses non-ASCII
|
|
// bytes (PS 5.1's `$OutputEncoding` defaults to ASCIIEncoding). Issues
|
|
// #2198 (Chinese, Codex), #2236 (Chinese, Codex), #2376 (Cyrillic,
|
|
// non-Codex agent name) all match this signature.
|
|
//
|
|
// Not parallel: mutates the package-level runtimeGOOS.
|
|
func TestBuildCommentReplyInstructionsWindowsUsesContentFile(t *testing.T) {
|
|
saved := runtimeGOOS
|
|
t.Cleanup(func() { runtimeGOOS = saved })
|
|
runtimeGOOS = "windows"
|
|
|
|
issueID := "11111111-1111-1111-1111-111111111111"
|
|
triggerID := "22222222-2222-2222-2222-222222222222"
|
|
|
|
for _, provider := range []string{"codex", "claude", "opencode", "openclaw", "hermes", "kimi", "kiro", "cursor", "gemini"} {
|
|
t.Run(provider+"/windows", func(t *testing.T) {
|
|
got := BuildCommentReplyInstructions(provider, issueID, triggerID)
|
|
for _, want := range []string{
|
|
"multica issue comment add " + issueID + " --parent " + triggerID + " --content-file",
|
|
"On Windows, write the reply body to a UTF-8 file",
|
|
"Do NOT pipe via `--content-stdin`",
|
|
"silently drops non-ASCII",
|
|
"$OutputEncoding",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("%s reply instructions missing %q\n---\n%s", provider, want, got)
|
|
}
|
|
}
|
|
for _, banned := range []string{
|
|
"<<'COMMENT'",
|
|
"--parent " + triggerID + " --content-stdin",
|
|
"cat <<",
|
|
} {
|
|
if strings.Contains(got, banned) {
|
|
t.Errorf("%s/windows reply instructions should not contain %q\n---\n%s", provider, banned, got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildCommentReplyInstructionsEmptyWhenNoTrigger(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, provider := range []string{"codex", "claude", "opencode"} {
|
|
if got := BuildCommentReplyInstructions(provider, "issue-id", ""); got != "" {
|
|
t.Fatalf("expected empty string when triggerCommentID is empty for %s, got %q", provider, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pins runtimeGOOS to "linux" so the helper output is deterministic.
|
|
// Provider is "claude" — exercises the non-codex inline path through
|
|
// InjectRuntimeConfig end-to-end. Not parallel: mutates runtimeGOOS.
|
|
func TestInjectRuntimeConfigCommentTriggerUsesHelper(t *testing.T) {
|
|
saved := runtimeGOOS
|
|
t.Cleanup(func() { runtimeGOOS = saved })
|
|
runtimeGOOS = "linux"
|
|
|
|
dir := t.TempDir()
|
|
|
|
issueID := "11111111-1111-1111-1111-111111111111"
|
|
triggerID := "22222222-2222-2222-2222-222222222222"
|
|
|
|
ctx := TaskContextForEnv{
|
|
IssueID: issueID,
|
|
TriggerCommentID: triggerID,
|
|
}
|
|
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
|
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
|
}
|
|
|
|
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
|
if err != nil {
|
|
t.Fatalf("read CLAUDE.md: %v", err)
|
|
}
|
|
|
|
s := string(content)
|
|
for _, want := range []string{
|
|
triggerID,
|
|
"multica issue comment add " + issueID + " --parent " + triggerID,
|
|
"do NOT reuse --parent values from previous turns",
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("CLAUDE.md missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin asserts the
|
|
// end-to-end CLAUDE.md / AGENTS.md surface for a comment-triggered task on
|
|
// a Windows daemon — across Codex and non-Codex providers — has no
|
|
// prescriptive `--content-stdin` directive that could steer the agent at
|
|
// the broken Windows pipe path.
|
|
//
|
|
// Not parallel: mutates the package-level runtimeGOOS.
|
|
func TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin(t *testing.T) {
|
|
saved := runtimeGOOS
|
|
t.Cleanup(func() { runtimeGOOS = saved })
|
|
runtimeGOOS = "windows"
|
|
|
|
issueID := "11111111-1111-1111-1111-111111111111"
|
|
triggerID := "22222222-2222-2222-2222-222222222222"
|
|
ctx := TaskContextForEnv{
|
|
IssueID: issueID,
|
|
TriggerCommentID: triggerID,
|
|
}
|
|
|
|
for _, provider := range []string{"claude", "codex", "opencode"} {
|
|
t.Run(provider, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if _, err := InjectRuntimeConfig(dir, provider, ctx); err != nil {
|
|
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
|
}
|
|
fileName := "CLAUDE.md"
|
|
if provider != "claude" {
|
|
fileName = "AGENTS.md"
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, fileName))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", fileName, err)
|
|
}
|
|
s := string(data)
|
|
|
|
for _, want := range []string{
|
|
"multica issue comment add " + issueID + " --parent " + triggerID + " --content-file",
|
|
"--content-file",
|
|
"--description-file",
|
|
"On Windows, write the reply body to a UTF-8 file",
|
|
} {
|
|
if !strings.Contains(s, want) {
|
|
t.Errorf("%s missing %q\n---\n%s", fileName, want, s)
|
|
}
|
|
}
|
|
|
|
// Prescriptive stdin directives must NOT appear anywhere in
|
|
// the Windows surface. Pin sentence-level substrings (not
|
|
// bare flag names) so anti-prescriptive prose like "do NOT
|
|
// pipe via `--content-stdin`" doesn't trip the ban.
|
|
for _, banned := range []string{
|
|
"--parent " + triggerID + " --content-stdin",
|
|
"always use `--content-stdin` with a HEREDOC, even for short single-line replies",
|
|
"MUST pipe via stdin",
|
|
"use `--description-stdin` and pipe a HEREDOC",
|
|
"<<'COMMENT'",
|
|
"Agent-authored comments should always pipe content via stdin",
|
|
} {
|
|
if strings.Contains(s, banned) {
|
|
t.Errorf("%s still steers agent at stdin: %q\n---\n%s", fileName, banned, s)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|