Files
multica/server/internal/daemon/daemon_test.go
LinYushen 265d1854c9 fix(daemon): add fallback for failed session resume (#818)
* fix(daemon): add fallback for failed session resume

When the daemon tries to resume a prior session (--resume flag for
Claude, --session for OpenCode, session/resume RPC for Hermes) and the
session no longer exists, the agent fails immediately. This adds a
fallback that retries the execution with a fresh session instead of
marking the task as blocked.

Extracts the execute+drain logic into a reusable executeAndDrain method
to avoid code duplication between the initial attempt and the retry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): narrow session resume retry and merge usage

Address review feedback:
1. Narrow retry trigger: only retry when result.SessionID == "" (no
   session was established), not on any failure with PriorSessionID set
2. Merge token usage from both attempts so billing is accurate
3. Log errors when the retry itself fails to start
4. Add unit tests for mergeUsage, fallback behavior, and no-retry
   when session was already established

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:47:24 +08:00

283 lines
7.7 KiB
Go

package daemon
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/multica-ai/multica/server/pkg/agent"
)
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 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 directly.
for _, want := range []string{
issueID,
commentContent,
"comment that triggered this task",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("prompt missing %q", want)
}
}
// 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")
}
}
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 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())
}
}