feat: tui agents with workflows and code quality improvements (#360)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
xtermRef: React.MutableRefObject<Terminal | null>;
|
||||
fitAddonRef: React.MutableRefObject<FitAddon | null>;
|
||||
isInitializedRef: React.MutableRefObject<boolean>;
|
||||
lastDimensionsRef: React.MutableRefObject<{ cols: number; rows: number }>;
|
||||
resizeTimeoutRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
|
||||
webglAddonRef: React.MutableRefObject<WebglAddon | null>;
|
||||
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<Terminal | null>;
|
||||
fitAddonRef: React.MutableRefObject<FitAddon | null>;
|
||||
wsRef: React.MutableRefObject<WebSocket | null>;
|
||||
attachAddonRef: React.MutableRefObject<AttachAddon | null>;
|
||||
};
|
||||
|
||||
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<WebSocket | null>;
|
||||
attachAddonRef: React.MutableRefObject<AttachAddon | null>;
|
||||
isMountedCheck: () => boolean;
|
||||
onTimeout: (id: ReturnType<typeof setTimeout>) => 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<typeof setTimeout> | null = null;
|
||||
let settleTimeout: ReturnType<typeof setTimeout> | 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<WebSocket | null>) {
|
||||
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<Terminal | null>;
|
||||
fitAddonRef: React.MutableRefObject<FitAddon | null>;
|
||||
terminalRef: React.RefObject<HTMLDivElement | null>;
|
||||
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<string | null>(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 (
|
||||
<div
|
||||
className="h-full w-full overflow-hidden p-2 bg-background"
|
||||
data-testid={mode === "agent" ? "passthrough-terminal" : undefined}
|
||||
className="relative h-full w-full overflow-hidden bg-background"
|
||||
style={{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT }}
|
||||
>
|
||||
<div ref={terminalRef} className="h-full w-full" />
|
||||
<div ref={terminalRef} className="h-full w-full p-2" />
|
||||
{!isConnected && (
|
||||
<div
|
||||
data-testid="passthrough-loading"
|
||||
className="absolute inset-0 flex items-start justify-center pt-12 bg-background"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<GridSpinner />
|
||||
<span className="text-sm">
|
||||
{mode === "agent" ? "Starting agent..." : "Connecting..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
xtermRef: React.MutableRefObject<Terminal | null>;
|
||||
fitAddonRef: React.MutableRefObject<FitAddon | null>;
|
||||
isInitializedRef: React.MutableRefObject<boolean>;
|
||||
lastDimensionsRef: React.MutableRefObject<{ cols: number; rows: number }>;
|
||||
resizeTimeoutRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
|
||||
webglAddonRef: React.MutableRefObject<WebglAddon | null>;
|
||||
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<Terminal | null>;
|
||||
fitAddonRef: React.MutableRefObject<FitAddon | null>;
|
||||
wsRef: React.MutableRefObject<WebSocket | null>;
|
||||
attachAddonRef: React.MutableRefObject<AttachAddon | null>;
|
||||
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<WebSocket | null>;
|
||||
attachAddonRef: React.MutableRefObject<AttachAddon | null>;
|
||||
isMountedCheck: () => boolean;
|
||||
onTimeout: (id: ReturnType<typeof setTimeout>) => 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<typeof setTimeout> | null = null;
|
||||
let settleTimeout: ReturnType<typeof setTimeout> | 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<WebSocket | null>) {
|
||||
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<Terminal | null>;
|
||||
fitAddonRef: React.MutableRefObject<FitAddon | null>;
|
||||
terminalRef: React.RefObject<HTMLDivElement | null>;
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -125,6 +125,39 @@ export class ApiClient {
|
||||
return this.request("GET", "/api/v1/agents");
|
||||
}
|
||||
|
||||
async deleteAgentProfile(profileId: string): Promise<void> {
|
||||
await this.request("DELETE", `/api/v1/agent-profiles/${profileId}`);
|
||||
}
|
||||
|
||||
/** Delete all agent profiles except the ones in keepIds. */
|
||||
async cleanupTestProfiles(keepIds: string[]): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SessionPage> {
|
||||
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: <prompt>" 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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user