Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import java.util.Optional;
import lombok.Builder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
Expand Down Expand Up @@ -69,17 +70,18 @@ public DateInfoTool(ObjectMapper objectMapper) {
date. Use this whenever the user asks about today, a date, weekday, whether a \
day is a holiday, Chinese holiday schedule, or date difference. For relative \
dates, convert them using the system prompt's current date before passing \
date; omit date for today. Pass end_date only when a calendar-day difference \
is needed. Date difference counts start date inclusive and end date exclusive.\
start_date; omit start_date for today. Pass end_date only when a calendar-day \
difference is needed. Date difference counts start_date inclusive and \
end_date exclusive.\
""")
public String getDateInfo(
@ToolParam(
name = "date",
name = "start_date",
description =
"Date in yyyy-MM-dd format. Defaults to today. Also acts as"
+ " start date when end_date is provided.",
"Date in yyyy-MM-dd format. Defaults to today. When end_date"
+ " is provided, this acts as the start of the range.",
required = false)
String date,
String startDate,
@ToolParam(
name = "end_date",
description =
Expand All @@ -94,39 +96,39 @@ public String getDateInfo(
required = false)
String timezone) {
try {
return objectMapper.writeValueAsString(resolve(date, endDate, timezone));
return objectMapper.writeValueAsString(resolve(startDate, endDate, timezone));
} catch (Exception e) {
return "Error: failed to get date info: " + e.getMessage();
}
}

DateInfo resolve(String dateText, String timezoneText) {
return resolve(dateText, null, timezoneText);
}

DateInfo resolve(String dateText, String endDateText, String timezoneText) {
DateInfo resolve(String startDateText, String endDateText, String timezoneText) {
ZoneId zoneId =
ZoneId.of(StringUtils.hasText(timezoneText) ? timezoneText : DEFAULT_TIMEZONE);
LocalDate date =
StringUtils.hasText(dateText)
? LocalDate.parse(dateText)
LocalDate startDate =
StringUtils.hasText(startDateText)
? LocalDate.parse(startDateText)
: LocalDate.now(clock.withZone(zoneId));
Optional<HolidayApiResponse> holidayApiResponse = queryHolidayApi(date);
Optional<HolidayApiResponse> holidayApiResponse = queryHolidayApi(startDate);
HolidayDetail holiday = holidayApiResponse.map(HolidayApiResponse::holiday).orElse(null);

boolean legalHoliday = holiday != null && Boolean.TRUE.equals(holiday.holiday());
// API returns holiday={holiday:false} only for adjusted workdays (调休补班);
// regular non-holiday days return holiday=null.
boolean adjustedWorkday = holiday != null && Boolean.FALSE.equals(holiday.holiday());
boolean weekend = date.getDayOfWeek().getValue() >= 6;

return new DateInfo(
date.toString(),
date.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINA),
holiday != null ? holiday.name() : null,
dayType(legalHoliday, adjustedWorkday, weekend),
holidayApiResponse.isPresent(),
StringUtils.hasText(endDateText)
? resolveDateDiff(date, LocalDate.parse(endDateText))
: null);
boolean weekend = startDate.getDayOfWeek().getValue() >= 6;

return DateInfo.builder()
.date(startDate.toString())
.weekday(startDate.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINA))
.holidayName(holiday != null ? holiday.name() : null)
.dayType(dayType(legalHoliday, adjustedWorkday, weekend))
.holidayDataAvailable(holidayApiResponse.isPresent())
.dateDiff(
StringUtils.hasText(endDateText)
? resolveDateDiff(startDate, LocalDate.parse(endDateText))
: null)
.build();
}

DateDiff resolveDateDiff(LocalDate startDate, LocalDate endDate) {
Expand Down Expand Up @@ -174,6 +176,7 @@ private String dayType(boolean legalHoliday, boolean adjustedWorkday, boolean we
return weekend ? "WEEKEND" : "WORKDAY";
}

@Builder
record DateInfo(
String date,
String weekday,
Expand All @@ -185,11 +188,8 @@ record DateInfo(
record DateDiff(long calendarDays, long absoluteCalendarDays) {}

@JsonIgnoreProperties(ignoreUnknown = true)
record HolidayApiResponse(int code, HolidayType type, HolidayDetail holiday) {}

@JsonIgnoreProperties(ignoreUnknown = true)
record HolidayType(Integer type, String name, Integer week) {}
record HolidayApiResponse(int code, HolidayDetail holiday) {}

@JsonIgnoreProperties(ignoreUnknown = true)
record HolidayDetail(Boolean holiday, String name, String target, String date) {}
record HolidayDetail(Boolean holiday, String name) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright (C) 2026 github.com/MaloneTalk
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* limitations under the License.
*/
package io.github.malonetalk.agent.tools;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import org.junit.jupiter.api.Test;

class DateInfoToolTest {

private final ObjectMapper objectMapper = new ObjectMapper();
private final Clock fixedClock =
Clock.fixed(Instant.parse("2026-08-12T00:00:00Z"), ZoneId.of("Asia/Shanghai"));

// --- Date diff ---

@Test
void dateDiff_positive() {
DateInfoTool tool = new DateInfoTool(objectMapper, fixedClock, mock(HttpClient.class));
var diff = tool.resolveDateDiff(LocalDate.of(2026, 8, 12), LocalDate.of(2026, 8, 15));
assertEquals(3, diff.calendarDays());
assertEquals(3, diff.absoluteCalendarDays());
}

@Test
void dateDiff_negativeWhenEndBeforeStart() {
DateInfoTool tool = new DateInfoTool(objectMapper, fixedClock, mock(HttpClient.class));
var diff = tool.resolveDateDiff(LocalDate.of(2026, 8, 15), LocalDate.of(2026, 8, 12));
assertEquals(-3, diff.calendarDays());
assertEquals(3, diff.absoluteCalendarDays());
}

// --- Day classification: holiday API available ---

@Test
void publicHoliday() {
var tool =
toolWithHolidayApi("{\"code\":0,\"holiday\":{\"holiday\":true,\"name\":\"国庆节\"}}");
var info = tool.resolve("2026-10-01", null, "Asia/Shanghai");
assertEquals("2026-10-01", info.date());
assertEquals("PUBLIC_HOLIDAY", info.dayType());
assertEquals("国庆节", info.holidayName());
assertTrue(info.holidayDataAvailable());
}

@Test
void adjustedWorkday() {
var tool =
toolWithHolidayApi("{\"code\":0,\"holiday\":{\"holiday\":false,\"name\":\"调休\"}}");
var info = tool.resolve("2026-10-10", null, "Asia/Shanghai");
assertEquals("ADJUSTED_WORKDAY", info.dayType());
}

@Test
void regularWorkday() {
var tool =
toolWithHolidayApi(
"{\"code\":0,\"holiday\":null}"); // API returns null holiday for regular
// days
var info = tool.resolve("2026-08-12", null, "Asia/Shanghai");
assertEquals("WORKDAY", info.dayType());
assertNull(info.holidayName());
assertTrue(info.holidayDataAvailable());
}

@Test
void weekend() {
var tool = toolWithHolidayApi("{\"code\":0,\"holiday\":null}");
var info = tool.resolve("2026-08-15", null, "Asia/Shanghai"); // Saturday
assertEquals("WEEKEND", info.dayType());
}

// --- Holiday API unavailable → graceful degradation ---

@Test
@SuppressWarnings("unchecked")
void holidayApiUnavailable_fallsBackToWeekdayClassification() throws Exception {
HttpClient httpClient = mock(HttpClient.class);
HttpResponse<String> mockResponse = mock(HttpResponse.class);
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(mockResponse);
when(mockResponse.statusCode()).thenReturn(503);

var tool = new DateInfoTool(objectMapper, fixedClock, httpClient);
var info = tool.resolve("2026-08-12", null, "Asia/Shanghai");

assertFalse(info.holidayDataAvailable());
assertEquals("WORKDAY", info.dayType());
assertNull(info.holidayName());
}

// --- Resolve defaults to today when start_date omitted ---

@Test
void omittedStartDate_defaultsToToday() {
var tool = toolWithHolidayApi("{\"code\":0,\"holiday\":null}");
var info = tool.resolve(null, null, "Asia/Shanghai");
assertEquals("2026-08-12", info.date()); // fixedClock is 2026-08-12
}

// --- Date diff integrated ---

@Test
void withEndDate_includesDiff() {
var tool = toolWithHolidayApi("{\"code\":0,\"holiday\":null}");
var info = tool.resolve("2026-08-12", "2026-08-15", "Asia/Shanghai");
assertEquals(3, info.dateDiff().calendarDays());
}

// --- helper ---

@SuppressWarnings("unchecked")
private DateInfoTool toolWithHolidayApi(String jsonBody) {
try {
HttpClient httpClient = mock(HttpClient.class);
HttpResponse<String> mockResponse = mock(HttpResponse.class);
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
.thenReturn(mockResponse);
when(mockResponse.statusCode()).thenReturn(200);
when(mockResponse.body()).thenReturn(jsonBody);
return new DateInfoTool(objectMapper, fixedClock, httpClient);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
Loading