Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
46ccd12bac fix(runtimes): inject SKILL.md name when upstream frontmatter omits it
Skills imported with frontmatter that sets `description` but leaves `name`
implicit (relying on the directory slug, as common in GitHub/Skills.sh
imports) still hit OpenCode's "no parseable name → drop" path because the
DB Name fallback never made it into the SKILL.md body. ensureSkillFrontmatter
now scans the existing block and, when name is missing or empty, prepends
`name: <slug>` while preserving description, body, and any runtime-specific
keys verbatim.

Also tighten yamlEscapeInline to always double-quote so descriptions that
look like YAML keywords (`null`, `true`, `[foo]`, `{x: y}`, `2024-01-01`)
parse as strings rather than getting reinterpreted and rejected.

Adds regression test for the nameless-frontmatter case and updates the
existing OpenCode skill test for the always-quoted description format.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 16:06:26 +08:00
Jiang Bohan
531e1d8aef fix(runtimes): anchor OpenCode skill + AGENTS.md discovery to task workdir
OpenCode resolves its project discovery root from `--dir` and `PWD`
before falling back to `process.cwd()`. The daemon set `cmd.Dir =
workDir` but never overrode the inherited `PWD`, so OpenCode walked
from the daemon's shell directory and silently bypassed the per-task
workdir — agents lost visibility into `.opencode/skills/` and
`AGENTS.md`, falling back to whatever global skills the host had
installed (MUL-2416).

- Pass `opencode run --dir <workDir>` and override `PWD=<workDir>` in
  the child env so AGENTS.md walk-up + `.opencode/skills` project
  config scan both anchor on the task workdir.
- Block `--dir` from custom args so user overrides cannot re-introduce
  the regression.
- Plumb skill `description` from DB through service / daemon /
  execenv. `writeSkillFiles` synthesizes a YAML frontmatter block
  (`name`, optional `description`) when the stored content lacks one,
  since runtimes like OpenCode silently drop SKILL.md files without a
  parseable `name`. Existing frontmatter is preserved unchanged so
  upstream-imported skills (GitHub / ClawHub / Skills.sh) keep their
  hand-shaped metadata.

Tests:
- New fake-CLI test confirms argv carries `--dir <workDir>` and the
  child sees `PWD=<workDir>`.
- New test confirms a user-supplied `--dir` in custom_args is dropped.
- New execenv tests cover synthesized frontmatter and preservation of
  pre-existing frontmatter.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 15:52:28 +08:00
8 changed files with 403 additions and 22 deletions

View File

