@checkstack/ai-backend 0.1.3 → 0.1.5

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.
@@ -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
+ });
@@ -0,0 +1,334 @@
1
+ import { jsonSchema, type Schema } from "ai";
2
+ import { z } from "zod";
3
+
4
+ /** The slice of a JSON Schema node this module reads/writes. */
5
+ interface JsonSchemaNode {
6
+ type?: unknown;
7
+ properties?: Record<string, JsonSchemaNode>;
8
+ items?: JsonSchemaNode | JsonSchemaNode[];
9
+ additionalProperties?: unknown;
10
+ }
11
+
12
+ /**
13
+ * An RFC 3339 / ISO 8601 date-time WITH an explicit timezone designator (`Z` or
14
+ * `±HH:MM` / `±HHMM`). Seconds (and sub-seconds) are optional; the offset is
15
+ * NOT. We require the offset because the only alternative - feeding a zone-less
16
+ * string to `new Date()` - interprets it in the RUNTIME's local zone, so the
17
+ * same model string would resolve to different instants on pods in different
18
+ * timezones (this platform runs N pods sharing one DB). Date-only strings
19
+ * (`2026-07-01`) are likewise rejected: they silently mean UTC midnight and
20
+ * throw away the time the operator asked for. The model is told the reference
21
+ * timezone in the system prompt, so it can always produce an offset.
22
+ */
23
+ const RFC3339_WITH_OFFSET =
24
+ /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:?\d{2})$/;
25
+
26
+ /** Model-facing hint stamped on every date field's JSON Schema description. */
27
+ const DATE_FIELD_HINT =
28
+ "RFC 3339 date-time WITH an explicit timezone offset, e.g. " +
29
+ '"2026-07-01T22:00:00Z" or "2026-07-01T22:00:00+02:00". ' +
30
+ "Zone-less and date-only values are rejected.";
31
+
32
+ /**
33
+ * The single model-boundary date handler. The AI SDK builds the model-facing
34
+ * JSON Schema from a raw Zod schema via Zod v4's `toJSONSchema()` with the
35
+ * default `unrepresentable: "throw"`, which throws "Date cannot be represented
36
+ * in JSON Schema" for `z.date()` AND `z.coerce.date()`. A single date field in
37
+ * any tool input or `generateObject` output would therefore crash the model
38
+ * call (for the chat, before the model is even invoked).
39
+ *
40
+ * For date-bearing schemas we hand the SDK a ready-made schema (so it never
41
+ * runs the throwing converter) plus our own validator:
42
+ * - the model-facing schema renders dates as `{ type: "string", format:
43
+ * "date-time" }` (their wire shape) - matching the SDK's own options
44
+ * (draft-7 / input) so non-date parts are byte-identical to before;
45
+ * - the validator coerces the ISO strings the model emits back into real
46
+ * `Date`s before parsing with the ORIGINAL schema, so refinements and the
47
+ * downstream RPC client (which expects `Date`s) keep working.
48
+ *
49
+ * Apply this at EVERY model-schema boundary (gated by {@link schemaContainsDate})
50
+ * so individual tool / agent definitions never have to special-case dates - the
51
+ * thing that would otherwise regress one tool at a time. Non-date schemas are
52
+ * left as raw Zod so the SDK handles them exactly as it always has.
53
+ */
54
+ export function dateSafeModelSchema(input: z.ZodTypeAny): Schema<unknown> {
55
+ // Cast: bridges Zod's own JSONSchema type to the node shape this module
56
+ // mutates. It is the same JSON Schema object, just a narrower view.
57
+ const modelSchema = z.toJSONSchema(input, {
58
+ target: "draft-7",
59
+ io: "input",
60
+ unrepresentable: "any",
61
+ override: (ctx) => {
62
+ if (ctx.zodSchema instanceof z.ZodDate) {
63
+ ctx.jsonSchema.type = "string";
64
+ ctx.jsonSchema.format = "date-time";
65
+ // Tell the model the exact contract right on the field, so it emits an
66
+ // offset the first time instead of learning via a rejected tool call.
67
+ ctx.jsonSchema.description = ctx.jsonSchema.description
68
+ ? `${ctx.jsonSchema.description} ${DATE_FIELD_HINT}`
69
+ : DATE_FIELD_HINT;
70
+ }
71
+ },
72
+ }) as JsonSchemaNode;
73
+ lockAdditionalProperties(modelSchema);
74
+
75
+ // Cast: the SDK's `jsonSchema()` wants its JSONSchema7 type; the value above
76
+ // is exactly that JSON Schema object (just typed as our narrower view).
77
+ return jsonSchema<unknown>(modelSchema as Parameters<typeof jsonSchema>[0], {
78
+ validate: (value) => {
79
+ // Enforce the offset contract BEFORE parsing. This must run for
80
+ // `z.coerce.date()` too: Zod's own coercion is `new Date()`, which would
81
+ // happily accept a zone-less string and interpret it server-local - the
82
+ // exact ambiguity we reject. A plain Error (not a ZodError) carries the
83
+ // most readable message back to the model for self-repair.
84
+ const violations = collectDateOffsetIssues(value, input);
85
+ if (violations.length > 0) {
86
+ return { success: false, error: new Error(violations.join(" ")) };
87
+ }
88
+ const result = input.safeParse(coerceDateValues(value, input));
89
+ return result.success
90
+ ? { success: true, value: result.data }
91
+ : { success: false, error: result.error };
92
+ },
93
+ });
94
+ }
95
+
96
+ /**
97
+ * The ONE entry point for handing a Zod schema to the model. Date-bearing
98
+ * schemas get the date-safe + coercing treatment ({@link dateSafeModelSchema});
99
+ * everything else passes through as raw Zod so the SDK handles it natively.
100
+ *
101
+ * Call this at EVERY model-schema boundary (chat tool inputs, agent-runner tool
102
+ * inputs, `generateObject` output) so the gate can never be wired into some
103
+ * branches and forgotten in others.
104
+ */
105
+ export function toModelSchema(
106
+ input: z.ZodTypeAny,
107
+ ): Schema<unknown> | z.ZodTypeAny {
108
+ return schemaContainsDate(input) ? dateSafeModelSchema(input) : input;
109
+ }
110
+
111
+ /**
112
+ * Does any node of this schema declare a `Date`? Only such inputs need the
113
+ * special handling above; everything else stays on the SDK's native path.
114
+ */
115
+ export function schemaContainsDate(schema: z.ZodTypeAny): boolean {
116
+ if (schema instanceof z.ZodDate) return true;
117
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
118
+ return schemaContainsDate(schema.unwrap() as z.ZodTypeAny);
119
+ }
120
+ if (schema instanceof z.ZodDefault) {
121
+ return schemaContainsDate(schema.def.innerType as z.ZodTypeAny);
122
+ }
123
+ if (schema instanceof z.ZodArray) {
124
+ return schemaContainsDate(
125
+ (schema as z.ZodArray<z.ZodTypeAny>).element,
126
+ );
127
+ }
128
+ if (schema instanceof z.ZodUnion) {
129
+ return (schema.options as z.ZodTypeAny[]).some((option) =>
130
+ schemaContainsDate(option),
131
+ );
132
+ }
133
+ if (schema instanceof z.ZodRecord) {
134
+ return schemaContainsDate(schema.valueType as z.ZodTypeAny);
135
+ }
136
+ if ("shape" in schema) {
137
+ const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
138
+ return Object.values(shape).some((field) =>
139
+ schemaContainsDate(field as z.ZodTypeAny),
140
+ );
141
+ }
142
+ return false;
143
+ }
144
+
145
+ /**
146
+ * Convert the ISO date strings the model sends into `Date`s at the positions
147
+ * the schema declares as dates. Schema-guided (never touches a plain string
148
+ * field) and value-level (the original schema still validates), so refinements,
149
+ * metadata and unrecognized shapes are preserved untouched.
150
+ */
151
+ export function coerceDateValues(
152
+ value: unknown,
153
+ schema: z.ZodTypeAny,
154
+ ): unknown {
155
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
156
+ return value === null || value === undefined
157
+ ? value
158
+ : coerceDateValues(value, schema.unwrap() as z.ZodTypeAny);
159
+ }
160
+ if (schema instanceof z.ZodDefault) {
161
+ return value === undefined
162
+ ? value
163
+ : coerceDateValues(value, schema.def.innerType as z.ZodTypeAny);
164
+ }
165
+ if (schema instanceof z.ZodDate) {
166
+ // Only convert strings that carry an explicit offset, so the resulting
167
+ // instant is unambiguous regardless of the pod's local timezone. Anything
168
+ // else is left as-is and is rejected upstream by collectDateOffsetIssues
169
+ // (this branch never silently local-interprets a zone-less string).
170
+ return typeof value === "string" && RFC3339_WITH_OFFSET.test(value)
171
+ ? new Date(value)
172
+ : value;
173
+ }
174
+ if (schema instanceof z.ZodArray) {
175
+ if (!Array.isArray(value)) return value;
176
+ const element = (schema as z.ZodArray<z.ZodTypeAny>).element;
177
+ return value.map((item) => coerceDateValues(item, element));
178
+ }
179
+ if (schema instanceof z.ZodUnion) {
180
+ // Coerce against the first option that declares a date; unions of
181
+ // non-overlapping shapes are rare at model boundaries but cheap to support.
182
+ const dateOption = (schema.options as z.ZodTypeAny[]).find((option) =>
183
+ schemaContainsDate(option),
184
+ );
185
+ return dateOption ? coerceDateValues(value, dateOption) : value;
186
+ }
187
+ if (schema instanceof z.ZodRecord) {
188
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
189
+ return value;
190
+ }
191
+ const valueType = schema.valueType as z.ZodTypeAny;
192
+ return Object.fromEntries(
193
+ Object.entries(value as Record<string, unknown>).map(([key, item]) => [
194
+ key,
195
+ coerceDateValues(item, valueType),
196
+ ]),
197
+ );
198
+ }
199
+ if ("shape" in schema) {
200
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
201
+ return value;
202
+ }
203
+ const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
204
+ const out: Record<string, unknown> = {
205
+ ...(value as Record<string, unknown>),
206
+ };
207
+ for (const [key, fieldSchema] of Object.entries(shape)) {
208
+ if (key in out) {
209
+ out[key] = coerceDateValues(out[key], fieldSchema as z.ZodTypeAny);
210
+ }
211
+ }
212
+ return out;
213
+ }
214
+ return value;
215
+ }
216
+
217
+ /**
218
+ * Walk a value against its schema and report every date position whose value is
219
+ * NOT an RFC 3339 date-time with an explicit timezone offset (zone-less,
220
+ * date-only, a bare number, or otherwise unparseable). Mirrors
221
+ * {@link coerceDateValues}' traversal so the gate covers exactly the positions
222
+ * the schema declares as dates. Returns one human-readable sentence per
223
+ * violation (consumed by the model for self-repair); an empty array means the
224
+ * value is offset-clean. `undefined`/`null` at optional positions are skipped -
225
+ * a genuinely missing required date is left to the schema's own parse to report.
226
+ */
227
+ export function collectDateOffsetIssues(
228
+ value: unknown,
229
+ schema: z.ZodTypeAny,
230
+ path: string = "",
231
+ ): string[] {
232
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
233
+ return value === null || value === undefined
234
+ ? []
235
+ : collectDateOffsetIssues(value, schema.unwrap() as z.ZodTypeAny, path);
236
+ }
237
+ if (schema instanceof z.ZodDefault) {
238
+ return value === undefined
239
+ ? []
240
+ : collectDateOffsetIssues(
241
+ value,
242
+ schema.def.innerType as z.ZodTypeAny,
243
+ path,
244
+ );
245
+ }
246
+ if (schema instanceof z.ZodDate) {
247
+ if (value === undefined || value === null) return [];
248
+ const where = path ? `\`${path}\`` : "the date value";
249
+ if (typeof value !== "string") {
250
+ return [
251
+ `${where} must be a ${DATE_FIELD_HINT} (got a ${typeof value}).`,
252
+ ];
253
+ }
254
+ if (!RFC3339_WITH_OFFSET.test(value)) {
255
+ return [`${where} must be a ${DATE_FIELD_HINT} (got "${value}").`];
256
+ }
257
+ if (Number.isNaN(new Date(value).getTime())) {
258
+ return [`${where} is not a valid calendar date-time (got "${value}").`];
259
+ }
260
+ return [];
261
+ }
262
+ if (schema instanceof z.ZodArray) {
263
+ if (!Array.isArray(value)) return [];
264
+ const element = (schema as z.ZodArray<z.ZodTypeAny>).element;
265
+ return value.flatMap((item, i) =>
266
+ collectDateOffsetIssues(item, element, `${path}[${i}]`),
267
+ );
268
+ }
269
+ if (schema instanceof z.ZodUnion) {
270
+ const dateOption = (schema.options as z.ZodTypeAny[]).find((option) =>
271
+ schemaContainsDate(option),
272
+ );
273
+ return dateOption ? collectDateOffsetIssues(value, dateOption, path) : [];
274
+ }
275
+ if (schema instanceof z.ZodRecord) {
276
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
277
+ return [];
278
+ }
279
+ const valueType = schema.valueType as z.ZodTypeAny;
280
+ return Object.entries(value as Record<string, unknown>).flatMap(
281
+ ([key, item]) =>
282
+ collectDateOffsetIssues(
283
+ item,
284
+ valueType,
285
+ path ? `${path}.${key}` : key,
286
+ ),
287
+ );
288
+ }
289
+ if ("shape" in schema) {
290
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
291
+ return [];
292
+ }
293
+ const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
294
+ const record = value as Record<string, unknown>;
295
+ return Object.entries(shape).flatMap(([key, fieldSchema]) =>
296
+ key in record
297
+ ? collectDateOffsetIssues(
298
+ record[key],
299
+ fieldSchema as z.ZodTypeAny,
300
+ path ? `${path}.${key}` : key,
301
+ )
302
+ : [],
303
+ );
304
+ }
305
+ return [];
306
+ }
307
+
308
+ /**
309
+ * Stamp `additionalProperties: false` on every fixed-property object node,
310
+ * mirroring what the SDK's zod adapter does so strict providers (e.g. OpenAI)
311
+ * accept the schema. Records already carry an `additionalProperties` schema, so
312
+ * they are left untouched.
313
+ */
314
+ function lockAdditionalProperties(node: JsonSchemaNode): void {
315
+ if (typeof node !== "object" || node === null) return;
316
+ if (node.properties && node.additionalProperties === undefined) {
317
+ node.additionalProperties = false;
318
+ }
319
+ if (node.properties) {
320
+ for (const child of Object.values(node.properties)) {
321
+ if (typeof child === "object") lockAdditionalProperties(child);
322
+ }
323
+ }
324
+ if (node.items && typeof node.items === "object") {
325
+ const items = node.items;
326
+ if (Array.isArray(items)) {
327
+ for (const item of items) {
328
+ if (typeof item === "object") lockAdditionalProperties(item);
329
+ }
330
+ } else {
331
+ lockAdditionalProperties(items);
332
+ }
333
+ }
334
+ }
@@ -3,6 +3,7 @@ import type { AuthUser } from "@checkstack/backend-api";
3
3
  import type { AiPermissionMode, AiFieldDiff } from "@checkstack/ai-common";
