From 08e8040c185a5027862348c1d2d92144a5140ee5 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <87276974+itsddvn@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:30:42 +0700 Subject: [PATCH] fix(tools): return authoritative datetime fields (#1468) Co-authored-by: ntduc --- internal/tools/datetime.go | 42 ++++++++++-- internal/tools/datetime_test.go | 115 ++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 internal/tools/datetime_test.go diff --git a/internal/tools/datetime.go b/internal/tools/datetime.go index 25f63d2e..3765664c 100644 --- a/internal/tools/datetime.go +++ b/internal/tools/datetime.go @@ -10,9 +10,11 @@ import ( // DateTimeTool provides precise current date/time with timezone support. // Models use this before creating cron jobs or any time-sensitive operation // instead of guessing timestamps from the system prompt's date-only field. -type DateTimeTool struct{} +type DateTimeTool struct { + now func() time.Time +} -func NewDateTimeTool() *DateTimeTool { return &DateTimeTool{} } +func NewDateTimeTool() *DateTimeTool { return &DateTimeTool{now: time.Now} } func (t *DateTimeTool) Name() string { return "datetime" } @@ -20,7 +22,8 @@ func (t *DateTimeTool) Description() string { return `Get the current date and time. Use this when you need precise timestamps for scheduling (cron jobs), logging, or any time-sensitive operation. Returns current time in both UTC and the requested timezone. -If no timezone is provided, returns UTC only.` +If no timezone is provided, returns UTC only. +Calendar fields such as weekday and iso_weekday are computed by the server and are authoritative. Use them verbatim; never infer or recalculate the weekday from a date.` } func (t *DateTimeTool) Parameters() map[string]any { @@ -37,9 +40,17 @@ func (t *DateTimeTool) Parameters() map[string]any { func (t *DateTimeTool) Execute(_ context.Context, args map[string]any) *Result { now := time.Now() + if t != nil && t.now != nil { + now = t.now() + } + utc := now.UTC() result := map[string]any{ - "utc": now.UTC().Format(time.RFC3339), - "unix_ms": now.UnixMilli(), + "utc": utc.Format(time.RFC3339), + "utc_date": utc.Format(time.DateOnly), + "utc_time": utc.Format(time.TimeOnly), + "utc_weekday": utc.Weekday().String(), + "utc_iso_weekday": isoWeekday(utc), + "unix_ms": now.UnixMilli(), } if tz, ok := args["timezone"].(string); ok && tz != "" { @@ -47,10 +58,27 @@ func (t *DateTimeTool) Execute(_ context.Context, args map[string]any) *Result { if err != nil { return ErrorResult(fmt.Sprintf("invalid timezone '%s': use IANA names like 'Asia/Ho_Chi_Minh', 'America/New_York'", tz)) } - result["local"] = now.In(loc).Format(time.RFC3339) + local := now.In(loc) + result["local"] = local.Format(time.RFC3339) + result["local_date"] = local.Format(time.DateOnly) + result["local_time"] = local.Format(time.TimeOnly) + result["weekday"] = local.Weekday().String() + result["iso_weekday"] = isoWeekday(local) + result["utc_offset"] = local.Format("Z07:00") result["timezone"] = tz } - data, _ := json.MarshalIndent(result, "", " ") + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return ErrorResult(fmt.Sprintf("failed to encode datetime result: %v", err)) + } return NewResult(string(data)) } + +func isoWeekday(value time.Time) int { + weekday := int(value.Weekday()) + if weekday == 0 { + return 7 + } + return weekday +} diff --git a/internal/tools/datetime_test.go b/internal/tools/datetime_test.go new file mode 100644 index 00000000..3eb9ecfa --- /dev/null +++ b/internal/tools/datetime_test.go @@ -0,0 +1,115 @@ +package tools + +import ( + "encoding/json" + "testing" + "time" +) + +func TestDateTimeToolReturnsDerivedLocalCalendarFields(t *testing.T) { + t.Parallel() + + tool := NewDateTimeTool() + tool.now = func() time.Time { + return time.Date(2026, time.July, 23, 1, 42, 19, 0, time.UTC) + } + result := tool.Execute(t.Context(), map[string]any{ + "timezone": "Asia/Ho_Chi_Minh", + }) + if result.IsError { + t.Fatalf("Execute() returned error: %s", result.ForLLM) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(result.ForLLM), &payload); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + wants := map[string]any{ + "utc": "2026-07-23T01:42:19Z", + "utc_date": "2026-07-23", + "utc_time": "01:42:19", + "utc_weekday": "Thursday", + "utc_iso_weekday": float64(4), + "unix_ms": float64(1784770939000), + "local": "2026-07-23T08:42:19+07:00", + "local_date": "2026-07-23", + "local_time": "08:42:19", + "weekday": "Thursday", + "iso_weekday": float64(4), + "utc_offset": "+07:00", + "timezone": "Asia/Ho_Chi_Minh", + } + for key, want := range wants { + if got := payload[key]; got != want { + t.Errorf("%s = %#v, want %#v", key, got, want) + } + } +} + +func TestDateTimeToolReturnsUTCOnlyWithoutTimezone(t *testing.T) { + t.Parallel() + + tool := NewDateTimeTool() + tool.now = func() time.Time { + return time.Date(2026, time.July, 26, 23, 59, 58, 0, time.UTC) + } + result := tool.Execute(t.Context(), nil) + if result.IsError { + t.Fatalf("Execute() returned error: %s", result.ForLLM) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(result.ForLLM), &payload); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if got := payload["utc_weekday"]; got != "Sunday" { + t.Errorf("utc_weekday = %#v, want %q", got, "Sunday") + } + if got := payload["utc_iso_weekday"]; got != float64(7) { + t.Errorf("utc_iso_weekday = %#v, want 7", got) + } + for _, key := range []string{"local", "local_date", "local_time", "weekday", "iso_weekday", "utc_offset", "timezone"} { + if _, ok := payload[key]; ok { + t.Errorf("UTC-only result unexpectedly contains %q", key) + } + } +} + +func TestDateTimeToolRejectsInvalidTimezone(t *testing.T) { + t.Parallel() + + result := NewDateTimeTool().Execute(t.Context(), map[string]any{ + "timezone": "UTC+7", + }) + if !result.IsError { + t.Fatal("Execute() accepted an invalid IANA timezone") + } +} + +func TestDateTimeToolExecuteSupportsZeroValueAndNilReceiver(t *testing.T) { + t.Parallel() + + var nilTool *DateTimeTool + for name, tool := range map[string]*DateTimeTool{ + "zero value": {}, + "nil receiver": nilTool, + } { + t.Run(name, func(t *testing.T) { + result := tool.Execute(t.Context(), nil) + if result.IsError { + t.Fatalf("Execute() returned error: %s", result.ForLLM) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(result.ForLLM), &payload); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + for _, key := range []string{"utc", "utc_date", "utc_time", "utc_weekday", "utc_iso_weekday", "unix_ms"} { + if payload[key] == nil { + t.Errorf("result is missing %q", key) + } + } + }) + } +}