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
5 changes: 2 additions & 3 deletions cmd/mecatui/client/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,10 @@ type Capabilities struct {
SessionMediaPresent bool
}

// capabilitiesWithSessionMedia overlays the selected session's media support while
// capabilitiesWithSessionMediaFrom overlays the selected session's media support while
// retaining server-wide feature bits. A nil session capability is an older-server
// response, so the global media values remain unchanged.
func capabilitiesWithSessionMedia(global *mecatlv1.ServerCapabilities, sessionCaps *mecatlv1.SessionCapabilities) Capabilities {
caps := capabilitiesFrom(global)
func capabilitiesWithSessionMediaFrom(caps Capabilities, sessionCaps *mecatlv1.SessionCapabilities) Capabilities {
if sessionCaps != nil {
caps.Image = sessionCaps.GetImage()
caps.Audio = sessionCaps.GetAudio()
Expand Down
4 changes: 2 additions & 2 deletions cmd/mecatui/client/capabilities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import (
func TestCapabilitiesWithSessionMedia(t *testing.T) {
global := &mecatlv1.ServerCapabilities{Image: true, Audio: true, Teams: true}

overlaid := capabilitiesWithSessionMedia(global, &mecatlv1.SessionCapabilities{})
overlaid := capabilitiesWithSessionMediaFrom(capabilitiesFrom(global), &mecatlv1.SessionCapabilities{})
if overlaid.Image || overlaid.Audio || !overlaid.Teams || !overlaid.SessionMediaPresent {
t.Fatalf("explicit text-only session capabilities = %+v", overlaid)
}
fallback := capabilitiesWithSessionMedia(global, nil)
fallback := capabilitiesWithSessionMediaFrom(capabilitiesFrom(global), nil)
if !fallback.Image || !fallback.Audio || !fallback.Teams || fallback.SessionMediaPresent {
t.Fatalf("older-server fallback capabilities = %+v", fallback)
}
Expand Down
6 changes: 1 addition & 5 deletions cmd/mecatui/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,11 +486,7 @@ func (c *Client) createSession(ctx context.Context, req *mecatlv1.CreateSessionR
if err != nil {
return "", Capabilities{}, ResolvedModel{}, fmt.Errorf("create session: %w", err)
}
if media := resp.GetSessionCapabilities(); media != nil {
caps.Image = media.GetImage()
caps.Audio = media.GetAudio()
caps.SessionMediaPresent = true
}
caps = capabilitiesWithSessionMediaFrom(caps, resp.GetSessionCapabilities())
return resp.GetSessionId(), caps, resolvedModelFrom(resp.GetResolvedModel()), nil
}

Expand Down
4 changes: 4 additions & 0 deletions cmd/mecatui/client/placement_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ func (f *fakePlacementClient) ClearSession(_ context.Context, req *mecatlv1.Clea
return &mecatlv1.ClearSessionResponse{SessionId: "successor"}, nil
}

func (*fakePlacementClient) GetCompatibilityInfo(context.Context, *mecatlv1.GetCompatibilityInfoRequest, ...grpc.CallOption) (*mecatlv1.GetCompatibilityInfoResponse, error) {
return &mecatlv1.GetCompatibilityInfoResponse{ApiMajor: 1}, nil
}

func (*fakePlacementClient) GetSession(context.Context, *mecatlv1.GetSessionRequest, ...grpc.CallOption) (*mecatlv1.GetSessionResponse, error) {
return &mecatlv1.GetSessionResponse{Session: &mecatlv1.Session{
State: "idle", Placement: &mecatlv1.PlacementMetadata{Kind: "git", Label: "feature", Branch: "topic", Revision: "abc"},
Expand Down
15 changes: 13 additions & 2 deletions cmd/mecatui/client/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ func contextOccupancyFrom(occupancy *mecatlv1.ContextOccupancy) *ContextOccupanc
}

func snapshotFrom(s *mecatlv1.Session) SessionSnapshot {
return snapshotFromWithGlobalCapabilities(s, Capabilities{})
}

func snapshotFromWithGlobalCapabilities(s *mecatlv1.Session, global Capabilities) SessionSnapshot {
if s == nil {
return SessionSnapshot{Mode: ModeDefaultString}
}
Expand All @@ -68,7 +72,7 @@ func snapshotFrom(s *mecatlv1.Session) SessionSnapshot {
Title: titleFromProto(s),
TitleProvenance: titleProvenanceFromProto(s),
TitleRevision: s.GetTitleMetadata().GetRevision(),
Capabilities: capabilitiesWithSessionMedia(nil, s.GetSessionCapabilities()),
Capabilities: capabilitiesWithSessionMediaFrom(global, s.GetSessionCapabilities()),
}
}

Expand Down Expand Up @@ -97,7 +101,14 @@ func (c *Client) GetSession(ctx context.Context, id string) (SessionSnapshot, er
if err != nil {
return SessionSnapshot{}, fmt.Errorf("get session: %w", err)
}
return snapshotFrom(resp.GetSession()), nil
global, err := c.compatibilityCapabilities(ctx)
if err != nil {
// Servers predating GetCompatibilityInfo still support session resume. Their
// session media snapshot remains authoritative; absent global bits degrade
// safely to false rather than making resume fail.
global = Capabilities{}
}
return snapshotFromWithGlobalCapabilities(resp.GetSession(), global), nil
}

// SetMode asks the server to change the session's permission posture and returns
Expand Down
4 changes: 2 additions & 2 deletions cmd/mecatui/client/session_affinity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ func TestSessionAffinityAndHandoff_Scenario4_MecatuiUnaryAndStreamPropagation(t
})
}

if len(conn.calls) != len(calls)+1 {
t.Fatalf("recorded calls = %d, want %d", len(conn.calls), len(calls)+1)
if len(conn.calls) != len(calls)+2 {
t.Fatalf("recorded calls = %d, want %d", len(conn.calls), len(calls)+2)
}
for _, call := range conn.calls {
if strings.HasSuffix(call.method, "/GetCompatibilityInfo") {
Expand Down
75 changes: 74 additions & 1 deletion cmd/mecatui/client/session_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package client

import (
"context"
"net"
"testing"

"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"

mecatlv1 "github.com/stacklok/mecatl/contracts/gen/go/mecatl/v1"
)

Expand All @@ -19,11 +27,76 @@ func TestSnapshotFromUsesSessionMediaCapabilities(t *testing.T) {
}
}

type sessionCapabilitiesServer struct {
mecatlv1.UnimplementedHarnessServiceServer
global *mecatlv1.ServerCapabilities
globalErr error
sessionMedia *mecatlv1.SessionCapabilities
}

func (s *sessionCapabilitiesServer) GetCompatibilityInfo(context.Context, *mecatlv1.GetCompatibilityInfoRequest) (*mecatlv1.GetCompatibilityInfoResponse, error) {
if s.globalErr != nil {
return nil, s.globalErr
}
return &mecatlv1.GetCompatibilityInfoResponse{ApiMajor: 1, Capabilities: s.global}, nil
}

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

func newSessionCapabilitiesClient(t *testing.T, server *sessionCapabilitiesServer) *Client {
t.Helper()
listener := bufconn.Listen(1 << 20)
grpcServer := grpc.NewServer()
mecatlv1.RegisterHarnessServiceServer(grpcServer, server)
go func() { _ = grpcServer.Serve(listener) }()
t.Cleanup(grpcServer.Stop)

conn, err := grpc.NewClient("passthrough:///session-capabilities", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = conn.Close() })
return &Client{svc: mecatlv1.NewHarnessServiceClient(conn)}
}

func TestGetSessionCapabilitiesOverlaysSessionMediaOnGlobalAdvertisement(t *testing.T) {
cl := newSessionCapabilitiesClient(t, &sessionCapabilitiesServer{
global: &mecatlv1.ServerCapabilities{Steer: true, Teams: true, Image: true, Audio: true},
sessionMedia: &mecatlv1.SessionCapabilities{Audio: true},
})

snapshot, err := cl.GetSession(t.Context(), "resume-session")
if err != nil {
t.Fatal(err)
}
if !snapshot.Capabilities.Steer || !snapshot.Capabilities.Teams || snapshot.Capabilities.Image || !snapshot.Capabilities.Audio || !snapshot.Capabilities.SessionMediaPresent {
t.Fatalf("resume capabilities = %+v", snapshot.Capabilities)
}
}

func TestGetSessionCapabilitiesFallsBackWhenCompatibilityInfoIsUnavailable(t *testing.T) {
cl := newSessionCapabilitiesClient(t, &sessionCapabilitiesServer{
globalErr: status.Error(codes.Unimplemented, "older server"),
sessionMedia: &mecatlv1.SessionCapabilities{Image: true},
})

snapshot, err := cl.GetSession(t.Context(), "resume-session")
if err != nil {
t.Fatalf("GetSession returned compatibility error: %v", err)
}
if !snapshot.Capabilities.Image || snapshot.Capabilities.Audio || !snapshot.Capabilities.SessionMediaPresent || snapshot.Capabilities.Steer || snapshot.Capabilities.Teams {
t.Fatalf("older-server resume capabilities = %+v", snapshot.Capabilities)
}
}

// TestSnapshotFromReadsState asserts snapshotFrom projects the proto Session's
// State field (issue #245 Phase 1) — the picker row needs it to render an
// "open existing session" affordance. Covers the populated and nil cases.
func TestSnapshotFromReadsState(t *testing.T) {
// Populated: a completed session carries its state through.
snap := snapshotFrom(&mecatlv1.Session{State: "completed"})
if snap.State != "completed" {
t.Fatalf("State = %q, want %q", snap.State, "completed")
Expand Down
3 changes: 2 additions & 1 deletion cmd/mecatui/startup_resume_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func TestResumeLatestLoaderPreservesStatusSnapshot(t *testing.T) {
ResolvedModel: client.ResolvedModel{ContextWindow: 200_000},
Usage: client.Usage{InputTokens: 120_000, OutputTokens: 4_000, CacheReadTokens: 90_000},
ContextOccupancy: &client.ContextOccupancy{InputTokens: 40_000, Estimated: true},
Capabilities: client.Capabilities{Steer: true},
}
source := &fakeStartupResumeSource{
rows: []client.SessionListItem{
Expand All @@ -68,7 +69,7 @@ func TestResumeLatestLoaderPreservesStatusSnapshot(t *testing.T) {
if selection == nil || selection.Row.ID != "status-chat" {
t.Fatalf("resume-latest selection = %+v, want status-chat", selection)
}
if selection.Snapshot.Usage != want.Usage || selection.Snapshot.ResolvedModel != want.ResolvedModel ||
if selection.Snapshot.Usage != want.Usage || selection.Snapshot.ResolvedModel != want.ResolvedModel || selection.Snapshot.Capabilities != want.Capabilities ||
selection.Snapshot.ContextOccupancy == nil || *selection.Snapshot.ContextOccupancy != *want.ContextOccupancy {
t.Fatalf("startup selection status = %+v, want %+v", selection.Snapshot, want)
}
Expand Down
59 changes: 58 additions & 1 deletion cmd/mecatui/ui/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,64 @@ func (m Model) diagnosticsReport(serverBuild, serverImplementation, displayServe
return report + "\n" +
"active provider: " + provider + "\n" +
"active model: " + model + "\n" +
"permission mode: " + mode + m.statusCommandDiagnosticsReport()
"permission mode: " + mode + m.capabilityDiagnosticsReport() + "\n" +
"Converse stream: " + map[bool]string{true: "connected", false: unavailableText}[m.stream != nil] + m.statusCommandDiagnosticsReport()
}

func (m Model) capabilityDiagnosticsReport() string {
caps := m.caps
manualDream := caps.ManualDream
capabilities := []struct {
name string
enabled bool
}{
{"mcp_connector_status", caps.MCPConnectorStatus},
{"mcp_refresh", caps.MCPRefresh},
{"mcp", caps.MCP},
{"slash_commands", caps.SlashCommands},
{"memory", caps.Memory},
{"skills", caps.Skills},
{"teams", caps.Teams},
{"agents", caps.Agents},
{"shell", caps.Shell},
{"soul", caps.Soul},
{"user_model", caps.UserModel},
{"model_selection", caps.ModelSelection},
{"image", caps.Image},
{"audio", caps.Audio},
{"worktrees", caps.Worktrees},
{"scheduling", caps.Scheduling},
{"reflection", caps.Reflection},
{"learning_proposals", caps.LearningProposals},
{"learned_skills", caps.LearnedSkills},
{"storage_health", caps.StorageHealth},
{"storage_cleanup", caps.StorageCleanup},
{"manual_dream_advertised", manualDream != nil},
{"manual_dream_project_memory_generate", manualDream != nil && manualDream.ProjectMemory.Generate},
{"manual_dream_project_memory_decide", manualDream != nil && manualDream.ProjectMemory.Decide},
{"manual_dream_user_model_generate", manualDream != nil && manualDream.UserModel.Generate},
{"manual_dream_user_model_decide", manualDream != nil && manualDream.UserModel.Decide},
{"steer", caps.Steer},
{"manual_compaction", caps.ManualCompaction},
{"session_debug", caps.SessionDebug},
{"debug_mcp", caps.DebugMCP},
{"workspace_enrollment", caps.WorkspaceEnrollment},
{"session_media_present", caps.SessionMediaPresent},
}
var report strings.Builder
report.WriteString("\ncaps:")
for _, capability := range capabilities {
report.WriteByte(' ')
if capability.enabled {
report.WriteByte('+')
} else {
report.WriteByte('-')
}
report.WriteString(capability.name)
}
report.WriteString(" posture=")
report.WriteString(diagnosticToken(caps.Posture))
return report.String()
}

func (m Model) statusCommandDiagnosticsReport() string {
Expand Down
48 changes: 43 additions & 5 deletions cmd/mecatui/ui/diagnostics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ func TestDebugLaunchPrependsAutomaticRemoteDiagnosticsToFirstTurn(t *testing.T)
}
m = m0.(Model)
msg := lookup().(diagnosticsMsg)
wantPrompt := debuggerInitialPrompt(
m.diagnosticsReport(info.info.BuildID, info.info.ServerImplementation, info.info.DisplayServerEndpoint, info.info.LLMProviderDisplayEndpoint, "ok"),
"Why did it fail?",
)
m0, submit := m.Update(msg)
m = m0.(Model)
runBatchLeaves(submit)
Expand All @@ -71,10 +75,6 @@ func TestDebugLaunchPrependsAutomaticRemoteDiagnosticsToFirstTurn(t *testing.T)
t.Fatalf("lookup calls/first turns = %d/%d, want 1/1", info.calls, len(frames))
}
got := frames[0].GetPrompt().GetText()
wantPrompt := debuggerInitialPrompt(
m.diagnosticsReport(info.info.BuildID, info.info.ServerImplementation, info.info.DisplayServerEndpoint, info.info.LLMProviderDisplayEndpoint, "ok"),
"Why did it fail?",
)
if got != wantPrompt {
t.Fatalf("first debugger turn differs from objective-first contract:\n--- got ---\n%s\n--- want ---\n%s", got, wantPrompt)
}
Expand Down Expand Up @@ -168,12 +168,50 @@ func TestDiagnosticsCommandSendsSafeRemoteReport(t *testing.T) {
t.Errorf("report missing %q:\n%s", want, got)
}
}
const expectedLines = 12
const expectedLines = 14
if lines := strings.Split(got, "\n"); len(lines) != expectedLines {
t.Fatalf("report has %d lines, want the %d-line allowlist: %q", len(lines), expectedLines, got)
}
}

func TestDiagnosticsReportsCapabilitiesAndConverseStreamState(t *testing.T) {
caps := client.Capabilities{
MCPConnectorStatus: true, MCPRefresh: true, MCP: true, SlashCommands: true, Memory: true, Skills: true, Teams: true, Agents: true,
Shell: true, Soul: true, UserModel: true, ModelSelection: true, Image: true, Audio: true, Posture: "yolo", Worktrees: true,
Scheduling: true, Reflection: true, LearningProposals: true, LearnedSkills: true, StorageHealth: true, StorageCleanup: true,
ManualDream: &client.ManualDreamCapabilities{
ProjectMemory: client.DreamTargetCapability{Generate: true, Decide: true},
UserModel: client.DreamTargetCapability{Generate: true},
},
Steer: true, ManualCompaction: true, SessionDebug: true, DebugMCP: true, WorkspaceEnrollment: true, SessionMediaPresent: true,
}
m, _ := builtinDispatchModel(t, caps, false)
m.stream = client.NewStream(nil, nil)

report := m.diagnosticsReport("", "", "", "", "embedded")
const wantCaps = "caps: +mcp_connector_status +mcp_refresh +mcp +slash_commands +memory +skills +teams +agents +shell +soul +user_model +model_selection +image +audio +worktrees +scheduling +reflection +learning_proposals +learned_skills +storage_health +storage_cleanup +manual_dream_advertised +manual_dream_project_memory_generate +manual_dream_project_memory_decide +manual_dream_user_model_generate -manual_dream_user_model_decide +steer +manual_compaction +session_debug +debug_mcp +workspace_enrollment +session_media_present posture=yolo"
for _, want := range []string{wantCaps, "Converse stream: connected"} {
if !strings.Contains(report, want) {
t.Errorf("report missing %q: %q", want, report)
}
}

m.caps = client.Capabilities{}
m.stream = nil
report = m.diagnosticsReport("", "", "", "", "embedded")
for _, want := range []string{
"caps: -mcp_connector_status -mcp_refresh -mcp",
"-manual_dream_advertised",
"-steer",
"-workspace_enrollment -session_media_present posture=unavailable",
"Converse stream: unavailable",
} {
if !strings.Contains(report, want) {
t.Errorf("report missing %q: %q", want, report)
}
}
}

func TestDiagnosticsCommandIncludesSafeStatusCommandState(t *testing.T) {
m, send := builtinDispatchModel(t, client.Capabilities{}, false)
m.deps.StatusSource = diagnosticStatusSourceFake{diagnostics: customization.CommandDiagnostics{
Expand Down
23 changes: 23 additions & 0 deletions cmd/mecatui/ui/session_continuity_scenario6_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,29 @@ func TestSessionContinuityUX_Scenario6_NoThrowawaySession(t *testing.T) {
}
}

func TestResumeStartupRetainsAdvertisedCapabilities(t *testing.T) {
resume := startupSelection("existing", "completed")
resume.Snapshot.Capabilities = client.Capabilities{
Steer: true, Teams: true, Image: false, Audio: true, SessionMediaPresent: true,
}
conv := &fakeConv{recv: &fakeRecver{}, send: &fakeSender{}}
m := newTestModelFromDeps(Deps{
Session: conv, Conv: conv, Theme: testTheme(), Ctx: t.Context(), Workspace: "/launch", Resume: resume,
})

if !m.caps.Steer || !m.caps.Teams || m.caps.Image || !m.caps.Audio || !m.caps.SessionMediaPresent {
t.Fatalf("resumed capabilities = %+v", m.caps)
}
m = startRunning(t, m, "first")
m = enqueueSteer(t, m, "follow this instead")
if got := steerTexts(conv.send); !reflect.DeepEqual(got, []string{"follow this instead"}) {
t.Fatalf("steer frames = %#v, want resumed session to steer", got)
}
if len(m.queued) != 0 {
t.Fatalf("queued prompts = %#v, want no local queue in steer mode", m.queued)
}
}

func TestSessionContinuityUX_Scenario6_StaleRunningDefersToRunEntry(t *testing.T) {
conv := &fakeConv{}
m := startupResumeUI(t, conv, "", "running")
Expand Down
11 changes: 6 additions & 5 deletions user-docs/mecatui/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,12 @@ reduces an oversized log to its most recent 10 MiB. An unsafe path disables
logging without changing the existing file.

Use `/diagnostics` to send a concise, sanitized bug-report snapshot through the
normal prompt path. It includes build identities and available display
information for the connection target and active provider. It excludes
credentials, TLS and authentication settings, raw errors, and other
configuration. A `mecatui connect` client does not write an equivalent local
server log; inspect the remote server's operator logs instead.
normal prompt path. It includes build identities, available display information
for the connection target and active provider, a compact capability snapshot
currently advertised to the client, and whether a Converse stream is currently
attached. It excludes credentials, TLS and authentication settings, raw errors,
and other configuration. A `mecatui connect` client does not write an equivalent
local server log; inspect the remote server's operator logs instead.

For exhaustive flags and failure behavior, see
[`docs/tui.md`](https://github.com/stacklok/mecatl/blob/main/docs/tui.md).
Expand Down
Loading