mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
* feat(plugins): add orca.document.session() for custom editors One call replaces the document sync every editor rebuilds: decode/encode (plain text by default), optional merge, debounce. One edit in flight with later changes coalesced; the baseline moves only on accept; a refused edit keeps the draft and rejects flush(); own accepted edits never reach onReplace; a conflict merges once against the last accepted value and resubmits, or keeps the draft without merge. The session answers editor.flush itself and resolves after hello and the first document. The --template editor now uses it. Docs and typings state the session and the undo model (the editor owns undo). * fix(plugins): let the outside document win a session conflict without merge Agents write files while people edit them, so keeping the draft lost the agent's write on the next keystroke. Without merge the session now drops the draft, fires onReplace with the outside value and rejects flush() with conflict; editors that want both sides pass merge. * fix(plugins): serialize session edits through saves and settle before answering - One edit in flight at all times, saves included; editor.flush is answered only once every change is accepted or refused with nothing newer to send. - The editor host commits a held edit when a save starts and admits edits during the save at once, so waiting fits the 1 s deadline. - A change generation replaces identity checks, and sent bytes are snapshotted, so a reused buffer is never mistaken for an accepted edit. - A waiting flush keeps its conflict rejection when the outside document wins, and nothing merges or sends after dispose. * test(plugins): read session test edits through typed helpers * fix(plugins): drain session flushes, reject dropped drafts, snapshot the baseline - flush() keeps going until no change made during it is left unsent. - A waiting flush rejects with conflict whenever the outside document replaced a draft, newer ones included. - The accepted baseline is decoded from the sent snapshot, so a reused value buffer cannot change merge's base. * test(plugins): name the session test option and message types
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
// @vitest-environment happy-dom
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
mountShell,
|
|
type FrameDocumentSessionOptions,
|
|
type FrameHostMessage
|
|
} from './plugin-frame-shell-mount.test-fixture'
|
|
|
|
type Content = { kind: 'text'; text: string } | { kind: 'bytes'; bytes: ArrayBuffer }
|
|
|
|
const hello = {
|
|
orca: 1,
|
|
kind: 'evt',
|
|
event: 'hello',
|
|
payload: { theme: { colorScheme: 'dark', tokens: '' }, locale: 'en' }
|
|
}
|
|
const doc = (version: number, text: string, readOnly = false) => ({
|
|
orca: 1,
|
|
kind: 'evt',
|
|
event: 'editor.document',
|
|
payload: { version, readOnly, content: { kind: 'text', text } }
|
|
})
|
|
const bytesDoc = (version: number, bytes: number[]) => ({
|
|
orca: 1,
|
|
kind: 'evt',
|
|
event: 'editor.document',
|
|
payload: {
|
|
version,
|
|
readOnly: false,
|
|
content: { kind: 'bytes', bytes: new Uint8Array(bytes).buffer }
|
|
}
|
|
})
|
|
const accept = (id: number) => ({ orca: 1, kind: 'res', id, result: null })
|
|
const refuse = (id: number, code: string) => ({
|
|
orca: 1,
|
|
kind: 'res',
|
|
id,
|
|
error: { code, message: code }
|
|
})
|
|
const settle = () => vi.advanceTimersByTimeAsync(0)
|
|
|
|
function isContent(value: unknown): value is Content {
|
|
if (typeof value !== 'object' || value === null || !('kind' in value)) {
|
|
return false
|
|
}
|
|
return value.kind === 'text'
|
|
? 'text' in value && typeof value.text === 'string'
|
|
: value.kind === 'bytes' && 'bytes' in value && value.bytes instanceof ArrayBuffer
|
|
}
|
|
|
|
/** The content an `editor.applyEdit` message carries. */
|
|
function sentContent(message: Record<string, unknown> | undefined): Content {
|
|
const params = message?.params
|
|
const content =
|
|
typeof params === 'object' && params !== null && 'content' in params ? params.content : null
|
|
if (!isContent(content)) {
|
|
throw new Error('not an editor edit')
|
|
}
|
|
return content
|
|
}
|
|
const sentText = (message: Record<string, unknown>): string | undefined => {
|
|
const content = sentContent(message)
|
|
return content.kind === 'text' ? content.text : undefined
|
|
}
|
|
const sentBytes = (message: Record<string, unknown> | undefined): number[] => {
|
|
const content = sentContent(message)
|
|
return content.kind === 'bytes' ? [...new Uint8Array(content.bytes)] : []
|
|
}
|
|
|
|
async function open(options?: FrameDocumentSessionOptions, first: FrameHostMessage = doc(1, 'a')) {
|
|
const shell = mountShell('editor')
|
|
fromHostAll(shell.fromHost, [hello, first])
|
|
const session = await shell.orca.document!.session(options)
|
|
const replaced = vi.fn()
|
|
session.onReplace(replaced)
|
|
const edits = () => shell.posted().filter((message) => message.method === 'editor.applyEdit')
|
|
return { ...shell, session, replaced, edits }
|
|
}
|
|
|
|
function fromHostAll(fromHost: (data: unknown) => unknown, messages: unknown[]): void {
|
|
messages.forEach((message) => fromHost(message))
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
})
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('orca.document.session', () => {
|
|
it('is feature-detectable and resolves only after hello and the first document', async () => {
|
|
const { orca, fromHost } = mountShell('editor')
|
|
expect('session' in orca.document!).toBe(true)
|
|
const started = vi.fn()
|
|
void orca.document!.session().then(started)
|
|
fromHost(doc(1, 'a'))
|
|
await settle()
|
|
expect(started).not.toHaveBeenCalled()
|
|
fromHost(hello)
|
|
await settle()
|
|
expect(started).toHaveBeenCalledWith(expect.objectContaining({ value: 'a', readOnly: false }))
|
|
})
|
|
|
|
it('coalesces changes in the debounce window into one edit', async () => {
|
|
const { session, edits } = await open()
|
|
session.change('b')
|
|
session.change('bc')
|
|
await vi.advanceTimersByTimeAsync(249)
|
|
expect(edits()).toHaveLength(0)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(edits()).toEqual([
|
|
expect.objectContaining({ params: { baseVersion: 1, content: { kind: 'text', text: 'bc' } } })
|
|
])
|
|
expect(session.value).toBe('bc')
|
|
})
|
|
|
|
it('keeps one edit in flight and sends the newest change once it settles', async () => {
|
|
const { session, edits, fromHost } = await open({ debounceMs: 0 })
|
|
session.change('b')
|
|
await settle()
|
|
session.change('c')
|
|
session.change('d')
|
|
await settle()
|
|
expect(edits()).toHaveLength(1)
|
|
fromHost(accept(0))
|
|
await settle()
|
|
expect(edits().map(sentText)).toEqual(['b', 'd'])
|
|
})
|
|
|
|
it('moves the baseline only on accept and keeps a refused draft', async () => {
|
|
const { session, fromHost, edits } = await open({ debounceMs: 0 })
|
|
session.change('b')
|
|
const flushed = session.flush()
|
|
fromHost(refuse(0, 'too_large'))
|
|
await expect(flushed).rejects.toMatchObject({ code: 'too_large' })
|
|
expect(session.value).toBe('b')
|
|
const retried = session.flush()
|
|
expect(edits()).toHaveLength(2)
|
|
fromHost(accept(1))
|
|
await expect(retried).resolves.toBeUndefined()
|
|
await expect(session.flush()).resolves.toBeUndefined()
|
|
expect(edits()).toHaveLength(2)
|
|
})
|
|
|
|
it('never reports its own accepted edits as replacements', async () => {
|
|
const { session, fromHost, replaced, edits } = await open({ debounceMs: 0 })
|
|
session.change('b')
|
|
await settle()
|
|
fromHost(accept(0))
|
|
await settle()
|
|
// The same text again, at a new version (a read-only flip would look like this).
|
|
fromHost(doc(2, 'b'))
|
|
expect(replaced).not.toHaveBeenCalled()
|
|
fromHost(doc(3, 'outside'))
|
|
expect(replaced).toHaveBeenCalledWith('outside')
|
|
expect(session.value).toBe('outside')
|
|
session.change('next')
|
|
await settle()
|
|
expect(edits().at(-1)).toMatchObject({ params: { baseVersion: 3 } })
|
|
})
|
|
|
|
it('merges a conflict once against the last accepted value and resubmits', async () => {
|
|
const merge = vi.fn(
|
|
(base: string, outside: string, mine: string) => `${base}|${outside}|${mine}`
|
|
)
|
|
const { session, fromHost, replaced, edits } = await open({ debounceMs: 0, merge })
|
|
session.change('x')
|
|
await settle()
|
|
fromHost(accept(0))
|
|
await settle()
|
|
session.change('mine')
|
|
fromHost(doc(2, 'outside'))
|
|
await settle()
|
|
expect(replaced).not.toHaveBeenCalled()
|
|
fromHost(refuse(1, 'conflict'))
|
|
await settle()
|
|
expect(merge).toHaveBeenCalledTimes(1)
|
|
expect(merge).toHaveBeenCalledWith('x', 'outside', 'mine')
|
|
expect(replaced).toHaveBeenCalledWith('x|outside|mine')
|
|
expect(edits().at(-1)).toMatchObject({
|
|
params: { baseVersion: 2, content: { kind: 'text', text: 'x|outside|mine' } }
|
|
})
|
|
})
|
|
|
|
it('lets the outside document win a conflict without merge', async () => {
|
|
const { session, fromHost, replaced, edits } = await open({ debounceMs: 0 })
|
|
session.change('mine')
|
|
fromHost(doc(2, 'outside'))
|
|
const flushed = session.flush()
|
|
fromHost(refuse(0, 'conflict'))
|
|
await expect(flushed).rejects.toMatchObject({ code: 'conflict' })
|
|
expect(session.value).toBe('outside')
|
|
expect(replaced).toHaveBeenCalledWith('outside')
|
|
// The draft is gone, so nothing overwrites the outside write.
|
|
await expect(session.flush()).resolves.toBeUndefined()
|
|
expect(edits()).toHaveLength(1)
|
|
session.change('next')
|
|
await settle()
|
|
expect(edits().at(-1)).toMatchObject({ params: { baseVersion: 2 } })
|
|
})
|
|
|
|
it('answers editor.flush only once the change it posted is accepted', async () => {
|
|
const { session, fromHost, posted } = await open()
|
|
session.change('b')
|
|
const flushRequest = { orca: 1, kind: 'req', id: 9, method: 'editor.flush' }
|
|
fromHost(flushRequest)
|
|
await settle()
|
|
expect(posted().at(-1)).toMatchObject({ method: 'editor.applyEdit' })
|
|
fromHost(accept(0))
|
|
await settle()
|
|
expect(posted().at(-1)).toEqual({ orca: 1, kind: 'res', id: 9, result: null })
|
|
})
|
|
|
|
it('keeps one edit in flight through a save, so the baseline never goes back', async () => {
|
|
const { orca, session, fromHost, edits, posted } = await open({ debounceMs: 0 })
|
|
session.change('a1')
|
|
await settle()
|
|
// `a1` is held by Orca's rate limit; the save must not post `b` beside it.
|
|
session.change('b')
|
|
fromHost({ orca: 1, kind: 'req', id: 9, method: 'editor.flush' })
|
|
await settle()
|
|
expect(edits()).toHaveLength(1)
|
|
fromHost(accept(0))
|
|
await settle()
|
|
expect(edits().at(-1)).toMatchObject({ params: { content: { text: 'b' } } })
|
|
fromHost(accept(1))
|
|
await settle()
|
|
expect(posted().at(-1)).toMatchObject({ kind: 'res', id: 9, result: null })
|
|
await expect(orca.document!.get()).resolves.toMatchObject({ content: { text: 'b' } })
|
|
})
|
|
|
|
it('answers a save only after a conflict merge commits', async () => {
|
|
const merge = (_base: string, outside: string, mine: string) => `${outside}+${mine}`
|
|
const { session, fromHost, edits, posted } = await open({ debounceMs: 0, merge })
|
|
session.change('mine')
|
|
await settle()
|
|
fromHost(doc(2, 'outside'))
|
|
fromHost({ orca: 1, kind: 'req', id: 9, method: 'editor.flush' })
|
|
fromHost(refuse(0, 'conflict'))
|
|
await settle()
|
|
expect(edits().at(-1)).toMatchObject({ params: { content: { text: 'outside+mine' } } })
|
|
expect(posted().some((message) => message.kind === 'res' && message.id === 9)).toBe(false)
|
|
fromHost(accept(1))
|
|
await settle()
|
|
expect(posted().at(-1)).toMatchObject({ kind: 'res', id: 9, result: null })
|
|
})
|
|
|
|
it('counts a reused buffer as a new change and sends what it held when sent', async () => {
|
|
const buffer = new Uint8Array([3])
|
|
const { session, fromHost, edits } = await open(
|
|
{
|
|
debounceMs: 0,
|
|
decode: () => buffer,
|
|
encode: (value: Uint8Array) => ({ kind: 'bytes', bytes: value.buffer })
|
|
},
|
|
{
|
|
...doc(1, ''),
|
|
payload: {
|
|
version: 1,
|
|
readOnly: false,
|
|
content: { kind: 'bytes', bytes: new ArrayBuffer(1) }
|
|
}
|
|
}
|
|
)
|
|
session.change(buffer)
|
|
await settle()
|
|
buffer[0] = 4
|
|
session.change(buffer)
|
|
fromHost(accept(0))
|
|
await settle()
|
|
const sent = edits().map(sentBytes)
|
|
expect(sent).toEqual([[3], [4]])
|
|
})
|
|
|
|
it('rejects a waiting flush with conflict when the outside document wins', async () => {
|
|
const { session, fromHost } = await open({ debounceMs: 0 })
|
|
session.change('mine')
|
|
await settle()
|
|
const flushed = session.flush()
|
|
fromHost(doc(2, 'outside'))
|
|
fromHost(refuse(0, 'conflict'))
|
|
await expect(flushed).rejects.toMatchObject({ code: 'conflict' })
|
|
expect(session.value).toBe('outside')
|
|
})
|
|
|
|
it('keeps a flush going until a change typed during it commits too', async () => {
|
|
const { session, fromHost, edits } = await open()
|
|
session.change('b')
|
|
const flushed = vi.fn()
|
|
void session.flush().then(flushed)
|
|
session.change('c')
|
|
fromHost(accept(0))
|
|
await settle()
|
|
expect(flushed).not.toHaveBeenCalled()
|
|
expect(edits().map(sentText)).toEqual(['b', 'c'])
|
|
fromHost(accept(1))
|
|
await settle()
|
|
expect(flushed).toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects a waiting flush when a conflict drops a newer draft too', async () => {
|
|
const { session, fromHost } = await open({ debounceMs: 0 })
|
|
session.change('mine')
|
|
await settle()
|
|
session.change('mine, more')
|
|
const flushed = session.flush()
|
|
fromHost(doc(2, 'outside'))
|
|
fromHost(refuse(0, 'conflict'))
|
|
await expect(flushed).rejects.toMatchObject({ code: 'conflict' })
|
|
expect(session.value).toBe('outside')
|
|
})
|
|
|
|
it("keeps merge's base apart from a value buffer reused after it was accepted", async () => {
|
|
const merge = vi.fn((base: Uint8Array, outside: Uint8Array, mine: Uint8Array) => [
|
|
...base,
|
|
...outside,
|
|
...mine
|
|
])
|
|
const buffer = new Uint8Array([1])
|
|
const { session, fromHost } = await open(
|
|
{
|
|
debounceMs: 0,
|
|
merge,
|
|
decode: (content: Content) =>
|
|
content.kind === 'bytes' ? new Uint8Array(content.bytes) : new Uint8Array(),
|
|
encode: (value: Uint8Array | number[]) => ({
|
|
kind: 'bytes',
|
|
bytes: Uint8Array.from(value).buffer
|
|
})
|
|
},
|
|
bytesDoc(1, [0])
|
|
)
|
|
session.change(buffer)
|
|
await settle()
|
|
fromHost(accept(0))
|
|
await settle()
|
|
buffer[0] = 9
|
|
session.change(buffer)
|
|
await settle()
|
|
fromHost(bytesDoc(2, [5]))
|
|
fromHost(refuse(1, 'conflict'))
|
|
await settle()
|
|
expect([...(merge.mock.calls[0]?.[0] ?? [])]).toEqual([1])
|
|
})
|
|
|
|
it('neither merges nor sends after dispose', async () => {
|
|
const merge = vi.fn((_base: string, outside: string) => outside)
|
|
const { session, fromHost, edits } = await open({ debounceMs: 0, merge })
|
|
session.change('mine')
|
|
await settle()
|
|
fromHost(doc(2, 'outside'))
|
|
session.dispose()
|
|
fromHost(refuse(0, 'conflict'))
|
|
await settle()
|
|
expect(merge).not.toHaveBeenCalled()
|
|
expect(edits()).toHaveLength(1)
|
|
})
|
|
|
|
it('resends a kept draft on editor.flush, but not one still in flight', async () => {
|
|
const { session, fromHost, edits } = await open({ debounceMs: 0 })
|
|
session.change('b')
|
|
await settle()
|
|
fromHost({ orca: 1, kind: 'req', id: 8, method: 'editor.flush' })
|
|
await settle()
|
|
expect(edits()).toHaveLength(1)
|
|
fromHost(refuse(0, 'too_large'))
|
|
await settle()
|
|
fromHost({ orca: 1, kind: 'req', id: 9, method: 'editor.flush' })
|
|
await settle()
|
|
expect(edits()).toHaveLength(2)
|
|
expect(session.value).toBe('b')
|
|
})
|
|
|
|
it('ignores changes while read-only', async () => {
|
|
const { session, edits } = await open({}, doc(1, 'a', true))
|
|
expect(session.readOnly).toBe(true)
|
|
session.change('b')
|
|
await vi.advanceTimersByTimeAsync(500)
|
|
expect(session.value).toBe('a')
|
|
expect(edits()).toHaveLength(0)
|
|
})
|
|
|
|
it('needs decode and encode for bytes, and sends what encode returns', async () => {
|
|
const bytesDoc = (version: number, bytes: number[]) => ({
|
|
orca: 1,
|
|
kind: 'evt',
|
|
event: 'editor.document',
|
|
payload: {
|
|
version,
|
|
readOnly: false,
|
|
content: { kind: 'bytes', bytes: new Uint8Array(bytes).buffer }
|
|
}
|
|
})
|
|
const shell = mountShell('editor')
|
|
fromHostAll(shell.fromHost, [hello, bytesDoc(1, [1, 2])])
|
|
await expect(shell.orca.document!.session()).rejects.toThrow(/decode and encode/)
|
|
const session = await shell.orca.document!.session({
|
|
debounceMs: 0,
|
|
decode: (content: Content) =>
|
|
content.kind === 'bytes' ? [...new Uint8Array(content.bytes)] : [],
|
|
encode: (value: number[]) => ({ kind: 'bytes', bytes: new Uint8Array(value).buffer })
|
|
})
|
|
expect(session.value).toEqual([1, 2])
|
|
session.change([3])
|
|
await settle()
|
|
const edit = shell.posted().find((message) => message.method === 'editor.applyEdit')
|
|
expect(sentBytes(edit)).toEqual([3])
|
|
})
|
|
|
|
it('stops sending and reporting after dispose', async () => {
|
|
const { session, fromHost, replaced, edits, posted } = await open({ debounceMs: 0 })
|
|
session.change('b')
|
|
session.dispose()
|
|
await settle()
|
|
session.change('c')
|
|
fromHost(doc(2, 'outside'))
|
|
fromHost({ orca: 1, kind: 'req', id: 4, method: 'editor.flush' })
|
|
await settle()
|
|
expect(edits()).toHaveLength(0)
|
|
expect(replaced).not.toHaveBeenCalled()
|
|
expect(posted().at(-1)).toEqual({ orca: 1, kind: 'res', id: 4, result: null })
|
|
})
|
|
})
|