@checkstack/ai-backend 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # @checkstack/ai-backend
2
2
 
3
+ ## 0.1.4
4
+
5
+ ### Patch Changes
6
+
7
+ - b50916d: Fix "Date cannot be represented in JSON Schema" crashing the AI chat. Zod v4's
8
+ `toJSONSchema()` throws on `z.date()` (and even `z.coerce.date()`) by default,
9
+ and the chat hit this in TWO places:
10
+
11
+ - **`@checkstack/backend-api`** `toJsonSchema()` (the OpenAPI generator and AI
12
+ tool-introspection / MCP substrate) called it with no options.
13
+ - **`@checkstack/ai-backend`** the agent loop hands the Vercel AI SDK the raw
14
+ Zod tool input, and the SDK runs its OWN `toJSONSchema()` (throwing) to build
15
+ the model-facing tool schema - so a single date field in any tool input
16
+ crashed every chat turn (the whole tool list is projected before the model is
17
+ called).
18
+
19
+ Both now render dates as `{ type: "string", format: "date-time" }` (their wire
20
+ shape) and degrade other unrepresentable types to `{}` instead of throwing.
21
+
22
+ For the model boundary, a single `dateSafeModelSchema()` helper hands the SDK a
23
+ ready-made date-safe schema plus a validator that COERCES the ISO strings the
24
+ model emits back into real `Date`s before parsing with the original schema
25
+ (refinements and the downstream RPC client, which expects `Date`s, keep
26
+ working). A single `toModelSchema()` entry point applies this at EVERY point a
27
+ schema is handed to the model - chat tool inputs, the headless agent runner's
28
+ tool inputs (the automation "AI Action"), and `generateObject` structured
29
+ output - gated so non-date schemas are untouched, so individual tool / agent
30
+ definitions never special-case dates. Regression tests cover the converter, the
31
+ AI tool serializer, and the model-schema generation + coercion helper, including
32
+ the full inbound round-trip with the exact ISO shape a live model emits
33
+ (`...T22:00:00Z`, no milliseconds).
34
+
35
+ **Timezone correctness.** Because the model produces dates as text, the chat now
36
+ enforces an unambiguous wire contract: a date-time tool argument MUST be RFC 3339
37
+ with an explicit timezone offset. Zone-less (`2026-07-01T22:00:00`) and date-only
38
+ (`2026-07-01`) values are rejected with a model-readable error (the model
39
+ self-repairs), instead of being silently interpreted in the pod's local zone -
40
+ which would resolve the same string to different instants across pods. To resolve
41
+ an operator's bare "22:00", the browser's IANA timezone is sent with every chat
42
+ turn and folded into the system prompt, so each operator's times are interpreted
43
+ in their own zone by default. When no browser zone is available (a headless
44
+ automation AI Action), the reference zone falls back to the host/container
45
+ timezone (`TZ`), not UTC. A format-matrix test covers every common shape a model
46
+ might emit. The chat UI shows the operator which timezone is in use, and the
47
+ `TZ` override is documented for operators.
48
+
49
+ **Current time in context.** The model has no clock, so the system prompt now
50
+ includes the current instant (UTC plus the reference-zone wall clock), letting it
51
+ resolve relative dates like "today at 10:00" without asking. Applied to both the
52
+ chat and the headless agent runner, computed per turn/run so it is never stale.
53
+
54
+ **Less-strict topic classifier.** The chat's off-topic pre-classifier was
55
+ refusing legitimate requests like "create a maintenance" because maintenances
56
+ (and several other domains) were not listed. The classifier now enumerates the
57
+ full domain set and treats any create/list/update/delete action on a platform
58
+ resource as on-topic by default.
59
+
60
+ - Updated dependencies [b50916d]
61
+ - @checkstack/backend-api@0.21.4
62
+ - @checkstack/integration-backend@0.4.4
63
+
3
64
  ## 0.1.3
4
65
 
