detached fd33ff0

fix(core): harden time and tool execution context

commit
fd33ff0dcba2c7e66de6cabc43fcc7e8ef23691c
author
brice · · 2026-08-09 12:03 UTC
parent
b446379
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +128 -16
M packages/agent/cli.go +2 -0
@@ -1974,6 +1974,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
19741974 oldCloseErr = oldSess.Close()
19751975 }
19761976 sess = candidate.session
1977+ candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
19771978 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
19781979 // The live agent only receives a compact resume window, but the
19791980 // session file remains intact. Keep the persistence baseline at
@@ -2180,6 +2181,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21802181 }
21812182 sess = newSess
21822183 if newSess != nil {
2184+ newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)
21832185 newStates = copyExtensionStates(newSess.ExtensionState)
21842186 }
21852187 sessBaselineMsgs = 0
@@ -1974,6 +1974,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1974 oldCloseErr = oldSess.Close()1974 oldCloseErr = oldSess.Close()
1975 }1975 }
1976 sess = candidate.session1976 sess = candidate.session
1977+ candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
1977 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint1978 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
1978 // The live agent only receives a compact resume window, but the1979 // The live agent only receives a compact resume window, but the
1979 // session file remains intact. Keep the persistence baseline at1980 // session file remains intact. Keep the persistence baseline at
@@ -2180,6 +2181,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2180 }2181 }
2181 sess = newSess2182 sess = newSess
2182 if newSess != nil {2183 if newSess != nil {
2184+ newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)
2183 newStates = copyExtensionStates(newSess.ExtensionState)2185 newStates = copyExtensionStates(newSess.ExtensionState)
2184 }2186 }
2185 sessBaselineMsgs = 02187 sessBaselineMsgs = 0
M packages/agent/tools/tools_test.go +6 -4
@@ -353,10 +353,12 @@ func TestBashSuccess(t *testing.T) {
353353 if strings.Contains(got, "Took ") {
354354 t.Fatalf("bash output still contains duplicate timing: %q", got)
355355 }
356- if details, ok := res.Details.(map[string]any); ok {
357- if _, exists := details["duration_ms"]; exists {
358- t.Fatalf("bash details still contain duplicate duration: %#v", details)
359- }
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)
360362 }
361363 if res.IsError {
362364 t.Fatal("unexpected error flag")
@@ -353,10 +353,12 @@ func TestBashSuccess(t *testing.T) {
353 if strings.Contains(got, "Took ") {353 if strings.Contains(got, "Took ") {
354 t.Fatalf("bash output still contains duplicate timing: %q", got)354 t.Fatalf("bash output still contains duplicate timing: %q", got)
355 }355 }
356- if details, ok := res.Details.(map[string]any); ok {356+ details, ok := res.Details.(map[string]any)
357- if _, exists := details["duration_ms"]; exists {357+ if !ok || details == nil {
358- t.Fatalf("bash details still contain duplicate duration: %#v", details)358+ t.Fatalf("bash details = %#v (%T), want non-nil map[string]any", res.Details, res.Details)
359- }359+ }
360+ if _, exists := details["duration_ms"]; exists {
361+ t.Fatalf("bash details still contain duplicate duration: %#v", details)
360 }362 }
361 if res.IsError {363 if res.IsError {
362 t.Fatal("unexpected error flag")364 t.Fatal("unexpected error flag")
M packages/core/agent.go +3 -4
@@ -892,9 +892,9 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
892892 func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) (res ToolResult) {
893893 started := time.Now()
894894 defer func() {
895- if recovered := recover(); recovered != nil {
895+ if recover() != nil {
896896 res = ToolResult{
897- Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", recovered)}},
897+ Content: []provider.Content{provider.TextBlock{Text: "tool execution failed"}},
898898 IsError: true,
899899 }
900900 }
@@ -905,8 +905,6 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
905905 }
906906 }()
907907
908- sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
909-
910908 tool, err := tools.Get(tc.Name)
911909 if err != nil {
912910 return ToolResult{
@@ -945,6 +943,7 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
945943
946944 // Tool panics are recovered by the invocation-level defer above so
947945 // guard, lookup, progress, and Execute panics all receive timing.
946+ sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
948947 out, err := tool.Execute(ctx, args, func(text string) {
949948 sink(EvToolProgress{ID: tc.ID, Text: text})
950949 })
@@ -892,9 +892,9 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
892 func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) (res ToolResult) {892 func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) (res ToolResult) {
893 started := time.Now()893 started := time.Now()
894 defer func() {894 defer func() {
895- if recovered := recover(); recovered != nil {895+ if recover() != nil {
896 res = ToolResult{896 res = ToolResult{
897- Content: []provider.Content{provider.TextBlock{Text: fmt.Sprintf("panic: %v", recovered)}},897+ Content: []provider.Content{provider.TextBlock{Text: "tool execution failed"}},
898 IsError: true,898 IsError: true,
899 }899 }
900 }900 }
@@ -905,8 +905,6 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
905 }905 }
906 }()906 }()
907 907
908- sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
909-
910 tool, err := tools.Get(tc.Name)908 tool, err := tools.Get(tc.Name)
911 if err != nil {909 if err != nil {
912 return ToolResult{910 return ToolResult{
@@ -945,6 +943,7 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools
945 943
946 // Tool panics are recovered by the invocation-level defer above so944 // Tool panics are recovered by the invocation-level defer above so
947 // guard, lookup, progress, and Execute panics all receive timing.945 // guard, lookup, progress, and Execute panics all receive timing.
946+ sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name})
948 out, err := tool.Execute(ctx, args, func(text string) {947 out, err := tool.Execute(ctx, args, func(text string) {
949 sink(EvToolProgress{ID: tc.ID, Text: text})948 sink(EvToolProgress{ID: tc.ID, Text: text})
950 })949 })
M packages/core/context_budget.go +11 -2
@@ -30,6 +30,15 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
3030 continue
3131 }
3232
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+ }
40+ }
41+
3342 textBytes := toolResultTextBytes(result.Content)
3443 if textBytes > 0 {
3544 limit := maxToolResultTextBytes
@@ -40,8 +49,8 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
4049 result.Content, retained = projectToolResultContent(result.Content, limit)
4150 remaining -= retained
4251 }
43- if result.Timing != nil {
44- result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)})
52+ if timingText != "" {
53+ result.Content = append(result.Content, provider.TextBlock{Text: timingText})
4554 }
4655 projected[i].Content[j] = result
4756 }
@@ -30,6 +30,15 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
30 continue30 continue
31 }31 }
32 32
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+ }
40+ }
41+
33 textBytes := toolResultTextBytes(result.Content)42 textBytes := toolResultTextBytes(result.Content)
34 if textBytes > 0 {43 if textBytes > 0 {
35 limit := maxToolResultTextBytes44 limit := maxToolResultTextBytes
@@ -40,8 +49,8 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message {
40 result.Content, retained = projectToolResultContent(result.Content, limit)49 result.Content, retained = projectToolResultContent(result.Content, limit)
41 remaining -= retained50 remaining -= retained
42 }51 }
43- if result.Timing != nil {52+ if timingText != "" {
44- result.Content = append(result.Content, provider.TextBlock{Text: formatToolTiming(result.Timing)})53+ result.Content = append(result.Content, provider.TextBlock{Text: timingText})
45 }54 }
46 projected[i].Content[j] = result55 projected[i].Content[j] = result
47 }56 }
M packages/core/context_budget_test.go +63 -1
@@ -53,6 +53,24 @@ func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) {
5353 }
5454 }
5555
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+
5674 func TestProjectToolTimingIsModelOnly(t *testing.T) {
5775 started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60))
5876 timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(1500 * time.Millisecond), Duration: 1500 * time.Millisecond}
@@ -75,6 +93,40 @@ func TestProjectToolTimingIsModelOnly(t *testing.T) {
7593 }
7694 }
7795
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+
78130 func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
79131 msgs := make([]provider.Message, 0, 10)
80132 for i := 0; i < 5; i++ {
@@ -223,12 +275,14 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
223275 client := &captureClient{}
224276 agent := NewAgent(client, "model", "system", Registry{})
225277 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}
226279 agent.SetMessages([]provider.Message{
227280 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
228281 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
229282 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
230283 CallID: "call-1",
231284 Content: []provider.Content{provider.TextBlock{Text: full}},
285+ Timing: timing,
232286 }}},
233287 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-2", Name: "read"}}},
234288 })
@@ -237,9 +291,12 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
237291 t.Fatalf("Continue: %v", err)
238292 }
239293 projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")
240- 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) {
241295 t.Fatalf("normal request result was not bounded: %d bytes, %q", len(projectedText), projectedText[:min(len(projectedText), 80)])
242296 }
297+ if !strings.Contains(projectedText, formatToolTiming(timing)) {
298+ t.Fatal("normal request is missing the tool timing annotation")
299+ }
243300 if !hasToolResultCall(client.lastReq.Messages, "call-2") {
244301 t.Fatal("pair repair result was not preserved in normal request")
245302 }
@@ -255,6 +312,7 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
255312 client := &compactLifecycleClient{}
256313 agent := NewAgent(client, "model", "system", Registry{})
257314 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}
258316 tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes)
259317 agent.SetMessages([]provider.Message{
260318 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}},
@@ -262,6 +320,7 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
262320 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
263321 CallID: "old-call",
264322 Content: []provider.Content{provider.TextBlock{Text: oldText}},
323+ Timing: timing,
265324 }}},
266325 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "tail-call", Name: "read"}}},
267326 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
@@ -281,6 +340,9 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
281340 if !strings.Contains(requestText, toolResultOmissionMarker) {
282341 t.Fatal("compaction request is missing the tool-result omission marker")
283342 }
343+ if !strings.Contains(requestText, formatToolTiming(timing)) {
344+ t.Fatal("compaction request is missing the tool timing annotation")
345+ }
284346 if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText {
285347 t.Fatalf("compacted Agent.Messages tail changed: got %d bytes, want %d", len(got), len(tailText))
286348 }
@@ -53,6 +53,24 @@ func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) {
53 }53 }
54 }54 }
55 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+
56 func TestProjectToolTimingIsModelOnly(t *testing.T) {74 func TestProjectToolTimingIsModelOnly(t *testing.T) {
57 started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60))75 started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60))
58 timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(1500 * time.Millisecond), Duration: 1500 * time.Millisecond}76 timing := &provider.ToolTiming{StartedAt: started, CompletedAt: started.Add(1500 * time.Millisecond), Duration: 1500 * time.Millisecond}
@@ -75,6 +93,40 @@ func TestProjectToolTimingIsModelOnly(t *testing.T) {
75 }93 }
76 }94 }
77 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+
78 func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {130 func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) {
79 msgs := make([]provider.Message, 0, 10)131 msgs := make([]provider.Message, 0, 10)
80 for i := 0; i < 5; i++ {132 for i := 0; i < 5; i++ {
@@ -223,12 +275,14 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
223 client := &captureClient{}275 client := &captureClient{}
224 agent := NewAgent(client, "model", "system", Registry{})276 agent := NewAgent(client, "model", "system", Registry{})
225 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}
226 agent.SetMessages([]provider.Message{279 agent.SetMessages([]provider.Message{
227 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},280 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
228 {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"}}},
229 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{282 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
230 CallID: "call-1",283 CallID: "call-1",
231 Content: []provider.Content{provider.TextBlock{Text: full}},284 Content: []provider.Content{provider.TextBlock{Text: full}},
285+ Timing: timing,
232 }}},286 }}},
233 {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"}}},
234 })288 })
@@ -237,9 +291,12 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) {
237 t.Fatalf("Continue: %v", err)291 t.Fatalf("Continue: %v", err)
238 }292 }
239 projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")293 projectedText := toolResultTextForCall(client.lastReq.Messages, "call-1")
240- 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) {
241 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)])
242 }296 }
297+ if !strings.Contains(projectedText, formatToolTiming(timing)) {
298+ t.Fatal("normal request is missing the tool timing annotation")
299+ }
243 if !hasToolResultCall(client.lastReq.Messages, "call-2") {300 if !hasToolResultCall(client.lastReq.Messages, "call-2") {
244 t.Fatal("pair repair result was not preserved in normal request")301 t.Fatal("pair repair result was not preserved in normal request")
245 }302 }
@@ -255,6 +312,7 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
255 client := &compactLifecycleClient{}312 client := &compactLifecycleClient{}
256 agent := NewAgent(client, "model", "system", Registry{})313 agent := NewAgent(client, "model", "system", Registry{})
257 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}
258 tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes)316 tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes)
259 agent.SetMessages([]provider.Message{317 agent.SetMessages([]provider.Message{
260 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}},318 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}},
@@ -262,6 +320,7 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
262 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{320 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
263 CallID: "old-call",321 CallID: "old-call",
264 Content: []provider.Content{provider.TextBlock{Text: oldText}},322 Content: []provider.Content{provider.TextBlock{Text: oldText}},
323+ Timing: timing,
265 }}},324 }}},
266 {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"}}},
267 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{326 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
@@ -281,6 +340,9 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T
281 if !strings.Contains(requestText, toolResultOmissionMarker) {340 if !strings.Contains(requestText, toolResultOmissionMarker) {
282 t.Fatal("compaction request is missing the tool-result omission marker")341 t.Fatal("compaction request is missing the tool-result omission marker")
283 }342 }
343+ if !strings.Contains(requestText, formatToolTiming(timing)) {
344+ t.Fatal("compaction request is missing the tool timing annotation")
345+ }
284 if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText {346 if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText {
285 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))
286 }348 }
M packages/core/intercept_test.go +35 -0
@@ -116,6 +116,35 @@ func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) {
116116 }
117117 }
118118
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+
119148 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
120149 // the interceptor returns an error ToolResult with the reason text.
121150 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
@@ -174,6 +203,12 @@ func TestRunOneToolTimingCoversOutcomes(t *testing.T) {
174203 }
175204 res := a.runOneTool(tc.ctx, provider.ToolCallBlock{ID: "timed", Name: callName}, tc.tools, func(AgentEvent) {})
176205 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+ }
177212 })
178213 }
179214 }
@@ -116,6 +116,35 @@ func TestRunOneToolEmitsExecutionStartedAfterApproval(t *testing.T) {
116 }116 }
117 }117 }
118 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+
119 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from148 // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from
120 // the interceptor returns an error ToolResult with the reason text.149 // the interceptor returns an error ToolResult with the reason text.
121 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {150 func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) {
@@ -174,6 +203,12 @@ func TestRunOneToolTimingCoversOutcomes(t *testing.T) {
174 }203 }
175 res := a.runOneTool(tc.ctx, provider.ToolCallBlock{ID: "timed", Name: callName}, tc.tools, func(AgentEvent) {})204 res := a.runOneTool(tc.ctx, provider.ToolCallBlock{ID: "timed", Name: callName}, tc.tools, func(AgentEvent) {})
176 assertToolTiming(t, res.Timing)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+ }
177 })212 })
178 }213 }
179 }214 }
M packages/core/time_context.go +8 -5
@@ -38,13 +38,16 @@ func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string
3838 return
3939 }
4040 location := time.Local
41- if offset != "" {
42- if fixed := parseUTCOffset(offset); fixed != nil {
43- location = fixed
44- }
45- } else if timezone != "" && timezone != "Local" {
41+ loadedTimezone := false
42+ if timezone != "" && timezone != "Local" {
4643 if loaded, err := time.LoadLocation(timezone); err == nil {
4744 location = loaded
45+ loadedTimezone = true
46+ }
47+ }
48+ if !loadedTimezone && offset != "" {
49+ if fixed := parseUTCOffset(offset); fixed != nil {
50+ location = fixed
4851 }
4952 }
5053 if timezone == "" {
@@ -38,13 +38,16 @@ func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string
38 return38 return
39 }39 }
40 location := time.Local40 location := time.Local
41- if offset != "" {41+ loadedTimezone := false
42- if fixed := parseUTCOffset(offset); fixed != nil {42+ if timezone != "" && timezone != "Local" {
43- location = fixed
44- }
45- } else if timezone != "" && timezone != "Local" {
46 if loaded, err := time.LoadLocation(timezone); err == nil {43 if loaded, err := time.LoadLocation(timezone); err == nil {
47 location = loaded44 location = loaded
45+ loadedTimezone = true
46+ }
47+ }
48+ if !loadedTimezone && offset != "" {
49+ if fixed := parseUTCOffset(offset); fixed != nil {
50+ location = fixed
48 }51 }
49 }52 }
50 if timezone == "" {53 if timezone == "" {

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close