@agent-native/core 0.98.8 → 0.98.10

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.
Files changed (52) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/a2a/handlers.ts +39 -24
  5. package/corpus/core/src/agent/durable-background.ts +6 -0
  6. package/corpus/core/src/mcp/build-server.ts +104 -26
  7. package/corpus/core/src/server/agent-chat/plugin-options.ts +3 -1
  8. package/corpus/templates/analytics/server/plugins/agent-chat.ts +4 -0
  9. package/corpus/templates/calendar/.agents/skills/event-management/SKILL.md +31 -2
  10. package/corpus/templates/calendar/AGENTS.md +11 -0
  11. package/corpus/templates/calendar/README.md +2 -1
  12. package/corpus/templates/calendar/actions/create-event.ts +7 -6
  13. package/corpus/templates/calendar/actions/event-action-helpers.ts +49 -0
  14. package/corpus/templates/calendar/actions/update-event.ts +225 -14
  15. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +185 -70
  16. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +26 -4
  17. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +112 -69
  18. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +621 -524
  19. package/corpus/templates/calendar/app/components/calendar/WeekView.tsx +275 -164
  20. package/corpus/templates/calendar/app/components/calendar/WorkingLocationEditor.tsx +222 -0
  21. package/corpus/templates/calendar/app/hooks/use-events.ts +151 -79
  22. package/corpus/templates/calendar/app/i18n/zh-TW.ts +6 -0
  23. package/corpus/templates/calendar/app/i18n-data.ts +60 -0
  24. package/corpus/templates/calendar/app/lib/all-day-layout.ts +126 -0
  25. package/corpus/templates/calendar/app/lib/event-form-utils.ts +18 -1
  26. package/corpus/templates/calendar/app/lib/event-mutation-inputs.ts +1 -1
  27. package/corpus/templates/calendar/app/lib/working-location.ts +163 -0
  28. package/corpus/templates/calendar/app/pages/CalendarView.tsx +21 -2
  29. package/corpus/templates/calendar/changelog/2026-07-07-working-locations-from-google-calendar-now-appear-as-native-.md +6 -0
  30. package/corpus/templates/calendar/server/lib/calendar-availability.ts +2 -0
  31. package/corpus/templates/calendar/server/lib/google-api.ts +6 -1
  32. package/corpus/templates/calendar/server/lib/google-calendar.ts +49 -19
  33. package/corpus/templates/calendar/shared/api.ts +2 -0
  34. package/dist/a2a/handlers.d.ts.map +1 -1
  35. package/dist/a2a/handlers.js +37 -24
  36. package/dist/a2a/handlers.js.map +1 -1
  37. package/dist/agent/durable-background.d.ts.map +1 -1
  38. package/dist/agent/durable-background.js +7 -0
  39. package/dist/agent/durable-background.js.map +1 -1
  40. package/dist/collab/routes.d.ts +1 -1
  41. package/dist/mcp/build-server.d.ts.map +1 -1
  42. package/dist/mcp/build-server.js +90 -28
  43. package/dist/mcp/build-server.js.map +1 -1
  44. package/dist/notifications/routes.d.ts +3 -3
  45. package/dist/observability/routes.d.ts +5 -5
  46. package/dist/secrets/routes.d.ts +9 -9
  47. package/dist/server/agent-chat/plugin-options.d.ts +3 -1
  48. package/dist/server/agent-chat/plugin-options.d.ts.map +1 -1
  49. package/dist/server/agent-chat/plugin-options.js.map +1 -1
  50. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  51. package/dist/server/transcribe-voice.d.ts +1 -1
  52. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2239
31
- - template files: 5508
31
+ - template files: 5512
@@ -1,5 +1,18 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.98.10
4
+
5
+ ### Patch Changes
6
+
7
+ - 079713c: Fall back to the portable A2A processor when a configured durable background worker rejects a task dispatch, and allow apps to explicitly opt out of stale deploy-wide background flags.
8
+ - 079713c: Preserve structured payloads from read-only MCP actions so external agents can inspect detailed records and replay data directly.
9
+
10
+ ## 0.98.9
11
+
12
+ ### Patch Changes
13
+
14
+ - 944c202: Preserve structured payloads from read-only MCP actions so external agents can inspect detailed records and replay data directly.
15
+
3
16
  ## 0.98.8