5
66
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/ai-backend",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -17,11 +17,11 @@
17
17
  "dependencies": {
18
18
  "@ai-sdk/openai-compatible": "^2.0.48",
19
19
  "@checkstack/ai-common": "0.1.2",
20
- "@checkstack/backend-api": "0.21.3",
20
+ "@checkstack/backend-api": "0.21.4",
21
21
  "@checkstack/common": "0.14.1",
22
22
  "@checkstack/drizzle-helper": "0.0.5",
23
- "@checkstack/integration-backend": "0.4.3",
24
- "@checkstack/sdk": "0.98.1",
23
+ "@checkstack/integration-backend": "0.4.4",
24
+ "@checkstack/sdk": "0.99.0",
25
25
  "@orpc/client": "^1.14.4",
26
26
  "@orpc/contract": "^1.14.4",
27
27
  "@orpc/server": "^1.14.4",
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it, mock } from "bun:test";
2
+ import { asSchema } from "ai";
2
3
  import { z } from "zod";
3
4
  import type { AuthUser, RpcClient } from "@checkstack/backend-api";
4
5
  import type { OpenAiCompatibleConnection } from "@checkstack/ai-common";
@@ -108,6 +109,55 @@ describe("createAgentRunner", () => {
108
109
  expect(result.toolCalls).toEqual([{ tool: "plugin.read", ok: true }]);
109
110
  });
110
111
 
