feat: track inferred context compactions (#2162)

This commit is contained in:
Carlos Florêncio
2026-08-02 17:25:55 +01:00
committed by GitHub
parent 5ab60bf26d
commit 484d5b148b
43 changed files with 1085 additions and 62 deletions
+8 -4
View File
@@ -381,16 +381,20 @@ func appendContextWindowMessage(sessionID string, session *models.TaskSession, r
if session.Metadata == nil {
return result
}
contextWindow, ok := session.Metadata["context_window"]
contextWindow, ok := session.Metadata[models.SessionMetaKeyContextWindow]
if !ok {
return result
}
metadata := map[string]interface{}{
models.SessionMetaKeyContextWindow: contextWindow,
}
if count, present := session.Metadata[models.SessionMetaKeyContextCompactionCount]; present {
metadata[models.SessionMetaKeyContextCompactionCount] = count
}
notification, err := ws.NewNotification(ws.ActionSessionStateChanged, map[string]interface{}{
"session_id": sessionID,
"task_id": session.TaskID,
"metadata": map[string]interface{}{
"context_window": contextWindow,
},
"metadata": metadata,
})
if err == nil {
result = append(result, notification)
@@ -1767,7 +1767,8 @@ func TestAppendContextWindowMessage_DoesNotEmitStateSnapshot(t *testing.T) {
State: models.TaskSessionStateRunning,
UpdatedAt: time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC),
Metadata: map[string]interface{}{
"context_window": map[string]interface{}{"size": 100},
models.SessionMetaKeyContextWindow: map[string]interface{}{"size": 100},
models.SessionMetaKeyContextCompactionCount: int64(3),
},
}
@@ -1779,6 +1780,13 @@ func TestAppendContextWindowMessage_DoesNotEmitStateSnapshot(t *testing.T) {
if _, present := payload["new_state"]; present {
t.Fatal("context-window snapshot must not carry new_state and overwrite fresher session state")
}
metadata, ok := payload["metadata"].(map[string]interface{})
if !ok {
t.Fatal("context-window snapshot missing metadata")
}
if got := metadata[models.SessionMetaKeyContextCompactionCount]; got != float64(3) {
t.Fatalf("expected compaction count 3, got %v", got)
}
}
// TestAppendSessionStateMessage_OmitsEmptyTaskEnvironmentID — sessions without
@@ -104,6 +104,7 @@ func provideOrchestrator(
orchestratorSvc.SetTaskGitCredentialPolicyResolver(githubExecutorCredentialPolicyAdapter{service: githubSvc})
}
taskSvc.SetExecutionStopper(orchestratorSvc)
taskSvc.SetContextWindowResetter(orchestratorSvc.ResetContextWindow)
taskSvc.SetGitArchiveCapture(orchestratorSvc)
orchestratorSvc.SetWorktreeManager(lifecycleMgr.WorktreeManager())
@@ -7,12 +7,11 @@ import (
"github.com/kandev/kandev/internal/task/models"
)
const contextWindowMetadataKey = "context_window"
const contextWindowMetadataKey = models.SessionMetaKeyContextWindow
// contextWindowWriteGuard serializes context-window metadata writes for one
// session and advances its generation at each successful agent reset boundary.
// A context update that was observed before a reset cannot write after the
// reset's clear, even though its persistence is performed asynchronously.
// session and advances its generation at each agent reset boundary. A context
// update that was observed before a reset cannot write after the reset's clear.
type contextWindowWriteGuard struct {
mu sync.Mutex
generation uint64
@@ -38,7 +37,15 @@ func (s *Service) clearContextWindowForReset(ctx context.Context, sessionID stri
guard.mu.Lock()
defer guard.mu.Unlock()
guard.generation++
return s.repo.SetSessionMetadataKey(ctx, sessionID, contextWindowMetadataKey, nil)
return s.repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyContextWindow, nil)
}
// ResetContextWindow clears the persisted context-window reading while
// invalidating any update captured before the reset. It is exposed as a narrow
// callback for services that can reset a session without going through the
// orchestrator's reset state machine.
func (s *Service) ResetContextWindow(ctx context.Context, sessionID string) error {
return s.clearContextWindowForReset(ctx, sessionID)
}
// persistContextWindowUpdate stores an update only when it belongs to the
@@ -48,17 +55,18 @@ func (s *Service) persistContextWindowUpdate(
sessionID string,
generation uint64,
contextWindowData map[string]interface{},
) (bool, error) {
) (bool, int64, error) {
guard := s.contextWindowGuard(sessionID)
guard.mu.Lock()
defer guard.mu.Unlock()
if guard.generation != generation {
return false, nil
return false, 0, nil
}
if err := s.repo.SetSessionMetadataKey(ctx, sessionID, contextWindowMetadataKey, contextWindowData); err != nil {
return false, err
count, err := s.repo.UpdateSessionContextWindow(ctx, sessionID, contextWindowData)
if err != nil {
return false, 0, err
}
return true, nil
return true, count, nil
}
func clearInMemoryContextWindow(session *models.TaskSession) {
@@ -426,30 +426,29 @@ func (s *Service) handleContextWindowUpdated(ctx context.Context, data watcher.C
"source": source,
}
// Persist to database asynchronously using json_set to atomically set one
// key without clobbering other metadata keys (plan_mode, prepare_result).
// The generation check prevents a pre-reset update from resurrecting stale
// usage after resetAgentContext clears the metadata.
go func() {
persisted, err := s.persistAndPublishContextWindowUpdate(
context.Background(), data.TaskID, data.TaskSessionID, generation, contextWindowData,
)
switch {
case err != nil:
s.logger.Error("failed to update session with context window",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.TaskSessionID),
zap.Error(err))
case !persisted:
s.logger.Debug("discarded stale context window update after reset",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.TaskSessionID))
default:
s.logger.Debug("persisted context window to session",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.TaskSessionID))
}
}()
// Persist synchronously using json_set to atomically set one key without
// clobbering other metadata keys (plan_mode, prepare_result). The watcher
// delivers updates in arrival order; keeping persistence in this callback
// preserves that order instead of letting independent goroutines reorder
// samples and produce an incorrect compaction count.
persisted, err := s.persistAndPublishContextWindowUpdate(
context.Background(), data.TaskID, data.TaskSessionID, generation, contextWindowData,
)
switch {
case err != nil:
s.logger.Error("failed to update session with context window",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.TaskSessionID),
zap.Error(err))
case !persisted:
s.logger.Debug("discarded stale context window update after reset",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.TaskSessionID))
default:
s.logger.Debug("persisted context window to session",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.TaskSessionID))
}
}
func (s *Service) persistAndPublishContextWindowUpdate(
@@ -458,7 +457,7 @@ func (s *Service) persistAndPublishContextWindowUpdate(
generation uint64,
contextWindowData map[string]interface{},
) (bool, error) {
persisted, err := s.persistContextWindowUpdate(ctx, sessionID, generation, contextWindowData)
persisted, count, err := s.persistContextWindowUpdate(ctx, sessionID, generation, contextWindowData)
if !persisted || err != nil || s.eventBus == nil {
return persisted, err
}
@@ -469,7 +468,8 @@ func (s *Service) persistAndPublishContextWindowUpdate(
"task_id": taskID,
"session_id": sessionID,
"metadata": map[string]interface{}{
contextWindowMetadataKey: contextWindowData,
contextWindowMetadataKey: contextWindowData,
models.SessionMetaKeyContextCompactionCount: count,
},
},
))
@@ -333,7 +333,7 @@ func TestContextWindowResetDropsStalePersistence(t *testing.T) {
staleGeneration := svc.captureContextWindowGeneration("s1")
require.NoError(t, svc.clearContextWindowForReset(ctx, "s1"))
persisted, err := svc.persistContextWindowUpdate(ctx, "s1", staleGeneration, map[string]interface{}{
persisted, _, err := svc.persistContextWindowUpdate(ctx, "s1", staleGeneration, map[string]interface{}{
"size": int64(200000), "used": int64(195000),
})
require.NoError(t, err)
@@ -344,6 +344,116 @@ func TestContextWindowResetDropsStalePersistence(t *testing.T) {
require.Nil(t, updated.Metadata["context_window"])
}
func TestContextWindowUpdatesPersistInArrivalOrder(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
require.NoError(t, repo.SetSessionMetadataKey(ctx, "s1", models.SessionMetaKeyContextWindow, map[string]interface{}{
"size": int64(200000), "used": int64(120000),
}))
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.handleContextWindowUpdated(ctx, watcher.ContextWindowData{
TaskID: "t1",
TaskSessionID: "s1",
ContextWindowSize: 200000,
ContextWindowUsed: 80000,
ContextWindowRemaining: 120000,
})
svc.handleContextWindowUpdated(ctx, watcher.ContextWindowData{
TaskID: "t1",
TaskSessionID: "s1",
ContextWindowSize: 200000,
ContextWindowUsed: 120000,
ContextWindowRemaining: 80000,
})
updated, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
window, ok := updated.Metadata[models.SessionMetaKeyContextWindow].(map[string]interface{})
require.True(t, ok)
require.EqualValues(t, 120000, window["used"])
require.Equal(t, float64(1), updated.Metadata[models.SessionMetaKeyContextCompactionCount])
}
func TestContextWindowDecreasePersistsCompactionCount(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
require.NoError(t, repo.SetSessionMetadataKey(ctx, "s1", "context_window", map[string]interface{}{
"size": int64(200000), "used": int64(120000),
}))
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
generation := svc.captureContextWindowGeneration("s1")
persisted, _, err := svc.persistContextWindowUpdate(ctx, "s1", generation, map[string]interface{}{
"size": int64(200000), "used": int64(80000),
})
require.NoError(t, err)
require.True(t, persisted)
updated, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
require.Equal(t, float64(1), updated.Metadata["context_compaction_count"])
}
func TestContextWindowCompactionCountSurvivesResetAndServiceRestart(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
require.NoError(t, repo.SetSessionMetadataKey(ctx, "s1", models.SessionMetaKeyContextWindow, map[string]interface{}{
"size": int64(200000), "used": int64(120000),
}))
first := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
generation := first.captureContextWindowGeneration("s1")
_, _, err := first.persistContextWindowUpdate(ctx, "s1", generation, map[string]interface{}{
"size": int64(200000), "used": int64(80000),
})
require.NoError(t, err)
require.NoError(t, first.clearContextWindowForReset(ctx, "s1"))
cleared, err := repo.GetTaskSession(ctx, "s1")
require.NoError(t, err)
require.Nil(t, cleared.Metadata[models.SessionMetaKeyContextWindow])
require.Equal(t, float64(1), cleared.Metadata[models.SessionMetaKeyContextCompactionCount])
second := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
secondGeneration := second.captureContextWindowGeneration("s1")
_, count, err := second.persistContextWindowUpdate(ctx, "s1", secondGeneration, map[string]interface{}{
"size": int64(200000), "used": int64(40000),
})
require.NoError(t, err)
require.Equal(t, int64(1), count)
}
func TestContextWindowCompactionCountIsPublishedWithWindowUpdate(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
require.NoError(t, repo.SetSessionMetadataKey(ctx, "s1", models.SessionMetaKeyContextWindow, map[string]interface{}{
"size": int64(200000), "used": int64(120000),
}))
eventBus := &recordingEventBus{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.eventBus = eventBus
generation := svc.captureContextWindowGeneration("s1")
persisted, err := svc.persistAndPublishContextWindowUpdate(ctx, "t1", "s1", generation, map[string]interface{}{
"size": int64(200000), "used": int64(80000),
})
require.NoError(t, err)
require.True(t, persisted)
require.Len(t, eventBus.events, 1)
payload, ok := eventBus.events[0].event.Data.(map[string]interface{})
require.True(t, ok)
metadata, ok := payload["metadata"].(map[string]interface{})
require.True(t, ok)
require.Equal(t, int64(1), metadata[models.SessionMetaKeyContextCompactionCount])
require.NotNil(t, metadata[models.SessionMetaKeyContextWindow])
}
func TestContextWindowResetDropsStaleBroadcast(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
@@ -2630,7 +2630,7 @@ func (s *Service) persistSessionRuntimeConfigOnSession(ctx context.Context, sess
s.runtimeModelBySession.Store(sessionID, cfg.Model)
}
if cfg.Model != "" && cfg.Model != previousModel {
if err := s.repo.SetSessionMetadataKey(writeCtx, sessionID, "context_window", nil); err != nil {
if err := s.clearContextWindowForReset(writeCtx, sessionID); err != nil {
s.logger.Warn("failed to clear stale context window after runtime model change",
zap.String("session_id", sessionID),
zap.String("previous_model", previousModel),
@@ -627,6 +627,10 @@ type LaunchFailedFunc func(ctx context.Context, taskID, sessionID, repositoryID
// frontend receives the primary_session_id.
type PrimarySessionSetFunc func(ctx context.Context, taskID, sessionID string)
// ContextWindowResetFunc clears a session's persisted context-window reading
// and invalidates updates captured before the reset.
type ContextWindowResetFunc func(ctx context.Context, sessionID string) error
// ExecutorTypeCapabilities provides behavioral queries about executor types.
// Implemented by the lifecycle manager using its backend registry.
type ExecutorTypeCapabilities interface {
@@ -709,6 +713,10 @@ type Executor struct {
// Callback when the first session for a task is marked primary.
onPrimarySessionSet PrimarySessionSetFunc
// Callback for model changes that invalidate the current context window.
// The orchestrator owns the per-session generation guard used by this reset.
onContextWindowReset ContextWindowResetFunc
// Per-session locks to prevent concurrent resume/launch operations on the same session.
// This prevents race conditions when the backend restarts and multiple resume requests
// arrive simultaneously (e.g., from frontend auto-resume).
@@ -888,6 +896,11 @@ func (e *Executor) SetOnPrimarySessionSet(fn PrimarySessionSetFunc) {
e.onPrimarySessionSet = fn
}
// SetOnContextWindowReset wires the guarded context-window reset callback.
func (e *Executor) SetOnContextWindowReset(fn ContextWindowResetFunc) {
e.onContextWindowReset = fn
}
// SetOnLaunchFailed sets a callback for launch failures that happen before
// the agent process has started.
func (e *Executor) SetOnLaunchFailed(fn LaunchFailedFunc) {
@@ -741,7 +741,13 @@ func (e *Executor) persistRuntimeModelMetadata(ctx context.Context, sessionID st
zap.Error(err))
return
}
if err := e.repo.SetSessionMetadataKey(writeCtx, sessionID, "context_window", nil); err != nil {
resetContextWindow := e.onContextWindowReset
if resetContextWindow == nil {
resetContextWindow = func(ctx context.Context, sessionID string) error {
return e.repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyContextWindow, nil)
}
}
if err := resetContextWindow(writeCtx, sessionID); err != nil {
e.logger.Warn("failed to clear context window after model switch",
zap.String("session_id", sessionID),
zap.String("model", modelID),
@@ -122,7 +122,7 @@ func (e *Executor) rebindOfficeSessionExecutionProfile(
models.SessionMetaKeyRuntimeConfigOverrides,
models.SessionMetaKeyACPConfigBaseline,
models.SessionMetaKeyACPModelState,
"context_window",
models.SessionMetaKeyContextWindow,
models.SessionMetaKeyLastAgentError,
} {
delete(updated.Metadata, key)
@@ -134,7 +134,7 @@ func (e *Executor) rebindOfficeSessionExecutionProfile(
models.SessionMetaKeyRuntimeConfigOverrides,
models.SessionMetaKeyACPConfigBaseline,
models.SessionMetaKeyACPModelState,
"context_window",
models.SessionMetaKeyContextWindow,
models.SessionMetaKeyLastAgentError,
})
if err != nil {
@@ -222,6 +222,11 @@ func TestPersistRuntimeModelMetadataStoresRuntimeConfigAndClearsContextWindow(t
},
}
repo.sessions[session.ID] = session
resetCalls := 0
exec.SetOnContextWindowReset(func(ctx context.Context, sessionID string) error {
resetCalls++
return repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyContextWindow, nil)
})
exec.persistRuntimeModelMetadata(context.Background(), session.ID, session, "gpt-5.3-codex-spark")
@@ -242,6 +247,9 @@ func TestPersistRuntimeModelMetadataStoresRuntimeConfigAndClearsContextWindow(t
if updated.Metadata["context_window"] != nil {
t.Fatalf("expected context_window to be cleared, got %#v", updated.Metadata["context_window"])
}
if resetCalls != 1 {
t.Fatalf("expected guarded context-window reset callback once, got %d", resetCalls)
}
if len(repo.setSessionMetadataKeyCalls) != 2 {
t.Fatalf("expected runtime config and context window metadata writes, got %d", len(repo.setSessionMetadataKeyCalls))
}
@@ -200,6 +200,7 @@ type sessionExecutorStore interface {
UpdateSessionMetadata(ctx context.Context, sessionID string, metadata map[string]interface{}) error
SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error
SetSessionMetadataKeyIfAbsent(ctx context.Context, sessionID, key string, value interface{}) (bool, error)
UpdateSessionContextWindow(ctx context.Context, sessionID string, contextWindow map[string]interface{}) (int64, error)
SetSessionACPSessionID(ctx context.Context, sessionID, acpSessionID string) (bool, error)
// Executor running state
ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error)
@@ -652,6 +653,7 @@ func NewService(
clarificationWatchdogTimeout: 15 * time.Second,
gitSnapshotCache: newGitSnapshotCache(),
}
exec.SetOnContextWindowReset(s.clearContextWindowForReset)
// Wire executor state changes through the orchestrator so events are published
// (e.g. WebSocket notifications to the frontend). Must be set after service
+4 -2
View File
@@ -128,8 +128,10 @@ func IsAgentTitlePending(metadata map[string]interface{}) bool {
// workflow_switch means the session profile was selected by workflow routing
// rather than direct user selection.
const (
SessionMetaKeyCreatedBy = "created_by"
SessionCreatedByWorkflowSwitch = "workflow_switch"
SessionMetaKeyCreatedBy = "created_by"
SessionCreatedByWorkflowSwitch = "workflow_switch"
SessionMetaKeyContextWindow = "context_window"
SessionMetaKeyContextCompactionCount = "context_compaction_count"
)
// SessionMetaKeySessionMode records the agent's last-known session permission
@@ -352,6 +352,46 @@ func TestPostgresSetSessionMetadataKeyIfAbsentIsWriteOnce(t *testing.T) {
}
}
func TestPostgresUpdateSessionContextWindowCountsStrictUsageDrops(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("init postgres schema: %v", err)
}
ctx := context.Background()
now := time.Now().UTC()
if _, err := db.Exec(db.Rebind(`
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`), "task-context-count-pg", "ws-context-count-pg", "Context count", now, now); err != nil {
t.Fatalf("seed task: %v", err)
}
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "session-context-count-pg", TaskID: "task-context-count-pg", State: models.TaskSessionStateWaitingForInput,
}); err != nil {
t.Fatalf("CreateTaskSession: %v", err)
}
count, err := repo.UpdateSessionContextWindow(ctx, "session-context-count-pg", map[string]interface{}{
"size": int64(200000), "used": int64(120000),
})
if err != nil || count != 0 {
t.Fatalf("first context update = (%d, %v), want (0, nil)", count, err)
}
count, err = repo.UpdateSessionContextWindow(ctx, "session-context-count-pg", map[string]interface{}{
"size": int64(200000), "used": int64(80000),
})
if err != nil || count != 1 {
t.Fatalf("decreased context update = (%d, %v), want (1, nil)", count, err)
}
count, err = repo.UpdateSessionContextWindow(ctx, "session-context-count-pg", map[string]interface{}{
"size": int64(200000), "used": int64(80000),
})
if err != nil || count != 1 {
t.Fatalf("duplicate context update = (%d, %v), want (1, nil)", count, err)
}
}
func TestPostgresSkipsLegacyTaskEnvironmentBackfill(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := NewWithDB(db, db, nil)
@@ -1025,6 +1025,104 @@ func (r *Repository) SetSessionMetadataKey(ctx context.Context, sessionID, key s
return nil
}
// UpdateSessionContextWindow stores a context-window sample and atomically
// increments the session's inferred compaction count when the new used-token
// value is lower than the previous persisted sample.
func (r *Repository) UpdateSessionContextWindow(
ctx context.Context,
sessionID string,
contextWindow map[string]interface{},
) (int64, error) {
windowJSON, err := json.Marshal(contextWindow)
if err != nil {
return 0, fmt.Errorf("failed to serialize context window: %w", err)
}
used, err := contextWindowUsed(contextWindow)
if err != nil {
return 0, err
}
now := time.Now().UTC()
var count int64
row := r.db.QueryRowxContext(ctx, r.db.Rebind(updateSessionContextWindowQuery(r.db.DriverName())), string(windowJSON), used, now, sessionID)
if err := row.Scan(&count); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, fmt.Errorf("agent session not found: %s", sessionID)
}
return 0, err
}
return count, nil
}
func contextWindowUsed(contextWindow map[string]interface{}) (int64, error) {
value, ok := contextWindow["used"]
if !ok {
return 0, errors.New("context window is missing used tokens")
}
switch typed := value.(type) {
case int:
return int64(typed), nil
case int32:
return int64(typed), nil
case int64:
return typed, nil
case float64:
return int64(typed), nil
case json.Number:
used, err := typed.Int64()
if err != nil {
return 0, fmt.Errorf("invalid context window used tokens: %w", err)
}
return used, nil
default:
return 0, fmt.Errorf("invalid context window used tokens: %T", value)
}
}
//nolint:dupword // nested JSON setter calls are intentional in the atomic SQL.
func updateSessionContextWindowQuery(driver string) string {
if dialect.IsPostgres(driver) {
base := "CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END"
count := "GREATEST(COALESCE(NULLIF((" + base + ") #>> '{context_compaction_count}', '')::bigint, 0), 0)"
previousUsed := "(" + base + ") #>> '{context_window,used}'"
return `
UPDATE task_sessions
SET metadata = jsonb_set(
jsonb_set(` + base + `, '{context_window}', ?::jsonb, true),
'{context_compaction_count}',
to_jsonb(CASE
WHEN jsonb_typeof((` + base + `) #> '{context_window,used}') = 'number'
AND (` + previousUsed + `)::bigint > ?
THEN ` + count + ` + 1
ELSE ` + count + `
END),
true
)::text,
updated_at = ?
WHERE id = ?
RETURNING ((metadata::jsonb) #>> '{context_compaction_count}')::bigint
`
}
base := "CASE WHEN metadata IS NULL OR metadata = 'null' OR metadata = '' THEN '{}' ELSE metadata END"
count := "MAX(COALESCE(CAST(json_extract(" + base + ", '$.context_compaction_count') AS INTEGER), 0), 0)"
previousUsed := "json_extract(" + base + ", '$.context_window.used')"
return `
UPDATE task_sessions
SET metadata = json_set(
json_set(` + base + `, '$.context_window', json(?)),
'$.context_compaction_count',
CASE
WHEN json_type(` + base + `, '$.context_window.used') IN ('integer', 'real')
AND CAST(` + previousUsed + ` AS INTEGER) > ?
THEN ` + count + ` + 1
ELSE ` + count + `
END
),
updated_at = ?
WHERE id = ?
RETURNING CAST(json_extract(metadata, '$.context_compaction_count') AS INTEGER)
`
}
// SetSessionMetadataKeyIfAbsent atomically writes a metadata key only when it
// does not already exist. The returned bool reports whether this call stored
// the value.
@@ -546,6 +546,51 @@ func TestSetSessionMetadataKeyIfAbsentSQLiteIsWriteOnce(t *testing.T) {
}
}
func TestUpdateSessionContextWindowSQLiteCountsStrictUsageDrops(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedForMsgTest(t, repo, "task-context-count", "session-context-count", "turn-context-count")
require.NoError(t, repo.SetSessionMetadataKey(ctx, "session-context-count", "unrelated", "kept"))
count, err := repo.UpdateSessionContextWindow(ctx, "session-context-count", map[string]interface{}{
"size": int64(200000), "used": int64(120000), "remaining": int64(80000),
})
require.NoError(t, err)
require.Equal(t, int64(0), count)
count, err = repo.UpdateSessionContextWindow(ctx, "session-context-count", map[string]interface{}{
"size": int64(200000), "used": int64(120000), "remaining": int64(80000),
})
require.NoError(t, err)
require.Equal(t, int64(0), count)
count, err = repo.UpdateSessionContextWindow(ctx, "session-context-count", map[string]interface{}{
"size": int64(200000), "used": int64(80000), "remaining": int64(120000),
})
require.NoError(t, err)
require.Equal(t, int64(1), count)
count, err = repo.UpdateSessionContextWindow(ctx, "session-context-count", map[string]interface{}{
"size": int64(200000), "used": int64(80000), "remaining": int64(120000),
})
require.NoError(t, err)
require.Equal(t, int64(1), count)
count, err = repo.UpdateSessionContextWindow(ctx, "session-context-count", map[string]interface{}{
"size": int64(200000), "used": int64(100000), "remaining": int64(100000),
})
require.NoError(t, err)
require.Equal(t, int64(1), count)
session, err := repo.GetTaskSession(ctx, "session-context-count")
require.NoError(t, err)
require.Equal(t, "kept", session.Metadata["unrelated"])
require.Equal(t, float64(1), session.Metadata[models.SessionMetaKeyContextCompactionCount])
window, ok := session.Metadata[models.SessionMetaKeyContextWindow].(map[string]interface{})
require.True(t, ok)
require.Equal(t, float64(100000), window["used"])
}
func TestSetSessionMetadataKeyIfAbsentQueryUsesPostgresJSONB(t *testing.T) {
query := setSessionMetadataKeyIfAbsentQuery(dialect.PGX)
if strings.Contains(query, "json_set") || strings.Contains(query, "json_type") || strings.Contains(query, "json(?)") {
@@ -243,6 +243,7 @@ type Service struct {
discoveryConfig RepositoryDiscoveryConfig
worktreeCleanup WorktreeCleanup
executionStopper TaskExecutionStopper
contextWindowResetter func(context.Context, string) error
cleanupActivity TaskResourceCleanupActivityGate
branchMaterializer BranchMaterializer
workspaceSourceMaterializer WorkspaceSourceMaterializer
@@ -370,6 +371,20 @@ func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) {
s.executionStopper = stopper
}
// SetContextWindowResetter wires the guarded context-window reset callback
// owned by the orchestrator. It is optional for isolated task-service users;
// those callers fall back to clearing the session metadata directly.
func (s *Service) SetContextWindowResetter(resetter func(context.Context, string) error) {
s.contextWindowResetter = resetter
}
func (s *Service) resetContextWindow(ctx context.Context, sessionID string) error {
if s.contextWindowResetter != nil {
return s.contextWindowResetter(ctx, sessionID)
}
return s.sessions.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyContextWindow, nil)
}
func (s *Service) SetTaskResourceCleanupActivityGate(gate TaskResourceCleanupActivityGate) {
s.cleanupActivity = gate
}
@@ -822,7 +822,7 @@ func (s *Service) PersistSessionRuntimeModel(ctx context.Context, sessionID, mod
}); err != nil {
return err
}
return s.sessions.SetSessionMetadataKey(ctx, sessionID, "context_window", nil)
return s.resetContextWindow(ctx, sessionID)
}
// PersistSessionRuntimeMode records the session's selected ACP permission mode.
@@ -855,7 +855,7 @@ func (s *Service) PersistSessionRuntimeConfigOption(ctx context.Context, session
return err
}
if configID == runtimeModelConfigID {
return s.sessions.SetSessionMetadataKey(ctx, sessionID, "context_window", nil)
return s.resetContextWindow(ctx, sessionID)
}
return nil
}
@@ -52,6 +52,7 @@ describe("TokenUsageDisplay", () => {
used: 233_900,
remaining: -33_900,
efficiency: 117,
compactionCount: 0,
});
const { container } = render(<TokenUsageDisplay sessionId="sess-1" />);
@@ -65,6 +66,7 @@ describe("TokenUsageDisplay", () => {
used: 56_047,
remaining: 143_953,
efficiency: 28,
compactionCount: 0,
});
const { getByRole, getByTestId } = render(<TokenUsageDisplay sessionId="sess-1" />);
@@ -72,6 +74,9 @@ describe("TokenUsageDisplay", () => {
fireEvent.click(getByRole("button", { name: "Context window: 28% used" }));
expect(getByTestId("tooltip-root").getAttribute("data-open")).toBe("true");
const compactionRow = getByTestId("context-window-compactions-row");
expect(compactionRow.textContent).toContain("Compactions");
expect(compactionRow.textContent).toContain("0");
});
it("closes a tapped tooltip when Escape is pressed", () => {
@@ -80,6 +85,7 @@ describe("TokenUsageDisplay", () => {
used: 56_047,
remaining: 143_953,
efficiency: 28,
compactionCount: 0,
});
const { getByRole, getByTestId } = render(<TokenUsageDisplay sessionId="sess-1" />);
@@ -98,6 +104,7 @@ describe("TokenUsageDisplay context source", () => {
used: 56_047,
remaining: 143_953,
efficiency: 28,
compactionCount: 2,
source: "acp",
});
@@ -118,12 +125,47 @@ describe("TokenUsageDisplay context source", () => {
expect(getByText(/ACP is the active session's effective window/i)).toBeDefined();
});
it("shows the inferred compaction count and its accuracy disclosure", () => {
vi.mocked(useSessionContextWindow).mockReturnValue({
size: 200_000,
used: 56_047,
remaining: 143_953,
efficiency: 28,
compactionCount: 2,
source: "acp",
});
const { container, getByLabelText, getByText } = render(
<TokenUsageDisplay sessionId="sess-1" />,
);
expect(getByText("Compactions")).toBeDefined();
expect(getByText("2", { selector: "span" })).toBeDefined();
expect(getByLabelText("About inferred compaction count")).toBeDefined();
expect(container.textContent).toContain(
"Kandev infers this count from observed context usage drops",
);
const helpButton = getByLabelText("About inferred compaction count");
const helpId = helpButton.getAttribute("aria-describedby");
if (!helpId) throw new Error("Expected compaction help to be described");
const help = document.getElementById(helpId);
if (!help) throw new Error("Expected compaction help element");
expect(help.className).toContain("opacity-0");
fireEvent.click(helpButton);
expect(help.className).toContain("opacity-100");
fireEvent.click(helpButton);
expect(help.className).toContain("opacity-0");
});
it("labels model API fallback data", () => {
vi.mocked(useSessionContextWindow).mockReturnValue({
size: 128_000,
used: 64_000,
remaining: 64_000,
efficiency: 50,
compactionCount: 0,
source: "api",
});
@@ -3,6 +3,7 @@
import { memo, useEffect, useId, useRef, useState } from "react";
import { IconInfoCircle } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useSessionContextWindow } from "@/hooks/domains/session/use-session-context-window";
@@ -151,6 +152,64 @@ function ContextWindowSource({ source }: { source: "acp" | "api" | undefined })
);
}
function ContextCompactionCount({ count }: { count: number }) {
const { t } = useTranslation();
const helpId = useId();
const [helpOpen, setHelpOpen] = useState(false);
const [touchMode, setTouchMode] = useState(false);
const pointerDownRef = useRef(false);
return (
<div
className="flex min-h-6 items-center justify-between gap-3"
data-testid="context-window-compactions-row"
>
<span className="text-[11px] text-muted-foreground">{t("common:contextCompactions")}</span>
<div className="group relative flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground">
<span className="font-medium tabular-nums text-foreground">{count}</span>
<button
type="button"
aria-label={t("common:aboutContextCompactionCount")}
aria-describedby={helpId}
aria-expanded={helpOpen}
className="inline-flex size-6 cursor-help items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:size-4"
onPointerDown={(event) => {
pointerDownRef.current = true;
if (event.pointerType !== "touch") setTouchMode(false);
}}
onTouchStart={() => {
setTouchMode(true);
}}
onFocus={() => {
if (!pointerDownRef.current) setHelpOpen(true);
}}
onBlur={() => {
pointerDownRef.current = false;
setHelpOpen(false);
}}
onClick={() => {
pointerDownRef.current = false;
setHelpOpen((open) => !open);
}}
>
<IconInfoCircle className="h-3 w-3" />
</button>
<span
id={helpId}
role="tooltip"
className={cn(
"pointer-events-none absolute right-0 bottom-[calc(100%+0.375rem)] z-10 w-60 rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground opacity-0 shadow-sm transition-opacity",
!touchMode && "group-hover:opacity-100",
helpOpen && "opacity-100",
)}
>
{t("common:contextCompactionCountHelp")}
</span>
</div>
</div>
);
}
export const TokenUsageDisplay = memo(function TokenUsageDisplay({
sessionId,
className,
@@ -160,7 +219,7 @@ export const TokenUsageDisplay = memo(function TokenUsageDisplay({
if (!contextWindow) return null;
const { size, used, source } = contextWindow;
const { size, used, source, compactionCount } = contextWindow;
// Hide when there's no data yet (size 0) or the report is impossible
// (used > size) — see isContextWindowReliable.
@@ -218,6 +277,7 @@ export const TokenUsageDisplay = memo(function TokenUsageDisplay({
</span>
<ContextWindowSource source={source} />
</div>
<ContextCompactionCount count={compactionCount} />
</div>
</div>
</TooltipContent>
@@ -1,4 +1,4 @@
import type { Locator, Page } from "@playwright/test";
import { expect, type Locator, type Page } from "@playwright/test";
import type { ApiClient } from "../../helpers/api-client";
import type { SeedData } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
@@ -13,6 +13,7 @@ type ContextWindowStore = Window & {
used: number;
remaining: number;
efficiency: number;
compactionCount: number;
source: "acp" | "api";
},
) => void;
@@ -51,11 +52,19 @@ export async function seedContextWindowTask(
used: 54_100,
remaining: 204_300,
efficiency: 21,
compactionCount: 2,
source: "acp",
});
}, task.session_id);
}
export async function expectCompactionCount(contextTooltip: Locator): Promise<void> {
const contextUsage = contextTooltip.getByTestId("context-window-usage").first();
const row = contextUsage.getByTestId("context-window-compactions-row");
await expect(row.getByText("Compactions", { exact: true })).toBeVisible();
await expect(row.getByText("2", { exact: true })).toBeVisible();
}
export async function expectSourceRightOfTokenCount(contextTooltip: Locator): Promise<void> {
const row = contextTooltip.getByTestId("context-window-token-row").first();
const tokenCount = row.getByText("54.1K of 258.4K tokens");
@@ -1,5 +1,6 @@
import { test, expect } from "../../fixtures/test-base";
import {
expectCompactionCount,
expectSourceRightOfTokenCount,
seedContextWindowTask,
} from "./context-window-source-helpers";
@@ -15,11 +16,12 @@ test("context source help stays open when hovered", async ({
const contextTrigger = testPage.getByRole("button", { name: "Context window: 21% used" });
await contextTrigger.hover();
const contextTooltip = testPage
.locator('[data-slot="tooltip-content"][data-state]')
.locator('[data-slot="tooltip-content"]:not([data-state="closed"])')
.filter({ has: testPage.getByTestId("context-window-usage") });
const contextUsage = contextTooltip.getByTestId("context-window-usage").first();
await expect(contextUsage).toBeVisible();
await expectSourceRightOfTokenCount(contextTooltip);
await expectCompactionCount(contextTooltip);
const sourceHelpButton = contextUsage.locator('button[aria-label="About context window source"]');
const sourceHelpId = await sourceHelpButton.getAttribute("aria-describedby");
@@ -28,7 +30,17 @@ test("context source help stays open when hovered", async ({
await expect(contextUsage).toBeVisible();
await expect(testPage.locator(`[id="${sourceHelpId}"]`)).toHaveCSS("opacity", "1");
const compactionHelpButton = contextUsage.locator(
'button[aria-label="About inferred compaction count"]',
);
const compactionHelpId = await compactionHelpButton.getAttribute("aria-describedby");
if (!compactionHelpId) throw new Error("Expected compaction help to be described");
await compactionHelpButton.hover();
await expect(contextUsage).toBeVisible();
await expect(testPage.locator(`[id="${compactionHelpId}"]`)).toHaveCSS("opacity", "1");
await prCapture.screenshot("context-source-help", {
caption: "Context source shown inline with the token count and its help visible",
caption: "Context source and inferred compaction count shown with their help visible",
});
});
@@ -1,5 +1,6 @@
import { test, expect } from "../../fixtures/test-base";
import {
expectCompactionCount,
expectSourceRightOfTokenCount,
seedContextWindowTask,
} from "./context-window-source-helpers";
@@ -14,11 +15,12 @@ test("context source help is reachable by touch without overflow", async ({
const contextTrigger = testPage.getByRole("button", { name: "Context window: 21% used" });
await contextTrigger.tap();
const contextTooltip = testPage
.locator('[data-slot="tooltip-content"][data-state]')
.locator('[data-slot="tooltip-content"]:not([data-state="closed"])')
.filter({ has: testPage.getByTestId("context-window-usage") });
const contextUsage = contextTooltip.getByTestId("context-window-usage").first();
await expect(contextUsage).toBeVisible();
await expectSourceRightOfTokenCount(contextTooltip);
await expectCompactionCount(contextTooltip);
const sourceHelpButton = contextUsage.locator('button[aria-label="About context window source"]');
const sourceHelpId = await sourceHelpButton.getAttribute("aria-describedby");
@@ -27,6 +29,18 @@ test("context source help is reachable by touch without overflow", async ({
await expect(contextUsage).toBeVisible();
await expect(testPage.locator(`[id="${sourceHelpId}"]`)).toHaveCSS("opacity", "1");
const compactionHelpButton = contextUsage.locator(
'button[aria-label="About inferred compaction count"]',
);
const compactionHelpId = await compactionHelpButton.getAttribute("aria-describedby");
if (!compactionHelpId) throw new Error("Expected compaction help to be described");
await compactionHelpButton.tap();
await expect(contextUsage).toBeVisible();
await expect(testPage.locator(`[id="${compactionHelpId}"]`)).toHaveCSS("opacity", "1");
await compactionHelpButton.tap();
await expect(testPage.locator(`[id="${compactionHelpId}"]`)).toHaveCSS("opacity", "0");
const hasHorizontalOverflow = await testPage.evaluate(() => {
const root = document.scrollingElement ?? document.documentElement;
return root.scrollWidth > root.clientWidth + 1;
@@ -15,6 +15,7 @@ type ContextWindowStoreWindow = Window & {
used: number;
remaining: number;
efficiency: number;
compactionCount: number;
source: "acp" | "api";
},
) => void;
@@ -67,6 +68,7 @@ async function seedStaleContextWindow(testPage: Page): Promise<void> {
used: 190_000,
remaining: 10_000,
efficiency: 95,
compactionCount: 0,
source: "acp",
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
const subscribeSession = vi.fn(() => vi.fn());
const setContextWindow = vi.fn();
let storeState: Record<string, unknown>;
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ subscribeSession }),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) => selector(storeState),
}));
import { useSessionContextWindow } from "./use-session-context-window";
describe("useSessionContextWindow", () => {
beforeEach(() => {
vi.clearAllMocks();
storeState = {
contextWindow: { bySessionId: {} },
taskSessions: {
items: {
"session-1": {
metadata: {
context_window: {
size: 100_000,
used: 42_000,
remaining: 58_000,
efficiency: 42,
source: "acp",
},
context_compaction_count: 4,
},
},
},
},
setContextWindow,
connection: { status: "disconnected" },
};
});
it("hydrates the persisted compaction count with session context metadata", async () => {
renderHook(() => useSessionContextWindow("session-1"));
await waitFor(() =>
expect(setContextWindow).toHaveBeenCalledWith("session-1", {
size: 100_000,
used: 42_000,
remaining: 58_000,
efficiency: 42,
compactionCount: 4,
source: "acp",
}),
);
});
});
@@ -18,6 +18,9 @@ export function useSessionContextWindow(sessionId: string | null): ContextWindow
const efficiency = useAppStore((state) =>
sessionId ? state.contextWindow.bySessionId[sessionId]?.efficiency : undefined,
);
const compactionCount = useAppStore((state) =>
sessionId ? state.contextWindow.bySessionId[sessionId]?.compactionCount : undefined,
);
const source = useAppStore((state) =>
sessionId ? state.contextWindow.bySessionId[sessionId]?.source : undefined,
);
@@ -33,10 +36,11 @@ export function useSessionContextWindow(sessionId: string | null): ContextWindow
used: used ?? 0,
remaining: remaining ?? 0,
efficiency: efficiency ?? 0,
compactionCount: compactionCount ?? 0,
source,
timestamp,
};
}, [size, used, remaining, efficiency, source, timestamp]);
}, [size, used, remaining, efficiency, compactionCount, source, timestamp]);
const session = useAppStore((state) =>
sessionId ? state.taskSessions.items[sessionId] : undefined,
@@ -53,7 +57,11 @@ export function useSessionContextWindow(sessionId: string | null): ContextWindow
if (!metadata || typeof metadata !== "object") return;
const storedContextWindow = (metadata as Record<string, unknown>).context_window;
const entry = parseContextWindowEntry(storedContextWindow);
const entry = parseContextWindowEntry(
storedContextWindow,
undefined,
(metadata as Record<string, unknown>).context_compaction_count,
);
if (!entry) return;
setContextWindow(sessionId, entry);
@@ -16,6 +16,7 @@ describe("parseContextWindowEntry", () => {
used: 95_100,
remaining: 163_300,
efficiency: 36.8,
compactionCount: 0,
source,
timestamp: undefined,
});
@@ -27,6 +28,35 @@ describe("parseContextWindowEntry", () => {
);
});
it("parses a caller-supplied persisted inferred compaction count", () => {
expect(
parseContextWindowEntry(
{
size: 128_000,
used: 64_000,
remaining: 64_000,
efficiency: 50,
},
undefined,
3,
),
).toEqual(expect.objectContaining({ compactionCount: 3 }));
});
it.each([
{ label: "missing", value: undefined },
{ label: "invalid", value: "3" },
{ label: "negative", value: -2 },
])("normalizes a $label compaction count to zero", ({ value }) => {
expect(
parseContextWindowEntry(
{ size: 128_000, used: 64_000, compaction_count: value },
undefined,
value,
),
).toEqual(expect.objectContaining({ compactionCount: 0 }));
});
it("prefers a caller-supplied timestamp over embedded metadata", () => {
expect(
parseContextWindowEntry(
@@ -1,8 +1,14 @@
import type { ContextWindowEntry } from "./types";
function normalizeCompactionCount(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}
export function parseContextWindowEntry(
value: unknown,
timestamp?: string,
compactionCount?: unknown,
): ContextWindowEntry | null {
if (!value || typeof value !== "object") return null;
@@ -17,6 +23,7 @@ export function parseContextWindowEntry(
used: (contextWindow.used as number) ?? 0,
remaining: (contextWindow.remaining as number) ?? 0,
efficiency: (contextWindow.efficiency as number) ?? 0,
compactionCount: normalizeCompactionCount(compactionCount),
source,
timestamp: timestamp ?? (contextWindow.timestamp as string | undefined),
};
@@ -20,7 +20,13 @@ describe("purgeSessionRuntimeState", () => {
s.registerSessionEnvironment("session-1", "env-1");
s.appendShellOutput("session-1", "shell noise");
s.setShellStatus("session-1", { available: true });
s.setContextWindow("session-1", { size: 1, used: 1, remaining: 0, efficiency: 1 });
s.setContextWindow("session-1", {
size: 1,
used: 1,
remaining: 0,
efficiency: 1,
compactionCount: 0,
});
s.setSessionTodos("session-1", [{ description: "do", status: "pending" }]);
s.upsertProcessStatus({
processId: "proc-1",
@@ -155,6 +155,7 @@ export type ContextWindowEntry = {
used: number;
remaining: number;
efficiency: number;
compactionCount: number;
source?: "acp" | "api";
timestamp?: string;
};
@@ -33,7 +33,13 @@ describe("removeTaskSession cleanup cascade", () => {
const s = store.getState();
s.registerSessionEnvironment(SESSION_ID, "env-1");
s.appendShellOutput(SESSION_ID, "shell output");
s.setContextWindow(SESSION_ID, { size: 1, used: 1, remaining: 0, efficiency: 1 });
s.setContextWindow(SESSION_ID, {
size: 1,
used: 1,
remaining: 0,
efficiency: 1,
compactionCount: 0,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
s.addMessage({ id: "m1", session_id: SESSION_ID, role: "user", content: "hi" } as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -337,13 +337,14 @@ describe("session.state_changed context window provenance", () => {
efficiency: 36.8,
source: "acp",
},
context_compaction_count: 3,
},
}),
);
expect(setContextWindow).toHaveBeenCalledWith(
"s-1",
expect.objectContaining({ source: "acp" }),
expect.objectContaining({ source: "acp", compactionCount: 3 }),
);
});
+5 -1
View File
@@ -295,7 +295,11 @@ function extractContextWindow(store: StoreApi<AppState>, sessionId: string, payl
) as Record<string, unknown> | undefined;
if (!metadata) return;
const contextWindow = metadata.context_window;
const entry = parseContextWindowEntry(contextWindow, new Date().toISOString());
const entry = parseContextWindowEntry(
contextWindow,
new Date().toISOString(),
metadata.context_compaction_count,
);
if (entry) store.getState().setContextWindow(sessionId, entry);
else store.getState().clearContextWindow(sessionId);
}
@@ -87,6 +87,7 @@ describe("session.models_updated handler", () => {
used: 114000,
remaining: 144400,
efficiency: 44,
compactionCount: 0,
},
},
} as AppState["contextWindow"],
@@ -121,6 +122,7 @@ describe("session.models_updated handler", () => {
used: 114000,
remaining: 144400,
efficiency: 44,
compactionCount: 0,
},
},
} as AppState["contextWindow"],
@@ -144,6 +146,7 @@ describe("session.models_updated handler", () => {
used: 114000,
remaining: 144400,
efficiency: 44,
compactionCount: 0,
});
expect(store.getState().setSessionModels).toHaveBeenCalledWith(
"session-1",
+3
View File
@@ -1,6 +1,9 @@
{
"agents": "Agents",
"automations": "Automations",
"contextCompactions": "Compactions",
"contextCompactionCountHelp": "Kandev infers this count from observed context usage drops. Missing samples or provider resets can make it approximate.",
"aboutContextCompactionCount": "About inferred compaction count",
"cancel": "Cancel",
"createAWorkspaceToConfigureThisIntegration": "Create a workspace to configure this integration.",
"dAgo": "{{diffDay}}d ago",
+3
View File
@@ -1,6 +1,9 @@
{
"agents": "Àĝēńţś",
"automations": "Àũţōḿàţĩōńś",
"contextCompactions": "Ćōḿƥàćţĩōńś",
"contextCompactionCountHelp": "Ķàńďēv ĩńƒēŕś ţĥĩś ćōũńţ ƒŕōḿ ōƀśēŕvēď ćōńţēxţ ũśàĝē ďŕōƥś. Ḿĩśśĩńĝ śàḿƥĺēś ōŕ ƥŕōvĩďēŕ ŕēśēţś ćàń ḿàķē ĩţ àƥƥŕōxĩḿàţē.",
"aboutContextCompactionCount": "Àƀōũţ ĩńƒēŕŕēď ćōḿƥàćţĩōń ćōũńţ",
"cancel": "Ćàńćēĺ",
"createAWorkspaceToConfigureThisIntegration": "Ćŕēàţē à ŵōŕķśƥàćē ţō ćōńƒĩĝũŕē ţĥĩś ĩńţēĝŕàţĩōń.",
"dAgo": "{{diffDay}}ď àĝō",
+108
View File
@@ -0,0 +1,108 @@
---
spec: docs/specs/context-compaction-count/spec.md
created: 2026-08-02
status: complete
---
# Implementation Plan: Context Compaction Count
## Overview
Extend the existing context-window persistence boundary so one database update stores the latest sample and atomically increments a session-owned lifetime count when usage decreases. Propagate that count through the existing session metadata event, hydrate it into the shared session-runtime store, and add a compact translated row with an accuracy disclosure to the existing context hover. Focused repository/orchestrator tests prove restart and duplicate-delivery behavior; existing desktop and mobile context-hover E2E scenarios prove the user-visible path.
## Backend
### Session metadata contract
- Add `SessionMetaKeyContextWindow` and `SessionMetaKeyContextCompactionCount` constants in `apps/backend/internal/task/models/models.go` so reset, model-change, repository, and orchestrator paths use one spelling for the two session-owned metadata keys.
- Keep the feature in `task_sessions.metadata`; no schema migration or backfill is needed. A missing `context_compaction_count` reads as zero.
### Atomic context-window persistence
- Add `UpdateSessionContextWindow(ctx context.Context, sessionID string, contextWindow map[string]interface{}) (int64, error)` to the concrete task repository and the orchestrator's narrow `sessionExecutorStore` in `apps/backend/internal/orchestrator/service.go`. Keeping it on the narrow context-window boundary avoids forcing unrelated repository test doubles to implement a method they do not use. The returned value is the persisted lifetime count.
- Implement the method in `apps/backend/internal/task/repository/sqlite/session.go` with dialect-specific SQL that, in one row update, compares the incoming `used` value with the previously persisted `metadata.context_window.used`, increments `metadata.context_compaction_count` only for a strict decrease, stores the new `context_window`, and returns the resulting count.
- Treat an absent/non-numeric previous sample or absent count as zero without incrementing. Re-delivering the same decreased sample is idempotent because the first update makes it equal to the persisted baseline.
- Add SQLite behavior coverage in `apps/backend/internal/task/repository/sqlite/session_test.go` for the first sample, increase/equality, decrease, repeated decrease, a legacy missing count, and preservation of unrelated metadata.
- Add the equivalent environment-gated PostgreSQL behavior coverage in `apps/backend/internal/task/repository/sqlite/postgres_schema_test.go`, as required for changed dialect-sensitive task-repository methods.
### Orchestrator event propagation
- Update `apps/backend/internal/orchestrator/context_window.go` so the existing per-session generation guard calls `UpdateSessionContextWindow` and returns the persisted count with the generation result.
- Update `apps/backend/internal/orchestrator/event_handlers_git.go` so a successful write publishes one `session.state_changed` metadata patch containing both `context_window` and `context_compaction_count`. Failed or generation-stale writes publish neither.
- Replace literal context-window metadata keys in the touched reset/model-change paths with the model constants while preserving the existing behavior: clearing the current sample never clears or increments the lifetime count.
- Include the persisted count in context-window replay snapshots used during WebSocket session hydration.
- Extend `apps/backend/internal/orchestrator/event_handlers_git_test.go` to prove a decrease increments, a backend-service recreation continues from persisted metadata, duplicate delivery does not double count, reset leaves the count intact, and the published patch contains the resulting count.
## Frontend
### Session-runtime state and hydration
- Extend `ContextWindowEntry` in `apps/web/lib/state/slices/session-runtime/types.ts` with `compactionCount: number`.
- Update `parseContextWindowEntry` in `apps/web/lib/state/slices/session-runtime/context-window.ts` to accept the adjacent metadata count, normalize missing/invalid/negative values to zero, and include it in the parsed entry.
- Update `apps/web/lib/ws/handlers/agent-session.ts` to read `context_compaction_count` from the same `metadata` or `session_metadata` object as `context_window`, then pass it to the parser. Preserve explicit `context_window: null` cache invalidation.
- Update `apps/web/hooks/domains/session/use-session-context-window.ts` so both Zustand subscriptions and initial session-metadata hydration retain the count across refresh and restart recovery.
- Cover parsing, live metadata patches, legacy missing counts, and hydrated session metadata in the existing focused unit tests.
### Context hover
- Update `apps/web/components/task/chat/token-usage-display.tsx` to render a `Compactions` row with the tabular count and an inline info control. The help text states that Kandev infers the count from observed token drops and that missing samples or provider resets can make it approximate.
- Reuse the component's current inline help pattern instead of nesting another Radix tooltip inside `TooltipContent`; the help must remain reachable by pointer hover, keyboard focus, and touch focus while the parent context hover stays open.
- Add the new user-facing strings to `apps/web/src/locales/en/common.json`, regenerate `apps/web/src/locales/pseudo/common.json`, and resolve them at render time with `useTranslation`.
- Extend `apps/web/components/task/chat/token-usage-display.test.tsx` for zero and non-zero counts plus the accessible disclosure. Update context-window store/parser tests and fixtures that construct `ContextWindowEntry` values.
### Mobile design contract
- **Desktop outcome:** the existing context ring hover adds the lifetime count and accuracy help below the token/source row.
- **Mobile entry point and exemplar:** the existing context ring remains tap-pinnable; `apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts` and the component's existing source-help control are the nearest shipped pattern.
- **Hierarchy and presentation:** current percent, progress, token count, and source remain primary; compaction history is a secondary row inside the same compact hover. No drawer, route, new scroll owner, breakpoint branch, or persistent mobile preference is introduced.
- **Shared behavior:** desktop and mobile use the same session-runtime entry, translated copy, count row, and help behavior. Existing parent-tooltip pinning continues to own outside-tap and Escape dismissal.
- **Mobile proof:** the mobile E2E scenario taps the ring and compaction help, asserts the disclosure remains visible inside the pinned hover, and confirms no document-level horizontal overflow.
## Tests
- **What:** first/equal/increasing/decreasing/duplicate samples update one durable count while preserving unrelated metadata.
- **File:** `apps/backend/internal/task/repository/sqlite/session_test.go`
- **How:** table-driven repository test against a real SQLite database.
- **What:** the same atomic compare-and-increment contract works with PostgreSQL JSONB.
- **File:** `apps/backend/internal/task/repository/sqlite/postgres_schema_test.go`
- **How:** environment-gated real PostgreSQL repository test.
- **What:** restart-style service recreation uses persisted metadata, resets retain the count, and live session patches include it exactly once.
- **File:** `apps/backend/internal/orchestrator/event_handlers_git_test.go`
- **How:** real task repository plus fresh `Service` instances and the recording event bus.
- **What:** live and hydrated session metadata normalize the count into `ContextWindowEntry`, including legacy missing values.
- **Files:** `apps/web/lib/state/slices/session-runtime/context-window.test.ts`, `apps/web/lib/ws/handlers/agent-session.test.ts`, `apps/web/hooks/domains/session/use-session-context-window.test.ts`
- **How:** focused parser, store-handler, and hook tests.
- **What:** the hover shows zero/non-zero counts and exposes the translated accuracy explanation accessibly.
- **File:** `apps/web/components/task/chat/token-usage-display.test.tsx`
- **How:** render with mocked session state under the existing tooltip test boundary and focus the inline help control.
## E2E Tests
- **Scenario:** **GIVEN** a counted session has a reliable context sample, **WHEN** a desktop user hovers the context ring and then the compaction info control, **THEN** the count and approximate-count explanation remain visible in the same hover.
- **File:** `apps/web/e2e/tests/chat/context-window-source.spec.ts`
- **What to verify:** count text, pointer-hover disclosure, and parent-hover stability.
- **Scenario:** **GIVEN** the same counted session on a phone viewport, **WHEN** the user taps the ring and compaction info control, **THEN** the count and explanation are reachable without horizontal overflow.
- **File:** `apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts`
- **What to verify:** tap-pinned hover, disclosure visibility, and document containment.
## Verification Results
- Task 01: SQLite, backend-app replay, task-service, orchestrator, and full `go test ./...` checks passed; PostgreSQL behavior test is present but skipped without `KANDEV_TEST_POSTGRES_DSN`.
- Task 02: focused frontend suite passed (4 files, 68 tests); typecheck, i18n check, and i18n ratchet passed.
- Task 03: managed Chromium desktop and mobile-chrome E2E scenarios passed (1 test each).
- Review fixup: the context-update handler now persists synchronously to preserve arrival order; model-change clears use the guarded reset boundary; the compaction help has explicit touch toggle state; and parser/E2E selectors now require the adjacent count contract. Full `go test -race ./...` was not rerun locally; the PR's race-enabled backend job had already exposed an unrelated `internal/integration` failure before this fixup.
## Implementation Waves And Parallel Candidates
Execution is sequential in the primary conversation because the frontend contract depends on the backend metadata shape and E2E depends on both implementation slices.
- [x] [Task 01: Persist inferred compaction counts](task-01-persist-compaction-count.md) — done
- [x] [Task 02: Show compactions in context hover](task-02-context-hover-ui.md) — done
- [x] [Task 03: Cover desktop and mobile hover](task-03-context-hover-e2e.md) — done
## Risks and boundaries
- ACP supplies usage samples, not compaction events. Strict decreases are intentionally approximate and the UI must not describe them as provider-confirmed.
- The database update must compare and increment atomically so duplicate event delivery or multiple backend subscribers cannot double count one observed drop.
- Reset and model-change paths may clear `context_window`, but must not clear `context_compaction_count`; the next sample after a cleared baseline must not increment.
- Public session documentation now mentions the inferred count and its approximate nature; no separate reference page is needed for this contextual hover detail.
@@ -0,0 +1,78 @@
---
id: "01-persist-compaction-count"
title: "Persist inferred compaction counts"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/context-compaction-count/spec.md"
---
# Task 01: Persist inferred compaction counts
## Intent
Turn consecutive persisted context-usage samples into an idempotent, restart-safe lifetime count on each task session and publish that count with live context updates.
## Acceptance
- One atomic repository update stores the incoming context window and increments `context_compaction_count` only when its valid `used` value is lower than the prior persisted baseline; duplicate, first, equal, and increasing samples do not increment.
- A new backend service instance continues from persisted metadata, while explicit context resets and model changes retain the count and clear only the comparison baseline.
- Successful `session.state_changed` metadata patches contain the window and resulting count together; failed or generation-stale writes publish neither.
## TDD sequence
1. Add SQLite and PostgreSQL repository behavior cases and run them to confirm no atomic context-window/count method exists.
2. Add orchestrator persistence, restart, reset, duplicate, and event-patch cases and run them to confirm the count is absent.
3. Implement the typed metadata keys, repository contract, dialect-specific atomic update, and guarded orchestrator propagation; rerun the exact checks and refactor without widening scope.
## Files likely touched
- `apps/backend/internal/task/models/models.go`
- `apps/backend/internal/task/repository/sqlite/session.go`
- `apps/backend/internal/task/repository/sqlite/session_test.go`
- `apps/backend/internal/task/repository/sqlite/postgres_schema_test.go`
- `apps/backend/internal/orchestrator/service.go`
- `apps/backend/internal/orchestrator/context_window.go`
- `apps/backend/internal/orchestrator/event_handlers_git.go`
- `apps/backend/internal/orchestrator/event_handlers_git_test.go`
- `apps/backend/internal/orchestrator/event_handlers_streaming.go`
- `apps/backend/internal/orchestrator/executor/executor_interaction.go`
- `apps/backend/internal/orchestrator/executor/executor_office.go`
- `apps/backend/internal/backendapp/helpers.go`
- `apps/backend/internal/backendapp/helpers_test.go`
- `apps/backend/internal/task/service/service_turns.go`
## Dependencies
None.
## Parallelism
`sequential` — repository and orchestrator changes share the session metadata contract and must land as one coherent backend slice.
## Verification
- `cd apps/backend && go test -run 'Test(UpdateSessionContextWindow|PostgresUpdateSessionContextWindow|ContextWindowCompaction)' ./internal/task/repository/sqlite ./internal/orchestrator`
## Inputs
- Spec `What`, `Data model`, `Failure modes`, `Persistence guarantees`, and the first six scenarios.
- Existing guarded write boundary in `apps/backend/internal/orchestrator/context_window.go`.
- Existing context event persistence and broadcast in `apps/backend/internal/orchestrator/event_handlers_git.go`.
- Dialect rules in `apps/backend/AGENTS.md` and replay-safe metadata patterns in `apps/backend/internal/task/repository/sqlite/session.go`.
## Output contract
Report the result, actual files changed, exact tests and counts, blockers, risks, and synchronized task/plan status in this conversation.
## Results
- RED: `cd apps/backend && go test -run TestContextWindowDecreasePersistsCompactionCount ./internal/orchestrator` — failed as expected because the pre-change persistence path had no `context_compaction_count`.
- GREEN: `cd apps/backend && go test -run TestUpdateSessionContextWindowSQLiteCountsStrictUsageDrops ./internal/task/repository/sqlite` — 1 passed.
- GREEN: `cd apps/backend && go test -run 'Test(ContextWindowDecreasePersistsCompactionCount|ContextWindowCompactionCountSurvivesResetAndServiceRestart|ContextWindowCompactionCountIsPublishedWithWindowUpdate|ContextWindowResetDropsStale)' ./internal/orchestrator` — 5 passed.
- GREEN: `cd apps/backend && go test -run 'Test(UpdateSessionContextWindowSQLiteCountsStrictUsageDrops|PostgresUpdateSessionContextWindowCountsStrictUsageDrops)' ./internal/task/repository/sqlite` — SQLite behavior passed; PostgreSQL case was environment-gated and skipped because no test DSN was configured.
- Final targeted package check: `cd apps/backend && go test ./internal/task/repository/sqlite ./internal/orchestrator ./internal/backendapp ./internal/task/service` — passed.
- Full backend check: `cd apps/backend && go test ./...` — passed.
- Review fixup: context-window event persistence is synchronous to preserve arrival order, and model-change clears route through the guarded reset callback. Full `go test -race ./...` was not rerun locally; the PR race job had already exposed an unrelated `internal/integration` failure on the previous head.
- `git diff --check` — passed. No external services or durable data outside the test databases were changed.
@@ -0,0 +1,76 @@
---
id: "02-context-hover-ui"
title: "Show compactions in context hover"
status: done
wave: 2
depends_on: ["01-persist-compaction-count"]
plan: "plan.md"
spec: "../../specs/context-compaction-count/spec.md"
---
# Task 02: Show compactions in context hover
## Intent
Carry the persisted count through live and hydrated frontend state, then show it with an accessible, translated accuracy disclosure in the existing context hover on every viewport.
## Acceptance
- Live session patches and page hydration populate `ContextWindowEntry.compactionCount`, treating absent, invalid, or negative legacy values as zero.
- The existing context hover shows zero and non-zero counts and explains that Kandev infers them from token drops, so missing samples or provider resets can make the value approximate.
- The shared inline help remains accessible by pointer, keyboard, and touch without nesting another tooltip or changing the existing desktop/mobile composition.
## TDD sequence
1. Add parser, live-handler, hydration-hook, and rendered-hover cases; run them and confirm count expectations fail.
2. Add English i18n keys, regenerate the pseudo locale, and implement the smallest state/hook/UI changes.
3. Rerun the exact tests, typecheck, and i18n checks; refactor only where needed to keep the component within repository limits.
## Files likely touched
- `apps/web/lib/state/slices/session-runtime/types.ts`
- `apps/web/lib/state/slices/session-runtime/context-window.ts`
- `apps/web/lib/state/slices/session-runtime/context-window.test.ts`
- `apps/web/lib/ws/handlers/agent-session.ts`
- `apps/web/lib/ws/handlers/agent-session.test.ts`
- `apps/web/hooks/domains/session/use-session-context-window.ts`
- `apps/web/hooks/domains/session/use-session-context-window.test.ts`
- `apps/web/components/task/chat/token-usage-display.tsx`
- `apps/web/components/task/chat/token-usage-display.test.tsx`
- `apps/web/src/locales/en/common.json`
- `apps/web/src/locales/pseudo/common.json`
## Dependencies
Task 01, which defines the persisted and live metadata field.
## Parallelism
`sequential` — the UI consumes the backend metadata contract and shares its state/parser files with all hover behavior.
## Verification
- `cd apps && pnpm install --frozen-lockfile`
- `cd apps && pnpm --filter @kandev/web test -- --run lib/state/slices/session-runtime/context-window.test.ts lib/ws/handlers/agent-session.test.ts hooks/domains/session/use-session-context-window.test.ts components/task/chat/token-usage-display.test.tsx`
- `cd apps/web && pnpm run typecheck`
- `cd apps && pnpm --filter @kandev/web run i18n:check`
## Inputs
- Spec `What`, `API surface`, and the final scenario.
- Frontend state flow and i18n rules in `apps/web/AGENTS.md`.
- Mobile content-only guidance in `.agents/skills/mobile-parity/SKILL.md`.
- Existing source disclosure and pinnable parent hover in `apps/web/components/task/chat/token-usage-display.tsx`.
## Output contract
Report the result, actual files changed, exact tests and counts, translated keys, rendered-check status, blockers, risks, and synchronized task/plan status in this conversation.
## Results
- RED: the rendered-hover case failed before the count row existed; parser/live-handler tests also established the missing-field baseline.
- GREEN: `cd apps && pnpm --filter @kandev/web test -- --run lib/state/slices/session-runtime/context-window.test.ts lib/ws/handlers/agent-session.test.ts hooks/domains/session/use-session-context-window.test.ts components/task/chat/token-usage-display.test.tsx` — 4 files passed, 68 tests passed.
- `cd apps/web && pnpm run typecheck` — passed.
- `cd apps && pnpm --filter @kandev/web run i18n:check` — passed; English and pseudo locale contain the three new keys.
- `cd apps && pnpm --filter @kandev/web run i18n:ratchet` — passed.
- Public docs impact: updated `docs/public/sessions-and-review.md` with the inferred-count limitation; public-doc validation passed.
@@ -0,0 +1,63 @@
---
id: "03-context-hover-e2e"
title: "Cover desktop and mobile hover"
status: done
wave: 3
depends_on: ["02-context-hover-ui"]
plan: "plan.md"
spec: "../../specs/context-compaction-count/spec.md"
---
# Task 03: Cover desktop and mobile hover
## Intent
Prove that pointer and touch users can read the inferred count and its accuracy disclosure without destabilizing or overflowing the existing context-window hover.
## Acceptance
- The desktop context-hover scenario verifies the seeded count, pointer-reachable accuracy help, and continued visibility of the parent hover.
- The mobile scenario verifies the same count and help through tap-pinned interaction with no document-level horizontal overflow.
- Shared E2E seeding supplies the count through the same `ContextWindowEntry` shape used by production state.
## TDD sequence
1. Extend the shared seed plus desktop/mobile expectations and run both focused specs against the pre-change UI to observe the missing count/help failure.
2. With Task 02 present, rerun the managed production-build E2E commands and inspect any failure artifacts.
3. Reconcile selectors and geometry assertions without changing the feature contract.
## Files likely touched
- `apps/web/e2e/tests/chat/context-window-source-helpers.ts`
- `apps/web/e2e/tests/chat/context-window-source.spec.ts`
- `apps/web/e2e/tests/chat/mobile-context-window-source.spec.ts`
## Dependencies
Task 02.
## Parallelism
`sequential` — browser proof depends on the completed backend-shaped frontend state and hover UI.
## Verification
- `cd apps && pnpm install --frozen-lockfile`
- `cd apps/web && pnpm e2e:run tests/chat/context-window-source.spec.ts`
- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-context-window-source.spec.ts`
## Inputs
- Spec final scenario and `Out of scope`.
- Plan `Mobile design contract` and `E2E Tests`.
- Existing E2E mechanics in `.agents/skills/e2e/SKILL.md` and current source-help tests.
## Output contract
Report the result, actual files changed, discovered test counts, exact commands, browser artifacts, teardown evidence, blockers, risks, and synchronized task/plan status in this conversation.
## Results
- `cd apps/web && pnpm e2e:run tests/chat/context-window-source.spec.ts` — Chromium desktop passed (1 test).
- `cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-context-window-source.spec.ts` — mobile-chrome passed (1 test).
- Both managed runs built the backend and Vite assets, seeded the shared count (`2`), verified the inline accuracy disclosure, and completed teardown without overflow failures.
+4 -1
View File
@@ -119,7 +119,10 @@ Structured shell-command activity keeps the command, working directory, status,
The ring in the chat-input toolbar shows the active session's context-window use
when the agent reports a trustworthy window size. Open it to see used and total
tokens; it focuses on the active session's context window. For account-wide
tokens; it focuses on the active session's context window. The hover also shows
a session compaction count inferred from observed drops in used tokens. ACP does
not report compaction events, so missing samples or provider resets can make the
count approximate. For account-wide
provider usage, install the [Provider Usage
plugin](https://github.com/kdlbs/kandev-plugin-provider-usage), which adds a
provider pill to the session top bar and can add a compact display to the global
+1
View File
@@ -157,6 +157,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [embedded-vscode-executor-availability](ui/embedded-vscode-executor-availability.md) | approved |
| [embedded-vscode-windows-availability](ui/embedded-vscode-windows-availability.md) | archived; superseded by embedded-vscode-executor-availability |
| [ws-connectivity-warning](ui/ws-connectivity-warning.md) | approved |
| [context-compaction-count](context-compaction-count/spec.md) | approved |
| [context-window reset freshness](context-window-reset-freshness/spec.md) | shipped |
## system-page/ — operational diagnostics & maintenance UI
@@ -0,0 +1,65 @@
---
status: approved
created: 2026-08-02
owner: kandev
---
# Context Compaction Count
## Why
Users can see how much of an agent session's context window is currently occupied, but they cannot tell how often the conversation has already been compacted. That history helps them understand when an old session has repeatedly shed context and when starting a fresh session may be preferable.
## What
- The context-window hover shows a non-negative compaction count for the active agent session.
- Kandev infers an automatic compaction when a valid context-window sample reports fewer used tokens than the previous persisted sample for the same session.
- The first valid sample establishes a comparison baseline and does not increment the count.
- Equal or increasing usage does not increment the count.
- A successful explicit context reset or model change clears the usage comparison baseline without incrementing or erasing the session's lifetime count.
- The hover includes an info control explaining that Kandev derives the count from observed usage drops rather than an ACP compaction event, so missing samples and provider-side resets can make it approximate.
- The count and its explanatory control are available through the existing desktop hover and mobile tap-pinned context-window surface.
## Data model
`task_sessions.metadata` owns both the current sample and the lifetime count:
- `context_window`: existing nullable object containing `size`, `used`, `remaining`, `efficiency`, and `source`; its `used` value is the comparison baseline.
- `context_compaction_count`: non-negative integer. Absence is equivalent to `0` for sessions created before this feature.
The count belongs to one `task_sessions` row. It is not shared across sessions for the same task and is not reset when `context_window` is cleared.
## API surface
No new HTTP route or ACP extension is introduced. Existing session metadata and `session.state_changed` payloads expose `context_compaction_count` alongside `context_window`. Context-window frontend state represents the count as `compactionCount` for the active session hover.
## Failure modes
- If a context-window update cannot be persisted, neither its baseline nor a derived increment becomes visible; Kandev logs the failure and a later valid sample compares against the last successfully persisted sample.
- ACP does not identify compaction events. A provider-side usage reset that appears as a decrease can be counted as a compaction, and compactions between missing usage samples can be missed. The UI disclosure states that the value is approximate.
- Invalid or unusable context-window reports remain hidden under the existing reliability rules and do not create user-visible count-only UI.
## Persistence guarantees
- `context_compaction_count` survives backend restarts, agent execution recovery, page refreshes, and explicit context resets for as long as the owning task session exists.
- Restart recovery uses the persisted `context_window.used` value as the next comparison baseline when it is present.
- Legacy sessions without `context_compaction_count` begin at zero without a schema backfill.
- Deleting the owning task session deletes its count with the session metadata.
## Scenarios
- **GIVEN** a session has no persisted context-window sample, **WHEN** its first valid sample arrives, **THEN** Kandev stores the sample and shows `0` compactions without incrementing the count.
- **GIVEN** a session's persisted usage is 120,000 tokens with zero compactions, **WHEN** a valid sample reports 80,000 used tokens, **THEN** Kandev persists and shows a count of `1`.
- **GIVEN** a session already has a compaction count, **WHEN** equal or increasing usage samples arrive, **THEN** the count remains unchanged.
- **GIVEN** a session has a persisted baseline and count, **WHEN** the backend restarts and the next valid sample reports lower usage, **THEN** the persisted count increments exactly once.
- **GIVEN** the same decreased-usage update is delivered more than once, **WHEN** Kandev persists each delivery, **THEN** the count increments only for the first delivery because subsequent deliveries equal the persisted baseline.
- **GIVEN** a session has a non-zero count, **WHEN** the user successfully resets context or changes models, **THEN** the current context-window sample is cleared and the lifetime count remains unchanged.
- **GIVEN** a counted session has a reliable current context-window sample, **WHEN** the user opens its context hover with a pointer or touch, **THEN** the hover shows the count and an accessible explanation that Kandev infers it from usage drops and that it may be approximate.
## Out of scope
- Adding a compaction event to ACP or agent adapters.
- Distinguishing an automatic compaction from every provider-side reset when both produce the same observed usage decrease.
- Counting explicit user context resets or model changes as compactions.
- Aggregating compactions across sessions, tasks, agents, or workspaces.
- Showing a count outside the existing context-window hover.