feat: enhance comment system and e2e testing

- Add comment handlers for WebSocket API (comment.add, comment.list)
- Update e2e-test.sh to focus on comment system testing
- Test multi-turn conversation flow (3 turns)
- Display comment content and metadata in test output
- Reduce test timeouts for faster execution
- Fix various orchestrator and lifecycle manager issues
- Add CODE_REVIEW_ISSUES.md documenting known issues
This commit is contained in:
jcfs
2026-01-11 22:08:00 +00:00
parent 6dd0d7ffa4
commit 195be5e575
12 changed files with 650 additions and 727 deletions
+52
View File
@@ -0,0 +1,52 @@
# Backend Code Review Issues
Generated: 2026-01-11
## High Priority
### 1. Security TODOs
- [ ] `internal/gateway/websocket/handler.go:20` - TODO: In production, validate origin
- [ ] `internal/gateway/websocket/handler.go:46` - TODO: Implement proper JWT validation
### 2. Deprecated API
- [ ] `internal/agent/docker/client.go:300-301` - `inspect.NetworkSettings.IPAddress` is deprecated (removed in Docker v29). Use `NetworkSettings.Networks` instead.
## Medium Priority
### 3. Unused Functions
- [ ] `internal/agent/lifecycle/manager.go:641` - `updateInstanceComplete` is unused
- [ ] `internal/agent/streaming/reader.go:232` - `marshalMessage` is unused
- [ ] `internal/task/repository/sqlite.go:617` - `scanTasks` is unused
### 4. Unused Field / Race Condition
- [ ] `internal/gateway/websocket/client.go:36` - `mu sync.RWMutex` field is unused. Either use it to protect `subscriptions` or remove it.
### 5. Outdated Comment
- [ ] `internal/agent/agentctl/acp.go:108` - Comment references `needs_input` which was removed. Update to: `// The StopReason indicates why the agent stopped (e.g., "end_turn")`
## Low Priority
### 6. Unused Test Helpers
- [ ] `internal/agent/lifecycle/manager_test.go:130` - `testManager` type unused
- [ ] `internal/agent/lifecycle/manager_test.go:135` - `newTestManager` function unused
### 7. Code Style (staticcheck S1000)
- [ ] `internal/agentctl/api/acp.go:215` - Use `for range ch` instead of `for { select {} }`
- [ ] `internal/agentctl/api/acp.go:336` - Use `for range ch` instead of `for { select {} }`
### 8. Hardcoded Sleep
- [ ] `internal/agent/lifecycle/manager.go:253` - `time.Sleep(500 * time.Millisecond)` - Consider proper ready-check mechanism
### 9. Ignored Error
- [ ] `internal/task/repository/sqlite.go:626` - `_ = json.Unmarshal(...)` ignores error
## Questions / Review Needed
### 10. InputRequestHandler Redundancy
Files: `internal/orchestrator/service.go` (lines 44-45, 71, 387-389, 507-512), `cmd/kandev/main.go` (lines 305-363)
The `InputRequestHandler` and `handleInputRequired` functionality still exists using `input_required` message type. Is this still needed given the new comment-based conversation flow?
### 11. Other TODOs
- [ ] `internal/gateway/websocket/hub.go:127` - TODO: Add topic-based routing for task-specific notifications
+38 -2
View File
@@ -194,6 +194,9 @@ func main() {
serviceCfg := orchestrator.DefaultServiceConfig()
orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, nil, agentManagerClient, taskRepoAdapter, log)
// Set up comment creator for saving agent responses as comments
orchestratorSvc.SetCommentCreator(&commentCreatorAdapter{svc: taskSvc})
// ============================================
// WEBSOCKET GATEWAY (All communication via WebSocket)
// ============================================
@@ -386,6 +389,7 @@ func main() {
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log)
taskhandlers.RegisterCommentRoutes(router, gateway.Dispatcher, taskSvc, &orchestratorAdapter{svc: orchestratorSvc}, log)
log.Info("Registered Task Service handlers (HTTP + WebSocket)")
// Health check (simple HTTP for load balancers/monitoring)
@@ -568,8 +572,8 @@ func (a *lifecycleAdapter) PromptAgent(ctx context.Context, agentInstanceID stri
return nil, err
}
return &executor.PromptResult{
StopReason: result.StopReason,
NeedsInput: result.NeedsInput,
StopReason: result.StopReason,
AgentMessage: result.AgentMessage,
}, nil
}
@@ -594,3 +598,35 @@ func corsMiddleware() gin.HandlerFunc {
c.Next()
}
}
// orchestratorAdapter adapts the orchestrator.Service to the taskhandlers.OrchestratorService interface
type orchestratorAdapter struct {
svc *orchestrator.Service
}
// PromptTask forwards to the orchestrator service and converts the result type
func (a *orchestratorAdapter) PromptTask(ctx context.Context, taskID string, prompt string) (*taskhandlers.PromptResult, error) {
result, err := a.svc.PromptTask(ctx, taskID, prompt)
if err != nil {
return nil, err
}
return &taskhandlers.PromptResult{
StopReason: result.StopReason,
AgentMessage: result.AgentMessage,
}, nil
}
// commentCreatorAdapter adapts the task service to the orchestrator.CommentCreator interface
type commentCreatorAdapter struct {
svc *taskservice.Service
}
// CreateAgentComment creates a comment with author_type="agent"
func (a *commentCreatorAdapter) CreateAgentComment(ctx context.Context, taskID, content string) error {
_, err := a.svc.CreateComment(ctx, &taskservice.CreateCommentRequest{
TaskID: taskID,
Content: content,
AuthorType: "agent",
})
return err
}
@@ -43,6 +43,10 @@ type AgentInstance struct {
// agentctl client for this instance
agentctl *agentctl.Client
// Message buffer for accumulating agent response during a prompt
messageBuffer strings.Builder
messageMu sync.Mutex
}
// LaunchRequest contains parameters for launching an agent
@@ -321,6 +325,11 @@ func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInsta
zap.String("session_id", sessionID),
zap.String("task_description", taskDescription))
// Clear message buffer before starting prompt
instance.messageMu.Lock()
instance.messageBuffer.Reset()
instance.messageMu.Unlock()
// Prompt is SYNCHRONOUS - it blocks until the agent completes the task
// Use a long timeout context for this
promptCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
@@ -334,7 +343,12 @@ func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInsta
return fmt.Errorf("prompt failed: %w", err)
}
// Check if agent needs input - if so, publish input_required event
// Extract accumulated message from buffer
instance.messageMu.Lock()
agentMessage := instance.messageBuffer.String()
instance.messageBuffer.Reset()
instance.messageMu.Unlock()
stopReason := ""
if resp != nil {
stopReason = string(resp.StopReason)
@@ -343,20 +357,16 @@ func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInsta
zap.String("instance_id", instance.ID),
zap.String("stop_reason", stopReason))
if resp != nil && resp.StopReason == "needs_input" {
m.logger.Info("agent requesting user input on initial prompt",
// Publish prompt_complete event with the agent's response (for saving as comment)
m.publishPromptComplete(instance, agentMessage)
// Prompt completed - mark agent as READY for follow-up prompts
m.logger.Info("agent ready for follow-up prompts",
zap.String("instance_id", instance.ID))
if err := m.MarkReady(instance.ID); err != nil {
m.logger.Error("failed to mark instance as ready",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID))
m.publishInputRequiredFromPrompt(instance, "The agent needs more information to proceed.")
} else {
// Prompt completed - mark agent as READY for follow-up prompts
m.logger.Info("agent ready for follow-up prompts",
zap.String("instance_id", instance.ID))
if err := m.MarkReady(instance.ID); err != nil {
m.logger.Error("failed to mark instance as ready",
zap.String("instance_id", instance.ID),
zap.Error(err))
}
zap.Error(err))
}
} else {
m.logger.Warn("no task description provided, marking as ready",
@@ -466,6 +476,13 @@ func (m *Manager) handleSessionNotification(instance *AgentInstance, notificatio
m.logger.Debug("agent message chunk",
zap.String("instance_id", instance.ID))
m.updateInstanceProgress(instance.ID, 50)
// Accumulate message content for saving as comment when prompt completes
if update.AgentMessageChunk.Content.Text != nil {
instance.messageMu.Lock()
instance.messageBuffer.WriteString(update.AgentMessageChunk.Content.Text.Text)
instance.messageMu.Unlock()
}
}
if update.AgentThoughtChunk != nil {
@@ -536,39 +553,6 @@ func (m *Manager) publishSessionNotification(instance *AgentInstance, notificati
}
}
// publishInputRequiredFromPrompt publishes an input_required event when the agent needs user input
// This is called after a prompt completes with stopReason="needs_input"
func (m *Manager) publishInputRequiredFromPrompt(instance *AgentInstance, message string) {
if m.eventBus == nil {
return
}
// Structure must match protocol.Message with type "input_required"
data := map[string]interface{}{
"type": "input_required",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"data": map[string]interface{}{
"message": message,
},
}
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
subject := events.BuildACPSubject(instance.TaskID)
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish input_required event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
} else {
m.logger.Info("published input_required event",
zap.String("task_id", instance.TaskID),
zap.String("message", message))
}
}
// publishSessionInfo publishes a session_info event with the session ID
// This allows the orchestrator to store the session ID in task metadata for resumption
func (m *Manager) publishSessionInfo(instance *AgentInstance, sessionID string) {
@@ -604,6 +588,42 @@ func (m *Manager) publishSessionInfo(instance *AgentInstance, sessionID string)
}
}
// publishPromptComplete publishes a prompt_complete event when an agent finishes responding
// This is used to save the agent's response as a comment on the task
func (m *Manager) publishPromptComplete(instance *AgentInstance, agentMessage string) {
if m.eventBus == nil {
return
}
// Only publish if there's actual content
if agentMessage == "" {
return
}
data := map[string]interface{}{
"type": "prompt_complete",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": instance.ID,
"task_id": instance.TaskID,
"agent_message": agentMessage,
}
event := bus.NewEvent(events.PromptComplete, "agent-manager", data)
// Publish on task-specific subject so orchestrator can subscribe
subject := events.PromptComplete + "." + instance.TaskID
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish prompt_complete event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
} else {
m.logger.Info("published prompt_complete event",
zap.String("task_id", instance.TaskID),
zap.Int("message_length", len(agentMessage)))
}
}
// updateInstanceProgress updates an instance's progress
func (m *Manager) updateInstanceProgress(instanceID string, progress int) {
m.mu.Lock()
@@ -755,8 +775,8 @@ func (m *Manager) expandMountTemplate(source, workspacePath, taskID string) stri
// PromptAgent sends a follow-up prompt to a running agent
// PromptResult contains the result of a prompt operation
type PromptResult struct {
StopReason string // The reason the agent stopped (e.g., "end_turn", "needs_input")
NeedsInput bool // True if the agent is requesting user input
StopReason string // The reason the agent stopped (e.g., "end_turn")
AgentMessage string // The agent's accumulated response message
}
// PromptAgent sends a follow-up prompt to a running agent
@@ -781,6 +801,12 @@ func (m *Manager) PromptAgent(ctx context.Context, instanceID string, prompt str
// Set status to RUNNING while processing
instance.Status = v1.AgentStatusRunning
// Clear message buffer before starting new prompt
instance.messageMu.Lock()
instance.messageBuffer.Reset()
instance.messageMu.Unlock()
m.mu.Unlock()
m.logger.Info("sending prompt to agent",
@@ -793,24 +819,25 @@ func (m *Manager) PromptAgent(ctx context.Context, instanceID string, prompt str
return nil, err
}
// Extract accumulated message from buffer
instance.messageMu.Lock()
agentMessage := instance.messageBuffer.String()
instance.messageBuffer.Reset()
instance.messageMu.Unlock()
result := &PromptResult{
StopReason: string(resp.StopReason),
NeedsInput: resp.StopReason == "needs_input",
StopReason: string(resp.StopReason),
AgentMessage: agentMessage,
}
// Check if agent needs input - if so, publish input_required event
if result.NeedsInput {
m.logger.Info("agent requesting user input",
// Publish prompt_complete event with the agent's response (for saving as comment)
m.publishPromptComplete(instance, agentMessage)
// Prompt completed - mark as READY for next prompt
if err := m.MarkReady(instanceID); err != nil {
m.logger.Error("failed to mark instance as ready after prompt",
zap.String("instance_id", instanceID),
zap.String("task_id", instance.TaskID))
m.publishInputRequiredFromPrompt(instance, "The agent needs more information to proceed.")
} else {
// Prompt completed normally - mark as READY for next prompt
if err := m.MarkReady(instanceID); err != nil {
m.logger.Error("failed to mark instance as ready after prompt",
zap.String("instance_id", instanceID),
zap.Error(err))
}
zap.Error(err))
}
return result, nil
+5
View File
@@ -50,6 +50,11 @@ const (
ACPMessage = "acp.message" // Base subject for ACP messages
)
// Event types for agent prompts
const (
PromptComplete = "prompt.complete" // Agent finished responding to a prompt
)
// BuildACPSubject creates an ACP subject for a specific task
func BuildACPSubject(taskID string) string {
return ACPMessage + "." + taskID
@@ -344,7 +344,6 @@ func (s *SimulatedAgentManagerClient) PromptAgent(ctx context.Context, agentInst
return &executor.PromptResult{
StopReason: "end_turn",
NeedsInput: false,
}, nil
}
@@ -22,8 +22,8 @@ var (
// PromptResult contains the result of a prompt operation
type PromptResult struct {
StopReason string // The reason the agent stopped (e.g., "end_turn", "needs_input")
NeedsInput bool // True if the agent is requesting user input
StopReason string // The reason the agent stopped (e.g., "end_turn")
AgentMessage string // The agent's accumulated response message
}
// AgentManagerClient is an interface for the Agent Manager service
@@ -394,7 +394,7 @@ func (m *MockAgentManagerClient) PromptAgent(ctx context.Context, agentInstanceI
m.logger.Info("mock: prompting agent",
zap.String("agent_instance_id", agentInstanceID),
zap.Int("prompt_length", len(prompt)))
return &PromptResult{StopReason: "end_turn", NeedsInput: false}, nil
return &PromptResult{StopReason: "end_turn"}, nil
}
// RespondToPermissionByTaskID mocks responding to a permission request
@@ -56,7 +56,7 @@ func (m *mockAgentManager) ListAgentTypes(ctx context.Context) ([]*v1.AgentType,
}
func (m *mockAgentManager) PromptAgent(ctx context.Context, agentInstanceID string, prompt string) (*executor.PromptResult, error) {
return &executor.PromptResult{StopReason: "end_turn", NeedsInput: false}, nil
return &executor.PromptResult{StopReason: "end_turn"}, nil
}
func (m *mockAgentManager) RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error {
+38 -5
View File
@@ -44,6 +44,11 @@ func DefaultServiceConfig() ServiceConfig {
// InputRequestHandler is called when an agent requests user input
type InputRequestHandler func(ctx context.Context, taskID, agentID, message string) error
// CommentCreator is an interface for creating comments on tasks
type CommentCreator interface {
CreateAgentComment(ctx context.Context, taskID, content string) error
}
// Service is the main orchestrator service
type Service struct {
config ServiceConfig
@@ -65,6 +70,9 @@ type Service struct {
// Input request handler (for agent-user conversation)
inputRequestHandler InputRequestHandler
// Comment creator for saving agent responses
commentCreator CommentCreator
// Service state
mu sync.RWMutex
running bool
@@ -122,12 +130,18 @@ func NewService(
OnAgentCompleted: s.handleAgentCompleted,
OnAgentFailed: s.handleAgentFailed,
OnACPMessage: s.handleACPMessage,
OnPromptComplete: s.handlePromptComplete,
}
s.watcher = watcher.NewWatcher(eventBus, handlers, log)
return s
}
// SetCommentCreator sets the comment creator for saving agent responses
func (s *Service) SetCommentCreator(cc CommentCreator) {
s.commentCreator = cc
}
// Start starts all orchestrator components
func (s *Service) Start(ctx context.Context) error {
s.mu.Lock()
@@ -273,12 +287,11 @@ func (s *Service) StopTask(ctx context.Context, taskID string, reason string, fo
// PromptResult contains the result of a prompt operation
type PromptResult struct {
StopReason string // The reason the agent stopped (e.g., "end_turn", "needs_input")
NeedsInput bool // True if the agent is requesting user input
StopReason string // The reason the agent stopped (e.g., "end_turn")
AgentMessage string // The agent's accumulated response message
}
// PromptTask sends a follow-up prompt to a running agent for a task
// Returns PromptResult indicating if the agent needs input
func (s *Service) PromptTask(ctx context.Context, taskID string, prompt string) (*PromptResult, error) {
s.logger.Info("sending prompt to task agent",
zap.String("task_id", taskID),
@@ -288,8 +301,8 @@ func (s *Service) PromptTask(ctx context.Context, taskID string, prompt string)
return nil, err
}
return &PromptResult{
StopReason: result.StopReason,
NeedsInput: result.NeedsInput,
StopReason: result.StopReason,
AgentMessage: result.AgentMessage,
}, nil
}
@@ -500,3 +513,23 @@ func (s *Service) handleInputRequired(ctx context.Context, taskID string, msg *p
}
}
// handlePromptComplete handles prompt complete events and saves agent response as comment
func (s *Service) handlePromptComplete(ctx context.Context, data watcher.PromptCompleteData) {
s.logger.Info("handling prompt complete",
zap.String("task_id", data.TaskID),
zap.Int("message_length", len(data.AgentMessage)))
// Save agent response as a comment if we have a comment creator
if s.commentCreator != nil && data.AgentMessage != "" {
if err := s.commentCreator.CreateAgentComment(ctx, data.TaskID, data.AgentMessage); err != nil {
s.logger.Error("failed to create agent comment",
zap.String("task_id", data.TaskID),
zap.Error(err))
} else {
s.logger.Info("created agent comment",
zap.String("task_id", data.TaskID),
zap.Int("message_length", len(data.AgentMessage)))
}
}
}
@@ -33,6 +33,13 @@ type AgentEventData struct {
ErrorMessage string `json:"error_message,omitempty"`
}
// PromptCompleteData contains data from prompt_complete events
type PromptCompleteData struct {
TaskID string `json:"task_id"`
AgentID string `json:"agent_id"`
AgentMessage string `json:"agent_message"`
}
// EventHandlers contains callbacks for different event types
type EventHandlers struct {
// Task events
@@ -50,6 +57,9 @@ type EventHandlers struct {
// ACP messages
OnACPMessage func(ctx context.Context, taskID string, msg *protocol.Message)
// Prompt events
OnPromptComplete func(ctx context.Context, data PromptCompleteData)
}
// Watcher subscribes to events and dispatches to handlers
@@ -104,6 +114,12 @@ func (w *Watcher) Start(ctx context.Context) error {
return err
}
// Subscribe to prompt complete events
if err := w.subscribeToPromptCompleteEvents(); err != nil {
w.unsubscribeAll()
return err
}
w.running = true
w.logger.Info("Event watcher started", zap.Int("subscriptions", len(w.subscriptions)))
return nil
@@ -302,6 +318,48 @@ func (w *Watcher) createACPMessageHandler() bus.EventHandler {
}
}
// subscribeToPromptCompleteEvents subscribes to prompt complete events
func (w *Watcher) subscribeToPromptCompleteEvents() error {
if w.handlers.OnPromptComplete == nil {
return nil
}
// Use wildcard to subscribe to all prompt complete events (prompt.complete.*)
subject := events.PromptComplete + ".*"
// Use regular subscription (each instance needs all messages for comment creation)
sub, err := w.eventBus.Subscribe(subject, w.createPromptCompleteHandler())
if err != nil {
w.logger.Error("Failed to subscribe to prompt complete events",
zap.String("subject", subject),
zap.Error(err))
return err
}
w.subscriptions = append(w.subscriptions, sub)
return nil
}
// createPromptCompleteHandler creates a bus.EventHandler for prompt complete events
func (w *Watcher) createPromptCompleteHandler() bus.EventHandler {
return func(ctx context.Context, event *bus.Event) error {
var data PromptCompleteData
if err := w.parseEventData(event.Data, &data); err != nil {
w.logger.Error("Failed to parse prompt complete event data",
zap.String("event_type", event.Type),
zap.String("event_id", event.ID),
zap.Error(err))
return nil // Don't return error to continue processing other events
}
w.logger.Debug("Handling prompt complete event",
zap.String("task_id", data.TaskID),
zap.Int("message_length", len(data.AgentMessage)))
w.handlers.OnPromptComplete(ctx, data)
return nil
}
}
// parseEventData converts event data map to a typed struct
func (w *Watcher) parseEventData(data map[string]interface{}, target interface{}) error {
// Marshal to JSON and unmarshal to target type
@@ -204,7 +204,6 @@ func (h *Handlers) PromptTask(ctx context.Context, msg *ws.Message) (*ws.Message
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
"needs_input": result.NeedsInput,
"stop_reason": result.StopReason,
})
}
@@ -0,0 +1,118 @@
package handlers
import (
"context"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
)
// PromptResult represents the result of prompting an agent
type PromptResult struct {
StopReason string `json:"stop_reason"`
AgentMessage string `json:"agent_message"`
}
// OrchestratorService defines the interface for orchestrator operations
type OrchestratorService interface {
PromptTask(ctx context.Context, taskID string, prompt string) (*PromptResult, error)
}
// CommentHandlers handles WebSocket requests for comments
type CommentHandlers struct {
svc *service.Service
orchestrator OrchestratorService
logger *logger.Logger
}
// NewCommentHandlers creates a new CommentHandlers instance
func NewCommentHandlers(svc *service.Service, orchestrator OrchestratorService, log *logger.Logger) *CommentHandlers {
return &CommentHandlers{
svc: svc,
orchestrator: orchestrator,
logger: log.WithFields(zap.String("component", "task-comment-handlers")),
}
}
// RegisterCommentRoutes registers comment WebSocket handlers
func RegisterCommentRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *service.Service, orchestrator OrchestratorService, log *logger.Logger) {
handlers := NewCommentHandlers(svc, orchestrator, log)
handlers.registerWS(dispatcher)
}
func (h *CommentHandlers) registerWS(dispatcher *ws.Dispatcher) {
dispatcher.RegisterFunc(ws.ActionCommentAdd, h.wsAddComment)
dispatcher.RegisterFunc(ws.ActionCommentList, h.wsListComments)
}
// WS handlers
type wsAddCommentRequest struct {
TaskID string `json:"task_id"`
Content string `json:"content"`
AuthorID string `json:"author_id,omitempty"`
}
func (h *CommentHandlers) wsAddComment(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsAddCommentRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if req.Content == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content is required", nil)
}
comment, err := h.svc.CreateComment(ctx, &service.CreateCommentRequest{
TaskID: req.TaskID,
Content: req.Content,
AuthorType: "user",
AuthorID: req.AuthorID,
})
if err != nil {
h.logger.Error("failed to create comment", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create comment", nil)
}
// Auto-forward comment as prompt to running agent if orchestrator is available
// The agent's response will be saved as a comment via the prompt_complete event
if h.orchestrator != nil {
_, err := h.orchestrator.PromptTask(ctx, req.TaskID, req.Content)
if err != nil {
h.logger.Warn("failed to forward comment as prompt to agent",
zap.String("task_id", req.TaskID),
zap.Error(err))
}
}
return ws.NewResponse(msg.ID, msg.Action, comment)
}
type wsListCommentsRequest struct {
TaskID string `json:"task_id"`
}
func (h *CommentHandlers) wsListComments(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsListCommentsRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
comments, err := h.svc.ListComments(ctx, req.TaskID)
if err != nil {
h.logger.Error("failed to list comments", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list comments", nil)
}
return ws.NewResponse(msg.ID, msg.Action, comments)
}
+247 -651
View File
File diff suppressed because it is too large Load Diff