fix(core): harden time and tool execution context
- commit
- fd33ff0dcba2c7e66de6cabc43fcc7e8ef23691c
- author
- brice · · 2026-08-09 12:03 UTC
- parent
- b446379
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 1974 | 1974 | oldCloseErr = oldSess.Close() | |
| 1975 | 1975 | } | |
| 1976 | 1976 | sess = candidate.session | |
| 1977 | + | candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset) | |
| 1977 | 1978 | sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint | |
| 1978 | 1979 | // The live agent only receives a compact resume window, but the | |
| 1979 | 1980 | // 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 | 2182 | sess = newSess | |
| 2182 | 2183 | if newSess != nil { | |
| 2184 | + | newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset) | |
| 2183 | 2185 | newStates = copyExtensionStates(newSess.ExtensionState) | |
| 2184 | 2186 | } | |
| 2185 | 2187 | 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.session | 1976 | 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.ForkPoint | 1978 | 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 the | 1979 | // The live agent only receives a compact resume window, but the | ||
| 1979 | // session file remains intact. Keep the persistence baseline at | 1980 | // 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 = newSess | 2182 | 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 = 0 | 2187 | sessBaselineMsgs = 0 | ||
M packages/agent/tools/tools_test.go +6 -4
| @@ -353,10 +353,12 @@ func TestBashSuccess(t *testing.T) { | |||
| 353 | 353 | if strings.Contains(got, "Took ") { | |
| 354 | 354 | t.Fatalf("bash output still contains duplicate timing: %q", got) | |
| 355 | 355 | } | |
| 356 | - | if details, ok := res.Details.(map[string]any); ok { | |
| 357 | - | if _, exists := details["duration_ms"]; exists { | |
| 358 | - | t.Fatalf("bash details still contain duplicate duration: %#v", details) | |
| 359 | - | } | |
| 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) | |
| 360 | 362 | } | |
| 361 | 363 | if res.IsError { | |
| 362 | 364 | 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 | |||
| 892 | 892 | func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools Registry, sink func(AgentEvent)) (res ToolResult) { | |
| 893 | 893 | started := time.Now() | |
| 894 | 894 | defer func() { | |
| 895 | - | if recovered := recover(); recovered != nil { | |
| 895 | + | if recover() != nil { | |
| 896 | 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 | 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 | 908 | tool, err := tools.Get(tc.Name) | |
| 911 | 909 | if err != nil { | |
| 912 | 910 | return ToolResult{ | |
| @@ -945,6 +943,7 @@ func (a *Agent) runOneTool(ctx context.Context, tc provider.ToolCallBlock, tools | |||
| 945 | 943 | ||
| 946 | 944 | // Tool panics are recovered by the invocation-level defer above so | |
| 947 | 945 | // guard, lookup, progress, and Execute panics all receive timing. | |
| 946 | + | sink(EvToolExecutionStarted{ID: tc.ID, Name: tc.Name}) | |
| 948 | 947 | out, err := tool.Execute(ctx, args, func(text string) { | |
| 949 | 948 | sink(EvToolProgress{ID: tc.ID, Text: text}) | |
| 950 | 949 | }) | |
| @@ -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 so | 944 | // 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 { | |||
| 30 | 30 | 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 | 42 | textBytes := toolResultTextBytes(result.Content) | |
| 34 | 43 | if textBytes > 0 { | |
| 35 | 44 | limit := maxToolResultTextBytes | |
| @@ -40,8 +49,8 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message { | |||
| 40 | 49 | result.Content, retained = projectToolResultContent(result.Content, limit) | |
| 41 | 50 | remaining -= retained | |
| 42 | 51 | } | |
| 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}) | |
| 45 | 54 | } | |
| 46 | 55 | projected[i].Content[j] = result | |
| 47 | 56 | } | |
| @@ -30,6 +30,15 @@ func projectToolResultMessages(msgs []provider.Message) []provider.Message { | |||||
| 30 | continue | 30 | 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 := maxToolResultTextBytes | 44 | 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 -= retained | 50 | 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] = result | 55 | 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) { | |||
| 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 | 74 | func TestProjectToolTimingIsModelOnly(t *testing.T) { | |
| 57 | 75 | started := time.Date(2026, 8, 8, 12, 0, 0, 0, time.FixedZone("UTC-4", -4*60*60)) | |
| 58 | 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 | 130 | func TestProjectToolResultMessagesBoundsAndUTF8(t *testing.T) { | |
| 79 | 131 | msgs := make([]provider.Message, 0, 10) | |
| 80 | 132 | for i := 0; i < 5; i++ { | |
| @@ -223,12 +275,14 @@ func TestAgentNormalRequestProjectsToolResultsAfterPairRepair(t *testing.T) { | |||
| 223 | 275 | client := &captureClient{} | |
| 224 | 276 | agent := NewAgent(client, "model", "system", Registry{}) | |
| 225 | 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 | 279 | agent.SetMessages([]provider.Message{ | |
| 227 | 280 | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}}, | |
| 228 | 281 | {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}}, | |
| 229 | 282 | {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{ | |
| 230 | 283 | CallID: "call-1", | |
| 231 | 284 | Content: []provider.Content{provider.TextBlock{Text: full}}, | |
| 285 | + | Timing: timing, | |
| 232 | 286 | }}}, | |
| 233 | 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 | 291 | t.Fatalf("Continue: %v", err) | |
| 238 | 292 | } | |
| 239 | 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 | 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 | 300 | if !hasToolResultCall(client.lastReq.Messages, "call-2") { | |
| 244 | 301 | t.Fatal("pair repair result was not preserved in normal request") | |
| 245 | 302 | } | |
| @@ -255,6 +312,7 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T | |||
| 255 | 312 | client := &compactLifecycleClient{} | |
| 256 | 313 | agent := NewAgent(client, "model", "system", Registry{}) | |
| 257 | 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 | 316 | tailText := strings.Repeat("full-tail-result ", maxToolResultTextBytes) | |
| 259 | 317 | agent.SetMessages([]provider.Message{ | |
| 260 | 318 | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "summarize this"}}}, | |
| @@ -262,6 +320,7 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T | |||
| 262 | 320 | {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{ | |
| 263 | 321 | CallID: "old-call", | |
| 264 | 322 | Content: []provider.Content{provider.TextBlock{Text: oldText}}, | |
| 323 | + | Timing: timing, | |
| 265 | 324 | }}}, | |
| 266 | 325 | {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "tail-call", Name: "read"}}}, | |
| 267 | 326 | {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{ | |
| @@ -281,6 +340,9 @@ func TestCompactionProjectsSummaryInputWithoutChangingPersistedTail(t *testing.T | |||
| 281 | 340 | if !strings.Contains(requestText, toolResultOmissionMarker) { | |
| 282 | 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 | 346 | if got := toolResultTextForCall(agent.Messages(), "tail-call"); got != tailText { | |
| 285 | 347 | t.Fatalf("compacted Agent.Messages tail changed: got %d bytes, want %d", len(got), len(tailText)) | |
| 286 | 348 | } | |
| @@ -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) { | |||
| 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 | 148 | // TestBeforeToolExecuteBlockSurfacesReason verifies a refusal from | |
| 120 | 149 | // the interceptor returns an error ToolResult with the reason text. | |
| 121 | 150 | func TestBeforeToolExecuteBlockSurfacesReason(t *testing.T) { | |
| @@ -174,6 +203,12 @@ func TestRunOneToolTimingCoversOutcomes(t *testing.T) { | |||
| 174 | 203 | } | |
| 175 | 204 | res := a.runOneTool(tc.ctx, provider.ToolCallBlock{ID: "timed", Name: callName}, tc.tools, func(AgentEvent) {}) | |
| 176 | 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 | } | |
| @@ -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 from | 148 | // 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 | |||
| 38 | 38 | return | |
| 39 | 39 | } | |
| 40 | 40 | 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" { | |
| 46 | 43 | if loaded, err := time.LoadLocation(timezone); err == nil { | |
| 47 | 44 | 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 | 53 | if timezone == "" { | |
| @@ -38,13 +38,16 @@ func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string | |||||
| 38 | return | 38 | return | ||
| 39 | } | 39 | } | ||
| 40 | location := time.Local | 40 | 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 = loaded | 44 | 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 == "" { | ||