4
17
 
5
18
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.98.8",
3
+ "version": "0.98.10",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -82,33 +82,50 @@ async function fireProcessTaskDispatch(
82
82
  event: any,
83
83
  taskId: string,
84
84
  config: A2AConfig,
85
- options?: { awaitResponse?: boolean },
86
85
  ): Promise<void> {
87
86
  const backgroundPath = resolveAgentChatProcessRunDispatchPath();
88
87
  const useBackgroundWorker =
89
88
  isAgentChatDurableBackgroundEnabled({
90
- appOptIn: config.durableBackgroundRuns === true,
89
+ appOptIn: config.durableBackgroundRuns,
91
90
  }) && dispatchPathTargetsNetlifyBackgroundFunction(backgroundPath);
92
91
 
93
- await fireInternalDispatch({
94
- event,
95
- path: useBackgroundWorker ? backgroundPath : A2A_PROCESS_TASK_PATH,
96
- taskId,
97
- ...(useBackgroundWorker
98
- ? {
99
- body: {
100
- [AGENT_BACKGROUND_PROCESSOR_FIELD]: AGENT_BACKGROUND_PROCESSOR_A2A,
101
- },
102
- }
103
- : {}),
104
- // Only Netlify's background-function URL acknowledges enqueue with 202.
105
- // The portable framework route keeps its HTTP response open until the
106
- // processor completes, so awaiting that response would turn async
107
- // message/send into a long synchronous request (or a 15s timeout).
108
- ...(options?.awaitResponse && useBackgroundWorker
109
- ? { awaitResponse: true }
110
- : {}),
111
- });
92
+ if (!useBackgroundWorker) {
93
+ await fireInternalDispatch({
94
+ event,
95
+ path: A2A_PROCESS_TASK_PATH,
96
+ taskId,
97
+ });
98
+ return;
99
+ }
100
+
101
+ try {
102
+ // A real Netlify background function acknowledges the enqueue quickly.
103
+ // Await that acknowledgement so a missing or rejected worker can fall
104
+ // back before the task is left in `working` with no processor.
105
+ await fireInternalDispatch({
106
+ event,
107
+ path: backgroundPath,
108
+ taskId,
109
+ body: {
110
+ [AGENT_BACKGROUND_PROCESSOR_FIELD]: AGENT_BACKGROUND_PROCESSOR_A2A,
111
+ },
112
+ awaitResponse: true,
113
+ });
114
+ } catch (backgroundError) {
115
+ // Deploys can retain a runtime env opt-in after the corresponding
116
+ // background function was removed from the build. Keep async A2A useful
117
+ // in that state by falling back to the portable processor route, which
118
+ // runs in the regular framework function with the same task/auth checks.
119
+ console.error(
120
+ "[a2a] Durable background dispatch failed; falling back to portable processor:",
121
+ backgroundError,
122
+ );
123
+ await fireInternalDispatch({
124
+ event,
125
+ path: A2A_PROCESS_TASK_PATH,
126
+ taskId,
127
+ });
128
+ }
112
129
  }
113
130
 
