Compare commits

...

4 Commits

Author SHA1 Message Date
Jiang Bohan
8ca87bd5ff refactor(execenv): scope MUST-stdin mandate back to Codex-only
Followup to #2247: roll back the over-spread of #1795 / #1851. Both PRs
were intended to fix Codex's habit of emitting literal `\n` inside
`--content "..."` (MUL-1467), but the implementation landed strong
"MUST pipe via stdin" / `--description-stdin` directives in the
all-provider Available Commands section AND in the provider-agnostic
`BuildCommentReplyInstructions` helper. That global mandate then broke
non-ASCII bytes for every provider on Windows shells (#2198 / #2236),
because PowerShell 5.1 / cmd.exe re-encode piped HEREDOC bytes through
the active console codepage and silently drop non-representable bytes
as `?`.

Three concrete changes:

- `runtime_config.go` Available Commands: replace the platform-branched
  "MUST pipe via stdin" block with a neutral three-line description of
  the three input modes (`--content`, `--content-stdin`,
  `--content-file`). Same text on every host, no GOOS branch. The
  `--content-file` line still flags the Windows-shell-codepage caveat
  in passing so an agent that picks stdin and lands on Win11 has a
  pointer to file as the safe fallback, but no provider gets a "MUST"
  here anymore.

- `BuildCommentReplyInstructions`: add a `provider` parameter. Codex
  keeps the platform-aware mandate (Windows → file, non-Windows →
  stdin/HEREDOC) — the original MUL-1467 fix lives where it belongs.
  Every other provider gets the lightweight pre-#1795 inline template
  (`--content "..."` with a pointer to stdin/file for richer
  formatting); the CLI's `util.UnescapeBackslashEscapes` still decodes
  `\n` server-side so multi-line inline works on every platform, and
  argv goes through CreateProcessW UTF-16 on Windows so non-ASCII
  survives.

- Plumbing: thread `provider` through `BuildPrompt` →
  `buildCommentPrompt` → `execenv.BuildCommentReplyInstructions`. Sole
  caller is `daemon.runTask`, where `provider` is already in scope.

Test rework:

- `TestInjectRuntimeConfigDirectsMultiLineWritesToStdin` is replaced by
  `TestInjectRuntimeConfigAvailableCommandsIsNeutral`, which sweeps every
  non-Codex provider × every host OS and pins (a) the three-mode menu is
  present, (b) the over-spread substrings (`MUST pipe via stdin`,
  `Agent-authored comments should always pipe content via stdin`,
  `use --description-stdin and pipe a HEREDOC`) are GONE.
- `TestInjectRuntimeConfigWindowsRecommendsContentFile` becomes
  `TestInjectRuntimeConfigCodexWindowsRecommendsContentFile`, scoped to
  the Codex section. Linux Codex still pins `always use --content-stdin
  with a HEREDOC` so the original MUL-1467 protection isn't dropped.
- `TestBuildCommentReplyInstructionsIncludesTriggerID` becomes
  `TestBuildCommentReplyInstructionsCodexLinux` (codex/linux still gets
  stdin) plus a new
  `TestBuildCommentReplyInstructionsNonCodexUsesInline` that sweeps
  claude / opencode / openclaw / hermes / kimi / kiro / cursor / gemini
  on linux / darwin / windows and pins the inline template, with a ban
  on the codex-specific stdin/file substrings.
- `TestBuildCommentReplyInstructionsCodexWindowsUsesContentFile` and
  `TestInjectRuntimeConfigCodexWindowsCommentTriggerHasNoStdin` keep
  the Windows file-first end-to-end pin for codex.
- `BuildPrompt` callers in daemon_test.go updated for the new
  `provider` arg.

Net effect: Windows non-Codex agents (Claude / Opencode / Hermes /
etc.) on Win11 stop getting steered at the broken stdin path entirely;
Codex on Windows stays on `--content-file`; Codex on Linux/macOS keeps
its MUL-1467 protection; every other provider's CLAUDE.md / AGENTS.md
loses the "MUST stdin" mandate it never needed.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 16:08:45 +08:00
Jiang Bohan
25a331641c fix(execenv): make Windows comment block file-first, pin tests by GOOS
GPT-Boy's second review on PR #2247 flagged two follow-up blockers:

1. The Windows comment/description block in `buildMetaSkillContent` was
   "stdin first, file caveat appended" — agents on Windows still saw
   "Agent-authored comments should always pipe content via stdin" /
   "MUST pipe via stdin" / `--description-stdin` directives before
   reaching the Windows fallback, so the contradicting instruction was
   live in the same prompt. Rewrite the entire Available Commands
   bullet for Windows hosts as file-first: the headline line names
   `--content-file`, the bulleted rules name `--content-file` /
   `--description-file`, and stdin only appears in anti-prescriptive
   "do NOT pipe via …" prose.

2. The existing non-Windows tests (TestBuildCommentReplyInstructions
   IncludesTriggerID, TestInjectRuntimeConfigDirectsMultiLineWritesToStdin,
   TestInjectRuntimeConfigCodexEmphasizesStdinForFormattedComments,
   TestInjectRuntimeConfigCommentTriggerUsesHelper) all depended on
   `runtimeGOOS` defaulting to non-Windows; they would silently fail on
   a Windows test runner. Pin them to `runtimeGOOS = "linux"` via
   save+restore and drop t.Parallel so they don't race with the
   GOOS-mutating Windows tests.

Test additions:

- TestInjectRuntimeConfigWindowsRecommendsContentFile now asserts the
  Windows AGENTS.md does NOT contain prescriptive stdin phrasings
  (`MUST pipe via stdin`, `use --description-stdin and pipe a HEREDOC`,
  `<<'COMMENT'`, `Agent-authored comments should always pipe content via
  stdin`, `always use --content-stdin`) on top of the file-first
  positive assertions. The ban list pins prescriptive substrings, not
  bare flag names, so anti-prescriptive prose like "do NOT pipe via
  --content-stdin" doesn't trip the ban.
- TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin gets the same
  expanded ban list across the Available Commands, Codex paragraph,
  and per-turn reply template surfaces.
- The non-Windows side of TestInjectRuntimeConfigWindowsRecommendsContentFile
  pins that the Linux stdin/HEREDOC contract is still in place, so a
  future refactor can't accidentally move every host to file-first.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 11:27:45 +08:00
Jiang Bohan
83a90d6443 fix(execenv): route every Windows-host stdin directive at --content-file
GPT-Boy on PR #2247 caught that the previous patch only inserted a Windows
fallback into the Available Commands section. Two later prompt surfaces
still hard-coded `--content-stdin` and overrode it for the agent:

- The Codex-specific paragraph in `buildMetaSkillContent`, which always
  said "always use `--content-stdin` with a HEREDOC".
- `BuildCommentReplyInstructions`, which is re-emitted on every turn for
  comment-triggered tasks (both via the AGENTS.md/CLAUDE.md workflow and
  the daemon's per-turn prompt) and mandated the same HEREDOC pipe.

On Windows hosts we now branch both surfaces to a file-based template:
the agent writes the body to a UTF-8 file with its file-write tool and
posts via `--content-file <path>`. Non-Windows hosts keep the existing
stdin/HEREDOC guidance untouched.

Tests:

- `TestBuildCommentReplyInstructionsWindowsUsesContentFile` pins the
  Windows / non-Windows reply-instruction text directly.
- `TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin` asserts that
  the end-to-end CLAUDE.md / AGENTS.md surface for a comment-triggered
  Windows task has no remaining `--content-stdin` directive that could
  override the Windows fallback (covers Claude + Codex providers).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 11:13:02 +08:00
Jiang Bohan
9367b10080 fix(cli): add --content-file / --description-file for non-ASCII on Windows
Windows PowerShell 5.1 (the Win11 default) and cmd.exe re-encode HEREDOC
content through the active console codepage before piping it to a child
process. Characters the codepage cannot represent are silently replaced
with `?`, so agents on Chinese Win11 hosts emitting `--content-stdin` /
`--description-stdin` HEREDOCs land all of their Chinese as `?` in the
issue body and comments. The daemon log shows the original Chinese
correctly because slog writes to a file directly, so the regression
hides until the user opens the issue page.

Add a `--content-file <path>` / `--description-file <path>` source to
`resolveTextFlag`: the CLI reads the file straight off disk, preserves
UTF-8 bytes verbatim, and skips the shell entirely. The runtime config
injected into AGENTS.md / CLAUDE.md now surfaces this as the canonical
Windows fallback when the daemon host runs on Windows; non-Windows hosts
keep the existing stdin/HEREDOC guidance untouched.

Closes #2198, #2236.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-08 11:04:13 +08:00
9 changed files with 559 additions and 87 deletions

View File

@@ -16,20 +16,41 @@ import (
"github.com/multica-ai/multica/server/internal/util"
)
// resolveTextFlag picks between a `--<name>` flag value and a paired
// `--<name>-stdin` flag, mirroring the existing `--content` / `--content-stdin`
// pattern. It returns the resolved string and an error when both are set or
// stdin is requested but produces no body. Inline flag values are passed
// through util.UnescapeBackslashEscapes so bash-double-quoted `\n` becomes a
// real newline; stdin bodies are returned verbatim so literal backslashes
// resolveTextFlag picks between a `--<name>` inline value, a `--<name>-stdin`
// flag, and a `--<name>-file <path>` flag, mirroring the existing `--content`
// / `--content-stdin` pattern. It returns the resolved string and an error
// when more than one source is set, or when stdin/file is requested but
// produces no body. Inline flag values are passed through
// util.UnescapeBackslashEscapes so bash-double-quoted `\n` becomes a real
// newline; stdin and file bodies are returned verbatim so literal backslashes
// survive intact.
//
// The `-file` source exists for Windows agents: piping HEREDOC content to
// `--<name>-stdin` from a non-UTF-8 console (Windows PowerShell 5.1, cmd.exe
// with default codepage) silently drops non-ASCII bytes — Chinese characters
// arrive as `?`. Reading a UTF-8 file directly bypasses the shell's pipe
// re-encoding entirely. See issues #2198, #2236.
func resolveTextFlag(cmd *cobra.Command, flagName string) (string, bool, error) {
stdinFlag := flagName + "-stdin"
fileFlag := flagName + "-file"
useStdin, _ := cmd.Flags().GetBool(stdinFlag)
inline, _ := cmd.Flags().GetString(flagName)
if useStdin && inline != "" {
return "", false, fmt.Errorf("--%s and --%s are mutually exclusive", flagName, stdinFlag)
filePath, _ := cmd.Flags().GetString(fileFlag)
sources := 0
if useStdin {
sources++
}
if inline != "" {
sources++
}
if filePath != "" {
sources++
}
if sources > 1 {
return "", false, fmt.Errorf("--%s, --%s, and --%s are mutually exclusive", flagName, stdinFlag, fileFlag)
}
if useStdin {
data, err := io.ReadAll(os.Stdin)
if err != nil {
@@ -41,6 +62,17 @@ func resolveTextFlag(cmd *cobra.Command, flagName string) (string, bool, error)
}
return body, true, nil
}
if filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", false, fmt.Errorf("read file for --%s: %w", fileFlag, err)
}
body := strings.TrimSuffix(string(data), "\n")
if body == "" {
return "", false, fmt.Errorf("file content for --%s is empty", fileFlag)
}
return body, true, nil
}
if inline == "" {
return "", false, nil
}
@@ -221,6 +253,7 @@ func init() {
issueCreateCmd.Flags().String("title", "", "Issue title (required)")
issueCreateCmd.Flags().String("description", "", "Issue description (decodes \\n, \\r, \\t, \\\\; pipe via --description-stdin to preserve literal backslashes)")
issueCreateCmd.Flags().Bool("description-stdin", false, "Read issue description from stdin (preserves multi-line content verbatim)")
issueCreateCmd.Flags().String("description-file", "", "Read issue description from a UTF-8 file (preserves multi-line content verbatim; use this on Windows when stdin piping mangles non-ASCII bytes)")
issueCreateCmd.Flags().String("status", "", "Issue status")
issueCreateCmd.Flags().String("priority", "", "Issue priority")
issueCreateCmd.Flags().String("assignee", "", "Assignee name (member or agent; fuzzy match)")
@@ -235,6 +268,7 @@ func init() {
issueUpdateCmd.Flags().String("title", "", "New title")
issueUpdateCmd.Flags().String("description", "", "New description (decodes \\n, \\r, \\t, \\\\; pipe via --description-stdin to preserve literal backslashes)")
issueUpdateCmd.Flags().Bool("description-stdin", false, "Read new description from stdin (preserves multi-line content verbatim)")
issueUpdateCmd.Flags().String("description-file", "", "Read new description from a UTF-8 file (preserves multi-line content verbatim; use this on Windows when stdin piping mangles non-ASCII bytes)")
issueUpdateCmd.Flags().String("status", "", "New status")
issueUpdateCmd.Flags().String("priority", "", "New priority")
issueUpdateCmd.Flags().String("assignee", "", "New assignee name (member or agent; fuzzy match)")
@@ -272,6 +306,7 @@ func init() {
// issue comment add
issueCommentAddCmd.Flags().String("content", "", "Comment content (decodes \\n, \\r, \\t, \\\\; pipe via --content-stdin for multi-line bodies or to preserve literal backslashes)")
issueCommentAddCmd.Flags().Bool("content-stdin", false, "Read comment content from stdin (preserves multi-line content verbatim)")
issueCommentAddCmd.Flags().String("content-file", "", "Read comment content from a UTF-8 file (preserves multi-line content verbatim; use this on Windows when stdin piping mangles non-ASCII bytes)")
issueCommentAddCmd.Flags().String("parent", "", "Parent comment ID (reply to a specific comment)")
issueCommentAddCmd.Flags().StringSlice("attachment", nil, "File path(s) to attach (can be specified multiple times)")
issueCommentAddCmd.Flags().String("output", "json", "Output format: table or json")
@@ -582,7 +617,7 @@ func runIssueUpdate(cmd *cobra.Command, args []string) error {
v, _ := cmd.Flags().GetString("title")
body["title"] = v
}
if cmd.Flags().Changed("description") || cmd.Flags().Changed("description-stdin") {
if cmd.Flags().Changed("description") || cmd.Flags().Changed("description-stdin") || cmd.Flags().Changed("description-file") {
desc, _, err := resolveTextFlag(cmd, "description")
if err != nil {
return err
@@ -828,7 +863,7 @@ func runIssueCommentAdd(cmd *cobra.Command, args []string) error {
return err
}
if !hasContent {
return fmt.Errorf("--content or --content-stdin is required")
return fmt.Errorf("--content, --content-stdin, or --content-file is required")
}
client, err := newAPIClient(cmd)

View File

@@ -39,11 +39,12 @@ func pipeStdin(t *testing.T, body string, fn func()) {
}
// newFlagTestCmd builds a throwaway cobra.Command carrying the inline +
// stdin flag pair that resolveTextFlag expects.
// stdin + file flag triplet that resolveTextFlag expects.
func newFlagTestCmd(name string) *cobra.Command {
c := &cobra.Command{Use: "test"}
c.Flags().String(name, "", "")
c.Flags().Bool(name+"-stdin", false, "")
c.Flags().String(name+"-file", "", "")
return c
}
@@ -96,6 +97,85 @@ func TestResolveTextFlag(t *testing.T) {
t.Errorf("expected absent flag to yield (\"\", false), got (%q, %v)", got, ok)
}
})
// --content-file / --description-file exists for Windows agents — piping
// HEREDOC content through Windows PowerShell 5.1 or cmd.exe re-encodes
// non-ASCII bytes through the console codepage, replacing characters the
// codepage cannot represent (e.g. Chinese on a non-UTF-8 console) with
// `?`. Reading the body straight off disk skips the shell entirely.
// See issues #2198, #2236.
t.Run("file body is preserved verbatim with non-ASCII content", func(t *testing.T) {
dir := t.TempDir()
path := dir + string(os.PathSeparator) + "desc.md"
body := "标题\n\n中文段落 with `code` and \"quotes\".\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write tempfile: %v", err)
}
c := newFlagTestCmd("description")
_ = c.Flags().Set("description-file", path)
got, ok, err := resolveTextFlag(c, "description")
if err != nil || !ok {
t.Fatalf("unexpected: ok=%v err=%v", ok, err)
}
want := "标题\n\n中文段落 with `code` and \"quotes\"."
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("file path that doesn't exist surfaces a useful error", func(t *testing.T) {
c := newFlagTestCmd("content")
_ = c.Flags().Set("content-file", "/this/path/does/not/exist.txt")
_, _, err := resolveTextFlag(c, "content")
if err == nil {
t.Fatalf("expected error for missing file")
}
if !strings.Contains(err.Error(), "content-file") {
t.Errorf("error should mention --content-file, got %v", err)
}
})
t.Run("empty file is rejected", func(t *testing.T) {
dir := t.TempDir()
path := dir + string(os.PathSeparator) + "empty.md"
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
t.Fatalf("write tempfile: %v", err)
}
c := newFlagTestCmd("description")
_ = c.Flags().Set("description-file", path)
_, _, err := resolveTextFlag(c, "description")
if err == nil {
t.Fatalf("expected error for empty file")
}
})
t.Run("file plus inline is rejected", func(t *testing.T) {
dir := t.TempDir()
path := dir + string(os.PathSeparator) + "x.md"
if err := os.WriteFile(path, []byte("body"), 0o644); err != nil {
t.Fatalf("write tempfile: %v", err)
}
c := newFlagTestCmd("description")
_ = c.Flags().Set("description", "inline")
_ = c.Flags().Set("description-file", path)
if _, _, err := resolveTextFlag(c, "description"); err == nil {
t.Fatalf("expected mutually-exclusive error for inline + file")
}
})
t.Run("file plus stdin is rejected", func(t *testing.T) {
dir := t.TempDir()
path := dir + string(os.PathSeparator) + "x.md"
if err := os.WriteFile(path, []byte("body"), 0o644); err != nil {
t.Fatalf("write tempfile: %v", err)
}
c := newFlagTestCmd("description")
_ = c.Flags().Set("description-stdin", "true")
_ = c.Flags().Set("description-file", path)
if _, _, err := resolveTextFlag(c, "description"); err == nil {
t.Fatalf("expected mutually-exclusive error for stdin + file")
}
})
}
func TestTruncateID(t *testing.T) {

View File

@@ -1621,7 +1621,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
// the same (agent, issue) pair. The work_dir path is stored in DB on
// task completion and passed back via PriorWorkDir on the next claim.
prompt := BuildPrompt(task)
prompt := BuildPrompt(task, provider)
// Pass the daemon's auth credentials and context so the spawned agent CLI
// can call the Multica API and the local daemon (e.g. `multica repo checkout`).

View File

@@ -101,7 +101,7 @@ func TestBuildPromptContainsIssueID(t *testing.T) {
{Name: "Concise", Content: "Be concise."},
},
},
})
}, "claude")
// Prompt should contain the issue ID and CLI hint.
for _, want := range []string{
@@ -127,7 +127,7 @@ func TestBuildPromptNoIssueDetails(t *testing.T) {
prompt := BuildPrompt(Task{
IssueID: "test-id",
Agent: &AgentData{Name: "Test"},
})
}, "claude")
// Prompt should not contain issue title/description (agent fetches via CLI).
for _, absent := range []string{"**Issue:**", "**Summary:**"} {
@@ -146,7 +146,7 @@ func TestBuildPromptAutopilotRunOnly(t *testing.T) {
AutopilotTitle: "Daily dependency check",
AutopilotDescription: "Check dependencies and report outdated packages.",
AutopilotSource: "manual",
})
}, "claude")
for _, want := range []string{
"run-only mode",
@@ -178,7 +178,7 @@ func TestBuildPromptCommentTriggered(t *testing.T) {
TriggerCommentID: commentID,
TriggerCommentContent: commentContent,
Agent: &AgentData{Name: "Test"},
})
}, "claude")
// Prompt should contain the comment content, the trigger comment id, and
// the full reply command with --parent. Re-emitting --parent on every turn
@@ -223,7 +223,7 @@ func TestBuildPromptCommentTriggeredByAgent(t *testing.T) {
TriggerAuthorType: "agent",
TriggerAuthorName: "Atlas",
Agent: &AgentData{Name: "Test"},
})
}, "claude")
for _, want := range []string{
"Another agent (Atlas)",
@@ -249,7 +249,7 @@ func TestBuildPromptCommentTriggeredByMember(t *testing.T) {
TriggerAuthorType: "member",
TriggerAuthorName: "Alice",
Agent: &AgentData{Name: "Test"},
})
}, "claude")
if !strings.Contains(prompt, "A user just left a new comment") {
t.Fatalf("member-triggered prompt should label the author as a user\n---\n%s", prompt)
@@ -278,7 +278,7 @@ func TestBuildPromptCommentTriggeredNoContent(t *testing.T) {
IssueID: "test-id",
TriggerCommentID: "comment-id",
Agent: &AgentData{Name: "Test"},
})
}, "claude")
if !strings.Contains(prompt, "multica issue get") {
t.Fatal("prompt missing CLI hint")

View File

@@ -982,41 +982,167 @@ func TestInjectRuntimeConfigRequiresExplicitCommentPost(t *testing.T) {
}
}
// TestInjectRuntimeConfigDirectsMultiLineWritesToStdin pins the guidance that
// any multi-line content for `multica issue comment add` must go through
// `--content-stdin` + a HEREDOC. Agents that reached for the inline
// `--content "...\n\n..."` form ended up with literal 4-char `\n` sequences
// in stored comments because bash does not expand backslash escapes inside
// double quotes; see MUL-1467. This test prevents the multi-line guidance
// from silently regressing back into a "for special characters" footnote.
func TestInjectRuntimeConfigDirectsMultiLineWritesToStdin(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{IssueID: "issue-1"}); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
s := string(data)
// TestInjectRuntimeConfigAvailableCommandsIsNeutral pins that the global
// Available Commands section lists the three input modes neutrally for
// every provider, with no "MUST pipe via stdin" mandate.
//
// Background: #1795 / #1851 introduced "MUST pipe via stdin" /
// `--description-stdin` directives in the global section to fix Codex's
// habit of emitting literal `\n` inside `--content "..."` (MUL-1467).
// That mandate landed in the all-provider section and ended up steering
// every provider at stdin — which then broke non-ASCII bytes on Windows
// shells (#2198 / #2236). This rollback keeps the strong Codex-specific
// mandate in the Codex-Specific section (pinned by
// TestInjectRuntimeConfigCodexEmphasizesStdinForFormattedComments) and
// leaves the global section neutral. Pinning the neutrality here so a
// future refactor can't accidentally re-introduce the over-spread.
//
// Not parallel: mutates the package-level runtimeGOOS.
func TestInjectRuntimeConfigAvailableCommandsIsNeutral(t *testing.T) {
saved := runtimeGOOS
t.Cleanup(func() { runtimeGOOS = saved })
for _, want := range []string{
"multi-line content",
"MUST pipe via stdin",
"--content-stdin",
"<<'COMMENT'",
"`--description`",
"--description-stdin",
} {
if !strings.Contains(s, want) {
t.Errorf("CLAUDE.md missing multi-line guidance %q\n---\n%s", want, s)
for _, host := range []string{"linux", "darwin", "windows"} {
for _, provider := range []string{"claude", "opencode", "openclaw", "hermes", "kimi", "kiro", "cursor", "gemini"} {
t.Run(provider+"/"+host, func(t *testing.T) {
runtimeGOOS = host
dir := t.TempDir()
if err := InjectRuntimeConfig(dir, provider, TaskContextForEnv{IssueID: "issue-1"}); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
configFile := "CLAUDE.md"
if provider != "claude" {
configFile = "AGENTS.md"
}
if provider == "gemini" {
configFile = "GEMINI.md"
}
data, err := os.ReadFile(filepath.Join(dir, configFile))
if err != nil {
t.Fatalf("read %s: %v", configFile, err)
}
s := string(data)
// Available Commands lists all three input modes as fact.
for _, want := range []string{
"`--content \"...\"`",
"`--content-stdin`",
"`--content-file <path>`",
"`--description-stdin`",
"`--description-file <path>`",
} {
if !strings.Contains(s, want) {
t.Errorf("%s missing flag mention %q\n---\n%s", configFile, want, s)
}
}
// "MUST pipe via stdin" must NOT appear in any non-Codex
// provider's runtime config: it was the over-spread of
// the Codex-specific fix.
for _, banned := range []string{
"MUST pipe via stdin",
"Agent-authored comments should always pipe content via stdin",
"use `--description-stdin` and pipe a HEREDOC",
} {
if strings.Contains(s, banned) {
t.Errorf("%s carries over-spread Codex mandate %q for non-Codex provider %s\n---\n%s", configFile, banned, provider, s)
}
}
})
}
}
}
// TestInjectRuntimeConfigCodexWindowsRecommendsContentFile pins the
// Windows-specific Codex carve-out: on Windows the Codex-Specific section
// directs the agent at `--content-file` instead of `--content-stdin`,
// because Windows PowerShell 5.1 / cmd.exe re-encode piped HEREDOC bytes
// through the active console codepage and silently drop non-ASCII as `?`
// before reaching `multica.exe` (#2198 / #2236). On non-Windows hosts the
// Codex section keeps the canonical stdin/HEREDOC mandate.
//
// The Available Commands section is provider-neutral and lists all three
// input modes regardless of host — its neutrality is pinned separately by
// TestInjectRuntimeConfigAvailableCommandsIsNeutral.
//
// Not parallel: mutates the package-level runtimeGOOS.
func TestInjectRuntimeConfigCodexWindowsRecommendsContentFile(t *testing.T) {
saved := runtimeGOOS
t.Cleanup(func() { runtimeGOOS = saved })
t.Run("codex/windows points at --content-file", func(t *testing.T) {
runtimeGOOS = "windows"
dir := t.TempDir()
if err := InjectRuntimeConfig(dir, "codex", TaskContextForEnv{IssueID: "issue-1"}); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err != nil {
t.Fatalf("read AGENTS.md: %v", err)
}
s := string(data)
for _, want := range []string{
"On Windows, **always write the comment body to a UTF-8 file",
"console codepage",
"--content-file",
"silently drop non-ASCII characters as `?`",
} {
if !strings.Contains(s, want) {
t.Errorf("AGENTS.md missing Codex/Windows file-first guidance %q\n---\n%s", want, s)
}
}
// On Windows the Codex section must NOT prescribe stdin — that's
// the exact path the Windows console codepage mangles. Pin the
// prescriptive phrasings (sentence-level), not bare flag names,
// so anti-prescriptive prose like "do NOT pipe via
// `--content-stdin`" doesn't trip the ban.
for _, banned := range []string{
"always use `--content-stdin` with a HEREDOC, even for short single-line replies",
} {
if strings.Contains(s, banned) {
t.Errorf("AGENTS.md still carries Codex stdin mandate %q on Windows\n---\n%s", banned, s)
}
}
})
t.Run("codex/linux keeps the stdin-first Codex section", func(t *testing.T) {
runtimeGOOS = "linux"
dir := t.TempDir()
if err := InjectRuntimeConfig(dir, "codex", TaskContextForEnv{IssueID: "issue-1"}); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err != nil {
t.Fatalf("read AGENTS.md: %v", err)
}
s := string(data)
// On Linux the Codex section keeps the canonical stdin/HEREDOC
// mandate — pin it so a future refactor can't accidentally drop
// the protection that originally fixed MUL-1467.
for _, want := range []string{
"always use `--content-stdin` with a HEREDOC",
"Never use inline `--content` for agent-authored comments",
} {
if !strings.Contains(s, want) {
t.Errorf("AGENTS.md missing Codex/Linux stdin mandate %q\n---\n%s", want, s)
}
}
if strings.Contains(s, "On Windows, **always write the comment body to a UTF-8 file") {
t.Errorf("AGENTS.md should not surface Windows codex guidance on linux host\n---\n%s", s)
}
})
}
// Pins runtimeGOOS to "linux": the Windows branch of the Codex paragraph is
// covered by TestInjectRuntimeConfigWindowsCommentTriggerHasNoStdin. Not
// parallel: mutates the package-level runtimeGOOS.
func TestInjectRuntimeConfigCodexEmphasizesStdinForFormattedComments(t *testing.T) {
t.Parallel()
saved := runtimeGOOS
t.Cleanup(func() { runtimeGOOS = saved })
runtimeGOOS = "linux"
dir := t.TempDir()
if err := InjectRuntimeConfig(dir, "codex", TaskContextForEnv{
IssueID: "issue-1",

View File

@@ -11,22 +11,69 @@ import "fmt"
// The explicit "do not reuse --parent from previous turns" wording exists
// because resumed Claude sessions keep prior turns' tool calls in context
// and will otherwise copy the old --parent UUID forward.
func BuildCommentReplyInstructions(issueID, triggerCommentID string) string {
//
// The template is provider-aware. The strong "use stdin / use file" mandate
// originated from #1795 / #1851 to fix Codex's habit of emitting literal
// `\n` escapes inside `--content "..."`. Other providers handle inline
// escaping correctly (the CLI's `util.UnescapeBackslashEscapes` decodes
// `\n` server-side anyway), so they get the original lightweight inline
// template that worked on every platform — including Windows non-ASCII,
// where argv goes through CreateProcessW UTF-16 and bytes survive intact.
//
// Codex on Windows must use `--content-file` because piping a HEREDOC
// through PowerShell 5.1 / cmd.exe re-encodes bytes via the active console
// codepage and drops non-ASCII as `?` before reaching `multica.exe` —
// see issues #2198 / #2236.
func BuildCommentReplyInstructions(provider, issueID, triggerCommentID string) string {
if triggerCommentID == "" {
return ""
}
if provider == "codex" {
if runtimeGOOS == "windows" {
return fmt.Sprintf(
"If you decide to reply, post it as a comment — always use the trigger comment ID below, "+
"do NOT reuse --parent values from previous turns in this session.\n\n"+
"On Windows, write the reply body to a UTF-8 file with your file-write tool, then post it with `--content-file`. "+
"Do NOT pipe via `--content-stdin` — Windows PowerShell 5.1 and cmd.exe re-encode piped bytes through the active console codepage and silently drop non-ASCII characters as `?`. "+
"Do NOT use inline `--content`; it is easy to lose formatting or accidentally compress a structured reply into one line.\n\n"+
"Use this form, preserving the same issue ID and --parent value:\n\n"+
" # 1. Write the reply body to a UTF-8 file (e.g. reply.md) with your file-write tool.\n"+
" # 2. Then run:\n"+
" multica issue comment add %s --parent %s --content-file ./reply.md\n\n"+
"Do NOT write literal `\\n` escapes to simulate line breaks; the file preserves real newlines.\n",
issueID, triggerCommentID,
)
}
return fmt.Sprintf(
"If you decide to reply, post it as a comment — always use the trigger comment ID below, "+
"do NOT reuse --parent values from previous turns in this session.\n\n"+
"Always use `--content-stdin` with a HEREDOC for agent-authored issue comments, even when the reply is a single line. "+
"Do NOT use inline `--content`; it is easy to lose formatting or accidentally compress a structured reply into one line.\n\n"+
"Use this form, preserving the same issue ID and --parent value:\n\n"+
" cat <<'COMMENT' | multica issue comment add %s --parent %s --content-stdin\n"+
" First paragraph.\n"+
"\n"+
" Second paragraph.\n"+
" COMMENT\n\n"+
"Do NOT write literal `\\n` escapes to simulate line breaks; the HEREDOC preserves real newlines.\n",
issueID, triggerCommentID,
)
}
// Non-Codex providers: lightweight inline template, no platform branch.
// Pre-#1795 default, restored after we found that #1795 / #1851 had
// expanded a Codex-specific fix into a global mandate that broke
// Windows non-ASCII for every provider. The CLI decodes `\n` etc.
// server-side, so escaped multi-line is fine; for richer formatting
// the agent can still reach for `--content-stdin` (works on Linux /
// macOS) or `--content-file <path>` (works on every platform), both
// listed in Available Commands above.
return fmt.Sprintf(
"If you decide to reply, post it as a comment — always use the trigger comment ID below, "+
"do NOT reuse --parent values from previous turns in this session.\n\n"+
"Always use `--content-stdin` with a HEREDOC for agent-authored issue comments, even when the reply is a single line. "+
"Do NOT use inline `--content`; it is easy to lose formatting or accidentally compress a structured reply into one line.\n\n"+
"Use this form, preserving the same issue ID and --parent value:\n\n"+
" cat <<'COMMENT' | multica issue comment add %s --parent %s --content-stdin\n"+
" First paragraph.\n"+
"\n"+
" Second paragraph.\n"+
" COMMENT\n\n"+
"Do NOT write literal `\\n` escapes to simulate line breaks; the HEREDOC preserves real newlines.\n",
" multica issue comment add %s --parent %s --content \"...\"\n\n"+
"For multi-line bodies, code blocks, or content with quotes/backticks, prefer `--content-stdin` "+
"(pipe a HEREDOC) or `--content-file <path>` (read a UTF-8 file). See Available Commands above for the full menu.\n",
issueID, triggerCommentID,
)
}

View File

@@ -7,43 +7,113 @@ import (
"testing"
)
func TestBuildCommentReplyInstructionsIncludesTriggerID(t *testing.T) {
t.Parallel()
// TestBuildCommentReplyInstructionsCodexLinux pins that the strong
// "MUST use --content-stdin + HEREDOC" mandate is 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(issueID, triggerID)
got := BuildCommentReplyInstructions("codex", issueID, triggerID)
for _, want := range []string{
"multica issue comment add " + issueID + " --parent " + triggerID,
"multica issue comment add " + issueID + " --parent " + triggerID + " --content-stdin",
"Always use `--content-stdin`",
"even when the reply is a single line",
"--content-stdin",
"<<'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("reply instructions missing %q\n---\n%s", want, got)
t.Fatalf("codex/linux reply instructions missing %q\n---\n%s", want, got)
}
}
if strings.Contains(got, "--content \"...\"") {
t.Fatalf("reply instructions should not offer inline --content form\n---\n%s", got)
t.Fatalf("codex reply instructions should not offer inline --content form\n---\n%s", got)
}
}
// TestBuildCommentReplyInstructionsNonCodexUsesInline pins that every
// non-Codex provider gets the lightweight pre-#1795 inline template,
// regardless of host OS. The "MUST stdin" mandate was originally a
// Codex-specific fix that #1795 / #1851 accidentally spread to every
// provider — and on Windows that spread broke non-ASCII bytes via the
// console codepage (#2198 / #2236). Non-Codex providers handle inline
// escaping correctly and the CLI server-decodes `\n` etc., so the
// inline template works on every platform including Windows non-ASCII
// (argv goes through CreateProcessW UTF-16).
//
// Not parallel: mutates the package-level runtimeGOOS.
func TestBuildCommentReplyInstructionsNonCodexUsesInline(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", "windows"} {
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 providers must NOT receive the Codex-specific
// "MUST stdin" mandate or its HEREDOC template, even on
// Linux/macOS — 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)
}
}
})
}
}
}
func TestBuildCommentReplyInstructionsEmptyWhenNoTrigger(t *testing.T) {
t.Parallel()
if got := BuildCommentReplyInstructions("issue-id", ""); got != "" {
if got := BuildCommentReplyInstructions("codex", "issue-id", ""); got != "" {
t.Fatalf("expected empty string when triggerCommentID is empty, got %q", got)
}
if got := BuildCommentReplyInstructions("claude", "issue-id", ""); got != "" {
t.Fatalf("expected empty string when triggerCommentID is empty, got %q", 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) {
t.Parallel()
saved := runtimeGOOS
t.Cleanup(func() { runtimeGOOS = saved })
runtimeGOOS = "linux"
dir := t.TempDir()
issueID := "11111111-1111-1111-1111-111111111111"
@@ -73,3 +143,97 @@ func TestInjectRuntimeConfigCommentTriggerUsesHelper(t *testing.T) {
}
}
}
// TestBuildCommentReplyInstructionsCodexWindowsUsesContentFile pins that on
// Windows hosts the Codex per-turn reply template points at
// `--content-file` instead of `--content-stdin`. PowerShell 5.1 / cmd.exe
// re-encode piped HEREDOC bytes through the active console codepage and
// silently drop non-ASCII characters as `?` before they reach
// `multica.exe` (issues #2198 / #2236).
//
// Not parallel: mutates the package-level runtimeGOOS.
func TestBuildCommentReplyInstructionsCodexWindowsUsesContentFile(t *testing.T) {
saved := runtimeGOOS
t.Cleanup(func() { runtimeGOOS = saved })
issueID := "11111111-1111-1111-1111-111111111111"
triggerID := "22222222-2222-2222-2222-222222222222"
t.Run("codex/windows points at --content-file", func(t *testing.T) {
runtimeGOOS = "windows"
got := BuildCommentReplyInstructions("codex", 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 drop non-ASCII characters as `?`",
} {
if !strings.Contains(got, want) {
t.Errorf("codex/windows reply instructions missing %q\n---\n%s", want, got)
}
}
for _, banned := range []string{
"<<'COMMENT'",
"--parent " + triggerID + " --content-stdin",
"cat <<",
} {
if strings.Contains(got, banned) {
t.Errorf("codex/windows reply instructions should not contain %q\n---\n%s", banned, got)
}
}
})
}
// TestInjectRuntimeConfigCodexWindowsCommentTriggerHasNoStdin asserts the
// end-to-end AGENTS.md surface for a Codex comment-triggered task on a
// Windows daemon: the Codex-Specific paragraph + the per-turn reply
// template are file-first, with no remaining `--content-stdin` directive
// that would override the Windows file mandate. The Available Commands
// section is now neutral on every platform (post-rollback of #1795 /
// #1851), so it is allowed to mention `--content-stdin` as one of the
// three input modes.
func TestInjectRuntimeConfigCodexWindowsCommentTriggerHasNoStdin(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,
}
dir := t.TempDir()
if err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
if err != nil {
t.Fatalf("read AGENTS.md: %v", err)
}
s := string(data)
for _, want := range []string{
"multica issue comment add " + issueID + " --parent " + triggerID + " --content-file",
"--content-file",
"--description-file",
} {
if !strings.Contains(s, want) {
t.Errorf("AGENTS.md missing %q\n---\n%s", want, s)
}
}
// The per-turn reply template and the Codex-specific paragraph must
// not direct the agent at stdin on Windows. Pin prescriptive
// substrings rather than 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",
} {
if strings.Contains(s, banned) {
t.Errorf("AGENTS.md still steers codex at stdin on Windows: %q\n---\n%s", banned, s)
}
}
}

