Files
multica/server/internal/handler/issue_agent_create_e2e_test.go
Multica Eve 86c3f30524 fix(server): keep originator on agent-created issues so A2A mentions stay authorized (MUL-4305) (#5149)
* fix(server): attribute agent-created issues so downstream A2A mentions keep the originator (MUL-4305)

An agent creating an issue via the ordinary `issue create` path left the new
issue with no origin link, so resolveOriginatorForIssueTask could not recover
the top-of-chain human. Any assignment / squad-leader run derived from that
issue lost the originator, and A2A @-mentions those runs emitted failed the
canInvokeAgent gate against private agents (after MUL-3963).

Fix (mirrors the comment.source_task_id stamp from MUL-4015):
- CreateIssue stamps origin_type='agent_create' + origin_id=<acting task>,
  resolved from the SERVER-trusted X-Task-ID (never a client-reported field).
- resolveOriginatorForIssueTask inherits the origin task's originator for
  agent_create just like quick_create.
- Align the squad-leader gate originator with the enqueue path via the new
  exported OriginatorForIssueTask, so the gate and the persisted task row
  agree instead of drifting to an empty originator for agent-triggered assigns.
- Migration 149 adds 'agent_create' to issue_origin_type_check.
- Classify agent_create in analytics to avoid the unknown-origin warning.

Tests: agent_create attribution + gate/enqueue consistency (service), and the
HTTP boundary stamp + security regression (member / forged X-Agent-ID must not
smuggle an agent_create origin).

Co-authored-by: multica-agent <github@multica.ai>

* test(server): add end-to-end regression for agent-created issue originator chain (MUL-4305)

Locks the real product path the layered tests could miss, per PR review:

1. CreateAssignSquad_PrivateWorkerTriggered — human H triggers agent A → A
   creates an issue via the ordinary create path AND assigns it to a squad
   whose leader is a private agent owned by H → the leader's assignment run
   @-mentions a second private agent J (owned by H) → asserts the leader task
   carries H and J ends up with a queued task attributed to H. This is the
   exact line-failure shape from the issue.
2. UpdateAssignSquad_HandlerGateAdmitsPrivateLeader — agent A creates an
   unassigned issue then assigns it to a private-leader squad via UpdateIssue,
   exercising the handler enqueueSquadLeaderTask gate (which the create path's
   ungated service enqueue does not hit); asserts the leader task is enqueued
   carrying H.

Both wire handler create stamp → origin resolution → squad-leader gate →
comment source-task stamp → private-worker invocation gate. Verified they FAIL
against a simulated pre-fix resolver (leader originator empty; private worker /
leader get 0 tasks) and PASS with the fix.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-09 15:10:09 +08:00

255 lines
11 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// createPrivateAgentOwnedBy inserts a private agent owned by ownerID and
// returns its id. Mirrors privateAgentTestFixture's agent shape (visibility
// 'private', no explicit permission_mode so it keeps the private default the
// canInvokeAgent gate treats as owner-only), but lets a test stand up a SECOND
// private agent under the same human so a leader and a worker can both be
// private and owned by the same originator.
func createPrivateAgentOwnedBy(t *testing.T, name, ownerID string) string {
t.Helper()
var agentID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id,
instructions, custom_env, custom_args
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb,
$3, 'private', 1, $4, '', '{}'::jsonb, '[]'::jsonb)
RETURNING id
`, testWorkspaceID, name, handlerTestRuntimeID(t), ownerID).Scan(&agentID); err != nil {
t.Fatalf("create private agent %q: %v", name, err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID) })
return agentID
}
// TestAgentCreateOriginator_E2E_CreateAssignSquad_PrivateWorkerTriggered walks
// the exact line failure shape from MUL-4305 end to end, deliberately NOT
// re-testing the resolver in isolation but locking the real wiring between the
// handler create stamp, the create-time squad gate, the squad-leader task's
// stored originator, the comment source-task stamp, and the private-worker
// invocation gate:
//
// human H triggers agent A → A creates an issue via the ordinary
// `issue create` path AND assigns it to a squad whose leader is a private
// agent owned by H → the leader's assignment run @-mentions a *second*
// private agent J (also owned by H) → J must be triggered.
//
// Pre-fix, A's create left the issue unattributed, the leader task stored a
// NULL originator, and the leader's mention of J failed canInvokeAgent — J
// silently got 0 tasks. This asserts the leader task carries H and that J ends
// up with a queued task whose originator is the original human H.
func TestAgentCreateOriginator_E2E_CreateAssignSquad_PrivateWorkerTriggered(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Private worker J and the human originator H (J's owner).
workerJID, ownerH, _ := privateAgentTestFixture(t)
// Private squad leader L, owned by the same human H.
leaderID := createPrivateAgentOwnedBy(t, "mul4305-e2e-private-leader", ownerH)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'MUL-4305 E2E Squad', '', $2, $3)
RETURNING id
`, testWorkspaceID, leaderID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID) })
// Creator agent A, running a task on behalf of the human H. resolveActor
// validates the (A, task) pair; the handler then trusts X-Task-ID as A's
// acting task and inherits H from it.
creatorAID := createHandlerTestAgent(t, "mul4305-e2e-creator-agent", nil)
var creatorTaskID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, originator_user_id)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, $2)
RETURNING id
`, creatorAID, ownerH).Scan(&creatorTaskID); err != nil {
t.Fatalf("create A's acting task: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, creatorTaskID) })
// Step 1: agent A creates an issue through the ordinary create path and
// assigns it to the private-leader squad in the same call.
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "MUL-4305 E2E agent-created + squad-assigned",
"assignee_type": "squad",
"assignee_id": squadID,
})
r.Header.Set("X-Agent-ID", creatorAID)
r.Header.Set("X-Task-ID", creatorTaskID)
testHandler.CreateIssue(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created IssueResponse
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, created.ID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, created.ID)
})
// The create must have stamped the issue back to A's acting task.
var originType, originID string
if err := testPool.QueryRow(ctx,
`SELECT COALESCE(origin_type, ''), COALESCE(origin_id::text, '') FROM issue WHERE id = $1`, created.ID,
).Scan(&originType, &originID); err != nil {
t.Fatalf("load issue origin: %v", err)
}
if originType != "agent_create" || originID != creatorTaskID {
t.Fatalf("issue origin = (%q,%q), want (agent_create,%s)", originType, originID, creatorTaskID)
}
// The squad-leader assignment task must have been enqueued carrying H.
var leaderTaskID, leaderOriginator string
if err := testPool.QueryRow(ctx, `
SELECT id, COALESCE(originator_user_id::text, '')
FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND is_leader_task
ORDER BY created_at DESC LIMIT 1
`, created.ID, leaderID).Scan(&leaderTaskID, &leaderOriginator); err != nil {
t.Fatalf("load squad-leader task (expected one enqueued on create): %v", err)
}
if leaderOriginator != ownerH {
t.Fatalf("squad-leader task originator = %q, want the original human H %q", leaderOriginator, ownerH)
}
// Step 2: the leader L, running its assignment task, posts a comment that
// @-mentions the private worker J. The comment stamps source_task_id = the
// leader task, so the originator chain resolves to H.
w = httptest.NewRecorder()
r = newRequest("POST", "/api/issues/"+created.ID+"/comments", map[string]any{
"content": "handing the private part to [@Worker](mention://agent/" + workerJID + ")",
})
r.Header.Set("X-Agent-ID", leaderID)
r.Header.Set("X-Task-ID", leaderTaskID)
r = withURLParam(r, "id", created.ID)
testHandler.CreateComment(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment (leader mentions worker): expected 201, got %d: %s", w.Code, w.Body.String())
}
// Step 3: the private worker J must now have a queued task whose originator
// is the original human H — the whole point of the fix.
var queuedForHuman int
if err := testPool.QueryRow(ctx, `
SELECT count(*) FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued' AND originator_user_id = $3
`, created.ID, workerJID, ownerH).Scan(&queuedForHuman); err != nil {
t.Fatalf("count worker tasks: %v", err)
}
if queuedForHuman == 0 {
t.Fatalf("private worker got 0 queued tasks attributed to H; the A2A mention was denied (MUL-4305 regression)")
}
}
// TestAgentCreateOriginator_E2E_UpdateAssignSquad_HandlerGateAdmitsPrivateLeader
// locks the specific squad-leader GATE change in this PR, which the create path
// above does not exercise (create routes through the ungated service enqueue).
//
// Agent A (running for human H) creates an UNASSIGNED issue via the ordinary
// path, then assigns it to a private-leader squad via UpdateIssue. That assign
// routes through the handler enqueueSquadLeaderTask gate. Pre-fix the gate saw
// an empty originator for an agent actor and denied the private leader, so no
// leader task was enqueued even though the HTTP assign returned 200. With the
// agent_create stamp feeding the shared OriginatorForIssueTask, the gate now
// resolves H and enqueues the leader task carrying H.
func TestAgentCreateOriginator_E2E_UpdateAssignSquad_HandlerGateAdmitsPrivateLeader(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Reuse the private-agent fixture purely for a private agent + its human
// owner H; the fixture agent here plays the squad leader.
leaderID, ownerH, _ := privateAgentTestFixture(t)
var squadID string
if err := testPool.QueryRow(ctx, `
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
VALUES ($1, 'MUL-4305 E2E Update-Assign Squad', '', $2, $3)
RETURNING id
`, testWorkspaceID, leaderID, testUserID).Scan(&squadID); err != nil {
t.Fatalf("create squad: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID) })
creatorAID := createHandlerTestAgent(t, "mul4305-e2e-update-creator", nil)
var creatorTaskID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, originator_user_id)
VALUES ($1, (SELECT runtime_id FROM agent WHERE id = $1), 'running', 0, $2)
RETURNING id
`, creatorAID, ownerH).Scan(&creatorTaskID); err != nil {
t.Fatalf("create A's acting task: %v", err)
}
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, creatorTaskID) })
// Agent A creates an unassigned issue via the ordinary path.
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "MUL-4305 E2E unassigned then squad-assigned",
})
r.Header.Set("X-Agent-ID", creatorAID)
r.Header.Set("X-Task-ID", creatorTaskID)
testHandler.CreateIssue(w, r)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created IssueResponse
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, created.ID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Agent A assigns the issue to the private-leader squad via UpdateIssue.
w = httptest.NewRecorder()
r = newRequest("PATCH", "/api/issues/"+created.ID, map[string]any{
"assignee_type": "squad",
"assignee_id": squadID,
})
r.Header.Set("X-Agent-ID", creatorAID)
r.Header.Set("X-Task-ID", creatorTaskID)
r = withURLParam(r, "id", created.ID)
testHandler.UpdateIssue(w, r)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue (assign squad): expected 200, got %d: %s", w.Code, w.Body.String())
}
// The gated handler path must have enqueued the private leader carrying H.
var leaderCount int
if err := testPool.QueryRow(ctx, `
SELECT count(*) FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND is_leader_task AND originator_user_id = $3
`, created.ID, leaderID, ownerH).Scan(&leaderCount); err != nil {
t.Fatalf("count leader tasks: %v", err)
}
if leaderCount == 0 {
t.Fatalf("private squad leader got 0 tasks attributed to H after agent-triggered assign; the enqueue gate denied it (MUL-4305 gate regression)")
}
}