diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index b2683eac8b..482c49f9e5 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -359,6 +359,21 @@ func main() { } } + // Send available commands from the execution (for slash command menu after page refresh) + if lifecycleMgr != nil { + commands := lifecycleMgr.GetAvailableCommandsForSession(sessionID) + if len(commands) > 0 { + notification, err := ws.NewNotification(ws.ActionSessionAvailableCommands, map[string]interface{}{ + "session_id": sessionID, + "task_id": session.TaskID, + "available_commands": commands, + }) + if err == nil { + result = append(result, notification) + } + } + } + return result, nil }) log.Info("Session data provider configured for session subscriptions (git status from snapshots)") diff --git a/apps/backend/internal/agent/lifecycle/event_types.go b/apps/backend/internal/agent/lifecycle/event_types.go index 5daa235b1c..d8de52b626 100644 --- a/apps/backend/internal/agent/lifecycle/event_types.go +++ b/apps/backend/internal/agent/lifecycle/event_types.go @@ -65,6 +65,10 @@ type AgentStreamEventData struct { IsAppend bool `json:"is_append,omitempty"` // MessageType distinguishes between "message" and "thinking" content types MessageType string `json:"message_type,omitempty"` + + // AvailableCommands contains the slash commands available from the agent. + // Populated when Type is "available_commands". + AvailableCommands []streams.AvailableCommand `json:"available_commands,omitempty"` } // AgentStreamEventPayload is the payload for agent stream events (WebSocket streaming). @@ -281,3 +285,17 @@ type ContextWindowEventPayload struct { func (p ContextWindowEventPayload) GetSessionID() string { return p.SessionID } + +// AvailableCommandsEventPayload is the payload for available commands update events. +type AvailableCommandsEventPayload struct { + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + AgentID string `json:"agent_id"` + AvailableCommands []streams.AvailableCommand `json:"available_commands"` + Timestamp string `json:"timestamp"` +} + +// GetSessionID returns the session ID for this event (used by event routing). +func (p AvailableCommandsEventPayload) GetSessionID() string { + return p.SessionID +} diff --git a/apps/backend/internal/agent/lifecycle/events.go b/apps/backend/internal/agent/lifecycle/events.go index 35afa2a062..2b8447a7c6 100644 --- a/apps/backend/internal/agent/lifecycle/events.go +++ b/apps/backend/internal/agent/lifecycle/events.go @@ -9,6 +9,7 @@ import ( "go.uber.org/zap" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" @@ -115,18 +116,19 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even // Build the nested event data // event.SessionID is the ACP session ID (internal agent protocol session) eventData := &AgentStreamEventData{ - Type: event.Type, - ACPSessionID: event.SessionID, - Text: event.Text, - ToolCallID: event.ToolCallID, - ParentToolCallID: event.ParentToolCallID, - ToolName: event.ToolName, - ToolTitle: event.ToolTitle, - ToolStatus: event.ToolStatus, - Error: event.Error, - SessionStatus: event.SessionStatus, - Data: event.Data, - Normalized: event.NormalizedPayload, + Type: event.Type, + ACPSessionID: event.SessionID, + Text: event.Text, + ToolCallID: event.ToolCallID, + ParentToolCallID: event.ParentToolCallID, + ToolName: event.ToolName, + ToolTitle: event.ToolTitle, + ToolStatus: event.ToolStatus, + Error: event.Error, + SessionStatus: event.SessionStatus, + Data: event.Data, + Normalized: event.NormalizedPayload, + AvailableCommands: event.AvailableCommands, } // Build agent event message payload @@ -476,3 +478,31 @@ func (p *EventPublisher) PublishContextWindow(execution *AgentExecution, size, u } } +// PublishAvailableCommands publishes an available commands update event to the event bus. +func (p *EventPublisher) PublishAvailableCommands(execution *AgentExecution, commands []streams.AvailableCommand) { + if p.eventBus == nil { + return + } + + sessionID := execution.SessionID + + payload := AvailableCommandsEventPayload{ + TaskID: execution.TaskID, + SessionID: sessionID, + AgentID: execution.ID, + AvailableCommands: commands, + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + } + + event := bus.NewEvent(events.AvailableCommandsUpdated, "agent-manager", payload) + subject := events.BuildAvailableCommandsSubject(sessionID) + + if err := p.eventBus.Publish(context.Background(), subject, event); err != nil { + p.logger.Error("failed to publish available commands event", + zap.String("instance_id", execution.ID), + zap.String("task_id", execution.TaskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } +} + diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 08151b4993..147ed7c862 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -1378,6 +1378,14 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age bufferPreview = bufferPreview[:100] + "..." } + // Check if this is an error completion (agent failed to process the prompt) + isError := false + if event.Data != nil { + if v, ok := event.Data["is_error"].(bool); ok { + isError = v + } + } + m.logger.Info("agent turn complete", zap.String("execution_id", execution.ID), zap.String("operation_id", event.OperationID), @@ -1386,7 +1394,8 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age zap.String("current_msg_id", currentMsgID), zap.String("event_text", event.Text), zap.Int("buffer_length", len(bufferContentBeforeFlush)), - zap.String("buffer_preview", bufferPreview)) + zap.String("buffer_preview", bufferPreview), + zap.Bool("is_error", isError)) // Get the agent message text: // 1. If streaming was used, flush the buffer (which publishes final chunk) and ignore event.Text @@ -1429,11 +1438,33 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age } } - // Mark agent as READY for follow-up prompts - if err := m.MarkReady(execution.ID); err != nil { - m.logger.Error("failed to mark execution as ready after complete", + // If this was an error completion, mark the execution as failed + // The agent process will likely exit soon after sending an error result + if isError { + errorMsg := "agent error completion" + if event.Error != "" { + errorMsg = event.Error + } + m.logger.Warn("error completion received, marking execution as failed", zap.String("execution_id", execution.ID), - zap.Error(err)) + zap.String("error", errorMsg)) + + if err := m.MarkCompleted(execution.ID, 1, errorMsg); err != nil { + m.logger.Error("failed to mark execution as failed after error completion", + zap.String("execution_id", execution.ID), + zap.Error(err)) + } + + // Remove the execution from the store so a new one can be created + // The AgentFailed event has already been published by MarkCompleted + m.RemoveExecution(execution.ID) + } else { + // Mark agent as READY for follow-up prompts + if err := m.MarkReady(execution.ID); err != nil { + m.logger.Error("failed to mark execution as ready after complete", + zap.String("execution_id", execution.ID), + zap.Error(err)) + } } case "permission_request": @@ -1464,6 +1495,21 @@ func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.Age ) // Return early - context window events don't need to be published as stream events return + + case "available_commands": + // Store available commands in the execution for later retrieval (e.g., after page refresh) + if len(event.AvailableCommands) > 0 { + execution.SetAvailableCommands(event.AvailableCommands) + m.logger.Debug("stored available commands", + zap.String("execution_id", execution.ID), + zap.String("session_id", execution.SessionID), + zap.Int("command_count", len(event.AvailableCommands))) + + // Also publish to event bus for WebSocket broadcast + m.eventPublisher.PublishAvailableCommands(execution, event.AvailableCommands) + } + // Return early - we handle broadcasting ourselves + return } // Publish agent stream event to event bus for WebSocket streaming @@ -1894,6 +1940,18 @@ func (m *Manager) GetExecutionBySessionID(sessionID string) (*AgentExecution, bo return m.executionStore.GetBySessionID(sessionID) } +// GetAvailableCommandsForSession returns the available slash commands for a session. +// Returns nil if the session doesn't exist or has no commands stored. +// +// Thread-safe: Can be called concurrently from multiple goroutines. +func (m *Manager) GetAvailableCommandsForSession(sessionID string) []streams.AvailableCommand { + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return nil + } + return execution.GetAvailableCommands() +} + // ListExecutions returns all currently tracked agent executions. // // Returns a snapshot of all executions in memory at the time of call. The returned slice diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go index 9320cafbe0..8aeb0c6405 100644 --- a/apps/backend/internal/agent/lifecycle/types.go +++ b/apps/backend/internal/agent/lifecycle/types.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/agent/mcpconfig" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/types/streams" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -74,6 +75,10 @@ type AgentExecution struct { // via the stream protocol. When true, the session manager should NOT emit a complete // event after prompt returns, as the adapter already sent one via the stream. ReportsStatusViaStream bool + + // Available commands from the agent (for slash command menu) + availableCommands []streams.AvailableCommand + availableCommandsMu sync.RWMutex } // GetAgentCtlClient returns the agentctl client for this execution @@ -95,6 +100,20 @@ func (ae *AgentExecution) GetWorkspaceStream() *agentctl.WorkspaceStream { return ae.workspaceStream } +// SetAvailableCommands sets the available commands for this execution +func (ae *AgentExecution) SetAvailableCommands(commands []streams.AvailableCommand) { + ae.availableCommandsMu.Lock() + defer ae.availableCommandsMu.Unlock() + ae.availableCommands = commands +} + +// GetAvailableCommands returns the available commands for this execution +func (ae *AgentExecution) GetAvailableCommands() []streams.AvailableCommand { + ae.availableCommandsMu.RLock() + defer ae.availableCommandsMu.RUnlock() + return ae.availableCommands +} + // LaunchRequest contains parameters for launching an agent type LaunchRequest struct { TaskID string diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json index 7d4ab06d13..e28ebe747c 100644 --- a/apps/backend/internal/agent/registry/agents.json +++ b/apps/backend/internal/agent/registry/agents.json @@ -318,12 +318,13 @@ "cmd": [ "npx", "-y", - "@anthropic-ai/claude-code@2.1.19", + "@anthropic-ai/claude-code@2.1.29", "-p", "--output-format=stream-json", "--input-format=stream-json", "--permission-prompt-tool=stdio", "--disallowedTools=AskUserQuestion", + "--setting-sources=user,project", "--verbose" ], "passthrough_config": { @@ -333,7 +334,7 @@ "passthrough_cmd": [ "npx", "-y", - "@anthropic-ai/claude-code@2.1.19", + "@anthropic-ai/claude-code", "--verbose" ], "model_flag": "--model {model}", diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index 12b6c5bfe0..86030cdde2 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -532,6 +532,20 @@ func (a *Adapter) convertNotification(n acp.SessionNotification) *AgentEvent { SessionID: string(n.SessionId), PlanEntries: entries, } + + case u.AvailableCommandsUpdate != nil: + commands := make([]streams.AvailableCommand, len(u.AvailableCommandsUpdate.AvailableCommands)) + for i, cmd := range u.AvailableCommandsUpdate.AvailableCommands { + commands[i] = streams.AvailableCommand{ + Name: cmd.Name, + Description: cmd.Description, + } + } + return &AgentEvent{ + Type: streams.EventTypeAvailableCommands, + SessionID: string(n.SessionId), + AvailableCommands: commands, + } } return nil diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go index 92062c7e9d..3728d0f411 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go @@ -7,7 +7,9 @@ import ( "encoding/json" "fmt" "io" + "strings" "sync" + "time" "github.com/google/uuid" "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" @@ -90,6 +92,9 @@ type Adapter struct { mainModelContextWindow int64 // Context window size (updated from result's model_usage) contextTokensUsed int64 // Total tokens used (input + output + cache) + // Available commands from initialize response (stored until session is created) + pendingAvailableCommands []streams.AvailableCommand + // Synchronization mu sync.RWMutex closed bool @@ -163,7 +168,16 @@ func (a *Adapter) Initialize(ctx context.Context) error { a.client.SetMessageHandler(a.handleMessage) // Start reading from stdout with the adapter's context - a.client.Start(a.ctx) + // Wait for the read loop to be ready before sending initialize + readyC := a.client.Start(a.ctx) + select { + case <-readyC: + a.logger.Info("read loop is ready") + case <-ctx.Done(): + return ctx.Err() + case <-time.After(5 * time.Second): + a.logger.Warn("timeout waiting for read loop to start") + } // Store agent info (version will be populated from first message) a.agentInfo = &AgentInfo{ @@ -171,6 +185,28 @@ func (a *Adapter) Initialize(ctx context.Context) error { Version: "unknown", } + // Send initialize control request to get slash commands + // This is required for streaming mode (input-format=stream-json) + initResp, err := a.client.Initialize(ctx, 30*time.Second) + if err != nil { + a.logger.Warn("failed to initialize (continuing anyway)", zap.Error(err)) + } else if initResp != nil && len(initResp.Commands) > 0 { + // Store available commands to emit after session is created + commands := make([]streams.AvailableCommand, len(initResp.Commands)) + for i, cmd := range initResp.Commands { + commands[i] = streams.AvailableCommand{ + Name: cmd.Name, + Description: cmd.Description, + } + } + a.mu.Lock() + a.pendingAvailableCommands = commands + a.mu.Unlock() + + a.logger.Info("received slash commands from initialize", + zap.Int("count", len(commands))) + } + a.logger.Info("stream-json adapter initialized") return nil @@ -181,19 +217,42 @@ func (a *Adapter) GetAgentInfo() *AgentInfo { return a.agentInfo } +// emitPendingCommands emits any pending available commands for the given session. +// Must be called after the mutex is unlocked. +func (a *Adapter) emitPendingCommands(sessionID string, commands []streams.AvailableCommand) { + if len(commands) == 0 { + return + } + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeAvailableCommands, + SessionID: sessionID, + AvailableCommands: commands, + }) + a.logger.Debug("emitted pending slash commands", + zap.String("session_id", sessionID), + zap.Int("count", len(commands))) +} + +// takePendingCommands atomically takes and clears pending commands. +// Must be called with the mutex held. +func (a *Adapter) takePendingCommands() []streams.AvailableCommand { + commands := a.pendingAvailableCommands + a.pendingAvailableCommands = nil + return commands +} + // NewSession creates a new stream-json session. // Note: Sessions are created implicitly with the first prompt. // The mcpServers parameter is ignored as this protocol handles MCP separately. func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) { a.mu.Lock() - defer a.mu.Unlock() - - // Generate a session ID - the agent will return its own session ID - // which we'll update when we receive the system message sessionID := uuid.New().String() a.sessionID = sessionID + pendingCommands := a.takePendingCommands() + a.mu.Unlock() a.logger.Info("created new session placeholder", zap.String("session_id", sessionID)) + a.emitPendingCommands(sessionID, pendingCommands) return sessionID, nil } @@ -203,9 +262,11 @@ func (a *Adapter) NewSession(ctx context.Context, _ []types.McpServer) (string, func (a *Adapter) LoadSession(ctx context.Context, sessionID string) error { a.mu.Lock() a.sessionID = sessionID + pendingCommands := a.takePendingCommands() a.mu.Unlock() a.logger.Info("loaded session", zap.String("session_id", sessionID)) + a.emitPendingCommands(sessionID, pendingCommands) return nil } @@ -444,7 +505,7 @@ func (a *Adapter) handleToolPermission(requestID string, req *claudecode.Control // If no handler, auto-allow if handler == nil { - a.logger.Info("auto-allowing tool (no handler)", + a.logger.Debug("auto-allowing tool (no handler)", zap.String("tool", req.ToolName)) a.sendPermissionResponse(requestID, claudecode.BehaviorAllow) return @@ -547,7 +608,8 @@ func (a *Adapter) handleMessage(msg *claudecode.CLIMessage) { func (a *Adapter) handleSystemMessage(msg *claudecode.CLIMessage) { a.logger.Info("received system message", zap.String("session_id", msg.SessionID), - zap.String("status", msg.SessionStatus)) + zap.String("status", msg.SessionStatus), + zap.Int("slash_commands_count", len(msg.SlashCommands))) // Update session ID if provided a.mu.Lock() @@ -558,6 +620,25 @@ func (a *Adapter) handleSystemMessage(msg *claudecode.CLIMessage) { a.sessionStatusSent = true a.mu.Unlock() + // Emit available commands if present (do this on every system message, + // not just the first, in case commands change) + // Note: System message slash_commands is just an array of names (strings), + // so we only have the name, not description. The initialize response has full details. + if len(msg.SlashCommands) > 0 { + commands := make([]streams.AvailableCommand, len(msg.SlashCommands)) + for i, name := range msg.SlashCommands { + commands[i] = streams.AvailableCommand{ + Name: name, + Description: "", // System message only has names, not descriptions + } + } + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeAvailableCommands, + SessionID: msg.SessionID, + AvailableCommands: commands, + }) + } + // Only send session status event once per session (on first prompt) // The agent sends system messages on every prompt, but we only want to // show "New session started" or "Session resumed" once @@ -585,13 +666,16 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, // Extract parent tool use ID for subagent nesting parentToolUseID := msg.ParentToolUseID + // Get content blocks (may be nil if content is a string) + contentBlocks := msg.Message.GetContentBlocks() + // Log content block types for debugging - blockTypes := make([]string, 0, len(msg.Message.Content)) - for _, block := range msg.Message.Content { + blockTypes := make([]string, 0, len(contentBlocks)) + for _, block := range contentBlocks { blockTypes = append(blockTypes, block.Type) } - a.logger.Info("processing assistant message", - zap.Int("num_blocks", len(msg.Message.Content)), + a.logger.Debug("processing assistant message", + zap.Int("num_blocks", len(contentBlocks)), zap.Strings("block_types", blockTypes), zap.String("parent_tool_use_id", parentToolUseID)) @@ -610,7 +694,7 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, } // Process content blocks - for _, block := range msg.Message.Content { + for _, block := range contentBlocks { switch block.Type { case "text": if block.Text != "" { @@ -646,7 +730,7 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, } else if path, ok := block.Input["file_path"].(string); ok { toolTitle = fmt.Sprintf("%s: %s", block.Name, path) } - a.logger.Info("tool_use block received", + a.logger.Debug("tool_use block received", zap.String("tool_call_id", block.ID), zap.String("tool_name", block.Name), zap.String("tool_type", toolType), @@ -703,15 +787,41 @@ func (a *Adapter) handleAssistantMessage(msg *claudecode.CLIMessage, sessionID, } } -// handleUserMessage processes user messages containing tool results. +// handleUserMessage processes user messages containing tool results or slash command output. // Claude Code sends tool results back as user messages with tool_result content blocks. +// For slash commands, content may be a plain string wrapped in tags. func (a *Adapter) handleUserMessage(msg *claudecode.CLIMessage, sessionID, operationID string) { if msg.Message == nil { return } + // Check if content is a string (slash command output) + if contentStr := msg.Message.GetContentString(); contentStr != "" { + // Extract text from tags if present + text := contentStr + if strings.HasPrefix(text, "") && strings.HasSuffix(text, "") { + text = strings.TrimPrefix(text, "") + text = strings.TrimSuffix(text, "") + } + + if text != "" { + a.logger.Info("received user message with string content (slash command output)", + zap.String("session_id", sessionID), + zap.Int("content_length", len(text))) + + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeMessageChunk, + SessionID: sessionID, + OperationID: operationID, + Text: text, + }) + } + return + } + // Process content blocks looking for tool_result - for _, block := range msg.Message.Content { + contentBlocks := msg.Message.GetContentBlocks() + for _, block := range contentBlocks { if block.Type != "tool_result" { continue } @@ -769,7 +879,7 @@ func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, ope a.mu.Unlock() for _, toolID := range pendingTools { - a.logger.Info("auto-completing pending tool call on result", + a.logger.Debug("auto-completing pending tool call on result", zap.String("tool_call_id", toolID)) a.sendUpdate(AgentEvent{ Type: streams.EventTypeToolUpdate, @@ -829,13 +939,19 @@ func (a *Adapter) handleResultMessage(msg *claudecode.CLIMessage, sessionID, ope }) // If there's result text, send it as a message chunk - // Result can be either a ResultData object or an error string + // Result can be either a ResultData object or a plain string + var resultText string if resultData := msg.GetResultData(); resultData != nil && resultData.Text != "" { + resultText = resultData.Text + } else if resultStr := msg.GetResultString(); resultStr != "" { + resultText = resultStr + } + if resultText != "" { a.sendUpdate(AgentEvent{ Type: streams.EventTypeMessageChunk, SessionID: sessionID, OperationID: operationID, - Text: resultData.Text, + Text: resultText, }) } diff --git a/apps/backend/internal/agentctl/types/streams/agent.go b/apps/backend/internal/agentctl/types/streams/agent.go index 4076a72c8f..e74e18da9f 100644 --- a/apps/backend/internal/agentctl/types/streams/agent.go +++ b/apps/backend/internal/agentctl/types/streams/agent.go @@ -36,6 +36,9 @@ const ( // EventTypeContextWindow indicates a context window update. EventTypeContextWindow = "context_window" + + // EventTypeAvailableCommands indicates available slash commands from the agent. + EventTypeAvailableCommands = "available_commands" ) // Session status constants for EventTypeSessionStatus events. @@ -153,6 +156,11 @@ type AgentEvent struct { // ContextEfficiency is the percentage utilization (0-100). ContextEfficiency float64 `json:"context_efficiency,omitempty"` + + // --- Available commands fields (for "available_commands" type) --- + + // AvailableCommands contains the slash commands available from the agent. + AvailableCommands []AvailableCommand `json:"available_commands,omitempty"` } // PlanEntry represents an entry in the agent's execution plan. @@ -166,3 +174,12 @@ type PlanEntry struct { // Priority indicates relative importance. Priority string `json:"priority,omitempty"` } + +// AvailableCommand represents a slash command available from the agent. +type AvailableCommand struct { + // Name is the command name (e.g., "draftpr", "commit"). + Name string `json:"name"` + + // Description is a human-readable description of the command. + Description string `json:"description,omitempty"` +} diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 9551eaa00f..6b47107597 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -128,6 +128,11 @@ const ( ContextWindowUpdated = "context_window.updated" // Context window usage updated ) +// Event types for available commands +const ( + AvailableCommandsUpdated = "available_commands.updated" // Available slash commands updated +) + // BuildShellOutputSubject creates a shell output subject for a specific session func BuildShellOutputSubject(sessionID string) string { return ShellOutput + "." + sessionID @@ -229,3 +234,13 @@ func BuildContextWindowWildcardSubject() string { return ContextWindowUpdated + ".*" } +// BuildAvailableCommandsSubject creates an available commands subject for a specific session +func BuildAvailableCommandsSubject(sessionID string) string { + return AvailableCommandsUpdated + "." + sessionID +} + +// BuildAvailableCommandsWildcardSubject creates a wildcard subscription for all available commands events +func BuildAvailableCommandsWildcardSubject() string { + return AvailableCommandsUpdated + ".*" +} + diff --git a/apps/backend/internal/gateway/websocket/session_notifications.go b/apps/backend/internal/gateway/websocket/session_notifications.go index 96addc64d9..9d615d0274 100644 --- a/apps/backend/internal/gateway/websocket/session_notifications.go +++ b/apps/backend/internal/gateway/websocket/session_notifications.go @@ -47,6 +47,7 @@ func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventB b.subscribe(eventBus, events.BuildShellExitWildcardSubject(), ws.ActionSessionShellOutput) b.subscribe(eventBus, events.BuildProcessOutputWildcardSubject(), ws.ActionSessionProcessOutput) b.subscribe(eventBus, events.BuildProcessStatusWildcardSubject(), ws.ActionSessionProcessStatus) + b.subscribe(eventBus, events.BuildAvailableCommandsWildcardSubject(), ws.ActionSessionAvailableCommands) go func() { <-ctx.Done() diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index 6fc558a3de..2e2ce56982 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -460,9 +460,15 @@ func (s *Service) handleReviewStepRollback(ctx context.Context, taskID, sessionI func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEventData) { s.logger.Warn("handling agent failed", zap.String("task_id", data.TaskID), + zap.String("session_id", data.SessionID), zap.String("agent_execution_id", data.AgentExecutionID), zap.String("error_message", data.ErrorMessage)) + // Update session state to FAILED + if data.SessionID != "" { + s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, models.TaskSessionStateFailed, data.ErrorMessage, false) + } + // Trigger retry logic s.scheduler.HandleTaskCompleted(data.TaskID, false) if !s.scheduler.RetryTask(data.TaskID) { @@ -479,6 +485,14 @@ func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEvent s.logger.Info("task moved to REVIEW state after failure (for user review)", zap.String("task_id", data.TaskID)) } + } else { + // If retry is triggered, also move task to REVIEW state + // The retry will start a new agent when the task is re-launched + if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil { + s.logger.Error("failed to update task state to REVIEW after failure (with retry pending)", + zap.String("task_id", data.TaskID), + zap.Error(err)) + } } } @@ -617,6 +631,19 @@ func (s *Service) handleAgentStreamEvent(ctx context.Context, payload *lifecycle } } + case "available_commands": + // Handle available commands events - broadcast to WebSocket for frontend + if sessionID != "" && s.eventBus != nil && len(payload.Data.AvailableCommands) > 0 { + eventPayload := lifecycle.AvailableCommandsEventPayload{ + TaskID: taskID, + SessionID: sessionID, + AgentID: payload.AgentID, + AvailableCommands: payload.Data.AvailableCommands, + } + subject := events.BuildAvailableCommandsSubject(sessionID) + _ = s.eventBus.Publish(ctx, subject, bus.NewEvent(events.AvailableCommandsUpdated, "orchestrator", eventPayload)) + } + case "log": // Handle log events - store agent log messages to the database if sessionID != "" && s.messageCreator != nil { diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 4146829e20..a30ceb699f 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -27,6 +27,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator/watcher" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" + v1 "github.com/kandev/kandev/pkg/api/v1" ) // Common errors @@ -464,17 +465,33 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) { previousState := session.State - // Handle sessions in terminal states - clean up executor record + // Handle sessions in terminal states - clean up executor record and fix task state switch previousState { case models.TaskSessionStateCompleted, models.TaskSessionStateFailed, models.TaskSessionStateCancelled: s.logger.Info("session in terminal state; cleaning up executor record", zap.String("session_id", sessionID), + zap.String("task_id", session.TaskID), zap.String("state", string(previousState))) if err := s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID); err != nil { s.logger.Warn("failed to remove executor record for terminal session", zap.String("session_id", sessionID), zap.Error(err)) } + + // If session failed, ensure task is in REVIEW state (not stuck IN_PROGRESS) + if previousState == models.TaskSessionStateFailed && session.TaskID != "" { + task, taskErr := s.taskRepo.GetTask(ctx, session.TaskID) + if taskErr == nil && task.State == v1.TaskStateInProgress { + s.logger.Info("fixing task state: session failed but task still IN_PROGRESS", + zap.String("task_id", session.TaskID), + zap.String("session_id", sessionID)) + if updateErr := s.taskRepo.UpdateTaskState(ctx, session.TaskID, v1.TaskStateReview); updateErr != nil { + s.logger.Warn("failed to update task state to REVIEW", + zap.String("task_id", session.TaskID), + zap.Error(updateErr)) + } + } + } continue } diff --git a/apps/backend/pkg/claudecode/client.go b/apps/backend/pkg/claudecode/client.go index c196250348..0aac177482 100644 --- a/apps/backend/pkg/claudecode/client.go +++ b/apps/backend/pkg/claudecode/client.go @@ -7,7 +7,9 @@ import ( "fmt" "io" "sync" + "time" + "github.com/google/uuid" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) @@ -19,6 +21,11 @@ type RequestHandler func(requestID string, req *ControlRequest) // MessageHandler handles streaming messages from Claude Code CLI. type MessageHandler func(msg *CLIMessage) +// pendingRequest tracks a control request waiting for a response. +type pendingRequest struct { + ch chan *IncomingControlResponse +} + // Client handles Claude Code CLI communication over stdin/stdout streams. // It reads streaming JSON from stdout and writes control messages to stdin. type Client struct { @@ -30,6 +37,10 @@ type Client struct { requestHandler RequestHandler messageHandler MessageHandler + // Pending control requests (requests we sent, waiting for responses) + pendingRequests map[string]*pendingRequest + pendingRequestsMu sync.Mutex + // Synchronization mu sync.RWMutex done chan struct{} @@ -38,10 +49,11 @@ type Client struct { // NewClient creates a new Claude Code CLI client. func NewClient(stdin io.Writer, stdout io.Reader, log *logger.Logger) *Client { return &Client{ - stdin: stdin, - stdout: stdout, - logger: log.WithFields(zap.String("component", "claudecode-client")), - done: make(chan struct{}), + stdin: stdin, + stdout: stdout, + logger: log.WithFields(zap.String("component", "claudecode-client")), + done: make(chan struct{}), + pendingRequests: make(map[string]*pendingRequest), } } @@ -60,8 +72,11 @@ func (c *Client) SetMessageHandler(handler MessageHandler) { } // Start begins reading from stdout in a goroutine. -func (c *Client) Start(ctx context.Context) { - go c.readLoop(ctx) +// Returns a channel that is closed when the read loop is ready. +func (c *Client) Start(ctx context.Context) <-chan struct{} { + ready := make(chan struct{}) + go c.readLoop(ctx, ready) + return ready } // Stop stops the client and closes the done channel. @@ -77,6 +92,62 @@ func (c *Client) Stop() { } } +// Initialize sends the initialize control request to Claude Code CLI and waits for the response. +// This must be called in streaming mode (input-format=stream-json) to get slash commands. +func (c *Client) Initialize(ctx context.Context, timeout time.Duration) (*InitializeResponseData, error) { + requestID := uuid.New().String() + + // Create pending request channel + pending := &pendingRequest{ + ch: make(chan *IncomingControlResponse, 1), + } + + c.pendingRequestsMu.Lock() + c.pendingRequests[requestID] = pending + c.pendingRequestsMu.Unlock() + + defer func() { + c.pendingRequestsMu.Lock() + delete(c.pendingRequests, requestID) + c.pendingRequestsMu.Unlock() + }() + + // Send initialize control request + req := &SDKControlRequest{ + Type: MessageTypeControlRequest, + RequestID: requestID, + Request: SDKControlRequestBody{ + Subtype: SubtypeInitialize, + Hooks: nil, // We don't use SDK hooks + }, + } + + c.logger.Info("sending initialize control request", zap.String("request_id", requestID)) + + if err := c.send(req); err != nil { + return nil, fmt.Errorf("failed to send initialize request: %w", err) + } + + // Wait for response with timeout + c.logger.Info("waiting for initialize response", zap.Duration("timeout", timeout)) + select { + case <-ctx.Done(): + c.logger.Warn("initialize cancelled by context") + return nil, ctx.Err() + case <-time.After(timeout): + c.logger.Warn("initialize request timed out", zap.Duration("timeout", timeout)) + return nil, fmt.Errorf("initialize request timed out after %v", timeout) + case resp := <-pending.ch: + if resp.Subtype == "error" { + return nil, fmt.Errorf("initialize failed: %s", resp.Error) + } + c.logger.Info("initialize response received", + zap.Int("commands", len(resp.Response.Commands)), + zap.Int("agents", len(resp.Response.Agents))) + return resp.Response, nil + } +} + // SendControlRequest sends a control request to Claude Code CLI. func (c *Client) SendControlRequest(req *SDKControlRequest) error { return c.send(req) @@ -113,12 +184,16 @@ func (c *Client) send(msg any) error { return nil } -func (c *Client) readLoop(ctx context.Context) { +func (c *Client) readLoop(ctx context.Context, ready chan<- struct{}) { scanner := bufio.NewScanner(c.stdout) // Allow for large JSON messages (up to 10MB) buf := make([]byte, 0, 64*1024) scanner.Buffer(buf, 10*1024*1024) + // Signal that we're ready to read + c.logger.Info("claudecode: read loop starting") + close(ready) + for scanner.Scan() { select { case <-ctx.Done(): @@ -142,6 +217,10 @@ func (c *Client) readLoop(ctx context.Context) { } func (c *Client) handleLine(line []byte) { + // Log all incoming lines for debugging + c.logger.Debug("claudecode: received raw line", + zap.String("line", string(line))) + // First, parse the basic structure to determine message type var msg CLIMessage if err := json.Unmarshal(line, &msg); err != nil { @@ -149,16 +228,24 @@ func (c *Client) handleLine(line []byte) { return } - c.logger.Debug("claudecode: received message", + c.logger.Debug("claudecode: parsed message", zap.String("type", msg.Type), - zap.String("request_id", msg.RequestID)) + zap.String("request_id", msg.RequestID), + zap.Bool("has_response", msg.Response != nil)) - // Handle control requests specially - they need immediate response + // Handle control requests (from Claude to us, e.g., permission requests) if msg.Type == MessageTypeControlRequest && msg.Request != nil { c.handleControlRequest(msg.RequestID, msg.Request) return } + // Handle control responses (from Claude back to us, e.g., initialize response) + // Note: request_id is inside the response object, not at the message level + if msg.Type == MessageTypeControlResponse && msg.Response != nil { + c.handleControlResponse(msg.Response) + return + } + // Forward other messages to the message handler c.mu.RLock() handler := c.messageHandler @@ -195,3 +282,29 @@ func (c *Client) handleControlRequest(requestID string, req *ControlRequest) { } } } + +func (c *Client) handleControlResponse(resp *IncomingControlResponse) { + requestID := resp.RequestID + + c.pendingRequestsMu.Lock() + pending, ok := c.pendingRequests[requestID] + c.pendingRequestsMu.Unlock() + + if !ok { + c.logger.Warn("received control response for unknown request", + zap.String("request_id", requestID), + zap.String("subtype", resp.Subtype)) + return + } + + c.logger.Info("received control response", + zap.String("request_id", requestID), + zap.String("subtype", resp.Subtype)) + + // Send response to waiting goroutine + select { + case pending.ch <- resp: + default: + c.logger.Warn("pending request channel full", zap.String("request_id", requestID)) + } +} diff --git a/apps/backend/pkg/claudecode/client_test.go b/apps/backend/pkg/claudecode/client_test.go index f743f3deeb..d10ce5fbff 100644 --- a/apps/backend/pkg/claudecode/client_test.go +++ b/apps/backend/pkg/claudecode/client_test.go @@ -278,3 +278,63 @@ func TestClient_InvalidJSON(t *testing.T) { t.Errorf("count = %d, want 1", count) } } + +func TestClient_HandleControlResponse(t *testing.T) { + // Simulate a control response coming back from Claude Code CLI + responseJSON := `{"type":"control_response","response":{"subtype":"success","request_id":"test-req-123","response":{"commands":[{"name":"cost","description":"Show cost"}],"agents":["Bash"]}}}` + "\n" + + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(responseJSON), newTestLogger()) + + // Register a pending request + pending := &pendingRequest{ + ch: make(chan *IncomingControlResponse, 1), + } + client.pendingRequestsMu.Lock() + client.pendingRequests["test-req-123"] = pending + client.pendingRequestsMu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + client.Start(ctx) + + // Wait for the response + select { + case resp := <-pending.ch: + if resp.Subtype != "success" { + t.Errorf("Subtype = %q, want %q", resp.Subtype, "success") + } + if resp.RequestID != "test-req-123" { + t.Errorf("RequestID = %q, want %q", resp.RequestID, "test-req-123") + } + if resp.Response == nil { + t.Fatal("Response is nil") + } + if len(resp.Response.Commands) != 1 { + t.Errorf("Commands count = %d, want %d", len(resp.Response.Commands), 1) + } + if resp.Response.Commands[0].Name != "cost" { + t.Errorf("Commands[0].Name = %q, want %q", resp.Response.Commands[0].Name, "cost") + } + case <-time.After(50 * time.Millisecond): + t.Error("timeout waiting for control response") + } +} + +func TestClient_HandleControlResponse_UnknownRequest(t *testing.T) { + // Simulate a control response for an unknown request ID (should be ignored) + responseJSON := `{"type":"control_response","response":{"subtype":"success","request_id":"unknown-req","response":{"commands":[]}}}` + "\n" + + var buf bytes.Buffer + client := NewClient(&buf, strings.NewReader(responseJSON), newTestLogger()) + + // Don't register any pending request - response should be ignored + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + client.Start(ctx) + time.Sleep(50 * time.Millisecond) + + // Test passes if no panic occurs - the unknown response is just logged and ignored +} diff --git a/apps/backend/pkg/claudecode/types.go b/apps/backend/pkg/claudecode/types.go index 782b729b20..26dd7aca45 100644 --- a/apps/backend/pkg/claudecode/types.go +++ b/apps/backend/pkg/claudecode/types.go @@ -45,16 +45,20 @@ const ( // CLIMessage represents messages from Claude Code CLI stdout. // The message type determines which fields are populated. type CLIMessage struct { - // Type is the message type (system, assistant, result, control_request, etc.) + // Type is the message type (system, assistant, result, control_request, control_response, etc.) Type string `json:"type"` - // For control_request messages + // For control_request messages (from Claude to us) RequestID string `json:"request_id,omitempty"` Request *ControlRequest `json:"request,omitempty"` + // For control_response messages (from Claude back to us after we send a control_request) + Response *IncomingControlResponse `json:"response,omitempty"` + // For system messages - SessionID string `json:"session_id,omitempty"` - SessionStatus string `json:"session_status,omitempty"` + SessionID string `json:"session_id,omitempty"` + SessionStatus string `json:"session_status,omitempty"` + SlashCommands []string `json:"slash_commands,omitempty"` // System message uses string array // For subagent context (when running inside a Task tool call) ParentToolUseID string `json:"parent_tool_use_id,omitempty"` @@ -80,12 +84,40 @@ type CLIMessage struct { } // AssistantMessage contains the assistant's response content. +// Note: For user messages (type="user"), Content may be a plain string instead of []ContentBlock. +// Use RawContent and GetContentBlocks()/GetContentString() for flexible parsing. type AssistantMessage struct { - Role string `json:"role"` - Content []ContentBlock `json:"content,omitempty"` - Model string `json:"model,omitempty"` - StopReason string `json:"stop_reason,omitempty"` - Usage *Usage `json:"usage,omitempty"` + Role string `json:"role"` + Content json.RawMessage `json:"content,omitempty"` // Can be string or []ContentBlock + Model string `json:"model,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + Usage *Usage `json:"usage,omitempty"` +} + +// GetContentBlocks attempts to parse Content as []ContentBlock. +// Returns nil if Content is a string or cannot be parsed. +func (m *AssistantMessage) GetContentBlocks() []ContentBlock { + if len(m.Content) == 0 { + return nil + } + var blocks []ContentBlock + if err := json.Unmarshal(m.Content, &blocks); err != nil { + return nil + } + return blocks +} + +// GetContentString attempts to parse Content as a plain string. +// Returns empty string if Content is []ContentBlock or cannot be parsed. +func (m *AssistantMessage) GetContentString() string { + if len(m.Content) == 0 { + return "" + } + var s string + if err := json.Unmarshal(m.Content, &s); err != nil { + return "" + } + return s } // ContentBlock represents a block of content in an assistant message. @@ -190,6 +222,38 @@ type ControlResponseMessage struct { Response *ControlResponse `json:"response"` } +// IncomingControlResponse represents a control_response message from Claude Code CLI +// (response to a control_request we sent, like initialize). +type IncomingControlResponse struct { + // Subtype is the response type (success, error) + Subtype string `json:"subtype"` + + // RequestID is the ID of the control_request this responds to + RequestID string `json:"request_id"` + + // Response contains the response data (e.g., slash_commands for initialize) + Response *InitializeResponseData `json:"response,omitempty"` + + // Error message if subtype is "error" + Error string `json:"error,omitempty"` +} + +// InitializeResponseData contains the response data from an initialize control request. +type InitializeResponseData struct { + // Commands are the available commands (Claude Code uses "commands" not "slash_commands") + Commands []Command `json:"commands,omitempty"` + + // Agents are the available agent names + Agents []string `json:"agents,omitempty"` +} + +// Command represents an available command from the initialize response. +type Command struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ArgumentHint string `json:"argumentHint,omitempty"` +} + // ControlResponse is the response to a control request. type ControlResponse struct { // Subtype is the response type (success, error) diff --git a/apps/backend/pkg/claudecode/types_test.go b/apps/backend/pkg/claudecode/types_test.go index 39fa816aaa..e3cd108692 100644 --- a/apps/backend/pkg/claudecode/types_test.go +++ b/apps/backend/pkg/claudecode/types_test.go @@ -289,3 +289,154 @@ func TestModelUsageStats_ContextWindow(t *testing.T) { t.Errorf("ContextWindow = %v, want nil", stats2.ContextWindow) } } + +func TestAssistantMessage_GetContentBlocks(t *testing.T) { + tests := []struct { + name string + content string + wantCount int + wantType string + }{ + { + name: "array of content blocks", + content: `[{"type":"text","text":"Hello"},{"type":"text","text":"World"}]`, + wantCount: 2, + wantType: "text", + }, + { + name: "single content block", + content: `[{"type":"thinking","thinking":"Let me think..."}]`, + wantCount: 1, + wantType: "thinking", + }, + { + name: "empty array", + content: `[]`, + wantCount: 0, + wantType: "", + }, + { + name: "string content (not blocks)", + content: `"This is a string"`, + wantCount: 0, + wantType: "", + }, + { + name: "empty content", + content: ``, + wantCount: 0, + wantType: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := &AssistantMessage{ + Content: json.RawMessage(tt.content), + } + blocks := msg.GetContentBlocks() + if len(blocks) != tt.wantCount { + t.Errorf("GetContentBlocks() returned %d blocks, want %d", len(blocks), tt.wantCount) + } + if tt.wantCount > 0 && blocks[0].Type != tt.wantType { + t.Errorf("GetContentBlocks()[0].Type = %q, want %q", blocks[0].Type, tt.wantType) + } + }) + } +} + +func TestAssistantMessage_GetContentString(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "plain string content", + content: `"Hello, World!"`, + want: "Hello, World!", + }, + { + name: "string with local-command-stdout tags", + content: `"Command output here"`, + want: "Command output here", + }, + { + name: "empty string", + content: `""`, + want: "", + }, + { + name: "array content (not string)", + content: `[{"type":"text","text":"Hello"}]`, + want: "", + }, + { + name: "empty content", + content: ``, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := &AssistantMessage{ + Content: json.RawMessage(tt.content), + } + got := msg.GetContentString() + if got != tt.want { + t.Errorf("GetContentString() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIncomingControlResponse_JSONParsing(t *testing.T) { + // Test successful initialize response + jsonStr := `{ + "subtype": "success", + "request_id": "req-123", + "response": { + "commands": [ + {"name": "cost", "description": "Show cost"}, + {"name": "context", "description": "Show context"} + ], + "agents": ["Bash", "Explore"] + } + }` + var resp IncomingControlResponse + if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil { + t.Fatalf("failed to parse: %v", err) + } + if resp.Subtype != "success" { + t.Errorf("Subtype = %q, want %q", resp.Subtype, "success") + } + if resp.RequestID != "req-123" { + t.Errorf("RequestID = %q, want %q", resp.RequestID, "req-123") + } + if resp.Response == nil { + t.Fatal("Response is nil") + } + if len(resp.Response.Commands) != 2 { + t.Errorf("Commands count = %d, want %d", len(resp.Response.Commands), 2) + } + if resp.Response.Commands[0].Name != "cost" { + t.Errorf("Commands[0].Name = %q, want %q", resp.Response.Commands[0].Name, "cost") + } + if len(resp.Response.Agents) != 2 { + t.Errorf("Agents count = %d, want %d", len(resp.Response.Agents), 2) + } + + // Test error response + errorJSON := `{"subtype": "error", "request_id": "req-456", "error": "Something went wrong"}` + var errorResp IncomingControlResponse + if err := json.Unmarshal([]byte(errorJSON), &errorResp); err != nil { + t.Fatalf("failed to parse error response: %v", err) + } + if errorResp.Subtype != "error" { + t.Errorf("Subtype = %q, want %q", errorResp.Subtype, "error") + } + if errorResp.Error != "Something went wrong" { + t.Errorf("Error = %q, want %q", errorResp.Error, "Something went wrong") + } +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index ed24d45b25..be90e909f4 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -141,8 +141,9 @@ const ( ActionSessionAgentctlStarting = "session.agentctl_starting" ActionSessionAgentctlReady = "session.agentctl_ready" ActionSessionAgentctlError = "session.agentctl_error" - ActionSessionTurnStarted = "session.turn.started" - ActionSessionTurnCompleted = "session.turn.completed" + ActionSessionTurnStarted = "session.turn.started" + ActionSessionTurnCompleted = "session.turn.completed" + ActionSessionAvailableCommands = "session.available_commands" ActionInputRequested = "input.requested" ActionRepositoryCreated = "repository.created" ActionRepositoryUpdated = "repository.updated" diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index 770d1a4aee..f7e887abd1 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -208,8 +208,19 @@ export function ChatInputContainer({ // Mention menu hook const mention = useInlineMention(inputRef, value, setValue, sessionId); + // Handler for agent slash commands - sends /{commandName} as a message + const handleAgentCommand = useCallback( + (commandName: string) => { + onSubmit(`/${commandName}`); + }, + [onSubmit] + ); + // Slash command menu hook - const slash = useInlineSlash(inputRef, value, setValue, onPlanModeChange); + const slash = useInlineSlash(inputRef, value, setValue, { + sessionId, + onAgentCommand: handleAgentCommand, + }); // Use refs for menu state to keep handleSubmit stable const menuStateRef = useRef({ mentionOpen: mention.isOpen, slashOpen: slash.isOpen }); diff --git a/apps/web/components/task/chat/mention-menu.tsx b/apps/web/components/task/chat/mention-menu.tsx index 23f3c15a65..f04a583a84 100644 --- a/apps/web/components/task/chat/mention-menu.tsx +++ b/apps/web/components/task/chat/mention-menu.tsx @@ -1,10 +1,8 @@ 'use client'; -import { useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; import { IconAt, IconFile, IconFolder } from '@tabler/icons-react'; -import { cn } from '@/lib/utils'; import type { MentionItem } from '@/hooks/use-inline-mention'; +import { PopupMenu, PopupMenuItem, useMenuItemRefs } from './popup-menu'; type MentionMenuProps = { isOpen: boolean; @@ -18,9 +16,6 @@ type MentionMenuProps = { setSelectedIndex: (index: number) => void; }; -const MENU_HEIGHT = 280; -const MENU_WIDTH = 320; - // Extract filename and parent path from a full path function parseFilePath(filePath: string): { name: string; parent: string } { const parts = filePath.split('/'); @@ -36,6 +31,24 @@ function isDirectory(filePath: string): boolean { return !name.includes('.'); } +// Get the appropriate icon for an item +function getItemIcon(item: MentionItem) { + if (item.type === 'prompt') { + return ; + } + const isDir = isDirectory(item.label); + return isDir ? : ; +} + +// Get the label and description for an item +function getItemDisplay(item: MentionItem): { label: string; description?: string } { + if (item.type === 'prompt') { + return { label: item.label, description: item.description }; + } + const { name, parent } = parseFilePath(item.label); + return { label: name, description: parent || undefined }; +} + export function MentionMenu({ isOpen, isLoading, @@ -47,152 +60,39 @@ export function MentionMenu({ onClose, setSelectedIndex, }: MentionMenuProps) { - const menuRef = useRef(null); - const itemRefs = useRef>(new Map()); + const { setItemRef } = useMenuItemRefs(selectedIndex); - // Scroll selected item into view - useEffect(() => { - const selectedItem = itemRefs.current.get(selectedIndex); - if (selectedItem) { - selectedItem.scrollIntoView({ block: 'nearest' }); - } - }, [selectedIndex]); - - // Close on click outside - useEffect(() => { - if (!isOpen) return; - - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - onClose(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [isOpen, onClose]); - - if (!isOpen || !position) { - return null; - } - - // Calculate position (above cursor) - const menuStyle: React.CSSProperties = { - position: 'fixed', - left: Math.max(8, Math.min(position.x, window.innerWidth - MENU_WIDTH - 8)), - bottom: window.innerHeight - position.y + 8, - width: MENU_WIDTH, - maxHeight: MENU_HEIGHT, - zIndex: 50, - }; - - // Group items by type - const prompts = items.filter((item) => item.type === 'prompt'); - const files = items.filter((item) => item.type === 'file'); - - // Calculate indices for sections - let currentIndex = 0; - - const menu = ( -
- {/* Header */} -
- Mention files, prompts -
- - {/* Content */} -
- {items.length === 0 ? ( -
- {isLoading ? 'Loading...' : query ? 'No results found' : 'Type to search...'} -
- ) : ( - <> - {prompts.length > 0 && ( -
- {prompts.map((item) => { - const index = currentIndex++; - return ( - - ); - })} -
- )} - - {files.length > 0 && ( -
- {files.map((item) => { - const index = currentIndex++; - const { name, parent } = parseFilePath(item.label); - const isDir = isDirectory(item.label); - - return ( - - ); - })} -
- )} - - )} -
+ const emptyState = ( +
+ {isLoading ? 'Loading...' : query ? 'No results found' : 'Type to search...'}
); - // Render via portal to escape any overflow containers - if (typeof document === 'undefined') return null; - return createPortal(menu, document.body); + return ( + 0} + emptyState={emptyState} + > + {items.map((item, index) => { + const { label, description } = getItemDisplay(item); + return ( + onSelect(item)} + onMouseEnter={() => setSelectedIndex(index)} + itemRef={setItemRef(index)} + /> + ); + })} + + ); } diff --git a/apps/web/components/task/chat/popup-menu.tsx b/apps/web/components/task/chat/popup-menu.tsx new file mode 100644 index 0000000000..ab3e38a87b --- /dev/null +++ b/apps/web/components/task/chat/popup-menu.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { useEffect, useRef, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { cn } from '@/lib/utils'; + +const MENU_HEIGHT = 280; +const MENU_MAX_WIDTH = 700; + +export type PopupMenuProps = { + isOpen: boolean; + position: { x: number; y: number } | null; + title: string; + selectedIndex: number; + onClose: () => void; + children: ReactNode; + emptyState?: ReactNode; + hasItems?: boolean; +}; + +export function PopupMenu({ + isOpen, + position, + title, + onClose, + children, + emptyState, + hasItems = true, +}: PopupMenuProps) { + const menuRef = useRef(null); + + // Close on click outside + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen, onClose]); + + if (!isOpen || !position) { + return null; + } + + // Calculate position (above cursor) with content-based width + const menuStyle: React.CSSProperties = { + position: 'fixed', + left: Math.max(8, position.x), + bottom: window.innerHeight - position.y + 8, + maxWidth: Math.min(MENU_MAX_WIDTH, window.innerWidth - position.x - 8), + maxHeight: MENU_HEIGHT, + zIndex: 50, + }; + + const menu = ( +
+ {/* Header */} +
+ {title} +
+ + {/* Content */} +
+ {hasItems ? children : emptyState} +
+
+ ); + + // Render via portal to escape any overflow containers + if (typeof document === 'undefined') return null; + return createPortal(menu, document.body); +} + +export type PopupMenuItemProps = { + icon: ReactNode; + label: string; + description?: string; + isSelected: boolean; + onClick: () => void; + onMouseEnter: () => void; + itemRef?: (el: HTMLButtonElement | null) => void; +}; + +export function PopupMenuItem({ + icon, + label, + description, + isSelected, + onClick, + onMouseEnter, + itemRef, +}: PopupMenuItemProps) { + return ( + + ); +} + +// Hook for scroll-into-view behavior +export function useMenuItemRefs(selectedIndex: number) { + const itemRefs = useRef>(new Map()); + + useEffect(() => { + const selectedItem = itemRefs.current.get(selectedIndex); + if (selectedItem) { + selectedItem.scrollIntoView({ block: 'nearest' }); + } + }, [selectedIndex]); + + const setItemRef = (index: number) => (el: HTMLButtonElement | null) => { + if (el) { + itemRefs.current.set(index, el); + } + }; + + return { setItemRef }; +} diff --git a/apps/web/components/task/chat/slash-command-menu.tsx b/apps/web/components/task/chat/slash-command-menu.tsx index 523d0cc804..8d4292fa20 100644 --- a/apps/web/components/task/chat/slash-command-menu.tsx +++ b/apps/web/components/task/chat/slash-command-menu.tsx @@ -1,10 +1,8 @@ 'use client'; -import { useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { IconCommand } from '@tabler/icons-react'; -import { cn } from '@/lib/utils'; +import { IconRobot } from '@tabler/icons-react'; import type { SlashCommand } from '@/hooks/use-inline-slash'; +import { PopupMenu, PopupMenuItem, useMenuItemRefs } from './popup-menu'; type SlashCommandMenuProps = { isOpen: boolean; @@ -16,9 +14,6 @@ type SlashCommandMenuProps = { setSelectedIndex: (index: number) => void; }; -const MENU_HEIGHT = 280; -const MENU_WIDTH = 320; - export function SlashCommandMenu({ isOpen, position, @@ -28,86 +23,32 @@ export function SlashCommandMenu({ onClose, setSelectedIndex, }: SlashCommandMenuProps) { - const menuRef = useRef(null); - const itemRefs = useRef>(new Map()); + const { setItemRef } = useMenuItemRefs(selectedIndex); - // Scroll selected item into view - useEffect(() => { - const selectedItem = itemRefs.current.get(selectedIndex); - if (selectedItem) { - selectedItem.scrollIntoView({ block: 'nearest' }); - } - }, [selectedIndex]); - - // Close on click outside - useEffect(() => { - if (!isOpen) return; - - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - onClose(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [isOpen, onClose]); - - if (!isOpen || !position || commands.length === 0) { + if (commands.length === 0) { return null; } - // Calculate position (above cursor) - const menuStyle: React.CSSProperties = { - position: 'fixed', - left: Math.max(8, Math.min(position.x - 16, window.innerWidth - MENU_WIDTH - 8)), - bottom: window.innerHeight - position.y + 8, - width: MENU_WIDTH, - maxHeight: MENU_HEIGHT, - zIndex: 50, - }; - - const menu = ( -
- {/* Header */} -
- Commands -
- - {/* Content */} -
- {commands.map((command, index) => ( - - ))} -
-
+ {commands.map((command, index) => ( + } + label={command.label} + description={command.description} + isSelected={selectedIndex === index} + onClick={() => onSelect(command)} + onMouseEnter={() => setSelectedIndex(index)} + itemRef={setItemRef(index)} + /> + ))} + ); - - // Render via portal to escape any overflow containers - if (typeof document === 'undefined') return null; - return createPortal(menu, document.body); } diff --git a/apps/web/components/task/markdown-editor.tsx b/apps/web/components/task/markdown-editor.tsx index b574a466e6..3473b189be 100644 --- a/apps/web/components/task/markdown-editor.tsx +++ b/apps/web/components/task/markdown-editor.tsx @@ -86,9 +86,9 @@ const mermaidCodeBlockView = $view(codeBlockSchema.node, () => { // Render the mermaid diagram const id = `mermaid-${++mermaidIdCounter}`; - mermaid.render(id, currentContent).then(({ svg }) => { + mermaid.render(id, currentContent).then(({ svg }: { svg: string }) => { dom.innerHTML = svg; - }).catch((err) => { + }).catch((err: Error) => { dom.innerHTML = `
Error rendering diagram: ${err.message}
`; }); @@ -103,9 +103,9 @@ const mermaidCodeBlockView = $view(codeBlockSchema.node, () => { if (newContent !== currentContent) { currentContent = newContent; // Update tracked content const newId = `mermaid-${++mermaidIdCounter}`; - mermaid.render(newId, newContent).then(({ svg }) => { + mermaid.render(newId, newContent).then(({ svg }: { svg: string }) => { dom.innerHTML = svg; - }).catch((err) => { + }).catch((err: Error) => { dom.innerHTML = `
Error rendering diagram: ${err.message}
`; }); } diff --git a/apps/web/hooks/use-inline-mention.ts b/apps/web/hooks/use-inline-mention.ts index 1c17a83b49..c233750744 100644 --- a/apps/web/hooks/use-inline-mention.ts +++ b/apps/web/hooks/use-inline-mention.ts @@ -22,6 +22,9 @@ type Position = { // Debounce delay for file search (ms) const FILE_SEARCH_DEBOUNCE = 300; +// Close menu if no results after this many characters +const NO_RESULTS_CLOSE_THRESHOLD = 3; + function isValidMentionTrigger(text: string, pos: number): boolean { if (pos === 0) return true; const charBefore = text[pos - 1]; @@ -180,6 +183,20 @@ export function useInlineMention( }, [filteredItems.length]); /* eslint-enable react-hooks/set-state-in-effect */ + // Auto-close menu when no results and query is long enough + // This prevents showing "No results found" when user is just typing normally + /* eslint-disable react-hooks/set-state-in-effect -- auto-close logic is intentional */ + useEffect(() => { + if (!isOpen || isLoading) return; + + if (filteredItems.length === 0 && query.length >= NO_RESULTS_CLOSE_THRESHOLD) { + setIsOpen(false); + setTriggerStart(-1); + setQuery(''); + } + }, [isOpen, isLoading, filteredItems.length, query.length]); + /* eslint-enable react-hooks/set-state-in-effect */ + // Handle text changes to detect @ trigger const handleChange = useCallback( (newValue: string) => { diff --git a/apps/web/hooks/use-inline-slash.ts b/apps/web/hooks/use-inline-slash.ts index 2cc3a3e237..80f2388f6f 100644 --- a/apps/web/hooks/use-inline-slash.ts +++ b/apps/web/hooks/use-inline-slash.ts @@ -2,12 +2,17 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; import type { RichTextInputHandle } from '@/components/task/chat/rich-text-input'; +import { useAppStore } from '@/components/state-provider'; + +export type SlashCommandAction = 'agent'; export type SlashCommand = { id: string; label: string; description: string; - action: 'plan' | 'todo' | 'review' | 'summarize'; + action: SlashCommandAction; + // For agent commands + agentCommandName?: string; }; type Position = { @@ -15,70 +20,79 @@ type Position = { y: number; }; -const SLASH_COMMANDS: SlashCommand[] = [ - { - id: 'plan', - label: '/plan', - description: 'Start a planning response', - action: 'plan', - }, - { - id: 'todo', - label: '/todo', - description: 'Add a todo list', - action: 'todo', - }, - { - id: 'review', - label: '/review', - description: 'Request a review', - action: 'review', - }, - { - id: 'summarize', - label: '/summarize', - description: 'Summarize the task', - action: 'summarize', - }, -]; - function isValidSlashTrigger(text: string, pos: number): boolean { if (pos === 0) return true; const charBefore = text[pos - 1]; return charBefore === ' ' || charBefore === '\n' || charBefore === '\t'; } -function filterCommands(query: string): SlashCommand[] { - if (!query) return SLASH_COMMANDS; +function filterCommands(query: string, allCommands: SlashCommand[]): SlashCommand[] { + if (!query) return allCommands; const lowerQuery = query.toLowerCase(); - return SLASH_COMMANDS.filter((cmd) => { + return allCommands.filter((cmd) => { const label = cmd.label.toLowerCase(); - return label.startsWith('/' + lowerQuery) || cmd.action.startsWith(lowerQuery); + const cmdName = cmd.agentCommandName?.toLowerCase(); + return label.startsWith('/' + lowerQuery) || cmdName?.startsWith(lowerQuery); }).sort((a, b) => { // Prefer exact prefix matches - const aStartsWithQuery = a.action.toLowerCase().startsWith(lowerQuery); - const bStartsWithQuery = b.action.toLowerCase().startsWith(lowerQuery); + const aName = a.agentCommandName?.toLowerCase(); + const bName = b.agentCommandName?.toLowerCase(); + const aStartsWithQuery = aName?.startsWith(lowerQuery) ?? false; + const bStartsWithQuery = bName?.startsWith(lowerQuery) ?? false; if (aStartsWithQuery && !bStartsWithQuery) return -1; if (!aStartsWithQuery && bStartsWithQuery) return 1; return 0; }); } +type UseInlineSlashOptions = { + sessionId?: string | null; + onAgentCommand?: (commandName: string) => void; +}; + export function useInlineSlash( inputRef: React.RefObject, value: string, onChange: (value: string) => void, - onPlanModeChange?: (enabled: boolean) => void + options?: UseInlineSlashOptions ) { + const { sessionId, onAgentCommand } = options ?? {}; const [isOpen, setIsOpen] = useState(false); const [position, setPosition] = useState(null); const [triggerStart, setTriggerStart] = useState(-1); const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); + // Get agent commands from store + const agentCommands = useAppStore((state) => + sessionId ? state.availableCommands.bySessionId[sessionId] : undefined + ); + + // Convert agent commands to slash commands + // Filter out "bundled" commands (skills) that don't produce visible output + const allCommands = useMemo(() => { + if (!agentCommands || agentCommands.length === 0) { + return []; + } + + return agentCommands + .filter((cmd) => { + // Skip bundled skills - they don't produce output + const desc = cmd.description || ''; + return !desc.includes('(bundled)'); + }) + .map((cmd) => ({ + id: `agent-${cmd.name}`, + label: `/${cmd.name}`, + description: cmd.description || `Run /${cmd.name} command`, + action: 'agent' as const, + agentCommandName: cmd.name, + })); + }, [agentCommands]); + // Filter commands based on query - const filteredCommands = useMemo(() => filterCommands(query), [query]); + const filteredCommands = useMemo(() => filterCommands(query, allCommands), [query, allCommands]); // Reset selected index when commands change /* eslint-disable react-hooks/set-state-in-effect -- resetting selection on items change is intentional */ @@ -143,45 +157,9 @@ export function useInlineSlash( const newValue = value.substring(0, triggerStart) + value.substring(cursorPos); onChange(newValue); - // Execute the command action - switch (command.action) { - case 'plan': - onPlanModeChange?.(true); - break; - case 'todo': - // Insert todo template - const todoText = '## TODO\n- [ ] '; - const todoValue = value.substring(0, triggerStart) + todoText + value.substring(cursorPos); - onChange(todoValue); - requestAnimationFrame(() => { - const newPos = triggerStart + todoText.length; - input.setSelectionRange(newPos, newPos); - input.focus(); - }); - break; - case 'review': - // Insert review request - const reviewText = 'Please review '; - const reviewValue = value.substring(0, triggerStart) + reviewText + value.substring(cursorPos); - onChange(reviewValue); - requestAnimationFrame(() => { - const newPos = triggerStart + reviewText.length; - input.setSelectionRange(newPos, newPos); - input.focus(); - }); - break; - case 'summarize': - // Insert summarize request - const summaryText = 'Please summarize '; - const summaryValue = - value.substring(0, triggerStart) + summaryText + value.substring(cursorPos); - onChange(summaryValue); - requestAnimationFrame(() => { - const newPos = triggerStart + summaryText.length; - input.setSelectionRange(newPos, newPos); - input.focus(); - }); - break; + // Execute the command action - only agent commands are supported now + if (command.agentCommandName && onAgentCommand) { + onAgentCommand(command.agentCommandName); } setIsOpen(false); @@ -193,7 +171,7 @@ export function useInlineSlash( input.focus(); }); }, - [inputRef, triggerStart, value, onChange, onPlanModeChange] + [inputRef, triggerStart, value, onChange, onAgentCommand] ); // Handle keyboard navigation diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 9eeef574f4..fd811b5bd7 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -73,6 +73,8 @@ export type { ContextWindowEntry, ContextWindowState, AgentState, + AvailableCommand, + AvailableCommandsState, } from './session-runtime/types'; export type { PreviewStage, diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index 5b97eb7262..2286f76af2 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -25,6 +25,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = { sessionCommits: { bySessionId: {}, loading: {} }, contextWindow: { bySessionId: {} }, agents: { agents: [] }, + availableCommands: { bySessionId: {} }, }; export const createSessionRuntimeSlice: StateCreator< @@ -129,4 +130,13 @@ export const createSessionRuntimeSlice: StateCreator< // Delete the entry so hook will refetch (undefined triggers fetch, [] does not) delete draft.sessionCommits.bySessionId[sessionId]; }), + // Available commands actions + setAvailableCommands: (sessionId, commands) => + set((draft) => { + draft.availableCommands.bySessionId[sessionId] = commands; + }), + clearAvailableCommands: (sessionId) => + set((draft) => { + delete draft.availableCommands.bySessionId[sessionId]; + }), }); diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 6ef17f1b40..39526bfdf3 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -137,6 +137,15 @@ export type AgentState = { agents: Array<{ id: string; status: 'idle' | 'running' | 'error' }>; }; +export type AvailableCommand = { + name: string; + description?: string; +}; + +export type AvailableCommandsState = { + bySessionId: Record; +}; + export type SessionRuntimeSliceState = { terminal: TerminalState; shell: ShellState; @@ -146,6 +155,7 @@ export type SessionRuntimeSliceState = { sessionCommits: SessionCommitsState; contextWindow: ContextWindowState; agents: AgentState; + availableCommands: AvailableCommandsState; }; export type SessionRuntimeSliceActions = { @@ -172,6 +182,9 @@ export type SessionRuntimeSliceActions = { setSessionCommitsLoading: (sessionId: string, loading: boolean) => void; addSessionCommit: (sessionId: string, commit: SessionCommit) => void; clearSessionCommits: (sessionId: string) => void; + // Available commands actions + setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void; + clearAvailableCommands: (sessionId: string) => void; }; export type SessionRuntimeSlice = SessionRuntimeSliceState & SessionRuntimeSliceActions; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index b4d87bf5e0..2c07d2ea36 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -137,6 +137,7 @@ export type AppState = { sessionCommits: typeof defaultSessionRuntimeState['sessionCommits']; contextWindow: typeof defaultSessionRuntimeState['contextWindow']; agents: typeof defaultSessionRuntimeState['agents']; + availableCommands: typeof defaultSessionRuntimeState['availableCommands']; // UI slice previewPanel: typeof defaultUIState['previewPanel']; @@ -237,6 +238,9 @@ export type AppState = { setTaskPlanLoading: (taskId: string, loading: boolean) => void; setTaskPlanSaving: (taskId: string, saving: boolean) => void; clearTaskPlan: (taskId: string) => void; + // Available commands actions + setAvailableCommands: (sessionId: string, commands: import('./slices/session-runtime/types').AvailableCommand[]) => void; + clearAvailableCommands: (sessionId: string) => void; }; export type AppStore = ReturnType; @@ -277,6 +281,7 @@ const defaultState = { sessionCommits: defaultSessionRuntimeState.sessionCommits, contextWindow: defaultSessionRuntimeState.contextWindow, agents: defaultSessionRuntimeState.agents, + availableCommands: defaultSessionRuntimeState.availableCommands, previewPanel: defaultUIState.previewPanel, rightPanel: defaultUIState.rightPanel, diffs: defaultUIState.diffs, diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index bfc7fcc7cd..9f51d1c4b1 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -31,6 +31,7 @@ export type BackendMessageType = | 'session.agentctl_error' | 'session.turn.started' | 'session.turn.completed' + | 'session.available_commands' | 'executor.created' | 'executor.updated' | 'executor.deleted' @@ -297,6 +298,19 @@ export type TurnEventPayload = { updated_at: string; }; +export type AvailableCommandPayload = { + name: string; + description?: string; +}; + +export type AvailableCommandsPayload = { + task_id: string; + session_id: string; + agent_id: string; + available_commands: AvailableCommandPayload[]; + timestamp: string; +}; + export type TaskPlanEventPayload = { id: string; task_id: string; @@ -340,6 +354,7 @@ export type BackendMessageMap = { 'session.agentctl_error': BackendMessage<'session.agentctl_error', TaskSessionAgentctlPayload>; 'session.turn.started': BackendMessage<'session.turn.started', TurnEventPayload>; 'session.turn.completed': BackendMessage<'session.turn.completed', TurnEventPayload>; + 'session.available_commands': BackendMessage<'session.available_commands', AvailableCommandsPayload>; 'executor.created': BackendMessage<'executor.created', ExecutorPayload>; 'executor.updated': BackendMessage<'executor.updated', ExecutorPayload>; 'executor.deleted': BackendMessage<'executor.deleted', ExecutorPayload>; diff --git a/apps/web/lib/ws/handlers/available-commands.ts b/apps/web/lib/ws/handlers/available-commands.ts new file mode 100644 index 0000000000..18e4c5f151 --- /dev/null +++ b/apps/web/lib/ws/handlers/available-commands.ts @@ -0,0 +1,19 @@ +import type { StoreApi } from 'zustand'; +import type { AppState } from '@/lib/state/store'; +import type { WsHandlers } from '@/lib/ws/handlers/types'; +import type { AvailableCommand } from '@/lib/state/slices/session-runtime/types'; + +export function registerAvailableCommandsHandlers(store: StoreApi): WsHandlers { + return { + 'session.available_commands': (message) => { + const payload = message.payload; + if (!payload?.session_id) { + return; + } + const sessionId = payload.session_id as string; + const commands = (payload.available_commands || []) as AvailableCommand[]; + + store.getState().setAvailableCommands(sessionId, commands); + }, + }; +} diff --git a/apps/web/lib/ws/router.ts b/apps/web/lib/ws/router.ts index aae09996ff..288153bf39 100644 --- a/apps/web/lib/ws/router.ts +++ b/apps/web/lib/ws/router.ts @@ -2,6 +2,7 @@ import type { StoreApi } from 'zustand'; import type { AppState } from '@/lib/state/store'; import { registerAgentsHandlers } from '@/lib/ws/handlers/agents'; import { registerTaskSessionHandlers } from '@/lib/ws/handlers/agent-session'; +import { registerAvailableCommandsHandlers } from '@/lib/ws/handlers/available-commands'; import { registerBoardsHandlers } from '@/lib/ws/handlers/boards'; import { registerMessagesHandlers } from '@/lib/ws/handlers/messages'; @@ -31,6 +32,7 @@ export function registerWsHandlers(store: StoreApi) { ...registerEnvironmentsHandlers(store), ...registerAgentsHandlers(store), ...registerTaskSessionHandlers(store), + ...registerAvailableCommandsHandlers(store), ...registerUsersHandlers(store), ...registerTerminalsHandlers(store), ...registerDiffsHandlers(store),