detached a04f503

Merge pull request #94 from bnema/feat/time-aware-agents

Time-aware agents
commit
a04f50394cfdd3f41a85e474a8877db951a4bdaa
author
bnema · · 2026-08-09 12:23 UTC
committer
GitHub · · 2026-08-09 12:23 UTC
merge of
c2d193e + fd33ff0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +687 -120
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 +16 -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
@@ -1809,6 +1811,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18091811 if a == nil {
18101812 return a
18111813 }
1814+ persistMu.Lock()
1815+ currentSession := sess
1816+ persistMu.Unlock()
1817+ if currentSession != nil {
1818+ a.SetSessionTimeContext(currentSession.Meta.Started, currentSession.Meta.Timezone, currentSession.Meta.TimezoneOffset)
1819+ }
18121820 a.OnMessageAppended = persistMessage
18131821 a.OnUsage = persistUsage
18141822 a.OnTranscriptCompacted = persistCompaction
@@ -1921,6 +1929,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
19211929 oldCloseErr = oldSess.Close()
19221930 }
19231931 sess = candidate.session
1932+ candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
19241933 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
19251934 // The live agent only receives a compact resume window, but the
19261935 // session file remains intact. Keep the persistence baseline at
@@ -2120,6 +2129,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21202129 }
21212130 sess = newSess
21222131 if newSess != nil {
2132+ newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)
21232133 newStates = copyExtensionStates(newSess.ExtensionState)
21242134 }
21252135 sessBaselineMsgs = 0
@@ -2691,6 +2701,7 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
26912701 return nil, err
26922702 }
26932703 if s != nil {
2704+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
26942705 ag.SetMessages(msgs)
26952706 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
26962707 ag.SeedCost(cum)
@@ -2698,7 +2709,11 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
26982709 }
26992710 return s, nil
27002711 }
2701- return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2712+ s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2713+ if err == nil {
2714+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2715+ }
2716+ return s, err
27022717 }
27032718
27042719 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
@@ -1809,6 +1811,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1809 if a == nil {1811 if a == nil {
1810 return a1812 return a
1811 }1813 }
1814+ persistMu.Lock()
1815+ currentSession := sess
1816+ persistMu.Unlock()
1817+ if currentSession != nil {
1818+ a.SetSessionTimeContext(currentSession.Meta.Started, currentSession.Meta.Timezone, currentSession.Meta.TimezoneOffset)
1819+ }
1812 a.OnMessageAppended = persistMessage1820 a.OnMessageAppended = persistMessage
1813 a.OnUsage = persistUsage1821 a.OnUsage = persistUsage
1814 a.OnTranscriptCompacted = persistCompaction1822 a.OnTranscriptCompacted = persistCompaction
@@ -1921,6 +1929,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1921 oldCloseErr = oldSess.Close()1929 oldCloseErr = oldSess.Close()
1922 }1930 }
1923 sess = candidate.session1931 sess = candidate.session
1932+ candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
1924 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint1933 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
1925 // The live agent only receives a compact resume window, but the1934 // The live agent only receives a compact resume window, but the
1926 // session file remains intact. Keep the persistence baseline at1935 // session file remains intact. Keep the persistence baseline at
@@ -2120,6 +2129,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2120 }2129 }
2121 sess = newSess2130 sess = newSess
2122 if newSess != nil {2131 if newSess != nil {
2132+ newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)
2123 newStates = copyExtensionStates(newSess.ExtensionState)2133 newStates = copyExtensionStates(newSess.ExtensionState)
2124 }2134 }
2125 sessBaselineMsgs = 02135 sessBaselineMsgs = 0
@@ -2691,6 +2701,7 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
2691 return nil, err2701 return nil, err
2692 }2702 }
2693 if s != nil {2703 if s != nil {
2704+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2694 ag.SetMessages(msgs)2705 ag.SetMessages(msgs)
2695 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {2706 if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
2696 ag.SeedCost(cum)2707 ag.SeedCost(cum)
@@ -2698,7 +2709,11 @@ func openOrCreateSession(ctx context.Context, args Args, r Resolved, ag *core.Ag
2698 }2709 }
2699 return s, nil2710 return s, nil
2700 }2711 }
2701- return core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)2712+ s, err = core.NewSession(sessionsRoot, args.CWD, r.Provider, r.Model, version)
2713+ if err == nil {
2714+ ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset)
2715+ }
2716+ return s, err
2702 }2717 }
2703 2718
2704 func pickSession(root, cwd string) (string, error) {2719 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 +10 -0
@@ -350,6 +350,16 @@ 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+ details, ok := res.Details.(map[string]any)
357+ if !ok || details == nil {
358+ t.Fatalf("bash details = %#v (%T), want non-nil map[string]any", res.Details, res.Details)
359+ }
360+ if _, exists := details["duration_ms"]; exists {
361+ t.Fatalf("bash details still contain duplicate duration: %#v", details)
362+ }
353363 if res.IsError {
354364 t.Fatal("unexpected error flag")
355365 }
@@ -350,6 +350,16 @@ 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+ details, ok := res.Details.(map[string]any)
357+ if !ok || details == nil {
358+ t.Fatalf("bash details = %#v (%T), want non-nil map[string]any", res.Details, res.Details)
359+ }
360+ if _, exists := details["duration_ms"]; exists {
361+ t.Fatalf("bash details still contain duplicate duration: %#v", details)
362+ }
353 if res.IsError {363 if res.IsError {
354 t.Fatal("unexpected error flag")364 t.Fatal("unexpected error flag")
355 }365 }
M packages/core/agent.go +67 -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,22 @@ 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 recover() != nil {
896+ res = ToolResult{
897+ Content: []provider.Content{provider.TextBlock{Text: "tool execution failed"}},
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+
873908 tool, err := tools.Get(tc.Name)
874909 if err != nil {
875910 return ToolResult{
@@ -906,38 +941,25 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
906941 args = json.RawMessage("{}")
907942 }
908943
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()}},
944+ // Tool panics are recovered by the invocation-level defer above so
945+ // guard, lookup, progress, and Execute panics all receive timing.
946+ sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
947+ out, err := tool.Execute(ctx, args, func(text string) {
948+ sink(EvToolProgress{ID: tc.ID, Text: text})
949+ })
950+ if err != nil {
951+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
952+ return ToolResult{
953+ Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
934954 IsError: true,
935955 }
936- return
937956 }
938- res = out
939- }()
940- return res
957+ return ToolResult{
958+ Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
959+ IsError: true,
960+ }
961+ }
962+ return out
941963 }
942964
943965 // 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,22 @@ 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 recover() != nil {
896+ res = ToolResult{
897+ Content: []provider.Content{provider.TextBlock{Text: "tool execution failed"}},
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+
873 tool, err := tools.Get(tc.Name)908 tool, err := tools.Get(tc.Name)
874 if err != nil {909 if err != nil {
875 return ToolResult{910 return ToolResult{
@@ -906,38 +941,25 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
906 args = json.RawMessage("{}")941 args = json.RawMessage("{}")
907 }942 }
908 943
909- // Recover panics so a buggy tool does not crash the agent.944+ // Tool panics are recovered by the invocation-level defer above so
910- var res ToolResult945+ // guard, lookup, progress, and Execute panics all receive timing.
911- func() {946+ sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
912- defer func() {947+ out, err := tool.Execute(ctx, args, func(text string) {
913- if r := recover(); r != nil {948+ sink(EvToolProgress{ID: tc.ID, Text: text})
914- res = ToolResult{949+ })
915- Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", r)}},950+ if err != nil {
916- IsError: true,951+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
917- }952+ return ToolResult{
918- }953+ Content: []provider.Content{provider.TextBlock{Text: "aborted: " + err.Error()}},
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,954 IsError: true,
935 }955 }
936- return
937 }956 }
938- res = out957+ return ToolResult{
939- }()958+ Content: []provider.Content{provider.TextBlock{Text: err.Error()}},
940- return res959+ IsError: true,
960+ }
961+ }
962+ return out
941 }963 }
942 964
943 // extractText concatenates all TextBlock content in a message. Used965 // 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 +46 -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"
@@ -28,25 +30,60 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
2830 continue
2931 }
3032
31- textBytes := toolResultTextBytes(result.Content)
32- if textBytes == 0 {
33- continue
33+ timingText := ""
34+ if result.Timing != nil {
35+ candidate := formatToolTiming(result.Timing)
36+ if len(candidate) <= remaining {
37+ timingText = candidate
38+ remaining -= len(candidate)
39+ }
3440 }
3541
36- limit := maxToolResultTextBytes
37- if remaining < limit {
38- limit = remaining
42+ textBytes := toolResultTextBytes(result.Content)
43+ if textBytes > 0 {
44+ limit := maxToolResultTextBytes
45+ if remaining < limit {
46+ limit = remaining
47+ }
48+ var retained int
49+ result.Content, retained = projectToolResultContent(result.Content, limit)
50+ remaining -= retained
51+ }
52+ if timingText != "" {
53+ result.Content = append(result.Content, provider.TextBlock{Text: timingText})
3954 }
40- var retained int
41- result.Content, retained = projectToolResultContent(result.Content, limit)
4255 projected[i].Content[j] = result
43- remaining -= retained
4456 }
4557 }
4658
4759 return projected
4860 }
4961
62+// projectProviderMessages creates the provider-only transcript view. The
63+// timestamp and timing annotations are deliberately added to this copy so
64+// the visible transcript and its persisted historical content remain intact.
65+func projectProviderMessages(msgs []provider.Message) []provider.Message {
66+ projected := projectToolResultMessages(msgs)
67+ for i, message := range projected {
68+ if message.Role != provider.RoleUser || message.Time.IsZero() {
69+ continue
70+ }
71+ content := make([]provider.Content, 0, len(message.Content)+1)
72+ content = append(content, provider.TextBlock{Text: fmt.Sprintf("[message time: %s]", message.Time.Format(time.RFC3339))})
73+ content = append(content, message.Content...)
74+ projected[i].Content = content
75+ }
76+ return projected
77+}
78+
79+func formatToolTiming(timing *provider.ToolTiming) string {
80+ return fmt.Sprintf("[tool timing: started=%s completed=%s duration=%s]",
81+ timing.StartedAt.Format(time.RFC3339),
82+ timing.CompletedAt.Format(time.RFC3339),
83+ timing.Duration,
84+ )
85+}
86+
5087 // copyToolResultMessages copies the slices that the projection can replace.
5188 // Content blocks are values, so sharing their immutable fields is safe; the
5289 // 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"
@@ -28,25 +30,60 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
28 continue30 continue
29 }31 }
30 32
31- textBytes := toolResultTextBytes(result.Content)33+ timingText := ""
32- if textBytes == 0 {34+ if result.Timing != nil {
33- continue35+ candidate := formatToolTiming(result.Timing)
36+ if len(candidate) <= remaining {
37+ timingText = candidate
38+ remaining -= len(candidate)
39+ }
34 }40 }
35 41
36- limit := maxToolResultTextBytes42+ textBytes := toolResultTextBytes(result.Content)
37- if remaining < limit {43+ if textBytes > 0 {
38- limit = remaining44+ limit := maxToolResultTextBytes
45+ if remaining < limit {
46+ limit = remaining
47+ }
48+ var retained int
49+ result.Content, retained = projectToolResultContent(result.Content, limit)
50+ remaining -= retained
51+ }
52+ if timingText != "" {
53+ result.Content = append(result.Content, provider.TextBlock{Text: timingText})
39 }54 }
40- var retained int
41- result.Content, retained = projectToolResultContent(result.Content, limit)
42 projected[i].Content[j] = result55 projected[i].Content[j] = result
43- remaining -= retained
44 }56 }
45 }57 }
46 58
47 return projected59 return projected
48 }60 }
49 61
62+// projectProviderMessages creates the provider-only transcript view. The
63+// timestamp and timing annotations are deliberately added to this copy so
64+// the visible transcript and its persisted historical content remain intact.
65+func projectProviderMessages(msgs []provider.Message) []provider.Message {
66+ projected := projectToolResultMessages(msgs)
67+ for i, message := range projected {
68+ if message.Role != provider.RoleUser || message.Time.IsZero() {
69+ continue
70+ }
71+ content := make([]provider.Content, 0, len(message.Content)+1)
72+ content = append(content, provider.TextBlock{Text: fmt.Sprintf("[message time: %s]", message.Time.Format(time.RFC3339))})
73+ content = append(content, message.Content...)
74+ projected[i].Content = content
75+ }
76+ return projected
77+}
78+
79+func formatToolTiming(timing *provider.ToolTiming) string {
80+ return fmt.Sprintf("[tool timing: started=%s completed=%s duration=%s]",
81+ timing.StartedAt.Format(time.RFC3339),
82+ timing.CompletedAt.Format(time.RFC3339),
83+ timing.Duration,
84+ )
85+}
86+
50 // copyToolResultMessages copies the slices that the projection can replace.87 // copyToolResultMessages copies the slices that the projection can replace.
51 // Content blocks are values, so sharing their immutable fields is safe; the88 // Content blocks are values, so sharing their immutable fields is safe; the
52 // nested content slice of every ToolResultBlock is copied before projection.89 // nested content slice of every ToolResultBlock is copied before projection.
M packages/core/context_budget_test.go +128 -1
@@ -5,11 +5,128 @@ 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 TestSessionTimeContextUsesNamedTimezoneForProjectedWinterMessages(t *testing.T) {
57+ a := NewAgent(nil, "model", "", Registry{})
58+ started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
59+ a.SetSessionTimeContext(started, "America/Los_Angeles", "-07:00")
60+
61+ context := a.providerTimeContext()
62+ december := time.Date(2026, 12, 8, 12, 0, 0, 0, time.UTC).In(context.location)
63+ projected := projectProviderMessages([]provider.Message{{
64+ Role: provider.RoleUser,
65+ Content: []provider.Content{provider.TextBlock{Text: "winter message"}},
66+ Time: december,
67+ }})
68+ annotation := projected[0].Content[0].(provider.TextBlock).Text
69+ if annotation != "[message time: 2026-12-08T04:00:00-08:00]" {
70+ t.Fatalf("winter message annotation = %q, want Pacific Standard Time", annotation)
71+ }
72+}
73+
74+func TestProjectToolTimingIsModelOnly(t *testing.T) {
75+ started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60))
76+ timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(1500 * time.Millisecond), Duration: 1500 * time.Millisecond}
77+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
78+ CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "output"}}, Timing: timing,
79+ }}}}
80+
81+ projected := projectProviderMessages(messages)
82+ original := messages[0].Content[0].(provider.ToolResultBlock)
83+ if len(original.Content) != 1 {
84+ t.Fatalf("raw result content was changed: %#v", original.Content)
85+ }
86+ result := projected[0].Content[0].(provider.ToolResultBlock)
87+ if len(result.Content) != 2 {
88+ t.Fatalf("projected result content = %d blocks, want output and timing", len(result.Content))
89+ }
90+ timingText := result.Content[1].(provider.TextBlock).Text
91+ if !strings.Contains(timingText, "started=2026-08-08T12:00:00-04:00") || !strings.Contains(timingText, "duration=1.5s") {
92+ t.Fatalf("timing projection = %q", timingText)
93+ }
94+}
95+
96+func TestProjectToolTimingCountsTowardAggregateBudget(t *testing.T) {
97+ started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
98+ timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(time.Second), Duration: time.Second}
99+ msgs := make([]provider.Message, 0, maxToolResultTotalTextBytes/maxToolResultTextBytes+1)
100+ for i := 0; i < maxToolResultTotalTextBytes/maxToolResultTextBytes; i++ {
101+ msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
102+ CallID: string(rune('a' + i)),
103+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", maxToolResultTextBytes)}},
104+ }}})
105+ }
106+ msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
107+ CallID: "timed-empty", Timing: timing,
108+ }}})
109+
110+ projected := projectToolResultMessages(msgs)
111+ if got := toolResultTextForCall(projected, "timed-empty"); got != formatToolTiming(timing) {
112+ t.Fatalf("empty timed result = %q, want timing annotation", got)
113+ }
114+ var total int
115+ for _, message := range projected {
116+ for _, content := range message.Content {
117+ if result, ok := content.(provider.ToolResultBlock); ok {
118+ total += retainedToolResultTextBytes(result.Content)
119+ }
120+ }
121+ }
122+ if total > maxToolResultTotalTextBytes {
123+ t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes)
124+ }
125+ if got := toolResultTextForCall(projected, "a"); !strings.Contains(got, toolResultOmissionMarker) {
126+ t.Fatalf("oldest result = %q, want truncation after timing reservation", got)
127+ }
128+}
129+
13130 func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
14131 msgs := make([]provider.Message, 0, 10)
15132 for i := 0; i < 5; i++ {
@@ -158,12 +275,14 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
158275 client := &captureClient{}
159276 agent := NewAgent(client, "model", "system", Registry{})
160277 full := strings.Repeat("full-normal-result ", maxToolResultTextBytes)
278+ timing := &provider.ToolTiming{StartedAt: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC), CompletedAt: time.Date(2026, 8, 8, 12, 0, 1, 0, time.UTC), Duration: time.Second}
161279 agent.SetMessages([]provider.Message{
162280 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
163281 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
164282 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
165283 CallID: "call-1",
166284 Content: []provider.Content{provider.TextBlock{Text: full}},
285+ Timing: timing,
167286 }}},
168287 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-2", Name: "read"}}},
169288 })
@@ -172,9 +291,12 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
172291 t.Fatalf("Continue: %v", err)
173292 }
174293 projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")
175- if retainedToolResultTextBytesForCall(client.lastReq.Messages, "call-1") > maxToolResultTextBytes || !strings.Contains(projectedText, toolResultOmissionMarker) {
294+ if retainedToolResultTextBytesForCall(client.lastReq.Messages, "call-1") > maxToolResultTotalTextBytes || !strings.Contains(projectedText, toolResultOmissionMarker) {
176295 t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])
177296 }
297+ if !strings.Contains(projectedText, formatToolTiming(timing)) {
298+ t.Fatal("normal request is missing the tool timing annotation")
299+ }
178300 if !hasToolResultCall(client.lastReq.Messages, "call-2") {
179301 t.Fatal("pair repair result was not preserved in normal request")
180302 }
@@ -190,6 +312,7 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
190312 client := &compactLifecycleClient{}
191313 agent := NewAgent(client, "model", "system", Registry{})
192314 oldText := strings.Repeat("full-compaction-result ", maxToolResultTextBytes)
315+ timing := &provider.ToolTiming{StartedAt: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC), CompletedAt: time.Date(2026, 8, 8, 12, 0, 1, 0, time.UTC), Duration: time.Second}
193316 tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes)
194317 agent.SetMessages([]provider.Message{
195318 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}},
@@ -197,6 +320,7 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
197320 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
198321 CallID: "old-call",
199322 Content: []provider.Content{provider.TextBlock{Text: oldText}},
323+ Timing: timing,
200324 }}},
201325 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "tail-call", Name: "read"}}},
202326 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
@@ -216,6 +340,9 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
216340 if !strings.Contains(requestText, toolResultOmissionMarker) {
217341 t.Fatal("compaction request is missing the tool-result omission marker")
218342 }
343+ if !strings.Contains(requestText, formatToolTiming(timing)) {
344+ t.Fatal("compaction request is missing the tool timing annotation")
345+ }
219346 if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText {
220347 t.Fatalf("compacted Agent.Messages tail changed: got %d bytes, want %d", len(got), len(tailText))
221348 }
@@ -5,11 +5,128 @@ 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 TestSessionTimeContextUsesNamedTimezoneForProjectedWinterMessages(t *testing.T) {
57+ a := NewAgent(nil, "model", "", Registry{})
58+ started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
59+ a.SetSessionTimeContext(started, "America/Los_Angeles", "-07:00")
60+
61+ context := a.providerTimeContext()
62+ december := time.Date(2026, 12, 8, 12, 0, 0, 0, time.UTC).In(context.location)
63+ projected := projectProviderMessages([]provider.Message{{
64+ Role: provider.RoleUser,
65+ Content: []provider.Content{provider.TextBlock{Text: "winter message"}},
66+ Time: december,
67+ }})
68+ annotation := projected[0].Content[0].(provider.TextBlock).Text
69+ if annotation != "[message time: 2026-12-08T04:00:00-08:00]" {
70+ t.Fatalf("winter message annotation = %q, want Pacific Standard Time", annotation)
71+ }
72+}
73+
74+func TestProjectToolTimingIsModelOnly(t *testing.T) {
75+ started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60))
76+ timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(1500 * time.Millisecond), Duration: 1500 * time.Millisecond}
77+ messages := []provider.Message{{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
78+ CallID: "call-1", Content: []provider.Content{provider.TextBlock{Text: "output"}}, Timing: timing,
79+ }}}}
80+
81+ projected := projectProviderMessages(messages)
82+ original := messages[0].Content[0].(provider.ToolResultBlock)
83+ if len(original.Content) != 1 {
84+ t.Fatalf("raw result content was changed: %#v", original.Content)
85+ }
86+ result := projected[0].Content[0].(provider.ToolResultBlock)
87+ if len(result.Content) != 2 {
88+ t.Fatalf("projected result content = %d blocks, want output and timing", len(result.Content))
89+ }
90+ timingText := result.Content[1].(provider.TextBlock).Text
91+ if !strings.Contains(timingText, "started=2026-08-08T12:00:00-04:00") || !strings.Contains(timingText, "duration=1.5s") {
92+ t.Fatalf("timing projection = %q", timingText)
93+ }
94+}
95+
96+func TestProjectToolTimingCountsTowardAggregateBudget(t *testing.T) {
97+ started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
98+ timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(time.Second), Duration: time.Second}
99+ msgs := make([]provider.Message, 0, maxToolResultTotalTextBytes/maxToolResultTextBytes+1)
100+ for i := 0; i < maxToolResultTotalTextBytes/maxToolResultTextBytes; i++ {
101+ msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
102+ CallID: string(rune('a' + i)),
103+ Content: []provider.Content{provider.TextBlock{Text: strings.Repeat("x", maxToolResultTextBytes)}},
104+ }}})
105+ }
106+ msgs = append(msgs, provider.Message{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
107+ CallID: "timed-empty", Timing: timing,
108+ }}})
109+
110+ projected := projectToolResultMessages(msgs)
111+ if got := toolResultTextForCall(projected, "timed-empty"); got != formatToolTiming(timing) {
112+ t.Fatalf("empty timed result = %q, want timing annotation", got)
113+ }
114+ var total int
115+ for _, message := range projected {
116+ for _, content := range message.Content {
117+ if result, ok := content.(provider.ToolResultBlock); ok {
118+ total += retainedToolResultTextBytes(result.Content)
119+ }
120+ }
121+ }
122+ if total > maxToolResultTotalTextBytes {
123+ t.Fatalf("projected tool-result text = %d bytes, want <= %d", total, maxToolResultTotalTextBytes)
124+ }
125+ if got := toolResultTextForCall(projected, "a"); !strings.Contains(got, toolResultOmissionMarker) {
126+ t.Fatalf("oldest result = %q, want truncation after timing reservation", got)
127+ }
128+}
129+
13 func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {130 func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
14 msgs := make([]provider.Message, 0, 10)131 msgs := make([]provider.Message, 0, 10)
15 for i := 0; i < 5; i++ {132 for i := 0; i < 5; i++ {
@@ -158,12 +275,14 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
158 client := &captureClient{}275 client := &captureClient{}
159 agent := NewAgent(client, "model", "system", Registry{})276 agent := NewAgent(client, "model", "system", Registry{})
160 full := strings.Repeat("full-normal-result ", maxToolResultTextBytes)277 full := strings.Repeat("full-normal-result ", maxToolResultTextBytes)
278+ timing := &provider.ToolTiming{StartedAt: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC), CompletedAt: time.Date(2026, 8, 8, 12, 0, 1, 0, time.UTC), Duration: time.Second}
161 agent.SetMessages([]provider.Message{279 agent.SetMessages([]provider.Message{
162 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},280 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
163 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},281 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
164 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{282 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
165 CallID: "call-1",283 CallID: "call-1",
166 Content: []provider.Content{provider.TextBlock{Text: full}},284 Content: []provider.Content{provider.TextBlock{Text: full}},
285+ Timing: timing,
167 }}},286 }}},
168 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-2", Name: "read"}}},287 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-2", Name: "read"}}},
169 })288 })
@@ -172,9 +291,12 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
172 t.Fatalf("Continue: %v", err)291 t.Fatalf("Continue: %v", err)
173 }292 }
174 projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")293 projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")
175- if retainedToolResultTextBytesForCall(client.lastReq.Messages, "call-1") > maxToolResultTextBytes || !strings.Contains(projectedText, toolResultOmissionMarker) {294+ if retainedToolResultTextBytesForCall(client.lastReq.Messages, "call-1") > maxToolResultTotalTextBytes || !strings.Contains(projectedText, toolResultOmissionMarker) {
176 t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])295 t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])
177 }296 }
297+ if !strings.Contains(projectedText, formatToolTiming(timing)) {
298+ t.Fatal("normal request is missing the tool timing annotation")
299+ }
178 if !hasToolResultCall(client.lastReq.Messages, "call-2") {300 if !hasToolResultCall(client.lastReq.Messages, "call-2") {
179 t.Fatal("pair repair result was not preserved in normal request")301 t.Fatal("pair repair result was not preserved in normal request")
180 }302 }
@@ -190,6 +312,7 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
190 client := &compactLifecycleClient{}312 client := &compactLifecycleClient{}
191 agent := NewAgent(client, "model", "system", Registry{})313 agent := NewAgent(client, "model", "system", Registry{})
192 oldText := strings.Repeat("full-compaction-result ", maxToolResultTextBytes)314 oldText := strings.Repeat("full-compaction-result ", maxToolResultTextBytes)
315+ timing := &provider.ToolTiming{StartedAt: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC), CompletedAt: time.Date(2026, 8, 8, 12, 0, 1, 0, time.UTC), Duration: time.Second}
193 tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes)316 tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes)
194 agent.SetMessages([]provider.Message{317 agent.SetMessages([]provider.Message{
195 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}},318 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}},
@@ -197,6 +320,7 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
197 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{320 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
198 CallID: "old-call",321 CallID: "old-call",
199 Content: []provider.Content{provider.TextBlock{Text: oldText}},322 Content: []provider.Content{provider.TextBlock{Text: oldText}},
323+ Timing: timing,
200 }}},324 }}},
201 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "tail-call", Name: "read"}}},325 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "tail-call", Name: "read"}}},
202 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{326 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
@@ -216,6 +340,9 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
216 if !strings.Contains(requestText, toolResultOmissionMarker) {340 if !strings.Contains(requestText, toolResultOmissionMarker) {
217 t.Fatal("compaction request is missing the tool-result omission marker")341 t.Fatal("compaction request is missing the tool-result omission marker")
218 }342 }
343+ if !strings.Contains(requestText, formatToolTiming(timing)) {
344+ t.Fatal("compaction request is missing the tool timing annotation")
345+ }
219 if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText {346 if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText {
220 t.Fatalf("compacted Agent.Messages tail changed: got %d bytes, want %d", len(got), len(tailText))347 t.Fatalf("compacted Agent.Messages tail changed: got %d bytes, want %d", len(got), len(tailText))
221 }348 }
M packages/core/intercept_test.go +98 -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 }
@@ -98,6 +116,35 @@ func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) {
98116 }
99117 }
100118
119+func TestRunOneToolDoesNotEmitExecutionStartedForMissingOrDeniedTools(t *testing.T) {
120+ for _, tc := range []struct {
121+ name string
122+ tools Registry
123+ guard func(provider.ToolCallBlock) (bool, string, json.RawMessage)
124+ }{
125+ {name: "missing tool", tools: Registry{}},
126+ {
127+ name: "denied tool",
128+ tools: Registry{"echo": &recordingTool{}},
129+ guard: func(provider.ToolCallBlock) (bool, string, json.RawMessage) { return false, "denied", nil },
130+ },
131+ } {
132+ t.Run(tc.name, func(t *testing.T) {
133+ a := NewAgent(nil, "test", "", tc.tools)
134+ a.BeforeToolExecute = tc.guard
135+ var started int
136+ a.runOneTool(context.Background(), provider.ToolCallBlock{ID: "T1", Name: "echo"}, a.ToolsSnapshot(), func(event AgentEvent) {
137+ if _, ok := event.(EvToolExecutionStarted); ok {
138+ started++
139+ }
140+ })
141+ if started != 0 {
142+ t.Fatalf("execution-started events = %d, want none", started)
143+ }
144+ })
145+ }
146+}
147+
101148 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
102149 // the interceptor returns an error ToolResult with the reason text.
103150 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
@@ -129,4 +176,55 @@ func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
129176 if rec.lastArgs != nil {
130177 t.Error("tool ran despite block")
131178 }
179+ assertToolTiming(t, res.Timing)
180+}
181+
182+func TestRunOneToolTimingCoversOutcomes(t *testing.T) {
183+ cancelled, cancel := context.WithCancel(context.Background())
184+ cancel()
185+ cases := []struct {
186+ name string
187+ tools Registry
188+ ctx context.Context
189+ want string
190+ }{
191+ {name: "success", tools: Registry{"result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}}}, ctx: context.Background()},
192+ {name: "ordinary error", tools: Registry{"result": &resultTool{err: errors.New("ordinary")}}, ctx: context.Background()},
193+ {name: "cancellation", tools: Registry{"result": &resultTool{err: context.Canceled}}, ctx: cancelled},
194+ {name: "missing", tools: Registry{}, ctx: context.Background()},
195+ {name: "panic", tools: Registry{"result": &resultTool{panic: true}}, ctx: context.Background()},
196+ }
197+ for _, tc := range cases {
198+ t.Run(tc.name, func(t *testing.T) {
199+ a := NewAgent(nil, "test", "", tc.tools)
200+ callName := "result"
201+ if tc.name == "missing" {
202+ callName = "missing"
203+ }
204+ res := a.runOneTool(tc.ctx, provider.ToolCallBlock{ID: "timed", Name: callName}, tc.tools, func(AgentEvent) {})
205+ assertToolTiming(t, res.Timing)
206+ if tc.name == "panic" {
207+ text, ok := res.Content[0].(provider.TextBlock)
208+ if !ok || text.Text != "tool execution failed" {
209+ t.Fatalf("panic result = %#v, want generic tool execution error", res.Content)
210+ }
211+ }
212+ })
213+ }
214+}
215+
216+func assertToolTiming(t *testing.T, timing *provider.ToolTiming) {
217+ t.Helper()
218+ if timing == nil {
219+ t.Fatal("tool result has no timing")
220+ }
221+ if timing.StartedAt.IsZero() || timing.CompletedAt.IsZero() {
222+ t.Fatalf("tool timing has zero wall-clock bound: %#v", timing)
223+ }
224+ if timing.CompletedAt.Before(timing.StartedAt) {
225+ t.Fatalf("tool completed before start: %#v", timing)
226+ }
227+ if timing.Duration < 0 {
228+ t.Fatalf("tool duration = %s, want non-negative", timing.Duration)
229+ }
132230 }
@@ -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 }
@@ -98,6 +116,35 @@ func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) {
98 }116 }
99 }117 }
100 118
119+func TestRunOneToolDoesNotEmitExecutionStartedForMissingOrDeniedTools(t *testing.T) {
120+ for _, tc := range []struct {
121+ name string
122+ tools Registry
123+ guard func(provider.ToolCallBlock) (bool, string, json.RawMessage)
124+ }{
125+ {name: "missing tool", tools: Registry{}},
126+ {
127+ name: "denied tool",
128+ tools: Registry{"echo": &recordingTool{}},
129+ guard: func(provider.ToolCallBlock) (bool, string, json.RawMessage) { return false, "denied", nil },
130+ },
131+ } {
132+ t.Run(tc.name, func(t *testing.T) {
133+ a := NewAgent(nil, "test", "", tc.tools)
134+ a.BeforeToolExecute = tc.guard
135+ var started int
136+ a.runOneTool(context.Background(), provider.ToolCallBlock{ID: "T1", Name: "echo"}, a.ToolsSnapshot(), func(event AgentEvent) {
137+ if _, ok := event.(EvToolExecutionStarted); ok {
138+ started++
139+ }
140+ })
141+ if started != 0 {
142+ t.Fatalf("execution-started events = %d, want none", started)
143+ }
144+ })
145+ }
146+}
147+
101 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from148 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
102 // the interceptor returns an error ToolResult with the reason text.149 // the interceptor returns an error ToolResult with the reason text.
103 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {150 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
@@ -129,4 +176,55 @@ func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
129 if rec.lastArgs != nil {176 if rec.lastArgs != nil {
130 t.Error("tool ran despite block")177 t.Error("tool ran despite block")
131 }178 }
179+ assertToolTiming(t, res.Timing)
180+}
181+
182+func TestRunOneToolTimingCoversOutcomes(t *testing.T) {
183+ cancelled, cancel := context.WithCancel(context.Background())
184+ cancel()
185+ cases := []struct {
186+ name string
187+ tools Registry
188+ ctx context.Context
189+ want string
190+ }{
191+ {name: "success", tools: Registry{"result": &resultTool{result: ToolResult{Content: []provider.Content{provider.TextBlock{Text: "ok"}}}}}, ctx: context.Background()},
192+ {name: "ordinary error", tools: Registry{"result": &resultTool{err: errors.New("ordinary")}}, ctx: context.Background()},
193+ {name: "cancellation", tools: Registry{"result": &resultTool{err: context.Canceled}}, ctx: cancelled},
194+ {name: "missing", tools: Registry{}, ctx: context.Background()},
195+ {name: "panic", tools: Registry{"result": &resultTool{panic: true}}, ctx: context.Background()},
196+ }
197+ for _, tc := range cases {
198+ t.Run(tc.name, func(t *testing.T) {
199+ a := NewAgent(nil, "test", "", tc.tools)
200+ callName := "result"
201+ if tc.name == "missing" {
202+ callName = "missing"
203+ }
204+ res := a.runOneTool(tc.ctx, provider.ToolCallBlock{ID: "timed", Name: callName}, tc.tools, func(AgentEvent) {})
205+ assertToolTiming(t, res.Timing)
206+ if tc.name == "panic" {
207+ text, ok := res.Content[0].(provider.TextBlock)
208+ if !ok || text.Text != "tool execution failed" {
209+ t.Fatalf("panic result = %#v, want generic tool execution error", res.Content)
210+ }
211+ }
212+ })
213+ }
214+}
215+
216+func assertToolTiming(t *testing.T, timing *provider.ToolTiming) {
217+ t.Helper()
218+ if timing == nil {
219+ t.Fatal("tool result has no timing")
220+ }
221+ if timing.StartedAt.IsZero() || timing.CompletedAt.IsZero() {
222+ t.Fatalf("tool timing has zero wall-clock bound: %#v", timing)
223+ }
224+ if timing.CompletedAt.Before(timing.StartedAt) {
225+ t.Fatalf("tool completed before start: %#v", timing)
226+ }
227+ if timing.Duration < 0 {
228+ t.Fatalf("tool duration = %s, want non-negative", timing.Duration)
229+ }
132 }230 }
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 +120 -0
@@ -0,0 +1,120 @@
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+ loadedTimezone := false
42+ if timezone != "" && timezone != "Local" {
43+ if loaded, err := time.LoadLocation(timezone); err == nil {
44+ location = loaded
45+ loadedTimezone = true
46+ }
47+ }
48+ if !loadedTimezone && offset != "" {
49+ if fixed := parseUTCOffset(offset); fixed != nil {
50+ location = fixed
51+ }
52+ }
53+ if timezone == "" {
54+ timezone = location.String()
55+ }
56+ if offset == "" {
57+ _, seconds := started.In(location).Zone()
58+ offset = formatUTCOffset(seconds)
59+ }
60+
61+ a.mu.Lock()
62+ a.timeContext = agentTimeContext{
63+ started: started,
64+ location: location,
65+ zone: timezone,
66+ offset: offset,
67+ }
68+ a.mu.Unlock()
69+}
70+
71+func (a *Agent) providerTimeContext() agentTimeContext {
72+ a.mu.Lock()
73+ defer a.mu.Unlock()
74+ return a.timeContext
75+}
76+
77+func (c agentTimeContext) systemText() string {
78+ if c.started.IsZero() {
79+ return ""
80+ }
81+ location := c.location
82+ if location == nil {
83+ location = time.UTC
84+ }
85+ zone := c.zone
86+ if zone == "" {
87+ zone = location.String()
88+ }
89+ offset := c.offset
90+ if offset == "" {
91+ _, seconds := c.started.In(location).Zone()
92+ offset = formatUTCOffset(seconds)
93+ }
94+ return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format(time.RFC3339), zone, offset)
95+}
96+
97+func formatUTCOffset(seconds int) string {
98+ sign := "+"
99+ if seconds < 0 {
100+ sign = "-"
101+ seconds = -seconds
102+ }
103+ return fmt.Sprintf("%s%02d:%02d", sign, seconds/3600, (seconds%3600)/60)
104+}
105+
106+func parseUTCOffset(value string) *time.Location {
107+ if len(value) != len("+00:00") || (value[0] != '+' && value[0] != '-') || value[3] != ':' {
108+ return nil
109+ }
110+ hours, errHours := strconv.Atoi(value[1:3])
111+ minutes, errMinutes := strconv.Atoi(value[4:6])
112+ if errHours != nil || errMinutes != nil || hours > 23 || minutes > 59 {
113+ return nil
114+ }
115+ seconds := hours*3600 + minutes*60
116+ if strings.HasPrefix(value, "-") {
117+ seconds = -seconds
118+ }
119+ return time.FixedZone(value, seconds)
120+}
@@ -0,0 +1,120 @@
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+ loadedTimezone := false
42+ if timezone != "" && timezone != "Local" {
43+ if loaded, err := time.LoadLocation(timezone); err == nil {
44+ location = loaded
45+ loadedTimezone = true
46+ }
47+ }
48+ if !loadedTimezone && offset != "" {
49+ if fixed := parseUTCOffset(offset); fixed != nil {
50+ location = fixed
51+ }
52+ }
53+ if timezone == "" {
54+ timezone = location.String()
55+ }
56+ if offset == "" {
57+ _, seconds := started.In(location).Zone()
58+ offset = formatUTCOffset(seconds)
59+ }
60+
61+ a.mu.Lock()
62+ a.timeContext = agentTimeContext{
63+ started: started,
64+ location: location,
65+ zone: timezone,
66+ offset: offset,
67+ }
68+ a.mu.Unlock()
69+}
70+
71+func (a *Agent) providerTimeContext() agentTimeContext {
72+ a.mu.Lock()
73+ defer a.mu.Unlock()
74+ return a.timeContext
75+}
76+
77+func (c agentTimeContext) systemText() string {
78+ if c.started.IsZero() {
79+ return ""
80+ }
81+ location := c.location
82+ if location == nil {
83+ location = time.UTC
84+ }
85+ zone := c.zone
86+ if zone == "" {
87+ zone = location.String()
88+ }
89+ offset := c.offset
90+ if offset == "" {
91+ _, seconds := c.started.In(location).Zone()
92+ offset = formatUTCOffset(seconds)
93+ }
94+ return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format(time.RFC3339), zone, offset)
95+}
96+
97+func formatUTCOffset(seconds int) string {
98+ sign := "+"
99+ if seconds < 0 {
100+ sign = "-"
101+ seconds = -seconds
102+ }
103+ return fmt.Sprintf("%s%02d:%02d", sign, seconds/3600, (seconds%3600)/60)
104+}
105+
106+func parseUTCOffset(value string) *time.Location {
107+ if len(value) != len("+00:00") || (value[0] != '+' && value[0] != '-') || value[3] != ':' {
108+ return nil
109+ }
110+ hours, errHours := strconv.Atoi(value[1:3])
111+ minutes, errMinutes := strconv.Atoi(value[4:6])
112+ if errHours != nil || errMinutes != nil || hours > 23 || minutes > 59 {
113+ return nil
114+ }
115+ seconds := hours*3600 + minutes*60
116+ if strings.HasPrefix(value, "-") {
117+ seconds = -seconds
118+ }
119+ return time.FixedZone(value, seconds)
120+}
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 +9 -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)
@@ -412,6 +412,13 @@ func buildOAIUserContent(blocks []Content, textOnly bool) interface{} {
412412 }
413413 }
414414 if textOnly || !hasImage {
415+ // Keep multiple text blocks structured. The core provider projection
416+ // adds message-time annotations as a separate block; collapsing that
417+ // block with the user text changes the wire representation of the
418+ // original prompt and can make consumers misclassify the request.
419+ if !textOnly && len(blocks) > 1 {
420+ return buildOAIContentBlocks(blocks, false)
421+ }
415422 var sb strings.Builder
416423 for _, b := range blocks {
417424 if tb, ok := b.(TextBlock); ok {
@@ -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)
@@ -412,6 +412,13 @@ func buildOAIUserContent(blocks []Content, textOnly bool) interface{} {
412 }412 }
413 }413 }
414 if textOnly || !hasImage {414 if textOnly || !hasImage {
415+ // Keep multiple text blocks structured. The core provider projection
416+ // adds message-time annotations as a separate block; collapsing that
417+ // block with the user text changes the wire representation of the
418+ // original prompt and can make consumers misclassify the request.
419+ if !textOnly && len(blocks) > 1 {
420+ return buildOAIContentBlocks(blocks, false)
421+ }
415 var sb strings.Builder422 var sb strings.Builder
416 for _, b := range blocks {423 for _, b := range blocks {
417 if tb, ok := b.(TextBlock); ok {424 if tb, ok := b.(TextBlock); ok {
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"
M packages/provider/provider_test.go +30 -0
@@ -110,6 +110,36 @@ func TestOpenAIErrorStatus(t *testing.T) {
110110 }
111111 }
112112
113+func TestOpenAIUserContentPreservesProjectedTextBlocks(t *testing.T) {
114+ c := NewOpenAI("x", "").(*openaiClient)
115+ wire, err := c.buildRequest(Request{
116+ Model: "gpt-5",
117+ Messages: []Message{{
118+ Role: RoleUser,
119+ Content: []Content{
120+ TextBlock{Text: "[message time: 2026-08-08T12:34:56-07:00]"},
121+ TextBlock{Text: "fail"},
122+ },
123+ }},
124+ })
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ blocks, ok := wire.Messages[0].Content.([]interface{})
129+ if !ok {
130+ t.Fatalf("user content = %T, want structured text blocks", wire.Messages[0].Content)
131+ }
132+ if len(blocks) != 2 {
133+ t.Fatalf("user content blocks = %d, want 2", len(blocks))
134+ }
135+ if got := blocks[0].(oaiContentText).Text; got != "[message time: 2026-08-08T12:34:56-07:00]" {
136+ t.Fatalf("timestamp block = %q", got)
137+ }
138+ if got := blocks[1].(oaiContentText).Text; got != "fail" {
139+ t.Fatalf("user text block = %q", got)
140+ }
141+}
142+
113143 func TestMiniMaxM3CatalogAndRequest(t *testing.T) {
114144 for _, provider := range []string{"minimax", "minimax-cn"} {
115145 m, err := FindModel(provider, "MiniMax-M3")
@@ -110,6 +110,36 @@ func TestOpenAIErrorStatus(t *testing.T) {
110 }110 }
111 }111 }
112 112
113+func TestOpenAIUserContentPreservesProjectedTextBlocks(t *testing.T) {
114+ c := NewOpenAI("x", "").(*openaiClient)
115+ wire, err := c.buildRequest(Request{
116+ Model: "gpt-5",
117+ Messages: []Message{{
118+ Role: RoleUser,
119+ Content: []Content{
120+ TextBlock{Text: "[message time: 2026-08-08T12:34:56-07:00]"},
121+ TextBlock{Text: "fail"},
122+ },
123+ }},
124+ })
125+ if err != nil {
126+ t.Fatal(err)
127+ }
128+ blocks, ok := wire.Messages[0].Content.([]interface{})
129+ if !ok {
130+ t.Fatalf("user content = %T, want structured text blocks", wire.Messages[0].Content)
131+ }
132+ if len(blocks) != 2 {
133+ t.Fatalf("user content blocks = %d, want 2", len(blocks))
134+ }
135+ if got := blocks[0].(oaiContentText).Text; got != "[message time: 2026-08-08T12:34:56-07:00]" {
136+ t.Fatalf("timestamp block = %q", got)
137+ }
138+ if got := blocks[1].(oaiContentText).Text; got != "fail" {
139+ t.Fatalf("user text block = %q", got)
140+ }
141+}
142+
113 func TestMiniMaxM3CatalogAndRequest(t *testing.T) {143 func TestMiniMaxM3CatalogAndRequest(t *testing.T) {
114 for _, provider := range []string{"minimax", "minimax-cn"} {144 for _, provider := range []string{"minimax", "minimax-cn"} {
115 m, err := FindModel(provider, "MiniMax-M3")145 m, err := FindModel(provider, "MiniMax-M3")

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 477

↑↓ moveenter openesc close