detached 5bef29b

Merge pull request #171 from airtonix/expose-zot-agent-env

Expose zot execution to child processes
commit
5bef29b9edaccc9c3424f54480c38f4f1b27c85e
author
Patric Eckhart · · 2026-09-09 05:51 UTC
committer
GitHub · · 2026-09-09 05:51 UTC
merge of
6abb410 + b5b7ce1 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.70
8 files · +186 -2
M README.md +19 -0
@@ -814,6 +814,25 @@ Opening `/model` refreshes the router and lists every loaded model under provide
814814
815815 A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually.
816816
817+## Child process environment
818+
819+Scripts and shell commands launched by zot inherit runtime metadata:
820+
821+| Variable | Meaning |
822+| --- | --- |
823+| `AI_AGENT` | Generic agent marker, set to `zot` |
824+| `ZOT_AGENT` | Zot-specific process marker, set to `1` |
825+| `ZOT_CHILD_SESSION` | Set to `1` for commands launched by a zot tool |
826+| `ZOT_SESSION_ID` | Current session ID, when session persistence is enabled |
827+| `ZOT_SESSION_FILE` | Absolute path to the current session file, when persisted |
828+| `ZOT_PROVIDER` | Current model provider |
829+| `ZOT_MODEL` | Current model ID |
830+| `ZOT_REASONING_LEVEL` | Current reasoning level, when configured |
831+
832+These values contain no credentials. Use `ZOT_AGENT` or `AI_AGENT` to distinguish execution inside zot from a standalone invocation; use the session and model values for diagnostics or attribution.
833+
834+Interactive shell commands receive model metadata even with `--no-session`; only session ID and file are omitted. Newly launched commands reflect session switches, working-directory changes, and reasoning-level changes. Turning reasoning off removes `ZOT_REASONING_LEVEL`. Already-running subprocesses retain the environment they inherited at launch.
835+
817836 ## Inline images
818837
819838 When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:
@@ -814,6 +814,25 @@ Opening `/model` refreshes the router and lists every loaded model under provide
814 814
815 A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually.815 A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually.
816 816
817+## Child process environment
818+
819+Scripts and shell commands launched by zot inherit runtime metadata:
820+
821+| Variable | Meaning |
822+| --- | --- |
823+| `AI_AGENT` | Generic agent marker, set to `zot` |
824+| `ZOT_AGENT` | Zot-specific process marker, set to `1` |
825+| `ZOT_CHILD_SESSION` | Set to `1` for commands launched by a zot tool |
826+| `ZOT_SESSION_ID` | Current session ID, when session persistence is enabled |
827+| `ZOT_SESSION_FILE` | Absolute path to the current session file, when persisted |
828+| `ZOT_PROVIDER` | Current model provider |
829+| `ZOT_MODEL` | Current model ID |
830+| `ZOT_REASONING_LEVEL` | Current reasoning level, when configured |
831+
832+These values contain no credentials. Use `ZOT_AGENT` or `AI_AGENT` to distinguish execution inside zot from a standalone invocation; use the session and model values for diagnostics or attribution.
833+
834+Interactive shell commands receive model metadata even with `--no-session`; only session ID and file are omitted. Newly launched commands reflect session switches, working-directory changes, and reasoning-level changes. Turning reasoning off removes `ZOT_REASONING_LEVEL`. Already-running subprocesses retain the environment they inherited at launch.
835+
817 ## Inline images836 ## Inline images
818 837
819 When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:838 When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:
M packages/agent/args_test.go +15 -0
@@ -98,6 +98,21 @@ func TestRunHelpHelperProcess(t *testing.T) {
9898 os.Exit(0)
9999 }
100100
101+func TestRunSetsAgentEnvironment(t *testing.T) {
102+ t.Setenv("ZOT_AGENT", "")
103+ t.Setenv("AI_AGENT", "")
104+ isolateSessionEnvironment(t)
105+ if err := Run([]string{"--help"}, "test"); err != nil {
106+ t.Fatalf("Run returned %v", err)
107+ }
108+ if got := os.Getenv("ZOT_AGENT"); got != "1" {
109+ t.Fatalf("ZOT_AGENT = %q, want %q", got, "1")
110+ }
111+ if got := os.Getenv("AI_AGENT"); got != "zot" {
112+ t.Fatalf("AI_AGENT = %q, want %q", got, "zot")
113+ }
114+}
115+
101116 func TestHelpOutputStreams(t *testing.T) {
102117 run := func(args string) (stdout, stderr string, err error) {
103118 t.Helper()
@@ -98,6 +98,21 @@ func TestRunHelpHelperProcess(t *testing.T) {
98 os.Exit(0)98 os.Exit(0)
99 }99 }
100 100
101+func TestRunSetsAgentEnvironment(t *testing.T) {
102+ t.Setenv("ZOT_AGENT", "")
103+ t.Setenv("AI_AGENT", "")
104+ isolateSessionEnvironment(t)
105+ if err := Run([]string{"--help"}, "test"); err != nil {
106+ t.Fatalf("Run returned %v", err)
107+ }
108+ if got := os.Getenv("ZOT_AGENT"); got != "1" {
109+ t.Fatalf("ZOT_AGENT = %q, want %q", got, "1")
110+ }
111+ if got := os.Getenv("AI_AGENT"); got != "zot" {
112+ t.Fatalf("AI_AGENT = %q, want %q", got, "zot")
113+ }
114+}
115+
101 func TestHelpOutputStreams(t *testing.T) {116 func TestHelpOutputStreams(t *testing.T) {
102 run := func(args string) (stdout, stderr string, err error) {117 run := func(args string) (stdout, stderr string, err error) {
103 t.Helper()118 t.Helper()
M packages/agent/cli.go +46 -1
@@ -206,6 +206,16 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
206206
207207 // Run is the top-level entrypoint for the zot binary.
208208 func Run(rawArgs []string, version string) error {
209+ // Mark the process and all child processes as running under zot. This
210+ // lets shell tools, extensions, and scripts distinguish zot execution
211+ // from an ordinary invocation. Clear per-session values because embedded
212+ // callers may invoke Run more than once in one process.
213+ _ = os.Setenv("AI_AGENT", "zot")
214+ _ = os.Setenv("ZOT_AGENT", "1")
215+ for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} {
216+ _ = os.Unsetenv(name)
217+ }
218+
209219 // Apply network configuration before any subcommand can make an HTTP
210220 // request. Standard proxy environment variables retain precedence.
211221 applyConfiguredHTTPProxy()
@@ -942,7 +952,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
942952 // (run on the TUI goroutine). Without this, a /sessions swap that
943953 // races with a finishing turn could double-write or lose messages.
944954 var persistMu sync.Mutex
945- if !args.NoSess && ag != nil {
955+ if ag != nil {
946956 sess, _ = openOrCreateSession(args, r, ag, version)
947957 if ag != nil {
948958 sessBaselineMsgs = len(ag.Messages())
@@ -1060,6 +1070,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10601070 sess = newSess
10611071 currentAg.SetMessages(msgs)
10621072 bindAgentSession(currentAg, sess)
1073+ setZotSessionEnvironment(r, sess)
10631074 startExtensionSession(extMgr, currentAg, r.CWD, "session_switch")
10641075 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
10651076 currentAg.SeedCost(cum)
@@ -1198,6 +1209,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
11981209 bindAgentSession(newAg, newSess)
11991210 }
12001211
1212+ r.Provider = newProvider
1213+ r.Model = newModel
1214+ r.Reasoning = newAg.Reasoning
1215+ setZotSessionEnvironment(r, sess)
12011216 startExtensionSession(extMgr, newAg, absPath, "cwd_change")
12021217
12031218 // Push the new state into the running Interactive.
@@ -1457,6 +1472,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14571472 },
14581473 NoYolo: args.NoYolo,
14591474 ConfirmGate: confirmGate,
1475+ OnReasoningChanged: func(level string) {
1476+ r.Reasoning = level
1477+ setZotSessionEnvironment(r, sess)
1478+ },
14601479 PersistModel: func(providerName, model string) {
14611480 // Update config.json so next launch uses the same pick.
14621481 cfg, _ := LoadConfig()
@@ -1467,6 +1486,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14671486 if sess != nil {
14681487 _ = sess.UpdateModel(providerName, model)
14691488 }
1489+ r.Provider = providerName
1490+ r.Model = model
1491+ setZotSessionEnvironment(r, sess)
14701492 },
14711493 })
14721494
@@ -1543,6 +1565,7 @@ func agentSessionsRoot(root string, args Args) string {
15431565 // exists, its id is bound onto ag so providers can sticky-route.
15441566 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
15451567 if args.NoSess {
1568+ setZotSessionEnvironment(r, nil)
15461569 return nil, nil
15471570 }
15481571 // Sweep meta-only files left over from older zot versions (and from
@@ -1600,9 +1623,31 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
16001623 }
16011624 }
16021625 bindAgentSession(ag, s)
1626+ setZotSessionEnvironment(r, s)
16031627 return s, nil
16041628 }
16051629
1630+// setZotSessionEnvironment publishes the non-secret runtime metadata that
1631+// zot's child processes can use for attribution and diagnostics.
1632+func setZotSessionEnvironment(r Resolved, sess *core.Session) {
1633+ values := map[string]string{
1634+ "ZOT_PROVIDER": r.Provider,
1635+ "ZOT_MODEL": r.Model,
1636+ "ZOT_REASONING_LEVEL": r.Reasoning,
1637+ }
1638+ if sess != nil {
1639+ values["ZOT_SESSION_ID"] = sess.ID
1640+ values["ZOT_SESSION_FILE"] = sess.Path
1641+ }
1642+ for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} {
1643+ if value := values[name]; value != "" {
1644+ _ = os.Setenv(name, value)
1645+ } else {
1646+ _ = os.Unsetenv(name)
1647+ }
1648+ }
1649+}
1650+
16061651 func pickSession(root, cwd string) (string, error) {
16071652 files := core.ListSessions(root, cwd)
16081653 if len(files) == 0 {
@@ -206,6 +206,16 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
206 206
207 // Run is the top-level entrypoint for the zot binary.207 // Run is the top-level entrypoint for the zot binary.
208 func Run(rawArgs []string, version string) error {208 func Run(rawArgs []string, version string) error {
209+ // Mark the process and all child processes as running under zot. This
210+ // lets shell tools, extensions, and scripts distinguish zot execution
211+ // from an ordinary invocation. Clear per-session values because embedded
212+ // callers may invoke Run more than once in one process.
213+ _ = os.Setenv("AI_AGENT", "zot")
214+ _ = os.Setenv("ZOT_AGENT", "1")
215+ for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} {
216+ _ = os.Unsetenv(name)
217+ }
218+
209 // Apply network configuration before any subcommand can make an HTTP219 // Apply network configuration before any subcommand can make an HTTP
210 // request. Standard proxy environment variables retain precedence.220 // request. Standard proxy environment variables retain precedence.
211 applyConfiguredHTTPProxy()221 applyConfiguredHTTPProxy()
@@ -942,7 +952,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
942 // (run on the TUI goroutine). Without this, a /sessions swap that952 // (run on the TUI goroutine). Without this, a /sessions swap that
943 // races with a finishing turn could double-write or lose messages.953 // races with a finishing turn could double-write or lose messages.
944 var persistMu sync.Mutex954 var persistMu sync.Mutex
945- if !args.NoSess && ag != nil {955+ if ag != nil {
946 sess, _ = openOrCreateSession(args, r, ag, version)956 sess, _ = openOrCreateSession(args, r, ag, version)
947 if ag != nil {957 if ag != nil {
948 sessBaselineMsgs = len(ag.Messages())958 sessBaselineMsgs = len(ag.Messages())
@@ -1060,6 +1070,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1060 sess = newSess1070 sess = newSess
1061 currentAg.SetMessages(msgs)1071 currentAg.SetMessages(msgs)
1062 bindAgentSession(currentAg, sess)1072 bindAgentSession(currentAg, sess)
1073+ setZotSessionEnvironment(r, sess)
1063 startExtensionSession(extMgr, currentAg, r.CWD, "session_switch")1074 startExtensionSession(extMgr, currentAg, r.CWD, "session_switch")
1064 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {1075 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
1065 currentAg.SeedCost(cum)1076 currentAg.SeedCost(cum)
@@ -1198,6 +1209,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1198 bindAgentSession(newAg, newSess)1209 bindAgentSession(newAg, newSess)
1199 }1210 }
1200 1211
1212+ r.Provider = newProvider
1213+ r.Model = newModel
1214+ r.Reasoning = newAg.Reasoning
1215+ setZotSessionEnvironment(r, sess)
1201 startExtensionSession(extMgr, newAg, absPath, "cwd_change")1216 startExtensionSession(extMgr, newAg, absPath, "cwd_change")
1202 1217
1203 // Push the new state into the running Interactive.1218 // Push the new state into the running Interactive.
@@ -1457,6 +1472,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1457 },1472 },
1458 NoYolo: args.NoYolo,1473 NoYolo: args.NoYolo,
1459 ConfirmGate: confirmGate,1474 ConfirmGate: confirmGate,
1475+ OnReasoningChanged: func(level string) {
1476+ r.Reasoning = level
1477+ setZotSessionEnvironment(r, sess)
1478+ },
1460 PersistModel: func(providerName, model string) {1479 PersistModel: func(providerName, model string) {
1461 // Update config.json so next launch uses the same pick.1480 // Update config.json so next launch uses the same pick.
1462 cfg, _ := LoadConfig()1481 cfg, _ := LoadConfig()
@@ -1467,6 +1486,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1467 if sess != nil {1486 if sess != nil {
1468 _ = sess.UpdateModel(providerName, model)1487 _ = sess.UpdateModel(providerName, model)
1469 }1488 }
1489+ r.Provider = providerName
1490+ r.Model = model
1491+ setZotSessionEnvironment(r, sess)
1470 },1492 },
1471 })1493 })
1472 1494
@@ -1543,6 +1565,7 @@ func agentSessionsRoot(root string, args Args) string {
1543 // exists, its id is bound onto ag so providers can sticky-route.1565 // exists, its id is bound onto ag so providers can sticky-route.
1544 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {1566 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
1545 if args.NoSess {1567 if args.NoSess {
1568+ setZotSessionEnvironment(r, nil)
1546 return nil, nil1569 return nil, nil
1547 }1570 }
1548 // Sweep meta-only files left over from older zot versions (and from1571 // Sweep meta-only files left over from older zot versions (and from
@@ -1600,9 +1623,31 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
1600 }1623 }
1601 }1624 }
1602 bindAgentSession(ag, s)1625 bindAgentSession(ag, s)
1626+ setZotSessionEnvironment(r, s)
1603 return s, nil1627 return s, nil
1604 }1628 }
1605 1629
1630+// setZotSessionEnvironment publishes the non-secret runtime metadata that
1631+// zot's child processes can use for attribution and diagnostics.
1632+func setZotSessionEnvironment(r Resolved, sess *core.Session) {
1633+ values := map[string]string{
1634+ "ZOT_PROVIDER": r.Provider,
1635+ "ZOT_MODEL": r.Model,
1636+ "ZOT_REASONING_LEVEL": r.Reasoning,
1637+ }
1638+ if sess != nil {
1639+ values["ZOT_SESSION_ID"] = sess.ID
1640+ values["ZOT_SESSION_FILE"] = sess.Path
1641+ }
1642+ for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} {
1643+ if value := values[name]; value != "" {
1644+ _ = os.Setenv(name, value)
1645+ } else {
1646+ _ = os.Unsetenv(name)
1647+ }
1648+ }
1649+}
1650+
1606 func pickSession(root, cwd string) (string, error) {1651 func pickSession(root, cwd string) (string, error) {
1607 files := core.ListSessions(root, cwd)1652 files := core.ListSessions(root, cwd)
1608 if len(files) == 0 {1653 if len(files) == 0 {
M packages/agent/modes/interactive.go +6 -0
@@ -269,6 +269,9 @@ type InteractiveConfig struct {
269269 // write a new meta row so resume picks up the same model.
270270 PersistModel func(providerName, model string)
271271
272+ // OnReasoningChanged reports the normalized level after updating the live agent.
273+ OnReasoningChanged func(level string)
274+
272275 OnAssistant func(m provider.Message)
273276 OnToolResult func(id string, r core.ToolResult)
274277
@@ -4196,6 +4199,9 @@ func (i *Interactive) applyReasoningSetting(level string) {
41964199 i.statusOK = "reasoning level " + label
41974200 i.statusErr = ""
41984201 i.mu.Unlock()
4202+ if i.cfg.OnReasoningChanged != nil {
4203+ i.cfg.OnReasoningChanged(level)
4204+ }
41994205 }
42004206
42014207 // buildStudyPrompt returns the canned prompt the /study command
@@ -269,6 +269,9 @@ type InteractiveConfig struct {
269 // write a new meta row so resume picks up the same model.269 // write a new meta row so resume picks up the same model.
270 PersistModel func(providerName, model string)270 PersistModel func(providerName, model string)
271 271
272+ // OnReasoningChanged reports the normalized level after updating the live agent.
273+ OnReasoningChanged func(level string)
274+
272 OnAssistant func(m provider.Message)275 OnAssistant func(m provider.Message)
273 OnToolResult func(id string, r core.ToolResult)276 OnToolResult func(id string, r core.ToolResult)
274 277
@@ -4196,6 +4199,9 @@ func (i *Interactive) applyReasoningSetting(level string) {
4196 i.statusOK = "reasoning level " + label4199 i.statusOK = "reasoning level " + label
4197 i.statusErr = ""4200 i.statusErr = ""
4198 i.mu.Unlock()4201 i.mu.Unlock()
4202+ if i.cfg.OnReasoningChanged != nil {
4203+ i.cfg.OnReasoningChanged(level)
4204+ }
4199 }4205 }
4200 4206
4201 // buildStudyPrompt returns the canned prompt the /study command4207 // buildStudyPrompt returns the canned prompt the /study command
M packages/agent/modes/interactive_model_test.go +17 -0
@@ -6,6 +6,7 @@ import (
66 "strings"
77 "testing"
88
9+ "github.com/patriceckhart/zot/packages/core"
910 "github.com/patriceckhart/zot/packages/tui"
1011 )
1112
@@ -81,6 +82,22 @@ func TestReasoningCommandOpensDirectSelector(t *testing.T) {
8182 }
8283 }
8384
85+func TestReasoningSettingNotifiesRuntime(t *testing.T) {
86+ i := &Interactive{agent: &core.Agent{Reasoning: "high"}}
87+ var levels []string
88+ i.cfg.OnReasoningChanged = func(level string) {
89+ if i.agent.Reasoning != level {
90+ t.Fatalf("callback ran before agent update: got %q, want %q", i.agent.Reasoning, level)
91+ }
92+ levels = append(levels, level)
93+ }
94+ i.applyReasoningSetting("medium")
95+ i.applyReasoningSetting("off")
96+ if !slices.Equal(levels, []string{"medium", ""}) {
97+ t.Fatalf("reasoning notifications = %q", levels)
98+ }
99+}
100+
84101 func TestNormalizeModelQueryKeepsPresetAtSign(t *testing.T) {
85102 if got := normalizeModelQuery("@preset/flash"); got != "@presetflash" {
86103 t.Fatalf("id = %q; want @presetflash", got)
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 "testing"7 "testing"
8 8
9+ "github.com/patriceckhart/zot/packages/core"
9 "github.com/patriceckhart/zot/packages/tui"10 "github.com/patriceckhart/zot/packages/tui"
10 )11 )
11 12
@@ -81,6 +82,22 @@ func TestReasoningCommandOpensDirectSelector(t *testing.T) {
81 }82 }
82 }83 }
83 84
85+func TestReasoningSettingNotifiesRuntime(t *testing.T) {
86+ i := &Interactive{agent: &core.Agent{Reasoning: "high"}}
87+ var levels []string
88+ i.cfg.OnReasoningChanged = func(level string) {
89+ if i.agent.Reasoning != level {
90+ t.Fatalf("callback ran before agent update: got %q, want %q", i.agent.Reasoning, level)
91+ }
92+ levels = append(levels, level)
93+ }
94+ i.applyReasoningSetting("medium")
95+ i.applyReasoningSetting("off")
96+ if !slices.Equal(levels, []string{"medium", ""}) {
97+ t.Fatalf("reasoning notifications = %q", levels)
98+ }
99+}
100+
84 func TestNormalizeModelQueryKeepsPresetAtSign(t *testing.T) {101 func TestNormalizeModelQueryKeepsPresetAtSign(t *testing.T) {
85 if got := normalizeModelQuery("@preset/flash"); got != "@presetflash" {102 if got := normalizeModelQuery("@preset/flash"); got != "@presetflash" {
86 t.Fatalf("id = %q; want @presetflash", got)103 t.Fatalf("id = %q; want @presetflash", got)
A packages/agent/session_environment_test.go +65 -0
@@ -0,0 +1,65 @@
1+package agent
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+)
10+
11+func isolateSessionEnvironment(t *testing.T) {
12+ t.Helper()
13+ for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} {
14+ t.Setenv(name, "inherited")
15+ }
16+}
17+
18+func assertSessionEnvironment(t *testing.T, r Resolved, sess *core.Session) {
19+ t.Helper()
20+ want := map[string]string{
21+ "ZOT_SESSION_ID": "", "ZOT_SESSION_FILE": "",
22+ "ZOT_PROVIDER": r.Provider, "ZOT_MODEL": r.Model, "ZOT_REASONING_LEVEL": r.Reasoning,
23+ }
24+ if sess != nil {
25+ want["ZOT_SESSION_ID"] = sess.ID
26+ want["ZOT_SESSION_FILE"] = sess.Path
27+ }
28+ for name, value := range want {
29+ got, present := os.LookupEnv(name)
30+ if got != value || present != (value != "") {
31+ t.Errorf("%s = %q (present=%v), want %q (present=%v)", name, got, present, value, value != "")
32+ }
33+ }
34+}
35+
36+func TestOpenSessionWithoutPersistencePublishesModelEnvironment(t *testing.T) {
37+ isolateSessionEnvironment(t)
38+ r := Resolved{Provider: "test-provider", Model: "test-model", Reasoning: "high"}
39+ sess, err := openOrCreateSession(Args{NoSess: true}, r, nil, "test")
40+ if err != nil || sess != nil {
41+ t.Fatalf("openOrCreateSession = %v, %v; want nil, nil", sess, err)
42+ }
43+ assertSessionEnvironment(t, r, nil)
44+}
45+
46+func TestSessionEnvironmentReplacesSessionAndClearsReasoning(t *testing.T) {
47+ isolateSessionEnvironment(t)
48+ r := Resolved{Provider: "old-provider", Model: "old-model", Reasoning: "high"}
49+ oldSession := &core.Session{ID: "old", Path: filepath.Join(t.TempDir(), "old.jsonl")}
50+ setZotSessionEnvironment(r, oldSession)
51+ assertSessionEnvironment(t, r, oldSession)
52+
53+ // A directory change replaces the session and may rebuild a different model.
54+ r.Provider, r.Model, r.Reasoning = "new-provider", "new-model", "medium"
55+ newSession := &core.Session{ID: "new", Path: filepath.Join(t.TempDir(), "new.jsonl")}
56+ setZotSessionEnvironment(r, newSession)
57+ assertSessionEnvironment(t, r, newSession)
58+
59+ r.Reasoning = ""
60+ setZotSessionEnvironment(r, newSession)
61+ assertSessionEnvironment(t, r, newSession)
62+
63+ setZotSessionEnvironment(r, nil)
64+ assertSessionEnvironment(t, r, nil)
65+}
@@ -0,0 +1,65 @@
1+package agent
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+)
10+
11+func isolateSessionEnvironment(t *testing.T) {
12+ t.Helper()
13+ for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} {
14+ t.Setenv(name, "inherited")
15+ }
16+}
17+
18+func assertSessionEnvironment(t *testing.T, r Resolved, sess *core.Session) {
19+ t.Helper()
20+ want := map[string]string{
21+ "ZOT_SESSION_ID": "", "ZOT_SESSION_FILE": "",
22+ "ZOT_PROVIDER": r.Provider, "ZOT_MODEL": r.Model, "ZOT_REASONING_LEVEL": r.Reasoning,
23+ }
24+ if sess != nil {
25+ want["ZOT_SESSION_ID"] = sess.ID
26+ want["ZOT_SESSION_FILE"] = sess.Path
27+ }
28+ for name, value := range want {
29+ got, present := os.LookupEnv(name)
30+ if got != value || present != (value != "") {
31+ t.Errorf("%s = %q (present=%v), want %q (present=%v)", name, got, present, value, value != "")
32+ }
33+ }
34+}
35+
36+func TestOpenSessionWithoutPersistencePublishesModelEnvironment(t *testing.T) {
37+ isolateSessionEnvironment(t)
38+ r := Resolved{Provider: "test-provider", Model: "test-model", Reasoning: "high"}
39+ sess, err := openOrCreateSession(Args{NoSess: true}, r, nil, "test")
40+ if err != nil || sess != nil {
41+ t.Fatalf("openOrCreateSession = %v, %v; want nil, nil", sess, err)
42+ }
43+ assertSessionEnvironment(t, r, nil)
44+}
45+
46+func TestSessionEnvironmentReplacesSessionAndClearsReasoning(t *testing.T) {
47+ isolateSessionEnvironment(t)
48+ r := Resolved{Provider: "old-provider", Model: "old-model", Reasoning: "high"}
49+ oldSession := &core.Session{ID: "old", Path: filepath.Join(t.TempDir(), "old.jsonl")}
50+ setZotSessionEnvironment(r, oldSession)
51+ assertSessionEnvironment(t, r, oldSession)
52+
53+ // A directory change replaces the session and may rebuild a different model.
54+ r.Provider, r.Model, r.Reasoning = "new-provider", "new-model", "medium"
55+ newSession := &core.Session{ID: "new", Path: filepath.Join(t.TempDir(), "new.jsonl")}
56+ setZotSessionEnvironment(r, newSession)
57+ assertSessionEnvironment(t, r, newSession)
58+
59+ r.Reasoning = ""
60+ setZotSessionEnvironment(r, newSession)
61+ assertSessionEnvironment(t, r, newSession)
62+
63+ setZotSessionEnvironment(r, nil)
64+ assertSessionEnvironment(t, r, nil)
65+}
M packages/agent/tools/bash.go +3 -1
@@ -81,7 +81,9 @@ func executeShell(ctx context.Context, raw json.RawMessage, progress func(string
8181 return core.ToolResult{}, err
8282 }
8383 cmd.Dir = cwd
84- cmd.Env = os.Environ()
84+ // Mark direct tool subprocesses separately from the zot process itself.
85+ // Descendants inherit this marker as usual.
86+ cmd.Env = append(os.Environ(), "ZOT_CHILD_SESSION=1")
8587 // Bound output draining if descendants retain handles after cancellation.
8688 cmd.WaitDelay = 2 * time.Second
8789 setProcessGroup(cmd)
@@ -81,7 +81,9 @@ func executeShell(ctx context.Context, raw json.RawMessage, progress func(string
81 return core.ToolResult{}, err81 return core.ToolResult{}, err
82 }82 }
83 cmd.Dir = cwd83 cmd.Dir = cwd
84- cmd.Env = os.Environ()84+ // Mark direct tool subprocesses separately from the zot process itself.
85+ // Descendants inherit this marker as usual.
86+ cmd.Env = append(os.Environ(), "ZOT_CHILD_SESSION=1")
85 // Bound output draining if descendants retain handles after cancellation.87 // Bound output draining if descendants retain handles after cancellation.
86 cmd.WaitDelay = 2 * time.Second88 cmd.WaitDelay = 2 * time.Second
87 setProcessGroup(cmd)89 setProcessGroup(cmd)
M packages/agent/tools/tools_test.go +15 -0
@@ -355,6 +355,21 @@ func TestBashSuccess(t *testing.T) {
355355 }
356356 }
357357
358+func TestBashExposesChildSessionMarker(t *testing.T) {
359+ if runtime.GOOS == "windows" {
360+ t.Skip("posix shell only")
361+ }
362+ tool := &BashTool{CWD: t.TempDir()}
363+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "printf '%s' \"$ZOT_CHILD_SESSION\""}), nil)
364+ if err != nil {
365+ t.Fatal(err)
366+ }
367+ got := res.Content[0].(provider.TextBlock).Text
368+ if !strings.Contains(got, "1") {
369+ t.Fatalf("ZOT_CHILD_SESSION = %q, want 1", got)
370+ }
371+}
372+
358373 func TestBashSyntax(t *testing.T) {
359374 if runtime.GOOS == "windows" {
360375 t.Skip("bash syntax is not used on Windows")
@@ -355,6 +355,21 @@ func TestBashSuccess(t *testing.T) {
355 }355 }
356 }356 }
357 357
358+func TestBashExposesChildSessionMarker(t *testing.T) {
359+ if runtime.GOOS == "windows" {
360+ t.Skip("posix shell only")
361+ }
362+ tool := &BashTool{CWD: t.TempDir()}
363+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "printf '%s' \"$ZOT_CHILD_SESSION\""}), nil)
364+ if err != nil {
365+ t.Fatal(err)
366+ }
367+ got := res.Content[0].(provider.TextBlock).Text
368+ if !strings.Contains(got, "1") {
369+ t.Fatalf("ZOT_CHILD_SESSION = %q, want 1", got)
370+ }
371+}
372+
358 func TestBashSyntax(t *testing.T) {373 func TestBashSyntax(t *testing.T) {
359 if runtime.GOOS == "windows" {374 if runtime.GOOS == "windows" {
360 t.Skip("bash syntax is not used on Windows")375 t.Skip("bash syntax is not used on Windows")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close