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
8 changes: 4 additions & 4 deletions cmd/entire/cli/session/phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ func transitionFromIdle(event Event, ctx TransitionContext) TransitionResult {
}
return TransitionResult{
NewPhase: PhaseIdle,
Actions: []Action{ActionCondense, ActionUpdateLastInteraction},
Actions: []Action{ActionCondense},
}
case EventSessionStart:
// Already condensable, no-op.
Expand Down Expand Up @@ -208,7 +208,7 @@ func transitionFromActive(event Event, ctx TransitionContext) TransitionResult {
}
return TransitionResult{
NewPhase: PhaseActive,
Actions: []Action{ActionCondense, ActionUpdateLastInteraction},
Actions: []Action{ActionCondense},
}
case EventSessionStart:
return TransitionResult{
Expand Down Expand Up @@ -249,12 +249,12 @@ func transitionFromEnded(event Event, ctx TransitionContext) TransitionResult {
if ctx.HasFilesTouched {
return TransitionResult{
NewPhase: PhaseEnded,
Actions: []Action{ActionCondenseIfFilesTouched, ActionUpdateLastInteraction},
Actions: []Action{ActionCondenseIfFilesTouched},
}
}
return TransitionResult{
NewPhase: PhaseEnded,
Actions: []Action{ActionDiscardIfNoFiles, ActionUpdateLastInteraction},
Actions: []Action{ActionDiscardIfNoFiles},
}
case EventSessionStart:
return TransitionResult{
Expand Down
44 changes: 29 additions & 15 deletions cmd/entire/cli/session/phase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ func TestTransitionFromIdle(t *testing.T) {
current: PhaseIdle,
event: EventGitCommit,
wantPhase: PhaseIdle,
wantActions: []Action{ActionCondense, ActionUpdateLastInteraction},
wantActions: []Action{ActionCondense},
},
{
name: "GitCommit_rebase_skips_everything",
Expand Down Expand Up @@ -196,7 +196,7 @@ func TestTransitionFromActive(t *testing.T) {
current: PhaseActive,
event: EventGitCommit,
wantPhase: PhaseActive,
wantActions: []Action{ActionCondense, ActionUpdateLastInteraction},
wantActions: []Action{ActionCondense},
},
{
name: "GitCommit_rebase_skips_everything",
Expand Down Expand Up @@ -239,14 +239,14 @@ func TestTransitionFromEnded(t *testing.T) {
event: EventGitCommit,
ctx: TransitionContext{HasFilesTouched: true},
wantPhase: PhaseEnded,
wantActions: []Action{ActionCondenseIfFilesTouched, ActionUpdateLastInteraction},
wantActions: []Action{ActionCondenseIfFilesTouched},
},
{
name: "GitCommit_without_files_discards",
current: PhaseEnded,
event: EventGitCommit,
wantPhase: PhaseEnded,
wantActions: []Action{ActionDiscardIfNoFiles, ActionUpdateLastInteraction},
wantActions: []Action{ActionDiscardIfNoFiles},
},
{
name: "GitCommit_rebase_skips_everything",
Expand Down Expand Up @@ -295,7 +295,7 @@ func TestTransitionBackwardCompat(t *testing.T) {
current: Phase(""),
event: EventGitCommit,
wantPhase: PhaseIdle,
wantActions: []Action{ActionCondense, ActionUpdateLastInteraction},
wantActions: []Action{ActionCondense},
},
{
name: "empty_phase_SessionStop_treated_as_IDLE",
Expand Down Expand Up @@ -441,15 +441,14 @@ func TestApplyTransition_CallsHandlerForCondense(t *testing.T) {
handler := &mockActionHandler{}
result := TransitionResult{
NewPhase: PhaseIdle,
Actions: []Action{ActionCondense, ActionUpdateLastInteraction},
Actions: []Action{ActionCondense},
}

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

require.NoError(t, err)
assert.True(t, handler.condenseCalled)
assert.Equal(t, PhaseIdle, state.Phase)
require.NotNil(t, state.LastInteractionTime)
}

func TestApplyTransition_CallsHandlerForCondenseIfFilesTouched(t *testing.T) {
Expand All @@ -459,7 +458,7 @@ func TestApplyTransition_CallsHandlerForCondenseIfFilesTouched(t *testing.T) {
handler := &mockActionHandler{}
result := TransitionResult{
NewPhase: PhaseEnded,
Actions: []Action{ActionCondenseIfFilesTouched, ActionUpdateLastInteraction},
Actions: []Action{ActionCondenseIfFilesTouched},
}

err := ApplyTransition(context.Background(), state, result, handler)
Expand All @@ -475,7 +474,7 @@ func TestApplyTransition_CallsHandlerForDiscardIfNoFiles(t *testing.T) {
handler := &mockActionHandler{}
result := TransitionResult{
NewPhase: PhaseEnded,
Actions: []Action{ActionDiscardIfNoFiles, ActionUpdateLastInteraction},
Actions: []Action{ActionDiscardIfNoFiles},
}

err := ApplyTransition(context.Background(), state, result, handler)
Expand Down Expand Up @@ -552,25 +551,22 @@ func TestApplyTransition_ClearsEndedAt(t *testing.T) {
}
}

func TestApplyTransition_ReturnsHandlerError_ButRunsCommonActions(t *testing.T) {
func TestApplyTransition_ReturnsHandlerError_ButSetsPhase(t *testing.T) {
t.Parallel()

state := &State{Phase: PhaseActive}
handler := &mockActionHandler{returnErr: errors.New("condense failed")}
// Synthetic transition with [Condense, UpdateLastInteraction] actions.
result := TransitionResult{
NewPhase: PhaseIdle,
Actions: []Action{ActionCondense, ActionUpdateLastInteraction},
Actions: []Action{ActionCondense},
}

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

require.Error(t, err)
assert.Contains(t, err.Error(), "condense failed")
// Phase must still be set even though handler failed.
assert.Equal(t, PhaseIdle, state.Phase)
// Common action must still run even though handler failed.
require.NotNil(t, state.LastInteractionTime,
"UpdateLastInteraction must run despite earlier handler error")
}

func TestApplyTransition_StopsOnFirstHandlerError(t *testing.T) {
Expand All @@ -590,6 +586,24 @@ func TestApplyTransition_StopsOnFirstHandlerError(t *testing.T) {
assert.False(t, handler.warnStaleSessionCalled, "should stop on first error")
}

func TestApplyTransition_UpdateLastInteractionRunsDespiteHandlerError(t *testing.T) {
t.Parallel()

state := &State{Phase: PhaseEnded}
handler := &mockActionHandler{returnErr: errors.New("condense failed")}
result := TransitionResult{
NewPhase: PhaseEnded,
Actions: []Action{ActionCondenseIfFilesTouched, ActionUpdateLastInteraction},
}

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

require.Error(t, err)
assert.Contains(t, err.Error(), "condense failed")
assert.True(t, handler.condenseIfFilesTouchedCalled)
require.NotNil(t, state.LastInteractionTime, "UpdateLastInteraction must run despite earlier handler error")
}

func TestApplyTransition_ClearEndedAtRunsDespiteHandlerError(t *testing.T) {
t.Parallel()

Expand Down
3 changes: 2 additions & 1 deletion cmd/entire/cli/session/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ type State struct {
// - Cleared when session is reset (ResetSession deletes the state file entirely)
TurnCheckpointIDs []string `json:"turn_checkpoint_ids,omitempty"`

// LastInteractionTime is updated on every hook invocation.
// LastInteractionTime is updated on agent-interaction events (TurnStart,
// TurnEnd, SessionStop, Compaction) but NOT on git commit hooks.
// Used for stale session detection in "entire doctor".
LastInteractionTime *time.Time `json:"last_interaction_time,omitempty"`

Expand Down