#1115·cc-connect

auto_compress estimateTokens ignores system prompt / tools overhead + idle reset never fires due to Unlock() updating UpdatedAt

Author: kevinsutianxingCreated May 25, 2026Updated Sep 14, 2026
LabelsbugP2triagedstalebug-confirmedarea-core

Bug 1: estimateTokens only counts history content, ignoring system prompt / tools / skills / memory overhead

Description

The auto_compress feature triggers when estimated token usage exceeds max_tokens. However, estimateTokensWithPendingAssistant (in core/engine.go, line 572) only sums the character length of history entries and any pending assistant response, then divides by 4 as a rough heuristic:

go
func estimateTokensWithPendingAssistant(entries []HistoryEntry, pendingAssistant string) int {
    count := 0
    for _, h := range entries {
        count += len([]rune(h.Content))
    }
    if pendingAssistant != "" {
        count += len([]rune(pendingAssistant))
    }
    if count == 0 {
        return 0
    }
    return (count + 3) / 4
}

The problem: this estimate ignores the large fixed token cost of system prompts, tool definitions (tool_use), skills, memory injection, and other per-turn overhead that the upstream agent (Claude Code, Codex, etc.) injects into each API call. In practice, these can consume ~60K tokens per turn.

With max_tokens = 15000, auto-compress only triggers when history content alone reaches 15K tokens. But by that point, the actual total context (history + system prompt + tools + skills + memory) is already ~75K tokens, well past the model's useful context window. The compress fires too late.

Steps to Reproduce

  1. Configure auto_compress with max_tokens = 15000.
  2. Use a cc-connect session with Claude Code (which injects ~60K tokens of system prompt / tools / skills / memory per turn).
  3. Have a conversation until history accumulates enough characters that estimateTokens returns >= 15000.
  4. Observe: by the time auto-compress triggers, the actual context window is far beyond the intended threshold, and the model has already been degraded for many turns.

Expected Behavior

Auto-compress should trigger when the total estimated context (history + fixed overhead) reaches max_tokens. The max_tokens threshold should reflect actual token consumption, not just the history portion.

Suggested Fix

Add a configurable system_overhead_tokens field (or auto-detect it from the first API call's usage response) that represents the fixed per-turn token cost:

go
func estimateTokensWithPendingAssistant(entries []HistoryEntry, pendingAssistant string, systemOverhead int) int {
    count := systemOverhead  // ~60K for Claude Code with tools/skills/memory
    for _, h := range entries {
        count += len([]rune(h.Content))
    }
    if pendingAssistant != "" {
        count += len([]rune(pendingAssistant))
    }
    if count == 0 {
        return 0
    }
    return (count + 3) / 4
}

Or add a system_overhead_tokens config option under [projects.auto_compress].


Bug 2: session.Unlock() updates UpdatedAt on every turn completion, preventing idle session reset

Description

The reset_on_idle_mins feature is supposed to rotate sessions after a period of inactivity. The idle check in maybeAutoResetSessionOnIdle (core/engine.go, line 2145) compares session.GetUpdatedAt() against e.resetOnIdle:

go
lastActive := session.GetUpdatedAt()
if lastActive.IsZero() || time.Since(lastActive) < e.resetOnIdle {
    return nil
}

However, Session.Unlock() (core/session.go, line 58) always sets UpdatedAt = time.Now() when called with the default update = true:

go
func (s *Session) unlock(update bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.busy = false
    if update {
        s.UpdatedAt = time.Now()
    }
}

This means every turn completion (via session.Unlock() at the end of drainPendingMessages, heartbeat execution, or unsolicited agent responses) resets the UpdatedAt timestamp. Even automated actions like heartbeat execution update the timestamp.

The net effect: as long as the agent produces any activity (heartbeat, unsolicited responses), the UpdatedAt timestamp is continuously refreshed and the idle threshold is never reached, so reset_on_idle_mins effectively never triggers.

Steps to Reproduce

  1. Configure reset_on_idle_mins = 25.
  2. Start a session and have a conversation.
  3. Wait 25+ minutes without sending any user messages, but with heartbeat enabled or the agent producing unsolicited output.
  4. Observe: the session is never auto-reset because UpdatedAt keeps getting bumped.

Expected Behavior

reset_on_idle_mins should trigger based on the last user activity, not the last time the session was unlocked by any code path. Automated actions (heartbeat, unsolicited agent output) should not reset the idle timer.

Suggested Fix

Track user-initiated activity separately from internal session operations:

Option A: Use workspaceState.LastActivity (which is only Touch()ed on actual message receipt) instead of session.UpdatedAt in the idle check.

Option B: Add a LastUserActivity time.Time field to Session that is only set when a user message is processed. Replace session.Unlock() calls in internal code paths with session.UnlockWithoutUpdate() (which already exists and skips the timestamp update).

Environment

  • cc-connect version: 1.3.3-beta.2
  • Agent: Claude Code (SDK mode)
  • Config: auto_compress.max_tokens = 15000, reset_on_idle_mins = 25