detached 52c14bf

feat(core): make agents time aware

commit
52c14bfa20274b868c7be043e75f1c736a6e7015
author
brice · · 2026-08-09 09:40 UTC
parent
66e9275
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +537 -119
M packages/agent/botcmd.go +4 -0
@@ -309,11 +309,15 @@ func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version st
309309 if err != nil {
310310 return nil, nil, err
311311 }
312+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
312313 ag.SetMessages(msgs)
313314 return s, nil, nil
314315 }
315316 }
316317 s, err := core.NewSession(ZutHome(), args.CWD, r.Provider, r.Model, version)
318+ if err == nil {
319+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
320+ }
317321 return s, nil, err
318322 }
319323
@@ -309,11 +309,15 @@ func openOrCreateSessionForBot(args Args, r Resolved, ag *core.Agent, version st
309 if err != nil {309 if err != nil {
310 return nil, nil, err310 return nil, nil, err
311 }311 }
312+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
312 ag.SetMessages(msgs)313 ag.SetMessages(msgs)
313 return s, nil, nil314 return s, nil, nil
314 }315 }
315 }316 }
316 s, err := core.NewSession(ZutHome(), args.CWD, r.Provider, r.Model, version)317 s, err := core.NewSession(ZutHome(), args.CWD, r.Provider, r.Model, version)
318+ if err == nil {
319+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
320+ }
317 return s, nil, err321 return s, nil, err
318 }322 }
319 323
M packages/agent/cli.go +14 -1
@@ -570,6 +570,7 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
570570 if sess == nil || ag == nil {
571571 return candidate, nil
572572 }
573+ ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset)
573574 // A newly created meta-only session is already on the active pair. Avoid
574575 // reopening and closing it through the resume path: Close intentionally
575576 // removes fresh empty sessions, and a second append handle would otherwise
@@ -591,6 +592,7 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
591592 if closeErr := sess.Close(); closeErr != nil {
592593 return sessionResumeCandidate{}, errors.Join(fmt.Errorf("close session: %w", closeErr), joinSessionCloseError(nil, candidate.session))
593594 }
595+ candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
594596 return candidate, nil
595597 }
596598
@@ -1859,6 +1861,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18591861 if a == nil {
18601862 return a
18611863 }
1864+ persistMu.Lock()
1865+ currentSession := sess
1866+ persistMu.Unlock()
1867+ if currentSession != nil {
1868+ a.SetSessionTimeContext(currentSession.Meta.Started, currentSession.Meta.Timezone, currentSession.Meta.TimezoneOffset)
1869+ }
18621870 a.OnMessageAppended = persistMessage
18631871 a.OnUsage = persistUsage
18641872 a.OnTranscriptCompacted = persistCompaction
@@ -2752,6 +2760,7 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
27522760 return nil, err
27532761 }
27542762 if s != nil {
2763+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
27552764 ag.SetMessages(msgs)
27562765 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
27572766 ag.SeedCost(cum)
@@ -2759,7 +2768,11 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
27592768 }
27602769 return s, nil
27612770 }
2762- return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2771+ s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2772+ if err == nil {
2773+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2774+ }
2775+ return s, err
27632776 }
27642777
27652778 func pickSession(root, cwd string) (string, error) {
@@ -570,6 +570,7 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
570 if sess == nil || ag == nil {570 if sess == nil || ag == nil {
571 return candidate, nil571 return candidate, nil
572 }572 }
573+ ag.SetSessionTimeContext(sess.Meta.Started, sess.Meta.Timezone, sess.Meta.TimezoneOffset)
573 // A newly created meta-only session is already on the active pair. Avoid574 // A newly created meta-only session is already on the active pair. Avoid
574 // reopening and closing it through the resume path: Close intentionally575 // reopening and closing it through the resume path: Close intentionally
575 // removes fresh empty sessions, and a second append handle would otherwise576 // removes fresh empty sessions, and a second append handle would otherwise
@@ -591,6 +592,7 @@ func applySessionResume(sess *core.Session, ag *core.Agent, currentProvider, cur
591 if closeErr := sess.Close(); closeErr != nil {592 if closeErr := sess.Close(); closeErr != nil {
592 return sessionResumeCandidate{}, errors.Join(fmt.Errorf("close session: %w", closeErr), joinSessionCloseError(nil, candidate.session))593 return sessionResumeCandidate{}, errors.Join(fmt.Errorf("close session: %w", closeErr), joinSessionCloseError(nil, candidate.session))
593 }594 }
595+ candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
594 return candidate, nil596 return candidate, nil
595 }597 }
596 598
@@ -1859,6 +1861,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1859 if a == nil {1861 if a == nil {
1860 return a1862 return a
1861 }1863 }
1864+ persistMu.Lock()
1865+ currentSession := sess
1866+ persistMu.Unlock()
1867+ if currentSession != nil {
1868+ a.SetSessionTimeContext(currentSession.Meta.Started, currentSession.Meta.Timezone, currentSession.Meta.TimezoneOffset)
1869+ }
1862 a.OnMessageAppended = persistMessage1870 a.OnMessageAppended = persistMessage
1863 a.OnUsage = persistUsage1871 a.OnUsage = persistUsage
1864 a.OnTranscriptCompacted = persistCompaction1872 a.OnTranscriptCompacted = persistCompaction
@@ -2752,6 +2760,7 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
2752 return nil, err2760 return nil, err
2753 }2761 }
2754 if s != nil {2762 if s != nil {
2763+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2755 ag.SetMessages(msgs)2764 ag.SetMessages(msgs)
2756 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {2765 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
2757 ag.SeedCost(cum)2766 ag.SeedCost(cum)
@@ -2759,7 +2768,11 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
2759 }2768 }
2760 return s, nil2769 return s, nil
2761 }2770 }
2762- return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)2771+ s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2772+ if err == nil {
2773+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2774+ }
2775+ return s, err
2763 }2776 }
2764 2777
2765 func pickSession(root, cwd string) (string, error) {2778 func pickSession(root, cwd string) (string, error) {
M packages/agent/tools/bash.go +2 -30
@@ -67,7 +67,6 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
6767 defer cancel()
6868 }
6969
70- start := time.Now()
7170 cmd := newShellCmd(runCtx, a.Command)
7271 cmd.Dir = cwd
7372 cmd.Env = os.Environ()
@@ -147,13 +146,10 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
147146 }
148147 }
149148
150- elapsed := time.Since(start)
151-
152149 // Terminal-log style: echo the command on the first line with
153150 // a shell-prompt prefix, a blank line, the captured output, and
154- // a footer showing exit code + elapsed time. Matches the look
155- // a human would see if they ran the command themselves, which
156- // makes the model's reasoning about it more natural too.
151+ // a footer showing the exit code. Invocation timing is added once
152+ // by the core tool boundary for the model-only projection.
157153 var sb strings.Builder
158154 fmt.Fprintf(&sb, "$ %s\n", a.Command)
159155 if trimmed != "" {
@@ -183,7 +179,6 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
183179 fmt.Fprintf(&sb, " (full output: %s)", fullPath)
184180 }
185181 }
186- fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed))
187182
188183 isErr := exitCode != 0 || ctx.Err() != nil
189184 return core.ToolResult{
@@ -194,33 +189,10 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
194189 "full_output_path": fullPath,
195190 "lines_truncated": truncLines,
196191 "bytes_truncated": truncBytes,
197- "duration_ms": elapsed.Milliseconds(),
198192 },
199193 }, nil
200194 }
201195
202-// humanDuration renders a duration in the "Took X.Ys" style used by
203-// the shell-log output: tenths of a second for sub-minute runs,
204-// whole seconds once we pass a minute. Trailing zeros dropped so
205-// "0.1s" instead of "0.10s".
206-func humanDuration(d time.Duration) string {
207- switch {
208- case d < time.Millisecond:
209- return "0.0s"
210- case d < time.Minute:
211- s := d.Seconds()
212- return fmt.Sprintf("%.1fs", s)
213- case d < time.Hour:
214- m := int(d.Minutes())
215- s := int(d.Seconds()) - m*60
216- return fmt.Sprintf("%dm%ds", m, s)
217- default:
218- h := int(d.Hours())
219- m := int(d.Minutes()) - h*60
220- return fmt.Sprintf("%dh%dm", h, m)
221- }
222-}
223-
224196 func writeFullOutput(s string) string {
225197 b := make([]byte, 6)
226198 _, _ = rand.Read(b)
@@ -67,7 +67,6 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
67 defer cancel()67 defer cancel()
68 }68 }
69 69
70- start := time.Now()
71 cmd := newShellCmd(runCtx, a.Command)70 cmd := newShellCmd(runCtx, a.Command)
72 cmd.Dir = cwd71 cmd.Dir = cwd
73 cmd.Env = os.Environ()72 cmd.Env = os.Environ()
@@ -147,13 +146,10 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
147 }146 }
148 }147 }
149 148
150- elapsed := time.Since(start)
151-
152 // Terminal-log style: echo the command on the first line with149 // Terminal-log style: echo the command on the first line with
153 // a shell-prompt prefix, a blank line, the captured output, and150 // a shell-prompt prefix, a blank line, the captured output, and
154- // a footer showing exit code + elapsed time. Matches the look151+ // a footer showing the exit code. Invocation timing is added once
155- // a human would see if they ran the command themselves, which152+ // by the core tool boundary for the model-only projection.
156- // makes the model's reasoning about it more natural too.
157 var sb strings.Builder153 var sb strings.Builder
158 fmt.Fprintf(&sb, "$ %s\n", a.Command)154 fmt.Fprintf(&sb, "$ %s\n", a.Command)
159 if trimmed != "" {155 if trimmed != "" {
@@ -183,7 +179,6 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
183 fmt.Fprintf(&sb, " (full output: %s)", fullPath)179 fmt.Fprintf(&sb, " (full output: %s)", fullPath)
184 }180 }
185 }181 }
186- fmt.Fprintf(&sb, " Took %s", humanDuration(elapsed))
187 182
188 isErr := exitCode != 0 || ctx.Err() != nil183 isErr := exitCode != 0 || ctx.Err() != nil
189 return core.ToolResult{184 return core.ToolResult{
@@ -194,33 +189,10 @@ func (t *BashTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
194 "full_output_path": fullPath,189 "full_output_path": fullPath,
195 "lines_truncated": truncLines,190 "lines_truncated": truncLines,
196 "bytes_truncated": truncBytes,191 "bytes_truncated": truncBytes,
197- "duration_ms": elapsed.Milliseconds(),
198 },192 },
199 }, nil193 }, nil
200 }194 }
201 195
202-// humanDuration renders a duration in the "Took X.Ys" style used by
203-// the shell-log output: tenths of a second for sub-minute runs,
204-// whole seconds once we pass a minute. Trailing zeros dropped so
205-// "0.1s" instead of "0.10s".
206-func humanDuration(d time.Duration) string {
207- switch {
208- case d < time.Millisecond:
209- return "0.0s"
210- case d < time.Minute:
211- s := d.Seconds()
212- return fmt.Sprintf("%.1fs", s)
213- case d < time.Hour:
214- m := int(d.Minutes())
215- s := int(d.Seconds()) - m*60
216- return fmt.Sprintf("%dm%ds", m, s)
217- default:
218- h := int(d.Hours())
219- m := int(d.Minutes()) - h*60
220- return fmt.Sprintf("%dh%dm", h, m)
221- }
222-}
223-
224 func writeFullOutput(s string) string {196 func writeFullOutput(s string) string {
225 b := make([]byte, 6)197 b := make([]byte, 6)
226 _, _ = rand.Read(b)198 _, _ = rand.Read(b)
M packages/agent/tools/tools_test.go +8 -0
@@ -350,6 +350,14 @@ func TestBashSuccess(t *testing.T) {
350350 if !strings.Contains(got, "hi") || !strings.Contains(got, "[exit 0]") {
351351 t.Fatalf("got %q", got)
352352 }
353+ if strings.Contains(got, "Took ") {
354+ t.Fatalf("bash output still contains duplicate timing: %q", got)
355+ }
356+ if details, ok := res.Details.(map[string]any); ok {
357+ if _, exists := details["duration_ms"]; exists {
358+ t.Fatalf("bash details still contain duplicate duration: %#v", details)
359+ }
360+ }
353361 if res.IsError {
354362 t.Fatal("unexpected error flag")
355363 }
@@ -350,6 +350,14 @@ func TestBashSuccess(t *testing.T) {
350 if !strings.Contains(got, "hi") || !strings.Contains(got, "[exit 0]") {350 if !strings.Contains(got, "hi") || !strings.Contains(got, "[exit 0]") {
351 t.Fatalf("got %q", got)351 t.Fatalf("got %q", got)
352 }352 }
353+ if strings.Contains(got, "Took ") {
354+ t.Fatalf("bash output still contains duplicate timing: %q", got)
355+ }
356+ if details, ok := res.Details.(map[string]any); ok {
357+ if _, exists := details["duration_ms"]; exists {
358+ t.Fatalf("bash details still contain duplicate duration: %#v", details)
359+ }
360+ }
353 if res.IsError {361 if res.IsError {
354 t.Fatal("unexpected error flag")362 t.Fatal("unexpected error flag")
355 }363 }
M packages/core/agent.go +68 -45
@@ -13,6 +13,11 @@ import (
1313 "github.com/bnema/zut/packages/provider"
1414 )
1515
16+type queuedMessage struct {
17+ text string
18+ accepted time.Time
19+}
20+
1621 // Agent is a stateful conversation bound to a provider client, a model,
1722 // and a set of tools.
1823 type Agent struct {
@@ -131,7 +136,8 @@ type Agent struct {
131136 // boundaries: before the next model call after a tool batch, or
132137 // after a text-only assistant turn finishes. It never interrupts
133138 // a running tool or cancels an in-flight provider request.
134- queued []string
139+ queued []queuedMessage
140+ timeContext agentTimeContext
135141 }
136142
137143 // NewAgent returns an Agent with sensible defaults.
@@ -144,6 +150,7 @@ func NewAgent(client provider.Client, model, system string, tools Registry) *Age
144150 MaxSteps: 0, // 0 = unlimited
145151 MaxRetries: 3,
146152 RetryBaseDelay: 2 * time.Second,
153+ timeContext: newAgentTimeContext(time.Now()),
147154 }
148155 }
149156
@@ -174,7 +181,7 @@ func (a *Agent) QueueMessage(text string) bool {
174181 return false
175182 }
176183 a.mu.Lock()
177- a.queued = append(a.queued, text)
184+ a.queued = append(a.queued, queuedMessage{text: text, accepted: time.Now()})
178185 a.mu.Unlock()
179186 return true
180187 }
@@ -186,7 +193,9 @@ func (a *Agent) PendingQueuedMessages() []string {
186193 a.mu.Lock()
187194 defer a.mu.Unlock()
188195 out := make([]string, len(a.queued))
189- copy(out, a.queued)
196+ for i, queued := range a.queued {
197+ out[i] = queued.text
198+ }
190199 return out
191200 }
192201
@@ -207,7 +216,7 @@ func (a *Agent) PopQueuedMessage() (string, bool) {
207216 if n == 0 {
208217 return "", false
209218 }
210- text := a.queued[n-1]
219+ text := a.queued[n-1].text
211220 a.queued = a.queued[:n-1]
212221 return text, true
213222 }
@@ -216,24 +225,32 @@ func (a *Agent) PopQueuedMessage() (string, bool) {
216225 // Hosts use this on explicit cancel/clear so stale follow-ups do
217226 // not run after the user aborted the turn.
218227 func (a *Agent) DrainQueuedMessages() []string {
219- return a.drainQueuedMessages()
228+ queued := a.drainQueuedMessages()
229+ out := make([]string, len(queued))
230+ for i, message := range queued {
231+ out[i] = message.text
232+ }
233+ return out
220234 }
221235
222-func (a *Agent) drainQueuedMessages() []string {
236+func (a *Agent) drainQueuedMessages() []queuedMessage {
223237 a.mu.Lock()
224238 defer a.mu.Unlock()
225- out := make([]string, len(a.queued))
226- copy(out, a.queued)
239+ out := append([]queuedMessage(nil), a.queued...)
227240 a.queued = nil
228241 return out
229242 }
230243
231-func (a *Agent) appendQueuedAsUser(texts []string, sink func(AgentEvent)) {
232- for _, text := range texts {
244+func (a *Agent) appendQueuedAsUser(messages []queuedMessage, sink func(AgentEvent)) {
245+ for _, queued := range messages {
246+ accepted := queued.accepted
247+ if accepted.IsZero() {
248+ accepted = time.Now()
249+ }
233250 msg := provider.Message{
234251 Role: provider.RoleUser,
235- Content: []provider.Content{provider.TextBlock{Text: text}},
236- Time: time.Now(),
252+ Content: []provider.Content{provider.TextBlock{Text: queued.text}},
253+ Time: accepted,
237254 }
238255 a.mu.Lock()
239256 a.messages = append(a.messages, msg)
@@ -690,6 +707,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
690707 return provider.StopError, provider.Message{}, err
691708 }
692709 system, tools := a.PromptConfig()
710+ systemContext := a.providerTimeContext().systemText()
693711 if contextText := boundedTurnContext(turnContext); contextText != "" {
694712 if system != "" {
695713 system += "\n\n"
@@ -701,8 +719,9 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
701719 // remain in the request so every tool call still has a matching result.
702720 messages := repairToolUseResultPairs(a.Messages())
703721 req := provider.Request{
704- Model: a.Model,
705- System: system,
722+ Model: a.Model,
723+ System: system,
724+ SystemContext: systemContext,
706725 // Repair any dangling tool_use blocks before sending. A turn
707726 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a
708727 // dev server, etc.) can leave an assistant tool_use with no
@@ -711,7 +730,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
711730 // next in-process request is rejected by providers like Anthropic
712731 // with "tool_use ids were found without tool_result blocks". The
713732 // repair is pure and a no-op on already-valid transcripts.
714- Messages: projectToolResultMessages(messages),
733+ Messages: projectProviderMessages(messages),
715734 Tools: tools.Specs(),
716735 Reasoning: a.Reasoning,
717736 FastMode: fastMode,
@@ -849,6 +868,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
849868 CallID: tc.ID,
850869 Content: res.Content,
851870 IsError: res.IsError,
871+ Timing: res.Timing,
852872 })
853873 for _, name := range res.ActivateTools {
854874 if _, err := tools.Get(name); err == nil && !containsString(addedTools, name) {
@@ -869,7 +889,24 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
869889 }, hadError
870890 }
871891
872-func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) ToolResult {
892+func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) (res ToolResult) {
893+ started := time.Now()
894+ defer func() {
895+ if recovered := recover(); recovered != nil {
896+ res = ToolResult{
897+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", recovered)}},
898+ IsError: true,
899+ }
900+ }
901+ res.Timing = &provider.ToolTiming{
902+ StartedAt: started,
903+ CompletedAt: time.Now(),
904+ Duration: time.Since(started),
905+ }
906+ }()
907+
908+ sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
909+
873910 tool, err := tools.Get(tc.Name)
874911 if err != nil {
875912 return ToolResult{
@@ -906,38 +943,24 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
906943 args = json.RawMessage("{}")
907944 }
908945
909- // Recover panics so a buggy tool does not crash the agent.
910- var res ToolResult
911- func() {
912- defer func() {
913- if r := recover(); r != nil {
914- res = ToolResult{
915- Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", r)}},
916- IsError: true,
917- }
918- }
919- }()
920- sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
921- out, err := tool.Execute(ctx, args, func(text string) {
922- sink(EvToolProgress{ID: tc.ID, Text: text})
923- })
924- if err != nil {
925- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
926- res = ToolResult{
927- Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
928- IsError: true,
929- }
930- return
931- }
932- res = ToolResult{
933- Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
946+ // Tool panics are recovered by the invocation-level defer above so
947+ // guard, lookup, progress, and Execute panics all receive timing.
948+ out, err := tool.Execute(ctx, args, func(text string) {
949+ sink(EvToolProgress{ID: tc.ID, Text: text})
950+ })
951+ if err != nil {
952+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
953+ return ToolResult{
954+ Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
934955 IsError: true,
935956 }
936- return
937957 }
938- res = out
939- }()
940- return res
958+ return ToolResult{
959+ Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
960+ IsError: true,
961+ }
962+ }
963+ return out
941964 }
942965
943966 // extractText concatenates all TextBlock content in a message. Used
@@ -13,6 +13,11 @@ import (
13 "github.com/bnema/zut/packages/provider"13 "github.com/bnema/zut/packages/provider"
14 )14 )
15 15
16+type queuedMessage struct {
17+ text string
18+ accepted time.Time
19+}
20+
16 // Agent is a stateful conversation bound to a provider client, a model,21 // Agent is a stateful conversation bound to a provider client, a model,
17 // and a set of tools.22 // and a set of tools.
18 type Agent struct {23 type Agent struct {
@@ -131,7 +136,8 @@ type Agent struct {
131 // boundaries: before the next model call after a tool batch, or136 // boundaries: before the next model call after a tool batch, or
132 // after a text-only assistant turn finishes. It never interrupts137 // after a text-only assistant turn finishes. It never interrupts
133 // a running tool or cancels an in-flight provider request.138 // a running tool or cancels an in-flight provider request.
134- queued []string139+ queued []queuedMessage
140+ timeContext agentTimeContext
135 }141 }
136 142
137 // NewAgent returns an Agent with sensible defaults.143 // NewAgent returns an Agent with sensible defaults.
@@ -144,6 +150,7 @@ func NewAgent(client provider.Client, model, system string, tools Registry) *Age
144 MaxSteps: 0, // 0 = unlimited150 MaxSteps: 0, // 0 = unlimited
145 MaxRetries: 3,151 MaxRetries: 3,
146 RetryBaseDelay: 2 * time.Second,152 RetryBaseDelay: 2 * time.Second,
153+ timeContext: newAgentTimeContext(time.Now()),
147 }154 }
148 }155 }
149 156
@@ -174,7 +181,7 @@ func (a *Agent) QueueMessage(text string) bool {
174 return false181 return false
175 }182 }
176 a.mu.Lock()183 a.mu.Lock()
177- a.queued = append(a.queued, text)184+ a.queued = append(a.queued, queuedMessage{text: text, accepted: time.Now()})
178 a.mu.Unlock()185 a.mu.Unlock()
179 return true186 return true
180 }187 }
@@ -186,7 +193,9 @@ func (a *Agent) PendingQueuedMessages() []string {
186 a.mu.Lock()193 a.mu.Lock()
187 defer a.mu.Unlock()194 defer a.mu.Unlock()
188 out := make([]string, len(a.queued))195 out := make([]string, len(a.queued))
189- copy(out, a.queued)196+ for i, queued := range a.queued {
197+ out[i] = queued.text
198+ }
190 return out199 return out
191 }200 }
192 201
@@ -207,7 +216,7 @@ func (a *Agent) PopQueuedMessage() (string, bool) {
207 if n == 0 {216 if n == 0 {
208 return "", false217 return "", false
209 }218 }
210- text := a.queued[n-1]219+ text := a.queued[n-1].text
211 a.queued = a.queued[:n-1]220 a.queued = a.queued[:n-1]
212 return text, true221 return text, true
213 }222 }
@@ -216,24 +225,32 @@ func (a *Agent) PopQueuedMessage() (string, bool) {
216 // Hosts use this on explicit cancel/clear so stale follow-ups do225 // Hosts use this on explicit cancel/clear so stale follow-ups do
217 // not run after the user aborted the turn.226 // not run after the user aborted the turn.
218 func (a *Agent) DrainQueuedMessages() []string {227 func (a *Agent) DrainQueuedMessages() []string {
219- return a.drainQueuedMessages()228+ queued := a.drainQueuedMessages()
229+ out := make([]string, len(queued))
230+ for i, message := range queued {
231+ out[i] = message.text
232+ }
233+ return out
220 }234 }
221 235
222-func (a *Agent) drainQueuedMessages() []string {236+func (a *Agent) drainQueuedMessages() []queuedMessage {
223 a.mu.Lock()237 a.mu.Lock()
224 defer a.mu.Unlock()238 defer a.mu.Unlock()
225- out := make([]string, len(a.queued))239+ out := append([]queuedMessage(nil), a.queued...)
226- copy(out, a.queued)
227 a.queued = nil240 a.queued = nil
228 return out241 return out
229 }242 }
230 243
231-func (a *Agent) appendQueuedAsUser(texts []string, sink func(AgentEvent)) {244+func (a *Agent) appendQueuedAsUser(messages []queuedMessage, sink func(AgentEvent)) {
232- for _, text := range texts {245+ for _, queued := range messages {
246+ accepted := queued.accepted
247+ if accepted.IsZero() {
248+ accepted = time.Now()
249+ }
233 msg := provider.Message{250 msg := provider.Message{
234 Role: provider.RoleUser,251 Role: provider.RoleUser,
235- Content: []provider.Content{provider.TextBlock{Text: text}},252+ Content: []provider.Content{provider.TextBlock{Text: queued.text}},
236- Time: time.Now(),253+ Time: accepted,
237 }254 }
238 a.mu.Lock()255 a.mu.Lock()
239 a.messages = append(a.messages, msg)256 a.messages = append(a.messages, msg)
@@ -690,6 +707,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
690 return provider.StopError, provider.Message{}, err707 return provider.StopError, provider.Message{}, err
691 }708 }
692 system, tools := a.PromptConfig()709 system, tools := a.PromptConfig()
710+ systemContext := a.providerTimeContext().systemText()
693 if contextText := boundedTurnContext(turnContext); contextText != "" {711 if contextText := boundedTurnContext(turnContext); contextText != "" {
694 if system != "" {712 if system != "" {
695 system += "\n\n"713 system += "\n\n"
@@ -701,8 +719,9 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
701 // remain in the request so every tool call still has a matching result.719 // remain in the request so every tool call still has a matching result.
702 messages := repairToolUseResultPairs(a.Messages())720 messages := repairToolUseResultPairs(a.Messages())
703 req := provider.Request{721 req := provider.Request{
704- Model: a.Model,722+ Model: a.Model,
705- System: system,723+ System: system,
724+ SystemContext: systemContext,
706 // Repair any dangling tool_use blocks before sending. A turn725 // Repair any dangling tool_use blocks before sending. A turn
707 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a726 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a
708 // dev server, etc.) can leave an assistant tool_use with no727 // dev server, etc.) can leave an assistant tool_use with no
@@ -711,7 +730,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
711 // next in-process request is rejected by providers like Anthropic730 // next in-process request is rejected by providers like Anthropic
712 // with "tool_use ids were found without tool_result blocks". The731 // with "tool_use ids were found without tool_result blocks". The
713 // repair is pure and a no-op on already-valid transcripts.732 // repair is pure and a no-op on already-valid transcripts.
714- Messages: projectToolResultMessages(messages),733+ Messages: projectProviderMessages(messages),
715 Tools: tools.Specs(),734 Tools: tools.Specs(),
716 Reasoning: a.Reasoning,735 Reasoning: a.Reasoning,
717 FastMode: fastMode,736 FastMode: fastMode,
@@ -849,6 +868,7 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
849 CallID: tc.ID,868 CallID: tc.ID,
850 Content: res.Content,869 Content: res.Content,
851 IsError: res.IsError,870 IsError: res.IsError,
871+ Timing: res.Timing,
852 })872 })
853 for _, name := range res.ActivateTools {873 for _, name := range res.ActivateTools {
854 if _, err := tools.Get(name); err == nil && !containsString(addedTools, name) {874 if _, err := tools.Get(name); err == nil && !containsString(addedTools, name) {
@@ -869,7 +889,24 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
869 }, hadError889 }, hadError
870 }890 }
871 891
872-func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) ToolResult {892+func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) (res ToolResult) {
893+ started := time.Now()
894+ defer func() {
895+ if recovered := recover(); recovered != nil {
896+ res = ToolResult{
897+ Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", recovered)}},
898+ IsError: true,
899+ }
900+ }
901+ res.Timing = &provider.ToolTiming{
902+ StartedAt: started,
903+ CompletedAt: time.Now(),
904+ Duration: time.Since(started),
905+ }
906+ }()
907+
908+ sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
909+
873 tool, err := tools.Get(tc.Name)910 tool, err := tools.Get(tc.Name)
874 if err != nil {911 if err != nil {
875 return ToolResult{912 return ToolResult{
@@ -906,38 +943,24 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
906 args = json.RawMessage("{}")943 args = json.RawMessage("{}")
907 }944 }
908 945
909- // Recover panics so a buggy tool does not crash the agent.946+ // Tool panics are recovered by the invocation-level defer above so
910- var res ToolResult947+ // guard, lookup, progress, and Execute panics all receive timing.
911- func() {948+ out, err := tool.Execute(ctx, args, func(text string) {
912- defer func() {949+ sink(EvToolProgress{ID: tc.ID, Text: text})
913- if r := recover(); r != nil {950+ })
914- res = ToolResult{951+ if err != nil {
915- Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", r)}},952+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
916- IsError: true,953+ return ToolResult{
917- }954+ Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
918- }
919- }()
920- sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
921- out, err := tool.Execute(ctx, args, func(text string) {
922- sink(EvToolProgress{ID: tc.ID, Text: text})
923- })
924- if err != nil {
925- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
926- res = ToolResult{
927- Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
928- IsError: true,
929- }
930- return
931- }
932- res = ToolResult{
933- Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
934 IsError: true,955 IsError: true,
935 }956 }
936- return
937 }957 }
938- res = out958+ return ToolResult{
939- }()959+ Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
940- return res960+ IsError: true,
961+ }
962+ }
963+ return out
941 }964 }
942 965
943 // extractText concatenates all TextBlock content in a message. Used966 // extractText concatenates all TextBlock content in a message. Used
M packages/core/compact.go +8 -6
@@ -62,12 +62,14 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
6262
6363 prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt
6464
65+ systemContext := a.providerTimeContext().systemText()
6566 req := provider.Request{
66- Model: a.Model,
67- System: summarizationSystem,
68- MaxTokens: 4096,
69- Temperature: a.Temperature,
70- FastMode: fastMode,
67+ Model: a.Model,
68+ System: summarizationSystem,
69+ SystemContext: systemContext,
70+ MaxTokens: 4096,
71+ Temperature: a.Temperature,
72+ FastMode: fastMode,
7173 Messages: []provider.Message{
7274 {
7375 Role: provider.RoleUser,
@@ -193,7 +195,7 @@ func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {
193195 // text transcript the summarization model can read without trying to
194196 // continue the conversation.
195197 func serializeTranscript(msgs []provider.Message) string {
196- msgs = projectToolResultMessages(msgs)
198+ msgs = projectProviderMessages(msgs)
197199 var sb strings.Builder
198200 for _, m := range msgs {
199201 switch m.Role {
@@ -62,12 +62,14 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
62 62
63 prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt63 prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt
64 64
65+ systemContext := a.providerTimeContext().systemText()
65 req := provider.Request{66 req := provider.Request{
66- Model: a.Model,67+ Model: a.Model,
67- System: summarizationSystem,68+ System: summarizationSystem,
68- MaxTokens: 4096,69+ SystemContext: systemContext,
69- Temperature: a.Temperature,70+ MaxTokens: 4096,
70- FastMode: fastMode,71+ Temperature: a.Temperature,
72+ FastMode: fastMode,
71 Messages: []provider.Message{73 Messages: []provider.Message{
72 {74 {
73 Role: provider.RoleUser,75 Role: provider.RoleUser,
@@ -193,7 +195,7 @@ func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {
193 // text transcript the summarization model can read without trying to195 // text transcript the summarization model can read without trying to
194 // continue the conversation.196 // continue the conversation.
195 func serializeTranscript(msgs []provider.Message) string {197 func serializeTranscript(msgs []provider.Message) string {
196- msgs = projectToolResultMessages(msgs)198+ msgs = projectProviderMessages(msgs)
197 var sb strings.Builder199 var sb strings.Builder
198 for _, m := range msgs {200 for _, m := range msgs {
199 switch m.Role {201 switch m.Role {
M packages/core/context_budget.go +37 -9
@@ -1,6 +1,8 @@
11 package core
22
33 import (
4+ "fmt"
5+ "time"
46 "unicode/utf8"
57
68 "github.com/bnema/zut/packages/provider"
@@ -29,24 +31,50 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
2931 }
3032
3133 textBytes := toolResultTextBytes(result.Content)
32- if textBytes == 0 {
33- continue
34+ if textBytes > 0 {
35+ limit := maxToolResultTextBytes
36+ if remaining < limit {
37+ limit = remaining
38+ }
39+ var retained int
40+ result.Content, retained = projectToolResultContent(result.Content, limit)
41+ remaining -= retained
3442 }
35-
36- limit := maxToolResultTextBytes
37- if remaining < limit {
38- limit = remaining
43+ if result.Timing != nil {
44+ result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)})
3945 }
40- var retained int
41- result.Content, retained = projectToolResultContent(result.Content, limit)
4246 projected[i].Content[j] = result
43- remaining -= retained
4447 }
4548 }
4649
4750 return projected
4851 }
4952
53+// projectProviderMessages creates the provider-only transcript view. The
54+// timestamp and timing annotations are deliberately added to this copy so
55+// the visible transcript and its persisted historical content remain intact.
56+func projectProviderMessages(msgs []provider.Message) []provider.Message {
57+ projected := projectToolResultMessages(msgs)
58+ for i, message := range projected {
59+ if message.Role != provider.RoleUser || message.Time.IsZero() {
60+ continue
61+ }
62+ content := make([]provider.Content, 0, len(message.Content)+1)
63+ content = append(content, provider.TextBlock{Text: fmt.Sprintf("[message time: %s]", message.Time.Format(time.RFC3339))})
64+ content = append(content, message.Content...)
65+ projected[i].Content = content
66+ }
67+ return projected
68+}
69+
70+func formatToolTiming(timing *provider.ToolTiming) string {
71+ return fmt.Sprintf("[tool timing: started=%s completed=%s duration=%s]",
72+ timing.StartedAt.Format(time.RFC3339),
73+ timing.CompletedAt.Format(time.RFC3339),
74+ timing.Duration,
75+ )
76+}
77+
5078 // copyToolResultMessages copies the slices that the projection can replace.
5179 // Content blocks are values, so sharing their immutable fields is safe; the
5280 // nested content slice of every ToolResultBlock is copied before projection.
@@ -1,6 +1,8 @@
1 package core1 package core
2 2
3 import (3 import (
4+ "fmt"
5+ "time"
4 "unicode/utf8"6 "unicode/utf8"
5 7
6 "github.com/bnema/zut/packages/provider"8 "github.com/bnema/zut/packages/provider"
@@ -29,24 +31,50 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
29 }31 }
30 32
31 textBytes := toolResultTextBytes(result.Content)33 textBytes := toolResultTextBytes(result.Content)
32- if textBytes == 0 {34+ if textBytes > 0 {
33- continue35+ limit := maxToolResultTextBytes
36+ if remaining < limit {
37+ limit = remaining
38+ }
39+ var retained int
40+ result.Content, retained = projectToolResultContent(result.Content, limit)
41+ remaining -= retained
34 }42 }
35-43+ if result.Timing != nil {
36- limit := maxToolResultTextBytes44+ result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)})
37- if remaining < limit {
38- limit = remaining
39 }45 }
40- var retained int
41- result.Content, retained = projectToolResultContent(result.Content, limit)
42 projected[i].Content[j] = result46 projected[i].Content[j] = result
43- remaining -= retained
44 }47 }
45 }48 }
46 49
47 return projected50 return projected
48 }51 }
49 52
53+// projectProviderMessages creates the provider-only transcript view. The
54+// timestamp and timing annotations are deliberately added to this copy so
55+// the visible transcript and its persisted historical content remain intact.
56+func projectProviderMessages(msgs []provider.Message) []provider.Message {
57+ projected := projectToolResultMessages(msgs)
58+ for i, message := range projected {
59+ if message.Role != provider.RoleUser || message.Time.IsZero() {
60+ continue
61+ }
62+ content := make([]provider.Content, 0, len(message.Content)+1)
63+ content = append(content, provider.TextBlock{Text: fmt.Sprintf("[message time: %s]", message.Time.Format(time.RFC3339))})
64+ content = append(content, message.Content...)
65+ projected[i].Content = content
66+ }
67+ return projected
68+}
69+
70+func formatToolTiming(timing *provider.ToolTiming) string {
71+ return fmt.Sprintf("[tool timing: started=%s completed=%s duration=%s]",
72+ timing.StartedAt.Format(time.RFC3339),
73+ timing.CompletedAt.Format(time.RFC3339),
74+ timing.Duration,
75+ )
76+}
77+
50 // copyToolResultMessages copies the slices that the projection can replace.78 // copyToolResultMessages copies the slices that the projection can replace.
51 // Content blocks are values, so sharing their immutable fields is safe; the79 // Content blocks are values, so sharing their immutable fields is safe; the
52 // nested content slice of every ToolResultBlock is copied before projection.80 // nested content slice of every ToolResultBlock is copied before projection.
M packages/core/context_budget_test.go +65 -0
@@ -5,11 +5,76 @@ import (
55 "reflect"
66 "strings"
77 "testing"
8+ "time"
89 "unicode/utf8"
910
1011 "github.com/bnema/zut/packages/provider"
1112 )
1213
14+func TestProjectProviderMessagesAnnotatesUserTimeWithoutMutatingTranscript(t *testing.T) {
15+ zone := time.FixedZone("PDT", -7*60*60)
16+ accepted := time.Date(2026, 8, 8, 12, 34, 56, 0, zone)
17+ messages := []provider.Message{{
18+ Role: provider.RoleUser,
19+ Content: []provider.Content{provider.TextBlock{Text: "hello"}},
20+ Time: accepted,
21+ }}
22+
23+ projected := projectProviderMessages(messages)
24+ if got := messages[0].Content[0].(provider.TextBlock).Text; got != "hello" {
25+ t.Fatalf("raw user text changed to %q", got)
26+ }
27+ if len(projected[0].Content) != 2 {
28+ t.Fatalf("projected content = %d blocks, want timestamp and original", len(projected[0].Content))
29+ }
30+ annotation := projected[0].Content[0].(provider.TextBlock).Text
31+ if annotation != "[message time: 2026-08-08T12:34:56-07:00]" {
32+ t.Fatalf("timestamp annotation = %q", annotation)
33+ }
34+ if got := projected[0].Content[1].(provider.TextBlock).Text; got != "hello" {
35+ t.Fatalf("projected user text = %q", got)
36+ }
37+}
38+
39+func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) {
40+ a := NewAgent(nil, "model", "system", Registry{})
41+ started := time.Date(2026, 8, 8, 9, 10, 11, 0, time.UTC)
42+ a.SetSessionTimeContext(started, "America/Los_Angeles", "-07:00")
43+
44+ first := a.providerTimeContext().systemText()
45+ second := a.providerTimeContext().systemText()
46+ if first != second {
47+ t.Fatalf("time context changed between requests: %q != %q", first, second)
48+ }
49+ for _, want := range []string{"session_started: 2026-08-08T02:10:11-07:00", "local_timezone: America/Los_Angeles (UTC-07:00)"} {
50+ if !strings.Contains(first, want) {
51+ t.Fatalf("time context %q missing %q", first, want)
52+ }
53+ }
54+}
55+
56+func TestProjectToolTimingIsModelOnly(t *testing.T) {
57+ started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60))
58+ timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(1500 * time.Millisecond), Duration: 1500 * time.Millisecond}
59+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
60+ CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "output"}}, Timing: timing,
61+ }}}}
62+
63+ projected := projectProviderMessages(messages)
64+ original := messages[0].Content[0].(provider.ToolResultBlock)
65+ if len(original.Content) != 1 {
66+ t.Fatalf("raw result content was changed: %#v", original.Content)
67+ }
68+ result := projected[0].Content[0].(provider.ToolResultBlock)
69+ if len(result.Content) != 2 {
70+ t.Fatalf("projected result content = %d blocks, want output and timing", len(result.Content))
71+ }
72+ timingText := result.Content[1].(provider.TextBlock).Text
73+ if !strings.Contains(timingText, "started=2026-08-08T12:00:00-04:00") || !strings.Contains(timingText, "duration=1.5s") {
74+ t.Fatalf("timing projection = %q", timingText)
75+ }
76+}
77+
1378 func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
1479 msgs := make([]provider.Message, 0, 10)
1580 for i := 0; i < 5; i++ {
@@ -5,11 +5,76 @@ import (
5 "reflect"5 "reflect"
6 "strings"6 "strings"
7 "testing"7 "testing"
8+ "time"
8 "unicode/utf8"9 "unicode/utf8"
9 10
10 "github.com/bnema/zut/packages/provider"11 "github.com/bnema/zut/packages/provider"
11 )12 )
12 13
14+func TestProjectProviderMessagesAnnotatesUserTimeWithoutMutatingTranscript(t *testing.T) {
15+ zone := time.FixedZone("PDT", -7*60*60)
16+ accepted := time.Date(2026, 8, 8, 12, 34, 56, 0, zone)
17+ messages := []provider.Message{{
18+ Role: provider.RoleUser,
19+ Content: []provider.Content{provider.TextBlock{Text: "hello"}},
20+ Time: accepted,
21+ }}
22+
23+ projected := projectProviderMessages(messages)
24+ if got := messages[0].Content[0].(provider.TextBlock).Text; got != "hello" {
25+ t.Fatalf("raw user text changed to %q", got)
26+ }
27+ if len(projected[0].Content) != 2 {
28+ t.Fatalf("projected content = %d blocks, want timestamp and original", len(projected[0].Content))
29+ }
30+ annotation := projected[0].Content[0].(provider.TextBlock).Text
31+ if annotation != "[message time: 2026-08-08T12:34:56-07:00]" {
32+ t.Fatalf("timestamp annotation = %q", annotation)
33+ }
34+ if got := projected[0].Content[1].(provider.TextBlock).Text; got != "hello" {
35+ t.Fatalf("projected user text = %q", got)
36+ }
37+}
38+
39+func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) {
40+ a := NewAgent(nil, "model", "system", Registry{})
41+ started := time.Date(2026, 8, 8, 9, 10, 11, 0, time.UTC)
42+ a.SetSessionTimeContext(started, "America/Los_Angeles", "-07:00")
43+
44+ first := a.providerTimeContext().systemText()
45+ second := a.providerTimeContext().systemText()
46+ if first != second {
47+ t.Fatalf("time context changed between requests: %q != %q", first, second)
48+ }
49+ for _, want := range []string{"session_started: 2026-08-08T02:10:11-07:00", "local_timezone: America/Los_Angeles (UTC-07:00)"} {
50+ if !strings.Contains(first, want) {
51+ t.Fatalf("time context %q missing %q", first, want)
52+ }
53+ }
54+}
55+
56+func TestProjectToolTimingIsModelOnly(t *testing.T) {
57+ started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60))
58+ timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(1500 * time.Millisecond), Duration: 1500 * time.Millisecond}
59+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
60+ CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "output"}}, Timing: timing,
61+ }}}}
62+
63+ projected := projectProviderMessages(messages)
64+ original := messages[0].Content[0].(provider.ToolResultBlock)
65+ if len(original.Content) != 1 {
66+ t.Fatalf("raw result content was changed: %#v", original.Content)
67+ }
68+ result := projected[0].Content[0].(provider.ToolResultBlock)
69+ if len(result.Content) != 2 {
70+ t.Fatalf("projected result content = %d blocks, want output and timing", len(result.Content))
71+ }
72+ timingText := result.Content[1].(provider.TextBlock).Text
73+ if !strings.Contains(timingText, "started=2026-08-08T12:00:00-04:00") || !strings.Contains(timingText, "duration=1.5s") {
74+ t.Fatalf("timing projection = %q", timingText)
75+ }
76+}
77+
13 func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {78 func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
14 msgs := make([]provider.Message, 0, 10)79 msgs := make([]provider.Message, 0, 10)
15 for i := 0; i < 5; i++ {80 for i := 0; i < 5; i++ {
M packages/core/intercept_test.go +63 -0
@@ -3,6 +3,7 @@ package core
33 import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "testing"
78
89 "github.com/bnema/zut/packages/provider"
@@ -14,6 +15,22 @@ type recordingTool struct {
1415 lastArgs json.RawMessage
1516 }
1617
18+type resultTool struct {
19+ result ToolResult
20+ err error
21+ panic bool
22+}
23+
24+func (t *resultTool) Name() string { return "result" }
25+func (t *resultTool) Description() string { return "result" }
26+func (t *resultTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
27+func (t *resultTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
28+ if t.panic {
29+ panic("tool boom")
30+ }
31+ return t.result, t.err
32+}
33+
1734 func (r *recordingTool) Name() string { return "echo" }
1835 func (r *recordingTool) Description() string { return "echoes" }
1936 func (r *recordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
@@ -48,6 +65,7 @@ func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
4865 if res.IsError {
4966 t.Fatalf("unexpected error result: %v", res.Content)
5067 }
68+ assertToolTiming(t, res.Timing)
5169 if string(rec.lastArgs) != string(newArgs) {
5270 t.Errorf("tool saw %s, want %s", string(rec.lastArgs), string(newArgs))
5371 }
@@ -129,4 +147,49 @@ func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
129147 if rec.lastArgs != nil {
130148 t.Error("tool ran despite block")
131149 }
150+ assertToolTiming(t, res.Timing)
151+}
152+
153+func TestRunOneToolTimingCoversOutcomes(t *testing.T) {
154+ cancelled, cancel := context.WithCancel(context.Background())
155+ cancel()
156+ cases := []struct {
157+ name string
158+ tools Registry
159+ ctx context.Context
160+ want string
161+ }{
162+ {name: "success", tools: Registry{"result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}}}, ctx: context.Background()},
163+ {name: "ordinary error", tools: Registry{"result": &resultTool{err: errors.New("ordinary")}}, ctx: context.Background()},
164+ {name: "cancellation", tools: Registry{"result": &resultTool{err: context.Canceled}}, ctx: cancelled},
165+ {name: "missing", tools: Registry{}, ctx: context.Background()},
166+ {name: "panic", tools: Registry{"result": &resultTool{panic: true}}, ctx: context.Background()},
167+ }
168+ for _, tc := range cases {
169+ t.Run(tc.name, func(t *testing.T) {
170+ a := NewAgent(nil, "test", "", tc.tools)
171+ callName := "result"
172+ if tc.name == "missing" {
173+ callName = "missing"
174+ }
175+ res := a.runOneTool(tc.ctx, provider.ToolCallBlock{ID: "timed", Name: callName}, tc.tools, func(AgentEvent) {})
176+ assertToolTiming(t, res.Timing)
177+ })
178+ }
179+}
180+
181+func assertToolTiming(t *testing.T, timing *provider.ToolTiming) {
182+ t.Helper()
183+ if timing == nil {
184+ t.Fatal("tool result has no timing")
185+ }
186+ if timing.StartedAt.IsZero() || timing.CompletedAt.IsZero() {
187+ t.Fatalf("tool timing has zero wall-clock bound: %#v", timing)
188+ }
189+ if timing.CompletedAt.Before(timing.StartedAt) {
190+ t.Fatalf("tool completed before start: %#v", timing)
191+ }
192+ if timing.Duration < 0 {
193+ t.Fatalf("tool duration = %s, want non-negative", timing.Duration)
194+ }
132195 }
@@ -3,6 +3,7 @@ package core
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "errors"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/zut/packages/provider"9 "github.com/bnema/zut/packages/provider"
@@ -14,6 +15,22 @@ type recordingTool struct {
14 lastArgs json.RawMessage15 lastArgs json.RawMessage
15 }16 }
16 17
18+type resultTool struct {
19+ result ToolResult
20+ err error
21+ panic bool
22+}
23+
24+func (t *resultTool) Name() string { return "result" }
25+func (t *resultTool) Description() string { return "result" }
26+func (t *resultTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
27+func (t *resultTool) Execute(context.Context, json.RawMessage, func(string)) (ToolResult, error) {
28+ if t.panic {
29+ panic("tool boom")
30+ }
31+ return t.result, t.err
32+}
33+
17 func (r *recordingTool) Name() string { return "echo" }34 func (r *recordingTool) Name() string { return "echo" }
18 func (r *recordingTool) Description() string { return "echoes" }35 func (r *recordingTool) Description() string { return "echoes" }
19 func (r *recordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }36 func (r *recordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
@@ -48,6 +65,7 @@ func TestBeforeToolExecuteModifiesArgs(t *testing.T) {
48 if res.IsError {65 if res.IsError {
49 t.Fatalf("unexpected error result: %v", res.Content)66 t.Fatalf("unexpected error result: %v", res.Content)
50 }67 }
68+ assertToolTiming(t, res.Timing)
51 if string(rec.lastArgs) != string(newArgs) {69 if string(rec.lastArgs) != string(newArgs) {
52 t.Errorf("tool saw %s, want %s", string(rec.lastArgs), string(newArgs))70 t.Errorf("tool saw %s, want %s", string(rec.lastArgs), string(newArgs))
53 }71 }
@@ -129,4 +147,49 @@ func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
129 if rec.lastArgs != nil {147 if rec.lastArgs != nil {
130 t.Error("tool ran despite block")148 t.Error("tool ran despite block")
131 }149 }
150+ assertToolTiming(t, res.Timing)
151+}
152+
153+func TestRunOneToolTimingCoversOutcomes(t *testing.T) {
154+ cancelled, cancel := context.WithCancel(context.Background())
155+ cancel()
156+ cases := []struct {
157+ name string
158+ tools Registry
159+ ctx context.Context
160+ want string
161+ }{
162+ {name: "success", tools: Registry{"result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}}}, ctx: context.Background()},
163+ {name: "ordinary error", tools: Registry{"result": &resultTool{err: errors.New("ordinary")}}, ctx: context.Background()},
164+ {name: "cancellation", tools: Registry{"result": &resultTool{err: context.Canceled}}, ctx: cancelled},
165+ {name: "missing", tools: Registry{}, ctx: context.Background()},
166+ {name: "panic", tools: Registry{"result": &resultTool{panic: true}}, ctx: context.Background()},
167+ }
168+ for _, tc := range cases {
169+ t.Run(tc.name, func(t *testing.T) {
170+ a := NewAgent(nil, "test", "", tc.tools)
171+ callName := "result"
172+ if tc.name == "missing" {
173+ callName = "missing"
174+ }
175+ res := a.runOneTool(tc.ctx, provider.ToolCallBlock{ID: "timed", Name: callName}, tc.tools, func(AgentEvent) {})
176+ assertToolTiming(t, res.Timing)
177+ })
178+ }
179+}
180+
181+func assertToolTiming(t *testing.T, timing *provider.ToolTiming) {
182+ t.Helper()
183+ if timing == nil {
184+ t.Fatal("tool result has no timing")
185+ }
186+ if timing.StartedAt.IsZero() || timing.CompletedAt.IsZero() {
187+ t.Fatalf("tool timing has zero wall-clock bound: %#v", timing)
188+ }
189+ if timing.CompletedAt.Before(timing.StartedAt) {
190+ t.Fatalf("tool completed before start: %#v", timing)
191+ }
192+ if timing.Duration < 0 {
193+ t.Fatalf("tool duration = %s, want non-negative", timing.Duration)
194+ }
132 }195 }
M packages/core/queue_test.go +18 -0
@@ -6,6 +6,7 @@ import (
66 "sync"
77 "sync/atomic"
88 "testing"
9+ "time"
910
1011 "github.com/bnema/zut/packages/provider"
1112 )
@@ -130,6 +131,23 @@ func queueTestContains(xs []string, want string) bool {
130131 return false
131132 }
132133
134+func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) {
135+ a := NewAgent(nil, "fake", "", Registry{})
136+ before := time.Now()
137+ if !a.QueueMessage("queued") {
138+ t.Fatal("QueueMessage returned false")
139+ }
140+ after := time.Now()
141+
142+ queued := a.drainQueuedMessages()
143+ if len(queued) != 1 {
144+ t.Fatalf("drained %d messages, want 1", len(queued))
145+ }
146+ if queued[0].accepted.Before(before) || queued[0].accepted.After(after) {
147+ t.Fatalf("accepted time %s outside queue submission window %s..%s", queued[0].accepted, before, after)
148+ }
149+}
150+
133151 func TestQueueMessageSnapshotPopAndDrain(t *testing.T) {
134152 a := NewAgent(nil, "fake", "", Registry{})
135153 if a.QueueMessage(" ") {
@@ -6,6 +6,7 @@ import (
6 "sync"6 "sync"
7 "sync/atomic"7 "sync/atomic"
8 "testing"8 "testing"
9+ "time"
9 10
10 "github.com/bnema/zut/packages/provider"11 "github.com/bnema/zut/packages/provider"
11 )12 )
@@ -130,6 +131,23 @@ func queueTestContains(xs []string, want string) bool {
130 return false131 return false
131 }132 }
132 133
134+func TestQueuedMessageKeepsAcceptedTimeAcrossDrain(t *testing.T) {
135+ a := NewAgent(nil, "fake", "", Registry{})
136+ before := time.Now()
137+ if !a.QueueMessage("queued") {
138+ t.Fatal("QueueMessage returned false")
139+ }
140+ after := time.Now()
141+
142+ queued := a.drainQueuedMessages()
143+ if len(queued) != 1 {
144+ t.Fatalf("drained %d messages, want 1", len(queued))
145+ }
146+ if queued[0].accepted.Before(before) || queued[0].accepted.After(after) {
147+ t.Fatalf("accepted time %s outside queue submission window %s..%s", queued[0].accepted, before, after)
148+ }
149+}
150+
133 func TestQueueMessageSnapshotPopAndDrain(t *testing.T) {151 func TestQueueMessageSnapshotPopAndDrain(t *testing.T) {
134 a := NewAgent(nil, "fake", "", Registry{})152 a := NewAgent(nil, "fake", "", Registry{})
135 if a.QueueMessage(" ") {153 if a.QueueMessage(" ") {
M packages/core/session.go +23 -7
@@ -56,6 +56,10 @@ type SessionMeta struct {
5656 Started time.Time `json:"started"`
5757 Version string `json:"version"`
5858 Title string `json:"title,omitempty"`
59+ // Timezone and TimezoneOffset capture the local semantics at session
60+ // start. They are optional so older session headers remain compatible.
61+ Timezone string `json:"timezone,omitempty"`
62+ TimezoneOffset string `json:"timezone_offset,omitempty"`
5963
6064 // CompactHandoff is opaque host-managed state for a live compaction
6165 // continuation. It is session metadata, never provider context.
@@ -179,10 +183,21 @@ func newSessionAt(p, cwd, providerName, model, version string) (*Session, error)
179183 if err != nil {
180184 return nil, err
181185 }
186+ now := time.Now()
187+ timezone, timezoneOffset := localTimeMetadata(now)
182188 s := &Session{
183- ID: id,
184- Path: p,
185- Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
189+ ID: id,
190+ Path: p,
191+ Meta: SessionMeta{
192+ ID: id,
193+ CWD: cwd,
194+ Provider: providerName,
195+ Model: model,
196+ Started: now.UTC(),
197+ Version: version,
198+ Timezone: timezone,
199+ TimezoneOffset: timezoneOffset,
200+ },
186201 writer: f,
187202 buf: bufio.NewWriter(f),
188203 freshFile: true,
@@ -1513,9 +1528,10 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
15131528 })
15141529 case head.CallID != "":
15151530 var tr struct {
1516- CallID string `json:"call_id"`
1517- Content []json.RawMessage `json:"content"`
1518- IsError bool `json:"is_error"`
1531+ CallID string `json:"call_id"`
1532+ Content []json.RawMessage `json:"content"`
1533+ IsError bool `json:"is_error"`
1534+ Timing *provider.ToolTiming `json:"timing,omitempty"`
15191535 }
15201536 if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil {
15211537 if err == nil {
@@ -1523,7 +1539,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
15231539 }
15241540 return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
15251541 }
1526- block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}
1542+ block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError, Timing: tr.Timing}
15271543 for innerIdx, c := range tr.Content {
15281544 var inner struct {
15291545 Text string `json:"text"`
@@ -56,6 +56,10 @@ type SessionMeta struct {
56 Started time.Time `json:"started"`56 Started time.Time `json:"started"`
57 Version string `json:"version"`57 Version string `json:"version"`
58 Title string `json:"title,omitempty"`58 Title string `json:"title,omitempty"`
59+ // Timezone and TimezoneOffset capture the local semantics at session
60+ // start. They are optional so older session headers remain compatible.
61+ Timezone string `json:"timezone,omitempty"`
62+ TimezoneOffset string `json:"timezone_offset,omitempty"`
59 63
60 // CompactHandoff is opaque host-managed state for a live compaction64 // CompactHandoff is opaque host-managed state for a live compaction
61 // continuation. It is session metadata, never provider context.65 // continuation. It is session metadata, never provider context.
@@ -179,10 +183,21 @@ func newSessionAt(p, cwd, providerName, model, version string) (*Session, error)
179 if err != nil {183 if err != nil {
180 return nil, err184 return nil, err
181 }185 }
186+ now := time.Now()
187+ timezone, timezoneOffset := localTimeMetadata(now)
182 s := &Session{188 s := &Session{
183- ID: id,189+ ID: id,
184- Path: p,190+ Path: p,
185- Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},191+ Meta: SessionMeta{
192+ ID: id,
193+ CWD: cwd,
194+ Provider: providerName,
195+ Model: model,
196+ Started: now.UTC(),
197+ Version: version,
198+ Timezone: timezone,
199+ TimezoneOffset: timezoneOffset,
200+ },
186 writer: f,201 writer: f,
187 buf: bufio.NewWriter(f),202 buf: bufio.NewWriter(f),
188 freshFile: true,203 freshFile: true,
@@ -1513,9 +1528,10 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
1513 })1528 })
1514 case head.CallID != "":1529 case head.CallID != "":
1515 var tr struct {1530 var tr struct {
1516- CallID string `json:"call_id"`1531+ CallID string `json:"call_id"`
1517- Content []json.RawMessage `json:"content"`1532+ Content []json.RawMessage `json:"content"`
1518- IsError bool `json:"is_error"`1533+ IsError bool `json:"is_error"`
1534+ Timing *provider.ToolTiming `json:"timing,omitempty"`
1519 }1535 }
1520 if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil {1536 if err := json.Unmarshal(raw, &tr); err != nil || tr.Content == nil {
1521 if err == nil {1537 if err == nil {
@@ -1523,7 +1539,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
1523 }1539 }
1524 return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)1540 return provider.Message{}, fmt.Errorf("content block %d: %w", idx, err)
1525 }1541 }
1526- block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError}1542+ block := provider.ToolResultBlock{CallID: tr.CallID, IsError: tr.IsError, Timing: tr.Timing}
1527 for innerIdx, c := range tr.Content {1543 for innerIdx, c := range tr.Content {
1528 var inner struct {1544 var inner struct {
1529 Text string `json:"text"`1545 Text string `json:"text"`
M packages/core/session_portable.go +5 -1
@@ -215,14 +215,18 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
215215 bw := bufio.NewWriter(dst)
216216
217217 // Write a fresh meta row claiming ownership.
218+ now := time.Now()
219+ timezone, timezoneOffset := localTimeMetadata(now)
218220 importMeta := SessionMeta{
219221 ID: newID,
220222 CWD: cwd,
221223 Model: snapshot.Meta.Model,
222224 Provider: snapshot.Meta.Provider,
223- Started: time.Now().UTC(),
225+ Started: now.UTC(),
224226 Version: version,
225227 Title: snapshot.Title,
228+ Timezone: timezone,
229+ TimezoneOffset: timezoneOffset,
226230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
227231 }
228232 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
@@ -215,14 +215,18 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
215 bw := bufio.NewWriter(dst)215 bw := bufio.NewWriter(dst)
216 216
217 // Write a fresh meta row claiming ownership.217 // Write a fresh meta row claiming ownership.
218+ now := time.Now()
219+ timezone, timezoneOffset := localTimeMetadata(now)
218 importMeta := SessionMeta{220 importMeta := SessionMeta{
219 ID: newID,221 ID: newID,
220 CWD: cwd,222 CWD: cwd,
221 Model: snapshot.Meta.Model,223 Model: snapshot.Meta.Model,
222 Provider: snapshot.Meta.Provider,224 Provider: snapshot.Meta.Provider,
223- Started: time.Now().UTC(),225+ Started: now.UTC(),
224 Version: version,226 Version: version,
225 Title: snapshot.Title,227 Title: snapshot.Title,
228+ Timezone: timezone,
229+ TimezoneOffset: timezoneOffset,
226 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
227 }231 }
228 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})232 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
A packages/core/time_aware_test.go +56 -0
@@ -0,0 +1,56 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestSessionPersistsAndReplaysToolTiming(t *testing.T) {
11+ session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test")
12+ if err != nil {
13+ t.Fatal(err)
14+ }
15+ started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60))
16+ timing := &provider.ToolTiming{
17+ StartedAt: started,
18+ CompletedAt: started.Add(250 * time.Millisecond),
19+ Duration: 250 * time.Millisecond,
20+ }
21+ if err := session.AppendMessage(provider.Message{
22+ Role: provider.RoleAssistant,
23+ Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "echo"}},
24+ }); err != nil {
25+ t.Fatal(err)
26+ }
27+ if err := session.AppendMessage(provider.Message{
28+ Role: provider.RoleTool,
29+ Content: []provider.Content{provider.ToolResultBlock{
30+ CallID: "call-1",
31+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
32+ Timing: timing,
33+ }},
34+ }); err != nil {
35+ t.Fatal(err)
36+ }
37+ path := session.Path
38+ if err := session.Close(); err != nil {
39+ t.Fatal(err)
40+ }
41+
42+ snapshot, err := ReadSessionSnapshot(path)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ result, ok := snapshot.Messages[1].Content[0].(provider.ToolResultBlock)
47+ if !ok {
48+ t.Fatalf("replayed content = %T, want ToolResultBlock", snapshot.Messages[1].Content[0])
49+ }
50+ if result.Timing == nil {
51+ t.Fatal("replayed tool result lost timing")
52+ }
53+ if !result.Timing.StartedAt.Equal(timing.StartedAt) || !result.Timing.CompletedAt.Equal(timing.CompletedAt) || result.Timing.Duration != timing.Duration {
54+ t.Fatalf("replayed timing = %#v, want %#v", result.Timing, timing)
55+ }
56+}
@@ -0,0 +1,56 @@
1+package core
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestSessionPersistsAndReplaysToolTiming(t *testing.T) {
11+ session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test")
12+ if err != nil {
13+ t.Fatal(err)
14+ }
15+ started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60))
16+ timing := &provider.ToolTiming{
17+ StartedAt: started,
18+ CompletedAt: started.Add(250 * time.Millisecond),
19+ Duration: 250 * time.Millisecond,
20+ }
21+ if err := session.AppendMessage(provider.Message{
22+ Role: provider.RoleAssistant,
23+ Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "echo"}},
24+ }); err != nil {
25+ t.Fatal(err)
26+ }
27+ if err := session.AppendMessage(provider.Message{
28+ Role: provider.RoleTool,
29+ Content: []provider.Content{provider.ToolResultBlock{
30+ CallID: "call-1",
31+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
32+ Timing: timing,
33+ }},
34+ }); err != nil {
35+ t.Fatal(err)
36+ }
37+ path := session.Path
38+ if err := session.Close(); err != nil {
39+ t.Fatal(err)
40+ }
41+
42+ snapshot, err := ReadSessionSnapshot(path)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ result, ok := snapshot.Messages[1].Content[0].(provider.ToolResultBlock)
47+ if !ok {
48+ t.Fatalf("replayed content = %T, want ToolResultBlock", snapshot.Messages[1].Content[0])
49+ }
50+ if result.Timing == nil {
51+ t.Fatal("replayed tool result lost timing")
52+ }
53+ if !result.Timing.StartedAt.Equal(timing.StartedAt) || !result.Timing.CompletedAt.Equal(timing.CompletedAt) || result.Timing.Duration != timing.Duration {
54+ t.Fatalf("replayed timing = %#v, want %#v", result.Timing, timing)
55+ }
56+}
A packages/core/time_context.go +117 -0
@@ -0,0 +1,117 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+ "time"
8+)
9+
10+type agentTimeContext struct {
11+ started time.Time
12+ location *time.Location
13+ zone string
14+ offset string
15+}
16+
17+func newAgentTimeContext(now time.Time) agentTimeContext {
18+ zoneName, offset := localTimeMetadata(now)
19+ return agentTimeContext{
20+ started: now,
21+ location: now.Location(),
22+ zone: zoneName,
23+ offset: offset,
24+ }
25+}
26+
27+func localTimeMetadata(now time.Time) (string, string) {
28+ zone, seconds := now.Zone()
29+ zoneName := now.Location().String()
30+ if zoneName == "" || zoneName == "Local" {
31+ zoneName = zone
32+ }
33+ return zoneName, formatUTCOffset(seconds)
34+}
35+
36+func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string) {
37+ if started.IsZero() {
38+ return
39+ }
40+ location := time.Local
41+ if offset != "" {
42+ if fixed := parseUTCOffset(offset); fixed != nil {
43+ location = fixed
44+ }
45+ } else if timezone != "" && timezone != "Local" {
46+ if loaded, err := time.LoadLocation(timezone); err == nil {
47+ location = loaded
48+ }
49+ }
50+ if timezone == "" {
51+ timezone = location.String()
52+ }
53+ if offset == "" {
54+ _, seconds := started.In(location).Zone()
55+ offset = formatUTCOffset(seconds)
56+ }
57+
58+ a.mu.Lock()
59+ a.timeContext = agentTimeContext{
60+ started: started,
61+ location: location,
62+ zone: timezone,
63+ offset: offset,
64+ }
65+ a.mu.Unlock()
66+}
67+
68+func (a *Agent) providerTimeContext() agentTimeContext {
69+ a.mu.Lock()
70+ defer a.mu.Unlock()
71+ return a.timeContext
72+}
73+
74+func (c agentTimeContext) systemText() string {
75+ if c.started.IsZero() {
76+ return ""
77+ }
78+ location := c.location
79+ if location == nil {
80+ location = time.UTC
81+ }
82+ zone := c.zone
83+ if zone == "" {
84+ zone = location.String()
85+ }
86+ offset := c.offset
87+ if offset == "" {
88+ _, seconds := c.started.In(location).Zone()
89+ offset = formatUTCOffset(seconds)
90+ }
91+ return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format(time.RFC3339), zone, offset)
92+}
93+
94+func formatUTCOffset(seconds int) string {
95+ sign := "+"
96+ if seconds < 0 {
97+ sign = "-"
98+ seconds = -seconds
99+ }
100+ return fmt.Sprintf("%s%02d:%02d", sign, seconds/3600, (seconds%3600)/60)
101+}
102+
103+func parseUTCOffset(value string) *time.Location {
104+ if len(value) != len("+00:00") || (value[0] != '+' && value[0] != '-') || value[3] != ':' {
105+ return nil
106+ }
107+ hours, errHours := strconv.Atoi(value[1:3])
108+ minutes, errMinutes := strconv.Atoi(value[4:6])
109+ if errHours != nil || errMinutes != nil || hours > 23 || minutes > 59 {
110+ return nil
111+ }
112+ seconds := hours*3600 + minutes*60
113+ if strings.HasPrefix(value, "-") {
114+ seconds = -seconds
115+ }
116+ return time.FixedZone(value, seconds)
117+}
@@ -0,0 +1,117 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "strings"
7+ "time"
8+)
9+
10+type agentTimeContext struct {
11+ started time.Time
12+ location *time.Location
13+ zone string
14+ offset string
15+}
16+
17+func newAgentTimeContext(now time.Time) agentTimeContext {
18+ zoneName, offset := localTimeMetadata(now)
19+ return agentTimeContext{
20+ started: now,
21+ location: now.Location(),
22+ zone: zoneName,
23+ offset: offset,
24+ }
25+}
26+
27+func localTimeMetadata(now time.Time) (string, string) {
28+ zone, seconds := now.Zone()
29+ zoneName := now.Location().String()
30+ if zoneName == "" || zoneName == "Local" {
31+ zoneName = zone
32+ }
33+ return zoneName, formatUTCOffset(seconds)
34+}
35+
36+func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string) {
37+ if started.IsZero() {
38+ return
39+ }
40+ location := time.Local
41+ if offset != "" {
42+ if fixed := parseUTCOffset(offset); fixed != nil {
43+ location = fixed
44+ }
45+ } else if timezone != "" && timezone != "Local" {
46+ if loaded, err := time.LoadLocation(timezone); err == nil {
47+ location = loaded
48+ }
49+ }
50+ if timezone == "" {
51+ timezone = location.String()
52+ }
53+ if offset == "" {
54+ _, seconds := started.In(location).Zone()
55+ offset = formatUTCOffset(seconds)
56+ }
57+
58+ a.mu.Lock()
59+ a.timeContext = agentTimeContext{
60+ started: started,
61+ location: location,
62+ zone: timezone,
63+ offset: offset,
64+ }
65+ a.mu.Unlock()
66+}
67+
68+func (a *Agent) providerTimeContext() agentTimeContext {
69+ a.mu.Lock()
70+ defer a.mu.Unlock()
71+ return a.timeContext
72+}
73+
74+func (c agentTimeContext) systemText() string {
75+ if c.started.IsZero() {
76+ return ""
77+ }
78+ location := c.location
79+ if location == nil {
80+ location = time.UTC
81+ }
82+ zone := c.zone
83+ if zone == "" {
84+ zone = location.String()
85+ }
86+ offset := c.offset
87+ if offset == "" {
88+ _, seconds := c.started.In(location).Zone()
89+ offset = formatUTCOffset(seconds)
90+ }
91+ return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format(time.RFC3339), zone, offset)
92+}
93+
94+func formatUTCOffset(seconds int) string {
95+ sign := "+"
96+ if seconds < 0 {
97+ sign = "-"
98+ seconds = -seconds
99+ }
100+ return fmt.Sprintf("%s%02d:%02d", sign, seconds/3600, (seconds%3600)/60)
101+}
102+
103+func parseUTCOffset(value string) *time.Location {
104+ if len(value) != len("+00:00") || (value[0] != '+' && value[0] != '-') || value[3] != ':' {
105+ return nil
106+ }
107+ hours, errHours := strconv.Atoi(value[1:3])
108+ minutes, errMinutes := strconv.Atoi(value[4:6])
109+ if errHours != nil || errMinutes != nil || hours > 23 || minutes > 59 {
110+ return nil
111+ }
112+ seconds := hours*3600 + minutes*60
113+ if strings.HasPrefix(value, "-") {
114+ seconds = -seconds
115+ }
116+ return time.FixedZone(value, seconds)
117+}
M packages/core/tool.go +4 -0
@@ -43,6 +43,10 @@ type ToolResult struct {
4343 ActivateTools []string
4444 // Details is arbitrary data for UIs and logs; not sent to the LLM.
4545 Details any
46+ // Timing is populated by the core invocation boundary. It is also copied
47+ // to the persisted/provider-facing tool-result block, where it remains
48+ // optional for compatibility with older sessions.
49+ Timing *provider.ToolTiming
4650 }
4751
4852 // Registry is a name->Tool map.
@@ -43,6 +43,10 @@ type ToolResult struct {
43 ActivateTools []string43 ActivateTools []string
44 // Details is arbitrary data for UIs and logs; not sent to the LLM.44 // Details is arbitrary data for UIs and logs; not sent to the LLM.
45 Details any45 Details any
46+ // Timing is populated by the core invocation boundary. It is also copied
47+ // to the persisted/provider-facing tool-result block, where it remains
48+ // optional for compatibility with older sessions.
49+ Timing *provider.ToolTiming
46 }50 }
47 51
48 // Registry is a name->Tool map.52 // Registry is a name->Tool map.
M packages/provider/amazon_bedrock.go +2 -2
@@ -333,8 +333,8 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
333333 reasoning := ClampReasoningForModel(model, req.Reasoning)
334334 adaptive := usesAdaptiveThinking(model)
335335
336- if req.System != "" {
337- sysBlock := map[string]interface{}{"text": req.System}
336+ if system := req.SystemPrompt(); system != "" {
337+ sysBlock := map[string]interface{}{"text": system}
338338 if caching {
339339 // Append cachePoint after the system text so the stable system
340340 // prompt is cached as the first breakpoint.
@@ -333,8 +333,8 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
333 reasoning := ClampReasoningForModel(model, req.Reasoning)333 reasoning := ClampReasoningForModel(model, req.Reasoning)
334 adaptive := usesAdaptiveThinking(model)334 adaptive := usesAdaptiveThinking(model)
335 335
336- if req.System != "" {336+ if system := req.SystemPrompt(); system != "" {
337- sysBlock := map[string]interface{}{"text": req.System}337+ sysBlock := map[string]interface{}{"text": system}
338 if caching {338 if caching {
339 // Append cachePoint after the system text so the stable system339 // Append cachePoint after the system text so the stable system
340 // prompt is cached as the first breakpoint.340 // prompt is cached as the first breakpoint.
M packages/provider/anthropic.go +6 -4
@@ -246,6 +246,8 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
246246 out.Temperature = req.Temperature
247247 }
248248
249+ system := req.SystemPrompt()
250+
249251 // System prompt assembly differs between api-key and OAuth modes.
250252 // OAuth requests MUST begin with the Claude Code identity line or
251253 // Anthropic rejects them (429 rate_limit_error with zero tokens used).
@@ -270,17 +272,17 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
270272 Text: claudeCodeIdentity,
271273 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
272274 }}
273- if req.System != "" {
275+ if system != "" {
274276 out.System = append(out.System, anthSystemBlock{
275277 Type: "text",
276- Text: req.System,
278+ Text: system,
277279 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
278280 })
279281 }
280- } else if req.System != "" {
282+ } else if system != "" {
281283 out.System = []anthSystemBlock{{
282284 Type: "text",
283- Text: req.System,
285+ Text: system,
284286 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
285287 }}
286288 }
@@ -246,6 +246,8 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
246 out.Temperature = req.Temperature246 out.Temperature = req.Temperature
247 }247 }
248 248
249+ system := req.SystemPrompt()
250+
249 // System prompt assembly differs between api-key and OAuth modes.251 // System prompt assembly differs between api-key and OAuth modes.
250 // OAuth requests MUST begin with the Claude Code identity line or252 // OAuth requests MUST begin with the Claude Code identity line or
251 // Anthropic rejects them (429 rate_limit_error with zero tokens used).253 // Anthropic rejects them (429 rate_limit_error with zero tokens used).
@@ -270,17 +272,17 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
270 Text: claudeCodeIdentity,272 Text: claudeCodeIdentity,
271 CacheControl: &anthCacheCtrl{Type: "ephemeral"},273 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
272 }}274 }}
273- if req.System != "" {275+ if system != "" {
274 out.System = append(out.System, anthSystemBlock{276 out.System = append(out.System, anthSystemBlock{
275 Type: "text",277 Type: "text",
276- Text: req.System,278+ Text: system,
277 CacheControl: &anthCacheCtrl{Type: "ephemeral"},279 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
278 })280 })
279 }281 }
280- } else if req.System != "" {282+ } else if system != "" {
281 out.System = []anthSystemBlock{{283 out.System = []anthSystemBlock{{
282 Type: "text",284 Type: "text",
283- Text: req.System,285+ Text: system,
284 CacheControl: &anthCacheCtrl{Type: "ephemeral"},286 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
285 }}287 }}
286 }288 }
M packages/provider/gemini.go +2 -2
@@ -145,9 +145,9 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
145145 out := &gemRequest{}
146146
147147 // System prompt → systemInstruction.parts[0].text.
148- if strings.TrimSpace(req.System) != "" {
148+ if system := req.SystemPrompt(); strings.TrimSpace(system) != "" {
149149 out.SystemInstruction = &gemSystemInstruction{
150- Parts: []gemPart{{Text: req.System}},
150+ Parts: []gemPart{{Text: system}},
151151 }
152152 }
153153
@@ -145,9 +145,9 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
145 out := &gemRequest{}145 out := &gemRequest{}
146 146
147 // System prompt → systemInstruction.parts[0].text.147 // System prompt → systemInstruction.parts[0].text.
148- if strings.TrimSpace(req.System) != "" {148+ if system := req.SystemPrompt(); strings.TrimSpace(system) != "" {
149 out.SystemInstruction = &gemSystemInstruction{149 out.SystemInstruction = &gemSystemInstruction{
150- Parts: []gemPart{{Text: req.System}},150+ Parts: []gemPart{{Text: system}},
151 }151 }
152 }152 }
153 153
M packages/provider/openai.go +2 -2
@@ -274,8 +274,8 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
274274 out.MaxTokens = &maxTok
275275 }
276276
277- if req.System != "" {
278- out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
277+ if system := req.SystemPrompt(); system != "" {
278+ out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: system})
279279 }
280280
281281 deferredMode := isKimiDeferredModel(req.Model)
@@ -274,8 +274,8 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
274 out.MaxTokens = &maxTok274 out.MaxTokens = &maxTok
275 }275 }
276 276
277- if req.System != "" {277+ if system := req.SystemPrompt(); system != "" {
278- out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})278+ out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: system})
279 }279 }
280 280
281 deferredMode := isKimiDeferredModel(req.Model)281 deferredMode := isKimiDeferredModel(req.Model)
M packages/provider/openai_codex.go +1 -1
@@ -198,7 +198,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
198198 Model: req.Model,
199199 Store: false,
200200 Stream: true,
201- Instructions: req.System,
201+ Instructions: req.SystemPrompt(),
202202 ParallelToolCalls: true,
203203 Include: []string{"reasoning.encrypted_content"},
204204 }
@@ -198,7 +198,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
198 Model: req.Model,198 Model: req.Model,
199 Store: false,199 Store: false,
200 Stream: true,200 Stream: true,
201- Instructions: req.System,201+ Instructions: req.SystemPrompt(),
202 ParallelToolCalls: true,202 ParallelToolCalls: true,
203 Include: []string{"reasoning.encrypted_content"},203 Include: []string{"reasoning.encrypted_content"},
204 }204 }
M packages/provider/provider.go +32 -9
@@ -58,12 +58,22 @@ type ToolCallBlock struct {
5858
5959 func (ToolCallBlock) isContent() {}
6060
61+// ToolTiming records the wall-clock bounds and monotonic duration of a tool
62+// invocation. It is optional so sessions written before timing was added
63+// remain valid and compact.
64+type ToolTiming struct {
65+ StartedAt time.Time `json:"started_at,omitempty"`
66+ CompletedAt time.Time `json:"completed_at,omitempty"`
67+ Duration time.Duration `json:"duration,omitempty"`
68+}
69+
6170 // ToolResultBlock is the result of a tool execution, attached to a
6271 // Message with Role == RoleTool.
6372 type ToolResultBlock struct {
64- CallID string `json:"call_id"`
65- Content []Content `json:"content"`
66- IsError bool `json:"is_error"`
73+ CallID string `json:"call_id"`
74+ Content []Content `json:"content"`
75+ IsError bool `json:"is_error"`
76+ Timing *ToolTiming `json:"timing,omitempty"`
6777 }
6878
6979 func (ToolResultBlock) isContent() {}
@@ -258,12 +268,13 @@ type RequestLifecycle interface {
258268
259269 // Request is a single LLM call.
260270 type Request struct {
261- Model string
262- System string
263- Messages []Message
264- Tools []Tool
265- MaxTokens int
266- Temperature *float32
271+ Model string
272+ System string
273+ SystemContext string
274+ Messages []Message
275+ Tools []Tool
276+ MaxTokens int
277+ Temperature *float32
267278 // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or
268279 // "max". Empty disables reasoning. The max tier is sent natively only to
269280 // models that support it and clamped elsewhere.
@@ -276,6 +287,18 @@ type Request struct {
276287 Lifecycle RequestLifecycle
277288 }
278289
290+// SystemPrompt returns the stable system prompt plus optional model-only
291+// session context without requiring callers to mutate their system prompt.
292+func (r Request) SystemPrompt() string {
293+ if r.SystemContext == "" {
294+ return r.System
295+ }
296+ if r.System == "" {
297+ return r.SystemContext
298+ }
299+ return r.System + "\n\n" + r.SystemContext
300+}
301+
279302 // OpenAI's current Fast mode is represented as the priority service tier on
280303 // the wire, including the ChatGPT/Codex Responses endpoint.
281304 const fastModeServiceTier = "priority"
@@ -58,12 +58,22 @@ type ToolCallBlock struct {
58 58
59 func (ToolCallBlock) isContent() {}59 func (ToolCallBlock) isContent() {}
60 60
61+// ToolTiming records the wall-clock bounds and monotonic duration of a tool
62+// invocation. It is optional so sessions written before timing was added
63+// remain valid and compact.
64+type ToolTiming struct {
65+ StartedAt time.Time `json:"started_at,omitempty"`
66+ CompletedAt time.Time `json:"completed_at,omitempty"`
67+ Duration time.Duration `json:"duration,omitempty"`
68+}
69+
61 // ToolResultBlock is the result of a tool execution, attached to a70 // ToolResultBlock is the result of a tool execution, attached to a
62 // Message with Role == RoleTool.71 // Message with Role == RoleTool.
63 type ToolResultBlock struct {72 type ToolResultBlock struct {
64- CallID string `json:"call_id"`73+ CallID string `json:"call_id"`
65- Content []Content `json:"content"`74+ Content []Content `json:"content"`
66- IsError bool `json:"is_error"`75+ IsError bool `json:"is_error"`
76+ Timing *ToolTiming `json:"timing,omitempty"`
67 }77 }
68 78
69 func (ToolResultBlock) isContent() {}79 func (ToolResultBlock) isContent() {}
@@ -258,12 +268,13 @@ type RequestLifecycle interface {
258 268
259 // Request is a single LLM call.269 // Request is a single LLM call.
260 type Request struct {270 type Request struct {
261- Model string271+ Model string
262- System string272+ System string
263- Messages []Message273+ SystemContext string
264- Tools []Tool274+ Messages []Message
265- MaxTokens int275+ Tools []Tool
266- Temperature *float32276+ MaxTokens int
277+ Temperature *float32
267 // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or278 // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or
268 // "max". Empty disables reasoning. The max tier is sent natively only to279 // "max". Empty disables reasoning. The max tier is sent natively only to
269 // models that support it and clamped elsewhere.280 // models that support it and clamped elsewhere.
@@ -276,6 +287,18 @@ type Request struct {
276 Lifecycle RequestLifecycle287 Lifecycle RequestLifecycle
277 }288 }
278 289
290+// SystemPrompt returns the stable system prompt plus optional model-only
291+// session context without requiring callers to mutate their system prompt.
292+func (r Request) SystemPrompt() string {
293+ if r.SystemContext == "" {
294+ return r.System
295+ }
296+ if r.System == "" {
297+ return r.SystemContext
298+ }
299+ return r.System + "\n\n" + r.SystemContext
300+}
301+
279 // OpenAI's current Fast mode is represented as the priority service tier on302 // OpenAI's current Fast mode is represented as the priority service tier on
280 // the wire, including the ChatGPT/Codex Responses endpoint.303 // the wire, including the ChatGPT/Codex Responses endpoint.
281 const fastModeServiceTier = "priority"304 const fastModeServiceTier = "priority"

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