112
+ it("hands the model a date-safe schema for tools with Date inputs (no throw)", async () => {
113
+ // Regression: the AI Action (headless agent runner) builds its OWN tools.
114
+ // A `z.date()` input would make the SDK's Zod->JSON-Schema conversion throw
115
+ // "Date cannot be represented...", crashing the action - the same bug as the
116
+ // chat. The runner must gate date inputs through dateSafeModelSchema too.
117
+ const registry = createAiToolRegistry();
118
+ registry.register({
119
+ name: "plugin.history",
120
+ description: "history",
121
+ effect: "read",
122
+ input: z.object({ since: z.date() }),
123
+ requiredAccessRules: [],
124
+ execute: async () => ({ ok: true }),
125
+ } as RegisteredAiTool);
126
+ const resolver = createAiToolResolver({ registry });
127
+
128
+ let offeredSchema: unknown;
129
+ const generateText = mock(
130
+ async (args: {
131
+ tools?: Record<string, { inputSchema: unknown }>;
132
+ }) => {
133
+ const t = (args.tools ?? {})["plugin.history"];
134
+ // Exactly what the SDK does internally to build the model request; this
135
+ // threw before the fix.
136
+ offeredSchema = await asSchema(t.inputSchema as never).jsonSchema;
137
+ return { text: "ok", usage: {} };
138
+ },
139
+ );
140
+
141
+ const runner = createAgentRunner({
142
+ resolver,
143
+ resolveConnection: async () => connection,
144
+ modelFns: { generateText: generateText as never },
145
+ });
146
+
147
+ await runner({
148
+ principal,
149
+ rpcClient,
150
+ connectionId: "conn-1",
151
+ prompt: "go",
152
+ });
153
+
154
+ const props = (
155
+ offeredSchema as { properties: Record<string, Record<string, unknown>> }
156
+ ).properties;
157
+ expect(props.since?.type).toBe("string");
158
+ expect(props.since?.format).toBe("date-time");
159
+ });
160
+
111
161
  it("offers a projected read tool and routes it through the principal's client", async () => {
112
162
  const registry = createAiToolRegistry();
113
163
  registry.register({
@@ -30,6 +30,8 @@ import {
30
30
  type LanguageModel,
31
31
  } from "ai";
32
32
  import { z } from "zod";
33
+ import { toModelSchema } from "./chat/model-schema";
34
+ import { buildDateTimeContext } from "./chat/system-prompt";
33
35
  import {
34
36
  createServiceRef,
35
37
  type AuthUser,
@@ -201,7 +203,8 @@ export function createAgentRunner({
201
203
 
202
204
  sdkTools[t.name] = aiTool({
203
205
  description: t.description,
204
- inputSchema: t.input as z.ZodType,
206
+ // Single model-boundary date handling, same as the chat tool path.
207
+ inputSchema: toModelSchema(t.input as z.ZodType),
205
208
  execute: async (input: unknown) => {
206
209
  try {
207
210
  const result = await invoke(input);
@@ -237,9 +240,13 @@ export function createAgentRunner({
237
240
  });
238
241
  }
239
242
 
243
+ // Append the date/time context at call time (NOT module load) so the model
244
+ // gets the CURRENT instant and the host-zone wire contract. Headless: no
245
+ // operator, so the reference zone is the host/container TZ.
246
+ const dateContext = buildDateTimeContext({ audience: "headless" });
240
247
  const { text } = await gen({
241
248
  model: languageModel,
242
- system: systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
249
+ system: `${systemPrompt ?? DEFAULT_SYSTEM_PROMPT} ${dateContext}`,
243
250
  prompt,
244
251
  tools: sdkTools,
245
252
  stopWhen: stepCountIs(maxSteps ?? DEFAULT_MAX_STEPS),
@@ -249,7 +256,10 @@ export function createAgentRunner({
249
256
  if (outputSchema) {
250
257
  const res = await genObj({
251
258
  model: languageModel,
252
- schema: outputSchema,
259
+ // Same single model-boundary date handling as the tool path: the
260
+ // structured-output schema's dates must serialize AND the model's ISO
261
+ // strings coerce back to Date.
262
+ schema: toModelSchema(outputSchema),
253
263
  system:
254
264
  "Produce the structured result from the analysis below. Use only information present in it; do not invent values.",
255
265
  prompt: `Task: ${prompt}\n\n--- Analysis ---\n${text}`,
@@ -10,6 +10,8 @@ const ChatTurnBodySchema = z.object({
10
10
  connectionId: z.string(),
11
11
  model: z.string().optional(),
12
12
  message: z.string().min(1),
13
+ /** Browser IANA timezone, used to resolve bare times the operator types. */
14
+ timeZone: z.string().optional(),
13
15
  });
14
16
 
15
17
  /**
@@ -25,6 +27,8 @@ const ChatDecisionBodySchema = z.object({
25
27
  token: z.string().min(1),
26
28
  kind: z.enum(["apply", "decline"]),
27
29
  }),
30
+ /** Browser IANA timezone, used to resolve bare times the operator types. */
31
+ timeZone: z.string().optional(),
28
32
  });
29
33
 
30
34
  /** A /chat POST is either a new user turn or a confirm-card decision turn. */
@@ -91,6 +95,7 @@ export function createChatRequestHandler({
91
95
  forwardHeaders,
92
96
  token: body.decision.token,
93
97
  decision: body.decision.kind,
98
+ timeZone: body.timeZone,
94
99
  });
95
100
  }
96
101
  return await chatService.streamTurn({
@@ -100,6 +105,7 @@ export function createChatRequestHandler({
100
105
  model: body.model,
101
106
  forwardHeaders,
102
107
  userText: body.message,
108
+ timeZone: body.timeZone,
103
109
  });
104
110
  } catch (error) {
105
111
  return Response.json(
@@ -41,6 +41,7 @@ import {
41
41
  type AgentToolCallbacks,
42
42
  } from "./sdk-tools";
43
43
  import type { ChatReadInvoker } from "./read-invoker";
44
+ import { buildChatSystemPrompt } from "./system-prompt";
44
45
  import { createUserScopedRpcClient } from "../user-rpc-client";
45
46
 
46
47
  type AiDatabase = SafeDatabase<typeof schema>;
@@ -200,6 +201,8 @@ export interface ChatTurnInput {
200
201
  forwardHeaders: Record<string, string>;
201
202
  /** The user's new message text. */
202
203
  userText: string;
204
+ /** The operator's IANA timezone (browser-detected) for resolving bare times. */
205
+ timeZone?: string;
203
206
  }
204
207
 
205
208
  /**
@@ -220,25 +223,10 @@ export interface ChatDecisionInput {
220
223
  token: string;
221
224
  /** Whether the operator applied or declined the card. */
222
225
  decision: DecisionKind;
226
+ /** The operator's IANA timezone (browser-detected) for resolving bare times. */
227
+ timeZone?: string;
223
228
  }
224
229
 
225
- const SYSTEM_PROMPT =
226
- "You are Checkstack's built-in assistant. You ONLY help operators run " +
227
- "Checkstack: incidents, health checks, anomalies, automations, and the " +
228
- "monitoring and operations of THIS platform. Use the provided tools to read " +
229
- "live data. For any change to the platform, call the appropriate tool: " +
230
- "depending on the conversation's permission mode it either returns a " +
231
- "confirmation card the operator must approve, or applies immediately and " +
232
- "returns the applied result. Never claim a change took effect until the tool " +
233
- "result confirms it (an applied result, or the operator approving the card). " +
234
- "Call each change tool ONCE per request: a confirm-card result means the " +
235
- "proposal succeeded and is awaiting the operator - do NOT call the tool again " +
236
- "to retry; just tell the operator you are waiting for their decision. " +
237
- "Politely DECLINE anything unrelated to operating Checkstack " +
238
- "(general coding help, writing, or general knowledge) with a one-line " +
239
- "redirect back to Checkstack monitoring and operations. Be concise and " +
240
- "engineering-focused.";
241
-
242
230
  /** Max agent steps (tool-call round trips) per turn. */
243
231
  const MAX_STEPS = 8;
244
232
 
@@ -532,6 +520,7 @@ export function createChatService({
532
520
  languageModel,
533
521
  recordUsage,
534
522
  modelMessages,
523
+ timeZone,
535
524
  }: {
536
525
  principal: AuthUser;
537
526
  conversation: { permissionMode: AiPermissionMode };
@@ -541,6 +530,8 @@ export function createChatService({
541
530
  languageModel: ReturnType<typeof buildLanguageModel>;
542
531
  recordUsage: (usage: LanguageModelUsage) => Promise<void>;
543
532
  modelMessages: ModelMessage[];
533
+ /** The operator's IANA timezone (from the browser), folded into the prompt. */
534
+ timeZone?: string;
544
535
  }): Response => {
545
536
  // Build the SDK tools from the resolver-allowed set only. The model is never
546
537
  // offered a tool the principal cannot use. Tool callbacks (budget + audit +
@@ -568,7 +559,7 @@ export function createChatService({
568
559
 
569
560
  const result = streamText({
570
561
  model: languageModel,
571
- system: SYSTEM_PROMPT,
562
+ system: buildChatSystemPrompt({ timeZone }),
572
563
  // Defensively normalize: drop empty-content rows and merge consecutive
573
564
  // same-role messages so a failed prior turn (which persists no assistant
574
565
  // reply, leaving consecutive `user` rows) cannot poison the history into a
@@ -680,6 +671,7 @@ export function createChatService({
680
671
  model,
681
672
  forwardHeaders,
682
673
  userText,
674
+ timeZone,
683
675
  } = input;
684
676
 
685
677
  // Ownership: the conversation MUST belong to the principal.
@@ -810,6 +802,7 @@ export function createChatService({
810
802
  languageModel,
811
803
  recordUsage,
812
804
  modelMessages,
805
+ timeZone,
813
806
  });
814
807
  },
815
808
 
@@ -831,6 +824,7 @@ export function createChatService({
831
824
  forwardHeaders,
832
825
  token,
833
826
  decision,
827
+ timeZone,
834
828
  } = input;
835
829
 
836
830
  const conversation = await loadOwnedConversation({
@@ -915,6 +909,7 @@ export function createChatService({
915
909
  languageModel,
916
910
  recordUsage,
917
911
  modelMessages,
912
+ timeZone,
918
913
  });
919
914
  },
920
915
  };
@@ -48,6 +48,17 @@ describe("buildClassifierPrompt", () => {
48
48
  expect(system).toMatch(/clearly unrelated|CLEARLY unrelated/i);
49
49
  });
50
50
 
51
+ test("system prompt names maintenances and a CRUD-action allowance as ON_TOPIC", () => {
52
+ // Regression for the real bug: "Create a maintenance" was refused because
53
+ // maintenances were not listed and there was no generic action allowance.
54
+ const { system } = buildClassifierPrompt({
55
+ userText: "Create a maintenance",
56
+ });
57
+ expect(system.toLowerCase()).toContain("maintenance");
58
+ // Any create/list/update/delete request must be ON_TOPIC by default.
59
+ expect(system).toMatch(/create[^.]*list[^.]*update[^.]*delete/i);
60
+ });
61
+
51
62
  test("system prompt retains the 'when in doubt' ON_TOPIC default", () => {
52
63
  const { system } = buildClassifierPrompt({ userText: "???" });
53
64
  expect(system).toMatch(/when in doubt.*on_topic/i);
@@ -19,18 +19,25 @@ export type ClassifierVerdict = "ON_TOPIC" | "OFF_TOPIC";
19
19
  * against any decoration regardless.
20
20
  */
21
21
  const CLASSIFIER_SYSTEM_PROMPT =
22
- "You are a topical classifier for Checkstack, an incident, health-check, " +
23
- "anomaly, automation, and monitoring/operations platform. Decide whether the " +
22
+ "You are a topical classifier for Checkstack, an operations platform covering " +
23
+ "incidents, health checks, anomalies, automations, maintenances/maintenance " +
24
+ "windows, dependencies, systems and services, notifications, SLOs, " +
25
+ "integrations, on-call, and general monitoring/operations. Decide whether the " +
24
26
  "user's message is ON_TOPIC or OFF_TOPIC. " +
25
- "ON_TOPIC includes: operating or reasoning about Checkstack (incidents, " +
26
- "health checks, anomalies, automations, monitoring, on-call, the platform's " +
27
- "data and configuration); meta/capability questions about the assistant itself " +
28
- "(\"what can you do\", \"who are you\", \"help\", \"what features do you have\"); " +
29
- "greetings and conversational openers (\"hi\", \"hello\", \"hey\"); " +
27
+ "ON_TOPIC includes: operating or reasoning about Checkstack or any of its " +
28
+ "resources and configuration; meta/capability questions about the assistant " +
29
+ "itself (\"what can you do\", \"who are you\", \"help\", \"what features do you " +
30
+ "have\"); greetings and conversational openers (\"hi\", \"hello\", \"hey\"); " +
30
31
  "how-to or conceptual questions about using Checkstack features or workflows " +
31
32
  "(\"how do health checks work\", \"how do I create an automation\"). " +
32
- "OFF_TOPIC means CLEARLY unrelated requests: general coding help unrelated to " +
33
- "Checkstack, creative writing, and general trivia or knowledge questions. " +
33
+ "IMPORTANT: any request to create, add, list, show, view, find, update, edit, " +
34
+ "schedule, start, stop, resolve, acknowledge, or delete something is ON_TOPIC " +
35
+ "by default - it is almost certainly an action on a platform resource (e.g. " +
36
+ "\"create a maintenance\", \"list incidents\", \"schedule downtime\"), EVEN IF " +
37
+ "the resource type is not named in the list above. " +
38
+ "OFF_TOPIC means ONLY requests that are CLEARLY unrelated to operating this " +
39
+ "platform: general-purpose coding help, creative writing, math homework, and " +
40
+ "general trivia or knowledge questions. " +
34
41
  "When in doubt, reply ON_TOPIC. Reply with the token only.";
35
42
 
36
43
  /**
@@ -0,0 +1,264 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { tool as aiTool, asSchema } from "ai";
3
+ import { z } from "zod";
4
+ import {
5
+ dateSafeModelSchema,
6
+ coerceDateValues,
7
+ collectDateOffsetIssues,
8
+ schemaContainsDate,
9
+ toModelSchema,
10
+ } from "./model-schema";
11
+
12
+ describe("schemaContainsDate", () => {
13
+ test("detects dates in object / array / optional / coerce positions", () => {
14
+ expect(schemaContainsDate(z.object({ at: z.date() }))).toBe(true);
15
+ expect(schemaContainsDate(z.object({ at: z.date().optional() }))).toBe(true);
16
+ expect(schemaContainsDate(z.object({ at: z.coerce.date() }))).toBe(true);
17
+ expect(schemaContainsDate(z.object({ seen: z.array(z.date()) }))).toBe(true);
18
+ expect(
19
+ schemaContainsDate(z.object({ d: z.date() }).refine(() => true)),
20
+ ).toBe(true);
21
+ });
22
+
23
+ test("returns false when there is no date", () => {
24
+ expect(
25
+ schemaContainsDate(z.object({ name: z.string(), n: z.number() })),
26
+ ).toBe(false);
27
+ });
28
+ });
29
+
30
+ describe("coerceDateValues", () => {
31
+ test("coerces ISO strings to Date only at date positions", () => {
32
+ const schema = z.object({ at: z.date(), name: z.string() });
33
+ const out = coerceDateValues(
34
+ { at: "2026-01-02T03:04:05.000Z", name: "2026-01-02T03:04:05.000Z" },
35
+ schema,
36
+ ) as { at: unknown; name: unknown };
37
+ expect(out.at).toBeInstanceOf(Date);
38
+ // A string field that merely looks like a date is left a string.
39
+ expect(out.name).toBe("2026-01-02T03:04:05.000Z");
40
+ });
41
+
42
+ test("recurses arrays and optionals", () => {
43
+ const schema = z.object({
44
+ seen: z.array(z.date()),
45
+ at: z.date().optional(),
46
+ });
47
+ const out = coerceDateValues(
48
+ { seen: ["2026-01-02T00:00:00.000Z"], at: undefined },
49
+ schema,
50
+ ) as { seen: unknown[]; at: unknown };
51
+ expect(out.seen[0]).toBeInstanceOf(Date);
52
+ expect(out.at).toBeUndefined();
53
+ });
54
+ });
55
+
56
+ describe("dateSafeModelSchema", () => {
57
+ // The core regression: the AI SDK would throw "Date cannot be represented in
58
+ // JSON Schema" building the model-facing schema for these inputs.
59
+ test("produces a date-time string schema without throwing", async () => {
60
+ const schema = dateSafeModelSchema(
61
+ z.object({ id: z.string(), createdAt: z.date() }),
62
+ );
63
+ const js = (await schema.jsonSchema) as {
64
+ properties: Record<string, Record<string, unknown>>;
65
+ additionalProperties?: unknown;
66
+ };
67
+ expect(js.properties.createdAt?.type).toBe("string");
68
+ expect(js.properties.createdAt?.format).toBe("date-time");
69
+ // The model is told the offset contract right on the field.
70
+ expect(String(js.properties.createdAt?.description)).toContain(
71
+ "explicit timezone offset",
72
+ );
73
+ // Strict-provider friendly (matches the SDK's own zod adapter).
74
+ expect(js.additionalProperties).toBe(false);
75
+ });
76
+
77
+ test("validator coerces the model's ISO string into a Date", async () => {
78
+ const schema = dateSafeModelSchema(z.object({ at: z.date() }));
79
+ const result = await schema.validate?.({ at: "2026-01-02T03:04:05.000Z" });
80
+ expect(result?.success).toBe(true);
81
+ if (result?.success) {
82
+ expect((result.value as { at: Date }).at).toBeInstanceOf(Date);
83
+ }
84
+ });
85
+
86
+ test("validator preserves the original schema's refinement", async () => {
87
+ const schema = dateSafeModelSchema(
88
+ z
89
+ .object({ startAt: z.coerce.date(), endAt: z.coerce.date() })
90
+ .refine((v) => v.endAt > v.startAt, { message: "endAt after startAt" }),
91
+ );
92
+ const bad = await schema.validate?.({
93
+ startAt: "2026-01-02T00:00:00.000Z",
94
+ endAt: "2026-01-01T00:00:00.000Z",
95
+ });
96
+ expect(bad?.success).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe("date format matrix (the wire contract)", () => {
101
+ // Run every case through BOTH a raw `z.date()` (which exercises OUR coercion)
102
+ // and a `z.coerce.date()` (whose own `new Date()` coercion is lenient and
103
+ // MUST still be gated). A model can emit any of these shapes; the contract is:
104
+ // only an RFC 3339 date-time WITH an explicit offset is accepted, and it maps
105
+ // to the one unambiguous instant. Zone-less, date-only, numeric and garbage
106
+ // values are rejected so the model self-repairs instead of us guessing a zone.
107
+ const schemas = {
108
+ "z.date()": z.object({ at: z.date() }),
109
+ "z.coerce.date()": z.object({ at: z.coerce.date() }),
110
+ };
111
+
112
+ // Offset-bearing inputs and the single UTC instant they must resolve to.
113
+ // Deterministic regardless of the machine's local timezone (each carries Z or
114
+ // an explicit offset), so the exact ISO is safe to assert in CI.
115
+ const accepted: Array<[input: string, iso: string]> = [
116
+ ["2026-07-01T22:00:00.000Z", "2026-07-01T22:00:00.000Z"],
117
+ ["2026-07-01T22:00:00Z", "2026-07-01T22:00:00.000Z"],
118
+ ["2026-07-01T22:00Z", "2026-07-01T22:00:00.000Z"], // no seconds
119
+ ["2026-07-01T22:00:00.123Z", "2026-07-01T22:00:00.123Z"], // sub-seconds
120
+ ["2026-07-01T22:00:00+00:00", "2026-07-01T22:00:00.000Z"],
121
+ ["2026-07-01T22:00:00+02:00", "2026-07-01T20:00:00.000Z"],
122
+ ["2026-07-01T22:00:00-05:00", "2026-07-02T03:00:00.000Z"],
123
+ ["2026-07-01T22:00:00+0200", "2026-07-01T20:00:00.000Z"], // offset w/o colon
124
+ ];
125
+
126
+ // Rejected: zone-less (would be interpreted server-local), date-only (drops
127
+ // the time), non-ISO human forms, and outright garbage.
128
+ const rejected = [
129
+ "2026-07-01T22:00:00", // no offset
130
+ "2026-07-01 22:00:00", // space + no offset
131
+ "2026-07-01", // date only
132
+ "2026/07/01", // slashes
133
+ "July 1, 2026", // human
134
+ "Wed, 01 Jul 2026 22:00:00 GMT", // RFC 1123 (no offset designator we accept)
135
+ "2026-13-01T00:00:00Z", // matches the offset shape but is not a real date
136
+ "not a date",
137
+ "",
138
+ "tomorrow",
139
+ ];
140
+
141
+ for (const [label, schema] of Object.entries(schemas)) {
142
+ for (const [input, iso] of accepted) {
143
+ test(`${label}: accepts "${input}" -> ${iso}`, async () => {
144
+ const result = await dateSafeModelSchema(schema).validate?.({
145
+ at: input,
146
+ });
147
+ expect(result?.success).toBe(true);
148
+ if (result?.success) {
149
+ const at = (result.value as { at: Date }).at;
150
+ expect(at).toBeInstanceOf(Date);
151
+ expect(at.toISOString()).toBe(iso);
152
+ }
153
+ });
154
+ }
155
+
156
+ for (const input of rejected) {
157
+ test(`${label}: rejects ${JSON.stringify(input)}`, async () => {
158
+ const result = await dateSafeModelSchema(schema).validate?.({
159
+ at: input,
160
+ });
161
+ expect(result?.success).toBe(false);
162
+ });
163
+ }
164
+
165
+ test(`${label}: rejects a bare epoch number`, async () => {
166
+ const result = await dateSafeModelSchema(schema).validate?.({
167
+ at: 1782000000000,
168
+ });
169
+ expect(result?.success).toBe(false);
170
+ });
171
+ }
172
+
173
+ test("rejection message names the field and the offset requirement", () => {
174
+ const issues = collectDateOffsetIssues(
175
+ { startAt: "2026-07-01T22:00:00" },
176
+ z.object({ startAt: z.date() }),
177
+ );
178
+ expect(issues).toHaveLength(1);
179
+ expect(issues[0]).toContain("startAt");
180
+ expect(issues[0]).toContain("explicit timezone offset");
181
+ });
182
+
183
+ test("a regex-shaped but impossible date reports an invalid-date message", () => {
184
+ const issues = collectDateOffsetIssues(
185
+ { at: "2026-13-01T00:00:00Z" },
186
+ z.object({ at: z.date() }),
187
+ );
188
+ expect(issues[0]).toContain("not a valid calendar date-time");
189
+ });
190
+
191
+ test("nested arrays and optionals are gated too", () => {
192
+ const schema = z.object({
193
+ windows: z.array(z.object({ at: z.date() })),
194
+ maybe: z.date().optional(),
195
+ });
196
+ const issues = collectDateOffsetIssues(
197
+ { windows: [{ at: "2026-07-01" }], maybe: "2026-07-01T00:00:00" },
198
+ schema,
199
+ );
200
+ expect(issues).toHaveLength(2);
201
+ expect(issues.some((m) => m.includes("windows[0].at"))).toBe(true);
202
+ expect(issues.some((m) => m.includes("maybe"))).toBe(true);
203
+ });
204
+
205
+ test("an absent optional date is not flagged", () => {
206
+ expect(
207
+ collectDateOffsetIssues({}, z.object({ at: z.date().optional() })),
208
+ ).toEqual([]);
209
+ });
210
+ });
211
+
212
+ describe("toModelSchema (the single boundary entry)", () => {
213
+ test("returns the raw Zod schema when there is no date", () => {
214
+ const schema = z.object({ q: z.string() });
215
+ expect(toModelSchema(schema)).toBe(schema);
216
+ });
217
+
218
+ test("returns a date-safe Schema when a date is present", () => {
219
+ const schema = z.object({ at: z.date() });
220
+ expect(toModelSchema(schema)).not.toBe(schema);
221
+ });
222
+
223
+ // The full inbound round-trip exactly as the AI SDK runtime drives it: the
224
+ // model emits an object with an ISO date STRING, the tool's inputSchema
225
+ // validates it, and `execute` is called with the validated value. We assert
226
+ // `execute` receives a real `Date` - i.e. the model can create date-bearing
227
+ // objects and they are parsed back to Date in our backend. Uses a raw
228
+ // `z.date()` (not coerce.date) so this proves OUR coercion, not Zod's.
229
+ //
230
+ // The input string is the EXACT shape a real model emits, captured from a
231
+ // live deepseek-v4-flash maintenance-window creation: ISO 8601 with a `Z`
232
+ // offset and NO milliseconds (`...T22:00:00Z`, not `...T22:00:00.000Z`). The
233
+ // less-precise form is what providers actually return, so the test asserts
234
+ // `new Date()` normalizes it to a real Date with the milliseconds filled in.
235
+ test("model's ISO date object (no millis) is parsed to a real Date for execute", async () => {
236
+ const schema = z.object({ startAt: z.date(), label: z.string() });
237
+ let received: { startAt: unknown; label: unknown } | undefined;
238
+ const t = aiTool({
239
+ inputSchema: toModelSchema(schema) as never,
240
+ execute: async (input: unknown) => {
241
+ received = input as { startAt: unknown; label: unknown };
242
+ return { ok: true };
243
+ },
244
+ });
245
+
246
+ const validated = await asSchema(t.inputSchema).validate?.({
247
+ startAt: "2026-07-01T22:00:00Z",
248
+ label: "window",
249
+ });
250
+ expect(validated?.success).toBe(true);
251
+ if (validated?.success) {
252
+ await t.execute?.(validated.value, {
253
+ toolCallId: "call-1",
254
+ messages: [],
255
+ });
256
+ }
257
+
258
+ expect(received?.startAt).toBeInstanceOf(Date);
259
+ expect((received?.startAt as Date).toISOString()).toBe(
260
+ "2026-07-01T22:00:00.000Z",
261
+ );
262
+ expect(received?.label).toBe("window");
263
+ });
264
+ });