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
3 changes: 2 additions & 1 deletion cmd/mecatui/client/msgs.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ type TurnEndMsg struct {
Turn int32
Usage Usage
DurationMs int64
Estimated bool
}

// ToolCallMsg announces a tool invocation (status: running until its result).
Expand Down Expand Up @@ -1165,7 +1166,7 @@ func EventToMsg(ev *mecatlv1.Event) tea.Msg {
return TurnStartMsg{Turn: ev.GetTurn()}
case "turn.end":
te := ev.GetTurnEnd()
return TurnEndMsg{Turn: ev.GetTurn(), Usage: usageFrom(te.GetUsage()), DurationMs: te.GetDurationMs()}
return TurnEndMsg{Turn: ev.GetTurn(), Usage: usageFrom(te.GetUsage()), DurationMs: te.GetDurationMs(), Estimated: te.GetEstimated()}
case "message.delta":
return AssistantDeltaMsg{Turn: ev.GetTurn(), Text: ev.GetText()}
case "reasoning.delta":
Expand Down
4 changes: 2 additions & 2 deletions cmd/mecatui/client/msgs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ func TestEventToMsg(t *testing.T) {
{
"turn.end",
&mecatlv1.Event{Type: "turn.end", Turn: 2, TurnEnd: &mecatlv1.TurnEnd{
DurationMs: 4100, Usage: &mecatlv1.Usage{InputTokens: 1200, OutputTokens: 340}}},
TurnEndMsg{Turn: 2, Usage: Usage{InputTokens: 1200, OutputTokens: 340}, DurationMs: 4100},
DurationMs: 4100, Estimated: true, Usage: &mecatlv1.Usage{InputTokens: 1200, OutputTokens: 340}}},
TurnEndMsg{Turn: 2, Usage: Usage{InputTokens: 1200, OutputTokens: 340}, DurationMs: 4100, Estimated: true},
},
{"message.delta", &mecatlv1.Event{Type: "message.delta", Turn: 2, Text: "hi"}, AssistantDeltaMsg{Turn: 2, Text: "hi"}},
{"reasoning.delta", &mecatlv1.Event{Type: "reasoning.delta", Turn: 2, Text: "pondering"}, ReasoningDeltaMsg{Turn: 2, Text: "pondering"}},
Expand Down
120 changes: 83 additions & 37 deletions cmd/mecatui/client/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ import (

// SessionSnapshot is the proto-free subset of a server session snapshot mecatui needs.
type SessionSnapshot struct {
Mode string
State string
Placement Placement
CreatedAt int64
ResolvedModel ResolvedModel
Title string
TitleProvenance string
TitleRevision uint64
Mode string
State string
Placement Placement
CreatedAt int64
ResolvedModel ResolvedModel
// Usage is the canonical cumulative main-session ledger. It is distinct from
// ContextOccupancy, which is only the latest context-meter display state.
Usage Usage
// ContextOccupancy is nil when a legacy or pre-turn snapshot has no known
// context-meter numerator.
ContextOccupancy *ContextOccupancy
Title string
TitleProvenance string
TitleRevision uint64
// Capabilities is the server's feature-advertisement snapshot from the Session
// proto (the SAME global value CreateSessionResponse carries), with per-session
// media overlaid when SessionCapabilities is present. A client that reloads or
Expand All @@ -33,20 +39,36 @@ type SessionSnapshot struct {
Capabilities Capabilities
}

// ContextOccupancy is the optional latest context-meter display value from a
// session snapshot. It is neither lifetime usage nor a model budget.
type ContextOccupancy struct {
InputTokens int64
Estimated bool
}

func contextOccupancyFrom(occupancy *mecatlv1.ContextOccupancy) *ContextOccupancy {
if occupancy == nil {
return nil
}
return &ContextOccupancy{InputTokens: occupancy.GetInputTokens(), Estimated: occupancy.GetEstimated()}
}

func snapshotFrom(s *mecatlv1.Session) SessionSnapshot {
if s == nil {
return SessionSnapshot{Mode: ModeDefaultString}
}
return SessionSnapshot{
Mode: ModeString(s.GetMode()),
State: s.GetState(),
Placement: placementFrom(s.GetPlacement()),
CreatedAt: s.GetCreatedAtUnix(),
ResolvedModel: resolvedModelFrom(s.GetResolvedModel()),
Title: titleFromProto(s),
TitleProvenance: titleProvenanceFromProto(s),
TitleRevision: s.GetTitleMetadata().GetRevision(),
Capabilities: capabilitiesWithSessionMedia(nil, s.GetSessionCapabilities()),
Mode: ModeString(s.GetMode()),
State: s.GetState(),
Placement: placementFrom(s.GetPlacement()),
CreatedAt: s.GetCreatedAtUnix(),
ResolvedModel: resolvedModelFrom(s.GetResolvedModel()),
Usage: usageFrom(s.GetTokenUsage()["main"].GetTotal()),
ContextOccupancy: contextOccupancyFrom(s.GetLatestContextOccupancy()),
Title: titleFromProto(s),
TitleProvenance: titleProvenanceFromProto(s),
TitleRevision: s.GetTitleMetadata().GetRevision(),
Capabilities: capabilitiesWithSessionMedia(nil, s.GetSessionCapabilities()),
}
}

Expand Down Expand Up @@ -96,7 +118,14 @@ func (c *Client) SetMode(ctx context.Context, id, mode string) (string, error) {
// the reducer can drop a result that landed AFTER a /models switch rebound the
// ui to a new session (a stale window must never clobber the new session's
// denominator). Err set ⇒ the refetch failed; the reducer keeps the current
// denominator (benign — the heal simply retries on the next turn boundary).
// snapshot-derived display state (benign — the heal simply retries on the next
// turn boundary).
//
// ContextOccupancy carries the persisted latest context-meter numerator. It
// lets an authoritative startup-resume refetch restore the display state when
// the initial snapshot was incomplete, without treating cumulative usage as
// current context occupancy. It is adopted only when
// AdoptContextOccupancy identifies that startup-resume handoff.
//
// Mode carries the server-confirmed permission posture from the session snapshot.
// When non-empty (the plan-approval refresh path) the reducer applies it to the
Expand All @@ -118,10 +147,19 @@ func (c *Client) SetMode(ctx context.Context, id, mode string) (string, error) {
type ResolvedModelMsg struct {
SessionID string
Resolved ResolvedModel
Mode string
State string
Placement Placement
CreatedAt int64
// ContextOccupancy is the persisted latest context-meter display state from
// the same authoritative snapshot as Resolved.
ContextOccupancy *ContextOccupancy
// AdoptContextOccupancy scopes occupancy adoption to startup resume so a
// delayed ordinary model refresh cannot overwrite newer live turn metrics.
AdoptContextOccupancy bool
// StartupResumeRefreshAttempt orders the bounded startup-resume denominator
// retries. It is meaningful only with AdoptContextOccupancy.
StartupResumeRefreshAttempt int
Mode string
State string
Placement Placement
CreatedAt int64
// Title is the session's stored title from the snapshot (self-heal channel for
// the window title). See the struct doc.
Title string
Expand Down Expand Up @@ -164,25 +202,33 @@ func SetModeCmd(ctx context.Context, s ModeSetter, id, mode string) tea.Cmd {

// RefreshResolvedModelCmd refetches the resolved model for session id off the
// update goroutine; the result (success or error) arrives as a ResolvedModelMsg
// with SessionID stamped so the reducer can correlate/drop it. It backs two paths:
//
// 1. The footer context-meter heal: the ui fires it on a turn boundary while the
// meter's denominator is still unknown, and the ResolvedModelMsg arm raises the
// window once the server's live-first resolution heals it.
// 2. The plan-approval mode+model refresh (issue #206): after a plan_approved
// terminal, the ui fires it to refetch the server's flipped mode (plan→default/
// acceptEdits) and the execute model, so the header updates from the session
// snapshot.
//
// Mode is carried alongside ResolvedModel so the reducer can update both the mode
// echo and the effective model in one refetch (reusing the ResolvedModelMsg arm).
// Capabilities is carried so the caps-heal path (/sessions continue, /effort fork,
// issue #348) can re-derive affordances in the same round-trip.
// with SessionID stamped so the reducer can correlate/drop it. It backs the
// footer context-meter denominator heal, plan-approval model/mode refresh, and
// caps-heal path for /sessions continue + /effort fork.
func RefreshResolvedModelCmd(ctx context.Context, g SessionGetter, id string) tea.Cmd {
return refreshResolvedModelCmd(ctx, g, id, false, 0)
}

// RefreshStartupResumeCmd additionally carries persisted context occupancy for
// the startup-resume handoff. Its result is marked so the UI can restore this
// display-only snapshot without allowing ordinary delayed refreshes to replace
// newer live turn metrics.
func RefreshStartupResumeCmd(ctx context.Context, g SessionGetter, id string) tea.Cmd {
return refreshResolvedModelCmd(ctx, g, id, true, 0)
}

// RefreshStartupResumeRetryCmd retries startup-resume model metadata after an
// explicitly provisional zero context window. attempt is diagnostic ordering;
// the UI owns the bounded retry policy and stale-session guard.
func RefreshStartupResumeRetryCmd(ctx context.Context, g SessionGetter, id string, attempt int) tea.Cmd {
return refreshResolvedModelCmd(ctx, g, id, true, attempt)
}

func refreshResolvedModelCmd(ctx context.Context, g SessionGetter, id string, adoptContextOccupancy bool, startupResumeRefreshAttempt int) tea.Cmd {
return func() tea.Msg {
snap, err := g.GetSession(ctx, id)
return ResolvedModelMsg{
SessionID: id, Resolved: snap.ResolvedModel, Mode: snap.Mode,
SessionID: id, Resolved: snap.ResolvedModel, ContextOccupancy: snap.ContextOccupancy, AdoptContextOccupancy: adoptContextOccupancy, StartupResumeRefreshAttempt: startupResumeRefreshAttempt, Mode: snap.Mode,
State: snap.State, Placement: snap.Placement, CreatedAt: snap.CreatedAt,
Title: snap.Title, TitleProvenance: snap.TitleProvenance, TitleRevision: snap.TitleRevision, Capabilities: snap.Capabilities, Err: err,
}
Expand Down
19 changes: 19 additions & 0 deletions cmd/mecatui/client/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,22 @@ func TestSnapshotFromReadsTitle(t *testing.T) {
t.Fatalf("nil Title = %q, want empty", nilSnap.Title)
}
}

func TestSnapshotFromProjectsMainUsageAndOptionalContextOccupancy(t *testing.T) {
snap := snapshotFrom(&mecatlv1.Session{
TokenUsage: map[string]*mecatlv1.TokenUsage{
"main": {Total: &mecatlv1.Usage{InputTokens: 120_000, OutputTokens: 4_000, CacheReadTokens: 90_000}},
},
LatestContextOccupancy: &mecatlv1.ContextOccupancy{InputTokens: 40_000, Estimated: true},
})
if snap.Usage != (Usage{InputTokens: 120_000, OutputTokens: 4_000, CacheReadTokens: 90_000}) {
t.Fatalf("main usage = %+v", snap.Usage)
}
if snap.ContextOccupancy == nil || *snap.ContextOccupancy != (ContextOccupancy{InputTokens: 40_000, Estimated: true}) {
t.Fatalf("context occupancy = %+v", snap.ContextOccupancy)
}
legacy := snapshotFrom(&mecatlv1.Session{TokenUsage: map[string]*mecatlv1.TokenUsage{"main": {Total: &mecatlv1.Usage{InputTokens: 120_000}}}})
if legacy.ContextOccupancy != nil || legacy.Usage.InputTokens != 120_000 {
t.Fatalf("legacy snapshot = %+v", legacy)
}
}
19 changes: 15 additions & 4 deletions cmd/mecatui/customization/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,14 +251,25 @@ func TestStatusLineCommandDoesNotTrimNonASCIIOutputBoundary(t *testing.T) {
}
}

func TestADR_0296_StatusInputProtocolV3WorkspacePathAndName(t *testing.T) {
input := Input{Version: ProtocolVersion, Workspace: Workspace{Location: "local", Name: "provider label", Path: "/eligible/root"}, Terminal: Terminal{FooterAvailCols: 80}}
func TestADR_0296_StatusInputProtocolV4WorkspacePathNameAndContext(t *testing.T) {
input := Input{
Version: ProtocolVersion,
Context: Context{
Used: ContextAtom{Raw: 75, Human: "75"},
Window: ContextAtom{Raw: 100, Human: "100"},
Percent: 75,
Known: true,
Estimated: true,
},
Workspace: Workspace{Location: "local", Name: "provider label", Path: "/eligible/root"},
Terminal: Terminal{FooterAvailCols: 80},
}
wire, err := json.Marshal(input)
if err != nil {
t.Fatalf("marshal status input: %v", err)
}
if ProtocolVersion != 3 || !strings.Contains(string(wire), `"Name":"provider label"`) || !strings.Contains(string(wire), `"Path":"/eligible/root"`) || strings.Contains(string(wire), "Basename") {
t.Fatalf("status input v3 workspace projection = %s", wire)
if ProtocolVersion != 4 || !strings.Contains(string(wire), `"Name":"provider label"`) || !strings.Contains(string(wire), `"Path":"/eligible/root"`) || !strings.Contains(string(wire), `"Context":{"Used":{"Raw":75,"Human":"75"},"Window":{"Raw":100,"Human":"100"},"Percent":75,"Known":true,"Estimated":true}`) || strings.Contains(string(wire), "Basename") {
t.Fatalf("status input v4 workspace/context projection = %s", wire)
}
templates := NewTemplateSource(TemplateSet{Footer: SurfaceTemplates{Full: `<footer><text>[{{.Workspace.Path}}]</text></footer>`}}, 0)
t.Cleanup(func() { _ = templates.Close(context.Background()) })
Expand Down
18 changes: 11 additions & 7 deletions cmd/mecatui/customization/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,18 @@ type ContextAtom struct {
Human string
}

// Context describes current context-window consumption. Percent is Used.Raw as
// an integer percentage of Window.Raw, or zero when the window is unknown. The
// visual bar is renderer-owned because its glyphs and semantic style depend on
// the active theme and available surface width.
// Context describes current context-window consumption. Known reports whether Used
// reflects a known occupancy; Estimated reports whether that occupancy is an
// estimate. Percent is Used.Raw as an integer percentage of Window.Raw, or zero
// when the window or occupancy is unknown. The visual bar is renderer-owned
// because its glyphs and semantic style depend on the active theme and available
// surface width.
type Context struct {
Used ContextAtom
Window ContextAtom
Percent int
Used ContextAtom
Window ContextAtom
Percent int
Known bool
Estimated bool
}

// Workspace is the active session workspace's display-safe provenance. Location
Expand Down
26 changes: 23 additions & 3 deletions cmd/mecatui/customization/statusline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ func TestStatusLine_InputCarriesUsageContextAndSurfaceColumns(t *testing.T) {
CacheReadPercent: 50,
},
Context: Context{
Used: ContextAtom{Raw: 75, Human: "75"},
Window: ContextAtom{Raw: 100, Human: "100"},
Percent: 75,
Used: ContextAtom{Raw: 75, Human: "75"},
Window: ContextAtom{Raw: 100, Human: "100"},
Percent: 75,
Known: true,
Estimated: true,
},
Terminal: Terminal{Rows: 40, Cols: 120, HeaderAvailCols: 92, FooterAvailCols: 101},
MainAgent: MainAgent{State: "running", Activity: "thinking", Approval: "awaiting"},
Expand All @@ -120,6 +122,12 @@ func TestStatusLine_InputCarriesUsageContextAndSurfaceColumns(t *testing.T) {
if got, want := input.Context.Window.Human, "100"; got != want {
t.Fatalf("context window human = %q, want %q", got, want)
}
if got, want := input.Context.Known, true; got != want {
t.Fatalf("context known = %t, want %t", got, want)
}
if got, want := input.Context.Estimated, true; got != want {
t.Fatalf("context estimated = %t, want %t", got, want)
}
if got, want := input.Terminal.HeaderAvailCols, 92; got != want {
t.Fatalf("header available columns = %d, want %d", got, want)
}
Expand Down Expand Up @@ -153,6 +161,18 @@ func TestStatusLine_InputCarriesUsageContextAndSurfaceColumns(t *testing.T) {
}
}

func TestStatusLine_TemplateProjectsContextState(t *testing.T) {
status := parseStatusTemplate("footer", `<footer><text>{{.Context.Known}}/{{.Context.Estimated}}/{{.Context.Used.Human}}</text></footer>`, defaultTemplateSet().Footer.Full)
doc := status.render(context.Background(), newTemplateInput(Input{Context: Context{
Used: ContextAtom{Raw: 75, Human: "~75"},
Known: true,
Estimated: true,
}}))
if got, want := statusSurfaceText(doc.Footer), "true/true/~75"; got != want {
t.Fatalf("context template projection = %q, want %q", got, want)
}
}

func TestStatusLine_ShippedFooterNormalTextUsesTextToken(t *testing.T) {
source := NewDefaultSource(0)
t.Cleanup(func() { _ = source.Close(context.Background()) })
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecatui/customization/statusml.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

const (
// ProtocolVersion is the current Input wire-independent contract version.
ProtocolVersion uint8 = 3
ProtocolVersion uint8 = 4

maxMarkupBytes = 16 << 10
maxSpanRunes = 4 << 10
Expand Down
7 changes: 4 additions & 3 deletions cmd/mecatui/customization/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,9 @@ type templateContextAtom struct {
Human templateText
}
type templateContext struct {
Used, Window templateContextAtom
Percent int
Used, Window templateContextAtom
Percent int
Known, Estimated bool
}

// contextMeter is the shipped template primitive for the context-pressure bar.
Expand Down Expand Up @@ -274,7 +275,7 @@ func newTemplateInput(input Input) templateInput {
Session: templateSession{escapeTemplateText(input.Session.Title), escapeTemplateText(input.Session.Handle), escapeTemplateText(input.Session.Mode), escapeTemplateText(input.Session.ReasoningEffort)},
Model: templateModel{escapeTemplateText(input.Model.ProviderID), escapeTemplateText(input.Model.ID), escapeTemplateText(input.Model.DisplayName), escapeTemplateText(input.Model.Route), templateContextAtom{input.Model.ContextWindow.Raw, escapeTemplateText(input.Model.ContextWindow.Human)}},
Usage: templateUsage{templateUsageAtom{input.Usage.Input.Raw, escapeTemplateText(input.Usage.Input.Human)}, templateUsageAtom{input.Usage.Output.Raw, escapeTemplateText(input.Usage.Output.Human)}, templateUsageAtom{input.Usage.CacheRead.Raw, escapeTemplateText(input.Usage.CacheRead.Human)}, templateUsageAtom{input.Usage.CacheWrite.Raw, escapeTemplateText(input.Usage.CacheWrite.Human)}, input.Usage.CacheReadPercent},
Context: templateContext{templateContextAtom{input.Context.Used.Raw, escapeTemplateText(input.Context.Used.Human)}, templateContextAtom{input.Context.Window.Raw, escapeTemplateText(input.Context.Window.Human)}, input.Context.Percent},
Context: templateContext{templateContextAtom{input.Context.Used.Raw, escapeTemplateText(input.Context.Used.Human)}, templateContextAtom{input.Context.Window.Raw, escapeTemplateText(input.Context.Window.Human)}, input.Context.Percent, input.Context.Known, input.Context.Estimated},
Workspace: templateWorkspace{escapeTemplateText(input.Workspace.Location), escapeTemplateText(input.Workspace.Name), escapeTemplateText(input.Workspace.Path)},
Terminal: input.Terminal,
MainAgent: templateMainAgent{escapeTemplateText(input.MainAgent.State), escapeTemplateText(input.MainAgent.Activity), escapeTemplateText(input.MainAgent.Approval)},
Expand Down
14 changes: 12 additions & 2 deletions cmd/mecatui/startup_resume_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ func TestSessionContinuityUX_Scenario6_EmbeddedAndConnectE2E(t *testing.T) {

target, stop := tc.start(t, cfg)
id := seedStartupResumeSession(ctx, t, target, workspace)
seedClient, err := client.Dial(client.DialConfig{Server: target})
if err != nil {
t.Fatalf("dial seeded server: %v", err)
}
seedSnapshot, err := seedClient.GetSession(ctx, id)
_ = seedClient.Close()
if err != nil || seedSnapshot.ContextOccupancy == nil || seedSnapshot.ContextOccupancy.InputTokens <= 0 {
t.Fatalf("seeded session snapshot = %+v, %v; want persisted non-zero context occupancy", seedSnapshot, err)
}
stop()

target, stop = tc.start(t, cfg)
Expand All @@ -73,8 +82,9 @@ func TestSessionContinuityUX_Scenario6_EmbeddedAndConnectE2E(t *testing.T) {
if err != nil {
t.Fatalf("resolve startup resume: %v", err)
}
if selection.Row.ID != id || selection.Transcript.SessionID != id || !selection.Transcript.Complete || len(selection.Transcript.Messages) == 0 {
t.Fatalf("selection = %#v, want complete transcript for %q", selection, id)
if selection.Row.ID != id || selection.Transcript.SessionID != id || !selection.Transcript.Complete || len(selection.Transcript.Messages) == 0 ||
selection.Snapshot.ContextOccupancy == nil || selection.Snapshot.ContextOccupancy.InputTokens <= 0 {
t.Fatalf("selection = %#v, want complete transcript and persisted occupancy for %q", selection, id)
}
rows, err := cl.ListSessions(ctx)
if err != nil {
Expand Down
Loading
Loading