@@ -3091,8 +3091,9 @@ func convertSkillsForEnv(skills []SkillData) []execenv.SkillContextForEnv {
result := make([]execenv.SkillContextForEnv, len(skills))
for i, s := range skills {
result[i] = execenv.SkillContextForEnv{
Name: s.Name,
Content: s.Content,
Name: s.Name,
Description: s.Description,
Content: s.Content,
}
for _, f := range s.Files {
result[i].Files = append(result[i].Files, execenv.SkillFileContextForEnv{

View File

@@ -132,7 +132,14 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
// See: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference
skillsDir = filepath.Join(workDir, ".github", "skills")
case "opencode":
// OpenCode natively discovers skills from .opencode/skills/ in the workdir.
// OpenCode natively discovers project skills from .opencode/skills/ in
// the workdir. ConfigPaths.directories() walks up from the discovery
// root looking for a bare `.opencode` directory (no opencode.json
// signal required), then skill/index.ts scans `{skill,skills}/**/SKILL.md`
// under each match. Discovery is anchored at the task workdir via
// `opencode run --dir <workDir>` + PWD override in opencodeBackend —
// without those, OpenCode walks from the daemon's inherited PWD and
// misses .opencode/skills + AGENTS.md entirely (MUL-2416).
skillsDir = filepath.Join(workDir, ".opencode", "skills")
case "openclaw":
// OpenClaw's native skill scanner reads <workspaceDir>/skills/. The
@@ -168,6 +175,99 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
// ensureSkillFrontmatter returns SKILL.md content guaranteed to lead with a
// YAML frontmatter block carrying a parseable, non-empty `name` key.
//
// Runtimes like OpenCode silently drop SKILL.md whose frontmatter is missing
// or whose `name` doesn't parse, so we handle three cases:
//
// - No frontmatter at all → synthesize one with `name: <slug>` (and the DB
// description when available).
// - Frontmatter present and already has a non-empty `name` → leave it
// untouched. The upstream import may have shaped that block deliberately
// to match a specific runtime, and we don't want to clobber it.
// - Frontmatter present but missing `name` (e.g. an upstream skill whose
// YAML only set `description`, with the directory slug filling in for
// `name` at import time) → prepend `name: <slug>` as the first key of
// the existing block so OpenCode can still route the skill.
func ensureSkillFrontmatter(content, slug, description string) string {
fmStart, ok := frontmatterBodyStart(content)
if !ok {
var b strings.Builder
b.WriteString("---\n")
fmt.Fprintf(&b, "name: %s\n", slug)
if d := strings.TrimSpace(description); d != "" {
fmt.Fprintf(&b, "description: %s\n", yamlEscapeInline(d))
}
b.WriteString("---\n\n")
b.WriteString(content)
return b.String()
}
if hasFrontmatterName(content[fmStart:]) {
return content
}
// Frontmatter exists but lacks a parseable `name`. Inject one as the
// first key of the existing block and keep the rest verbatim (including
// `description`, body, and any runtime-specific keys the import path
// preserved).
return content[:fmStart] + "name: " + slug + "\n" + content[fmStart:]
}
// frontmatterBodyStart returns the byte offset where the YAML body begins
// (just after the opening `---` line) and whether a valid opening delimiter
// was found.
func frontmatterBodyStart(content string) (int, bool) {
if strings.HasPrefix(content, "---\n") {
return 4, true
}
if strings.HasPrefix(content, "---\r\n") {
return 5, true
}
return 0, false
}
// hasFrontmatterName reports whether the frontmatter body (the slice starting
// just after the opening `---` line) contains a parseable, non-empty `name:`
// scalar before the closing `---`.
func hasFrontmatterName(fmBody string) bool {
closeIdx := strings.Index(fmBody, "\n---")
if closeIdx < 0 {
// Missing close — scan everything we have and fall through. The
// frontmatter is malformed and OpenCode will reject it anyway, but
// detecting an existing name keeps us from layering a second one
// on top.
closeIdx = len(fmBody)
}
for _, line := range strings.Split(fmBody[:closeIdx], "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "name:") {
continue
}
v := strings.TrimSpace(strings.TrimPrefix(line, "name:"))
v = strings.Trim(v, `"'`)
if v != "" {
return true
}
}
return false
}
// yamlEscapeInline returns a double-quoted YAML scalar that always parses as
// a string. Plain scalars are deliberately avoided: values like `[foo]`,
// `{x: y}`, `false`, `null`, or `2024-01-01` would parse as flow sequences,
// flow mappings, booleans, nulls, or timestamps under YAML 1.2, and
// OpenCode's frontmatter check rejects non-string descriptions outright. We
// flatten newlines (frontmatter values are single-line per key) and escape
// `\` and `"` so any input is a safe inline string.
func yamlEscapeInline(s string) string {
flat := strings.ReplaceAll(s, "\r\n", " ")
flat = strings.ReplaceAll(flat, "\n", " ")
flat = strings.ReplaceAll(flat, "\r", " ")
escaped := strings.ReplaceAll(flat, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return `"` + escaped + `"`
}
// sanitizeSkillName converts a skill name to a safe directory name.
func sanitizeSkillName(name string) string {
s := strings.ToLower(strings.TrimSpace(name))
@@ -187,13 +287,15 @@ func writeSkillFiles(skillsDir string, skills []SkillContextForEnv) error {
}
for _, skill := range skills {
dir := filepath.Join(skillsDir, sanitizeSkillName(skill.Name))
slug := sanitizeSkillName(skill.Name)
dir := filepath.Join(skillsDir, slug)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
// Write main SKILL.md
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skill.Content), 0o644); err != nil {
body := ensureSkillFrontmatter(skill.Content, slug, skill.Description)
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
return err
}

View File

@@ -66,9 +66,10 @@ type TaskContextForEnv struct {
// SkillContextForEnv represents a skill to be written into the execution environment.
type SkillContextForEnv struct {
Name string
Content string
Files []SkillFileContextForEnv
Name string
Description string
Content string
Files []SkillFileContextForEnv
}
// SkillFileContextForEnv represents a supporting file within a skill.

View File

@@ -798,8 +798,9 @@ func TestWriteContextFilesOpencodeNativeSkills(t *testing.T) {
IssueID: "opencode-skill-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Go Conventions",
Content: "Follow Go conventions.",
Name: "Go Conventions",
Description: "Follow our internal Go style.",
Content: "Follow Go conventions.",
Files: []SkillFileContextForEnv{
{Path: "templates/example.go", Content: "package main"},
},
@@ -816,9 +817,27 @@ func TestWriteContextFilesOpencodeNativeSkills(t *testing.T) {
if err != nil {
t.Fatalf("failed to read .opencode/skills/go-conventions/SKILL.md: %v", err)
}
if !strings.Contains(string(skillMd), "Follow Go conventions.") {
body := string(skillMd)
if !strings.Contains(body, "Follow Go conventions.") {
t.Error("SKILL.md missing content")
}
// OpenCode (and every other runtime) silently drops SKILL.md without a
// parseable frontmatter `name`. The synthesized frontmatter must lead
// with `name:` matching the parent directory slug and carry the
// description verbatim from the DB so OpenCode's `skill` tool can route
// the model to it by name. The description is always double-quoted so
// values that happen to be YAML keywords (`null`, `true`, `[foo]`,
// etc.) still parse as strings and don't get dropped.
prefix := body
if len(prefix) > 120 {
prefix = prefix[:120]
}
if !strings.HasPrefix(body, "---\nname: go-conventions\n") {
t.Errorf("SKILL.md missing synthesized frontmatter name; got: %q", prefix)
}
if !strings.Contains(body, `description: "Follow our internal Go style."`) {
t.Errorf("SKILL.md missing synthesized quoted description; got: %q", prefix)
}
// Supporting files should also be under .opencode/skills/.
supportFile, err := os.ReadFile(filepath.Join(dir, ".opencode", "skills", "go-conventions", "templates", "example.go"))
@@ -840,6 +859,77 @@ func TestWriteContextFilesOpencodeNativeSkills(t *testing.T) {
}
}
// Skill content imported from upstream sources (GitHub, ClawHub, Skills.sh)
// often already carries its own YAML frontmatter — possibly with a `name`
// that differs from the DB row's display name to match a specific runtime's
// expectations. The writer must not clobber that block; it should only
// synthesize when frontmatter is absent.
func TestWriteContextFilesPreservesExistingSkillFrontmatter(t *testing.T) {
t.Parallel()
dir := t.TempDir()
preExisting := "---\nname: upstream-name\ndescription: imported as-is\n---\n\nbody"
ctx := TaskContextForEnv{
IssueID: "preserve-frontmatter-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Display Name",
Description: "overridden by upstream frontmatter",
Content: preExisting,
},
},
}
if err := writeContextFiles(dir, "opencode", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
skillMd, err := os.ReadFile(filepath.Join(dir, ".opencode", "skills", "display-name", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read SKILL.md: %v", err)
}
if string(skillMd) != preExisting {
t.Errorf("SKILL.md was rewritten; got:\n%s\nwant:\n%s", skillMd, preExisting)
}
}
// Some upstream skills (GitHub imports, Skills.sh) ship a frontmatter block
// that sets `description` but omits `name` — the directory layout is what
// identifies the skill there. OpenCode's scanner requires a parseable `name`
// in the frontmatter or it silently drops the SKILL.md. The writer must
// inject `name: <slug>` into the existing block (not replace it) so the
// upstream description and body still ride along intact.
func TestWriteContextFilesInjectsNameIntoNamelessFrontmatter(t *testing.T) {
t.Parallel()
dir := t.TempDir()
preExisting := "---\ndescription: Review pull requests\n---\n\nbody"
ctx := TaskContextForEnv{
IssueID: "inject-name-test",
AgentSkills: []SkillContextForEnv{
{
Name: "Review PRs",
Description: "DB description ignored when content already carries one",
Content: preExisting,
},
},
}
if err := writeContextFiles(dir, "opencode", ctx); err != nil {
t.Fatalf("writeContextFiles failed: %v", err)
}
skillMd, err := os.ReadFile(filepath.Join(dir, ".opencode", "skills", "review-prs", "SKILL.md"))
if err != nil {
t.Fatalf("failed to read SKILL.md: %v", err)
}
got := string(skillMd)
want := "---\nname: review-prs\ndescription: Review pull requests\n---\n\nbody"
if got != want {
t.Errorf("SKILL.md was not patched correctly;\n got: %q\nwant: %q", got, want)
}
}
// OpenClaw's native skill scanner reads {workspaceDir}/skills/. The daemon
// pairs writeContextFiles with a per-task synthesized openclaw-config.json
// (see openclaw_config.go) that pins agents.defaults.workspace to workDir,
@@ -2092,7 +2182,7 @@ func TestReuseWritesMissingCodexWorkspaceSkills(t *testing.T) {
if err != nil {
t.Fatalf("missing reused codex workspace skill: %v", err)
}
if string(data) != "Write clearly." {
if !strings.Contains(string(data), "Write clearly.") {
t.Errorf("skill content = %q", data)
}
example, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "examples", "example.md"))
@@ -2151,7 +2241,7 @@ func TestReuseUpdatesCodexWorkspaceSkills(t *testing.T) {
if err != nil {
t.Fatalf("missing reused codex workspace skill: %v", err)
}
if string(data) != "Updated writing guidance." {
if !strings.Contains(string(data), "Updated writing guidance.") {
t.Errorf("skill content = %q", data)
}
example, err := os.ReadFile(filepath.Join(reused.CodexHome, "skills", "writing", "examples", "example.md"))
@@ -2271,7 +2361,7 @@ func TestPrepareCodexWorkspaceSkillBeatsUserSkillOnConflict(t *testing.T) {
if err != nil {
t.Fatalf("workspace skill not written: %v", err)
}
if string(data) != "workspace writing" {
if !strings.Contains(string(data), "workspace writing") {
t.Errorf("SKILL.md = %q, want workspace content", data)
}
// The user's stale support file must not leak through — seeding is
@@ -2469,7 +2559,7 @@ func TestReuseClearsUserSkillResidueOnWorkspaceConflict(t *testing.T) {
if err != nil {
t.Fatalf("workspace SKILL.md missing after reuse: %v", err)
}
if string(data) != "workspace writing" {
if !strings.Contains(string(data), "workspace writing") {
t.Errorf("SKILL.md = %q, want workspace content", data)
}
if _, err := os.Stat(filepath.Join(reused.CodexHome, "skills", "writing", "drafts", "stale.md")); !os.IsNotExist(err) {

View File

@@ -88,9 +88,10 @@ type AgentData struct {
// SkillData represents a structured skill for task execution.
type SkillData struct {
Name string `json:"name"`
Content string `json:"content"`
Files []SkillFileData `json:"files,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Content string `json:"content"`
Files []SkillFileData `json:"files,omitempty"`
}
// SkillFileData represents a supporting file within a skill.

View File

@@ -1546,7 +1546,7 @@ func (s *TaskService) LoadAgentSkills(ctx context.Context, agentID pgtype.UUID)
result := make([]AgentSkillData, 0, len(skills))
for _, sk := range skills {
data := AgentSkillData{Name: sk.Name, Content: sk.Content}
data := AgentSkillData{Name: sk.Name, Description: sk.Description, Content: sk.Content}
files, _ := s.Queries.ListSkillFiles(ctx, sk.ID)
for _, f := range files {
data.Files = append(data.Files, AgentSkillFileData{Path: f.Path, Content: f.Content})
@@ -1558,9 +1558,10 @@ func (s *TaskService) LoadAgentSkills(ctx context.Context, agentID pgtype.UUID)
// AgentSkillData represents a skill for task execution responses.
type AgentSkillData struct {
Name string `json:"name"`
Content string `json:"content"`
Files []AgentSkillFileData `json:"files,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Content string `json:"content"`
Files []AgentSkillFileData `json:"files,omitempty"`
}
// AgentSkillFileData represents a supporting file within a skill.

View File

@@ -18,6 +18,7 @@ import (
// overridden by user-configured custom_args.
var opencodeBlockedArgs = map[string]blockedArgMode{
"--format": blockedWithValue, // json output format for daemon communication
"--dir": blockedWithValue, // task workdir anchor for skill / AGENTS.md discovery
}
// opencodeBackend implements Backend by spawning `opencode run --format json`
@@ -50,6 +51,18 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO
runCtx, cancel := context.WithTimeout(ctx, timeout)
args := []string{"run", "--format", "json"}
// Anchor OpenCode's project discovery (AGENTS.md walk-up + .opencode/skills/
// project config scan) at the task workdir. Without this, OpenCode falls
// back to PWD (inherited from the daemon process) or process.cwd(), which
// in self-host deployments can resolve to the user's shell working
// directory and silently bypass the per-task workdir — agents lose
// visibility into their assigned skills and AGENTS.md instructions.
// PWD is also overridden below because OpenCode prefers PWD over cwd when
// `--dir` is absent and uses it as the starting point for any further
// path resolution.
if opts.Cwd != "" {
args = append(args, "--dir", opts.Cwd)
}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
@@ -76,6 +89,14 @@ func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecO
env := buildEnv(b.cfg.Env)
// Auto-approve all tool use in daemon mode.
env = append(env, `OPENCODE_PERMISSION={"*":"allow"}`)
// Override PWD so the child OpenCode process resolves its discovery root
// to the task workdir. cmd.Dir alone is not enough: OpenCode reads PWD
// (inherited from the parent daemon) before falling back to process.cwd()
// when computing the directory it walks for AGENTS.md / .opencode/skills.
// See packages/opencode/src/cli/cmd/run.ts in the upstream source.
if opts.Cwd != "" {
env = append(env, "PWD="+opts.Cwd)
}
cmd.Env = env
stdout, err := cmd.StdoutPipe()

View File

@@ -1,6 +1,7 @@
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -9,6 +10,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestNewReturnsOpencodeBackend(t *testing.T) {
@@ -831,6 +833,168 @@ func TestOpencodeWindowsPackageCandidatesAmd64(t *testing.T) {
}
}
// fakeOpencodeScript returns a POSIX-sh script that impersonates `opencode`
// for argv / env capture. It writes the argv (one per line) to
// $OPENCODE_ARGS_FILE and the resolved PWD to $OPENCODE_PWD_FILE, emits a
// minimal completed step on stdout so the daemon's event loop terminates,
// then exits.
func fakeOpencodeScript() string {
return `#!/bin/sh
if [ -n "$OPENCODE_ARGS_FILE" ]; then
for arg in "$@"; do
printf '%s\n' "$arg" >> "$OPENCODE_ARGS_FILE"
done
fi
if [ -n "$OPENCODE_PWD_FILE" ]; then
printf '%s\n' "$PWD" > "$OPENCODE_PWD_FILE"
fi
printf '{"type":"step_start","timestamp":1,"sessionID":"ses_fake","part":{"type":"step-start"}}\n'
printf '{"type":"text","timestamp":2,"sessionID":"ses_fake","part":{"type":"text","text":"ok"}}\n'
printf '{"type":"step_finish","timestamp":3,"sessionID":"ses_fake","part":{"type":"step-finish"}}\n'
`
}
// TestOpencodeBackendAnchorsDirAndPWD pins the discovery-root fix from
// MUL-2416: OpenCode resolves its AGENTS.md walk-up and .opencode/skills
// project config scan from `--dir` and PWD. cmd.Dir alone is not enough
// because OpenCode reads PWD (inherited from the daemon) before falling
// back to process.cwd(). Without this anchor, skills written into the
// task workdir are silently invisible and the agent runs against the
// daemon's shell working directory.
func TestOpencodeBackendAnchorsDirAndPWD(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
argsFile := filepath.Join(tempDir, "argv.txt")
pwdFile := filepath.Join(tempDir, "pwd.txt")
fakePath := filepath.Join(tempDir, "opencode")
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
workDir := t.TempDir()
backend, err := New("opencode", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{
"OPENCODE_ARGS_FILE": argsFile,
"OPENCODE_PWD_FILE": pwdFile,
},
})
if err != nil {
t.Fatalf("new opencode backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Cwd: workDir,
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
<-session.Result
// argv should include `--dir <workDir>` immediately after the `run` /
// `--format json` prefix and nowhere else.
raw, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read args file: %v", err)
}
args := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(args) < 2 || args[0] != "run" {
t.Fatalf("expected first arg to be 'run', got %q", args)
}
dirIdx := -1
for i, a := range args {
if a == "--dir" {
dirIdx = i
break
}
}
if dirIdx == -1 {
t.Fatalf("expected --dir flag in argv, got %q", args)
}
if dirIdx+1 >= len(args) || args[dirIdx+1] != workDir {
t.Fatalf("expected --dir %q, got args=%q", workDir, args)
}
// PWD inside the child process must resolve to the task workdir,
// otherwise OpenCode's project discovery walk starts in the wrong
// directory and silently misses .opencode/skills + AGENTS.md.
gotPWD, err := os.ReadFile(pwdFile)
if err != nil {
t.Fatalf("read PWD file: %v", err)
}
if got := strings.TrimSpace(string(gotPWD)); got != workDir {
t.Errorf("child PWD = %q, want %q", got, workDir)
}
}
// TestOpencodeBackendBlocksDirOverride ensures user-supplied custom args
// cannot replace the daemon-managed `--dir` anchor. Letting custom args
// override it would re-introduce the MUL-2416 regression.
func TestOpencodeBackendBlocksDirOverride(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
argsFile := filepath.Join(tempDir, "argv.txt")
fakePath := filepath.Join(tempDir, "opencode")
writeTestExecutable(t, fakePath, []byte(fakeOpencodeScript()))
workDir := t.TempDir()
bogusDir := t.TempDir()
backend, err := New("opencode", Config{
ExecutablePath: fakePath,
Logger: slog.Default(),
Env: map[string]string{
"OPENCODE_ARGS_FILE": argsFile,
},
})
if err != nil {
t.Fatalf("new opencode backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
Cwd: workDir,
Timeout: 5 * time.Second,
CustomArgs: []string{"--dir", bogusDir},
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
<-session.Result
raw, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("read args file: %v", err)
}
args := strings.Split(strings.TrimSpace(string(raw)), "\n")
for i, a := range args {
if a == "--dir" {
if i+1 >= len(args) || args[i+1] != workDir {
t.Errorf("--dir was overridden by custom args: got %q", args)
}
}
if a == bogusDir {
t.Errorf("custom --dir value %q leaked into argv: %q", bogusDir, args)
}
}
}
func equalStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false