Files
mulit-agent/controller/internal/controlplane/controlplane_test.go
T
toot aa57ea69de feat(controller): 工具仓库、Skills 录入、多轮追问与终端运行日志
- 插件/MCP 改为通用工具仓库:内置、HTTP MCP、本机命令(stdio)三类工具,可增删改、
  按 Agent 分配;定义变更自动提升下发版本并重发,删除即回收分配并通知 Agent 清空。
- 旧 desktop-filesystem 降级为仓库中的一个内置条目;本机预设工具改为总控只展示、
  Agent 默认启用,任务执行来源新增 builtin(审计 task.builtin_tool_execution)。
- Skills 补齐录入闭环:新建或按名称覆盖更新,并可从旧版技能目录(SKILL.md + skill.json)
  批量导入;同名更新保持标识不变并自动重新应用,旧包入口脚本只提示不执行。
- 单独指派支持基于某条已完成任务的多轮追问:任务内容框仍下发无上下文的一次性任务,
  追问时由总控组装历史(仅成功轮次、默认最近 50 轮 / 约 6 万字符),会话按 Agent 隔离;
  执行动态把同一会话合并为一条记录,任务详情弹窗底部提供继续对话输入框。
- 任务详情支持运行中观测:打开期间每 2 秒刷新,终端风格输出全部事件并自动滚动;
  progress 事件不改变任务状态且顺延无进展超时,避免正在执行工具的任务被误判超时。
- 修复 build.ps1 缺 UTF-8 BOM 导致 build.bat 在 PowerShell 5.1 下语法报错。
2026-09-16 15:30:50 +08:00

