Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-clocks-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Stabilize A2A polling deadline coverage under full-suite load.
5 changes: 5 additions & 0 deletions .changeset/fuzzy-lemons-save.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/toolkit": patch
---

Allow newly created empty collaborative editors to persist their first real user edit after the shared document finishes loading.
26 changes: 17 additions & 9 deletions packages/core/src/a2a/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,22 +173,30 @@ describe("A2AClient", () => {
{ role: "user", parts: [{ type: "text", text: "hello" }] },
{ timeoutMs: 5_000, pollIntervalMs: 1_000 },
);
const assertion = expect(result).rejects.toMatchObject({
name: "A2ATaskTimeoutError",
taskId: "task-hung-poll",
lastState: "working",
timeoutMs: 5_000,
});
// Attach a handler before advancing timers so the intentional rejection is
// never reported as unhandled while the fake clock is moving.
void result.catch(() => undefined);

const hasTaskRead = () =>
fetchMock.mock.calls.some(
([, init]) =>
init?.method === "POST" &&
JSON.parse(String(init.body)).method === "tasks/get",
);
while (!hasTaskRead()) await vi.advanceTimersByTimeAsync(1);
await vi.advanceTimersByTimeAsync(5_000);
await assertion;
// waitFor advances fake time in coarse intervals. Stepping the clock 1ms at
// a time performs 1,000 async flushes and can exceed Vitest's real 5s test
// timeout when the full suite is under load.
await vi.waitFor(() => expect(hasTaskRead()).toBe(true), {
interval: 100,
timeout: 5_000,
});
await vi.runAllTimersAsync();
await expect(result).rejects.toMatchObject({
name: "A2ATaskTimeoutError",
taskId: "task-hung-poll",
lastState: "working",
timeoutMs: 5_000,
});
expect(hasTaskRead()).toBe(true);
expect(
fetchMock.mock.calls.find(
Expand Down
271 changes: 270 additions & 1 deletion packages/toolkit/src/editor/useCollabReconcile.concurrent.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// @vitest-environment happy-dom

import { Editor as CoreEditor } from "@tiptap/core";
import { useEditor, type Editor } from "@tiptap/react";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Awareness } from "y-protocols/awareness";
import * as Y from "yjs";

import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js";
import { useCollabReconcile, getEditorMarkdown } from "./useCollabReconcile.js";
Expand Down Expand Up @@ -38,6 +41,7 @@ beforeEach(() => {

afterEach(() => {
act(() => root.unmount());
vi.useRealTimers();
container.remove();
});

Expand Down Expand Up @@ -330,6 +334,37 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
expect(results).toEqual([true]);
});

it("allows the first user edit after an authoritative empty doc finishes loading", async () => {
let shouldIgnoreUpdate:
| ((transaction: Editor["state"]["tr"]) => boolean)
| null = null;
let editor: Editor | null = null;

function Probe() {
editor = useEditor({
extensions: createRichMarkdownExtensions({ dialect: "gfm" }),
content: "",
});
const fakeYdoc = { clientID: 1, getXmlFragment: () => ({ length: 0 }) };
const guards = useCollabReconcile({
editor,
ydoc: fakeYdoc as never,
collabSynced: true,
value: "",
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
editable: true,
});
shouldIgnoreUpdate = guards.shouldIgnoreUpdate;
return React.createElement("div", null);
}

act(() => root.render(React.createElement(Probe)));

expect(editor).not.toBeNull();
expect(shouldIgnoreUpdate).not.toBeNull();
expect(shouldIgnoreUpdate!(editor!.state.tr)).toBe(false);
});

it("refuses to persist an empty doc in collab mode (registerEmitted guard)", async () => {
// Directly exercise the guard contract: in collab mode an empty markdown
// string must not be registered/persisted (would clobber stored content
Expand Down Expand Up @@ -399,6 +434,240 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
expect(setContentValues).toEqual(["second seed"]);
});

it("does not seed beside persisted Y.Doc content projected during initial sync", async () => {
const persistedYdoc = new Y.Doc();
const persistedEditor = new CoreEditor({
extensions: createRichMarkdownExtensions({
dialect: "gfm",
ydoc: persistedYdoc,
}),
});
persistedEditor.commands.setContent("persisted collab body");
const persistedUpdate = Y.encodeStateAsUpdate(persistedYdoc);
persistedEditor.destroy();
persistedYdoc.destroy();

const liveYdoc = new Y.Doc();
const setContentValues: string[] = [];
let capturedEditor: Editor | null = null;

function Probe({ collabSynced }: { collabSynced: boolean }) {
const editor = useEditor({
extensions: createRichMarkdownExtensions({
dialect: "gfm",
ydoc: liveYdoc,
}),
});
capturedEditor = editor;
useCollabReconcile({
editor,
ydoc: liveYdoc,
collabSynced,
value: "persisted collab body",
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
editable: true,
setContent: (_editor, nextValue) => {
setContentValues.push(nextValue);
},
});
return React.createElement("div", null);
}

act(() => root.render(React.createElement(Probe, { collabSynced: false })));
act(() => Y.applyUpdate(liveYdoc, persistedUpdate, "remote"));
act(() => root.render(React.createElement(Probe, { collabSynced: true })));
await flush();

expect(getEditorMarkdown(capturedEditor!)).toBe("persisted collab body");
expect(setContentValues).toEqual([]);
expect(
getEditorMarkdown(capturedEditor!).match(/persisted collab body/g),
).toHaveLength(1);
liveYdoc.destroy();
});

it("adopts a nonempty synced Y.Doc instead of reconciling an empty SQL snapshot", async () => {
vi.useFakeTimers();
const persistedYdoc = new Y.Doc();
const persistedEditor = new CoreEditor({
extensions: createRichMarkdownExtensions({
dialect: "gfm",
ydoc: persistedYdoc,
}),
});
persistedEditor.commands.setContent("live collaborator body");
const liveYdoc = new Y.Doc();
Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote");
persistedEditor.destroy();
persistedYdoc.destroy();

const awareness = new Awareness(liveYdoc);
const setContentValues: string[] = [];
let capturedEditor: Editor | null = null;

function Probe() {
const editor = useEditor({
extensions: createRichMarkdownExtensions({
dialect: "gfm",
ydoc: liveYdoc,
}),
});
capturedEditor = editor;
useCollabReconcile({
editor,
ydoc: liveYdoc,
awareness,
collabSynced: true,
value: "",
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
editable: true,
setContent: (_editor, nextValue) => {
setContentValues.push(nextValue);
},
});
return React.createElement("div", null);
}

act(() => root.render(React.createElement(Probe)));
await act(async () => vi.advanceTimersByTimeAsync(0));
// The document state can finish syncing before the first awareness poll.
// Publish the active peer after mount to cover that real transport order.
act(() => {
awareness.getStates().set(4_294_967_295, {
user: { name: "Active peer" },
visible: true,
});
awareness.emit("change", [
{ added: [4_294_967_295], updated: [], removed: [] },
"remote",
]);
});
await act(async () => vi.advanceTimersByTimeAsync(2500));

expect(getEditorMarkdown(capturedEditor!)).toBe("live collaborator body");
expect(setContentValues).toEqual([]);
vi.useRealTimers();
awareness.destroy();
liveYdoc.destroy();
});

it("lets canonical empty SQL clear stale persisted Y.Doc content with no active peer", async () => {
const persistedYdoc = new Y.Doc();
const persistedEditor = new CoreEditor({
extensions: createRichMarkdownExtensions({
dialect: "gfm",
ydoc: persistedYdoc,
}),
});
persistedEditor.commands.setContent("stale persisted body");
const liveYdoc = new Y.Doc();
Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote");
persistedEditor.destroy();
persistedYdoc.destroy();

const setContentValues: string[] = [];
let capturedEditor: Editor | null = null;
const awareness = new Awareness(liveYdoc);

function Probe() {
const editor = useEditor({
extensions: createRichMarkdownExtensions({
dialect: "gfm",
ydoc: liveYdoc,
}),
});
capturedEditor = editor;
useCollabReconcile({
editor,
ydoc: liveYdoc,
awareness,
collabSynced: true,
value: "",
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
editable: true,
setContent: (editorToClear, nextValue) => {
setContentValues.push(nextValue);
editorToClear.commands.setContent(nextValue);
},
});
return React.createElement("div", null);
}

vi.useFakeTimers();
act(() => root.render(React.createElement(Probe)));
await act(async () => vi.advanceTimersByTimeAsync(2499));
expect(setContentValues).toEqual([]);
await act(async () => vi.advanceTimersByTimeAsync(51));

expect(setContentValues).toContain("");
expect(getEditorMarkdown(capturedEditor!)).toBe("");
vi.useRealTimers();
awareness.destroy();
liveYdoc.destroy();
});

it("preserves and permits a first local edit during the awareness settle window", async () => {
const persistedYdoc = new Y.Doc();
const persistedEditor = new CoreEditor({
extensions: createRichMarkdownExtensions({
dialect: "gfm",
ydoc: persistedYdoc,
}),
});
persistedEditor.commands.setContent("stale persisted body");
const liveYdoc = new Y.Doc();
Y.applyUpdate(liveYdoc, Y.encodeStateAsUpdate(persistedYdoc), "remote");
persistedEditor.destroy();
persistedYdoc.destroy();

const awareness = new Awareness(liveYdoc);
let capturedEditor: Editor | null = null;
let guards: ReturnType<typeof useCollabReconcile> | null = null;
const setContentValues: string[] = [];

function Probe() {
const editor = useEditor({
extensions: createRichMarkdownExtensions({
dialect: "gfm",
ydoc: liveYdoc,
}),
});
capturedEditor = editor;
guards = useCollabReconcile({
editor,
ydoc: liveYdoc,
awareness,
collabSynced: true,
value: "",
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
editable: true,
setContent: (editorToSet, nextValue) => {
setContentValues.push(nextValue);
editorToSet.commands.setContent(nextValue);
},
});
return React.createElement("div", null);
}

vi.useFakeTimers();
act(() => root.render(React.createElement(Probe)));
await act(async () => vi.advanceTimersByTimeAsync(0));

expect(guards).not.toBeNull();
expect(capturedEditor).not.toBeNull();
expect(guards!.shouldIgnoreUpdate(capturedEditor!.state.tr)).toBe(false);
expect(guards!.registerEmitted("first local edit")).toBe(true);
act(() => capturedEditor!.commands.setContent("first local edit"));

await act(async () => vi.advanceTimersByTimeAsync(2500));
expect(getEditorMarkdown(capturedEditor!)).toBe("first local edit");
expect(setContentValues).toEqual([]);

vi.useRealTimers();
awareness.destroy();
liveYdoc.destroy();
});

it("applies a genuinely newer external value once the user is no longer focused", async () => {
const { captured, Harness } = makeHarness();

Expand Down
Loading
Loading