View File

@@ -5,9 +5,16 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// runtimeGOOS is the host-platform string used by buildMetaSkillContent to
// emit Windows-specific guidance. Defaults to runtime.GOOS; tests override
// it to exercise the cross-platform branches deterministically without
// having to run on every target OS.
var runtimeGOOS = runtime.GOOS
// formatProjectResource renders a single resource as a human-readable bullet.
// Unknown resource types fall back to a JSON-encoded ref so the agent can
// still read what the user attached. New resource types should add a case
@@ -132,18 +139,22 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica issue label remove <issue-id> <label-id>` — Detach a label from an issue\n")
b.WriteString("- `multica issue subscriber add <issue-id> [--user <name>|--user-id <uuid>]` — Subscribe a member or agent to issue updates (defaults to the caller when neither flag is set; the two flags are mutually exclusive)\n")
b.WriteString("- `multica issue subscriber remove <issue-id> [--user <name>|--user-id <uuid>]` — Unsubscribe a member or agent\n")
b.WriteString("- `multica issue comment add <issue-id> --content-stdin [--parent <comment-id>] [--attachment <path>]` — Post a comment. Agent-authored comments should always pipe content via stdin, even for short single-line replies. Use `--parent` to reply to a specific comment; `--attachment` may be repeated.\n")
b.WriteString(" - **For comment content, you MUST pipe via stdin; this is mandatory for multi-line content (anything with line breaks, paragraphs, code blocks, backticks, or quotes).** Do not use inline `--content` and do not write `\\n` escapes. Use a HEREDOC instead:\n")
b.WriteString("\n")
b.WriteString(" ```\n")
b.WriteString(" cat <<'COMMENT' | multica issue comment add <issue-id> --content-stdin\n")
b.WriteString(" First paragraph.\n")
b.WriteString("\n")
b.WriteString(" Second paragraph with `code` and \"quotes\".\n")
b.WriteString(" COMMENT\n")
b.WriteString(" ```\n")
b.WriteString("\n")
b.WriteString(" - The same rule applies to `--description` on `multica issue create` and `multica issue update` — use `--description-stdin` and pipe a HEREDOC for any multi-line description; the inline `--description \"...\"` form is for short single-line text only.\n")
// Available Commands lists `multica issue comment add` and the
// description flags neutrally — three input modes, pick what fits.
// The previous "MUST pipe via stdin" mandate (#1795 / #1851) was
// originally a Codex-specific fix for codex emitting literal `\n`
// escapes inside `--content "..."`, but it landed in this global
// section and ended up steering every provider at stdin, which then
// burned non-ASCII bytes on Windows shells (issues #2198 / #2236).
// Strong "MUST" wording lives in the Codex-Specific section below
// where it actually belongs; non-Codex providers handle inline
// escaping correctly and can pick whichever flag suits their content.
b.WriteString("- `multica issue comment add <issue-id> [--content \"...\" | --content-stdin | --content-file <path>] [--parent <comment-id>] [--attachment <path>]` — Post a comment. Three input modes, pick whichever fits the content:\n")
b.WriteString(" - `--content \"...\"` for short single-line text. The CLI decodes `\\n`, `\\r`, `\\t`, `\\\\` so escaped multi-line is OK; do not embed raw newlines in the argument.\n")
b.WriteString(" - `--content-stdin` to pipe the body via HEREDOC. Preserves multi-line and special characters verbatim. Cleanest in `bash` / `zsh`.\n")
b.WriteString(" - `--content-file <path>` to read a UTF-8 file off disk. Preserves bytes verbatim regardless of the shell — use this on Windows when stdin would re-encode non-ASCII (Chinese, Japanese, accents, emoji) through the console codepage and drop them as `?`.\n")
b.WriteString(" - Use `--parent` to reply to a specific comment; `--attachment` may be repeated.\n")
b.WriteString("- `multica issue create` / `multica issue update` accept the same three modes for `--description`: `--description \"...\"`, `--description-stdin`, or `--description-file <path>`.\n")
b.WriteString("- `multica issue comment delete <comment-id>` — Delete a comment\n")
b.WriteString("- `multica label create --name \"...\" --color \"#hex\"` — Define a new workspace label (use this only when the label you need does not exist yet; reuse existing labels via `multica label list` first)\n")
b.WriteString("- `multica autopilot create --title \"...\" --agent <name> --mode create_issue [--description \"...\"]` — Create an autopilot\n")
@@ -153,9 +164,15 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
if provider == "codex" {
b.WriteString("## Codex-Specific Comment Formatting\n\n")
b.WriteString("Codex often follows the per-turn reply command literally. For issue comments, always use `--content-stdin` with a HEREDOC, even for short single-line replies. ")
b.WriteString("Never use inline `--content` for agent-authored comments. Keep the same `--parent` value from the trigger comment when replying. ")
b.WriteString("Do not compress a multi-paragraph answer into one line and do not rely on `\\n` escapes.\n\n")
if runtimeGOOS == "windows" {
b.WriteString("Codex often follows the per-turn reply command literally. On Windows, **always write the comment body to a UTF-8 file with your file-write tool first, then post it with `--content-file <path>`** — do NOT pipe via `--content-stdin`. PowerShell 5.1 / cmd.exe re-encode piped bytes through the active console codepage and silently drop non-ASCII characters as `?`. Never use inline `--content` for agent-authored comments. ")
b.WriteString("Keep the same `--parent` value from the trigger comment when replying. ")
b.WriteString("Do not compress a multi-paragraph answer into one line and do not rely on `\\n` escapes.\n\n")
} else {
b.WriteString("Codex often follows the per-turn reply command literally. For issue comments, always use `--content-stdin` with a HEREDOC, even for short single-line replies. ")
b.WriteString("Never use inline `--content` for agent-authored comments. Keep the same `--parent` value from the trigger comment when replying. ")
b.WriteString("Do not compress a multi-paragraph answer into one line and do not rely on `\\n` escapes.\n\n")
}
}
// Inject available repositories section.
@@ -252,7 +269,7 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("4. **Decide whether a reply is warranted.** If you produced actual work this turn (investigated, fixed, answered a real question), post the result via step 6 — that is a normal reply, not a noise comment. If the triggering comment was a pure acknowledgment / thanks / sign-off from another agent AND you produced no work this turn, do NOT post a reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is a valid and preferred way to end agent-to-agent conversations.\n")
b.WriteString("5. If a reply IS warranted: do any requested work first, then **decide whether to include any `@mention` link.** The default is NO mention. Only mention when you are escalating to a human owner who is not yet involved, delegating a concrete new sub-task to another agent for the first time, or the user explicitly asked you to loop someone in. Never @mention the agent you are replying to as a thank-you or sign-off.\n")
b.WriteString("6. **If you reply, post it as a comment — this step is mandatory when you reply.** Text in your terminal or run logs is NOT delivered to the user. ")
b.WriteString(BuildCommentReplyInstructions(ctx.IssueID, ctx.TriggerCommentID))
b.WriteString(BuildCommentReplyInstructions(provider, ctx.IssueID, ctx.TriggerCommentID))
b.WriteString("7. Do NOT change the issue status unless the comment explicitly asks for it\n\n")
} else {
// Assignment-triggered: defer to agent Skills for workflow specifics.

View File

@@ -9,13 +9,16 @@ import (
// BuildPrompt constructs the task prompt for an agent CLI.
// Keep this minimal — detailed instructions live in CLAUDE.md / AGENTS.md
// injected by execenv.InjectRuntimeConfig.
func BuildPrompt(task Task) string {
// injected by execenv.InjectRuntimeConfig. The provider string is used by
// comment-triggered tasks: Codex's per-turn reply template needs the
// platform-aware "stdin or file" variant, every other provider gets a
// lightweight inline template.
func BuildPrompt(task Task, provider string) string {
if task.ChatSessionID != "" {
return buildChatPrompt(task)
}
if task.TriggerCommentID != "" {
return buildCommentPrompt(task)
return buildCommentPrompt(task, provider)
}
if task.AutopilotRunID != "" {
return buildAutopilotPrompt(task)
@@ -96,7 +99,7 @@ func buildQuickCreatePrompt(task Task) string {
// The reply instructions (including the current TriggerCommentID as --parent)
// are re-emitted on every turn so resumed sessions cannot carry forward a
// previous turn's --parent UUID.
func buildCommentPrompt(task Task) string {
func buildCommentPrompt(task Task, provider string) string {
var b strings.Builder
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
@@ -117,7 +120,7 @@ func buildCommentPrompt(task Task) string {
}
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then decide how to proceed.\n\n", task.IssueID)
fmt.Fprintf(&b, "If you need comment history, `multica issue comment list %s` returns the latest 50 by default — pass --limit or --since to scope older windows. Long issues can have thousands of comments; do not fetch everything blindly.\n\n", task.IssueID)
b.WriteString(execenv.BuildCommentReplyInstructions(task.IssueID, task.TriggerCommentID))
b.WriteString(execenv.BuildCommentReplyInstructions(provider, task.IssueID, task.TriggerCommentID))
return b.String()
}