diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 2b1eb861d2..e3d7ca6dd6 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -223,6 +223,14 @@ func (a *lifecycleAdapter) IsPassthroughSession(ctx context.Context, sessionID s return a.mgr.IsPassthroughSession(ctx, sessionID) } +func (a *lifecycleAdapter) WritePassthroughStdin(ctx context.Context, sessionID string, data string) error { + return a.mgr.WritePassthroughStdin(ctx, sessionID, data) +} + +func (a *lifecycleAdapter) MarkPassthroughRunning(sessionID string) error { + return a.mgr.MarkPassthroughRunning(sessionID) +} + func (a *lifecycleAdapter) PollRemoteStatusForRecords(ctx context.Context, records []executor.RemoteStatusPollRequest) { lcRecords := make([]lifecycle.RemoteStatusPollRecord, len(records)) for i, r := range records { diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index 9b8c5170b5..7ea1289bfd 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -27,6 +27,13 @@ var mcpServers map[string]mcpServerDef func main() { model := parseModelFlag() + + // TUI mode: simple terminal UI for passthrough/PTY testing + if parseTUIFlag() { + runTUI(model, parsePromptFlag()) + return + } + mcpServers = parseMCPConfigFlag() defer closeMCPClients() diff --git a/apps/backend/cmd/mock-agent/tui.go b/apps/backend/cmd/mock-agent/tui.go new file mode 100644 index 0000000000..4b3ffc6629 --- /dev/null +++ b/apps/backend/cmd/mock-agent/tui.go @@ -0,0 +1,93 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "slices" + "strings" +) + +// runTUI starts a simple terminal UI mode for passthrough/PTY testing. +// It renders a prompt, processes input, shows a response, and waits for more input. +// The idle timeout in InteractiveRunner triggers turn completion when output stops. +func runTUI(model, initialPrompt string) { + // Print header + fmt.Print("\033[1;36m╭─ Mock Agent ─╮\033[0m\r\n") + fmt.Print("\033[1;36m╰──────────────╯\033[0m\r\n\r\n") + + // Process initial prompt if provided via --prompt flag + if initialPrompt != "" { + fmt.Printf("\033[1;32m❯\033[0m %s\r\n", initialPrompt) + processTUIPrompt(initialPrompt, model) + } + + // Show ready prompt and wait for stdin + fmt.Print("\033[1;32m❯\033[0m ") + + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + fmt.Print("\033[1;32m❯\033[0m ") + continue + } + processTUIPrompt(line, model) + fmt.Print("\033[1;32m❯\033[0m ") + } + + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "mock-agent tui: scanner error: %v\n", err) + os.Exit(1) + } +} + +// processTUIPrompt simulates agent processing: shows thinking, then a response. +func processTUIPrompt(prompt, model string) { + // Show thinking state + fmt.Print("\033[33m⠋ Thinking...\033[0m\r\n") + fixedDelay(tuiDelay(model)) + + // Show response + fmt.Print("\r\n") + fmt.Print("This is a simple mock response to your request.\r\n") + if len(prompt) > 80 { + prompt = prompt[:80] + "..." + } + fmt.Printf("Processed: %s\r\n\r\n", prompt) +} + +// tuiDelay returns the thinking delay in milliseconds based on the model. +func tuiDelay(model string) int { + switch model { + case "mock-fast": + return 100 + case "mock-slow": + return 2000 + default: + return 500 + } +} + +// parseTUIFlag checks if --tui is present in the command line args. +func parseTUIFlag() bool { + return slices.Contains(os.Args[1:], "--tui") +} + +// parsePromptFlag extracts --prompt value from command line args. +func parsePromptFlag() string { + return parsePromptFromArgs(os.Args) +} + +// parsePromptFromArgs extracts --prompt value from the given args slice. +func parsePromptFromArgs(args []string) string { + for i, arg := range args[1:] { + if arg == "--prompt" && i+1 < len(args)-1 { + return args[i+2] + } + if v, ok := strings.CutPrefix(arg, "--prompt="); ok { + return v + } + } + return "" +} diff --git a/apps/backend/cmd/mock-agent/tui_test.go b/apps/backend/cmd/mock-agent/tui_test.go new file mode 100644 index 0000000000..4ffd672d48 --- /dev/null +++ b/apps/backend/cmd/mock-agent/tui_test.go @@ -0,0 +1,72 @@ +package main + +import "testing" + +func TestParsePromptFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "no flag returns empty", + args: []string{"mock-agent"}, + want: "", + }, + { + name: "separate flag and value", + args: []string{"mock-agent", "--prompt", "fix the bug"}, + want: "fix the bug", + }, + { + name: "equals syntax", + args: []string{"mock-agent", "--prompt=fix the bug"}, + want: "fix the bug", + }, + { + name: "prompt as last arg without value", + args: []string{"mock-agent", "--prompt"}, + want: "", + }, + { + name: "prompt with other flags before", + args: []string{"mock-agent", "--model", "mock-fast", "--prompt", "hello world"}, + want: "hello world", + }, + { + name: "prompt with other flags after", + args: []string{"mock-agent", "--prompt", "hello world", "--tui"}, + want: "hello world", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parsePromptFromArgs(tt.args) + if got != tt.want { + t.Errorf("parsePromptFromArgs(%v) = %q, want %q", tt.args, got, tt.want) + } + }) + } +} + +func TestTuiDelay(t *testing.T) { + tests := []struct { + model string + want int + }{ + {"mock-fast", 100}, + {"mock-slow", 2000}, + {"mock-default", 500}, + {"unknown", 500}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + got := tuiDelay(tt.model) + if got != tt.want { + t.Errorf("tuiDelay(%q) = %d, want %d", tt.model, got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go index 0426a66e58..20b54be7b8 100644 --- a/apps/backend/internal/agent/agents/mock.go +++ b/apps/backend/internal/agent/agents/mock.go @@ -14,21 +14,45 @@ var mockLogoLight []byte //go:embed logos/mock_dark.svg var mockLogoDark []byte -var _ Agent = (*MockAgent)(nil) +var ( + _ Agent = (*MockAgent)(nil) + _ PassthroughAgent = (*MockAgent)(nil) +) type MockAgent struct { + StandardPassthrough enabled bool binaryPath string supportsMCP bool } -func NewMockAgent() *MockAgent { return &MockAgent{supportsMCP: true} } +func NewMockAgent() *MockAgent { + return &MockAgent{ + StandardPassthrough: StandardPassthrough{ + Cfg: PassthroughConfig{ + Supported: true, + Label: "TUI Passthrough", + Description: "Terminal UI mode for testing", + PassthroughCmd: NewCommand("mock-agent", "--tui"), + ModelFlag: NewParam("--model", "{model}"), + PromptFlag: NewParam("--prompt", "{prompt}"), + IdleTimeout: 2 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, + }, + }, + supportsMCP: true, + } +} // SetEnabled enables or disables the mock agent at runtime. func (a *MockAgent) SetEnabled(enabled bool) { a.enabled = enabled } // SetBinaryPath overrides the mock-agent binary path. -func (a *MockAgent) SetBinaryPath(path string) { a.binaryPath = path } +// Also updates the passthrough command to use the same binary. +func (a *MockAgent) SetBinaryPath(path string) { + a.binaryPath = path + a.Cfg.PassthroughCmd = NewCommand(path, "--tui") +} // SetSupportsMCP controls whether the mock agent reports MCP support. // Defaults to true so plan mode workflow events work in E2E tests. diff --git a/apps/backend/internal/agent/lifecycle/manager_interaction.go b/apps/backend/internal/agent/lifecycle/manager_interaction.go index 23818a8fdf..ecf02d7878 100644 --- a/apps/backend/internal/agent/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/lifecycle/manager_interaction.go @@ -138,16 +138,20 @@ func (m *Manager) StopBySessionID(ctx context.Context, sessionID string, force b return m.StopAgent(ctx, execution.ID, force) } -// RestartAgentProcess stops the agent subprocess and starts a fresh one with a new ACP session, -// clearing the agent's conversation context. The execution environment (container/agentctl) is -// preserved — only the agent process inside agentctl is restarted. Works for both local and remote -// executors since it operates via the agentctl HTTP API. +// RestartAgentProcess stops the agent subprocess and starts a fresh one, clearing the agent's +// conversation context. For ACP agents this restarts via agentctl with a new ACP session. +// For passthrough (TUI) agents this kills the PTY process and relaunches without --resume. func (m *Manager) RestartAgentProcess(ctx context.Context, executionID string) error { execution, exists := m.executionStore.Get(executionID) if !exists { return fmt.Errorf("execution %q not found: %w", executionID, ErrExecutionNotFound) } + // Passthrough agents: kill PTY and relaunch fresh (no --resume). + if execution.PassthroughProcessID != "" { + return m.restartPassthroughProcess(ctx, execution) + } + if execution.agentctl == nil { return fmt.Errorf("execution %q has no agentctl client", executionID) } diff --git a/apps/backend/internal/agent/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/lifecycle/manager_passthrough.go index 46fec5d2a0..9c98e5b168 100644 --- a/apps/backend/internal/agent/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/lifecycle/manager_passthrough.go @@ -162,6 +162,43 @@ func (m *Manager) startPassthroughShell(ctx context.Context, execution *AgentExe } } +// resolvedPassthrough holds the agent config, passthrough config, runtime config, and profile +// info resolved from an execution. Used as the basis for building passthrough commands. +type resolvedPassthrough struct { + agentID string + agent agents.PassthroughAgent + pt agents.PassthroughConfig + rt *agents.RuntimeConfig + profile *AgentProfileInfo +} + +// resolvePassthroughAgent loads the agent config and profile for a passthrough execution. +// Shared by passthroughAgentCommand, freshPassthroughCommand, and ResumePassthroughSession. +func (m *Manager) resolvePassthroughAgent(ctx context.Context, execution *AgentExecution) (*resolvedPassthrough, error) { + agentConfig, err := m.getAgentConfigForExecution(execution) + if err != nil { + return nil, fmt.Errorf("failed to get agent config: %w", err) + } + + ptAgent, ok := agentConfig.(agents.PassthroughAgent) + if !ok { + return nil, fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) + } + + var profileInfo *AgentProfileInfo + if m.profileResolver != nil && execution.AgentProfileID != "" { + profileInfo, _ = m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) + } + + return &resolvedPassthrough{ + agentID: agentConfig.ID(), + agent: ptAgent, + pt: ptAgent.PassthroughConfig(), + rt: agentConfig.Runtime(), + profile: profileInfo, + }, nil +} + // passthroughAgentCommand validates passthrough support and builds the command for a passthrough session. // Returns the PassthroughAgent, PassthroughConfig, RuntimeConfig pointer, command, and any error. func (m *Manager) passthroughAgentCommand(execution *AgentExecution, profileInfo *AgentProfileInfo) (agents.PassthroughAgent, agents.PassthroughConfig, *agents.RuntimeConfig, agents.Command, error) { @@ -191,6 +228,27 @@ func (m *Manager) passthroughAgentCommand(execution *AgentExecution, profileInfo return ptAgent, pt, rt, cmd, nil } +// buildInteractiveStartRequest builds the InteractiveStartRequest for a passthrough session. +// immediateStart overrides pt.WaitForTerminal when true (used for restart/resume where the +// terminal WebSocket is already connected). +func buildInteractiveStartRequest(sessionID string, execution *AgentExecution, pt agents.PassthroughConfig, env map[string]string, cmd agents.Command, immediateStart bool) process.InteractiveStartRequest { + return process.InteractiveStartRequest{ + SessionID: sessionID, + Command: cmd.Args(), + WorkingDir: execution.WorkspacePath, + Env: env, + PromptPattern: pt.PromptPattern, + IdleTimeout: pt.IdleTimeout, + BufferMaxBytes: pt.BufferMaxBytes, + StatusDetector: pt.StatusDetector, + CheckInterval: pt.CheckInterval, + StabilityWindow: pt.StabilityWindow, + ImmediateStart: immediateStart, + DefaultCols: 120, + DefaultRows: 40, + } +} + // startInteractiveProcess launches the interactive PTY process for a passthrough session. // Returns the process info on success. func (m *Manager) startInteractiveProcess(ctx context.Context, execution *AgentExecution, pt agents.PassthroughConfig, env map[string]string, cmd agents.Command) (*process.InteractiveProcessInfo, error) { @@ -201,21 +259,7 @@ func (m *Manager) startInteractiveProcess(ctx context.Context, execution *AgentE // Some agents (like Codex) require the terminal to be connected first because // they query the terminal for cursor position on startup. - startReq := process.InteractiveStartRequest{ - SessionID: execution.SessionID, - Command: cmd.Args(), - WorkingDir: execution.WorkspacePath, - Env: env, - PromptPattern: pt.PromptPattern, - IdleTimeout: pt.IdleTimeout, - BufferMaxBytes: pt.BufferMaxBytes, - StatusDetector: pt.StatusDetector, - CheckInterval: pt.CheckInterval, - StabilityWindow: pt.StabilityWindow, - ImmediateStart: !pt.WaitForTerminal, - DefaultCols: 120, - DefaultRows: 40, - } + startReq := buildInteractiveStartRequest(execution.SessionID, execution, pt, env, cmd, !pt.WaitForTerminal) processInfo, err := interactiveRunner.Start(ctx, startReq) if err != nil { @@ -283,6 +327,89 @@ func profilePermissionValues(p *AgentProfileInfo) map[string]bool { } } +// freshPassthroughCommand resolves the agent config and profile, and builds a +// bare passthrough command with no session, resume, or prompt flags. +func (m *Manager) freshPassthroughCommand(ctx context.Context, execution *AgentExecution) (agents.PassthroughConfig, *agents.RuntimeConfig, agents.Command, error) { + resolved, err := m.resolvePassthroughAgent(ctx, execution) + if err != nil { + return agents.PassthroughConfig{}, nil, agents.Command{}, err + } + + cmd := resolved.agent.BuildPassthroughCommand(agents.PassthroughOptions{ + Model: profileModel(resolved.profile), + PermissionValues: profilePermissionValues(resolved.profile), + }) + if cmd.IsEmpty() { + return agents.PassthroughConfig{}, nil, agents.Command{}, fmt.Errorf("passthrough command is empty for agent %s", resolved.agentID) + } + + return resolved.pt, resolved.rt, cmd, nil +} + +// restartPassthroughProcess kills the current PTY process and relaunches a fresh one +// without --resume, effectively clearing the agent's conversation context. +// The workflow step prompt is delivered afterwards via stdin (autoStartPassthroughPrompt). +func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *AgentExecution) error { + m.logger.Info("restarting passthrough process for context reset", + zap.String("execution_id", execution.ID), + zap.String("session_id", execution.SessionID), + zap.String("old_process_id", execution.PassthroughProcessID)) + + // 1. Stop the current PTY process. + // Clear PassthroughProcessID before stopping so that handlePassthroughStatus + // doesn't trigger auto-restart for the deliberately-killed process. + interactiveRunner := m.GetInteractiveRunner() + if interactiveRunner == nil { + return fmt.Errorf("interactive runner not available") + } + + oldProcessID := execution.PassthroughProcessID + execution.PassthroughProcessID = "" + + if err := interactiveRunner.Stop(ctx, oldProcessID); err != nil { + m.logger.Warn("failed to stop passthrough process during context reset", + zap.String("execution_id", execution.ID), + zap.String("process_id", oldProcessID), + zap.Error(err)) + } + + // 2. Build fresh command (no SessionID, no Resume, no Prompt) + pt, rt, cmd, err := m.freshPassthroughCommand(ctx, execution) + if err != nil { + return err + } + + // 3. Start new PTY process with ImmediateStart (terminal is already connected) + env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv) + startReq := buildInteractiveStartRequest(execution.SessionID, execution, pt, env, cmd, true) + + processInfo, err := interactiveRunner.Start(ctx, startReq) + if err != nil { + m.updateExecutionError(execution.ID, "failed to restart passthrough session: "+err.Error()) + return fmt.Errorf("failed to restart passthrough session: %w", err) + } + + // 4. Update execution with new process ID + execution.PassthroughProcessID = processInfo.ID + + m.logger.Info("passthrough process restarted with fresh context", + zap.String("execution_id", execution.ID), + zap.String("session_id", execution.SessionID), + zap.String("new_process_id", processInfo.ID)) + + // 5. Reconnect existing WebSocket to the new process + if interactiveRunner.ConnectSessionWebSocket(processInfo.ID) { + m.logger.Debug("reconnected WebSocket to restarted passthrough process", + zap.String("session_id", execution.SessionID), + zap.String("process_id", processInfo.ID)) + } + + // 6. Publish context reset event + m.eventPublisher.PublishAgentEvent(ctx, events.AgentContextReset, execution) + + return nil +} + // ResumePassthroughSession restarts a passthrough session after backend restart. // This is called when user reconnects to a terminal but the PTY process is no longer running. // If the agent supports resume, it uses the resume flag to continue the last conversation. @@ -293,34 +420,19 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string return fmt.Errorf("no execution found for session: %s", sessionID) } - // Get agent config - agentConfig, err := m.getAgentConfigForExecution(execution) + resolved, err := m.resolvePassthroughAgent(ctx, execution) if err != nil { - return fmt.Errorf("failed to get agent config: %w", err) - } - - ptAgent, ok := agentConfig.(agents.PassthroughAgent) - if !ok { - return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) - } - - pt := ptAgent.PassthroughConfig() - rt := agentConfig.Runtime() - - // Get profile info for permission settings - var profileInfo *AgentProfileInfo - if m.profileResolver != nil && execution.AgentProfileID != "" { - profileInfo, _ = m.profileResolver.ResolveProfile(ctx, execution.AgentProfileID) + return err } // Build the resume command - cmd := ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ - Model: profileModel(profileInfo), + cmd := resolved.agent.BuildPassthroughCommand(agents.PassthroughOptions{ + Model: profileModel(resolved.profile), Resume: true, - PermissionValues: profilePermissionValues(profileInfo), + PermissionValues: profilePermissionValues(resolved.profile), }) if cmd.IsEmpty() { - return fmt.Errorf("passthrough resume command is empty for agent %s", agentConfig.ID()) + return fmt.Errorf("passthrough resume command is empty for agent %s", resolved.agentID) } m.logger.Info("resuming passthrough session", @@ -328,42 +440,25 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string zap.String("execution_id", execution.ID), zap.Strings("command", cmd.Args())) - // Get the interactive runner interactiveRunner := m.GetInteractiveRunner() if interactiveRunner == nil { return fmt.Errorf("interactive runner not available") } - // Build environment variables including required credentials - env := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv) + env := m.buildPassthroughEnv(ctx, execution, resolved.rt.RequiredEnv) - // Start the interactive process. // Always use immediate start on resume — the terminal WebSocket is already connected, // so we don't need to wait for a resize to get exact dimensions. The first resize // from the terminal will correct the dimensions. Without this, TUI apps that use // WaitForTerminal would never start because the frontend may not send resizes // to a process it doesn't know about yet. - startReq := process.InteractiveStartRequest{ - SessionID: sessionID, - Command: cmd.Args(), - WorkingDir: execution.WorkspacePath, - Env: env, - IdleTimeout: pt.IdleTimeout, - BufferMaxBytes: pt.BufferMaxBytes, - StatusDetector: pt.StatusDetector, - CheckInterval: pt.CheckInterval, - StabilityWindow: pt.StabilityWindow, - ImmediateStart: true, - DefaultCols: 120, - DefaultRows: 40, - } + startReq := buildInteractiveStartRequest(sessionID, execution, resolved.pt, env, cmd, true) processInfo, err := interactiveRunner.Start(ctx, startReq) if err != nil { return fmt.Errorf("failed to start passthrough session: %w", err) } - // Update the execution with new process ID execution.PassthroughProcessID = processInfo.ID m.logger.Info("passthrough session resumed", diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index 2041b898db..c1248b762c 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -154,11 +154,19 @@ func (wt *WorkspaceTracker) resolveSafePath(reqPath string) (string, error) { cleanWorkDir := filepath.Clean(wt.workDir) cleanReqPath := filepath.Clean(reqPath) + // Resolve workspace directory symlinks first so that all constructed + // paths share the same canonical prefix (e.g. /private/var on macOS + // where /var is a symlink). + realWorkDir, err := filepath.EvalSymlinks(cleanWorkDir) + if err != nil { + realWorkDir = cleanWorkDir + } + var safePath string if filepath.IsAbs(cleanReqPath) { safePath = cleanReqPath } else { - safePath = filepath.Join(wt.workDir, cleanReqPath) + safePath = filepath.Join(realWorkDir, cleanReqPath) } // Resolve symlinks to prevent bypassing validation @@ -176,12 +184,6 @@ func (wt *WorkspaceTracker) resolveSafePath(reqPath string) (string, error) { } } - // Resolve workspace directory symlinks for consistent comparison - realWorkDir, err := filepath.EvalSymlinks(cleanWorkDir) - if err != nil { - realWorkDir = cleanWorkDir - } - // Check that the real path is within the workspace relPath, err := filepath.Rel(realWorkDir, realPath) if err != nil { diff --git a/apps/backend/internal/integration/simulated_agent_manager_test.go b/apps/backend/internal/integration/simulated_agent_manager_test.go index 00e5b697e1..1dd905e2c1 100644 --- a/apps/backend/internal/integration/simulated_agent_manager_test.go +++ b/apps/backend/internal/integration/simulated_agent_manager_test.go @@ -408,6 +408,12 @@ func (s *SimulatedAgentManagerClient) RestartAgentProcess(ctx context.Context, a func (s *SimulatedAgentManagerClient) IsPassthroughSession(ctx context.Context, sessionID string) bool { return false } +func (s *SimulatedAgentManagerClient) WritePassthroughStdin(_ context.Context, _ string, _ string) error { + return nil +} +func (s *SimulatedAgentManagerClient) MarkPassthroughRunning(_ string) error { + return nil +} func (s *SimulatedAgentManagerClient) GetRemoteRuntimeStatusBySession(ctx context.Context, sessionID string) (*executor.RemoteRuntimeStatus, error) { return nil, nil diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index cfd7725261..da76b6e0b2 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -158,12 +158,19 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD zap.Bool("is_queued", queueStatus.IsQueued), zap.Any("message", queueStatus.Message)) - // 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. + // Passthrough sessions: deliver queued messages via PTY stdin instead of ACP. if s.agentManager.IsPassthroughSession(ctx, data.SessionID) { - s.logger.Info("skipping queued message for passthrough session", - zap.String("session_id", data.SessionID)) + queuedMsg, exists := s.messageQueue.TakeQueued(ctx, data.SessionID) + if !exists { + return + } + if queuedMsg.Content != "" { + if err := s.deliverPassthroughPrompt(ctx, data.SessionID, queuedMsg.Content); err != nil { + s.logger.Warn("failed to deliver queued message to passthrough", + zap.String("session_id", data.SessionID), + zap.Error(err)) + } + } return } diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 544cd55310..c6ae51f61d 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" "errors" + "fmt" "path/filepath" "sync" "testing" @@ -104,6 +105,12 @@ type mockAgentManager struct { mu sync.Mutex stopAgentWithReasonArgs []stopAgentCall // tracks StopAgentWithReason calls + + // Passthrough stdin tracking + passthroughStdinCalls []passthroughStdinCall + passthroughStdinErr error + markPassthroughCalls []string // session IDs + markPassthroughErr error } type stopAgentCall struct { @@ -112,6 +119,11 @@ type stopAgentCall struct { Force bool } +type passthroughStdinCall struct { + SessionID string + Data string +} + func (m *mockAgentManager) LaunchAgent(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { return nil, nil } @@ -158,6 +170,18 @@ func (m *mockAgentManager) SetExecutionDescription(_ context.Context, _, _ strin func (m *mockAgentManager) IsPassthroughSession(_ context.Context, _ string) bool { return m.isPassthrough } +func (m *mockAgentManager) WritePassthroughStdin(_ context.Context, sessionID string, data string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.passthroughStdinCalls = append(m.passthroughStdinCalls, passthroughStdinCall{SessionID: sessionID, Data: data}) + return m.passthroughStdinErr +} +func (m *mockAgentManager) MarkPassthroughRunning(sessionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.markPassthroughCalls = append(m.markPassthroughCalls, sessionID) + return m.markPassthroughErr +} func (m *mockAgentManager) GetRemoteRuntimeStatusBySession(_ context.Context, _ string) (*executor.RemoteRuntimeStatus, error) { return nil, nil } @@ -843,6 +867,183 @@ func TestHandleAgentRunning_PassthroughGuard(t *testing.T) { }) } +func TestDeliverPassthroughPrompt(t *testing.T) { + ctx := context.Background() + + t.Run("writes to stdin and marks running", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + agentMgr := &mockAgentManager{isPassthrough: true} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + + err := svc.deliverPassthroughPrompt(ctx, "s1", "hello") + if err != nil { + t.Fatalf("deliverPassthroughPrompt returned error: %v", err) + } + + agentMgr.mu.Lock() + defer agentMgr.mu.Unlock() + + if len(agentMgr.passthroughStdinCalls) != 1 { + t.Fatalf("expected 1 stdin call, got %d", len(agentMgr.passthroughStdinCalls)) + } + call := agentMgr.passthroughStdinCalls[0] + if call.SessionID != "s1" { + t.Errorf("stdin sessionID = %q, want %q", call.SessionID, "s1") + } + if call.Data != "hello\r" { + t.Errorf("stdin data = %q, want %q", call.Data, "hello\r") + } + if len(agentMgr.markPassthroughCalls) != 1 || agentMgr.markPassthroughCalls[0] != "s1" { + t.Errorf("markPassthroughRunning calls = %v, want [s1]", agentMgr.markPassthroughCalls) + } + }) + + t.Run("returns error when stdin write fails", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + agentMgr := &mockAgentManager{ + isPassthrough: true, + passthroughStdinErr: fmt.Errorf("stdin write failed"), + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + + err := svc.deliverPassthroughPrompt(ctx, "s1", "hello") + if err == nil { + t.Fatal("expected error, got nil") + } + + agentMgr.mu.Lock() + defer agentMgr.mu.Unlock() + + // Should not call markPassthroughRunning when stdin write fails + if len(agentMgr.markPassthroughCalls) != 0 { + t.Errorf("markPassthroughRunning should not be called when stdin fails, got %d calls", len(agentMgr.markPassthroughCalls)) + } + }) +} + +func TestHandleAgentReady_PassthroughQueuedMessage(t *testing.T) { + ctx := context.Background() + + t.Run("delivers queued message to passthrough via stdin", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + // Set session to RUNNING so handleAgentReady doesn't early-return + session, _ := repo.GetTaskSession(ctx, "s1") + session.State = models.TaskSessionStateRunning + _ = repo.UpdateTaskSession(ctx, session) + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + } + + agentMgr := &mockAgentManager{isPassthrough: true} + svc := createTestServiceWithAgent(repo, stepGetter, newMockTaskRepo(), agentMgr) + + // Queue a message + if _, err := svc.messageQueue.QueueMessage(ctx, "s1", "t1", "queued prompt", "", "test", false, nil); err != nil { + t.Fatalf("failed to queue message: %v", err) + } + + svc.handleAgentReady(ctx, watcher.AgentEventData{TaskID: "t1", SessionID: "s1"}) + + agentMgr.mu.Lock() + defer agentMgr.mu.Unlock() + + // Verify the queued message was delivered to passthrough stdin + if len(agentMgr.passthroughStdinCalls) != 1 { + t.Fatalf("expected 1 stdin call, got %d", len(agentMgr.passthroughStdinCalls)) + } + call := agentMgr.passthroughStdinCalls[0] + if call.Data != "queued prompt\r" { + t.Errorf("stdin data = %q, want %q", call.Data, "queued prompt\r") + } + + // Queue should be empty after delivery + status := svc.messageQueue.GetStatus(ctx, "s1") + if status.IsQueued { + t.Error("expected queue to be empty after delivery") + } + }) + + t.Run("skips delivery when no queued message exists", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + session, _ := repo.GetTaskSession(ctx, "s1") + session.State = models.TaskSessionStateRunning + _ = repo.UpdateTaskSession(ctx, session) + + stepGetter := newMockStepGetter() + stepGetter.steps["step1"] = &wfmodels.WorkflowStep{ + ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0, + } + + agentMgr := &mockAgentManager{isPassthrough: true} + svc := createTestServiceWithAgent(repo, stepGetter, newMockTaskRepo(), agentMgr) + + // No queued message — should return early + svc.handleAgentReady(ctx, watcher.AgentEventData{TaskID: "t1", SessionID: "s1"}) + + agentMgr.mu.Lock() + defer agentMgr.mu.Unlock() + + if len(agentMgr.passthroughStdinCalls) != 0 { + t.Errorf("expected no stdin calls, got %d", len(agentMgr.passthroughStdinCalls)) + } + }) +} + +func TestAutoStartPassthroughPrompt(t *testing.T) { + ctx := context.Background() + + t.Run("writes prompt to stdin and logs step name", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + agentMgr := &mockAgentManager{isPassthrough: true} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + + session, _ := repo.GetTaskSession(ctx, "s1") + err := svc.autoStartPassthroughPrompt(ctx, "t1", session, "Analyze", "do analysis") + if err != nil { + t.Fatalf("autoStartPassthroughPrompt returned error: %v", err) + } + + agentMgr.mu.Lock() + defer agentMgr.mu.Unlock() + + if len(agentMgr.passthroughStdinCalls) != 1 { + t.Fatalf("expected 1 stdin call, got %d", len(agentMgr.passthroughStdinCalls)) + } + if agentMgr.passthroughStdinCalls[0].Data != "do analysis\r" { + t.Errorf("stdin data = %q, want %q", agentMgr.passthroughStdinCalls[0].Data, "do analysis\r") + } + }) + + t.Run("returns error when stdin write fails", func(t *testing.T) { + repo := setupTestRepo(t) + seedSession(t, repo, "t1", "s1", "step1") + + agentMgr := &mockAgentManager{ + isPassthrough: true, + passthroughStdinErr: fmt.Errorf("stdin write failed"), + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + + session, _ := repo.GetTaskSession(ctx, "s1") + err := svc.autoStartPassthroughPrompt(ctx, "t1", session, "Analyze", "do analysis") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + // waitForStopCall polls until the mock agent manager has received at least one // StopAgentWithReason call, or fails the test after a timeout. func waitForStopCall(t *testing.T, agentMgr *mockAgentManager) { diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 05a546dc17..e5b3160349 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -450,15 +450,28 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo } } - // 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 + switch { + case hasAutoStart && isPassthrough: + // Passthrough path: write prompt directly to PTY stdin. + // By the time processOnEnter runs (from an on_turn_complete transition), + // the agent has finished its previous turn and the PTY is waiting for input. effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID) - planMode := hasPlanMode + if err := s.autoStartPassthroughPrompt(ctx, taskID, session, step.Name, effectivePrompt); err != nil { + s.logger.Error("failed to auto-start passthrough agent for step", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.String("step_name", step.Name), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, sessionID, session) + s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID) + } + case hasAutoStart: + // ACP path: build prompt from step configuration. // Run auto-start inline so queue state is visible before handleAgentReady // checks for queued messages. + effectivePrompt := s.buildWorkflowPrompt(taskDescription, step, taskID) + planMode := hasPlanMode err := s.autoStartStepPrompt(ctx, taskID, session, step.Name, effectivePrompt, planMode, true) if err != nil { s.logger.Error("failed to auto-start agent for step", @@ -468,13 +481,47 @@ func (s *Service) processOnEnter(ctx context.Context, taskID string, session *mo zap.Error(err)) s.setSessionWaitingForInput(ctx, taskID, sessionID, session) } - return - } else { + + default: s.setSessionWaitingForInput(ctx, taskID, sessionID, session) s.publishSessionWaitingEvent(ctx, taskID, sessionID, step.ID) } } +// deliverPassthroughPrompt writes a prompt to PTY stdin and marks the session as running. +// Appends \r (carriage return) to simulate pressing Enter — TUI agents in raw terminal mode +// expect CR, not LF, as the submit key. Returns an error only if writing fails. +func (s *Service) deliverPassthroughPrompt(ctx context.Context, sessionID, content string) error { + if err := s.agentManager.WritePassthroughStdin(ctx, sessionID, content+"\r"); err != nil { + return fmt.Errorf("write to passthrough stdin: %w", err) + } + if err := s.agentManager.MarkPassthroughRunning(sessionID); err != nil { + s.logger.Warn("failed to mark passthrough as running", + zap.String("session_id", sessionID), + zap.Error(err)) + } + return nil +} + +// autoStartPassthroughPrompt writes a workflow prompt to the PTY stdin of a +// passthrough session and marks it as running. TUI agents read stdin line-by-line; +// the idle timeout fires when output stops, triggering turn complete. +func (s *Service) autoStartPassthroughPrompt( + ctx context.Context, + taskID string, + session *models.TaskSession, + stepName, prompt string, +) error { + if err := s.deliverPassthroughPrompt(ctx, session.ID, prompt); err != nil { + return err + } + s.logger.Info("auto-start: wrote prompt to passthrough stdin", + zap.String("task_id", taskID), + zap.String("session_id", session.ID), + zap.String("step_name", stepName)) + return nil +} + func (s *Service) autoStartStepPrompt( ctx context.Context, taskID string, session *models.TaskSession, stepName, prompt string, diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index ce0b94183e..c2c33a64a9 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -101,6 +101,12 @@ type AgentManagerClient interface { // IsPassthroughSession checks if the given session is running in passthrough (PTY) mode. IsPassthroughSession(ctx context.Context, sessionID string) bool + // WritePassthroughStdin writes data to the agent's PTY stdin for passthrough sessions. + WritePassthroughStdin(ctx context.Context, sessionID string, data string) error + + // MarkPassthroughRunning marks a passthrough execution as running. + MarkPassthroughRunning(sessionID string) error + // GetRemoteRuntimeStatusBySession returns remote runtime status metadata for a session // (used by UI cloud indicators). Returns nil,nil when unavailable. GetRemoteRuntimeStatusBySession(ctx context.Context, sessionID string) (*RemoteRuntimeStatus, error) diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index ecd0f05f13..9ec1be9881 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -80,6 +80,12 @@ func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, session func (m *mockAgentManager) IsPassthroughSession(ctx context.Context, sessionID string) bool { return false } +func (m *mockAgentManager) WritePassthroughStdin(_ context.Context, _ string, _ string) error { + return nil +} +func (m *mockAgentManager) MarkPassthroughRunning(_ string) error { + return nil +} func (m *mockAgentManager) GetRemoteRuntimeStatusBySession(ctx context.Context, sessionID string) (*RemoteRuntimeStatus, error) { return nil, nil diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index d572bbed26..2ccea8eb45 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -79,6 +79,12 @@ func (m *mockAgentManager) IsAgentRunningForSession(ctx context.Context, session func (m *mockAgentManager) IsPassthroughSession(ctx context.Context, sessionID string) bool { return false } +func (m *mockAgentManager) WritePassthroughStdin(_ context.Context, _ string, _ string) error { + return nil +} +func (m *mockAgentManager) MarkPassthroughRunning(_ string) error { + return nil +} func (m *mockAgentManager) GetRemoteRuntimeStatusBySession(ctx context.Context, sessionID string) (*executor.RemoteRuntimeStatus, error) { return nil, nil diff --git a/apps/backend/internal/orchestrator/workflow_e2e_test.go b/apps/backend/internal/orchestrator/workflow_e2e_test.go index 66fa5adbb0..f1222839d8 100644 --- a/apps/backend/internal/orchestrator/workflow_e2e_test.go +++ b/apps/backend/internal/orchestrator/workflow_e2e_test.go @@ -155,15 +155,16 @@ var workflowTestCases = []workflowTestCase{ }, }, { - Name: "passthrough_skips_auto_start", + Name: "passthrough_auto_start_via_stdin", WorkflowJSON: developmentWorkflowJSON, StartStep: "In Progress", IsPassthrough: true, Events: []testEvent{ - // Passthrough: reset fires but auto_start is skipped + // Passthrough: reset fires, auto_start writes prompt to PTY stdin + // (not queued via message queue). Session is not set to WaitingForInput + // because the agent is processing the stdin prompt. {Trigger: engine.TriggerOnTurnComplete, ExpectStep: "New Context", - ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 1, - ExpectState: models.TaskSessionStateWaitingForInput}, + ExpectTransitioned: true, ExpectQueued: false, ExpectResets: 1}, }, }, } diff --git a/apps/web/components/task/passthrough-terminal.tsx b/apps/web/components/task/passthrough-terminal.tsx index 05018f759e..604697460d 100644 --- a/apps/web/components/task/passthrough-terminal.tsx +++ b/apps/web/components/task/passthrough-terminal.tsx @@ -1,17 +1,25 @@ "use client"; -import React, { useEffect, useRef, useCallback, useMemo, useState } from "react"; +import React, { useRef, useCallback, useMemo, useState } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { AttachAddon } from "@xterm/addon-attach"; -import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebglAddon } from "@xterm/addon-webgl"; import "@xterm/xterm/css/xterm.css"; +import { GridSpinner } from "@/components/grid-spinner"; import { useAppStore } from "@/components/state-provider"; import { useSession } from "@/hooks/domains/session/use-session"; import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl"; import { getBackendConfig } from "@/lib/config"; -import { getTerminalTheme } from "@/lib/theme/terminal-theme"; +import { + log, + MIN_WIDTH, + MIN_HEIGHT, + useTerminalInit, + useWebSocketConnection, + useSendResize, + useFitAndResize, +} from "./use-passthrough-terminal"; type BaseProps = { sessionId?: string | null; @@ -20,492 +28,6 @@ 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; -const log = (...args: unknown[]) => { - if (DEBUG) console.log("[PassthroughTerminal]", ...args); -}; - -// Minimum dimensions to prevent zero-size issues -const MIN_WIDTH = 100; -const MIN_HEIGHT = 100; - -type TerminalInitOptions = { - terminalRef: React.RefObject; - xtermRef: React.MutableRefObject; - fitAddonRef: React.MutableRefObject; - isInitializedRef: React.MutableRefObject; - lastDimensionsRef: React.MutableRefObject<{ cols: number; rows: number }>; - resizeTimeoutRef: React.MutableRefObject | null>; - webglAddonRef: React.MutableRefObject; - fitAndResize: (force?: boolean) => void; - onReady: () => void; -}; - -function initTerminalInstance( - termContainer: HTMLDivElement, - refs: Pick< - TerminalInitOptions, - | "xtermRef" - | "fitAddonRef" - | "isInitializedRef" - | "lastDimensionsRef" - | "webglAddonRef" - | "resizeTimeoutRef" - >, - fitAndResize: (force?: boolean) => void, -) { - if (refs.isInitializedRef.current || refs.xtermRef.current) return undefined; - refs.isInitializedRef.current = true; - log("Creating terminal"); - const terminal = new Terminal({ - allowProposedApi: true, - cursorBlink: true, - disableStdin: false, - convertEol: false, - scrollOnUserInput: true, - scrollback: 5000, - fontSize: 13, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - theme: getTerminalTheme(termContainer), - }); - const fitAddon = new FitAddon(); - terminal.loadAddon(fitAddon); - const unicode11Addon = new Unicode11Addon(); - terminal.loadAddon(unicode11Addon); - terminal.unicode.activeVersion = "11"; - log("Opening terminal in container"); - terminal.open(termContainer); - try { - fitAddon.fit(); - refs.lastDimensionsRef.current = { cols: terminal.cols, rows: terminal.rows }; - log("Initial fit:", terminal.cols, "x", terminal.rows); - } catch (e) { - log("Initial fit failed:", e); - } - try { - const webglAddon = new WebglAddon(); - webglAddon.onContextLoss(() => { - log("WebGL context lost"); - webglAddon.dispose(); - refs.webglAddonRef.current = null; - }); - terminal.loadAddon(webglAddon); - refs.webglAddonRef.current = webglAddon; - log("WebGL addon loaded"); - } catch (e) { - log("WebGL failed, using canvas:", e); - } - refs.xtermRef.current = terminal; - refs.fitAddonRef.current = fitAddon; - // Expose buffer reader on the container for e2e tests (xterm renders to - // canvas so text isn't accessible in the DOM). - (termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer = - () => { - const buf = terminal.buffer.active; - const lines: string[] = []; - for (let i = 0; i <= buf.baseY + buf.cursorY; i++) { - lines.push(buf.getLine(i)?.translateToString(true) ?? ""); - } - return lines.join("\n"); - }; - const handleResize = () => { - const rect = termContainer.getBoundingClientRect(); - if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) { - log("Skipping resize - too small"); - return; - } - if (refs.resizeTimeoutRef.current) clearTimeout(refs.resizeTimeoutRef.current); - refs.resizeTimeoutRef.current = setTimeout(() => { - fitAndResize(); - }, 100); - }; - const resizeObserver = new ResizeObserver(handleResize); - resizeObserver.observe(termContainer); - return () => { - log("Terminal cleanup"); - if (refs.resizeTimeoutRef.current) clearTimeout(refs.resizeTimeoutRef.current); - resizeObserver.disconnect(); - if (refs.webglAddonRef.current) { - refs.webglAddonRef.current.dispose(); - refs.webglAddonRef.current = null; - } - terminal.dispose(); - (termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer = - undefined; - refs.xtermRef.current = null; - refs.fitAddonRef.current = null; - refs.isInitializedRef.current = false; - refs.lastDimensionsRef.current = { cols: 0, rows: 0 }; - }; -} - -function useTerminalInit({ - terminalRef, - xtermRef, - fitAddonRef, - isInitializedRef, - lastDimensionsRef, - resizeTimeoutRef, - webglAddonRef, - fitAndResize, - onReady, -}: TerminalInitOptions) { - const refs = { - xtermRef, - fitAddonRef, - isInitializedRef, - lastDimensionsRef, - resizeTimeoutRef, - webglAddonRef, - }; - useEffect(() => { - log("Terminal init effect"); - const container = terminalRef.current; - if (!container) { - log("No container ref"); - return; - } - if (isInitializedRef.current) { - log("Already initialized"); - return; - } - - const tryInit = () => { - if (isInitializedRef.current) return true; - const rect = container.getBoundingClientRect(); - log("Init check: dimensions", rect.width, "x", rect.height); - if (rect.width >= MIN_WIDTH && rect.height >= MIN_HEIGHT) { - initTerminalInstance(container, refs, fitAndResize); - onReady(); - return true; - } - return false; - }; - - // If the container already has dimensions, init immediately. - if (tryInit()) { - // Already initialized — cleanup handled by initTerminalInstance's return - } else { - // Container is 0×0 (e.g. terminal is in a background dockview tab). - // Use a ResizeObserver to wait until the container becomes visible - // instead of polling with rAF and force-initializing at 0×0 - // (which causes black backgrounds since CSS vars can't be read). - log("Container not visible, waiting via ResizeObserver"); - const observer = new ResizeObserver(() => { - if (tryInit()) { - observer.disconnect(); - } - }); - observer.observe(container); - return () => { - observer.disconnect(); - }; - } - - const resizeTimeout = resizeTimeoutRef; - const webgl = webglAddonRef; - const xterm = xtermRef; - const fitAddon = fitAddonRef; - return () => { - log("Effect cleanup"); - if (resizeTimeout.current) clearTimeout(resizeTimeout.current); - if (webgl.current) { - webgl.current.dispose(); - webgl.current = null; - } - if (xterm.current) { - xterm.current.dispose(); - xterm.current = null; - } - fitAddon.current = null; - isInitializedRef.current = false; - lastDimensionsRef.current = { cols: 0, rows: 0 }; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fitAndResize]); -} - -type WebSocketConnectionOptions = { - taskId: string | null; - sessionId: string | null | undefined; - canConnect: boolean; - isTerminalReady: boolean; - fitAndResize: (force?: boolean) => void; - wsBaseUrl: string; - mode: "agent" | "shell"; - terminalId: string | undefined; - label?: string; - xtermRef: React.MutableRefObject; - fitAddonRef: React.MutableRefObject; - wsRef: React.MutableRefObject; - attachAddonRef: React.MutableRefObject; -}; - -function buildWsUrl( - wsBaseUrl: string, - sessionId: string, - mode: "agent" | "shell", - terminalId: string | undefined, - label?: string, -): string { - let wsUrl = - mode === "agent" - ? `${wsBaseUrl}/terminal/${sessionId}?mode=agent` - : `${wsBaseUrl}/terminal/${sessionId}?mode=shell&terminalId=${encodeURIComponent(terminalId!)}`; - if (label) wsUrl += `&label=${encodeURIComponent(label)}`; - return wsUrl; -} - -type ConnectWebSocketOptions = { - sessionId: string; - wsBaseUrl: string; - mode: "agent" | "shell"; - terminalId: string | undefined; - label: string | undefined; - terminal: Terminal; - fitAndResize: (force?: boolean) => void; - wsRef: React.MutableRefObject; - attachAddonRef: React.MutableRefObject; - isMountedCheck: () => boolean; - onTimeout: (id: ReturnType) => void; -}; - -function connectWebSocket({ - sessionId, - wsBaseUrl, - mode, - terminalId, - label, - terminal, - fitAndResize, - wsRef, - attachAddonRef, - isMountedCheck, - onTimeout, -}: ConnectWebSocketOptions) { - if (attachAddonRef.current) { - attachAddonRef.current.dispose(); - attachAddonRef.current = null; - } - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - const wsUrl = buildWsUrl(wsBaseUrl, sessionId, mode, terminalId, label); - log("Connecting to", wsUrl, { mode, terminalId, label }); - const ws = new WebSocket(wsUrl); - ws.binaryType = "arraybuffer"; - wsRef.current = ws; - ws.onopen = () => { - if (!isMountedCheck()) { - ws.close(); - return; - } - log("WebSocket connected"); - const attachAddon = new AttachAddon(ws, { bidirectional: true }); - terminal.loadAddon(attachAddon); - attachAddonRef.current = attachAddon; - // Send initial resize (forced) so the backend knows our terminal dimensions, - // then one deferred resize to catch layout settling + force a full redraw. - // The WebGL renderer can become stale when the container transitions through - // 0×0 (portal system detach/reattach), so we must explicitly refresh. - requestAnimationFrame(() => { - if (isMountedCheck() && ws.readyState === WebSocket.OPEN) { - fitAndResize(true); - } - }); - const settleTimeout = setTimeout(() => { - if (!isMountedCheck()) return; - if (ws.readyState === WebSocket.OPEN) { - fitAndResize(true); - } - // Force full redraw — WebGL canvas may be stale after portal 0×0 transition - terminal.refresh(0, terminal.rows - 1); - }, 500); - onTimeout(settleTimeout); - }; - ws.onclose = (event) => { - log("WebSocket closed:", event.code, event.reason); - if (attachAddonRef.current) { - attachAddonRef.current.dispose(); - attachAddonRef.current = null; - } - }; - ws.onerror = (error) => { - log("WebSocket error:", error); - }; -} - -function useWebSocketConnection({ - taskId, - sessionId, - canConnect, - isTerminalReady, - fitAndResize, - wsBaseUrl, - mode, - terminalId, - label, - xtermRef, - fitAddonRef, - wsRef, - attachAddonRef, -}: WebSocketConnectionOptions) { - useEffect(() => { - log("WebSocket effect:", { - taskId, - sessionId, - mode, - terminalId, - canConnect, - isTerminalReady, - hasTerminal: !!xtermRef.current, - }); - if (!taskId || !sessionId || !canConnect || !isTerminalReady) { - log("WebSocket effect: early return", { taskId, sessionId, canConnect, isTerminalReady }); - return; - } - if (!xtermRef.current || !fitAddonRef.current) { - log("Terminal not ready for WebSocket (refs missing despite isTerminalReady)"); - return; - } - const terminal = xtermRef.current; - let isMounted = true; - let connectTimeout: ReturnType | null = null; - let settleTimeout: ReturnType | null = null; - connectTimeout = setTimeout(() => { - if (!isMounted) return; - connectWebSocket({ - sessionId, - wsBaseUrl, - mode, - terminalId, - label, - terminal, - fitAndResize, - wsRef, - attachAddonRef, - isMountedCheck: () => isMounted, - onTimeout: (id) => { - settleTimeout = id; - }, - }); - }, 150); - return () => { - log("WebSocket cleanup"); - isMounted = false; - if (connectTimeout) clearTimeout(connectTimeout); - if (settleTimeout) clearTimeout(settleTimeout); - if (attachAddonRef.current) { - attachAddonRef.current.dispose(); - attachAddonRef.current = null; - } - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - }; - }, [ - taskId, - sessionId, - canConnect, - isTerminalReady, - fitAndResize, - wsBaseUrl, - mode, - terminalId, - label, - xtermRef, - fitAddonRef, - wsRef, - attachAddonRef, - ]); -} - -function buildResizeBuffer(cols: number, rows: number): Uint8Array { - const json = JSON.stringify({ cols, rows }); - const encoder = new TextEncoder(); - const jsonBytes = encoder.encode(json); - const buffer = new Uint8Array(1 + jsonBytes.length); - buffer[0] = 0x01; - buffer.set(jsonBytes, 1); - return buffer; -} - -function useSendResize(wsRef: React.MutableRefObject) { - return useCallback( - (cols: number, rows: number) => { - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) { - log("sendResize: WebSocket not ready", ws?.readyState); - return; - } - if (cols <= 0 || rows <= 0) { - log("sendResize: invalid dimensions", cols, rows); - return; - } - log("sendResize:", cols, "x", rows); - ws.send(buildResizeBuffer(cols, rows)); - }, - [wsRef], - ); -} - -type FitAndResizeOptions = { - xtermRef: React.MutableRefObject; - fitAddonRef: React.MutableRefObject; - terminalRef: React.RefObject; - lastDimensionsRef: React.MutableRefObject<{ cols: number; rows: number }>; - sendResize: (cols: number, rows: number) => void; -}; - -function useFitAndResize({ - xtermRef, - fitAddonRef, - terminalRef, - lastDimensionsRef, - sendResize, -}: FitAndResizeOptions) { - return useCallback( - (force = false) => { - const terminal = xtermRef.current; - const fitAddon = fitAddonRef.current; - const container = terminalRef.current; - if (!terminal || !fitAddon || !container) { - log("fitAndResize: missing refs"); - return; - } - const rect = container.getBoundingClientRect(); - if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) { - log("fitAndResize: container too small, skipping"); - return; - } - try { - fitAddon.fit(); - log("fitAndResize: fit done", terminal.cols, "x", terminal.rows); - } catch (e) { - log("fitAndResize: fit failed", e); - return; - } - const { cols, rows } = terminal; - const last = lastDimensionsRef.current; - const changed = cols !== last.cols || rows !== last.rows; - const wasZero = last.cols === 0 && last.rows === 0; - if (force || changed) { - lastDimensionsRef.current = { cols, rows }; - sendResize(cols, rows); - } - // Force full redraw when transitioning from uninitialized/zero dimensions — - // the WebGL canvas may be stale after the container was at 0×0 (portal moves). - if (wasZero || changed) { - terminal.refresh(0, terminal.rows - 1); - } - }, - [xtermRef, fitAddonRef, terminalRef, lastDimensionsRef, sendResize], - ); -} - /** * PassthroughTerminal provides direct terminal interaction with an agent CLI. * @@ -556,6 +78,17 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { setIsTerminalReady(true); }, []); + // Track which session has an active WebSocket connection. The loading + // overlay is shown whenever the current sessionId doesn't match the + // connected one — this naturally resets on session switches without + // needing a separate effect that calls setState. + const [connectedSessionId, setConnectedSessionId] = useState(null); + const isConnected = sessionId != null && connectedSessionId === sessionId; + const onConnected = useCallback(() => { + log("WebSocket connected — hiding loading overlay for session", sessionId); + setConnectedSessionId(sessionId ?? null); + }, [sessionId]); + const sendResize = useSendResize(wsRef); const fitAndResize = useFitAndResize({ xtermRef, @@ -576,6 +109,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { fitAndResize, onReady: onTerminalReady, }); + useWebSocketConnection({ taskId, sessionId, @@ -590,14 +124,29 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { fitAddonRef, wsRef, attachAddonRef, + onConnected, }); return (
-
+
+ {!isConnected && ( +
+
+ + + {mode === "agent" ? "Starting agent..." : "Connecting..."} + +
+
+ )}
); } diff --git a/apps/web/components/task/use-passthrough-terminal.ts b/apps/web/components/task/use-passthrough-terminal.ts new file mode 100644 index 0000000000..b2e205c3bf --- /dev/null +++ b/apps/web/components/task/use-passthrough-terminal.ts @@ -0,0 +1,508 @@ +import { useEffect, useCallback } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import { AttachAddon } from "@xterm/addon-attach"; +import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebglAddon } from "@xterm/addon-webgl"; +import { getTerminalTheme } from "@/lib/theme/terminal-theme"; + +// Debug flag - set to true to see detailed logs +const DEBUG = false; +export const log = (...args: unknown[]) => { + if (DEBUG) console.log("[PassthroughTerminal]", ...args); +}; + +// Minimum dimensions to prevent zero-size issues +export const MIN_WIDTH = 100; +export const MIN_HEIGHT = 100; + +export type TerminalInitOptions = { + terminalRef: React.RefObject; + xtermRef: React.MutableRefObject; + fitAddonRef: React.MutableRefObject; + isInitializedRef: React.MutableRefObject; + lastDimensionsRef: React.MutableRefObject<{ cols: number; rows: number }>; + resizeTimeoutRef: React.MutableRefObject | null>; + webglAddonRef: React.MutableRefObject; + fitAndResize: (force?: boolean) => void; + onReady: () => void; +}; + +function initTerminalInstance( + termContainer: HTMLDivElement, + refs: Pick< + TerminalInitOptions, + | "xtermRef" + | "fitAddonRef" + | "isInitializedRef" + | "lastDimensionsRef" + | "webglAddonRef" + | "resizeTimeoutRef" + >, + fitAndResize: (force?: boolean) => void, +) { + if (refs.isInitializedRef.current || refs.xtermRef.current) return undefined; + refs.isInitializedRef.current = true; + log("Creating terminal"); + const terminal = new Terminal({ + allowProposedApi: true, + cursorBlink: true, + disableStdin: false, + convertEol: false, + scrollOnUserInput: true, + scrollback: 5000, + fontSize: 13, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + theme: getTerminalTheme(termContainer), + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + const unicode11Addon = new Unicode11Addon(); + terminal.loadAddon(unicode11Addon); + terminal.unicode.activeVersion = "11"; + log("Opening terminal in container"); + terminal.open(termContainer); + try { + fitAddon.fit(); + refs.lastDimensionsRef.current = { cols: terminal.cols, rows: terminal.rows }; + log("Initial fit:", terminal.cols, "x", terminal.rows); + } catch (e) { + log("Initial fit failed:", e); + } + try { + const webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + log("WebGL context lost"); + webglAddon.dispose(); + refs.webglAddonRef.current = null; + }); + terminal.loadAddon(webglAddon); + refs.webglAddonRef.current = webglAddon; + log("WebGL addon loaded"); + } catch (e) { + log("WebGL failed, using canvas:", e); + } + refs.xtermRef.current = terminal; + refs.fitAddonRef.current = fitAddon; + // Expose buffer reader on the container for e2e tests (xterm renders to + // canvas so text isn't accessible in the DOM). + (termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer = + () => { + const buf = terminal.buffer.active; + const lines: string[] = []; + for (let i = 0; i <= buf.baseY + buf.cursorY; i++) { + lines.push(buf.getLine(i)?.translateToString(true) ?? ""); + } + return lines.join("\n"); + }; + const handleResize = () => { + const rect = termContainer.getBoundingClientRect(); + if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) { + log("Skipping resize - too small"); + return; + } + if (refs.resizeTimeoutRef.current) clearTimeout(refs.resizeTimeoutRef.current); + refs.resizeTimeoutRef.current = setTimeout(() => { + fitAndResize(); + }, 100); + }; + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(termContainer); + return () => { + log("Terminal cleanup"); + if (refs.resizeTimeoutRef.current) clearTimeout(refs.resizeTimeoutRef.current); + resizeObserver.disconnect(); + if (refs.webglAddonRef.current) { + refs.webglAddonRef.current.dispose(); + refs.webglAddonRef.current = null; + } + terminal.dispose(); + (termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer = + undefined; + refs.xtermRef.current = null; + refs.fitAddonRef.current = null; + refs.isInitializedRef.current = false; + refs.lastDimensionsRef.current = { cols: 0, rows: 0 }; + }; +} + +export function useTerminalInit({ + terminalRef, + xtermRef, + fitAddonRef, + isInitializedRef, + lastDimensionsRef, + resizeTimeoutRef, + webglAddonRef, + fitAndResize, + onReady, +}: TerminalInitOptions) { + const refs = { + xtermRef, + fitAddonRef, + isInitializedRef, + lastDimensionsRef, + resizeTimeoutRef, + webglAddonRef, + }; + useEffect(() => { + log("Terminal init effect"); + const container = terminalRef.current; + if (!container) { + log("No container ref"); + return; + } + if (isInitializedRef.current) { + log("Already initialized"); + return; + } + + const tryInit = () => { + if (isInitializedRef.current) return true; + const rect = container.getBoundingClientRect(); + log("Init check: dimensions", rect.width, "x", rect.height); + if (rect.width >= MIN_WIDTH && rect.height >= MIN_HEIGHT) { + initTerminalInstance(container, refs, fitAndResize); + onReady(); + return true; + } + return false; + }; + + // If the container already has dimensions, init immediately. + if (tryInit()) { + // Already initialized — cleanup handled by initTerminalInstance's return + } else { + // Container is 0×0 (e.g. terminal is in a background dockview tab). + // Use a ResizeObserver to wait until the container becomes visible + // instead of polling with rAF and force-initializing at 0×0 + // (which causes black backgrounds since CSS vars can't be read). + log("Container not visible, waiting via ResizeObserver"); + const observer = new ResizeObserver(() => { + if (tryInit()) { + observer.disconnect(); + } + }); + observer.observe(container); + return () => { + observer.disconnect(); + }; + } + + const resizeTimeout = resizeTimeoutRef; + const webgl = webglAddonRef; + const xterm = xtermRef; + const fitAddon = fitAddonRef; + return () => { + log("Effect cleanup"); + if (resizeTimeout.current) clearTimeout(resizeTimeout.current); + if (webgl.current) { + webgl.current.dispose(); + webgl.current = null; + } + if (xterm.current) { + xterm.current.dispose(); + xterm.current = null; + } + fitAddon.current = null; + isInitializedRef.current = false; + lastDimensionsRef.current = { cols: 0, rows: 0 }; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fitAndResize]); +} + +export type WebSocketConnectionOptions = { + taskId: string | null; + sessionId: string | null | undefined; + canConnect: boolean; + isTerminalReady: boolean; + fitAndResize: (force?: boolean) => void; + wsBaseUrl: string; + mode: "agent" | "shell"; + terminalId: string | undefined; + label?: string; + xtermRef: React.MutableRefObject; + fitAddonRef: React.MutableRefObject; + wsRef: React.MutableRefObject; + attachAddonRef: React.MutableRefObject; + onConnected: () => void; +}; + +function buildWsUrl( + wsBaseUrl: string, + sessionId: string, + mode: "agent" | "shell", + terminalId: string | undefined, + label?: string, +): string { + let wsUrl = + mode === "agent" + ? `${wsBaseUrl}/terminal/${sessionId}?mode=agent` + : `${wsBaseUrl}/terminal/${sessionId}?mode=shell&terminalId=${encodeURIComponent(terminalId!)}`; + if (label) wsUrl += `&label=${encodeURIComponent(label)}`; + return wsUrl; +} + +type ConnectWebSocketOptions = { + sessionId: string; + wsBaseUrl: string; + mode: "agent" | "shell"; + terminalId: string | undefined; + label: string | undefined; + terminal: Terminal; + fitAndResize: (force?: boolean) => void; + wsRef: React.MutableRefObject; + attachAddonRef: React.MutableRefObject; + isMountedCheck: () => boolean; + onTimeout: (id: ReturnType) => void; + onConnected: () => void; +}; + +function connectWebSocket({ + sessionId, + wsBaseUrl, + mode, + terminalId, + label, + terminal, + fitAndResize, + wsRef, + attachAddonRef, + isMountedCheck, + onTimeout, + onConnected, +}: ConnectWebSocketOptions) { + if (attachAddonRef.current) { + attachAddonRef.current.dispose(); + attachAddonRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + const wsUrl = buildWsUrl(wsBaseUrl, sessionId, mode, terminalId, label); + log("Connecting to", wsUrl, { mode, terminalId, label }); + const ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + wsRef.current = ws; + ws.onopen = () => { + if (!isMountedCheck()) { + ws.close(); + return; + } + log("WebSocket connected"); + const attachAddon = new AttachAddon(ws, { bidirectional: true }); + terminal.loadAddon(attachAddon); + attachAddonRef.current = attachAddon; + onConnected(); + // Send initial resize (forced) so the backend knows our terminal dimensions, + // then one deferred resize to catch layout settling + force a full redraw. + // The WebGL renderer can become stale when the container transitions through + // 0×0 (portal system detach/reattach), so we must explicitly refresh. + requestAnimationFrame(() => { + if (isMountedCheck() && ws.readyState === WebSocket.OPEN) { + fitAndResize(true); + } + }); + const settleTimeout = setTimeout(() => { + if (!isMountedCheck()) return; + if (ws.readyState === WebSocket.OPEN) { + fitAndResize(true); + } + // Force full redraw — WebGL canvas may be stale after portal 0×0 transition + terminal.refresh(0, terminal.rows - 1); + }, 500); + onTimeout(settleTimeout); + }; + ws.onclose = (event) => { + log("WebSocket closed:", event.code, event.reason); + if (attachAddonRef.current) { + attachAddonRef.current.dispose(); + attachAddonRef.current = null; + } + }; + ws.onerror = (error) => { + log("WebSocket error:", error); + }; +} + +export function useWebSocketConnection({ + taskId, + sessionId, + canConnect, + isTerminalReady, + fitAndResize, + wsBaseUrl, + mode, + terminalId, + label, + xtermRef, + fitAddonRef, + wsRef, + attachAddonRef, + onConnected, +}: WebSocketConnectionOptions) { + useEffect(() => { + log("WebSocket effect:", { + taskId, + sessionId, + mode, + terminalId, + canConnect, + isTerminalReady, + hasTerminal: !!xtermRef.current, + }); + if (!taskId || !sessionId || !canConnect || !isTerminalReady) { + log("WebSocket effect: early return", { taskId, sessionId, canConnect, isTerminalReady }); + return; + } + if (!xtermRef.current || !fitAddonRef.current) { + log("Terminal not ready for WebSocket (refs missing despite isTerminalReady)"); + return; + } + const terminal = xtermRef.current; + + // Clear the terminal buffer before reconnecting. The chat panel is global + // (not session-scoped), so the same xterm instance persists across session + // switches. Without this reset, the previous session's PTY output leaks + // into the new session's scrollback. + log("Resetting terminal buffer for session", sessionId); + terminal.reset(); + + let isMounted = true; + let connectTimeout: ReturnType | null = null; + let settleTimeout: ReturnType | null = null; + connectTimeout = setTimeout(() => { + if (!isMounted) return; + connectWebSocket({ + sessionId, + wsBaseUrl, + mode, + terminalId, + label, + terminal, + fitAndResize, + wsRef, + attachAddonRef, + isMountedCheck: () => isMounted, + onTimeout: (id) => { + settleTimeout = id; + }, + onConnected, + }); + }, 150); + return () => { + log("WebSocket cleanup"); + isMounted = false; + if (connectTimeout) clearTimeout(connectTimeout); + if (settleTimeout) clearTimeout(settleTimeout); + if (attachAddonRef.current) { + attachAddonRef.current.dispose(); + attachAddonRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [ + taskId, + sessionId, + canConnect, + isTerminalReady, + fitAndResize, + wsBaseUrl, + mode, + terminalId, + label, + xtermRef, + fitAddonRef, + wsRef, + attachAddonRef, + onConnected, + ]); +} + +function buildResizeBuffer(cols: number, rows: number): Uint8Array { + const json = JSON.stringify({ cols, rows }); + const encoder = new TextEncoder(); + const jsonBytes = encoder.encode(json); + const buffer = new Uint8Array(1 + jsonBytes.length); + buffer[0] = 0x01; + buffer.set(jsonBytes, 1); + return buffer; +} + +export function useSendResize(wsRef: React.MutableRefObject) { + return useCallback( + (cols: number, rows: number) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + log("sendResize: WebSocket not ready", ws?.readyState); + return; + } + if (cols <= 0 || rows <= 0) { + log("sendResize: invalid dimensions", cols, rows); + return; + } + log("sendResize:", cols, "x", rows); + ws.send(buildResizeBuffer(cols, rows)); + }, + [wsRef], + ); +} + +export type FitAndResizeOptions = { + xtermRef: React.MutableRefObject; + fitAddonRef: React.MutableRefObject; + terminalRef: React.RefObject; + lastDimensionsRef: React.MutableRefObject<{ cols: number; rows: number }>; + sendResize: (cols: number, rows: number) => void; +}; + +export function useFitAndResize({ + xtermRef, + fitAddonRef, + terminalRef, + lastDimensionsRef, + sendResize, +}: FitAndResizeOptions) { + return useCallback( + (force = false) => { + const terminal = xtermRef.current; + const fitAddon = fitAddonRef.current; + const container = terminalRef.current; + if (!terminal || !fitAddon || !container) { + log("fitAndResize: missing refs"); + return; + } + const rect = container.getBoundingClientRect(); + if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) { + log("fitAndResize: container too small, skipping"); + return; + } + try { + fitAddon.fit(); + log("fitAndResize: fit done", terminal.cols, "x", terminal.rows); + } catch (e) { + log("fitAndResize: fit failed", e); + return; + } + const { cols, rows } = terminal; + const last = lastDimensionsRef.current; + const changed = cols !== last.cols || rows !== last.rows; + const wasZero = last.cols === 0 && last.rows === 0; + if (force || changed) { + lastDimensionsRef.current = { cols, rows }; + sendResize(cols, rows); + } + // Force full redraw when transitioning from uninitialized/zero dimensions — + // the WebGL canvas may be stale after the container was at 0×0 (portal moves). + if (wasZero || changed) { + terminal.refresh(0, terminal.rows - 1); + } + }, + [xtermRef, fitAddonRef, terminalRef, lastDimensionsRef, sendResize], + ); +} diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index fd72d13af5..c74c5633e2 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -80,9 +80,11 @@ export const test = backendFixture.extend< // SSR always resolves to the correct workspace regardless of what commitSettings // may have written during previous tests. testPage: async ({ browser, backend, apiClient, seedData }, use) => { - // Clean up tasks and test-created workflows from previous tests in this worker. - // Keep the seeded workflow so the worker-scoped seedData fixture remains valid. + // Clean up tasks, test-created workflows, and extra agent profiles from + // previous tests in this worker. Keep the seeded workflow and the seed + // agent profile so the worker-scoped seedData fixture remains valid. await apiClient.e2eReset(seedData.workspaceId, [seedData.workflowId]); + await apiClient.cleanupTestProfiles([seedData.agentProfileId]); await apiClient.saveUserSettings({ workspace_id: seedData.workspaceId, diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 03cec2ff4e..be0a48c674 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -125,6 +125,39 @@ export class ApiClient { return this.request("GET", "/api/v1/agents"); } + async deleteAgentProfile(profileId: string): Promise { + await this.request("DELETE", `/api/v1/agent-profiles/${profileId}`); + } + + /** Delete all agent profiles except the ones in keepIds. */ + async cleanupTestProfiles(keepIds: string[]): Promise { + const { agents } = await this.listAgents(); + for (const agent of agents) { + for (const profile of agent.profiles ?? []) { + if (!keepIds.includes(profile.id)) { + await this.deleteAgentProfile(profile.id); + } + } + } + } + + async createAgentProfile( + agentId: string, + name: string, + opts: { + model: string; + auto_approve?: boolean; + cli_passthrough?: boolean; + }, + ): Promise<{ id: string }> { + return this.request("POST", `/api/v1/agents/${agentId}/profiles`, { + name, + model: opts.model, + auto_approve: opts.auto_approve ?? true, + cli_passthrough: opts.cli_passthrough ?? false, + }); + } + async createTaskWithAgent( workspaceId: string, title: string, diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index f33e2833ed..88fef8e022 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -7,6 +7,7 @@ export class SessionPage { readonly files: Locator; readonly planPanel: Locator; readonly stepper: Locator; + readonly passthroughTerminal: Locator; constructor(private readonly page: Page) { this.chat = page.getByTestId("session-chat"); @@ -15,12 +16,74 @@ export class SessionPage { this.files = page.getByTestId("files-panel"); this.planPanel = page.getByTestId("plan-panel"); this.stepper = page.getByTestId("workflow-stepper"); + this.passthroughTerminal = page.getByTestId("passthrough-terminal"); } async waitForLoad(timeout = 15_000) { await this.chat.waitFor({ state: "visible", timeout }); } + /** Wait for the passthrough terminal to be visible (for TUI/passthrough sessions). */ + async waitForPassthroughLoad(timeout = 15_000) { + await this.passthroughTerminal.waitFor({ state: "visible", timeout }); + } + + /** Wait for the passthrough loading indicator to be visible (scoped to agent terminal). */ + async waitForPassthroughLoading(timeout = 5_000) { + await this.passthroughTerminal + .getByTestId("passthrough-loading") + .waitFor({ state: "visible", timeout }); + } + + /** Wait for the passthrough loading indicator to disappear (scoped to agent terminal). */ + async waitForPassthroughLoaded(timeout = 15_000) { + await this.passthroughTerminal + .getByTestId("passthrough-loading") + .waitFor({ state: "hidden", timeout }); + } + + /** + * Read the text content of an xterm.js terminal buffer. + * xterm renders to canvas/WebGL so text isn't in the DOM. Uses the + * __xtermReadBuffer() helper exposed on the terminal container element. + */ + private readXtermBuffer(testId: string): Promise { + return this.page.evaluate((tid) => { + const panel = document.querySelector(`[data-testid="${tid}"]`); + if (!panel) return ""; + const xtermEl = panel.querySelector(".xterm"); + type XC = HTMLElement & { __xtermReadBuffer?: () => string }; + const container = xtermEl?.parentElement as XC | null | undefined; + return container?.__xtermReadBuffer?.() ?? ""; + }, testId); + } + + /** + * Assert the passthrough terminal buffer contains the given text. + */ + async expectPassthroughHasText(text: string, timeout = 15_000): Promise { + await expect + .poll(async () => (await this.readXtermBuffer("passthrough-terminal")).includes(text), { + timeout, + message: `Expected passthrough terminal to contain "${text}"`, + }) + .toBe(true); + } + + /** + * Assert the passthrough terminal buffer does NOT contain the given text. + * Waits briefly to confirm absence (text could arrive asynchronously). + */ + async expectPassthroughNotHasText(text: string, stableMs = 2_000): Promise { + const start = Date.now(); + while (Date.now() - start < stableMs) { + if ((await this.readXtermBuffer("passthrough-terminal")).includes(text)) { + throw new Error(`Expected passthrough terminal NOT to contain "${text}", but it was found`); + } + await this.page.waitForTimeout(200); + } + } + /** Scoped to the sidebar — finds task title text rendered by TaskItem. */ taskInSidebar(title: string): Locator { return this.sidebar.getByText(title, { exact: false }); @@ -120,19 +183,10 @@ export class SessionPage { */ async typeInTerminal(command: string): Promise { await expect - .poll( - async () => { - return this.page.evaluate(() => { - const panel = document.querySelector('[data-testid="terminal-panel"]'); - const xtermEl = panel?.querySelector(".xterm"); - type XC = HTMLElement & { __xtermReadBuffer?: () => string }; - const container = xtermEl?.parentElement as XC | null | undefined; - const buf = container?.__xtermReadBuffer?.(); - return (buf?.length ?? 0) > 0; - }); - }, - { timeout: 15_000, message: "Waiting for terminal shell to connect" }, - ) + .poll(async () => (await this.readXtermBuffer("terminal-panel")).length > 0, { + timeout: 15_000, + message: "Waiting for terminal shell to connect", + }) .toBe(true); const xterm = this.terminal.locator(".xterm"); @@ -143,25 +197,13 @@ export class SessionPage { /** * Assert the terminal buffer contains the given text. - * xterm renders to canvas/WebGL so text isn't in the DOM. Uses the - * __xtermReadBuffer() helper exposed on the terminal container element. */ async expectTerminalHasText(text: string): Promise { await expect - .poll( - async () => { - return this.page.evaluate((marker) => { - const panel = document.querySelector('[data-testid="terminal-panel"]'); - if (!panel) return false; - const xtermEl = panel.querySelector(".xterm"); - type XC = HTMLElement & { __xtermReadBuffer?: () => string }; - const container = xtermEl?.parentElement as XC | null | undefined; - const buf = container?.__xtermReadBuffer?.(); - return buf?.includes(marker) ?? false; - }, text); - }, - { timeout: 10_000, message: `Expected terminal to contain "${text}"` }, - ) + .poll(async () => (await this.readXtermBuffer("terminal-panel")).includes(text), { + timeout: 10_000, + message: `Expected terminal to contain "${text}"`, + }) .toBe(true); } diff --git a/apps/web/e2e/tests/terminal-agent.spec.ts b/apps/web/e2e/tests/terminal-agent.spec.ts new file mode 100644 index 0000000000..20d8369da6 --- /dev/null +++ b/apps/web/e2e/tests/terminal-agent.spec.ts @@ -0,0 +1,314 @@ +import { test, expect } from "../fixtures/test-base"; +import type { SeedData } from "../fixtures/test-base"; +import type { ApiClient } from "../helpers/api-client"; +import { KanbanPage } from "../pages/kanban-page"; +import { SessionPage } from "../pages/session-page"; +import type { Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Helpers shared across TUI passthrough tests +// --------------------------------------------------------------------------- + +/** Create a passthrough (TUI) agent profile for the mock agent. */ +async function createTUIProfile(apiClient: ApiClient, name: string) { + const { agents } = await apiClient.listAgents(); + return apiClient.createAgentProfile(agents[0].id, name, { + model: "mock-fast", + auto_approve: true, + cli_passthrough: true, + }); +} + +/** Navigate to a kanban card by title and open its session page. */ +async function openTaskSession(page: Page, title: string): Promise { + const kanban = new KanbanPage(page); + await kanban.goto(); + + const card = kanban.taskCardByTitle(title); + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(page).toHaveURL(/\/s\//, { timeout: 15_000 }); + + const session = new SessionPage(page); + await session.waitForPassthroughLoad(); + return session; +} + +/** Seed a 4-step workflow (Backlog → Analyze → Implement → Review) with configurable step events. */ +async function seedCascadeWorkflow( + apiClient: ApiClient, + seedData: SeedData, + name: string, + implementOnEnter: Array<{ type: string }>, +) { + const workflow = await apiClient.createWorkflow(seedData.workspaceId, name); + + const backlogStep = await apiClient.createWorkflowStep(workflow.id, "Backlog", 0); + const analyzeStep = await apiClient.createWorkflowStep(workflow.id, "Analyze", 1); + const implementStep = await apiClient.createWorkflowStep(workflow.id, "Implement", 2); + await apiClient.createWorkflowStep(workflow.id, "Review", 3); + + await apiClient.updateWorkflowStep(analyzeStep.id, { + prompt: "Analyze: {{task_prompt}}", + events: { + on_enter: [{ type: "auto_start_agent" }], + on_turn_complete: [{ type: "move_to_next" }], + }, + }); + await apiClient.updateWorkflowStep(implementStep.id, { + prompt: "Implement: {{task_prompt}}", + events: { + on_enter: implementOnEnter, + on_turn_complete: [{ type: "move_to_next" }], + }, + }); + + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: workflow.id, + enable_preview_on_click: false, + }); + + return { workflow, backlogStep, analyzeStep, implementStep }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("Terminal agent (TUI passthrough)", () => { + /** + * Creates a task using a passthrough (TUI) agent profile. + * The mock agent starts in --tui mode inside a PTY, processes the initial + * prompt (passed via --prompt flag from the task description), renders a + * response in the terminal, and returns to idle. + * + * The idle timeout triggers turn completion, and the "simple" workflow + * template's on_turn_complete: move_to_step("review") advances the task. + * + * Verifies: + * - Passthrough terminal is visible (not the chat panel) + * - Mock agent TUI output appears in the terminal buffer + * - Workflow step transitions from In Progress → Review + * - Sidebar reflects the new step + */ + test("creates task with TUI agent, processes prompt, and advances to Review", async ({ + testPage, + apiClient, + seedData, + }) => { + const profile = await createTUIProfile(apiClient, "Mock TUI"); + + await apiClient.createTaskWithAgent(seedData.workspaceId, "TUI Test Task", profile.id, { + description: "hello from e2e test", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + const session = await openTaskSession(testPage, "TUI Test Task"); + + // Loading overlay is visible while the WebSocket connects + await session.waitForPassthroughLoading(); + + // Once connected, loading overlay disappears and terminal content is visible + await session.waitForPassthroughLoaded(); + + // The passthrough terminal shows the mock agent TUI header (rendered via xterm.js canvas). + // Note: response body text ("mock response") may not appear if the agent finishes + // before the frontend establishes the WebSocket — PTY history isn't replayed. + await session.expectPassthroughHasText("Mock Agent"); + + // After the agent completes its turn (idle timeout fires), the workflow + // advances from In Progress to Review via on_turn_complete: move_to_step + await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { + timeout: 30_000, + }); + + // Sidebar shows the task under the Review section + await expect(session.sidebarSection("Review")).toBeVisible({ timeout: 15_000 }); + }); + + /** + * Seeds a 4-step workflow (Backlog → Analyze → Implement → Review). + * Analyze and Implement both have: + * on_enter: [auto_start_agent], custom prompt with {{task_prompt}}, + * on_turn_complete: [move_to_next] + * Review has no events (terminal step). + * + * The agent starts in Backlog so we can navigate to the session page and + * establish the live terminal WebSocket. Then a moveTask to Analyze triggers + * the cascade: + * Analyze on_enter → prompt written to PTY stdin → turn complete → move_to_next + * Implement on_enter → prompt written to PTY stdin → turn complete → move_to_next + * → Review (terminal step, no events) + * + * Verifies: + * - Workflow auto_start_agent delivers custom prompts to passthrough agents via stdin + * - Each step's prompt text appears in the terminal buffer (captured via live WS) + * - Task cascades through all steps and lands in Review + * - Stepper reflects the final step + */ + test("multi-step workflow cascade with custom prompts via stdin", async ({ + testPage, + apiClient, + seedData, + }) => { + const { workflow, backlogStep, analyzeStep } = await seedCascadeWorkflow( + apiClient, + seedData, + "TUI Cascade Workflow", + [{ type: "auto_start_agent" }], + ); + + const profile = await createTUIProfile(apiClient, "TUI Cascade"); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "TUI Cascade Task", + profile.id, + { + description: "hello from cascade test", + workflow_id: workflow.id, + workflow_step_id: backlogStep.id, + repository_ids: [seedData.repositoryId], + }, + ); + + const session = await openTaskSession(testPage, "TUI Cascade Task"); + + // Confirm the mock TUI is running and processed the initial prompt + await session.expectPassthroughHasText("Mock Agent"); + await session.expectPassthroughHasText("mock response"); + + // Now trigger the cascade: move to Analyze → auto_start writes prompt to stdin + // → turn complete → move_to_next → Implement → auto_start writes prompt to stdin + // → turn complete → move_to_next → Review + await apiClient.moveTask(task.id, workflow.id, analyzeStep.id); + + // Terminal should show output from both auto-started step prompts. + // The mock TUI prints "Processed: " for each stdin line it receives. + await session.expectPassthroughHasText("Analyze", 30_000); + await session.expectPassthroughHasText("Implement", 30_000); + + // Stepper shows Review as current step after the full cascade + await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { + timeout: 30_000, + }); + }); + + /** + * Seeds a 4-step workflow (Backlog → Analyze → Implement → Review). + * Analyze has: on_enter: [auto_start_agent], on_turn_complete: [move_to_next] + * Implement has: on_enter: [reset_agent_context, auto_start_agent], + * on_turn_complete: [move_to_next] + * Review has no events (terminal step). + * + * The reset_agent_context action kills the PTY process and relaunches a fresh + * one without --resume. Then auto_start_agent delivers the step prompt via stdin. + * + * Verifies: + * - context_reset kills the PTY and relaunches (fresh "Mock Agent" header appears) + * - auto_start_agent delivers the Implement step prompt after reset + * - Task cascades through all steps and lands in Review + */ + test("context reset relaunches PTY and delivers prompt in cascade", async ({ + testPage, + apiClient, + seedData, + }) => { + const { workflow, backlogStep, analyzeStep } = await seedCascadeWorkflow( + apiClient, + seedData, + "TUI Reset Workflow", + [{ type: "reset_agent_context" }, { type: "auto_start_agent" }], + ); + + const profile = await createTUIProfile(apiClient, "TUI Reset"); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "TUI Reset Task", + profile.id, + { + description: "hello from reset test", + workflow_id: workflow.id, + workflow_step_id: backlogStep.id, + repository_ids: [seedData.repositoryId], + }, + ); + + const session = await openTaskSession(testPage, "TUI Reset Task"); + await session.expectPassthroughHasText("Mock Agent"); + + // Trigger cascade: Analyze → (turn complete) → Implement (reset + auto_start) → Review + await apiClient.moveTask(task.id, workflow.id, analyzeStep.id); + + // Terminal shows output from Analyze step (before reset) + await session.expectPassthroughHasText("Analyze", 30_000); + + // After context reset, Implement step prompt is delivered to the fresh process + await session.expectPassthroughHasText("Implement", 30_000); + + // Stepper shows Review as current step after the full cascade + await expect(session.stepperStep("Review")).toHaveAttribute("aria-current", "step", { + timeout: 30_000, + }); + }); + + /** + * Creates two tasks with TUI agents using distinct descriptions. + * Navigates to task A, confirms its terminal output, then switches to + * task B via the sidebar and confirms: + * - Task B's terminal shows its own output + * - Task A's output is NOT present in the terminal buffer + * + * This catches a bug where the passthrough terminal (rendered inside the + * global chat panel) kept the previous session's xterm buffer when switching. + */ + test("switching between TUI sessions clears terminal buffer", async ({ + testPage, + apiClient, + seedData, + }) => { + const profile = await createTUIProfile(apiClient, "TUI Switch"); + + // Create two tasks with distinct descriptions so their terminal output differs + await apiClient.createTaskWithAgent(seedData.workspaceId, "TUI Alpha Task", profile.id, { + description: "alpha-unique-marker", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await apiClient.createTaskWithAgent(seedData.workspaceId, "TUI Beta Task", profile.id, { + description: "beta-unique-marker", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + const session = await openTaskSession(testPage, "TUI Alpha Task"); + + // Task A's terminal shows its prompt output + await session.expectPassthroughHasText("alpha-unique-marker", 15_000); + + // Switch to task B via sidebar + const taskB = session.taskInSidebar("TUI Beta Task"); + await expect(taskB).toBeVisible({ timeout: 15_000 }); + await taskB.click(); + await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 }); + + // Loading overlay reappears during the session switch + await session.waitForPassthroughLoading(); + + // Wait for task B's passthrough terminal to load + await session.waitForPassthroughLoad(); + await session.waitForPassthroughLoaded(); + + // Task B's terminal shows its own output + await session.expectPassthroughHasText("beta-unique-marker", 15_000); + + // Task A's output must NOT be in the buffer (verifies reset on switch) + await session.expectPassthroughNotHasText("alpha-unique-marker"); + }); +}); diff --git a/apps/web/e2e/tests/workflow-automation.spec.ts b/apps/web/e2e/tests/workflow-automation.spec.ts index 15886e071c..ba42bde66c 100644 --- a/apps/web/e2e/tests/workflow-automation.spec.ts +++ b/apps/web/e2e/tests/workflow-automation.spec.ts @@ -47,9 +47,8 @@ test.describe("Workflow automation", () => { enable_preview_on_click: false, }); - // Resolve an agent profile so auto_start_agent can pick it up from task metadata - const { agents } = await apiClient.listAgents(); - const agentProfileId = agents[0]?.profiles[0]?.id ?? ""; + // Use the seed profile so tests don't pick up passthrough profiles from other tests. + const agentProfileId = seedData.agentProfileId; // Create task in Inbox — does NOT trigger auto_start_agent const task = await apiClient.createTask(seedData.workspaceId, "Auto Agent Workflow Task", { @@ -162,8 +161,7 @@ test.describe("Workflow automation", () => { enable_preview_on_click: false, }); - const { agents } = await apiClient.listAgents(); - const agentProfileId = agents[0]?.profiles[0]?.id ?? ""; + const agentProfileId = seedData.agentProfileId; // Create task in Todo without starting an agent. const task = await apiClient.createTask(seedData.workspaceId, "Lifecycle Workflow Task", { @@ -282,8 +280,7 @@ test.describe("Workflow automation", () => { enable_preview_on_click: false, }); - const { agents } = await apiClient.listAgents(); - const agentProfileId = agents[0]?.profiles[0]?.id ?? ""; + const agentProfileId = seedData.agentProfileId; const task = await apiClient.createTask(seedData.workspaceId, "Plan Mode Workflow Task", { workflow_id: workflow.id, @@ -394,8 +391,7 @@ test.describe("Workflow automation", () => { enable_preview_on_click: false, }); - const { agents } = await apiClient.listAgents(); - const agentProfileId = agents[0]?.profiles[0]?.id ?? ""; + const agentProfileId = seedData.agentProfileId; // Create task in Backlog (staging step, no events). // The description (not title) is what {{task_prompt}} resolves to in step prompts.