Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions cmd/entire/cli/integration_test/fully_condensed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//go:build integration

package integration

import (
"testing"

"github.com/entireio/cli/cmd/entire/cli/session"
)

// TestFullyCondensed_ReactivationClearsFlag tests that when a fully-condensed
// ENDED session is reactivated (new UserPromptSubmit), the FullyCondensed flag
// is cleared so the session is processed normally on future commits.
//
// This is the critical safety test: if FullyCondensed isn't cleared on
// reactivation, the session's new work would be silently skipped in PostCommit.
//
// State machine transitions tested:
// - IDLE + SessionStop -> ENDED
// - ENDED + GitCommit -> ENDED + ActionCondenseIfFilesTouched (sets FullyCondensed)
// - ENDED + TurnStart -> ACTIVE + ActionClearEndedAt (clears FullyCondensed)
func TestFullyCondensed_ReactivationClearsFlag(t *testing.T) {
t.Parallel()

env := NewFeatureBranchEnv(t)

// ========================================
// Phase 1: Create session, do work, stop, end, then commit → FullyCondensed
// ========================================
t.Log("Phase 1: Run a full session lifecycle and commit after ending")

sess := env.NewSession()

if err := env.SimulateUserPromptSubmit(sess.ID); err != nil {
t.Fatalf("user-prompt-submit failed: %v", err)
}

env.WriteFile("feature.go", "package main\n\nfunc Feature() {}\n")
sess.CreateTranscript("Create feature function", []FileChange{
{Path: "feature.go", Content: "package main\n\nfunc Feature() {}\n"},
})

if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
t.Fatalf("SimulateStop failed: %v", err)
}

// Verify IDLE with files touched
state, err := env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState failed: %v", err)
}
if state.Phase != session.PhaseIdle {
t.Fatalf("Expected IDLE after stop, got %s", state.Phase)
}
if len(state.FilesTouched) == 0 {
t.Fatal("FilesTouched should be non-empty after agent work")
}

// End the session BEFORE committing — FullyCondensed is only set for ENDED sessions
if err := env.SimulateSessionEnd(sess.ID); err != nil {
t.Fatalf("SimulateSessionEnd failed: %v", err)
}

state, err = env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState failed: %v", err)
}
if state.Phase != session.PhaseEnded {
t.Fatalf("Expected ENDED after session end, got %s", state.Phase)
}

// Commit the work — PostCommit condenses the ENDED session with files touched,
// all files are committed so no carry-forward remains → FullyCondensed = true
env.GitCommitWithShadowHooks("Add feature", "feature.go")

// Verify ENDED with FullyCondensed
state, err = env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState failed: %v", err)
}
if state.Phase != session.PhaseEnded {
t.Fatalf("Expected ENDED after commit, got %s", state.Phase)
}
if !state.FullyCondensed {
t.Fatal("Session should be FullyCondensed after condensation with no carry-forward")
}

// ========================================
// Phase 2: Reactivate the session → FullyCondensed should be cleared
// ========================================
t.Log("Phase 2: Reactivate the ended session")

if err := env.SimulateUserPromptSubmit(sess.ID); err != nil {
t.Fatalf("user-prompt-submit (reactivation) failed: %v", err)
}

state, err = env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState failed: %v", err)
}
if state.Phase != session.PhaseActive {
t.Errorf("Expected ACTIVE after reactivation, got %s", state.Phase)
}
if state.FullyCondensed {
t.Error("FullyCondensed must be cleared on reactivation — " +
"otherwise new work would be silently skipped in PostCommit")
}
}
1 change: 1 addition & 0 deletions cmd/entire/cli/session/phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ func ApplyTransition(ctx context.Context, state *State, result TransitionResult,
state.LastInteractionTime = &now
case ActionClearEndedAt:
state.EndedAt = nil
state.FullyCondensed = false

// Strategy-specific actions: skip remaining after the first handler error.
case ActionCondense:
Expand Down
53 changes: 44 additions & 9 deletions cmd/entire/cli/session/phase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -503,18 +503,53 @@ func TestApplyTransition_CallsHandlerForWarnStaleSession(t *testing.T) {
func TestApplyTransition_ClearsEndedAt(t *testing.T) {
t.Parallel()

endedAt := time.Now().Add(-time.Hour)
state := &State{Phase: PhaseEnded, EndedAt: &endedAt}
handler := &mockActionHandler{}
result := TransitionResult{
NewPhase: PhaseIdle,
Actions: []Action{ActionClearEndedAt},
tests := []struct {
name string
fullyCondensed bool
newPhase Phase
actions []Action
}{
{
name: "SessionStart_to_IDLE",
newPhase: PhaseIdle,
actions: []Action{ActionClearEndedAt},
},
{
name: "TurnStart_to_ACTIVE",
newPhase: PhaseActive,
actions: []Action{ActionClearEndedAt, ActionUpdateLastInteraction},
},
{
name: "TurnStart_clears_FullyCondensed",
fullyCondensed: true,
newPhase: PhaseActive,
actions: []Action{ActionClearEndedAt, ActionUpdateLastInteraction},
},
{
name: "SessionStart_clears_FullyCondensed",
fullyCondensed: true,
newPhase: PhaseIdle,
actions: []Action{ActionClearEndedAt},
},
}

err := ApplyTransition(context.Background(), state, result, handler)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

require.NoError(t, err)
assert.Nil(t, state.EndedAt)
endedAt := time.Now().Add(-time.Hour)
state := &State{Phase: PhaseEnded, EndedAt: &endedAt, FullyCondensed: tt.fullyCondensed}
handler := &mockActionHandler{}
result := TransitionResult{NewPhase: tt.newPhase, Actions: tt.actions}

err := ApplyTransition(context.Background(), state, result, handler)

require.NoError(t, err)
assert.Nil(t, state.EndedAt)
assert.False(t, state.FullyCondensed,
"FullyCondensed must be cleared when ActionClearEndedAt runs")
})
}
}

