mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 17:47:43 +02:00
Compare commits
4 Commits
codex/agen
...
fix/local-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a597f49b79 | ||
|
|
c1c268c647 | ||
|
|
44a4d3da60 | ||
|
|
e13ea9c687 |
@@ -2566,9 +2566,25 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
|
||||
if err != nil {
|
||||
d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err)
|
||||
}
|
||||
// NOTE: No cleanup — workdir is preserved for reuse by future tasks on
|
||||
// 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.
|
||||
// Workdir is preserved for reuse by future tasks on the same (agent,
|
||||
// issue) pair in cloud mode; the work_dir path is stored in DB on task
|
||||
// completion and passed back via PriorWorkDir on the next claim, so
|
||||
// rewriting the marker block in place is the right behavior.
|
||||
//
|
||||
// In local_directory mode the workdir is the user's own repo, reuse is
|
||||
// already disabled above (see localAssignment == nil), and the brief
|
||||
// would otherwise live on inside the user's repository — a subsequent
|
||||
// manual `claude` / `codex` / `gemini` run in that directory would pick
|
||||
// up stale Multica instructions (issue id, trigger comment id, reply
|
||||
// rules) and start acting on the previous task's context. Excise the
|
||||
// marker block on the way out instead.
|
||||
if env.LocalDirectory {
|
||||
defer func() {
|
||||
if cerr := execenv.CleanupRuntimeConfig(env.WorkDir, provider); cerr != nil {
|
||||
d.logger.Warn("execenv: cleanup runtime config failed (non-fatal)", "error", cerr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
prompt := BuildPrompt(task, provider)
|
||||
|
||||
|
||||
@@ -2,13 +2,53 @@ package execenv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runtimeMarkerBegin and runtimeMarkerEnd delimit the Multica-managed brief
|
||||
// inside the runtime config file (CLAUDE.md / AGENTS.md / GEMINI.md). The
|
||||
// markers exist so writeRuntimeConfigFile can:
|
||||
//
|
||||
// - preserve user-authored content in the same file (the user's repo may
|
||||
// already ship a CLAUDE.md / AGENTS.md when the agent is pointed at a
|
||||
// local_directory project resource),
|
||||
// - replace the brief idempotently on subsequent runs in the same workdir
|
||||
// instead of appending duplicate copies, and
|
||||
// - leave a precise excision target for a future cleanup pass.
|
||||
//
|
||||
// HTML comments are used so the markers are inert in every Markdown renderer
|
||||
// and harmless when fed to the agent as instructions. Changing the marker
|
||||
// text is a breaking change for any file that already carries the previous
|
||||
// markers — bump deliberately.
|
||||
const (
|
||||
runtimeMarkerBegin = "<!-- BEGIN MULTICA-RUNTIME (auto-managed; do not edit) -->"
|
||||
runtimeMarkerEnd = "<!-- END MULTICA-RUNTIME -->"
|
||||
|
||||
// runtimeManagedSeparator is the fixed separator inserted between any
|
||||
// pre-existing user content and the marker block whenever Inject
|
||||
// appends to a file that already exists. The separator is considered
|
||||
// part of the managed region: Cleanup strips it together with the
|
||||
// block, so the file rolls back to its exact pre-injection bytes
|
||||
// regardless of whether the user file ended with no newline, one
|
||||
// newline, or multiple trailing newlines. Without a fixed-width
|
||||
// separator the cleanup path would have to renormalise the user's
|
||||
// trailing bytes and would leave a subtle but real diff every run
|
||||
// (see MUL-2753 review on PR #3438).
|
||||
//
|
||||
// Cleanup distinguishes "file we created" (no managed separator
|
||||
// precedes the block — write a missing file from scratch) from "file
|
||||
// that pre-existed" (managed separator precedes the block) so the
|
||||
// file's existence is preserved exactly across the inject→cleanup
|
||||
// cycle, including empty / whitespace-only pre-existing files.
|
||||
runtimeManagedSeparator = "\n\n"
|
||||
)
|
||||
|
||||
// runtimeGOOS is the host-platform string used by buildMetaSkillContent and
|
||||
// BuildCommentReplyInstructions to emit Windows-specific guidance. Defaults
|
||||
// to runtime.GOOS; tests override it to exercise the cross-platform branches
|
||||
@@ -101,18 +141,201 @@ func formatProjectResource(r ProjectResourceForEnv) string {
|
||||
// For Antigravity: writes {workDir}/AGENTS.md (agy CLI reads AGENTS.md natively; skills discovered natively from .agents/skills/ — see https://antigravity.google/docs/gcli-migration)
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) (string, error) {
|
||||
content := buildMetaSkillContent(provider, ctx)
|
||||
|
||||
switch provider {
|
||||
case "claude":
|
||||
return content, os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity":
|
||||
return content, os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
case "gemini":
|
||||
return content, os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
|
||||
default:
|
||||
path := runtimeConfigPath(workDir, provider)
|
||||
if path == "" {
|
||||
// Unknown provider — skip config injection, prompt-only mode.
|
||||
return content, nil
|
||||
}
|
||||
return content, writeRuntimeConfigFile(path, content)
|
||||
}
|
||||
|
||||
// runtimeConfigPath returns the absolute path to the runtime config file that
|
||||
// InjectRuntimeConfig writes for the given provider, or "" when the provider
|
||||
// has no file-based config target. Centralising the mapping keeps Inject /
|
||||
// Cleanup in lockstep — both paths consult the same table so a new provider
|
||||
// added to one side cannot drift past the other.
|
||||
func runtimeConfigPath(workDir, provider string) string {
|
||||
switch provider {
|
||||
case "claude":
|
||||
return filepath.Join(workDir, "CLAUDE.md")
|
||||
case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity":
|
||||
return filepath.Join(workDir, "AGENTS.md")
|
||||
case "gemini":
|
||||
return filepath.Join(workDir, "GEMINI.md")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// writeRuntimeConfigFile writes the Multica runtime brief to path without
|
||||
// clobbering any user-authored content already present. Behaviour by file
|
||||
// state:
|
||||
//
|
||||
// - file missing → create the file containing only the marker block, no
|
||||
// leading separator. Cleanup detects the absence of the separator and
|
||||
// restores the missing-file state by removing the file outright.
|
||||
// - file present (any content, including empty), no marker block →
|
||||
// append `<runtimeManagedSeparator>` + the marker block. The
|
||||
// separator's bytes are part of the managed region so Cleanup can
|
||||
// restore the user's pre-injection bytes exactly (no trailing-newline
|
||||
// normalisation, no surprises for files that ended without a newline
|
||||
// or with extra trailing newlines).
|
||||
// - file present, marker block already there → replace the body between
|
||||
// the markers in place so repeated runs in the same workdir don't grow
|
||||
// the file unboundedly. The pre-block content (including any managed
|
||||
// separator established by the first inject) is preserved verbatim.
|
||||
//
|
||||
// The previous implementation called os.WriteFile unconditionally, which
|
||||
// silently truncated a repository's CLAUDE.md / AGENTS.md / GEMINI.md the
|
||||
// first time the agent was pointed at the user's own directory via the
|
||||
// local_directory project resource flow. See MUL-2753.
|
||||
func writeRuntimeConfigFile(path, brief string) error {
|
||||
block := runtimeMarkerBegin + "\n" + strings.TrimRight(brief, "\n") + "\n" + runtimeMarkerEnd + "\n"
|
||||
|
||||
existing, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return os.WriteFile(path, []byte(block), 0o644)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read existing runtime config %s: %w", path, err)
|
||||
}
|
||||
|
||||
existingStr := string(existing)
|
||||
if start, end, ok := locateMarkerBlock(existingStr); ok {
|
||||
// Replace the existing block in place. locateMarkerBlock already
|
||||
// consumes the trailing newline that closed the previous block, so
|
||||
// successive runs don't accumulate blank lines around the block.
|
||||
// The managed separator (if any) lives in existingStr[:start] and
|
||||
// is preserved untouched.
|
||||
newContent := existingStr[:start] + block + existingStr[end:]
|
||||
return os.WriteFile(path, []byte(newContent), 0o644)
|
||||
}
|
||||
|
||||
// No marker block present. Append the fixed managed separator followed
|
||||
// by the block. The separator is unconditional — including for files
|
||||
// that already end in two or more newlines — so the byte boundary
|
||||
// between user content and the managed region is deterministic, which
|
||||
// is what lets Cleanup roll back to the user's exact original bytes.
|
||||
return os.WriteFile(path, []byte(existingStr+runtimeManagedSeparator+block), 0o644)
|
||||
}
|
||||
|
||||
// locateMarkerBlock finds the [start, end) byte range of the Multica marker
|
||||
// block inside content. The returned `end` is one past the block's trailing
|
||||
// newline (if any) so callers can splice the block out without leaving an
|
||||
// orphan blank line behind.
|
||||
//
|
||||
// The end marker is searched for strictly after the begin marker. This
|
||||
// matters for two malformed cases that the previous naive `strings.Index`
|
||||
// pair would mishandle:
|
||||
//
|
||||
// - User content carries a stray `<!-- END MULTICA-RUNTIME -->` (e.g. a
|
||||
// documentation snippet showing what the wire format looks like) before
|
||||
// any begin marker. The naive parser would find that end and reject the
|
||||
// block (`endIdx > startIdx` false), then append a fresh block — and
|
||||
// since the stray end stays in place, every subsequent run would append
|
||||
// yet another block, growing the file unboundedly.
|
||||
// - A previous run crashed between writing begin and end and left the file
|
||||
// with a half-block. The naive parser would not find an end, fall
|
||||
// through to the append branch, and stack a new block after the
|
||||
// half-block. Treating "begin found, no end after" as "the block ends
|
||||
// at EOF" makes the next write replace the half-block in place.
|
||||
func locateMarkerBlock(content string) (start, end int, found bool) {
|
||||
start = strings.Index(content, runtimeMarkerBegin)
|
||||
if start < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
afterBegin := start + len(runtimeMarkerBegin)
|
||||
endRel := strings.Index(content[afterBegin:], runtimeMarkerEnd)
|
||||
if endRel < 0 {
|
||||
// Malformed — no end marker after begin. Treat the rest of the file
|
||||
// as the block so the next write replaces it cleanly instead of
|
||||
// stacking another block beneath the half-block.
|
||||
return start, len(content), true
|
||||
}
|
||||
end = afterBegin + endRel + len(runtimeMarkerEnd)
|
||||
if end < len(content) && content[end] == '\n' {
|
||||
end++
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// CleanupRuntimeConfig excises the Multica marker block from the runtime
|
||||
// config file for the given provider and restores the file to its exact
|
||||
// pre-injection state, byte for byte. The cleanup is the second half of
|
||||
// the contract `writeRuntimeConfigFile` establishes: together they must
|
||||
// round-trip a user's local repository config across an arbitrary number
|
||||
// of Multica runs without ever touching a single non-managed byte.
|
||||
//
|
||||
// Behaviour, mirroring the three Inject states:
|
||||
//
|
||||
// - file has no marker block → no-op (nothing was ever injected here);
|
||||
// - block is at the start of the file with no preceding managed
|
||||
// separator → the file was created by Inject from a missing-file
|
||||
// state. Remove the file outright so the post-cleanup directory
|
||||
// listing is byte-identical to the pre-Inject one.
|
||||
// - block is preceded by the fixed managed separator → strip the
|
||||
// separator together with the block; whatever remains (which may be
|
||||
// an empty pre-existing file, a whitespace-only file, or arbitrary
|
||||
// user content) is the user's original file, written back verbatim
|
||||
// with NO trailing-newline normalisation and NO TrimSpace-based file
|
||||
// removal heuristic. Both of those were sources of subtle diff in
|
||||
// PR #3438 review feedback.
|
||||
//
|
||||
// Required for the local_directory flow (WorkDir is the user's own repo):
|
||||
// without this pass, a manual `claude` / `codex` / `gemini` run started by
|
||||
// the user inside the same directory after a Multica task would pick up
|
||||
// the stale brief and act on the previous task's issue id, trigger
|
||||
// comment id, and reply rules. Cloud workspace runs never trigger this
|
||||
// pollution because their workdir is daemon scratch that the GC loop
|
||||
// deletes wholesale; the daemon skips this Cleanup on those workdirs.
|
||||
//
|
||||
// Missing files, unknown providers, and files without a marker block are
|
||||
// no-ops — Cleanup is safe to call defensively.
|
||||
func CleanupRuntimeConfig(workDir, provider string) error {
|
||||
path := runtimeConfigPath(workDir, provider)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
existing, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read runtime config %s: %w", path, err)
|
||||
}
|
||||
existingStr := string(existing)
|
||||
start, end, ok := locateMarkerBlock(existingStr)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
pre := existingStr[:start]
|
||||
post := existingStr[end:]
|
||||
|
||||
// Detect — and strip — the fixed managed separator that Inject puts
|
||||
// immediately before the block whenever it appended to a file that
|
||||
// pre-existed. The absence of the separator is the marker that says
|
||||
// "Inject created this file from scratch", which is the only case
|
||||
// where Cleanup is allowed to delete the file.
|
||||
hadManagedSeparator := strings.HasSuffix(pre, runtimeManagedSeparator)
|
||||
if hadManagedSeparator {
|
||||
pre = pre[:len(pre)-len(runtimeManagedSeparator)]
|
||||
}
|
||||
remainder := pre + post
|
||||
|
||||
if !hadManagedSeparator && remainder == "" {
|
||||
// Inject created the file (no managed separator → block was the
|
||||
// only content). Restore the missing-file state.
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("remove runtime config %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// File pre-existed (possibly empty, possibly whitespace-only,
|
||||
// possibly with user content) — write the remainder back exactly,
|
||||
// without any normalisation. An empty `remainder` here means the
|
||||
// user's original file was empty; we still write it (zero-byte file)
|
||||
// so the file's existence is preserved.
|
||||
return os.WriteFile(path, []byte(remainder), 0o644)
|
||||
}
|
||||
|
||||
// buildMetaSkillContent generates the meta skill markdown that teaches the agent
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package execenv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -382,3 +385,793 @@ func TestSubIssueCreationSectionSkippedForNonIssueModes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeRuntimeConfigFile is the safe replacement for the previous
|
||||
// unconditional os.WriteFile of CLAUDE.md / AGENTS.md / GEMINI.md. The three
|
||||
// states it must handle correctly are: file missing, file present without
|
||||
// markers (user-authored content already there — the regression case from
|
||||
// MUL-2753), and file present with markers (idempotent second-run replace).
|
||||
|
||||
func TestWriteRuntimeConfigFileCreatesMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
const brief = "# Multica Agent Runtime\n\nbrief body line"
|
||||
|
||||
if err := writeRuntimeConfigFile(path, brief); err != nil {
|
||||
t.Fatalf("writeRuntimeConfigFile returned error: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back file: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
if !strings.HasPrefix(s, runtimeMarkerBegin+"\n") {
|
||||
t.Errorf("output should start with begin marker, got:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, brief) {
|
||||
t.Errorf("output should contain brief body, got:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, "\n"+runtimeMarkerEnd+"\n") {
|
||||
t.Errorf("output should contain end marker followed by newline, got:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRuntimeConfigFilePreservesUserContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
const userContent = "# User repo CLAUDE.md\n\n- rule one\n- rule two\n"
|
||||
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
|
||||
t.Fatalf("seed user file: %v", err)
|
||||
}
|
||||
|
||||
const brief = "## Multica brief\n\ninjected body"
|
||||
if err := writeRuntimeConfigFile(path, brief); err != nil {
|
||||
t.Fatalf("writeRuntimeConfigFile returned error: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back file: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
// The user's original content must be untouched and appear before the
|
||||
// injected marker block; this is the core regression case from MUL-2753.
|
||||
if !strings.HasPrefix(s, userContent) {
|
||||
t.Errorf("user content must be preserved verbatim at the top of the file, got:\n%s", s)
|
||||
}
|
||||
beginIdx := strings.Index(s, runtimeMarkerBegin)
|
||||
endIdx := strings.Index(s, runtimeMarkerEnd)
|
||||
if beginIdx < 0 || endIdx <= beginIdx {
|
||||
t.Fatalf("expected a well-formed marker block in:\n%s", s)
|
||||
}
|
||||
if beginIdx < len(userContent) {
|
||||
t.Errorf("begin marker must appear after user content, beginIdx=%d userLen=%d", beginIdx, len(userContent))
|
||||
}
|
||||
if !strings.Contains(s, brief) {
|
||||
t.Errorf("brief body missing from output:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRuntimeConfigFileReplacesExistingBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "AGENTS.md")
|
||||
const userBefore = "# User AGENTS.md\n\nuser line above\n"
|
||||
const userAfter = "\nuser line below the block\n"
|
||||
original := userBefore +
|
||||
runtimeMarkerBegin + "\n" +
|
||||
"OLD BRIEF CONTENT THAT MUST GO AWAY\n" +
|
||||
runtimeMarkerEnd + "\n" +
|
||||
userAfter
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
const newBrief = "## New Multica brief\n\nfresh body"
|
||||
if err := writeRuntimeConfigFile(path, newBrief); err != nil {
|
||||
t.Fatalf("writeRuntimeConfigFile returned error: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back file: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
if !strings.HasPrefix(s, userBefore) {
|
||||
t.Errorf("content above the marker block must be preserved, got:\n%s", s)
|
||||
}
|
||||
if !strings.HasSuffix(s, userAfter) {
|
||||
t.Errorf("content below the marker block must be preserved, got:\n%s", s)
|
||||
}
|
||||
if strings.Contains(s, "OLD BRIEF CONTENT THAT MUST GO AWAY") {
|
||||
t.Errorf("previous block body must be replaced, got:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, newBrief) {
|
||||
t.Errorf("new brief body missing from output:\n%s", s)
|
||||
}
|
||||
if strings.Count(s, runtimeMarkerBegin) != 1 || strings.Count(s, runtimeMarkerEnd) != 1 {
|
||||
t.Errorf("there must be exactly one begin/end marker pair, got:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRuntimeConfigFileIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
const userContent = "# User CLAUDE.md\n\nimportant rules\n"
|
||||
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
|
||||
t.Fatalf("seed user file: %v", err)
|
||||
}
|
||||
|
||||
const brief = "## Multica brief\n\nbody"
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := writeRuntimeConfigFile(path, brief); err != nil {
|
||||
t.Fatalf("iteration %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back file: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
if strings.Count(s, runtimeMarkerBegin) != 1 {
|
||||
t.Errorf("repeated runs must not duplicate the begin marker, count=%d, file:\n%s", strings.Count(s, runtimeMarkerBegin), s)
|
||||
}
|
||||
if strings.Count(s, runtimeMarkerEnd) != 1 {
|
||||
t.Errorf("repeated runs must not duplicate the end marker, count=%d, file:\n%s", strings.Count(s, runtimeMarkerEnd), s)
|
||||
}
|
||||
if strings.Count(s, brief) != 1 {
|
||||
t.Errorf("repeated runs must not duplicate the brief body, count=%d, file:\n%s", strings.Count(s, brief), s)
|
||||
}
|
||||
if !strings.HasPrefix(s, userContent) {
|
||||
t.Errorf("user content must remain intact at the top of the file, got:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectRuntimeConfig is the production entry point — verify the marker
|
||||
// semantics propagate through it for each provider's target filename.
|
||||
func TestInjectRuntimeConfigPreservesUserContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
provider string
|
||||
filename string
|
||||
}{
|
||||
{"claude", "CLAUDE.md"},
|
||||
{"codex", "AGENTS.md"},
|
||||
{"copilot", "AGENTS.md"},
|
||||
{"opencode", "AGENTS.md"},
|
||||
{"openclaw", "AGENTS.md"},
|
||||
{"hermes", "AGENTS.md"},
|
||||
{"pi", "AGENTS.md"},
|
||||
{"cursor", "AGENTS.md"},
|
||||
{"kimi", "AGENTS.md"},
|
||||
{"kiro", "AGENTS.md"},
|
||||
{"antigravity", "AGENTS.md"},
|
||||
{"gemini", "GEMINI.md"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.provider, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, tc.filename)
|
||||
const userContent = "# User-authored file\n\ndon't touch this\n"
|
||||
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
content, err := InjectRuntimeConfig(dir, tc.provider, TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig: %v", err)
|
||||
}
|
||||
if content == "" {
|
||||
t.Fatalf("returned brief content must be non-empty")
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
if !strings.HasPrefix(s, userContent) {
|
||||
t.Errorf("[%s] user content must be preserved verbatim at the top of %s, got:\n%s", tc.provider, tc.filename, s)
|
||||
}
|
||||
if !strings.Contains(s, runtimeMarkerBegin) || !strings.Contains(s, runtimeMarkerEnd) {
|
||||
t.Errorf("[%s] %s must contain the runtime marker block, got:\n%s", tc.provider, tc.filename, s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigUnknownProviderSkipsWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
// Seed all three candidate filenames so we can verify none of them get
|
||||
// written when the provider is unknown.
|
||||
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("untouched\n"), 0o644); err != nil {
|
||||
t.Fatalf("seed %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := InjectRuntimeConfig(dir, "totally-unknown-provider", TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
}); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig: %v", err)
|
||||
}
|
||||
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
|
||||
got, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
if string(got) != "untouched\n" {
|
||||
t.Errorf("unknown provider must not write %s; got:\n%s", name, string(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parser hardening: the end marker must be found strictly after the begin
|
||||
// marker so a stray end marker that appears earlier in user content (e.g.
|
||||
// a documentation snippet showing what the wire format looks like) doesn't
|
||||
// trick writeRuntimeConfigFile into thinking the file is malformed and
|
||||
// appending another block on every run.
|
||||
func TestWriteRuntimeConfigFileIgnoresStrayEndMarkerBeforeBegin(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
|
||||
// Seed a file whose user-authored portion documents the marker format
|
||||
// (so the *end* marker appears before any *begin* marker), then has a
|
||||
// real block authored by an earlier Multica run below.
|
||||
const userDoc = "# Repo CLAUDE.md\n\nExample of what Multica writes:\n" +
|
||||
runtimeMarkerEnd + "\n\n# Real config below\n"
|
||||
original := userDoc +
|
||||
runtimeMarkerBegin + "\nFIRST BRIEF\n" + runtimeMarkerEnd + "\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
const newBrief = "SECOND BRIEF"
|
||||
if err := writeRuntimeConfigFile(path, newBrief); err != nil {
|
||||
t.Fatalf("writeRuntimeConfigFile: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
|
||||
// The user's stray end marker line plus surrounding doc text must still
|
||||
// be present, and the file must contain exactly one begin marker and
|
||||
// one *additional* end marker (so two end markers total — the stray
|
||||
// one and the one closing our block).
|
||||
if !strings.Contains(s, userDoc) {
|
||||
t.Errorf("user doc with stray end marker must be preserved verbatim, got:\n%s", s)
|
||||
}
|
||||
if got, want := strings.Count(s, runtimeMarkerBegin), 1; got != want {
|
||||
t.Errorf("expected exactly %d begin markers, got %d:\n%s", want, got, s)
|
||||
}
|
||||
if got, want := strings.Count(s, runtimeMarkerEnd), 2; got != want {
|
||||
t.Errorf("expected exactly %d end markers (1 user stray + 1 closing our block), got %d:\n%s", want, got, s)
|
||||
}
|
||||
if strings.Contains(s, "FIRST BRIEF") {
|
||||
t.Errorf("previous brief body must be replaced, got:\n%s", s)
|
||||
}
|
||||
if !strings.Contains(s, newBrief) {
|
||||
t.Errorf("new brief body missing from output:\n%s", s)
|
||||
}
|
||||
|
||||
// Idempotency under the stray-end pattern: a second write must not
|
||||
// stack another block.
|
||||
if err := writeRuntimeConfigFile(path, newBrief); err != nil {
|
||||
t.Fatalf("second writeRuntimeConfigFile: %v", err)
|
||||
}
|
||||
got2, _ := os.ReadFile(path)
|
||||
s2 := string(got2)
|
||||
if got, want := strings.Count(s2, runtimeMarkerBegin), 1; got != want {
|
||||
t.Errorf("repeat write must not grow begin markers, got %d, want %d:\n%s", got, want, s2)
|
||||
}
|
||||
}
|
||||
|
||||
// Parser hardening: a file containing only a begin marker (e.g. a previous
|
||||
// run that crashed mid-write) must not cause every subsequent run to stack
|
||||
// another block beneath the half-block.
|
||||
func TestWriteRuntimeConfigFileReplacesMalformedHalfBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "AGENTS.md")
|
||||
|
||||
const userTop = "# Repo AGENTS.md\n\nrules above\n"
|
||||
const halfBlock = "leftover from crashed write\nsecond line\n"
|
||||
original := userTop + runtimeMarkerBegin + "\n" + halfBlock
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
const newBrief = "recovered brief"
|
||||
if err := writeRuntimeConfigFile(path, newBrief); err != nil {
|
||||
t.Fatalf("writeRuntimeConfigFile: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
if !strings.HasPrefix(s, userTop) {
|
||||
t.Errorf("user content above the half-block must be preserved, got:\n%s", s)
|
||||
}
|
||||
if strings.Contains(s, "leftover from crashed write") {
|
||||
t.Errorf("half-block contents must be replaced, got:\n%s", s)
|
||||
}
|
||||
if got, want := strings.Count(s, runtimeMarkerBegin), 1; got != want {
|
||||
t.Errorf("expected exactly %d begin marker, got %d:\n%s", want, got, s)
|
||||
}
|
||||
if got, want := strings.Count(s, runtimeMarkerEnd), 1; got != want {
|
||||
t.Errorf("expected exactly %d end marker after recovery, got %d:\n%s", want, got, s)
|
||||
}
|
||||
if !strings.Contains(s, newBrief) {
|
||||
t.Errorf("new brief body missing from output:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup excises the marker block, preserving every byte of surrounding
|
||||
// user content. This is the local_directory invariant: a `claude` /
|
||||
// `codex` run started by the user after a Multica task must see the same
|
||||
// file the user wrote.
|
||||
func TestCleanupRuntimeConfigPreservesUserContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
|
||||
const userBefore = "# Repo CLAUDE.md\n\nuser line above\n"
|
||||
const userAfter = "\nuser line below the block\n"
|
||||
const userExpected = "# Repo CLAUDE.md\n\nuser line above\n\nuser line below the block\n"
|
||||
// Inject via the production write path so we exercise the actual
|
||||
// marker block format, not a hand-rolled approximation.
|
||||
if err := os.WriteFile(path, []byte(userBefore+userAfter), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := writeRuntimeConfigFile(path, "brief body"); err != nil {
|
||||
t.Fatalf("seed brief: %v", err)
|
||||
}
|
||||
|
||||
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
|
||||
t.Fatalf("CleanupRuntimeConfig: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
if strings.Contains(s, runtimeMarkerBegin) || strings.Contains(s, runtimeMarkerEnd) {
|
||||
t.Errorf("marker block must be removed, got:\n%s", s)
|
||||
}
|
||||
if strings.Contains(s, "brief body") {
|
||||
t.Errorf("brief body must be removed, got:\n%s", s)
|
||||
}
|
||||
if s != userExpected {
|
||||
t.Errorf("user content must be preserved byte-for-byte\n got:\n%q\nwant:\n%q", s, userExpected)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup removes the file entirely when the marker block was the only
|
||||
// content — i.e. we created the file from scratch in a directory that had
|
||||
// no pre-existing CLAUDE.md / AGENTS.md / GEMINI.md.
|
||||
func TestCleanupRuntimeConfigRemovesFileWhenOnlyBlockRemained(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
|
||||
// No seed — writeRuntimeConfigFile creates the file with only the
|
||||
// marker block inside.
|
||||
if err := writeRuntimeConfigFile(path, "brief body"); err != nil {
|
||||
t.Fatalf("seed brief: %v", err)
|
||||
}
|
||||
|
||||
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
|
||||
t.Fatalf("CleanupRuntimeConfig: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("expected file to be removed, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup is a no-op when no marker block exists or when the file is
|
||||
// missing — Cleanup is safe to call defensively from the daemon's defer.
|
||||
func TestCleanupRuntimeConfigNoOpCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("missing file", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
|
||||
t.Errorf("missing file must be no-op, got: %v", err)
|
||||
}
|
||||
// And the directory must remain untouched.
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readdir: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("expected dir to remain empty, got: %v", entries)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file without marker block", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
const userContent = "# Repo CLAUDE.md\n\nrules\n"
|
||||
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
|
||||
t.Errorf("no-marker-block file must be no-op, got: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if string(got) != userContent {
|
||||
t.Errorf("file must be untouched\n got:\n%q\nwant:\n%q", string(got), userContent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown provider", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
// Seed every candidate filename to verify none of them get touched.
|
||||
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("untouched\n"), 0o644); err != nil {
|
||||
t.Fatalf("seed %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := CleanupRuntimeConfig(dir, "totally-unknown-provider"); err != nil {
|
||||
t.Errorf("unknown provider must be no-op, got: %v", err)
|
||||
}
|
||||
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
|
||||
got, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
if string(got) != "untouched\n" {
|
||||
t.Errorf("unknown provider must not touch %s; got:\n%s", name, string(got))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Cleanup must handle a half-block left by a previous crashed run: begin
|
||||
// marker present but no end. Otherwise the half-block would survive
|
||||
// cleanup and pollute the next manual CLI invocation in the same dir.
|
||||
func TestCleanupRuntimeConfigRemovesMalformedHalfBlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "AGENTS.md")
|
||||
|
||||
const userTop = "# Repo AGENTS.md\n\nrules\n"
|
||||
original := userTop + runtimeMarkerBegin + "\nhalf-written brief no end\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if err := CleanupRuntimeConfig(dir, "codex"); err != nil {
|
||||
t.Fatalf("CleanupRuntimeConfig: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
if strings.Contains(s, runtimeMarkerBegin) {
|
||||
t.Errorf("half-block begin marker must be excised, got:\n%s", s)
|
||||
}
|
||||
if strings.Contains(s, "half-written brief no end") {
|
||||
t.Errorf("half-block body must be excised, got:\n%s", s)
|
||||
}
|
||||
if !strings.HasPrefix(s, userTop) {
|
||||
t.Errorf("user content above the half-block must remain, got:\n%s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup must remove the marker block for every provider's target file,
|
||||
// using the same provider→filename mapping as InjectRuntimeConfig — so a
|
||||
// new provider added to one side cannot drift past the other.
|
||||
func TestCleanupRuntimeConfigByProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
provider string
|
||||
filename string
|
||||
}{
|
||||
{"claude", "CLAUDE.md"},
|
||||
{"codex", "AGENTS.md"},
|
||||
{"copilot", "AGENTS.md"},
|
||||
{"opencode", "AGENTS.md"},
|
||||
{"openclaw", "AGENTS.md"},
|
||||
{"hermes", "AGENTS.md"},
|
||||
{"pi", "AGENTS.md"},
|
||||
{"cursor", "AGENTS.md"},
|
||||
{"kimi", "AGENTS.md"},
|
||||
{"kiro", "AGENTS.md"},
|
||||
{"antigravity", "AGENTS.md"},
|
||||
{"gemini", "GEMINI.md"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.provider, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, tc.filename)
|
||||
const userContent = "# User file\n\ndon't touch this\n"
|
||||
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// Inject through the production path so cleanup runs against
|
||||
// the same wire format the agent saw.
|
||||
if _, err := InjectRuntimeConfig(dir, tc.provider, TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
}); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig: %v", err)
|
||||
}
|
||||
if err := CleanupRuntimeConfig(dir, tc.provider); err != nil {
|
||||
t.Fatalf("CleanupRuntimeConfig: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
if strings.Contains(s, runtimeMarkerBegin) || strings.Contains(s, runtimeMarkerEnd) {
|
||||
t.Errorf("[%s] marker block must be removed from %s, got:\n%s", tc.provider, tc.filename, s)
|
||||
}
|
||||
if s != userContent {
|
||||
t.Errorf("[%s] user content in %s must be preserved byte-for-byte\n got:\n%q\nwant:\n%q", tc.provider, tc.filename, s, userContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Inject → Cleanup → manual edit → Inject must converge back to the
|
||||
// pre-injection state on the next Cleanup. This is the end-to-end
|
||||
// regression that locks in: the user's repo is byte-identical to what
|
||||
// they had before the task, every task cycle.
|
||||
func TestInjectThenCleanupRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
const userContent = "# User-authored CLAUDE.md\n\n- rule A\n- rule B\n"
|
||||
if err := os.WriteFile(path, []byte(userContent), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// Two full inject→cleanup cycles — covers both the "first task on a
|
||||
// fresh user file" path and the "subsequent task hits a clean file
|
||||
// again" path.
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
}); err != nil {
|
||||
t.Fatalf("iter %d inject: %v", i, err)
|
||||
}
|
||||
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
|
||||
t.Fatalf("iter %d cleanup: %v", i, err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d read back: %v", i, err)
|
||||
}
|
||||
if string(got) != userContent {
|
||||
t.Errorf("iter %d: user file must be byte-identical to pre-injection state\n got:\n%q\nwant:\n%q", i, string(got), userContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Byte-exact boundary coverage flagged in PR #3438 review (Elon): the
|
||||
// previous cleanup used TrimRight + "\n" and TrimSpace-based file removal,
|
||||
// which created a real diff in three boundary cases. The table walks each
|
||||
// one through a full inject→cleanup cycle and asserts the file ends up
|
||||
// byte-identical (or, for missing-file, that it stays missing).
|
||||
func TestInjectThenCleanupRoundTripByteExactBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
// seed describes the pre-inject filesystem state. When seedExists
|
||||
// is false the file is absent; when true the file is created with
|
||||
// seedContent (which may be empty / whitespace-only / arbitrary
|
||||
// bytes).
|
||||
seedExists bool
|
||||
seedContent string
|
||||
}{
|
||||
{
|
||||
name: "file missing — Inject creates, Cleanup removes",
|
||||
seedExists: false,
|
||||
seedContent: "",
|
||||
},
|
||||
{
|
||||
name: "pre-existing empty file (zero bytes)",
|
||||
seedExists: true,
|
||||
seedContent: "",
|
||||
},
|
||||
{
|
||||
name: "pre-existing whitespace-only file",
|
||||
seedExists: true,
|
||||
seedContent: " \n",
|
||||
},
|
||||
{
|
||||
name: "no trailing newline",
|
||||
seedExists: true,
|
||||
seedContent: "rules",
|
||||
},
|
||||
{
|
||||
name: "one trailing newline (the common markdown shape)",
|
||||
seedExists: true,
|
||||
seedContent: "# Rules\n\nbody\n",
|
||||
},
|
||||
{
|
||||
name: "two trailing newlines",
|
||||
seedExists: true,
|
||||
seedContent: "rules\n\n",
|
||||
},
|
||||
{
|
||||
name: "many trailing newlines",
|
||||
seedExists: true,
|
||||
seedContent: "rules\n\n\n\n",
|
||||
},
|
||||
{
|
||||
name: "CRLF line endings",
|
||||
seedExists: true,
|
||||
seedContent: "rule A\r\nrule B\r\n",
|
||||
},
|
||||
{
|
||||
name: "no final newline AND embedded blank lines",
|
||||
seedExists: true,
|
||||
seedContent: "para 1\n\npara 2\n\npara 3",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
|
||||
if tc.seedExists {
|
||||
if err := os.WriteFile(path, []byte(tc.seedContent), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two cycles to cover both "first inject hits user file" and
|
||||
// "subsequent inject hits a cleaned file" paths.
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
}); err != nil {
|
||||
t.Fatalf("iter %d inject: %v", i, err)
|
||||
}
|
||||
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
|
||||
t.Fatalf("iter %d cleanup: %v", i, err)
|
||||
}
|
||||
|
||||
if !tc.seedExists {
|
||||
// Missing file must remain missing after the cycle so
|
||||
// the user's directory listing is also byte-identical
|
||||
// (no zero-byte stub left behind).
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("iter %d: file must remain missing, stat err=%v", i, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d read back: %v", i, err)
|
||||
}
|
||||
if string(got) != tc.seedContent {
|
||||
t.Errorf("iter %d: file must be byte-identical to seed\n got: %q\n want: %q", i, string(got), tc.seedContent)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotency across the byte-exact boundaries: when a second Inject runs
|
||||
// against a file that already carries a marker block (the "replace in
|
||||
// place" branch), the surrounding bytes must stay untouched and the
|
||||
// subsequent Cleanup must still restore the user's original file
|
||||
// byte-exactly. This guards against a regression where the replace path
|
||||
// would re-normalise pre/post bytes the way the old cleanup did.
|
||||
func TestInjectReplaceThenCleanupRestoresByteExact(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
seedContent string
|
||||
}{
|
||||
{name: "no trailing newline", seedContent: "rules"},
|
||||
{name: "two trailing newlines", seedContent: "rules\n\n"},
|
||||
{name: "empty file", seedContent: ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
if err := os.WriteFile(path, []byte(tc.seedContent), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// First inject — append path.
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
}); err != nil {
|
||||
t.Fatalf("first inject: %v", err)
|
||||
}
|
||||
// Second inject — replace-in-place path.
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{
|
||||
IssueID: "11111111-2222-3333-4444-555555555555",
|
||||
}); err != nil {
|
||||
t.Fatalf("second inject: %v", err)
|
||||
}
|
||||
if err := CleanupRuntimeConfig(dir, "claude"); err != nil {
|
||||
t.Fatalf("cleanup: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if string(got) != tc.seedContent {
|
||||
t.Errorf("file must be byte-identical to seed after replace+cleanup\n got: %q\n want: %q", string(got), tc.seedContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The fixed managed separator is the invariant that makes byte-exact
|
||||
// cleanup possible. This test pins it: writeRuntimeConfigFile must
|
||||
// produce exactly `<user-bytes><\n\n><marker-block>` for ANY non-empty
|
||||
// or empty pre-existing file, with no trailing-newline normalisation.
|
||||
func TestWriteRuntimeConfigFileAlwaysInsertsFixedManagedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, seed := range []string{"", "rules", "rules\n", "rules\n\n", "rules\n\n\n\n"} {
|
||||
seed := seed
|
||||
t.Run(fmt.Sprintf("seed=%q", seed), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CLAUDE.md")
|
||||
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := writeRuntimeConfigFile(path, "brief body"); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
s := string(got)
|
||||
// The seed must appear verbatim at the start of the file —
|
||||
// no extra newline appended, no trailing newline trimmed.
|
||||
if !strings.HasPrefix(s, seed) {
|
||||
t.Errorf("seed bytes must survive verbatim at the start of the file\n got: %q\n seed: %q", s, seed)
|
||||
}
|
||||
// Immediately after the seed we must see the fixed managed
|
||||
// separator, then the begin marker.
|
||||
markerStart := len(seed) + len(runtimeManagedSeparator)
|
||||
if len(s) < markerStart+len(runtimeMarkerBegin) {
|
||||
t.Fatalf("file shorter than expected layout\n got: %q", s)
|
||||
}
|
||||
if got, want := s[len(seed):markerStart], runtimeManagedSeparator; got != want {
|
||||
t.Errorf("expected managed separator %q immediately after seed, got %q", want, got)
|
||||
}
|
||||
if got, want := s[markerStart:markerStart+len(runtimeMarkerBegin)], runtimeMarkerBegin; got != want {
|
||||
t.Errorf("expected begin marker after managed separator, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user