fix(agentctl): sticky-max context window size with reset on model switch (#1254)

This commit is contained in:
Carlos Florêncio
2026-06-02 22:58:31 +01:00
committed by GitHub
parent dfaab11959
commit 3e2aba45ed
4 changed files with 149 additions and 25 deletions
@@ -525,6 +525,7 @@ func (a *Adapter) SetModel(ctx context.Context, modelID string) error {
if err != nil {
return fmt.Errorf("set session model failed: %w", err)
}
a.resetContextWindowMaxSize(sessionID)
a.emitSetModelEvent(sessionID, modelID, available, cachedConfig)
return nil
}
@@ -270,27 +270,50 @@ type acpUsageUpdate struct {
type usageTracker struct {
lastUsed int64
lastCostSubcents int64
// maxSize is the largest context-window size reported for this session.
// claude-acp's default model emits a 200K turn-start frame and a 1M
// turn-end frame; sticky-max prevents the stale 200K from shrinking
// the displayed window after the authoritative 1M frame lands.
maxSize int64
}
// recordUsageDelta updates the per-session tracker. Returns the
// integer delta against the previous `used` snapshot — the next prompt
// complete call consumes this via consumeUsageDelta. Cost is forwarded
// as the latest value (claude-acp emits a per-turn cumulative cost; we
// treat the most recent value as the turn's cost).
func (a *Adapter) recordUsageDelta(sessionID string, used int64, costSubcents int64) {
a.mu.Lock()
defer a.mu.Unlock()
// ensureUsageTracker returns the per-session usage tracker, creating it if
// needed. Callers must hold a.mu (write lock).
func (a *Adapter) ensureUsageTracker(sessionID string) *usageTracker {
tr := a.usageBySession[sessionID]
if tr == nil {
tr = &usageTracker{}
a.usageBySession[sessionID] = tr
}
return tr
}
// recordUsageAndMaxSize updates per-session usage/cost tracking and returns
// the sticky-max context window size for the session.
func (a *Adapter) recordUsageAndMaxSize(sessionID string, size, used, costSubcents int64) int64 {
a.mu.Lock()
defer a.mu.Unlock()
tr := a.ensureUsageTracker(sessionID)
if size > tr.maxSize {
tr.maxSize = size
}
if used > tr.lastUsed {
tr.lastUsed = used
}
if costSubcents > 0 {
tr.lastCostSubcents = costSubcents
}
return tr.maxSize
}
// resetContextWindowMaxSize clears the sticky-max window for a session so the
// next usage_update can establish the new model's window after SetModel.
func (a *Adapter) resetContextWindowMaxSize(sessionID string) {
a.mu.Lock()
defer a.mu.Unlock()
if tr := a.usageBySession[sessionID]; tr != nil {
tr.maxSize = 0
}
}
// consumeUsageDelta returns the delta of cumulative `used` since the
@@ -339,16 +362,16 @@ func (a *Adapter) tryConvertUntypedUpdate(rawNotification []byte, sessionID stri
if usage.Cost != nil {
costSubcents = int64(usage.Cost.Amount * 10000)
}
a.recordUsageDelta(sessionID, usage.Used, costSubcents)
effectiveSize := a.recordUsageAndMaxSize(sessionID, usage.Size, usage.Used, costSubcents)
remaining := max(usage.Size-usage.Used, 0)
remaining := max(effectiveSize-usage.Used, 0)
return &AgentEvent{
Type: streams.EventTypeContextWindow,
SessionID: sessionID,
ContextWindowSize: usage.Size,
ContextWindowSize: effectiveSize,
ContextWindowUsed: usage.Used,
ContextWindowRemaining: remaining,
ContextEfficiency: float64(usage.Used) / float64(usage.Size) * 100,
ContextEfficiency: float64(usage.Used) / float64(effectiveSize) * 100,
}
}
@@ -1,6 +1,7 @@
package acp
import (
"fmt"
"testing"
"github.com/coder/acp-go-sdk"
@@ -891,6 +892,97 @@ func TestTryConvertUntypedUpdate_ZeroSize(t *testing.T) {
}
}
func usageUpdateRaw(size, used int64) []byte {
return []byte(fmt.Sprintf(
`{"sessionId":"s1","update":{"sessionUpdate":"usage_update","size":%d,"used":%d}}`,
size, used,
))
}
func TestTryConvertUntypedUpdate_StickyMaxSize(t *testing.T) {
t.Run("default turn raises max from 200K to 1M", func(t *testing.T) {
a := newTestAdapter()
first := a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 5_000), "s1")
if first == nil || first.ContextWindowSize != 200_000 {
t.Fatalf("first size = %d, want 200000", sizeOrZero(first))
}
second := a.tryConvertUntypedUpdate(usageUpdateRaw(1_000_000, 5_000), "s1")
if second == nil || second.ContextWindowSize != 1_000_000 {
t.Fatalf("second size = %d, want 1000000", sizeOrZero(second))
}
})
t.Run("stale 200K start-frame cannot shrink after 1M end-frame", func(t *testing.T) {
a := newTestAdapter()
_, _ = a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 5_000), "s1"), a.tryConvertUntypedUpdate(usageUpdateRaw(1_000_000, 5_000), "s1")
stale := a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 233_900), "s1")
if stale == nil {
t.Fatal("expected non-nil stale frame result")
}
if stale.ContextWindowSize != 1_000_000 {
t.Errorf("ContextWindowSize = %d, want 1000000", stale.ContextWindowSize)
}
expectedEff := float64(233_900) / float64(1_000_000) * 100
if stale.ContextEfficiency != expectedEff {
t.Errorf("ContextEfficiency = %f, want %f", stale.ContextEfficiency, expectedEff)
}
if stale.ContextWindowRemaining != 766_100 {
t.Errorf("ContextWindowRemaining = %d, want 766100", stale.ContextWindowRemaining)
}
})
t.Run("sonnet stays at 200K", func(t *testing.T) {
a := newTestAdapter()
first := a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 5_000), "s1")
second := a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 7_000), "s1")
if first.ContextWindowSize != 200_000 || second.ContextWindowSize != 200_000 {
t.Fatalf("sizes = %d, %d; want 200000 both", first.ContextWindowSize, second.ContextWindowSize)
}
})
t.Run("sonnet[1m] is 1M from first frame", func(t *testing.T) {
a := newTestAdapter()
result := a.tryConvertUntypedUpdate(usageUpdateRaw(1_000_000, 5_000), "s1")
if result == nil || result.ContextWindowSize != 1_000_000 {
t.Fatalf("size = %d, want 1000000", sizeOrZero(result))
}
})
t.Run("sessions track max independently", func(t *testing.T) {
a := newTestAdapter()
_, _ = a.tryConvertUntypedUpdate(usageUpdateRaw(1_000_000, 5_000), "s1"), a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 5_000), "s2")
s1 := a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 10_000), "s1")
s2 := a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 10_000), "s2")
if s1.ContextWindowSize != 1_000_000 {
t.Errorf("s1 size = %d, want 1000000", s1.ContextWindowSize)
}
if s2.ContextWindowSize != 200_000 {
t.Errorf("s2 size = %d, want 200000", s2.ContextWindowSize)
}
})
t.Run("reset after model switch allows downshift to 200K", func(t *testing.T) {
a := newTestAdapter()
_, _ = a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 5_000), "s1"), a.tryConvertUntypedUpdate(usageUpdateRaw(1_000_000, 5_000), "s1")
a.resetContextWindowMaxSize("s1")
afterSwitch := a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 26_000), "s1")
if afterSwitch == nil || afterSwitch.ContextWindowSize != 200_000 {
t.Fatalf("after switch size = %d, want 200000", sizeOrZero(afterSwitch))
}
})
}
func sizeOrZero(ev *AgentEvent) int64 {
if ev == nil {
return 0
}
return ev.ContextWindowSize
}
// --- emitInitialModeState ---
func TestEmitInitialModeState(t *testing.T) {
@@ -1,6 +1,7 @@
package acp
import (
"fmt"
"testing"
"github.com/coder/acp-go-sdk"
@@ -156,16 +157,17 @@ func TestExtractUsage(t *testing.T) {
}
// TestUsageTracker_CumulativeDelta asserts the codex-acp fallback path:
// usage_update updates the cumulative used counter; consumeUsageDelta
// returns the running total and resets to zero so the next turn starts
// fresh. Cost (when present) is forwarded as the latest snapshot.
// usage_update updates the cumulative used counter via tryConvertUntypedUpdate;
// consumeUsageDelta returns the running total and resets to zero so the next
// turn starts fresh.
func TestUsageTracker_CumulativeDelta(t *testing.T) {
a := newTestAdapter()
const sess = "sess-codex"
a.recordUsageDelta("sess-codex", 100, 0)
a.recordUsageDelta("sess-codex", 350, 0)
a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 100), sess)
a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 350), sess)
delta, cost := a.consumeUsageDelta("sess-codex")
delta, cost := a.consumeUsageDelta(sess)
if delta != 350 {
t.Errorf("first consume delta = %d, want 350", delta)
}
@@ -173,9 +175,8 @@ func TestUsageTracker_CumulativeDelta(t *testing.T) {
t.Errorf("first consume cost = %d, want 0 (no cost reported)", cost)
}
// After consume the tracker resets; new turn starts from 0.
a.recordUsageDelta("sess-codex", 200, 0)
delta, _ = a.consumeUsageDelta("sess-codex")
a.tryConvertUntypedUpdate(usageUpdateRaw(200_000, 200), sess)
delta, _ = a.consumeUsageDelta(sess)
if delta != 200 {
t.Errorf("second consume delta = %d, want 200 (reset baseline)", delta)
}
@@ -186,20 +187,27 @@ func TestUsageTracker_CumulativeDelta(t *testing.T) {
// recent value; consume returns it and resets.
func TestUsageTracker_ForwardsCost(t *testing.T) {
a := newTestAdapter()
const sess = "sess-claude"
a.recordUsageDelta("sess-claude", 25068, 615) // 0.06156125 USD -> 616 (rounded)
_, cost := a.consumeUsageDelta("sess-claude")
a.tryConvertUntypedUpdate(usageUpdateCostRaw(200_000, 25_068, 0.06156125), sess)
_, cost := a.consumeUsageDelta(sess)
if cost != 615 {
t.Errorf("cost = %d, want 615 (subcents)", cost)
}
// Second consume after no updates returns zero (reset).
_, cost = a.consumeUsageDelta("sess-claude")
_, cost = a.consumeUsageDelta(sess)
if cost != 0 {
t.Errorf("second consume cost = %d, want 0", cost)
}
}
func usageUpdateCostRaw(size, used int64, costUSD float64) []byte {
return []byte(fmt.Sprintf(
`{"sessionId":"s1","update":{"sessionUpdate":"usage_update","size":%d,"used":%d,"cost":{"amount":%g,"currency":"USD"}}}`,
size, used, costUSD,
))
}
// TestConsumeUsageDelta_UnknownSession returns zero for sessions that
// never recorded usage (e.g. claude-acp where the typed resp.Usage is
// the authoritative path and the tracker was never touched).