]> git.giacomolli.uk - onechat/commitdiff
Name and order the agent and config code
authorAnderson A. Giacomolli <redacted>
Sat, 7 Nov 2026 17:06:14 +0000 (14:06 -0300)
committerAnderson A. Giacomolli <redacted>
Sat, 7 Nov 2026 17:06:14 +0000 (14:06 -0300)
The names that only the agent or only the config uses start with agent
or config: the instructions file of the agent and the endpoint values of
config.json. Config and ModelConfig stay generic, because the daemon,
the probe, the model client, and the compactor read them.

The data directory is in datadir.go, with its tests in datadir_test.go,
because the login, the terminal client, the daemon, and the probe use
it, and it is not a part of config.json. The declarations of these files
follow the order of the go-code skill. The behavior does not change.

Issue: 947bddb

agent.go
agent_test.go
chatgpt_test.go
config.go
config_test.go
datadir.go [new file with mode: 0644]
datadir_test.go [new file with mode: 0644]
model.go

index 1b4873af8e54f5dd7c273b3fb156a3f5440347de..4892840786da9a7ff4567d4c4321cd9e45172dd8 100644 (file)
--- a/agent.go
+++ b/agent.go
@@ -14,34 +14,14 @@ import (
 )
 
 const (
-       // instructionsFileName is the file of the user's instructions in the
-       // directory of an agent.
-       instructionsFileName = "instructions.md"
+       // agentInstructionsFileName is the file of the user's instructions in
+       // the directory of an agent.
+       agentInstructionsFileName = "instructions.md"
 
        // agentIDBytes is the number of random bytes of an agent ID.
        agentIDBytes = 16
 )
 
-// AgentConfig holds the settings of the config.json of an agent. ID is random,
-// made once when the file is made, and never changes. The cache key comes from
-// the ID, so a rename keeps the prompt cache and two agents with one name do
-// not share a key. Workdir is the directory where the commands of the agent
-// run.
-type AgentConfig struct {
-       Name    string `json:"name"`
-       ID      string `json:"id"`
-       Model   string `json:"model"`
-       Effort  string `json:"effort"`
-       Workdir string `json:"workdir"`
-}
-
-// Agent is one agent and its directory. The directory holds config.json, the
-// user's instructions in instructions.md, the chat, and the usage file.
-type Agent struct {
-       dir    string
-       config AgentConfig
-}
-
 // openAgent reads the config.json of the agent in dir. When the file does not
 // exist, it makes the directory and writes the file with the defaults and a new
 // ID. A missing field of an existing file takes the default, but the ID must be
@@ -89,6 +69,65 @@ func openAgent(dir string) (*Agent, error) {
        }, nil
 }
 
+// Agent is one agent and its directory. The directory holds config.json, the
+// user's instructions in instructions.md, the chat, and the usage file.
+type Agent struct {
+       dir    string
+       config AgentConfig
+}
+
+// CacheKey returns the prompt cache key of every model call of the agent.
+func (a *Agent) CacheKey() string {
+       return "onechat-" + a.config.ID
+}
+
+// Prompt returns the system prompt of the agent with the user's instructions
+// from instructions.md. It reads the file on each call, so a change applies at
+// the next call. A missing file means no instructions.
+func (a *Agent) Prompt() (string, error) {
+       data, err := os.ReadFile(filepath.Join(a.dir, agentInstructionsFileName))
+       if err != nil && !errors.Is(err, os.ErrNotExist) {
+               return "", fmt.Errorf("read instructions: %w", err)
+       }
+
+       return prompt(a.config.Name, string(data)), nil
+}
+
+// AgentConfig holds the settings of the config.json of an agent. ID is random,
+// made once when the file is made, and never changes. The cache key comes from
+// the ID, so a rename keeps the prompt cache and two agents with one name do
+// not share a key. Workdir is the directory where the commands of the agent
+// run.
+type AgentConfig struct {
+       Name    string `json:"name"`
+       ID      string `json:"id"`
+       Model   string `json:"model"`
+       Effort  string `json:"effort"`
+       Workdir string `json:"workdir"`
+}
+
+// validate makes sure that each setting has a valid value.
+func (c AgentConfig) validate() error {
+       if c.Name == "" || strings.ContainsAny(c.Name, "\r\n") {
+               return errors.New("name must be one line of text")
+       }
+
+       id, err := hex.DecodeString(c.ID)
+       if err != nil || len(id) != agentIDBytes {
+               return fmt.Errorf("id must be %d hexadecimal digits", 2*agentIDBytes)
+       }
+
+       if c.Model == "" || c.Effort == "" {
+               return errors.New("agent needs a model and an effort")
+       }
+
+       if !filepath.IsAbs(c.Workdir) {
+               return fmt.Errorf("workdir %q is not an absolute path", c.Workdir)
+       }
+
+       return nil
+}
+
 // createAgent writes the config.json of a new agent in dir with the defaults
 // and a new ID.
 func createAgent(dir string) (*Agent, error) {
@@ -135,42 +174,3 @@ func defaultAgentConfig() (AgentConfig, error) {
                Workdir: home,
        }, nil
 }
