Merge pull request #137 from bnema/feat/cache-usage-summaries
feat(tui): show cache usage for agents
16 files · +549 -80
M packages/agent/modes/resident_subagent_indicator.go +33 -7
| @@ -20,7 +20,7 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner( | |||
| 20 | 20 | // child is waiting for a resident scheduler slot; only running children use | |
| 21 | 21 | // the animated glyph. | |
| 22 | 22 | func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string { | |
| 23 | - | lines := make([]string, 0, len(snapshots)) | |
| 23 | + | lines := make([]string, 0, len(snapshots)*2) | |
| 24 | 24 | for _, snapshot := range snapshots { | |
| 25 | 25 | // Foreign journals remain discoverable through /subagents, but their | |
| 26 | 26 | // activity belongs to another zut host and must not occupy this | |
| @@ -58,10 +58,24 @@ func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, s | |||
| 58 | 58 | continue | |
| 59 | 59 | } | |
| 60 | 60 | lines = append(lines, " "+theme.FGColor(color, plain)) | |
| 61 | + | if metadata := residentUsageMetadata(snapshot); metadata != "" { | |
| 62 | + | metadata = truncateResidentSubagentIndicator(metadata, width-4) | |
| 63 | + | if metadata != "" { | |
| 64 | + | lines = append(lines, " "+theme.FGColor(theme.Muted, metadata)) | |
| 65 | + | } | |
| 66 | + | } | |
| 61 | 67 | } | |
| 62 | 68 | return lines | |
| 63 | 69 | } | |
| 64 | 70 | ||
| 71 | + | func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string { | |
| 72 | + | parts := tui.UsageStatsParts(tui.UsageStatsParams{Usage: snapshot.Usage, Subscription: snapshot.Subscription}) | |
| 73 | + | if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" { | |
| 74 | + | parts = append(parts, context) | |
| 75 | + | } | |
| 76 | + | return strings.Join(parts, " ") | |
| 77 | + | } | |
| 78 | + | ||
| 65 | 79 | func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time { | |
| 66 | 80 | if !snapshot.TurnStartedAt.IsZero() { | |
| 67 | 81 | return snapshot.TurnStartedAt | |
| @@ -104,18 +118,30 @@ func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, | |||
| 104 | 118 | if maxRows <= 0 || (len(lines) == 0 && hidden == 0) { | |
| 105 | 119 | return nil | |
| 106 | 120 | } | |
| 121 | + | groups := make([][]string, 0, len(lines)) | |
| 122 | + | for _, line := range lines { | |
| 123 | + | if strings.HasPrefix(line, " ") && len(groups) > 0 { | |
| 124 | + | groups[len(groups)-1] = append(groups[len(groups)-1], line) | |
| 125 | + | continue | |
| 126 | + | } | |
| 127 | + | groups = append(groups, []string{line}) | |
| 128 | + | } | |
| 107 | 129 | if hidden == 0 && len(lines) <= maxRows { | |
| 108 | 130 | return lines | |
| 109 | 131 | } | |
| 110 | 132 | if maxRows == 1 { | |
| 111 | - | return []string{residentSubagentActivityOverflowLine(theme, len(lines)+hidden, width)} | |
| 133 | + | return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)} | |
| 112 | 134 | } | |
| 113 | - | visible := len(lines) | |
| 114 | - | if visible > maxRows-1 { | |
| 115 | - | visible = maxRows - 1 | |
| 135 | + | out := make([]string, 0, maxRows) | |
| 136 | + | visibleGroups := 0 | |
| 137 | + | for _, group := range groups { | |
| 138 | + | if len(out)+len(group) > maxRows-1 { | |
| 139 | + | break | |
| 140 | + | } | |
| 141 | + | out = append(out, group...) | |
| 142 | + | visibleGroups++ | |
| 116 | 143 | } | |
| 117 | - | out := append([]string(nil), lines[:visible]...) | |
| 118 | - | return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(lines)-visible, width)) | |
| 144 | + | return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width)) | |
| 119 | 145 | } | |
| 120 | 146 | ||
| 121 | 147 | func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string { | |
| @@ -20,7 +20,7 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner( | |||||
| 20 | // child is waiting for a resident scheduler slot; only running children use | 20 | // child is waiting for a resident scheduler slot; only running children use | ||
| 21 | // the animated glyph. | 21 | // the animated glyph. | ||
| 22 | func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string { | 22 | func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string { | ||
| 23 | - | lines := make([]string, 0, len(snapshots)) | 23 | + | lines := make([]string, 0, len(snapshots)*2) |
| 24 | for _, snapshot := range snapshots { | 24 | for _, snapshot := range snapshots { | ||
| 25 | // Foreign journals remain discoverable through /subagents, but their | 25 | // Foreign journals remain discoverable through /subagents, but their | ||
| 26 | // activity belongs to another zut host and must not occupy this | 26 | // activity belongs to another zut host and must not occupy this | ||
| @@ -58,10 +58,24 @@ func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, s | |||||
| 58 | continue | 58 | continue | ||
| 59 | } | 59 | } | ||
| 60 | lines = append(lines, " "+theme.FGColor(color, plain)) | 60 | lines = append(lines, " "+theme.FGColor(color, plain)) | ||
| 61 | + | if metadata := residentUsageMetadata(snapshot); metadata != "" { | |||
| 62 | + | metadata = truncateResidentSubagentIndicator(metadata, width-4) | |||
| 63 | + | if metadata != "" { | |||
| 64 | + | lines = append(lines, " "+theme.FGColor(theme.Muted, metadata)) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 61 | } | 67 | } | ||
| 62 | return lines | 68 | return lines | ||
| 63 | } | 69 | } | ||
| 64 | 70 | ||||
| 71 | + | func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string { | |||
| 72 | + | parts := tui.UsageStatsParts(tui.UsageStatsParams{Usage: snapshot.Usage, Subscription: snapshot.Subscription}) | |||
| 73 | + | if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" { | |||
| 74 | + | parts = append(parts, context) | |||
| 75 | + | } | |||
| 76 | + | return strings.Join(parts, " ") | |||
| 77 | + | } | |||
| 78 | + | ||||
| 65 | func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time { | 79 | func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time { | ||
| 66 | if !snapshot.TurnStartedAt.IsZero() { | 80 | if !snapshot.TurnStartedAt.IsZero() { | ||
| 67 | return snapshot.TurnStartedAt | 81 | return snapshot.TurnStartedAt | ||
| @@ -104,18 +118,30 @@ func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, | |||||
| 104 | if maxRows <= 0 || (len(lines) == 0 && hidden == 0) { | 118 | if maxRows <= 0 || (len(lines) == 0 && hidden == 0) { | ||
| 105 | return nil | 119 | return nil | ||
| 106 | } | 120 | } | ||
| 121 | + | groups := make([][]string, 0, len(lines)) | |||
| 122 | + | for _, line := range lines { | |||
| 123 | + | if strings.HasPrefix(line, " ") && len(groups) > 0 { | |||
| 124 | + | groups[len(groups)-1] = append(groups[len(groups)-1], line) | |||
| 125 | + | continue | |||
| 126 | + | } | |||
| 127 | + | groups = append(groups, []string{line}) | |||
| 128 | + | } | |||
| 107 | if hidden == 0 && len(lines) <= maxRows { | 129 | if hidden == 0 && len(lines) <= maxRows { | ||
| 108 | return lines | 130 | return lines | ||
| 109 | } | 131 | } | ||
| 110 | if maxRows == 1 { | 132 | if maxRows == 1 { | ||
| 111 | - | return []string{residentSubagentActivityOverflowLine(theme, len(lines)+hidden, width)} | 133 | + | return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)} |
| 112 | } | 134 | } | ||
| 113 | - | visible := len(lines) | 135 | + | out := make([]string, 0, maxRows) |
| 114 | - | if visible > maxRows-1 { | 136 | + | visibleGroups := 0 |
| 115 | - | visible = maxRows - 1 | 137 | + | for _, group := range groups { |
| 138 | + | if len(out)+len(group) > maxRows-1 { | |||
| 139 | + | break | |||
| 140 | + | } | |||
| 141 | + | out = append(out, group...) | |||
| 142 | + | visibleGroups++ | |||
| 116 | } | 143 | } | ||
| 117 | - | out := append([]string(nil), lines[:visible]...) | 144 | + | return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width)) |
| 118 | - | return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(lines)-visible, width)) | |||
| 119 | } | 145 | } | ||
| 120 | 146 | ||||
| 121 | func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string { | 147 | func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string { | ||
M packages/agent/modes/resident_subagent_indicator_test.go +23 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/zut/packages/agent/subagents" | |
| 9 | + | "github.com/bnema/zut/packages/provider" | |
| 9 | 10 | "github.com/bnema/zut/packages/tui" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -26,6 +27,19 @@ func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T | |||
| 26 | 27 | } | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 30 | + | func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) { | |
| 31 | + | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |
| 32 | + | lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{ | |
| 33 | + | ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, | |
| 34 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}, | |
| 35 | + | ContextUsed: 45_152, ContextMax: 272_000, Subscription: true, | |
| 36 | + | }}, 100, now) | |
| 37 | + | got := strings.Join(plainResidentIndicatorLines(lines), "\n") | |
| 38 | + | if !strings.Contains(got, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k") { | |
| 39 | + | t.Fatalf("activity lines = %q, want usage metadata line", got) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 29 | 43 | func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) { | |
| 30 | 44 | started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |
| 31 | 45 | now := started.Add(2 * time.Second) | |
| @@ -59,6 +73,15 @@ func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) { | |||
| 59 | 73 | } | |
| 60 | 74 | } | |
| 61 | 75 | ||
| 76 | + | func TestLimitResidentSubagentActivityLinesKeepsMetadataWithItsAgent(t *testing.T) { | |
| 77 | + | lines := []string{" agent one", " usage one", " agent two", " usage two"} | |
| 78 | + | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80)) | |
| 79 | + | want := []string{" agent one", " usage one", " … 1 more active subagents"} | |
| 80 | + | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |
| 81 | + | t.Fatalf("limited lines = %#v, want %#v", got, want) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 62 | 85 | func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) { | |
| 63 | 86 | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80)) | |
| 64 | 87 | want := []string{"one", " … 6 more active subagents"} | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/zut/packages/agent/subagents" | 8 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 9 | + | "github.com/bnema/zut/packages/provider" | |||
| 9 | "github.com/bnema/zut/packages/tui" | 10 | "github.com/bnema/zut/packages/tui" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| @@ -26,6 +27,19 @@ func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T | |||||
| 26 | } | 27 | } | ||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 30 | + | func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) { | |||
| 31 | + | now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | |||
| 32 | + | lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{ | |||
| 33 | + | ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, | |||
| 34 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}, | |||
| 35 | + | ContextUsed: 45_152, ContextMax: 272_000, Subscription: true, | |||
| 36 | + | }}, 100, now) | |||
| 37 | + | got := strings.Join(plainResidentIndicatorLines(lines), "\n") | |||
| 38 | + | if !strings.Contains(got, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k") { | |||
| 39 | + | t.Fatalf("activity lines = %q, want usage metadata line", got) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 29 | func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) { | 43 | func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) { | ||
| 30 | started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | 44 | started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) | ||
| 31 | now := started.Add(2 * time.Second) | 45 | now := started.Add(2 * time.Second) | ||
| @@ -59,6 +73,15 @@ func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) { | |||||
| 59 | } | 73 | } | ||
| 60 | } | 74 | } | ||
| 61 | 75 | ||||
| 76 | + | func TestLimitResidentSubagentActivityLinesKeepsMetadataWithItsAgent(t *testing.T) { | |||
| 77 | + | lines := []string{" agent one", " usage one", " agent two", " usage two"} | |||
| 78 | + | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80)) | |||
| 79 | + | want := []string{" agent one", " usage one", " … 1 more active subagents"} | |||
| 80 | + | if strings.Join(got, "\n") != strings.Join(want, "\n") { | |||
| 81 | + | t.Fatalf("limited lines = %#v, want %#v", got, want) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 62 | func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) { | 85 | func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) { | ||
| 63 | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80)) | 86 | got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80)) | ||
| 64 | want := []string{"one", " … 6 more active subagents"} | 87 | want := []string{"one", " … 6 more active subagents"} | ||
M packages/agent/modes/resident_subagents_dialog.go +37 -7
| @@ -43,8 +43,8 @@ func (d *residentSubagentsDialog) refresh(limit int) { | |||
| 43 | 43 | d.total = 0 | |
| 44 | 44 | return | |
| 45 | 45 | } | |
| 46 | - | if limit < 1 { | |
| 47 | - | limit = 1 | |
| 46 | + | if limit < 0 { | |
| 47 | + | limit = 0 | |
| 48 | 48 | } | |
| 49 | 49 | _, total := d.manager.SnapshotPage(0, 0) | |
| 50 | 50 | d.total = total | |
| @@ -54,6 +54,10 @@ func (d *residentSubagentsDialog) refresh(limit int) { | |||
| 54 | 54 | if d.selected < 0 || d.total == 0 { | |
| 55 | 55 | d.selected = 0 | |
| 56 | 56 | } | |
| 57 | + | if limit == 0 { | |
| 58 | + | d.rows = nil | |
| 59 | + | return | |
| 60 | + | } | |
| 57 | 61 | if d.selected < d.offset { | |
| 58 | 62 | d.offset = d.selected | |
| 59 | 63 | } | |
| @@ -91,14 +95,24 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s | |||
| 91 | 95 | if d == nil { | |
| 92 | 96 | return nil | |
| 93 | 97 | } | |
| 94 | - | maxRows := height - 3 // title, hint, and page indicator/empty state | |
| 95 | - | if maxRows < 1 { | |
| 96 | - | maxRows = 1 | |
| 98 | + | // Reserve two lines per child so usage metadata never pushes the dialog | |
| 99 | + | // beyond its content height. Children without usage simply consume less. | |
| 100 | + | maxRows := (height - 3) / 2 // title, hint, and page indicator/empty state | |
| 101 | + | if maxRows < 0 { | |
| 102 | + | maxRows = 0 | |
| 97 | 103 | } | |
| 98 | 104 | d.refresh(maxRows) | |
| 99 | 105 | lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"} | |
| 100 | 106 | if len(d.rows) == 0 { | |
| 101 | - | return append(lines, "", " No resident subagents.") | |
| 107 | + | if d.total > 0 { | |
| 108 | + | return limitResidentSubagentsDialogHeight(lines, height) | |
| 109 | + | } | |
| 110 | + | if height == len(lines)+1 { | |
| 111 | + | lines = append(lines, " No resident subagents.") | |
| 112 | + | } else { | |
| 113 | + | lines = append(lines, "", " No resident subagents.") | |
| 114 | + | } | |
| 115 | + | return limitResidentSubagentsDialogHeight(lines, height) | |
| 102 | 116 | } | |
| 103 | 117 | for index, row := range d.rows { | |
| 104 | 118 | marker := " " | |
| @@ -114,11 +128,27 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s | |||
| 114 | 128 | label += " required" | |
| 115 | 129 | } | |
| 116 | 130 | lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2)) | |
| 131 | + | if metadata := residentUsageMetadata(row); metadata != "" { | |
| 132 | + | metadata = truncateResidentSubagentIndicator(metadata, width-4) | |
| 133 | + | if metadata != "" { | |
| 134 | + | lines = append(lines, " "+metadata) | |
| 135 | + | } | |
| 136 | + | } | |
| 117 | 137 | } | |
| 118 | 138 | if d.total > len(d.rows) { | |
| 119 | 139 | lines = append(lines, fmt.Sprintf(" %d-%d of %d", d.offset+1, d.offset+len(d.rows), d.total)) | |
| 120 | 140 | } | |
| 121 | - | return lines | |
| 141 | + | return limitResidentSubagentsDialogHeight(lines, height) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func limitResidentSubagentsDialogHeight(lines []string, height int) []string { | |
| 145 | + | if height <= 0 { | |
| 146 | + | return nil | |
| 147 | + | } | |
| 148 | + | if len(lines) <= height { | |
| 149 | + | return lines | |
| 150 | + | } | |
| 151 | + | return lines[:height] | |
| 122 | 152 | } | |
| 123 | 153 | ||
| 124 | 154 | func residentDisplayName(row subagents.ResidentSnapshot) string { | |
| @@ -43,8 +43,8 @@ func (d *residentSubagentsDialog) refresh(limit int) { | |||||
| 43 | d.total = 0 | 43 | d.total = 0 | ||
| 44 | return | 44 | return | ||
| 45 | } | 45 | } | ||
| 46 | - | if limit < 1 { | 46 | + | if limit < 0 { |
| 47 | - | limit = 1 | 47 | + | limit = 0 |
| 48 | } | 48 | } | ||
| 49 | _, total := d.manager.SnapshotPage(0, 0) | 49 | _, total := d.manager.SnapshotPage(0, 0) | ||
| 50 | d.total = total | 50 | d.total = total | ||
| @@ -54,6 +54,10 @@ func (d *residentSubagentsDialog) refresh(limit int) { | |||||
| 54 | if d.selected < 0 || d.total == 0 { | 54 | if d.selected < 0 || d.total == 0 { | ||
| 55 | d.selected = 0 | 55 | d.selected = 0 | ||
| 56 | } | 56 | } | ||
| 57 | + | if limit == 0 { | |||
| 58 | + | d.rows = nil | |||
| 59 | + | return | |||
| 60 | + | } | |||
| 57 | if d.selected < d.offset { | 61 | if d.selected < d.offset { | ||
| 58 | d.offset = d.selected | 62 | d.offset = d.selected | ||
| 59 | } | 63 | } | ||
| @@ -91,14 +95,24 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s | |||||
| 91 | if d == nil { | 95 | if d == nil { | ||
| 92 | return nil | 96 | return nil | ||
| 93 | } | 97 | } | ||
| 94 | - | maxRows := height - 3 // title, hint, and page indicator/empty state | 98 | + | // Reserve two lines per child so usage metadata never pushes the dialog |
| 95 | - | if maxRows < 1 { | 99 | + | // beyond its content height. Children without usage simply consume less. |
| 96 | - | maxRows = 1 | 100 | + | maxRows := (height - 3) / 2 // title, hint, and page indicator/empty state |
| 101 | + | if maxRows < 0 { | |||
| 102 | + | maxRows = 0 | |||
| 97 | } | 103 | } | ||
| 98 | d.refresh(maxRows) | 104 | d.refresh(maxRows) | ||
| 99 | lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"} | 105 | lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"} | ||
| 100 | if len(d.rows) == 0 { | 106 | if len(d.rows) == 0 { | ||
| 101 | - | return append(lines, "", " No resident subagents.") | 107 | + | if d.total > 0 { |
| 108 | + | return limitResidentSubagentsDialogHeight(lines, height) | |||
| 109 | + | } | |||
| 110 | + | if height == len(lines)+1 { | |||
| 111 | + | lines = append(lines, " No resident subagents.") | |||
| 112 | + | } else { | |||
| 113 | + | lines = append(lines, "", " No resident subagents.") | |||
| 114 | + | } | |||
| 115 | + | return limitResidentSubagentsDialogHeight(lines, height) | |||
| 102 | } | 116 | } | ||
| 103 | for index, row := range d.rows { | 117 | for index, row := range d.rows { | ||
| 104 | marker := " " | 118 | marker := " " | ||
| @@ -114,11 +128,27 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s | |||||
| 114 | label += " required" | 128 | label += " required" | ||
| 115 | } | 129 | } | ||
| 116 | lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2)) | 130 | lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2)) | ||
| 131 | + | if metadata := residentUsageMetadata(row); metadata != "" { | |||
| 132 | + | metadata = truncateResidentSubagentIndicator(metadata, width-4) | |||
| 133 | + | if metadata != "" { | |||
| 134 | + | lines = append(lines, " "+metadata) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 117 | } | 137 | } | ||
| 118 | if d.total > len(d.rows) { | 138 | if d.total > len(d.rows) { | ||
| 119 | lines = append(lines, fmt.Sprintf(" %d-%d of %d", d.offset+1, d.offset+len(d.rows), d.total)) | 139 | lines = append(lines, fmt.Sprintf(" %d-%d of %d", d.offset+1, d.offset+len(d.rows), d.total)) | ||
| 120 | } | 140 | } | ||
| 121 | - | return lines | 141 | + | return limitResidentSubagentsDialogHeight(lines, height) |
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func limitResidentSubagentsDialogHeight(lines []string, height int) []string { | |||
| 145 | + | if height <= 0 { | |||
| 146 | + | return nil | |||
| 147 | + | } | |||
| 148 | + | if len(lines) <= height { | |||
| 149 | + | return lines | |||
| 150 | + | } | |||
| 151 | + | return lines[:height] | |||
| 122 | } | 152 | } | ||
| 123 | 153 | ||||
| 124 | func residentDisplayName(row subagents.ResidentSnapshot) string { | 154 | func residentDisplayName(row subagents.ResidentSnapshot) string { | ||
M packages/agent/modes/resident_subagents_dialog_test.go +70 -0
| @@ -1,10 +1,17 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "slices" | |
| 7 | + | "strings" | |
| 4 | 8 | "testing" | |
| 5 | 9 | "time" | |
| 6 | 10 | ||
| 7 | 11 | "github.com/bnema/zut/packages/agent/subagents" | |
| 12 | + | "github.com/bnema/zut/packages/core" | |
| 13 | + | "github.com/bnema/zut/packages/provider" | |
| 14 | + | "github.com/bnema/zut/packages/tui" | |
| 8 | 15 | ) | |
| 9 | 16 | ||
| 10 | 17 | func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testing.T) { | |
| @@ -23,6 +30,69 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi | |||
| 23 | 30 | } | |
| 24 | 31 | } | |
| 25 | 32 | ||
| 33 | + | func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) { | |
| 34 | + | completed := make(chan struct{}, 2) | |
| 35 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 36 | + | journal.ConfigureUsage(272_000, true) | |
| 37 | + | return func(context.Context, string) error { | |
| 38 | + | usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525} | |
| 39 | + | return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}) | |
| 40 | + | }, nil | |
| 41 | + | }) | |
| 42 | + | manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} }) | |
| 43 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |
| 44 | + | for index := range 2 { | |
| 45 | + | id := fmt.Sprintf("usage-child-%d", index) | |
| 46 | + | if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: id, SessionID: "session-" + id, Profile: "reviewer", Provider: "openai-codex", Model: "gpt-test"}, "task"); err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | for range 2 { | |
| 51 | + | select { | |
| 52 | + | case <-completed: | |
| 53 | + | case <-time.After(time.Second): | |
| 54 | + | t.Fatal("resident child did not complete") | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | dialog := newResidentSubagentsDialog() | |
| 58 | + | dialog.Open(manager) | |
| 59 | + | plain := strings.Join(plainResidentIndicatorLines(dialog.Render(tui.Dark, 100, 10)), "\n") | |
| 60 | + | if !strings.Contains(plain, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 76.1%/272k") { | |
| 61 | + | t.Fatalf("dialog = %q, want usage metadata line", plain) | |
| 62 | + | } | |
| 63 | + | for _, height := range []int{3, 4} { | |
| 64 | + | if lines := dialog.Render(tui.Dark, 100, height); len(lines) > height { | |
| 65 | + | t.Fatalf("height %d rendered %d lines: %q", height, len(lines), plainResidentIndicatorLines(lines)) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | for width := 1; width <= 3; width++ { | |
| 69 | + | for _, line := range plainResidentIndicatorLines(dialog.Render(tui.Dark, width, 10)) { | |
| 70 | + | if line == " " { | |
| 71 | + | t.Fatalf("width %d rendered an empty metadata row", width) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestResidentSubagentsDialogBoundsEmptyStateByHeight(t *testing.T) { | |
| 78 | + | dialog := newResidentSubagentsDialog() | |
| 79 | + | dialog.Open(nil) | |
| 80 | + | tests := []struct { | |
| 81 | + | height int | |
| 82 | + | want []string | |
| 83 | + | }{ | |
| 84 | + | {height: 0, want: nil}, | |
| 85 | + | {height: 1, want: []string{" Resident subagents"}}, | |
| 86 | + | {height: 2, want: []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}}, | |
| 87 | + | {height: 3, want: []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn", " No resident subagents."}}, | |
| 88 | + | } | |
| 89 | + | for _, test := range tests { | |
| 90 | + | if got := plainResidentIndicatorLines(dialog.Render(tui.Dark, 100, test.height)); !slices.Equal(got, test.want) { | |
| 91 | + | t.Errorf("height %d rendered %q, want %q", test.height, got, test.want) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 26 | 96 | func TestResidentResultStatusIncludesFinalSummary(t *testing.T) { | |
| 27 | 97 | got := residentResultStatus("child", subagents.ResidentResult{State: subagents.ResidentIdle, Summary: "the child answer"}) | |
| 28 | 98 | if got != "completed result child: the child answer" { | |
| @@ -1,10 +1,17 @@ | |||||
| 1 | package modes | 1 | package modes | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "slices" | |||
| 7 | + | "strings" | |||
| 4 | "testing" | 8 | "testing" | ||
| 5 | "time" | 9 | "time" | ||
| 6 | 10 | ||||
| 7 | "github.com/bnema/zut/packages/agent/subagents" | 11 | "github.com/bnema/zut/packages/agent/subagents" | ||
| 12 | + | "github.com/bnema/zut/packages/core" | |||
| 13 | + | "github.com/bnema/zut/packages/provider" | |||
| 14 | + | "github.com/bnema/zut/packages/tui" | |||
| 8 | ) | 15 | ) | ||
| 9 | 16 | ||||
| 10 | func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testing.T) { | 17 | func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testing.T) { | ||
| @@ -23,6 +30,69 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi | |||||
| 23 | } | 30 | } | ||
| 24 | } | 31 | } | ||
| 25 | 32 | ||||
| 33 | + | func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) { | |||
| 34 | + | completed := make(chan struct{}, 2) | |||
| 35 | + | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |||
| 36 | + | journal.ConfigureUsage(272_000, true) | |||
| 37 | + | return func(context.Context, string) error { | |||
| 38 | + | usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525} | |||
| 39 | + | return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}) | |||
| 40 | + | }, nil | |||
| 41 | + | }) | |||
| 42 | + | manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} }) | |||
| 43 | + | t.Cleanup(func() { _ = manager.Close(context.Background()) }) | |||
| 44 | + | for index := range 2 { | |||
| 45 | + | id := fmt.Sprintf("usage-child-%d", index) | |||
| 46 | + | if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: id, SessionID: "session-" + id, Profile: "reviewer", Provider: "openai-codex", Model: "gpt-test"}, "task"); err != nil { | |||
| 47 | + | t.Fatal(err) | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | for range 2 { | |||
| 51 | + | select { | |||
| 52 | + | case <-completed: | |||
| 53 | + | case <-time.After(time.Second): | |||
| 54 | + | t.Fatal("resident child did not complete") | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | dialog := newResidentSubagentsDialog() | |||
| 58 | + | dialog.Open(manager) | |||
| 59 | + | plain := strings.Join(plainResidentIndicatorLines(dialog.Render(tui.Dark, 100, 10)), "\n") | |||
| 60 | + | if !strings.Contains(plain, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 76.1%/272k") { | |||
| 61 | + | t.Fatalf("dialog = %q, want usage metadata line", plain) | |||
| 62 | + | } | |||
| 63 | + | for _, height := range []int{3, 4} { | |||
| 64 | + | if lines := dialog.Render(tui.Dark, 100, height); len(lines) > height { | |||
| 65 | + | t.Fatalf("height %d rendered %d lines: %q", height, len(lines), plainResidentIndicatorLines(lines)) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | for width := 1; width <= 3; width++ { | |||
| 69 | + | for _, line := range plainResidentIndicatorLines(dialog.Render(tui.Dark, width, 10)) { | |||
| 70 | + | if line == " " { | |||
| 71 | + | t.Fatalf("width %d rendered an empty metadata row", width) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestResidentSubagentsDialogBoundsEmptyStateByHeight(t *testing.T) { | |||
| 78 | + | dialog := newResidentSubagentsDialog() | |||
| 79 | + | dialog.Open(nil) | |||
| 80 | + | tests := []struct { | |||
| 81 | + | height int | |||
| 82 | + | want []string | |||
| 83 | + | }{ | |||
| 84 | + | {height: 0, want: nil}, | |||
| 85 | + | {height: 1, want: []string{" Resident subagents"}}, | |||
| 86 | + | {height: 2, want: []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}}, | |||
| 87 | + | {height: 3, want: []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn", " No resident subagents."}}, | |||
| 88 | + | } | |||
| 89 | + | for _, test := range tests { | |||
| 90 | + | if got := plainResidentIndicatorLines(dialog.Render(tui.Dark, 100, test.height)); !slices.Equal(got, test.want) { | |||
| 91 | + | t.Errorf("height %d rendered %q, want %q", test.height, got, test.want) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 26 | func TestResidentResultStatusIncludesFinalSummary(t *testing.T) { | 96 | func TestResidentResultStatusIncludesFinalSummary(t *testing.T) { | ||
| 27 | got := residentResultStatus("child", subagents.ResidentResult{State: subagents.ResidentIdle, Summary: "the child answer"}) | 97 | got := residentResultStatus("child", subagents.ResidentResult{State: subagents.ResidentIdle, Summary: "the child answer"}) | ||
| 28 | if got != "completed result child: the child answer" { | 98 | if got != "completed result child: the child answer" { | ||
M packages/agent/subagent_factory.go +4 -0
| @@ -43,6 +43,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||
| 43 | 43 | agent.Reasoning = resolved.Reasoning | |
| 44 | 44 | agent.Temperature = resolved.Temperature | |
| 45 | 45 | agent.FastMode = resolved.FastMode | |
| 46 | + | if journal != nil { | |
| 47 | + | baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") | |
| 48 | + | agent.SeedCost(baseline.Usage) | |
| 49 | + | } | |
| 46 | 50 | if err := agent.BindSessionID(spec.SessionID); err != nil { | |
| 47 | 51 | return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err) | |
| 48 | 52 | } | |
| @@ -43,6 +43,10 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal | |||||
| 43 | agent.Reasoning = resolved.Reasoning | 43 | agent.Reasoning = resolved.Reasoning | ||
| 44 | agent.Temperature = resolved.Temperature | 44 | agent.Temperature = resolved.Temperature | ||
| 45 | agent.FastMode = resolved.FastMode | 45 | agent.FastMode = resolved.FastMode | ||
| 46 | + | if journal != nil { | |||
| 47 | + | baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth") | |||
| 48 | + | agent.SeedCost(baseline.Usage) | |||
| 49 | + | } | |||
| 46 | if err := agent.BindSessionID(spec.SessionID); err != nil { | 50 | if err := agent.BindSessionID(spec.SessionID); err != nil { | ||
| 47 | return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err) | 51 | return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err) | ||
| 48 | } | 52 | } | ||
M packages/agent/subagents/resident_child.go +1 -0
| @@ -101,6 +101,7 @@ func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJour | |||
| 101 | 101 | live: newResidentLiveProjection(), | |
| 102 | 102 | } | |
| 103 | 103 | if journal != nil { | |
| 104 | + | child.live.SeedUsage(journal.usageSnapshot()) | |
| 104 | 105 | journal.SetEventObserver(func(event core.AgentEvent) { | |
| 105 | 106 | child.live.Apply(event) | |
| 106 | 107 | child.recordActivity() | |
| @@ -101,6 +101,7 @@ func newResidentChildWithWorkspace(spec ResidentChildSpec, journal *ResidentJour | |||||
| 101 | live: newResidentLiveProjection(), | 101 | live: newResidentLiveProjection(), | ||
| 102 | } | 102 | } | ||
| 103 | if journal != nil { | 103 | if journal != nil { | ||
| 104 | + | child.live.SeedUsage(journal.usageSnapshot()) | |||
| 104 | journal.SetEventObserver(func(event core.AgentEvent) { | 105 | journal.SetEventObserver(func(event core.AgentEvent) { | ||
| 105 | child.live.Apply(event) | 106 | child.live.Apply(event) | ||
| 106 | child.recordActivity() | 107 | child.recordActivity() | ||
M packages/agent/subagents/resident_journal.go +127 -24
| @@ -33,6 +33,7 @@ const ( | |||
| 33 | 33 | residentRecordAssistant = "message.assistant" | |
| 34 | 34 | residentRecordToolCall = "tool.call" | |
| 35 | 35 | residentRecordToolResult = "tool.result" | |
| 36 | + | residentRecordUsage = "usage" | |
| 36 | 37 | residentJournalVersion = 1 | |
| 37 | 38 | residentInterruptedText = "tool interrupted by resident host restart" | |
| 38 | 39 | residentMaxRecordBytes = 2 << 20 | |
| @@ -90,19 +91,23 @@ type ResidentChildSpec struct { | |||
| 90 | 91 | } | |
| 91 | 92 | ||
| 92 | 93 | type residentRecord struct { | |
| 93 | - | Version int `json:"version"` | |
| 94 | - | Type string `json:"type"` | |
| 95 | - | Time time.Time `json:"time"` | |
| 96 | - | Spec *ResidentChildSpec `json:"spec,omitempty"` | |
| 97 | - | TurnID string `json:"turn_id,omitempty"` | |
| 98 | - | Prompt string `json:"prompt,omitempty"` | |
| 99 | - | Outcome string `json:"outcome,omitempty"` | |
| 100 | - | Message json.RawMessage `json:"message,omitempty"` | |
| 101 | - | ToolID string `json:"tool_id,omitempty"` | |
| 102 | - | ToolName string `json:"tool_name,omitempty"` | |
| 103 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 104 | - | ToolResult json.RawMessage `json:"tool_result,omitempty"` | |
| 105 | - | raw json.RawMessage `json:"-"` | |
| 94 | + | Version int `json:"version"` | |
| 95 | + | Type string `json:"type"` | |
| 96 | + | Time time.Time `json:"time"` | |
| 97 | + | Spec *ResidentChildSpec `json:"spec,omitempty"` | |
| 98 | + | TurnID string `json:"turn_id,omitempty"` | |
| 99 | + | Prompt string `json:"prompt,omitempty"` | |
| 100 | + | Outcome string `json:"outcome,omitempty"` | |
| 101 | + | Message json.RawMessage `json:"message,omitempty"` | |
| 102 | + | ToolID string `json:"tool_id,omitempty"` | |
| 103 | + | ToolName string `json:"tool_name,omitempty"` | |
| 104 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | |
| 105 | + | ToolResult json.RawMessage `json:"tool_result,omitempty"` | |
| 106 | + | Usage *provider.Usage `json:"usage,omitempty"` | |
| 107 | + | ContextUsed int `json:"context_used,omitempty"` | |
| 108 | + | ContextMax int `json:"context_max,omitempty"` | |
| 109 | + | Subscription bool `json:"subscription,omitempty"` | |
| 110 | + | raw json.RawMessage `json:"-"` | |
| 106 | 111 | } | |
| 107 | 112 | ||
| 108 | 113 | // RecordAgentEvent persists finalized provider-neutral transcript events. It | |
| @@ -135,6 +140,15 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error { | |||
| 135 | 140 | return fmt.Errorf("resident journal encode tool result: %w", err) | |
| 136 | 141 | } | |
| 137 | 142 | record = residentRecord{Type: residentRecordToolResult, ToolID: value.ID, ToolResult: result} | |
| 143 | + | case core.EvUsage: | |
| 144 | + | j.usageMu.RLock() | |
| 145 | + | contextUsed, contextMax, subscription := j.contextUsed, j.contextMax, j.subscription | |
| 146 | + | j.usageMu.RUnlock() | |
| 147 | + | if current := value.Usage.PromptTokens(); current > 0 { | |
| 148 | + | contextUsed = current | |
| 149 | + | } | |
| 150 | + | usage := value.Cumulative | |
| 151 | + | record = residentRecord{Type: residentRecordUsage, Usage: &usage, ContextUsed: contextUsed, ContextMax: contextMax, Subscription: subscription} | |
| 138 | 152 | default: | |
| 139 | 153 | j.publishAgentEvent(event) | |
| 140 | 154 | return nil | |
| @@ -147,6 +161,15 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error { | |||
| 147 | 161 | return errors.New("resident journal: closed") | |
| 148 | 162 | } | |
| 149 | 163 | err := j.appendSync(record) | |
| 164 | + | if usageEvent, ok := event.(core.EvUsage); err == nil && ok { | |
| 165 | + | j.usageMu.Lock() | |
| 166 | + | j.usage = usageEvent.Cumulative | |
| 167 | + | if current := usageEvent.Usage.PromptTokens(); current > 0 { | |
| 168 | + | j.contextUsed = current | |
| 169 | + | } | |
| 170 | + | j.usageMu.Unlock() | |
| 171 | + | err = j.projectUsageMetadata() | |
| 172 | + | } | |
| 150 | 173 | j.mu.Unlock() | |
| 151 | 174 | if err == nil { | |
| 152 | 175 | switch message := event.(type) { | |
| @@ -167,12 +190,16 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error { | |||
| 167 | 190 | // the authority for acceptance; callers must never infer acceptance solely | |
| 168 | 191 | // from this file. | |
| 169 | 192 | type ResidentMetadata struct { | |
| 170 | - | Version int `json:"version"` | |
| 171 | - | ID string `json:"id"` | |
| 172 | - | SessionID string `json:"session_id"` | |
| 173 | - | ParentSessionID string `json:"parent_session_id,omitempty"` | |
| 174 | - | State ResidentState `json:"state"` | |
| 175 | - | UpdatedAt time.Time `json:"updated_at"` | |
| 193 | + | Version int `json:"version"` | |
| 194 | + | ID string `json:"id"` | |
| 195 | + | SessionID string `json:"session_id"` | |
| 196 | + | ParentSessionID string `json:"parent_session_id,omitempty"` | |
| 197 | + | State ResidentState `json:"state"` | |
| 198 | + | UpdatedAt time.Time `json:"updated_at"` | |
| 199 | + | Usage provider.Usage `json:"usage,omitempty"` | |
| 200 | + | ContextUsed int `json:"context_used,omitempty"` | |
| 201 | + | ContextMax int `json:"context_max,omitempty"` | |
| 202 | + | Subscription bool `json:"subscription,omitempty"` | |
| 176 | 203 | } | |
| 177 | 204 | ||
| 178 | 205 | // ResidentResult is the bounded latest-turn projection. The transcript | |
| @@ -198,11 +225,52 @@ type ResidentJournal struct { | |||
| 198 | 225 | eventObserver func(core.AgentEvent) | |
| 199 | 226 | summaryMu sync.RWMutex | |
| 200 | 227 | latestSummary string | |
| 228 | + | usageMu sync.RWMutex | |
| 229 | + | usage provider.Usage | |
| 230 | + | contextUsed int | |
| 231 | + | contextMax int | |
| 232 | + | subscription bool | |
| 201 | 233 | dir string | |
| 202 | 234 | file *os.File | |
| 203 | 235 | lease *residentLease | |
| 204 | 236 | } | |
| 205 | 237 | ||
| 238 | + | // ResidentUsageSnapshot is the bounded usage projection shared by durable and | |
| 239 | + | // live resident state. | |
| 240 | + | type ResidentUsageSnapshot struct { | |
| 241 | + | Usage provider.Usage | |
| 242 | + | ContextUsed int | |
| 243 | + | ContextMax int | |
| 244 | + | Subscription bool | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // ConfigureUsage records resolved model metadata and restores the latest | |
| 248 | + | // durable usage baseline when a resident child is reconstructed. | |
| 249 | + | func (j *ResidentJournal) ConfigureUsage(contextMax int, subscription bool) ResidentUsageSnapshot { | |
| 250 | + | if j == nil { | |
| 251 | + | return ResidentUsageSnapshot{} | |
| 252 | + | } | |
| 253 | + | j.usageMu.Lock() | |
| 254 | + | if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil { | |
| 255 | + | j.usage = metadata.Usage | |
| 256 | + | j.contextUsed = metadata.ContextUsed | |
| 257 | + | } | |
| 258 | + | j.contextMax = contextMax | |
| 259 | + | j.subscription = subscription | |
| 260 | + | snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription} | |
| 261 | + | j.usageMu.Unlock() | |
| 262 | + | return snapshot | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func (j *ResidentJournal) usageSnapshot() ResidentUsageSnapshot { | |
| 266 | + | if j == nil { | |
| 267 | + | return ResidentUsageSnapshot{} | |
| 268 | + | } | |
| 269 | + | j.usageMu.RLock() | |
| 270 | + | defer j.usageMu.RUnlock() | |
| 271 | + | return ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription} | |
| 272 | + | } | |
| 273 | + | ||
| 206 | 274 | // SetEventObserver publishes in-memory agent events without creating another | |
| 207 | 275 | // durable transcript. Finalized events are observed only after journal append. | |
| 208 | 276 | func (j *ResidentJournal) SetEventObserver(observer func(core.AgentEvent)) { | |
| @@ -272,7 +340,7 @@ func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error { | |||
| 272 | 340 | if err := j.appendSync(record); err != nil { | |
| 273 | 341 | return err | |
| 274 | 342 | } | |
| 275 | - | return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentQueued, UpdatedAt: record.Time}) | |
| 343 | + | return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, record.Time)) | |
| 276 | 344 | } | |
| 277 | 345 | ||
| 278 | 346 | // RecordFailure makes a post-acceptance construction failure durable. | |
| @@ -286,7 +354,7 @@ func (j *ResidentJournal) RecordFailure(spec ResidentChildSpec) error { | |||
| 286 | 354 | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordFailed, Time: now}); err != nil { | |
| 287 | 355 | return err | |
| 288 | 356 | } | |
| 289 | - | return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentFailed, UpdatedAt: now}) | |
| 357 | + | return writeResidentMetadata(j.dir, j.metadata(spec, ResidentFailed, now)) | |
| 290 | 358 | } | |
| 291 | 359 | ||
| 292 | 360 | // AcceptFollowUp establishes a durable follow-up before a live child is told | |
| @@ -305,7 +373,7 @@ func (j *ResidentJournal) AcceptFollowUp(spec ResidentChildSpec, turnID, prompt | |||
| 305 | 373 | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt}); err != nil { | |
| 306 | 374 | return err | |
| 307 | 375 | } | |
| 308 | - | return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentQueued, UpdatedAt: now}) | |
| 376 | + | return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now)) | |
| 309 | 377 | } | |
| 310 | 378 | ||
| 311 | 379 | func (j *ResidentJournal) RecordTurnStarted(spec ResidentChildSpec, turnID string) error { | |
| @@ -416,7 +484,32 @@ func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, recordType, | |||
| 416 | 484 | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: recordType, Time: now, TurnID: turnID, Outcome: outcome}); err != nil { | |
| 417 | 485 | return err | |
| 418 | 486 | } | |
| 419 | - | return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: now}) | |
| 487 | + | return writeResidentMetadata(j.dir, j.metadata(spec, state, now)) | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, updatedAt time.Time) ResidentMetadata { | |
| 491 | + | usage := j.usageSnapshot() | |
| 492 | + | return ResidentMetadata{ | |
| 493 | + | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | |
| 494 | + | ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt, | |
| 495 | + | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | // projectUsageMetadata updates the rebuildable projection while j.mu holds the | |
| 500 | + | // lifecycle ordering lock. The transcript remains authoritative if this write | |
| 501 | + | // fails and reconciliation can reconstruct the same values. | |
| 502 | + | func (j *ResidentJournal) projectUsageMetadata() error { | |
| 503 | + | metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)) | |
| 504 | + | if err != nil { | |
| 505 | + | return err | |
| 506 | + | } | |
| 507 | + | usage := j.usageSnapshot() | |
| 508 | + | metadata.Usage = usage.Usage | |
| 509 | + | metadata.ContextUsed = usage.ContextUsed | |
| 510 | + | metadata.ContextMax = usage.ContextMax | |
| 511 | + | metadata.Subscription = usage.Subscription | |
| 512 | + | return writeResidentMetadata(j.dir, metadata) | |
| 420 | 513 | } | |
| 421 | 514 | ||
| 422 | 515 | func (j *ResidentJournal) appendSync(record residentRecord) error { | |
| @@ -594,6 +687,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 594 | 687 | state := ResidentQueued | |
| 595 | 688 | lastStateAt := records[0].Time | |
| 596 | 689 | lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary := "", "", "" | |
| 690 | + | usage := ResidentUsageSnapshot{} | |
| 597 | 691 | seenTurns := make(map[string]string) | |
| 598 | 692 | // The initial prompt is accepted atomically with child.accepted rather than | |
| 599 | 693 | // through a separate turn.accepted record. Seed it so interruption and | |
| @@ -673,12 +767,21 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||
| 673 | 767 | return ResidentMetadata{}, errors.New("resident journal: duplicate tool result") | |
| 674 | 768 | } | |
| 675 | 769 | toolResults[record.ToolID] = struct{}{} | |
| 770 | + | case residentRecordUsage: | |
| 771 | + | if record.Usage == nil { | |
| 772 | + | return ResidentMetadata{}, errors.New("resident journal: invalid usage record") | |
| 773 | + | } | |
| 774 | + | usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription} | |
| 676 | 775 | } | |
| 677 | 776 | } | |
| 678 | 777 | if lastStateAt.IsZero() { | |
| 679 | 778 | lastStateAt = time.Now().UTC() | |
| 680 | 779 | } | |
| 681 | - | metadata := ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt} | |
| 780 | + | metadata := ResidentMetadata{ | |
| 781 | + | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | |
| 782 | + | ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt, | |
| 783 | + | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | |
| 784 | + | } | |
| 682 | 785 | needsInterruption := state == ResidentQueued || state == ResidentRunning | |
| 683 | 786 | needsToolRepair := len(toolCalls) != len(toolResults) | |
| 684 | 787 | if needsInterruption || needsToolRepair { | |
| @@ -33,6 +33,7 @@ const ( | |||||
| 33 | residentRecordAssistant = "message.assistant" | 33 | residentRecordAssistant = "message.assistant" | ||
| 34 | residentRecordToolCall = "tool.call" | 34 | residentRecordToolCall = "tool.call" | ||
| 35 | residentRecordToolResult = "tool.result" | 35 | residentRecordToolResult = "tool.result" | ||
| 36 | + | residentRecordUsage = "usage" | |||
| 36 | residentJournalVersion = 1 | 37 | residentJournalVersion = 1 | ||
| 37 | residentInterruptedText = "tool interrupted by resident host restart" | 38 | residentInterruptedText = "tool interrupted by resident host restart" | ||
| 38 | residentMaxRecordBytes = 2 << 20 | 39 | residentMaxRecordBytes = 2 << 20 | ||
| @@ -90,19 +91,23 @@ type ResidentChildSpec struct { | |||||
| 90 | } | 91 | } | ||
| 91 | 92 | ||||
| 92 | type residentRecord struct { | 93 | type residentRecord struct { | ||
| 93 | - | Version int `json:"version"` | 94 | + | Version int `json:"version"` |
| 94 | - | Type string `json:"type"` | 95 | + | Type string `json:"type"` |
| 95 | - | Time time.Time `json:"time"` | 96 | + | Time time.Time `json:"time"` |
| 96 | - | Spec *ResidentChildSpec `json:"spec,omitempty"` | 97 | + | Spec *ResidentChildSpec `json:"spec,omitempty"` |
| 97 | - | TurnID string `json:"turn_id,omitempty"` | 98 | + | TurnID string `json:"turn_id,omitempty"` |
| 98 | - | Prompt string `json:"prompt,omitempty"` | 99 | + | Prompt string `json:"prompt,omitempty"` |
| 99 | - | Outcome string `json:"outcome,omitempty"` | 100 | + | Outcome string `json:"outcome,omitempty"` |
| 100 | - | Message json.RawMessage `json:"message,omitempty"` | 101 | + | Message json.RawMessage `json:"message,omitempty"` |
| 101 | - | ToolID string `json:"tool_id,omitempty"` | 102 | + | ToolID string `json:"tool_id,omitempty"` |
| 102 | - | ToolName string `json:"tool_name,omitempty"` | 103 | + | ToolName string `json:"tool_name,omitempty"` |
| 103 | - | ToolArgs json.RawMessage `json:"tool_args,omitempty"` | 104 | + | ToolArgs json.RawMessage `json:"tool_args,omitempty"` |
| 104 | - | ToolResult json.RawMessage `json:"tool_result,omitempty"` | 105 | + | ToolResult json.RawMessage `json:"tool_result,omitempty"` |
| 105 | - | raw json.RawMessage `json:"-"` | 106 | + | Usage *provider.Usage `json:"usage,omitempty"` |
| 107 | + | ContextUsed int `json:"context_used,omitempty"` | |||
| 108 | + | ContextMax int `json:"context_max,omitempty"` | |||
| 109 | + | Subscription bool `json:"subscription,omitempty"` | |||
| 110 | + | raw json.RawMessage `json:"-"` | |||
| 106 | } | 111 | } | ||
| 107 | 112 | ||||
| 108 | // RecordAgentEvent persists finalized provider-neutral transcript events. It | 113 | // RecordAgentEvent persists finalized provider-neutral transcript events. It | ||
| @@ -135,6 +140,15 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error { | |||||
| 135 | return fmt.Errorf("resident journal encode tool result: %w", err) | 140 | return fmt.Errorf("resident journal encode tool result: %w", err) | ||
| 136 | } | 141 | } | ||
| 137 | record = residentRecord{Type: residentRecordToolResult, ToolID: value.ID, ToolResult: result} | 142 | record = residentRecord{Type: residentRecordToolResult, ToolID: value.ID, ToolResult: result} | ||
| 143 | + | case core.EvUsage: | |||
| 144 | + | j.usageMu.RLock() | |||
| 145 | + | contextUsed, contextMax, subscription := j.contextUsed, j.contextMax, j.subscription | |||
| 146 | + | j.usageMu.RUnlock() | |||
| 147 | + | if current := value.Usage.PromptTokens(); current > 0 { | |||
| 148 | + | contextUsed = current | |||
| 149 | + | } | |||
| 150 | + | usage := value.Cumulative | |||
| 151 | + | record = residentRecord{Type: residentRecordUsage, Usage: &usage, ContextUsed: contextUsed, ContextMax: contextMax, Subscription: subscription} | |||
| 138 | default: | 152 | default: | ||
| 139 | j.publishAgentEvent(event) | 153 | j.publishAgentEvent(event) | ||
| 140 | return nil | 154 | return nil | ||
| @@ -147,6 +161,15 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error { | |||||
| 147 | return errors.New("resident journal: closed") | 161 | return errors.New("resident journal: closed") | ||
| 148 | } | 162 | } | ||
| 149 | err := j.appendSync(record) | 163 | err := j.appendSync(record) | ||
| 164 | + | if usageEvent, ok := event.(core.EvUsage); err == nil && ok { | |||
| 165 | + | j.usageMu.Lock() | |||
| 166 | + | j.usage = usageEvent.Cumulative | |||
| 167 | + | if current := usageEvent.Usage.PromptTokens(); current > 0 { | |||
| 168 | + | j.contextUsed = current | |||
| 169 | + | } | |||
| 170 | + | j.usageMu.Unlock() | |||
| 171 | + | err = j.projectUsageMetadata() | |||
| 172 | + | } | |||
| 150 | j.mu.Unlock() | 173 | j.mu.Unlock() | ||
| 151 | if err == nil { | 174 | if err == nil { | ||
| 152 | switch message := event.(type) { | 175 | switch message := event.(type) { | ||
| @@ -167,12 +190,16 @@ func (j *ResidentJournal) RecordAgentEvent(event core.AgentEvent) error { | |||||
| 167 | // the authority for acceptance; callers must never infer acceptance solely | 190 | // the authority for acceptance; callers must never infer acceptance solely | ||
| 168 | // from this file. | 191 | // from this file. | ||
| 169 | type ResidentMetadata struct { | 192 | type ResidentMetadata struct { | ||
| 170 | - | Version int `json:"version"` | 193 | + | Version int `json:"version"` |
| 171 | - | ID string `json:"id"` | 194 | + | ID string `json:"id"` |
| 172 | - | SessionID string `json:"session_id"` | 195 | + | SessionID string `json:"session_id"` |
| 173 | - | ParentSessionID string `json:"parent_session_id,omitempty"` | 196 | + | ParentSessionID string `json:"parent_session_id,omitempty"` |
| 174 | - | State ResidentState `json:"state"` | 197 | + | State ResidentState `json:"state"` |
| 175 | - | UpdatedAt time.Time `json:"updated_at"` | 198 | + | UpdatedAt time.Time `json:"updated_at"` |
| 199 | + | Usage provider.Usage `json:"usage,omitempty"` | |||
| 200 | + | ContextUsed int `json:"context_used,omitempty"` | |||
| 201 | + | ContextMax int `json:"context_max,omitempty"` | |||
| 202 | + | Subscription bool `json:"subscription,omitempty"` | |||
| 176 | } | 203 | } | ||
| 177 | 204 | ||||
| 178 | // ResidentResult is the bounded latest-turn projection. The transcript | 205 | // ResidentResult is the bounded latest-turn projection. The transcript | ||
| @@ -198,11 +225,52 @@ type ResidentJournal struct { | |||||
| 198 | eventObserver func(core.AgentEvent) | 225 | eventObserver func(core.AgentEvent) | ||
| 199 | summaryMu sync.RWMutex | 226 | summaryMu sync.RWMutex | ||
| 200 | latestSummary string | 227 | latestSummary string | ||
| 228 | + | usageMu sync.RWMutex | |||
| 229 | + | usage provider.Usage | |||
| 230 | + | contextUsed int | |||
| 231 | + | contextMax int | |||
| 232 | + | subscription bool | |||
| 201 | dir string | 233 | dir string | ||
| 202 | file *os.File | 234 | file *os.File | ||
| 203 | lease *residentLease | 235 | lease *residentLease | ||
| 204 | } | 236 | } | ||
| 205 | 237 | ||||
| 238 | + | // ResidentUsageSnapshot is the bounded usage projection shared by durable and | |||
| 239 | + | // live resident state. | |||
| 240 | + | type ResidentUsageSnapshot struct { | |||
| 241 | + | Usage provider.Usage | |||
| 242 | + | ContextUsed int | |||
| 243 | + | ContextMax int | |||
| 244 | + | Subscription bool | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // ConfigureUsage records resolved model metadata and restores the latest | |||
| 248 | + | // durable usage baseline when a resident child is reconstructed. | |||
| 249 | + | func (j *ResidentJournal) ConfigureUsage(contextMax int, subscription bool) ResidentUsageSnapshot { | |||
| 250 | + | if j == nil { | |||
| 251 | + | return ResidentUsageSnapshot{} | |||
| 252 | + | } | |||
| 253 | + | j.usageMu.Lock() | |||
| 254 | + | if metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)); err == nil { | |||
| 255 | + | j.usage = metadata.Usage | |||
| 256 | + | j.contextUsed = metadata.ContextUsed | |||
| 257 | + | } | |||
| 258 | + | j.contextMax = contextMax | |||
| 259 | + | j.subscription = subscription | |||
| 260 | + | snapshot := ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription} | |||
| 261 | + | j.usageMu.Unlock() | |||
| 262 | + | return snapshot | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | func (j *ResidentJournal) usageSnapshot() ResidentUsageSnapshot { | |||
| 266 | + | if j == nil { | |||
| 267 | + | return ResidentUsageSnapshot{} | |||
| 268 | + | } | |||
| 269 | + | j.usageMu.RLock() | |||
| 270 | + | defer j.usageMu.RUnlock() | |||
| 271 | + | return ResidentUsageSnapshot{Usage: j.usage, ContextUsed: j.contextUsed, ContextMax: j.contextMax, Subscription: j.subscription} | |||
| 272 | + | } | |||
| 273 | + | ||||
| 206 | // SetEventObserver publishes in-memory agent events without creating another | 274 | // SetEventObserver publishes in-memory agent events without creating another | ||
| 207 | // durable transcript. Finalized events are observed only after journal append. | 275 | // durable transcript. Finalized events are observed only after journal append. | ||
| 208 | func (j *ResidentJournal) SetEventObserver(observer func(core.AgentEvent)) { | 276 | func (j *ResidentJournal) SetEventObserver(observer func(core.AgentEvent)) { | ||
| @@ -272,7 +340,7 @@ func (j *ResidentJournal) Accept(spec ResidentChildSpec, prompt string) error { | |||||
| 272 | if err := j.appendSync(record); err != nil { | 340 | if err := j.appendSync(record); err != nil { | ||
| 273 | return err | 341 | return err | ||
| 274 | } | 342 | } | ||
| 275 | - | return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentQueued, UpdatedAt: record.Time}) | 343 | + | return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, record.Time)) |
| 276 | } | 344 | } | ||
| 277 | 345 | ||||
| 278 | // RecordFailure makes a post-acceptance construction failure durable. | 346 | // RecordFailure makes a post-acceptance construction failure durable. | ||
| @@ -286,7 +354,7 @@ func (j *ResidentJournal) RecordFailure(spec ResidentChildSpec) error { | |||||
| 286 | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordFailed, Time: now}); err != nil { | 354 | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordFailed, Time: now}); err != nil { | ||
| 287 | return err | 355 | return err | ||
| 288 | } | 356 | } | ||
| 289 | - | return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentFailed, UpdatedAt: now}) | 357 | + | return writeResidentMetadata(j.dir, j.metadata(spec, ResidentFailed, now)) |
| 290 | } | 358 | } | ||
| 291 | 359 | ||||
| 292 | // AcceptFollowUp establishes a durable follow-up before a live child is told | 360 | // AcceptFollowUp establishes a durable follow-up before a live child is told | ||
| @@ -305,7 +373,7 @@ func (j *ResidentJournal) AcceptFollowUp(spec ResidentChildSpec, turnID, prompt | |||||
| 305 | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt}); err != nil { | 373 | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: residentRecordTurnAccepted, Time: now, TurnID: turnID, Prompt: prompt}); err != nil { | ||
| 306 | return err | 374 | return err | ||
| 307 | } | 375 | } | ||
| 308 | - | return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: ResidentQueued, UpdatedAt: now}) | 376 | + | return writeResidentMetadata(j.dir, j.metadata(spec, ResidentQueued, now)) |
| 309 | } | 377 | } | ||
| 310 | 378 | ||||
| 311 | func (j *ResidentJournal) RecordTurnStarted(spec ResidentChildSpec, turnID string) error { | 379 | func (j *ResidentJournal) RecordTurnStarted(spec ResidentChildSpec, turnID string) error { | ||
| @@ -416,7 +484,32 @@ func (j *ResidentJournal) recordTurnBoundary(spec ResidentChildSpec, recordType, | |||||
| 416 | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: recordType, Time: now, TurnID: turnID, Outcome: outcome}); err != nil { | 484 | if err := j.appendSync(residentRecord{Version: residentJournalVersion, Type: recordType, Time: now, TurnID: turnID, Outcome: outcome}); err != nil { | ||
| 417 | return err | 485 | return err | ||
| 418 | } | 486 | } | ||
| 419 | - | return writeResidentMetadata(j.dir, ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: now}) | 487 | + | return writeResidentMetadata(j.dir, j.metadata(spec, state, now)) |
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState, updatedAt time.Time) ResidentMetadata { | |||
| 491 | + | usage := j.usageSnapshot() | |||
| 492 | + | return ResidentMetadata{ | |||
| 493 | + | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | |||
| 494 | + | ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt, | |||
| 495 | + | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | |||
| 496 | + | } | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | // projectUsageMetadata updates the rebuildable projection while j.mu holds the | |||
| 500 | + | // lifecycle ordering lock. The transcript remains authoritative if this write | |||
| 501 | + | // fails and reconciliation can reconstruct the same values. | |||
| 502 | + | func (j *ResidentJournal) projectUsageMetadata() error { | |||
| 503 | + | metadata, err := ReadResidentMetadata(filepath.Join(j.dir, residentMetadataName)) | |||
| 504 | + | if err != nil { | |||
| 505 | + | return err | |||
| 506 | + | } | |||
| 507 | + | usage := j.usageSnapshot() | |||
| 508 | + | metadata.Usage = usage.Usage | |||
| 509 | + | metadata.ContextUsed = usage.ContextUsed | |||
| 510 | + | metadata.ContextMax = usage.ContextMax | |||
| 511 | + | metadata.Subscription = usage.Subscription | |||
| 512 | + | return writeResidentMetadata(j.dir, metadata) | |||
| 420 | } | 513 | } | ||
| 421 | 514 | ||||
| 422 | func (j *ResidentJournal) appendSync(record residentRecord) error { | 515 | func (j *ResidentJournal) appendSync(record residentRecord) error { | ||
| @@ -594,6 +687,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 594 | state := ResidentQueued | 687 | state := ResidentQueued | ||
| 595 | lastStateAt := records[0].Time | 688 | lastStateAt := records[0].Time | ||
| 596 | lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary := "", "", "" | 689 | lastFinishedTurn, lastFinishedOutcome, lastAssistantSummary := "", "", "" | ||
| 690 | + | usage := ResidentUsageSnapshot{} | |||
| 597 | seenTurns := make(map[string]string) | 691 | seenTurns := make(map[string]string) | ||
| 598 | // The initial prompt is accepted atomically with child.accepted rather than | 692 | // The initial prompt is accepted atomically with child.accepted rather than | ||
| 599 | // through a separate turn.accepted record. Seed it so interruption and | 693 | // through a separate turn.accepted record. Seed it so interruption and | ||
| @@ -673,12 +767,21 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata, | |||||
| 673 | return ResidentMetadata{}, errors.New("resident journal: duplicate tool result") | 767 | return ResidentMetadata{}, errors.New("resident journal: duplicate tool result") | ||
| 674 | } | 768 | } | ||
| 675 | toolResults[record.ToolID] = struct{}{} | 769 | toolResults[record.ToolID] = struct{}{} | ||
| 770 | + | case residentRecordUsage: | |||
| 771 | + | if record.Usage == nil { | |||
| 772 | + | return ResidentMetadata{}, errors.New("resident journal: invalid usage record") | |||
| 773 | + | } | |||
| 774 | + | usage = ResidentUsageSnapshot{Usage: *record.Usage, ContextUsed: record.ContextUsed, ContextMax: record.ContextMax, Subscription: record.Subscription} | |||
| 676 | } | 775 | } | ||
| 677 | } | 776 | } | ||
| 678 | if lastStateAt.IsZero() { | 777 | if lastStateAt.IsZero() { | ||
| 679 | lastStateAt = time.Now().UTC() | 778 | lastStateAt = time.Now().UTC() | ||
| 680 | } | 779 | } | ||
| 681 | - | metadata := ResidentMetadata{Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt} | 780 | + | metadata := ResidentMetadata{ |
| 781 | + | Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID, | |||
| 782 | + | ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt, | |||
| 783 | + | Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription, | |||
| 784 | + | } | |||
| 682 | needsInterruption := state == ResidentQueued || state == ResidentRunning | 785 | needsInterruption := state == ResidentQueued || state == ResidentRunning | ||
| 683 | needsToolRepair := len(toolCalls) != len(toolResults) | 786 | needsToolRepair := len(toolCalls) != len(toolResults) | ||
| 684 | if needsInterruption || needsToolRepair { | 787 | if needsInterruption || needsToolRepair { | ||
M packages/agent/subagents/resident_journal_test.go +61 -0
| @@ -62,6 +62,67 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin | |||
| 62 | 62 | } | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | + | func TestResidentJournalReconcilesUsageMetadata(t *testing.T) { | |
| 66 | + | root := t.TempDir() | |
| 67 | + | journal, err := OpenResidentJournal(root, "usage-child") | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | spec := ResidentChildSpec{ID: "usage-child", SessionID: "usage-session", Provider: "openai-codex", Model: "gpt-test"} | |
| 72 | + | if err := journal.Accept(spec, "task"); err != nil { | |
| 73 | + | t.Fatal(err) | |
| 74 | + | } | |
| 75 | + | journal.ConfigureUsage(272_000, true) | |
| 76 | + | turn := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525} | |
| 77 | + | if err := journal.RecordAgentEvent(core.EvUsage{Usage: turn, Cumulative: turn}); err != nil { | |
| 78 | + | t.Fatal(err) | |
| 79 | + | } | |
| 80 | + | if err := journal.RecordAgentEvent(core.EvUsage{Usage: provider.Usage{}, Cumulative: turn}); err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | if err := journal.Close(); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)) | |
| 87 | + | if err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | if metadata.Usage != turn || metadata.ContextUsed != 207_000 || metadata.ContextMax != 272_000 || !metadata.Subscription { | |
| 91 | + | t.Fatalf("metadata = %#v, want durable usage projection", metadata) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) { | |
| 96 | + | root := t.TempDir() | |
| 97 | + | journal, err := OpenResidentJournal(root, "interrupted-usage") | |
| 98 | + | if err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | t.Cleanup(func() { _ = journal.Close() }) | |
| 102 | + | spec := ResidentChildSpec{ID: "interrupted-usage", SessionID: "usage-session", Provider: "openai", Model: "gpt-test"} | |
| 103 | + | if err := journal.Accept(spec, "task"); err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | journal.ConfigureUsage(272_000, false) | |
| 107 | + | if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil { | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | if err := journal.RecordTurnInterrupted(spec, "turn-1"); err != nil { | |
| 111 | + | t.Fatal(err) | |
| 112 | + | } | |
| 113 | + | usage := provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000} | |
| 114 | + | if err := journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}); err != nil { | |
| 115 | + | t.Fatal(err) | |
| 116 | + | } | |
| 117 | + | metadata, err := ReadResidentMetadata(filepath.Join(journal.Dir(), residentMetadataName)) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | if metadata.State != ResidentInterrupted || metadata.Usage != usage || metadata.ContextUsed != 207_000 { | |
| 122 | + | t.Fatalf("metadata = %#v, want interrupted state with latest usage", metadata) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 65 | 126 | func TestOpenResidentJournalRejectsDotChildIDs(t *testing.T) { | |
| 66 | 127 | for _, childID := range []string{".", "..", "nested/child"} { | |
| 67 | 128 | t.Run(childID, func(t *testing.T) { | |
| @@ -62,6 +62,67 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin | |||||
| 62 | } | 62 | } | ||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | + | func TestResidentJournalReconcilesUsageMetadata(t *testing.T) { | |||
| 66 | + | root := t.TempDir() | |||
| 67 | + | journal, err := OpenResidentJournal(root, "usage-child") | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | spec := ResidentChildSpec{ID: "usage-child", SessionID: "usage-session", Provider: "openai-codex", Model: "gpt-test"} | |||
| 72 | + | if err := journal.Accept(spec, "task"); err != nil { | |||
| 73 | + | t.Fatal(err) | |||
| 74 | + | } | |||
| 75 | + | journal.ConfigureUsage(272_000, true) | |||
| 76 | + | turn := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525} | |||
| 77 | + | if err := journal.RecordAgentEvent(core.EvUsage{Usage: turn, Cumulative: turn}); err != nil { | |||
| 78 | + | t.Fatal(err) | |||
| 79 | + | } | |||
| 80 | + | if err := journal.RecordAgentEvent(core.EvUsage{Usage: provider.Usage{}, Cumulative: turn}); err != nil { | |||
| 81 | + | t.Fatal(err) | |||
| 82 | + | } | |||
| 83 | + | if err := journal.Close(); err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID)) | |||
| 87 | + | if err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | if metadata.Usage != turn || metadata.ContextUsed != 207_000 || metadata.ContextMax != 272_000 || !metadata.Subscription { | |||
| 91 | + | t.Fatalf("metadata = %#v, want durable usage projection", metadata) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) { | |||
| 96 | + | root := t.TempDir() | |||
| 97 | + | journal, err := OpenResidentJournal(root, "interrupted-usage") | |||
| 98 | + | if err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | t.Cleanup(func() { _ = journal.Close() }) | |||
| 102 | + | spec := ResidentChildSpec{ID: "interrupted-usage", SessionID: "usage-session", Provider: "openai", Model: "gpt-test"} | |||
| 103 | + | if err := journal.Accept(spec, "task"); err != nil { | |||
| 104 | + | t.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | journal.ConfigureUsage(272_000, false) | |||
| 107 | + | if err := journal.RecordTurnStarted(spec, "turn-1"); err != nil { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | if err := journal.RecordTurnInterrupted(spec, "turn-1"); err != nil { | |||
| 111 | + | t.Fatal(err) | |||
| 112 | + | } | |||
| 113 | + | usage := provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000} | |||
| 114 | + | if err := journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage}); err != nil { | |||
| 115 | + | t.Fatal(err) | |||
| 116 | + | } | |||
| 117 | + | metadata, err := ReadResidentMetadata(filepath.Join(journal.Dir(), residentMetadataName)) | |||
| 118 | + | if err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | if metadata.State != ResidentInterrupted || metadata.Usage != usage || metadata.ContextUsed != 207_000 { | |||
| 122 | + | t.Fatalf("metadata = %#v, want interrupted state with latest usage", metadata) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 65 | func TestOpenResidentJournalRejectsDotChildIDs(t *testing.T) { | 126 | func TestOpenResidentJournalRejectsDotChildIDs(t *testing.T) { | ||
| 66 | for _, childID := range []string{".", "..", "nested/child"} { | 127 | for _, childID := range []string{".", "..", "nested/child"} { | ||
| 67 | t.Run(childID, func(t *testing.T) { | 128 | t.Run(childID, func(t *testing.T) { | ||
M packages/agent/subagents/resident_manager.go +16 -3
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "sync" | |
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/zut/packages/provider" | |
| 14 | 15 | "github.com/google/uuid" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| @@ -157,6 +158,10 @@ type ResidentSnapshot struct { | |||
| 157 | 158 | ActivityUpdatedAt time.Time | |
| 158 | 159 | WaitingForModel bool | |
| 159 | 160 | OwnedElsewhere bool | |
| 161 | + | Usage provider.Usage | |
| 162 | + | ContextUsed int | |
| 163 | + | ContextMax int | |
| 164 | + | Subscription bool | |
| 160 | 165 | } | |
| 161 | 166 | ||
| 162 | 167 | func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |
| @@ -168,7 +173,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||
| 168 | 173 | Provider: child.spec.Provider, Model: child.spec.Model, | |
| 169 | 174 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | |
| 170 | 175 | UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(), | |
| 171 | - | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel} | |
| 176 | + | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, | |
| 177 | + | Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription} | |
| 172 | 178 | } | |
| 173 | 179 | ||
| 174 | 180 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | |
| @@ -785,7 +791,10 @@ func (m *ResidentManager) Reconcile() []error { | |||
| 785 | 791 | state, updatedAt = ResidentQueued, time.Now().UTC() | |
| 786 | 792 | } | |
| 787 | 793 | m.mu.Lock() | |
| 788 | - | m.recovered[childID] = ResidentSnapshot{ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true} | |
| 794 | + | m.recovered[childID] = ResidentSnapshot{ | |
| 795 | + | ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true, | |
| 796 | + | Usage: foreign.Usage, ContextUsed: foreign.ContextUsed, ContextMax: foreign.ContextMax, Subscription: foreign.Subscription, | |
| 797 | + | } | |
| 789 | 798 | m.mu.Unlock() | |
| 790 | 799 | continue | |
| 791 | 800 | } | |
| @@ -795,7 +804,11 @@ func (m *ResidentManager) Reconcile() []error { | |||
| 795 | 804 | } | |
| 796 | 805 | m.mu.Lock() | |
| 797 | 806 | if _, live := m.children[childID]; !live { | |
| 798 | - | m.recovered[childID] = ResidentSnapshot{ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model, WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt} | |
| 807 | + | m.recovered[childID] = ResidentSnapshot{ | |
| 808 | + | ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model, | |
| 809 | + | WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt, | |
| 810 | + | Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription, | |
| 811 | + | } | |
| 799 | 812 | m.recoveredSpec[childID] = spec | |
| 800 | 813 | } | |
| 801 | 814 | m.mu.Unlock() | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "sync" | 11 | "sync" | ||
| 12 | "time" | 12 | "time" | ||
| 13 | 13 | ||||
| 14 | + | "github.com/bnema/zut/packages/provider" | |||
| 14 | "github.com/google/uuid" | 15 | "github.com/google/uuid" | ||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| @@ -157,6 +158,10 @@ type ResidentSnapshot struct { | |||||
| 157 | ActivityUpdatedAt time.Time | 158 | ActivityUpdatedAt time.Time | ||
| 158 | WaitingForModel bool | 159 | WaitingForModel bool | ||
| 159 | OwnedElsewhere bool | 160 | OwnedElsewhere bool | ||
| 161 | + | Usage provider.Usage | |||
| 162 | + | ContextUsed int | |||
| 163 | + | ContextMax int | |||
| 164 | + | Subscription bool | |||
| 160 | } | 165 | } | ||
| 161 | 166 | ||||
| 162 | func residentSnapshot(child *ResidentChild) ResidentSnapshot { | 167 | func residentSnapshot(child *ResidentChild) ResidentSnapshot { | ||
| @@ -168,7 +173,8 @@ func residentSnapshot(child *ResidentChild) ResidentSnapshot { | |||||
| 168 | Provider: child.spec.Provider, Model: child.spec.Model, | 173 | Provider: child.spec.Provider, Model: child.spec.Model, | ||
| 169 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | 174 | WorkspaceMode: child.spec.WorkspaceMode, Required: child.spec.Required, | ||
| 170 | UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(), | 175 | UpdatedAt: child.StateUpdatedAt(), TurnStartedAt: child.TurnStartedAt(), | ||
| 171 | - | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel} | 176 | + | ActivityUpdatedAt: child.ActivityUpdatedAt(), WaitingForModel: live.WaitingForModel, |
| 177 | + | Usage: live.Usage, ContextUsed: live.ContextUsed, ContextMax: live.ContextMax, Subscription: live.Subscription} | |||
| 172 | } | 178 | } | ||
| 173 | 179 | ||||
| 174 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | 180 | func NewResidentManager(root string, factory ResidentFactory) *ResidentManager { | ||
| @@ -785,7 +791,10 @@ func (m *ResidentManager) Reconcile() []error { | |||||
| 785 | state, updatedAt = ResidentQueued, time.Now().UTC() | 791 | state, updatedAt = ResidentQueued, time.Now().UTC() | ||
| 786 | } | 792 | } | ||
| 787 | m.mu.Lock() | 793 | m.mu.Lock() | ||
| 788 | - | m.recovered[childID] = ResidentSnapshot{ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true} | 794 | + | m.recovered[childID] = ResidentSnapshot{ |
| 795 | + | ID: childID, State: state, UpdatedAt: updatedAt, OwnedElsewhere: true, | |||
| 796 | + | Usage: foreign.Usage, ContextUsed: foreign.ContextUsed, ContextMax: foreign.ContextMax, Subscription: foreign.Subscription, | |||
| 797 | + | } | |||
| 789 | m.mu.Unlock() | 798 | m.mu.Unlock() | ||
| 790 | continue | 799 | continue | ||
| 791 | } | 800 | } | ||
| @@ -795,7 +804,11 @@ func (m *ResidentManager) Reconcile() []error { | |||||
| 795 | } | 804 | } | ||
| 796 | m.mu.Lock() | 805 | m.mu.Lock() | ||
| 797 | if _, live := m.children[childID]; !live { | 806 | if _, live := m.children[childID]; !live { | ||
| 798 | - | m.recovered[childID] = ResidentSnapshot{ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model, WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt} | 807 | + | m.recovered[childID] = ResidentSnapshot{ |
| 808 | + | ID: childID, State: metadata.State, Profile: spec.Profile, Provider: spec.Provider, Model: spec.Model, | |||
| 809 | + | WorkspaceMode: spec.WorkspaceMode, Required: spec.Required, UpdatedAt: metadata.UpdatedAt, | |||
| 810 | + | Usage: metadata.Usage, ContextUsed: metadata.ContextUsed, ContextMax: metadata.ContextMax, Subscription: metadata.Subscription, | |||
| 811 | + | } | |||
| 799 | m.recoveredSpec[childID] = spec | 812 | m.recoveredSpec[childID] = spec | ||
| 800 | } | 813 | } | ||
| 801 | m.mu.Unlock() | 814 | m.mu.Unlock() | ||
M packages/agent/subagents/resident_projection.go +23 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "sync" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/zut/packages/core" | |
| 7 | + | "github.com/bnema/zut/packages/provider" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | const residentLiveArgumentBytes = 64 << 10 | |
| @@ -35,6 +36,10 @@ type ResidentLiveSnapshot struct { | |||
| 35 | 36 | WaitingForModel bool | |
| 36 | 37 | AssistantText string | |
| 37 | 38 | Tools []ResidentLiveTool | |
| 39 | + | Usage provider.Usage | |
| 40 | + | ContextUsed int | |
| 41 | + | ContextMax int | |
| 42 | + | Subscription bool | |
| 38 | 43 | } | |
| 39 | 44 | ||
| 40 | 45 | type residentLiveProjection struct { | |
| @@ -44,6 +49,19 @@ type residentLiveProjection struct { | |||
| 44 | 49 | ||
| 45 | 50 | func newResidentLiveProjection() *residentLiveProjection { return &residentLiveProjection{} } | |
| 46 | 51 | ||
| 52 | + | func (p *residentLiveProjection) SeedUsage(usage ResidentUsageSnapshot) { | |
| 53 | + | if p == nil { | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | p.mu.Lock() | |
| 57 | + | p.snapshot.Usage = usage.Usage | |
| 58 | + | p.snapshot.ContextUsed = usage.ContextUsed | |
| 59 | + | p.snapshot.ContextMax = usage.ContextMax | |
| 60 | + | p.snapshot.Subscription = usage.Subscription | |
| 61 | + | p.snapshot.Revision++ | |
| 62 | + | p.mu.Unlock() | |
| 63 | + | } | |
| 64 | + | ||
| 47 | 65 | func (p *residentLiveProjection) Start(turnID string) { | |
| 48 | 66 | if p == nil { | |
| 49 | 67 | return | |
| @@ -125,6 +143,11 @@ func (p *residentLiveProjection) Apply(event core.AgentEvent) { | |||
| 125 | 143 | case core.EvAssistantMessage: | |
| 126 | 144 | p.snapshot.WaitingForModel = false | |
| 127 | 145 | p.snapshot.AssistantText = "" | |
| 146 | + | case core.EvUsage: | |
| 147 | + | p.snapshot.Usage = value.Cumulative | |
| 148 | + | if current := value.Usage.PromptTokens(); current > 0 { | |
| 149 | + | p.snapshot.ContextUsed = current | |
| 150 | + | } | |
| 128 | 151 | default: | |
| 129 | 152 | changed = false | |
| 130 | 153 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "sync" | 4 | "sync" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/zut/packages/core" | 6 | "github.com/bnema/zut/packages/core" | ||
| 7 | + | "github.com/bnema/zut/packages/provider" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | const residentLiveArgumentBytes = 64 << 10 | 10 | const residentLiveArgumentBytes = 64 << 10 | ||
| @@ -35,6 +36,10 @@ type ResidentLiveSnapshot struct { | |||||
| 35 | WaitingForModel bool | 36 | WaitingForModel bool | ||
| 36 | AssistantText string | 37 | AssistantText string | ||
| 37 | Tools []ResidentLiveTool | 38 | Tools []ResidentLiveTool | ||
| 39 | + | Usage provider.Usage | |||
| 40 | + | ContextUsed int | |||
| 41 | + | ContextMax int | |||
| 42 | + | Subscription bool | |||
| 38 | } | 43 | } | ||
| 39 | 44 | ||||
| 40 | type residentLiveProjection struct { | 45 | type residentLiveProjection struct { | ||
| @@ -44,6 +49,19 @@ type residentLiveProjection struct { | |||||
| 44 | 49 | ||||
| 45 | func newResidentLiveProjection() *residentLiveProjection { return &residentLiveProjection{} } | 50 | func newResidentLiveProjection() *residentLiveProjection { return &residentLiveProjection{} } | ||
| 46 | 51 | ||||
| 52 | + | func (p *residentLiveProjection) SeedUsage(usage ResidentUsageSnapshot) { | |||
| 53 | + | if p == nil { | |||
| 54 | + | return | |||
| 55 | + | } | |||
| 56 | + | p.mu.Lock() | |||
| 57 | + | p.snapshot.Usage = usage.Usage | |||
| 58 | + | p.snapshot.ContextUsed = usage.ContextUsed | |||
| 59 | + | p.snapshot.ContextMax = usage.ContextMax | |||
| 60 | + | p.snapshot.Subscription = usage.Subscription | |||
| 61 | + | p.snapshot.Revision++ | |||
| 62 | + | p.mu.Unlock() | |||
| 63 | + | } | |||
| 64 | + | ||||
| 47 | func (p *residentLiveProjection) Start(turnID string) { | 65 | func (p *residentLiveProjection) Start(turnID string) { | ||
| 48 | if p == nil { | 66 | if p == nil { | ||
| 49 | return | 67 | return | ||
| @@ -125,6 +143,11 @@ func (p *residentLiveProjection) Apply(event core.AgentEvent) { | |||||
| 125 | case core.EvAssistantMessage: | 143 | case core.EvAssistantMessage: | ||
| 126 | p.snapshot.WaitingForModel = false | 144 | p.snapshot.WaitingForModel = false | ||
| 127 | p.snapshot.AssistantText = "" | 145 | p.snapshot.AssistantText = "" | ||
| 146 | + | case core.EvUsage: | |||
| 147 | + | p.snapshot.Usage = value.Cumulative | |||
| 148 | + | if current := value.Usage.PromptTokens(); current > 0 { | |||
| 149 | + | p.snapshot.ContextUsed = current | |||
| 150 | + | } | |||
| 128 | default: | 151 | default: | ||
| 129 | changed = false | 152 | changed = false | ||
| 130 | } | 153 | } | ||
M packages/agent/subagents/resident_projection_test.go +11 -0
| @@ -39,6 +39,17 @@ func TestResidentLiveProjectionPublishesCopiesAndFinalizesLiveItems(t *testing.T | |||
| 39 | 39 | } | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | func TestResidentLiveProjectionPreservesContextAcrossZeroUsage(t *testing.T) { | |
| 43 | + | projection := newResidentLiveProjection() | |
| 44 | + | usage := provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000} | |
| 45 | + | projection.Apply(core.EvUsage{Usage: usage, Cumulative: usage}) | |
| 46 | + | projection.Apply(core.EvUsage{Usage: provider.Usage{}, Cumulative: usage}) | |
| 47 | + | got := projection.Snapshot() | |
| 48 | + | if got.Usage != usage || got.ContextUsed != 207_000 { | |
| 49 | + | t.Fatalf("live usage = %#v, context = %d; want cumulative usage and preserved context", got.Usage, got.ContextUsed) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 42 | 53 | func TestResidentLiveProjectionBoundsToolArguments(t *testing.T) { | |
| 43 | 54 | projection := newResidentLiveProjection() | |
| 44 | 55 | projection.Start("turn-1") | |
| @@ -39,6 +39,17 @@ func TestResidentLiveProjectionPublishesCopiesAndFinalizesLiveItems(t *testing.T | |||||
| 39 | } | 39 | } | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | func TestResidentLiveProjectionPreservesContextAcrossZeroUsage(t *testing.T) { | |||
| 43 | + | projection := newResidentLiveProjection() | |||
| 44 | + | usage := provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000} | |||
| 45 | + | projection.Apply(core.EvUsage{Usage: usage, Cumulative: usage}) | |||
| 46 | + | projection.Apply(core.EvUsage{Usage: provider.Usage{}, Cumulative: usage}) | |||
| 47 | + | got := projection.Snapshot() | |||
| 48 | + | if got.Usage != usage || got.ContextUsed != 207_000 { | |||
| 49 | + | t.Fatalf("live usage = %#v, context = %d; want cumulative usage and preserved context", got.Usage, got.ContextUsed) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 42 | func TestResidentLiveProjectionBoundsToolArguments(t *testing.T) { | 53 | func TestResidentLiveProjectionBoundsToolArguments(t *testing.T) { | ||
| 43 | projection := newResidentLiveProjection() | 54 | projection := newResidentLiveProjection() | ||
| 44 | projection.Start("turn-1") | 55 | projection.Start("turn-1") | ||
M packages/provider/provider.go +16 -0
| @@ -180,6 +180,22 @@ type Usage struct { | |||
| 180 | 180 | CostUSD float64 `json:"cost_usd"` | |
| 181 | 181 | } | |
| 182 | 182 | ||
| 183 | + | // PromptTokens returns all input tokens, regardless of cache disposition. | |
| 184 | + | func (u Usage) PromptTokens() int { | |
| 185 | + | return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // CacheHitRatio returns the share of prompt tokens served from cache. The | |
| 189 | + | // boolean is false when the provider reported no cache reads, which avoids | |
| 190 | + | // presenting an unsupported cache metric as a zero-percent hit rate. | |
| 191 | + | func (u Usage) CacheHitRatio() (float64, bool) { | |
| 192 | + | total := u.PromptTokens() | |
| 193 | + | if total <= 0 || u.CacheReadTokens <= 0 { | |
| 194 | + | return 0, false | |
| 195 | + | } | |
| 196 | + | return float64(u.CacheReadTokens) / float64(total), true | |
| 197 | + | } | |
| 198 | + | ||
| 183 | 199 | // Add returns u plus v. A reasoning total is known only when both | |
| 184 | 200 | // component usage reports include a separate reasoning-token count. | |
| 185 | 201 | func (u Usage) Add(v Usage) Usage { | |
| @@ -180,6 +180,22 @@ type Usage struct { | |||||
| 180 | CostUSD float64 `json:"cost_usd"` | 180 | CostUSD float64 `json:"cost_usd"` | ||
| 181 | } | 181 | } | ||
| 182 | 182 | ||||
| 183 | + | // PromptTokens returns all input tokens, regardless of cache disposition. | |||
| 184 | + | func (u Usage) PromptTokens() int { | |||
| 185 | + | return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // CacheHitRatio returns the share of prompt tokens served from cache. The | |||
| 189 | + | // boolean is false when the provider reported no cache reads, which avoids | |||
| 190 | + | // presenting an unsupported cache metric as a zero-percent hit rate. | |||
| 191 | + | func (u Usage) CacheHitRatio() (float64, bool) { | |||
| 192 | + | total := u.PromptTokens() | |||
| 193 | + | if total <= 0 || u.CacheReadTokens <= 0 { | |||
| 194 | + | return 0, false | |||
| 195 | + | } | |||
| 196 | + | return float64(u.CacheReadTokens) / float64(total), true | |||
| 197 | + | } | |||
| 198 | + | ||||
| 183 | // Add returns u plus v. A reasoning total is known only when both | 199 | // Add returns u plus v. A reasoning total is known only when both | ||
| 184 | // component usage reports include a separate reasoning-token count. | 200 | // component usage reports include a separate reasoning-token count. | ||
| 185 | func (u Usage) Add(v Usage) Usage { | 201 | func (u Usage) Add(v Usage) Usage { | ||
M packages/provider/provider_test.go +17 -0
| @@ -11,6 +11,23 @@ import ( | |||
| 11 | 11 | "testing" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) { | |
| 15 | + | u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000} | |
| 16 | + | if got := u.PromptTokens(); got != 210_000 { | |
| 17 | + | t.Fatalf("prompt tokens = %d, want 210000", got) | |
| 18 | + | } | |
| 19 | + | ratio, ok := u.CacheHitRatio() | |
| 20 | + | if !ok || math.Abs(ratio-123.0/210.0) > 1e-9 { | |
| 21 | + | t.Fatalf("cache hit ratio = %v, %v, want %v, true", ratio, ok, 123.0/210.0) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestUsageCacheHitRatioUnavailableWithoutCacheReads(t *testing.T) { | |
| 26 | + | if ratio, ok := (Usage{InputTokens: 84_000}).CacheHitRatio(); ok || ratio != 0 { | |
| 27 | + | t.Fatalf("cache hit ratio = %v, %v, want 0, false", ratio, ok) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 14 | 31 | func TestSSEParse(t *testing.T) { | |
| 15 | 32 | r := strings.NewReader("event: foo\ndata: {\"a\":1}\n\ndata: hello\ndata: world\n\n") | |
| 16 | 33 | ch := make(chan sseEvent, 4) | |
| @@ -11,6 +11,23 @@ import ( | |||||
| 11 | "testing" | 11 | "testing" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | + | func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) { | |||
| 15 | + | u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000} | |||
| 16 | + | if got := u.PromptTokens(); got != 210_000 { | |||
| 17 | + | t.Fatalf("prompt tokens = %d, want 210000", got) | |||
| 18 | + | } | |||
| 19 | + | ratio, ok := u.CacheHitRatio() | |||
| 20 | + | if !ok || math.Abs(ratio-123.0/210.0) > 1e-9 { | |||
| 21 | + | t.Fatalf("cache hit ratio = %v, %v, want %v, true", ratio, ok, 123.0/210.0) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestUsageCacheHitRatioUnavailableWithoutCacheReads(t *testing.T) { | |||
| 26 | + | if ratio, ok := (Usage{InputTokens: 84_000}).CacheHitRatio(); ok || ratio != 0 { | |||
| 27 | + | t.Fatalf("cache hit ratio = %v, %v, want 0, false", ratio, ok) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 14 | func TestSSEParse(t *testing.T) { | 31 | func TestSSEParse(t *testing.T) { | ||
| 15 | r := strings.NewReader("event: foo\ndata: {\"a\":1}\n\ndata: hello\ndata: world\n\n") | 32 | r := strings.NewReader("event: foo\ndata: {\"a\":1}\n\ndata: hello\ndata: world\n\n") | ||
| 16 | ch := make(chan sseEvent, 4) | 33 | ch := make(chan sseEvent, 4) | ||
M packages/tui/statusbar_test.go +30 -0
| @@ -7,6 +7,36 @@ import ( | |||
| 7 | 7 | "github.com/bnema/zut/packages/provider" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestStatusBarShowsCacheHitRatio(t *testing.T) { | |
| 11 | + | lines := StatusBar(StatusBarParams{ | |
| 12 | + | Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200, | |
| 13 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000}, | |
| 14 | + | ContextUsed: 45_152, ContextMax: 272_000, | |
| 15 | + | }) | |
| 16 | + | plain := stripANSI(strings.Join(lines, "\n")) | |
| 17 | + | if !strings.Contains(plain, "↑84k ↓1.5k R123k/ C59.4%") { | |
| 18 | + | t.Fatalf("status bar = %q, want compact cache hit ratio", plain) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) { | |
| 23 | + | const cols = 30 | |
| 24 | + | lines := StatusBar(StatusBarParams{ | |
| 25 | + | Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols, | |
| 26 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525}, | |
| 27 | + | ContextUsed: 209_000, ContextMax: 272_000, | |
| 28 | + | }) | |
| 29 | + | plain := stripANSI(strings.Join(lines, "\n")) | |
| 30 | + | if !strings.Contains(plain, "C58.9%") { | |
| 31 | + | t.Fatalf("narrow status bar = %q, want cache hit ratio", plain) | |
| 32 | + | } | |
| 33 | + | for _, line := range lines { | |
| 34 | + | if width := visibleWidth(line); width > cols { | |
| 35 | + | t.Fatalf("narrow status line width = %d, want <= %d: %q", width, cols, stripANSI(line)) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 10 | 40 | func TestStatusBarShowsActiveGoal(t *testing.T) { | |
| 11 | 41 | params := StatusBarParams{ | |
| 12 | 42 | Theme: Dark, | |
| @@ -7,6 +7,36 @@ import ( | |||||
| 7 | "github.com/bnema/zut/packages/provider" | 7 | "github.com/bnema/zut/packages/provider" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | func TestStatusBarShowsCacheHitRatio(t *testing.T) { | |||
| 11 | + | lines := StatusBar(StatusBarParams{ | |||
| 12 | + | Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200, | |||
| 13 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000}, | |||
| 14 | + | ContextUsed: 45_152, ContextMax: 272_000, | |||
| 15 | + | }) | |||
| 16 | + | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 17 | + | if !strings.Contains(plain, "↑84k ↓1.5k R123k/ C59.4%") { | |||
| 18 | + | t.Fatalf("status bar = %q, want compact cache hit ratio", plain) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) { | |||
| 23 | + | const cols = 30 | |||
| 24 | + | lines := StatusBar(StatusBarParams{ | |||
| 25 | + | Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols, | |||
| 26 | + | Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525}, | |||
| 27 | + | ContextUsed: 209_000, ContextMax: 272_000, | |||
| 28 | + | }) | |||
| 29 | + | plain := stripANSI(strings.Join(lines, "\n")) | |||
| 30 | + | if !strings.Contains(plain, "C58.9%") { | |||
| 31 | + | t.Fatalf("narrow status bar = %q, want cache hit ratio", plain) | |||
| 32 | + | } | |||
| 33 | + | for _, line := range lines { | |||
| 34 | + | if width := visibleWidth(line); width > cols { | |||
| 35 | + | t.Fatalf("narrow status line width = %d, want <= %d: %q", width, cols, stripANSI(line)) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 10 | func TestStatusBarShowsActiveGoal(t *testing.T) { | 40 | func TestStatusBarShowsActiveGoal(t *testing.T) { | ||
| 11 | params := StatusBarParams{ | 41 | params := StatusBarParams{ | ||
| 12 | Theme: Dark, | 42 | Theme: Dark, | ||
A packages/tui/usage.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/provider" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // UsageStatsParams describes cumulative usage shown by compact status surfaces. | |
| 10 | + | type UsageStatsParams struct { | |
| 11 | + | Usage provider.Usage | |
| 12 | + | Subscription bool | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // UsageStatsParts returns compact, provider-neutral token and cost labels. | |
| 16 | + | func UsageStatsParts(p UsageStatsParams) []string { | |
| 17 | + | var parts []string | |
| 18 | + | if p.Usage.InputTokens > 0 { | |
| 19 | + | parts = append(parts, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens))) | |
| 20 | + | } | |
| 21 | + | if p.Usage.OutputTokens > 0 { | |
| 22 | + | parts = append(parts, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens))) | |
| 23 | + | } | |
| 24 | + | if p.Usage.CacheReadTokens > 0 { | |
| 25 | + | parts = append(parts, fmt.Sprintf("R%s/", formatTokens(p.Usage.CacheReadTokens))) | |
| 26 | + | } | |
| 27 | + | if ratio, ok := p.Usage.CacheHitRatio(); ok { | |
| 28 | + | parts = append(parts, fmt.Sprintf("C%.1f%%", ratio*100)) | |
| 29 | + | } | |
| 30 | + | if p.Usage.CacheWriteTokens > 0 { | |
| 31 | + | parts = append(parts, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens))) | |
| 32 | + | } | |
| 33 | + | if p.Usage.CostUSD > 0 || p.Subscription { | |
| 34 | + | cost := fmt.Sprintf("$%.3f", p.Usage.CostUSD) | |
| 35 | + | if p.Subscription { | |
| 36 | + | cost += " (sub)" | |
| 37 | + | } | |
| 38 | + | parts = append(parts, cost) | |
| 39 | + | } | |
| 40 | + | return parts | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // ContextUsageText returns the latest request's context utilization without | |
| 44 | + | // styling so callers can safely measure, truncate, and color the whole line. | |
| 45 | + | func ContextUsageText(used, max int) string { | |
| 46 | + | label, _ := contextUsage(Theme{}, used, max) | |
| 47 | + | return label | |
| 48 | + | } | |
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zut/packages/provider" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // UsageStatsParams describes cumulative usage shown by compact status surfaces. | |||
| 10 | + | type UsageStatsParams struct { | |||
| 11 | + | Usage provider.Usage | |||
| 12 | + | Subscription bool | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // UsageStatsParts returns compact, provider-neutral token and cost labels. | |||
| 16 | + | func UsageStatsParts(p UsageStatsParams) []string { | |||
| 17 | + | var parts []string | |||
| 18 | + | if p.Usage.InputTokens > 0 { | |||
| 19 | + | parts = append(parts, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens))) | |||
| 20 | + | } | |||
| 21 | + | if p.Usage.OutputTokens > 0 { | |||
| 22 | + | parts = append(parts, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens))) | |||
| 23 | + | } | |||
| 24 | + | if p.Usage.CacheReadTokens > 0 { | |||
| 25 | + | parts = append(parts, fmt.Sprintf("R%s/", formatTokens(p.Usage.CacheReadTokens))) | |||
| 26 | + | } | |||
| 27 | + | if ratio, ok := p.Usage.CacheHitRatio(); ok { | |||
| 28 | + | parts = append(parts, fmt.Sprintf("C%.1f%%", ratio*100)) | |||
| 29 | + | } | |||
| 30 | + | if p.Usage.CacheWriteTokens > 0 { | |||
| 31 | + | parts = append(parts, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens))) | |||
| 32 | + | } | |||
| 33 | + | if p.Usage.CostUSD > 0 || p.Subscription { | |||
| 34 | + | cost := fmt.Sprintf("$%.3f", p.Usage.CostUSD) | |||
| 35 | + | if p.Subscription { | |||
| 36 | + | cost += " (sub)" | |||
| 37 | + | } | |||
| 38 | + | parts = append(parts, cost) | |||
| 39 | + | } | |||
| 40 | + | return parts | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // ContextUsageText returns the latest request's context utilization without | |||
| 44 | + | // styling so callers can safely measure, truncate, and color the whole line. | |||
| 45 | + | func ContextUsageText(used, max int) string { | |||
| 46 | + | label, _ := contextUsage(Theme{}, used, max) | |||
| 47 | + | return label | |||
| 48 | + | } | |||
M packages/tui/view.go +32 -39
| @@ -2897,36 +2897,7 @@ type StatusBarParams struct { | |||
| 2897 | 2897 | func StatusBar(p StatusBarParams) []string { | |
| 2898 | 2898 | th := p.Theme | |
| 2899 | 2899 | ||
| 2900 | - | // Token stats: only include each segment when non-zero. Keeps | |
| 2901 | - | // the bar compact on brand-new sessions. | |
| 2902 | - | var stats []string | |
| 2903 | - | if p.Usage.InputTokens > 0 { | |
| 2904 | - | stats = append(stats, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens))) | |
| 2905 | - | } | |
| 2906 | - | if p.Usage.OutputTokens > 0 { | |
| 2907 | - | stats = append(stats, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens))) | |
| 2908 | - | } | |
| 2909 | - | if p.Usage.CacheReadTokens > 0 { | |
| 2910 | - | stats = append(stats, fmt.Sprintf("R%s", formatTokens(p.Usage.CacheReadTokens))) | |
| 2911 | - | } | |
| 2912 | - | if p.Usage.CacheWriteTokens > 0 { | |
| 2913 | - | stats = append(stats, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens))) | |
| 2914 | - | } | |
| 2915 | - | ||
| 2916 | - | // Cost: always show the dollar value computed from token counts, | |
| 2917 | - | // even on subscription. Lets you see what the equivalent api cost | |
| 2918 | - | // would be (handy for gauging subscription value). Append "(sub)" | |
| 2919 | - | // only as a hint that no real money moved. | |
| 2920 | - | var costStr string | |
| 2921 | - | if p.Usage.CostUSD > 0 || p.Subscription { | |
| 2922 | - | costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD) | |
| 2923 | - | if p.Subscription { | |
| 2924 | - | costStr += " (sub)" | |
| 2925 | - | } | |
| 2926 | - | } | |
| 2927 | - | if costStr != "" { | |
| 2928 | - | stats = append(stats, costStr) | |
| 2929 | - | } | |
| 2900 | + | stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription}) | |
| 2930 | 2901 | if p.GoalStatus != "" { | |
| 2931 | 2902 | stats = append(stats, "goal:"+p.GoalStatus) | |
| 2932 | 2903 | } | |
| @@ -3020,7 +2991,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3020 | 2991 | modelLine := pad + th.FGColor(th.Muted, left) | |
| 3021 | 2992 | lines := []string{busyLine} | |
| 3022 | 2993 | if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols { | |
| 3023 | - | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols) | |
| 2994 | + | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols) | |
| 3024 | 2995 | } else { | |
| 3025 | 2996 | var infoBuilder strings.Builder | |
| 3026 | 2997 | infoBuilder.WriteString(modelLine) | |
| @@ -3042,7 +3013,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3042 | 3013 | // into an awkward position on small widths. | |
| 3043 | 3014 | if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols { | |
| 3044 | 3015 | var lines []string | |
| 3045 | - | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols) | |
| 3016 | + | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols) | |
| 3046 | 3017 | if cwd != "" { | |
| 3047 | 3018 | lines = append(lines, pad+th.FGColor(th.Muted, cwd)) | |
| 3048 | 3019 | } | |
| @@ -3059,7 +3030,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3059 | 3030 | return []string{primary, cwdRendered} | |
| 3060 | 3031 | } | |
| 3061 | 3032 | ||
| 3062 | - | func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string { | |
| 3033 | + | func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText string, stats []string, cols int) []string { | |
| 3063 | 3034 | modelLine := pad + th.FGColor(th.Muted, modelText) | |
| 3064 | 3035 | infoParts := make([]string, 0, 2) | |
| 3065 | 3036 | if fastText != "" { | |
| @@ -3071,10 +3042,7 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText | |||
| 3071 | 3042 | infoText := strings.Join(infoParts, pad) | |
| 3072 | 3043 | if infoText == "" { | |
| 3073 | 3044 | lines = append(lines, modelLine) | |
| 3074 | - | if statsText != "" { | |
| 3075 | - | lines = append(lines, pad+th.FGColor(th.Muted, statsText)) | |
| 3076 | - | } | |
| 3077 | - | return lines | |
| 3045 | + | return appendWrappedUsageStats(lines, th, pad, stats, cols) | |
| 3078 | 3046 | } | |
| 3079 | 3047 | ||
| 3080 | 3048 | modelInfoPlain := pad + modelText + pad + infoText | |
| @@ -3093,9 +3061,34 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText | |||
| 3093 | 3061 | lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText)) | |
| 3094 | 3062 | } | |
| 3095 | 3063 | } | |
| 3096 | - | if statsText != "" { | |
| 3097 | - | lines = append(lines, pad+th.FGColor(th.Muted, statsText)) | |
| 3064 | + | return appendWrappedUsageStats(lines, th, pad, stats, cols) | |
| 3065 | + | } | |
| 3066 | + | ||
| 3067 | + | func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []string, cols int) []string { | |
| 3068 | + | if len(stats) == 0 { | |
| 3069 | + | return lines | |
| 3070 | + | } | |
| 3071 | + | available := cols - visibleWidth(pad) | |
| 3072 | + | if cols <= 0 || available <= 0 { | |
| 3073 | + | return append(lines, pad+th.FGColor(th.Muted, strings.Join(stats, " "))) | |
| 3074 | + | } | |
| 3075 | + | current := make([]string, 0, len(stats)) | |
| 3076 | + | flush := func() { | |
| 3077 | + | if len(current) == 0 { | |
| 3078 | + | return | |
| 3079 | + | } | |
| 3080 | + | line := pad + th.FGColor(th.Muted, strings.Join(current, " ")) | |
| 3081 | + | lines = append(lines, truncateToWidth(line, cols)) | |
| 3082 | + | current = current[:0] | |
| 3083 | + | } | |
| 3084 | + | for _, stat := range stats { | |
| 3085 | + | candidate := append(append([]string(nil), current...), stat) | |
| 3086 | + | if len(current) > 0 && visibleWidth(strings.Join(candidate, " ")) > available { | |
| 3087 | + | flush() | |
| 3088 | + | } | |
| 3089 | + | current = append(current, stat) | |
| 3098 | 3090 | } | |
| 3091 | + | flush() | |
| 3099 | 3092 | return lines | |
| 3100 | 3093 | } | |
| 3101 | 3094 | ||
| @@ -2897,36 +2897,7 @@ type StatusBarParams struct { | |||||
| 2897 | func StatusBar(p StatusBarParams) []string { | 2897 | func StatusBar(p StatusBarParams) []string { | ||
| 2898 | th := p.Theme | 2898 | th := p.Theme | ||
| 2899 | 2899 | ||||
| 2900 | - | // Token stats: only include each segment when non-zero. Keeps | 2900 | + | stats := UsageStatsParts(UsageStatsParams{Usage: p.Usage, Subscription: p.Subscription}) |
| 2901 | - | // the bar compact on brand-new sessions. | |||
| 2902 | - | var stats []string | |||
| 2903 | - | if p.Usage.InputTokens > 0 { | |||
| 2904 | - | stats = append(stats, fmt.Sprintf("↑%s", formatTokens(p.Usage.InputTokens))) | |||
| 2905 | - | } | |||
| 2906 | - | if p.Usage.OutputTokens > 0 { | |||
| 2907 | - | stats = append(stats, fmt.Sprintf("↓%s", formatTokens(p.Usage.OutputTokens))) | |||
| 2908 | - | } | |||
| 2909 | - | if p.Usage.CacheReadTokens > 0 { | |||
| 2910 | - | stats = append(stats, fmt.Sprintf("R%s", formatTokens(p.Usage.CacheReadTokens))) | |||
| 2911 | - | } | |||
| 2912 | - | if p.Usage.CacheWriteTokens > 0 { | |||
| 2913 | - | stats = append(stats, fmt.Sprintf("W%s", formatTokens(p.Usage.CacheWriteTokens))) | |||
| 2914 | - | } | |||
| 2915 | - | ||||
| 2916 | - | // Cost: always show the dollar value computed from token counts, | |||
| 2917 | - | // even on subscription. Lets you see what the equivalent api cost | |||
| 2918 | - | // would be (handy for gauging subscription value). Append "(sub)" | |||
| 2919 | - | // only as a hint that no real money moved. | |||
| 2920 | - | var costStr string | |||
| 2921 | - | if p.Usage.CostUSD > 0 || p.Subscription { | |||
| 2922 | - | costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD) | |||
| 2923 | - | if p.Subscription { | |||
| 2924 | - | costStr += " (sub)" | |||
| 2925 | - | } | |||
| 2926 | - | } | |||
| 2927 | - | if costStr != "" { | |||
| 2928 | - | stats = append(stats, costStr) | |||
| 2929 | - | } | |||
| 2930 | if p.GoalStatus != "" { | 2901 | if p.GoalStatus != "" { | ||
| 2931 | stats = append(stats, "goal:"+p.GoalStatus) | 2902 | stats = append(stats, "goal:"+p.GoalStatus) | ||
| 2932 | } | 2903 | } | ||
| @@ -3020,7 +2991,7 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 3020 | modelLine := pad + th.FGColor(th.Muted, left) | 2991 | modelLine := pad + th.FGColor(th.Muted, left) | ||
| 3021 | lines := []string{busyLine} | 2992 | lines := []string{busyLine} | ||
| 3022 | if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols { | 2993 | if middle != "" && visibleWidth(modelLine+pad+th.FGColor(th.Muted, middle)) > p.Cols { | ||
| 3023 | - | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols) | 2994 | + | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols) |
| 3024 | } else { | 2995 | } else { | ||
| 3025 | var infoBuilder strings.Builder | 2996 | var infoBuilder strings.Builder | ||
| 3026 | infoBuilder.WriteString(modelLine) | 2997 | infoBuilder.WriteString(modelLine) | ||
| @@ -3042,7 +3013,7 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 3042 | // into an awkward position on small widths. | 3013 | // into an awkward position on small widths. | ||
| 3043 | if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols { | 3014 | if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols { | ||
| 3044 | var lines []string | 3015 | var lines []string | ||
| 3045 | - | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, statsText, p.Cols) | 3016 | + | lines = appendWrappedStatusLines(lines, th, pad, left, fastText, reasoningText, stats, p.Cols) |
| 3046 | if cwd != "" { | 3017 | if cwd != "" { | ||
| 3047 | lines = append(lines, pad+th.FGColor(th.Muted, cwd)) | 3018 | lines = append(lines, pad+th.FGColor(th.Muted, cwd)) | ||
| 3048 | } | 3019 | } | ||
| @@ -3059,7 +3030,7 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 3059 | return []string{primary, cwdRendered} | 3030 | return []string{primary, cwdRendered} | ||
| 3060 | } | 3031 | } | ||
| 3061 | 3032 | ||||
| 3062 | - | func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText, statsText string, cols int) []string { | 3033 | + | func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText, reasoningText string, stats []string, cols int) []string { |
| 3063 | modelLine := pad + th.FGColor(th.Muted, modelText) | 3034 | modelLine := pad + th.FGColor(th.Muted, modelText) | ||
| 3064 | infoParts := make([]string, 0, 2) | 3035 | infoParts := make([]string, 0, 2) | ||
| 3065 | if fastText != "" { | 3036 | if fastText != "" { | ||
| @@ -3071,10 +3042,7 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText | |||||
| 3071 | infoText := strings.Join(infoParts, pad) | 3042 | infoText := strings.Join(infoParts, pad) | ||
| 3072 | if infoText == "" { | 3043 | if infoText == "" { | ||
| 3073 | lines = append(lines, modelLine) | 3044 | lines = append(lines, modelLine) | ||
| 3074 | - | if statsText != "" { | 3045 | + | return appendWrappedUsageStats(lines, th, pad, stats, cols) |
| 3075 | - | lines = append(lines, pad+th.FGColor(th.Muted, statsText)) | |||
| 3076 | - | } | |||
| 3077 | - | return lines | |||
| 3078 | } | 3046 | } | ||
| 3079 | 3047 | ||||
| 3080 | modelInfoPlain := pad + modelText + pad + infoText | 3048 | modelInfoPlain := pad + modelText + pad + infoText | ||
| @@ -3093,9 +3061,34 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText | |||||
| 3093 | lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText)) | 3061 | lines = append(lines, pad+th.FGColor(reasoningStatusColor(th, reasoningText), reasoningText)) | ||
| 3094 | } | 3062 | } | ||
| 3095 | } | 3063 | } | ||
| 3096 | - | if statsText != "" { | 3064 | + | return appendWrappedUsageStats(lines, th, pad, stats, cols) |
| 3097 | - | lines = append(lines, pad+th.FGColor(th.Muted, statsText)) | 3065 | + | } |
| 3066 | + | ||||
| 3067 | + | func appendWrappedUsageStats(lines []string, th Theme, pad string, stats []string, cols int) []string { | |||
| 3068 | + | if len(stats) == 0 { | |||
| 3069 | + | return lines | |||
| 3070 | + | } | |||
| 3071 | + | available := cols - visibleWidth(pad) | |||
| 3072 | + | if cols <= 0 || available <= 0 { | |||
| 3073 | + | return append(lines, pad+th.FGColor(th.Muted, strings.Join(stats, " "))) | |||
| 3074 | + | } | |||
| 3075 | + | current := make([]string, 0, len(stats)) | |||
| 3076 | + | flush := func() { | |||
| 3077 | + | if len(current) == 0 { | |||
| 3078 | + | return | |||
| 3079 | + | } | |||
| 3080 | + | line := pad + th.FGColor(th.Muted, strings.Join(current, " ")) | |||
| 3081 | + | lines = append(lines, truncateToWidth(line, cols)) | |||
| 3082 | + | current = current[:0] | |||
| 3083 | + | } | |||
| 3084 | + | for _, stat := range stats { | |||
| 3085 | + | candidate := append(append([]string(nil), current...), stat) | |||
| 3086 | + | if len(current) > 0 && visibleWidth(strings.Join(candidate, " ")) > available { | |||
| 3087 | + | flush() | |||
| 3088 | + | } | |||
| 3089 | + | current = append(current, stat) | |||
| 3098 | } | 3090 | } | ||
| 3091 | + | flush() | |||
| 3099 | return lines | 3092 | return lines | ||
| 3100 | } | 3093 | } | ||
| 3101 | 3094 | ||||