diff --git a/commands/boss.check.md b/commands/boss.check.md index 2beb3f2..8b18b4f 100644 --- a/commands/boss.check.md +++ b/commands/boss.check.md @@ -1,4 +1,4 @@ -STOP. This is a mechanical status sync. Do NOT plan or analyze. Execute these 3 steps literally, then STOP. +STOP. This is a mechanical status sync. Do NOT plan or analyze. Execute these 4 steps literally, then STOP. **Parse `$ARGUMENTS`:** `$ARGUMENTS` contains two words separated by a space. The FIRST word is your agent name. The SECOND word is the space name. Example: if `$ARGUMENTS` is `Overlord sdk-backend-replacement`, then your agent name is `Overlord` and the space name is `sdk-backend-replacement`. @@ -10,14 +10,20 @@ If `$ARGUMENTS` contains only ONE word, it is the space name. Run `tmux display- curl -s http://localhost:8899/spaces/SPACE_NAME/raw ``` -Replace SPACE_NAME with the space name from `$ARGUMENTS`. Scan for anything addressed to you. Do NOT analyze other agents. +Replace SPACE_NAME with the space name from `$ARGUMENTS`. Scan your section for: +- **Messages** — look for a `#### Messages` section under your agent name. These are messages from the boss or other agents. Note any instructions or questions. +- **Standing orders** — anything addressed to you in shared contracts. -**Important rule**: Always use `curl`, never use Fetch tool. Fetch will *not* work on localhost. **Always** use curl. This is important! +Do NOT analyze other agents' sections. + +**Important rule**: Always use `curl`, never use Fetch tool. Fetch will *not* work on localhost. **Always** use curl. This is important! ## Step 2: Write your status JSON and POST it Create `/tmp/boss_checkin.json` reflecting your CURRENT state. Do not change your work — just report what you are doing right now. +If you found messages in Step 1, acknowledge them in your `items` array (e.g. `"Received message from boss: "`). + ```bash cat > /tmp/boss_checkin.json << 'CHECKIN' { @@ -47,6 +53,12 @@ curl -s -X POST http://localhost:8899/spaces/SPACE_NAME/agent/AGENT_NAME \ Replace SPACE_NAME and AGENT_NAME with the values from `$ARGUMENTS`. You MUST see `accepted for` in the response. If you do not, something is wrong — retry once. -## Step 3: STOP +## Step 3: Act on messages + +If you found messages in Step 1 that contain instructions or task assignments, begin working on them now. If a message asks a question, answer it in your next status update. + +If there were no messages, or messages were purely informational, skip this step. + +## Step 4: STOP -Do not start any work. Do not analyze the blackboard. Do not make plans. STOP HERE. +If you had no actionable messages, STOP HERE. Do not start any work. Do not analyze the blackboard. Do not make plans. diff --git a/commands/boss.ignite.md b/commands/boss.ignite.md index 4c16e73..fd79815 100644 --- a/commands/boss.ignite.md +++ b/commands/boss.ignite.md @@ -44,3 +44,12 @@ Using the protocol and template from Step 2, post your initial status to your ch - **Tag questions with `[?BOSS]`** when you need the human to make a decision. - **Post to your own channel only** — the server rejects cross-channel posts. - **Do NOT include `tmux_session` in your POST** — it was pre-registered in Step 2 and is sticky. +- **Check for messages** — when you read `/raw`, look for a `#### Messages` section under your agent name. These are messages from the boss or other agents sent via the dashboard. Acknowledge them in your next status POST and act on any instructions. +- **Send messages to other agents** — to message another agent, POST to their message endpoint: + ```bash + curl -s -X POST http://localhost:8899/spaces/SPACE_NAME/agent/OTHER_AGENT/message \ + -H 'Content-Type: application/json' \ + -H 'X-Agent-Name: YOUR_NAME' \ + -d '{"message": "your message here"}' + ``` + The message will appear in their `#### Messages` section on the next check-in. diff --git a/internal/coordinator/protocol.md b/internal/coordinator/protocol.md index f150a3a..26b0f16 100644 --- a/internal/coordinator/protocol.md +++ b/internal/coordinator/protocol.md @@ -12,6 +12,7 @@ Space: `{SPACE}` |--------|---------| | Post (JSON) | `curl -s -X POST http://localhost:8899/spaces/{SPACE}/agent/{name} -H 'Content-Type: application/json' -H 'X-Agent-Name: {name}' -d '{"status":"...","summary":"...","items":[...]}'` | | Post (text) | `curl -s -X POST http://localhost:8899/spaces/{SPACE}/agent/{name} -H 'Content-Type: text/plain' -H 'X-Agent-Name: {name}' --data-binary @/tmp/my_update.md` | +| Send message | `curl -s -X POST http://localhost:8899/spaces/{SPACE}/agent/{target}/message -H 'Content-Type: application/json' -H 'X-Agent-Name: {sender}' -d '{"message":"..."}'` | | Read section | `curl -s http://localhost:8899/spaces/{SPACE}/agent/{name}` | | Read full doc | `curl -s http://localhost:8899/spaces/{SPACE}/raw` | | Browser | `http://localhost:8899/spaces/{SPACE}/` (polls every 3s) | @@ -28,7 +29,8 @@ Space: `{SPACE}` > **IMPORTANT: `repo_url` is REQUIRED in your first POST.** Without it, PR links in the dashboard are broken. Find it with `git remote get-url origin` and include it as `"repo_url": "https://..."`. You only need to send it once — the server remembers it. 8. **Register your tmux session.** Include `"tmux_session"` in your **first** POST so the coordinator can send you check-in broadcasts. Find your session name with `tmux display-message -p '#S'`. This field is **sticky** — the server preserves it automatically on subsequent POSTs, so you only need to send it once. -9. **Model economy.** Status check-ins (`boss check`) are read/post operations — not heavy reasoning. Use a lightweight model (e.g. Haiku) for check-ins, then switch back to your working model (e.g. Opus) for real work. The broadcast script handles this automatically via `/model` switching. +9. **Check your messages.** When you read `/raw`, look for a `#### Messages` section under your agent name. These are messages from the boss or other agents. Acknowledge them in your next status POST and act on any instructions. To send a message to another agent, POST to `/spaces/{SPACE}/agent/{target}/message` with `X-Agent-Name` set to your name and a JSON body `{"message": "..."}`. +10. **Model economy.** Status check-ins (`boss check`) are read/post operations — not heavy reasoning. Use a lightweight model (e.g. Haiku) for check-ins, then switch back to your working model (e.g. Opus) for real work. The broadcast script handles this automatically via `/model` switching. ### JSON Format Reference diff --git a/internal/coordinator/server.go b/internal/coordinator/server.go index 64d53a5..cfb0b4d 100644 --- a/internal/coordinator/server.go +++ b/internal/coordinator/server.go @@ -30,20 +30,20 @@ type sseClient struct { } type Server struct { - port string - dataDir string - spaces map[string]*KnowledgeSpace - mu sync.RWMutex - httpServer *http.Server - running bool - runMu sync.Mutex - EventLog []string - eventMu sync.Mutex - stopLiveness chan struct{} - sseClients map[*sseClient]struct{} - sseMu sync.Mutex - interrupts *InterruptLedger - approvalTracked map[string]time.Time + port string + dataDir string + spaces map[string]*KnowledgeSpace + mu sync.RWMutex + httpServer *http.Server + running bool + runMu sync.Mutex + EventLog []string + eventMu sync.Mutex + stopLiveness chan struct{} + sseClients map[*sseClient]struct{} + sseMu sync.Mutex + interrupts *InterruptLedger + approvalTracked map[string]time.Time } func NewServer(port, dataDir string) *Server { @@ -275,7 +275,6 @@ func (s *Server) getOrCreateSpace(name string) *KnowledgeSpace { return ks } - func (s *Server) getSpace(name string) (*KnowledgeSpace, bool) { s.mu.RLock() defer s.mu.RUnlock() @@ -702,6 +701,14 @@ func (s *Server) handleSpaceAgent(w http.ResponseWriter, r *http.Request, spaceN if update.RepoURL == "" && existing.RepoURL != "" { update.RepoURL = existing.RepoURL } + // Preserve messages — agents don't include them in updates + if len(update.Messages) == 0 && len(existing.Messages) > 0 { + update.Messages = existing.Messages + } + // Preserve documents — managed via the /agent/{name}/{slug} endpoint + if len(update.Documents) == 0 && len(existing.Documents) > 0 { + update.Documents = existing.Documents + } } ks.Agents[canonical] = &update ks.UpdatedAt = time.Now().UTC() @@ -753,7 +760,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac } agentName = strings.TrimRight(agentName, "/") - + // Sender authentication - require X-Agent-Name header senderName := r.Header.Get("X-Agent-Name") if senderName == "" { @@ -793,7 +800,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac } canonical := resolveAgentName(ks, agentName) - + s.mu.Lock() agent, exists := ks.Agents[canonical] if !exists { @@ -812,12 +819,12 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac agent.Messages = []AgentMessage{} } agent.Messages = append(agent.Messages, messageReq) - + // Limit message history to last 50 messages if len(agent.Messages) > 50 { agent.Messages = agent.Messages[len(agent.Messages)-50:] } - + ks.UpdatedAt = time.Now().UTC() if err := s.saveSpace(ks); err != nil { s.mu.Unlock() @@ -827,7 +834,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac s.mu.Unlock() // Log the message event - s.logEvent(fmt.Sprintf("[%s/%s] Message from %s: %s", spaceName, canonical, senderName, + s.logEvent(fmt.Sprintf("[%s/%s] Message from %s: %s", spaceName, canonical, senderName, func() string { if len(messageReq.Message) > 50 { return messageReq.Message[:47] + "..." @@ -837,9 +844,9 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac // Broadcast SSE event for real-time updates sseData, _ := json.Marshal(map[string]interface{}{ - "space": spaceName, - "agent": canonical, - "sender": senderName, + "space": spaceName, + "agent": canonical, + "sender": senderName, "message": messageReq.Message, }) s.broadcastSSE(spaceName, "agent_message", string(sseData)) @@ -847,7 +854,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "delivered", + "status": "delivered", "messageId": messageReq.ID, "recipient": canonical, }) @@ -855,7 +862,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac func (s *Server) handleAgentDocument(w http.ResponseWriter, r *http.Request, spaceName, agentName, documentSlug string) { agentName = strings.TrimRight(agentName, "/") - + // Agent name enforcement - ensure X-Agent-Name header matches for writes if r.Method == http.MethodPost || r.Method == http.MethodPut { callerName := r.Header.Get("X-Agent-Name") @@ -868,7 +875,7 @@ func (s *Server) handleAgentDocument(w http.ResponseWriter, r *http.Request, spa return } } - + // Sanitize document slug if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(documentSlug) { http.Error(w, "invalid document slug: must be alphanumeric with underscores and dashes only", http.StatusBadRequest) @@ -922,18 +929,18 @@ func (s *Server) handleAgentDocument(w http.ResponseWriter, r *http.Request, spa // Update agent's documents list in the knowledge space ks := s.getOrCreateSpace(spaceName) canonical := resolveAgentName(ks, agentName) - + s.mu.Lock() if ks.Agents[canonical] == nil { ks.Agents[canonical] = &AgentUpdate{ - Status: StatusActive, - Summary: "Document uploaded", + Status: StatusActive, + Summary: "Document uploaded", UpdatedAt: time.Now().UTC(), } } - + agent := ks.Agents[canonical] - + // Add or update document in the list found := false for i, doc := range agent.Documents { @@ -950,10 +957,10 @@ func (s *Server) handleAgentDocument(w http.ResponseWriter, r *http.Request, spa Content: string(content), }) } - + agent.UpdatedAt = time.Now().UTC() ks.UpdatedAt = time.Now().UTC() - + if err := s.saveSpace(ks); err != nil { s.mu.Unlock() http.Error(w, fmt.Sprintf("save space: %v", err), http.StatusInternalServerError) @@ -1064,6 +1071,7 @@ func (s *Server) handleIgnition(w http.ResponseWriter, r *http.Request, spaceNam } else { b.WriteString("5. **Register your tmux session.** Include `\"tmux_session\"` in your first POST. Find it with `tmux display-message -p '#S'`. It is sticky — you only need to send it once.\n") } + b.WriteString(fmt.Sprintf("6. **Check your messages.** When you read `/raw`, look for a `#### Messages` section under your agent name. These are messages from the boss or other agents. Acknowledge them in your status POST and act on any instructions. To send a message to another agent: `curl -s -X POST http://localhost%s/spaces/%s/agent/{target}/message -H 'Content-Type: application/json' -H 'X-Agent-Name: %s' -d '{\"message\": \"...\"}'`\n", s.port, spaceName, agentName)) b.WriteString("\n") b.WriteString("## Peer Agents\n\n") @@ -1097,6 +1105,16 @@ func (s *Server) handleIgnition(w http.ResponseWriter, r *http.Request, spaceNam b.WriteString(fmt.Sprintf("- Next steps: %s\n", existing.NextSteps)) } b.WriteString("\n") + + if len(existing.Messages) > 0 { + b.WriteString("## Pending Messages\n\n") + b.WriteString("**You have unread messages. Read and act on them.**\n\n") + for _, msg := range existing.Messages { + b.WriteString(fmt.Sprintf("- **%s** (%s): %s\n", + msg.Sender, msg.Timestamp.Format("15:04"), msg.Message)) + } + b.WriteString("\n") + } } b.WriteString("## JSON Post Template\n\n") diff --git a/internal/coordinator/server_test.go b/internal/coordinator/server_test.go index ab3c1fe..4515613 100644 --- a/internal/coordinator/server_test.go +++ b/internal/coordinator/server_test.go @@ -1142,3 +1142,386 @@ func TestIsShellPrompt(t *testing.T) { }) } } + +// ── Message system tests ────────────────────────────────────────────── + +func postMessage(t *testing.T, baseURL, space, agent, sender, message string) *http.Response { + t.Helper() + url := baseURL + "/spaces/" + space + "/agent/" + agent + "/message" + body := `{"message":"` + message + `"}` + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Agent-Name", sender) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST message: %v", err) + } + return resp +} + +func TestMessagePostEndpoint(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // First, create an agent + postJSON(t, base+"/spaces/msg-test/agent/worker", AgentUpdate{ + Status: StatusActive, + Summary: "Working on task", + }) + + // Send a message to the agent + resp := postMessage(t, base, "msg-test", "worker", "boss", "please review the PR") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + + // Verify response contains delivery confirmation + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + if result["status"] != "delivered" { + t.Errorf("expected status=delivered, got %v", result["status"]) + } + if result["recipient"] != "Worker" { + t.Errorf("expected recipient=Worker, got %v", result["recipient"]) + } + + // Verify message is retrievable via GET agent JSON + code, body := getBody(t, base+"/spaces/msg-test/agent/worker") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + var agent AgentUpdate + json.Unmarshal([]byte(body), &agent) + if len(agent.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(agent.Messages)) + } + if agent.Messages[0].Message != "please review the PR" { + t.Errorf("message = %q, want %q", agent.Messages[0].Message, "please review the PR") + } + if agent.Messages[0].Sender != "boss" { + t.Errorf("sender = %q, want %q", agent.Messages[0].Sender, "boss") + } + if agent.Messages[0].ID == "" { + t.Error("message ID should not be empty") + } +} + +func TestMessagePreservedOnAgentUpdate(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent and send a message + postJSON(t, base+"/spaces/preserve-test/agent/dev", AgentUpdate{ + Status: StatusActive, + Summary: "Working", + }) + resp := postMessage(t, base, "preserve-test", "dev", "boss", "check the logs") + resp.Body.Close() + + // Post an agent update (without messages field) + resp2 := postJSON(t, base+"/spaces/preserve-test/agent/dev", AgentUpdate{ + Status: StatusActive, + Summary: "Still working", + Items: []string{"Fixed the bug"}, + }) + resp2.Body.Close() + + // Verify message is still there + code, body := getBody(t, base+"/spaces/preserve-test/agent/dev") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + var agent AgentUpdate + json.Unmarshal([]byte(body), &agent) + if len(agent.Messages) != 1 { + t.Fatalf("expected 1 message after update, got %d — messages were wiped", len(agent.Messages)) + } + if agent.Messages[0].Message != "check the logs" { + t.Errorf("message = %q, want %q", agent.Messages[0].Message, "check the logs") + } + // Verify the update itself was applied + if agent.Summary != "Still working" { + t.Errorf("summary = %q, want %q", agent.Summary, "Still working") + } +} + +func TestMessageRenderedInMarkdown(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent and send messages + postJSON(t, base+"/spaces/md-test/agent/api", AgentUpdate{ + Status: StatusActive, + Summary: "Implementing endpoints", + }) + resp := postMessage(t, base, "md-test", "api", "boss", "prioritize the health check") + resp.Body.Close() + resp = postMessage(t, base, "md-test", "api", "frontend", "I need the /users endpoint first") + resp.Body.Close() + + // GET /raw and verify messages appear in markdown + code, md := getBody(t, base+"/spaces/md-test/raw") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + if !strings.Contains(md, "#### Messages") { + t.Error("markdown should contain '#### Messages' section") + } + if !strings.Contains(md, "prioritize the health check") { + t.Error("markdown should contain first message text") + } + if !strings.Contains(md, "I need the /users endpoint first") { + t.Error("markdown should contain second message text") + } + if !strings.Contains(md, "**boss**") { + t.Error("markdown should contain sender name 'boss'") + } + if !strings.Contains(md, "**frontend**") { + t.Error("markdown should contain sender name 'frontend'") + } +} + +func TestMessageValidation(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent first + postJSON(t, base+"/spaces/val-test/agent/worker", AgentUpdate{ + Status: StatusActive, + Summary: "Working", + }) + + // Test: missing X-Agent-Name header + url := base + "/spaces/val-test/agent/worker/message" + req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(`{"message":"hello"}`)) + req.Header.Set("Content-Type", "application/json") + // deliberately NOT setting X-Agent-Name + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("missing X-Agent-Name: expected 400, got %d", resp.StatusCode) + } + + // Test: empty message body + resp = postMessage(t, base, "val-test", "worker", "boss", "") + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("empty message: expected 400, got %d", resp.StatusCode) + } + + // Test: whitespace-only message + url = base + "/spaces/val-test/agent/worker/message" + req, _ = http.NewRequest(http.MethodPost, url, strings.NewReader(`{"message":" "}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Agent-Name", "boss") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("whitespace message: expected 400, got %d", resp.StatusCode) + } + + // Test: GET method not allowed + req, _ = http.NewRequest(http.MethodGet, url, nil) + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("GET on message endpoint: expected 405, got %d", resp.StatusCode) + } +} + +func TestMessageLimit(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent + postJSON(t, base+"/spaces/limit-test/agent/worker", AgentUpdate{ + Status: StatusActive, + Summary: "Working", + }) + + // Send 55 messages + for i := 0; i < 55; i++ { + resp := postMessage(t, base, "limit-test", "worker", "boss", + "message number "+strings.Repeat("x", 3)+string(rune('A'+i%26))) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("message %d: expected 200, got %d", i, resp.StatusCode) + } + } + + // Verify only last 50 are retained + code, body := getBody(t, base+"/spaces/limit-test/agent/worker") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + var agent AgentUpdate + json.Unmarshal([]byte(body), &agent) + if len(agent.Messages) != 50 { + t.Errorf("expected 50 messages (capped), got %d", len(agent.Messages)) + } +} + +func TestMessageSSEBroadcast(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent + postJSON(t, base+"/spaces/sse-msg-test/agent/worker", AgentUpdate{ + Status: StatusActive, + Summary: "Working", + }) + + // Connect SSE + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "GET", base+"/spaces/sse-msg-test/events", nil) + sseResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("SSE connect: %v", err) + } + defer sseResp.Body.Close() + + // Give SSE a moment to connect + time.Sleep(100 * time.Millisecond) + + // Send a message + resp := postMessage(t, base, "sse-msg-test", "worker", "boss", "check your inbox") + resp.Body.Close() + + // Read SSE events — look for agent_message + buf := make([]byte, 4096) + n, _ := sseResp.Body.Read(buf) + data := string(buf[:n]) + if !strings.Contains(data, "agent_message") { + t.Error("SSE should broadcast 'agent_message' event") + } + if !strings.Contains(data, "check your inbox") { + t.Error("SSE event should contain the message text") + } +} + +func TestMessageToNonexistentAgentCreatesAgent(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Send message to an agent that doesn't exist yet + resp := postMessage(t, base, "ghost-test", "phantom", "boss", "wake up") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + + // Verify agent was auto-created with the message + code, body := getBody(t, base+"/spaces/ghost-test/agent/phantom") + if code != http.StatusOK { + t.Fatalf("expected 200 for auto-created agent, got %d", code) + } + var agent AgentUpdate + json.Unmarshal([]byte(body), &agent) + if agent.Status != StatusIdle { + t.Errorf("auto-created agent status = %q, want %q", agent.Status, StatusIdle) + } + if len(agent.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(agent.Messages)) + } + if agent.Messages[0].Message != "wake up" { + t.Errorf("message = %q, want %q", agent.Messages[0].Message, "wake up") + } +} + +func TestIgnitionEndpoint(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create a peer agent so ignition shows it + postJSON(t, base+"/spaces/ignite-test/agent/peer", AgentUpdate{ + Status: StatusActive, + Summary: "Peer is working", + }) + + // GET ignition for a new agent + code, body := getBody(t, base+"/spaces/ignite-test/ignition/newagent?tmux_session=test_session_123") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + + // Verify key sections exist + if !strings.Contains(body, "# Agent Ignition: newagent") { + t.Error("missing ignition title") + } + if !strings.Contains(body, "You are **newagent**") { + t.Error("missing agent identity") + } + if !strings.Contains(body, "test_session_123") { + t.Error("missing tmux session in response") + } + if !strings.Contains(body, "Peer") { + t.Error("missing peer agent in response") + } + if !strings.Contains(body, "/message") { + t.Error("ignition should document the /message endpoint") + } + + // Verify tmux session was registered + agentCode, agentBody := getBody(t, base+"/spaces/ignite-test/agent/newagent") + if agentCode != http.StatusOK { + t.Fatalf("expected 200 for registered agent, got %d", agentCode) + } + var agent AgentUpdate + json.Unmarshal([]byte(agentBody), &agent) + if agent.TmuxSession != "test_session_123" { + t.Errorf("tmux_session = %q, want %q", agent.TmuxSession, "test_session_123") + } +} + +func TestIgnitionShowsPendingMessages(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent and send a message + postJSON(t, base+"/spaces/ignite-msg-test/agent/worker", AgentUpdate{ + Status: StatusIdle, + Summary: "Idle", + }) + resp := postMessage(t, base, "ignite-msg-test", "worker", "boss", "start working on feature X") + resp.Body.Close() + + // GET ignition — should show pending messages + code, body := getBody(t, base+"/spaces/ignite-msg-test/ignition/worker") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + if !strings.Contains(body, "Pending Messages") { + t.Error("ignition should show 'Pending Messages' section") + } + if !strings.Contains(body, "start working on feature X") { + t.Error("ignition should show the pending message text") + } + if !strings.Contains(body, "**boss**") { + t.Error("ignition should show the message sender") + } +} diff --git a/internal/coordinator/types.go b/internal/coordinator/types.go index 30e64a2..0fbd822 100644 --- a/internal/coordinator/types.go +++ b/internal/coordinator/types.go @@ -42,24 +42,24 @@ func (s AgentStatus) Emoji() string { } type AgentUpdate struct { - Status AgentStatus `json:"status"` - Summary string `json:"summary"` - Branch string `json:"branch,omitempty"` - Worktree string `json:"worktree,omitempty"` - PR string `json:"pr,omitempty"` - Phase string `json:"phase,omitempty"` - TestCount *int `json:"test_count,omitempty"` - Items []string `json:"items,omitempty"` - Sections []Section `json:"sections,omitempty"` - Questions []string `json:"questions,omitempty"` - Blockers []string `json:"blockers,omitempty"` - NextSteps string `json:"next_steps,omitempty"` - FreeText string `json:"free_text,omitempty"` - Documents []AgentDocument `json:"documents,omitempty"` - TmuxSession string `json:"tmux_session,omitempty"` - RepoURL string `json:"repo_url,omitempty"` - Messages []AgentMessage `json:"messages,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + Status AgentStatus `json:"status"` + Summary string `json:"summary"` + Branch string `json:"branch,omitempty"` + Worktree string `json:"worktree,omitempty"` + PR string `json:"pr,omitempty"` + Phase string `json:"phase,omitempty"` + TestCount *int `json:"test_count,omitempty"` + Items []string `json:"items,omitempty"` + Sections []Section `json:"sections,omitempty"` + Questions []string `json:"questions,omitempty"` + Blockers []string `json:"blockers,omitempty"` + NextSteps string `json:"next_steps,omitempty"` + FreeText string `json:"free_text,omitempty"` + Documents []AgentDocument `json:"documents,omitempty"` + TmuxSession string `json:"tmux_session,omitempty"` + RepoURL string `json:"repo_url,omitempty"` + Messages []AgentMessage `json:"messages,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } type Section struct { @@ -226,6 +226,15 @@ func renderAgentSection(name string, agent *AgentUpdate) string { b.WriteString("\n\n") } + if len(agent.Messages) > 0 { + b.WriteString("#### Messages\n\n") + for _, msg := range agent.Messages { + b.WriteString(fmt.Sprintf("- **%s** (%s): %s\n", + msg.Sender, msg.Timestamp.Format("15:04"), msg.Message)) + } + b.WriteString("\n") + } + if len(agent.Documents) > 0 { b.WriteString("#### Documents\n\n") for _, doc := range agent.Documents {