mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 00:19:29 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/f4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46ccd12bac | ||
|
|
531e1d8aef |
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user