feat: improved passthrough and workflow (#237)
This commit is contained in:
@@ -201,6 +201,11 @@ func (a *lifecycleAdapter) IsAgentRunningForSession(ctx context.Context, session
|
||||
return a.mgr.IsAgentRunningForSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
// IsPassthroughSession checks if the given session is running in passthrough (PTY) mode.
|
||||
func (a *lifecycleAdapter) IsPassthroughSession(ctx context.Context, sessionID string) bool {
|
||||
return a.mgr.IsPassthroughSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
// ResolveAgentProfile resolves an agent profile ID to profile information
|
||||
func (a *lifecycleAdapter) ResolveAgentProfile(ctx context.Context, profileID string) (*executor.AgentProfileInfo, error) {
|
||||
info, err := a.mgr.ResolveAgentProfile(ctx, profileID)
|
||||
|
||||
@@ -2487,6 +2487,15 @@ func (m *Manager) WritePassthroughStdin(ctx context.Context, sessionID string, d
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsPassthroughSession checks if the given session is running in passthrough (PTY) mode.
|
||||
func (m *Manager) IsPassthroughSession(ctx context.Context, sessionID string) bool {
|
||||
execution, exists := m.executionStore.GetBySessionID(sessionID)
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return execution.PassthroughProcessID != ""
|
||||
}
|
||||
|
||||
// ResizePassthroughPTY resizes the PTY for a passthrough process.
|
||||
// Returns an error if the session is not in passthrough mode or if resizing fails.
|
||||
func (m *Manager) ResizePassthroughPTY(ctx context.Context, sessionID string, cols, rows uint16) error {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
@@ -58,16 +59,12 @@ func TestMemoryEventBus_PublishSubscribe(t *testing.T) {
|
||||
t.Fatalf("Publish failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case e := <-received:
|
||||
if e.ID != event.ID {
|
||||
t.Errorf("Expected event ID %s, got %s", event.ID, e.ID)
|
||||
}
|
||||
if e.Type != event.Type {
|
||||
t.Errorf("Expected event type %s, got %s", event.Type, e.Type)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Timeout waiting for event")
|
||||
e := <-received
|
||||
if e.ID != event.ID {
|
||||
t.Errorf("Expected event ID %s, got %s", event.ID, e.ID)
|
||||
}
|
||||
if e.Type != event.Type {
|
||||
t.Errorf("Expected event type %s, got %s", event.Type, e.Type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +95,6 @@ func TestMemoryEventBus_MultipleSubscribers(t *testing.T) {
|
||||
t.Fatalf("Publish failed: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond) // Allow goroutines to complete
|
||||
|
||||
if atomic.LoadInt32(&count) != 3 {
|
||||
t.Errorf("Expected 3 handlers to be called, got %d", count)
|
||||
}
|
||||
@@ -126,7 +121,6 @@ func TestMemoryEventBus_Unsubscribe(t *testing.T) {
|
||||
if err := bus.Publish(ctx, "test.unsub", event); err != nil {
|
||||
t.Fatalf("Publish failed: %v", err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Unsubscribe
|
||||
if err := sub.Unsubscribe(); err != nil {
|
||||
@@ -140,7 +134,6 @@ func TestMemoryEventBus_Unsubscribe(t *testing.T) {
|
||||
if err := bus.Publish(ctx, "test.unsub", event); err != nil {
|
||||
t.Fatalf("Publish failed: %v", err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if atomic.LoadInt32(&count) != 1 {
|
||||
t.Errorf("Expected 1 handler call, got %d", count)
|
||||
@@ -179,8 +172,6 @@ func TestMemoryEventBus_SingleTokenWildcard(t *testing.T) {
|
||||
t.Fatalf("Publish failed: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if atomic.LoadInt32(&count) != 2 {
|
||||
t.Errorf("Expected 2 events received, got %d", count)
|
||||
}
|
||||
@@ -256,8 +247,6 @@ func TestMemoryEventBus_WildcardNoMatch(t *testing.T) {
|
||||
t.Fatalf("Publish failed: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if atomic.LoadInt32(&count) != 0 {
|
||||
t.Errorf("Expected 0 events (no match), got %d", count)
|
||||
}
|
||||
@@ -294,8 +283,6 @@ func TestMemoryEventBus_ExactMatch(t *testing.T) {
|
||||
t.Fatalf("Publish failed: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if atomic.LoadInt32(&count) != 1 {
|
||||
t.Errorf("Expected 1 event, got %d", count)
|
||||
}
|
||||
@@ -337,8 +324,6 @@ func TestMemoryEventBus_QueueSubscribe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Each event should be handled by exactly one subscriber (round-robin)
|
||||
if atomic.LoadInt32(&count) != 6 {
|
||||
t.Errorf("Expected 6 handler calls, got %d", count)
|
||||
@@ -388,8 +373,6 @@ func TestMemoryEventBus_ConcurrentAccess(t *testing.T) {
|
||||
if publishErrorCount > 0 {
|
||||
t.Errorf("publish errors: %d", publishErrorCount)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond) // Allow handlers to complete
|
||||
|
||||
expectedCount := int32(numGoroutines * eventsPerGoroutine)
|
||||
if atomic.LoadInt32(&receivedCount) != expectedCount {
|
||||
t.Errorf("Expected %d events, got %d", expectedCount, receivedCount)
|
||||
@@ -476,19 +459,21 @@ func TestMemoryEventBus_Request(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMemoryEventBus_RequestTimeout(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
bus := NewMemoryEventBus(log)
|
||||
defer bus.Close()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
bus := NewMemoryEventBus(log)
|
||||
defer bus.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := context.Background()
|
||||
|
||||
// Make a request with no responder
|
||||
request := NewEvent("service.nonexistent", "requester", map[string]interface{}{})
|
||||
// Make a request with no responder
|
||||
request := NewEvent("service.nonexistent", "requester", map[string]interface{}{})
|
||||
|
||||
_, err := bus.Request(ctx, "service.nonexistent", request, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error")
|
||||
}
|
||||
_, err := bus.Request(ctx, "service.nonexistent", request, 100*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewEvent(t *testing.T) {
|
||||
|
||||
@@ -54,7 +54,7 @@ func (g *Gateway) SetupRoutes(router *gin.Engine) {
|
||||
|
||||
// Add dedicated terminal WebSocket route if terminal handler is configured
|
||||
if g.TerminalHandler != nil {
|
||||
router.GET("/xterm.js/:sessionId", g.TerminalHandler.HandleTerminalWS)
|
||||
router.GET("/terminal/:sessionId", g.TerminalHandler.HandleTerminalWS)
|
||||
}
|
||||
|
||||
// Add LSP routes if LSP handler is configured
|
||||
|
||||
@@ -139,11 +139,11 @@ func (w *wsWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleTerminalWS handles WebSocket connections at /xterm.js/:sessionId
|
||||
// HandleTerminalWS handles WebSocket connections at /terminal/:sessionId
|
||||
// This creates a binary WebSocket bridge between xterm.js and the PTY.
|
||||
// Query parameter terminalId determines the type of terminal:
|
||||
// - "default" or empty: Agent passthrough terminal (CLI passthrough mode)
|
||||
// - "shell-1", "shell-2", etc.: Independent user shell terminals
|
||||
// Query parameter "mode" determines the type of terminal:
|
||||
// - "agent": Agent passthrough terminal (CLI passthrough mode)
|
||||
// - "shell": Independent user shell terminal (requires terminalId query param)
|
||||
func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) {
|
||||
sessionID := c.Param("sessionId")
|
||||
if sessionID == "" {
|
||||
@@ -151,25 +151,6 @@ func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
terminalID := c.Query("terminalId")
|
||||
// terminalId is required - "default" is for agent passthrough, anything else is for user shells
|
||||
// Empty terminalId is rejected to prevent accidental fallback behavior
|
||||
if terminalID == "" {
|
||||
h.logger.Warn("terminal WebSocket connection rejected: terminalId query parameter is required (use 'default' for agent passthrough or a unique ID for user shells)",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("raw_query", c.Request.URL.RawQuery),
|
||||
zap.String("remote_addr", c.Request.RemoteAddr))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "terminalId query parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("terminal WebSocket connection request",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("terminal_id", terminalID),
|
||||
zap.Bool("is_agent_passthrough", terminalID == "default"),
|
||||
zap.String("raw_query", c.Request.URL.RawQuery),
|
||||
zap.String("remote_addr", c.Request.RemoteAddr))
|
||||
|
||||
// Get the interactive runner first
|
||||
interactiveRunner := h.lifecycleMgr.GetInteractiveRunner()
|
||||
if interactiveRunner == nil {
|
||||
@@ -178,14 +159,41 @@ func (h *TerminalHandler) HandleTerminalWS(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Route based on terminal ID
|
||||
if terminalID != "default" {
|
||||
// User shell terminal - create independent shell process
|
||||
h.handleUserShellWS(c, sessionID, terminalID, interactiveRunner)
|
||||
return
|
||||
}
|
||||
// Route based on explicit mode parameter
|
||||
mode := c.Query("mode")
|
||||
switch mode {
|
||||
case "agent":
|
||||
h.logger.Info("terminal WebSocket connection request",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("mode", "agent"),
|
||||
zap.String("remote_addr", c.Request.RemoteAddr))
|
||||
h.handleAgentPassthroughWS(c, sessionID, interactiveRunner)
|
||||
|
||||
// Agent passthrough terminal - existing logic
|
||||
case "shell":
|
||||
terminalID := c.Query("terminalId")
|
||||
if terminalID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "terminalId required for shell mode"})
|
||||
return
|
||||
}
|
||||
h.logger.Info("terminal WebSocket connection request",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("mode", "shell"),
|
||||
zap.String("terminal_id", terminalID),
|
||||
zap.String("remote_addr", c.Request.RemoteAddr))
|
||||
h.handleUserShellWS(c, sessionID, terminalID, interactiveRunner)
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "mode query param required: agent or shell"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleAgentPassthroughWS handles WebSocket connections for agent passthrough terminals.
|
||||
// It ensures a passthrough execution exists, upgrades to WebSocket, and bridges I/O.
|
||||
func (h *TerminalHandler) handleAgentPassthroughWS(
|
||||
c *gin.Context,
|
||||
sessionID string,
|
||||
interactiveRunner *process.InteractiveRunner,
|
||||
) {
|
||||
// Ensure passthrough execution exists and is running.
|
||||
// This handles:
|
||||
// 1. Normal case: execution exists with running process
|
||||
|
||||
@@ -426,6 +426,10 @@ func (s *SimulatedAgentManagerClient) ResolveAgentProfile(ctx context.Context, p
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SimulatedAgentManagerClient) IsPassthroughSession(ctx context.Context, sessionID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ORCHESTRATOR TEST SERVER
|
||||
// ============================================
|
||||
@@ -1116,6 +1120,16 @@ func TestOrchestratorStopTask(t *testing.T) {
|
||||
_, err = client.SendRequest("session-sub-1", ws.ActionSessionSubscribe, map[string]string{"session_id": sessionID})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for the session to become active before stopping
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
session, sErr := ts.TaskRepo.GetTaskSession(context.Background(), sessionID)
|
||||
if sErr == nil && session.State == models.TaskSessionStateRunning {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Stop task
|
||||
stopResp, err := client.SendRequest("stop-1", ws.ActionOrchestratorStop, map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
@@ -1126,7 +1140,9 @@ func TestOrchestratorStopTask(t *testing.T) {
|
||||
|
||||
var stopPayload map[string]interface{}
|
||||
require.NoError(t, stopResp.ParsePayload(&stopPayload))
|
||||
assert.True(t, stopPayload["success"].(bool))
|
||||
success, ok := stopPayload["success"].(bool)
|
||||
assert.True(t, ok, "expected 'success' key in stop response, got: %v", stopPayload)
|
||||
assert.True(t, success)
|
||||
}
|
||||
|
||||
func TestOrchestratorPromptTask(t *testing.T) {
|
||||
|
||||
@@ -96,6 +96,11 @@ func (s *Service) handleAgentRunning(ctx context.Context, data watcher.AgentEven
|
||||
return
|
||||
}
|
||||
|
||||
// Process on_turn_start workflow events (step transitions).
|
||||
// For ACP sessions this happens in the message handler before PromptTask;
|
||||
// for passthrough it happens here when the PTY detects user input.
|
||||
s.processOnTurnStart(ctx, data.TaskID, data.SessionID)
|
||||
|
||||
// Move session to running and task to in progress
|
||||
s.setSessionRunning(ctx, data.TaskID, data.SessionID)
|
||||
}
|
||||
@@ -152,85 +157,96 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD
|
||||
zap.Bool("is_queued", queueStatus.IsQueued),
|
||||
zap.Any("message", queueStatus.Message))
|
||||
|
||||
if queuedMsg, exists := s.messageQueue.TakeQueued(ctx, data.SessionID); exists {
|
||||
// Skip if the queued message has empty content (might have been cleared accidentally)
|
||||
if queuedMsg.Content == "" && len(queuedMsg.Attachments) == 0 {
|
||||
s.logger.Warn("skipping empty queued message",
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.String("queue_id", queuedMsg.ID))
|
||||
// Skip queued messages for passthrough sessions — PromptTask uses ACP which
|
||||
// doesn't work for passthrough, and writing to PTY stdin is unreliable.
|
||||
// Check before TakeQueued to avoid destructively removing the message.
|
||||
if s.agentManager.IsPassthroughSession(ctx, data.SessionID) {
|
||||
s.logger.Info("skipping queued message for passthrough session",
|
||||
zap.String("session_id", data.SessionID))
|
||||
return
|
||||
}
|
||||
|
||||
// Still publish status change to clear frontend state
|
||||
s.publishQueueStatusEvent(ctx, data.SessionID)
|
||||
return
|
||||
queuedMsg, exists := s.messageQueue.TakeQueued(ctx, data.SessionID)
|
||||
if !exists {
|
||||
s.logger.Debug("no queued message to execute",
|
||||
zap.String("session_id", data.SessionID))
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if the queued message has empty content (might have been cleared accidentally)
|
||||
if queuedMsg.Content == "" && len(queuedMsg.Attachments) == 0 {
|
||||
s.logger.Warn("skipping empty queued message",
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.String("queue_id", queuedMsg.ID))
|
||||
|
||||
// Still publish status change to clear frontend state
|
||||
s.publishQueueStatusEvent(ctx, data.SessionID)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("auto-executing queued message",
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.String("task_id", queuedMsg.TaskID),
|
||||
zap.String("queue_id", queuedMsg.ID))
|
||||
|
||||
// Publish queue status changed event to notify frontend
|
||||
s.publishQueueStatusEvent(ctx, data.SessionID)
|
||||
|
||||
// Execute the queued message asynchronously
|
||||
go func() {
|
||||
promptCtx := context.Background() // Use a fresh context for async execution
|
||||
|
||||
// Create user message for the queued message (so it appears in chat history)
|
||||
if s.messageCreator != nil {
|
||||
turnID := s.getActiveTurnID(queuedMsg.SessionID)
|
||||
if turnID == "" {
|
||||
// Start a new turn if needed
|
||||
s.startTurnForSession(promptCtx, queuedMsg.SessionID)
|
||||
turnID = s.getActiveTurnID(queuedMsg.SessionID)
|
||||
}
|
||||
|
||||
s.logger.Info("auto-executing queued message",
|
||||
// Note: Attachments will be sent to the agent via PromptTask but not stored in the message
|
||||
// This matches the behavior of direct prompts
|
||||
meta := NewUserMessageMeta().WithPlanMode(queuedMsg.PlanMode)
|
||||
err := s.messageCreator.CreateUserMessage(promptCtx, queuedMsg.TaskID, queuedMsg.Content, queuedMsg.SessionID, turnID, meta.ToMap())
|
||||
if err != nil {
|
||||
s.logger.Error("failed to create user message for queued message",
|
||||
zap.String("session_id", queuedMsg.SessionID),
|
||||
zap.Error(err))
|
||||
// Continue anyway - the prompt should still be sent
|
||||
}
|
||||
}
|
||||
|
||||
// Convert queue attachments to v1 attachments
|
||||
attachments := make([]v1.MessageAttachment, len(queuedMsg.Attachments))
|
||||
for i, att := range queuedMsg.Attachments {
|
||||
attachments[i] = v1.MessageAttachment{
|
||||
Type: att.Type,
|
||||
Data: att.Data,
|
||||
MimeType: att.MimeType,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.PromptTask(promptCtx, queuedMsg.TaskID, queuedMsg.SessionID,
|
||||
queuedMsg.Content, queuedMsg.Model, queuedMsg.PlanMode, attachments)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to execute queued message",
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.String("task_id", queuedMsg.TaskID),
|
||||
zap.String("queue_id", queuedMsg.ID))
|
||||
zap.String("queue_id", queuedMsg.ID),
|
||||
zap.Error(err))
|
||||
|
||||
// Publish queue status changed event to notify frontend
|
||||
s.publishQueueStatusEvent(ctx, data.SessionID)
|
||||
|
||||
// Execute the queued message asynchronously
|
||||
go func() {
|
||||
promptCtx := context.Background() // Use a fresh context for async execution
|
||||
|
||||
// Create user message for the queued message (so it appears in chat history)
|
||||
if s.messageCreator != nil {
|
||||
turnID := s.getActiveTurnID(queuedMsg.SessionID)
|
||||
if turnID == "" {
|
||||
// Start a new turn if needed
|
||||
s.startTurnForSession(promptCtx, queuedMsg.SessionID)
|
||||
turnID = s.getActiveTurnID(queuedMsg.SessionID)
|
||||
}
|
||||
|
||||
// Note: Attachments will be sent to the agent via PromptTask but not stored in the message
|
||||
// This matches the behavior of direct prompts
|
||||
meta := NewUserMessageMeta().WithPlanMode(queuedMsg.PlanMode)
|
||||
err := s.messageCreator.CreateUserMessage(promptCtx, queuedMsg.TaskID, queuedMsg.Content, queuedMsg.SessionID, turnID, meta.ToMap())
|
||||
if err != nil {
|
||||
s.logger.Error("failed to create user message for queued message",
|
||||
zap.String("session_id", queuedMsg.SessionID),
|
||||
zap.Error(err))
|
||||
// Continue anyway - the prompt should still be sent
|
||||
}
|
||||
}
|
||||
|
||||
// Convert queue attachments to v1 attachments
|
||||
attachments := make([]v1.MessageAttachment, len(queuedMsg.Attachments))
|
||||
for i, att := range queuedMsg.Attachments {
|
||||
attachments[i] = v1.MessageAttachment{
|
||||
Type: att.Type,
|
||||
Data: att.Data,
|
||||
MimeType: att.MimeType,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := s.PromptTask(promptCtx, queuedMsg.TaskID, queuedMsg.SessionID,
|
||||
queuedMsg.Content, queuedMsg.Model, queuedMsg.PlanMode, attachments)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to execute queued message",
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.String("task_id", queuedMsg.TaskID),
|
||||
zap.String("queue_id", queuedMsg.ID),
|
||||
zap.Error(err))
|
||||
|
||||
// TODO: Implement dead letter queue for failed queued messages
|
||||
// Currently, failed messages are lost. Consider:
|
||||
// 1. Retry mechanism with exponential backoff
|
||||
// 2. Persist failed messages to database for manual intervention
|
||||
// 3. Notification to user about failed queue execution
|
||||
s.logger.Warn("queued message execution failed - message is lost (no retry/dead letter queue)",
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.String("queue_id", queuedMsg.ID),
|
||||
zap.String("content_preview", queuedMsg.Content[:min(50, len(queuedMsg.Content))]))
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
s.logger.Debug("no queued message to execute",
|
||||
zap.String("session_id", data.SessionID))
|
||||
// TODO: Implement dead letter queue for failed queued messages
|
||||
// Currently, failed messages are lost. Consider:
|
||||
// 1. Retry mechanism with exponential backoff
|
||||
// 2. Persist failed messages to database for manual intervention
|
||||
// 3. Notification to user about failed queue execution
|
||||
s.logger.Warn("queued message execution failed - message is lost (no retry/dead letter queue)",
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.String("queue_id", queuedMsg.ID),
|
||||
zap.String("content_preview", queuedMsg.Content[:min(50, len(queuedMsg.Content))]))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// handleAgentCompleted handles agent completion events
|
||||
@@ -486,6 +502,9 @@ func (s *Service) ProcessOnTurnStart(ctx context.Context, taskID, sessionID stri
|
||||
// If triggerOnEnter is true, on_enter actions (like auto_start_agent) are processed.
|
||||
// If false, only the step change is applied (used for on_turn_start where the user is about to send a message).
|
||||
func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID string, fromStep *wfmodels.WorkflowStep, toStepID string, triggerOnEnter bool) {
|
||||
// Process on_exit actions for the step we're leaving (before the step change)
|
||||
s.processOnExit(ctx, taskID, sessionID, fromStep)
|
||||
|
||||
// Get the target step
|
||||
targetStep, err := s.workflowStepGetter.GetStep(ctx, toStepID)
|
||||
if err != nil {
|
||||
@@ -571,6 +590,8 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s
|
||||
|
||||
// processOnEnter processes the on_enter events for a step after transitioning to it.
|
||||
func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string, step *wfmodels.WorkflowStep, taskDescription string) {
|
||||
isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID)
|
||||
|
||||
// Check if this step enables plan mode
|
||||
hasPlanMode := false
|
||||
for _, action := range step.Events.OnEnter {
|
||||
@@ -580,9 +601,12 @@ func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string,
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-disable plan mode when entering a step that doesn't explicitly enable it
|
||||
if !hasPlanMode {
|
||||
s.clearSessionPlanMode(ctx, sessionID)
|
||||
// Skip plan mode management for passthrough sessions — the CLI manages its own state.
|
||||
// For ACP sessions, auto-disable plan mode when entering a step that doesn't explicitly enable it.
|
||||
if !isPassthrough {
|
||||
if !hasPlanMode {
|
||||
s.clearSessionPlanMode(ctx, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(step.Events.OnEnter) == 0 {
|
||||
@@ -609,18 +633,23 @@ func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string,
|
||||
for _, action := range step.Events.OnEnter {
|
||||
switch action.Type {
|
||||
case wfmodels.OnEnterEnablePlanMode:
|
||||
s.setSessionPlanMode(ctx, sessionID, true)
|
||||
// Skip plan mode for passthrough — CLI manages its own state
|
||||
if !isPassthrough {
|
||||
s.setSessionPlanMode(ctx, sessionID, true)
|
||||
}
|
||||
case wfmodels.OnEnterAutoStartAgent:
|
||||
hasAutoStart = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasAutoStart {
|
||||
// Skip auto-start for passthrough sessions — stdin is unreliable and the
|
||||
// process may not be expecting input. Let the user interact directly.
|
||||
if hasAutoStart && !isPassthrough {
|
||||
// Build prompt from step configuration
|
||||
effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID)
|
||||
planMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode)
|
||||
|
||||
// Auto-start the agent
|
||||
// Auto-start the agent via ACP prompt
|
||||
go func() {
|
||||
bgCtx := context.Background()
|
||||
_, err := s.PromptTask(bgCtx, taskID, sessionID, effectivePrompt, "", planMode, nil)
|
||||
@@ -653,6 +682,28 @@ func (s *Service) processOnEnter(ctx context.Context, taskID, sessionID string,
|
||||
}
|
||||
}
|
||||
|
||||
// processOnExit processes the on_exit events for a step when leaving it.
|
||||
// This is called before transitioning to the next step. Only side-effect actions
|
||||
// are supported (no transitions — those are decided by on_turn_complete).
|
||||
func (s *Service) processOnExit(ctx context.Context, taskID, sessionID string, step *wfmodels.WorkflowStep) {
|
||||
if len(step.Events.OnExit) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip plan mode management for passthrough sessions — the CLI manages its own state.
|
||||
isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID)
|
||||
|
||||
for _, action := range step.Events.OnExit {
|
||||
if action.Type == wfmodels.OnExitDisablePlanMode && !isPassthrough {
|
||||
s.clearSessionPlanMode(ctx, sessionID)
|
||||
s.logger.Debug("on_exit: disabled plan mode",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("step_name", step.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearSessionPlanMode clears plan mode from session metadata.
|
||||
func (s *Service) clearSessionPlanMode(ctx context.Context, sessionID string) {
|
||||
s.setSessionPlanMode(ctx, sessionID, false)
|
||||
@@ -1293,6 +1344,7 @@ func (s *Service) updateTaskSessionState(ctx context.Context, taskID, sessionID
|
||||
"new_state": string(nextState),
|
||||
"agent_profile_id": session.AgentProfileID,
|
||||
"agent_profile_snapshot": session.AgentProfileSnapshot,
|
||||
"is_passthrough": session.IsPassthrough,
|
||||
}
|
||||
// Include review_status and workflow_step_id if present to ensure frontend state consistency
|
||||
if session.ReviewStatus != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
@@ -83,6 +84,34 @@ func (m *mockTaskRepo) UpdateTaskState(_ context.Context, taskID string, state v
|
||||
return nil
|
||||
}
|
||||
|
||||
// mockAgentManager is a minimal mock of executor.AgentManagerClient for testing.
|
||||
type mockAgentManager struct {
|
||||
isPassthrough bool
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) LaunchAgent(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockAgentManager) StartAgentProcess(_ context.Context, _ string) error { return nil }
|
||||
func (m *mockAgentManager) StopAgent(_ context.Context, _ string, _ bool) error { return nil }
|
||||
func (m *mockAgentManager) PromptAgent(_ context.Context, _ string, _ string, _ []v1.MessageAttachment) (*executor.PromptResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockAgentManager) CancelAgent(_ context.Context, _ string) error { return nil }
|
||||
func (m *mockAgentManager) RespondToPermissionBySessionID(_ context.Context, _, _, _ string, _ bool) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockAgentManager) IsAgentRunningForSession(_ context.Context, _ string) bool { return false }
|
||||
func (m *mockAgentManager) ResolveAgentProfile(_ context.Context, _ string) (*executor.AgentProfileInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockAgentManager) SetExecutionDescription(_ context.Context, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockAgentManager) IsPassthroughSession(_ context.Context, _ string) bool {
|
||||
return m.isPassthrough
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func testLogger() *logger.Logger {
|
||||
@@ -164,11 +193,16 @@ func seedSession(t *testing.T, repo repository.Repository, taskID, sessionID, wo
|
||||
|
||||
// createTestService creates a Service with minimal dependencies for event handler testing.
|
||||
func createTestService(repo repository.Repository, stepGetter *mockStepGetter, taskRepo *mockTaskRepo) *Service {
|
||||
return createTestServiceWithAgent(repo, stepGetter, taskRepo, &mockAgentManager{})
|
||||
}
|
||||
|
||||
func createTestServiceWithAgent(repo repository.Repository, stepGetter *mockStepGetter, taskRepo *mockTaskRepo, agentMgr executor.AgentManagerClient) *Service {
|
||||
return &Service{
|
||||
logger: testLogger(),
|
||||
repo: repo,
|
||||
workflowStepGetter: stepGetter,
|
||||
taskRepo: taskRepo,
|
||||
agentManager: agentMgr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,3 +599,141 @@ func TestSetSessionPlanMode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessOnExit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("no actions is a no-op", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
|
||||
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
|
||||
|
||||
step := &wfmodels.WorkflowStep{
|
||||
ID: "step1", WorkflowID: "wf1", Name: "Step 1",
|
||||
Events: wfmodels.StepEvents{},
|
||||
}
|
||||
|
||||
// Should not panic or modify anything
|
||||
svc.processOnExit(ctx, "t1", "s1", step)
|
||||
})
|
||||
|
||||
t.Run("disable_plan_mode clears plan mode", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
|
||||
// Set plan_mode in session metadata
|
||||
session, _ := repo.GetTaskSession(ctx, "s1")
|
||||
session.Metadata = map[string]interface{}{"plan_mode": true}
|
||||
_ = repo.UpdateTaskSession(ctx, session)
|
||||
|
||||
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
|
||||
|
||||
step := &wfmodels.WorkflowStep{
|
||||
ID: "step1", WorkflowID: "wf1", Name: "Step 1",
|
||||
Events: wfmodels.StepEvents{
|
||||
OnExit: []wfmodels.OnExitAction{
|
||||
{Type: wfmodels.OnExitDisablePlanMode},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc.processOnExit(ctx, "t1", "s1", step)
|
||||
|
||||
updated, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if updated.Metadata != nil {
|
||||
if _, hasPlanMode := updated.Metadata["plan_mode"]; hasPlanMode {
|
||||
t.Error("expected plan_mode to be cleared from session metadata")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disable_plan_mode skipped for passthrough session", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
|
||||
// Set plan_mode in session metadata
|
||||
session, _ := repo.GetTaskSession(ctx, "s1")
|
||||
session.Metadata = map[string]interface{}{"plan_mode": true}
|
||||
_ = repo.UpdateTaskSession(ctx, session)
|
||||
|
||||
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{isPassthrough: true})
|
||||
|
||||
step := &wfmodels.WorkflowStep{
|
||||
ID: "step1", WorkflowID: "wf1", Name: "Step 1",
|
||||
Events: wfmodels.StepEvents{
|
||||
OnExit: []wfmodels.OnExitAction{
|
||||
{Type: wfmodels.OnExitDisablePlanMode},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc.processOnExit(ctx, "t1", "s1", step)
|
||||
|
||||
// plan_mode should still be set
|
||||
updated, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if updated.Metadata == nil {
|
||||
t.Fatal("expected metadata to still be set")
|
||||
}
|
||||
if pm, ok := updated.Metadata["plan_mode"].(bool); !ok || !pm {
|
||||
t.Error("expected plan_mode to remain true for passthrough session")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessOnEnterPassthrough(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("plan mode not set for passthrough session", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
|
||||
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{isPassthrough: true})
|
||||
|
||||
step := &wfmodels.WorkflowStep{
|
||||
ID: "step1", WorkflowID: "wf1", Name: "Plan Step",
|
||||
Events: wfmodels.StepEvents{
|
||||
OnEnter: []wfmodels.OnEnterAction{
|
||||
{Type: wfmodels.OnEnterEnablePlanMode},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
svc.processOnEnter(ctx, "t1", "s1", step, "test task")
|
||||
|
||||
session, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if session.Metadata != nil {
|
||||
if _, hasPlanMode := session.Metadata["plan_mode"]; hasPlanMode {
|
||||
t.Error("expected plan_mode NOT to be set for passthrough session")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plan mode not cleared for passthrough session", func(t *testing.T) {
|
||||
repo := setupTestRepo(t)
|
||||
seedSession(t, repo, "t1", "s1", "step1")
|
||||
|
||||
// Set plan_mode in session metadata
|
||||
session, _ := repo.GetTaskSession(ctx, "s1")
|
||||
session.Metadata = map[string]interface{}{"plan_mode": true}
|
||||
_ = repo.UpdateTaskSession(ctx, session)
|
||||
|
||||
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{isPassthrough: true})
|
||||
|
||||
step := &wfmodels.WorkflowStep{
|
||||
ID: "step1", Name: "Regular Step",
|
||||
Events: wfmodels.StepEvents{}, // no enable_plan_mode
|
||||
}
|
||||
|
||||
svc.processOnEnter(ctx, "t1", "s1", step, "test task")
|
||||
|
||||
// plan_mode should still be set since passthrough sessions skip plan mode management
|
||||
updated, _ := repo.GetTaskSession(ctx, "s1")
|
||||
if updated.Metadata == nil {
|
||||
t.Fatal("expected metadata to still be set")
|
||||
}
|
||||
if pm, ok := updated.Metadata["plan_mode"].(bool); !ok || !pm {
|
||||
t.Error("expected plan_mode to remain true for passthrough session")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,9 @@ type AgentManagerClient interface {
|
||||
// SetExecutionDescription updates the task description in an existing execution's metadata.
|
||||
// Used when starting an agent on a session whose workspace was already launched.
|
||||
SetExecutionDescription(ctx context.Context, agentExecutionID string, description string) error
|
||||
|
||||
// IsPassthroughSession checks if the given session is running in passthrough (PTY) mode.
|
||||
IsPassthroughSession(ctx context.Context, sessionID string) bool
|
||||
}
|
||||
|
||||
// AgentProfileInfo contains resolved profile information
|
||||
@@ -356,6 +359,7 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi
|
||||
|
||||
// Resolve agent profile to get model and other settings for snapshot
|
||||
var agentProfileSnapshot map[string]interface{}
|
||||
var isPassthrough bool
|
||||
if profileInfo, err := e.agentManager.ResolveAgentProfile(ctx, agentProfileID); err == nil && profileInfo != nil {
|
||||
agentProfileSnapshot = map[string]interface{}{
|
||||
"id": profileInfo.ProfileID,
|
||||
@@ -367,6 +371,7 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi
|
||||
"dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions,
|
||||
"cli_passthrough": profileInfo.CLIPassthrough,
|
||||
}
|
||||
isPassthrough = profileInfo.CLIPassthrough
|
||||
} else {
|
||||
agentProfileSnapshot = map[string]interface{}{
|
||||
"id": agentProfileID,
|
||||
@@ -388,6 +393,7 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi
|
||||
UpdatedAt: now,
|
||||
AgentProfileSnapshot: agentProfileSnapshot,
|
||||
IsPrimary: true,
|
||||
IsPassthrough: isPassthrough,
|
||||
}
|
||||
if workflowStepID != "" {
|
||||
session.WorkflowStepID = &workflowStepID
|
||||
|
||||
@@ -64,6 +64,10 @@ func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, session
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) IsPassthroughSession(ctx context.Context, sessionID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) ResolveAgentProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error) {
|
||||
if m.resolveAgentProfileFunc != nil {
|
||||
return m.resolveAgentProfileFunc(ctx, profileID)
|
||||
|
||||
@@ -2,6 +2,7 @@ package queue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
@@ -195,22 +196,25 @@ func TestUnlimitedQueue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFIFOWithSamePriority(t *testing.T) {
|
||||
q := NewTaskQueue(10)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
q := NewTaskQueue(10)
|
||||
|
||||
// All tasks have same priority - should be FIFO
|
||||
_ = q.Enqueue(createTestTask("first", 5))
|
||||
time.Sleep(1 * time.Millisecond) // Ensure different timestamps
|
||||
_ = q.Enqueue(createTestTask("second", 5))
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
_ = q.Enqueue(createTestTask("third", 5))
|
||||
// All tasks have same priority - should be FIFO
|
||||
// Using 1s sleeps with synctest fake clock ensures distinct timestamps instantly
|
||||
_ = q.Enqueue(createTestTask("first", 5))
|
||||
time.Sleep(1 * time.Second)
|
||||
_ = q.Enqueue(createTestTask("second", 5))
|
||||
time.Sleep(1 * time.Second)
|
||||
_ = q.Enqueue(createTestTask("third", 5))
|
||||
|
||||
first := q.Dequeue()
|
||||
if first.TaskID != "first" {
|
||||
t.Errorf("expected 'first' with FIFO ordering, got %s", first.TaskID)
|
||||
}
|
||||
first := q.Dequeue()
|
||||
if first.TaskID != "first" {
|
||||
t.Errorf("expected 'first' with FIFO ordering, got %s", first.TaskID)
|
||||
}
|
||||
|
||||
second := q.Dequeue()
|
||||
if second.TaskID != "second" {
|
||||
t.Errorf("expected 'second' with FIFO ordering, got %s", second.TaskID)
|
||||
}
|
||||
second := q.Dequeue()
|
||||
if second.TaskID != "second" {
|
||||
t.Errorf("expected 'second' with FIFO ordering, got %s", second.TaskID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, session
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) IsPassthroughSession(ctx context.Context, sessionID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mockAgentManager) CancelAgent(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
@@ -202,157 +203,163 @@ func TestStopNotRunning(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTaskEventHandling(t *testing.T) {
|
||||
eventBus := newMockEventBus()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
eventBus := newMockEventBus()
|
||||
|
||||
var receivedData TaskEventData
|
||||
var received bool
|
||||
var mu sync.Mutex
|
||||
var receivedData TaskEventData
|
||||
var received bool
|
||||
var mu sync.Mutex
|
||||
|
||||
handlers := EventHandlers{
|
||||
OnTaskStateChanged: func(ctx context.Context, data TaskEventData) {
|
||||
mu.Lock()
|
||||
receivedData = data
|
||||
received = true
|
||||
mu.Unlock()
|
||||
},
|
||||
}
|
||||
log := createTestLogger()
|
||||
handlers := EventHandlers{
|
||||
OnTaskStateChanged: func(ctx context.Context, data TaskEventData) {
|
||||
mu.Lock()
|
||||
receivedData = data
|
||||
received = true
|
||||
mu.Unlock()
|
||||
},
|
||||
}
|
||||
log := createTestLogger()
|
||||
|
||||
w := NewWatcher(eventBus, handlers, log)
|
||||
_ = w.Start(context.Background())
|
||||
defer func() {
|
||||
_ = w.Stop()
|
||||
}()
|
||||
w := NewWatcher(eventBus, handlers, log)
|
||||
_ = w.Start(context.Background())
|
||||
defer func() {
|
||||
_ = w.Stop()
|
||||
}()
|
||||
|
||||
// Simulate publishing a task state changed event
|
||||
oldState := v1.TaskStateTODO
|
||||
newState := v1.TaskStateInProgress
|
||||
event := bus.NewEvent(events.TaskStateChanged, "test", map[string]interface{}{
|
||||
"task_id": "task-123",
|
||||
"old_state": string(oldState),
|
||||
"new_state": string(newState),
|
||||
// Simulate publishing a task state changed event
|
||||
oldState := v1.TaskStateTODO
|
||||
newState := v1.TaskStateInProgress
|
||||
event := bus.NewEvent(events.TaskStateChanged, "test", map[string]interface{}{
|
||||
"task_id": "task-123",
|
||||
"old_state": string(oldState),
|
||||
"new_state": string(newState),
|
||||
})
|
||||
|
||||
_ = eventBus.Publish(context.Background(), events.TaskStateChanged, event)
|
||||
|
||||
// Wait for goroutines in mock event bus to settle
|
||||
synctest.Wait()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !received {
|
||||
t.Error("OnTaskStateChanged handler was not called")
|
||||
}
|
||||
if receivedData.TaskID != "task-123" {
|
||||
t.Errorf("expected task_id = 'task-123', got %s", receivedData.TaskID)
|
||||
}
|
||||
})
|
||||
|
||||
_ = eventBus.Publish(context.Background(), events.TaskStateChanged, event)
|
||||
|
||||
// Wait for handler to be called
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !received {
|
||||
t.Error("OnTaskStateChanged handler was not called")
|
||||
}
|
||||
if receivedData.TaskID != "task-123" {
|
||||
t.Errorf("expected task_id = 'task-123', got %s", receivedData.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentEventHandling(t *testing.T) {
|
||||
eventBus := newMockEventBus()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
eventBus := newMockEventBus()
|
||||
|
||||
var receivedData AgentEventData
|
||||
var received bool
|
||||
var mu sync.Mutex
|
||||
var receivedData AgentEventData
|
||||
var received bool
|
||||
var mu sync.Mutex
|
||||
|
||||
handlers := EventHandlers{
|
||||
OnAgentCompleted: func(ctx context.Context, data AgentEventData) {
|
||||
mu.Lock()
|
||||
receivedData = data
|
||||
received = true
|
||||
mu.Unlock()
|
||||
},
|
||||
}
|
||||
log := createTestLogger()
|
||||
handlers := EventHandlers{
|
||||
OnAgentCompleted: func(ctx context.Context, data AgentEventData) {
|
||||
mu.Lock()
|
||||
receivedData = data
|
||||
received = true
|
||||
mu.Unlock()
|
||||
},
|
||||
}
|
||||
log := createTestLogger()
|
||||
|
||||
w := NewWatcher(eventBus, handlers, log)
|
||||
_ = w.Start(context.Background())
|
||||
defer func() {
|
||||
_ = w.Stop()
|
||||
}()
|
||||
w := NewWatcher(eventBus, handlers, log)
|
||||
_ = w.Start(context.Background())
|
||||
defer func() {
|
||||
_ = w.Stop()
|
||||
}()
|
||||
|
||||
// Simulate publishing an agent completed event
|
||||
exitCode := 0
|
||||
event := bus.NewEvent(events.AgentCompleted, "test", map[string]interface{}{
|
||||
"task_id": "task-123",
|
||||
"agent_execution_id": "agent-456",
|
||||
"agent_type": "test-agent",
|
||||
"exit_code": exitCode,
|
||||
// Simulate publishing an agent completed event
|
||||
exitCode := 0
|
||||
event := bus.NewEvent(events.AgentCompleted, "test", map[string]interface{}{
|
||||
"task_id": "task-123",
|
||||
"agent_execution_id": "agent-456",
|
||||
"agent_type": "test-agent",
|
||||
"exit_code": exitCode,
|
||||
})
|
||||
|
||||
_ = eventBus.Publish(context.Background(), events.AgentCompleted, event)
|
||||
|
||||
// Wait for goroutines in mock event bus to settle
|
||||
synctest.Wait()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !received {
|
||||
t.Error("OnAgentCompleted handler was not called")
|
||||
}
|
||||
if receivedData.TaskID != "task-123" {
|
||||
t.Errorf("expected task_id = 'task-123', got %s", receivedData.TaskID)
|
||||
}
|
||||
if receivedData.AgentExecutionID != "agent-456" {
|
||||
t.Errorf("expected agent_execution_id = 'agent-456', got %s", receivedData.AgentExecutionID)
|
||||
}
|
||||
})
|
||||
|
||||
_ = eventBus.Publish(context.Background(), events.AgentCompleted, event)
|
||||
|
||||
// Wait for handler to be called
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !received {
|
||||
t.Error("OnAgentCompleted handler was not called")
|
||||
}
|
||||
if receivedData.TaskID != "task-123" {
|
||||
t.Errorf("expected task_id = 'task-123', got %s", receivedData.TaskID)
|
||||
}
|
||||
if receivedData.AgentExecutionID != "agent-456" {
|
||||
t.Errorf("expected agent_execution_id = 'agent-456', got %s", receivedData.AgentExecutionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStreamEventHandling(t *testing.T) {
|
||||
eventBus := newMockEventBus()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
eventBus := newMockEventBus()
|
||||
|
||||
var receivedPayload *lifecycle.AgentStreamEventPayload
|
||||
var received bool
|
||||
var mu sync.Mutex
|
||||
var receivedPayload *lifecycle.AgentStreamEventPayload
|
||||
var received bool
|
||||
var mu sync.Mutex
|
||||
|
||||
handlers := EventHandlers{
|
||||
OnAgentStreamEvent: func(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
|
||||
mu.Lock()
|
||||
receivedPayload = payload
|
||||
received = true
|
||||
mu.Unlock()
|
||||
},
|
||||
}
|
||||
log := createTestLogger()
|
||||
handlers := EventHandlers{
|
||||
OnAgentStreamEvent: func(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
|
||||
mu.Lock()
|
||||
receivedPayload = payload
|
||||
received = true
|
||||
mu.Unlock()
|
||||
},
|
||||
}
|
||||
log := createTestLogger()
|
||||
|
||||
w := NewWatcher(eventBus, handlers, log)
|
||||
_ = w.Start(context.Background())
|
||||
defer func() {
|
||||
_ = w.Stop()
|
||||
}()
|
||||
w := NewWatcher(eventBus, handlers, log)
|
||||
_ = w.Start(context.Background())
|
||||
defer func() {
|
||||
_ = w.Stop()
|
||||
}()
|
||||
|
||||
// Simulate publishing an agent stream event
|
||||
event := bus.NewEvent(events.BuildAgentStreamSubject("session-789"), "test", map[string]interface{}{
|
||||
"type": "agent/event",
|
||||
"task_id": "task-789",
|
||||
"session_id": "session-789",
|
||||
"agent_id": "agent-123",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"data": map[string]interface{}{
|
||||
"type": "tool_call",
|
||||
"tool_call_id": "tc-1",
|
||||
"tool_name": "view",
|
||||
"tool_title": "View file",
|
||||
"tool_status": "running",
|
||||
},
|
||||
// Simulate publishing an agent stream event
|
||||
event := bus.NewEvent(events.BuildAgentStreamSubject("session-789"), "test", map[string]interface{}{
|
||||
"type": "agent/event",
|
||||
"task_id": "task-789",
|
||||
"session_id": "session-789",
|
||||
"agent_id": "agent-123",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"data": map[string]interface{}{
|
||||
"type": "tool_call",
|
||||
"tool_call_id": "tc-1",
|
||||
"tool_name": "view",
|
||||
"tool_title": "View file",
|
||||
"tool_status": "running",
|
||||
},
|
||||
})
|
||||
|
||||
_ = eventBus.Publish(context.Background(), events.BuildAgentStreamWildcardSubject(), event)
|
||||
|
||||
// Wait for goroutines in mock event bus to settle
|
||||
synctest.Wait()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !received {
|
||||
t.Error("OnAgentStreamEvent handler was not called")
|
||||
}
|
||||
if receivedPayload == nil {
|
||||
t.Error("received payload should not be nil")
|
||||
}
|
||||
if receivedPayload.TaskID != "task-789" {
|
||||
t.Errorf("expected task_id = 'task-789', got %s", receivedPayload.TaskID)
|
||||
}
|
||||
})
|
||||
|
||||
_ = eventBus.Publish(context.Background(), events.BuildAgentStreamWildcardSubject(), event)
|
||||
|
||||
// Wait for handler to be called
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !received {
|
||||
t.Error("OnAgentStreamEvent handler was not called")
|
||||
}
|
||||
if receivedPayload == nil {
|
||||
t.Error("received payload should not be nil")
|
||||
}
|
||||
if receivedPayload.TaskID != "task-789" {
|
||||
t.Errorf("expected task_id = 'task-789', got %s", receivedPayload.TaskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoHandlersRegistered(t *testing.T) {
|
||||
|
||||
@@ -141,6 +141,7 @@ type TaskSessionDTO struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Workflow fields
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
IsPassthrough bool `json:"is_passthrough"`
|
||||
WorkflowStepID *string `json:"workflow_step_id,omitempty"`
|
||||
ReviewStatus *string `json:"review_status,omitempty"`
|
||||
}
|
||||
@@ -430,6 +431,7 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO {
|
||||
UpdatedAt: session.UpdatedAt,
|
||||
// Workflow fields
|
||||
IsPrimary: session.IsPrimary,
|
||||
IsPassthrough: session.IsPassthrough,
|
||||
WorkflowStepID: session.WorkflowStepID,
|
||||
ReviewStatus: session.ReviewStatus,
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ type TaskSession struct {
|
||||
|
||||
// Workflow-related fields
|
||||
IsPrimary bool `json:"is_primary"` // Whether this is the primary session for the task
|
||||
IsPassthrough bool `json:"is_passthrough"` // Whether this session uses passthrough (PTY) mode
|
||||
WorkflowStepID *string `json:"workflow_step_id,omitempty"` // Current workflow step ID
|
||||
ReviewStatus *string `json:"review_status,omitempty"` // pending, approved
|
||||
}
|
||||
@@ -274,6 +275,7 @@ func (s *TaskSession) ToAPI() map[string]interface{} {
|
||||
if s.RepositorySnapshot != nil {
|
||||
result["repository_snapshot"] = s.RepositorySnapshot
|
||||
}
|
||||
result["is_passthrough"] = s.IsPassthrough
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -402,18 +402,22 @@ func (r *Repository) initSchema() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.ensureIsPassthroughColumn(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureArchivedAtColumn adds the archived_at column to tasks if it doesn't exist.
|
||||
func (r *Repository) ensureArchivedAtColumn() error {
|
||||
rows, err := r.db.Query("PRAGMA table_info(tasks)")
|
||||
// ensureColumnExists checks if a column exists on a table and runs the provided
|
||||
// SQL statements to add it if missing.
|
||||
func (r *Repository) ensureColumnExists(table, column string, migrationSQL ...string) error {
|
||||
rows, err := r.db.Query("PRAGMA table_info(" + table + ")")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var hasColumn bool
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, colType string
|
||||
@@ -423,21 +427,29 @@ func (r *Repository) ensureArchivedAtColumn() error {
|
||||
if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "archived_at" {
|
||||
hasColumn = true
|
||||
break
|
||||
if name == column {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasColumn {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := r.db.Exec("ALTER TABLE tasks ADD COLUMN archived_at DATETIME"); err != nil {
|
||||
return err
|
||||
for _, stmt := range migrationSQL {
|
||||
if _, err := r.db.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = r.db.Exec("CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)")
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ensureIsPassthroughColumn() error {
|
||||
return r.ensureColumnExists("task_sessions", "is_passthrough",
|
||||
"ALTER TABLE task_sessions ADD COLUMN is_passthrough INTEGER DEFAULT 0")
|
||||
}
|
||||
|
||||
func (r *Repository) ensureArchivedAtColumn() error {
|
||||
return r.ensureColumnExists("tasks", "archived_at",
|
||||
"ALTER TABLE tasks ADD COLUMN archived_at DATETIME",
|
||||
"CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)")
|
||||
}
|
||||
|
||||
@@ -205,14 +205,15 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
is_primary, workflow_step_id, review_status, is_passthrough
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID,
|
||||
session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch,
|
||||
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
|
||||
string(session.State), session.ErrorMessage, string(metadataJSON),
|
||||
session.StartedAt, session.CompletedAt, session.UpdatedAt,
|
||||
commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus)
|
||||
commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus,
|
||||
commonsqlite.BoolToInt(session.IsPassthrough))
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -228,6 +229,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
|
||||
var repositorySnapshotJSON string
|
||||
var completedAt sql.NullTime
|
||||
var isPrimary int
|
||||
var isPassthrough int
|
||||
var workflowStepID sql.NullString
|
||||
var reviewStatus sql.NullString
|
||||
|
||||
@@ -236,7 +238,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
is_primary, workflow_step_id, review_status, is_passthrough
|
||||
FROM task_sessions WHERE id = ?
|
||||
`, id).Scan(
|
||||
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
|
||||
@@ -244,7 +246,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
|
||||
&session.RepositoryID, &session.BaseBranch,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &workflowStepID, &reviewStatus,
|
||||
&isPrimary, &workflowStepID, &reviewStatus, &isPassthrough,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -256,6 +258,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
|
||||
|
||||
session.State = models.TaskSessionState(state)
|
||||
session.IsPrimary = isPrimary == 1
|
||||
session.IsPassthrough = isPassthrough == 1
|
||||
if workflowStepID.Valid {
|
||||
session.WorkflowStepID = &workflowStepID.String
|
||||
}
|
||||
@@ -311,6 +314,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
|
||||
var repositorySnapshotJSON string
|
||||
var completedAt sql.NullTime
|
||||
var isPrimary int
|
||||
var isPassthrough int
|
||||
var workflowStepID sql.NullString
|
||||
var reviewStatus sql.NullString
|
||||
|
||||
@@ -319,7 +323,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
is_primary, workflow_step_id, review_status, is_passthrough
|
||||
FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1
|
||||
`, taskID).Scan(
|
||||
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
|
||||
@@ -327,7 +331,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
|
||||
&session.RepositoryID, &session.BaseBranch,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &workflowStepID, &reviewStatus,
|
||||
&isPrimary, &workflowStepID, &reviewStatus, &isPassthrough,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -339,6 +343,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
|
||||
|
||||
session.State = models.TaskSessionState(state)
|
||||
session.IsPrimary = isPrimary == 1
|
||||
session.IsPassthrough = isPassthrough == 1
|
||||
if workflowStepID.Valid {
|
||||
session.WorkflowStepID = &workflowStepID.String
|
||||
}
|
||||
@@ -394,6 +399,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
|
||||
var repositorySnapshotJSON string
|
||||
var completedAt sql.NullTime
|
||||
var isPrimary int
|
||||
var isPassthrough int
|
||||
var workflowStepID sql.NullString
|
||||
var reviewStatus sql.NullString
|
||||
|
||||
@@ -402,7 +408,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
is_primary, workflow_step_id, review_status, is_passthrough
|
||||
FROM task_sessions
|
||||
WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
@@ -412,7 +418,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
|
||||
&session.RepositoryID, &session.BaseBranch,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &workflowStepID, &reviewStatus,
|
||||
&isPrimary, &workflowStepID, &reviewStatus, &isPassthrough,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -424,6 +430,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
|
||||
|
||||
session.State = models.TaskSessionState(state)
|
||||
session.IsPrimary = isPrimary == 1
|
||||
session.IsPassthrough = isPassthrough == 1
|
||||
if workflowStepID.Valid {
|
||||
session.WorkflowStepID = &workflowStepID.String
|
||||
}
|
||||
@@ -499,13 +506,14 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task
|
||||
repository_id = ?, base_branch = ?,
|
||||
agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?,
|
||||
state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?,
|
||||
is_primary = ?, workflow_step_id = ?, review_status = ?
|
||||
is_primary = ?, workflow_step_id = ?, review_status = ?, is_passthrough = ?
|
||||
WHERE id = ?
|
||||
`, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID,
|
||||
session.RepositoryID, session.BaseBranch,
|
||||
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
|
||||
string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt,
|
||||
commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus,
|
||||
commonsqlite.BoolToInt(session.IsPassthrough),
|
||||
session.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -565,7 +573,7 @@ func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*mo
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
is_primary, workflow_step_id, review_status, is_passthrough
|
||||
FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC
|
||||
`, taskID)
|
||||
if err != nil {
|
||||
@@ -594,7 +602,7 @@ func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.Task
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
is_primary, workflow_step_id, review_status, is_passthrough
|
||||
FROM task_sessions WHERE state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -623,7 +631,7 @@ func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
is_primary, workflow_step_id, review_status, is_passthrough
|
||||
FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC
|
||||
`, taskID)
|
||||
if err != nil {
|
||||
@@ -700,6 +708,7 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m
|
||||
var repositorySnapshotJSON string
|
||||
var completedAt sql.NullTime
|
||||
var isPrimary int
|
||||
var isPassthrough int
|
||||
var workflowStepID sql.NullString
|
||||
var reviewStatus sql.NullString
|
||||
|
||||
@@ -709,7 +718,7 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m
|
||||
&session.RepositoryID, &session.BaseBranch,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &workflowStepID, &reviewStatus,
|
||||
&isPrimary, &workflowStepID, &reviewStatus, &isPassthrough,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -717,6 +726,7 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m
|
||||
|
||||
session.State = models.TaskSessionState(state)
|
||||
session.IsPrimary = isPrimary == 1
|
||||
session.IsPassthrough = isPassthrough == 1
|
||||
if workflowStepID.Valid {
|
||||
session.WorkflowStepID = &workflowStepID.String
|
||||
}
|
||||
@@ -870,6 +880,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin
|
||||
var repositorySnapshotJSON string
|
||||
var completedAt sql.NullTime
|
||||
var isPrimary int
|
||||
var isPassthrough int
|
||||
var workflowStepID sql.NullString
|
||||
var reviewStatus sql.NullString
|
||||
|
||||
@@ -878,7 +889,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
is_primary, workflow_step_id, review_status, is_passthrough
|
||||
FROM task_sessions WHERE task_id = ? AND is_primary = 1 LIMIT 1
|
||||
`, taskID).Scan(
|
||||
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
|
||||
@@ -886,7 +897,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin
|
||||
&session.RepositoryID, &session.BaseBranch,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &workflowStepID, &reviewStatus,
|
||||
&isPrimary, &workflowStepID, &reviewStatus, &isPassthrough,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -898,6 +909,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin
|
||||
|
||||
session.State = models.TaskSessionState(state)
|
||||
session.IsPrimary = isPrimary == 1
|
||||
session.IsPassthrough = isPassthrough == 1
|
||||
if workflowStepID.Valid {
|
||||
session.WorkflowStepID = &workflowStepID.String
|
||||
}
|
||||
|
||||
@@ -50,11 +50,25 @@ type OnTurnCompleteAction struct {
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// OnExitActionType represents the type of action to execute when leaving a step.
|
||||
type OnExitActionType string
|
||||
|
||||
const (
|
||||
OnExitDisablePlanMode OnExitActionType = "disable_plan_mode"
|
||||
)
|
||||
|
||||
// OnExitAction represents an action to execute when leaving a step.
|
||||
type OnExitAction struct {
|
||||
Type OnExitActionType `json:"type"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// StepEvents contains event-driven actions for a workflow step.
|
||||
type StepEvents struct {
|
||||
OnEnter []OnEnterAction `json:"on_enter,omitempty"`
|
||||
OnTurnStart []OnTurnStartAction `json:"on_turn_start,omitempty"`
|
||||
OnTurnComplete []OnTurnCompleteAction `json:"on_turn_complete,omitempty"`
|
||||
OnExit []OnExitAction `json:"on_exit,omitempty"`
|
||||
}
|
||||
|
||||
// ReviewStatus represents the review state of a session
|
||||
@@ -183,5 +197,6 @@ func RemapStepEvents(events StepEvents, idMap map[string]string) StepEvents {
|
||||
}
|
||||
result.OnTurnComplete = append(result.OnTurnComplete, a)
|
||||
}
|
||||
result.OnExit = append(result.OnExit, events.OnExit...)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ function AgentSetupForm({
|
||||
label: `${profile.agent_display_name} • ${profile.name}`,
|
||||
agent_id: agent.id,
|
||||
agent_name: agent.name,
|
||||
cli_passthrough: profile.cli_passthrough,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -28,9 +28,10 @@ type SessionCommandsProps = {
|
||||
baseBranch?: string;
|
||||
isAgentRunning?: boolean;
|
||||
hasWorktree?: boolean;
|
||||
isPassthrough?: boolean;
|
||||
};
|
||||
|
||||
export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWorktree }: SessionCommandsProps) {
|
||||
export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWorktree, isPassthrough }: SessionCommandsProps) {
|
||||
const git = useGitOperations(sessionId);
|
||||
const panels = usePanelActions();
|
||||
const { openCommitDialog, openPRDialog } = useVcsDialogs();
|
||||
@@ -77,14 +78,16 @@ export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWork
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: 'session-plan-mode',
|
||||
label: 'Toggle Plan Mode',
|
||||
group: 'Session',
|
||||
icon: <IconFileTextSpark className="size-3.5" />,
|
||||
keywords: ['plan', 'mode', 'toggle'],
|
||||
action: () => panels.addPlan(),
|
||||
});
|
||||
if (!isPassthrough) {
|
||||
items.push({
|
||||
id: 'session-plan-mode',
|
||||
label: 'Toggle Plan Mode',
|
||||
group: 'Session',
|
||||
icon: <IconFileTextSpark className="size-3.5" />,
|
||||
keywords: ['plan', 'mode', 'toggle'],
|
||||
action: () => panels.addPlan(),
|
||||
});
|
||||
}
|
||||
|
||||
// Git — only when worktree is set up
|
||||
if (hasWorktree) {
|
||||
@@ -164,26 +167,30 @@ export function SessionCommands({ sessionId, baseBranch, isAgentRunning, hasWork
|
||||
keywords: ['terminal', 'shell', 'console'],
|
||||
action: () => panels.addTerminal(),
|
||||
},
|
||||
{
|
||||
);
|
||||
|
||||
if (!isPassthrough) {
|
||||
items.push({
|
||||
id: 'panel-plan',
|
||||
label: 'Add Plan Panel',
|
||||
group: 'Panels',
|
||||
icon: <IconFileText className="size-3.5" />,
|
||||
keywords: ['plan', 'document'],
|
||||
action: () => panels.addPlan(),
|
||||
},
|
||||
{
|
||||
id: 'panel-changes',
|
||||
label: 'Add Changes Panel',
|
||||
group: 'Panels',
|
||||
icon: <IconFileDiff className="size-3.5" />,
|
||||
keywords: ['changes', 'diff', 'git'],
|
||||
action: () => panels.addChanges(),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: 'panel-changes',
|
||||
label: 'Add Changes Panel',
|
||||
group: 'Panels',
|
||||
icon: <IconFileDiff className="size-3.5" />,
|
||||
keywords: ['changes', 'diff', 'git'],
|
||||
action: () => panels.addChanges(),
|
||||
});
|
||||
|
||||
return items.map((cmd) => ({ ...cmd, priority: PAGE_PRIORITY }));
|
||||
}, [sessionId, git, panels, cancelTurn, baseBranch, isAgentRunning, hasWorktree, openCommitDialog, openPRDialog, runGitWithFeedback]);
|
||||
}, [sessionId, git, panels, cancelTurn, baseBranch, isAgentRunning, hasWorktree, isPassthrough, openCommitDialog, openPRDialog, runGitWithFeedback]);
|
||||
|
||||
useRegisterCommands(commands);
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ function ProfileEditor({ agent, profile, modelConfig, permissionSettings, passth
|
||||
label: `${agentProfile.agent_display_name} • ${agentProfile.name}`,
|
||||
agent_id: agentItem.id,
|
||||
agent_name: agentItem.name,
|
||||
cli_passthrough: agentProfile.cli_passthrough,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@kandev/ui/tooltip';
|
||||
import type { WorkflowStep, OnEnterAction, OnTurnStartAction, OnTurnCompleteAction } from '@/lib/types/http';
|
||||
import type { WorkflowStep, OnEnterAction, OnTurnStartAction, OnTurnCompleteAction, OnExitAction } from '@/lib/types/http';
|
||||
import { useDebouncedCallback } from '@/hooks/use-debounce';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -93,6 +93,10 @@ function hasDisablePlanMode(step: WorkflowStep): boolean {
|
||||
return step.events?.on_turn_complete?.some((a) => a.type === 'disable_plan_mode') ?? false;
|
||||
}
|
||||
|
||||
function hasOnExitAction(step: WorkflowStep, type: string): boolean {
|
||||
return step.events?.on_exit?.some((a) => a.type === type) ?? false;
|
||||
}
|
||||
|
||||
function getTransitionLabel(step: WorkflowStep): string {
|
||||
const t = getTransitionType(step);
|
||||
if (t === 'move_to_next') return 'auto';
|
||||
@@ -368,6 +372,16 @@ function StepConfigPanel({ step, steps, onUpdate, onRemove, readOnly = false }:
|
||||
onUpdate({ events: { ...currentEvents, on_turn_complete: newOnTurnComplete } });
|
||||
};
|
||||
|
||||
const toggleOnExitAction = (type: string) => {
|
||||
const currentEvents = step.events ?? {};
|
||||
const onExit = currentEvents.on_exit ?? [];
|
||||
const exists = onExit.some((a) => a.type === type);
|
||||
const newOnExit = exists
|
||||
? onExit.filter((a) => a.type !== type)
|
||||
: [...onExit, { type } as OnExitAction];
|
||||
onUpdate({ events: { ...currentEvents, on_exit: newOnExit } });
|
||||
};
|
||||
|
||||
// Reset local state only when the step identity changes (not on local edits round-tripping)
|
||||
const resetKey = step.id;
|
||||
|
||||
@@ -657,6 +671,29 @@ function StepConfigPanel({ step, steps, onUpdate, onRemove, readOnly = false }:
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* On Exit */}
|
||||
{hasOnEnterAction(step, 'enable_plan_mode') && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label className="text-xs font-medium">On Exit</Label>
|
||||
<HelpTip text="Runs when leaving this step (before entering the next step)." />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`${step.id}-exit-disable-plan`}
|
||||
checked={hasOnExitAction(step, 'disable_plan_mode')}
|
||||
onCheckedChange={() => {
|
||||
if (readOnly) return;
|
||||
toggleOnExitAction('disable_plan_mode');
|
||||
}}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Label htmlFor={`${step.id}-exit-disable-plan`} className="text-sm">Disable plan mode</Label>
|
||||
<HelpTip text="Turn off plan mode when leaving this step." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt section */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type React from 'react';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconClipboard,
|
||||
IconDoorExit,
|
||||
IconMessageForward,
|
||||
IconRobot,
|
||||
} from '@tabler/icons-react';
|
||||
@@ -28,8 +29,9 @@ export function StepCapabilityIcons({ events, className, fallback }: StepCapabil
|
||||
const hasTransition = events?.on_turn_complete?.some((a) =>
|
||||
['move_to_next', 'move_to_previous', 'move_to_step'].includes(a.type)
|
||||
) ?? false;
|
||||
const hasOnExit = (events?.on_exit?.length ?? 0) > 0;
|
||||
|
||||
if (!hasOnTurnStart && !hasAuto && !hasPlan && !hasTransition) {
|
||||
if (!hasOnTurnStart && !hasAuto && !hasPlan && !hasTransition && !hasOnExit) {
|
||||
return fallback ? <div className={className ?? 'flex items-center gap-1.5 text-muted-foreground'}>{fallback}</div> : null;
|
||||
}
|
||||
|
||||
@@ -75,6 +77,16 @@ export function StepCapabilityIcons({ events, className, fallback }: StepCapabil
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasOnExit && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconDoorExit className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>On exit actions</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState, FormEvent, memo, useMemo, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconLoader2, IconGitBranch, IconFileInvoice, IconSend, IconChevronDown } from '@tabler/icons-react';
|
||||
import { IconLoader2, IconGitBranch, IconFileInvoice, IconSend, IconChevronDown, IconTerminal2 } from '@tabler/icons-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -294,6 +294,7 @@ type TaskFormInputsProps = {
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
descriptionValueRef: React.RefObject<{ getValue: () => string } | null>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const TaskFormInputs = memo(function TaskFormInputs({
|
||||
@@ -304,6 +305,7 @@ const TaskFormInputs = memo(function TaskFormInputs({
|
||||
onKeyDown,
|
||||
descriptionValueRef,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: TaskFormInputsProps) {
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -345,7 +347,7 @@ const TaskFormInputs = memo(function TaskFormInputs({
|
||||
<div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder={isSessionMode ? 'Describe what you want the agent to do...' : 'Write a prompt for the agent...'}
|
||||
placeholder={placeholder ?? (isSessionMode ? 'Describe what you want the agent to do...' : 'Write a prompt for the agent...')}
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -431,6 +433,13 @@ export function TaskCreateDialog({
|
||||
const agentProfilesLoading = open && !settingsData.agentsLoaded;
|
||||
const executorsLoading = open && !settingsData.executorsLoaded;
|
||||
|
||||
// Detect passthrough profile to disable prompt textarea
|
||||
const isPassthroughProfile = useMemo(() => {
|
||||
if (!agentProfileId) return false;
|
||||
const profile = agentProfiles.find((p: AgentProfileOption) => p.id === agentProfileId);
|
||||
return profile?.cli_passthrough === true;
|
||||
}, [agentProfileId, agentProfiles]);
|
||||
|
||||
// Effective workflow/steps: use fetched steps when a different workflow is selected
|
||||
const effectiveWorkflowId = selectedWorkflowId ?? workflowId;
|
||||
const effectiveDefaultStepId = (selectedWorkflowId && selectedWorkflowId !== workflowId && fetchedSteps)
|
||||
@@ -680,6 +689,7 @@ export function TaskCreateDialog({
|
||||
const parts = profile.label.split(' • ');
|
||||
const agentLabel = parts[0] ?? profile.label;
|
||||
const profileLabel = parts[1] ?? '';
|
||||
const isPassthrough = profile.cli_passthrough === true;
|
||||
return {
|
||||
value: profile.id,
|
||||
label: profile.label,
|
||||
@@ -689,11 +699,14 @@ export function TaskCreateDialog({
|
||||
<AgentLogo agentName={profile.agent_name} className="shrink-0" />
|
||||
<span>{agentLabel}</span>
|
||||
</span>
|
||||
{profileLabel ? (
|
||||
<ScrollOnOverflow className="rounded-full border border-border px-2 py-0.5 text-xs">
|
||||
{profileLabel}
|
||||
</ScrollOnOverflow>
|
||||
) : null}
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{isPassthrough && <IconTerminal2 className="size-3.5 text-muted-foreground" title="Passthrough terminal" />}
|
||||
{profileLabel ? (
|
||||
<ScrollOnOverflow className="rounded-full border border-border px-2 py-0.5 text-xs">
|
||||
{profileLabel}
|
||||
</ScrollOnOverflow>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
@@ -836,7 +849,8 @@ export function TaskCreateDialog({
|
||||
const handleSessionSubmit = useCallback(async () => {
|
||||
const description = descriptionInputRef.current?.getValue() ?? '';
|
||||
const trimmedDescription = description.trim();
|
||||
if (!trimmedDescription || !agentProfileId) return;
|
||||
if (!agentProfileId) return;
|
||||
if (!trimmedDescription && !isPassthroughProfile) return;
|
||||
|
||||
// Legacy mode: delegate to parent
|
||||
if (onCreateSession) {
|
||||
@@ -871,7 +885,7 @@ export function TaskCreateDialog({
|
||||
} finally {
|
||||
setIsCreatingSession(false);
|
||||
}
|
||||
}, [agentProfileId, environmentId, executorId, onCreateSession, onOpenChange, resetForm, router, taskId, toast]);
|
||||
}, [agentProfileId, environmentId, executorId, isPassthroughProfile, onCreateSession, onOpenChange, resetForm, router, taskId, toast]);
|
||||
|
||||
const performTaskUpdate = useCallback(async () => {
|
||||
if (!editingTask) return null;
|
||||
@@ -958,7 +972,7 @@ export function TaskCreateDialog({
|
||||
|
||||
setIsCreatingTask(true);
|
||||
try {
|
||||
if (trimmedDescription) {
|
||||
if (trimmedDescription || isPassthroughProfile) {
|
||||
// "Start" mode: create task + start agent in background, stay on kanban
|
||||
// Backend resolves the start step automatically
|
||||
const taskResponse = await createTask({
|
||||
@@ -975,7 +989,11 @@ export function TaskCreateDialog({
|
||||
const newSessionId = taskResponse.session_id ?? taskResponse.primary_session_id ?? null;
|
||||
onSuccess?.(taskResponse, 'create', { taskSessionId: newSessionId });
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
if (isPassthroughProfile && newSessionId) {
|
||||
router.push(linkToSession(newSessionId));
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} else {
|
||||
// "Plan" mode: create task + session only (no agent), navigate to session
|
||||
// Backend resolves the start step automatically
|
||||
@@ -1019,7 +1037,7 @@ export function TaskCreateDialog({
|
||||
}
|
||||
}, [
|
||||
taskName, workspaceId, effectiveWorkflowId, repositoryId, selectedLocalRepo, agentProfileId, executorId,
|
||||
getRepositoriesPayload, onSuccess, resetForm, onOpenChange,
|
||||
isPassthroughProfile, getRepositoriesPayload, onSuccess, resetForm, onOpenChange,
|
||||
setActiveDocument, setPlanMode, router, toast,
|
||||
]);
|
||||
|
||||
@@ -1137,8 +1155,12 @@ export function TaskCreateDialog({
|
||||
onDescriptionChange={setHasDescription}
|
||||
onKeyDown={handleKeyDown}
|
||||
descriptionValueRef={descriptionInputRef}
|
||||
disabled={isTaskStarted}
|
||||
disabled={isTaskStarted || isPassthroughProfile}
|
||||
placeholder={isPassthroughProfile ? 'Sending a prompt is not supported in passthrough mode' : undefined}
|
||||
/>
|
||||
{isPassthroughProfile && hasDescription && (
|
||||
<p className="text-xs text-amber-500">Prompt will be ignored — passthrough sessions don't support sending a prompt on start.</p>
|
||||
)}
|
||||
{!isSessionMode && !isTaskStarted && (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
||||
<div>
|
||||
@@ -1268,7 +1290,7 @@ export function TaskCreateDialog({
|
||||
'Update'
|
||||
)}
|
||||
</Button>
|
||||
) : (isCreateMode && hasDescription) || (isEditMode && agentProfileId) ? (
|
||||
) : (isCreateMode && (hasDescription || isPassthroughProfile)) || (isEditMode && agentProfileId) ? (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden sm:h-7 h-10">
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -1309,7 +1331,7 @@ export function TaskCreateDialog({
|
||||
isCreatingSession ||
|
||||
isCreatingTask ||
|
||||
(isSessionMode
|
||||
? !hasDescription || !agentProfileId
|
||||
? (!hasDescription && !isPassthroughProfile) || !agentProfileId
|
||||
: !hasTitle || (!repositoryId && !selectedLocalRepo) || !branch)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -54,6 +54,8 @@ import { ChangesPanel } from './changes-panel';
|
||||
import { FilesPanel } from './files-panel';
|
||||
import { TaskPlanPanel } from './task-plan-panel';
|
||||
import { FileEditorPanel } from './file-editor-panel';
|
||||
import { PassthroughTerminal } from './passthrough-terminal';
|
||||
import { PanelRoot, PanelBody } from './panel-primitives';
|
||||
import { TerminalPanel } from './terminal-panel';
|
||||
import { BrowserPanel } from './browser-panel';
|
||||
import { CommitDetailPanel } from './commit-detail-panel';
|
||||
@@ -116,6 +118,25 @@ function ChatPanel(props: IDockviewPanelProps) {
|
||||
const sessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const { openFile } = useFileEditors();
|
||||
|
||||
const isPassthrough = useAppStore((state) => {
|
||||
if (!sessionId) return false;
|
||||
return state.taskSessions.items[sessionId]?.is_passthrough === true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
props.api.setTitle('Agent');
|
||||
}, [props.api]);
|
||||
|
||||
if (isPassthrough) {
|
||||
return (
|
||||
<PanelRoot>
|
||||
<PanelBody padding={false} scroll={false}>
|
||||
<PassthroughTerminal sessionId={sessionId} mode="agent" />
|
||||
</PanelBody>
|
||||
</PanelRoot>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskChatPanel
|
||||
sessionId={sessionId}
|
||||
@@ -237,6 +258,10 @@ function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
|
||||
const sidebarGroupId = useDockviewStore((s) => s.sidebarGroupId);
|
||||
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const isPassthrough = useAppStore((state) => {
|
||||
if (!activeSessionId) return false;
|
||||
return state.taskSessions.items[activeSessionId]?.is_passthrough === true;
|
||||
});
|
||||
|
||||
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
|
||||
const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel);
|
||||
@@ -284,10 +309,12 @@ function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
|
||||
<IconDeviceDesktop className="h-3.5 w-3.5 mr-1.5" />
|
||||
Browser
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => addPlanPanel(group.id)} className="cursor-pointer text-xs">
|
||||
<IconFileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
Plan
|
||||
</DropdownMenuItem>
|
||||
{!isPassthrough && (
|
||||
<DropdownMenuItem onClick={() => addPlanPanel(group.id)} className="cursor-pointer text-xs">
|
||||
<IconFileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
Plan
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!hasChanges && (
|
||||
<DropdownMenuItem onClick={() => addChangesPanel(group.id)} className="cursor-pointer text-xs">
|
||||
<IconGitBranch className="h-3.5 w-3.5 mr-1.5" />
|
||||
@@ -696,7 +723,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
|
||||
id: 'chat',
|
||||
component: 'chat',
|
||||
tabComponent: 'permanentTab',
|
||||
title: 'Chat',
|
||||
title: 'Agent',
|
||||
position: sidebarPanel
|
||||
? { direction: 'right', referencePanel: 'sidebar' }
|
||||
: undefined,
|
||||
|
||||
@@ -101,7 +101,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
|
||||
{activeTaskId ? (
|
||||
isPassthroughMode ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<PassthroughTerminal key={effectiveSessionId} sessionId={sessionId} terminalId="default" />
|
||||
<PassthroughTerminal key={effectiveSessionId} sessionId={sessionId} mode="agent" />
|
||||
</div>
|
||||
) : (
|
||||
<TaskChatPanel
|
||||
|
||||
@@ -13,11 +13,12 @@ import { useSessionAgentctl } from '@/hooks/domains/session/use-session-agentctl
|
||||
import { getBackendConfig } from '@/lib/config';
|
||||
import { getTerminalTheme } from '@/lib/terminal-theme';
|
||||
|
||||
type PassthroughTerminalProps = {
|
||||
type BaseProps = {
|
||||
sessionId?: string | null;
|
||||
terminalId?: string; // Terminal tab identifier (e.g., "shell-1", "shell-2")
|
||||
label?: string; // Label for plain shell terminals (sent to backend for persistence)
|
||||
};
|
||||
type AgentTerminalProps = BaseProps & { mode: 'agent'; label?: string };
|
||||
type ShellTerminalProps = BaseProps & { mode: 'shell'; terminalId: string; label?: string };
|
||||
type PassthroughTerminalProps = AgentTerminalProps | ShellTerminalProps;
|
||||
|
||||
// Debug flag - set to true to see detailed logs
|
||||
const DEBUG = false;
|
||||
@@ -33,15 +34,17 @@ const MIN_HEIGHT = 100;
|
||||
* PassthroughTerminal provides direct terminal interaction with an agent CLI.
|
||||
*
|
||||
* Design: Dedicated Binary WebSocket + AttachAddon
|
||||
* - Uses a dedicated WebSocket connection to /xterm.js/:sessionId
|
||||
* - Uses a dedicated WebSocket connection to /terminal/:sessionId
|
||||
* - Raw binary frames bypass JSON encoding/decoding latency
|
||||
* - AttachAddon (official xterm.js addon) handles the bridging
|
||||
* - Unicode11Addon enables proper unicode character support
|
||||
* - Resize commands sent via binary protocol: [0x01][JSON {cols, rows}]
|
||||
*/
|
||||
export function PassthroughTerminal({ sessionId: propSessionId, terminalId, label }: PassthroughTerminalProps) {
|
||||
export function PassthroughTerminal(props: PassthroughTerminalProps) {
|
||||
const { sessionId: propSessionId, mode, label } = props;
|
||||
const terminalId = mode === 'shell' ? props.terminalId : undefined;
|
||||
// Debug: log props on every render
|
||||
log('Render - props:', { propSessionId, terminalId, label });
|
||||
log('Render - props:', { propSessionId, mode, terminalId, label });
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
@@ -313,7 +316,7 @@ export function PassthroughTerminal({ sessionId: propSessionId, terminalId, labe
|
||||
|
||||
// Connect WebSocket when ready
|
||||
useEffect(() => {
|
||||
log('WebSocket effect:', { taskId, sessionId, terminalId, canConnect, hasTerminal: !!xtermRef.current });
|
||||
log('WebSocket effect:', { taskId, sessionId, mode, terminalId, canConnect, hasTerminal: !!xtermRef.current });
|
||||
|
||||
if (!taskId || !sessionId || !canConnect) {
|
||||
log('WebSocket effect: early return - missing requirements', { taskId, sessionId, canConnect });
|
||||
@@ -343,18 +346,17 @@ export function PassthroughTerminal({ sessionId: propSessionId, terminalId, labe
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
// Build WebSocket URL - terminalId is required for user shells
|
||||
// If terminalId is not provided, this is an error for user shell terminals
|
||||
if (!terminalId) {
|
||||
log('ERROR: terminalId is required but was not provided!', { sessionId, terminalId });
|
||||
return;
|
||||
// Build WebSocket URL based on mode
|
||||
let wsUrl: string;
|
||||
if (mode === 'agent') {
|
||||
wsUrl = `${wsBaseUrl}/terminal/${sessionId}?mode=agent`;
|
||||
} else {
|
||||
wsUrl = `${wsBaseUrl}/terminal/${sessionId}?mode=shell&terminalId=${encodeURIComponent(terminalId!)}`;
|
||||
}
|
||||
let wsUrl = `${wsBaseUrl}/xterm.js/${sessionId}?terminalId=${encodeURIComponent(terminalId)}`;
|
||||
if (label) {
|
||||
// For plain shell terminals, send the label for persistence
|
||||
wsUrl += `&label=${encodeURIComponent(label)}`;
|
||||
}
|
||||
log('Connecting to', wsUrl, { terminalId, label });
|
||||
log('Connecting to', wsUrl, { mode, terminalId, label });
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
@@ -427,7 +429,7 @@ export function PassthroughTerminal({ sessionId: propSessionId, terminalId, labe
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskId, sessionId, canConnect, fitAndResize, wsBaseUrl, terminalId, label]);
|
||||
}, [taskId, sessionId, canConnect, fitAndResize, wsBaseUrl, mode, terminalId, label]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -596,7 +596,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
|
||||
{activeTaskId ? (
|
||||
isPassthroughMode ? (
|
||||
<div className="flex-1 min-h-0 h-full" style={{ minHeight: '150px' }}>
|
||||
<PassthroughTerminal key={activeSessionId} sessionId={sessionId} terminalId="default" />
|
||||
<PassthroughTerminal key={activeSessionId} sessionId={sessionId} mode="agent" />
|
||||
</div>
|
||||
) : (
|
||||
<TaskChatPanel
|
||||
|
||||
@@ -137,6 +137,9 @@ export function TaskPageContent({
|
||||
const storeSessionState = useAppStore((state) =>
|
||||
effectiveSessionId ? state.taskSessions.items[effectiveSessionId]?.state ?? null : null
|
||||
);
|
||||
const isSessionPassthrough = useAppStore((state) =>
|
||||
effectiveSessionId ? state.taskSessions.items[effectiveSessionId]?.is_passthrough === true : false
|
||||
);
|
||||
const agentctlStatus = useSessionAgentctl(effectiveSessionId);
|
||||
const previewOpen = useAppStore((state) =>
|
||||
effectiveSessionId ? state.previewPanel.openBySessionId[effectiveSessionId] ?? false : false
|
||||
@@ -247,6 +250,7 @@ export function TaskPageContent({
|
||||
baseBranch={task?.repositories?.[0]?.base_branch}
|
||||
isAgentRunning={isAgentWorking}
|
||||
hasWorktree={Boolean(worktreeBranch)}
|
||||
isPassthrough={isSessionPassthrough}
|
||||
/>
|
||||
{DEBUG_UI && showDebugOverlay && (
|
||||
<DebugOverlay
|
||||
|
||||
@@ -216,6 +216,7 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null
|
||||
<PassthroughTerminal
|
||||
key={terminal.id}
|
||||
sessionId={sessionId ?? undefined}
|
||||
mode="shell"
|
||||
terminalId={terminal.id}
|
||||
label={terminal.type === 'shell' ? terminal.label : undefined}
|
||||
/>
|
||||
|
||||
@@ -50,6 +50,7 @@ export const TerminalPanel = memo(function TerminalPanel(
|
||||
<PanelBody padding={false} scroll={false}>
|
||||
<PassthroughTerminal
|
||||
sessionId={sessionId ?? undefined}
|
||||
mode="shell"
|
||||
terminalId={terminalId}
|
||||
/>
|
||||
</PanelBody>
|
||||
|
||||
@@ -53,6 +53,7 @@ export function useSettingsData(enabled = true) {
|
||||
label: `${profile.agent_display_name} • ${profile.name}`,
|
||||
agent_id: agent.id,
|
||||
agent_name: agent.name,
|
||||
cli_passthrough: profile.cli_passthrough,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -267,7 +267,7 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
|
||||
id: 'chat',
|
||||
component: 'chat',
|
||||
tabComponent: 'permanentTab',
|
||||
title: 'Chat',
|
||||
title: 'Agent',
|
||||
});
|
||||
|
||||
// Sidebar panel — split off to the left
|
||||
@@ -340,7 +340,7 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
|
||||
id: 'chat',
|
||||
component: 'chat',
|
||||
tabComponent: 'permanentTab',
|
||||
title: 'Chat',
|
||||
title: 'Agent',
|
||||
position: { referenceGroup: centerGroupId },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ export type KanbanStepEvents = {
|
||||
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
on_exit?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
export type KanbanState = {
|
||||
|
||||
@@ -36,6 +36,7 @@ export type AgentProfileOption = {
|
||||
label: string;
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
cli_passthrough?: boolean;
|
||||
};
|
||||
|
||||
export type AgentProfilesState = {
|
||||
|
||||
@@ -176,6 +176,7 @@ export type TaskSessionStateChangedPayload = {
|
||||
agent_profile_id?: string;
|
||||
agent_profile_snapshot?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
is_passthrough?: boolean;
|
||||
// Workflow-related fields (sent during workflow transitions)
|
||||
review_status?: string;
|
||||
workflow_step_id?: string;
|
||||
|
||||
@@ -19,6 +19,9 @@ export type OnTurnStartActionType = 'move_to_next' | 'move_to_previous' | 'move_
|
||||
// On Turn Complete action types
|
||||
export type OnTurnCompleteActionType = 'move_to_next' | 'move_to_previous' | 'move_to_step' | 'disable_plan_mode';
|
||||
|
||||
// On Exit action types
|
||||
export type OnExitActionType = 'disable_plan_mode';
|
||||
|
||||
export type OnEnterAction = {
|
||||
type: OnEnterActionType;
|
||||
config?: Record<string, unknown>;
|
||||
@@ -34,10 +37,16 @@ export type OnTurnCompleteAction = {
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type OnExitAction = {
|
||||
type: OnExitActionType;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type StepEvents = {
|
||||
on_enter?: OnEnterAction[];
|
||||
on_turn_start?: OnTurnStartAction[];
|
||||
on_turn_complete?: OnTurnCompleteAction[];
|
||||
on_exit?: OnExitAction[];
|
||||
};
|
||||
|
||||
// Workflow Review Status
|
||||
@@ -278,6 +287,7 @@ export type TaskSession = {
|
||||
updated_at: string;
|
||||
// Workflow fields
|
||||
is_primary?: boolean;
|
||||
is_passthrough?: boolean;
|
||||
workflow_step_id?: string;
|
||||
review_status?: WorkflowReviewStatus;
|
||||
};
|
||||
|
||||
@@ -41,12 +41,15 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
const agentProfileId = payload.agent_profile_id;
|
||||
const agentProfileSnapshot = payload.agent_profile_snapshot as Record<string, unknown> | undefined;
|
||||
|
||||
const isPassthrough = payload.is_passthrough as boolean | undefined;
|
||||
|
||||
// Build the update object with all possible fields
|
||||
const sessionUpdate: Record<string, unknown> = {};
|
||||
if (newState) sessionUpdate.state = newState;
|
||||
if (reviewStatus !== undefined) sessionUpdate.review_status = reviewStatus;
|
||||
if (workflowStepId !== undefined) sessionUpdate.workflow_step_id = workflowStepId;
|
||||
if (agentProfileSnapshot) sessionUpdate.agent_profile_snapshot = agentProfileSnapshot;
|
||||
if (isPassthrough !== undefined) sessionUpdate.is_passthrough = isPassthrough;
|
||||
|
||||
if (existingSession) {
|
||||
// Update existing session - preserve all existing fields
|
||||
|
||||
Reference in New Issue
Block a user