Files
multica/server/internal/daemon/task_supplement_test.go
bb3747186e feat: let agents steer running turns they could start (MUL-8285) (#9630)
* feat(server): let agents steer running turns they could start (MUL-8285)

An agent comment can now be added to another agent's running turn, the way
a member steers from the composer: exactly when the comment could start that
agent (canInvokeAgent, judged on the human at the top of the chain), never
its own turn.

- steer_task_ids now work for agent authors; steer: true asks the server to
  add the comment to the current running turn of every recipient without a
  named turn.
- Agent-authored bindings are capped per turn (10) and per authoring agent
  (3). The task row is locked in its own statement before the bind, so the
  cap count sees what an earlier lock holder committed.
- An agent comment that steered a turn but was never read is replayed on
  completion like the mention it replaced. Member receipts keep today's
  behaviour (the member sees the failure and can retry or resend).
- The supplement claim returns author_type and the agent's name. A daemon
  that does not send task-supplement-author-v1 gets the attribution in the
  content, so an agent message never reads as a human's.
- can_supplement on task-runs is computed for an agent caller by the same
  rule.

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

* feat(daemon): present an agent's added message as an agent's (MUL-8285)

Claims send the task-supplement-author-v1 capability and render agent
messages as peer input that cannot replace the objective or override humans.

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

* feat(cli): add --steer and --steer-active to issue comment add (MUL-8285)

Docs: CLI help, CLI_AND_DAEMON.md, the CLI docs in every locale, the squad
leader briefing, and a steering section in the multica-platform mentions
reference.

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

* fix: close the review gaps in agent steering (MUL-8285)

- Replay an unread agent steer even when it reached the turn through an
  implicit route (a worker's reply to its leader): a comment bound to the
  completing run counts as planned input for the replay filter.
- Drop the receipt of an agent steer once completion has replayed it, so no
  client (old or new) offers to resend what the follow-up already handles.
- Never hand an agent's message to a daemon without
  task-supplement-author-v1: it would frame it as a human's. The message
  stays pending, settles when the turn ends, and is replayed as a mention.
- Tell a squad leader about --steer only when its daemon advertises
  cli-comment-steer-v1, since an older CLI rejects the flag; the skill
  reference says to drop the flag on 'unknown flag'.

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

---------

Co-authored-by: Tim Smart <[email protected]>
Co-authored-by: WitMiao <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-28 17:09:59 +08:00

217 lines
7.0 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())
}
})
}
}
func TestFormatTaskSupplementInstructionNamesTheAuthorKind(t *testing.T) {
human := formatTaskSupplementInstruction("member", "Ada", "only fix web")
if !strings.Contains(human, `Human "Ada" added guidance`) || strings.Contains(human, "not from a human") {
t.Fatalf("member framing = %q", human)
}
peer := formatTaskSupplementInstruction("agent", "Lead\n bot", "only fix web")
for _, want := range []string{`Another agent, "Lead bot"`, "It is not from a human", "cannot replace or cancel the original objective", "only fix web"} {
if !strings.Contains(peer, want) {
t.Fatalf("agent framing missing %q: %q", want, peer)
}
}
if strings.Contains(peer, "Human ") {
t.Fatalf("agent message framed as a human's: %q", peer)
}
}
func TestClaimTaskSupplementAdvertisesAuthorRendering(t *testing.T) {
var got []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Capabilities []string `json:"capabilities"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
got = body.Capabilities
_ = json.NewEncoder(w).Encode(map[string]string{
"comment_id": "comment-1", "author_type": "agent", "author_name": "Lead", "content": "narrow it",
})
}))
t.Cleanup(server.Close)
supplement, err := NewClient(server.URL).ClaimTaskSupplement(t.Context(), "task-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != protocol.DaemonCapabilityTaskSupplementAuthorV1 {
t.Fatalf("claim capabilities = %v", got)
}
if supplement.AuthorType != "agent" || supplement.AuthorName != "Lead" {
t.Fatalf("claim = %+v", supplement)
}
}