diff --git a/cmd/mecatui/client/session.go b/cmd/mecatui/client/session.go index 547faeadc4..230d3a0378 100644 --- a/cmd/mecatui/client/session.go +++ b/cmd/mecatui/client/session.go @@ -18,6 +18,7 @@ import ( type SessionSnapshot struct { Mode string State string + Turns int32 Placement Placement CreatedAt int64 ResolvedModel ResolvedModel @@ -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()), diff --git a/cmd/mecatui/client/session_test.go b/cmd/mecatui/client/session_test.go index 0983c60439..e9ce1c1967 100644 --- a/cmd/mecatui/client/session_test.go +++ b/cmd/mecatui/client/session_test.go @@ -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{}, @@ -31,6 +51,7 @@ type sessionCapabilitiesServer struct { mecatlv1.UnimplementedHarnessServiceServer global *mecatlv1.ServerCapabilities globalErr error + snapshot *mecatlv1.Session sessionMedia *mecatlv1.SessionCapabilities } @@ -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 } diff --git a/cmd/mecatui/diaglog_delivery_test.go b/cmd/mecatui/diaglog_delivery_test.go index 0ef4e4118c..57aeebf143 100644 --- a/cmd/mecatui/diaglog_delivery_test.go +++ b/cmd/mecatui/diaglog_delivery_test.go @@ -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) diff --git a/cmd/mecatui/exit_handoff.go b/cmd/mecatui/exit_handoff.go index 43b27597bd..b126516f0f 100644 --- a/cmd/mecatui/exit_handoff.go +++ b/cmd/mecatui/exit_handoff.go @@ -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=" @@ -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 @@ -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 } diff --git a/cmd/mecatui/exit_handoff_composition_test.go b/cmd/mecatui/exit_handoff_composition_test.go new file mode 100644 index 0000000000..542b177d56 --- /dev/null +++ b/cmd/mecatui/exit_handoff_composition_test.go @@ -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:]) + } +} diff --git a/cmd/mecatui/exit_handoff_scenarios_test.go b/cmd/mecatui/exit_handoff_scenarios_test.go new file mode 100644 index 0000000000..57facf3100 --- /dev/null +++ b/cmd/mecatui/exit_handoff_scenarios_test.go @@ -0,0 +1,233 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/stacklok/mecatl/cmd/mecatui/client" + "github.com/stacklok/mecatl/cmd/mecatui/theme" + "github.com/stacklok/mecatl/cmd/mecatui/ui" +) + +type handoffSnapshotGetter func(context.Context, string) (client.SessionSnapshot, error) + +func (f handoffSnapshotGetter) GetSession(ctx context.Context, id string) (client.SessionSnapshot, error) { + return f(ctx, id) +} + +func handoffOutput(t *testing.T, id string, embedded bool, getter handoffSnapshotGetter, runErr error, interrupted bool) string { + t.Helper() + var out bytes.Buffer + var source sessionSnapshotGetter + if getter != nil { + source = handoffSnapshotGetter(func(ctx context.Context, id string) (client.SessionSnapshot, error) { + if out.Len() != 0 { + t.Fatalf("GetSession called after cleanup/output: %q", out.String()) + } + deadline, ok := ctx.Deadline() + if !ok || time.Until(deadline) > time.Second || time.Until(deadline) <= 0 { + t.Fatalf("snapshot deadline = %v, want future deadline <= 1s", deadline) + } + return getter(ctx, id) + }) + } + finishFinalSessionHandoff(&out, handoffTestModel{id: id}, runErr, interrupted, embedded, source, func() { out.WriteString("cleanup-complete\n") }) + got := out.String() + if !strings.HasPrefix(got, "cleanup-complete\n") { + t.Fatalf("handoff before cleanup: %q", got) + } + return strings.TrimPrefix(got, "cleanup-complete\n") +} + +func runExitHandoffScenarioHarness() { + final, err := tea.NewProgram(handoffTestModel{id: "final chat"}, tea.WithInput(nil), tea.WithOutput(os.Stderr)).Run() + finishFinalSessionHandoff(os.Stderr, final, err, false, true, handoffSnapshotGetter(func(context.Context, string) (client.SessionSnapshot, error) { + return client.SessionSnapshot{State: "completed"}, nil + }), func() { _, _ = os.Stderr.WriteString("cleanup-complete\n") }) +} + +func TestMecatuiExitHandoff_Scenario1_EmbeddedResumeAfterTeardown(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(exe, "-test.run=^$") + cmd.Env = append(os.Environ(), "MECATUI_TEST_EXIT_HANDOFF_SCENARIO=1") + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("handoff process: %v; stderr=%q", err, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("stdout changed: %q", stdout.String()) + } + got := stderr.String() + teardown := strings.Index(got, "\x1b[?1049l") + cleanup := strings.Index(got, "cleanup-complete\n") + line := strings.Index(got, finalSessionHandoffPrefix) + if teardown < 0 || cleanup <= teardown || line <= cleanup || strings.Count(got, finalSessionHandoffPrefix) != 1 { + t.Fatalf("teardown/cleanup/ID record order = %q", got) + } + lines := strings.Split(got[line:], "\n") + var decoded string + if err := json.Unmarshal([]byte(strings.TrimPrefix(lines[0], finalSessionHandoffPrefix)), &decoded); err != nil || decoded != "final chat" { + t.Fatalf("ID record = %q, decoded=%q, err=%v", lines[0], decoded, err) + } + if !strings.Contains(got, "Resume: mecatui --resume 'final chat'\n") || !strings.Contains(got, "Or: mecatui --resume-latest (may select a different chat)\n") { + t.Fatalf("missing exact/qualified continuation: %q", got) + } +} + +func TestMecatuiExitHandoff_Scenario1_AuthoritativeSummary(t *testing.T) { + get := handoffSnapshotGetter(func(_ context.Context, id string) (client.SessionSnapshot, error) { + if id != "final" { + t.Fatalf("requested session %q, want final", id) + } + return client.SessionSnapshot{Title: "Server display title", State: "completed", Turns: 7, Usage: client.Usage{InputTokens: 42, OutputTokens: 13, CacheReadTokens: 9, CacheWriteTokens: 3}, ContextOccupancy: &client.ContextOccupancy{InputTokens: 999}}, nil + }) + got := handoffOutput(t, "final", true, get, nil, false) + for _, line := range []string{"Session: Server display title\n", "Model calls: 7\n", "Tokens (main): 42 input, 13 output, 9 cache read, 3 cache write\n"} { + if !strings.Contains(got, line) { + t.Fatalf("missing %q: %q", line, got) + } + } + if strings.Contains(got, "999") { + t.Fatalf("context meter leaked into lifetime totals: %q", got) + } + got = handoffOutput(t, "final", true, handoffSnapshotGetter(func(context.Context, string) (client.SessionSnapshot, error) { + return client.SessionSnapshot{State: "completed", Turns: 0}, nil + }), nil, false) + if strings.Contains(got, "Session:") || strings.Contains(got, "cache read") || !strings.Contains(got, "Model calls: 0\n") { + t.Fatalf("empty title/zero cache = %q", got) + } +} + +func TestMecatuiExitHandoff_Scenario1_SafePresentation(t *testing.T) { + id := "雪 space;$() ' \\ \" `printf injected`" + got := handoffOutput(t, id, true, nil, nil, false) + line := "" + for _, l := range strings.Split(got, "\n") { + if strings.HasPrefix(l, "Resume: ") { + line = strings.TrimPrefix(l, "Resume: ") + } + } + if line == "" { + t.Fatalf("missing command: %q", got) + } + cmd := exec.Command("sh", "-c", "set -- "+line+"; printf '%s\\n' \"$#\" \"$1\" \"$2\" \"$3\"") + parsed, err := cmd.Output() + if err != nil || string(parsed) != "3\nmecatui\n--resume\n"+id+"\n" { + t.Fatalf("sh parsed %q: output=%q, err=%v", line, parsed, err) + } + for _, unsafeID := range []string{"two\nlines", "escape\x1b[31m", "bidi\u202eright"} { + got := handoffOutput(t, unsafeID, true, nil, nil, false) + quoted, _ := json.Marshal(unsafeID) + if !strings.Contains(got, finalSessionHandoffPrefix+string(quoted)+"\n") || strings.Contains(got, "Resume:") || strings.Contains(got, "Or:") { + t.Fatalf("unsafe ID %q: %q", unsafeID, got) + } + } + got = handoffOutput(t, "final", true, handoffSnapshotGetter(func(context.Context, string) (client.SessionSnapshot, error) { + return client.SessionSnapshot{State: "completed", Title: "ok\nFAKE LINE\x1b[31m\u202eevil", Turns: 1}, nil + }), nil, false) + if strings.Contains(got, "\x1b") || strings.Contains(got, "\u202e") || strings.Contains(got, "\nFAKE LINE") || strings.Count(got, "Session:") != 1 || !strings.Contains(got, "Session: okFAKE LINE[31mevil\n") { + t.Fatalf("untrusted title controls/lines: %q", got) + } +} + +func TestMecatuiExitHandoff_Scenario2_SnapshotUnavailable(t *testing.T) { + for _, tc := range []struct { + name string + getter handoffSnapshotGetter + }{ + {"blocked", func(ctx context.Context, _ string) (client.SessionSnapshot, error) { + <-ctx.Done() + return client.SessionSnapshot{Title: "stale"}, ctx.Err() + }}, + {"failed", func(context.Context, string) (client.SessionSnapshot, error) { + return client.SessionSnapshot{Title: "stale"}, errors.New("unavailable") + }}, + {"missing", func(context.Context, string) (client.SessionSnapshot, error) { return client.SessionSnapshot{}, nil }}, + } { + t.Run(tc.name, func(t *testing.T) { + start := time.Now() + got := handoffOutput(t, "final", true, tc.getter, nil, false) + if time.Since(start) > 2*time.Second { + t.Fatalf("snapshot blocked cleanup beyond deadline: %s", time.Since(start)) + } + if !strings.Contains(got, finalSessionHandoffPrefix+`"final"`+"\n") || !strings.Contains(got, "Resume: mecatui --resume 'final'\n") || strings.Contains(got, "Session:") || strings.Contains(got, "Model calls:") || strings.Contains(got, "Tokens (main):") { + t.Fatalf("failed snapshot must retain only safe guidance: %q", got) + } + }) + } + t.Run("embedded GetSession missing", func(t *testing.T) { + checkEmbeddedCompositionChild(t, "missing") + }) +} + +func TestMecatuiExitHandoff_Scenario2_ExitMatrix(t *testing.T) { + for _, tc := range []struct { + name string + id string + embedded bool + err error + interrupted bool + want string + }{ + {"no session", "", true, nil, false, ""}, + {"failed", "final", true, errors.New("failed"), false, ""}, + {"interrupted", "final", true, nil, true, ""}, + {"connected", "remote", false, nil, false, "\n" + finalSessionHandoffPrefix + `"remote"` + "\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + called := false + got := handoffOutput(t, tc.id, tc.embedded, handoffSnapshotGetter(func(context.Context, string) (client.SessionSnapshot, error) { + called = true + return client.SessionSnapshot{Title: "must not display", Turns: 99}, nil + }), tc.err, tc.interrupted) + if got != tc.want || called { + t.Fatalf("got %q, lookup=%v; want %q and no lookup", got, called, tc.want) + } + }) + } + // Exercise a real UI connect intent through the same exit and restart seams. + m := ui.New(ui.Deps{Connect: staticConnectController{target: "remote.example:443"}, ConnectOpen: true, Theme: theme.New("aztec", theme.AztecPalette()), Ctx: t.Context(), NoAltScreen: true}) + model, _ := m.Update(m.Init()()) + m = model.(ui.Model) + for range 2 { + model, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = model.(ui.Model) + } + intent, ok := m.ConnectRestartIntent() + if !ok { + t.Fatal("connect intent not produced") + } + model, _ = m.Update(client.SessionReadyMsg{SessionID: "pre-restart"}) + m = model.(ui.Model) + if m.ActiveSessionID() != "pre-restart" { + t.Fatalf("pre-restart ID = %q", m.ActiveSessionID()) + } + var out bytes.Buffer + lookups := 0 + finishFinalSessionHandoff(&out, m, nil, false, true, handoffSnapshotGetter(func(context.Context, string) (client.SessionSnapshot, error) { + lookups++ + return client.SessionSnapshot{State: "completed", Title: "pre-restart title", Turns: 9}, nil + }), func() { out.WriteString("cleanup-complete\n") }) + if out.String() != "cleanup-complete\n" || lookups != 0 { + t.Fatalf("restart handoff=%q lookups=%d", out.String(), lookups) + } + if err := restartFromConnectIntentWith([]string{"mecatui"}, intent, restartTransport{}, connectRestartOps{run: func([]string, runOptions) error { + out.WriteString("successor started\n") + return nil + }}); err != nil || out.String() != "cleanup-complete\nsuccessor started\n" { + t.Fatalf("restart err=%v output=%q", err, out.String()) + } +} diff --git a/cmd/mecatui/exit_handoff_test.go b/cmd/mecatui/exit_handoff_test.go index a7833716e7..8a331875fb 100644 --- a/cmd/mecatui/exit_handoff_test.go +++ b/cmd/mecatui/exit_handoff_test.go @@ -97,7 +97,7 @@ func TestSessionContinuityUX_Scenario7_OutputContract(t *testing.T) { t.Fatalf("stdout changed: %q", stdout.String()) } if tc.want { - if strings.Count(stderr.String(), finalSessionHandoffPrefix) != 1 || !strings.HasSuffix(stderr.String(), "\n") { + if !strings.HasPrefix(stderr.String(), "\n"+finalSessionHandoffPrefix) || strings.Count(stderr.String(), finalSessionHandoffPrefix) != 1 || !strings.HasSuffix(stderr.String(), "\n") { t.Fatalf("stderr grammar = %q", stderr.String()) } } else if stderr.Len() != 0 { diff --git a/cmd/mecatui/main.go b/cmd/mecatui/main.go index 625504cae2..b1dc11fd32 100644 --- a/cmd/mecatui/main.go +++ b/cmd/mecatui/main.go @@ -448,21 +448,20 @@ func runWithOptions(argv []string, options runOptions) error { runErr = err } - runCleanup(forceExit, func() { - // Settle the lifecycle hook FIRST: this must complete before the restart - // below can build a successor notifier, or a slow hook command could - // deliver this generation's terminal after the next generation's busy - // signal and mark the host idle during a live run. - closeAgentLifecycleHook(agentHook) - _ = cl.Close() - transCleanup() + finishFinalSessionHandoff(os.Stderr, finalModel, runErr, interrupted, cfg.transportMode == modeLocal, cl, func() { + runCleanup(forceExit, func() { + // Settle the lifecycle hook FIRST: this must complete before the restart + // below can build a successor notifier, or a slow hook command could + // deliver this generation's terminal after the next generation's busy + // signal and mark the host idle during a live run. + closeAgentLifecycleHook(agentHook) + _ = cl.Close() + transCleanup() + }) }) if intent, ok := connectRestartIntent(finalModel); ok { return restartFromConnectIntent(argv, intent, restartTransport{Target: target, TLSCAFile: cfg.tlsCA}) } - if shouldWriteFinalSessionHandoff(finalModel, runErr, interrupted) { - writeFinalSessionHandoff(os.Stderr, finalModel) - } return runErr } @@ -1053,7 +1052,6 @@ func resolveTransportWithHook(ctx context.Context, cfg config, beforeEmbeddedSta diag.Log(ctx, port.LevelInfo, "mecatui: embedded server diagnostics log opened", "path", diagSink.Path) } - fmt.Fprintf(os.Stderr, "mecatui: hosting an embedded mecated at %s\n", srv.Target()) if addr := srv.AdminAddr(); addr != "" { paths := "/metrics /debug/pprof /debug/vars /debug/flightrecorder" if cfg.perfMCP { diff --git a/cmd/mecatui/main_test.go b/cmd/mecatui/main_test.go index ea3c92d0d5..8fc0fb0a62 100644 --- a/cmd/mecatui/main_test.go +++ b/cmd/mecatui/main_test.go @@ -29,17 +29,26 @@ import ( // TestMain dispatches to the subprocess harnesses when their environment variables // are set. Otherwise it runs the normal isolated test suite. func TestMain(m *testing.M) { - os.Exit(testhome.Run("mecatui", func() int { - if id, ok := os.LookupEnv("MECATUI_TEST_EXIT_HANDOFF_ID"); ok { - runExitHandoffProcessHarness(id) - return 0 - } - if os.Getenv("MECATUI_TEST_SIGNAL_HANDLER") != "" { - run([]string{}) - return 0 - } - return m.Run() - })) + if os.Getenv("MECATUI_TEST_HANDOFF_COMPOSITION") != "" { + os.Exit(runMecatuiTest(m)) + } + os.Exit(testhome.Run("mecatui", func() int { return runMecatuiTest(m) })) +} + +func runMecatuiTest(m *testing.M) int { + if os.Getenv("MECATUI_TEST_EXIT_HANDOFF_SCENARIO") != "" { + runExitHandoffScenarioHarness() + return 0 + } + if id, ok := os.LookupEnv("MECATUI_TEST_EXIT_HANDOFF_ID"); ok { + runExitHandoffProcessHarness(id) + return 0 + } + if os.Getenv("MECATUI_TEST_SIGNAL_HANDLER") != "" { + run([]string{}) + return 0 + } + return m.Run() } type inertMicroVMReadyManager struct{} diff --git a/docs/acceptance/README.md b/docs/acceptance/README.md index 2d3f30f9f2..8ee5ade3b3 100644 --- a/docs/acceptance/README.md +++ b/docs/acceptance/README.md @@ -128,7 +128,7 @@ PR after verification. There is no cleanup or status-only PR. ## Plans -- [Mecatui exit handoff](mecatui-exit-handoff.md) - an exact embedded-session resume command, qualified latest alternative, and authoritative session summary after clean exit, while preserving the machine-readable ID record. Status: proposed. +- [Mecatui exit handoff](mecatui-exit-handoff.md) - an exact embedded-session resume command, qualified latest alternative, and authoritative session summary after clean exit, while preserving the machine-readable ID record. Status: landed in this implementation candidate; authoritative on merge. - [Tool-result availability](tool-result-availability.md) - adds one live-only, call-ID-correlated availability projection for every effective tool result so concurrent cards expose the batch's long pole while canonical results retain ordered history. Status: proposed. - [Configurable Mecatl commit co-author guidance](configurable-commit-coauthor-guidance.md) — default-on, operator-only standard-prompt commit attribution for main and delegated engines, with a typed opt-out and no repository authority to disable it. Status: proposed. diff --git a/docs/acceptance/mecatui-exit-handoff.md b/docs/acceptance/mecatui-exit-handoff.md index 3f3260183b..772d95c182 100644 --- a/docs/acceptance/mecatui-exit-handoff.md +++ b/docs/acceptance/mecatui-exit-handoff.md @@ -4,7 +4,7 @@ **Work classification:** Bounded — improves one client-owned exit presentation without changing session storage, run authority, or public service contracts. **Decision record:** None — the existing session snapshot and resume flags supply the required data; this is a bounded terminal-client workflow decision. **Phase:** session continuation -**Status:** proposed, 2026-10-01. Ready for Plan / Interface review. +**Status:** landed in this implementation candidate, 2026-10-01. Authoritative on merge. **Delivery:** Split. The additional CLI output and its compatibility with the existing machine-readable line warrant interface review before implementation. **Expected tasks:** deferred to orchestration. **Issue:** [stacklok/mecatl#1992](https://github.com/stacklok/mecatl/issues/1992). diff --git a/user-docs/mecatui/sessions.md b/user-docs/mecatui/sessions.md index 698951c622..d5c554af64 100644 --- a/user-docs/mecatui/sessions.md +++ b/user-docs/mecatui/sessions.md @@ -71,12 +71,31 @@ will not add watch support. Plan approvals and guardrail reviews use their exist dedicated flows and cannot be recovered this way. To get the active session ID, run `/session` and press `c` to copy it. On a -normal exit, `mecatui` also writes a machine-readable handoff to standard error: +normal exit, `mecatui` writes a JSON-quoted ID record to standard error: ```text mecatui: final-session-id="01JOPAQUESESSIONID" ``` +A blank line separates the exit record from startup notices. `mecatui` does not +print the embedded server's private socket address. + +After an embedded session ends normally, the lines that follow show a +shell-quoted `mecatui --resume ''` command for that exact final chat. +Use it to return to the same chat. The separately labelled `--resume-latest` +alternative looks for the newest eligible chat and can select a different one. +A connected session prints the ID record; to resume it, use +`mecatui connect
--resume ` with the original server address. + +When the session snapshot is available, **Model calls** counts model calls begun +in the chat, and **Tokens (main)** shows lifetime input and output tokens for +that chat. Nonzero cache-read and cache-write counts appear separately, not +added to input or output. Title-generation tokens and the latest context-meter +reading are not included. If the snapshot is unavailable, the exact embedded +resume command and ID record still appear without a summary. An ID that cannot +be safely displayed as a single terminal line retains its JSON ID record but +has no copyable command. + ## Inspect the active session during a run Run `/session` after a session is bound to open its read-only details overlay,