Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/mecatui/client/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
type SessionSnapshot struct {
Mode string
State string
Turns int32
Placement Placement
CreatedAt int64
ResolvedModel ResolvedModel
Expand Down Expand Up @@ -64,6 +65,7 @@ func snapshotFromWithGlobalCapabilities(s *mecatlv1.Session, global Capabilities
return SessionSnapshot{
Mode: ModeString(s.GetMode()),
State: s.GetState(),
Turns: s.GetTurns(),
Placement: placementFrom(s.GetPlacement()),
CreatedAt: s.GetCreatedAtUnix(),
ResolvedModel: resolvedModelFrom(s.GetResolvedModel()),
Expand Down
24 changes: 24 additions & 0 deletions cmd/mecatui/client/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ import (
mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1"
)

func TestMecatuiExitHandoff_Scenario1_SnapshotProjection(t *testing.T) {
cl := newSessionCapabilitiesClient(t, &sessionCapabilitiesServer{
snapshot: &mecatlv1.Session{
Turns: 7,
TitleMetadata: &mecatlv1.SessionTitle{Title: "Server display title"},
TokenUsage: map[string]*mecatlv1.TokenUsage{
"main": {Total: &mecatlv1.Usage{InputTokens: 42, OutputTokens: 13, CacheReadTokens: 9, CacheWriteTokens: 3}},
"session_title": {Total: &mecatlv1.Usage{InputTokens: 900}},
},
},
})
snap, err := cl.GetSession(t.Context(), "resume-session")
if err != nil {
t.Fatal(err)
}
if snap.Turns != 7 || snap.Title != "Server display title" || snap.Usage != (Usage{InputTokens: 42, OutputTokens: 13, CacheReadTokens: 9, CacheWriteTokens: 3}) {
t.Fatalf("GetSession projection = %+v", snap)
}
}

func TestSnapshotFromUsesSessionMediaCapabilities(t *testing.T) {
textOnly := snapshotFrom(&mecatlv1.Session{
SessionCapabilities: &mecatlv1.SessionCapabilities{},
Expand All @@ -31,6 +51,7 @@ type sessionCapabilitiesServer struct {
mecatlv1.UnimplementedHarnessServiceServer
global *mecatlv1.ServerCapabilities
globalErr error
snapshot *mecatlv1.Session
sessionMedia *mecatlv1.SessionCapabilities
}

Expand All @@ -42,6 +63,9 @@ func (s *sessionCapabilitiesServer) GetCompatibilityInfo(context.Context, *mecat
}

func (s *sessionCapabilitiesServer) GetSession(context.Context, *mecatlv1.GetSessionRequest) (*mecatlv1.GetSessionResponse, error) {
if s.snapshot != nil {
return &mecatlv1.GetSessionResponse{Session: s.snapshot}, nil
}
return &mecatlv1.GetSessionResponse{Session: &mecatlv1.Session{SessionId: "resume-session", SessionCapabilities: s.sessionMedia}}, nil
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/mecatui/diaglog_delivery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ func TestResolveTransportDeliversContendedDiagnostics(t *testing.T) {
if !strings.HasPrefix(stderr.String(), wantNotice) || strings.Count(stderr.String(), wantNotice) != 1 {
t.Errorf("contention notice missing, late, or duplicated: %q", &stderr)
}
if got := strings.Contains(stderr.String(), "hosting an embedded mecated"); got != (scenario == "success") {
t.Errorf("startup outcome on stderr: %q", &stderr)
if strings.Contains(stderr.String(), "hosting an embedded mecated") {
t.Errorf("private socket address leaked to stderr: %q", &stderr)
}
if scenario != "fallback-failure" {
contents, err := os.ReadFile(fallback)
Expand Down
61 changes: 60 additions & 1 deletion cmd/mecatui/exit_handoff.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"unicode"
"unicode/utf8"

tea "charm.land/bubbletea/v2"

"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/internal/terminaltext"
)

const finalSessionHandoffPrefix = "mecatui: final-session-id="
Expand All @@ -14,6 +22,57 @@ type activeSessionReporter interface {
ActiveSessionID() string
}

type sessionSnapshotGetter interface {
GetSession(context.Context, string) (client.SessionSnapshot, error)
}

// finishFinalSessionHandoff reads before the client closes, then prints only after cleanup.
func finishFinalSessionHandoff(w io.Writer, final tea.Model, runErr error, interrupted, embedded bool, getter sessionSnapshotGetter, cleanup func()) {
var snapshot client.SessionSnapshot
var available bool
reporter, ok := final.(activeSessionReporter)
if embedded && ok && shouldWriteFinalSessionHandoff(final, runErr, interrupted) && reporter.ActiveSessionID() != "" && utf8.ValidString(reporter.ActiveSessionID()) && getter != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
var err error
snapshot, err = getter.GetSession(ctx, reporter.ActiveSessionID())
available = err == nil && ctx.Err() == nil && snapshot.State != ""
cancel()
}
cleanup()
if !shouldWriteFinalSessionHandoff(final, runErr, interrupted) || !writeFinalSessionHandoff(w, final) || !embedded || !ok {
return
}
id := reporter.ActiveSessionID()
var human strings.Builder
if available {
if title := strings.TrimSpace(terminaltext.SanitizeSingleLine(snapshot.Title)); title != "" {
_, _ = fmt.Fprintf(&human, "Session: %s\n", title)
}
_, _ = fmt.Fprintf(&human, "Model calls: %d\nTokens (main): %d input, %d output", snapshot.Turns, snapshot.Usage.InputTokens, snapshot.Usage.OutputTokens)
if snapshot.Usage.CacheReadTokens != 0 {
_, _ = fmt.Fprintf(&human, ", %d cache read", snapshot.Usage.CacheReadTokens)
}
if snapshot.Usage.CacheWriteTokens != 0 {
_, _ = fmt.Fprintf(&human, ", %d cache write", snapshot.Usage.CacheWriteTokens)
}
human.WriteByte('\n')
}
if safeHandoffID(id) {
_, _ = fmt.Fprintf(&human, "Resume: mecatui --resume '%s'\n", strings.ReplaceAll(id, "'", "'\"'\"'"))
human.WriteString("Or: mecatui --resume-latest (may select a different chat)\n")
}
_, _ = io.WriteString(w, human.String())
}

func safeHandoffID(id string) bool {
for _, r := range id {
if !unicode.IsPrint(r) || unicode.Is(unicode.Cf, r) {
return false
}
}
return true
}

func maybeWriteFinalSessionHandoff(w io.Writer, final tea.Model, runErr error, interrupted bool) bool {
if runErr != nil || interrupted {
return false
Expand All @@ -34,6 +93,6 @@ func writeFinalSessionHandoff(w io.Writer, final tea.Model) bool {
if err != nil {
return false
}
_, err = io.WriteString(w, finalSessionHandoffPrefix+string(quoted)+"\n")
_, err = io.WriteString(w, "\n"+finalSessionHandoffPrefix+string(quoted)+"\n")
return err == nil
}
232 changes: 232 additions & 0 deletions cmd/mecatui/exit_handoff_composition_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package main

import (
"bytes"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

tea "charm.land/bubbletea/v2"

"github.com/stacklok/mecatl/cmd/mecatui/client"
"github.com/stacklok/mecatl/cmd/mecatui/ui"
"github.com/stacklok/mecatl/internal/app"
)

// The child owns a real embedded server; the parent observes the process stderr
// independently of the terminal and the server's shutdown.
func TestMecatuiExitHandoff_Scenario1_EmbeddedComposition(t *testing.T) {
if os.Getenv("MECATUI_TEST_HANDOFF_COMPOSITION") == "" {
checkEmbeddedCompositionChild(t, "available")
return
}
configHome := os.Getenv("XDG_CONFIG_HOME")
runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
userModelDir := os.Getenv("MECATUI_TEST_HANDOFF_USER_MODEL_DIR")
workspace := os.Getenv("MECATUI_TEST_HANDOFF_WORKSPACE")
if configHome == "" || runtimeDir == "" || userModelDir == "" || workspace == "" {
t.Fatal("embedded composition requires synthetic test environment")
}
settingsDir := filepath.Join(configHome, "mecatl")
if err := os.MkdirAll(settingsDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(settingsDir, "settings.yaml"), []byte("execution:\n default_placement: "+app.PlacementHostLocal+"\n"), 0o600); err != nil {
t.Fatal(err)
}
before, err := filepath.Glob(filepath.Join(runtimeDir, "mecatui-*", "mecated.sock"))
if err != nil {
t.Fatal(err)
}
known := make(map[string]bool, len(before))
for _, socket := range before {
known[socket] = true
}
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
defer cancel()
err = runWithOptions([]string{"mecatui", "--mock", "--quiet", "--no-store", "--no-memory", "--no-user-model", "--user-model-dir=" + userModelDir, "--no-soul", "--no-skills", "--no-commands", "--workspace=" + workspace}, runOptions{runProgram: func(_ context.Context, m ui.Model) (tea.Model, error) {
sockets, err := filepath.Glob(filepath.Join(runtimeDir, "mecatui-*", "mecated.sock"))
var fresh []string
for _, socket := range sockets {
if !known[socket] {
fresh = append(fresh, socket)
}
}
if err != nil || len(fresh) != 1 {
t.Fatalf("embedded sockets=%v err=%v", fresh, err)
}
target := "unix://" + fresh[0]
cl, err := client.Dial(client.DialConfig{Server: target})
if err != nil {
t.Fatal(err)
}
defer func() { _ = cl.Close() }()
first, _, _, err := cl.CreateSession(ctx, client.ModeFromString("default"), client.ModelSelection{})
if err != nil {
t.Fatal(err)
}
if _, err := cl.RenameSession(ctx, first, "first title"); err != nil {
t.Fatal(err)
}
m0, _ := m.Update(client.SessionReadyMsg{SessionID: first})
m = m0.(ui.Model)
final := seedStartupResumeSession(ctx, t, target, "")
if final == first {
t.Fatal("seed must switch sessions")
}
if _, err := cl.RenameSession(ctx, final, "final title"); err != nil {
t.Fatal(err)
}
finalRecord, err := json.Marshal(final)
if err != nil {
t.Fatal(err)
}
_, _ = os.Stderr.WriteString("handoff-child-final-id=" + string(finalRecord) + "\n")
m0, _ = m.Update(client.SessionReadyMsg{SessionID: final})
m = m0.(ui.Model)
if m.ActiveSessionID() != final {
t.Fatalf("active=%q final=%q", m.ActiveSessionID(), final)
}
if os.Getenv("MECATUI_TEST_HANDOFF_COMPOSITION") == "missing" {
m0, _ = m.Update(client.SessionReadyMsg{SessionID: "missing-final"})
m = m0.(ui.Model)
}
// Run an alternate-screen program before returning the actual UI model.
if _, err := tea.NewProgram(handoffTestModel{}, tea.WithInput(nil), tea.WithOutput(os.Stderr)).Run(); err != nil {
t.Fatal(err)
}
return m, nil
}})
if err != nil {
t.Fatal(err)
}
sockets, err := filepath.Glob(filepath.Join(runtimeDir, "mecatui-*", "mecated.sock"))
for _, socket := range sockets {
if !known[socket] {
t.Fatalf("embedded server not cleaned: %v %v", sockets, err)
}
}
if err != nil {
t.Fatal(err)
}
_, _ = os.Stderr.WriteString("handoff-child-cleaned\n")
}

func compositionChildEnv(t *testing.T, scenario string) []string {
t.Helper()
root := compositionScratchRoot(t)
paths := map[string]string{
"HOME": filepath.Join(root, "home"),
"XDG_CONFIG_HOME": filepath.Join(root, "config"),
"XDG_STATE_HOME": filepath.Join(root, "state"),
"XDG_DATA_HOME": filepath.Join(root, "data"),
"XDG_RUNTIME_DIR": filepath.Join(root, "runtime"),
"MECATUI_TEST_HANDOFF_USER_MODEL_DIR": filepath.Join(root, "user-model"),
"MECATUI_TEST_HANDOFF_WORKSPACE": filepath.Join(root, "workspace"),
}
for name, dir := range paths {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("create %s: %v", name, err)
}
}
return []string{
"HOME=" + paths["HOME"],
"XDG_CONFIG_HOME=" + paths["XDG_CONFIG_HOME"],
"XDG_STATE_HOME=" + paths["XDG_STATE_HOME"],
"XDG_DATA_HOME=" + paths["XDG_DATA_HOME"],
"XDG_RUNTIME_DIR=" + paths["XDG_RUNTIME_DIR"],
"MECATUI_TEST_HANDOFF_USER_MODEL_DIR=" + paths["MECATUI_TEST_HANDOFF_USER_MODEL_DIR"],
"MECATUI_TEST_HANDOFF_WORKSPACE=" + paths["MECATUI_TEST_HANDOFF_WORKSPACE"],
"MECATUI_TEST_HANDOFF_COMPOSITION=" + scenario,
"PATH=" + os.Getenv("PATH"),
}
}

func compositionScratchRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
scratch := filepath.Join(dir, ".scratch")
if err := os.MkdirAll(scratch, 0o700); err != nil {
t.Fatal(err)
}
root, err := os.MkdirTemp(scratch, "mh-")
if err != nil {
t.Fatal(err)
}
if len(filepath.Join(root, "runtime", "mecatui-0123456789", "mecated.sock")) < 100 {
t.Cleanup(func() { _ = os.RemoveAll(root) })
return root
}
if err := os.RemoveAll(root); err != nil {
t.Fatal(err)
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
t.Fatal("no ancestor checkout has a short enough .scratch directory for embedded socket")
return ""
}

func checkEmbeddedCompositionChild(t *testing.T, scenario string) {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(exe, "-test.run=^TestMecatuiExitHandoff_Scenario1_EmbeddedComposition$")
cmd.Env = compositionChildEnv(t, scenario)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("embedded child: %v; stdout=%q stderr=%q", err, stdout.String(), stderr.String())
}
if stdout.String() != "PASS\n" {
t.Fatalf("stdout changed by program: %q", stdout.String())
}
got := stderr.String()
line := strings.Index(got, finalSessionHandoffPrefix)
if line < 1 || got[line-1] != '\n' || strings.Contains(got, "hosting an embedded mecated at") || strings.Count(got, finalSessionHandoffPrefix) != 1 || strings.LastIndex(got[:line], "\x1b[?1049l") < 0 || !strings.Contains(got[line:], "handoff-child-cleaned\n") {
t.Fatalf("handoff must follow teardown with a separating line and no startup socket address: %q", got)
}
fields := strings.SplitN(got[line:], "\n", 2)
var id string
if err := json.Unmarshal([]byte(strings.TrimPrefix(fields[0], finalSessionHandoffPrefix)), &id); err != nil || id == "" {
t.Fatalf("invalid ID: %q: %v", fields[0], err)
}
const finalIDPrefix = "handoff-child-final-id="
finalLine := strings.Index(got, finalIDPrefix)
if finalLine < 0 {
t.Fatalf("missing seeded final ID: %q", got)
}
var finalID string
if err := json.Unmarshal([]byte(strings.SplitN(got[finalLine+len(finalIDPrefix):], "\n", 2)[0]), &finalID); err != nil {
t.Fatalf("invalid seeded final ID: %v", err)
}
if scenario != "missing" && id != finalID {
t.Fatalf("handoff ID = %q, want seeded final ID %q", id, finalID)
}
if !strings.Contains(got[line:], "Resume: mecatui --resume '"+id+"'\n") || !strings.Contains(got[line:], "Or: mecatui --resume-latest (may select a different chat)\n") {
t.Fatalf("final ID command missing: %q", got[line:])
}
if scenario == "missing" {
if id != "missing-final" || strings.Contains(got[line:], "Session:") || strings.Contains(got[line:], "Model calls:") || strings.Contains(got[line:], "Tokens (main):") {
t.Fatalf("failed lookup retained stale summary: %q", got[line:])
}
} else if !strings.Contains(got[line:], "Session: final title\nModel calls: 1\nTokens (main): 0 input, 0 output\n") || strings.Contains(got[line:], "first title") {
t.Fatalf("wrong final session snapshot: %q", got[line:])
}
}
Loading
Loading