-
-// validate makes sure that each setting has a valid value.
-func (c AgentConfig) validate() error {
-       if c.Name == "" || strings.ContainsAny(c.Name, "\r\n") {
-               return errors.New("name must be one line of text")
-       }
-
-       id, err := hex.DecodeString(c.ID)
-       if err != nil || len(id) != agentIDBytes {
-               return fmt.Errorf("id must be %d hexadecimal digits", 2*agentIDBytes)
-       }
-
-       if c.Model == "" || c.Effort == "" {
-               return errors.New("agent needs a model and an effort")
-       }
-
-       if !filepath.IsAbs(c.Workdir) {
-               return fmt.Errorf("workdir %q is not an absolute path", c.Workdir)
-       }
-
-       return nil
-}
-
-// CacheKey returns the prompt cache key of every model call of the agent.
-func (a *Agent) CacheKey() string {
-       return "onechat-" + a.config.ID
-}
-
-// Prompt returns the system prompt of the agent with the user's instructions
-// from instructions.md. It reads the file on each call, so a change applies at
-// the next call. A missing file means no instructions.
-func (a *Agent) Prompt() (string, error) {
-       data, err := os.ReadFile(filepath.Join(a.dir, instructionsFileName))
-       if err != nil && !errors.Is(err, os.ErrNotExist) {
-               return "", fmt.Errorf("read instructions: %w", err)
-       }
-
-       return prompt(a.config.Name, string(data)), nil
-}
index dabecd27d28d55644ee5bf1b89467395f38a0188..775e2fe454dc23a8df38cf17bcbc16e21f2dbcb3 100644 (file)
@@ -46,7 +46,7 @@ func TestRenamedAgentKeepsItsCacheKey(t *testing.T) {
 
        config := agent.config
        config.Name = "Lena"
-       writeAgentConfig(t, dir, config)
+       agentWriteTestConfig(t, dir, config)
 
        renamed, err := openAgent(dir)
        require.NoError(t, err)
@@ -102,14 +102,14 @@ func TestAgentPromptReadsTheInstructionsEachTime(t *testing.T) {
        require.NoError(t, err)
        require.Equal(t, prompt(agent.config.Name, ""), text)
 
-       require.NoError(t, os.WriteFile(filepath.Join(dir, instructionsFileName), []byte("I am Lena.\n"), 0o600))
+       require.NoError(t, os.WriteFile(filepath.Join(dir, agentInstructionsFileName), []byte("I am Lena.\n"), 0o600))
        text, err = agent.Prompt()
        require.NoError(t, err)
        require.Equal(t, prompt(agent.config.Name, "I am Lena."), text)
 }
 
-// writeAgentConfig writes config as the config.json of the agent in dir.
-func writeAgentConfig(t *testing.T, dir string, config AgentConfig) {
+// agentWriteTestConfig writes config as the config.json of the agent in dir.
+func agentWriteTestConfig(t *testing.T, dir string, config AgentConfig) {
        t.Helper()
 
        data, err := json.Marshal(config)
index 982a6ff70e51d08164515a4f315f1c11e3ca54fd..c6db9bd9bbbcc42ddc2f734aea77ec79552d01ed 100644 (file)
@@ -192,7 +192,7 @@ func TestModelEndpointFollowsTheConfig(t *testing.T) {
        require.IsType(t, &ChatGPTAuth{}, auth)
        require.Equal(t, filepath.Join(dir, chatgptTokenFileName), auth.(*ChatGPTAuth).path)
 
-       config.Endpoint = endpointAPI
+       config.Endpoint = configEndpointAPI
        address, auth, err = modelEndpoint(config, dir)
        require.NoError(t, err)
        require.Equal(t, modelAPIURL, address)
index 2f0bfca3ba900a4d0e1fb2a64989f979e4f539be..6e2da84bee797abf2e913a380fa9e92463d7fb35 100644 (file)
--- a/config.go
+++ b/config.go
@@ -11,37 +11,14 @@ import (
 )
 
 const (
-       // endpointChatGPT names the ChatGPT backend with the subscription login.
-       endpointChatGPT = "chatgpt"
+       // configEndpointChatGPT names the ChatGPT backend with the subscription
+       // login.
+       configEndpointChatGPT = "chatgpt"
 
-       // endpointAPI names the OpenAI API with an API key.
-       endpointAPI = "api"
+       // configEndpointAPI names the OpenAI API with an API key.
+       configEndpointAPI = "api"
 )
 
-// Config holds the settings of config.json in the data directory.
-type Config struct {
-       Endpoint  string      `json:"endpoint"`
-       Compactor ModelConfig `json:"compactor"`
-}
-
-// ModelConfig names a model and its reasoning effort.
-type ModelConfig struct {
-       Model  string `json:"model"`
-       Effort string `json:"effort"`
-}
-
-// defaultConfig returns the settings that apply when config.json does not
-// set them.
-func defaultConfig() Config {
-       return Config{
-               Endpoint: endpointChatGPT,
-               Compactor: ModelConfig{
-                       Model:  "gpt-6.1-sol",
-                       Effort: "high",
-               },
-       }
-}
-
 // loadConfig reads config.json in dir over the defaults. A missing file gives
 // the defaults. An unknown field is an error, so a typo does not become a
 // silent default.
@@ -74,10 +51,16 @@ func loadConfig(dir string) (Config, error) {
        return config, nil
 }
 
+// Config holds the settings of config.json in the data directory.
+type Config struct {
+       Endpoint  string      `json:"endpoint"`
+       Compactor ModelConfig `json:"compactor"`
+}
+
 // validate makes sure that each setting has a valid value.
 func (c Config) validate() error {
-       if c.Endpoint != endpointChatGPT && c.Endpoint != endpointAPI {
-               return fmt.Errorf("endpoint %q is not %q or %q", c.Endpoint, endpointChatGPT, endpointAPI)
+       if c.Endpoint != configEndpointChatGPT && c.Endpoint != configEndpointAPI {
+               return fmt.Errorf("endpoint %q is not %q or %q", c.Endpoint, configEndpointChatGPT, configEndpointAPI)
        }
 
        if c.Compactor.Model == "" || c.Compactor.Effort == "" {
@@ -87,40 +70,20 @@ func (c Config) validate() error {
        return nil
 }
 
-// dataDir returns the data directory and creates it with mode 0700 when it
-// does not exist. It is ONECHAT_DIR when that is set. Otherwise it is
-// $XDG_DATA_HOME/onechat when XDG_DATA_HOME is an absolute path, or else
-// ~/.local/share/onechat.
-func dataDir() (string, error) {
-       dir := os.Getenv("ONECHAT_DIR")
-       if dir == "" {
-               var err error
-               dir, err = defaultDataDir()
-               if err != nil {
-                       return "", err
-               }
-       }
-
-       err := os.MkdirAll(dir, 0o700)
-       if err != nil {
-               return "", fmt.Errorf("create data directory: %w", err)
-       }
-
-       return dir, nil
+// ModelConfig names a model and its reasoning effort.
+type ModelConfig struct {
+       Model  string `json:"model"`
+       Effort string `json:"effort"`
 }
 
-// defaultDataDir returns the data directory for an unset ONECHAT_DIR. The XDG
-// specification says to ignore a relative XDG_DATA_HOME.
-func defaultDataDir() (string, error) {
-       xdg := os.Getenv("XDG_DATA_HOME")
-       if filepath.IsAbs(xdg) {
-               return filepath.Join(xdg, "onechat"), nil
-       }
-
-       home, err := os.UserHomeDir()
-       if err != nil {
-               return "", fmt.Errorf("find data directory: set ONECHAT_DIR: %w", err)
+// defaultConfig returns the settings that apply when config.json does not
+// set them.
+func defaultConfig() Config {
+       return Config{
+               Endpoint: configEndpointChatGPT,
+               Compactor: ModelConfig{
+                       Model:  "gpt-6.1-sol",
+                       Effort: "high",
+               },
        }
-
-       return filepath.Join(home, ".local", "share", "onechat"), nil
 }
index 92ff2e0b9e3551e6824bd5804118a8f03892a51b..7ad0b4efd48bd91ca548a1d036ac6314217af447 100644 (file)
@@ -18,12 +18,12 @@ func TestLoadConfigGivesDefaultsWithoutAFile(t *testing.T) {
 
 func TestLoadConfigKeepsDefaultsForMissingFields(t *testing.T) {
        dir := t.TempDir()
-       writeConfig(t, dir, `{"endpoint": "api", "compactor": {"effort": "xhigh"}}`)
+       configWriteTest(t, dir, `{"endpoint": "api", "compactor": {"effort": "xhigh"}}`)
 
        config, err := loadConfig(dir)
        require.NoError(t, err)
        require.Equal(t, Config{
-               Endpoint: endpointAPI,
+               Endpoint: configEndpointAPI,
                Compactor: ModelConfig{
                        Model:  "gpt-6.1-sol",
                        Effort: "xhigh",
@@ -65,7 +65,7 @@ func TestLoadConfigRejectsInvalidFiles(t *testing.T) {
        for _, tc := range testCases {
                t.Run(tc.name, func(t *testing.T) {
                        dir := t.TempDir()
-                       writeConfig(t, dir, tc.text)
+                       configWriteTest(t, dir, tc.text)
 
                        _, err := loadConfig(dir)
                        require.Error(t, err)
@@ -73,72 +73,7 @@ func TestLoadConfigRejectsInvalidFiles(t *testing.T) {
        }
 }
 
-func TestDataDirFollowsTheOrder(t *testing.T) {
-       root := t.TempDir()
-       testCases := []struct {
-               name string
-               dir  string
-               xdg  string
-               home string
-               want string
-       }{
-               {
-                       name: "ONECHAT_DIR first",
-                       dir:  filepath.Join(root, "a"),
-                       xdg:  filepath.Join(root, "xdg"),
-                       home: root,
-                       want: filepath.Join(root, "a"),
-               },
-               {
-                       name: "absolute XDG_DATA_HOME",
-                       dir:  "",
-                       xdg:  filepath.Join(root, "xdg"),
-                       home: root,
-                       want: filepath.Join(root, "xdg", "onechat"),
-               },
-               {
-                       name: "relative XDG_DATA_HOME ignored",
-                       dir:  "",
-                       xdg:  "xdg",
-                       home: root,
-                       want: filepath.Join(root, ".local", "share", "onechat"),
-               },
-               {
-                       name: "home directory last",
-                       dir:  "",
-                       xdg:  "",
-                       home: root,
-                       want: filepath.Join(root, ".local", "share", "onechat"),
-               },
-       }
-
-       for _, tc := range testCases {
-               t.Run(tc.name, func(t *testing.T) {
-                       t.Setenv("ONECHAT_DIR", tc.dir)
-                       t.Setenv("XDG_DATA_HOME", tc.xdg)
-                       t.Setenv("HOME", tc.home)
-
-                       dir, err := dataDir()
-                       require.NoError(t, err)
-                       require.Equal(t, tc.want, dir)
-
-                       info, err := os.Stat(dir)
-                       require.NoError(t, err)
-                       require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
-               })
-       }
-}
-
-func TestDataDirFailsWithoutAHome(t *testing.T) {
-       t.Setenv("ONECHAT_DIR", "")
-       t.Setenv("XDG_DATA_HOME", "")
-       t.Setenv("HOME", "")
-
-       _, err := dataDir()
-       require.ErrorContains(t, err, "ONECHAT_DIR")
-}
-
-func writeConfig(t *testing.T, dir, text string) {
+func configWriteTest(t *testing.T, dir, text string) {
        t.Helper()
 
        err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(text), 0o600)
diff --git a/datadir.go b/datadir.go
new file mode 100644 (file)
index 0000000..a16ee34
--- /dev/null
@@ -0,0 +1,45 @@
+package main
+
+import (
+       "fmt"
+       "os"
+       "path/filepath"
+)
+
+// dataDir returns the data directory and creates it with mode 0700 when it does
+// not exist. It is ONECHAT_DIR when that is set. Otherwise it is
+// $XDG_DATA_HOME/onechat when XDG_DATA_HOME is an absolute path, or else
+// ~/.local/share/onechat.
+func dataDir() (string, error) {
+       dir := os.Getenv("ONECHAT_DIR")
+       if dir == "" {
+               var err error
+               dir, err = defaultDataDir()
+               if err != nil {
+                       return "", err
+               }
+       }
+
+       err := os.MkdirAll(dir, 0o700)
+       if err != nil {
+               return "", fmt.Errorf("create data directory: %w", err)
+       }
+
+       return dir, nil
+}
+
+// defaultDataDir returns the data directory for an unset ONECHAT_DIR. The XDG
+// specification says to ignore a relative XDG_DATA_HOME.
+func defaultDataDir() (string, error) {
+       xdg := os.Getenv("XDG_DATA_HOME")
+       if filepath.IsAbs(xdg) {
+               return filepath.Join(xdg, "onechat"), nil
+       }
+
+       home, err := os.UserHomeDir()
+       if err != nil {
+               return "", fmt.Errorf("find data directory: set ONECHAT_DIR: %w", err)
+       }
+
+       return filepath.Join(home, ".local", "share", "onechat"), nil
+}
diff --git a/datadir_test.go b/datadir_test.go
new file mode 100644 (file)
index 0000000..fa895b7
--- /dev/null
@@ -0,0 +1,74 @@
+package main
+
+import (
+       "os"
+       "path/filepath"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestDataDirFollowsTheOrder(t *testing.T) {
+       root := t.TempDir()
+       testCases := []struct {
+               name string
+               dir  string
+               xdg  string
+               home string
+               want string
+       }{
+               {
+                       name: "ONECHAT_DIR first",
+                       dir:  filepath.Join(root, "a"),
+                       xdg:  filepath.Join(root, "xdg"),
+                       home: root,
+                       want: filepath.Join(root, "a"),
+               },
+               {
+                       name: "absolute XDG_DATA_HOME",
+                       dir:  "",
+                       xdg:  filepath.Join(root, "xdg"),
+                       home: root,
+                       want: filepath.Join(root, "xdg", "onechat"),
+               },
+               {
+                       name: "relative XDG_DATA_HOME ignored",
+                       dir:  "",
+                       xdg:  "xdg",
+                       home: root,
+                       want: filepath.Join(root, ".local", "share", "onechat"),
+               },
+               {
+                       name: "home directory last",
+                       dir:  "",
+                       xdg:  "",
+                       home: root,
+                       want: filepath.Join(root, ".local", "share", "onechat"),
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       t.Setenv("ONECHAT_DIR", tc.dir)
+                       t.Setenv("XDG_DATA_HOME", tc.xdg)
+                       t.Setenv("HOME", tc.home)
+
+                       dir, err := dataDir()
+                       require.NoError(t, err)
+                       require.Equal(t, tc.want, dir)
+
+                       info, err := os.Stat(dir)
+                       require.NoError(t, err)
+                       require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
+               })
+       }
+}
+
+func TestDataDirFailsWithoutAHome(t *testing.T) {
+       t.Setenv("ONECHAT_DIR", "")
+       t.Setenv("XDG_DATA_HOME", "")
+       t.Setenv("HOME", "")
+
+       _, err := dataDir()
+       require.ErrorContains(t, err, "ONECHAT_DIR")
+}
index 3f3eccd8fb908ae1e796ea855bd6e77dfba17588..f87240a0e5f948b20e6b957dd10b97634913701b 100644 (file)
--- a/model.go
+++ b/model.go
@@ -45,7 +45,7 @@ var errModelCallLimit = errors.New("call limit reached")
 // OPENAI_API_KEY, and there is no fallback from one to the other. All clients
 // share the authorizer, so one refresh runs at a time.
 func modelEndpoint(config Config, dir string) (string, ModelAuthorizer, error) {
-       if config.Endpoint == endpointAPI {
+       if config.Endpoint == configEndpointAPI {
                key := os.Getenv("OPENAI_API_KEY")
                if key == "" {
                        return "", nil, errors.New("endpoint api needs OPENAI_API_KEY")