Files
multica/server/internal/daemon/task_supplement_test.go
93ae552ba9 feat: add messages to active Claude Code and Codex runs (MUL-7565) (#8682)
* feat: add live run supplements (MUL-7565)

Co-authored-by: multica-agent <[email protected]>

* fix: address MUL-7565 review feedback

Co-authored-by: multica-agent <[email protected]>

* fix: resolve MUL-7565 supplement panel and receipt review

Co-authored-by: multica-agent <[email protected]>

* refactor: remove MUL-7565 supplement ordinals

Co-authored-by: multica-agent <[email protected]>

* fix: close supplement composer after run remount

* fix: exclude task supplements from completion reconciliation

* fix: build a persistent binary for local daemon launches

* feat: support Claude task supplements through SDK hooks

* refactor: simplify task supplement delivery

* test: remove redundant task supplement coverage

* fix: remove redundant supplement delivery notices

* refactor: extract local daemon launcher fix to its own PR

* fix: preserve ordinary comment size compatibility

* fix: accept empty task start acknowledgements

* fix: bound task start response decoding and retries

---------

Co-authored-by: Forge-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
Co-authored-by: yushen <[email protected]>
2026-09-23 16:10:07 +08:00

176 lines
5.4 KiB
Go

package daemon
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/multica-ai/multica/server/pkg/agent"
"github.com/multica-ai/multica/server/pkg/protocol"
)
func taskSupplementTestDaemon(t *testing.T, handler http.HandlerFunc) *Daemon {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return &Daemon{
client: NewClient(server.URL),
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
taskSupplementPollInterval: 10 * time.Millisecond,
taskSupplementReadyInterval: 5 * time.Millisecond,
}
}
func TestTaskSupplementLoopWaitsForTurnReadyBeforeClaim(t *testing.T) {
var claims atomic.Int32
d := taskSupplementTestDaemon(t, func(w http.ResponseWriter, r *http.Request) {
claims.Add(1)
w.WriteHeader(http.StatusNotFound)
})
var ready atomic.Bool
session := &agent.Session{
SupplementReady: ready.Load,
Supplement: func(context.Context, string) error { return nil },
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
wakeup, unsubscribe := d.taskSupplementSignals.subscribe("task-ready")
defer unsubscribe()
done := make(chan struct{})
go func() {
defer close(done)
d.runTaskSupplementLoop(ctx, session, "task-ready", wakeup, d.logger)
}()
time.Sleep(30 * time.Millisecond)
if got := claims.Load(); got != 0 {
t.Fatalf("claims before turn ready = %d, want 0", got)
}
ready.Store(true)
d.taskSupplementSignals.notify("task-ready")
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("loop did not stop after old-server 404")
}
if got := claims.Load(); got != 1 {
t.Fatalf("claims after turn ready = %d, want 1", got)
}
}
func TestTaskSupplementLoopAcknowledgesBeforeTurnEnds(t *testing.T) {
for _, tc := range []struct {
name string
err error
reason string
}{
{name: "delivered"},
{name: "provider timeout", err: context.DeadlineExceeded, reason: protocol.TaskSupplementFailureTimeout},
} {
t.Run(tc.name, func(t *testing.T) {
var claims atomic.Int32
ackSeen := make(chan struct{}, 1)
d := taskSupplementTestDaemon(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/supplements/claim"):
if claims.Add(1) == 1 {
_ = json.NewEncoder(w).Encode(map[string]string{
"comment_id": "comment-1", "author_name": "Ada", "content": "Create evidence.txt",
})
return
}
w.WriteHeader(http.StatusPreconditionFailed)
case strings.HasSuffix(r.URL.Path, "/supplements/comment-1/ack"):
var body struct {
Delivered bool `json:"delivered"`
Error string `json:"error"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
if body.Delivered != (tc.err == nil) || body.Error != tc.reason {
t.Errorf("ack = %#v, want delivered=%v reason=%q", body, tc.err == nil, tc.reason)
}
ackSeen <- struct{}{}
default:
t.Errorf("unexpected request %s", r.URL.Path)
}
})
injections := 0
session := &agent.Session{
SupplementReady: func() bool { return true },
Supplement: func(_ context.Context, instruction string) error {
injections++
if !strings.Contains(instruction, "Preserve and complete the original objective") || !strings.Contains(instruction, "Create evidence.txt") {
t.Errorf("injected instruction lost framing or content: %q", instruction)
}
return tc.err
},
}
wakeup, unsubscribe := d.taskSupplementSignals.subscribe("task-ack")
defer unsubscribe()
d.runTaskSupplementLoop(t.Context(), session, "task-ack", wakeup, d.logger)
if injections != 1 {
t.Fatalf("injections = %d, want 1", injections)
}
select {
case <-ackSeen:
default:
t.Fatal("loop returned before delivery acknowledgement")
}
})
}
}
type supplementGateBackend struct {
supplementCalls atomic.Int32
enabled bool
}
func (b *supplementGateBackend) Execute(_ context.Context, _ string, opts agent.ExecOptions) (*agent.Session, error) {
b.enabled = opts.EnableTaskSupplement
messages := make(chan agent.Message)
close(messages)
results := make(chan agent.Result, 1)
results <- agent.Result{Status: "completed", Output: "done"}
return &agent.Session{
Messages: messages,
Result: results,
SupplementReady: func() bool { return true },
Supplement: func(context.Context, string) error {
b.supplementCalls.Add(1)
return nil
},
}, nil
}
func TestExecuteAndDrainSupplementNegotiation(t *testing.T) {
for _, negotiated := range []bool{false, true} {
t.Run(fmt.Sprintf("negotiated=%v", negotiated), func(t *testing.T) {
var requests atomic.Int32
d := taskSupplementTestDaemon(t, func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusNotFound)
})
backend := &supplementGateBackend{}
opts := agent.ExecOptions{EnableTaskSupplement: negotiated}
if _, _, err := d.executeAndDrain(t.Context(), backend, "original", opts, d.logger, "task", "", new(atomic.Int32)); err != nil {
t.Fatal(err)
}
if backend.enabled != negotiated {
t.Fatalf("provider hooks enabled=%v, want %v", backend.enabled, negotiated)
}
if !negotiated && (requests.Load() != 0 || backend.supplementCalls.Load() != 0) {
t.Fatalf("unnegotiated run made HTTP requests=%d supplement calls=%d", requests.Load(), backend.supplementCalls.Load())
}
})
}
}