func TestApplyTransition_ReturnsHandlerError_ButRunsCommonActions(t *testing.T) {
Expand Down
7 changes: 7 additions & 0 deletions cmd/entire/cli/session/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ type State struct {
// sessions that have been condensed at least once. Cleared on new prompt.
LastCheckpointID id.CheckpointID `json:"last_checkpoint_id,omitempty"`

// FullyCondensed indicates this session has been condensed and has no remaining
// carry-forward files. PostCommit skips fully-condensed sessions entirely.
// Set after successful condensation when no files remain for carry-forward
// and the session phase is ENDED. Cleared on session reactivation (ENDED →
// ACTIVE via TurnStart, or ENDED → IDLE via SessionStart) by ActionClearEndedAt.
FullyCondensed bool `json:"fully_condensed,omitempty"`

// AgentType identifies the agent that created this session (e.g., "Claude Code", "Gemini CLI", "Cursor")
AgentType types.AgentType `json:"agent_type,omitempty"`

Expand Down
16 changes: 16 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,11 @@ func (s *ManualCommitStrategy) PostCommit(ctx context.Context) error { //nolint:
committedFileSet := filesChangedInCommit(commit, headTree, parentTree)

for _, state := range sessions {
// Skip fully-condensed ended sessions — no work remains.
// These sessions only persist for LastCheckpointID (amend trailer reuse).
if state.FullyCondensed && state.Phase == session.PhaseEnded {
continue
}
s.postCommitProcessSession(ctx, repo, state, &transitionCtx, checkpointID,
head, commit, newHead, headTree, parentTree, committedFileSet,
shadowBranchesToDelete, uncondensedActiveOnBranch)
Expand Down Expand Up @@ -947,6 +952,13 @@ func (s *ManualCommitStrategy) postCommitProcessSession(
}
}

// Mark ENDED sessions as fully condensed when no carry-forward remains.
// PostCommit will skip these sessions entirely on future commits.
// They persist only for LastCheckpointID (amend trailer restoration).
if handler.condensed && state.Phase == session.PhaseEnded && len(state.FilesTouched) == 0 {
state.FullyCondensed = true
}

// Save the updated state
if err := s.saveSessionState(ctx, state); err != nil {
logging.Warn(logCtx, "failed to update session state",
Expand Down Expand Up @@ -1093,6 +1105,10 @@ func (s *ManualCommitStrategy) filterSessionsWithNewContent(ctx context.Context,
var result []*SessionState

for _, state := range sessions {
// Skip fully-condensed ended sessions — no new content possible.
if state.FullyCondensed && state.Phase == session.PhaseEnded {
continue
}
hasNew, err := s.sessionHasNewContent(ctx, repo, state)
if err != nil {
// On error, include the session (fail open for hooks)
Expand Down
151 changes: 151 additions & 0 deletions cmd/entire/cli/strategy/phase_postcommit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1992,3 +1992,154 @@ func TestPostCommit_EndedSession_SkipsSentinelWait(t *testing.T) {
require.NoError(t, err, "entire/checkpoints/v1 branch should exist after condensation")
assert.NotNil(t, sessionsRef)
}

// TestPostCommit_EndedSession_SetsFullyCondensed verifies that an ENDED session
// is marked FullyCondensed after condensation when no carry-forward files remain.
func TestPostCommit_EndedSession_SetsFullyCondensed(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

repo, err := git.PlainOpen(dir)
require.NoError(t, err)

s := &ManualCommitStrategy{}
sessionID := "test-postcommit-ended-fully-condensed"

// Initialize session and save a checkpoint
setupSessionWithCheckpoint(t, s, repo, dir, sessionID)

// Set phase to ENDED with files touched (the committed file matches shadow branch)
state, err := s.loadSessionState(context.Background(), sessionID)
require.NoError(t, err)
now := time.Now()
state.Phase = session.PhaseEnded
state.EndedAt = &now
state.FilesTouched = []string{"test.txt"}
require.NoError(t, s.saveSessionState(context.Background(), state))

// Create a commit that includes test.txt — this commits the only touched file,
// so carry-forward will be empty afterward.
commitWithCheckpointTrailer(t, repo, dir, "fc01fc01fc01")

// Run PostCommit
err = s.PostCommit(context.Background())
require.NoError(t, err)

// Verify FullyCondensed is set
state, err = s.loadSessionState(context.Background(), sessionID)
require.NoError(t, err)
assert.True(t, state.FullyCondensed,
"ENDED session with no carry-forward should be marked FullyCondensed")
assert.Equal(t, session.PhaseEnded, state.Phase)
assert.Empty(t, state.FilesTouched,
"FilesTouched should be empty after all files were committed")
}

// TestPostCommit_FullyCondensedEndedSession_SkippedOnNextCommit verifies that
// a FullyCondensed ENDED session is skipped entirely on subsequent commits,
// avoiding redundant shadow branch resolution and condensation attempts.
func TestPostCommit_FullyCondensedEndedSession_SkippedOnNextCommit(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

repo, err := git.PlainOpen(dir)
require.NoError(t, err)

s := &ManualCommitStrategy{}
sessionID := "test-postcommit-skip-fully-condensed"

// Initialize session and save a checkpoint
setupSessionWithCheckpoint(t, s, repo, dir, sessionID)

// Set phase to ENDED with files touched
state, err := s.loadSessionState(context.Background(), sessionID)
require.NoError(t, err)
now := time.Now()
state.Phase = session.PhaseEnded
state.EndedAt = &now
state.FilesTouched = []string{"test.txt"}
require.NoError(t, s.saveSessionState(context.Background(), state))

// First commit — condenses the ENDED session and marks it FullyCondensed
commitWithCheckpointTrailer(t, repo, dir, "fc02fc02fc02")
err = s.PostCommit(context.Background())
require.NoError(t, err)

// Verify it's now fully condensed
state, err = s.loadSessionState(context.Background(), sessionID)
require.NoError(t, err)
require.True(t, state.FullyCondensed)

// Record the LastCheckpointID — this should persist (the reason the session exists)
lastCPID := state.LastCheckpointID

// Second commit — the fully-condensed session should be skipped entirely.
// Create a new file so there's something to commit.
require.NoError(t, os.WriteFile(filepath.Join(dir, "other.txt"), []byte("other"), 0o644))
wt, err := repo.Worktree()
require.NoError(t, err)
_, err = wt.Add("other.txt")
require.NoError(t, err)
commitMsg := "second commit\n\n" + trailers.CheckpointTrailerKey + ": fc03fc03fc03\n"
_, err = wt.Commit(commitMsg, &git.CommitOptions{
Author: &object.Signature{
Name: "Test",
Email: "test@test.com",
When: time.Now(),
},
})
require.NoError(t, err)

// Run PostCommit again
err = s.PostCommit(context.Background())
require.NoError(t, err)

// Verify state is unchanged — the session was skipped, not re-processed
state, err = s.loadSessionState(context.Background(), sessionID)
require.NoError(t, err)
assert.True(t, state.FullyCondensed,
"FullyCondensed should still be true after being skipped")
assert.Equal(t, session.PhaseEnded, state.Phase)
assert.Equal(t, lastCPID, state.LastCheckpointID,
"LastCheckpointID should be preserved across skipped commits")
}

// TestPostCommit_NonEndedSession_NotMarkedFullyCondensed verifies that ACTIVE
// and IDLE sessions are never marked FullyCondensed, even when condensed with
// no carry-forward. Only ENDED sessions get the flag.
func TestPostCommit_NonEndedSession_NotMarkedFullyCondensed(t *testing.T) {
for _, phase := range []session.Phase{session.PhaseActive, session.PhaseIdle} {
t.Run(string(phase), func(t *testing.T) {
dir := setupGitRepo(t)
t.Chdir(dir)

repo, err := git.PlainOpen(dir)
require.NoError(t, err)

s := &ManualCommitStrategy{}
sessionID := "test-postcommit-" + string(phase) + "-not-fully-condensed"

// Initialize session and save a checkpoint
setupSessionWithCheckpoint(t, s, repo, dir, sessionID)

state, err := s.loadSessionState(context.Background(), sessionID)
require.NoError(t, err)
state.Phase = phase
state.FilesTouched = []string{"test.txt"}
require.NoError(t, s.saveSessionState(context.Background(), state))

// Commit the file
commitWithCheckpointTrailer(t, repo, dir, "fc04fc04fc04")

// Run PostCommit
err = s.PostCommit(context.Background())
require.NoError(t, err)

// Verify FullyCondensed is NOT set
state, err = s.loadSessionState(context.Background(), sessionID)
require.NoError(t, err)
assert.False(t, state.FullyCondensed,
"%s sessions must never be marked FullyCondensed", phase)
})
}
}
Loading