detached b5b7ce1

fix(agent): refresh interactive child process metadata

commit
b5b7ce14e86e82a073ade67eb72e3b446521ea04
author
patriceckhart · · 2026-09-09 05:47 UTC
parent
6506e47
signature
unsigned
6 files · +104 -2
M README.md +5 -1
@@ -814,7 +814,7 @@ 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-## Inline images
817+## Child process environment
818818
819819 Scripts and shell commands launched by zot inherit runtime metadata:
820820
@@ -831,6 +831,10 @@ Scripts and shell commands launched by zot inherit runtime metadata:
831831
832832 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.
833833
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+
836+## Inline images
837+
834838 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:
835839
836840 | Value | Effect |
@@ -814,7 +814,7 @@ 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-## Inline images817+## Child process environment
818 818
819 Scripts and shell commands launched by zot inherit runtime metadata:819 Scripts and shell commands launched by zot inherit runtime metadata:
820 820
@@ -831,6 +831,10 @@ Scripts and shell commands launched by zot inherit runtime metadata:
831 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.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 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+
836+## Inline images
837+
834 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:
835 839
836 | Value | Effect |840 | Value | Effect |
M packages/agent/args_test.go +2 -0
@@ -100,6 +100,8 @@ func TestRunHelpHelperProcess(t *testing.T) {
100100
101101 func TestRunSetsAgentEnvironment(t *testing.T) {
102102 t.Setenv("ZOT_AGENT", "")
103+ t.Setenv("AI_AGENT", "")
104+ isolateSessionEnvironment(t)
103105 if err := Run([]string{"--help"}, "test"); err != nil {
104106 t.Fatalf("Run returned %v", err)
105107 }
@@ -100,6 +100,8 @@ func TestRunHelpHelperProcess(t *testing.T) {
100 100
101 func TestRunSetsAgentEnvironment(t *testing.T) {101 func TestRunSetsAgentEnvironment(t *testing.T) {
102 t.Setenv("ZOT_AGENT", "")102 t.Setenv("ZOT_AGENT", "")
103+ t.Setenv("AI_AGENT", "")
104+ isolateSessionEnvironment(t)
103 if err := Run([]string{"--help"}, "test"); err != nil {105 if err := Run([]string{"--help"}, "test"); err != nil {
104 t.Fatalf("Run returned %v", err)106 t.Fatalf("Run returned %v", err)
105 }107 }
M packages/agent/cli.go +9 -1
@@ -952,7 +952,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
952952 // (run on the TUI goroutine). Without this, a /sessions swap that
953953 // races with a finishing turn could double-write or lose messages.
954954 var persistMu sync.Mutex
955- if !args.NoSess && ag != nil {
955+ if ag != nil {
956956 sess, _ = openOrCreateSession(args, r, ag, version)
957957 if ag != nil {
958958 sessBaselineMsgs = len(ag.Messages())
@@ -1209,6 +1209,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
12091209 bindAgentSession(newAg, newSess)
12101210 }
12111211
1212+ r.Provider = newProvider
1213+ r.Model = newModel
1214+ r.Reasoning = newAg.Reasoning
1215+ setZotSessionEnvironment(r, sess)
12121216 startExtensionSession(extMgr, newAg, absPath, "cwd_change")
12131217
12141218 // Push the new state into the running Interactive.
@@ -1468,6 +1472,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14681472 },
14691473 NoYolo: args.NoYolo,
14701474 ConfirmGate: confirmGate,
1475+ OnReasoningChanged: func(level string) {
1476+ r.Reasoning = level
1477+ setZotSessionEnvironment(r, sess)
1478+ },
14711479 PersistModel: func(providerName, model string) {
14721480 // Update config.json so next launch uses the same pick.
14731481 cfg, _ := LoadConfig()
@@ -952,7 +952,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
952 // (run on the TUI goroutine). Without this, a /sessions swap that952 // (run on the TUI goroutine). Without this, a /sessions swap that
953 // races with a finishing turn could double-write or lose messages.953 // races with a finishing turn could double-write or lose messages.
954 var persistMu sync.Mutex954 var persistMu sync.Mutex
955- if !args.NoSess && ag != nil {955+ if ag != nil {
956 sess, _ = openOrCreateSession(args, r, ag, version)956 sess, _ = openOrCreateSession(args, r, ag, version)
957 if ag != nil {957 if ag != nil {
958 sessBaselineMsgs = len(ag.Messages())958 sessBaselineMsgs = len(ag.Messages())
@@ -1209,6 +1209,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1209 bindAgentSession(newAg, newSess)1209 bindAgentSession(newAg, newSess)
1210 }1210 }
1211 1211
1212+ r.Provider = newProvider
1213+ r.Model = newModel
1214+ r.Reasoning = newAg.Reasoning
1215+ setZotSessionEnvironment(r, sess)
1212 startExtensionSession(extMgr, newAg, absPath, "cwd_change")1216 startExtensionSession(extMgr, newAg, absPath, "cwd_change")
1213 1217
1214 // Push the new state into the running Interactive.1218 // Push the new state into the running Interactive.
@@ -1468,6 +1472,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1468 },1472 },
1469 NoYolo: args.NoYolo,1473 NoYolo: args.NoYolo,
1470 ConfirmGate: confirmGate,1474 ConfirmGate: confirmGate,
1475+ OnReasoningChanged: func(level string) {
1476+ r.Reasoning = level
1477+ setZotSessionEnvironment(r, sess)
1478+ },
1471 PersistModel: func(providerName, model string) {1479 PersistModel: func(providerName, model string) {
1472 // Update config.json so next launch uses the same pick.1480 // Update config.json so next launch uses the same pick.
1473 cfg, _ := LoadConfig()1481 cfg, _ := LoadConfig()
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+}

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