4
4
  import type { RegisteredAiTool } from "../tool-registry";
5
5
  import { decideToolDisposition } from "./permission-mode.logic";
6
+ import { toModelSchema } from "./model-schema";
6
7
 
7
8
  /**
8
9
  * Result a mutate/destructive tool's `execute` returns to the model in APPROVE
@@ -134,45 +135,41 @@ export function buildAgentSdkTools({
134
135
  for (const t of tools) {
135
136
  const disposition = decideToolDisposition({ effect: t.effect, mode });
136
137
 
137
- if (disposition === "auto-run") {
138
- sdkTools[t.name] = aiTool({
139
- description: t.description,
140
- inputSchema: t.input,
141
- execute: async (input: unknown) => {
142
- await callbacks.enforceBudget(principal);
143
- return callbacks.runRead({ principal, tool: t, input });
144
- },
145
- });
146
- continue;
147
- }
138
+ // Single model-boundary date handling (see toModelSchema): a raw z.date() /
139
+ // z.coerce.date() input would otherwise make the SDK's Zod->JSON-Schema
140
+ // conversion throw "Date cannot be represented...", crashing the turn.
141
+ const inputSchema = toModelSchema(t.input);
148
142
 
149
- if (disposition === "auto-apply") {
150
- // AUTO mode + mutate: apply immediately server-side. Same propose/apply
151
- // service (same authz re-check + audit) as a human apply - never weaker.
152
- sdkTools[t.name] = aiTool({
153
- description: `${t.description} (auto-applied immediately in this conversation's auto mode)`,
154
- inputSchema: t.input,
155
- execute: async (
156
- input: unknown,
157
- ): Promise<AutoAppliedResult | DuplicateToolCallResult> => {
158
- await callbacks.enforceBudget(principal);
159
- return callbacks.autoApply({ principal, tool: t, input });
160
- },
161
- });
162
- continue;
163
- }
143
+ // Disposition decides ONLY the description note + which callback runs; the
144
+ // tool is constructed in ONE place below so the schema/budget wiring can
145
+ // never drift between branches (the bug this consolidation removes).
146
+ const variant: { note: string; run: (input: unknown) => Promise<unknown> } =
147
+ disposition === "auto-run"
148
+ ? {
149
+ note: "",
150
+ run: (input) => callbacks.runRead({ principal, tool: t, input }),
151
+ }
152
+ : disposition === "auto-apply"
153
+ ? {
154
+ // AUTO mode + mutate: apply immediately server-side under the SAME
155
+ // propose/apply service (authz re-check + audit) as a human apply.
156
+ note: " (auto-applied immediately in this conversation's auto mode)",
157
+ run: (input) =>
158
+ callbacks.autoApply({ principal, tool: t, input }),
159
+ }
160
+ : {
161
+ // propose: mutate-in-APPROVE or ANY destructive tool. Returns a
162
+ // confirm card; nothing commits until the human applies.
163
+ note: " (requires human confirmation before it takes effect)",
164
+ run: (input) => callbacks.propose({ principal, tool: t, input }),
165
+ };
164
166
 
165
- // disposition === "propose": mutate-in-APPROVE or ANY destructive tool. The
166
- // returned confirm card is what the chat UI renders; nothing is committed
167
- // until the human applies.
168
167
  sdkTools[t.name] = aiTool({
169
- description: `${t.description} (requires human confirmation before it takes effect)`,
170
- inputSchema: t.input,
171
- execute: async (
172
- input: unknown,
173
- ): Promise<ConfirmCardResult | DuplicateToolCallResult> => {
168
+ description: `${t.description}${variant.note}`,
169
+ inputSchema,
170
+ execute: async (input: unknown) => {
174
171
  await callbacks.enforceBudget(principal);
175
- return callbacks.propose({ principal, tool: t, input });
172
+ return variant.run(input);
176
173
  },
177
174
  });
178
175
  }