@agent-native/toolkit 0.20.2 → 0.20.3-nightly-20260916144018

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.
@@ -124,13 +124,14 @@ describe("createTiptapComposerExtensions", () => {
124
124
  );
125
125
  });
126
126
 
127
- it("uses compact GPT-5.6 model and effort names in the collapsed trigger", () => {
128
- expect(compactComposerModelName("gpt-5.6-sol")).toBe("GPT-5.6 Sol");
129
- expect(compactComposerModelName("gpt-5-6-terra")).toBe("GPT-5.6 Terra");
130
- expect(compactComposerModelName("openai/gpt-5.6-luna")).toBe(
131
- "GPT-5.6 Luna",
127
+ it("uses concise OpenAI variant names in the trigger and picker", () => {
128
+ expect(compactComposerModelName("gpt-5.6-sol")).toBe("Sol");
129
+ expect(compactComposerModelName("gpt-5-6-terra")).toBe("Terra");
130
+ expect(compactComposerModelName("openai/gpt-5.6-luna")).toBe("Luna");
131
+ expect(compactComposerModelName("openai/gpt-6-astra")).toBe("Astra");
132
+ expect(compactComposerModelName("openai/gpt-6-astra-pro")).toBe(
133
+ "Astra Pro",
132
134
  );
133
- expect(compactComposerModelName("openai/gpt-6-astra")).toBe("GPT-6 Astra");
134
135
  expect(compactComposerModelName("google/gemini-3.8-flash")).toBe(
135
136
  "Gemini 3.8 Flash",
136
137
  );
@@ -1267,12 +1268,26 @@ describe("createTiptapComposerExtensions", () => {
1267
1268
  (tab) => tab.textContent?.includes("Connect keys"),
1268
1269
  );
1269
1270
  expect(modelTab?.textContent).toContain("Connect keys");
1271
+ act(() => modelButton?.click());
1270
1272
 
1271
1273
  act(() => root.render(React.createElement(Harness, { configured: true })));
1272
1274
  expect(
1273
1275
  container.querySelector('[data-agent-composer-slot="model-button"]')
1274
1276
  ?.textContent,
1275
- ).toContain("GPT-5.6 Luna");
1277
+ ).toContain("Luna");
1278
+
1279
+ act(() =>
1280
+ container
1281
+ .querySelector<HTMLButtonElement>(
1282
+ '[data-agent-composer-slot="model-button"]',
1283
+ )
1284
+ ?.click(),
1285
+ );
1286
+ const picker = document.querySelector(
1287
+ '[data-agent-native-composer-popover="true"]',
1288
+ );
1289
+ expect(picker?.textContent).toContain("Luna");
1290
+ expect(picker?.textContent).not.toContain("GPT-5.6 Luna");
1276
1291
  });
1277
1292
 
1278
1293
  it("resets a hidden model when switching to Claude Code", async () => {
@@ -1094,8 +1094,8 @@ const FRIENDLY_MODEL_NAMES: Record<string, string> = {
1094
1094
  "kimi-k2-5": "Kimi K2.5",
1095
1095
  "deepseek-v3-1": "DeepSeek v3.1",
1096
1096
  "z-ai/glm-5.2": "GLM 5.2",
1097
- "openai/gpt-6-astra": "GPT-6 Astra",
1098
- "openai/gpt-6-astra-pro": "GPT-6 Astra Pro",
1097
+ "openai/gpt-6-astra": "Astra",
1098
+ "openai/gpt-6-astra-pro": "Astra Pro",
1099
1099
  "anthropic/claude-fable-5.1": "Fable 5.1",
1100
1100
  "google/gemini-3.8-flash": "Gemini 3.8 Flash",
1101
1101
  "qwen/qwen3.8-max-0902": "Qwen 3.8 Max",
@@ -1210,18 +1210,18 @@ function friendlyModelName(model: string, t?: ComposerTranslate): string {
1210
1210
  return `${tier} ${claude[2]}${claude[3] ? `.${claude[3]}` : ""}`;
1211
1211
  }
1212
1212
  // GPT: gpt-{major}-{minor}[-suffix] or gpt-{major}.{minor}[-suffix]
1213
- if (model.startsWith("gpt-")) {
1214
- const rest = model.slice(4);
1213
+ if (isOpenAiModelId(model)) {
1214
+ const normalizedModel = model.replace(/^openai\//i, "");
1215
+ const rest = normalizedModel.slice(4);
1215
1216
  const gpt = rest.match(/^(\d+)[.-](\d+)(?:[.-](.+))?$/);
1217
+ if (gpt?.[3]) {
1218
+ return gpt[3]
1219
+ .split("-")
1220
+ .map((s) => s[0].toUpperCase() + s.slice(1))
1221
+ .join(" ");
1222
+ }
1216
1223
  if (gpt) {
1217
- const suffix = gpt[3]
1218
- ? " " +
1219
- gpt[3]
1220
- .split("-")
1221
- .map((s) => s[0].toUpperCase() + s.slice(1))
1222
- .join(" ")
1223
- : "";
1224
- return `GPT-${gpt[1]}.${gpt[2]}${suffix}`;
1224
+ return `GPT-${gpt[1]}.${gpt[2]}`;
1225
1225
  }
1226
1226
  return `GPT-${rest}`;
1227
1227
  }
@@ -1253,14 +1253,6 @@ export function compactComposerModelName(
1253
1253
  model: string,
1254
1254
  t?: ComposerTranslate,
1255
1255
  ): string {
1256
- const gpt56Variant = model.match(
1257
- /^(?:openai\/)?gpt-5[.-]6[.-](sol|terra|luna)$/i,
1258
- )?.[1];
1259
- if (gpt56Variant) {
1260
- const variant =
1261
- gpt56Variant[0].toUpperCase() + gpt56Variant.slice(1).toLowerCase();
1262
- return `GPT-5.6 ${variant}`;
1263
- }
1264
1256
  return friendlyModelName(model, t);
1265
1257
  }
1266
1258
 
@@ -49,6 +49,12 @@ interface HarnessProps {
49
49
  value: string;
50
50
  contentUpdatedAt: string;
51
51
  contentRevision?: string;
52
+ acknowledgedLocalSnapshot?: {
53
+ value: string;
54
+ revision: string;
55
+ updatedAt: string;
56
+ sequence: number;
57
+ } | null;
52
58
  editorOwnedFocus?: boolean;
53
59
  isEditorFocused?: () => boolean;
54
60
  }
@@ -80,6 +86,7 @@ function makeHarness() {
80
86
  value,
81
87
  contentUpdatedAt,
82
88
  contentRevision,
89
+ acknowledgedLocalSnapshot,
83
90
  editorOwnedFocus = false,
84
91
  isEditorFocused,
85
92
  }: HarnessProps) {
@@ -105,6 +112,7 @@ function makeHarness() {
105
112
  value,
106
113
  contentUpdatedAt,
107
114
  contentRevision,
115
+ acknowledgedLocalSnapshot,
108
116
  onBaseAwareReconcile: (result) => {
109
117
  (captured.reconciled ??= []).push({
110
118
  status: result.status,
@@ -997,6 +1005,225 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
997
1005
  ]);
998
1006
  });
999
1007
 
1008
+ it("adopts an idle successful local-save echo as a base without replaying it", async () => {
1009
+ vi.useFakeTimers();
1010
+ const { captured, Harness } = makeHarness();
1011
+ render(root, Harness, {
1012
+ value: "Alpha",
1013
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
1014
+ contentRevision: "revision-1",
1015
+ editorOwnedFocus: true,
1016
+ });
1017
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1018
+
1019
+ act(() => captured.editor!.commands.setContent("Alpha partial"));
1020
+ act(() => captured.editor!.commands.setContent("Alpha latest"));
1021
+ await act(async () => vi.advanceTimersByTimeAsync(1600));
1022
+
1023
+ render(root, Harness, {
1024
+ value: "Alpha partial",
1025
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
1026
+ contentRevision: "revision-2",
1027
+ acknowledgedLocalSnapshot: {
1028
+ value: "Alpha partial",
1029
+ revision: "revision-2",
1030
+ updatedAt: "2024-01-01T00:00:02.000Z",
1031
+ sequence: 1,
1032
+ },
1033
+ editorOwnedFocus: true,
1034
+ });
1035
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1036
+
1037
+ expect(getEditorMarkdown(captured.editor!)).toBe("Alpha latest");
1038
+ expect(captured.reconciled).toBeUndefined();
1039
+ });
1040
+
1041
+ it("reconciles an external revert with distinct revision even when its content matches a prior local emission", async () => {
1042
+ vi.useFakeTimers();
1043
+ const { captured, Harness } = makeHarness();
1044
+ render(root, Harness, {
1045
+ value: "Base",
1046
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
1047
+ contentRevision: "revision-1",
1048
+ editorOwnedFocus: true,
1049
+ });
1050
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1051
+
1052
+ act(() => captured.editor!.commands.setContent("Earlier local value"));
1053
+ act(() => captured.editor!.commands.setContent("Saved local base"));
1054
+ render(root, Harness, {
1055
+ value: "Saved local base",
1056
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
1057
+ contentRevision: "revision-2",
1058
+ acknowledgedLocalSnapshot: {
1059
+ value: "Saved local base",
1060
+ revision: "revision-2",
1061
+ updatedAt: "2024-01-01T00:00:02.000Z",
1062
+ sequence: 1,
1063
+ },
1064
+ editorOwnedFocus: true,
1065
+ });
1066
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1067
+
1068
+ render(root, Harness, {
1069
+ value: "Earlier local value",
1070
+ contentUpdatedAt: "2024-01-01T00:00:03.000Z",
1071
+ contentRevision: "revision-3",
1072
+ acknowledgedLocalSnapshot: {
1073
+ value: "Saved local base",
1074
+ revision: "revision-2",
1075
+ updatedAt: "2024-01-01T00:00:02.000Z",
1076
+ sequence: 1,
1077
+ },
1078
+ editorOwnedFocus: true,
1079
+ });
1080
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1081
+ expect(getEditorMarkdown(captured.editor!)).toBe("Saved local base");
1082
+ await act(async () => vi.advanceTimersByTimeAsync(2200));
1083
+
1084
+ expect(getEditorMarkdown(captured.editor!)).toBe("Earlier local value");
1085
+ });
1086
+
1087
+ it("merges a genuine remote change against the acknowledged local-save base", async () => {
1088
+ vi.useFakeTimers();
1089
+ const { captured, Harness } = makeHarness();
1090
+ render(root, Harness, {
1091
+ value: "Alpha\n\nBravo\n\nCharlie",
1092
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
1093
+ contentRevision: "revision-1",
1094
+ });
1095
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1096
+
1097
+ act(() =>
1098
+ captured.editor!.commands.setContent(
1099
+ "Alpha saved\n\nBravo\n\nCharlie local",
1100
+ ),
1101
+ );
1102
+ render(root, Harness, {
1103
+ value: "Alpha saved\n\nBravo\n\nCharlie",
1104
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
1105
+ contentRevision: "revision-2",
1106
+ acknowledgedLocalSnapshot: {
1107
+ value: "Alpha saved\n\nBravo\n\nCharlie",
1108
+ revision: "revision-2",
1109
+ updatedAt: "2024-01-01T00:00:02.000Z",
1110
+ sequence: 1,
1111
+ },
1112
+ });
1113
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1114
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1115
+ "Alpha saved\n\nBravo\n\nCharlie local",
1116
+ );
1117
+
1118
+ render(root, Harness, {
1119
+ value: "Alpha saved\n\nBravo server\n\nCharlie",
1120
+ contentUpdatedAt: "2024-01-01T00:00:03.000Z",
1121
+ contentRevision: "revision-3",
1122
+ acknowledgedLocalSnapshot: {
1123
+ value: "Alpha saved\n\nBravo\n\nCharlie",
1124
+ revision: "revision-2",
1125
+ updatedAt: "2024-01-01T00:00:02.000Z",
1126
+ sequence: 1,
1127
+ },
1128
+ });
1129
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1130
+
1131
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1132
+ "Alpha saved\n\nBravo server\n\nCharlie local",
1133
+ );
1134
+ expect(captured.reconciled).toEqual([
1135
+ {
1136
+ status: "merged",
1137
+ content: "Alpha saved\n\nBravo server\n\nCharlie local",
1138
+ },
1139
+ ]);
1140
+
1141
+ render(root, Harness, {
1142
+ value: "Alpha saved\n\nBravo\n\nCharlie",
1143
+ contentUpdatedAt: "2024-01-01T00:00:02.000Z",
1144
+ contentRevision: "revision-2",
1145
+ acknowledgedLocalSnapshot: {
1146
+ value: "Alpha saved\n\nBravo\n\nCharlie",
1147
+ revision: "revision-2",
1148
+ updatedAt: "2024-01-01T00:00:02.000Z",
1149
+ sequence: 1,
1150
+ },
1151
+ });
1152
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1153
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1154
+ "Alpha saved\n\nBravo server\n\nCharlie local",
1155
+ );
1156
+
1157
+ render(root, Harness, {
1158
+ value: "Alpha remote\n\nBravo server\n\nCharlie",
1159
+ contentUpdatedAt: "2024-01-01T00:00:04.000Z",
1160
+ contentRevision: "revision-4",
1161
+ acknowledgedLocalSnapshot: {
1162
+ value: "Alpha saved\n\nBravo\n\nCharlie",
1163
+ revision: "revision-2",
1164
+ updatedAt: "2024-01-01T00:00:02.000Z",
1165
+ sequence: 1,
1166
+ },
1167
+ });
1168
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1169
+ expect(getEditorMarkdown(captured.editor!)).toBe(
1170
+ "Alpha remote\n\nBravo server\n\nCharlie local",
1171
+ );
1172
+ });
1173
+
1174
+ it("does not clear a fresh conflict latch when an older acknowledged revision reappears", async () => {
1175
+ vi.useFakeTimers();
1176
+ const { captured, Harness } = makeHarness();
1177
+ render(root, Harness, {
1178
+ value: "Alpha",
1179
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
1180
+ contentRevision: "revision-1",
1181
+ });
1182
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1183
+
1184
+ act(() => captured.editor!.commands.setContent("Alpha latest"));
1185
+ const acknowledgedLocalSnapshot = {
1186
+ value: "Alpha saved",
1187
+ revision: "revision-2",
1188
+ updatedAt: "2024-01-01T00:00:02.000Z",
1189
+ sequence: 1,
1190
+ };
1191
+ render(root, Harness, {
1192
+ value: acknowledgedLocalSnapshot.value,
1193
+ contentUpdatedAt: acknowledgedLocalSnapshot.updatedAt,
1194
+ contentRevision: acknowledgedLocalSnapshot.revision,
1195
+ acknowledgedLocalSnapshot,
1196
+ });
1197
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1198
+
1199
+ const conflictingRemote = {
1200
+ value: "Alpha remote",
1201
+ contentUpdatedAt: "2024-01-01T00:00:03.000Z",
1202
+ contentRevision: "revision-3",
1203
+ acknowledgedLocalSnapshot,
1204
+ };
1205
+ render(root, Harness, conflictingRemote);
1206
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1207
+ expect(captured.reconciled).toEqual([
1208
+ { status: "conflict", content: "Alpha latest" },
1209
+ ]);
1210
+
1211
+ render(root, Harness, {
1212
+ value: acknowledgedLocalSnapshot.value,
1213
+ contentUpdatedAt: acknowledgedLocalSnapshot.updatedAt,
1214
+ contentRevision: acknowledgedLocalSnapshot.revision,
1215
+ acknowledgedLocalSnapshot,
1216
+ });
1217
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1218
+ render(root, Harness, conflictingRemote);
1219
+ await act(async () => vi.advanceTimersByTimeAsync(0));
1220
+
1221
+ expect(getEditorMarkdown(captured.editor!)).toBe("Alpha latest");
1222
+ expect(captured.reconciled).toEqual([
1223
+ { status: "conflict", content: "Alpha latest" },
1224
+ ]);
1225
+ });
1226
+
1000
1227
  it("persists the first local edit after a synced empty collaborative document", async () => {
1001
1228
  const captured: Captured = {
1002
1229
  editor: null,
@@ -63,6 +63,17 @@ export interface UseCollabReconcileOptions {
63
63
  contentUpdatedAt?: string | null;
64
64
  /** Opaque authoritative body revision. Enables base-aware reconciliation. */
65
65
  contentRevision?: string | null;
66
+ /**
67
+ * A server-confirmed snapshot written by this editor. This is deliberately
68
+ * separate from `registerEmitted`: an emitted value may still fail to save,
69
+ * while an acknowledged revision is safe to adopt as the next merge base.
70
+ */
71
+ acknowledgedLocalSnapshot?: {
72
+ value: string;
73
+ revision: string;
74
+ updatedAt: string;
75
+ sequence: number;
76
+ } | null;
66
77
  /** This exact body revision is already represented in durable Yjs state. */
67
78
  collabContentRevision?: string | null;
68
79
  /** Resolves as synced only after a fresh provider response is applied. */
@@ -252,6 +263,7 @@ export function useCollabReconcile({
252
263
  value,
253
264
  contentUpdatedAt,
254
265
  contentRevision,
266
+ acknowledgedLocalSnapshot,
255
267
  collabContentRevision,
256
268
  requestCollabSync,
257
269
  onBaseAwareReconcile,
@@ -302,6 +314,15 @@ export function useCollabReconcile({
302
314
  revision: string;
303
315
  } | null>(contentRevision ? { value, revision: contentRevision } : null);
304
316
  const reportedConflictRevisionRef = useRef<string | null>(null);
317
+ const acknowledgedLocalSnapshotRef = useRef<{
318
+ value: string;
319
+ revision: string;
320
+ updatedAt: string;
321
+ sequence: number;
322
+ } | null>(null);
323
+ const latestObservedUpdatedAtRef = useRef<string | null>(
324
+ contentUpdatedAt ?? null,
325
+ );
305
326
  const acknowledgedCollabRef = useRef<{ ydoc: YDoc; revision: string } | null>(
306
327
  null,
307
328
  );
@@ -595,6 +616,69 @@ export function useCollabReconcile({
595
616
  // change isn't inserted twice (Yjs + setContent → duplicated region).
596
617
  const apply = (deferred = false) => {
597
618
  if (cancelled || editor.isDestroyed) return;
619
+ if (
620
+ contentUpdatedAt &&
621
+ (!latestObservedUpdatedAtRef.current ||
622
+ contentUpdatedAt > latestObservedUpdatedAtRef.current)
623
+ ) {
624
+ latestObservedUpdatedAtRef.current = contentUpdatedAt;
625
+ }
626
+ if (acknowledgedLocalSnapshot) {
627
+ const acceptedAcknowledgement = acknowledgedLocalSnapshotRef.current;
628
+ const acknowledgementIsNewestAccepted =
629
+ !acceptedAcknowledgement ||
630
+ acknowledgedLocalSnapshot.sequence > acceptedAcknowledgement.sequence;
631
+ const acknowledgementIsNotSuperseded =
632
+ !latestObservedUpdatedAtRef.current ||
633
+ acknowledgedLocalSnapshot.updatedAt >=
634
+ latestObservedUpdatedAtRef.current;
635
+ if (acknowledgementIsNewestAccepted && acknowledgementIsNotSuperseded) {
636
+ acknowledgedLocalSnapshotRef.current = acknowledgedLocalSnapshot;
637
+ authoritativeBaseRef.current = {
638
+ value: acknowledgedLocalSnapshot.value,
639
+ revision: acknowledgedLocalSnapshot.revision,
640
+ };
641
+ if (
642
+ !lastAppliedUpdatedAtRef.current ||
643
+ acknowledgedLocalSnapshot.updatedAt >
644
+ lastAppliedUpdatedAtRef.current
645
+ ) {
646
+ lastAppliedUpdatedAtRef.current =
647
+ acknowledgedLocalSnapshot.updatedAt;
648
+ }
649
+ }
650
+ }
651
+ const acknowledged = acknowledgedLocalSnapshotRef.current;
652
+ if (
653
+ acknowledged &&
654
+ contentRevision === acknowledged.revision &&
655
+ value === acknowledged.value
656
+ ) {
657
+ const acknowledgementWasSuperseded =
658
+ latestObservedUpdatedAtRef.current !== null &&
659
+ acknowledged.updatedAt < latestObservedUpdatedAtRef.current;
660
+ if (!acknowledgementWasSuperseded) {
661
+ authoritativeBaseRef.current = {
662
+ value: acknowledged.value,
663
+ revision: acknowledged.revision,
664
+ };
665
+ if (
666
+ !lastAppliedUpdatedAtRef.current ||
667
+ acknowledged.updatedAt > lastAppliedUpdatedAtRef.current
668
+ ) {
669
+ lastAppliedUpdatedAtRef.current = acknowledged.updatedAt;
670
+ }
671
+ reportedConflictRevisionRef.current = null;
672
+ }
673
+ return;
674
+ }
675
+ if (
676
+ acknowledged &&
677
+ contentUpdatedAt &&
678
+ contentUpdatedAt < acknowledged.updatedAt
679
+ ) {
680
+ return;
681
+ }
598
682
  // In collab mode, defer all reconcile until the shared doc is seeded so we
599
683
  // never setContent over an unseeded fragment.
600
684
  if (collab && !collabSynced) {
@@ -661,6 +745,7 @@ export function useCollabReconcile({
661
745
  if (
662
746
  currentMarkdown === normalizedValue ||
663
747
  (typingRecently &&
748
+ !contentRevision &&
664
749
  // A stale echo of our own (possibly partial) save while the user is
665
750
  // actively typing would clobber the fresh tail. Outside active
666
751
  // typing, the same bytes can be a deliberate newer external revert
@@ -905,6 +990,7 @@ export function useCollabReconcile({
905
990
  }, [
906
991
  contentUpdatedAt,
907
992
  contentRevision,
993
+ acknowledgedLocalSnapshot,
908
994
  editor,
909
995
  ydoc,
910
996
  editable,