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
80 changes: 77 additions & 3 deletions apps/web/src/store/canvasStore.agentDeltaConflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ beforeEach(() => {
});

afterEach(() => {
useCanvasStore.setState({ nodes: [] });
vi.clearAllTimers();
vi.useRealTimers();
});
Expand Down Expand Up @@ -211,7 +212,7 @@ describe('agent delta conflict protection', () => {
nextMetadata: {},
},
])(
'still skips $name after JSON transport while local content is pending',
'applies independent $name while local content is pending',
({ prevMetadata, nextMetadata }) => {
useCanvasStore
.getState()
Expand All @@ -223,15 +224,88 @@ describe('agent delta conflict protection', () => {
.getState()
.applyDeltasFromAgent([delta], 2, pendingEffects);

expect(skipped).toEqual(['question-1']);
expect(skipped).toEqual([]);
expect(useCanvasStore.getState().nodes[0]).toEqual({
...before,
data: { ...before.data, status: 'running' },
data: {
...before.data,
...nextMetadata,
status: 'running',
},
});
expect(useCanvasStore.getState().version).toBe(2);
},
);

it('applies an automatic title while a local body edit is pending', () => {
useCanvasStore
.getState()
.patchNodeSilent('question-1', { content: 'Pending prompt' });
const prev = question('', 'idle');
const next = {
...question('', 'running'),
data: {
...question('', 'running').data,
label: 'Generated title',
labelSource: 'auto',
conversationTitleSource: 'generated',
},
};

expect(
useCanvasStore
.getState()
.applyDeltasFromAgent(
[{ type: 'REPLACE_NODE', prev, next }],
2,
pendingEffects,
true,
),
).toEqual([]);
expect(useCanvasStore.getState().nodes[0]?.data).toMatchObject({
content: 'Pending prompt',
label: 'Generated title',
labelSource: 'auto',
conversationTitleSource: 'generated',
status: 'running',
});
});

it('applies first-turn content while preserving a pending manual title', () => {
useCanvasStore.getState().patchNodeSilent('question-1', {
label: 'Manual title',
labelSource: 'user',
});
const prev = question('', 'idle');
const next = {
...question('First prompt', 'running'),
data: {
...question('First prompt', 'running').data,
label: 'Generated title',
labelSource: 'auto',
conversationTitleSource: 'generated',
},
};

expect(
useCanvasStore
.getState()
.applyDeltasFromAgent(
[{ type: 'REPLACE_NODE', prev, next }],
2,
pendingEffects,
true,
),
).toEqual(['question-1']);
expect(useCanvasStore.getState().nodes[0]?.data).toMatchObject({
content: 'First prompt',
label: 'Manual title',
labelSource: 'user',
conversationTitleSource: 'generated',
status: 'running',
});
});

it('applies lifecycle-only changes without overwriting pending local content', () => {
useCanvasStore
.getState()
Expand Down
64 changes: 45 additions & 19 deletions apps/web/src/store/canvasStore.questionRestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { nodeRevisionOf } from '@huabu/shared/canvas-engine';

const { associateNode, deleteNode, putCanvas, postCanvasExecute, forkThread } =
vi.hoisted(() => ({
associateNode: vi.fn(),
deleteNode: vi.fn().mockResolvedValue({ success: true }),
putCanvas: vi.fn(),
postCanvasExecute: vi.fn(),
forkThread: vi.fn(),
}));
const {
associateNode,
deleteNode,
putCanvas,
putNodeContent,
postCanvasExecute,
forkThread,
} = vi.hoisted(() => ({
associateNode: vi.fn(),
deleteNode: vi.fn().mockResolvedValue({ success: true }),
putCanvas: vi.fn(),
putNodeContent: vi.fn(),
postCanvasExecute: vi.fn(),
forkThread: vi.fn(),
}));
vi.mock('../api', async (importOriginal) => ({
...(await importOriginal<typeof CanvasApi>()),
associateAgentNode: associateNode,
Expand All @@ -23,17 +30,7 @@ vi.mock('../api', async (importOriginal) => ({
}));
vi.mock('../api/canvas', async (importOriginal) => ({
...(await importOriginal<typeof CanvasApi>()),
putNodeContent: vi.fn(
async (
_canvasId: string,
nodeId: string,
request: PutNodeContentRequest,
) => ({
nodeId,
label: null,
rev: nodeRevisionOf({ content: request.content }),
}),
),
putNodeContent,
preprocessNode: vi.fn().mockResolvedValue({ success: true }),
}));
vi.mock('../api/agent', () => ({ agentApi: { forkThread } }));
Expand Down Expand Up @@ -66,6 +63,18 @@ beforeEach(() => {
deleteNode.mockClear();
putCanvas.mockReset();
postCanvasExecute.mockReset();
putNodeContent.mockReset();
putNodeContent.mockImplementation(
async (
_canvasId: string,
nodeId: string,
request: PutNodeContentRequest,
) => ({
nodeId,
label: null,
rev: nodeRevisionOf({ content: request.content }),
}),
);
forkThread.mockReset();
canvasHistoryManager.activate('canvas-restore', true);
useCanvasStore.getState()._setStateNoAutosave({
Expand Down Expand Up @@ -112,6 +121,23 @@ function createQuestion(id: `node-${string}`): Node {
}

describe('Question creation settlement', () => {
it('does not issue a redundant sidecar PUT for the optimistic Question', async () => {
postCanvasExecute.mockResolvedValue({
canvasId: 'canvas-restore',
fromVersion: 1,
toVersion: 2,
results: [{ applied: true }],
deltas: [],
pendingEffects,
});

const node = createQuestion('node-created-on-server');
await awaitQuestionCreation('canvas-restore', node.id);
await vi.advanceTimersByTimeAsync(500);

expect(putNodeContent).not.toHaveBeenCalled();
});

it('rolls back an unsupported conversation fork without poisoning unrelated saves or undo', async () => {
const error = new Error('Fork not supported (501)');
forkThread.mockRejectedValue(error);
Expand Down
62 changes: 44 additions & 18 deletions apps/web/src/store/canvasStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,7 @@ const preprocessQueue = createPreprocessQueue({
const nodeContentQueue = createNodeContentQueue({
delayMs: NODE_CONTENT_DEBOUNCE_MS,
getState: () => useCanvasStore.getState(),
waitForCreation: awaitQuestionCreation,
retryStructure: () => useCanvasStore.getState().saveCanvas(),
onRestoredContentPersisted: (canvasId, nodeId) =>
preprocessQueue.resumeRestored(canvasId, nodeId),
Expand Down Expand Up @@ -1566,6 +1567,7 @@ const useCanvasStore = create<RFState>()(
!state.nodes.some((old) => old.id === node.id),
);
if (createdQuestions.length > 0 && resolvedSource === 'ui') {
nodeContentQueue.prepareServerCreatedNodes(createdQuestions);
const creation = Promise.all(
createdQuestions.map((node) =>
questionForks.get(`${state.canvasId}\0${node.data.threadId}`),
Expand Down Expand Up @@ -1766,6 +1768,7 @@ const useCanvasStore = create<RFState>()(
const dirty = new Set(nodeContentQueue.pendingNodeIds());
const skippedNodeIds: string[] = [];
const skippedRemoteNodes: Node[] = [];
const advancedRemoteBaselines: Node[] = [];
const preservedPendingNodeIds = new Set<string>();
const localNodesById = new Map(
get().nodes.map((node) => [node.id, node]),
Expand All @@ -1777,18 +1780,51 @@ const useCanvasStore = create<RFState>()(
if (d.type === 'REPLACE_NODE' && dirty.has(d.next.id)) {
const prevData = (d.prev.data ?? {}) as Record<string, unknown>;
const nextData = (d.next.data ?? {}) as Record<string, unknown>;
const pendingFields = nodeContentQueue.pendingFields(d.next.id);
// JSON transport gives unchanged keywords/provenance separate
// object identities; compare their values, not references.
const changesPendingContent = [...NODE_CONTENT_KEYS].some(
const changedContentFields = [...NODE_CONTENT_KEYS].filter(
(key) => !deepEqual(prevData[key], nextData[key]),
);
const changesPendingContent = changedContentFields.some((key) =>
pendingFields.has(key),
);
const local = localNodesById.get(d.next.id);
if (local?.type === 'question') {
const localData = (local.data ?? {}) as Record<
string,
unknown
>;
const mergedData = { ...nextData };
for (const key of pendingFields) {
if (key in localData) mergedData[key] = localData[key];
else delete mergedData[key];
}
preservedPendingNodeIds.add(d.next.id);
if (changesPendingContent) {
skippedNodeIds.push(d.next.id);
skippedRemoteNodes.push(d.next as unknown as Node);
} else if (
changedContentFields.some(
(key) => key === 'content' || key === 'src',
)
) {
advancedRemoteBaselines.push(d.next as unknown as Node);
}
return [
{
...d,
prev: local,
next: { ...local, data: mergedData },
},
];
}
if (!changesPendingContent) {
// Coarse REPLACE_NODE deltas carry the server's full node,
// including stale content fields. A lifecycle-only update
// (for example Question running/done/viewed) is safe to
// apply while preserving every locally pending content
// field; otherwise applying `next` would erase the edit.
const local = localNodesById.get(d.next.id);
if (local) {
const localData = (local.data ?? {}) as Record<
string,
Expand All @@ -1811,20 +1847,6 @@ const useCanvasStore = create<RFState>()(
}
skippedNodeIds.push(d.next.id);
skippedRemoteNodes.push(d.next as unknown as Node);
const local = localNodesById.get(d.next.id);
if (local?.type === 'question') {
preservedPendingNodeIds.add(d.next.id);
return [
{
...d,
prev: local,
next: {
...local,
data: preserveAgentNodeOwnedData(local.data, nextData),
},
},
];
}
return [];
}
if (d.type === 'DELETE_NODE' && dirty.has(d.node.id)) {
Expand All @@ -1842,8 +1864,12 @@ const useCanvasStore = create<RFState>()(
// change the local-first policy already decided the user wins. The
// "your version was kept" notice tells the user what happened; the
// change-review card lets them inspect the agent's dropped edit.
if (skippedRemoteNodes.length > 0) {
nodeContentQueue.seedBaselines(skippedRemoteNodes);
const rebasedRemoteNodes = [
...skippedRemoteNodes,
...advancedRemoteBaselines,
];
if (rebasedRemoteNodes.length > 0) {
nodeContentQueue.seedBaselines(rebasedRemoteNodes);
}

if (safeDeltas.length === 0) {
Expand Down
Loading
Loading