From b6446d18159acc9133533adc416faf54e6805b37 Mon Sep 17 00:00:00 2001 From: Viscerous Date: Tue, 23 Jun 2026 10:22:28 +0200 Subject: [PATCH] fix(tools): editProse merge-patches content instead of overwriting the whole fragment --- src/server/llm/tools.ts | 17 ++++++++++------- tests/llm/tools.test.ts | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/server/llm/tools.ts b/src/server/llm/tools.ts index 16f0ac3f..9cfe3ab4 100644 --- a/src/server/llm/tools.ts +++ b/src/server/llm/tools.ts @@ -6,7 +6,6 @@ import { getStory, updateStory, listFragments, - updateFragment, updateFragmentVersioned, deleteFragment, } from '../fragments/storage' @@ -397,12 +396,16 @@ export function createFragmentTools( skipped.push(f.id) continue } - const updated: Fragment = { - ...f, - content: newContent, - updatedAt: new Date().toISOString(), - } - await updateFragment(dataDir, storyId, updated) + // Merge-patch content (re-reads on write) so a concurrent librarian meta + // write isn't clobbered, and version the edit like the other write tools. + const updated = await updateFragmentVersioned( + dataDir, + storyId, + f.id, + { content: newContent }, + { reason: 'llm-editProse' }, + ) + if (!updated) continue edited.push(f.id) } } diff --git a/tests/llm/tools.test.ts b/tests/llm/tools.test.ts index a0bb3eae..238de317 100644 --- a/tests/llm/tools.test.ts +++ b/tests/llm/tools.test.ts @@ -339,6 +339,48 @@ describe('LLM tools', () => { }) }) + describe('editProse (write)', () => { + it('replaces text across active prose, versioning each edit and preserving meta', async () => { + const frag = makeFragment({ + id: 'pr-0001', + content: 'The ipsum sat on the mat.', + refs: ['ch-0001'], + meta: { _librarian: { summary: 'prior analysis', analysisId: 'la-1' } }, + }) + await createFragment(dataDir, storyId, frag) + + const tools = createFragmentTools(dataDir, storyId, { readOnly: false }) + const result = await tools.editProse.execute!( + { oldText: 'ipsum', newText: 'cat' }, + { toolCallId: 'tc-1', messages: [] }, + ) + + expect(result.ok).toBe(true) + expect(result.editedFragments).toContain('pr-0001') + + const updated = await getFragment(dataDir, storyId, 'pr-0001') + expect(updated!.content).toBe('The cat sat on the mat.') + // Merge-patch must not clobber fields outside the patch (e.g. a concurrent + // librarian meta write). + expect(updated!.refs).toEqual(['ch-0001']) + expect((updated!.meta._librarian as { analysisId: string }).analysisId).toBe('la-1') + // Edits are versioned like the updateFragment/editFragment tools. + expect(updated!.version).toBe(2) + expect(updated!.versions).toHaveLength(1) + expect(updated!.versions?.[0].content).toBe('The ipsum sat on the mat.') + }) + + it('returns an error when no active prose contains the text', async () => { + await createFragment(dataDir, storyId, makeFragment({ id: 'pr-0001', content: 'nothing here' })) + const tools = createFragmentTools(dataDir, storyId, { readOnly: false }) + const result = await tools.editProse.execute!( + { oldText: 'absent', newText: 'x' }, + { toolCallId: 'tc-1', messages: [] }, + ) + expect(result.error).toBeDefined() + }) + }) + describe('deleteFragment (write)', () => { it('deletes a fragment from storage', async () => { const frag = makeFragment({ id: 'pr-0001' })