Files
multica/server/internal/handler/agent_runtime_skills_broadcast_test.go
Jiayuan Zhang 5d9295ac65 feat(agents): add per-agent runtime skill controls (#5686)
* feat(agents): add per-agent runtime skill controls

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

* fix(agents): renumber runtime-skill migration and broadcast agent:status on toggle

Address the MUL-5101 review blockers on PR #5686:

- Rebase onto main and renumber the runtime-skill-disable migration
  202 -> 203. main added 202_runtime_profile_add_qwen, so the pair
  collided on prefix 202 and migrations_lint_test would reject the
  duplicate. 203 is the next free prefix.
- Publish an "agent:status" event after persisting a
  disabled_runtime_skills override, mirroring the workspace-skill toggle
  in writeUpdatedAgentSkills. The realtime layer keys off this event to
  invalidate workspaceKeys.agents, so other open web/desktop/mobile
  clients now drop their stale toggle state instead of only the
  initiating tab refreshing. Reload junction-table skills before the
  broadcast so it doesn't signal cleared skills (#3459).
- Add a handler regression test proving the broadcast fires on both
  disable and enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:38 +08:00

97 lines
3.1 KiB
Go

package handler
import (
"context"
"net/http/httptest"
"sync"
"testing"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// TestSetAgentRuntimeSkillEnabledBroadcastsAgentStatus guards the realtime
// invalidation path for runtime-skill toggles (Howard review on MUL-5101):
// persisting a disabled_runtime_skills override must publish an "agent:status"
// event so every other open web/desktop/mobile client invalidates
// workspaceKeys.agents and drops its stale toggle state — mirroring the
// workspace-skill toggle in writeUpdatedAgentSkills. Before the fix the handler
// committed and returned 204 without broadcasting, so only the initiating tab
// refreshed while other clients kept showing the old state.
func TestSetAgentRuntimeSkillEnabledBroadcastsAgentStatus(t *testing.T) {
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
var agentID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, permission_mode, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'local', '{}'::jsonb, $3, 'private', 'private', 1, $4)
RETURNING id
`, testWorkspaceID, "Runtime Skill Broadcast "+t.Name(), runtimeID, testUserID).Scan(&agentID); err != nil {
t.Fatalf("create agent: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
})
// The bus is synchronous, so counts observed right after each request are
// deterministic; the mutex only guards against a future async listener.
var mu sync.Mutex
var broadcasts []string
testHandler.Bus.Subscribe(protocol.EventAgentStatus, func(e events.Event) {
payload, ok := e.Payload.(map[string]any)
if !ok {
return
}
agent, ok := payload["agent"].(AgentResponse)
if !ok {
return
}
mu.Lock()
broadcasts = append(broadcasts, agent.ID)
mu.Unlock()
})
countFor := func(id string) int {
mu.Lock()
defer mu.Unlock()
n := 0
for _, a := range broadcasts {
if a == id {
n++
}
}
return n
}
setEnabled := func(enabled bool) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/agents/"+agentID+"/runtime-skills/enabled", map[string]any{
"runtime_id": runtimeID,
"root": "provider",
"key": "review",
"name": "Review Helper",
"enabled": enabled,
})
req = withURLParam(req, "id", agentID)
testHandler.SetAgentRuntimeSkillEnabled(w, req)
return w
}
if w := setEnabled(false); w.Code != 204 {
t.Fatalf("disable inherited skill: expected 204, got %d: %s", w.Code, w.Body.String())
}
if n := countFor(agentID); n != 1 {
t.Fatalf("disabling a runtime skill must broadcast exactly one agent:status for %s, got %d", agentID, n)
}
if w := setEnabled(true); w.Code != 204 {
t.Fatalf("enable inherited skill: expected 204, got %d: %s", w.Code, w.Body.String())
}
if n := countFor(agentID); n != 2 {
t.Fatalf("re-enabling a runtime skill must broadcast a second agent:status for %s, got %d", agentID, n)
}
}