@agent-native/core 0.84.0 → 0.84.1

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 (89) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/docs/content/evals.mdx +34 -12
  4. package/corpus/core/docs/content/locales/ar-SA/evals.mdx +7 -0
  5. package/corpus/core/docs/content/locales/de-DE/evals.mdx +8 -0
  6. package/corpus/core/docs/content/locales/es-ES/evals.mdx +8 -0
  7. package/corpus/core/docs/content/locales/fr-FR/evals.mdx +8 -0
  8. package/corpus/core/docs/content/locales/hi-IN/evals.mdx +8 -0
  9. package/corpus/core/docs/content/locales/ja-JP/evals.mdx +7 -0
  10. package/corpus/core/docs/content/locales/ko-KR/evals.mdx +7 -0
  11. package/corpus/core/docs/content/locales/pt-BR/evals.mdx +8 -0
  12. package/corpus/core/docs/content/locales/zh-CN/evals.mdx +7 -0
  13. package/corpus/core/docs/content/locales/zh-TW/evals.mdx +7 -0
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/eval/define-eval.ts +4 -1
  16. package/corpus/core/src/eval/report.ts +23 -8
  17. package/corpus/core/src/eval/runner.ts +41 -6
  18. package/corpus/core/src/eval/types.ts +11 -0
  19. package/corpus/templates/content/AGENTS.md +9 -2
  20. package/corpus/templates/content/actions/_database-row-batch.ts +230 -0
  21. package/corpus/templates/content/actions/_database-utils.ts +17 -9
  22. package/corpus/templates/content/actions/_notion-action-utils.ts +26 -0
  23. package/corpus/templates/content/actions/connect-notion-status.ts +13 -4
  24. package/corpus/templates/content/actions/create-and-link-notion-page.ts +23 -0
  25. package/corpus/templates/content/actions/delete-database-items.ts +55 -0
  26. package/corpus/templates/content/actions/delete-document.ts +4 -4
  27. package/corpus/templates/content/actions/disconnect-notion.ts +16 -0
  28. package/corpus/templates/content/actions/duplicate-database-item.ts +1 -1
  29. package/corpus/templates/content/actions/duplicate-database-items.ts +191 -0
  30. package/corpus/templates/content/actions/link-notion-page.ts +10 -7
  31. package/corpus/templates/content/actions/list-notion-links.ts +3 -4
  32. package/corpus/templates/content/actions/pull-notion-page.ts +7 -9
  33. package/corpus/templates/content/actions/push-notion-page.ts +7 -9
  34. package/corpus/templates/content/actions/refresh-notion-sync-status.ts +24 -0
  35. package/corpus/templates/content/actions/resolve-notion-sync-conflict.ts +23 -0
  36. package/corpus/templates/content/actions/search-notion-pages.ts +67 -0
  37. package/corpus/templates/content/actions/unlink-notion-page.ts +23 -0
  38. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +27 -34
  39. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +16 -13
  40. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +10 -10
  41. package/corpus/templates/content/app/components/editor/NotionConflictBanner.tsx +1 -1
  42. package/corpus/templates/content/app/components/editor/NotionSyncBar.tsx +18 -9
  43. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +27 -34
  44. package/corpus/templates/content/app/components/editor/database/navigation-state.ts +10 -4
  45. package/corpus/templates/content/app/components/sidebar/NotionButton.tsx +32 -17
  46. package/corpus/templates/content/app/hooks/use-content-database.ts +35 -0
  47. package/corpus/templates/content/app/hooks/use-notion.ts +108 -99
  48. package/corpus/templates/content/package.json +3 -0
  49. package/corpus/templates/content/parity/README.md +69 -0
  50. package/corpus/templates/content/parity/eval-scenarios.ts +118 -0
  51. package/corpus/templates/content/parity/exceptions.allowlist.ts +17 -0
  52. package/corpus/templates/content/parity/matrix.md +28 -0
  53. package/corpus/templates/content/parity/matrix.ts +606 -0
  54. package/corpus/templates/content/parity/matrix.types.ts +42 -0
  55. package/corpus/templates/content/parity/parity-evals.eval.ts +4 -0
  56. package/corpus/templates/content/parity/render-matrix.ts +90 -0
  57. package/corpus/templates/content/parity/scenario-to-eval.ts +57 -0
  58. package/corpus/templates/content/shared/api.ts +11 -0
  59. package/dist/collab/awareness.d.ts +2 -2
  60. package/dist/collab/awareness.d.ts.map +1 -1
  61. package/dist/collab/routes.d.ts +1 -1
  62. package/dist/eval/define-eval.d.ts.map +1 -1
  63. package/dist/eval/define-eval.js +2 -1
  64. package/dist/eval/define-eval.js.map +1 -1
  65. package/dist/eval/report.d.ts.map +1 -1
  66. package/dist/eval/report.js +19 -4
  67. package/dist/eval/report.js.map +1 -1
  68. package/dist/eval/runner.d.ts.map +1 -1
  69. package/dist/eval/runner.js +38 -6
  70. package/dist/eval/runner.js.map +1 -1
  71. package/dist/eval/types.d.ts +11 -0
  72. package/dist/eval/types.d.ts.map +1 -1
  73. package/dist/eval/types.js.map +1 -1
  74. package/dist/notifications/routes.d.ts +3 -3
  75. package/dist/progress/routes.d.ts +1 -1
  76. package/dist/resources/handlers.d.ts +1 -1
  77. package/docs/content/evals.mdx +34 -12
  78. package/docs/content/locales/ar-SA/evals.mdx +7 -0
  79. package/docs/content/locales/de-DE/evals.mdx +8 -0
  80. package/docs/content/locales/es-ES/evals.mdx +8 -0
  81. package/docs/content/locales/fr-FR/evals.mdx +8 -0
  82. package/docs/content/locales/hi-IN/evals.mdx +8 -0
  83. package/docs/content/locales/ja-JP/evals.mdx +7 -0
  84. package/docs/content/locales/ko-KR/evals.mdx +7 -0
  85. package/docs/content/locales/pt-BR/evals.mdx +8 -0
  86. package/docs/content/locales/zh-CN/evals.mdx +7 -0
  87. package/docs/content/locales/zh-TW/evals.mdx +7 -0
  88. package/package.json +1 -1
  89. package/corpus/templates/content/server/routes/api/notion/disconnect.post.ts +0 -12