663 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controlplane
import (
"crypto/tls"
"database/sql"
"encoding/json"
"errors"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func TestPairDispatchAndTaskEvent(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()
configPath := filepath.Join(t.TempDir(), "controller-config.json")
if err := os.WriteFile(configPath, []byte(`{"agent_host":"127.0.0.1","agent_port":`+strconv.Itoa(port)+`}`), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(configPath)
if err != nil {
t.Fatal(err)
}
defer controller.Close()
if err := controller.StartAgentServer(); err != nil {
t.Fatal(err)
}
dialer := websocket.Dialer{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} // Test client pins no certificate.
client, _, err := dialer.Dial("wss://127.0.0.1:"+strconv.Itoa(port), nil)
if err != nil {
t.Fatal(err)
}
defer client.Close()
code, err := controller.CreatePairingCode()
if err != nil {
t.Fatal(err)
}
if err := client.WriteJSON(map[string]any{"type": "pair_request", "code": code, "agent_name": "test-agent"}); err != nil {
t.Fatal(err)
}
var paired map[string]any
if err := client.ReadJSON(&paired); err != nil {
t.Fatal(err)
}
agentID, _ := paired["agent_id"].(string)
credential, _ := paired["credential"].(string)
if paired["type"] != "pair_accepted" || agentID == "" {
t.Fatalf("unexpected pairing response: %#v", paired)
}
if err := client.WriteJSON(map[string]any{"type": "agent_rename", "name": "renamed-from-agent"}); err != nil {
t.Fatal(err)
}
var renameAck map[string]any
if err := client.ReadJSON(&renameAck); err != nil || renameAck["type"] != "agent_rename_ack" {
t.Fatalf("expected rename acknowledgement: %#v, %v", renameAck, err)
}
var pairedName string
if err := controller.db.QueryRow("SELECT name FROM managed_agents WHERE agent_id = ?", agentID).Scan(&pairedName); err != nil || pairedName != "renamed-from-agent" {
t.Fatalf("expected Agent rename, got %q, %v", pairedName, err)
}
profile, err := controller.SaveLLMProfile(LLMProfileInput{
Name: "test-openai", Provider: "openai", BaseURL: "https://example.test/v1", Model: "test-model", APIKey: "test-secret",
})
if err != nil {
t.Fatal(err)
}
if profiles, err := controller.LLMProfiles(); err != nil || len(profiles) != 1 || !profiles[0].HasAPIKey {
t.Fatalf("expected protected LLM profile, got %#v, %v", profiles, err)
}
if err := controller.DeployLLMProfile(profile.ID, []string{agentID}); err != nil {
t.Fatal(err)
}
var configSnapshot map[string]any
if err := client.ReadJSON(&configSnapshot); err != nil {
t.Fatal(err)
}
llm, _ := configSnapshot["llm"].(map[string]any)
if configSnapshot["type"] != "config_snapshot" || llm["model"] != "test-model" || llm["api_key"] != "test-secret" {
t.Fatalf("unexpected LLM snapshot: %#v", configSnapshot)
}
if err := client.WriteJSON(map[string]any{"type": "config_ack", "config_version": configSnapshot["config_version"], "status": "applied"}); err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
if statuses, err := controller.AgentLLMStatuses(); err != nil || len(statuses) != 1 || statuses[0].ApplyStatus != "applied" {
t.Fatalf("expected applied LLM deployment, got %#v, %v", statuses, err)
}
if err := controller.SetAgentMCPTools(agentID, []string{"desktop-filesystem"}); err != nil {
t.Fatal(err)
}
var mcpSnapshot map[string]any
if err := client.ReadJSON(&mcpSnapshot); err != nil {
t.Fatal(err)
}
if mcpSnapshot["type"] != "mcp_snapshot" {
t.Fatalf("expected MCP snapshot, got %#v", mcpSnapshot)
}
mcpTools, _ := mcpSnapshot["tools"].([]any)
mcpTool, _ := mcpTools[0].(map[string]any)
mcpVersion, _ := mcpTool["deployment_version"].(float64)
if mcpVersion != 1 {
t.Fatalf("expected first MCP deployment version, got %#v", mcpSnapshot)
}
var persistedVersion int
if err := controller.db.QueryRow("SELECT deployment_version FROM agent_mcp_assignments WHERE agent_id=? AND tool_id='desktop-filesystem'", agentID).Scan(&persistedVersion); err != nil || persistedVersion != 1 {
t.Fatalf("expected persisted MCP deployment assignment, got version %d / %v", persistedVersion, err)
}
if err := client.WriteJSON(map[string]any{"type": "mcp_ack", "tool_id": "desktop-filesystem", "deployment_version": mcpVersion, "status": "applied"}); err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
skill, err := controller.SaveSkill(SkillInput{Name: "图生图工具", Version: "0.3.2", Content: "# 图生图工具\n\n测试内容。"})
if err != nil {
t.Fatal(err)
}
if err := controller.SetAgentSkills(agentID, []string{skill.ID}); err != nil {
t.Fatal(err)
}
var skillSnapshot map[string]any
if err := client.ReadJSON(&skillSnapshot); err != nil || skillSnapshot["type"] != "skill_snapshot" {
t.Fatalf("expected Skill snapshot, got %#v, %v", skillSnapshot, err)
}
if err := client.WriteJSON(map[string]any{"type": "skill_ack", "skill_id": skill.ID, "status": "applied"}); err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
deployments, err := controller.AgentDeploymentStatuses()
if err != nil || len(deployments) != 1 || deployments[0].MCPStatus != "applied" || deployments[0].LLMStatus != "applied" {
t.Fatalf("expected applied Agent deployment summary, got %#v, %v", deployments, err)
}
// A stale acknowledgement must not overwrite the recorded applied deployment.
if err := client.WriteJSON(map[string]any{"type": "mcp_ack", "tool_id": "desktop-filesystem", "deployment_version": mcpVersion - 1, "status": "failed", "error": "stale"}); err != nil {
t.Fatal(err)
}
time.Sleep(30 * time.Millisecond)
if deployments, err = controller.AgentDeploymentStatuses(); err != nil || deployments[0].MCPStatus != "applied" {
t.Fatalf("stale MCP acknowledgement changed state: %#v, %v", deployments, err)
}
// Re-authentication must replay the durable MCP assignment. The Agent can then
// acknowledge that same version again without creating a duplicate deployment.
_ = client.Close()
time.Sleep(30 * time.Millisecond)
client, _, err = dialer.Dial("wss://127.0.0.1:"+strconv.Itoa(port), nil)
if err != nil {
t.Fatal(err)
}
defer client.Close()
if err := client.WriteJSON(map[string]any{"type": "authenticate", "agent_id": agentID, "credential": credential, "agent_name": "renamed-from-agent"}); err != nil {
t.Fatal(err)
}
var authenticated map[string]any
if err := client.ReadJSON(&authenticated); err != nil || authenticated["type"] != "auth_accepted" {
t.Fatalf("expected re-authentication acknowledgement: %#v, %v", authenticated, err)
}
var replayedMCPSnapshot map[string]any
_ = client.SetReadDeadline(time.Now().Add(time.Second))
for replayedMCPSnapshot == nil {
var message map[string]any
if err := client.ReadJSON(&message); err != nil {
t.Fatalf("expected MCP replay after reconnect: %v", err)
}
if message["type"] == "mcp_snapshot" {
replayedMCPSnapshot = message
}
}
_ = client.SetReadDeadline(time.Time{})
replayedTools, _ := replayedMCPSnapshot["tools"].([]any)
replayedTool, _ := replayedTools[0].(map[string]any)
if replayedTool["deployment_version"] != mcpVersion {
t.Fatalf("reconnect replayed wrong MCP version: %#v", replayedMCPSnapshot)
}
statusDeadline := time.Now().Add(time.Second)
for {
deployments, err = controller.AgentDeploymentStatuses()
if err == nil && len(deployments) == 1 && deployments[0].MCPStatus == "sent" {
break
}
if time.Now().After(statusDeadline) {
break
}
time.Sleep(10 * time.Millisecond)
}
if err != nil || len(deployments) != 1 || deployments[0].MCPStatus != "sent" {
t.Fatalf("expected MCP to await replay acknowledgement: %#v, %v", deployments, err)
}
if err := client.WriteJSON(map[string]any{"type": "mcp_ack", "tool_id": "desktop-filesystem", "deployment_version": mcpVersion, "status": "applied"}); err != nil {
t.Fatal(err)
}
time.Sleep(30 * time.Millisecond)
if deployments, err = controller.AgentDeploymentStatuses(); err != nil || deployments[0].MCPStatus != "applied" {
t.Fatalf("expected replay acknowledgement to restore applied state: %#v, %v", deployments, err)
}
taskID, err := controller.CreateTask(agentID, "验证 Wails 控制平面", 30)
if err != nil {
t.Fatal(err)
}
var dispatch map[string]any
_ = client.SetReadDeadline(time.Now().Add(time.Second))
for {
if err := client.ReadJSON(&dispatch); err != nil {
t.Fatal(err)
}
if dispatch["type"] == "task_dispatch" && dispatch["task_id"] == taskID {
break
}
}
_ = client.SetReadDeadline(time.Time{})
if dispatch["type"] != "task_dispatch" || dispatch["task_id"] != taskID || dispatch["deadline_at"] == nil {
t.Fatalf("unexpected task dispatch: %#v", dispatch)
}
if err := client.WriteJSON(map[string]any{"type": "task_event", "task_id": taskID, "status": "completed", "execution": "desktop-filesystem", "output": "<think>内部推理不能显示</think>已在桌面创建文件夹:test"}); err != nil {
t.Fatal(err)
}
time.Sleep(30 * time.Millisecond)
tasks, err := controller.RecentTasks()
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 || tasks[0].Status != "completed" || tasks[0].Output != "已在桌面创建文件夹:test" {
encoded, _ := json.Marshal(tasks)
t.Fatalf("unexpected task result: %s", encoded)
}
if tasks[0].Execution != "desktop-filesystem" {
t.Fatalf("expected MCP execution source, got %#v", tasks[0])
}
if tasks[0].AgentName != "renamed-from-agent" {
t.Fatalf("expected persisted task Agent name, got %#v", tasks[0])
}
detail, err := controller.TaskDetails(taskID)
if err != nil {
t.Fatal(err)
}
if detail.Task.ID != taskID || detail.Task.Execution != "desktop-filesystem" || len(detail.Events) < 2 {
t.Fatalf("expected task detail with execution timeline, got %#v", detail)
}
batch, err := controller.CreateTasks([]TaskRequest{
{AgentID: agentID, Instruction: "并发面板任务一", TimeoutSeconds: 30},
{AgentID: agentID, Instruction: "并发面板任务二", TimeoutSeconds: 30},
{AgentID: "offline-agent", Instruction: "不会阻塞其他任务", TimeoutSeconds: 30},
})
if err != nil || len(batch) != 3 || batch[0].TaskID == "" || batch[1].TaskID == "" || batch[2].Error == "" {
t.Fatalf("expected independent batch outcomes, got %#v / %v", batch, err)
}
dispatchedIDs := map[string]bool{}
for range 2 {
if err := client.ReadJSON(&dispatch); err != nil || dispatch["type"] != "task_dispatch" {
t.Fatalf("expected batch task dispatch: %#v / %v", dispatch, err)
}
id, _ := dispatch["task_id"].(string)
dispatchedIDs[id] = true
}
if !dispatchedIDs[batch[0].TaskID] || !dispatchedIDs[batch[1].TaskID] {
t.Fatalf("batch dispatch IDs missing: %#v", dispatchedIDs)
}
if _, err := controller.CreateTasks(nil); err == nil {
t.Fatal("empty task batch must be rejected")
}
auditEvents, err := controller.RecentAuditEvents()
if err != nil || len(auditEvents) < 4 {
t.Fatalf("expected MCP deployment and execution audit records, got %#v, %v", auditEvents, err)
}
for _, event := range auditEvents {
if event.Detail == "<think>内部推理不能显示</think>已在桌面创建文件夹:test" {
t.Fatalf("audit event leaked internal reasoning: %#v", event)
}
}
if _, err := controller.RetryTask(taskID); err == nil {
t.Fatal("completed tasks must not be retryable")
}
cancellableID, err := controller.CreateTask(agentID, "验证取消", 30)
if err != nil {
t.Fatal(err)
}
if err := client.ReadJSON(&dispatch); err != nil || dispatch["task_id"] != cancellableID {
t.Fatalf("expected cancellable dispatch: %#v, %v", dispatch, err)
}
if err := controller.CancelTask(cancellableID); err != nil {
t.Fatal(err)
}
var cancellation map[string]any
if err := client.ReadJSON(&cancellation); err != nil || cancellation["type"] != "task_cancel" {
t.Fatalf("expected cancellation: %#v, %v", cancellation, err)
}
if err := client.WriteJSON(map[string]any{"type": "task_event", "task_id": cancellableID, "status": "cancelled", "message": "已取消"}); err != nil {
t.Fatal(err)
}
time.Sleep(30 * time.Millisecond)
retryID, err := controller.RetryTask(cancellableID)
if err != nil {
t.Fatal(err)
}
if err := client.ReadJSON(&dispatch); err != nil || dispatch["task_id"] != retryID || dispatch["attempt"].(float64) != 2 {
t.Fatalf("expected retry dispatch: %#v, %v", dispatch, err)
}
pastDeadline := time.Now().UTC().Add(-time.Second).Format(time.RFC3339Nano)
if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, instruction, status, created_at, deadline_at, timeout_seconds, attempt) VALUES (?, ?, ?, 'running', ?, ?, 5, 1)", "expired-task", agentID, "过期任务", time.Now().UTC().Format(time.RFC3339Nano), pastDeadline); err != nil {
t.Fatal(err)
}
if _, err := controller.RecentTasks(); err != nil {
t.Fatal(err)
}
var expiredStatus string
if err := controller.db.QueryRow("SELECT status FROM task_runs WHERE task_id = 'expired-task'").Scan(&expiredStatus); err != nil || expiredStatus != "timed_out" {
t.Fatalf("expected timeout, got %q, %v", expiredStatus, err)
}
staleAt := time.Now().UTC().Add(-agentHeartbeatTimeout - time.Second).Format(time.RFC3339Nano)
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES (?, ?, ?, 'online', ?)", "stale-agent", "stale-agent", "unused", staleAt); err != nil {
t.Fatal(err)
}
if _, err := controller.Agents(); err != nil {
t.Fatal(err)
}
var staleStatus string
if err := controller.db.QueryRow("SELECT status FROM managed_agents WHERE agent_id = 'stale-agent'").Scan(&staleStatus); err != nil || staleStatus != "offline" {
t.Fatalf("expected stale Agent to be offline, got %q, %v", staleStatus, err)
}
agents, err := controller.Agents()
if err != nil || len(agents) != 2 {
t.Fatalf("unexpected Agent list: %#v, %v", agents, err)
}
for _, agent := range agents {
if agent.ID == agentID && agent.Status != "online" {
t.Fatalf("active Agent incorrectly marked %q", agent.Status)
}
}
if err := controller.RenameAgent(agentID, "renamed-agent"); err != nil {
t.Fatal(err)
}
if err := controller.RevokeAgent(agentID); err != nil {
t.Fatal(err)
}
if controller.authenticate(agentID, credential) {
t.Fatal("revoked Agent credential must no longer authenticate")
}
var removedName string
if err := controller.db.QueryRow("SELECT name FROM managed_agents WHERE agent_id = ?", agentID).Scan(&removedName); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("revoked Agent record must be deleted, got %q / %v", removedName, err)
}
}
func TestUsableLANAddressRejectsTunnelAndSpecialAddresses(t *testing.T) {
for _, value := range []string{"198.18.0.1", "127.0.0.1", "169.254.1.1", "8.8.8.8"} {
if isUsableLANIPv4(net.ParseIP(value)) {
t.Fatalf("%s must not be selected as a LAN address", value)
}
}
for _, value := range []string{"192.168.1.15", "172.22.201.16", "10.1.2.3"} {
if !isUsableLANIPv4(net.ParseIP(value)) {
t.Fatalf("%s must be accepted as a private LAN address", value)
}
}
}
func TestClearTaskHistoryPreservesActiveTasksAndAuditLog(t *testing.T) {
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
if err != nil {
t.Fatal(err)
}
defer controller.Close()
now := time.Now().UTC().Format(time.RFC3339Nano)
for _, task := range []struct{ id, status string }{{"finished-task", "completed"}, {"active-task", "running"}} {
if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, timeout_seconds) VALUES (?, 'agent-1', '历史 Agent', '测试任务', ?, ?, 60)", task.id, task.status, now); err != nil {
t.Fatal(err)
}
if _, err := controller.db.Exec("INSERT INTO task_events(task_id, event_type, message, created_at) VALUES (?, 'accepted', '已接收', ?)", task.id, now); err != nil {
t.Fatal(err)
}
}
deleted, err := controller.ClearTaskHistory()
if err != nil || deleted != 1 {
t.Fatalf("expected one terminal task to be cleared, got %d / %v", deleted, err)
}
var activeCount, finishedEvents int
if err := controller.db.QueryRow("SELECT COUNT(*) FROM task_runs WHERE task_id = 'active-task'").Scan(&activeCount); err != nil || activeCount != 1 {
t.Fatalf("active task must remain, got %d / %v", activeCount, err)
}
if err := controller.db.QueryRow("SELECT COUNT(*) FROM task_events WHERE task_id = 'finished-task'").Scan(&finishedEvents); err != nil || finishedEvents != 0 {
t.Fatalf("finished task events must be cleared, got %d / %v", finishedEvents, err)
}
auditEvents, err := controller.RecentAuditEvents()
if err != nil || len(auditEvents) != 1 || auditEvents[0].Action != "task.history_cleared" {
t.Fatalf("expected history cleanup audit record, got %#v / %v", auditEvents, err)
}
}
func TestMentionResolutionDoesNotMisrouteUnknownDuplicateOrOfflineAgents(t *testing.T) {
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
if err != nil {
t.Fatal(err)
}
defer controller.Close()
now := time.Now().UTC().Format(time.RFC3339Nano)
for _, agent := range []struct{ id, name, status string }{
{"design", "设计机", "online"}, {"writer", "文案机", "offline"}, {"dup-a", "重名机", "online"}, {"dup-b", "重名机", "online"},
} {
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES (?, ?, 'test', ?, ?)", agent.id, agent.name, agent.status, now); err != nil {
t.Fatal(err)
}
}
items, issues, err := controller.resolveMentions("@设计机 做海报方案\n@文案机 写三条标题\n@重名机 不应误发\n@未知机 不应误发")
if err != nil || len(issues) != 2 || len(items) != 2 {
t.Fatalf("unexpected mention parse result: %#v / %#v / %v", items, issues, err)
}
if items[0].AgentID != "design" || items[0].Instruction != "做海报方案" {
t.Fatalf("design mention was not split to its exact target: %#v", items[0])
}
if items[1].AgentID != "writer" || !strings.Contains(items[1].Error, "离线") {
t.Fatalf("offline mention must remain at its own target: %#v", items[1])
}
if !strings.Contains(issues[0], "重名") || !strings.Contains(issues[1], "未识别") {
t.Fatalf("invalid names must be reported without creating targets: %#v", issues)
}
noTargets, noTargetIssues, err := controller.resolveMentions("没有目标的公共文本")
if err != nil || len(noTargets) != 0 || len(noTargetIssues) != 1 || !strings.Contains(noTargetIssues[0], "未指定") {
t.Fatalf("message without @ target must be rejected safely: %#v / %#v / %v", noTargets, noTargetIssues, err)
}
}
func TestMentionLineCanTargetManyAgentsWithSameTask(t *testing.T) {
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
if err != nil {
t.Fatal(err)
}
defer controller.Close()
now := time.Now().UTC().Format(time.RFC3339Nano)
for _, agent := range []struct{ id, name string }{{"design", "设计机"}, {"writer", "文案机"}} {
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES (?, ?, 'test', 'online', ?)", agent.id, agent.name, now); err != nil {
t.Fatal(err)
}
}
items, issues, err := controller.resolveMentions("@设计机 @文案机 完成同一份发布检查")
if err != nil || len(issues) != 0 || len(items) != 2 {
t.Fatalf("expected one line to create two direct targets: %#v / %#v / %v", items, issues, err)
}
if items[0].Instruction != "完成同一份发布检查" || items[1].Instruction != "完成同一份发布检查" || items[0].AgentID == items[1].AgentID {
t.Fatalf("same-line task was not duplicated independently: %#v", items)
}
}
func TestMentionImageRoutingRequiresNumbersAndSharesOnlyTheNamedImage(t *testing.T) {
images := []taskImage{
{Name: "one", MediaType: "image/png", DataURL: "data:image/png;base64,aQ=="},
{Name: "two", MediaType: "image/png", DataURL: "data:image/png;base64,ag=="},
}
if _, err := imagesForMention("分析构图", images, true); err == nil {
t.Fatal("multiple valid image tasks without a number must be rejected")
}
selected, err := imagesForMention("比较图片 1 的风格", images, true)
if err != nil || len(selected) != 1 || selected[0].Name != "one" {
t.Fatalf("same-line targets must receive only image 1: %#v / %v", selected, err)
}
selected, err = imagesForMention("为图片 2 写标题", images, true)
if err != nil || len(selected) != 1 || selected[0].Name != "two" {
t.Fatalf("separate line must receive only image 2: %#v / %v", selected, err)
}
if _, err := imagesForMention("分析图片 3", images, true); err == nil {
t.Fatal("missing image number must be rejected")
}
}
func TestTaskTextRejectsCredentialsAndInternalReasoning(t *testing.T) {
pairingCode := "v1.0123456789abcdef0123456789abcdef.0123456789abcdef0123456789abcdef0123456789abcdef.0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
for _, value := range []string{"api_key: secret-value", "密码:secret-value", "sk-abcdefghijklmnopqrstuvwxyz", pairingCode, "<think>private reasoning</think>做海报"} {
if err := validateSafeTaskText(value); err == nil {
t.Fatalf("sensitive task text must be rejected: %q", value)
}
}
if got := sanitizeDisplayText(pairingCode); strings.Contains(got, "0123456789abcdef") {
t.Fatalf("pairing credential leaked after display sanitization: %q", got)
}
}
func TestTaskProgressIsRecordedWithoutChangingStateAndRenewsDeadline(t *testing.T) {
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
if err != nil {
t.Fatal(err)
}
defer controller.Close()
now := time.Now().UTC()
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES ('agent-1', '进展测试机', 'test', 'online', ?)", now.Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, deadline_at, timeout_seconds) VALUES ('tool-task', 'agent-1', '进展测试机', '长任务', 'running', ?, ?, 300)", now.Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
controller.applyTaskEvent("agent-1", map[string]any{"task_id": "tool-task", "status": "progress", "message": "调用 computer_time:{} api_key: do-not-show"})
var status, deadline string
if err := controller.db.QueryRow("SELECT status, deadline_at FROM task_runs WHERE task_id = 'tool-task'").Scan(&status, &deadline); err != nil || status != "running" {
t.Fatalf("progress must not change the task state: %q / %v", status, err)
}
parsed, err := time.Parse(time.RFC3339Nano, deadline)
if err != nil || parsed.Before(time.Now().UTC().Add(4*time.Minute)) {
t.Fatalf("progress did not renew the no-progress deadline: %q / %v", deadline, err)
}
detail, err := controller.TaskDetails("tool-task")
if err != nil {
t.Fatal(err)
}
progressEvents := 0
for _, event := range detail.Events {
if event.Type == "progress" {
progressEvents++
if strings.Contains(event.Message, "do-not-show") || !strings.Contains(event.Message, "[已脱敏]") {
t.Fatalf("progress text leaked a credential: %q", event.Message)
}
}
}
if progressEvents != 1 {
t.Fatalf("expected one recorded progress step, got %d in %#v", progressEvents, detail.Events)
}
// Progress for a task that already ended must be ignored entirely.
controller.applyTaskEvent("agent-1", map[string]any{"task_id": "tool-task", "status": "completed", "message": "完成"})
controller.applyTaskEvent("agent-1", map[string]any{"task_id": "tool-task", "status": "progress", "message": "迟到的进展"})
if detail, err = controller.TaskDetails("tool-task"); err != nil {
t.Fatal(err)
}
for _, event := range detail.Events {
if event.Message == "迟到的进展" {
t.Fatal("a finished task must not gain new progress steps")
}
}
}
func TestAgentProgressRenewsNoProgressDeadline(t *testing.T) {
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
if err != nil {
t.Fatal(err)
}
defer controller.Close()
now := time.Now().UTC()
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES ('agent-1', '进展测试机', 'test', 'online', ?)", now.Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, deadline_at, timeout_seconds) VALUES ('progress-task', 'agent-1', '进展测试机', '长任务', 'queued', ?, ?, 300)", now.Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
controller.applyTaskEvent("agent-1", map[string]any{"task_id": "progress-task", "status": "accepted", "message": "已接收"})
var acceptedDeadline string
if err := controller.db.QueryRow("SELECT deadline_at FROM task_runs WHERE task_id = 'progress-task'").Scan(&acceptedDeadline); err != nil {
t.Fatal(err)
}
parsedAccepted, err := time.Parse(time.RFC3339Nano, acceptedDeadline)
if err != nil || parsedAccepted.Before(time.Now().UTC().Add(4*time.Minute)) {
t.Fatalf("accepted progress did not renew the five-minute deadline: %q / %v", acceptedDeadline, err)
}
controller.applyTaskEvent("agent-1", map[string]any{"task_id": "progress-task", "status": "running", "message": "执行中"})
var runningDeadline, status string
if err := controller.db.QueryRow("SELECT deadline_at, status FROM task_runs WHERE task_id = 'progress-task'").Scan(&runningDeadline, &status); err != nil || status != "running" {
t.Fatalf("running progress was not recorded: %q / %q / %v", runningDeadline, status, err)
}
parsedRunning, err := time.Parse(time.RFC3339Nano, runningDeadline)
if err != nil || parsedRunning.Before(time.Now().UTC().Add(4*time.Minute)) {
t.Fatalf("running progress did not renew the five-minute deadline: %q / %v", runningDeadline, err)
}
}
func TestAgentDirectoryPaginatesFiltersAndProvidesOperationalDetails(t *testing.T) {
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
if err != nil {
t.Fatal(err)
}
defer controller.Close()
now := time.Now().UTC().Format(time.RFC3339Nano)
for _, agent := range []struct{ id, name, status string }{
{"agent-online", "Alpha desktop", "online"},
{"agent-offline", "Beta laptop", "offline"},
{"agent-failed", "Gamma workstation", "offline"},
} {
if _, err := controller.db.Exec("INSERT INTO managed_agents(agent_id, name, credential_hash, status, last_seen_at) VALUES (?, ?, 'test', ?, ?)", agent.id, agent.name, agent.status, now); err != nil {
t.Fatal(err)
}
}
if _, err := controller.db.Exec("INSERT INTO agent_mcp_assignments(agent_id, tool_id, deployment_version, apply_status, last_error, updated_at) VALUES ('agent-online', 'desktop-filesystem', 1, 'applied', '', ?), ('agent-failed', 'desktop-filesystem', 2, 'failed', 'token: do-not-show', ?)", now, now); err != nil {
t.Fatal(err)
}
if _, err := controller.db.Exec("INSERT INTO agent_llm_assignments(agent_id, profile_id, config_version, apply_status, last_error, updated_at) VALUES ('agent-online', 'profile-1', 1, 'applied', '', ?)", now); err != nil {
t.Fatal(err)
}
if _, err := controller.db.Exec("INSERT INTO llm_profiles(profile_id, name, provider, base_url, model, secret_id, updated_at) VALUES ('profile-1', 'Production', 'openai', 'https://example.test/v1', 'model', 'secret-1', ?)", now); err != nil {
t.Fatal(err)
}
if _, err := controller.db.Exec("INSERT INTO skill_packages(skill_id, name, version, content, checksum, updated_at) VALUES ('skill-1', 'Safe Skill', '1.0', '# skill', 'sum', ?)", now); err != nil {
t.Fatal(err)
}
if _, err := controller.db.Exec("INSERT INTO agent_skill_assignments(agent_id, skill_id, apply_status, last_error, updated_at) VALUES ('agent-failed', 'skill-1', 'failed', 'password=do-not-show', ?)", now); err != nil {
t.Fatal(err)
}
page, err := controller.AgentDirectory(AgentDirectoryFilter{Page: 1, PageSize: 1, ConnectionStatus: "offline"})
if err != nil || page.TotalItems != 2 || page.TotalPages != 2 || len(page.Items) != 1 {
t.Fatalf("expected paged offline Agents, got %#v / %v", page, err)
}
page, err = controller.AgentDirectory(AgentDirectoryFilter{Page: 1, PageSize: 10, Name: "gamma", MCPStatus: "failed", SkillStatus: "failed"})
if err != nil || page.TotalItems != 1 || len(page.Items) != 1 || page.Items[0].ID != "agent-failed" || page.Items[0].MCPFailure != "token=[已脱敏]" {
t.Fatalf("expected server-side name/status filtering and redaction, got %#v / %v", page, err)
}
if _, err := controller.AgentDirectory(AgentDirectoryFilter{ConnectionStatus: "unexpected"}); err == nil {
t.Fatal("invalid directory filter must be rejected")
}
if _, err := controller.db.Exec("INSERT INTO task_runs(task_id, agent_id, agent_name, instruction, status, created_at, timeout_seconds, output) VALUES ('detail-task', 'agent-failed', 'Gamma workstation', '检查状态', 'failed', ?, 60, '失败')", now); err != nil {
t.Fatal(err)
}
controller.recordAudit("mcp.deployment_ack", "agent:agent-failed", "agent:agent-failed:desktop-filesystem", "failed", "token: do-not-show")
detail, err := controller.AgentOperationalDetails("agent-failed")
if err != nil || detail.Agent.Name != "Gamma workstation" || len(detail.RecentTasks) != 1 || len(detail.RecentAudit) != 1 || len(detail.DeploymentHistory) != 1 || len(detail.Failures) != 2 {
t.Fatalf("expected bounded Agent operational detail, got %#v / %v", detail, err)
}
if detail.RecentAudit[0].Detail != "token=[已脱敏]" || detail.Failures[0].Reason == "token: do-not-show" {
t.Fatalf("Agent detail leaked sensitive operational text: %#v", detail)
}
}
func TestAuditTrailUsesUnifiedFieldsFiltersAndSafeDetails(t *testing.T) {
t.Setenv("MULTICLAW_CONTROLLER_DATA_DIR", t.TempDir())
controller, err := Open(filepath.Join(t.TempDir(), "controller-config.json"))
if err != nil {
t.Fatal(err)
}
defer controller.Close()
controller.recordAudit("pairing.code_created", "controller", "pairing-token", "created", "配对码不应写入详情。")
controller.recordAudit("mcp.deployment_ack", "agent:agent-1", "agent:agent-1:desktop-filesystem", "failed", "api_key: secret-value")
controller.recordAudit("task.status_changed", "agent:agent-2", "agent:agent-2", "completed", "Agent 已上报任务状态变更。")
page, err := controller.AuditTrail(AuditFilter{Page: 1, PageSize: 1, AgentID: "agent-1", ResourceType: "mcp", Result: "failed"})
if err != nil || page.TotalItems != 1 || page.TotalPages != 1 || len(page.Items) != 1 {
t.Fatalf("expected one filtered unified audit event, got %#v / %v", page, err)
}
event := page.Items[0]
if event.Operator != "agent:agent-1" || event.TargetAgentID != "agent-1" || event.ResourceType != "mcp" || event.Result != "failed" {
t.Fatalf("unexpected unified audit fields: %#v", event)
}
if event.SecurityDetail != "api_key=[已脱敏]" || strings.Contains(event.SecurityDetail, "secret-value") {
t.Fatalf("audit detail leaked a secret: %#v", event)
}
if _, err := controller.AuditTrail(AuditFilter{ResourceType: "invalid"}); err == nil {
t.Fatal("invalid audit resource filter must be rejected")
}
if _, err := controller.AuditTrail(AuditFilter{FromAt: "not-a-time"}); err == nil {
t.Fatal("invalid audit time filter must be rejected")
}
}