mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* feat: add backend default agent args env vars * docs: document default agent args env vars
739 lines
23 KiB
Go
739 lines
23 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/internal/daemon/repocache"
|
|
"github.com/multica-ai/multica/server/pkg/agent"
|
|
)
|
|
|
|
func createDaemonTestRepo(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for _, args := range [][]string{
|
|
{"init", dir},
|
|
{"-C", dir, "commit", "--allow-empty", "-m", "initial"},
|
|
} {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com",
|
|
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com",
|
|
)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git setup failed: %s: %v", out, err)
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestNormalizeServerBaseURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := NormalizeServerBaseURL("ws://localhost:8080/ws")
|
|
if err != nil {
|
|
t.Fatalf("NormalizeServerBaseURL returned error: %v", err)
|
|
}
|
|
if got != "http://localhost:8080" {
|
|
t.Fatalf("expected http://localhost:8080, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildPromptContainsIssueID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
issueID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
prompt := BuildPrompt(Task{
|
|
IssueID: issueID,
|
|
Agent: &AgentData{
|
|
Name: "Local Codex",
|
|
Skills: []SkillData{
|
|
{Name: "Concise", Content: "Be concise."},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Prompt should contain the issue ID and CLI hint.
|
|
for _, want := range []string{
|
|
issueID,
|
|
"multica issue get",
|
|
} {
|
|
if !strings.Contains(prompt, want) {
|
|
t.Fatalf("prompt missing %q", want)
|
|
}
|
|
}
|
|
|
|
// Skills should NOT be inlined in the prompt (they're in runtime config).
|
|
for _, absent := range []string{"## Agent Skills", "Be concise."} {
|
|
if strings.Contains(prompt, absent) {
|
|
t.Fatalf("prompt should NOT contain %q (skills are in runtime config)", absent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildPromptNoIssueDetails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prompt := BuildPrompt(Task{
|
|
IssueID: "test-id",
|
|
Agent: &AgentData{Name: "Test"},
|
|
})
|
|
|
|
// Prompt should not contain issue title/description (agent fetches via CLI).
|
|
for _, absent := range []string{"**Issue:**", "**Summary:**"} {
|
|
if strings.Contains(prompt, absent) {
|
|
t.Fatalf("prompt should NOT contain %q — agent fetches details via CLI", absent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildPromptAutopilotRunOnly(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prompt := BuildPrompt(Task{
|
|
AutopilotRunID: "run-1",
|
|
AutopilotID: "autopilot-1",
|
|
AutopilotTitle: "Daily dependency check",
|
|
AutopilotDescription: "Check dependencies and report outdated packages.",
|
|
AutopilotSource: "manual",
|
|
})
|
|
|
|
for _, want := range []string{
|
|
"run-only mode",
|
|
"Autopilot run ID: run-1",
|
|
"Daily dependency check",
|
|
"Check dependencies and report outdated packages.",
|
|
"multica autopilot get autopilot-1 --output json",
|
|
"Do not run `multica issue get`",
|
|
} {
|
|
if !strings.Contains(prompt, want) {
|
|
t.Fatalf("autopilot prompt missing %q\n---\n%s", want, prompt)
|
|
}
|
|
}
|
|
|
|
if strings.Contains(prompt, "Your assigned issue ID is:") {
|
|
t.Fatalf("autopilot prompt should not use issue assignment template\n---\n%s", prompt)
|
|
}
|
|
}
|
|
|
|
func TestBuildPromptCommentTriggered(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
issueID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
commentID := "c1c2c3c4-d5d6-7890-abcd-ef1234567890"
|
|
commentContent := "请把报告翻译成英文"
|
|
|
|
prompt := BuildPrompt(Task{
|
|
IssueID: issueID,
|
|
TriggerCommentID: commentID,
|
|
TriggerCommentContent: commentContent,
|
|
Agent: &AgentData{Name: "Test"},
|
|
})
|
|
|
|
// Prompt should contain the comment content, the trigger comment id, and
|
|
// the full reply command with --parent. Re-emitting --parent on every turn
|
|
// is what prevents resumed sessions from reusing the previous turn's
|
|
// --parent UUID.
|
|
for _, want := range []string{
|
|
issueID,
|
|
commentContent,
|
|
"Focus on THIS comment",
|
|
commentID,
|
|
"multica issue comment add " + issueID + " --parent " + commentID,
|
|
"do NOT reuse --parent values from previous turns",
|
|
// Silence-as-valid-exit for agent-to-agent loops depends on the
|
|
// reply command being framed conditionally rather than as a hard
|
|
// requirement. Guard the phrasing so the conflict with the new
|
|
// workflow (MUL-1323) doesn't come back.
|
|
"If you decide to reply",
|
|
} {
|
|
if !strings.Contains(prompt, want) {
|
|
t.Fatalf("prompt missing %q\n---\n%s", want, prompt)
|
|
}
|
|
}
|
|
|
|
// Should still contain CLI hint for fetching issue context.
|
|
if !strings.Contains(prompt, "multica issue get") {
|
|
t.Fatal("prompt missing CLI hint for issue context")
|
|
}
|
|
}
|
|
|
|
// TestBuildPromptCommentTriggeredByAgent covers the agent-to-agent mention
|
|
// loop signal injected into the per-turn prompt (MUL-1323 / GH#1576). When
|
|
// the triggering comment was posted by another agent, the prompt must name
|
|
// the author, warn against sign-off @mentions, and point at silence as a
|
|
// valid exit.
|
|
func TestBuildPromptCommentTriggeredByAgent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prompt := BuildPrompt(Task{
|
|
IssueID: "issue-1",
|
|
TriggerCommentID: "comment-1",
|
|
TriggerCommentContent: "thanks, looks good!",
|
|
TriggerAuthorType: "agent",
|
|
TriggerAuthorName: "Atlas",
|
|
Agent: &AgentData{Name: "Test"},
|
|
})
|
|
|
|
for _, want := range []string{
|
|
"Another agent (Atlas)",
|
|
"do not @mention the other agent as a sign-off",
|
|
"Silence is the preferred way",
|
|
} {
|
|
if !strings.Contains(prompt, want) {
|
|
t.Fatalf("prompt missing %q\n---\n%s", want, prompt)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuildPromptCommentTriggeredByMember guards against the agent-loop warning
|
|
// leaking into human-authored triggers — a human asking a question should not
|
|
// be pre-discouraged from getting a reply.
|
|
func TestBuildPromptCommentTriggeredByMember(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prompt := BuildPrompt(Task{
|
|
IssueID: "issue-1",
|
|
TriggerCommentID: "comment-1",
|
|
TriggerCommentContent: "can you translate this?",
|
|
TriggerAuthorType: "member",
|
|
TriggerAuthorName: "Alice",
|
|
Agent: &AgentData{Name: "Test"},
|
|
})
|
|
|
|
if !strings.Contains(prompt, "A user just left a new comment") {
|
|
t.Fatalf("member-triggered prompt should label the author as a user\n---\n%s", prompt)
|
|
}
|
|
if strings.Contains(prompt, "Another agent") {
|
|
t.Fatalf("member-triggered prompt should not claim the author was another agent")
|
|
}
|
|
// Must NOT use the old "You MUST respond" language — that conflicts with
|
|
// the agent-to-agent silence-as-valid-exit workflow. Even on human-authored
|
|
// triggers, the reply command is framed conditionally for a single
|
|
// consistent rule across turn types.
|
|
if strings.Contains(prompt, "MUST respond") {
|
|
t.Fatalf("prompt should not contain unconditional \"MUST respond\" language\n---\n%s", prompt)
|
|
}
|
|
if !strings.Contains(prompt, "If you decide to reply") {
|
|
t.Fatalf("prompt should frame the reply command conditionally\n---\n%s", prompt)
|
|
}
|
|
}
|
|
|
|
func TestBuildPromptCommentTriggeredNoContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// When TriggerCommentID is set but content is empty (e.g. fetch failed),
|
|
// it should still use the comment prompt path.
|
|
prompt := BuildPrompt(Task{
|
|
IssueID: "test-id",
|
|
TriggerCommentID: "comment-id",
|
|
Agent: &AgentData{Name: "Test"},
|
|
})
|
|
|
|
if !strings.Contains(prompt, "multica issue get") {
|
|
t.Fatal("prompt missing CLI hint")
|
|
}
|
|
}
|
|
|
|
func TestIsWorkspaceNotFoundError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := &requestError{
|
|
Method: http.MethodPost,
|
|
Path: "/api/daemon/register",
|
|
StatusCode: http.StatusNotFound,
|
|
Body: `{"error":"workspace not found"}`,
|
|
}
|
|
if !isWorkspaceNotFoundError(err) {
|
|
t.Fatal("expected workspace not found error to be recognized")
|
|
}
|
|
|
|
if isWorkspaceNotFoundError(&requestError{StatusCode: http.StatusInternalServerError, Body: `{"error":"workspace not found"}`}) {
|
|
t.Fatal("did not expect 500 to be treated as workspace not found")
|
|
}
|
|
}
|
|
|
|
func TestMergeUsage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
a := map[string]agent.TokenUsage{
|
|
"model-a": {InputTokens: 10, OutputTokens: 5},
|
|
}
|
|
b := map[string]agent.TokenUsage{
|
|
"model-a": {InputTokens: 20, OutputTokens: 10, CacheReadTokens: 3},
|
|
"model-b": {InputTokens: 100},
|
|
}
|
|
merged := mergeUsage(a, b)
|
|
|
|
if got := merged["model-a"]; got.InputTokens != 30 || got.OutputTokens != 15 || got.CacheReadTokens != 3 {
|
|
t.Fatalf("model-a: expected {30,15,3,0}, got %+v", got)
|
|
}
|
|
if got := merged["model-b"]; got.InputTokens != 100 {
|
|
t.Fatalf("model-b: expected InputTokens=100, got %+v", got)
|
|
}
|
|
|
|
if got := mergeUsage(nil, b); len(got) != 2 {
|
|
t.Fatal("mergeUsage(nil, b) should return b")
|
|
}
|
|
if got := mergeUsage(a, nil); len(got) != 1 {
|
|
t.Fatal("mergeUsage(a, nil) should return a")
|
|
}
|
|
}
|
|
|
|
// fakeBackend is a test double for agent.Backend that returns preconfigured
|
|
// results. Each call to Execute pops the next entry from the results slice.
|
|
type fakeBackend struct {
|
|
calls []agent.ExecOptions
|
|
results []agent.Result
|
|
errors []error
|
|
idx atomic.Int32
|
|
}
|
|
|
|
func (b *fakeBackend) Execute(_ context.Context, _ string, opts agent.ExecOptions) (*agent.Session, error) {
|
|
i := int(b.idx.Add(1)) - 1
|
|
b.calls = append(b.calls, opts)
|
|
if i < len(b.errors) && b.errors[i] != nil {
|
|
return nil, b.errors[i]
|
|
}
|
|
msgCh := make(chan agent.Message)
|
|
resCh := make(chan agent.Result, 1)
|
|
close(msgCh)
|
|
resCh <- b.results[i]
|
|
return &agent.Session{Messages: msgCh, Result: resCh}, nil
|
|
}
|
|
|
|
func newTestDaemon(t *testing.T) *Daemon {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return &Daemon{
|
|
client: NewClient(srv.URL),
|
|
logger: slog.Default(),
|
|
}
|
|
}
|
|
|
|
func newRepoReadyTestDaemon(t *testing.T, handler http.HandlerFunc) *Daemon {
|
|
t.Helper()
|
|
srv := httptest.NewServer(handler)
|
|
t.Cleanup(srv.Close)
|
|
return &Daemon{
|
|
client: NewClient(srv.URL),
|
|
repoCache: repocache.New(t.TempDir(), slog.Default()),
|
|
logger: slog.Default(),
|
|
workspaces: make(map[string]*workspaceState),
|
|
runtimeIndex: make(map[string]Runtime),
|
|
}
|
|
}
|
|
|
|
func TestExecuteAndDrain_ResumeFailureFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newTestDaemon(t)
|
|
ctx := context.Background()
|
|
taskLog := slog.Default()
|
|
|
|
fb := &fakeBackend{
|
|
results: []agent.Result{
|
|
{Status: "failed", Error: "session not found", Usage: map[string]agent.TokenUsage{
|
|
"m1": {InputTokens: 5},
|
|
}},
|
|
{Status: "completed", Output: "done", SessionID: "new-sess", Usage: map[string]agent.TokenUsage{
|
|
"m1": {InputTokens: 10, OutputTokens: 20},
|
|
}},
|
|
},
|
|
}
|
|
|
|
// First attempt: resume fails (no SessionID in result).
|
|
opts := agent.ExecOptions{ResumeSessionID: "stale-id"}
|
|
result, _, err := d.executeAndDrain(ctx, fb, "prompt", opts, taskLog, "task-1")
|
|
if err != nil {
|
|
t.Fatalf("first call error: %v", err)
|
|
}
|
|
if result.Status != "failed" || result.SessionID != "" {
|
|
t.Fatalf("expected failed result with empty SessionID, got %+v", result)
|
|
}
|
|
|
|
// Simulate the retry logic from runTask.
|
|
if result.Status == "failed" && result.SessionID == "" {
|
|
firstUsage := result.Usage
|
|
opts.ResumeSessionID = ""
|
|
retryResult, _, retryErr := d.executeAndDrain(ctx, fb, "prompt", opts, taskLog, "task-1")
|
|
if retryErr != nil {
|
|
t.Fatalf("retry error: %v", retryErr)
|
|
}
|
|
result = retryResult
|
|
result.Usage = mergeUsage(firstUsage, result.Usage)
|
|
}
|
|
|
|
if result.Status != "completed" || result.Output != "done" {
|
|
t.Fatalf("expected completed result, got %+v", result)
|
|
}
|
|
if result.SessionID != "new-sess" {
|
|
t.Fatalf("expected new-sess, got %s", result.SessionID)
|
|
}
|
|
// Usage should be merged.
|
|
if u := result.Usage["m1"]; u.InputTokens != 15 || u.OutputTokens != 20 {
|
|
t.Fatalf("expected merged usage {15,20}, got %+v", u)
|
|
}
|
|
// Second call should NOT have ResumeSessionID.
|
|
if fb.calls[1].ResumeSessionID != "" {
|
|
t.Fatal("retry should not have ResumeSessionID")
|
|
}
|
|
}
|
|
|
|
func TestExecuteAndDrain_NoRetryWhenSessionEstablished(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newTestDaemon(t)
|
|
|
|
fb := &fakeBackend{
|
|
results: []agent.Result{
|
|
{Status: "failed", Error: "model error", SessionID: "valid-sess"},
|
|
},
|
|
}
|
|
|
|
opts := agent.ExecOptions{ResumeSessionID: "some-id"}
|
|
result, _, err := d.executeAndDrain(context.Background(), fb, "p", opts, slog.Default(), "t")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// SessionID is set → session was established → should NOT retry.
|
|
shouldRetry := result.Status == "failed" && result.SessionID == ""
|
|
if shouldRetry {
|
|
t.Fatal("should not retry when SessionID is present")
|
|
}
|
|
if int(fb.idx.Load()) != 1 {
|
|
t.Fatalf("expected 1 call, got %d", fb.idx.Load())
|
|
}
|
|
}
|
|
|
|
func TestExecuteAndDrain_CodexInactivityReportsToolResultTranscript(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell-script fixture is POSIX-only")
|
|
}
|
|
|
|
fakePath := filepath.Join(t.TempDir(), "codex")
|
|
script := "#!/bin/sh\n" +
|
|
`read line` + "\n" +
|
|
`echo '{"jsonrpc":"2.0","id":1,"result":{}}'` + "\n" +
|
|
`read line` + "\n" +
|
|
`read line` + "\n" +
|
|
`echo '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thr-drain"}}}'` + "\n" +
|
|
`read line` + "\n" +
|
|
`echo '{"jsonrpc":"2.0","id":3,"result":{}}'` + "\n" +
|
|
`echo '{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-drain","turn":{"id":"turn-drain"}}}'` + "\n" +
|
|
`echo '{"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thr-drain","item":{"type":"commandExecution","id":"cmd-1","command":"git status"}}}'` + "\n" +
|
|
`echo '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-drain","item":{"type":"commandExecution","id":"cmd-1","aggregatedOutput":"clean"}}}'` + "\n" +
|
|
`sleep 5` + "\n"
|
|
if err := os.WriteFile(fakePath, []byte(script), 0o755); err != nil {
|
|
t.Fatalf("write fake codex: %v", err)
|
|
}
|
|
if err := os.Chmod(fakePath, 0o755); err != nil {
|
|
t.Fatalf("chmod fake codex: %v", err)
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
var reported []TaskMessageData
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/daemon/tasks/task-stale/messages" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
var body struct {
|
|
Messages []TaskMessageData `json:"messages"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("decode task messages: %v", err)
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
mu.Lock()
|
|
reported = append(reported, body.Messages...)
|
|
mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
backend, err := agent.New("codex", agent.Config{ExecutablePath: fakePath, Logger: slog.Default()})
|
|
if err != nil {
|
|
t.Fatalf("new codex backend: %v", err)
|
|
}
|
|
d := &Daemon{client: NewClient(srv.URL), logger: slog.Default()}
|
|
result, tools, err := d.executeAndDrain(context.Background(), backend, "prompt", agent.ExecOptions{
|
|
Timeout: 5 * time.Second,
|
|
SemanticInactivityTimeout: 100 * time.Millisecond,
|
|
}, slog.Default(), "task-stale")
|
|
if err != nil {
|
|
t.Fatalf("executeAndDrain: %v", err)
|
|
}
|
|
if result.Status != "timeout" {
|
|
t.Fatalf("expected timeout, got status=%q error=%q", result.Status, result.Error)
|
|
}
|
|
if tools != 1 {
|
|
t.Fatalf("expected one tool use, got %d", tools)
|
|
}
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
mu.Lock()
|
|
var gotToolUse, gotToolResult bool
|
|
for _, msg := range reported {
|
|
if msg.Seq == 1 && msg.Type == "tool_use" && msg.Tool == "exec_command" {
|
|
gotToolUse = true
|
|
}
|
|
if msg.Seq == 2 && msg.Type == "tool_result" && msg.Tool == "exec_command" && msg.Output == "clean" {
|
|
gotToolResult = true
|
|
}
|
|
}
|
|
mu.Unlock()
|
|
if gotToolUse && gotToolResult {
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
t.Fatalf("expected tool_use seq=1 and tool_result seq=2 in transcript, got %+v", reported)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// blockingBackend returns a Session whose Result channel is never written to,
|
|
// so executeAndDrain can only exit via the drainCtx.Done() path.
|
|
type blockingBackend struct{}
|
|
|
|
func (blockingBackend) Execute(_ context.Context, _ string, _ agent.ExecOptions) (*agent.Session, error) {
|
|
msgCh := make(chan agent.Message)
|
|
resCh := make(chan agent.Result)
|
|
close(msgCh)
|
|
return &agent.Session{Messages: msgCh, Result: resCh}, nil
|
|
}
|
|
|
|
func TestExecuteAndDrain_ContextCancelled_ReportsCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newTestDaemon(t)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
result, _, err := d.executeAndDrain(ctx, blockingBackend{}, "p", agent.ExecOptions{}, slog.Default(), "t")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result.Status != "cancelled" {
|
|
t.Fatalf("expected status=cancelled when parent ctx is cancelled, got %q (err=%q)", result.Status, result.Error)
|
|
}
|
|
}
|
|
|
|
func TestEnsureRepoReadyFastPathDoesNotRefresh(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sourceRepo := createDaemonTestRepo(t)
|
|
var refreshCalls atomic.Int32
|
|
d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) {
|
|
refreshCalls.Add(1)
|
|
http.Error(w, "unexpected refresh", http.StatusInternalServerError)
|
|
})
|
|
if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("seed repo cache: %v", err)
|
|
}
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}})
|
|
|
|
if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil {
|
|
t.Fatalf("ensureRepoReady: %v", err)
|
|
}
|
|
if got := refreshCalls.Load(); got != 0 {
|
|
t.Fatalf("expected no refresh calls, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestEnsureRepoReadyTrimsURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sourceRepo := createDaemonTestRepo(t)
|
|
var refreshCalls atomic.Int32
|
|
d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) {
|
|
refreshCalls.Add(1)
|
|
http.Error(w, "unexpected refresh", http.StatusInternalServerError)
|
|
})
|
|
if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
|
|
t.Fatalf("seed repo cache: %v", err)
|
|
}
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}})
|
|
|
|
// URL with trailing whitespace should still hit the fast path.
|
|
if err := d.ensureRepoReady(context.Background(), "ws-1", " "+sourceRepo+" "); err != nil {
|
|
t.Fatalf("ensureRepoReady with padded URL: %v", err)
|
|
}
|
|
if got := refreshCalls.Load(); got != 0 {
|
|
t.Fatalf("expected no refresh calls for trimmed URL, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestEnsureRepoReadyRefreshesOnMiss(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sourceRepo := createDaemonTestRepo(t)
|
|
var refreshCalls atomic.Int32
|
|
d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet || r.URL.Path != "/api/daemon/workspaces/ws-1/repos" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
refreshCalls.Add(1)
|
|
json.NewEncoder(w).Encode(WorkspaceReposResponse{
|
|
WorkspaceID: "ws-1",
|
|
Repos: []RepoData{{URL: sourceRepo, Description: "repo"}},
|
|
ReposVersion: "v2",
|
|
})
|
|
})
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
|
|
|
if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil {
|
|
t.Fatalf("ensureRepoReady: %v", err)
|
|
}
|
|
if got := refreshCalls.Load(); got != 1 {
|
|
t.Fatalf("expected 1 refresh call, got %d", got)
|
|
}
|
|
if d.repoCache.Lookup("ws-1", sourceRepo) == "" {
|
|
t.Fatal("expected repo to be cached after refresh")
|
|
}
|
|
}
|
|
|
|
func TestEnsureRepoReadyReturnsNotConfigured(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(WorkspaceReposResponse{
|
|
WorkspaceID: "ws-1",
|
|
Repos: []RepoData{},
|
|
ReposVersion: "v1",
|
|
})
|
|
})
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
|
|
|
err := d.ensureRepoReady(context.Background(), "ws-1", "git@example.com:team/api.git")
|
|
if !errors.Is(err, ErrRepoNotConfigured) {
|
|
t.Fatalf("expected ErrRepoNotConfigured, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureRepoReadyReportsSyncFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
missingRepo := filepath.Join(t.TempDir(), "missing-repo")
|
|
d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) {
|
|
json.NewEncoder(w).Encode(WorkspaceReposResponse{
|
|
WorkspaceID: "ws-1",
|
|
Repos: []RepoData{{URL: missingRepo, Description: "missing"}},
|
|
ReposVersion: "v1",
|
|
})
|
|
})
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
|
|
|
err := d.ensureRepoReady(context.Background(), "ws-1", missingRepo)
|
|
if err == nil || !strings.Contains(err.Error(), "repo is configured but not synced:") {
|
|
t.Fatalf("expected sync failure error, got %v", err)
|
|
}
|
|
if got := d.workspaceLastRepoSyncErr("ws-1"); got == "" {
|
|
t.Fatal("expected lastRepoSyncErr to be recorded")
|
|
}
|
|
}
|
|
|
|
func TestEnsureRepoReadyConcurrentMissRefreshesOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sourceRepo := createDaemonTestRepo(t)
|
|
var refreshCalls atomic.Int32
|
|
d := newRepoReadyTestDaemon(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet || r.URL.Path != "/api/daemon/workspaces/ws-1/repos" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
refreshCalls.Add(1)
|
|
json.NewEncoder(w).Encode(WorkspaceReposResponse{
|
|
WorkspaceID: "ws-1",
|
|
Repos: []RepoData{{URL: sourceRepo, Description: "repo"}},
|
|
ReposVersion: "v2",
|
|
})
|
|
})
|
|
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
|
|
|
const concurrency = 8
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, concurrency)
|
|
for range concurrency {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
errCh <- d.ensureRepoReady(context.Background(), "ws-1", sourceRepo)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errCh)
|
|
|
|
for err := range errCh {
|
|
if err != nil {
|
|
t.Fatalf("ensureRepoReady returned error: %v", err)
|
|
}
|
|
}
|
|
// All 8 goroutines race on a cold miss; the per-workspace mutex
|
|
// must serialize them so the server is only called once.
|
|
if got := refreshCalls.Load(); got != 1 {
|
|
t.Fatalf("expected exactly 1 refresh call, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestShellArgsFromEnv(t *testing.T) {
|
|
t.Setenv("MULTICA_CLAUDE_ARGS", `--max-turns 60 --append-system-prompt "multi word"`)
|
|
got, err := shellArgsFromEnv("MULTICA_CLAUDE_ARGS")
|
|
if err != nil {
|
|
t.Fatalf("shellArgsFromEnv: %v", err)
|
|
}
|
|
want := []string{"--max-turns", "60", "--append-system-prompt", "multi word"}
|
|
if strings.Join(got, "\x00") != strings.Join(want, "\x00") {
|
|
t.Fatalf("got %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestShellArgsFromEnvEmptyIsNil(t *testing.T) {
|
|
t.Setenv("MULTICA_CODEX_ARGS", " ")
|
|
got, err := shellArgsFromEnv("MULTICA_CODEX_ARGS")
|
|
if err != nil {
|
|
t.Fatalf("shellArgsFromEnv: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("expected nil for empty env, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestDefaultArgsForProvider(t *testing.T) {
|
|
cfg := Config{ClaudeArgs: []string{"--max-turns", "60"}, CodexArgs: []string{"--sandbox", "workspace-write"}}
|
|
if got := defaultArgsForProvider(cfg, "claude"); strings.Join(got, " ") != "--max-turns 60" {
|
|
t.Fatalf("unexpected claude args: %#v", got)
|
|
}
|
|
if got := defaultArgsForProvider(cfg, "codex"); strings.Join(got, " ") != "--sandbox workspace-write" {
|
|
t.Fatalf("unexpected codex args: %#v", got)
|
|
}
|
|
if got := defaultArgsForProvider(cfg, "gemini"); got != nil {
|
|
t.Fatalf("expected nil for unsupported provider, got %#v", got)
|
|
}
|
|
}
|