@@ -146,8 +146,10 @@ import {
146
146
  useContentDatabase,
147
147
  useContentDatabases,
148
148
  contentDatabaseQueryKey,
149
+ useDeleteDatabaseItems,
149
150
  useDisconnectContentDatabaseSource,
150
151
  useDuplicateDatabaseItem,
152
+ useDuplicateDatabaseItems,
151
153
  useExecuteBuilderSourceExecution,
152
154
  useMoveDatabaseItem,
153
155
  usePrepareBuilderSourceReview,
@@ -2766,9 +2768,9 @@ function DatabaseTableView({
2766
2768
  }) {
2767
2769
  const queryClient = useQueryClient();
2768
2770
  const moveItem = useMoveDatabaseItem(databaseDocumentId);
2769
- const duplicateItem = useDuplicateDatabaseItem(databaseDocumentId);
2771
+ const duplicateItems = useDuplicateDatabaseItems(databaseDocumentId);
2770
2772
  const setProperty = useSetDocumentProperty(databaseDocumentId);
2771
- const deleteDocument = useDeleteDocument();
2773
+ const deleteItems = useDeleteDatabaseItems(databaseDocumentId);
2772
2774
  const [draggedItemId, setDraggedItemId] = useState<string | null>(null);
2773
2775
  const [dropTargetItemId, setDropTargetItemId] = useState<string | null>(null);
2774
2776
  const [draggedPropertyId, setDraggedPropertyId] = useState<string | null>(
@@ -3012,14 +3014,15 @@ function DatabaseTableView({
3012
3014
  async function deleteSelectedRows() {
3013
3015
  if (selectedItems.length === 0) return;
3014
3016
  const selectedSnapshot = selectedItems;
3015
- onClearSelection();
3016
3017
  setConfirmDeleteSelectedOpen(false);
3017
3018
 
3018
3019
  try {
3020
+ await deleteItems.mutateAsync({
3021
+ documentId: databaseDocumentId,
3022
+ itemIds: selectedSnapshot.map((item) => item.id),
3023
+ });
3024
+ onClearSelection();
3019
3025
  onDeletedPreviewItems(selectedSnapshot);
3020
- for (const item of selectedSnapshot) {
3021
- await deleteDocument.mutateAsync({ id: item.document.id });
3022
- }
3023
3026
  await queryClient.invalidateQueries({
3024
3027
  queryKey: [
3025
3028
  "action",
@@ -3043,22 +3046,11 @@ function DatabaseTableView({
3043
3046
  const selectedSnapshot = selectedItems;
3044
3047
  setIsDuplicatingSelected(true);
3045
3048
 
3046
- let duplicatedPreviewItem: ContentDatabaseItem | null = null;
3047
- let duplicatedCount = 0;
3048
- let failedCount = 0;
3049
-
3050
3049
  try {
3051
- for (const item of selectedSnapshot) {
3052
- try {
3053
- const response = await duplicateItem.mutateAsync({ itemId: item.id });
3054
- duplicatedCount += 1;
3055
- duplicatedPreviewItem =
3056
- databaseDuplicatedItemFromResponse(response) ??
3057
- duplicatedPreviewItem;
3058
- } catch {
3059
- failedCount += 1;
3060
- }
3061
- }
3050
+ const response = await duplicateItems.mutateAsync({
3051
+ documentId: databaseDocumentId,
3052
+ itemIds: selectedSnapshot.map((item) => item.id),
3053
+ });
3062
3054
 
3063
3055
  await queryClient.invalidateQueries({
3064
3056
  queryKey: [
@@ -3071,16 +3063,15 @@ function DatabaseTableView({
3071
3063
  queryKey: ["action", "list-documents"],
3072
3064
  });
3073
3065
 
3066
+ const duplicatedPreviewItem =
3067
+ databaseDuplicatedItemFromResponse(response);
3074
3068
  if (duplicatedPreviewItem) onPreview(duplicatedPreviewItem);
3075
- if (duplicatedCount > 0) onClearSelection();
3076
- if (failedCount > 0) {
3077
- toast.error(dbText("failedToDuplicateEverySelectedRow"), {
3078
- description:
3079
- duplicatedCount > 0
3080
- ? `${duplicatedCount} duplicated, ${failedCount} failed.`
3081
- : "No rows were duplicated.",
3082
- });
3083
- }
3069
+ onClearSelection();
3070
+ } catch (err) {
3071
+ toast.error(dbText("failedToDuplicateEverySelectedRow"), {
3072
+ description:
3073
+ err instanceof Error ? err.message : dbText("somethingWentWrong"),
3074
+ });
3084
3075
  } finally {
3085
3076
  setIsDuplicatingSelected(false);
3086
3077
  }
@@ -3139,9 +3130,11 @@ function DatabaseTableView({
3139
3130
  canEdit={canEdit}
3140
3131
  properties={bulkEditableProperties}
3141
3132
  duplicateDisabled={
3142
- isDuplicatingSelected || deleteDocument.isPending
3133
+ isDuplicatingSelected ||
3134
+ duplicateItems.isPending ||
3135
+ deleteItems.isPending
3143
3136
  }
3144
- deleteDisabled={deleteDocument.isPending}
3137
+ deleteDisabled={deleteItems.isPending}
3145
3138
  updateDisabled={setProperty.isPending}
3146
3139
  onClearSelection={onClearSelection}
3147
3140
  onSetPropertyValue={setSelectedPropertyValue}
@@ -3360,10 +3353,10 @@ function DatabaseTableView({
3360
3353
  <AlertDialogCancel>Cancel</AlertDialogCancel>
3361
3354
  <AlertDialogAction
3362
3355
  className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
3363
- disabled={deleteDocument.isPending}
3356
+ disabled={deleteItems.isPending}
3364
3357
  onClick={() => void deleteSelectedRows()}
3365
3358
  >
3366
- {deleteDocument.isPending ? "Deleting..." : "Delete"}
3359
+ {deleteItems.isPending ? "Deleting..." : "Delete"}
3367
3360
  </AlertDialogAction>
3368
3361
  </AlertDialogFooter>
3369
3362
  </AlertDialogContent>
@@ -250,11 +250,17 @@ export function databaseBulkScalarInputState(
250
250
 
251
251
  export function databaseDuplicatedItemFromResponse(
252
252
  response: Pick<ContentDatabaseResponse, "items"> &
253
- Pick<Partial<ContentDatabaseResponse>, "duplicatedItemId">,
253
+ Pick<
254
+ Partial<ContentDatabaseResponse>,
255
+ "duplicatedItemId" | "duplicatedItemIds"
256
+ >,
254
257
  ) {
255
- return (
256
- response.items.find((item) => item.id === response.duplicatedItemId) ?? null
257
- );
258
+ const duplicatedItemIds = response.duplicatedItemIds ?? [];
259
+ const duplicatedItemId =
260
+ duplicatedItemIds.length > 0
261
+ ? duplicatedItemIds[duplicatedItemIds.length - 1]
262
+ : response.duplicatedItemId;
263
+ return response.items.find((item) => item.id === duplicatedItemId) ?? null;
258
264
  }
259
265
 
260
266
  export function toggleDatabaseRowSelection(
@@ -22,7 +22,11 @@ import {
22
22
  TooltipContent,
23
23
  TooltipTrigger,
24
24
  } from "@/components/ui/tooltip";
25
- import { useNotionConnection, useDisconnectNotion } from "@/hooks/use-notion";
25
+ import {
26
+ openNotionOAuthUrl,
27
+ useDisconnectNotion,
28
+ useNotionConnection,
29
+ } from "@/hooks/use-notion";
26
30
  import { cn } from "@/lib/utils";
27
31
 
28
32
  // ─── Notion SVG icon ────────────────────────────────────────────────────────
@@ -125,8 +129,8 @@ export function NotionButton() {
125
129
  };
126
130
  }, []);
127
131
 
128
- function handleConnect() {
129
- if (!connection?.authUrl) {
132
+ async function handleConnect() {
133
+ if (connection?.error === "missing_credentials") {
130
134
  if (needsCredentials) {
131
135
  setShowWizard(true);
132
136
  return;
@@ -134,7 +138,24 @@ export function NotionButton() {
134
138
  toast.error(t("sidebar.notionOAuthNotConfigured"));
135
139
  return;
136
140
  }
137
- window.open(connection.authUrl, "_blank");
141
+ const popup = window.open("about:blank", "_blank");
142
+ if (!popup) {
143
+ toast.error(t("sidebar.notionOAuthNotConfigured"));
144
+ return;
145
+ }
146
+ popup.opener = null;
147
+
148
+ try {
149
+ popup.location.href = await openNotionOAuthUrl();
150
+ } catch (error) {
151
+ popup.close();
152
+ toast.error(
153
+ error instanceof Error
154
+ ? error.message
155
+ : t("sidebar.notionOAuthNotConfigured"),
156
+ );
157
+ return;
158
+ }
138
159
 
139
160
  // Clear any existing poll before starting a new one
140
161
  if (pollRef.current) clearInterval(pollRef.current);
@@ -142,18 +163,12 @@ export function NotionButton() {
142
163
 
143
164
  // Poll for connection
144
165
  pollRef.current = setInterval(async () => {
145
- const res = await fetch(appApiPath("/api/notion/status")).catch(
146
- () => null,
147
- );
148
- if (res?.ok) {
149
- const data = await res.json();
150
- if (data.connected) {
151
- clearInterval(pollRef.current);
152
- pollRef.current = undefined;
153
- setShowWizard(false);
154
- setOpen(false);
155
- refetch();
156
- }
166
+ const result = await refetch();
167
+ if (result.data?.connected) {
168
+ clearInterval(pollRef.current);
169
+ pollRef.current = undefined;
170
+ setShowWizard(false);
171
+ setOpen(false);
157
172
  }
158
173
  }, 2000);
159
174
 
@@ -166,7 +181,7 @@ export function NotionButton() {
166
181
 
167
182
  async function handleDisconnect() {
168
183
  try {
169
- await disconnectNotion.mutateAsync();
184
+ await disconnectNotion.mutateAsync({});
170
185
  toast.success(t("sidebar.notionDisconnectedWorkspace"));
171
186
  } catch (err) {
172
187
  toast.error(
@@ -13,6 +13,7 @@ import type {
13
13
  ContentDatabaseSourceFieldPropertyResponse,
14
14
  ContentDatabaseSourceStatusResponse,
15
15
  CreateDatabaseRequest,
16
+ DatabaseItemsBatchRequest,
16
17
  DisconnectContentDatabaseSourceRequest,
17
18
  ExecuteBuilderSourceBatchRequest,
18
19
  ExecuteBuilderSourceBatchResponse,
@@ -258,6 +259,40 @@ export function useDuplicateDatabaseItem(documentId: string) {
258
259
  });
259
260
  }
260
261
 
262
+ export function useDuplicateDatabaseItems(documentId: string) {
263
+ const queryClient = useQueryClient();
264
+ return useActionMutation<ContentDatabaseResponse, DatabaseItemsBatchRequest>(
265
+ "duplicate-database-items",
266
+ {
267
+ onSuccess: () => {
268
+ queryClient.invalidateQueries({
269
+ queryKey: contentDatabaseQueryKey(documentId),
270
+ });
271
+ queryClient.invalidateQueries({
272
+ queryKey: ["action", "list-documents"],
273
+ });
274
+ },
275
+ },
276
+ );
277
+ }
278
+
279
+ export function useDeleteDatabaseItems(documentId: string) {
280
+ const queryClient = useQueryClient();
281
+ return useActionMutation<ContentDatabaseResponse, DatabaseItemsBatchRequest>(
282
+ "delete-database-items",
283
+ {
284
+ onSuccess: () => {
285
+ queryClient.invalidateQueries({
286
+ queryKey: contentDatabaseQueryKey(documentId),
287
+ });
288
+ queryClient.invalidateQueries({
289
+ queryKey: ["action", "list-documents"],
290
+ });
291
+ },
292
+ },
293
+ );
294
+ }
295
+
261
296
  export function useMoveDatabaseItem(documentId: string) {
262
297
  const queryClient = useQueryClient();
263
298
  return useActionMutation<ContentDatabaseResponse, MoveDatabaseItemRequest>(
@@ -1,4 +1,9 @@
1
- import { appApiPath } from "@agent-native/core/client";
1
+ import {
2
+ appApiPath,
3
+ callAction,
4
+ useActionMutation,
5
+ useActionQuery,
6
+ } from "@agent-native/core/client";
2
7
  import type {
3
8
  CreateNotionPageRequest,
4
9
  Document,
@@ -8,18 +13,20 @@ import type {
8
13
  NotionSearchResponse,
9
14
  ResolveDocumentSyncConflictRequest,
10
15
  } from "@shared/api";
11
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
16
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
12
17
  import { useEffect, useRef } from "react";
13
18
 
14
- async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
15
- const res = await fetch(appApiPath(url), init);
19
+ async function fetchNotionAuthUrl(): Promise<string> {
20
+ const res = await fetch(appApiPath("/api/notion/auth-url"));
16
21
  if (!res.ok) {
17
22
  const body = await res.json().catch(() => null);
18
23
  throw new Error(
19
24
  body?.error || body?.message || `${res.status} ${res.statusText}`,
20
25
  );
21
26
  }
22
- return res.json();
27
+ const body = (await res.json()) as { url?: string };
28
+ if (!body.url) throw new Error("Notion OAuth URL is unavailable");
29
+ return body.url;
23
30
  }
24
31
 
25
32
  function invalidateDocumentQueries(
@@ -27,34 +34,65 @@ function invalidateDocumentQueries(
27
34
  documentId: string,
28
35
  ) {
29
36
  queryClient.invalidateQueries({ queryKey: ["action"] });
30
- queryClient.invalidateQueries({ queryKey: ["document-sync", documentId] });
37
+ queryClient.invalidateQueries({
38
+ queryKey: documentSyncStatusQueryKey(documentId),
39
+ });
40
+ queryClient.invalidateQueries({
41
+ queryKey: documentSyncStatusQueryKey(documentId, { autoSync: true }),
42
+ });
43
+ }
44
+
45
+ export function documentSyncStatusQueryKey(
46
+ documentId: string,
47
+ options?: { autoSync?: boolean },
48
+ ) {
49
+ return [
50
+ "action",
51
+ "refresh-notion-sync-status",
52
+ { documentId, autoSync: !!options?.autoSync },
53
+ ] as const;
31
54
  }
32
55
 
33
56
  export function useNotionConnection() {
57
+ return useActionQuery<NotionConnectionStatus>(
58
+ "connect-notion-status",
59
+ undefined,
60
+ {
61
+ staleTime: 30_000,
62
+ },
63
+ );
64
+ }
65
+
66
+ export function useNotionAuthUrl(enabled: boolean) {
34
67
  return useQuery({
35
- queryKey: ["notion-connection"],
36
- queryFn: () => fetchJson<NotionConnectionStatus>("/api/notion/status"),
68
+ queryKey: ["notion-auth-url"],
69
+ queryFn: fetchNotionAuthUrl,
70
+ enabled,
37
71
  staleTime: 30_000,
38
72
  });
39
73
  }
40
74
 
75
+ export async function openNotionOAuthUrl() {
76
+ return fetchNotionAuthUrl();
77
+ }
78
+
41
79
  export function useDocumentSyncStatus(
42
80
  documentId: string | null,
43
81
  options?: { autoSync?: boolean },
44
82
  ) {
45
83
  const queryClient = useQueryClient();
46
84
  const lastObservedSyncedAtRef = useRef<string | null>(null);
47
- const query = useQuery({
48
- queryKey: ["document-sync", documentId],
49
- queryFn: () =>
50
- fetchJson<DocumentSyncStatus>(
51
- `/api/documents/${documentId}/notion/refresh`,
52
- {
53
- method: "POST",
54
- headers: { "Content-Type": "application/json" },
55
- body: JSON.stringify({ autoSync: !!options?.autoSync }),
56
- },
57
- ),
85
+ const query = useQuery<DocumentSyncStatus>({
86
+ queryKey: documentId
87
+ ? documentSyncStatusQueryKey(documentId, options)
88
+ : ["action", "refresh-notion-sync-status", null],
89
+ queryFn: () => {
90
+ if (!documentId) throw new Error("documentId is required");
91
+ return callAction<DocumentSyncStatus>("refresh-notion-sync-status", {
92
+ documentId,
93
+ autoSync: !!options?.autoSync,
94
+ });
95
+ },
58
96
  enabled: !!documentId,
59
97
  // Poll Notion aggressively when auto-sync is on so remote changes appear
60
98
  // within ~2s. Server throttles match (see REFRESH_THROTTLE_AUTO_SYNC_MS in
@@ -97,118 +135,89 @@ export function useDocumentSyncStatus(
97
135
 
98
136
  export function useDisconnectNotion() {
99
137
  const queryClient = useQueryClient();
100
- return useMutation({
101
- mutationFn: () =>
102
- fetchJson<{ success: boolean }>("/api/notion/disconnect", {
103
- method: "POST",
104
- }),
105
- onSuccess: () => {
106
- queryClient.invalidateQueries({ queryKey: ["notion-connection"] });
107
- queryClient.invalidateQueries({ queryKey: ["document-sync"] });
138
+ return useActionMutation<{ success: boolean; deleted: number }>(
139
+ "disconnect-notion",
140
+ {
141
+ onSuccess: () => {
142
+ queryClient.invalidateQueries({
143
+ queryKey: ["action", "connect-notion-status"],
144
+ });
145
+ queryClient.invalidateQueries({
146
+ queryKey: ["action", "refresh-notion-sync-status"],
147
+ });
148
+ },
108
149
  },
109
- });
150
+ );
110
151
  }
111
152
 
112
153
  export function useLinkDocumentToNotion(documentId: string) {
113
154
  const queryClient = useQueryClient();
114
- return useMutation({
115
- mutationFn: (data: LinkNotionPageRequest) =>
116
- fetchJson<DocumentSyncStatus>(
117
- `/api/documents/${documentId}/notion/link`,
118
- {
119
- method: "POST",
120
- headers: { "Content-Type": "application/json" },
121
- body: JSON.stringify(data),
122
- },
123
- ),
155
+ return useActionMutation<
156
+ DocumentSyncStatus,
157
+ LinkNotionPageRequest & { documentId: string }
158
+ >("link-notion-page", {
124
159
  onSuccess: () => invalidateDocumentQueries(queryClient, documentId),
125
160
  });
126
161
  }
127
162
 
128
163
  export function useUnlinkDocumentFromNotion(documentId: string) {
129
164
  const queryClient = useQueryClient();
130
- return useMutation({
131
- mutationFn: () =>
132
- fetchJson<{ success: boolean }>(
133
- `/api/documents/${documentId}/notion/unlink`,
134
- {
135
- method: "DELETE",
136
- },
137
- ),
138
- onSuccess: () => invalidateDocumentQueries(queryClient, documentId),
139
- });
165
+ return useActionMutation<{ success: boolean }, { documentId: string }>(
166
+ "unlink-notion-page",
167
+ {
168
+ method: "DELETE",
169
+ onSuccess: () => invalidateDocumentQueries(queryClient, documentId),
170
+ },
171
+ );
140
172
  }
141
173
 
142
174
  export function usePullDocumentFromNotion(documentId: string) {
143
175
  const queryClient = useQueryClient();
144
- return useMutation({
145
- mutationFn: () =>
146
- fetchJson<DocumentSyncStatus>(
147
- `/api/documents/${documentId}/notion/pull`,
148
- {
149
- method: "POST",
150
- },
151
- ),
152
- onSuccess: () => invalidateDocumentQueries(queryClient, documentId),
153
- });
176
+ return useActionMutation<DocumentSyncStatus, { documentId: string }>(
177
+ "pull-notion-page",
178
+ {
179
+ onSuccess: () => invalidateDocumentQueries(queryClient, documentId),
180
+ },
181
+ );
154
182
  }
155
183
 
156
184
  export function usePushDocumentToNotion(documentId: string) {
157
185
  const queryClient = useQueryClient();
158
- return useMutation({
159
- mutationFn: () =>
160
- fetchJson<DocumentSyncStatus>(
161
- `/api/documents/${documentId}/notion/push`,
162
- {
163
- method: "POST",
164
- },
165
- ),
166
- onSuccess: () => invalidateDocumentQueries(queryClient, documentId),
167
- });
186
+ return useActionMutation<DocumentSyncStatus, { documentId: string }>(
187
+ "push-notion-page",
188
+ {
189
+ onSuccess: () => invalidateDocumentQueries(queryClient, documentId),
190
+ },
191
+ );
168
192
  }
169
193
 
170
194
  export function useResolveDocumentSyncConflict(documentId: string) {
171
195
  const queryClient = useQueryClient();
172
- return useMutation({
173
- mutationFn: (data: ResolveDocumentSyncConflictRequest) =>
174
- fetchJson<DocumentSyncStatus>(
175
- `/api/documents/${documentId}/notion/resolve`,
176
- {
177
- method: "POST",
178
- headers: { "Content-Type": "application/json" },
179
- body: JSON.stringify(data),
180
- },
181
- ),
196
+ return useActionMutation<
197
+ DocumentSyncStatus,
198
+ ResolveDocumentSyncConflictRequest & { documentId: string }
199
+ >("resolve-notion-sync-conflict", {
182
200
  onSuccess: () => invalidateDocumentQueries(queryClient, documentId),
183
201
  });
184
202
  }
185
203
 
186
204
  export function useCreateAndLinkNotionPage(documentId: string) {
187
205
  const queryClient = useQueryClient();
188
- return useMutation({
189
- mutationFn: (data?: CreateNotionPageRequest) =>
190
- fetchJson<DocumentSyncStatus>(
191
- `/api/documents/${documentId}/notion/create-and-link`,
192
- {
193
- method: "POST",
194
- headers: { "Content-Type": "application/json" },
195
- body: JSON.stringify(data ?? {}),
196
- },
197
- ),
206
+ return useActionMutation<
207
+ DocumentSyncStatus,
208
+ CreateNotionPageRequest & { documentId: string }
209
+ >("create-and-link-notion-page", {
198
210
  onSuccess: () => invalidateDocumentQueries(queryClient, documentId),
199
211
  });
200
212
  }
201
213
 
202
214
  export function useSearchNotionPages(query: string, enabled: boolean) {
203
- return useQuery({
204
- queryKey: ["notion-search", query],
205
- queryFn: () =>
206
- fetchJson<NotionSearchResponse>("/api/notion/search", {
207
- method: "POST",
208
- headers: { "Content-Type": "application/json" },
209
- body: JSON.stringify({ query }),
210
- }),
211
- enabled,
212
- staleTime: 10_000,
213
- });
215
+ return useActionQuery<NotionSearchResponse>(
216
+ "search-notion-pages",
217
+ { query },
218
+ {
219
+ enabled,
220
+ staleTime: 10_000,
221
+ },
222
+ );
214
223
  }
@@ -9,6 +9,9 @@
9
9
  "build": "agent-native build",
10
10
  "start": "agent-native start",
11
11
  "test": "vitest --run",
12
+ "test:parity": "vitest --run parity",
13
+ "test:parity-capabilities": "vitest --run parity actions/content-database-lifecycle.db.test.ts actions/bind-content-database-source-field.db.test.ts actions/_local-file-documents.test.ts actions/builder-source-review-gates.db.test.ts",
14
+ "eval:parity": "agent-native eval parity",
12
15
  "format.fix": "oxfmt --write .",
13
16
  "typecheck": "agent-native typecheck",
14
17
  "action": "agent-native action",
@@ -0,0 +1,69 @@
1
+ # Content Parity Harness
2
+
3
+ This folder is the living audit for Content's agent/UI action parity. The
4
+ matrix records which visible workflows are action-backed, which have explicit
5
+ exceptions, and which gaps need follow-up.
6
+
7
+ PR 2.2 adds two executable tiers:
8
+
9
+ - Deterministic parity tests run in normal CI. They use fixture data and
10
+ existing action tests, make no model calls, and require no private provider
11
+ credentials.
12
+ - Gated agent evals run through `agent-native eval parity`. They are opt-in via
13
+ `CONTENT_PARITY_EVALS=1`, capped to four initial scenarios, and should be
14
+ reserved for manual or nightly checks.
15
+
16
+ ## Deterministic Checks
17
+
18
+ ```bash
19
+ cd templates/content
20
+ ./node_modules/.bin/vitest --run parity
21
+ ./node_modules/.bin/vitest --run actions/content-database-lifecycle.db.test.ts actions/bind-content-database-source-field.db.test.ts actions/_local-file-documents.test.ts actions/builder-source-review-gates.db.test.ts
22
+ ```
23
+
24
+ The matrix rows use `coverageRefs` to point at deterministic files that prove a
25
+ capability. The meta-tests fail with the row id when a priority capability loses
26
+ coverage, so reviewers do not have to map a random test name back to the audit.
27
+
28
+ ## Gated Agent Evals
29
+
30
+ ```bash
31
+ cd templates/content
32
+ ./node_modules/.bin/agent-native eval parity --json
33
+ CONTENT_PARITY_EVALS=1 ANTHROPIC_API_KEY=... ./node_modules/.bin/agent-native eval parity
34
+ ```
35
+
36
+ With `CONTENT_PARITY_EVALS` unset, parity evals return skipped rows and do not
37
+ call the agent runner. The CLI still exits `0`, but both readable and JSON
38
+ reports mark each row with `status: "skipped"` and a `skipReason` such as
39
+ `Skipped because CONTENT_PARITY_EVALS is unset`.
40
+
41
+ With the gate set, the eval files run the four PR 2.2 scenarios:
42
+
43
+ - `database-source-scope`
44
+ - `document-search-edit`
45
+ - `local-file-source-truth`
46
+ - `builder-source-review-readonly`
47
+
48
+ The scenarios use fake or fixture data only. They must not require private
49
+ Builder, Notion, customer, or workspace credentials. The Builder scenario is
50
+ readonly/mocked by default and must not execute a live write.
51
+
52
+ ## CI Posture
53
+
54
+ Normal PR CI runs deterministic parity checks through `pnpm test:content-parity`.
55
+ Live or LLM-backed parity evals are reserved for the gated eval workflow, which
56
+ can be triggered manually or by the nightly schedule. Missing provider secrets
57
+ should show as skipped in that workflow summary, not as a scored pass.
58
+
59
+ ## Updating The Matrix
60
+
61
+ Edit `matrix.ts`, then regenerate:
62
+
63
+ ```bash
64
+ cd templates/content
65
+ ./node_modules/.bin/tsx parity/render-matrix.ts --write
66
+ ```
67
+
68
+ `parity/__tests__/matrix-render-fresh.test.ts` keeps `matrix.md` synced with
69
+ the typed source.