Merge pull request #17 from aoagents/feat/zellij-runtime

feat: add zellij runtime adapter
This commit is contained in:
Harshit Singh Bhandari 2026-05-28 17:01:48 +05:30 committed by GitHub
commit 0ef3066312
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1288 additions and 0 deletions

View File

@ -0,0 +1,233 @@
package zellij
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const (
runtimeName = "zellij"
agentPaneName = "agent"
defaultChunkBytes = 16 * 1024
)
func versionArgs() []string {
return []string{"--version"}
}
func createSessionArgs(id, layoutPath string) []string {
return []string{
"attach", "--create-background", id,
"options",
"--default-layout", layoutPath,
"--pane-frames", "false",
"--session-serialization", "false",
"--show-startup-tips", "false",
"--show-release-notes", "false",
}
}
func listPanesArgs(id string) []string {
return []string{"--session", id, "action", "list-panes", "--all", "--json"}
}
func pasteArgs(id, paneID, chunk string) []string {
return []string{"--session", id, "action", "paste", "--pane-id", paneID, chunk}
}
func sendEnterArgs(id, paneID string) []string {
return []string{"--session", id, "action", "send-keys", "--pane-id", paneID, "Enter"}
}
func dumpScreenArgs(id, paneID string) []string {
return []string{"--session", id, "action", "dump-screen", "--pane-id", paneID, "--full"}
}
func listSessionsArgs() []string {
return []string{"list-sessions", "--no-formatting"}
}
func killSessionArgs(id string) []string {
return []string{"kill-session", id}
}
func attachArgs(id string) []string {
return []string{"attach", id}
}
func handleIDValue(sessionID, paneID string) string {
return sessionID + "/" + paneID
}
func terminalPaneID(id int) string {
return fmt.Sprintf("terminal_%d", id)
}
func buildLayout(cfg ports.RuntimeConfig, shellPath string) string {
spec := shellLaunchSpecFor(shellPath)
shellCommand := shellLaunchCommand(cfg, shellPath, spec)
return layoutString(cfg.WorkspacePath, shellPath, spec.args, shellCommand)
}
type shellLaunchSpec struct {
args []string
}
func shellLaunchSpecFor(shellPath string) shellLaunchSpec {
base := strings.ToLower(filepathBase(shellPath))
if strings.Contains(base, "cmd") {
return shellLaunchSpec{args: []string{"/D", "/S", "/K"}}
}
if strings.Contains(base, "powershell") || strings.Contains(base, "pwsh") {
return shellLaunchSpec{args: []string{"-NoLogo", "-NoProfile", "-NoExit", "-Command"}}
}
return shellLaunchSpec{args: []string{"-lc"}}
}
func layoutString(workspacePath, shellPath string, shellArgs []string, shellCommand string) string {
return "layout {\n" +
" cwd " + kdlQuote(workspacePath) + "\n" +
" pane command=" + kdlQuote(shellPath) + " name=" + kdlQuote(agentPaneName) + " {\n" +
" args " + kdlJoin(shellArgs) + " " + kdlQuote(shellCommand) + "\n" +
" }\n" +
"}\n"
}
func shellLaunchCommand(cfg ports.RuntimeConfig, shellPath string, spec shellLaunchSpec) string {
if len(spec.args) > 0 && spec.args[0] == "-NoLogo" {
return wrapLaunchCommandPowerShell(cfg, shellPath)
}
if len(spec.args) > 0 && spec.args[0] == "/D" {
return wrapLaunchCommandCmd(cfg)
}
return wrapLaunchCommandUnix(cfg, shellPath)
}
func wrapLaunchCommandUnix(cfg ports.RuntimeConfig, shellPath string) string {
path := cfg.Env["PATH"]
if path == "" {
path = getenv("PATH")
}
var b strings.Builder
for _, key := range sortedKeys(cfg.Env) {
if key == "PATH" {
continue
}
b.WriteString("export ")
b.WriteString(key)
b.WriteString("=")
b.WriteString(shellQuote(cfg.Env[key]))
b.WriteString("; ")
}
if path != "" {
b.WriteString("export PATH=")
b.WriteString(shellQuote(path))
b.WriteString("; ")
}
b.WriteString(cfg.LaunchCommand)
b.WriteString("; exec ")
b.WriteString(shellQuote(shellPath))
b.WriteString(" -i")
return b.String()
}
func wrapLaunchCommandPowerShell(cfg ports.RuntimeConfig, shellPath string) string {
path := cfg.Env["PATH"]
if path == "" {
path = getenv("PATH")
}
var b strings.Builder
for _, key := range sortedKeys(cfg.Env) {
if key == "PATH" {
continue
}
b.WriteString("$env:")
b.WriteString(key)
b.WriteString(" = ")
b.WriteString(psQuote(cfg.Env[key]))
b.WriteString("; ")
}
if path != "" {
b.WriteString("$env:PATH = ")
b.WriteString(psQuote(path))
b.WriteString("; ")
}
b.WriteString(cfg.LaunchCommand)
return b.String()
}
func wrapLaunchCommandCmd(cfg ports.RuntimeConfig) string {
path := cfg.Env["PATH"]
if path == "" {
path = getenv("PATH")
}
var b strings.Builder
for _, key := range sortedKeys(cfg.Env) {
if key == "PATH" {
continue
}
b.WriteString("set \"")
b.WriteString(key)
b.WriteString("=")
b.WriteString(cmdQuote(cfg.Env[key]))
b.WriteString("\" && ")
}
if path != "" {
b.WriteString("set \"PATH=")
b.WriteString(cmdQuote(path))
b.WriteString("\" && ")
}
b.WriteString(cfg.LaunchCommand)
return b.String()
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
func psQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func cmdQuote(s string) string {
return strings.ReplaceAll(s, "\"", "\"\"")
}
func kdlQuote(s string) string {
return strconv.Quote(s)
}
func kdlJoin(args []string) string {
parts := make([]string, 0, len(args))
for _, arg := range args {
parts = append(parts, kdlQuote(arg))
}
return strings.Join(parts, " ")
}
func filepathBase(path string) string {
if path == "" {
return ""
}
i := strings.LastIndexAny(path, `/\`)
if i < 0 {
return path
}
return path[i+1:]
}

View File

@ -0,0 +1,539 @@
// Package zellij implements ports.Runtime using Zellij sessions.
package zellij
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
const (
defaultTimeout = 5 * time.Second
minMajor = 0
minMinor = 44
minPatch = 3
)
var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
var paneIDPattern = regexp.MustCompile(`^terminal_[0-9]+$`)
var getenv = os.Getenv
type Options struct {
Binary string
Timeout time.Duration
Shell string
SocketDir string
ConfigDir string
ChunkSize int
}
type Runtime struct {
binary string
timeout time.Duration
shell string
socketDir string
configDir string
chunkSize int
runner runner
}
var _ ports.Runtime = (*Runtime)(nil)
type runner interface {
Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error)
}
type execRunner struct{}
func (execRunner) Run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
if len(env) > 0 {
cmd.Env = append(os.Environ(), env...)
}
return cmd.CombinedOutput()
}
func New(opts Options) *Runtime {
binary := opts.Binary
if binary == "" {
binary = "zellij"
}
timeout := opts.Timeout
if timeout == 0 {
timeout = defaultTimeout
}
shellPath := opts.Shell
if shellPath == "" {
shellPath = os.Getenv("SHELL")
}
if shellPath == "" {
if runtime.GOOS == "windows" {
shellPath = "powershell.exe"
} else {
shellPath = "/bin/sh"
}
}
chunkSize := opts.ChunkSize
if chunkSize <= 0 {
chunkSize = defaultChunkBytes
}
return &Runtime{binary: binary, timeout: timeout, shell: shellPath, socketDir: opts.SocketDir, configDir: opts.ConfigDir, chunkSize: chunkSize, runner: execRunner{}}
}
func (r *Runtime) Create(ctx context.Context, cfg ports.RuntimeConfig) (ports.RuntimeHandle, error) {
id, err := zellijSessionName(cfg.SessionID)
if err != nil {
return ports.RuntimeHandle{}, err
}
if cfg.WorkspacePath == "" {
return ports.RuntimeHandle{}, errors.New("zellij runtime: workspace path is required")
}
if cfg.LaunchCommand == "" {
return ports.RuntimeHandle{}, errors.New("zellij runtime: launch command is required")
}
if err := r.ensureSupportedVersion(ctx); err != nil {
return ports.RuntimeHandle{}, err
}
layoutPath, err := r.writeLayout(cfg)
if err != nil {
return ports.RuntimeHandle{}, err
}
defer os.Remove(layoutPath)
if _, err := r.run(ctx, createSessionArgs(id, layoutPath)...); err != nil {
return ports.RuntimeHandle{}, fmt.Errorf("zellij runtime: create session %s: %w", id, err)
}
paneID, err := r.findAgentPane(ctx, id)
if err != nil {
_ = r.Destroy(context.Background(), ports.RuntimeHandle{ID: id, RuntimeName: runtimeName})
return ports.RuntimeHandle{}, err
}
return ports.RuntimeHandle{ID: handleIDValue(id, paneID), RuntimeName: runtimeName}, nil
}
func (r *Runtime) Destroy(ctx context.Context, handle ports.RuntimeHandle) error {
id, _, err := handleID(handle)
if err != nil {
return err
}
if _, err := r.run(ctx, killSessionArgs(id)...); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return nil
}
return fmt.Errorf("zellij runtime: destroy session %s: %w", id, err)
}
return nil
}
func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error {
id, paneID, err := handleID(handle)
if err != nil {
return err
}
for _, chunk := range chunks(message, r.chunkSize) {
if _, err := r.run(ctx, pasteArgs(id, paneID, chunk)...); err != nil {
return fmt.Errorf("zellij runtime: paste message %s/%s: %w", id, paneID, err)
}
}
if _, err := r.run(ctx, sendEnterArgs(id, paneID)...); err != nil {
return fmt.Errorf("zellij runtime: send enter %s/%s: %w", id, paneID, err)
}
return nil
}
func (r *Runtime) GetOutput(ctx context.Context, handle ports.RuntimeHandle, lines int) (string, error) {
id, paneID, err := handleID(handle)
if err != nil {
return "", err
}
if lines <= 0 {
return "", errors.New("zellij runtime: lines must be positive")
}
out, err := r.run(ctx, dumpScreenArgs(id, paneID)...)
if err != nil {
return "", fmt.Errorf("zellij runtime: capture output %s/%s: %w", id, paneID, err)
}
return tailLines(string(out), lines), nil
}
func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool, error) {
id, _, err := handleID(handle)
if err != nil {
return false, err
}
out, err := r.run(ctx, listSessionsArgs()...)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return false, nil
}
return false, fmt.Errorf("zellij runtime: probe session %s: %w", id, err)
}
return sessionListedAlive(string(out), id), nil
}
func (r *Runtime) AttachCommand(handle ports.RuntimeHandle) ([]string, error) {
id, _, err := handleID(handle)
if err != nil {
return nil, err
}
args := append([]string{}, r.baseArgs()...)
args = append(args, attachArgs(id)...)
if r.socketDir == "" {
return append([]string{r.binary}, args...), nil
}
return attachCommandWithEnv(r.binary, r.socketDir, args...), nil
}
func (r *Runtime) ensureSupportedVersion(ctx context.Context) error {
out, err := r.run(ctx, versionArgs()...)
if err != nil {
return fmt.Errorf("zellij runtime: check version: %w", err)
}
version, err := parseVersion(string(out))
if err != nil {
return fmt.Errorf("zellij runtime: check version: %w", err)
}
if compareVersion(version, semver{minMajor, minMinor, minPatch}) < 0 {
return fmt.Errorf("zellij runtime: unsupported zellij version %s; require >= %d.%d.%d", version, minMajor, minMinor, minPatch)
}
return nil
}
func (r *Runtime) writeLayout(cfg ports.RuntimeConfig) (string, error) {
file, err := os.CreateTemp(os.TempDir(), "ao-zellij-layout-*.kdl")
if err != nil {
return "", fmt.Errorf("zellij runtime: create layout temp file: %w", err)
}
path := file.Name()
if _, err := file.WriteString(buildLayout(cfg, r.shell)); err != nil {
_ = file.Close()
_ = os.Remove(path)
return "", fmt.Errorf("zellij runtime: write layout temp file: %w", err)
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("zellij runtime: close layout temp file: %w", err)
}
return path, nil
}
func (r *Runtime) findAgentPane(ctx context.Context, id string) (string, error) {
deadline := time.Now().Add(r.timeout)
var lastErr error
for {
out, err := r.run(ctx, listPanesArgs(id)...)
if err == nil {
paneID, parseErr := agentPaneID(out)
if parseErr == nil {
return paneID, nil
}
lastErr = parseErr
} else {
lastErr = err
}
if time.Now().After(deadline) {
return "", fmt.Errorf("zellij runtime: list panes %s: %w", id, lastErr)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
}
func (r *Runtime) run(ctx context.Context, args ...string) ([]byte, error) {
cmdCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
fullArgs := append(r.baseArgs(), args...)
out, err := r.runner.Run(cmdCtx, r.env(), r.binary, fullArgs...)
if cmdCtx.Err() != nil {
return out, cmdCtx.Err()
}
if err != nil {
return out, commandError{err: err, output: strings.TrimSpace(string(out))}
}
return out, nil
}
func (r *Runtime) baseArgs() []string {
args := []string{}
if r.configDir != "" {
args = append(args, "--config-dir", r.configDir)
}
return args
}
func (r *Runtime) env() []string {
if r.socketDir == "" {
return nil
}
return []string{"ZELLIJ_SOCKET_DIR=" + r.socketDir}
}
func attachCommandWithEnv(binary, socketDir string, args ...string) []string {
if socketDir == "" {
return append([]string{binary}, args...)
}
if runtime.GOOS == "windows" {
command := strings.Builder{}
command.WriteString("$env:ZELLIJ_SOCKET_DIR = ")
command.WriteString(psQuote(socketDir))
command.WriteString("; & ")
command.WriteString(psQuote(binary))
for _, arg := range args {
command.WriteByte(' ')
command.WriteString(psQuote(arg))
}
return []string{"powershell.exe", "-NoLogo", "-NoProfile", "-Command", command.String()}
}
return append([]string{"env", "ZELLIJ_SOCKET_DIR=" + socketDir, binary}, args...)
}
func zellijSessionName(id domain.SessionID) (string, error) {
raw := string(id)
if raw == "" {
return "", errors.New("zellij runtime: session id is required")
}
if sessionIDPattern.MatchString(raw) && len(raw) <= 48 {
return raw, nil
}
return sanitizedSessionName(raw), nil
}
func sanitizedSessionName(raw string) string {
var b strings.Builder
lastDash := false
for _, r := range raw {
valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-'
if valid {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
base := strings.Trim(b.String(), "-")
if base == "" {
base = "session"
}
if len(base) > 32 {
base = strings.TrimRight(base[:32], "-")
}
sum := sha256.Sum256([]byte(raw))
return base + "-" + hex.EncodeToString(sum[:4])
}
func validateSessionID(id string) error {
if id == "" {
return errors.New("zellij runtime: session id is required")
}
if !sessionIDPattern.MatchString(id) {
return fmt.Errorf("zellij runtime: invalid session id %q", id)
}
return nil
}
func validatePaneID(id string) error {
if id == "" {
return errors.New("zellij runtime: pane id is required")
}
if !paneIDPattern.MatchString(id) {
return fmt.Errorf("zellij runtime: invalid pane id %q", id)
}
return nil
}
func handleID(handle ports.RuntimeHandle) (string, string, error) {
if handle.RuntimeName != "" && handle.RuntimeName != runtimeName {
return "", "", fmt.Errorf("zellij runtime: wrong runtime %q", handle.RuntimeName)
}
parts := strings.Split(handle.ID, "/")
if len(parts) == 1 {
if err := validateSessionID(parts[0]); err != nil {
return "", "", err
}
return parts[0], terminalPaneID(0), nil
}
if len(parts) != 2 {
return "", "", fmt.Errorf("zellij runtime: invalid handle id %q", handle.ID)
}
if err := validateSessionID(parts[0]); err != nil {
return "", "", err
}
if err := validatePaneID(parts[1]); err != nil {
return "", "", err
}
return parts[0], parts[1], nil
}
type paneInfo struct {
ID int `json:"id"`
IsPlugin bool `json:"is_plugin"`
Title string `json:"title"`
}
func agentPaneID(out []byte) (string, error) {
var panes []paneInfo
if err := json.Unmarshal(out, &panes); err != nil {
return "", fmt.Errorf("parse panes: %w", err)
}
for _, pane := range panes {
if !pane.IsPlugin && pane.Title == agentPaneName {
return terminalPaneID(pane.ID), nil
}
}
for _, pane := range panes {
if !pane.IsPlugin {
return terminalPaneID(pane.ID), nil
}
}
return "", errors.New("agent pane not found")
}
func chunks(s string, maxBytes int) []string {
if s == "" {
return []string{""}
}
if maxBytes <= 0 || len(s) <= maxBytes {
return []string{s}
}
parts := []string{}
for len(s) > 0 {
if len(s) <= maxBytes {
parts = append(parts, s)
break
}
end := maxBytes
for end > 0 && !utf8.ValidString(s[:end]) {
end--
}
if end == 0 {
_, size := utf8.DecodeRuneInString(s)
end = size
}
parts = append(parts, s[:end])
s = s[end:]
}
return parts
}
func tailLines(s string, n int) string {
if n <= 0 || s == "" {
return ""
}
lines := strings.SplitAfter(s, "\n")
if lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) <= n {
return s
}
return strings.Join(lines[len(lines)-n:], "")
}
type semver struct {
major int
minor int
patch int
}
func (v semver) String() string {
return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch)
}
func parseVersion(out string) (semver, error) {
fields := strings.Fields(strings.TrimSpace(out))
if len(fields) == 0 {
return semver{}, errors.New("empty version output")
}
raw := strings.TrimPrefix(fields[len(fields)-1], "v")
parts := strings.Split(raw, ".")
if len(parts) < 3 {
return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out))
}
major, err := parseVersionPart(parts[0])
if err != nil {
return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out))
}
minor, err := parseVersionPart(parts[1])
if err != nil {
return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out))
}
patch, err := parseVersionPart(parts[2])
if err != nil {
return semver{}, fmt.Errorf("invalid version output %q", strings.TrimSpace(out))
}
return semver{major: major, minor: minor, patch: patch}, nil
}
func parseVersionPart(s string) (int, error) {
end := 0
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
end++
}
if end == 0 {
return 0, errors.New("missing version number")
}
return strconv.Atoi(s[:end])
}
func compareVersion(a, b semver) int {
if a.major != b.major {
return a.major - b.major
}
if a.minor != b.minor {
return a.minor - b.minor
}
return a.patch - b.patch
}
func sessionListedAlive(out, id string) bool {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != id {
continue
}
return !strings.Contains(line, "(EXITED")
}
return false
}
type commandError struct {
err error
output string
}
func (e commandError) Error() string {
if e.output == "" {
return e.err.Error()
}
return e.err.Error() + ": " + e.output
}
func (e commandError) Unwrap() error { return e.err }

View File

@ -0,0 +1,113 @@
package zellij
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestRuntimeIntegration(t *testing.T) {
if _, err := exec.LookPath("zellij"); err != nil {
t.Skip("zellij unavailable")
}
ctx := context.Background()
id := "ao_itest_zj"
socketDir := filepath.Join(os.TempDir(), "ao-zj-itest")
if err := os.MkdirAll(socketDir, 0o755); err != nil {
t.Fatalf("mkdir socket dir: %v", err)
}
configDir := t.TempDir()
r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: configDir})
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: id, RuntimeName: runtimeName})
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_itest_zj",
WorkspacePath: t.TempDir(),
LaunchCommand: "printf ready-$AO_SESSION_ID\\n",
Env: map[string]string{"AO_SESSION_ID": id},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer r.Destroy(ctx, h)
alive, err := r.IsAlive(ctx, h)
if err != nil {
t.Fatalf("IsAlive: %v", err)
}
if !alive {
t.Fatal("alive = false, want true")
}
if err := r.SendMessage(ctx, h, "printf hello-from-zellij"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
deadline := time.Now().Add(3 * time.Second)
var out string
for time.Now().Before(deadline) {
out, err = r.GetOutput(ctx, h, 30)
if err != nil {
t.Fatalf("GetOutput: %v", err)
}
if strings.Contains(out, "hello-from-zellij") {
break
}
time.Sleep(100 * time.Millisecond)
}
if !strings.Contains(out, "hello-from-zellij") {
t.Fatalf("output = %q, want sent command output", out)
}
if err := r.Destroy(ctx, h); err != nil {
t.Fatalf("Destroy: %v", err)
}
alive, err = r.IsAlive(ctx, h)
if err != nil {
t.Fatalf("IsAlive after destroy: %v", err)
}
if alive {
t.Fatal("alive after destroy = true, want false")
}
}
func TestRuntimeIntegrationUsesExactSessionParsing(t *testing.T) {
if _, err := exec.LookPath("zellij"); err != nil {
t.Skip("zellij unavailable")
}
ctx := context.Background()
socketDir := filepath.Join(os.TempDir(), "ao-zj-exact-itest")
if err := os.MkdirAll(socketDir, 0o755); err != nil {
t.Fatalf("mkdir socket dir: %v", err)
}
r := New(Options{Timeout: 5 * time.Second, SocketDir: socketDir, ConfigDir: t.TempDir()})
longID := "ao_zj_exact_long"
prefixID := "ao_zj_exact"
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: longID, RuntimeName: runtimeName})
_ = r.Destroy(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName})
h, err := r.Create(ctx, ports.RuntimeConfig{
SessionID: "ao_zj_exact_long",
WorkspacePath: t.TempDir(),
LaunchCommand: "printf ready\\n",
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer r.Destroy(ctx, h)
alive, err := r.IsAlive(ctx, ports.RuntimeHandle{ID: prefixID, RuntimeName: runtimeName})
if err != nil {
t.Fatalf("IsAlive prefix: %v", err)
}
if alive {
t.Fatal("prefix handle reported alive; zellij session matching is not exact")
}
}

View File

@ -0,0 +1,403 @@
package zellij
import (
"context"
"errors"
"os/exec"
"reflect"
"runtime"
"strings"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func TestNewDefaultsToPortableShell(t *testing.T) {
t.Setenv("SHELL", "")
r := New(Options{})
want := "/bin/sh"
if runtime.GOOS == "windows" {
want = "powershell.exe"
}
if got := r.shell; got != want {
t.Fatalf("default shell = %q, want %q", got, want)
}
}
func TestCommandBuilders(t *testing.T) {
if got, want := versionArgs(), []string{"--version"}; !reflect.DeepEqual(got, want) {
t.Fatalf("versionArgs = %#v, want %#v", got, want)
}
if got, want := createSessionArgs("sess-1", "/tmp/layout.kdl"), []string{"attach", "--create-background", "sess-1", "options", "--default-layout", "/tmp/layout.kdl", "--pane-frames", "false", "--session-serialization", "false", "--show-startup-tips", "false", "--show-release-notes", "false"}; !reflect.DeepEqual(got, want) {
t.Fatalf("createSessionArgs = %#v, want %#v", got, want)
}
if got, want := listPanesArgs("sess-1"), []string{"--session", "sess-1", "action", "list-panes", "--all", "--json"}; !reflect.DeepEqual(got, want) {
t.Fatalf("listPanesArgs = %#v, want %#v", got, want)
}
if got, want := pasteArgs("sess-1", "terminal_0", "hello"), []string{"--session", "sess-1", "action", "paste", "--pane-id", "terminal_0", "hello"}; !reflect.DeepEqual(got, want) {
t.Fatalf("pasteArgs = %#v, want %#v", got, want)
}
if got, want := dumpScreenArgs("sess-1", "terminal_0"), []string{"--session", "sess-1", "action", "dump-screen", "--pane-id", "terminal_0", "--full"}; !reflect.DeepEqual(got, want) {
t.Fatalf("dumpScreenArgs = %#v, want %#v", got, want)
}
}
func TestZellijSessionNameSanitizesIssueRefs(t *testing.T) {
got, err := zellijSessionName("repo/issue#42.1")
if err != nil {
t.Fatalf("zellijSessionName: %v", err)
}
if err := validateSessionID(got); err != nil {
t.Fatalf("sanitized id %q is invalid: %v", got, err)
}
if !strings.HasPrefix(got, "repo-issue-42-1-") {
t.Fatalf("sanitized id = %q, want readable prefix", got)
}
if got == "repo/issue#42.1" {
t.Fatal("sanitized id still contains raw unsafe characters")
}
}
func TestValidateSessionAndPaneID(t *testing.T) {
for _, id := range []string{"sess-1", "S_2", "abc123"} {
if err := validateSessionID(id); err != nil {
t.Fatalf("validateSessionID(%q): %v", id, err)
}
}
for _, id := range []string{"", "sess.1", "sess/1", "$(boom)", "with space"} {
if err := validateSessionID(id); err == nil {
t.Fatalf("validateSessionID(%q): got nil, want error", id)
}
}
for _, id := range []string{"terminal_0", "terminal_42"} {
if err := validatePaneID(id); err != nil {
t.Fatalf("validatePaneID(%q): %v", id, err)
}
}
for _, id := range []string{"", "0", "plugin_0", "terminal_x", "terminal_1/2"} {
if err := validatePaneID(id); err == nil {
t.Fatalf("validatePaneID(%q): got nil, want error", id)
}
}
}
func TestHandleID(t *testing.T) {
session, pane, err := handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7", RuntimeName: runtimeName})
if err != nil {
t.Fatalf("handleID: %v", err)
}
if session != "sess-1" || pane != "terminal_7" {
t.Fatalf("handleID = %q/%q", session, pane)
}
_, _, err = handleID(ports.RuntimeHandle{ID: "sess-1/terminal_7", RuntimeName: "tmux"})
if err == nil {
t.Fatal("wrong runtime: got nil, want error")
}
}
func TestBuildLayoutExportsEnvAndKeepsPaneAlive(t *testing.T) {
oldGetenv := getenv
getenv = func(key string) string {
if key == "PATH" {
return "/usr/bin:/bin"
}
return ""
}
defer func() { getenv = oldGetenv }()
got := buildLayout(ports.RuntimeConfig{WorkspacePath: "/tmp/ws", LaunchCommand: "ao run", Env: map[string]string{
"AO_SESSION_ID": "sess-1",
"ODD": "can't",
"PATH": "/custom/bin:/usr/bin",
}}, "/bin/zsh")
for _, want := range []string{
`cwd "/tmp/ws"`,
`pane command="/bin/zsh" name="agent"`,
"export AO_SESSION_ID='sess-1';",
"export ODD='can'\\\\''t';",
"export PATH='/custom/bin:/usr/bin';",
"ao run; exec '/bin/zsh' -i",
} {
if !strings.Contains(got, want) {
t.Fatalf("layout missing %q in %q", want, got)
}
}
}
func TestBuildLayoutUsesPowerShellLaunchOnWindowsShells(t *testing.T) {
oldGetenv := getenv
getenv = func(key string) string {
if key == "PATH" {
return `C:\custom\bin`
}
return ""
}
defer func() { getenv = oldGetenv }()
got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, LaunchCommand: "Write-Host ready", Env: map[string]string{
"AO_SESSION_ID": "sess-1",
}}, `C:\Program Files\PowerShell\7\pwsh.exe`)
for _, want := range []string{
`pane command="C:\\Program Files\\PowerShell\\7\\pwsh.exe" name="agent"`,
`args "-NoLogo" "-NoProfile" "-NoExit" "-Command"`,
"$env:AO_SESSION_ID = 'sess-1';",
"$env:PATH = ",
"Write-Host ready",
} {
if !strings.Contains(got, want) {
t.Fatalf("powershell layout missing %q in %q", want, got)
}
}
}
func TestBuildLayoutUsesCmdLaunchOnCmdShells(t *testing.T) {
oldGetenv := getenv
getenv = func(key string) string {
return ""
}
defer func() { getenv = oldGetenv }()
got := buildLayout(ports.RuntimeConfig{WorkspacePath: `C:\ws`, LaunchCommand: "echo ready", Env: map[string]string{
"AO_SESSION_ID": "sess-1",
}}, `C:\Windows\System32\cmd.exe`)
for _, want := range []string{
`pane command="C:\\Windows\\System32\\cmd.exe" name="agent"`,
`args "/D" "/S" "/K"`,
`AO_SESSION_ID=sess-1`,
"echo ready",
} {
if !strings.Contains(got, want) {
t.Fatalf("cmd layout missing %q in %q", want, got)
}
}
}
func TestCreateStartsSessionAndDiscoversPane(t *testing.T) {
fr := &fakeRunner{outputs: [][]byte{[]byte("zellij 0.44.3"), nil, []byte(`[{"id":0,"is_plugin":true,"title":"zellij:tab-bar"},{"id":3,"is_plugin":false,"title":"agent"}]`)}}
r := New(Options{Binary: "zellij-test", Timeout: time.Second, Shell: "/bin/zsh", SocketDir: "/tmp/zj", ConfigDir: "/tmp/cfg"})
r.runner = fr
handle, err := r.Create(context.Background(), ports.RuntimeConfig{
SessionID: "sess-1",
WorkspacePath: "/tmp/ws",
LaunchCommand: "echo ready",
Env: map[string]string{"AO_SESSION_ID": "sess-1"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if handle != (ports.RuntimeHandle{ID: "sess-1/terminal_3", RuntimeName: runtimeName}) {
t.Fatalf("handle = %+v, want zellij handle", handle)
}
if len(fr.calls) != 3 {
t.Fatalf("calls = %d, want 3", len(fr.calls))
}
if got, want := fr.calls[0].args, []string{"--config-dir", "/tmp/cfg", "--version"}; !reflect.DeepEqual(got, want) {
t.Fatalf("version args = %#v, want %#v", got, want)
}
if got := fr.calls[1].args[:5]; !reflect.DeepEqual(got, []string{"--config-dir", "/tmp/cfg", "attach", "--create-background", "sess-1"}) {
t.Fatalf("create args prefix = %#v", got)
}
if got := fr.calls[2].args; !reflect.DeepEqual(got, append([]string{"--config-dir", "/tmp/cfg"}, listPanesArgs("sess-1")...)) {
t.Fatalf("list panes args = %#v", got)
}
if got, want := fr.calls[0].env, []string{"ZELLIJ_SOCKET_DIR=/tmp/zj"}; !reflect.DeepEqual(got, want) {
t.Fatalf("env = %#v, want %#v", got, want)
}
}
func TestAttachCommandUsesSocketDir(t *testing.T) {
r := New(Options{SocketDir: "/tmp/zj"})
args, err := r.AttachCommand(ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName})
if err != nil {
t.Fatalf("AttachCommand: %v", err)
}
if runtime.GOOS == "windows" {
if len(args) < 4 || args[0] != "powershell.exe" {
t.Fatalf("attach command = %#v, want powershell wrapper", args)
}
if !strings.Contains(strings.Join(args, " "), "ZELLIJ_SOCKET_DIR") {
t.Fatalf("attach command missing socket dir env: %#v", args)
}
return
}
if got, want := args[:3], []string{"env", "ZELLIJ_SOCKET_DIR=/tmp/zj", r.binary}; !reflect.DeepEqual(got, want) {
t.Fatalf("attach prefix = %#v, want %#v", got, want)
}
}
func TestFindAgentPaneRetriesTransientErrors(t *testing.T) {
fr := &fakeRunner{outputs: [][]byte{[]byte("boom"), []byte(`[{"id":0,"is_plugin":false,"title":"agent"}]`)}}
r := New(Options{Timeout: time.Second})
r.runner = fr
got, err := r.findAgentPane(context.Background(), "sess-1")
if err != nil {
t.Fatalf("findAgentPane: %v", err)
}
if got != "terminal_0" {
t.Fatalf("findAgentPane = %q, want terminal_0", got)
}
if len(fr.calls) != 2 {
t.Fatalf("calls = %d, want 2", len(fr.calls))
}
}
func TestParseVersion(t *testing.T) {
for _, tc := range []struct {
in string
want semver
}{
{in: "zellij 0.44.3", want: semver{0, 44, 3}},
{in: "zellij v1.2.3\n", want: semver{1, 2, 3}},
{in: "zellij 0.44.3-dev", want: semver{0, 44, 3}},
} {
got, err := parseVersion(tc.in)
if err != nil {
t.Fatalf("parseVersion(%q): %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("parseVersion(%q) = %v, want %v", tc.in, got, tc.want)
}
}
if _, err := parseVersion("zellij nope"); err == nil {
t.Fatal("parseVersion invalid: got nil, want error")
}
if compareVersion(semver{0, 44, 2}, semver{0, 44, 3}) >= 0 {
t.Fatal("compareVersion should order 0.44.2 before 0.44.3")
}
}
func TestSendMessageChunksAndSendsEnter(t *testing.T) {
fr := &fakeRunner{}
r := New(Options{Timeout: time.Second, ChunkSize: 5})
r.runner = fr
if err := r.SendMessage(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, "hello世界"); err != nil {
t.Fatalf("SendMessage: %v", err)
}
if len(fr.calls) != 4 {
t.Fatalf("calls = %d, want 4", len(fr.calls))
}
if got, want := fr.calls[0].args, pasteArgs("sess-1", "terminal_0", "hello"); !reflect.DeepEqual(got, want) {
t.Fatalf("paste 1 args = %#v, want %#v", got, want)
}
if got, want := fr.calls[1].args, pasteArgs("sess-1", "terminal_0", "世"); !reflect.DeepEqual(got, want) {
t.Fatalf("paste 2 args = %#v, want %#v", got, want)
}
if got, want := fr.calls[2].args, pasteArgs("sess-1", "terminal_0", "界"); !reflect.DeepEqual(got, want) {
t.Fatalf("paste 3 args = %#v, want %#v", got, want)
}
if got, want := fr.calls[3].args, sendEnterArgs("sess-1", "terminal_0"); !reflect.DeepEqual(got, want) {
t.Fatalf("enter args = %#v, want %#v", got, want)
}
}
func TestGetOutputTrimsLines(t *testing.T) {
fr := &fakeRunner{outputs: [][]byte{[]byte("one\ntwo\nthree\n")}}
r := New(Options{Timeout: time.Second})
r.runner = fr
out, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, 2)
if err != nil {
t.Fatalf("GetOutput: %v", err)
}
if out != "two\nthree\n" {
t.Fatalf("output = %q, want last two lines", out)
}
}
func TestIsAliveParsesNoFormattingOutput(t *testing.T) {
fr := &fakeRunner{outputs: [][]byte{[]byte("sess-1 [Created 1s ago] \nold [Created 2s ago] (EXITED - attach to resurrect)\n")}}
r := New(Options{Timeout: time.Second})
r.runner = fr
alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName})
if err != nil {
t.Fatalf("IsAlive: %v", err)
}
if !alive {
t.Fatal("alive = false, want true")
}
if sessionListedAlive("sess-1-long [Created 1s ago]", "sess-1") {
t.Fatal("prefix matched as alive")
}
if sessionListedAlive("sess-1 [Created 1s ago] (EXITED - attach to resurrect)", "sess-1") {
t.Fatal("exited session matched as alive")
}
}
func TestIsAliveTreatsExitStatusAsNotAlive(t *testing.T) {
fr := &fakeRunner{err: &exec.ExitError{}}
r := New(Options{Timeout: time.Second})
r.runner = fr
alive, err := r.IsAlive(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName})
if err != nil {
t.Fatalf("IsAlive: %v", err)
}
if alive {
t.Fatal("alive = true, want false")
}
}
func TestDestroyIsIdempotentWhenSessionMissing(t *testing.T) {
fr := &fakeRunner{err: &exec.ExitError{}}
r := New(Options{Timeout: time.Second})
r.runner = fr
if err := r.Destroy(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}); err != nil {
t.Fatalf("Destroy: %v", err)
}
if len(fr.calls) != 1 || fr.calls[0].args[0] != "kill-session" {
t.Fatalf("calls = %#v, want only kill-session", fr.calls)
}
}
func TestGetOutputValidatesLines(t *testing.T) {
r := New(Options{Timeout: time.Second})
_, err := r.GetOutput(context.Background(), ports.RuntimeHandle{ID: "sess-1/terminal_0", RuntimeName: runtimeName}, 0)
if err == nil {
t.Fatal("GetOutput lines=0: got nil, want error")
}
}
type fakeRunner struct {
calls []runnerCall
outputs [][]byte
err error
}
type runnerCall struct {
env []string
name string
args []string
}
func (f *fakeRunner) Run(_ context.Context, env []string, name string, args ...string) ([]byte, error) {
f.calls = append(f.calls, runnerCall{env: append([]string(nil), env...), name: name, args: append([]string(nil), args...)})
var out []byte
if len(f.outputs) > 0 {
out = f.outputs[0]
f.outputs = f.outputs[1:]
}
if f.err != nil {
return out, f.err
}
return out, nil
}
func TestCommandErrorUnwraps(t *testing.T) {
base := errors.New("base")
err := commandError{err: base, output: "details"}
if !errors.Is(err, base) {
t.Fatal("commandError should unwrap base error")
}
if !strings.Contains(err.Error(), "details") {
t.Fatalf("error = %q, want output details", err.Error())
}
}