114
131
  /**
@@ -531,9 +548,7 @@ async function handleSend(
531
548
  // race for the portable route, which holds its response open until the
532
549
  // handler finishes.
533
550
  try {
534
- await fireProcessTaskDispatch(event, task.id, config, {
535
- awaitResponse: true,
536
- });
551
+ await fireProcessTaskDispatch(event, task.id, config);
537
552
  } catch (err) {
538
553
  console.error("[a2a] Failed to dispatch process-task:", err);
539
554
  }
@@ -351,6 +351,12 @@ function isFlagExplicitlyDisabled(): boolean {
351
351
  export function isAgentChatDurableBackgroundEnabled(options?: {
352
352
  appOptIn?: boolean;
353
353
  }): boolean {
354
+ // An app-level opt-out must win over a stale deploy-wide env flag. Netlify
355
+ // environment variables can outlive the source config that originally set
356
+ // them; allowing that flag to re-enable a worker an app explicitly disabled
357
+ // recreates the missing-background-function failure this gate is meant to
358
+ // prevent.
359
+ if (options?.appOptIn === false) return false;
354
360
  const envOptIn = isFlagEnabled();
355
361
  const workspaceAppOptIn =
356
362
  options?.appOptIn === true &&
@@ -512,28 +512,91 @@ function routePathFromOpenUrl(value: string): string | null {
512
512
  * `mcpApp.resource` (the resource path already strips them via
513
513
  * `mcpAppStructuredContent`).
514
514
  *
515
- * Depth-capped to avoid pathological / circular structures. Strings that
516
- * embed an `isEmbedStartUrl` substring (e.g. a longer message that includes
517
- * the URL) are replaced with `[hidden embed URL]`.
515
+ * Circular structures are replaced with a marker. Strings that embed an
516
+ * `isEmbedStartUrl` substring (e.g. a longer message that includes the URL)
517
+ * are replaced with `[hidden embed URL]`. Credential-like `ticket` fields are
518
+ * removed only inside an embed-signaled object/branch, so ordinary business
519
+ * fields from unrelated read actions remain faithful.
518
520
  */
