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
16 changes: 0 additions & 16 deletions cmd/entire/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,22 +279,6 @@ func GetLogLevel() string {
return settings.LogLevel
}

// IsMultiSessionWarningDisabled checks if multi-session warnings are disabled.
// Returns false (show warnings) by default if settings cannot be loaded or the key is missing.
func IsMultiSessionWarningDisabled() bool {
settings, err := LoadEntireSettings()
if err != nil {
return false // Default: show warnings
}
if settings.StrategyOptions == nil {
return false
}
if disabled, ok := settings.StrategyOptions["disable_multisession_warning"].(bool); ok {
return disabled
}
return false
}

// GetAgentsWithHooksInstalled returns names of agents that have hooks installed.
func GetAgentsWithHooksInstalled() []agent.AgentName {
var installed []agent.AgentName
Expand Down
99 changes: 20 additions & 79 deletions cmd/entire/cli/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,19 @@ func handleSessionStartCommon() error {
return errors.New("no session_id in input")
}

if IsMultiSessionWarningDisabled() {
return nil
// Build informational message
message := "\n\nPowered by Entire:\n This conversation will be linked to your next commit."

// Check for concurrent sessions and append count if any
strat := GetStrategy()
if concurrentChecker, ok := strat.(strategy.ConcurrentSessionChecker); ok {
if count, err := concurrentChecker.CountOtherActiveSessionsWithCheckpoints(input.SessionID); err == nil && count > 0 {
message += fmt.Sprintf("\n %d other active conversation(s) in this workspace will also be included.\n Use 'entire status' for more information.", count)
}
}
if err := checkConcurrentSessions(ag, input.SessionID); err != nil {

// Output informational message using agent-specific format
if err := outputHookResponse(message); err != nil {
return err
}

Expand All @@ -277,87 +286,19 @@ func handleSessionStartCommon() error {
return nil
}

// checkConcurrentSessions checks for concurrent session conflicts and shows warnings if needed.
// Returns a non-nil error if the hook should be skipped due to an unresolved conflict or if a check fails; otherwise returns nil.
func checkConcurrentSessions(ag agent.Agent, sessionID string) error {
strat := GetStrategy()

concurrentChecker, ok := strat.(strategy.ConcurrentSessionChecker)
if !ok {
return nil // Strategy doesn't support concurrent checks
}

// Check for other active sessions with checkpoints (on current HEAD)
existingSessionState, checkErr := concurrentChecker.HasOtherActiveSessionsWithCheckpoints(sessionID)
hasConflict := checkErr == nil && existingSessionState != nil

if hasConflict {
// Try to get the conflicting session's agent type from its state file
// If it's a different agent type, use that agent's resume command format
var resumeCmd string
if existingSessionState != nil && existingSessionState.AgentType != "" {
if conflictingAgent, agentErr := agent.GetByAgentType(existingSessionState.AgentType); agentErr == nil {
resumeCmd = conflictingAgent.FormatResumeCommand(existingSessionState.SessionID)
}
}
// Fall back to current agent if we couldn't get the conflicting agent
if resumeCmd == "" {
resumeCmd = ag.FormatResumeCommand(existingSessionState.SessionID)
}

// Get CLI command name for fresh start (e.g., "claude" or "gemini")
cliCmd := "claude"
if ag.Type() == agent.AgentTypeGemini {
cliCmd = "gemini"
}

message := fmt.Sprintf(
"\nYou have an existing session running (%s).\n"+
"Do you want to continue with this new session (%s)?\n\n"+
"Yes: Ignore this warning\n"+
"No: Type /exit, then either:\n"+
" • Resume the other session: %s\n"+
" • Reset and start fresh: entire reset --force && %s\n\n"+
"To hide this notice in the future: entire enable --disable-multisession-warning",
existingSessionState.SessionID,
sessionID,
resumeCmd,
cliCmd,
)
// Output warning JSON response using agent-specific format
if err := outputConflictWarning(ag, message); err != nil {
return err
}
}

return nil
}

// outputConflictWarning outputs a conflict warning message using the appropriate format for the agent.
func outputConflictWarning(ag agent.Agent, message string) error {
//nolint:exhaustive // default case handles all other agent types
switch ag.Type() {
case agent.AgentTypeGemini:
return outputGeminiWarningResponse(message)
default:
return outputHookResponse(message)
}
}

// geminiWarningResponse represents a JSON response for Gemini CLI session-start warnings.
// Unlike blocking responses, this just injects a system message without blocking the session.
type geminiWarningResponse struct {
// hookResponse represents a JSON response.
// Used to control whether Agent continues processing the prompt.
type hookResponse struct {
SystemMessage string `json:"systemMessage,omitempty"`
}

// outputGeminiWarningResponse outputs a warning JSON response to stdout for Gemini CLI hooks.
// This injects a system message into the conversation without blocking the session.
func outputGeminiWarningResponse(message string) error {
resp := geminiWarningResponse{
SystemMessage: message,
// outputHookResponse outputs a JSON response to stdout
func outputHookResponse(reason string) error {
resp := hookResponse{
SystemMessage: reason,
}
if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil {
return fmt.Errorf("failed to encode gemini warning response: %w", err)
return fmt.Errorf("failed to encode hook response: %w", err)
}
return nil
}
18 changes: 0 additions & 18 deletions cmd/entire/cli/hooks_claudecode_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package cli

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
Expand Down Expand Up @@ -732,20 +731,3 @@ func markSessionEnded(sessionID string) error {
}
return nil
}

// hookResponse represents a JSON response for Claude Code hooks.
// Used to control whether Claude continues processing the prompt.
type hookResponse struct {
SystemMessage string `json:"systemMessage,omitempty"`
}

// outputHookResponse outputs a JSON response to stdout for Claude Code hooks.
func outputHookResponse(reason string) error {
resp := hookResponse{
SystemMessage: reason,
}
if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil {
return fmt.Errorf("failed to encode hook response: %w", err)
}
return nil
}
121 changes: 81 additions & 40 deletions cmd/entire/cli/integration_test/session_conflict_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,9 @@ func TestSessionIDConflict_ShadowBranchWithoutTrailer(t *testing.T) {
}
}

// TestSessionConflict_WarningMessageFormat tests that the session conflict warning message
// contains all expected components when a second session starts while another has uncommitted checkpoints.
func TestSessionConflict_WarningMessageFormat(t *testing.T) {
// TestSessionStart_InformationalMessage tests that the session start informational message
// contains the expected base text and concurrent session count when another session has uncommitted checkpoints.
func TestSessionStart_InformationalMessage(t *testing.T) {
env := NewTestEnv(t)
defer env.Cleanup()

Expand Down Expand Up @@ -346,10 +346,10 @@ func TestSessionConflict_WarningMessageFormat(t *testing.T) {
// Start a second session (different session ID, same base commit)
session2 := env.NewSession()

// Use SimulateSessionStartWithOutput to capture the warning message
// Use SimulateSessionStartWithOutput to capture the informational message
output := env.SimulateSessionStartWithOutput(session2.ID)

// The hook should succeed (no error) but output a warning message
// The hook should succeed (no error) and output an informational message
if output.Err != nil {
t.Fatalf("SimulateSessionStart (session2) failed: %v\nStderr: %s", output.Err, output.Stderr)
}
Expand All @@ -365,60 +365,101 @@ func TestSessionConflict_WarningMessageFormat(t *testing.T) {
}
}

// If there's no conflict (perhaps warning is disabled), skip the rest
if resp.SystemMessage == "" {
t.Log("No session conflict warning - this is expected if multi-session warning is disabled")
return
t.Fatal("Expected informational message in systemMessage, got empty")
}

msg := resp.SystemMessage
t.Logf("Session conflict warning message:\n%s", msg)
t.Logf("Session start message:\n%s", msg)

// Verify the warning message contains all expected components
// 1. The existing session ID
if !strings.Contains(msg, session1.EntireID) {
t.Errorf("Warning should contain existing session ID %q, got:\n%s", session1.EntireID, msg)
// Verify base informational message is present
if !strings.Contains(msg, "Powered by Entire") {
t.Errorf("Message should contain 'Powered by Entire', got:\n%s", msg)
}

// 2. The new session ID (as Entire session ID)
session2EntireID := sessionid.EntireSessionID(session2.ID)
if !strings.Contains(msg, session2EntireID) {
t.Errorf("Warning should contain new session ID %q, got:\n%s", session2EntireID, msg)
}

// 3. Resume command format: claude -r <session-id>
expectedResumeCmd := "claude -r " + session1.EntireID
if !strings.Contains(msg, expectedResumeCmd) {
t.Errorf("Warning should contain resume command %q, got:\n%s", expectedResumeCmd, msg)
if !strings.Contains(msg, "linked to your next commit") {
t.Errorf("Message should contain 'linked to your next commit', got:\n%s", msg)
}

// 4. Reset instruction: entire reset --force && claude
if !strings.Contains(msg, "entire reset --force && claude") {
t.Errorf("Warning should contain reset instruction 'entire reset --force && claude', got:\n%s", msg)
// Verify concurrent session count is shown
if !strings.Contains(msg, "1 other active conversation(s) in this workspace") {
t.Errorf("Message should contain '1 other active conversation(s) in this workspace', got:\n%s", msg)
}

// 5. Warning disable option: entire enable --disable-multisession-warning
if !strings.Contains(msg, "entire enable --disable-multisession-warning") {
t.Errorf("Warning should contain disable option 'entire enable --disable-multisession-warning', got:\n%s", msg)
}

// 6. Verify the message structure contains expected phrases
expectedPhrases := []string{
// Verify old warning phrases are NOT present
oldPhrases := []string{
"existing session running",
"Do you want to continue",
"Yes: Ignore this warning",
"No: Type /exit",
"Ignore this warning",
"/exit",
"Resume the other session",
"Reset and start fresh",
"hide this notice",
"disable-multisession-warning",
}
for _, phrase := range expectedPhrases {
if !strings.Contains(msg, phrase) {
t.Errorf("Warning should contain phrase %q, got:\n%s", phrase, msg)
for _, phrase := range oldPhrases {
if strings.Contains(msg, phrase) {
t.Errorf("Message should NOT contain old warning phrase %q, got:\n%s", phrase, msg)
}
}
}

// TestSessionStart_InformationalMessageNoConcurrentSessions tests that the base informational message
// is shown even when there are no concurrent sessions.
func TestSessionStart_InformationalMessageNoConcurrentSessions(t *testing.T) {
env := NewTestEnv(t)
defer env.Cleanup()

// Setup
env.InitRepo()
env.WriteFile("README.md", "# Test")
env.GitAdd("README.md")
env.GitCommit("Initial commit")

env.GitCheckoutNewBranch("feature/test")
env.InitEntire(strategy.StrategyNameManualCommit)

// Start a single session (no other sessions)
session1 := env.NewSession()

// Use SimulateSessionStartWithOutput to capture the informational message
output := env.SimulateSessionStartWithOutput(session1.ID)

// The hook should succeed
if output.Err != nil {
t.Fatalf("SimulateSessionStart failed: %v\nStderr: %s", output.Err, output.Stderr)
}

// Parse the JSON response
type sessionStartResponse struct {
SystemMessage string `json:"systemMessage,omitempty"`
}
var resp sessionStartResponse
if len(output.Stdout) > 0 {
if err := json.Unmarshal(output.Stdout, &resp); err != nil {
t.Fatalf("Failed to parse session-start response: %v\nStdout: %s", err, output.Stdout)
}
}

if resp.SystemMessage == "" {
t.Fatal("Expected informational message in systemMessage, got empty")
}

msg := resp.SystemMessage
t.Logf("Session start message:\n%s", msg)

// Verify base informational message is present
if !strings.Contains(msg, "Powered by Entire") {
t.Errorf("Message should contain 'Powered by Entire', got:\n%s", msg)
}
if !strings.Contains(msg, "linked to your next commit") {
t.Errorf("Message should contain 'linked to your next commit', got:\n%s", msg)
}

// Verify concurrent session info is NOT shown (no other sessions)
if strings.Contains(msg, "other active conversation") {
t.Errorf("Message should NOT mention other active conversations when none exist, got:\n%s", msg)
}
}

// createShadowBranchWithoutTrailer creates a shadow branch without an Entire-Session trailer.
func createShadowBranchWithoutTrailer(t *testing.T, repoDir, branchName string) {
t.Helper()
Expand Down
12 changes: 0 additions & 12 deletions cmd/entire/cli/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,15 +218,3 @@ func (s *EntireSettings) IsSummarizeEnabled() bool {
}
return enabled
}

// IsMultiSessionWarningDisabled checks if multi-session warnings are disabled.
// Returns false (show warnings) by default if the key is missing.
func (s *EntireSettings) IsMultiSessionWarningDisabled() bool {
if s.StrategyOptions == nil {
return false
}
if disabled, ok := s.StrategyOptions["disable_multisession_warning"].(bool); ok {
return disabled
}
return false
}
Loading