/ internal / daemon / server_test.go
server_test.go
   1  package daemon
   2  
   3  import (
   4  	"bufio"
   5  	"bytes"
   6  	"context"
   7  	"encoding/json"
   8  	"errors"
   9  	"fmt"
  10  	"io"
  11  	"net/http"
  12  	"net/http/httptest"
  13  	"os"
  14  	"path/filepath"
  15  	"strconv"
  16  	"strings"
  17  	"testing"
  18  	"time"
  19  
  20  	"github.com/Kocoro-lab/ShanClaw/internal/agents"
  21  	"github.com/Kocoro-lab/ShanClaw/internal/config"
  22  	"github.com/Kocoro-lab/ShanClaw/internal/mcp"
  23  	"github.com/Kocoro-lab/ShanClaw/internal/skills"
  24  	"github.com/spf13/viper"
  25  	"gopkg.in/yaml.v3"
  26  )
  27  
  28  func writeTestGlobalSkill(t *testing.T, shannonDir, name string) {
  29  	t.Helper()
  30  	if err := skills.WriteGlobalSkill(shannonDir, &skills.Skill{
  31  		Name:        name,
  32  		Description: name + " description",
  33  		Prompt:      "prompt for " + name,
  34  	}); err != nil {
  35  		t.Fatalf("write global skill %s: %v", name, err)
  36  	}
  37  }
  38  
  39  func TestServer_GlobalSkillStickyRoundTrip(t *testing.T) {
  40  	shannonDir := t.TempDir()
  41  	if err := skills.WriteGlobalSkill(shannonDir, &skills.Skill{
  42  		Name:                  "policy",
  43  		Description:           "policy description",
  44  		Prompt:                "# policy\n\nUse the API.",
  45  		License:               "MIT",
  46  		StickyInstructions:    true,
  47  		StickySnippetOverride: "Use the http tool for platform operations.",
  48  	}); err != nil {
  49  		t.Fatalf("seed global skill: %v", err)
  50  	}
  51  
  52  	deps := &ServerDeps{ShannonDir: shannonDir}
  53  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
  54  	srv := NewServer(0, c, deps, "test")
  55  	ctx, cancel := context.WithCancel(context.Background())
  56  	defer cancel()
  57  
  58  	go srv.Start(ctx)
  59  	time.Sleep(100 * time.Millisecond)
  60  
  61  	resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/skills/policy", srv.Port()))
  62  	if err != nil {
  63  		t.Fatal(err)
  64  	}
  65  	defer resp.Body.Close()
  66  
  67  	if resp.StatusCode != http.StatusOK {
  68  		t.Fatalf("GET /skills/policy status = %d", resp.StatusCode)
  69  	}
  70  	var detail struct {
  71  		Name               string `json:"name"`
  72  		StickyInstructions bool   `json:"sticky_instructions"`
  73  		StickySnippet      string `json:"sticky_snippet"`
  74  	}
  75  	if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil {
  76  		t.Fatalf("decode GET body: %v", err)
  77  	}
  78  	if detail.Name != "policy" {
  79  		t.Fatalf("GET returned name %q", detail.Name)
  80  	}
  81  	if !detail.StickyInstructions {
  82  		t.Fatal("GET dropped sticky_instructions")
  83  	}
  84  	if detail.StickySnippet != "Use the http tool for platform operations." {
  85  		t.Fatalf("GET sticky_snippet = %q", detail.StickySnippet)
  86  	}
  87  
  88  	reqBody := `{"description":"updated description","prompt":"# policy\n\nUpdated.","license":"Apache-2.0"}`
  89  	req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("http://127.0.0.1:%d/skills/policy", srv.Port()), strings.NewReader(reqBody))
  90  	if err != nil {
  91  		t.Fatal(err)
  92  	}
  93  	req.Header.Set("Content-Type", "application/json")
  94  	resp2, err := http.DefaultClient.Do(req)
  95  	if err != nil {
  96  		t.Fatal(err)
  97  	}
  98  	defer resp2.Body.Close()
  99  	if resp2.StatusCode != http.StatusOK {
 100  		body, _ := io.ReadAll(resp2.Body)
 101  		t.Fatalf("PUT /skills/policy status = %d body=%s", resp2.StatusCode, string(body))
 102  	}
 103  
 104  	loaded, err := skills.LoadSkills(skills.SkillSource{
 105  		Dir:    filepath.Join(shannonDir, "skills"),
 106  		Source: skills.SourceGlobal,
 107  	})
 108  	if err != nil {
 109  		t.Fatalf("reload skills: %v", err)
 110  	}
 111  	var policy *skills.Skill
 112  	for _, skill := range loaded {
 113  		if skill.Name == "policy" {
 114  			policy = skill
 115  			break
 116  		}
 117  	}
 118  	if policy == nil {
 119  		t.Fatal("reloaded skill not found")
 120  	}
 121  	if !policy.StickyInstructions {
 122  		t.Fatal("PUT dropped sticky instructions")
 123  	}
 124  	if policy.StickySnippetOverride != "Use the http tool for platform operations." {
 125  		t.Fatalf("PUT dropped sticky snippet override: %q", policy.StickySnippetOverride)
 126  	}
 127  	if policy.License != "Apache-2.0" {
 128  		t.Fatalf("license not updated: %q", policy.License)
 129  	}
 130  }
 131  
 132  func TestServer_Health(t *testing.T) {
 133  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
 134  	srv := NewServer(0, c, nil, "test")
 135  	ctx, cancel := context.WithCancel(context.Background())
 136  	defer cancel()
 137  
 138  	go srv.Start(ctx)
 139  	time.Sleep(100 * time.Millisecond)
 140  
 141  	resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health", srv.Port()))
 142  	if err != nil {
 143  		t.Fatal(err)
 144  	}
 145  	defer resp.Body.Close()
 146  
 147  	if resp.StatusCode != http.StatusOK {
 148  		t.Errorf("status = %d", resp.StatusCode)
 149  	}
 150  	var body map[string]string
 151  	json.NewDecoder(resp.Body).Decode(&body)
 152  	if body["status"] != "ok" {
 153  		t.Errorf("body = %v", body)
 154  	}
 155  	if body["version"] != "test" {
 156  		t.Errorf("version = %q, want %q", body["version"], "test")
 157  	}
 158  }
 159  
 160  func TestServer_Status(t *testing.T) {
 161  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
 162  	srv := NewServer(0, c, nil, "test")
 163  	ctx, cancel := context.WithCancel(context.Background())
 164  	defer cancel()
 165  
 166  	go srv.Start(ctx)
 167  	time.Sleep(100 * time.Millisecond)
 168  
 169  	resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/status", srv.Port()))
 170  	if err != nil {
 171  		t.Fatal(err)
 172  	}
 173  	defer resp.Body.Close()
 174  
 175  	var body struct {
 176  		IsConnected bool   `json:"is_connected"`
 177  		ActiveAgent string `json:"active_agent"`
 178  		Uptime      int    `json:"uptime"`
 179  		Version     string `json:"version"`
 180  	}
 181  	json.NewDecoder(resp.Body).Decode(&body)
 182  	if body.IsConnected {
 183  		t.Error("should not be connected")
 184  	}
 185  	if body.Uptime < 0 {
 186  		t.Error("uptime should be non-negative")
 187  	}
 188  	if body.Version != "test" {
 189  		t.Errorf("version = %q, want %q", body.Version, "test")
 190  	}
 191  }
 192  
 193  func TestServer_Shutdown(t *testing.T) {
 194  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
 195  	srv := NewServer(0, c, nil, "test")
 196  	ctx, cancel := context.WithCancel(context.Background())
 197  
 198  	go srv.Start(ctx)
 199  	time.Sleep(100 * time.Millisecond)
 200  
 201  	cancel()
 202  	time.Sleep(200 * time.Millisecond)
 203  
 204  	_, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/health", srv.Port()))
 205  	if err == nil {
 206  		t.Error("expected connection refused after shutdown")
 207  	}
 208  }
 209  
 210  func TestServer_Agents_Empty(t *testing.T) {
 211  	agentsDir := t.TempDir()
 212  	sessDir := t.TempDir()
 213  	deps := &ServerDeps{
 214  		AgentsDir:    agentsDir,
 215  		SessionCache: NewSessionCache(sessDir),
 216  	}
 217  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
 218  	srv := NewServer(0, c, deps, "test")
 219  	ctx, cancel := context.WithCancel(context.Background())
 220  	defer cancel()
 221  
 222  	go srv.Start(ctx)
 223  	time.Sleep(100 * time.Millisecond)
 224  
 225  	resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()))
 226  	if err != nil {
 227  		t.Fatal(err)
 228  	}
 229  	defer resp.Body.Close()
 230  
 231  	if resp.StatusCode != http.StatusOK {
 232  		t.Fatalf("status = %d", resp.StatusCode)
 233  	}
 234  	body, _ := io.ReadAll(resp.Body)
 235  	var parsed map[string]json.RawMessage
 236  	json.Unmarshal(body, &parsed)
 237  	if string(parsed["agents"]) != "[]" {
 238  		t.Errorf("expected empty agents array, got %s", string(body))
 239  	}
 240  }
 241  
 242  func TestServer_Sessions_Empty(t *testing.T) {
 243  	sessDir := t.TempDir()
 244  	deps := &ServerDeps{
 245  		SessionCache: NewSessionCache(sessDir),
 246  	}
 247  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
 248  	srv := NewServer(0, c, deps, "test")
 249  	ctx, cancel := context.WithCancel(context.Background())
 250  	defer cancel()
 251  
 252  	go srv.Start(ctx)
 253  	time.Sleep(100 * time.Millisecond)
 254  
 255  	resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/sessions", srv.Port()))
 256  	if err != nil {
 257  		t.Fatal(err)
 258  	}
 259  	defer resp.Body.Close()
 260  
 261  	if resp.StatusCode != http.StatusOK {
 262  		t.Fatalf("status = %d", resp.StatusCode)
 263  	}
 264  	body, _ := io.ReadAll(resp.Body)
 265  	var parsed map[string]json.RawMessage
 266  	json.Unmarshal(body, &parsed)
 267  	if string(parsed["sessions"]) != "[]" {
 268  		t.Errorf("expected empty sessions array, got %s", string(body))
 269  	}
 270  }
 271  
 272  func TestServer_Message_MissingText(t *testing.T) {
 273  	deps := &ServerDeps{}
 274  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
 275  	srv := NewServer(0, c, deps, "test")
 276  	ctx, cancel := context.WithCancel(context.Background())
 277  	defer cancel()
 278  
 279  	go srv.Start(ctx)
 280  	time.Sleep(100 * time.Millisecond)
 281  
 282  	resp, err := http.Post(
 283  		fmt.Sprintf("http://127.0.0.1:%d/message", srv.Port()),
 284  		"application/json",
 285  		strings.NewReader(`{}`),
 286  	)
 287  	if err != nil {
 288  		t.Fatal(err)
 289  	}
 290  	defer resp.Body.Close()
 291  
 292  	if resp.StatusCode != http.StatusBadRequest {
 293  		t.Errorf("expected 400, got %d", resp.StatusCode)
 294  	}
 295  }
 296  
 297  func TestServer_Message_AgentNotFound(t *testing.T) {
 298  	sessDir := t.TempDir()
 299  	deps := &ServerDeps{
 300  		Config:       &config.Config{},
 301  		AgentsDir:    t.TempDir(),
 302  		SessionCache: NewSessionCache(sessDir),
 303  	}
 304  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
 305  	srv := NewServer(0, c, deps, "test")
 306  	ctx, cancel := context.WithCancel(context.Background())
 307  	defer cancel()
 308  
 309  	go srv.Start(ctx)
 310  	time.Sleep(100 * time.Millisecond)
 311  
 312  	resp, err := http.Post(
 313  		fmt.Sprintf("http://127.0.0.1:%d/message", srv.Port()),
 314  		"application/json",
 315  		strings.NewReader(`{"text":"hello","agent":"nonexistent"}`),
 316  	)
 317  	if err != nil {
 318  		t.Fatal(err)
 319  	}
 320  	defer resp.Body.Close()
 321  
 322  	// Agent falls back to default when not found, but RunAgent will fail
 323  	// because deps are incomplete (no gateway, registry). 500 is expected.
 324  	if resp.StatusCode != http.StatusInternalServerError {
 325  		t.Errorf("expected 500, got %d", resp.StatusCode)
 326  	}
 327  	body, _ := io.ReadAll(resp.Body)
 328  	if !strings.Contains(string(body), "error") {
 329  		t.Errorf("expected error in body, got %s", string(body))
 330  	}
 331  }
 332  
 333  func TestServer_ChromeHandlersUseConfiguredPlaywrightPort(t *testing.T) {
 334  	oldShow := showChromeOnPortFn
 335  	oldHide := hideChromeOnPortFn
 336  	oldStatus := getChromeStatusOnPortFn
 337  	defer func() {
 338  		showChromeOnPortFn = oldShow
 339  		hideChromeOnPortFn = oldHide
 340  		getChromeStatusOnPortFn = oldStatus
 341  	}()
 342  
 343  	var showPort, hidePort, statusPort int
 344  	showChromeOnPortFn = func(port int) error {
 345  		showPort = port
 346  		return nil
 347  	}
 348  	hideChromeOnPortFn = func(port int) error {
 349  		hidePort = port
 350  		return nil
 351  	}
 352  	getChromeStatusOnPortFn = func(port int) mcp.CDPChromeStatus {
 353  		statusPort = port
 354  		return mcp.CDPChromeStatus{Running: true, Visible: true}
 355  	}
 356  
 357  	deps := &ServerDeps{
 358  		Config: &config.Config{
 359  			MCPServers: map[string]mcp.MCPServerConfig{
 360  				"playwright": {
 361  					Args: []string{"--cdp-endpoint", "http://127.0.0.1:9333"},
 362  				},
 363  			},
 364  		},
 365  	}
 366  	srv := NewServer(0, nil, deps, "test")
 367  
 368  	showRec := httptest.NewRecorder()
 369  	srv.handleChromeShow(showRec, httptest.NewRequest(http.MethodPost, "/chrome/show", nil))
 370  	if showPort != 9333 {
 371  		t.Fatalf("show used port %d, want 9333", showPort)
 372  	}
 373  	if showRec.Code != http.StatusOK {
 374  		t.Fatalf("show status = %d, want 200", showRec.Code)
 375  	}
 376  	var showBody map[string]string
 377  	if err := json.NewDecoder(showRec.Body).Decode(&showBody); err != nil {
 378  		t.Fatalf("decode show body: %v", err)
 379  	}
 380  	if showBody["status"] != "visible" {
 381  		t.Fatalf("show body = %v, want visible status", showBody)
 382  	}
 383  
 384  	hideRec := httptest.NewRecorder()
 385  	srv.handleChromeHide(hideRec, httptest.NewRequest(http.MethodPost, "/chrome/hide", nil))
 386  	if hidePort != 9333 {
 387  		t.Fatalf("hide used port %d, want 9333", hidePort)
 388  	}
 389  	if hideRec.Code != http.StatusOK {
 390  		t.Fatalf("hide status = %d, want 200", hideRec.Code)
 391  	}
 392  	var hideBody map[string]string
 393  	if err := json.NewDecoder(hideRec.Body).Decode(&hideBody); err != nil {
 394  		t.Fatalf("decode hide body: %v", err)
 395  	}
 396  	if hideBody["status"] != "hidden" {
 397  		t.Fatalf("hide body = %v, want hidden status", hideBody)
 398  	}
 399  
 400  	statusRec := httptest.NewRecorder()
 401  	srv.handleChromeStatus(statusRec, httptest.NewRequest(http.MethodGet, "/chrome/status", nil))
 402  	if statusPort != 9333 {
 403  		t.Fatalf("status used port %d, want 9333", statusPort)
 404  	}
 405  	if statusRec.Code != http.StatusOK {
 406  		t.Fatalf("status code = %d, want 200", statusRec.Code)
 407  	}
 408  	var statusBody map[string]bool
 409  	if err := json.NewDecoder(statusRec.Body).Decode(&statusBody); err != nil {
 410  		t.Fatalf("decode status body: %v", err)
 411  	}
 412  	if !statusBody["running"] || !statusBody["visible"] {
 413  		t.Fatalf("status body = %v, want running+visible", statusBody)
 414  	}
 415  	if statusBody["probe_error"] {
 416  		t.Fatalf("status body = %v, want probe_error=false", statusBody)
 417  	}
 418  }
 419  
 420  func TestServer_ChromeHandlersNormalizeLegacyPlaywrightPort(t *testing.T) {
 421  	oldShow := showChromeOnPortFn
 422  	defer func() { showChromeOnPortFn = oldShow }()
 423  
 424  	var showPort int
 425  	showChromeOnPortFn = func(port int) error {
 426  		showPort = port
 427  		return nil
 428  	}
 429  
 430  	deps := &ServerDeps{
 431  		Config: &config.Config{
 432  			MCPServers: map[string]mcp.MCPServerConfig{
 433  				"playwright": {
 434  					Args: []string{"--cdp-endpoint", "http://localhost:9222"},
 435  				},
 436  			},
 437  		},
 438  	}
 439  	srv := NewServer(0, nil, deps, "test")
 440  
 441  	rec := httptest.NewRecorder()
 442  	srv.handleChromeShow(rec, httptest.NewRequest(http.MethodPost, "/chrome/show", nil))
 443  	if showPort != mcp.DefaultCDPPort {
 444  		t.Fatalf("show used port %d, want normalized default %d", showPort, mcp.DefaultCDPPort)
 445  	}
 446  	if rec.Code != http.StatusOK {
 447  		t.Fatalf("show status = %d, want 200", rec.Code)
 448  	}
 449  	var body map[string]string
 450  	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
 451  		t.Fatalf("decode show body: %v", err)
 452  	}
 453  	if body["status"] != "visible" {
 454  		t.Fatalf("show body = %v, want visible status", body)
 455  	}
 456  }
 457  
 458  func TestServer_ChromeProfileHandlerUsesConfiguredProfile(t *testing.T) {
 459  	oldGet := getChromeProfileStateFn
 460  	defer func() { getChromeProfileStateFn = oldGet }()
 461  
 462  	var configured string
 463  	getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) {
 464  		configured = profile
 465  		return mcp.ChromeProfileState{
 466  			Mode:              "explicit",
 467  			ConfiguredProfile: profile,
 468  			EffectiveProfile:  profile,
 469  			CloneStatus:       mcp.ChromeProfileCloneCurrent,
 470  			Profiles: []mcp.ChromeProfileOption{
 471  				{Name: "Profile 6", DisplayName: "Work", Exists: true, IsConfigured: true, IsEffective: true},
 472  			},
 473  		}, nil
 474  	}
 475  
 476  	deps := &ServerDeps{
 477  		Config: &config.Config{
 478  			Daemon: config.DaemonConfig{ChromeProfile: "Profile 6"},
 479  		},
 480  	}
 481  	srv := NewServer(0, nil, deps, "test")
 482  
 483  	rec := httptest.NewRecorder()
 484  	srv.handleChromeProfile(rec, httptest.NewRequest(http.MethodGet, "/chrome/profile", nil))
 485  	if rec.Code != http.StatusOK {
 486  		t.Fatalf("status code = %d, want 200", rec.Code)
 487  	}
 488  	if configured != "Profile 6" {
 489  		t.Fatalf("expected configured profile 'Profile 6', got %q", configured)
 490  	}
 491  	var body mcp.ChromeProfileState
 492  	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
 493  		t.Fatalf("decode body: %v", err)
 494  	}
 495  	if body.EffectiveProfile != "Profile 6" {
 496  		t.Fatalf("expected effective profile 'Profile 6', got %q", body.EffectiveProfile)
 497  	}
 498  	if body.CloneStatus != mcp.ChromeProfileCloneCurrent {
 499  		t.Fatalf("expected clone status %q, got %q", mcp.ChromeProfileCloneCurrent, body.CloneStatus)
 500  	}
 501  }
 502  
 503  func TestServer_ChromeProfileUpdateExplicitPersistsAndRefreshesClone(t *testing.T) {
 504  	oldGet := getChromeProfileStateFn
 505  	oldStop := stopChromeFn
 506  	oldReset := resetChromeProfileCloneFn
 507  	oldProfile := mcp.GetCDPChromeProfile()
 508  	defer func() {
 509  		getChromeProfileStateFn = oldGet
 510  		stopChromeFn = oldStop
 511  		resetChromeProfileCloneFn = oldReset
 512  		mcp.SetCDPChromeProfile(oldProfile)
 513  	}()
 514  
 515  	getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) {
 516  		state := mcp.ChromeProfileState{
 517  			Mode:              "explicit",
 518  			ConfiguredProfile: profile,
 519  			EffectiveProfile:  profile,
 520  			CloneStatus:       mcp.ChromeProfileCloneMissing,
 521  			Profiles: []mcp.ChromeProfileOption{
 522  				{Name: "Default", DisplayName: "Default", Exists: true},
 523  				{Name: "Profile 6", DisplayName: "Work", Exists: true, IsConfigured: profile == "Profile 6", IsEffective: profile == "Profile 6"},
 524  			},
 525  		}
 526  		if profile == "" {
 527  			state.Mode = "auto"
 528  			state.DetectedProfile = "Profile 6"
 529  			state.EffectiveProfile = "Profile 6"
 530  		}
 531  		return state, nil
 532  	}
 533  
 534  	stopCalls := 0
 535  	stopChromeFn = func() { stopCalls++ }
 536  	resetCalls := 0
 537  	resetChromeProfileCloneFn = func() error {
 538  		resetCalls++
 539  		return nil
 540  	}
 541  
 542  	shannonDir := t.TempDir()
 543  	if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte("{}\n"), 0o600); err != nil {
 544  		t.Fatalf("write config: %v", err)
 545  	}
 546  	deps := &ServerDeps{
 547  		ShannonDir: shannonDir,
 548  		Config:     &config.Config{},
 549  	}
 550  	srv := NewServer(0, nil, deps, "test")
 551  
 552  	rec := httptest.NewRecorder()
 553  	req := httptest.NewRequest(http.MethodPost, "/chrome/profile", strings.NewReader(`{"mode":"explicit","profile":"Profile 6"}`))
 554  	req.Header.Set("Content-Type", "application/json")
 555  	srv.handleChromeProfileUpdate(rec, req)
 556  
 557  	if rec.Code != http.StatusOK {
 558  		t.Fatalf("status code = %d, want 200, body=%s", rec.Code, rec.Body.String())
 559  	}
 560  	if deps.Config.Daemon.ChromeProfile != "Profile 6" {
 561  		t.Fatalf("expected in-memory config to be updated, got %q", deps.Config.Daemon.ChromeProfile)
 562  	}
 563  	if mcp.GetCDPChromeProfile() != "Profile 6" {
 564  		t.Fatalf("expected runtime chrome profile override, got %q", mcp.GetCDPChromeProfile())
 565  	}
 566  	if stopCalls != 1 || resetCalls != 1 {
 567  		t.Fatalf("expected stop/reset to be called once each, got stop=%d reset=%d", stopCalls, resetCalls)
 568  	}
 569  	data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml"))
 570  	if err != nil {
 571  		t.Fatalf("read config: %v", err)
 572  	}
 573  	if !strings.Contains(string(data), "chrome_profile: Profile 6") {
 574  		t.Fatalf("expected config to persist chrome_profile, got %s", string(data))
 575  	}
 576  
 577  	var body mcp.ChromeProfileState
 578  	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
 579  		t.Fatalf("decode body: %v", err)
 580  	}
 581  	if body.ConfiguredProfile != "Profile 6" || body.EffectiveProfile != "Profile 6" {
 582  		t.Fatalf("unexpected response body: %+v", body)
 583  	}
 584  	if body.CloneStatus != mcp.ChromeProfileCloneMissing {
 585  		t.Fatalf("expected clone status %q, got %q", mcp.ChromeProfileCloneMissing, body.CloneStatus)
 586  	}
 587  }
 588  
 589  func TestServer_ChromeProfileUpdateAutoClearsConfigKey(t *testing.T) {
 590  	oldGet := getChromeProfileStateFn
 591  	oldStop := stopChromeFn
 592  	oldReset := resetChromeProfileCloneFn
 593  	oldProfile := mcp.GetCDPChromeProfile()
 594  	defer func() {
 595  		getChromeProfileStateFn = oldGet
 596  		stopChromeFn = oldStop
 597  		resetChromeProfileCloneFn = oldReset
 598  		mcp.SetCDPChromeProfile(oldProfile)
 599  	}()
 600  
 601  	getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) {
 602  		return mcp.ChromeProfileState{
 603  			Mode:             "auto",
 604  			DetectedProfile:  "Profile 6",
 605  			EffectiveProfile: "Profile 6",
 606  			CloneStatus:      mcp.ChromeProfileCloneMissing,
 607  			Profiles: []mcp.ChromeProfileOption{
 608  				{Name: "Profile 6", DisplayName: "Work", Exists: true, IsLastUsed: true, IsEffective: true},
 609  			},
 610  		}, nil
 611  	}
 612  	stopChromeFn = func() {}
 613  	resetChromeProfileCloneFn = func() error { return nil }
 614  
 615  	shannonDir := t.TempDir()
 616  	initial := "daemon:\n  auto_approve: true\n  chrome_profile: Profile 6\n"
 617  	if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte(initial), 0o600); err != nil {
 618  		t.Fatalf("write config: %v", err)
 619  	}
 620  	deps := &ServerDeps{
 621  		ShannonDir: shannonDir,
 622  		Config: &config.Config{
 623  			Daemon: config.DaemonConfig{AutoApprove: true, ChromeProfile: "Profile 6"},
 624  		},
 625  	}
 626  	srv := NewServer(0, nil, deps, "test")
 627  
 628  	rec := httptest.NewRecorder()
 629  	req := httptest.NewRequest(http.MethodPost, "/chrome/profile", strings.NewReader(`{"mode":"auto"}`))
 630  	req.Header.Set("Content-Type", "application/json")
 631  	srv.handleChromeProfileUpdate(rec, req)
 632  
 633  	if rec.Code != http.StatusOK {
 634  		t.Fatalf("status code = %d, want 200, body=%s", rec.Code, rec.Body.String())
 635  	}
 636  	if deps.Config.Daemon.ChromeProfile != "" {
 637  		t.Fatalf("expected in-memory chrome_profile to be cleared, got %q", deps.Config.Daemon.ChromeProfile)
 638  	}
 639  	if mcp.GetCDPChromeProfile() != "" {
 640  		t.Fatalf("expected runtime chrome profile override to be cleared, got %q", mcp.GetCDPChromeProfile())
 641  	}
 642  	data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml"))
 643  	if err != nil {
 644  		t.Fatalf("read config: %v", err)
 645  	}
 646  	text := string(data)
 647  	if strings.Contains(text, "chrome_profile:") {
 648  		t.Fatalf("expected chrome_profile key to be removed, got %s", text)
 649  	}
 650  	if !strings.Contains(text, "auto_approve: true") {
 651  		t.Fatalf("expected sibling daemon setting to remain, got %s", text)
 652  	}
 653  
 654  	var body mcp.ChromeProfileState
 655  	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
 656  		t.Fatalf("decode body: %v", err)
 657  	}
 658  	if body.Mode != "auto" || body.CloneStatus != mcp.ChromeProfileCloneMissing {
 659  		t.Fatalf("unexpected response body: %+v", body)
 660  	}
 661  }
 662  
 663  func TestServer_ChromeProfileUpdateDoesNotPersistWhenResetFails(t *testing.T) {
 664  	oldGet := getChromeProfileStateFn
 665  	oldStop := stopChromeFn
 666  	oldReset := resetChromeProfileCloneFn
 667  	oldProfile := mcp.GetCDPChromeProfile()
 668  	defer func() {
 669  		getChromeProfileStateFn = oldGet
 670  		stopChromeFn = oldStop
 671  		resetChromeProfileCloneFn = oldReset
 672  		mcp.SetCDPChromeProfile(oldProfile)
 673  	}()
 674  
 675  	getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) {
 676  		return mcp.ChromeProfileState{
 677  			Mode:             "auto",
 678  			DetectedProfile:  "Default",
 679  			EffectiveProfile: "Default",
 680  			CloneStatus:      mcp.ChromeProfileCloneCurrent,
 681  			Profiles: []mcp.ChromeProfileOption{
 682  				{Name: "Default", DisplayName: "Default", Exists: true, IsLastUsed: true, IsEffective: true},
 683  				{Name: "Profile 6", DisplayName: "Work", Exists: true},
 684  			},
 685  		}, nil
 686  	}
 687  	stopChromeFn = func() {}
 688  	resetChromeProfileCloneFn = func() error { return errors.New("directory not empty") }
 689  
 690  	shannonDir := t.TempDir()
 691  	t.Cleanup(func() { _ = os.Chmod(shannonDir, 0o700) })
 692  	initial := "daemon:\n  auto_approve: true\n"
 693  	if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte(initial), 0o600); err != nil {
 694  		t.Fatalf("write config: %v", err)
 695  	}
 696  	deps := &ServerDeps{
 697  		ShannonDir: shannonDir,
 698  		Config: &config.Config{
 699  			Daemon: config.DaemonConfig{AutoApprove: true},
 700  		},
 701  	}
 702  	srv := NewServer(0, nil, deps, "test")
 703  
 704  	rec := httptest.NewRecorder()
 705  	req := httptest.NewRequest(http.MethodPost, "/chrome/profile", strings.NewReader(`{"mode":"explicit","profile":"Profile 6"}`))
 706  	req.Header.Set("Content-Type", "application/json")
 707  	srv.handleChromeProfileUpdate(rec, req)
 708  
 709  	if rec.Code != http.StatusInternalServerError {
 710  		t.Fatalf("status code = %d, want 500, body=%s", rec.Code, rec.Body.String())
 711  	}
 712  	var body map[string]string
 713  	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
 714  		t.Fatalf("decode error body: %v", err)
 715  	}
 716  	if body["error"] != "directory not empty" {
 717  		t.Fatalf("unexpected error body: %v", body)
 718  	}
 719  	if deps.Config.Daemon.ChromeProfile != "" {
 720  		t.Fatalf("expected in-memory chrome_profile to remain unchanged, got %q", deps.Config.Daemon.ChromeProfile)
 721  	}
 722  	if mcp.GetCDPChromeProfile() != oldProfile {
 723  		t.Fatalf("expected runtime chrome profile override to remain unchanged, got %q", mcp.GetCDPChromeProfile())
 724  	}
 725  	data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml"))
 726  	if err != nil {
 727  		t.Fatalf("read config: %v", err)
 728  	}
 729  	text := string(data)
 730  	if strings.Contains(text, "chrome_profile:") {
 731  		t.Fatalf("expected rolled-back config to remove chrome_profile, got %s", text)
 732  	}
 733  	if !strings.Contains(text, "auto_approve: true") {
 734  		t.Fatalf("expected rolled-back config to keep sibling settings, got %s", text)
 735  	}
 736  }
 737  
 738  func TestServer_ChromeProfileUpdateRollbackFailureKeepsMemoryAlignedWithDisk(t *testing.T) {
 739  	oldGet := getChromeProfileStateFn
 740  	oldStop := stopChromeFn
 741  	oldReset := resetChromeProfileCloneFn
 742  	oldProfile := mcp.GetCDPChromeProfile()
 743  	defer func() {
 744  		getChromeProfileStateFn = oldGet
 745  		stopChromeFn = oldStop
 746  		resetChromeProfileCloneFn = oldReset
 747  		mcp.SetCDPChromeProfile(oldProfile)
 748  	}()
 749  
 750  	getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) {
 751  		return mcp.ChromeProfileState{
 752  			Mode:             "auto",
 753  			DetectedProfile:  "Default",
 754  			EffectiveProfile: "Default",
 755  			CloneStatus:      mcp.ChromeProfileCloneCurrent,
 756  			Profiles: []mcp.ChromeProfileOption{
 757  				{Name: "Default", DisplayName: "Default", Exists: true, IsLastUsed: true, IsEffective: true},
 758  				{Name: "Profile 6", DisplayName: "Work", Exists: true},
 759  			},
 760  		}, nil
 761  	}
 762  	stopChromeFn = func() {}
 763  
 764  	shannonDir := t.TempDir()
 765  	initial := "daemon:\n  auto_approve: true\n"
 766  	if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte(initial), 0o600); err != nil {
 767  		t.Fatalf("write config: %v", err)
 768  	}
 769  	resetChromeProfileCloneFn = func() error {
 770  		if err := os.RemoveAll(shannonDir); err != nil {
 771  			t.Fatalf("remove shannon dir: %v", err)
 772  		}
 773  		return errors.New("directory not empty")
 774  	}
 775  
 776  	deps := &ServerDeps{
 777  		ShannonDir: shannonDir,
 778  		Config: &config.Config{
 779  			Daemon: config.DaemonConfig{AutoApprove: true},
 780  		},
 781  	}
 782  	srv := NewServer(0, nil, deps, "test")
 783  
 784  	rec := httptest.NewRecorder()
 785  	req := httptest.NewRequest(http.MethodPost, "/chrome/profile", strings.NewReader(`{"mode":"explicit","profile":"Profile 6"}`))
 786  	req.Header.Set("Content-Type", "application/json")
 787  	srv.handleChromeProfileUpdate(rec, req)
 788  
 789  	if rec.Code != http.StatusInternalServerError {
 790  		t.Fatalf("status code = %d, want 500, body=%s", rec.Code, rec.Body.String())
 791  	}
 792  	var body map[string]string
 793  	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
 794  		t.Fatalf("decode error body: %v", err)
 795  	}
 796  	if !strings.Contains(body["error"], "rollback failed") {
 797  		t.Fatalf("expected rollback failure in error body, got %v", body)
 798  	}
 799  	if deps.Config.Daemon.ChromeProfile != "Profile 6" {
 800  		t.Fatalf("expected in-memory chrome_profile to stay aligned with disk, got %q", deps.Config.Daemon.ChromeProfile)
 801  	}
 802  	if mcp.GetCDPChromeProfile() != "Profile 6" {
 803  		t.Fatalf("expected runtime chrome profile override to stay aligned with disk, got %q", mcp.GetCDPChromeProfile())
 804  	}
 805  	if _, err := os.Stat(shannonDir); !os.IsNotExist(err) {
 806  		t.Fatalf("expected rollback failure setup to remove shannon dir, got err=%v", err)
 807  	}
 808  }
 809  
 810  func TestServer_ChromeProfileUpdateDoesNotStopWhenConfigWriteFails(t *testing.T) {
 811  	oldGet := getChromeProfileStateFn
 812  	oldStop := stopChromeFn
 813  	oldReset := resetChromeProfileCloneFn
 814  	oldProfile := mcp.GetCDPChromeProfile()
 815  	defer func() {
 816  		getChromeProfileStateFn = oldGet
 817  		stopChromeFn = oldStop
 818  		resetChromeProfileCloneFn = oldReset
 819  		mcp.SetCDPChromeProfile(oldProfile)
 820  	}()
 821  
 822  	getChromeProfileStateFn = func(profile string) (mcp.ChromeProfileState, error) {
 823  		return mcp.ChromeProfileState{
 824  			Mode:             "auto",
 825  			DetectedProfile:  "Default",
 826  			EffectiveProfile: "Default",
 827  			CloneStatus:      mcp.ChromeProfileCloneCurrent,
 828  			Profiles: []mcp.ChromeProfileOption{
 829  				{Name: "Default", DisplayName: "Default", Exists: true, IsLastUsed: true, IsEffective: true},
 830  				{Name: "Profile 6", DisplayName: "Work", Exists: true},
 831  			},
 832  		}, nil
 833  	}
 834  
 835  	stopCalls := 0
 836  	resetCalls := 0
 837  	stopChromeFn = func() { stopCalls++ }
 838  	resetChromeProfileCloneFn = func() error {
 839  		resetCalls++
 840  		return nil
 841  	}
 842  
 843  	mcp.SetCDPChromeProfile("Default")
 844  	deps := &ServerDeps{
 845  		ShannonDir: filepath.Join(t.TempDir(), "missing-config-dir"),
 846  		Config: &config.Config{
 847  			Daemon: config.DaemonConfig{ChromeProfile: "Default"},
 848  		},
 849  	}
 850  	srv := NewServer(0, nil, deps, "test")
 851  
 852  	rec := httptest.NewRecorder()
 853  	req := httptest.NewRequest(http.MethodPost, "/chrome/profile", strings.NewReader(`{"mode":"explicit","profile":"Profile 6"}`))
 854  	req.Header.Set("Content-Type", "application/json")
 855  	srv.handleChromeProfileUpdate(rec, req)
 856  
 857  	if rec.Code != http.StatusInternalServerError {
 858  		t.Fatalf("status code = %d, want 500, body=%s", rec.Code, rec.Body.String())
 859  	}
 860  	var body map[string]string
 861  	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
 862  		t.Fatalf("decode error body: %v", err)
 863  	}
 864  	if body["error"] == "" {
 865  		t.Fatalf("expected non-empty error body, got %v", body)
 866  	}
 867  	if stopCalls != 0 || resetCalls != 0 {
 868  		t.Fatalf("expected no destructive ops when config write fails, got stop=%d reset=%d", stopCalls, resetCalls)
 869  	}
 870  	if deps.Config.Daemon.ChromeProfile != "Default" {
 871  		t.Fatalf("expected in-memory chrome_profile to remain unchanged, got %q", deps.Config.Daemon.ChromeProfile)
 872  	}
 873  	if mcp.GetCDPChromeProfile() != "Default" {
 874  		t.Fatalf("expected runtime chrome profile override to remain unchanged, got %q", mcp.GetCDPChromeProfile())
 875  	}
 876  }
 877  
 878  func TestServer_PatchConfigNullRemovesChromeProfileKey(t *testing.T) {
 879  	shannonDir := t.TempDir()
 880  	initial := "daemon:\n  auto_approve: true\n  chrome_profile: Profile 6\n"
 881  	if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte(initial), 0o600); err != nil {
 882  		t.Fatalf("write config: %v", err)
 883  	}
 884  
 885  	srv := NewServer(0, nil, &ServerDeps{ShannonDir: shannonDir}, "test")
 886  
 887  	rec := httptest.NewRecorder()
 888  	req := httptest.NewRequest(http.MethodPatch, "/config", strings.NewReader(`{"daemon":{"chrome_profile":null}}`))
 889  	req.Header.Set("Content-Type", "application/json")
 890  	srv.handlePatchConfig(rec, req)
 891  
 892  	if rec.Code != http.StatusOK {
 893  		t.Fatalf("status code = %d, want 200, body=%s", rec.Code, rec.Body.String())
 894  	}
 895  	var body map[string]string
 896  	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
 897  		t.Fatalf("decode body: %v", err)
 898  	}
 899  	if body["status"] != "updated" {
 900  		t.Fatalf("unexpected response body: %v", body)
 901  	}
 902  	data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml"))
 903  	if err != nil {
 904  		t.Fatalf("read config: %v", err)
 905  	}
 906  	text := string(data)
 907  	if strings.Contains(text, "chrome_profile:") {
 908  		t.Fatalf("expected chrome_profile key to be removed, got %s", text)
 909  	}
 910  	if !strings.Contains(text, "auto_approve: true") {
 911  		t.Fatalf("expected sibling daemon setting to remain, got %s", text)
 912  	}
 913  }
 914  
 915  // --- Issue 1: rollback on create failure ---
 916  
 917  func TestServer_CreateAgent_Conflict(t *testing.T) {
 918  	agentsDir := t.TempDir()
 919  	sessDir := t.TempDir()
 920  	deps := &ServerDeps{
 921  		AgentsDir:    agentsDir,
 922  		ShannonDir:   t.TempDir(),
 923  		SessionCache: NewSessionCache(sessDir),
 924  	}
 925  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
 926  	srv := NewServer(0, c, deps, "test")
 927  	ctx, cancel := context.WithCancel(context.Background())
 928  	defer cancel()
 929  
 930  	go srv.Start(ctx)
 931  	time.Sleep(100 * time.Millisecond)
 932  
 933  	body := `{"name":"testbot","prompt":"hello world"}`
 934  	resp, err := http.Post(
 935  		fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()),
 936  		"application/json",
 937  		strings.NewReader(body),
 938  	)
 939  	if err != nil {
 940  		t.Fatal(err)
 941  	}
 942  	resp.Body.Close()
 943  	if resp.StatusCode != http.StatusCreated {
 944  		t.Fatalf("create: expected 201, got %d", resp.StatusCode)
 945  	}
 946  
 947  	// Duplicate create — should get 409
 948  	resp2, err := http.Post(
 949  		fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()),
 950  		"application/json",
 951  		strings.NewReader(body),
 952  	)
 953  	if err != nil {
 954  		t.Fatal(err)
 955  	}
 956  	resp2.Body.Close()
 957  	if resp2.StatusCode != http.StatusConflict {
 958  		t.Errorf("duplicate: expected 409, got %d", resp2.StatusCode)
 959  	}
 960  }
 961  
 962  func TestServer_CreateAgent_RollbackOnWriteFailure(t *testing.T) {
 963  	agentsDir := t.TempDir()
 964  	sessDir := t.TempDir()
 965  	deps := &ServerDeps{
 966  		AgentsDir:    agentsDir,
 967  		ShannonDir:   t.TempDir(),
 968  		SessionCache: NewSessionCache(sessDir),
 969  	}
 970  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
 971  	srv := NewServer(0, c, deps, "test")
 972  	ctx, cancel := context.WithCancel(context.Background())
 973  	defer cancel()
 974  
 975  	go srv.Start(ctx)
 976  	time.Sleep(100 * time.Millisecond)
 977  
 978  	// Make agents dir read-only so WriteAgentPrompt's MkdirAll fails
 979  	os.Chmod(agentsDir, 0500)
 980  	defer os.Chmod(agentsDir, 0700) // restore for cleanup
 981  
 982  	body := `{"name":"failbot","prompt":"should fail"}`
 983  	resp, err := http.Post(
 984  		fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()),
 985  		"application/json",
 986  		strings.NewReader(body),
 987  	)
 988  	if err != nil {
 989  		t.Fatal(err)
 990  	}
 991  	resp.Body.Close()
 992  	if resp.StatusCode != http.StatusInternalServerError {
 993  		t.Fatalf("expected 500, got %d", resp.StatusCode)
 994  	}
 995  
 996  	// Restore permissions and verify no orphaned directory
 997  	os.Chmod(agentsDir, 0700)
 998  	if _, err := os.Stat(filepath.Join(agentsDir, "failbot")); !os.IsNotExist(err) {
 999  		t.Error("agent dir should not exist after rollback")
1000  	}
1001  }
1002  
1003  func TestServer_CreateAgent_DoesNotCreateSessionManager(t *testing.T) {
1004  	agentsDir := t.TempDir()
1005  	sessDir := t.TempDir()
1006  	sessionCache := NewSessionCache(sessDir)
1007  	deps := &ServerDeps{
1008  		AgentsDir:    agentsDir,
1009  		ShannonDir:   t.TempDir(),
1010  		SessionCache: sessionCache,
1011  	}
1012  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
1013  	srv := NewServer(0, c, deps, "test")
1014  	ctx, cancel := context.WithCancel(context.Background())
1015  	defer cancel()
1016  
1017  	go srv.Start(ctx)
1018  	time.Sleep(100 * time.Millisecond)
1019  
1020  	body := `{"name":"cache-test","prompt":"hello world"}`
1021  	resp, err := http.Post(
1022  		fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()),
1023  		"application/json",
1024  		strings.NewReader(body),
1025  	)
1026  	if err != nil {
1027  		t.Fatal(err)
1028  	}
1029  	defer resp.Body.Close()
1030  
1031  	if resp.StatusCode != http.StatusCreated {
1032  		t.Fatalf("create: expected 201, got %d", resp.StatusCode)
1033  	}
1034  
1035  	sessionCache.mu.Lock()
1036  	route, ok := sessionCache.routes["agent:cache-test"]
1037  	sessionCache.mu.Unlock()
1038  	if !ok {
1039  		t.Fatalf("expected route cache entry for agent:cache-test to exist")
1040  	}
1041  	if route.manager != nil {
1042  		t.Fatalf("expected create path to avoid creating a route manager")
1043  	}
1044  }
1045  
1046  func TestServer_CreateAgent_AttachesInstalledSkills(t *testing.T) {
1047  	shannonDir := t.TempDir()
1048  	agentsDir := filepath.Join(shannonDir, "agents")
1049  	if err := os.MkdirAll(agentsDir, 0700); err != nil {
1050  		t.Fatalf("mkdir agents dir: %v", err)
1051  	}
1052  	sessDir := t.TempDir()
1053  	writeTestGlobalSkill(t, shannonDir, "check")
1054  	deps := &ServerDeps{
1055  		AgentsDir:    agentsDir,
1056  		ShannonDir:   shannonDir,
1057  		SessionCache: NewSessionCache(sessDir),
1058  	}
1059  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
1060  	srv := NewServer(0, c, deps, "test")
1061  	ctx, cancel := context.WithCancel(context.Background())
1062  	defer cancel()
1063  
1064  	go srv.Start(ctx)
1065  	time.Sleep(100 * time.Millisecond)
1066  
1067  	body := `{"name":"attach-bot","prompt":"hello world","skills":[{"name":"check"}]}`
1068  	resp, err := http.Post(
1069  		fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()),
1070  		"application/json",
1071  		strings.NewReader(body),
1072  	)
1073  	if err != nil {
1074  		t.Fatal(err)
1075  	}
1076  	defer resp.Body.Close()
1077  	if resp.StatusCode != http.StatusCreated {
1078  		t.Fatalf("expected 201, got %d", resp.StatusCode)
1079  	}
1080  
1081  	loaded, err := agents.LoadAgent(agentsDir, "attach-bot")
1082  	if err != nil {
1083  		t.Fatalf("load agent: %v", err)
1084  	}
1085  	if len(loaded.Skills) != 1 || loaded.Skills[0].Name != "check" {
1086  		t.Fatalf("expected attached global skill 'check', got %+v", loaded.Skills)
1087  	}
1088  
1089  	attached, err := agents.ReadAttachedSkills(agentsDir, "attach-bot")
1090  	if err != nil {
1091  		t.Fatalf("read attached skills: %v", err)
1092  	}
1093  	if len(attached) != 1 || attached[0] != "check" {
1094  		t.Fatalf("expected manifest to contain check, got %v", attached)
1095  	}
1096  
1097  	if _, err := os.Stat(filepath.Join(agentsDir, "attach-bot", "skills")); !os.IsNotExist(err) {
1098  		t.Fatalf("expected no agent-local skill directory, got err=%v", err)
1099  	}
1100  }
1101  
1102  func TestServer_PutSkill_AttachesInstalledGlobalSkill(t *testing.T) {
1103  	shannonDir := t.TempDir()
1104  	agentsDir := filepath.Join(shannonDir, "agents")
1105  	if err := os.MkdirAll(agentsDir, 0700); err != nil {
1106  		t.Fatalf("mkdir agents dir: %v", err)
1107  	}
1108  	sessDir := t.TempDir()
1109  	writeTestGlobalSkill(t, shannonDir, "check")
1110  	deps := &ServerDeps{
1111  		AgentsDir:    agentsDir,
1112  		ShannonDir:   shannonDir,
1113  		SessionCache: NewSessionCache(sessDir),
1114  	}
1115  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
1116  	srv := NewServer(0, c, deps, "test")
1117  	ctx, cancel := context.WithCancel(context.Background())
1118  	defer cancel()
1119  
1120  	go srv.Start(ctx)
1121  	time.Sleep(100 * time.Millisecond)
1122  
1123  	createBody := `{"name":"skill-bot","prompt":"hello world"}`
1124  	resp, err := http.Post(
1125  		fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()),
1126  		"application/json",
1127  		strings.NewReader(createBody),
1128  	)
1129  	if err != nil {
1130  		t.Fatal(err)
1131  	}
1132  	resp.Body.Close()
1133  	if resp.StatusCode != http.StatusCreated {
1134  		t.Fatalf("create: expected 201, got %d", resp.StatusCode)
1135  	}
1136  
1137  	req, err := http.NewRequest(
1138  		http.MethodPut,
1139  		fmt.Sprintf("http://127.0.0.1:%d/agents/skill-bot/skills/check", srv.Port()),
1140  		strings.NewReader(`{}`),
1141  	)
1142  	if err != nil {
1143  		t.Fatal(err)
1144  	}
1145  	req.Header.Set("Content-Type", "application/json")
1146  	resp, err = http.DefaultClient.Do(req)
1147  	if err != nil {
1148  		t.Fatal(err)
1149  	}
1150  	defer resp.Body.Close()
1151  	if resp.StatusCode != http.StatusOK {
1152  		t.Fatalf("attach: expected 200, got %d", resp.StatusCode)
1153  	}
1154  
1155  	loaded, err := agents.LoadAgent(agentsDir, "skill-bot")
1156  	if err != nil {
1157  		t.Fatalf("load agent: %v", err)
1158  	}
1159  	if len(loaded.Skills) != 1 || loaded.Skills[0].Name != "check" {
1160  		t.Fatalf("expected attached global skill 'check', got %+v", loaded.Skills)
1161  	}
1162  }
1163  
1164  func TestServer_DeleteSkill_DetachesManifestAndCleansLegacySkillDir(t *testing.T) {
1165  	shannonDir := t.TempDir()
1166  	agentsDir := filepath.Join(shannonDir, "agents")
1167  	if err := os.MkdirAll(agentsDir, 0700); err != nil {
1168  		t.Fatalf("mkdir agents dir: %v", err)
1169  	}
1170  	sessDir := t.TempDir()
1171  	writeTestGlobalSkill(t, shannonDir, "check")
1172  	deps := &ServerDeps{
1173  		AgentsDir:    agentsDir,
1174  		ShannonDir:   shannonDir,
1175  		SessionCache: NewSessionCache(sessDir),
1176  	}
1177  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
1178  	srv := NewServer(0, c, deps, "test")
1179  	ctx, cancel := context.WithCancel(context.Background())
1180  	defer cancel()
1181  
1182  	go srv.Start(ctx)
1183  	time.Sleep(100 * time.Millisecond)
1184  
1185  	createBody := `{"name":"detach-bot","prompt":"hello world","skills":[{"name":"check"}]}`
1186  	resp, err := http.Post(
1187  		fmt.Sprintf("http://127.0.0.1:%d/agents", srv.Port()),
1188  		"application/json",
1189  		strings.NewReader(createBody),
1190  	)
1191  	if err != nil {
1192  		t.Fatal(err)
1193  	}
1194  	resp.Body.Close()
1195  	if resp.StatusCode != http.StatusCreated {
1196  		t.Fatalf("create: expected 201, got %d", resp.StatusCode)
1197  	}
1198  
1199  	if err := agents.WriteAgentSkill(agentsDir, "detach-bot", &skills.Skill{
1200  		Name:        "check",
1201  		Description: "legacy local copy",
1202  		Prompt:      "legacy prompt",
1203  	}); err != nil {
1204  		t.Fatalf("write legacy agent-local skill: %v", err)
1205  	}
1206  
1207  	req, err := http.NewRequest(
1208  		http.MethodDelete,
1209  		fmt.Sprintf("http://127.0.0.1:%d/agents/detach-bot/skills/check", srv.Port()),
1210  		nil,
1211  	)
1212  	if err != nil {
1213  		t.Fatal(err)
1214  	}
1215  	resp, err = http.DefaultClient.Do(req)
1216  	if err != nil {
1217  		t.Fatal(err)
1218  	}
1219  	defer resp.Body.Close()
1220  	if resp.StatusCode != http.StatusOK {
1221  		t.Fatalf("delete: expected 200, got %d", resp.StatusCode)
1222  	}
1223  
1224  	attached, err := agents.ReadAttachedSkills(agentsDir, "detach-bot")
1225  	if err != nil {
1226  		t.Fatalf("read attached skills: %v", err)
1227  	}
1228  	if len(attached) != 0 {
1229  		t.Fatalf("expected empty attached skills after delete, got %v", attached)
1230  	}
1231  
1232  	loaded, err := agents.LoadAgent(agentsDir, "detach-bot")
1233  	if err != nil {
1234  		t.Fatalf("load agent: %v", err)
1235  	}
1236  	if len(loaded.Skills) != 0 {
1237  		t.Fatalf("expected no loaded skills after detach, got %+v", loaded.Skills)
1238  	}
1239  
1240  	if _, err := os.Stat(filepath.Join(agentsDir, "detach-bot", "skills", "check")); !os.IsNotExist(err) {
1241  		t.Fatalf("expected legacy agent-local skill dir to be removed, got err=%v", err)
1242  	}
1243  }
1244  
1245  // --- deepMerge unit tests ---
1246  
1247  func TestDeepMerge(t *testing.T) {
1248  	tests := []struct {
1249  		name     string
1250  		dst, src map[string]interface{}
1251  		want     map[string]interface{}
1252  	}{
1253  		{
1254  			name: "scalar replace",
1255  			dst:  map[string]interface{}{"a": "old"},
1256  			src:  map[string]interface{}{"a": "new"},
1257  			want: map[string]interface{}{"a": "new"},
1258  		},
1259  		{
1260  			name: "null deletes key",
1261  			dst:  map[string]interface{}{"a": "val", "b": "keep"},
1262  			src:  map[string]interface{}{"a": nil},
1263  			want: map[string]interface{}{"b": "keep"},
1264  		},
1265  		{
1266  			name: "nested merge preserves siblings",
1267  			dst: map[string]interface{}{
1268  				"agent": map[string]interface{}{"model": "old", "temp": 0.7},
1269  			},
1270  			src: map[string]interface{}{
1271  				"agent": map[string]interface{}{"model": "new"},
1272  			},
1273  			want: map[string]interface{}{
1274  				"agent": map[string]interface{}{"model": "new", "temp": 0.7},
1275  			},
1276  		},
1277  		{
1278  			name: "3-level deep merge",
1279  			dst: map[string]interface{}{
1280  				"a": map[string]interface{}{
1281  					"b": map[string]interface{}{"c": 1, "d": 2},
1282  				},
1283  			},
1284  			src: map[string]interface{}{
1285  				"a": map[string]interface{}{
1286  					"b": map[string]interface{}{"c": 99},
1287  				},
1288  			},
1289  			want: map[string]interface{}{
1290  				"a": map[string]interface{}{
1291  					"b": map[string]interface{}{"c": 99, "d": 2},
1292  				},
1293  			},
1294  		},
1295  		{
1296  			name: "src map replaces dst scalar",
1297  			dst:  map[string]interface{}{"a": "scalar"},
1298  			src:  map[string]interface{}{"a": map[string]interface{}{"nested": true}},
1299  			want: map[string]interface{}{"a": map[string]interface{}{"nested": true}},
1300  		},
1301  		{
1302  			name: "src scalar replaces dst map",
1303  			dst:  map[string]interface{}{"a": map[string]interface{}{"nested": true}},
1304  			src:  map[string]interface{}{"a": "scalar"},
1305  			want: map[string]interface{}{"a": "scalar"},
1306  		},
1307  		{
1308  			name: "new key added",
1309  			dst:  map[string]interface{}{"a": 1},
1310  			src:  map[string]interface{}{"b": 2},
1311  			want: map[string]interface{}{"a": 1, "b": 2},
1312  		},
1313  		{
1314  			name: "empty src is no-op",
1315  			dst:  map[string]interface{}{"a": 1},
1316  			src:  map[string]interface{}{},
1317  			want: map[string]interface{}{"a": 1},
1318  		},
1319  	}
1320  
1321  	for _, tc := range tests {
1322  		t.Run(tc.name, func(t *testing.T) {
1323  			deepMerge(tc.dst, tc.src)
1324  			gotJSON, _ := json.Marshal(tc.dst)
1325  			wantJSON, _ := json.Marshal(tc.want)
1326  			if string(gotJSON) != string(wantJSON) {
1327  				t.Errorf("got %s, want %s", gotJSON, wantJSON)
1328  			}
1329  		})
1330  	}
1331  }
1332  
1333  // --- Issue 2: PATCH config deep merge ---
1334  
1335  func TestServer_PatchConfig_DeepMerge(t *testing.T) {
1336  	shannonDir := t.TempDir()
1337  	sessDir := t.TempDir()
1338  	deps := &ServerDeps{
1339  		ShannonDir:   shannonDir,
1340  		SessionCache: NewSessionCache(sessDir),
1341  		Config:       &config.Config{},
1342  	}
1343  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
1344  	srv := NewServer(0, c, deps, "test")
1345  	ctx, cancel := context.WithCancel(context.Background())
1346  	defer cancel()
1347  
1348  	go srv.Start(ctx)
1349  	time.Sleep(100 * time.Millisecond)
1350  
1351  	base := fmt.Sprintf("http://127.0.0.1:%d", srv.Port())
1352  
1353  	// Step 1: Set initial config with nested agent block
1354  	initial := map[string]interface{}{
1355  		"agent": map[string]interface{}{
1356  			"model":          "claude-3-5-sonnet",
1357  			"max_iterations": 10,
1358  			"temperature":    0.7,
1359  		},
1360  		"top_level_key": "keep_me",
1361  	}
1362  	initialYAML, _ := yaml.Marshal(initial)
1363  	os.WriteFile(filepath.Join(shannonDir, "config.yaml"), initialYAML, 0600)
1364  
1365  	// Step 2: PATCH only agent.model — should preserve max_iterations and temperature
1366  	patch := `{"agent": {"model": "claude-4-opus"}}`
1367  	req, _ := http.NewRequest("PATCH", base+"/config", strings.NewReader(patch))
1368  	req.Header.Set("Content-Type", "application/json")
1369  	resp, err := http.DefaultClient.Do(req)
1370  	if err != nil {
1371  		t.Fatal(err)
1372  	}
1373  	resp.Body.Close()
1374  	if resp.StatusCode != http.StatusOK {
1375  		t.Fatalf("PATCH: expected 200, got %d", resp.StatusCode)
1376  	}
1377  
1378  	// Step 3: Read config back and verify deep merge
1379  	data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml"))
1380  	if err != nil {
1381  		t.Fatal(err)
1382  	}
1383  	var result map[string]interface{}
1384  	if err := yaml.Unmarshal(data, &result); err != nil {
1385  		t.Fatal(err)
1386  	}
1387  
1388  	agentBlock, ok := result["agent"].(map[string]interface{})
1389  	if !ok {
1390  		t.Fatalf("agent block not a map: %T", result["agent"])
1391  	}
1392  
1393  	// model should be updated
1394  	if agentBlock["model"] != "claude-4-opus" {
1395  		t.Errorf("model = %v, want claude-4-opus", agentBlock["model"])
1396  	}
1397  
1398  	// max_iterations and temperature should be preserved (deep merge)
1399  	if agentBlock["max_iterations"] == nil {
1400  		t.Error("max_iterations was lost during PATCH — shallow merge instead of deep merge")
1401  	}
1402  	if agentBlock["temperature"] == nil {
1403  		t.Error("temperature was lost during PATCH — shallow merge instead of deep merge")
1404  	}
1405  
1406  	// top_level_key should still be there
1407  	if result["top_level_key"] != "keep_me" {
1408  		t.Errorf("top_level_key = %v, want keep_me", result["top_level_key"])
1409  	}
1410  }
1411  
1412  func TestServer_PatchConfig_NullDeletes(t *testing.T) {
1413  	shannonDir := t.TempDir()
1414  	sessDir := t.TempDir()
1415  	deps := &ServerDeps{
1416  		ShannonDir:   shannonDir,
1417  		SessionCache: NewSessionCache(sessDir),
1418  		Config:       &config.Config{},
1419  	}
1420  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
1421  	srv := NewServer(0, c, deps, "test")
1422  	ctx, cancel := context.WithCancel(context.Background())
1423  	defer cancel()
1424  
1425  	go srv.Start(ctx)
1426  	time.Sleep(100 * time.Millisecond)
1427  
1428  	base := fmt.Sprintf("http://127.0.0.1:%d", srv.Port())
1429  
1430  	// Set initial config
1431  	initial := map[string]interface{}{
1432  		"agent":     map[string]interface{}{"model": "gpt-4"},
1433  		"to_delete": "bye",
1434  	}
1435  	initialYAML, _ := yaml.Marshal(initial)
1436  	os.WriteFile(filepath.Join(shannonDir, "config.yaml"), initialYAML, 0600)
1437  
1438  	// PATCH with null to delete a key
1439  	patch := `{"to_delete": null}`
1440  	req, _ := http.NewRequest("PATCH", base+"/config", strings.NewReader(patch))
1441  	req.Header.Set("Content-Type", "application/json")
1442  	resp, err := http.DefaultClient.Do(req)
1443  	if err != nil {
1444  		t.Fatal(err)
1445  	}
1446  	resp.Body.Close()
1447  	if resp.StatusCode != http.StatusOK {
1448  		t.Fatalf("PATCH: expected 200, got %d", resp.StatusCode)
1449  	}
1450  
1451  	data, _ := os.ReadFile(filepath.Join(shannonDir, "config.yaml"))
1452  	var result map[string]interface{}
1453  	yaml.Unmarshal(data, &result)
1454  
1455  	if _, exists := result["to_delete"]; exists {
1456  		t.Error("to_delete should have been removed by null patch")
1457  	}
1458  	if result["agent"] == nil {
1459  		t.Error("agent block should still exist")
1460  	}
1461  }
1462  
1463  // --- Issue 3: request body size limit ---
1464  
1465  func TestServer_BodySizeLimit(t *testing.T) {
1466  	agentsDir := t.TempDir()
1467  	sessDir := t.TempDir()
1468  	deps := &ServerDeps{
1469  		AgentsDir:    agentsDir,
1470  		ShannonDir:   t.TempDir(),
1471  		SessionCache: NewSessionCache(sessDir),
1472  		Config:       &config.Config{},
1473  	}
1474  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
1475  	srv := NewServer(0, c, deps, "test")
1476  	ctx, cancel := context.WithCancel(context.Background())
1477  	defer cancel()
1478  
1479  	go srv.Start(ctx)
1480  	time.Sleep(100 * time.Millisecond)
1481  
1482  	base := fmt.Sprintf("http://127.0.0.1:%d", srv.Port())
1483  
1484  	// Send a body exceeding maxBodySize (50MB) to POST /agents — should be rejected
1485  	bigBody := bytes.Repeat([]byte("x"), 51*1024*1024)
1486  	payload := append([]byte(`{"name":"big","prompt":"`), bigBody...)
1487  	payload = append(payload, '"', '}')
1488  
1489  	resp, err := http.Post(base+"/agents", "application/json", bytes.NewReader(payload))
1490  	if err != nil {
1491  		t.Fatal(err)
1492  	}
1493  	defer resp.Body.Close()
1494  
1495  	// Should get 413 or 400 (body too large), not 201
1496  	if resp.StatusCode == http.StatusCreated {
1497  		t.Error("expected rejection for oversized body, got 201 Created")
1498  	}
1499  	if resp.StatusCode != http.StatusRequestEntityTooLarge {
1500  		t.Logf("status = %d (acceptable if 400, ideal is 413)", resp.StatusCode)
1501  	}
1502  }
1503  
1504  func TestEventsSSEEndpoint(t *testing.T) {
1505  	bus := NewEventBus()
1506  	s := &Server{eventBus: bus}
1507  
1508  	handler := http.HandlerFunc(s.handleEvents)
1509  	srv := httptest.NewServer(handler)
1510  	defer srv.Close()
1511  
1512  	resp, err := http.Get(srv.URL)
1513  	if err != nil {
1514  		t.Fatal(err)
1515  	}
1516  	defer resp.Body.Close()
1517  
1518  	if resp.Header.Get("Content-Type") != "text/event-stream" {
1519  		t.Fatalf("expected text/event-stream, got %s", resp.Header.Get("Content-Type"))
1520  	}
1521  
1522  	// Wait for SSE handler to subscribe before emitting
1523  	time.Sleep(50 * time.Millisecond)
1524  
1525  	bus.Emit(Event{
1526  		Type:    EventAgentReply,
1527  		Payload: json.RawMessage(`{"agent":"test","text":"hello"}`),
1528  	})
1529  
1530  	scanner := bufio.NewScanner(resp.Body)
1531  	var eventLine, dataLine string
1532  	for scanner.Scan() {
1533  		line := scanner.Text()
1534  		if strings.HasPrefix(line, "event:") {
1535  			eventLine = line
1536  		}
1537  		if strings.HasPrefix(line, "data:") {
1538  			dataLine = line
1539  			break
1540  		}
1541  	}
1542  
1543  	if eventLine != "event: agent_reply" {
1544  		t.Fatalf("expected 'event: agent_reply', got %q", eventLine)
1545  	}
1546  	if !strings.Contains(dataLine, `"agent":"test"`) {
1547  		t.Fatalf("expected agent in data, got %q", dataLine)
1548  	}
1549  }
1550  
1551  // SSE endpoint must replay missed events when last_event_id is provided,
1552  // then switch to live events. This is the core of Desktop reconnection.
1553  func TestEventsSSEReplay(t *testing.T) {
1554  	bus := NewEventBus()
1555  	s := &Server{eventBus: bus}
1556  
1557  	// Pre-emit 5 events into ring buffer (IDs 1..5) before any client connects.
1558  	for i := 0; i < 5; i++ {
1559  		bus.Emit(Event{Type: "test", Payload: json.RawMessage(`{"seq":` + strconv.Itoa(i+1) + `}`)})
1560  	}
1561  
1562  	handler := http.HandlerFunc(s.handleEvents)
1563  	srv := httptest.NewServer(handler)
1564  	defer srv.Close()
1565  
1566  	// Connect with last_event_id=3 → expect replay of IDs 4, 5
1567  	resp, err := http.Get(srv.URL + "?last_event_id=3")
1568  	if err != nil {
1569  		t.Fatal(err)
1570  	}
1571  	defer resp.Body.Close()
1572  
1573  	scanner := bufio.NewScanner(resp.Body)
1574  	var replayed []uint64
1575  	deadline := time.After(2 * time.Second)
1576  
1577  	for len(replayed) < 2 {
1578  		lineCh := make(chan string, 1)
1579  		go func() {
1580  			if scanner.Scan() {
1581  				lineCh <- scanner.Text()
1582  			}
1583  		}()
1584  		select {
1585  		case line := <-lineCh:
1586  			if strings.HasPrefix(line, "id: ") {
1587  				id, _ := strconv.ParseUint(strings.TrimPrefix(line, "id: "), 10, 64)
1588  				replayed = append(replayed, id)
1589  			}
1590  		case <-deadline:
1591  			t.Fatalf("timeout waiting for replayed events, got %d so far: %v", len(replayed), replayed)
1592  		}
1593  	}
1594  
1595  	if replayed[0] != 4 || replayed[1] != 5 {
1596  		t.Fatalf("expected replayed IDs [4, 5], got %v", replayed)
1597  	}
1598  }
1599  
1600  // SSE endpoint must also support the standard Last-Event-ID header
1601  // (used by browser EventSource on reconnect).
1602  func TestEventsSSEReplayViaHeader(t *testing.T) {
1603  	bus := NewEventBus()
1604  	s := &Server{eventBus: bus}
1605  
1606  	for i := 0; i < 5; i++ {
1607  		bus.Emit(Event{Type: "test", Payload: json.RawMessage(`{"seq":` + strconv.Itoa(i+1) + `}`)})
1608  	}
1609  
1610  	handler := http.HandlerFunc(s.handleEvents)
1611  	srv := httptest.NewServer(handler)
1612  	defer srv.Close()
1613  
1614  	// Use Last-Event-ID header instead of query param
1615  	req, _ := http.NewRequest("GET", srv.URL, nil)
1616  	req.Header.Set("Last-Event-ID", "3")
1617  	resp, err := http.DefaultClient.Do(req)
1618  	if err != nil {
1619  		t.Fatal(err)
1620  	}
1621  	defer resp.Body.Close()
1622  
1623  	scanner := bufio.NewScanner(resp.Body)
1624  	var replayed []uint64
1625  	deadline := time.After(2 * time.Second)
1626  
1627  	for len(replayed) < 2 {
1628  		lineCh := make(chan string, 1)
1629  		go func() {
1630  			if scanner.Scan() {
1631  				lineCh <- scanner.Text()
1632  			}
1633  		}()
1634  		select {
1635  		case line := <-lineCh:
1636  			if strings.HasPrefix(line, "id: ") {
1637  				id, _ := strconv.ParseUint(strings.TrimPrefix(line, "id: "), 10, 64)
1638  				replayed = append(replayed, id)
1639  			}
1640  		case <-deadline:
1641  			t.Fatalf("timeout waiting for replayed events via header, got %d so far: %v", len(replayed), replayed)
1642  		}
1643  	}
1644  
1645  	if replayed[0] != 4 || replayed[1] != 5 {
1646  		t.Fatalf("expected replayed IDs [4, 5], got %v", replayed)
1647  	}
1648  }
1649  
1650  // SSE endpoint without last_event_id must behave identically to before
1651  // (backward compatible — no replay, live events only).
1652  func TestEventsSSENoReplayWithoutParam(t *testing.T) {
1653  	bus := NewEventBus()
1654  	s := &Server{eventBus: bus}
1655  
1656  	// Pre-emit events
1657  	for i := 0; i < 3; i++ {
1658  		bus.Emit(Event{Type: "old", Payload: json.RawMessage(`{}`)})
1659  	}
1660  
1661  	handler := http.HandlerFunc(s.handleEvents)
1662  	srv := httptest.NewServer(handler)
1663  	defer srv.Close()
1664  
1665  	resp, err := http.Get(srv.URL) // no last_event_id
1666  	if err != nil {
1667  		t.Fatal(err)
1668  	}
1669  	defer resp.Body.Close()
1670  
1671  	// Wait for handler to subscribe
1672  	time.Sleep(50 * time.Millisecond)
1673  
1674  	// Emit a live event
1675  	bus.Emit(Event{Type: "live", Payload: json.RawMessage(`{"new":true}`)})
1676  
1677  	scanner := bufio.NewScanner(resp.Body)
1678  	deadline := time.After(2 * time.Second)
1679  	var firstEventType string
1680  
1681  	for firstEventType == "" {
1682  		lineCh := make(chan string, 1)
1683  		go func() {
1684  			if scanner.Scan() {
1685  				lineCh <- scanner.Text()
1686  			}
1687  		}()
1688  		select {
1689  		case line := <-lineCh:
1690  			if strings.HasPrefix(line, "event: ") {
1691  				firstEventType = strings.TrimPrefix(line, "event: ")
1692  			}
1693  		case <-deadline:
1694  			t.Fatal("timeout waiting for live event")
1695  		}
1696  	}
1697  
1698  	// Must receive the live event, not the old pre-emitted ones
1699  	if firstEventType != "live" {
1700  		t.Fatalf("expected first event type 'live', got %q (old events leaked without last_event_id)", firstEventType)
1701  	}
1702  }
1703  
1704  func TestNormalizePatchKeys(t *testing.T) {
1705  	tests := []struct {
1706  		name       string
1707  		input      map[string]interface{}
1708  		want       map[string]interface{}
1709  		applyTwice bool // set to verify idempotency
1710  	}{
1711  		{
1712  			name:  "camelCase mcpServers renamed",
1713  			input: map[string]interface{}{"mcpServers": map[string]interface{}{"x-twitter": map[string]interface{}{}}},
1714  			want:  map[string]interface{}{"mcp_servers": map[string]interface{}{"x-twitter": map[string]interface{}{}}},
1715  		},
1716  		{
1717  			name:  "PascalCase MCPServers renamed",
1718  			input: map[string]interface{}{"MCPServers": map[string]interface{}{}},
1719  			want:  map[string]interface{}{"mcp_servers": map[string]interface{}{}},
1720  		},
1721  		{
1722  			name:  "apiKey renamed",
1723  			input: map[string]interface{}{"apiKey": "sk_abc"},
1724  			want:  map[string]interface{}{"api_key": "sk_abc"},
1725  		},
1726  		{
1727  			name:  "canonical snake_case unchanged",
1728  			input: map[string]interface{}{"mcp_servers": map[string]interface{}{}, "api_key": "sk_abc"},
1729  			want:  map[string]interface{}{"mcp_servers": map[string]interface{}{}, "api_key": "sk_abc"},
1730  		},
1731  		{
1732  			name:       "idempotent: applying twice gives same result",
1733  			input:      map[string]interface{}{"mcpServers": map[string]interface{}{"s": map[string]interface{}{}}},
1734  			want:       map[string]interface{}{"mcp_servers": map[string]interface{}{"s": map[string]interface{}{}}},
1735  			applyTwice: true,
1736  		},
1737  		{
1738  			name:  "alias + canonical both present: canonical wins, alias discarded",
1739  			input: map[string]interface{}{"mcpServers": map[string]interface{}{"alias": map[string]interface{}{}}, "mcp_servers": map[string]interface{}{"canonical": map[string]interface{}{}}},
1740  			want:  map[string]interface{}{"mcp_servers": map[string]interface{}{"canonical": map[string]interface{}{}}},
1741  		},
1742  	}
1743  	for _, tt := range tests {
1744  		t.Run(tt.name, func(t *testing.T) {
1745  			normalizePatchKeys(tt.input)
1746  			if tt.applyTwice {
1747  				normalizePatchKeys(tt.input)
1748  			}
1749  			if len(tt.input) != len(tt.want) {
1750  				t.Fatalf("key count mismatch: got %v, want %v", tt.input, tt.want)
1751  			}
1752  			for k := range tt.want {
1753  				if _, ok := tt.input[k]; !ok {
1754  					t.Errorf("missing expected key %q in result %v", k, tt.input)
1755  				}
1756  			}
1757  			for k := range tt.input {
1758  				if _, ok := tt.want[k]; !ok {
1759  					t.Errorf("unexpected key %q in result %v", k, tt.input)
1760  				}
1761  			}
1762  		})
1763  	}
1764  }
1765  
1766  func TestStripRedactedSecrets(t *testing.T) {
1767  	tests := []struct {
1768  		name           string
1769  		input          map[string]interface{}
1770  		wantDeleted    []string // top-level keys that should be absent
1771  		wantKept       []string // top-level keys that should still be present
1772  		wantEnvDeleted []string // mcp_servers.x-twitter.env keys that should be absent
1773  		wantEnvKept    []string // mcp_servers.x-twitter.env keys that should still be present
1774  	}{
1775  		{
1776  			name:        "api_key *** is dropped",
1777  			input:       map[string]interface{}{"api_key": "***"},
1778  			wantDeleted: []string{"api_key"},
1779  		},
1780  		{
1781  			name:     "api_key real value is kept",
1782  			input:    map[string]interface{}{"api_key": "sk_real"},
1783  			wantKept: []string{"api_key"},
1784  		},
1785  		{
1786  			name: "mcp env *** dropped, real kept",
1787  			input: map[string]interface{}{
1788  				"mcp_servers": map[string]interface{}{
1789  					"x-twitter": map[string]interface{}{
1790  						"env": map[string]interface{}{
1791  							"ACCESS_TOKEN":  "***",
1792  							"ACCESS_TOKEN2": "realvalue",
1793  						},
1794  					},
1795  				},
1796  			},
1797  			wantEnvDeleted: []string{"ACCESS_TOKEN"},
1798  			wantEnvKept:    []string{"ACCESS_TOKEN2"},
1799  		},
1800  		{
1801  			name:     "literal *** in non-sensitive field is kept",
1802  			input:    map[string]interface{}{"model_tier": "***"},
1803  			wantKept: []string{"model_tier"},
1804  		},
1805  	}
1806  	for _, tt := range tests {
1807  		t.Run(tt.name, func(t *testing.T) {
1808  			stripRedactedSecrets(tt.input)
1809  			for _, k := range tt.wantDeleted {
1810  				if _, ok := tt.input[k]; ok {
1811  					t.Errorf("expected key %q to be deleted, still present", k)
1812  				}
1813  			}
1814  			for _, k := range tt.wantKept {
1815  				if _, ok := tt.input[k]; !ok {
1816  					t.Errorf("expected key %q to be kept, was deleted", k)
1817  				}
1818  			}
1819  			if len(tt.wantEnvDeleted) > 0 || len(tt.wantEnvKept) > 0 {
1820  				servers := tt.input["mcp_servers"].(map[string]interface{})
1821  				env := servers["x-twitter"].(map[string]interface{})["env"].(map[string]interface{})
1822  				for _, k := range tt.wantEnvDeleted {
1823  					if _, ok := env[k]; ok {
1824  						t.Errorf("expected env key %q to be dropped, still present", k)
1825  					}
1826  				}
1827  				for _, k := range tt.wantEnvKept {
1828  					if _, ok := env[k]; !ok {
1829  						t.Errorf("expected env key %q to be kept, was deleted", k)
1830  					}
1831  				}
1832  			}
1833  		})
1834  	}
1835  }
1836  
1837  func TestServer_EditMessage_Validation(t *testing.T) {
1838  	shannonDir := t.TempDir()
1839  	deps := &ServerDeps{
1840  		ShannonDir:   shannonDir,
1841  		SessionCache: NewSessionCache(shannonDir),
1842  	}
1843  	srv := &Server{deps: deps}
1844  
1845  	tests := []struct {
1846  		name       string
1847  		sessionID  string
1848  		body       string
1849  		wantStatus int
1850  		wantErr    string
1851  	}{
1852  		{
1853  			name:       "empty new_content and no content blocks",
1854  			sessionID:  "test-session",
1855  			body:       `{"message_index":0,"new_content":""}`,
1856  			wantStatus: http.StatusBadRequest,
1857  			wantErr:    "new_content or content is required",
1858  		},
1859  		{
1860  			name:       "empty new_content with content blocks passes validation",
1861  			sessionID:  "nonexistent",
1862  			body:       `{"message_index":0,"new_content":"","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]}`,
1863  			wantStatus: http.StatusBadRequest,
1864  			wantErr:    "no such file or directory",
1865  		},
1866  		{
1867  			name:       "valid new_content only passes validation",
1868  			sessionID:  "nonexistent",
1869  			body:       `{"message_index":0,"new_content":"hello"}`,
1870  			wantStatus: http.StatusBadRequest,
1871  			wantErr:    "no such file or directory",
1872  		},
1873  		{
1874  			name:       "valid new_content with content blocks passes validation",
1875  			sessionID:  "nonexistent",
1876  			body:       `{"message_index":0,"new_content":"analyze this","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}}]}`,
1877  			wantStatus: http.StatusBadRequest,
1878  			wantErr:    "no such file or directory",
1879  		},
1880  		{
1881  			name:       "missing session id",
1882  			sessionID:  "",
1883  			body:       `{"message_index":0,"new_content":"hello"}`,
1884  			wantStatus: http.StatusBadRequest,
1885  			wantErr:    "session id required",
1886  		},
1887  		{
1888  			name:       "invalid agent name",
1889  			sessionID:  "test-session",
1890  			body:       `{"message_index":0,"new_content":"hello","agent":"../evil"}`,
1891  			wantStatus: http.StatusBadRequest,
1892  		},
1893  	}
1894  
1895  	for _, tc := range tests {
1896  		t.Run(tc.name, func(t *testing.T) {
1897  			rec := httptest.NewRecorder()
1898  			req := httptest.NewRequest(http.MethodPost, "/sessions/"+tc.sessionID+"/edit", strings.NewReader(tc.body))
1899  			req.Header.Set("Content-Type", "application/json")
1900  			req.SetPathValue("id", tc.sessionID)
1901  
1902  			srv.handleEditMessage(rec, req)
1903  
1904  			if rec.Code != tc.wantStatus {
1905  				t.Errorf("status = %d, want %d, body = %s", rec.Code, tc.wantStatus, rec.Body.String())
1906  			}
1907  			if tc.wantErr != "" && !strings.Contains(rec.Body.String(), tc.wantErr) {
1908  				t.Errorf("body = %q, want substring %q", rec.Body.String(), tc.wantErr)
1909  			}
1910  		})
1911  	}
1912  }
1913  
1914  func TestRunSyncLoop_StaysAliveWhenInitiallyDisabled(t *testing.T) {
1915  	// Regression: prior to the post-PR-78 fix, the goroutine returned early
1916  	// if sync.enabled was false at startup, so flipping enabled=true via
1917  	// config edit did nothing until daemon restart. The fix keeps the
1918  	// goroutine alive and re-checks Enabled per tick.
1919  
1920  	viper.Reset()
1921  	defer viper.Reset()
1922  
1923  	// Sync disabled at startup but with a valid (short) interval so the
1924  	// ticker actually fires while the test is watching.
1925  	viper.Set("sync.enabled", false)
1926  	viper.Set("sync.daemon_interval", "100ms")
1927  	viper.Set("sync.daemon_startup_delay", "0")
1928  
1929  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
1930  	srv := NewServer(0, c, nil, "test")
1931  
1932  	ctx, cancel := context.WithCancel(context.Background())
1933  	defer cancel()
1934  
1935  	done := make(chan struct{})
1936  	go func() {
1937  		srv.runSyncLoop(ctx)
1938  		close(done)
1939  	}()
1940  
1941  	// Wait through several ticker periods. Goroutine MUST still be running.
1942  	// (Pre-fix: it returned within microseconds because !Enabled at startup.)
1943  	select {
1944  	case <-done:
1945  		t.Fatalf("runSyncLoop returned early while sync.enabled=false — should stay alive for hot-enable")
1946  	case <-time.After(500 * time.Millisecond):
1947  		// Good: goroutine is still in its tick loop.
1948  	}
1949  
1950  	// Cancel ctx and confirm goroutine exits promptly.
1951  	cancel()
1952  	select {
1953  	case <-done:
1954  		// Good.
1955  	case <-time.After(2 * time.Second):
1956  		t.Fatalf("runSyncLoop did not exit within 2s of ctx cancel")
1957  	}
1958  }
1959  
1960  func TestRunSyncLoop_ReturnsImmediatelyOnZeroInterval(t *testing.T) {
1961  	// The only legitimate early-return path: misconfigured DaemonInterval <= 0.
1962  	viper.Reset()
1963  	defer viper.Reset()
1964  
1965  	viper.Set("sync.enabled", true)
1966  	viper.Set("sync.daemon_interval", "0s") // misconfigured
1967  
1968  	c := NewClient("ws://localhost:1/x", "", func(msg MessagePayload) string { return "" }, nil)
1969  	srv := NewServer(0, c, nil, "test")
1970  
1971  	done := make(chan struct{})
1972  	go func() {
1973  		srv.runSyncLoop(context.Background())
1974  		close(done)
1975  	}()
1976  
1977  	select {
1978  	case <-done:
1979  		// Good: returned immediately on misconfig.
1980  	case <-time.After(500 * time.Millisecond):
1981  		t.Fatalf("runSyncLoop should return immediately when DaemonInterval <= 0")
1982  	}
1983  }
1984  
1985  func TestServer_PatchConfigSetsDaemonAutoApprove(t *testing.T) {
1986  	shannonDir := t.TempDir()
1987  	if err := os.WriteFile(filepath.Join(shannonDir, "config.yaml"), []byte("daemon:\n  auto_approve: false\n"), 0o600); err != nil {
1988  		t.Fatalf("write config: %v", err)
1989  	}
1990  
1991  	srv := NewServer(0, nil, &ServerDeps{ShannonDir: shannonDir}, "test")
1992  
1993  	rec := httptest.NewRecorder()
1994  	req := httptest.NewRequest(http.MethodPatch, "/config", strings.NewReader(`{"daemon":{"auto_approve":true}}`))
1995  	req.Header.Set("Content-Type", "application/json")
1996  	srv.handlePatchConfig(rec, req)
1997  
1998  	if rec.Code != http.StatusOK {
1999  		t.Fatalf("status code = %d, want 200, body=%s", rec.Code, rec.Body.String())
2000  	}
2001  	var body map[string]string
2002  	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
2003  		t.Fatalf("decode body: %v", err)
2004  	}
2005  	if body["status"] != "updated" {
2006  		t.Fatalf("unexpected response body: %v", body)
2007  	}
2008  
2009  	data, err := os.ReadFile(filepath.Join(shannonDir, "config.yaml"))
2010  	if err != nil {
2011  		t.Fatalf("read config: %v", err)
2012  	}
2013  	if !strings.Contains(string(data), "auto_approve: true") {
2014  		t.Fatalf("expected auto_approve: true in persisted config, got %s", data)
2015  	}
2016  }