519
- function purgeEmbedStartUrls(value: unknown, depth = 0): unknown {
520
- if (depth > 5) return value;
521
+ const EMBED_RESULT_SENSITIVE_KEYS = new Set([
522
+ "embedTargetPath",
523
+ "embedExpiresAt",
524
+ "embedTicket",
525
+ ]);
526
+
527
+ function isEmbedCredentialKey(key: string): boolean {
528
+ return key === "ticket" || /Ticket$/.test(key);
529
+ }
530
+
531
+ function containsEmbedRoutingSignal(
532
+ value: unknown,
533
+ seen = new WeakSet<object>(),
534
+ ): boolean {
535
+ if (typeof value === "string") return isEmbedStartUrl(value);
536
+ if (!value || typeof value !== "object") return false;
537
+ if (seen.has(value)) return false;
538
+ seen.add(value);
539
+ if (Array.isArray(value)) {
540
+ const result = value.some((item) => containsEmbedRoutingSignal(item, seen));
541
+ seen.delete(value);
542
+ return result;
543
+ }
544
+ for (const [key, val] of Object.entries(value)) {
545
+ if (EMBED_RESULT_SENSITIVE_KEYS.has(key)) {
546
+ seen.delete(value);
547
+ return true;
548
+ }
549
+ if (containsEmbedRoutingSignal(val, seen)) {
550
+ seen.delete(value);
551
+ return true;
552
+ }
553
+ }
554
+ seen.delete(value);
555
+ return false;
556
+ }
557
+
558
+ function purgeEmbedStartUrls(
559
+ value: unknown,
560
+ seen = new WeakSet<object>(),
561
+ embedContext = false,
562
+ ): unknown {
521
563
  if (typeof value === "string") {
522
564
  return isEmbedStartUrl(value) ? "[hidden embed URL]" : value;
523
565
  }
524
566
  if (Array.isArray(value)) {
525
- return value.map((item) => purgeEmbedStartUrls(item, depth + 1));
567
+ if (seen.has(value)) return "[circular result]";
568
+ seen.add(value);
569
+ // An embed marker in one array item puts the whole result in the embed
570
+ // routing context. Credential fields in sibling items must not survive
571
+ // just because the marker lives elsewhere in the array.
572
+ const arrayEmbedContext = embedContext || containsEmbedRoutingSignal(value);
573
+ const out = value.map((item) =>
574
+ purgeEmbedStartUrls(item, seen, arrayEmbedContext),
575
+ );
576
+ seen.delete(value);
577
+ return out;
526
578
  }
527
579
  if (value && typeof value === "object") {
580
+ if (seen.has(value)) return "[circular result]";
581
+ seen.add(value);
582
+ const entries = Object.entries(value as Record<string, unknown>);
583
+ const localEmbedContext = embedContext || containsEmbedRoutingSignal(value);
528
584
  const out: Record<string, unknown> = {};
529
- for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
585
+ for (const [key, val] of entries) {
586
+ if (
587
+ EMBED_RESULT_SENSITIVE_KEYS.has(key) ||
588
+ (localEmbedContext && isEmbedCredentialKey(key))
589
+ ) {
590
+ continue;
591
+ }
530
592
  if (typeof val === "string" && isEmbedStartUrl(val)) {
531
593
  // Drop the key entirely for object-typed inputs so a tool result like
532
594
  // `{ embedStartUrl: "..." }` does not appear at all in the LLM text.
533
595
  continue;
534
596
  }
535
- out[key] = purgeEmbedStartUrls(val, depth + 1);
597
+ out[key] = purgeEmbedStartUrls(val, seen, localEmbedContext);
536
598
  }
599
+ seen.delete(value);
537
600
  return out;
538
601
  }
539
602
  return value;
@@ -1195,11 +1258,12 @@ function mcpAppStructuredContent(
1195
1258
  result: unknown,
1196
1259
  meta: Record<string, unknown> | undefined,
1197
1260
  ): Record<string, unknown> {
1261
+ const purged = purgeEmbedStartUrls(result);
1198
1262
  const out: Record<string, unknown> =
1199
- result && typeof result === "object" && !Array.isArray(result)
1200
- ? { ...(result as Record<string, unknown>) }
1201
- : primitiveValue(result)
1202
- ? { result }
1263
+ purged && typeof purged === "object" && !Array.isArray(purged)
1264
+ ? { ...(purged as Record<string, unknown>) }
1265
+ : primitiveValue(purged)
1266
+ ? { result: purged }
1203
1267
  : {};
1204
1268
  for (const key of ["embedStartUrl", "startUrl"]) {
1205
1269
  const value = out[key];
@@ -1213,17 +1277,6 @@ function mcpAppStructuredContent(
1213
1277
  // LLM). `embedTargetPath` reveals the exact route + thread/draft id the user
1214
1278
  // is looking at; `embedExpiresAt` is an unintended timestamp; ticket-bearing
1215
1279
  // fields are single-use credentials. Drop all of them unconditionally.
1216
- for (const key of [
1217
- "embedTargetPath",
1218
- "embedExpiresAt",
1219
- "ticket",
1220
- "embedTicket",
1221
- ]) {
1222
- delete out[key];
1223
- }
1224
- for (const key of Object.keys(out)) {
1225
- if (/Ticket$/.test(key)) delete out[key];
1226
- }
1227
1280
  const openLink = meta?.["agent-native/openLink"];
1228
1281
  if (openLink && typeof openLink === "object" && !Array.isArray(openLink)) {
1229
1282
  const webUrl = (openLink as Record<string, unknown>).webUrl;
@@ -1275,12 +1328,25 @@ function isSuccessOnlyResult(value: Record<string, unknown>): boolean {
1275
1328
  });
1276
1329
  }
1277
1330
 
1278
- function conciseToolResultText(name: string, result: unknown): string {
1331
+ function conciseToolResultText(
1332
+ name: string,
1333
+ result: unknown,
1334
+ options?: { preserveObjectResult?: boolean },
1335
+ ): string {
1279
1336
  const purged = purgeEmbedStartUrls(result);
1280
1337
  if (typeof purged === "string") return truncateToolText(purged);
1281
1338
  if (purged === true || purged == null) return `${name} completed.`;
1282
1339
  if (purged && typeof purged === "object" && !Array.isArray(purged)) {
1283
1340
  const record = purged as Record<string, unknown>;
1341
+ // Read-only actions are data reads, not mutations. Keep their object
1342
+ // payload available to MCP clients in the text fallback too; the
1343
+ // structuredContent branch below is the lossless path for clients that
1344
+ // support it. Mutating/action-style results retain the concise status
1345
+ // text so we do not unexpectedly dump write results into conversations.
1346
+ if (options?.preserveObjectResult) {
1347
+ const text = JSON.stringify(purged);
1348
+ return text === undefined ? `${name} completed.` : truncateToolText(text);
1349
+ }
1284
1350
  const message = record.message ?? record.summary;
1285
1351
  if (typeof message === "string" && message.trim()) {
1286
1352
  return truncateToolText(message.trim());
@@ -1704,6 +1770,14 @@ export async function createMCPServerForRequest(
1704
1770
  Array.isArray(toolVisibility) &&
1705
1771
  toolVisibility.length > 0 &&
1706
1772
  toolVisibility.every((v) => v === "app");
1773
+ const readOnlyStructuredResult =
1774
+ entry.readOnly === true &&
1775
+ rawResultForClient &&
1776
+ typeof rawResultForClient === "object"
1777
+ ? Array.isArray(rawResultForClient)
1778
+ ? { items: rawResultForClient }
1779
+ : rawResultForClient
1780
+ : undefined;
1707
1781
  const structuredContent = mcpAppResource
1708
1782
  ? mcpAppStructuredContent(rawResultForClient, responseMeta)
1709
1783
  : isAppOnlyVisibility &&
@@ -1711,10 +1785,14 @@ export async function createMCPServerForRequest(
1711
1785
  typeof rawResult === "object" &&
1712
1786
  !Array.isArray(rawResult)
1713
1787
  ? (rawResult as Record<string, unknown>)
1714
- : undefined;
1788
+ : readOnlyStructuredResult
1789
+ ? mcpAppStructuredContent(readOnlyStructuredResult, responseMeta)
1790
+ : undefined;
1715
1791
  const text = mcpAppResource
1716
1792
  ? conciseMcpAppToolText(name, resultForClient, structuredContent!)
1717
- : conciseToolResultText(name, resultForClient);
1793
+ : conciseToolResultText(name, resultForClient, {
1794
+ preserveObjectResult: entry.readOnly === true,
1795
+ });
1718
1796
  const content: any[] = [{ type: "text", text }];
1719
1797
  if (block) content.push(block);
1720
1798
  return {
@@ -42,7 +42,9 @@ export interface AgentChatPluginOptions {
42
42
  /**
43
43
  * Opt this app into Netlify durable background-function agent-chat runs. This
44
44
  * gives hosted agent turns the 15-minute async-function budget when the app's
45
- * Netlify build also emits the background function.
45
+ * Netlify build also emits the background function. Set this to `false` to
46
+ * explicitly disable a stale deploy-wide `AGENT_CHAT_DURABLE_BACKGROUND`
47
+ * flag for this app.
46
48
  */
47
49
  durableBackgroundRuns?: boolean;
48
50
  /** Anthropic API key. Falls back to ANTHROPIC_API_KEY env var */
@@ -257,6 +257,10 @@ export default createAgentChatPlugin({
257
257
  // Operators deploying to trusted internal environments can set
258
258
  // AGENT_PROD_CODE_EXECUTION=trusted to also enable bash/read/edit/write.
259
259
  codeExecution: { production: "sandboxed" },
260
+ // Analytics uses the portable A2A processor until its dedicated background
261
+ // worker is verified live. This explicit opt-out also protects production
262
+ // from a stale deploy-wide AGENT_CHAT_DURABLE_BACKGROUND flag.
263
+ durableBackgroundRuns: false,
260
264
  connectorCatalog: [...ANALYTICS_CONNECTOR_CATALOG],
261
265
  externalAgents: {
262
266
  // Keep the direct MCP surface deliberately curated. External agents
@@ -128,12 +128,26 @@ pnpm action create-event \
128
128
  # Working location
129
129
  pnpm action create-event \
130
130
  --title "Working from home" \
131
- --start 2026-04-03T09:00:00 \
132
- --end 2026-04-03T17:00:00 \
131
+ --start 2026-04-03 \
132
+ --end 2026-04-04 \
133
+ --allDay true \
133
134
  --eventType workingLocation \
134
135
  --workingLocationType homeOffice
135
136
  ```
136
137
 
138
+ Working-location events sync from Google with `workingLocationProperties` and
139
+ render as native working locations in the UI instead of generic all-day events.
140
+ They are transparent/non-blocking for availability. Google allows timed working
141
+ locations or single-day all-day working locations; multi-day all-day ranges must
142
+ be represented as separate daily working-location events.
143
+
144
+ For a visible occurrence in a recurring working-location series, default to
145
+ `scope: "single"` and pass the occurrence's event `id`, not its
146
+ `recurringEventId`. Use `scope: "all"` only when the user explicitly asks to
147
+ change every day in the series. Keep office building/floor/desk metadata when
148
+ editing an office label, and clear incompatible location labels when changing
149
+ between Home, Office, and Other.
150
+
137
151
  Do not use `eventType` for Tasks or appointment schedules. Google Calendar
138
152
  Tasks are a separate product/API surface, and appointment schedules should use
139
153
  booking links or availability workflows instead.
@@ -225,6 +239,12 @@ pnpm action update-event --id google-event-id --attendees "alice@example.com" --
225
239
  pnpm action update-event --id google-event-id --addGoogleMeet=true
226
240
  pnpm action update-event --id google-event-id --addZoom=true
227
241
 
242
+ # Update an existing working-location event's native metadata
243
+ pnpm action update-event \
244
+ --id google-working-location-id \
245
+ --workingLocationType officeLocation \
246
+ --workingLocationLabel "Pier 57"
247
+
228
248
  # Add multiple alerts, a Google event color, and an attachment
229
249
  pnpm action update-event \
230
250
  --id google-event-id \
@@ -237,6 +257,15 @@ pnpm action update-event \
237
257
 
238
258
  For "add Zoom to this meeting", fetch or use the visible event id and call `update-event --addZoom=true`. Do not create an extension for Zoom; Zoom is a first-party calendar integration handled by the event actions and the Settings page.
239
259
 
260
+ Google Calendar does not allow changing an existing event's `eventType`; use
261
+ `workingLocationType` and `workingLocationLabel` only on events that already
262
+ have `eventType: "workingLocation"`.
263
+
264
+ Google Calendar API v3 currently documents working locations on Events, but the
265
+ Settings API/discovery document does not expose working-hours settings. Treat
266
+ working-hours overlays or Find a Time constraints as a follow-up only after a
267
+ real provider data path exists.
268
+
240
269
  For recurring events, pass a Google Calendar RRULE in `--recurrence`. Example: to make a daily event weekdays only, use:
241
270
 
242
271
  ```bash
@@ -37,6 +37,17 @@ Detailed event, availability, booking, storage, and UI rules live in
37
37
  with `stageAs` and analyze them with `query-staged-dataset`.
38
38
  - For Google Calendar, distinguish an empty calendar from missing auth,
39
39
  reauth-needed, or fetch failures.
40
+ - Google Calendar working locations are status events (`eventType:
41
+ "workingLocation"`). Sync and display them as working locations, keep them
42
+ transparent/non-blocking, and preserve `workingLocationProperties` instead of
43
+ treating the summary as a generic all-day event title.
44
+ - When updating one visible occurrence in a recurring working-location series,
45
+ pass that occurrence's event `id` with `scope: "single"` by default. Use the
46
+ series scope only when the user explicitly chooses all days.
47
+ - Google Calendar API v3 exposes working locations through Events. The current
48
+ Settings API and Calendar v3 discovery document do not expose working-hours
49
+ settings, so do not promise working-hours UI or overlays unless a real
50
+ provider data path has been verified first.
40
51
  - Use framework sharing actions for calendars/events/booking resources when
41
52
  applicable.
42
53
  - Booking-link sharing controls who can manage the link. Public booking access
@@ -13,7 +13,8 @@ the agent can do through the same actions.
13
13
  ## Features
14
14
 
15
15
  - Day, week, and month views with multiple Google accounts overlayed.
16
- - Google Calendar sync and read-only ICS feed subscriptions.
16
+ - Google Calendar sync, native working locations, and read-only ICS feed
17
+ subscriptions.
17
18
  - Weekly availability with timezone support for slot-finding.
18
19
  - Calendly-style public booking links at `/book/{slug}` with custom fields.
19
20
  - Ask the agent anything schedule-related, from "am I free Thursday?" to
@@ -24,6 +24,7 @@ import {
24
24
  reminderMethodInput,
25
25
  reminderMinutesInput,
26
26
  remindersInput,
27
+ validateStatusEventTiming,
27
28
  visibilityInput,
28
29
  workingLocationTypeInput,
29
30
  } from "./event-action-helpers.js";
@@ -116,12 +117,12 @@ export default defineAction({
116
117
  if (args.addGoogleMeet && args.addZoom) {
117
118
  throw new Error("Choose either Google Meet or Zoom, not both.");
118
119
  }
119
- if (
120
- (args.eventType === "outOfOffice" || args.eventType === "focusTime") &&
121
- args.allDay === true
122
- ) {
123
- throw new Error("Out of office and focus time events must be timed.");
124
- }
120
+ validateStatusEventTiming({
121
+ eventType: args.eventType,
122
+ allDay: args.allDay,
123
+ start: args.start,
124
+ end: args.end,
125
+ });
125
126
 
126
127
  if (!(await googleCalendar.isConnected(email))) {
127
128
  throw new Error(
@@ -303,3 +303,52 @@ export function buildStatusEventFields(args: {
303
303
  : { type, customLocation: { label } },
304
304
  };
305
305
  }
306
+
307
+ function allDayDatePart(value: string): string {
308
+ const dateOnlyPattern = /^\d{4}-\d{2}-\d{2}$/;
309
+ if (dateOnlyPattern.test(value)) return value;
310
+ const date = new Date(value);
311
+ if (Number.isNaN(date.getTime())) {
312
+ throw new Error(
313
+ "All-day status events must use valid date or datetime start and end values.",
314
+ );
315
+ }
316
+ return date.toISOString().slice(0, 10);
317
+ }
318
+
319
+ function allDaySpanDays(start: string, end: string): number {
320
+ const startDate = allDayDatePart(start);
321
+ const endDate = allDayDatePart(end);
322
+ const startMs = Date.UTC(
323
+ Number(startDate.slice(0, 4)),
324
+ Number(startDate.slice(5, 7)) - 1,
325
+ Number(startDate.slice(8, 10)),
326
+ );
327
+ const endMs = Date.UTC(
328
+ Number(endDate.slice(0, 4)),
329
+ Number(endDate.slice(5, 7)) - 1,
330
+ Number(endDate.slice(8, 10)),
331
+ );
332
+ return Math.round((endMs - startMs) / 86_400_000);
333
+ }
334
+
335
+ export function validateStatusEventTiming(args: {
336
+ eventType?: "default" | "outOfOffice" | "focusTime" | "workingLocation";
337
+ allDay?: boolean;
338
+ start: string;
339
+ end: string;
340
+ }) {
341
+ if (
342
+ (args.eventType === "outOfOffice" || args.eventType === "focusTime") &&
343
+ args.allDay === true
344
+ ) {
345
+ throw new Error("Out of office and focus time events must be timed.");
346
+ }
347
+
348
+ if (args.eventType === "workingLocation" && args.allDay === true) {
349
+ const days = allDaySpanDays(args.start, args.end);
350
+ if (days !== 1) {
351
+ throw new Error("All-day working location events must be a single day.");
352
+ }
353
+ }
354
+ }